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