ForcePilot/backend/package/yuxi/channels/adapters/imessage/bridge_client.py
Kris 068cf70fe9 feat(imessage): 实现完整的 iMessage 适配器基础模块
新增 iMessage 通道适配器完整实现,包含:
1. 核心适配器与工具工厂导出
2. 运行时存储、反射防护、会话路由等基础组件
3. 消息信封、线程管理、回复上下文格式化
4. Tapback 表情反应处理、自定义异常体系
5. 审批按钮、联系人解析、速率限制功能
6. 文本净化、目标解析、缓存管理模块
7. 配置 schema、多账户支持、安装向导等配置模块
8. 审计日志、媒体AI处理等扩展功能
2026-05-12 00:44:44 +08:00

328 lines
12 KiB
Python

from __future__ import annotations
import asyncio
import os
import time
from typing import Any
from urllib.parse import urlparse
import aiohttp
from yuxi.channels.adapters.imessage.exceptions import BlueBubblesHTTPError
from yuxi.channels.infra.circuit_breaker import CircuitBreaker
from yuxi.channels.models import DeliveryResult
from yuxi.utils.logging_config import logger
def is_test_env() -> bool:
return os.environ.get("NODE_ENV") == "test" or os.environ.get("PYTEST_VERSION", "") != ""
class BlueBubblesClient:
def __init__(self, config: dict[str, Any]):
self._server_url = config.get("server_url", "http://localhost:1234").rstrip("/")
self._password = config.get("password", "")
self._http_timeout_s = config.get("http_timeout_s", config.get("httpTimeoutS", 30.0))
self._session: aiohttp.ClientSession | None = None
self._max_retries = config.get("max_retries", 3)
self._breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=30.0)
self._probe_timeout_ms = config.get("probeTimeoutMs", config.get("probe_timeout_ms", 10000))
self._remote_attachment_roots: list[str] = config.get(
"remoteAttachmentRoots", config.get("remote_attachment_roots", [])
)
self._auth_backoff_s = 1.0
self._last_auth_failure: float | None = None
async def __aenter__(self) -> BlueBubblesClient:
self._session = aiohttp.ClientSession(
headers={"X-BlueBubbles-Password": self._password},
timeout=aiohttp.ClientTimeout(total=self._http_timeout_s),
)
return self
async def __aexit__(self, *args) -> None:
await self.close()
async def close(self) -> None:
if self._session and not self._session.closed:
await self._session.close()
self._session = None
async def _ensure_session(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession(
headers={"X-BlueBubbles-Password": self._password},
timeout=aiohttp.ClientTimeout(total=self._http_timeout_s),
)
return self._session
async def probe_server(self) -> dict[str, Any]:
session = await self._ensure_session()
try:
timeout = aiohttp.ClientTimeout(total=self._probe_timeout_ms / 1000.0)
async with session.get(f"{self._server_url}/api/v1/server/info", timeout=timeout) as resp:
return await resp.json()
except Exception as e:
logger.error(f"[iMessage] probe_server failed: {e}")
raise
async def get_handle(self) -> dict[str, Any]:
session = await self._ensure_session()
try:
async with session.get(f"{self._server_url}/api/v1/handle") as resp:
return await resp.json()
except Exception as e:
logger.error(f"[iMessage] get_handle failed: {e}")
raise
async def send_text_message(
self,
chat_guid: str,
content: str,
reply_to: str | None = None,
is_html: bool = False,
mentions: list[str] | None = None,
disable_notification: bool = False,
) -> DeliveryResult:
payload: dict[str, Any] = {"chatGuid": chat_guid, "message": content}
if is_html:
payload["isHTML"] = True
if reply_to:
payload["replyTo"] = reply_to
if mentions:
payload["mentions"] = [{"type": "mention", "mention": m} for m in mentions]
if disable_notification:
payload["disableNotification"] = True
return await self._send_with_retry(
method="POST",
path="/api/v1/message/text",
json_payload=payload,
)
async def send_typing_indicator(self, chat_guid: str, display: bool = True) -> DeliveryResult:
return await self._send_with_retry(
method="POST",
path=f"/api/v1/chat/{chat_guid}/typing",
json_payload={"display": display},
)
async def send_read_receipt(self, chat_guid: str) -> DeliveryResult:
return await self._send_with_retry(
method="POST",
path=f"/api/v1/chat/{chat_guid}/readreceipt",
json_payload={},
)
async def download_attachment(self, attachment_path: str) -> bytes:
session = await self._ensure_session()
if attachment_path.startswith(("http://", "https://")):
parsed = urlparse(attachment_path)
if parsed.netloc not in urlparse(self._server_url).netloc:
raise BlueBubblesHTTPError(403, "Attachment URL origin mismatch")
url = attachment_path
else:
sanitized = attachment_path.lstrip("/")
url = f"{self._server_url}/{sanitized}"
try:
async with session.get(url) as resp:
if resp.status != 200:
raise BlueBubblesHTTPError(resp.status, f"Failed to download attachment: {resp.status}")
return await resp.read()
except BlueBubblesHTTPError:
raise
except Exception as e:
logger.error(f"[iMessage] download_attachment failed: {e}")
raise
async def send_attachment(
self,
chat_guid: str,
file_path: str,
caption: str = "",
reply_to: str | None = None,
) -> DeliveryResult:
import mimetypes
mime_type, _ = mimetypes.guess_type(file_path)
try:
loop = asyncio.get_running_loop()
def _read_file():
with open(file_path, "rb") as f:
return f.read()
file_data = await loop.run_in_executor(None, _read_file)
form = aiohttp.FormData()
form.add_field(
"file",
file_data,
filename=os.path.basename(file_path),
content_type=mime_type or "application/octet-stream",
)
form.add_field("chatGuid", chat_guid)
if caption:
form.add_field("caption", caption)
if reply_to:
form.add_field("replyTo", reply_to)
return await self._send_with_retry(
method="POST",
path="/api/v1/message/attachment",
data_payload=form,
)
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def send_reaction(
self,
chat_guid: str,
message_guid: str,
tapback: int,
) -> DeliveryResult:
payload = {"messageGuid": message_guid, "tapback": tapback}
return await self._send_with_retry(
method="POST",
path=f"/api/v1/message/{chat_guid}/tapback",
json_payload=payload,
)
async def edit_message(self, message_guid: str, new_content: str) -> DeliveryResult:
payload = {"message": new_content}
return await self._send_with_retry(
method="PUT",
path=f"/api/v1/message/{message_guid}",
json_payload=payload,
)
async def delete_message(self, chat_guid: str, message_guid: str) -> DeliveryResult:
payload = {"chatGuid": chat_guid, "messageGuid": message_guid}
return await self._send_with_retry(
method="POST",
path="/api/v1/message/delete",
json_payload=payload,
)
async def get_chats(self) -> list[dict[str, Any]]:
try:
session = await self._ensure_session()
async with session.get(f"{self._server_url}/api/v1/chat") as resp:
data = await resp.json()
return data.get("data", [])
except Exception:
logger.warning("[iMessage] Failed to fetch chats")
return []
async def get_chat_messages(self, chat_guid: str, limit: int = 50) -> list[dict[str, Any]]:
try:
session = await self._ensure_session()
params = {"limit": limit}
async with session.get(f"{self._server_url}/api/v1/chat/{chat_guid}/message", params=params) as resp:
data = await resp.json()
return data.get("data", [])
except Exception:
logger.warning(f"[iMessage] Failed to fetch messages for chat {chat_guid}")
return []
async def query_contacts(self, addresses: list[str]) -> list[dict[str, Any]]:
session = await self._ensure_session()
url = f"{self._server_url}/api/v1/contact/query"
async with session.post(url, json={"addresses": addresses}) as resp:
data = await resp.json()
return data.get("data", [])
async def unsend_message(self, message_guid: str) -> DeliveryResult:
return await self._send_with_retry(
method="POST",
path=f"/api/v1/message/{message_guid}/unsend",
json_payload={},
)
async def _send_with_retry(
self,
method: str,
path: str,
json_payload: dict[str, Any] | None = None,
data_payload: aiohttp.FormData | None = None,
) -> DeliveryResult:
async def _attempt() -> DeliveryResult:
if self._last_auth_failure is not None:
elapsed = time.time() - self._last_auth_failure
wait_s = min(self._auth_backoff_s, 120.0)
if elapsed < wait_s:
remaining = wait_s - elapsed
logger.debug(f"[iMessage] Auth backoff: waiting {remaining:.1f}s")
await asyncio.sleep(remaining)
else:
self._last_auth_failure = None
self._auth_backoff_s = 1.0
session = await self._ensure_session()
url = f"{self._server_url}{path}"
kwargs = {}
if json_payload is not None:
kwargs["json"] = json_payload
if data_payload is not None:
kwargs["data"] = data_payload
try:
async with session.request(method, url, **kwargs) as resp:
data = await resp.json()
if resp.status == 200:
return DeliveryResult(
success=True,
message_id=data.get("data", {}).get("guid") or data.get("guid"),
)
if resp.status == 401:
self._last_auth_failure = time.time()
self._auth_backoff_s = min(self._auth_backoff_s * 2, 120.0)
raise _RetryableError(f"Authentication failed (401): {data.get('message', '')}")
if resp.status == 429:
raise _RetryableError(f"Rate limited: {data.get('message', '')}")
if resp.status >= 500:
raise _RetryableError(f"Server error {resp.status}: {data.get('message', '')}")
return DeliveryResult(success=False, error=data.get("message", "Unknown error"))
except _RetryableError:
raise
except Exception as e:
raise _RetryableError(str(e))
async def _breaker_attempt() -> DeliveryResult:
return await self._breaker.call(lambda: _attempt())
base_delay = 1.0
for attempt in range(self._max_retries):
try:
return await _breaker_attempt()
except _RetryableError as e:
if attempt < self._max_retries - 1:
delay = base_delay * (2**attempt)
logger.warning(
f"[iMessage] {method} {path} failed (attempt {attempt + 1}/{self._max_retries}), "
f"retrying in {delay:.1f}s: {e}"
)
await asyncio.sleep(delay)
continue
logger.error(f"[iMessage] {method} {path} failed after {self._max_retries} attempts: {e}")
return DeliveryResult(success=False, error=str(e))
return DeliveryResult(success=False, error=f"Failed after {self._max_retries} retries")
@property
def ws_url(self) -> str:
url = self._server_url.replace("http://", "ws://").replace("https://", "wss://")
return f"{url}/ws"
@property
def password(self) -> str:
return self._password
class _RetryableError(Exception):
pass