From d9a2ac7e725173606c796a2088ea5554efbf8c21 Mon Sep 17 00:00:00 2001 From: Tyler Yust Date: Tue, 20 Jan 2026 00:46:48 -0800 Subject: [PATCH] feat: enhance BlueBubbles functionality by implementing macOS version checks for message editing and improving server info caching --- docs/channels/bluebubbles.md | 1 + extensions/bluebubbles/src/actions.ts | 12 ++++++++- extensions/bluebubbles/src/monitor.ts | 12 +++++++++ extensions/bluebubbles/src/probe.ts | 37 ++++++++++++++++++++++++--- src/auto-reply/reply/typing.ts | 6 +++-- 5 files changed, 61 insertions(+), 7 deletions(-) diff --git a/docs/channels/bluebubbles.md b/docs/channels/bluebubbles.md index b83dd1970..b566fc795 100644 --- a/docs/channels/bluebubbles.md +++ b/docs/channels/bluebubbles.md @@ -210,6 +210,7 @@ Prefer `chat_guid` for stable routing: - Reactions require the BlueBubbles private API (`POST /api/v1/message/react`); ensure the server version exposes it. - Edit/unsend require macOS 13+ and a compatible BlueBubbles server version. On macOS 26 (Tahoe), edit is currently broken due to private API changes. - Group icon updates can be flaky on macOS 26 (Tahoe): the API may return success but the new icon does not sync. +- Clawdbot auto-hides known-broken actions based on the BlueBubbles server's macOS version. If edit still appears on macOS 26 (Tahoe), disable it manually with `channels.bluebubbles.actions.edit=false`. - For status/health info: `clawdbot status --all` or `clawdbot status --deep`. For general channel workflow reference, see [Channels](/channels) and the [Plugins](/plugins) guide. diff --git a/extensions/bluebubbles/src/actions.ts b/extensions/bluebubbles/src/actions.ts index 21721f277..4af68be2b 100644 --- a/extensions/bluebubbles/src/actions.ts +++ b/extensions/bluebubbles/src/actions.ts @@ -12,6 +12,7 @@ import { } from "clawdbot/plugin-sdk"; import { resolveBlueBubblesAccount } from "./accounts.js"; +import { isMacOS26OrHigher } from "./probe.js"; import { sendBlueBubblesReaction } from "./reactions.js"; import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js"; import { @@ -68,8 +69,10 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { if (!account.enabled || !account.configured) return []; const gate = createActionGate((cfg as ClawdbotConfig).channels?.bluebubbles?.actions); const actions = new Set(); + // Check if running on macOS 26+ (edit not supported) + const macOS26 = isMacOS26OrHigher(account.accountId); if (gate("reactions")) actions.add("react"); - if (gate("edit")) actions.add("edit"); + if (gate("edit") && !macOS26) actions.add("edit"); if (gate("unsend")) actions.add("unsend"); if (gate("reply")) actions.add("reply"); if (gate("sendWithEffect")) actions.add("sendWithEffect"); @@ -167,6 +170,13 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = { // Handle edit action if (action === "edit") { + // Edit is not supported on macOS 26+ + if (isMacOS26OrHigher(accountId ?? undefined)) { + throw new Error( + "BlueBubbles edit is not supported on macOS 26 or higher. " + + "Apple removed the ability to edit iMessages in this version.", + ); + } const messageId = readStringParam(params, "messageId"); const newText = readStringParam(params, "text") ?? diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts index 2ae499c61..a33520688 100644 --- a/extensions/bluebubbles/src/monitor.ts +++ b/extensions/bluebubbles/src/monitor.ts @@ -10,6 +10,7 @@ import type { BlueBubblesAccountConfig, BlueBubblesAttachment } from "./types.js import type { ResolvedBlueBubblesAccount } from "./accounts.js"; import { getBlueBubblesRuntime } from "./runtime.js"; import { normalizeBlueBubblesReactionInput, sendBlueBubblesReaction } from "./reactions.js"; +import { fetchBlueBubblesServerInfo } from "./probe.js"; export type BlueBubblesRuntimeEnv = { log?: (message: string) => void; @@ -1499,6 +1500,17 @@ export async function monitorBlueBubblesProvider( const core = getBlueBubblesRuntime(); const path = options.webhookPath?.trim() || DEFAULT_WEBHOOK_PATH; + // Fetch and cache server info (for macOS version detection in action gating) + const serverInfo = await fetchBlueBubblesServerInfo({ + baseUrl: account.baseUrl, + password: account.config.password, + accountId: account.accountId, + timeoutMs: 5000, + }).catch(() => null); + if (serverInfo?.os_version) { + runtime.log?.(`[${account.accountId}] BlueBubbles server macOS ${serverInfo.os_version}`); + } + const unregister = registerBlueBubblesWebhookTarget({ account, config, diff --git a/extensions/bluebubbles/src/probe.ts b/extensions/bluebubbles/src/probe.ts index 5afd9657b..1a300c0d0 100644 --- a/extensions/bluebubbles/src/probe.ts +++ b/extensions/bluebubbles/src/probe.ts @@ -16,24 +16,29 @@ export type BlueBubblesServerInfo = { computer_id?: string; }; -/** Cache server info to avoid repeated API calls */ +/** Cache server info by account ID to avoid repeated API calls */ const serverInfoCache = new Map(); -const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes +const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes + +function buildCacheKey(accountId?: string): string { + return accountId?.trim() || "default"; +} /** - * Fetch server info from BlueBubbles API. + * 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 { const baseUrl = params.baseUrl?.trim(); const password = params.password?.trim(); if (!baseUrl || !password) return null; - const cacheKey = `${baseUrl}:${password}`; + const cacheKey = buildCacheKey(params.accountId); const cached = serverInfoCache.get(cacheKey); if (cached && cached.expires > Date.now()) { return cached.info; @@ -54,6 +59,19 @@ export async function fetchBlueBubblesServerInfo(params: { } } +/** + * 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; +} + /** * Parse macOS version string (e.g., "15.0.1" or "26.0") into major version number. */ @@ -63,6 +81,17 @@ export function parseMacOSMajorVersion(version?: string | null): number | null { 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(); diff --git a/src/auto-reply/reply/typing.ts b/src/auto-reply/reply/typing.ts index 67fd299bd..8271ebc78 100644 --- a/src/auto-reply/reply/typing.ts +++ b/src/auto-reply/reply/typing.ts @@ -1,3 +1,5 @@ +import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js"; + export type TypingController = { onReplyStart: () => Promise; startTypingLoop: () => Promise; @@ -20,7 +22,7 @@ export function createTypingController(params: { onReplyStart, typingIntervalSeconds = 6, typingTtlMs = 2 * 60_000, - silentToken, + silentToken = SILENT_REPLY_TOKEN, log, } = params; let started = false; @@ -119,7 +121,7 @@ export function createTypingController(params: { if (sealed) return; const trimmed = text?.trim(); if (!trimmed) return; - if (silentToken && trimmed === silentToken) return; + if (silentToken && isSilentReplyText(trimmed, silentToken)) return; refreshTypingTtl(); await startTypingLoop(); };