basic nostr actors
This commit is contained in:
parent
c14de9c3e9
commit
f1e54f2722
13 changed files with 1081 additions and 2 deletions
102
lib/src/nostr_fetch_actor.dart
Normal file
102
lib/src/nostr_fetch_actor.dart
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'package:actors/actors.dart';
|
||||
import 'relay_connection.dart';
|
||||
|
||||
sealed class FetchMessage {
|
||||
const FetchMessage();
|
||||
}
|
||||
|
||||
final class FetchEvents extends FetchMessage {
|
||||
final String filter;
|
||||
const FetchEvents(this.filter);
|
||||
}
|
||||
|
||||
sealed class FetchResult {
|
||||
const FetchResult();
|
||||
}
|
||||
|
||||
final class EventsFetched extends FetchResult {
|
||||
final List<String> events;
|
||||
const EventsFetched(this.events);
|
||||
}
|
||||
|
||||
class NostrFetchActor with Handler<FetchMessage, FetchResult> {
|
||||
final List<String> _relayUrls;
|
||||
final Duration _eoseDeadline;
|
||||
final RelayConnection Function(String url) _connectionFactory;
|
||||
|
||||
NostrFetchActor({
|
||||
required List<String> relayUrls,
|
||||
Duration eoseDeadline = const Duration(seconds: 5),
|
||||
RelayConnection Function(String url) connectionFactory =
|
||||
WebSocketRelayConnection.new,
|
||||
}) : _relayUrls = relayUrls,
|
||||
_eoseDeadline = eoseDeadline,
|
||||
_connectionFactory = connectionFactory;
|
||||
|
||||
@override
|
||||
Future<FetchResult> handle(FetchMessage message) => switch (message) {
|
||||
FetchEvents(:final filter) => _fetchEvents(filter),
|
||||
};
|
||||
|
||||
Future<FetchResult> _fetchEvents(String filter) async {
|
||||
final results = await Future.wait(
|
||||
_relayUrls.map(
|
||||
(url) => _fetchFromRelay(url, filter).catchError((_) => <String>[]),
|
||||
),
|
||||
);
|
||||
final seen = <String>{};
|
||||
final merged = <String>[];
|
||||
for (final batch in results) {
|
||||
for (final eventJson in batch) {
|
||||
final id =
|
||||
(jsonDecode(eventJson) as Map<String, dynamic>)['id'] as String;
|
||||
if (seen.add(id)) {
|
||||
merged.add(eventJson);
|
||||
}
|
||||
}
|
||||
}
|
||||
return EventsFetched(merged);
|
||||
}
|
||||
|
||||
Future<List<String>> _fetchFromRelay(String url, String filter) async {
|
||||
final rng = Random();
|
||||
final subId = List.generate(
|
||||
32,
|
||||
(_) => rng.nextInt(16).toRadixString(16),
|
||||
).join();
|
||||
|
||||
final conn = _connectionFactory(url);
|
||||
final collected = <String>[];
|
||||
|
||||
try {
|
||||
await conn.connect();
|
||||
await conn.send(jsonEncode(['REQ', subId, jsonDecode(filter)]));
|
||||
try {
|
||||
await for (final raw in conn.messages.timeout(_eoseDeadline)) {
|
||||
try {
|
||||
final list = jsonDecode(raw) as List;
|
||||
if (list[0] == 'EVENT') {
|
||||
collected.add(jsonEncode(list[2]));
|
||||
} else if (list[0] == 'EOSE') {
|
||||
break;
|
||||
}
|
||||
} catch (_) {
|
||||
// malformed message, skip
|
||||
}
|
||||
}
|
||||
} on TimeoutException {
|
||||
// return whatever was collected so far
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await conn.send(jsonEncode(['CLOSE', subId]));
|
||||
} catch (_) {}
|
||||
await conn.close();
|
||||
}
|
||||
|
||||
return collected;
|
||||
}
|
||||
}
|
||||
144
lib/src/nostr_publish_actor.dart
Normal file
144
lib/src/nostr_publish_actor.dart
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:actors/actors.dart';
|
||||
import 'package:retry/retry.dart';
|
||||
import 'relay_connection.dart';
|
||||
|
||||
final class RetryConfig {
|
||||
final int maxAttempts;
|
||||
final Duration baseDelay;
|
||||
final Duration maxDelay;
|
||||
final bool jitter;
|
||||
const RetryConfig({
|
||||
this.maxAttempts = 3,
|
||||
this.baseDelay = const Duration(seconds: 1),
|
||||
this.maxDelay = const Duration(seconds: 32),
|
||||
this.jitter = true,
|
||||
});
|
||||
}
|
||||
|
||||
sealed class RelayOutcome {
|
||||
const RelayOutcome();
|
||||
}
|
||||
|
||||
final class RelayConfirmed extends RelayOutcome {
|
||||
const RelayConfirmed();
|
||||
}
|
||||
|
||||
final class RelayRejected extends RelayOutcome {
|
||||
final String reason;
|
||||
const RelayRejected(this.reason);
|
||||
}
|
||||
|
||||
final class RelayFailed extends RelayOutcome {
|
||||
const RelayFailed();
|
||||
}
|
||||
|
||||
sealed class PublishMessage {
|
||||
const PublishMessage();
|
||||
}
|
||||
|
||||
final class PublishBatch extends PublishMessage {
|
||||
final List<String> events;
|
||||
const PublishBatch(this.events);
|
||||
}
|
||||
|
||||
sealed class PublishResult {
|
||||
const PublishResult();
|
||||
}
|
||||
|
||||
final class BatchPublished extends PublishResult {
|
||||
final Map<String, RelayOutcome> outcomes;
|
||||
const BatchPublished(this.outcomes);
|
||||
}
|
||||
|
||||
final class _RelayRejectedException implements Exception {
|
||||
final String reason;
|
||||
const _RelayRejectedException(this.reason);
|
||||
}
|
||||
|
||||
class NostrPublishActor with Handler<PublishMessage, PublishResult> {
|
||||
final List<String> _relayUrls;
|
||||
final RetryConfig _retryConfig;
|
||||
final Duration _batchTimeout;
|
||||
final RelayConnection Function(String url) _connectionFactory;
|
||||
|
||||
NostrPublishActor({
|
||||
required List<String> relayUrls,
|
||||
RetryConfig retryConfig = const RetryConfig(),
|
||||
Duration batchTimeout = const Duration(seconds: 10),
|
||||
RelayConnection Function(String url) connectionFactory =
|
||||
WebSocketRelayConnection.new,
|
||||
}) : _relayUrls = relayUrls,
|
||||
_retryConfig = retryConfig,
|
||||
_batchTimeout = batchTimeout,
|
||||
_connectionFactory = connectionFactory;
|
||||
|
||||
@override
|
||||
Future<PublishResult> handle(PublishMessage message) => switch (message) {
|
||||
PublishBatch(:final events) => _publishBatch(events),
|
||||
};
|
||||
|
||||
Future<PublishResult> _publishBatch(List<String> events) async {
|
||||
final ids = events
|
||||
.map((e) => (jsonDecode(e) as Map<String, dynamic>)['id'] as String)
|
||||
.toList();
|
||||
final outcomes = await Future.wait(
|
||||
_relayUrls.map((url) => _publishToRelay(url, events, ids)),
|
||||
);
|
||||
return BatchPublished(Map.fromIterables(_relayUrls, outcomes));
|
||||
}
|
||||
|
||||
Future<RelayOutcome> _publishToRelay(
|
||||
String url,
|
||||
List<String> events,
|
||||
List<String> ids,
|
||||
) async {
|
||||
try {
|
||||
await retry(
|
||||
() => _attemptPublish(url, events, ids),
|
||||
retryIf: (e) => e is! _RelayRejectedException,
|
||||
maxAttempts: _retryConfig.maxAttempts,
|
||||
delayFactor: _retryConfig.baseDelay,
|
||||
maxDelay: _retryConfig.maxDelay,
|
||||
randomizationFactor: _retryConfig.jitter ? 0.25 : 0.0,
|
||||
);
|
||||
return const RelayConfirmed();
|
||||
} on _RelayRejectedException catch (e) {
|
||||
return RelayRejected(e.reason);
|
||||
} catch (_) {
|
||||
return const RelayFailed();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _attemptPublish(
|
||||
String url,
|
||||
List<String> events,
|
||||
List<String> ids,
|
||||
) async {
|
||||
final conn = _connectionFactory(url);
|
||||
final pending = {...ids};
|
||||
try {
|
||||
await conn.connect();
|
||||
for (final e in events) {
|
||||
await conn.send(jsonEncode(['EVENT', jsonDecode(e)]));
|
||||
}
|
||||
await for (final raw in conn.messages.timeout(_batchTimeout)) {
|
||||
try {
|
||||
final list = jsonDecode(raw) as List;
|
||||
if (list[0] != 'OK') continue;
|
||||
if (list[2] == false) {
|
||||
throw _RelayRejectedException(list[3] as String);
|
||||
}
|
||||
pending.remove(list[1] as String);
|
||||
if (pending.isEmpty) break;
|
||||
} catch (e) {
|
||||
if (e is _RelayRejectedException) rethrow;
|
||||
// malformed message, skip
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await conn.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
85
lib/src/nostr_signer_actor.dart
Normal file
85
lib/src/nostr_signer_actor.dart
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:actors/actors.dart';
|
||||
import 'package:bip340/bip340.dart' as bip340;
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
||||
sealed class SignerMessage {
|
||||
const SignerMessage();
|
||||
}
|
||||
|
||||
final class SignEvent extends SignerMessage {
|
||||
final int kind;
|
||||
final List<List<String>> tags;
|
||||
final String content;
|
||||
final int? createdAt;
|
||||
const SignEvent({
|
||||
required this.kind,
|
||||
this.tags = const [],
|
||||
required this.content,
|
||||
this.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
final class GetPublicKey extends SignerMessage {
|
||||
const GetPublicKey();
|
||||
}
|
||||
|
||||
sealed class SignerResult {
|
||||
const SignerResult();
|
||||
}
|
||||
|
||||
final class EventSigned extends SignerResult {
|
||||
final String event;
|
||||
const EventSigned(this.event);
|
||||
}
|
||||
|
||||
final class PublicKeyResult extends SignerResult {
|
||||
final String publicKey;
|
||||
const PublicKeyResult(this.publicKey);
|
||||
}
|
||||
|
||||
class NostrSignerActor with Handler<SignerMessage, SignerResult> {
|
||||
final String _privateKey;
|
||||
final String _publicKey;
|
||||
|
||||
NostrSignerActor(String privateKey)
|
||||
: _privateKey = privateKey,
|
||||
_publicKey = bip340.getPublicKey(privateKey);
|
||||
|
||||
@override
|
||||
Future<SignerResult> handle(SignerMessage message) => switch (message) {
|
||||
SignEvent(:final kind, :final tags, :final content, :final createdAt) =>
|
||||
Future.value(_signEvent(kind, tags, content, createdAt)),
|
||||
GetPublicKey() => Future.value(PublicKeyResult(_publicKey)),
|
||||
};
|
||||
|
||||
EventSigned _signEvent(
|
||||
int kind,
|
||||
List<List<String>> tags,
|
||||
String content,
|
||||
int? createdAt,
|
||||
) {
|
||||
final ts = createdAt ?? DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
final ser = jsonEncode([0, _publicKey, ts, kind, tags, content]);
|
||||
final idBytes = sha256.convert(utf8.encode(ser)).bytes;
|
||||
final id = idBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
|
||||
final rng = Random.secure();
|
||||
final auxBytes = List.generate(32, (_) => rng.nextInt(256));
|
||||
final aux = auxBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
|
||||
final sig = bip340.sign(_privateKey, id, aux);
|
||||
|
||||
return EventSigned(jsonEncode({
|
||||
'id': id,
|
||||
'pubkey': _publicKey,
|
||||
'created_at': ts,
|
||||
'kind': kind,
|
||||
'tags': tags,
|
||||
'content': content,
|
||||
'sig': sig,
|
||||
}));
|
||||
}
|
||||
}
|
||||
42
lib/src/relay_connection.dart
Normal file
42
lib/src/relay_connection.dart
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import 'dart:async';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
abstract interface class RelayConnection {
|
||||
Future<void> connect();
|
||||
Future<void> send(String message);
|
||||
Stream<String> get messages;
|
||||
Future<void> close();
|
||||
}
|
||||
|
||||
final class WebSocketRelayConnection implements RelayConnection {
|
||||
final String _url;
|
||||
WebSocketChannel? _channel;
|
||||
StreamController<String>? _controller;
|
||||
|
||||
WebSocketRelayConnection(String url) : _url = url;
|
||||
|
||||
@override
|
||||
Future<void> connect() async {
|
||||
_channel = WebSocketChannel.connect(Uri.parse(_url));
|
||||
await _channel!.ready;
|
||||
_controller = StreamController<String>();
|
||||
_channel!.stream.cast<String>().listen(
|
||||
_controller!.add,
|
||||
onError: _controller!.addError,
|
||||
onDone: _controller!.close,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> send(String message) async => _channel!.sink.add(message);
|
||||
|
||||
@override
|
||||
Stream<String> get messages => _controller!.stream;
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
await _channel?.sink.close();
|
||||
_channel = null;
|
||||
_controller = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +1,5 @@
|
|||
export 'src/file_actor.dart';
|
||||
export 'src/relay_connection.dart';
|
||||
export 'src/nostr_publish_actor.dart';
|
||||
export 'src/nostr_fetch_actor.dart';
|
||||
export 'src/nostr_signer_actor.dart';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue