import crypto from "node:crypto"; import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
normalizeOptionalString,
stripMarkdown,
} from "openclaw/plugin-sdk/text-runtime"; import { resolveBlueBubblesServerAccount } from "./account-resolve.js"; import { createBlueBubblesClient, createBlueBubblesClientFromParts } from "./client.js"; import {
fetchBlueBubblesServerInfo,
getCachedBlueBubblesPrivateApiStatus,
isBlueBubblesPrivateApiStatusEnabled,
isMacOS26OrHigher,
} from "./probe.js"; import type { OpenClawConfig } from "./runtime-api.js"; import { warnBlueBubbles } from "./runtime.js"; import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js"; import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js"; import { DEFAULT_SEND_TIMEOUT_MS, type BlueBubblesSendTarget } from "./types.js";
export type BlueBubblesSendOpts = {
serverUrl?: string;
password?: string;
accountId?: string;
timeoutMs?: number;
cfg?: OpenClawConfig; /** Message GUID to reply to (reply threading) */
replyToMessageGuid?: string; /** Part index for reply (default: 0) */
replyToPartIndex?: number; /** Effect ID or short name for message effects (e.g., "slam", "balloons") */
effectId?: string;
};
export type BlueBubblesSendResult = {
messageId: string;
};
const limit = 500; // When matching by handle, prefer the caller's requested service. A user may // have both an `iMessage;-;<handle>` and `SMS;-;<handle>` chat: // - default / `service: "imessage"` / `service: "auto"` -> prefer iMessage // so we never silently downgrade to SMS when iMessage is available. // - explicit `service: "sms"` (e.g. caller passed `sms:+15551234567`) -> // prefer SMS so explicit SMS intent is respected. // // A direct `<preferred>;-;<handle>` match is the strongest signal and // returns immediately. Everything else is recorded as a ranked fallback. const preferredService: "iMessage" | "SMS" =
params.target.kind === "handle" && params.target.service === "sms" ? "SMS" : "iMessage"; const preferredPrefix = `${preferredService};-;`; const otherPrefix = preferredService === "iMessage" ? "SMS;-;" : "iMessage;-;";
// Note: a direct `preferredPrefix` match `return`s immediately below, so we // only need to remember the other-service and unknown-service direct fallbacks.
let directHandleOtherServiceMatch: string | null = null;
let directHandleUnknownServiceMatch: string | null = null;
let participantPreferredMatch: string | null = null;
let participantOtherServiceMatch: string | null = null;
let participantUnknownServiceMatch: 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,
allowPrivateNetwork: params.allowPrivateNetwork,
}); 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) { // Back-compat: some callers might pass a full chat GUID. if (guid === targetChatIdentifier) { return guid;
}
// Primary match: BlueBubbles `chat_identifier:*` targets correspond to the // third component of the chat GUID: `service;(+|-) ;identifier`. 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 && guid) { // A direct `<preferredPrefix><handle>` is the strongest signal and we // can return immediately. Other services are remembered as fallbacks // and we keep scanning in case a preferred-service chat exists later. if (guid.startsWith(preferredPrefix)) { return guid;
} if (guid.startsWith(otherPrefix)) { if (!directHandleOtherServiceMatch) {
directHandleOtherServiceMatch = guid;
}
} elseif (!directHandleUnknownServiceMatch) { // Unknown service; treat as a last-resort direct match.
directHandleUnknownServiceMatch = guid;
}
} if (guid) { // Only consider DM chats (`;-;` separator) as participant matches. // Group chats (`;+;` separator) should never match when searching by handle/phone. // This prevents routing "send to +1234567890" to a group chat that contains that number. const isDmChat = guid.includes(";-;"); if (isDmChat) { const participants = extractParticipantAddresses(chat).map((entry) =>
normalizeBlueBubblesHandle(entry),
); if (participants.includes(normalizedHandle)) { if (guid.startsWith(preferredPrefix)) { if (!participantPreferredMatch) {
participantPreferredMatch = guid;
}
} elseif (guid.startsWith(otherPrefix)) { if (!participantOtherServiceMatch) {
participantOtherServiceMatch = guid;
}
} elseif (!participantUnknownServiceMatch) {
participantUnknownServiceMatch = guid;
}
}
}
}
}
} // We deliberately do NOT break early on participant or non-preferred direct // matches: a higher-priority direct `<preferredPrefix><handle>` chat may // still exist on a later page, and only that branch can short-circuit.
} return (
participantPreferredMatch ??
directHandleOtherServiceMatch ??
participantOtherServiceMatch ??
directHandleUnknownServiceMatch ??
participantUnknownServiceMatch
);
}
export async function sendMessageBlueBubbles(
to: string,
text: string,
opts: BlueBubblesSendOpts = {},
): Promise<BlueBubblesSendResult> { const trimmedText = text ?? ""; if (!trimmedText.trim()) { thrownew Error("BlueBubbles send requires text");
} // Strip markdown early and validate - ensures messages like "***" or "---" don't become empty const strippedText = stripMarkdown(trimmedText); if (!strippedText.trim()) { thrownew Error("BlueBubbles send requires text (message was empty after markdown removal)");
}
const { baseUrl, password, accountId, allowPrivateNetwork, sendTimeoutMs } =
resolveBlueBubblesServerAccount({
cfg: opts.cfg ?? {},
accountId: opts.accountId,
serverUrl: opts.serverUrl,
password: opts.password,
}); // Send-path timeout: explicit caller override > per-account config > 30s default. // Kept separate from the default 10s client timeout so chat lookups, probes, // and health checks stay snappy while actual sends can ride out macOS 26 // Private API stalls. (#67486) const effectiveSendTimeoutMs = opts.timeoutMs ?? sendTimeoutMs ?? DEFAULT_SEND_TIMEOUT_MS;
let privateApiStatus = getCachedBlueBubblesPrivateApiStatus(accountId);
const target = resolveBlueBubblesSendTarget(to); const chatGuid = await resolveChatGuidForTarget({
baseUrl,
password,
timeoutMs: opts.timeoutMs,
target,
allowPrivateNetwork,
}); if (!chatGuid) { // If target is a phone number/handle and no existing chat found, // auto-create a new DM chat using the /api/v1/chat/new endpoint if (target.kind === "handle") { return createNewChatWithMessage({
baseUrl,
password,
address: target.address,
message: strippedText,
timeoutMs: effectiveSendTimeoutMs,
allowPrivateNetwork,
});
} thrownew 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 = normalizeOptionalString(opts.replyToMessageGuid) !== undefined; const wantsEffect = Boolean(effectId);
// Lazy refresh: when the cache has expired, fetch server info before // making the decision. Originally scoped to reply/effect features (#43764) // to avoid silent degradation after the 10-minute cache TTL expires. Now // always fires on null status, because `isMacOS26OrHigher()` reads from // the same cache and plain-text sends on macOS 26 need Private API too — // without this, `forceOnMacOS26` silently falls back to broken AppleScript // after TTL expiry or on a cold cache. (#64480, Greptile/Codex PR #69070) if (privateApiStatus === null) { try {
await fetchBlueBubblesServerInfo({
baseUrl,
password,
accountId,
timeoutMs: opts.timeoutMs ?? 5000,
allowPrivateNetwork,
});
privateApiStatus = getCachedBlueBubblesPrivateApiStatus(accountId);
} catch { // Refresh failed — proceed with null status (existing graceful degradation)
}
}
const privateApiDecision = resolvePrivateApiDecision({
privateApiStatus,
wantsReplyThread,
wantsEffect,
accountId,
}); if (privateApiDecision.throwEffectDisabledError) { thrownew Error( "BlueBubbles send failed: reply/effect requires Private API, but it is disabled on the BlueBubbles server.",
);
} if (privateApiDecision.warningMessage) {
warnBlueBubbles(privateApiDecision.warningMessage);
} // Always set `method` explicitly. BB Server's behavior on an omitted // `method` is version-dependent and silently drops on some setups (e.g. // macOS without Private API — message lands in Messages.app locally but // never reaches the phone). (#64480) const payload: Record<string, unknown> = {
chatGuid,
tempGuid: crypto.randomUUID(),
message: strippedText,
method: privateApiDecision.canUsePrivateApi ? "private-api" : "apple-script",
};
Die Informationen auf dieser Webseite wurden
nach bestem Wissen sorgfältig zusammengestellt. Es wird jedoch weder Vollständigkeit, noch Richtigkeit,
noch Qualität der bereit gestellten Informationen zugesichert.
Bemerkung:
Die farbliche Syntaxdarstellung und die Messung sind noch experimentell.