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