新增BlueBubbles适配器全套核心工具类与服务,包含会话管理、消息去重、Webhook验证、缓存系统、账号配置解析、聊天消息处理等完整功能模块,支持iMessage消息收发、群管理、反应特效、语音合成等能力,提供完善的健康检查与配置校验流程。
476 lines
13 KiB
Python
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
|