File size: 5,348 Bytes
fc93158 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 | import type { BaseProbeResult } from "openclaw/plugin-sdk/bluebubbles";
import { normalizeSecretInputString } from "./secret-input.js";
import { buildBlueBubblesApiUrl, blueBubblesFetchWithTimeout } from "./types.js";
export type BlueBubblesProbe = BaseProbeResult & {
status?: number | null;
};
export type BlueBubblesServerInfo = {
os_version?: string;
server_version?: string;
private_api?: boolean;
helper_connected?: boolean;
proxy_service?: string;
detected_icloud?: string;
computer_id?: string;
};
/** Cache server info by account ID to avoid repeated API calls.
* Size-capped to prevent unbounded growth (#4948). */
const MAX_SERVER_INFO_CACHE_SIZE = 64;
const serverInfoCache = new Map<string, { info: BlueBubblesServerInfo; expires: number }>();
const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
function buildCacheKey(accountId?: string): string {
return accountId?.trim() || "default";
}
/**
* Fetch server info from BlueBubbles API and cache it.
* Returns cached result if available and not expired.
*/
export async function fetchBlueBubblesServerInfo(params: {
baseUrl?: string | null;
password?: string | null;
accountId?: string;
timeoutMs?: number;
}): Promise<BlueBubblesServerInfo | null> {
const baseUrl = normalizeSecretInputString(params.baseUrl);
const password = normalizeSecretInputString(params.password);
if (!baseUrl || !password) {
return null;
}
const cacheKey = buildCacheKey(params.accountId);
const cached = serverInfoCache.get(cacheKey);
if (cached && cached.expires > Date.now()) {
return cached.info;
}
const url = buildBlueBubblesApiUrl({ baseUrl, path: "/api/v1/server/info", password });
try {
const res = await blueBubblesFetchWithTimeout(url, { method: "GET" }, params.timeoutMs ?? 5000);
if (!res.ok) {
return null;
}
const payload = (await res.json().catch(() => null)) as Record<string, unknown> | null;
const data = payload?.data as BlueBubblesServerInfo | undefined;
if (data) {
serverInfoCache.set(cacheKey, { info: data, expires: Date.now() + CACHE_TTL_MS });
// Evict oldest entries if cache exceeds max size
if (serverInfoCache.size > MAX_SERVER_INFO_CACHE_SIZE) {
const oldest = serverInfoCache.keys().next().value;
if (oldest !== undefined) {
serverInfoCache.delete(oldest);
}
}
}
return data ?? null;
} catch {
return null;
}
}
/**
* Get cached server info synchronously (for use in listActions).
* Returns null if not cached or expired.
*/
export function getCachedBlueBubblesServerInfo(accountId?: string): BlueBubblesServerInfo | null {
const cacheKey = buildCacheKey(accountId);
const cached = serverInfoCache.get(cacheKey);
if (cached && cached.expires > Date.now()) {
return cached.info;
}
return null;
}
/**
* Read cached private API capability for a BlueBubbles account.
* Returns null when capability is unknown (for example, before first probe).
*/
export function getCachedBlueBubblesPrivateApiStatus(accountId?: string): boolean | null {
const info = getCachedBlueBubblesServerInfo(accountId);
if (!info || typeof info.private_api !== "boolean") {
return null;
}
return info.private_api;
}
export function isBlueBubblesPrivateApiStatusEnabled(status: boolean | null): boolean {
return status === true;
}
export function isBlueBubblesPrivateApiEnabled(accountId?: string): boolean {
return isBlueBubblesPrivateApiStatusEnabled(getCachedBlueBubblesPrivateApiStatus(accountId));
}
/**
* Parse macOS version string (e.g., "15.0.1" or "26.0") into major version number.
*/
export function parseMacOSMajorVersion(version?: string | null): number | null {
if (!version) {
return null;
}
const match = /^(\d+)/.exec(version.trim());
return match ? Number.parseInt(match[1], 10) : null;
}
/**
* Check if the cached server info indicates macOS 26 or higher.
* Returns false if no cached info is available (fail open for action listing).
*/
export function isMacOS26OrHigher(accountId?: string): boolean {
const info = getCachedBlueBubblesServerInfo(accountId);
if (!info?.os_version) {
return false;
}
const major = parseMacOSMajorVersion(info.os_version);
return major !== null && major >= 26;
}
/** Clear the server info cache (for testing) */
export function clearServerInfoCache(): void {
serverInfoCache.clear();
}
export async function probeBlueBubbles(params: {
baseUrl?: string | null;
password?: string | null;
timeoutMs?: number;
}): Promise<BlueBubblesProbe> {
const baseUrl = normalizeSecretInputString(params.baseUrl);
const password = normalizeSecretInputString(params.password);
if (!baseUrl) {
return { ok: false, error: "serverUrl not configured" };
}
if (!password) {
return { ok: false, error: "password not configured" };
}
const url = buildBlueBubblesApiUrl({ baseUrl, path: "/api/v1/ping", password });
try {
const res = await blueBubblesFetchWithTimeout(url, { method: "GET" }, params.timeoutMs);
if (!res.ok) {
return { ok: false, status: res.status, error: `HTTP ${res.status}` };
}
return { ok: true, status: res.status };
} catch (err) {
return {
ok: false,
status: null,
error: err instanceof Error ? err.message : String(err),
};
}
}
|