这是一个批量整理提交,包含以下主要改动: 1. 删除多处冗余的空行和未使用的导入 2. 修复文件末尾缺少换行符的问题 3. 调整部分模块的导入顺序与代码排版 4. 修复部分配置默认值与策略逻辑 5. 新增多个功能模块与辅助工具 6. 完善异常处理与日志记录 7. 修复速率限制、消息缓存、权限校验等逻辑bug 8. 废弃部分旧有API与配置项并添加警告提示
331 lines
13 KiB
Python
331 lines
13 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
import time
|
|
from typing import Any
|
|
from urllib.parse import quote, urlparse
|
|
|
|
import aiohttp
|
|
|
|
from yuxi.channels.adapters.imessage.exceptions import BlueBubblesHTTPError
|
|
from yuxi.channels.adapters.imessage.media_security import validate_attachment_path
|
|
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("/")
|
|
if self._remote_attachment_roots and not validate_attachment_path(sanitized, self._remote_attachment_roots):
|
|
raise BlueBubblesHTTPError(403, "Attachment path not in allowed roots")
|
|
url = f"{self._server_url}/{quote(sanitized, safe='/')}"
|
|
|
|
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
|