| import crypto from "node:crypto"; |
|
|
| import { resolveBlueBubblesAccount } from "./accounts.js"; |
| import { |
| extractHandleFromChatGuid, |
| normalizeBlueBubblesHandle, |
| parseBlueBubblesTarget, |
| } from "./targets.js"; |
| import type { OpenClawConfig } from "openclaw/plugin-sdk"; |
| 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; |
| } |
|
|
| function resolveSendTarget(raw: string): BlueBubblesSendTarget { |
| const parsed = parseBlueBubblesTarget(raw); |
| if (parsed.kind === "handle") { |
| return { |
| kind: "handle", |
| address: normalizeBlueBubblesHandle(parsed.to), |
| service: parsed.service, |
| }; |
| } |
| if (parsed.kind === "chat_id") { |
| return { kind: "chat_id", chatId: parsed.chatId }; |
| } |
| if (parsed.kind === "chat_guid") { |
| return { kind: "chat_guid", chatGuid: parsed.chatGuid }; |
| } |
| return { kind: "chat_identifier", chatIdentifier: parsed.chatIdentifier }; |
| } |
|
|
| function extractMessageId(payload: unknown): string { |
| if (!payload || typeof payload !== "object") return "unknown"; |
| const record = payload as Record<string, unknown>; |
| const data = |
| record.data && typeof record.data === "object" |
| ? (record.data as Record<string, unknown>) |
| : null; |
| const candidates = [ |
| record.messageId, |
| record.messageGuid, |
| record.message_guid, |
| record.guid, |
| record.id, |
| data?.messageId, |
| data?.messageGuid, |
| data?.message_guid, |
| data?.message_id, |
| data?.guid, |
| data?.id, |
| ]; |
| for (const candidate of candidates) { |
| if (typeof candidate === "string" && candidate.trim()) return candidate.trim(); |
| if (typeof candidate === "number" && Number.isFinite(candidate)) return String(candidate); |
| } |
| return "unknown"; |
| } |
|
|
| 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, |
| }; |
| 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"}`); |
| } |
| const body = await res.text(); |
| if (!body) return { messageId: "ok" }; |
| try { |
| const parsed = JSON.parse(body) as unknown; |
| return { messageId: extractMessageId(parsed) }; |
| } catch { |
| return { messageId: "ok" }; |
| } |
| } |
|
|
| 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 account = resolveBlueBubblesAccount({ |
| cfg: opts.cfg ?? {}, |
| accountId: opts.accountId, |
| }); |
| const baseUrl = opts.serverUrl?.trim() || account.config.serverUrl?.trim(); |
| const password = opts.password?.trim() || account.config.password?.trim(); |
| if (!baseUrl) throw new Error("BlueBubbles serverUrl is required"); |
| if (!password) throw new Error("BlueBubbles password is required"); |
|
|
| const target = resolveSendTarget(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: trimmedText, |
| 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 needsPrivateApi = Boolean(opts.replyToMessageGuid || effectId); |
| const payload: Record<string, unknown> = { |
| chatGuid, |
| tempGuid: crypto.randomUUID(), |
| message: trimmedText, |
| }; |
| if (needsPrivateApi) { |
| payload.method = "private-api"; |
| } |
|
|
| |
| if (opts.replyToMessageGuid) { |
| payload.selectedMessageGuid = opts.replyToMessageGuid; |
| payload.partIndex = typeof opts.replyToPartIndex === "number" ? opts.replyToPartIndex : 0; |
| } |
|
|
| |
| if (effectId) { |
| 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"}`); |
| } |
| const body = await res.text(); |
| if (!body) return { messageId: "ok" }; |
| try { |
| const parsed = JSON.parse(body) as unknown; |
| return { messageId: extractMessageId(parsed) }; |
| } catch { |
| return { messageId: "ok" }; |
| } |
| } |
|
|