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