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

1""" 

2BlueBubbles Channel Adapter 

3 

4Implements BlueBubbles iMessage bridge integration. 

5Based on SantaClaw extension patterns for cross-platform messaging. 

6 

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""" 

19 

20from __future__ import annotations 

21 

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 

31 

32try: 

33 import aiohttp 

34 import socketio 

35 HAS_BLUEBUBBLES = True 

36except ImportError: 

37 HAS_BLUEBUBBLES = False 

38 

39from ..base import ( 

40 ChannelAdapter, 

41 ChannelConfig, 

42 ChannelStatus, 

43 Message, 

44 MessageType, 

45 MediaAttachment, 

46 SendResult, 

47 ChannelConnectionError, 

48 ChannelSendError, 

49 ChannelRateLimitError, 

50) 

51 

52logger = logging.getLogger(__name__) 

53 

54 

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" 

63 

64 

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" 

79 

80 

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 

94 

95 

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 

105 

106 

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 

117 

118 

119class BlueBubblesAdapter(ChannelAdapter): 

120 """ 

121 BlueBubbles iMessage bridge adapter. 

122 

123 Requires a running BlueBubbles server on a Mac. 

124 

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 """ 

134 

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 ) 

141 

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 

151 

152 @property 

153 def name(self) -> str: 

154 return "bluebubbles" 

155 

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 

161 

162 if not self.bb_config.password: 

163 logger.error("BlueBubbles password required") 

164 return False 

165 

166 try: 

167 # Create HTTP session 

168 self._session = aiohttp.ClientSession() 

169 

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 

176 

177 logger.info(f"BlueBubbles server: v{server_info.get('server_version', 'unknown')}") 

178 

179 # Connect Socket.IO 

180 await self._connect_socket() 

181 

182 # Load initial chats 

183 await self._load_chats() 

184 

185 self.status = ChannelStatus.CONNECTED 

186 self._connected = True 

187 self._reconnect_count = 0 

188 logger.info("BlueBubbles connected successfully") 

189 return True 

190 

191 except Exception as e: 

192 logger.error(f"Failed to connect to BlueBubbles: {e}") 

193 self.status = ChannelStatus.ERROR 

194 return False 

195 

196 async def disconnect(self) -> None: 

197 """Disconnect from BlueBubbles server.""" 

198 self._connected = False 

199 

200 if self._sio: 

201 await self._sio.disconnect() 

202 self._sio = None 

203 

204 if self._session: 

205 await self._session.close() 

206 self._session = None 

207 

208 self._chats.clear() 

209 self.status = ChannelStatus.DISCONNECTED 

210 

211 async def _get_server_info(self) -> Optional[Dict[str, Any]]: 

212 """Get BlueBubbles server information.""" 

213 if not self._session: 

214 return None 

215 

216 try: 

217 url = f"{self.bb_config.server_url}/api/v1/server/info" 

218 params = {"password": self.bb_config.password} 

219 

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}") 

226 

227 except Exception as e: 

228 logger.error(f"Failed to get server info: {e}") 

229 

230 return None 

231 

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) 

235 

236 @self._sio.event 

237 async def connect(): 

238 logger.info("Socket.IO connected") 

239 self._connected = True 

240 

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() 

247 

248 @self._sio.on("new-message") 

249 async def on_new_message(data): 

250 await self._handle_new_message(data) 

251 

252 @self._sio.on("updated-message") 

253 async def on_updated_message(data): 

254 await self._handle_updated_message(data) 

255 

256 @self._sio.on("typing-indicator") 

257 async def on_typing(data): 

258 await self._handle_typing_indicator(data) 

259 

260 @self._sio.on("group-name-change") 

261 async def on_group_change(data): 

262 await self._handle_group_change(data) 

263 

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 ) 

271 

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)) 

277 

278 logger.info(f"Reconnecting to BlueBubbles in {delay}s") 

279 await asyncio.sleep(delay) 

280 await self.connect() 

281 

282 async def _load_chats(self) -> None: 

283 """Load all chats from server.""" 

284 if not self._session: 

285 return 

286 

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 } 

295 

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 

302 

303 logger.info(f"Loaded {len(self._chats)} chats") 

304 

305 except Exception as e: 

306 logger.error(f"Failed to load chats: {e}") 

307 

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) 

315 

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 ) 

324 

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) 

329 

330 # Skip sent messages (from this device) 

331 if msg_data.get("isFromMe"): 

332 return 

333 

334 # Convert to unified message 

335 message = await self._convert_message(msg_data) 

336 if message: 

337 await self._dispatch_message(message) 

338 

339 except Exception as e: 

340 logger.error(f"Error handling new message: {e}") 

341 

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) 

346 

347 # Check for tapback (reaction) 

348 if msg_data.get("associatedMessageType") and self.bb_config.enable_reactions: 

349 await self._handle_tapback(msg_data) 

350 

351 except Exception as e: 

352 logger.error(f"Error handling message update: {e}") 

353 

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}") 

363 

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 

368 

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}") 

376 

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") 

381 

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}") 

385 

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 "" 

391 

392 # Get sender 

393 handle = data.get("handle", {}) 

394 sender_id = handle.get("address") or handle.get("id", "") 

395 

396 # Get text 

397 text = data.get("text", "") 

398 subject = data.get("subject") 

399 if subject: 

400 text = f"[Subject: {subject}] {text}" 

401 

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 )) 

415 

416 # Check if group 

417 chat = self._chats.get(chat_guid) 

418 is_group = chat.is_group if chat else False 

419 

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 ) 

437 

438 except Exception as e: 

439 logger.error(f"Error converting message: {e}") 

440 return None 

441 

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 

456 

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 

467 

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") 

479 

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) 

484 

485 # Send to existing chat 

486 return await self._send_to_chat(chat_id, text, reply_to, media) 

487 

488 except Exception as e: 

489 logger.error(f"Failed to send message: {e}") 

490 return SendResult(success=False, error=str(e)) 

491 

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") 

501 

502 try: 

503 url = f"{self.bb_config.server_url}/api/v1/message/text" 

504 params = {"password": self.bb_config.password} 

505 

506 data = { 

507 "chatGuid": f"iMessage;-;{address}", 

508 "message": text, 

509 } 

510 

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) 

522 

523 except Exception as e: 

524 return SendResult(success=False, error=str(e)) 

525 

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") 

536 

537 try: 

538 url = f"{self.bb_config.server_url}/api/v1/message/text" 

539 params = {"password": self.bb_config.password} 

540 

541 data = { 

542 "chatGuid": chat_guid, 

543 "message": text, 

544 } 

545 

546 # Add reply 

547 if reply_to: 

548 data["selectedMessageGuid"] = reply_to 

549 

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) 

555 

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) 

567 

568 except Exception as e: 

569 return SendResult(success=False, error=str(e)) 

570 

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") 

579 

580 if not media.file_path and not media.url: 

581 return SendResult(success=False, error="No file source") 

582 

583 try: 

584 url = f"{self.bb_config.server_url}/api/v1/message/attachment" 

585 params = {"password": self.bb_config.password} 

586 

587 # Prepare form data 

588 form = aiohttp.FormData() 

589 form.add_field("chatGuid", chat_guid) 

590 

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 ) 

610 

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) 

617 

618 except Exception as e: 

619 return SendResult(success=False, error=str(e)) 

620 

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") 

634 

635 if not self._session: 

636 return SendResult(success=False, error="Not connected") 

637 

638 try: 

639 url = f"{self.bb_config.server_url}/api/v1/message/{message_id}/edit" 

640 params = {"password": self.bb_config.password} 

641 

642 data = {"editedMessage": text} 

643 

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) 

650 

651 except Exception as e: 

652 return SendResult(success=False, error=str(e)) 

653 

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 

662 

663 if not self._session: 

664 return False 

665 

666 try: 

667 url = f"{self.bb_config.server_url}/api/v1/message/{message_id}/unsend" 

668 params = {"password": self.bb_config.password} 

669 

670 async with self._session.post(url, params=params) as resp: 

671 return resp.status == 200 

672 

673 except Exception as e: 

674 logger.error(f"Failed to unsend message: {e}") 

675 return False 

676 

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 

681 

682 if not self.bb_config.private_api_enabled: 

683 return 

684 

685 if not self._session: 

686 return 

687 

688 try: 

689 url = f"{self.bb_config.server_url}/api/v1/chat/{chat_id}/typing" 

690 params = {"password": self.bb_config.password} 

691 

692 await self._session.post(url, params=params) 

693 

694 except Exception: 

695 pass 

696 

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 } 

709 

710 # Fetch from API 

711 if not self._session: 

712 return None 

713 

714 try: 

715 url = f"{self.bb_config.server_url}/api/v1/chat/{chat_id}" 

716 params = {"password": self.bb_config.password} 

717 

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 } 

730 

731 except Exception as e: 

732 logger.error(f"Failed to get chat info: {e}") 

733 

734 return None 

735 

736 # BlueBubbles-specific methods 

737 

738 def on_reaction(self, handler: Callable[[Dict[str, Any]], Any]) -> None: 

739 """Register a tapback (reaction) handler.""" 

740 self._reaction_handlers.append(handler) 

741 

742 def on_typing(self, handler: Callable[[Dict[str, Any]], Any]) -> None: 

743 """Register a typing indicator handler.""" 

744 self._typing_handlers.append(handler) 

745 

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") 

755 

756 if not self.bb_config.private_api_enabled: 

757 return SendResult(success=False, error="Private API required") 

758 

759 if not self._session: 

760 return SendResult(success=False, error="Not connected") 

761 

762 try: 

763 url = f"{self.bb_config.server_url}/api/v1/message/{message_id}/react" 

764 params = {"password": self.bb_config.password} 

765 

766 data = { 

767 "chatGuid": chat_id, 

768 "reaction": tapback.value, 

769 } 

770 

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) 

777 

778 except Exception as e: 

779 return SendResult(success=False, error=str(e)) 

780 

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") 

790 

791 if not self.bb_config.private_api_enabled: 

792 return SendResult(success=False, error="Private API required") 

793 

794 if not self._session: 

795 return SendResult(success=False, error="Not connected") 

796 

797 try: 

798 url = f"{self.bb_config.server_url}/api/v1/message/text" 

799 params = {"password": self.bb_config.password} 

800 

801 data = { 

802 "chatGuid": chat_id, 

803 "message": text, 

804 "effectId": effect.value, 

805 } 

806 

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) 

818 

819 except Exception as e: 

820 return SendResult(success=False, error=str(e)) 

821 

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 

826 

827 if not self.bb_config.private_api_enabled: 

828 return False 

829 

830 if not self._session: 

831 return False 

832 

833 try: 

834 url = f"{self.bb_config.server_url}/api/v1/chat/{chat_id}/read" 

835 params = {"password": self.bb_config.password} 

836 

837 async with self._session.post(url, params=params) as resp: 

838 return resp.status == 200 

839 

840 except Exception: 

841 return False 

842 

843 async def get_attachment(self, attachment_guid: str) -> Optional[bytes]: 

844 """Download attachment content.""" 

845 if not self._session: 

846 return None 

847 

848 try: 

849 url = f"{self.bb_config.server_url}/api/v1/attachment/{attachment_guid}/download" 

850 params = {"password": self.bb_config.password} 

851 

852 async with self._session.get(url, params=params) as resp: 

853 if resp.status == 200: 

854 return await resp.read() 

855 

856 except Exception as e: 

857 logger.error(f"Failed to download attachment: {e}") 

858 

859 return None 

860 

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 

869 

870 try: 

871 url = f"{self.bb_config.server_url}/api/v1/chat/new" 

872 params = {"password": self.bb_config.password} 

873 

874 data = { 

875 "participants": participants, 

876 } 

877 

878 if name: 

879 data["displayName"] = name 

880 

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") 

886 

887 except Exception as e: 

888 logger.error(f"Failed to create group: {e}") 

889 

890 return None 

891 

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 

896 

897 if not self._session: 

898 return False 

899 

900 try: 

901 url = f"{self.bb_config.server_url}/api/v1/chat/{chat_id}/name" 

902 params = {"password": self.bb_config.password} 

903 

904 data = {"name": new_name} 

905 

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 

911 

912 except Exception as e: 

913 logger.error(f"Failed to rename group: {e}") 

914 

915 return False 

916 

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 

921 

922 if not self._session: 

923 return False 

924 

925 try: 

926 url = f"{self.bb_config.server_url}/api/v1/chat/{chat_id}/participant" 

927 params = {"password": self.bb_config.password} 

928 

929 data = {"address": address} 

930 

931 async with self._session.post(url, params=params, json=data) as resp: 

932 return resp.status == 200 

933 

934 except Exception: 

935 return False 

936 

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 

941 

942 if not self._session: 

943 return False 

944 

945 try: 

946 url = f"{self.bb_config.server_url}/api/v1/chat/{chat_id}/participant/{address}" 

947 params = {"password": self.bb_config.password} 

948 

949 async with self._session.delete(url, params=params) as resp: 

950 return resp.status == 200 

951 

952 except Exception: 

953 return False 

954 

955 

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. 

963 

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 

968 

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") 

974 

975 if not server_url: 

976 raise ValueError("BlueBubbles server URL required") 

977 if not password: 

978 raise ValueError("BlueBubbles password required") 

979 

980 config = BlueBubblesConfig( 

981 server_url=server_url, 

982 password=password, 

983 **kwargs 

984 ) 

985 return BlueBubblesAdapter(config)