ForcePilot/backend/package/yuxi/channel/extensions/nostr/gateway.py
Kris 16455cb303 feat(channel): 添加 Nostr 渠道扩展
新增 Nostr 渠道扩展,支持在 Yuxi 平台中集成 Nostr 去中心化社交协议。

包含以下功能模块:
- bus: 事件总线与中继通信
- account: 账户管理
- key_utils: 密钥工具
- gateway: SSE/WebSocket 网关接入
- outbound: 外发消息管理
- gift_wrap: Gift Wrap 加密
- nip44: NIP-44 加密协议
- config_schema: 配置模式
- defaults: 默认配置
- profile_core: 用户资料核心
- profile_publisher: 资料发布
- event_utils: 事件工具
- deletion: 事件删除
- reactions: 表情反应
- metrics: 指标监控
- seen_tracker: 已读追踪
- session_route: 会话路由
- state_store: 状态存储
2026-05-21 11:31:51 +08:00

126 lines
3.6 KiB
Python

import logging
import time
from dataclasses import dataclass
from pathlib import Path
from .account import ResolvedNostrAccount
from .bus import BusOptions, NostrBus
from .metrics import MetricsSnapshot
from .state_store import NostrBusState, load_bus_state, save_bus_state
logger = logging.getLogger(__name__)
class NostrGatewayError(Exception):
pass
@dataclass
class NostrBusHandle:
bus: NostrBus
state: NostrBusState
class NostrGateway:
def __init__(self, state_dir: str = ""):
self._active_buses: dict[str, NostrBusHandle] = {}
self._metrics_snapshots: dict[str, MetricsSnapshot] = {}
self._state_dir = state_dir
def _on_metric(self, name: str, relay: str | None, value: int):
if relay:
logger.debug("Nostr metric %s relay=%s value=%s", name, relay, value)
else:
logger.debug("Nostr metric %s value=%s", name, value)
def _resolve_state_dir(self) -> Path:
if self._state_dir:
return Path(self._state_dir)
return Path.home() / ".forcepilot" / "nostr"
async def start(
self,
account_id: str,
account: ResolvedNostrAccount,
on_message: callable,
authorize_sender: callable,
) -> NostrBus:
if not account.configured:
raise NostrGatewayError(f"Nostr account '{account_id}' not configured: missing privateKey")
from .key_utils import get_public_key, validate_private_key
sk = validate_private_key(account.private_key)
pk = get_public_key(sk)
state_dir = self._resolve_state_dir()
state = load_bus_state(account_id, state_dir)
now = int(time.time())
if state.gateway_started_at is None:
state.gateway_started_at = now
since = max(
state.last_processed_at or 0,
state.gateway_started_at or 0,
) - 120
bus = NostrBus(
sk=sk,
pk=pk,
relays=account.relays,
account_id=account_id,
options=BusOptions(
on_metric=self._on_metric,
state_dir=str(state_dir),
encryption=account.config.get("encryption", "nip17"),
),
)
bus._set_state(state)
bus.seen.seed(state.recent_event_ids)
await bus.start(since=since, on_message=on_message, authorize_sender=authorize_sender)
handle = NostrBusHandle(bus=bus, state=state)
self._active_buses[account_id] = handle
return bus
async def stop(self, account_id: str):
handle = self._active_buses.pop(account_id, None)
if handle is None:
return
bus = handle.bus
state = handle.state
state.last_processed_at = int(time.time())
state.recent_event_ids = bus.seen.get_recent_ids()
await bus.close()
state_dir = self._resolve_state_dir()
save_bus_state(account_id, state, state_dir)
self._metrics_snapshots.pop(account_id, None)
def get_bus(self, account_id: str) -> NostrBus | None:
handle = self._active_buses.get(account_id)
return handle.bus if handle else None
def is_active(self, account_id: str) -> bool:
return account_id in self._active_buses
async def stop_all(self):
for account_id in list(self._active_buses.keys()):
await self.stop(account_id)
def capture_metrics(self, account_id: str) -> MetricsSnapshot | None:
bus = self.get_bus(account_id)
if bus is None:
return None
snapshot = bus.get_metrics()
self._metrics_snapshots[account_id] = snapshot
return snapshot