该提交新增了完整的 Nostr 去中心化社交网络适配器实现,包含以下核心功能: 1. 基础加密与密钥处理:支持 nsec/npub/hex 格式密钥转换,实现 NIP-04/NIP-17 加解密 2. Relay 管理与健康监控:支持多 Relay 连接、自动重连、健康评分与自动选优 3. 事件与消息处理:实现事件校验、去重、速率限制、消息缓存与状态持久化 4. 个人资料管理:支持发布/导入/合并 Nostr Kind 0 元数据事件 5. 配对机制:实现安全的双向配对通信流程 6. Zap 功能:支持 NIP-57 打赏请求与收据解析 7. NIP-05 验证:实现域名身份验证 8. 配置系统:完整的配置校验与多账户支持 9. 监控与指标:提供连接监控、事件统计与健康快照 10. HTTP API:提供个人资料管理的 RESTful 接口 11. 安装向导:命令行配置向导与初始化流程 所有模块均遵循 Nostr 协议规范,支持多账户、多 Relay 部署,内置安全防护与流量控制机制。
383 lines
15 KiB
Python
383 lines
15 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import random
|
|
from collections.abc import Awaitable, Callable
|
|
|
|
import websockets
|
|
|
|
from yuxi.channels.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError
|
|
from yuxi.utils.logging_config import logger
|
|
|
|
|
|
class RelayManager:
|
|
def __init__(self, relay_urls: list[str], timeout: float = 30.0, reconnect_interval: float = 5.0):
|
|
self._relay_urls = relay_urls
|
|
self._timeout = timeout
|
|
self._reconnect_interval = reconnect_interval
|
|
self._connections: dict[str, websockets.WebSocketClientProtocol] = {}
|
|
self._event_handlers: list[Callable[[dict], Awaitable[None]]] = []
|
|
self._eose_handlers: list[Callable[[str], Awaitable[None]]] = []
|
|
self._reconnect_tasks: dict[str, asyncio.Task] = {}
|
|
self._subscriptions: list[dict] = []
|
|
self._running = False
|
|
self._circuit_breakers: dict[str, CircuitBreaker] = {}
|
|
self._on_connect_handlers: list[Callable[[str], Awaitable[None]]] = []
|
|
self._on_disconnect_handlers: list[Callable[[str], Awaitable[None]]] = []
|
|
self._relay_scores: dict[str, float] = {}
|
|
self._on_error_handlers: list[Callable[[str, dict], Awaitable[None]]] = []
|
|
self._reconnect_attempts: dict[str, int] = {}
|
|
|
|
for url in relay_urls:
|
|
self._circuit_breakers[url] = CircuitBreaker(
|
|
failure_threshold=5, recovery_timeout=30.0, half_open_max_calls=3
|
|
)
|
|
self._relay_scores[url] = 0.5
|
|
|
|
self._sub_pubkeys: list[str] = []
|
|
|
|
def set_pubkey_filter(self, pubkeys: list[str]) -> None:
|
|
self._sub_pubkeys = pubkeys
|
|
|
|
def _build_subscription_filters(self, filters: list[dict]) -> list[dict]:
|
|
if not self._sub_pubkeys:
|
|
return filters
|
|
return [{**f, "#p": self._sub_pubkeys} for f in filters]
|
|
|
|
def on_connect(self, handler: Callable[[str], Awaitable[None]]) -> None:
|
|
self._on_connect_handlers.append(handler)
|
|
|
|
def on_disconnect(self, handler: Callable[[str], Awaitable[None]]) -> None:
|
|
self._on_disconnect_handlers.append(handler)
|
|
|
|
def on_eose(self, handler: Callable[[str], Awaitable[None]]) -> None:
|
|
self._eose_handlers.append(handler)
|
|
|
|
def on_error(self, handler: Callable[[str, dict], Awaitable[None]]) -> None:
|
|
self._on_error_handlers.append(handler)
|
|
|
|
def _classify_error(self, url: str, error: Exception) -> str:
|
|
error_type = type(error).__name__
|
|
if "timeout" in error_type.lower() or "Timeout" in error_type:
|
|
return "timeout"
|
|
if "connect" in error_type.lower() or "Connection" in error_type:
|
|
return "connection"
|
|
if "circuit" in error_type.lower():
|
|
return "circuit_breaker"
|
|
if "json" in error_type.lower():
|
|
return "parse_error"
|
|
if "websocket" in error_type.lower():
|
|
return "websocket"
|
|
return "unknown"
|
|
|
|
async def _notify_error(self, url: str, category: str, error: Exception) -> None:
|
|
context = {
|
|
"url": url,
|
|
"category": category,
|
|
"error_type": type(error).__name__,
|
|
"error_message": str(error)[:200],
|
|
}
|
|
for handler in self._on_error_handlers:
|
|
try:
|
|
await handler(category, context)
|
|
except Exception:
|
|
logger.debug(f"on_error handler error for {url}", exc_info=True)
|
|
|
|
async def connect_all(self) -> None:
|
|
self._running = True
|
|
tasks = [self._connect_relay(url) for url in self._relay_urls]
|
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
connected = sum(1 for r in results if r is True)
|
|
logger.info(f"Nostr Relay 连接完成: {connected}/{len(self._relay_urls)} 个 Relay 已连接")
|
|
|
|
async def _connect_relay(self, url: str) -> bool:
|
|
try:
|
|
ws = await asyncio.wait_for(
|
|
websockets.connect(url, ping_interval=20, ping_timeout=10),
|
|
timeout=self._timeout,
|
|
)
|
|
self._connections[url] = ws
|
|
if self._subscriptions:
|
|
sub_id = "forcepilot_nostr_sub"
|
|
effective_filters = self._build_subscription_filters(self._subscriptions)
|
|
req = json.dumps(["REQ", sub_id, *effective_filters])
|
|
await ws.send(req)
|
|
for handler in self._on_connect_handlers:
|
|
try:
|
|
await handler(url)
|
|
except Exception:
|
|
logger.debug(f"on_connect handler error for {url}", exc_info=True)
|
|
logger.info(f"Nostr Relay 已连接: {url}")
|
|
return True
|
|
except Exception as e:
|
|
logger.warning(f"Nostr Relay 连接失败 {url}: {e}")
|
|
self._schedule_reconnect(url)
|
|
return False
|
|
|
|
def _schedule_reconnect(self, url: str) -> None:
|
|
if url in self._reconnect_tasks:
|
|
return
|
|
|
|
attempt = self._reconnect_attempts.get(url, 0) + 1
|
|
self._reconnect_attempts[url] = attempt
|
|
base_delay = min(self._reconnect_interval * (2 ** (attempt - 1)), 300)
|
|
jitter = random.uniform(0, base_delay * 0.3)
|
|
delay = base_delay + jitter
|
|
|
|
async def _reconnect():
|
|
await asyncio.sleep(delay)
|
|
if not self._running:
|
|
self._reconnect_tasks.pop(url, None)
|
|
return
|
|
logger.debug(f"Nostr Relay 尝试重连: {url} (尝试 #{attempt}, 延迟 {delay:.1f}s)")
|
|
success = await self._connect_relay(url)
|
|
if success:
|
|
self._reconnect_attempts.pop(url, None)
|
|
self._reconnect_tasks.pop(url, None)
|
|
|
|
self._reconnect_tasks[url] = asyncio.ensure_future(_reconnect())
|
|
|
|
def list_connections(self) -> dict[str, websockets.WebSocketClientProtocol]:
|
|
return dict(self._connections)
|
|
|
|
def get_circuit_breaker(self, url: str) -> CircuitBreaker | None:
|
|
return self._circuit_breakers.get(url)
|
|
|
|
async def disconnect_all(self) -> None:
|
|
self._running = False
|
|
for url, task in list(self._reconnect_tasks.items()):
|
|
task.cancel()
|
|
self._reconnect_tasks.pop(url, None)
|
|
for url in list(self._connections.keys()):
|
|
ws = self._connections.pop(url, None)
|
|
if ws:
|
|
try:
|
|
await ws.close()
|
|
except Exception:
|
|
pass
|
|
for handler in self._on_disconnect_handlers:
|
|
try:
|
|
await handler(url)
|
|
except Exception:
|
|
logger.debug(f"on_disconnect handler error for {url}", exc_info=True)
|
|
|
|
async def broadcast(self, event: dict) -> int:
|
|
message = json.dumps(["EVENT", event])
|
|
sorted_urls = sorted(
|
|
self._connections.keys(),
|
|
key=lambda url: self._relay_scores.get(url, 0.5),
|
|
reverse=True,
|
|
)
|
|
tasks = []
|
|
for url in sorted_urls:
|
|
cb = self._circuit_breakers.get(url)
|
|
if cb and cb.state == "open":
|
|
continue
|
|
ws = self._connections.get(url)
|
|
if ws and ws.open:
|
|
tasks.append(self._send_with_cb(ws, url, message))
|
|
if not tasks:
|
|
return 0
|
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
return sum(1 for r in results if r is True)
|
|
|
|
async def broadcast_with_ack(self, event: dict) -> dict:
|
|
message = json.dumps(["EVENT", event])
|
|
sorted_urls = sorted(
|
|
self._connections.keys(),
|
|
key=lambda url: self._relay_scores.get(url, 0.5),
|
|
reverse=True,
|
|
)
|
|
ack_results: dict[str, bool] = {}
|
|
tasks = []
|
|
for url in sorted_urls:
|
|
cb = self._circuit_breakers.get(url)
|
|
if cb and cb.state == "open":
|
|
ack_results[url] = False
|
|
continue
|
|
ws = self._connections.get(url)
|
|
if ws and ws.open:
|
|
tasks.append(self._send_and_wait_ok(ws, url, message, ack_results))
|
|
|
|
if tasks:
|
|
await asyncio.gather(*tasks, return_exceptions=True)
|
|
return ack_results
|
|
|
|
async def _send_and_wait_ok(self, ws, url: str, message: str, results: dict[str, bool]) -> None:
|
|
try:
|
|
await ws.send(message)
|
|
raw = await asyncio.wait_for(ws.recv(), timeout=5)
|
|
data = json.loads(raw)
|
|
ok = isinstance(data, list) and len(data) >= 4 and data[0] == "OK" and data[2] is True
|
|
results[url] = ok
|
|
except Exception:
|
|
results[url] = False
|
|
|
|
async def _send_with_cb(
|
|
self, ws: websockets.WebSocketClientProtocol, url: str, message: str, max_retries: int = 3
|
|
) -> bool:
|
|
cb = self._circuit_breakers.get(url)
|
|
retry_delays = [1, 2, 4]
|
|
|
|
for attempt in range(max_retries):
|
|
try:
|
|
if cb:
|
|
await cb.call(lambda: asyncio.wait_for(ws.send(message), timeout=10))
|
|
else:
|
|
await asyncio.wait_for(ws.send(message), timeout=10)
|
|
return True
|
|
except CircuitBreakerOpenError:
|
|
logger.debug(f"Relay {url} circuit breaker open, skipping")
|
|
return False
|
|
except Exception as e:
|
|
if cb:
|
|
await cb.record_failure()
|
|
category = self._classify_error(url, e)
|
|
await self._notify_error(url, category, e)
|
|
if attempt < max_retries - 1:
|
|
await asyncio.sleep(retry_delays[min(attempt, len(retry_delays) - 1)])
|
|
logger.warning(f"Relay 发送失败(已重试{max_retries}次): {url},断开并调度重连")
|
|
self._connections.pop(url, None)
|
|
self._schedule_reconnect(url)
|
|
return False
|
|
|
|
async def _send_with_retry(
|
|
self, ws: websockets.WebSocketClientProtocol, url: str, message: str, max_retries: int = 3
|
|
) -> bool:
|
|
return await self._send_with_cb(ws, url, message, max_retries)
|
|
|
|
def on_event(self, handler: Callable[[dict], Awaitable[None]]) -> None:
|
|
self._event_handlers.append(handler)
|
|
|
|
async def subscribe(self, filters: list[dict]) -> None:
|
|
self._subscriptions = filters
|
|
sub_id = "forcepilot_nostr_sub"
|
|
effective_filters = self._build_subscription_filters(filters)
|
|
req = json.dumps(["REQ", sub_id, *effective_filters])
|
|
for url in list(self._connections.keys()):
|
|
ws = self._connections.get(url)
|
|
if ws and ws.open:
|
|
try:
|
|
await ws.send(req)
|
|
except Exception:
|
|
pass
|
|
|
|
async def listen(self, url: str) -> None:
|
|
ws = self._connections.get(url)
|
|
if not ws or not ws.open:
|
|
return
|
|
try:
|
|
async for raw in ws:
|
|
if not self._running:
|
|
break
|
|
try:
|
|
data = json.loads(raw)
|
|
if isinstance(data, list) and len(data) >= 3 and data[0] == "EVENT":
|
|
event = data[2]
|
|
if isinstance(event, dict):
|
|
for handler in self._event_handlers:
|
|
await handler(event)
|
|
elif isinstance(data, list) and len(data) >= 2 and data[0] == "EOSE":
|
|
for handler in self._eose_handlers:
|
|
try:
|
|
await handler(url)
|
|
except Exception:
|
|
logger.debug(f"EOSE handler error for {url}", exc_info=True)
|
|
except (json.JSONDecodeError, KeyError, IndexError):
|
|
logger.debug(f"Relay {url} 返回无法解析的消息: {raw[:200]}")
|
|
continue
|
|
except Exception:
|
|
self._connections.pop(url, None)
|
|
for handler in self._on_disconnect_handlers:
|
|
try:
|
|
await handler(url)
|
|
except Exception:
|
|
pass
|
|
self._schedule_reconnect(url)
|
|
|
|
async def query(self, filters: list[dict], timeout: float = 10.0) -> list[dict]:
|
|
sub_id = "forcepilot_nostr_query"
|
|
req = json.dumps(["REQ", sub_id, *filters])
|
|
close_req = json.dumps(["CLOSE", sub_id])
|
|
|
|
active_ws = [(url, ws) for url, ws in self._connections.items() if ws and ws.open]
|
|
if not active_ws:
|
|
return []
|
|
|
|
for url, ws in active_ws:
|
|
try:
|
|
await ws.send(req)
|
|
except Exception:
|
|
continue
|
|
|
|
async def _receive_from_relay(_url: str, _ws) -> list[dict]:
|
|
results: list[dict] = []
|
|
try:
|
|
while True:
|
|
raw = await asyncio.wait_for(_ws.recv(), timeout=timeout)
|
|
try:
|
|
data = json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
if isinstance(data, list) and len(data) >= 2:
|
|
if data[0] == "EVENT" and len(data) >= 3 and isinstance(data[2], dict):
|
|
results.append(data[2])
|
|
elif data[0] in ("EOSE", "CLOSED"):
|
|
break
|
|
except TimeoutError:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
return results
|
|
|
|
receive_tasks = [_receive_from_relay(url, ws) for url, ws in active_ws]
|
|
all_results = await asyncio.gather(*receive_tasks)
|
|
|
|
seen: set[str] = set()
|
|
events: list[dict] = []
|
|
for batch in all_results:
|
|
for event in batch:
|
|
event_id = event.get("id", "")
|
|
if event_id and event_id not in seen:
|
|
seen.add(event_id)
|
|
events.append(event)
|
|
|
|
for url, ws in active_ws:
|
|
try:
|
|
await ws.send(close_req)
|
|
except Exception:
|
|
pass
|
|
|
|
return events
|
|
|
|
async def send_auth(self, raw_signed_auth_event: dict, urls: list[str] | None = None) -> dict[str, bool]:
|
|
message = json.dumps(["AUTH", raw_signed_auth_event])
|
|
target_urls = urls or list(self._connections.keys())
|
|
results: dict[str, bool] = {}
|
|
for url in target_urls:
|
|
ws = self._connections.get(url)
|
|
if not ws or not ws.open:
|
|
results[url] = False
|
|
continue
|
|
try:
|
|
await ws.send(message)
|
|
raw = await asyncio.wait_for(ws.recv(), timeout=10)
|
|
data = json.loads(raw)
|
|
results[url] = (isinstance(data, list) and len(data) >= 2 and data[0] == "AUTH" and data[1] == url) or (
|
|
isinstance(data, list) and len(data) >= 2 and data[0] == "OK"
|
|
)
|
|
except Exception:
|
|
results[url] = False
|
|
return results
|
|
|
|
def active_count(self) -> tuple[int, int]:
|
|
active = sum(1 for ws in self._connections.values() if ws.open)
|
|
return active, len(self._relay_urls)
|
|
|
|
def set_relay_scores(self, scores: dict[str, float]) -> None:
|
|
self._relay_scores.update(scores)
|
|
|
|
def get_relay_score(self, url: str) -> float:
|
|
return self._relay_scores.get(url, 0.5)
|