ForcePilot/backend/package/yuxi/channels/adapters/bluebubbles/send.py
Kris 6aa1d52a8b feat(bluebubbles): 实现完整的BlueBubbles适配器基础组件
新增BlueBubbles适配器全套核心工具类与服务,包含会话管理、消息去重、Webhook验证、缓存系统、账号配置解析、聊天消息处理等完整功能模块,支持iMessage消息收发、群管理、反应特效、语音合成等能力,提供完善的健康检查与配置校验流程。
2026-05-12 00:42:57 +08:00

476 lines
13 KiB
Python

from __future__ import annotations
import asyncio
import mimetypes
from pathlib import Path
from typing import Any
from yuxi.channels.adapters.bluebubbles.client import BlueBubblesClient
from yuxi.channels.adapters.bluebubbles.exceptions import BlueBubblesHTTPError
from yuxi.channels.models import DeliveryResult
from yuxi.utils.logging_config import logger
def _resolve_safe_path(file_path: str, allowed_roots: list[str] | None = None) -> Path:
path = Path(file_path).resolve()
if not path.is_file():
raise FileNotFoundError(f"File not found: {file_path}")
if path.is_symlink():
raise PermissionError(f"Symlinks not allowed: {file_path}")
if allowed_roots:
root_paths = [Path(r).resolve() for r in allowed_roots]
if not any(path.is_relative_to(root) for root in root_paths):
raise PermissionError(f"File outside allowed roots: {file_path}")
return path
def _detect_mime(filename: str) -> str:
mime_type, _ = mimetypes.guess_type(filename)
return mime_type or "application/octet-stream"
def _sanitize_filename(filename: str) -> str:
name = filename.replace("\r", "").replace("\n", "").strip()
return Path(name).name or "attachment"
async def send_text(
client: BlueBubblesClient,
chat_guid: str,
text: str,
reply_to_guid: str | None = None,
subject: str | None = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"chatGuid": chat_guid,
"text": text,
"isHTML": False,
}
if reply_to_guid:
payload["replyToGuid"] = reply_to_guid
if subject:
payload["subject"] = subject
return await client.post("/api/v1/message/text", json=payload)
async def send_html_message(
client: BlueBubblesClient,
chat_guid: str,
html: str,
reply_to_guid: str | None = None,
max_retries: int = 3,
) -> DeliveryResult:
payload: dict[str, Any] = {
"chatGuid": chat_guid,
"text": html,
"isHTML": True,
}
if reply_to_guid:
payload["replyToGuid"] = reply_to_guid
try:
result = await client.post("/api/v1/message/text", json=payload)
return DeliveryResult(
success=True,
message_id=result.get("guid") or result.get("messageGuid"),
)
except BlueBubblesHTTPError as e:
if e.status_code == 429 or e.status_code >= 500:
return await send_with_retry(
client,
"/api/v1/message/text",
payload,
max_retries,
)
return DeliveryResult(success=False, error=str(e))
except Exception as e:
logger.error(f"[BlueBubbles] HTML send failed: {e}")
return DeliveryResult(success=False, error=str(e))
async def send_attachment(
client: BlueBubblesClient,
chat_guid: str,
file_path: str,
caption: str = "",
reply_to_guid: str | None = None,
allowed_roots: list[str] | None = None,
) -> dict[str, Any]:
path = _resolve_safe_path(file_path, allowed_roots)
content = await asyncio.to_thread(path.read_bytes)
safe_name = _sanitize_filename(path.name)
files = {"attachment": (safe_name, content)}
data: dict[str, Any] = {"chatGuid": chat_guid}
if caption:
data["caption"] = caption
if reply_to_guid:
data["replyToGuid"] = reply_to_guid
return await client.post("/api/v1/message/attachment", data=data, files=files)
async def send_tapback(
client: BlueBubblesClient,
chat_guid: str,
message_guid: str,
tapback: int,
) -> dict[str, Any]:
return await client.post(
f"/api/v1/message/{chat_guid}/tapback",
json={"messageGuid": message_guid, "tapback": tapback},
)
async def send_typing_indicator(
client: BlueBubblesClient,
chat_guid: str,
display: bool = True,
) -> dict[str, Any]:
return await client.post(
f"/api/v1/chat/{chat_guid}/typing",
json={"display": display},
)
async def send_read_receipt(
client: BlueBubblesClient,
chat_guid: str,
) -> dict[str, Any]:
return await client.post(
f"/api/v1/chat/{chat_guid}/readreceipt",
json={},
)
async def update_activity(
client: BlueBubblesClient,
chat_guid: str,
activity_id: str,
text: str,
) -> dict[str, Any]:
return await client.put(
f"/api/v1/message/{chat_guid}/{activity_id}",
json={"text": text, "isHTML": True},
)
async def download_attachment(
client: BlueBubblesClient,
file_path_or_url: str,
) -> bytes:
return await client.download(file_path_or_url)
TAPBACK_MAP: dict[str, int] = {
"love": 0,
"heart": 0,
"❤️": 0,
"": 0,
"🧡": 0,
"💛": 0,
"💚": 0,
"💙": 0,
"💜": 0,
"🖤": 0,
"🤍": 0,
"🤎": 0,
"💕": 0,
"💗": 0,
"💖": 0,
"💝": 0,
"💞": 0,
"💓": 0,
"heart_eyes": 0,
"like": 1,
"thumbs_up": 1,
"thumbsup": 1,
"👍": 1,
"👍🏻": 1,
"👍🏼": 1,
"👍🏽": 1,
"👍🏾": 1,
"👍🏿": 1,
"+1": 1,
"yes": 1,
"dislike": 2,
"thumbs_down": 2,
"thumbsdown": 2,
"👎": 2,
"👎🏻": 2,
"👎🏼": 2,
"👎🏽": 2,
"👎🏾": 2,
"👎🏿": 2,
"-1": 2,
"no": 2,
"laugh": 3,
"haha": 3,
"😂": 3,
"😆": 3,
"🤣": 3,
"😹": 3,
"lol": 3,
"rofl": 3,
"exclaim": 4,
"": 4,
"‼️": 4,
"": 4,
"": 4,
"!": 4,
"!!": 4,
"emphasize": 4,
"question": 5,
"": 5,
"⁉️": 5,
"": 5,
"": 5,
"?": 5,
"??": 5,
"hmm": 5,
}
_LENIENT_FALLBACK = 0
def resolve_tapback_value(emoji_or_name: str) -> int | None:
return TAPBACK_MAP.get(emoji_or_name.lower().strip().lstrip(":"))
def normalize_tapback_lenient(emoji_or_name: str) -> int:
value = resolve_tapback_value(emoji_or_name)
if value is not None:
return value
return _LENIENT_FALLBACK
def normalize_tapback_strict(emoji_or_name: str) -> int:
value = resolve_tapback_value(emoji_or_name)
if value is None:
from yuxi.channels.adapters.bluebubbles.exceptions import TapbackError
raise TapbackError(f"Unsupported tapback reaction: {emoji_or_name!r}")
return value
async def unsend_message(
client: BlueBubblesClient,
chat_guid: str,
message_guid: str,
) -> dict[str, Any]:
return await client.post(
f"/api/v1/message/{chat_guid}/{message_guid}/unsend",
json={},
)
async def remove_tapback(
client: BlueBubblesClient,
chat_guid: str,
message_guid: str,
) -> dict[str, Any]:
return await client.delete(
f"/api/v1/message/{chat_guid}/tapback",
json={"messageGuid": message_guid},
)
async def send_attachment_bytes(
client: BlueBubblesClient,
chat_guid: str,
data: bytes,
filename: str,
caption: str = "",
reply_to_guid: str | None = None,
) -> dict[str, Any]:
safe_name = _sanitize_filename(filename)
files = {"attachment": (safe_name, data)}
form_data: dict[str, Any] = {"chatGuid": chat_guid}
if caption:
form_data["caption"] = caption
if reply_to_guid:
form_data["replyToGuid"] = reply_to_guid
return await client.post("/api/v1/message/attachment", data=form_data, files=files)
async def fetch_remote_media(client: BlueBubblesClient, url: str) -> bytes:
import httpx
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0)) as http_client:
resp = await http_client.get(url)
resp.raise_for_status()
return resp.content
async def send_silent_message(
client: BlueBubblesClient,
chat_guid: str,
text: str,
reply_to_guid: str | None = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"chatGuid": chat_guid,
"text": text,
"isHTML": False,
"disableNotification": True,
}
if reply_to_guid:
payload["replyToGuid"] = reply_to_guid
return await client.post("/api/v1/message/text", json=payload)
async def send_with_retry(
client: BlueBubblesClient,
endpoint: str,
payload: dict[str, Any],
max_retries: int = 3,
) -> DeliveryResult:
base_delay = 1.0
for attempt in range(max_retries):
try:
result = await client.post(endpoint, json=payload)
return DeliveryResult(
success=True,
message_id=result.get("guid") or result.get("messageGuid"),
)
except BlueBubblesHTTPError as e:
if e.status_code == 429:
retry_after = float(e.headers.get("Retry-After", base_delay))
await asyncio.sleep(retry_after)
continue
if e.status_code >= 500:
delay = base_delay * (2**attempt)
await asyncio.sleep(delay)
continue
return DeliveryResult(success=False, error=str(e))
except Exception as e:
if attempt < max_retries - 1:
delay = base_delay * (2**attempt)
await asyncio.sleep(delay)
continue
return DeliveryResult(success=False, error=str(e))
return DeliveryResult(success=False, error=f"Failed after {max_retries} retries")
async def send_sticker(
client: BlueBubblesClient,
chat_guid: str,
sticker_data: dict[str, Any] | bytes,
reply_to_guid: str | None = None,
) -> DeliveryResult:
if isinstance(sticker_data, bytes):
files = {"attachment": ("sticker.png", sticker_data, "image/png")}
data: dict[str, Any] = {"chatGuid": chat_guid, "isSticker": True}
if reply_to_guid:
data["replyToGuid"] = reply_to_guid
try:
result = await client.post("/api/v1/message/attachment", data=data, files=files)
return DeliveryResult(
success=True,
message_id=result.get("guid") or result.get("messageGuid"),
)
except Exception as e:
return DeliveryResult(success=False, error=str(e))
sticker_url = sticker_data.get("url") or sticker_data.get("filePath") or ""
sticker_desc = sticker_data.get("stickerDescription") or sticker_data.get("description") or ""
sticker_msg_id = sticker_data.get("messageGuid") or sticker_data.get("guid") or ""
if sticker_msg_id:
try:
result = await client.post(
f"/api/v1/message/{chat_guid}/{sticker_msg_id}/send",
json={"isSticker": True},
)
return DeliveryResult(
success=True,
message_id=result.get("guid") or sticker_msg_id,
)
except Exception:
pass
if sticker_url:
try:
import httpx
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as http_client:
resp = await http_client.get(sticker_url)
resp.raise_for_status()
image_data = resp.content
except Exception as e:
return DeliveryResult(success=False, error=f"Failed to fetch sticker: {e}")
files = {"attachment": ("sticker.png", image_data, "image/png")}
data = {"chatGuid": chat_guid, "isSticker": True}
if reply_to_guid:
data["replyToGuid"] = reply_to_guid
if sticker_desc:
data["caption"] = sticker_desc
try:
result = await client.post("/api/v1/message/attachment", data=data, files=files)
return DeliveryResult(
success=True,
message_id=result.get("guid") or result.get("messageGuid"),
)
except Exception as e:
return DeliveryResult(success=False, error=str(e))
return DeliveryResult(success=False, error="No valid sticker data provided")
async def send_text_safe(
client: BlueBubblesClient,
chat_guid: str,
text: str,
reply_to_guid: str | None = None,
max_retries: int = 3,
) -> DeliveryResult:
try:
result = await send_text(client, chat_guid, text, reply_to_guid)
return DeliveryResult(
success=True,
message_id=result.get("guid") or result.get("messageGuid"),
)
except BlueBubblesHTTPError as e:
if e.status_code == 429 or e.status_code >= 500:
payload: dict[str, Any] = {"chatGuid": chat_guid, "text": text}
if reply_to_guid:
payload["replyToGuid"] = reply_to_guid
return await send_with_retry(
client,
"/api/v1/message/text",
payload,
max_retries,
)
return DeliveryResult(success=False, error=str(e))
except Exception as e:
logger.error(f"[BlueBubbles] Send failed: {e}")
return DeliveryResult(success=False, error=str(e))
async def auto_create_chat(
client: BlueBubblesClient,
address: str,
initial_text: str,
) -> dict[str, Any]:
try:
new_chat = await client.post(
"/api/v1/chat/new",
json={"address": address},
)
chat_guid = new_chat.get("guid") or new_chat.get("chatGuid", "")
if chat_guid:
result = await send_text(client, chat_guid, initial_text)
return {"chatGuid": chat_guid, "result": result}
raise BlueBubblesHTTPError(0, "Auto-created chat has no GUID")
except BlueBubblesHTTPError:
raise
except Exception:
logger.warning(
"[BlueBubbles] Auto-create chat failed for %s, Private API may be unavailable",
address,
)
raise