ForcePilot/backend/package/yuxi/channel/extensions/bluebubbles/catchup.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

165 lines
5.0 KiB
Python

import hashlib
import json
import logging
import re
import time
from dataclasses import dataclass, field
from pathlib import Path
logger = logging.getLogger(__name__)
@dataclass
class CatchupCursor:
last_seen_ms: int | None = None
last_seen_rowid: int | None = None
updated_at: float = 0.0
failure_retries: dict[str, int] = field(default_factory=dict)
MAX_FAILURE_MAP = 5000
@dataclass
class CatchupSummary:
query_succeeded: bool = False
replayed: int = 0
skipped_from_me: int = 0
skipped_pre_cursor: int = 0
skipped_given_up: int = 0
failed: int = 0
given_up: int = 0
cursor_before: int | None = None
cursor_after: int = 0
window_start_ms: int = 0
window_end_ms: int = 0
fetched_count: int = 0
def _cursor_path(state_dir: Path, account_id: str) -> Path:
safe = re.sub(r"[^a-zA-Z0-9_-]", "_", account_id)
h = hashlib.sha256(account_id.encode()).hexdigest()[:12]
return state_dir / "bluebubbles" / "catchup" / f"{safe}__{h}.json"
def load_cursor(state_dir: Path, account_id: str) -> CatchupCursor:
path = _cursor_path(state_dir, account_id)
if not path.exists():
return CatchupCursor()
try:
with open(path, encoding="utf-8") as f:
data = json.load(f)
except (json.JSONDecodeError, OSError):
return CatchupCursor()
return CatchupCursor(
last_seen_ms=data.get("lastSeenMs"),
last_seen_rowid=data.get("lastSeenRowid"),
updated_at=data.get("updatedAt", 0),
failure_retries=data.get("failureRetries", {}),
)
def save_cursor(state_dir: Path, account_id: str, cursor: CatchupCursor):
path = _cursor_path(state_dir, account_id)
path.parent.mkdir(parents=True, exist_ok=True)
if len(cursor.failure_retries) > CatchupCursor.MAX_FAILURE_MAP:
sorted_items = sorted(cursor.failure_retries.items(), key=lambda x: x[1], reverse=True)
cursor.failure_retries = dict(sorted_items[: CatchupCursor.MAX_FAILURE_MAP])
with open(path, "w", encoding="utf-8") as f:
json.dump(
{
"lastSeenMs": cursor.last_seen_ms,
"lastSeenRowid": cursor.last_seen_rowid,
"updatedAt": time.time(),
"failureRetries": cursor.failure_retries,
},
f,
)
async def run_catchup(
client,
state_dir: Path,
account_id: str,
catchup_config,
process_message: callable,
dedupe_store,
) -> CatchupSummary:
if not catchup_config or not catchup_config.enabled:
return CatchupSummary()
cursor = load_cursor(state_dir, account_id)
summary = CatchupSummary(
cursor_before=cursor.last_seen_ms,
window_end_ms=int(time.time() * 1000),
)
now_ms = int(time.time() * 1000)
lookback_limit_ms = catchup_config.max_age_minutes * 60 * 1000
if cursor.last_seen_ms:
summary.window_start_ms = cursor.last_seen_ms
else:
summary.window_start_ms = now_ms - catchup_config.first_run_lookback_minutes * 60 * 1000
if summary.window_start_ms < now_ms - lookback_limit_ms:
summary.window_start_ms = now_ms - lookback_limit_ms
if summary.window_start_ms >= summary.window_end_ms:
return summary
try:
params = {
"limit": catchup_config.per_run_limit,
"sort": "ASC",
}
resp = await client.get("/api/v1/messages", params=params)
resp.raise_for_status()
summary.query_succeeded = True
data = resp.json()
messages = data if isinstance(data, list) else data.get("data", data.get("messages", []))
summary.fetched_count = len(messages)
max_failures = catchup_config.max_failure_retries
for msg_data in messages:
guid = msg_data.get("guid", "")
delivered = msg_data.get("dateDelivered", msg_data.get("date_delivered", 0))
if cursor.last_seen_ms and delivered <= cursor.last_seen_ms:
summary.skipped_pre_cursor += 1
continue
if msg_data.get("isFromMe", msg_data.get("is_from_me", False)):
summary.skipped_from_me += 1
continue
failures = cursor.failure_retries.get(guid, 0)
if failures >= max_failures:
summary.skipped_given_up += 1
continue
if dedupe_store and dedupe_store.is_duplicate(guid, account_id):
continue
try:
await process_message(msg_data)
summary.replayed += 1
except Exception:
summary.failed += 1
cursor.failure_retries[guid] = failures + 1
if failures + 1 >= max_failures:
summary.given_up += 1
if delivered > summary.cursor_after:
summary.cursor_after = delivered
cursor.last_seen_ms = summary.cursor_after or summary.window_end_ms
save_cursor(state_dir, account_id, cursor)
except Exception as e:
logger.warning("Catchup query failed for account %s: %s", account_id, e)
return summary