From d97f6cec7fa85038654b8b58529aed6307a104be Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 8 Apr 2026 23:54:03 -0700 Subject: [PATCH] feat(gateway): add BlueBubbles iMessage platform adapter (#6437) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Apple iMessage as a gateway platform via BlueBubbles macOS server. Architecture: - Webhook-based inbound (event-driven, no polling/dedup needed) - Email/phone → chat GUID resolution for user-friendly addressing - Private API safety (checks helper_connected before tapback/typing) - Inbound attachment downloading (images, audio, documents cached locally) - Markdown stripping for clean iMessage delivery - Smart progress suppression for platforms without message editing Based on PR #5869 by @benjaminsehl (webhook architecture, GUID resolution, Private API safety, progress suppression) with inbound attachment downloading from PR #4588 by @1960697431 (attachment cache routing). Integration points: Platform enum, env config, adapter factory, auth maps, cron delivery, send_message routing, channel directory, platform hints, toolset definition, setup wizard, status display. 27 tests covering config, adapter, webhook parsing, GUID resolution, attachment download routing, toolset consistency, and prompt hints. --- agent/prompt_builder.py | 7 + cron/scheduler.py | 5 +- gateway/channel_directory.py | 2 +- gateway/config.py | 27 + gateway/platforms/bluebubbles.py | 828 ++++++++++++++++++++++++++++++ gateway/run.py | 27 +- hermes_cli/config.py | 1 + hermes_cli/gateway.py | 28 + hermes_cli/status.py | 1 + hermes_cli/tools_config.py | 1 + tests/gateway/test_bluebubbles.py | 361 +++++++++++++ tools/cronjob_tools.py | 2 +- tools/send_message_tool.py | 30 ++ toolsets.py | 8 +- 14 files changed, 1321 insertions(+), 7 deletions(-) create mode 100644 gateway/platforms/bluebubbles.py create mode 100644 tests/gateway/test_bluebubbles.py diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index b1b0891f..8302973a 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -349,6 +349,13 @@ PLATFORM_HINTS = { "only — no markdown, no formatting. SMS messages are limited to ~1600 " "characters, so be brief and direct." ), + "bluebubbles": ( + "You are chatting via iMessage (BlueBubbles). iMessage does not render " + "markdown formatting — use plain text. Keep responses concise as they " + "appear as text messages. You can send media files natively: include " + "MEDIA:/absolute/path/to/file in your response. Images (.jpg, .png, " + ".heic) appear as photos and other files arrive as attachments." + ), } CONTEXT_FILE_MAX_CHARS = 20_000 diff --git a/cron/scheduler.py b/cron/scheduler.py index 33a9b899..6a7f12ac 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -44,7 +44,7 @@ logger = logging.getLogger(__name__) _KNOWN_DELIVERY_PLATFORMS = frozenset({ "telegram", "discord", "slack", "whatsapp", "signal", "matrix", "mattermost", "homeassistant", "dingtalk", "feishu", - "wecom", "sms", "email", "webhook", + "wecom", "sms", "email", "webhook", "bluebubbles", }) from cron.jobs import get_due_jobs, mark_job_run, save_job_output, advance_next_run @@ -91,7 +91,7 @@ def _resolve_delivery_target(job: dict) -> Optional[dict]: } # Origin missing (e.g. job created via API/script) — try each # platform's home channel as a fallback instead of silently dropping. - for platform_name in ("matrix", "telegram", "discord", "slack"): + for platform_name in ("matrix", "telegram", "discord", "slack", "bluebubbles"): chat_id = os.getenv(f"{platform_name.upper()}_HOME_CHANNEL", "") if chat_id: logger.info( @@ -236,6 +236,7 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option "wecom": Platform.WECOM, "email": Platform.EMAIL, "sms": Platform.SMS, + "bluebubbles": Platform.BLUEBUBBLES, } platform = platform_map.get(platform_name.lower()) if not platform: diff --git a/gateway/channel_directory.py b/gateway/channel_directory.py index 0d124721..022ebcae 100644 --- a/gateway/channel_directory.py +++ b/gateway/channel_directory.py @@ -77,7 +77,7 @@ def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]: logger.warning("Channel directory: failed to build %s: %s", platform.value, e) # Telegram, WhatsApp & Signal can't enumerate chats -- pull from session history - for plat_name in ("telegram", "whatsapp", "signal", "email", "sms"): + for plat_name in ("telegram", "whatsapp", "signal", "email", "sms", "bluebubbles"): if plat_name not in platforms: platforms[plat_name] = _build_from_sessions(plat_name) diff --git a/gateway/config.py b/gateway/config.py index 047ad542..96ee8317 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -63,6 +63,7 @@ class Platform(Enum): WEBHOOK = "webhook" FEISHU = "feishu" WECOM = "wecom" + BLUEBUBBLES = "bluebubbles" @dataclass @@ -287,6 +288,9 @@ class GatewayConfig: # WeCom uses extra dict for bot credentials elif platform == Platform.WECOM and config.extra.get("bot_id"): connected.append(platform) + # BlueBubbles uses extra dict for local server config + elif platform == Platform.BLUEBUBBLES and config.extra.get("server_url") and config.extra.get("password"): + connected.append(platform) return connected def get_home_channel(self, platform: Platform) -> Optional[HomeChannel]: @@ -948,6 +952,29 @@ def _apply_env_overrides(config: GatewayConfig) -> None: name=os.getenv("WECOM_HOME_CHANNEL_NAME", "Home"), ) + # BlueBubbles (iMessage) + bluebubbles_server_url = os.getenv("BLUEBUBBLES_SERVER_URL") + bluebubbles_password = os.getenv("BLUEBUBBLES_PASSWORD") + if bluebubbles_server_url and bluebubbles_password: + if Platform.BLUEBUBBLES not in config.platforms: + config.platforms[Platform.BLUEBUBBLES] = PlatformConfig() + config.platforms[Platform.BLUEBUBBLES].enabled = True + config.platforms[Platform.BLUEBUBBLES].extra.update({ + "server_url": bluebubbles_server_url.rstrip("/"), + "password": bluebubbles_password, + "webhook_host": os.getenv("BLUEBUBBLES_WEBHOOK_HOST", "127.0.0.1"), + "webhook_port": int(os.getenv("BLUEBUBBLES_WEBHOOK_PORT", "8645")), + "webhook_path": os.getenv("BLUEBUBBLES_WEBHOOK_PATH", "/bluebubbles-webhook"), + "send_read_receipts": os.getenv("BLUEBUBBLES_SEND_READ_RECEIPTS", "true").lower() in ("true", "1", "yes"), + }) + bluebubbles_home = os.getenv("BLUEBUBBLES_HOME_CHANNEL") + if bluebubbles_home and Platform.BLUEBUBBLES in config.platforms: + config.platforms[Platform.BLUEBUBBLES].home_channel = HomeChannel( + platform=Platform.BLUEBUBBLES, + chat_id=bluebubbles_home, + name=os.getenv("BLUEBUBBLES_HOME_CHANNEL_NAME", "Home"), + ) + # Session settings idle_minutes = os.getenv("SESSION_IDLE_MINUTES") if idle_minutes: diff --git a/gateway/platforms/bluebubbles.py b/gateway/platforms/bluebubbles.py new file mode 100644 index 00000000..83f94d3b --- /dev/null +++ b/gateway/platforms/bluebubbles.py @@ -0,0 +1,828 @@ +"""BlueBubbles iMessage platform adapter. + +Uses the local BlueBubbles macOS server for outbound REST sends and inbound +webhooks. Supports text messaging, media attachments (images, voice, video, +documents), tapback reactions, typing indicators, and read receipts. + +Architecture based on PR #5869 (benjaminsehl) with inbound attachment +downloading from PR #4588 (YuhangLin). +""" + +import asyncio +import json +import logging +import os +import re +import uuid +from datetime import datetime +from typing import Any, Dict, List, Optional +from urllib.parse import quote + +import httpx + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import ( + BasePlatformAdapter, + MessageEvent, + MessageType, + SendResult, + cache_image_from_bytes, + cache_audio_from_bytes, + cache_document_from_bytes, +) + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +DEFAULT_WEBHOOK_HOST = "127.0.0.1" +DEFAULT_WEBHOOK_PORT = 8645 +DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook" +MAX_TEXT_LENGTH = 4000 + +# Tapback reaction codes (BlueBubbles associatedMessageType values) +_TAPBACK_ADDED = { + 2000: "love", 2001: "like", 2002: "dislike", + 2003: "laugh", 2004: "emphasize", 2005: "question", +} +_TAPBACK_REMOVED = { + 3000: "love", 3001: "like", 3002: "dislike", + 3003: "laugh", 3004: "emphasize", 3005: "question", +} + +# Webhook event types that carry user messages +_MESSAGE_EVENTS = {"new-message", "message", "updated-message"} + +# Log redaction patterns +_PHONE_RE = re.compile(r"\+?\d{7,15}") +_EMAIL_RE = re.compile(r"[\w.+-]+@[\w-]+\.[\w.]+") + + +def _redact(text: str) -> str: + """Redact phone numbers and emails from log output.""" + text = _PHONE_RE.sub("[REDACTED]", text) + text = _EMAIL_RE.sub("[REDACTED]", text) + return text + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def check_bluebubbles_requirements() -> bool: + try: + import aiohttp # noqa: F401 + import httpx as _httpx # noqa: F401 + except ImportError: + return False + return True + + +def _normalize_server_url(raw: str) -> str: + value = (raw or "").strip() + if not value: + return "" + if not re.match(r"^https?://", value, flags=re.I): + value = f"http://{value}" + return value.rstrip("/") + + +def _strip_markdown(text: str) -> str: + """Strip common markdown formatting for iMessage plain-text delivery.""" + text = re.sub(r"\*\*(.+?)\*\*", r"\1", text, flags=re.DOTALL) + text = re.sub(r"\*(.+?)\*", r"\1", text, flags=re.DOTALL) + text = re.sub(r"__(.+?)__", r"\1", text, flags=re.DOTALL) + text = re.sub(r"_(.+?)_", r"\1", text, flags=re.DOTALL) + text = re.sub(r"```[a-zA-Z0-9_+-]*\n?", "", text) + text = re.sub(r"`(.+?)`", r"\1", text) + text = re.sub(r"^#{1,6}\s+", "", text, flags=re.MULTILINE) + text = re.sub(r"\[([^\]]+)\]\(([^\)]+)\)", r"\1", text) + text = re.sub(r"\n{3,}", "\n\n", text) + return text.strip() + + +# --------------------------------------------------------------------------- +# Adapter +# --------------------------------------------------------------------------- + +class BlueBubblesAdapter(BasePlatformAdapter): + platform = Platform.BLUEBUBBLES + MAX_MESSAGE_LENGTH = MAX_TEXT_LENGTH + + def __init__(self, config: PlatformConfig): + super().__init__(config, Platform.BLUEBUBBLES) + extra = config.extra or {} + self.server_url = _normalize_server_url( + extra.get("server_url") or os.getenv("BLUEBUBBLES_SERVER_URL", "") + ) + self.password = extra.get("password") or os.getenv("BLUEBUBBLES_PASSWORD", "") + self.webhook_host = ( + extra.get("webhook_host") + or os.getenv("BLUEBUBBLES_WEBHOOK_HOST", DEFAULT_WEBHOOK_HOST) + ) + self.webhook_port = int( + extra.get("webhook_port") + or os.getenv("BLUEBUBBLES_WEBHOOK_PORT", str(DEFAULT_WEBHOOK_PORT)) + ) + self.webhook_path = ( + extra.get("webhook_path") + or os.getenv("BLUEBUBBLES_WEBHOOK_PATH", DEFAULT_WEBHOOK_PATH) + ) + if not str(self.webhook_path).startswith("/"): + self.webhook_path = f"/{self.webhook_path}" + self.send_read_receipts = bool(extra.get("send_read_receipts", True)) + self.client: Optional[httpx.AsyncClient] = None + self._runner = None + self._private_api_enabled: Optional[bool] = None + self._helper_connected: bool = False + self._guid_cache: Dict[str, str] = {} + + # ------------------------------------------------------------------ + # API helpers + # ------------------------------------------------------------------ + + def _api_url(self, path: str) -> str: + sep = "&" if "?" in path else "?" + return f"{self.server_url}{path}{sep}password={quote(self.password, safe='')}" + + async def _api_get(self, path: str) -> Dict[str, Any]: + assert self.client is not None + res = await self.client.get(self._api_url(path)) + res.raise_for_status() + return res.json() + + async def _api_post(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]: + assert self.client is not None + res = await self.client.post(self._api_url(path), json=payload) + res.raise_for_status() + return res.json() + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + async def connect(self) -> bool: + if not self.server_url or not self.password: + logger.error( + "[bluebubbles] BLUEBUBBLES_SERVER_URL and BLUEBUBBLES_PASSWORD are required" + ) + return False + from aiohttp import web + + self.client = httpx.AsyncClient(timeout=30.0) + try: + await self._api_get("/api/v1/ping") + info = await self._api_get("/api/v1/server/info") + server_data = (info or {}).get("data", {}) + self._private_api_enabled = bool(server_data.get("private_api")) + self._helper_connected = bool(server_data.get("helper_connected")) + logger.info( + "[bluebubbles] connected to %s (private_api=%s, helper=%s)", + self.server_url, + self._private_api_enabled, + self._helper_connected, + ) + except Exception as exc: + logger.error( + "[bluebubbles] cannot reach server at %s: %s", self.server_url, exc + ) + if self.client: + await self.client.aclose() + self.client = None + return False + + app = web.Application() + app.router.add_get("/health", lambda _: web.Response(text="ok")) + app.router.add_post(self.webhook_path, self._handle_webhook) + self._runner = web.AppRunner(app) + await self._runner.setup() + site = web.TCPSite(self._runner, self.webhook_host, self.webhook_port) + await site.start() + self._mark_connected() + logger.info( + "[bluebubbles] webhook listening on http://%s:%s%s", + self.webhook_host, + self.webhook_port, + self.webhook_path, + ) + return True + + async def disconnect(self) -> None: + if self.client: + await self.client.aclose() + self.client = None + if self._runner: + await self._runner.cleanup() + self._runner = None + self._mark_disconnected() + + # ------------------------------------------------------------------ + # Chat GUID resolution + # ------------------------------------------------------------------ + + async def _resolve_chat_guid(self, target: str) -> Optional[str]: + """Resolve an email/phone to a BlueBubbles chat GUID. + + If *target* already contains a semicolon (raw GUID format like + ``iMessage;-;user@example.com``), it is returned as-is. Otherwise + the adapter queries the BlueBubbles chat list and matches on + ``chatIdentifier`` or participant address. + """ + target = (target or "").strip() + if not target: + return None + # Already a raw GUID + if ";" in target: + return target + if target in self._guid_cache: + return self._guid_cache[target] + try: + payload = await self._api_post( + "/api/v1/chat/query", + {"limit": 100, "offset": 0, "with": ["participants"]}, + ) + for chat in payload.get("data", []) or []: + guid = chat.get("guid") or chat.get("chatGuid") + identifier = chat.get("chatIdentifier") or chat.get("identifier") + if identifier == target: + if guid: + self._guid_cache[target] = guid + return guid + for part in chat.get("participants", []) or []: + if (part.get("address") or "").strip() == target and guid: + self._guid_cache[target] = guid + return guid + except Exception: + pass + return None + + async def _create_chat_for_handle( + self, address: str, message: str + ) -> SendResult: + """Create a new chat by sending the first message to *address*.""" + payload = { + "addresses": [address], + "message": message, + "tempGuid": f"temp-{datetime.utcnow().timestamp()}", + } + try: + res = await self._api_post("/api/v1/chat/new", payload) + data = res.get("data") or {} + msg_id = data.get("guid") or data.get("messageGuid") or "ok" + return SendResult(success=True, message_id=str(msg_id), raw_response=res) + except Exception as exc: + return SendResult(success=False, error=str(exc)) + + # ------------------------------------------------------------------ + # Text sending + # ------------------------------------------------------------------ + + async def send( + self, + chat_id: str, + content: str, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + text = _strip_markdown(content or "") + if not text: + return SendResult(success=False, error="BlueBubbles send requires text") + chunks = self.truncate_message(text, max_length=self.MAX_MESSAGE_LENGTH) + last = SendResult(success=True) + for chunk in chunks: + guid = await self._resolve_chat_guid(chat_id) + if not guid: + # If the target looks like an address, try creating a new chat + if self._private_api_enabled and ( + "@" in chat_id or re.match(r"^\+\d+", chat_id) + ): + return await self._create_chat_for_handle(chat_id, chunk) + return SendResult( + success=False, + error=f"BlueBubbles chat not found for target: {chat_id}", + ) + payload: Dict[str, Any] = { + "chatGuid": guid, + "tempGuid": f"temp-{datetime.utcnow().timestamp()}", + "message": chunk, + } + if reply_to and self._private_api_enabled and self._helper_connected: + payload["method"] = "private-api" + payload["selectedMessageGuid"] = reply_to + payload["partIndex"] = 0 + try: + res = await self._api_post("/api/v1/message/text", payload) + data = res.get("data") or {} + msg_id = data.get("guid") or data.get("messageGuid") or "ok" + last = SendResult( + success=True, message_id=str(msg_id), raw_response=res + ) + except Exception as exc: + return SendResult(success=False, error=str(exc)) + return last + + # ------------------------------------------------------------------ + # Media sending (outbound) + # ------------------------------------------------------------------ + + async def _send_attachment( + self, + chat_id: str, + file_path: str, + filename: Optional[str] = None, + caption: Optional[str] = None, + is_audio_message: bool = False, + ) -> SendResult: + """Send a file attachment via BlueBubbles multipart upload.""" + if not self.client: + return SendResult(success=False, error="Not connected") + if not os.path.isfile(file_path): + return SendResult(success=False, error=f"File not found: {file_path}") + + guid = await self._resolve_chat_guid(chat_id) + if not guid: + return SendResult(success=False, error=f"Chat not found: {chat_id}") + + fname = filename or os.path.basename(file_path) + try: + with open(file_path, "rb") as f: + files = {"attachment": (fname, f, "application/octet-stream")} + data: Dict[str, str] = { + "chatGuid": guid, + "name": fname, + "tempGuid": uuid.uuid4().hex, + } + if is_audio_message: + data["isAudioMessage"] = "true" + res = await self.client.post( + self._api_url("/api/v1/message/attachment"), + files=files, + data=data, + timeout=120, + ) + res.raise_for_status() + result = res.json() + + if caption: + await self.send(chat_id, caption) + + if result.get("status") == 200: + rdata = result.get("data") or {} + msg_id = rdata.get("guid") if isinstance(rdata, dict) else None + return SendResult( + success=True, message_id=msg_id, raw_response=result + ) + return SendResult( + success=False, + error=result.get("message", "Attachment upload failed"), + ) + except Exception as e: + return SendResult(success=False, error=str(e)) + + async def send_image( + self, + chat_id: str, + image_url: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + try: + from gateway.platforms.base import cache_image_from_url + + local_path = await cache_image_from_url(image_url) + return await self._send_attachment(chat_id, local_path, caption=caption) + except Exception: + return await super().send_image(chat_id, image_url, caption, reply_to) + + async def send_image_file( + self, + chat_id: str, + image_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + **kwargs, + ) -> SendResult: + return await self._send_attachment(chat_id, image_path, caption=caption) + + async def send_voice( + self, + chat_id: str, + audio_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + **kwargs, + ) -> SendResult: + return await self._send_attachment( + chat_id, audio_path, caption=caption, is_audio_message=True + ) + + async def send_video( + self, + chat_id: str, + video_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + **kwargs, + ) -> SendResult: + return await self._send_attachment(chat_id, video_path, caption=caption) + + async def send_document( + self, + chat_id: str, + file_path: str, + caption: Optional[str] = None, + file_name: Optional[str] = None, + reply_to: Optional[str] = None, + **kwargs, + ) -> SendResult: + return await self._send_attachment( + chat_id, file_path, filename=file_name, caption=caption + ) + + async def send_animation( + self, + chat_id: str, + animation_url: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + return await self.send_image( + chat_id, animation_url, caption, reply_to, metadata + ) + + # ------------------------------------------------------------------ + # Typing indicators + # ------------------------------------------------------------------ + + async def send_typing(self, chat_id: str, metadata=None) -> None: + if not self._private_api_enabled or not self._helper_connected or not self.client: + return + try: + guid = await self._resolve_chat_guid(chat_id) + if guid: + encoded = quote(guid, safe="") + await self.client.post( + self._api_url(f"/api/v1/chat/{encoded}/typing"), timeout=5 + ) + except Exception: + pass + + async def stop_typing(self, chat_id: str) -> None: + if not self._private_api_enabled or not self._helper_connected or not self.client: + return + try: + guid = await self._resolve_chat_guid(chat_id) + if guid: + encoded = quote(guid, safe="") + await self.client.delete( + self._api_url(f"/api/v1/chat/{encoded}/typing"), timeout=5 + ) + except Exception: + pass + + # ------------------------------------------------------------------ + # Read receipts + # ------------------------------------------------------------------ + + async def mark_read(self, chat_id: str) -> bool: + if not self._private_api_enabled or not self._helper_connected or not self.client: + return False + try: + guid = await self._resolve_chat_guid(chat_id) + if guid: + encoded = quote(guid, safe="") + await self.client.post( + self._api_url(f"/api/v1/chat/{encoded}/read"), timeout=5 + ) + return True + except Exception: + pass + return False + + # ------------------------------------------------------------------ + # Tapback reactions + # ------------------------------------------------------------------ + + async def send_reaction( + self, + chat_id: str, + message_guid: str, + reaction: str, + part_index: int = 0, + ) -> SendResult: + """Send a tapback reaction (requires Private API helper).""" + if not self._private_api_enabled or not self._helper_connected: + return SendResult( + success=False, error="Private API helper not connected" + ) + guid = await self._resolve_chat_guid(chat_id) + if not guid: + return SendResult(success=False, error=f"Chat not found: {chat_id}") + try: + res = await self._api_post( + "/api/v1/message/react", + { + "chatGuid": guid, + "selectedMessageGuid": message_guid, + "reaction": reaction, + "partIndex": part_index, + }, + ) + return SendResult(success=True, raw_response=res) + except Exception as exc: + return SendResult(success=False, error=str(exc)) + + # ------------------------------------------------------------------ + # Chat info + # ------------------------------------------------------------------ + + async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: + is_group = ";+;" in (chat_id or "") + info: Dict[str, Any] = { + "name": chat_id, + "type": "group" if is_group else "dm", + } + try: + guid = await self._resolve_chat_guid(chat_id) + if guid: + encoded = quote(guid, safe="") + res = await self._api_get( + f"/api/v1/chat/{encoded}?with=participants" + ) + data = (res or {}).get("data", {}) + display_name = ( + data.get("displayName") + or data.get("chatIdentifier") + or chat_id + ) + participants = [] + for p in data.get("participants", []) or []: + addr = (p.get("address") or "").strip() + if addr: + participants.append(addr) + info["name"] = display_name + if participants: + info["participants"] = participants + except Exception: + pass + return info + + def format_message(self, content: str) -> str: + return _strip_markdown(content) + + # ------------------------------------------------------------------ + # Inbound attachment downloading (from #4588) + # ------------------------------------------------------------------ + + async def _download_attachment( + self, att_guid: str, att_meta: Dict[str, Any] + ) -> Optional[str]: + """Download an attachment from BlueBubbles and cache it locally. + + Returns the local file path on success, None on failure. + """ + if not self.client: + return None + try: + encoded = quote(att_guid, safe="") + resp = await self.client.get( + self._api_url(f"/api/v1/attachment/{encoded}/download"), + timeout=60, + follow_redirects=True, + ) + resp.raise_for_status() + data = resp.content + + mime = (att_meta.get("mimeType") or "").lower() + transfer_name = att_meta.get("transferName", "") + + if mime.startswith("image/"): + ext_map = { + "image/jpeg": ".jpg", + "image/png": ".png", + "image/gif": ".gif", + "image/webp": ".webp", + "image/heic": ".jpg", + "image/heif": ".jpg", + "image/tiff": ".jpg", + } + ext = ext_map.get(mime, ".jpg") + return cache_image_from_bytes(data, ext) + + if mime.startswith("audio/"): + ext_map = { + "audio/mp3": ".mp3", + "audio/mpeg": ".mp3", + "audio/ogg": ".ogg", + "audio/wav": ".wav", + "audio/x-caf": ".mp3", + "audio/mp4": ".m4a", + "audio/aac": ".m4a", + } + ext = ext_map.get(mime, ".mp3") + return cache_audio_from_bytes(data, ext) + + # Videos, documents, and everything else + filename = transfer_name or f"file_{uuid.uuid4().hex[:8]}" + return cache_document_from_bytes(data, filename) + + except Exception as exc: + logger.warning( + "[bluebubbles] failed to download attachment %s: %s", + _redact(att_guid), + exc, + ) + return None + + # ------------------------------------------------------------------ + # Webhook handling + # ------------------------------------------------------------------ + + def _extract_payload_record( + self, payload: Dict[str, Any] + ) -> Optional[Dict[str, Any]]: + data = payload.get("data") + if isinstance(data, dict): + return data + if isinstance(data, list): + for item in data: + if isinstance(item, dict): + return item + if isinstance(payload.get("message"), dict): + return payload.get("message") + return payload if isinstance(payload, dict) else None + + @staticmethod + def _value(*candidates: Any) -> Optional[str]: + for candidate in candidates: + if isinstance(candidate, str) and candidate.strip(): + return candidate.strip() + return None + + async def _handle_webhook(self, request): + from aiohttp import web + + token = ( + request.query.get("password") + or request.query.get("guid") + or request.headers.get("x-password") + or request.headers.get("x-guid") + or request.headers.get("x-bluebubbles-guid") + ) + if token != self.password: + return web.json_response({"error": "unauthorized"}, status=401) + try: + raw = await request.read() + body = raw.decode("utf-8", errors="replace") + try: + payload = json.loads(body) + except Exception: + from urllib.parse import parse_qs + + form = parse_qs(body) + payload_str = ( + form.get("payload") + or form.get("data") + or form.get("message") + or [""] + )[0] + payload = json.loads(payload_str) if payload_str else {} + except Exception as exc: + logger.error("[bluebubbles] webhook parse error: %s", exc) + return web.json_response({"error": "invalid payload"}, status=400) + + event_type = self._value(payload.get("type"), payload.get("event")) or "" + # Only process message events; silently acknowledge everything else + if event_type and event_type not in _MESSAGE_EVENTS: + return web.Response(text="ok") + + record = self._extract_payload_record(payload) or {} + is_from_me = bool( + record.get("isFromMe") + or record.get("fromMe") + or record.get("is_from_me") + ) + if is_from_me: + return web.Response(text="ok") + + # Skip tapback reactions delivered as messages + assoc_type = record.get("associatedMessageType") + if isinstance(assoc_type, int) and assoc_type in { + **_TAPBACK_ADDED, + **_TAPBACK_REMOVED, + }: + return web.Response(text="ok") + + text = ( + self._value( + record.get("text"), record.get("message"), record.get("body") + ) + or "" + ) + + # --- Inbound attachment handling --- + attachments = record.get("attachments") or [] + media_urls: List[str] = [] + media_types: List[str] = [] + msg_type = MessageType.TEXT + + for att in attachments: + att_guid = att.get("guid", "") + if not att_guid: + continue + cached = await self._download_attachment(att_guid, att) + if cached: + mime = (att.get("mimeType") or "").lower() + media_urls.append(cached) + media_types.append(mime) + if mime.startswith("image/"): + msg_type = MessageType.PHOTO + elif mime.startswith("audio/") or (att.get("uti") or "").endswith( + "caf" + ): + msg_type = MessageType.VOICE + elif mime.startswith("video/"): + msg_type = MessageType.VIDEO + else: + msg_type = MessageType.DOCUMENT + + # With multiple attachments, prefer PHOTO if any images present + if len(media_urls) > 1: + mime_prefixes = {(m or "").split("/")[0] for m in media_types} + if "image" in mime_prefixes: + msg_type = MessageType.PHOTO + + if not text and media_urls: + text = "(attachment)" + # --- End attachment handling --- + + chat_guid = self._value( + record.get("chatGuid"), + payload.get("chatGuid"), + record.get("chat_guid"), + payload.get("chat_guid"), + payload.get("guid"), + ) + chat_identifier = self._value( + record.get("chatIdentifier"), + record.get("identifier"), + payload.get("chatIdentifier"), + payload.get("identifier"), + ) + sender = ( + self._value( + record.get("handle", {}).get("address") + if isinstance(record.get("handle"), dict) + else None, + record.get("sender"), + record.get("from"), + record.get("address"), + ) + or chat_identifier + or chat_guid + ) + if not (chat_guid or chat_identifier) and sender: + chat_identifier = sender + if not sender or not (chat_guid or chat_identifier) or not text: + return web.json_response({"error": "missing message fields"}, status=400) + + session_chat_id = chat_guid or chat_identifier + is_group = bool(record.get("isGroup")) or (";+;" in (chat_guid or "")) + source = self.build_source( + chat_id=session_chat_id, + chat_name=chat_identifier or sender, + chat_type="group" if is_group else "dm", + user_id=sender, + user_name=sender, + chat_id_alt=chat_identifier, + ) + event = MessageEvent( + text=text, + message_type=msg_type, + source=source, + raw_message=payload, + message_id=self._value( + record.get("guid"), + record.get("messageGuid"), + record.get("id"), + ), + reply_to_message_id=self._value( + record.get("threadOriginatorGuid"), + record.get("associatedMessageGuid"), + ), + media_urls=media_urls, + media_types=media_types, + ) + task = asyncio.create_task(self.handle_message(event)) + self._background_tasks.add(task) + task.add_done_callback(self._background_tasks.discard) + + # Fire-and-forget read receipt + if self.send_read_receipts and session_chat_id: + asyncio.create_task(self.mark_read(session_chat_id)) + + return web.Response(text="ok") diff --git a/gateway/run.py b/gateway/run.py index c9d3f07e..27703a10 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1075,6 +1075,7 @@ class GatewayRunner: "MATRIX_ALLOWED_USERS", "DINGTALK_ALLOWED_USERS", "FEISHU_ALLOWED_USERS", "WECOM_ALLOWED_USERS", + "BLUEBUBBLES_ALLOWED_USERS", "GATEWAY_ALLOWED_USERS") ) _allow_all = os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in ("true", "1", "yes") or any( @@ -1085,7 +1086,8 @@ class GatewayRunner: "SMS_ALLOW_ALL_USERS", "MATTERMOST_ALLOW_ALL_USERS", "MATRIX_ALLOW_ALL_USERS", "DINGTALK_ALLOW_ALL_USERS", "FEISHU_ALLOW_ALL_USERS", - "WECOM_ALLOW_ALL_USERS") + "WECOM_ALLOW_ALL_USERS", + "BLUEBUBBLES_ALLOW_ALL_USERS") ) if not _any_allowlist and not _allow_all: logger.warning( @@ -1656,6 +1658,13 @@ class GatewayRunner: adapter.gateway_runner = self # For cross-platform delivery return adapter + elif platform == Platform.BLUEBUBBLES: + from gateway.platforms.bluebubbles import BlueBubblesAdapter, check_bluebubbles_requirements + if not check_bluebubbles_requirements(): + logger.warning("BlueBubbles: aiohttp/httpx missing or BLUEBUBBLES_SERVER_URL/BLUEBUBBLES_PASSWORD not configured") + return None + return BlueBubblesAdapter(config) + return None def _is_user_authorized(self, source: SessionSource) -> bool: @@ -1694,6 +1703,7 @@ class GatewayRunner: Platform.DINGTALK: "DINGTALK_ALLOWED_USERS", Platform.FEISHU: "FEISHU_ALLOWED_USERS", Platform.WECOM: "WECOM_ALLOWED_USERS", + Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOWED_USERS", } platform_allow_all_map = { Platform.TELEGRAM: "TELEGRAM_ALLOW_ALL_USERS", @@ -1708,6 +1718,7 @@ class GatewayRunner: Platform.DINGTALK: "DINGTALK_ALLOW_ALL_USERS", Platform.FEISHU: "FEISHU_ALLOW_ALL_USERS", Platform.WECOM: "WECOM_ALLOW_ALL_USERS", + Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOW_ALL_USERS", } # Per-platform allow-all flag (e.g., DISCORD_ALLOW_ALL_USERS=true) @@ -5523,7 +5534,7 @@ class GatewayRunner: Platform.TELEGRAM, Platform.DISCORD, Platform.SLACK, Platform.WHATSAPP, Platform.SIGNAL, Platform.MATTERMOST, Platform.MATRIX, Platform.HOMEASSISTANT, Platform.EMAIL, Platform.SMS, Platform.DINGTALK, - Platform.FEISHU, Platform.WECOM, Platform.LOCAL, + Platform.FEISHU, Platform.WECOM, Platform.BLUEBUBBLES, Platform.LOCAL, }) async def _handle_update_command(self, event: MessageEvent) -> str: @@ -6426,6 +6437,18 @@ class GatewayRunner: if not adapter: return + # Skip tool progress for platforms that don't support message + # editing (e.g. iMessage/BlueBubbles) — each progress update + # would become a separate message bubble, which is noisy. + from gateway.platforms.base import BasePlatformAdapter as _BaseAdapter + if type(adapter).edit_message is _BaseAdapter.edit_message: + while not progress_queue.empty(): + try: + progress_queue.get_nowait() + except Exception: + break + return + progress_lines = [] # Accumulated tool lines progress_msg_id = None # ID of the progress message to edit can_edit = True # False once an edit fails (platform doesn't support it) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 8b5da352..4357119a 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -39,6 +39,7 @@ _EXTRA_ENV_KEYS = frozenset({ "DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET", "FEISHU_APP_ID", "FEISHU_APP_SECRET", "FEISHU_ENCRYPT_KEY", "FEISHU_VERIFICATION_TOKEN", "WECOM_BOT_ID", "WECOM_SECRET", + "BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_PASSWORD", "TERMINAL_ENV", "TERMINAL_SSH_KEY", "TERMINAL_SSH_PORT", "WHATSAPP_MODE", "WHATSAPP_ENABLED", "MATTERMOST_HOME_CHANNEL", "MATTERMOST_REPLY_MODE", diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 89b01b18..82689f8f 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -1588,6 +1588,34 @@ _PLATFORMS = [ "help": "Chat ID for scheduled results and notifications."}, ], }, + { + "key": "bluebubbles", + "label": "BlueBubbles (iMessage)", + "emoji": "💬", + "token_var": "BLUEBUBBLES_SERVER_URL", + "setup_instructions": [ + "1. Install BlueBubbles on a Mac that will act as your iMessage server:", + " https://bluebubbles.app/", + "2. Complete the BlueBubbles setup wizard — sign in with your Apple ID", + "3. In BlueBubbles Settings → API, note the Server URL and password", + "4. The server URL is typically http://:1234", + "5. Hermes connects via the BlueBubbles REST API and receives", + " incoming messages via a local webhook", + "6. To authorize users, use DM pairing: hermes pairing generate bluebubbles", + " Share the code — the user sends it via iMessage to get approved", + ], + "vars": [ + {"name": "BLUEBUBBLES_SERVER_URL", "prompt": "BlueBubbles server URL (e.g. http://192.168.1.10:1234)", "password": False, + "help": "The URL shown in BlueBubbles Settings → API."}, + {"name": "BLUEBUBBLES_PASSWORD", "prompt": "BlueBubbles server password", "password": True, + "help": "The password shown in BlueBubbles Settings → API."}, + {"name": "BLUEBUBBLES_ALLOWED_USERS", "prompt": "Pre-authorized phone numbers or iMessage IDs (comma-separated, or leave empty for DM pairing)", "password": False, + "is_allowlist": True, + "help": "Optional — pre-authorize specific users. Leave empty to use DM pairing instead (recommended)."}, + {"name": "BLUEBUBBLES_HOME_CHANNEL", "prompt": "Home channel (phone number or iMessage ID for cron/notifications, or empty)", "password": False, + "help": "Phone number or Apple ID to deliver cron results and notifications to."}, + ], + }, ] diff --git a/hermes_cli/status.py b/hermes_cli/status.py index 6fe8f7df..eed89885 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -302,6 +302,7 @@ def show_status(args): "DingTalk": ("DINGTALK_CLIENT_ID", None), "Feishu": ("FEISHU_APP_ID", "FEISHU_HOME_CHANNEL"), "WeCom": ("WECOM_BOT_ID", "WECOM_HOME_CHANNEL"), + "BlueBubbles": ("BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_HOME_CHANNEL"), } for name, (token_var, home_var) in platforms.items(): diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 65525d27..9a50a2c5 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -126,6 +126,7 @@ PLATFORMS = { "slack": {"label": "💼 Slack", "default_toolset": "hermes-slack"}, "whatsapp": {"label": "📱 WhatsApp", "default_toolset": "hermes-whatsapp"}, "signal": {"label": "📡 Signal", "default_toolset": "hermes-signal"}, + "bluebubbles": {"label": "💙 BlueBubbles", "default_toolset": "hermes-bluebubbles"}, "homeassistant": {"label": "🏠 Home Assistant", "default_toolset": "hermes-homeassistant"}, "email": {"label": "📧 Email", "default_toolset": "hermes-email"}, "matrix": {"label": "💬 Matrix", "default_toolset": "hermes-matrix"}, diff --git a/tests/gateway/test_bluebubbles.py b/tests/gateway/test_bluebubbles.py new file mode 100644 index 00000000..939a69ff --- /dev/null +++ b/tests/gateway/test_bluebubbles.py @@ -0,0 +1,361 @@ +"""Tests for the BlueBubbles iMessage gateway adapter.""" +import pytest + +from gateway.config import Platform, PlatformConfig + + +def _make_adapter(monkeypatch, **extra): + monkeypatch.setenv("BLUEBUBBLES_SERVER_URL", "http://localhost:1234") + monkeypatch.setenv("BLUEBUBBLES_PASSWORD", "secret") + from gateway.platforms.bluebubbles import BlueBubblesAdapter + + cfg = PlatformConfig( + enabled=True, + extra={ + "server_url": "http://localhost:1234", + "password": "secret", + **extra, + }, + ) + return BlueBubblesAdapter(cfg) + + +class TestBlueBubblesPlatformEnum: + def test_bluebubbles_enum_exists(self): + assert Platform.BLUEBUBBLES.value == "bluebubbles" + + +class TestBlueBubblesConfigLoading: + def test_apply_env_overrides_bluebubbles(self, monkeypatch): + monkeypatch.setenv("BLUEBUBBLES_SERVER_URL", "http://localhost:1234") + monkeypatch.setenv("BLUEBUBBLES_PASSWORD", "secret") + monkeypatch.setenv("BLUEBUBBLES_WEBHOOK_PORT", "9999") + from gateway.config import GatewayConfig, _apply_env_overrides + + config = GatewayConfig() + _apply_env_overrides(config) + assert Platform.BLUEBUBBLES in config.platforms + bc = config.platforms[Platform.BLUEBUBBLES] + assert bc.enabled is True + assert bc.extra["server_url"] == "http://localhost:1234" + assert bc.extra["password"] == "secret" + assert bc.extra["webhook_port"] == 9999 + + def test_connected_platforms_includes_bluebubbles(self, monkeypatch): + monkeypatch.setenv("BLUEBUBBLES_SERVER_URL", "http://localhost:1234") + monkeypatch.setenv("BLUEBUBBLES_PASSWORD", "secret") + from gateway.config import GatewayConfig, _apply_env_overrides + + config = GatewayConfig() + _apply_env_overrides(config) + assert Platform.BLUEBUBBLES in config.get_connected_platforms() + + def test_home_channel_set_from_env(self, monkeypatch): + monkeypatch.setenv("BLUEBUBBLES_SERVER_URL", "http://localhost:1234") + monkeypatch.setenv("BLUEBUBBLES_PASSWORD", "secret") + monkeypatch.setenv("BLUEBUBBLES_HOME_CHANNEL", "user@example.com") + from gateway.config import GatewayConfig, _apply_env_overrides + + config = GatewayConfig() + _apply_env_overrides(config) + hc = config.platforms[Platform.BLUEBUBBLES].home_channel + assert hc is not None + assert hc.chat_id == "user@example.com" + + def test_not_connected_without_password(self, monkeypatch): + monkeypatch.setenv("BLUEBUBBLES_SERVER_URL", "http://localhost:1234") + monkeypatch.delenv("BLUEBUBBLES_PASSWORD", raising=False) + from gateway.config import GatewayConfig, _apply_env_overrides + + config = GatewayConfig() + _apply_env_overrides(config) + assert Platform.BLUEBUBBLES not in config.get_connected_platforms() + + +class TestBlueBubblesHelpers: + def test_check_requirements(self, monkeypatch): + monkeypatch.setenv("BLUEBUBBLES_SERVER_URL", "http://localhost:1234") + monkeypatch.setenv("BLUEBUBBLES_PASSWORD", "secret") + from gateway.platforms.bluebubbles import check_bluebubbles_requirements + + assert check_bluebubbles_requirements() is True + + def test_format_message_strips_markdown(self, monkeypatch): + adapter = _make_adapter(monkeypatch) + assert adapter.format_message("**Hello** `world`") == "Hello world" + + def test_strip_markdown_headers(self, monkeypatch): + adapter = _make_adapter(monkeypatch) + assert adapter.format_message("## Heading\ntext") == "Heading\ntext" + + def test_strip_markdown_links(self, monkeypatch): + adapter = _make_adapter(monkeypatch) + assert adapter.format_message("[click here](http://example.com)") == "click here" + + def test_init_normalizes_webhook_path(self, monkeypatch): + adapter = _make_adapter(monkeypatch, webhook_path="bluebubbles-webhook") + assert adapter.webhook_path == "/bluebubbles-webhook" + + def test_init_preserves_leading_slash(self, monkeypatch): + adapter = _make_adapter(monkeypatch, webhook_path="/my-hook") + assert adapter.webhook_path == "/my-hook" + + def test_server_url_normalized(self, monkeypatch): + adapter = _make_adapter(monkeypatch, server_url="http://localhost:1234/") + assert adapter.server_url == "http://localhost:1234" + + def test_server_url_adds_scheme(self, monkeypatch): + adapter = _make_adapter(monkeypatch, server_url="localhost:1234") + assert adapter.server_url == "http://localhost:1234" + + +class TestBlueBubblesWebhookParsing: + def test_webhook_prefers_chat_guid_over_message_guid(self, monkeypatch): + adapter = _make_adapter(monkeypatch) + payload = { + "guid": "MESSAGE-GUID", + "chatGuid": "iMessage;-;user@example.com", + "chatIdentifier": "user@example.com", + } + record = adapter._extract_payload_record(payload) or {} + chat_guid = adapter._value( + record.get("chatGuid"), + payload.get("chatGuid"), + record.get("chat_guid"), + payload.get("chat_guid"), + payload.get("guid"), + ) + assert chat_guid == "iMessage;-;user@example.com" + + def test_webhook_can_fall_back_to_sender_when_chat_fields_missing(self, monkeypatch): + adapter = _make_adapter(monkeypatch) + payload = { + "data": { + "guid": "MESSAGE-GUID", + "text": "hello", + "handle": {"address": "user@example.com"}, + "isFromMe": False, + } + } + record = adapter._extract_payload_record(payload) or {} + chat_guid = adapter._value( + record.get("chatGuid"), + payload.get("chatGuid"), + record.get("chat_guid"), + payload.get("chat_guid"), + payload.get("guid"), + ) + chat_identifier = adapter._value( + record.get("chatIdentifier"), + record.get("identifier"), + payload.get("chatIdentifier"), + payload.get("identifier"), + ) + sender = ( + adapter._value( + record.get("handle", {}).get("address") + if isinstance(record.get("handle"), dict) + else None, + record.get("sender"), + record.get("from"), + record.get("address"), + ) + or chat_identifier + or chat_guid + ) + if not (chat_guid or chat_identifier) and sender: + chat_identifier = sender + assert chat_identifier == "user@example.com" + + def test_extract_payload_record_accepts_list_data(self, monkeypatch): + adapter = _make_adapter(monkeypatch) + payload = { + "type": "new-message", + "data": [ + { + "text": "hello", + "chatGuid": "iMessage;-;user@example.com", + "chatIdentifier": "user@example.com", + } + ], + } + record = adapter._extract_payload_record(payload) + assert record == payload["data"][0] + + def test_extract_payload_record_dict_data(self, monkeypatch): + adapter = _make_adapter(monkeypatch) + payload = {"data": {"text": "hello", "chatGuid": "iMessage;-;+1234"}} + record = adapter._extract_payload_record(payload) + assert record["text"] == "hello" + + def test_extract_payload_record_fallback_to_message(self, monkeypatch): + adapter = _make_adapter(monkeypatch) + payload = {"message": {"text": "hello"}} + record = adapter._extract_payload_record(payload) + assert record["text"] == "hello" + + +class TestBlueBubblesGuidResolution: + def test_raw_guid_returned_as_is(self, monkeypatch): + """If target already contains ';' it's a raw GUID — return unchanged.""" + adapter = _make_adapter(monkeypatch) + import asyncio + + result = asyncio.get_event_loop().run_until_complete( + adapter._resolve_chat_guid("iMessage;-;user@example.com") + ) + assert result == "iMessage;-;user@example.com" + + def test_empty_target_returns_none(self, monkeypatch): + adapter = _make_adapter(monkeypatch) + import asyncio + + result = asyncio.get_event_loop().run_until_complete( + adapter._resolve_chat_guid("") + ) + assert result is None + + +class TestBlueBubblesToolsetIntegration: + def test_toolset_exists(self): + from toolsets import TOOLSETS + + assert "hermes-bluebubbles" in TOOLSETS + + def test_toolset_in_gateway_composite(self): + from toolsets import TOOLSETS + + gateway = TOOLSETS["hermes-gateway"] + assert "hermes-bluebubbles" in gateway["includes"] + + +class TestBlueBubblesPromptHint: + def test_platform_hint_exists(self): + from agent.prompt_builder import PLATFORM_HINTS + + assert "bluebubbles" in PLATFORM_HINTS + hint = PLATFORM_HINTS["bluebubbles"] + assert "iMessage" in hint + assert "plain text" in hint + + +class TestBlueBubblesAttachmentDownload: + """Verify _download_attachment routes to the correct cache helper.""" + + def test_download_image_uses_image_cache(self, monkeypatch): + """Image MIME routes to cache_image_from_bytes.""" + adapter = _make_adapter(monkeypatch) + import asyncio + import httpx + + # Mock the HTTP client response + class MockResponse: + status_code = 200 + content = b"\x89PNG\r\n\x1a\n" + + def raise_for_status(self): + pass + + async def mock_get(*args, **kwargs): + return MockResponse() + + adapter.client = type("MockClient", (), {"get": mock_get})() + + cached_path = None + + def mock_cache_image(data, ext): + nonlocal cached_path + cached_path = f"/tmp/test_image{ext}" + return cached_path + + monkeypatch.setattr( + "gateway.platforms.bluebubbles.cache_image_from_bytes", + mock_cache_image, + ) + + att_meta = {"mimeType": "image/png", "transferName": "photo.png"} + result = asyncio.get_event_loop().run_until_complete( + adapter._download_attachment("att-guid-123", att_meta) + ) + assert result == "/tmp/test_image.png" + + def test_download_audio_uses_audio_cache(self, monkeypatch): + """Audio MIME routes to cache_audio_from_bytes.""" + adapter = _make_adapter(monkeypatch) + import asyncio + + class MockResponse: + status_code = 200 + content = b"fake-audio-data" + + def raise_for_status(self): + pass + + async def mock_get(*args, **kwargs): + return MockResponse() + + adapter.client = type("MockClient", (), {"get": mock_get})() + + cached_path = None + + def mock_cache_audio(data, ext): + nonlocal cached_path + cached_path = f"/tmp/test_audio{ext}" + return cached_path + + monkeypatch.setattr( + "gateway.platforms.bluebubbles.cache_audio_from_bytes", + mock_cache_audio, + ) + + att_meta = {"mimeType": "audio/mpeg", "transferName": "voice.mp3"} + result = asyncio.get_event_loop().run_until_complete( + adapter._download_attachment("att-guid-456", att_meta) + ) + assert result == "/tmp/test_audio.mp3" + + def test_download_document_uses_document_cache(self, monkeypatch): + """Non-image/audio MIME routes to cache_document_from_bytes.""" + adapter = _make_adapter(monkeypatch) + import asyncio + + class MockResponse: + status_code = 200 + content = b"fake-doc-data" + + def raise_for_status(self): + pass + + async def mock_get(*args, **kwargs): + return MockResponse() + + adapter.client = type("MockClient", (), {"get": mock_get})() + + cached_path = None + + def mock_cache_doc(data, filename): + nonlocal cached_path + cached_path = f"/tmp/{filename}" + return cached_path + + monkeypatch.setattr( + "gateway.platforms.bluebubbles.cache_document_from_bytes", + mock_cache_doc, + ) + + att_meta = {"mimeType": "application/pdf", "transferName": "report.pdf"} + result = asyncio.get_event_loop().run_until_complete( + adapter._download_attachment("att-guid-789", att_meta) + ) + assert result == "/tmp/report.pdf" + + def test_download_returns_none_without_client(self, monkeypatch): + """No client → returns None gracefully.""" + adapter = _make_adapter(monkeypatch) + adapter.client = None + import asyncio + + result = asyncio.get_event_loop().run_until_complete( + adapter._download_attachment("att-guid", {"mimeType": "image/png"}) + ) + assert result is None diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index 595ad8bc..ccb8bc6f 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -455,7 +455,7 @@ Important safety rule: cron-run sessions should not recursively schedule more cr }, "deliver": { "type": "string", - "description": "Delivery target: origin, local, telegram, discord, slack, whatsapp, signal, matrix, mattermost, homeassistant, dingtalk, feishu, wecom, email, sms, or platform:chat_id or platform:chat_id:thread_id for Telegram topics. Examples: 'origin', 'local', 'telegram', 'telegram:-1001234567890:17585', 'discord:#engineering'" + "description": "Delivery target: origin, local, telegram, discord, slack, whatsapp, signal, matrix, mattermost, homeassistant, dingtalk, feishu, wecom, email, sms, bluebubbles, or platform:chat_id or platform:chat_id:thread_id for Telegram topics. Examples: 'origin', 'local', 'telegram', 'telegram:-1001234567890:17585', 'discord:#engineering'" }, "skills": { "type": "array", diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 164b8a2f..76b3e158 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -148,6 +148,7 @@ def _handle_send(args): "slack": Platform.SLACK, "whatsapp": Platform.WHATSAPP, "signal": Platform.SIGNAL, + "bluebubbles": Platform.BLUEBUBBLES, "matrix": Platform.MATRIX, "mattermost": Platform.MATTERMOST, "homeassistant": Platform.HOMEASSISTANT, @@ -396,6 +397,8 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, result = await _send_feishu(pconfig, chat_id, chunk, thread_id=thread_id) elif platform == Platform.WECOM: result = await _send_wecom(pconfig.extra, chat_id, chunk) + elif platform == Platform.BLUEBUBBLES: + result = await _send_bluebubbles(pconfig.extra, chat_id, chunk) else: result = {"error": f"Direct sending not yet implemented for {platform.value}"} @@ -870,6 +873,33 @@ async def _send_wecom(extra, chat_id, message): return _error(f"WeCom send failed: {e}") +async def _send_bluebubbles(extra, chat_id, message): + """Send via BlueBubbles iMessage server using the adapter's REST API.""" + try: + from gateway.platforms.bluebubbles import BlueBubblesAdapter, check_bluebubbles_requirements + if not check_bluebubbles_requirements(): + return {"error": "BlueBubbles requirements not met (need aiohttp + httpx)."} + except ImportError: + return {"error": "BlueBubbles adapter not available."} + + try: + from gateway.config import PlatformConfig + pconfig = PlatformConfig(extra=extra) + adapter = BlueBubblesAdapter(pconfig) + connected = await adapter.connect() + if not connected: + return _error("BlueBubbles: failed to connect to server") + try: + result = await adapter.send(chat_id, message) + if not result.success: + return _error(f"BlueBubbles send failed: {result.error}") + return {"success": True, "platform": "bluebubbles", "chat_id": chat_id, "message_id": result.message_id} + finally: + await adapter.disconnect() + except Exception as e: + return _error(f"BlueBubbles send failed: {e}") + + async def _send_feishu(pconfig, chat_id, message, media_files=None, thread_id=None): """Send via Feishu/Lark using the adapter's send pipeline.""" try: diff --git a/toolsets.py b/toolsets.py index 2a359b60..a786ee7c 100644 --- a/toolsets.py +++ b/toolsets.py @@ -311,6 +311,12 @@ TOOLSETS = { "includes": [] }, + "hermes-bluebubbles": { + "description": "BlueBubbles iMessage bot toolset - Apple iMessage via local BlueBubbles server", + "tools": _HERMES_CORE_TOOLS, + "includes": [] + }, + "hermes-homeassistant": { "description": "Home Assistant bot toolset - smart home event monitoring and control", "tools": _HERMES_CORE_TOOLS, @@ -368,7 +374,7 @@ TOOLSETS = { "hermes-gateway": { "description": "Gateway toolset - union of all messaging platform tools", "tools": [], - "includes": ["hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-homeassistant", "hermes-email", "hermes-sms", "hermes-mattermost", "hermes-matrix", "hermes-dingtalk", "hermes-feishu", "hermes-wecom", "hermes-webhook"] + "includes": ["hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-bluebubbles", "hermes-homeassistant", "hermes-email", "hermes-sms", "hermes-mattermost", "hermes-matrix", "hermes-dingtalk", "hermes-feishu", "hermes-wecom", "hermes-webhook"] } }