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

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