ForcePilot/backend/package/yuxi/channel/extensions/bluebubbles/participant_contacts.py
Kris b018e3eda4 feat(bluebubbles): 新增BlueBubbles(iMessage)渠道插件完整实现
该提交新增了完整的BlueBubbles渠道插件,支持通过BlueBubbles Server集成iMessage功能,包含以下核心能力:
1.  支持私聊和群聊会话管理,自动区分会话类型
2.  完整的消息收发支持,包括文本、图片、语音、文件、视频消息
3.  支持消息反应、已读回执、消息编辑与撤回
4.  内置去重、防抖处理机制
5.  支持Webhook和WebSocket两种事件接收方式
6.  完善的权限与安全校验机制
7.  历史消息同步与抓包功能
8.  TTS语音合成与发送支持
9.  群组管理能力,包括重命名、修改头像、增减成员等
2026-05-21 10:40:33 +08:00

149 lines
4.5 KiB
Python

import logging
import platform
import re
import sqlite3
import time
from pathlib import Path
logger = logging.getLogger(__name__)
_contact_cache: dict[str, tuple[str, float]] = {}
_MAX_CACHE_ENTRIES = 2048
_POSITIVE_TTL = 3600
_NEGATIVE_TTL = 300
def normalize_phone_lookup_key(value: str) -> str | None:
digits = re.sub(r"\D", "", value)
if len(digits) == 11 and digits.startswith("1"):
digits = digits[1:]
return digits if len(digits) >= 7 else None
def is_darwin() -> bool:
return platform.system().lower() == "darwin"
async def query_address_book(participants: list[dict]) -> list[dict]:
if not is_darwin() or not participants:
return participants
sources_dir = Path.home() / "Library/Application Support/AddressBook/Sources"
if not sources_dir.exists():
return participants
db_files = list(sources_dir.glob("*/AddressBook-v22.abcddb"))
if not db_files:
return participants
numbers_to_lookup = []
for p in participants:
if not p.get("name") and p.get("phone"):
key = normalize_phone_lookup_key(p["phone"])
if key:
numbers_to_lookup.append(key)
if not numbers_to_lookup:
return participants
results: dict[str, str] = {}
for db_file in db_files:
try:
conn = sqlite3.connect(str(db_file))
cursor = conn.cursor()
placeholders = ",".join(["?" for _ in numbers_to_lookup])
cursor.execute(
f"""
SELECT ZABCDPHONENUMBER.ZFULLNUMBER,
ZABCDRECORD.ZFIRSTNAME,
ZABCDRECORD.ZLASTNAME,
ZABCDRECORD.ZORGANIZATION
FROM ZABCDPHONENUMBER
JOIN ZABCDRECORD ON ZABCDPHONENUMBER.ZOWNER = ZABCDRECORD.Z_PK
WHERE ZABCDPHONENUMBER.ZFULLNUMBER IN ({placeholders})
""",
numbers_to_lookup,
)
for row in cursor.fetchall():
full_number, first, last, org = row
digits = normalize_phone_lookup_key(full_number)
if digits and digits not in results:
name = org or f"{first or ''} {last or ''}".strip()
if name:
results[digits] = name
conn.close()
except Exception:
continue
for p in participants:
if not p.get("name") and p.get("phone"):
key = normalize_phone_lookup_key(p["phone"])
if key and key in results:
p["name"] = results[key]
return participants
def lookup_contact_name(phone: str) -> str | None:
now = time.time()
lookup_key = normalize_phone_lookup_key(phone)
if not lookup_key:
return None
cached = _contact_cache.get(lookup_key)
if cached:
name, ts = cached
if now - ts < _POSITIVE_TTL and name:
return name
if now - ts < _NEGATIVE_TTL and not name:
return None
if not is_darwin():
_contact_cache[lookup_key] = ("", now)
return None
sources_dir = Path.home() / "Library/Application Support/AddressBook/Sources"
if not sources_dir.exists():
_contact_cache[lookup_key] = ("", now)
return None
for db_file in sources_dir.glob("*/AddressBook-v22.abcddb"):
try:
conn = sqlite3.connect(str(db_file))
cursor = conn.cursor()
cursor.execute(
"""
SELECT ZABCDRECORD.ZFIRSTNAME, ZABCDRECORD.ZLASTNAME,
ZABCDRECORD.ZORGANIZATION
FROM ZABCDPHONENUMBER
JOIN ZABCDRECORD ON ZABCDPHONENUMBER.ZOWNER = ZABCDRECORD.Z_PK
WHERE ZABCDPHONENUMBER.ZFULLNUMBER = ?
""",
(phone,),
)
row = cursor.fetchone()
conn.close()
if row:
first, last, org = row
name = org or f"{first or ''} {last or ''}".strip()
if name:
_cache_and_trim(lookup_key, name)
return name
except Exception:
continue
_cache_and_trim(lookup_key, "")
return None
def _cache_and_trim(key: str, value: str):
now = time.time()
if len(_contact_cache) >= _MAX_CACHE_ENTRIES:
oldest = min(_contact_cache.items(), key=lambda x: x[1][1])
del _contact_cache[oldest[0]]
_contact_cache[key] = (value, now)
def clear_contact_cache():
_contact_cache.clear()