ForcePilot/backend/package/yuxi/channels/adapters/nostr/monitor.py
Kris a1f8288d20 feat(nostr): 实现完整的 Nostr 协议适配器模块
该提交新增了完整的 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 部署,内置安全防护与流量控制机制。
2026-05-12 00:47:41 +08:00

108 lines
4.2 KiB
Python

from __future__ import annotations
import asyncio
import time
from collections.abc import Awaitable, Callable
from yuxi.channels.adapters.nostr.crypto import NostrCrypto
from yuxi.channels.adapters.nostr.relay_manager import RelayManager
from yuxi.utils.logging_config import logger
class NostrMonitor:
def __init__(
self,
crypto: NostrCrypto,
relay_manager: RelayManager,
backfill_window_sec: int = 120,
):
self._crypto = crypto
self._relay_manager = relay_manager
self._listeners: list[asyncio.Task] = []
self._running = False
self._last_subscribe_ts: int = 0
self._backfill_window_sec = backfill_window_sec
self._eose_received: set[str] = set()
self._on_eose_handlers: list[Callable[[str, int], Awaitable[None]]] = []
self._onclose_handlers: list[Callable[[str], Awaitable[None]]] = []
self._subscription_active = False
@property
def running(self) -> bool:
return self._running
def on_event(self, handler: Callable[[dict], Awaitable[None]]) -> None:
self._relay_manager.on_event(handler)
def on_eose(self, handler: Callable[[str, int], Awaitable[None]]) -> None:
self._on_eose_handlers.append(handler)
def on_subscription_close(self, handler: Callable[[str], Awaitable[None]]) -> None:
self._onclose_handlers.append(handler)
async def _handle_subscription_close(self, reason: str) -> None:
self._subscription_active = False
logger.warning("[NostrMonitor] 订阅关闭: reason=%s", reason)
for handler in self._onclose_handlers:
try:
await handler(reason)
except Exception:
logger.debug("onclose handler error", exc_info=True)
async def subscribe(self, dm_policy: str) -> None:
now = int(time.time())
if self._last_subscribe_ts == 0:
self._last_subscribe_ts = now - self._backfill_window_sec
since = self._last_subscribe_ts
self._last_subscribe_ts = now
filters = [{"kinds": [1, 4, 5, 7, 1059], "since": since}]
self._relay_manager.set_pubkey_filter([self._crypto.pubkey_hex()])
await self._relay_manager.subscribe(filters)
self._subscription_active = True
async def start(self) -> None:
self._running = True
self._relay_manager.on_eose(self._handle_eose)
connections = self._relay_manager.list_connections()
for url, ws in connections.items():
if ws and ws.open:
task = asyncio.create_task(self._listen_relay(url))
self._listeners.append(task)
logger.info(f"Nostr Monitor 已启动,监听 {len(self._listeners)} 个 Relay")
async def stop(self) -> None:
self._running = False
for task in self._listeners:
task.cancel()
self._listeners.clear()
if self._subscription_active:
await self._handle_subscription_close("stop called")
async def _handle_eose(self, url: str) -> None:
self._eose_received.add(url)
total_connections = len(self._relay_manager.list_connections())
logger.debug(f"Nostr EOSE from {url} ({len(self._eose_received)}/{total_connections})")
for handler in self._on_eose_handlers:
try:
await handler(url, len(self._eose_received))
except Exception:
logger.debug("EOSE handler error", exc_info=True)
async def _listen_relay(self, url: str) -> None:
while self._running:
connections = self._relay_manager.list_connections()
ws = connections.get(url)
if ws and ws.open:
await self._relay_manager.listen(url)
await asyncio.sleep(self._relay_manager._reconnect_interval)
else:
await asyncio.sleep(self._relay_manager._reconnect_interval)
all_disconnected = True
for ws in self._relay_manager.list_connections().values():
if ws and ws.open:
all_disconnected = False
break
if all_disconnected and self._subscription_active:
await self._handle_subscription_close("all relays disconnected")