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

235 lines
8.0 KiB
Python

from __future__ import annotations
import hashlib
import json
import time
from pathlib import Path
from typing import Any
from yuxi.channels.adapters.bluebubbles.client import BlueBubblesClient
from yuxi.utils.logging_config import logger
class CatchupSummary:
def __init__(self) -> None:
self.replayed: int = 0
self.skipped_from_me: int = 0
self.skipped_pre_cursor: int = 0
self.skipped_given_up: int = 0
self.failed: int = 0
self.given_up: int = 0
self.cursor_before: str = ""
self.cursor_after: str = ""
self.window_start_ms: float = 0.0
self.window_end_ms: float = 0.0
self.fetched_count: int = 0
def to_dict(self) -> dict[str, Any]:
return {
"replayed": self.replayed,
"skippedFromMe": self.skipped_from_me,
"skippedPreCursor": self.skipped_pre_cursor,
"skippedGivenUp": self.skipped_given_up,
"failed": self.failed,
"givenUp": self.given_up,
"cursorBefore": self.cursor_before,
"cursorAfter": self.cursor_after,
"windowStartMs": self.window_start_ms,
"windowEndMs": self.window_end_ms,
"fetchedCount": self.fetched_count,
}
class CatchupCursorStore:
def __init__(self, state_dir: str | Path, account_id: str = "default") -> None:
self._state_dir = Path(state_dir) / "bluebubbles" / "catchup"
self._state_dir.mkdir(parents=True, exist_ok=True)
safe_prefix = _safe_account_prefix(account_id)
account_hash = hashlib.sha256(account_id.encode()).hexdigest()[:12]
self._cursor_path = self._state_dir / f"{safe_prefix}__{account_hash}.json"
self._data: dict[str, Any] = self._load()
def _load(self) -> dict[str, Any]:
if self._cursor_path.exists():
try:
return json.loads(self._cursor_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
logger.warning("[BlueBubbles] Corrupted catchup cursor, resetting")
return {"cursor": "", "failedGuids": {}, "givenUpGuids": set()}
def _save(self) -> None:
data = dict(self._data)
data["givenUpGuids"] = list(data.get("givenUpGuids", set()))
self._cursor_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
@property
def cursor(self) -> str:
return self._data.get("cursor", "")
@cursor.setter
def cursor(self, value: str) -> None:
self._data["cursor"] = value
self._save()
def record_failure(self, guid: str) -> int:
failed = self._data.setdefault("failedGuids", {})
failed[guid] = failed.get(guid, 0) + 1
self._save()
return failed[guid]
def is_given_up(self, guid: str) -> bool:
given_up = self._data.get("givenUpGuids", set())
return guid in given_up
def mark_given_up(self, guid: str) -> None:
given_up = set(self._data.get("givenUpGuids", set()))
given_up.add(guid)
self._data["givenUpGuids"] = given_up
self._save()
def reset_failures(self, guid: str) -> None:
failed = self._data.get("failedGuids", {})
if guid in failed:
del failed[guid]
self._save()
def _safe_account_prefix(account_id: str) -> str:
return "".join(c for c in account_id if c.isalnum() or c in ("-", "_")) or "default"
async def catchup_messages(
client: BlueBubblesClient,
chat_guid: str,
limit: int = 50,
before_guid: str | None = None,
) -> list[dict[str, Any]]:
try:
endpoint = f"/api/v1/chat/{chat_guid}/message"
params: dict[str, Any] = {"limit": min(limit, 100)}
if before_guid:
params["before"] = before_guid
result = await client.get(endpoint, params=params)
return result.get("data", [])
except Exception:
logger.warning(f"[BlueBubbles] Failed to catch up messages for {chat_guid}")
return []
async def catchup_and_replay(
client: BlueBubblesClient,
chat_guid: str,
since_ts: float | None = None,
limit: int = 50,
) -> list[dict[str, Any]]:
messages = await catchup_messages(client, chat_guid, limit=limit)
if since_ts is not None:
messages = [m for m in messages if (m.get("dateCreated", 0) / 1000) >= since_ts]
return messages
async def run_full_catchup(
client: BlueBubblesClient,
chat_guids: list[str],
cursor_store: CatchupCursorStore,
*,
max_age_minutes: int = 120,
per_run_limit: int = 50,
first_run_lookback_minutes: int = 30,
max_failure_retries: int = 10,
on_message: Any = None,
) -> CatchupSummary:
summary = CatchupSummary()
now_ms = int(time.time() * 1000)
if max_age_minutes > 0:
max_age_ms = max_age_minutes * 60 * 1000
cursor = cursor_store.cursor
if not cursor and first_run_lookback_minutes > 0:
summary.window_start_ms = now_ms - first_run_lookback_minutes * 60 * 1000
elif cursor:
try:
cursor_ms = int(cursor)
summary.window_start_ms = max(cursor_ms, now_ms - max_age_ms)
except ValueError:
summary.window_start_ms = now_ms - first_run_lookback_minutes * 60 * 1000
else:
summary.window_start_ms = now_ms - max_age_ms
else:
summary.window_start_ms = 0
summary.window_end_ms = now_ms
summary.cursor_before = cursor_store.cursor
replay_count = 0
highest_seen_ts: float = 0
for chat_guid in chat_guids:
if replay_count >= per_run_limit:
break
try:
messages = await catchup_messages(client, chat_guid, limit=per_run_limit)
summary.fetched_count += len(messages)
for msg in messages:
guid = msg.get("guid", "")
date_created = msg.get("dateCreated", 0)
if cursor_store.is_given_up(guid):
summary.skipped_given_up += 1
if date_created > highest_seen_ts:
highest_seen_ts = date_created
continue
msg_ts = date_created / 1000 if date_created else 0
if msg_ts < summary.window_start_ms / 1000:
summary.skipped_pre_cursor += 1
if date_created > highest_seen_ts:
highest_seen_ts = date_created
continue
is_from_me = msg.get("isFromMe", False)
if is_from_me:
summary.skipped_from_me += 1
if date_created > highest_seen_ts:
highest_seen_ts = date_created
continue
if replay_count >= per_run_limit:
break
try:
if on_message:
await on_message(chat_guid, msg)
summary.replayed += 1
replay_count += 1
if date_created > highest_seen_ts:
highest_seen_ts = date_created
cursor_store.reset_failures(guid)
except Exception:
summary.failed += 1
failures = cursor_store.record_failure(guid)
if failures >= max_failure_retries:
cursor_store.mark_given_up(guid)
summary.given_up += 1
if date_created > highest_seen_ts:
highest_seen_ts = date_created
logger.warning(
"[BlueBubbles] Catchup failed for guid=%s (attempt %d/%d)",
guid,
failures,
max_failure_retries,
)
except Exception:
logger.exception("[BlueBubbles] Catchup chat query failed for %s", chat_guid)
if highest_seen_ts > 0:
new_cursor = str(int(highest_seen_ts) + 1)
cursor_store.cursor = new_cursor
summary.cursor_after = new_cursor
return summary