新增BlueBubbles适配器全套核心工具类与服务,包含会话管理、消息去重、Webhook验证、缓存系统、账号配置解析、聊天消息处理等完整功能模块,支持iMessage消息收发、群管理、反应特效、语音合成等能力,提供完善的健康检查与配置校验流程。
189 lines
6.1 KiB
Python
189 lines
6.1 KiB
Python
from __future__ import annotations
|
|
|
|
import re
|
|
import time
|
|
from typing import Any
|
|
|
|
from yuxi.channels.adapters.bluebubbles.client import BlueBubblesClient
|
|
from yuxi.utils.logging_config import logger
|
|
|
|
_PHONE_NORMALIZE_PATTERN = re.compile(r"[^\d+]")
|
|
|
|
|
|
def normalize_phone_lookup_key(phone: str) -> str:
|
|
cleaned = _PHONE_NORMALIZE_PATTERN.sub("", phone.strip())
|
|
if cleaned.startswith("+"):
|
|
return cleaned
|
|
if len(cleaned) == 10:
|
|
return f"+1{cleaned}"
|
|
if len(cleaned) == 11 and cleaned.startswith("1"):
|
|
return f"+{cleaned}"
|
|
return cleaned
|
|
|
|
|
|
class ContactCache:
|
|
POSITIVE_TTL = 3600
|
|
NEGATIVE_TTL = 300
|
|
MAX_ENTRIES = 2048
|
|
|
|
def __init__(self) -> None:
|
|
self._cache: dict[str, tuple[dict[str, Any], float]] = {}
|
|
self._negative: dict[str, float] = {}
|
|
|
|
def get(self, key: str) -> dict[str, Any] | None:
|
|
entry = self._cache.get(key)
|
|
if entry is not None:
|
|
value, ts = entry
|
|
if time.monotonic() - ts < self.POSITIVE_TTL:
|
|
return value
|
|
del self._cache[key]
|
|
return None
|
|
|
|
neg_ts = self._negative.get(key)
|
|
if neg_ts is not None:
|
|
if time.monotonic() - neg_ts < self.NEGATIVE_TTL:
|
|
return None
|
|
del self._negative[key]
|
|
|
|
return None
|
|
|
|
def set(self, key: str, value: dict[str, Any]) -> None:
|
|
self._cache[key] = (value, time.monotonic())
|
|
self._trim()
|
|
|
|
def set_negative(self, key: str) -> None:
|
|
self._negative[key] = time.monotonic()
|
|
self._trim()
|
|
|
|
def _trim(self) -> None:
|
|
total = len(self._cache) + len(self._negative)
|
|
if total > self.MAX_ENTRIES:
|
|
now = time.monotonic()
|
|
expired_pos = [k for k, v in self._cache.items() if now - v[1] > self.POSITIVE_TTL]
|
|
for k in expired_pos:
|
|
del self._cache[k]
|
|
expired_neg = [k for k, v in self._negative.items() if now - v > self.NEGATIVE_TTL]
|
|
for k in expired_neg:
|
|
del self._negative[k]
|
|
|
|
|
|
_contact_cache = ContactCache()
|
|
|
|
|
|
async def _query_macos_contacts(address: str) -> dict[str, Any] | None:
|
|
try:
|
|
import sqlite3
|
|
from pathlib import Path
|
|
|
|
db_path = Path.home() / "Library" / "Application Support" / "AddressBook" / "Sources"
|
|
if not db_path.exists():
|
|
return None
|
|
|
|
normalized = normalize_phone_lookup_key(address)
|
|
for db_file in db_path.rglob("AddressBook-v22.abcddb"):
|
|
try:
|
|
conn = sqlite3.connect(str(db_file))
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
"SELECT ZFULLNUMBER, ZFIRSTNAME, ZLASTNAME FROM ZABCDRECORD WHERE ZFULLNUMBER LIKE ? LIMIT 1",
|
|
(f"%{normalized}%",),
|
|
)
|
|
row = cursor.fetchone()
|
|
conn.close()
|
|
if row:
|
|
return {
|
|
"address": address,
|
|
"display_name": f"{row[1] or ''} {row[2] or ''}".strip() or address,
|
|
"first_name": row[1] or "",
|
|
"last_name": row[2] or "",
|
|
}
|
|
except Exception:
|
|
continue
|
|
return None
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
async def resolve_contact(client: BlueBubblesClient, address: str) -> dict[str, Any]:
|
|
cached = _contact_cache.get(address)
|
|
if cached is not None:
|
|
return cached
|
|
|
|
try:
|
|
result = await client.post(
|
|
"/api/v1/contact/query",
|
|
json={"addresses": [address]},
|
|
)
|
|
contacts = result.get("data", [])
|
|
if contacts and len(contacts) > 0:
|
|
contact = contacts[0]
|
|
resolved = {
|
|
"address": contact.get("address", address),
|
|
"display_name": contact.get("displayName", ""),
|
|
"first_name": contact.get("firstName", ""),
|
|
"last_name": contact.get("lastName", ""),
|
|
"alias": contact.get("alias", ""),
|
|
}
|
|
_contact_cache.set(address, resolved)
|
|
return resolved
|
|
|
|
macos_contact = await _query_macos_contacts(address)
|
|
if macos_contact:
|
|
_contact_cache.set(address, macos_contact)
|
|
return macos_contact
|
|
|
|
_contact_cache.set_negative(address)
|
|
return {"address": address, "display_name": "", "alias": ""}
|
|
except Exception as e:
|
|
logger.warning(f"[BlueBubbles] Contact resolution failed for {address}: {e}")
|
|
return {"address": address, "display_name": "", "alias": ""}
|
|
|
|
|
|
async def resolve_contacts_batch(client: BlueBubblesClient, addresses: list[str]) -> dict[str, dict[str, Any]]:
|
|
if not addresses:
|
|
return {}
|
|
|
|
uncached: list[str] = []
|
|
resolved: dict[str, dict[str, Any]] = {}
|
|
|
|
for addr in addresses:
|
|
cached = _contact_cache.get(addr)
|
|
if cached is not None:
|
|
resolved[addr] = cached
|
|
else:
|
|
uncached.append(addr)
|
|
|
|
if not uncached:
|
|
return resolved
|
|
|
|
try:
|
|
result = await client.post(
|
|
"/api/v1/contact/query",
|
|
json={"addresses": uncached},
|
|
)
|
|
contacts = result.get("data", [])
|
|
for contact in contacts:
|
|
addr = contact.get("address", "")
|
|
entry = {
|
|
"address": addr,
|
|
"display_name": contact.get("displayName", ""),
|
|
"first_name": contact.get("firstName", ""),
|
|
"last_name": contact.get("lastName", ""),
|
|
"alias": contact.get("alias", ""),
|
|
}
|
|
resolved[addr] = entry
|
|
_contact_cache.set(addr, entry)
|
|
|
|
for addr in uncached:
|
|
if addr not in resolved:
|
|
resolved[addr] = {"address": addr, "display_name": "", "alias": ""}
|
|
_contact_cache.set_negative(addr)
|
|
|
|
return resolved
|
|
except Exception as e:
|
|
logger.warning(f"[BlueBubbles] Batch contact resolution failed: {e}")
|
|
for addr in uncached:
|
|
if addr not in resolved:
|
|
resolved[addr] = {"address": addr, "display_name": "", "alias": ""}
|
|
return resolved
|