Coverage for integrations / channels / extensions / bluebubbles_adapter.py: 25.2%
444 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1"""
2BlueBubbles Channel Adapter
4Implements BlueBubbles iMessage bridge integration.
5Based on SantaClaw extension patterns for cross-platform messaging.
7Features:
8- iMessage sending/receiving via BlueBubbles server
9- Attachments (images, videos, files)
10- Reactions (tapbacks)
11- Read receipts
12- Typing indicators
13- Group chats
14- Rich link previews
15- Message effects
16- Socket.IO real-time events
17- Reconnection with exponential backoff
18"""
20from __future__ import annotations
22import asyncio
23import logging
24import os
25import json
26import time
27from typing import Optional, List, Dict, Any, Callable, Set
28from datetime import datetime
29from dataclasses import dataclass, field
30from enum import Enum
32try:
33 import aiohttp
34 import socketio
35 HAS_BLUEBUBBLES = True
36except ImportError:
37 HAS_BLUEBUBBLES = False
39from ..base import (
40 ChannelAdapter,
41 ChannelConfig,
42 ChannelStatus,
43 Message,
44 MessageType,
45 MediaAttachment,
46 SendResult,
47 ChannelConnectionError,
48 ChannelSendError,
49 ChannelRateLimitError,
50)
52logger = logging.getLogger(__name__)
55class TapbackType(Enum):
56 """iMessage tapback (reaction) types."""
57 LOVE = "love"
58 LIKE = "like"
59 DISLIKE = "dislike"
60 LAUGH = "laugh"
61 EMPHASIZE = "emphasize"
62 QUESTION = "question"
65class MessageEffect(Enum):
66 """iMessage bubble and screen effects."""
67 SLAM = "com.apple.MobileSMS.expressivesend.impact"
68 LOUD = "com.apple.MobileSMS.expressivesend.loud"
69 GENTLE = "com.apple.MobileSMS.expressivesend.gentle"
70 INVISIBLE_INK = "com.apple.MobileSMS.expressivesend.invisibleink"
71 ECHO = "com.apple.messages.effect.CKEchoEffect"
72 SPOTLIGHT = "com.apple.messages.effect.CKSpotlightEffect"
73 BALLOONS = "com.apple.messages.effect.CKHappyBirthdayEffect"
74 CONFETTI = "com.apple.messages.effect.CKConfettiEffect"
75 HEART = "com.apple.messages.effect.CKHeartEffect"
76 LASERS = "com.apple.messages.effect.CKLasersEffect"
77 FIREWORKS = "com.apple.messages.effect.CKFireworksEffect"
78 CELEBRATION = "com.apple.messages.effect.CKSparklesEffect"
81@dataclass
82class BlueBubblesConfig(ChannelConfig):
83 """BlueBubbles-specific configuration."""
84 server_url: str = ""
85 password: str = ""
86 enable_read_receipts: bool = True
87 enable_typing_indicators: bool = True
88 enable_reactions: bool = True
89 enable_effects: bool = True
90 private_api_enabled: bool = False # Requires Private API helper
91 socket_reconnect: bool = True
92 reconnect_attempts: int = 5
93 reconnect_delay: float = 1.0
96@dataclass
97class BlueBubblesChat:
98 """BlueBubbles chat (conversation) information."""
99 guid: str
100 display_name: Optional[str] = None
101 participants: List[str] = field(default_factory=list)
102 is_group: bool = False
103 is_imessage: bool = True
104 last_message: Optional[str] = None
107@dataclass
108class BlueBubblesAttachment:
109 """Attachment information."""
110 guid: str
111 filename: str
112 mime_type: str
113 transfer_name: str
114 total_bytes: int
115 is_sticker: bool = False
116 hide_attachment: bool = False
119class BlueBubblesAdapter(ChannelAdapter):
120 """
121 BlueBubbles iMessage bridge adapter.
123 Requires a running BlueBubbles server on a Mac.
125 Usage:
126 config = BlueBubblesConfig(
127 server_url="http://192.168.1.100:1234",
128 password="your-server-password",
129 )
130 adapter = BlueBubblesAdapter(config)
131 adapter.on_message(my_handler)
132 await adapter.start()
133 """
135 def __init__(self, config: BlueBubblesConfig):
136 if not HAS_BLUEBUBBLES:
137 raise ImportError(
138 "aiohttp and python-socketio not installed. "
139 "Install with: pip install aiohttp python-socketio"
140 )
142 super().__init__(config)
143 self.bb_config: BlueBubblesConfig = config
144 self._session: Optional[aiohttp.ClientSession] = None
145 self._sio: Optional[socketio.AsyncClient] = None
146 self._chats: Dict[str, BlueBubblesChat] = {}
147 self._reaction_handlers: List[Callable] = []
148 self._typing_handlers: List[Callable] = []
149 self._reconnect_count: int = 0
150 self._connected: bool = False
152 @property
153 def name(self) -> str:
154 return "bluebubbles"
156 async def connect(self) -> bool:
157 """Connect to BlueBubbles server."""
158 if not self.bb_config.server_url:
159 logger.error("BlueBubbles server URL required")
160 return False
162 if not self.bb_config.password:
163 logger.error("BlueBubbles password required")
164 return False
166 try:
167 # Create HTTP session
168 self._session = aiohttp.ClientSession()
170 # Verify connection
171 server_info = await self._get_server_info()
172 if not server_info:
173 logger.error("Failed to connect to BlueBubbles server")
174 self.status = ChannelStatus.ERROR
175 return False
177 logger.info(f"BlueBubbles server: v{server_info.get('server_version', 'unknown')}")
179 # Connect Socket.IO
180 await self._connect_socket()
182 # Load initial chats
183 await self._load_chats()
185 self.status = ChannelStatus.CONNECTED
186 self._connected = True
187 self._reconnect_count = 0
188 logger.info("BlueBubbles connected successfully")
189 return True
191 except Exception as e:
192 logger.error(f"Failed to connect to BlueBubbles: {e}")
193 self.status = ChannelStatus.ERROR
194 return False
196 async def disconnect(self) -> None:
197 """Disconnect from BlueBubbles server."""
198 self._connected = False
200 if self._sio:
201 await self._sio.disconnect()
202 self._sio = None
204 if self._session:
205 await self._session.close()
206 self._session = None
208 self._chats.clear()
209 self.status = ChannelStatus.DISCONNECTED
211 async def _get_server_info(self) -> Optional[Dict[str, Any]]:
212 """Get BlueBubbles server information."""
213 if not self._session:
214 return None
216 try:
217 url = f"{self.bb_config.server_url}/api/v1/server/info"
218 params = {"password": self.bb_config.password}
220 async with self._session.get(url, params=params) as resp:
221 if resp.status == 200:
222 data = await resp.json()
223 return data.get("data", {})
224 else:
225 logger.error(f"Server info request failed: {resp.status}")
227 except Exception as e:
228 logger.error(f"Failed to get server info: {e}")
230 return None
232 async def _connect_socket(self) -> None:
233 """Connect to Socket.IO for real-time events."""
234 self._sio = socketio.AsyncClient(reconnection=self.bb_config.socket_reconnect)
236 @self._sio.event
237 async def connect():
238 logger.info("Socket.IO connected")
239 self._connected = True
241 @self._sio.event
242 async def disconnect():
243 logger.warning("Socket.IO disconnected")
244 self._connected = False
245 if self.bb_config.socket_reconnect:
246 await self._handle_disconnect()
248 @self._sio.on("new-message")
249 async def on_new_message(data):
250 await self._handle_new_message(data)
252 @self._sio.on("updated-message")
253 async def on_updated_message(data):
254 await self._handle_updated_message(data)
256 @self._sio.on("typing-indicator")
257 async def on_typing(data):
258 await self._handle_typing_indicator(data)
260 @self._sio.on("group-name-change")
261 async def on_group_change(data):
262 await self._handle_group_change(data)
264 # Connect with authentication
265 url = self.bb_config.server_url
266 await self._sio.connect(
267 url,
268 auth={"password": self.bb_config.password},
269 transports=["websocket"],
270 )
272 async def _handle_disconnect(self) -> None:
273 """Handle Socket.IO disconnection with reconnection."""
274 if self._reconnect_count < self.bb_config.reconnect_attempts:
275 self._reconnect_count += 1
276 delay = self.bb_config.reconnect_delay * (2 ** (self._reconnect_count - 1))
278 logger.info(f"Reconnecting to BlueBubbles in {delay}s")
279 await asyncio.sleep(delay)
280 await self.connect()
282 async def _load_chats(self) -> None:
283 """Load all chats from server."""
284 if not self._session:
285 return
287 try:
288 url = f"{self.bb_config.server_url}/api/v1/chat/query"
289 params = {
290 "password": self.bb_config.password,
291 "limit": 100,
292 "offset": 0,
293 "with": "lastMessage,participants",
294 }
296 async with self._session.post(url, params=params, json={}) as resp:
297 if resp.status == 200:
298 data = await resp.json()
299 for chat_data in data.get("data", []):
300 chat = self._parse_chat(chat_data)
301 self._chats[chat.guid] = chat
303 logger.info(f"Loaded {len(self._chats)} chats")
305 except Exception as e:
306 logger.error(f"Failed to load chats: {e}")
308 def _parse_chat(self, data: Dict[str, Any]) -> BlueBubblesChat:
309 """Parse chat data from API response."""
310 participants = []
311 for p in data.get("participants", []):
312 handle = p.get("address") or p.get("id", "")
313 if handle:
314 participants.append(handle)
316 return BlueBubblesChat(
317 guid=data.get("guid", ""),
318 display_name=data.get("displayName"),
319 participants=participants,
320 is_group=len(participants) > 1,
321 is_imessage=data.get("service", "iMessage") == "iMessage",
322 last_message=data.get("lastMessage", {}).get("text"),
323 )
325 async def _handle_new_message(self, data: Dict[str, Any]) -> None:
326 """Handle incoming message from Socket.IO."""
327 try:
328 msg_data = data if isinstance(data, dict) else json.loads(data)
330 # Skip sent messages (from this device)
331 if msg_data.get("isFromMe"):
332 return
334 # Convert to unified message
335 message = await self._convert_message(msg_data)
336 if message:
337 await self._dispatch_message(message)
339 except Exception as e:
340 logger.error(f"Error handling new message: {e}")
342 async def _handle_updated_message(self, data: Dict[str, Any]) -> None:
343 """Handle message update (reactions, read receipts)."""
344 try:
345 msg_data = data if isinstance(data, dict) else json.loads(data)
347 # Check for tapback (reaction)
348 if msg_data.get("associatedMessageType") and self.bb_config.enable_reactions:
349 await self._handle_tapback(msg_data)
351 except Exception as e:
352 logger.error(f"Error handling message update: {e}")
354 async def _handle_tapback(self, data: Dict[str, Any]) -> None:
355 """Handle tapback (reaction) event."""
356 for handler in self._reaction_handlers:
357 try:
358 result = handler(data)
359 if asyncio.iscoroutine(result):
360 await result
361 except Exception as e:
362 logger.error(f"Reaction handler error: {e}")
364 async def _handle_typing_indicator(self, data: Dict[str, Any]) -> None:
365 """Handle typing indicator event."""
366 if not self.bb_config.enable_typing_indicators:
367 return
369 for handler in self._typing_handlers:
370 try:
371 result = handler(data)
372 if asyncio.iscoroutine(result):
373 await result
374 except Exception as e:
375 logger.error(f"Typing handler error: {e}")
377 async def _handle_group_change(self, data: Dict[str, Any]) -> None:
378 """Handle group name change event."""
379 chat_guid = data.get("chatGuid")
380 new_name = data.get("newName")
382 if chat_guid in self._chats:
383 self._chats[chat_guid].display_name = new_name
384 logger.info(f"Group name changed: {new_name}")
386 async def _convert_message(self, data: Dict[str, Any]) -> Optional[Message]:
387 """Convert BlueBubbles message to unified Message format."""
388 try:
389 # Get chat info
390 chat_guid = data.get("chats", [{}])[0].get("guid") if data.get("chats") else ""
392 # Get sender
393 handle = data.get("handle", {})
394 sender_id = handle.get("address") or handle.get("id", "")
396 # Get text
397 text = data.get("text", "")
398 subject = data.get("subject")
399 if subject:
400 text = f"[Subject: {subject}] {text}"
402 # Handle attachments
403 media = []
404 for att_data in data.get("attachments", []):
405 attachment = self._parse_attachment(att_data)
406 if attachment:
407 media_type = self._get_media_type(attachment.mime_type)
408 media.append(MediaAttachment(
409 type=media_type,
410 file_id=attachment.guid,
411 file_name=attachment.filename,
412 mime_type=attachment.mime_type,
413 file_size=attachment.total_bytes,
414 ))
416 # Check if group
417 chat = self._chats.get(chat_guid)
418 is_group = chat.is_group if chat else False
420 return Message(
421 id=data.get("guid", str(int(time.time() * 1000))),
422 channel=self.name,
423 sender_id=sender_id,
424 sender_name=sender_id, # BlueBubbles doesn't provide names easily
425 chat_id=chat_guid,
426 text=text,
427 media=media,
428 timestamp=datetime.fromtimestamp(data.get("dateCreated", 0) / 1000) if data.get("dateCreated") else datetime.now(),
429 is_group=is_group,
430 raw={
431 "service": data.get("service"),
432 "is_imessage": data.get("service") == "iMessage",
433 "effect": data.get("expressiveSendStyleId"),
434 "thread_origin_guid": data.get("threadOriginatorGuid"),
435 },
436 )
438 except Exception as e:
439 logger.error(f"Error converting message: {e}")
440 return None
442 def _parse_attachment(self, data: Dict[str, Any]) -> Optional[BlueBubblesAttachment]:
443 """Parse attachment data."""
444 try:
445 return BlueBubblesAttachment(
446 guid=data.get("guid", ""),
447 filename=data.get("filename", ""),
448 mime_type=data.get("mimeType", "application/octet-stream"),
449 transfer_name=data.get("transferName", ""),
450 total_bytes=data.get("totalBytes", 0),
451 is_sticker=data.get("isSticker", False),
452 hide_attachment=data.get("hideAttachment", False),
453 )
454 except Exception:
455 return None
457 def _get_media_type(self, mime_type: str) -> MessageType:
458 """Get MessageType from MIME type."""
459 if mime_type.startswith("image/"):
460 return MessageType.IMAGE
461 elif mime_type.startswith("video/"):
462 return MessageType.VIDEO
463 elif mime_type.startswith("audio/"):
464 return MessageType.AUDIO
465 else:
466 return MessageType.DOCUMENT
468 async def send_message(
469 self,
470 chat_id: str,
471 text: str,
472 reply_to: Optional[str] = None,
473 media: Optional[List[MediaAttachment]] = None,
474 buttons: Optional[List[Dict]] = None,
475 ) -> SendResult:
476 """Send a message via BlueBubbles."""
477 if not self._session:
478 return SendResult(success=False, error="Not connected")
480 try:
481 # Check if sending to phone number (new conversation)
482 if chat_id.startswith("+") or "@" in chat_id:
483 return await self._send_to_address(chat_id, text, media)
485 # Send to existing chat
486 return await self._send_to_chat(chat_id, text, reply_to, media)
488 except Exception as e:
489 logger.error(f"Failed to send message: {e}")
490 return SendResult(success=False, error=str(e))
492 async def _send_to_address(
493 self,
494 address: str,
495 text: str,
496 media: Optional[List[MediaAttachment]] = None,
497 ) -> SendResult:
498 """Send message to a phone number or email."""
499 if not self._session:
500 return SendResult(success=False, error="Not connected")
502 try:
503 url = f"{self.bb_config.server_url}/api/v1/message/text"
504 params = {"password": self.bb_config.password}
506 data = {
507 "chatGuid": f"iMessage;-;{address}",
508 "message": text,
509 }
511 async with self._session.post(url, params=params, json=data) as resp:
512 if resp.status == 200:
513 result = await resp.json()
514 msg_data = result.get("data", {})
515 return SendResult(
516 success=True,
517 message_id=msg_data.get("guid"),
518 )
519 else:
520 error = await resp.text()
521 return SendResult(success=False, error=error)
523 except Exception as e:
524 return SendResult(success=False, error=str(e))
526 async def _send_to_chat(
527 self,
528 chat_guid: str,
529 text: str,
530 reply_to: Optional[str] = None,
531 media: Optional[List[MediaAttachment]] = None,
532 ) -> SendResult:
533 """Send message to existing chat."""
534 if not self._session:
535 return SendResult(success=False, error="Not connected")
537 try:
538 url = f"{self.bb_config.server_url}/api/v1/message/text"
539 params = {"password": self.bb_config.password}
541 data = {
542 "chatGuid": chat_guid,
543 "message": text,
544 }
546 # Add reply
547 if reply_to:
548 data["selectedMessageGuid"] = reply_to
550 # Handle attachments
551 if media and len(media) > 0:
552 # Send attachments separately
553 for m in media:
554 await self._send_attachment(chat_guid, m)
556 async with self._session.post(url, params=params, json=data) as resp:
557 if resp.status == 200:
558 result = await resp.json()
559 msg_data = result.get("data", {})
560 return SendResult(
561 success=True,
562 message_id=msg_data.get("guid"),
563 )
564 else:
565 error = await resp.text()
566 return SendResult(success=False, error=error)
568 except Exception as e:
569 return SendResult(success=False, error=str(e))
571 async def _send_attachment(
572 self,
573 chat_guid: str,
574 media: MediaAttachment,
575 ) -> SendResult:
576 """Send attachment to chat."""
577 if not self._session:
578 return SendResult(success=False, error="Not connected")
580 if not media.file_path and not media.url:
581 return SendResult(success=False, error="No file source")
583 try:
584 url = f"{self.bb_config.server_url}/api/v1/message/attachment"
585 params = {"password": self.bb_config.password}
587 # Prepare form data
588 form = aiohttp.FormData()
589 form.add_field("chatGuid", chat_guid)
591 if media.file_path:
592 with open(media.file_path, "rb") as f:
593 form.add_field(
594 "attachment",
595 f.read(),
596 filename=media.file_name or "attachment",
597 content_type=media.mime_type or "application/octet-stream",
598 )
599 elif media.url:
600 # Download and re-upload
601 async with self._session.get(media.url) as dl_resp:
602 if dl_resp.status == 200:
603 content = await dl_resp.read()
604 form.add_field(
605 "attachment",
606 content,
607 filename=media.file_name or "attachment",
608 content_type=media.mime_type or "application/octet-stream",
609 )
611 async with self._session.post(url, params=params, data=form) as resp:
612 if resp.status == 200:
613 return SendResult(success=True)
614 else:
615 error = await resp.text()
616 return SendResult(success=False, error=error)
618 except Exception as e:
619 return SendResult(success=False, error=str(e))
621 async def edit_message(
622 self,
623 chat_id: str,
624 message_id: str,
625 text: str,
626 buttons: Optional[List[Dict]] = None,
627 ) -> SendResult:
628 """
629 Edit an iMessage.
630 Note: Requires Private API and iOS 16+.
631 """
632 if not self.bb_config.private_api_enabled:
633 return SendResult(success=False, error="Private API not enabled")
635 if not self._session:
636 return SendResult(success=False, error="Not connected")
638 try:
639 url = f"{self.bb_config.server_url}/api/v1/message/{message_id}/edit"
640 params = {"password": self.bb_config.password}
642 data = {"editedMessage": text}
644 async with self._session.post(url, params=params, json=data) as resp:
645 if resp.status == 200:
646 return SendResult(success=True, message_id=message_id)
647 else:
648 error = await resp.text()
649 return SendResult(success=False, error=error)
651 except Exception as e:
652 return SendResult(success=False, error=str(e))
654 async def delete_message(self, chat_id: str, message_id: str) -> bool:
655 """
656 Unsend an iMessage.
657 Note: Requires Private API and iOS 16+.
658 """
659 if not self.bb_config.private_api_enabled:
660 logger.warning("Private API required for unsend")
661 return False
663 if not self._session:
664 return False
666 try:
667 url = f"{self.bb_config.server_url}/api/v1/message/{message_id}/unsend"
668 params = {"password": self.bb_config.password}
670 async with self._session.post(url, params=params) as resp:
671 return resp.status == 200
673 except Exception as e:
674 logger.error(f"Failed to unsend message: {e}")
675 return False
677 async def send_typing(self, chat_id: str) -> None:
678 """Send typing indicator."""
679 if not self.bb_config.enable_typing_indicators:
680 return
682 if not self.bb_config.private_api_enabled:
683 return
685 if not self._session:
686 return
688 try:
689 url = f"{self.bb_config.server_url}/api/v1/chat/{chat_id}/typing"
690 params = {"password": self.bb_config.password}
692 await self._session.post(url, params=params)
694 except Exception:
695 pass
697 async def get_chat_info(self, chat_id: str) -> Optional[Dict[str, Any]]:
698 """Get information about a chat."""
699 # Check cache
700 if chat_id in self._chats:
701 chat = self._chats[chat_id]
702 return {
703 "guid": chat.guid,
704 "display_name": chat.display_name,
705 "participants": chat.participants,
706 "is_group": chat.is_group,
707 "is_imessage": chat.is_imessage,
708 }
710 # Fetch from API
711 if not self._session:
712 return None
714 try:
715 url = f"{self.bb_config.server_url}/api/v1/chat/{chat_id}"
716 params = {"password": self.bb_config.password}
718 async with self._session.get(url, params=params) as resp:
719 if resp.status == 200:
720 data = await resp.json()
721 chat = self._parse_chat(data.get("data", {}))
722 self._chats[chat.guid] = chat
723 return {
724 "guid": chat.guid,
725 "display_name": chat.display_name,
726 "participants": chat.participants,
727 "is_group": chat.is_group,
728 "is_imessage": chat.is_imessage,
729 }
731 except Exception as e:
732 logger.error(f"Failed to get chat info: {e}")
734 return None
736 # BlueBubbles-specific methods
738 def on_reaction(self, handler: Callable[[Dict[str, Any]], Any]) -> None:
739 """Register a tapback (reaction) handler."""
740 self._reaction_handlers.append(handler)
742 def on_typing(self, handler: Callable[[Dict[str, Any]], Any]) -> None:
743 """Register a typing indicator handler."""
744 self._typing_handlers.append(handler)
746 async def send_tapback(
747 self,
748 chat_id: str,
749 message_id: str,
750 tapback: TapbackType,
751 ) -> SendResult:
752 """Send a tapback (reaction) to a message."""
753 if not self.bb_config.enable_reactions:
754 return SendResult(success=False, error="Reactions disabled")
756 if not self.bb_config.private_api_enabled:
757 return SendResult(success=False, error="Private API required")
759 if not self._session:
760 return SendResult(success=False, error="Not connected")
762 try:
763 url = f"{self.bb_config.server_url}/api/v1/message/{message_id}/react"
764 params = {"password": self.bb_config.password}
766 data = {
767 "chatGuid": chat_id,
768 "reaction": tapback.value,
769 }
771 async with self._session.post(url, params=params, json=data) as resp:
772 if resp.status == 200:
773 return SendResult(success=True)
774 else:
775 error = await resp.text()
776 return SendResult(success=False, error=error)
778 except Exception as e:
779 return SendResult(success=False, error=str(e))
781 async def send_with_effect(
782 self,
783 chat_id: str,
784 text: str,
785 effect: MessageEffect,
786 ) -> SendResult:
787 """Send a message with a bubble or screen effect."""
788 if not self.bb_config.enable_effects:
789 return SendResult(success=False, error="Effects disabled")
791 if not self.bb_config.private_api_enabled:
792 return SendResult(success=False, error="Private API required")
794 if not self._session:
795 return SendResult(success=False, error="Not connected")
797 try:
798 url = f"{self.bb_config.server_url}/api/v1/message/text"
799 params = {"password": self.bb_config.password}
801 data = {
802 "chatGuid": chat_id,
803 "message": text,
804 "effectId": effect.value,
805 }
807 async with self._session.post(url, params=params, json=data) as resp:
808 if resp.status == 200:
809 result = await resp.json()
810 msg_data = result.get("data", {})
811 return SendResult(
812 success=True,
813 message_id=msg_data.get("guid"),
814 )
815 else:
816 error = await resp.text()
817 return SendResult(success=False, error=error)
819 except Exception as e:
820 return SendResult(success=False, error=str(e))
822 async def mark_read(self, chat_id: str) -> bool:
823 """Mark chat as read."""
824 if not self.bb_config.enable_read_receipts:
825 return False
827 if not self.bb_config.private_api_enabled:
828 return False
830 if not self._session:
831 return False
833 try:
834 url = f"{self.bb_config.server_url}/api/v1/chat/{chat_id}/read"
835 params = {"password": self.bb_config.password}
837 async with self._session.post(url, params=params) as resp:
838 return resp.status == 200
840 except Exception:
841 return False
843 async def get_attachment(self, attachment_guid: str) -> Optional[bytes]:
844 """Download attachment content."""
845 if not self._session:
846 return None
848 try:
849 url = f"{self.bb_config.server_url}/api/v1/attachment/{attachment_guid}/download"
850 params = {"password": self.bb_config.password}
852 async with self._session.get(url, params=params) as resp:
853 if resp.status == 200:
854 return await resp.read()
856 except Exception as e:
857 logger.error(f"Failed to download attachment: {e}")
859 return None
861 async def create_group(
862 self,
863 participants: List[str],
864 name: Optional[str] = None,
865 ) -> Optional[str]:
866 """Create a new group chat."""
867 if not self._session:
868 return None
870 try:
871 url = f"{self.bb_config.server_url}/api/v1/chat/new"
872 params = {"password": self.bb_config.password}
874 data = {
875 "participants": participants,
876 }
878 if name:
879 data["displayName"] = name
881 async with self._session.post(url, params=params, json=data) as resp:
882 if resp.status == 200:
883 result = await resp.json()
884 chat_data = result.get("data", {})
885 return chat_data.get("guid")
887 except Exception as e:
888 logger.error(f"Failed to create group: {e}")
890 return None
892 async def rename_group(self, chat_id: str, new_name: str) -> bool:
893 """Rename a group chat."""
894 if not self.bb_config.private_api_enabled:
895 return False
897 if not self._session:
898 return False
900 try:
901 url = f"{self.bb_config.server_url}/api/v1/chat/{chat_id}/name"
902 params = {"password": self.bb_config.password}
904 data = {"name": new_name}
906 async with self._session.patch(url, params=params, json=data) as resp:
907 if resp.status == 200:
908 if chat_id in self._chats:
909 self._chats[chat_id].display_name = new_name
910 return True
912 except Exception as e:
913 logger.error(f"Failed to rename group: {e}")
915 return False
917 async def add_participant(self, chat_id: str, address: str) -> bool:
918 """Add participant to group."""
919 if not self.bb_config.private_api_enabled:
920 return False
922 if not self._session:
923 return False
925 try:
926 url = f"{self.bb_config.server_url}/api/v1/chat/{chat_id}/participant"
927 params = {"password": self.bb_config.password}
929 data = {"address": address}
931 async with self._session.post(url, params=params, json=data) as resp:
932 return resp.status == 200
934 except Exception:
935 return False
937 async def remove_participant(self, chat_id: str, address: str) -> bool:
938 """Remove participant from group."""
939 if not self.bb_config.private_api_enabled:
940 return False
942 if not self._session:
943 return False
945 try:
946 url = f"{self.bb_config.server_url}/api/v1/chat/{chat_id}/participant/{address}"
947 params = {"password": self.bb_config.password}
949 async with self._session.delete(url, params=params) as resp:
950 return resp.status == 200
952 except Exception:
953 return False
956def create_bluebubbles_adapter(
957 server_url: str = None,
958 password: str = None,
959 **kwargs
960) -> BlueBubblesAdapter:
961 """
962 Factory function to create BlueBubbles adapter.
964 Args:
965 server_url: BlueBubbles server URL (or set BLUEBUBBLES_SERVER_URL env var)
966 password: Server password (or set BLUEBUBBLES_PASSWORD env var)
967 **kwargs: Additional config options
969 Returns:
970 Configured BlueBubblesAdapter
971 """
972 server_url = server_url or os.getenv("BLUEBUBBLES_SERVER_URL")
973 password = password or os.getenv("BLUEBUBBLES_PASSWORD")
975 if not server_url:
976 raise ValueError("BlueBubbles server URL required")
977 if not password:
978 raise ValueError("BlueBubbles password required")
980 config = BlueBubblesConfig(
981 server_url=server_url,
982 password=password,
983 **kwargs
984 )
985 return BlueBubblesAdapter(config)