import 'dart:async'; import 'dart:convert'; import 'package:web_socket_channel/web_socket_channel.dart'; import 'models/nostr_keypair.dart'; import 'models/nostr_event.dart'; import 'models/nostr_relay.dart'; /// Exception thrown when Nostr operations fail. class NostrException implements Exception { /// Error message. final String message; /// Creates a [NostrException] with the provided message. NostrException(this.message); @override String toString() => 'NostrException: $message'; } /// Service for interacting with Nostr protocol. /// /// This service provides: /// - Keypair generation /// - Event publishing to relays /// - Metadata synchronization with multiple relays /// /// The service is modular and UI-independent, designed for testing without real relays. class NostrService { /// List of configured relays. final List _relays = []; /// Active WebSocket connections to relays. final Map _connections = {}; /// Stream controllers for relay messages. final Map>> _messageControllers = {}; /// Creates a [NostrService] instance. NostrService(); /// Generates a new Nostr keypair. /// /// Returns a [NostrKeyPair] with random private and public keys. NostrKeyPair generateKeyPair() { return NostrKeyPair.generate(); } /// Adds a relay to the service. /// /// [relayUrl] - The WebSocket URL of the relay (e.g., 'wss://relay.example.com'). void addRelay(String relayUrl) { final relay = NostrRelay.fromUrl(relayUrl); if (!_relays.contains(relay)) { _relays.add(relay); } } /// Removes a relay from the service. /// /// [relayUrl] - The URL of the relay to remove. void removeRelay(String relayUrl) { _relays.removeWhere((r) => r.url == relayUrl); disconnectRelay(relayUrl); } /// Gets the list of configured relays. List getRelays() { return List.unmodifiable(_relays); } /// Connects to a relay. /// /// [relayUrl] - The URL of the relay to connect to. /// /// Returns a [Stream] of messages from the relay. /// /// Throws [NostrException] if connection fails. Future>> connectRelay(String relayUrl) async { try { if (_connections.containsKey(relayUrl) && _connections[relayUrl] != null) { // Already connected return _messageControllers[relayUrl]!.stream; } final channel = WebSocketChannel.connect(Uri.parse(relayUrl)); _connections[relayUrl] = channel; final controller = StreamController>(); _messageControllers[relayUrl] = controller; // Update relay status final relay = _relays.firstWhere((r) => r.url == relayUrl, orElse: () => NostrRelay.fromUrl(relayUrl)); relay.isConnected = true; // Listen for messages channel.stream.listen( (message) { try { final data = jsonDecode(message as String); if (data is List) { controller.add({ 'type': data[0] as String, 'data': data.length > 1 ? data[1] : null, }); } } catch (e) { // Ignore invalid messages } }, onError: (error) { relay.isConnected = false; controller.addError(NostrException('Relay error: $error')); }, onDone: () { relay.isConnected = false; controller.close(); }, ); return controller.stream; } catch (e) { throw NostrException('Failed to connect to relay: $e'); } } /// Disconnects from a relay. /// /// [relayUrl] - The URL of the relay to disconnect from. void disconnectRelay(String relayUrl) { final channel = _connections[relayUrl]; if (channel != null) { channel.sink.close(); _connections.remove(relayUrl); } final controller = _messageControllers[relayUrl]; if (controller != null) { controller.close(); _messageControllers.remove(relayUrl); } final relay = _relays.firstWhere((r) => r.url == relayUrl, orElse: () => NostrRelay.fromUrl(relayUrl)); relay.isConnected = false; } /// Publishes an event to a relay. /// /// [event] - The Nostr event to publish. /// [relayUrl] - The URL of the relay to publish to. /// /// Returns a [Future] that completes when the event is published. /// /// Throws [NostrException] if publishing fails. Future publishEvent(NostrEvent event, String relayUrl) async { try { final channel = _connections[relayUrl]; if (channel == null) { throw NostrException('Not connected to relay: $relayUrl'); } // Send event in Nostr format: ["EVENT", ] final message = jsonEncode(['EVENT', event.toJson()]); channel.sink.add(message); } catch (e) { throw NostrException('Failed to publish event: $e'); } } /// Publishes an event to all connected relays. /// /// [event] - The Nostr event to publish. /// /// Returns a map of relay URLs to success/failure status. Future> publishEventToAllRelays(NostrEvent event) async { final results = {}; for (final relay in _relays) { if (relay.isConnected) { try { await publishEvent(event, relay.url); results[relay.url] = true; } catch (e) { results[relay.url] = false; } } else { results[relay.url] = false; } } return results; } /// Syncs metadata by publishing an event with metadata content. /// /// [metadata] - The metadata to sync (as a Map). /// [privateKey] - Private key for signing the event. /// [kind] - Event kind (default: 0 for metadata). /// /// Returns the created and published event. /// /// Throws [NostrException] if sync fails. Future syncMetadata({ required Map metadata, required String privateKey, int kind = 0, }) async { try { // Create event with metadata as content final content = jsonEncode(metadata); final event = NostrEvent.create( content: content, kind: kind, privateKey: privateKey, ); // Publish to all connected relays await publishEventToAllRelays(event); return event; } catch (e) { throw NostrException('Failed to sync metadata: $e'); } } /// Closes all connections and cleans up resources. void dispose() { for (final relayUrl in _connections.keys.toList()) { disconnectRelay(relayUrl); } _relays.clear(); } }