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",
};
[Diese Firma untersucht Systeme, entwickelt Software und berät
Organisationen zu Themen der Informationstechnolgie. Dabei wird Wert auf anerkannte Normen und Standards gelegt.
Die Qualität eigener und fremder Produkte ist Leitlinie und Ziel zugleich.
Unter bestimmten Bedingungen finden Sie hier weiterführende Anregungen.0.15Die F&E-Firma in NorddeutschlandÜbersetzung europäischer Sprachen durch Browser2026-06-10]