新增BlueBubbles适配器全套核心工具类与服务,包含会话管理、消息去重、Webhook验证、缓存系统、账号配置解析、聊天消息处理等完整功能模块,支持iMessage消息收发、群管理、反应特效、语音合成等能力,提供完善的健康检查与配置校验流程。
235 lines
8.0 KiB
Python
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
|