9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
344 lines
9.7 KiB
Dart
344 lines
9.7 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:nostr/nostr.dart' as nostr;
|
|
|
|
import '../../../shared/crypto/nip44.dart';
|
|
import '../../../shared/relay/relay.dart';
|
|
import 'observer_models.dart';
|
|
import 'transcript_builder.dart';
|
|
|
|
/// Maximum observer events to keep per agent.
|
|
const _maxObserverEvents = 800;
|
|
|
|
/// Key for channel-scoped transcript reads.
|
|
typedef ObserverKey = ({String channelId, String agentPubkey});
|
|
|
|
/// State emitted by the channel-scoped observer transcript provider.
|
|
@immutable
|
|
class ObserverState {
|
|
final ObserverConnectionState connection;
|
|
final List<TranscriptItem> transcript;
|
|
final String? errorMessage;
|
|
|
|
const ObserverState({
|
|
required this.connection,
|
|
required this.transcript,
|
|
this.errorMessage,
|
|
});
|
|
|
|
const ObserverState.initial()
|
|
: connection = ObserverConnectionState.idle,
|
|
transcript = const [],
|
|
errorMessage = null;
|
|
}
|
|
|
|
@immutable
|
|
class ObserverRelayState {
|
|
final ObserverConnectionState connection;
|
|
final Map<String, List<ObserverFrame>> framesByAgent;
|
|
final String? errorMessage;
|
|
|
|
const ObserverRelayState({
|
|
required this.connection,
|
|
required this.framesByAgent,
|
|
this.errorMessage,
|
|
});
|
|
|
|
const ObserverRelayState.initial()
|
|
: connection = ObserverConnectionState.idle,
|
|
framesByAgent = const {},
|
|
errorMessage = null;
|
|
}
|
|
|
|
class ObserverRelayNotifier extends Notifier<ObserverRelayState> {
|
|
final Map<String, List<ObserverFrame>> _framesByAgent = {};
|
|
final Map<String, Set<String>> _dedupeKeysByAgent = {};
|
|
final Map<String, Uint8List> _conversationKeysByAgent = {};
|
|
|
|
void Function()? _unsubscribe;
|
|
Future<void>? _startFuture;
|
|
String? _privHex;
|
|
String? _ownerPubkey;
|
|
String? _identityKey;
|
|
String? _errorMessage;
|
|
int _subscriptionEpoch = 0;
|
|
bool _disposed = false;
|
|
|
|
@override
|
|
ObserverRelayState build() {
|
|
final config = ref.watch(relayConfigProvider);
|
|
final sessionState = ref.watch(relaySessionProvider);
|
|
final identityKey = '${config.baseUrl}|${config.nsec ?? ''}';
|
|
|
|
_disposed = false;
|
|
if (_identityKey != null && _identityKey != identityKey) {
|
|
_reset();
|
|
}
|
|
_identityKey = identityKey;
|
|
|
|
ref.onDispose(() {
|
|
_disposed = true;
|
|
_reset();
|
|
});
|
|
|
|
if (sessionState.status == SessionStatus.connected) {
|
|
Future.microtask(_ensureSubscribed);
|
|
}
|
|
|
|
final hasSigningKey = config.nsec?.isNotEmpty == true;
|
|
return ObserverRelayState(
|
|
connection: hasSigningKey
|
|
? _connectionForSession(sessionState.status)
|
|
: ObserverConnectionState.idle,
|
|
framesByAgent: _snapshotFrames(),
|
|
errorMessage: _errorMessage,
|
|
);
|
|
}
|
|
|
|
Future<void> _ensureSubscribed() {
|
|
if (_disposed) return Future.value();
|
|
if (_unsubscribe != null) return Future.value();
|
|
if (_startFuture != null) return _startFuture!;
|
|
|
|
_startFuture = _subscribe(_subscriptionEpoch);
|
|
return _startFuture!;
|
|
}
|
|
|
|
Future<void> _subscribe(int epoch) async {
|
|
try {
|
|
if (_disposed || epoch != _subscriptionEpoch) {
|
|
return;
|
|
}
|
|
|
|
final config = ref.read(relayConfigProvider);
|
|
final nsec = config.nsec;
|
|
if (nsec == null || nsec.isEmpty) {
|
|
_errorMessage = null;
|
|
_emit(connection: ObserverConnectionState.idle);
|
|
return;
|
|
}
|
|
|
|
final privHex = _decodePrivkey(nsec);
|
|
final ownerPubkey = _derivePubkey(privHex);
|
|
if (_disposed || epoch != _subscriptionEpoch) {
|
|
return;
|
|
}
|
|
_privHex = privHex;
|
|
_ownerPubkey = ownerPubkey;
|
|
_errorMessage = null;
|
|
_emit(connection: ObserverConnectionState.connecting);
|
|
|
|
final session = ref.read(relaySessionProvider.notifier);
|
|
final unsubscribe = await session.subscribe(
|
|
NostrFilter(
|
|
kinds: [EventKind.agentObserverFrame],
|
|
tags: {
|
|
'#p': [ownerPubkey],
|
|
},
|
|
limit: 0,
|
|
),
|
|
_handleEvent,
|
|
onClosed: (message) {
|
|
_unsubscribe = null;
|
|
_errorMessage = 'Observer subscription closed: $message';
|
|
_emit(connection: ObserverConnectionState.error);
|
|
},
|
|
);
|
|
|
|
if (_disposed || epoch != _subscriptionEpoch) {
|
|
unsubscribe();
|
|
return;
|
|
}
|
|
|
|
_unsubscribe = unsubscribe;
|
|
_emit(connection: ObserverConnectionState.open);
|
|
} catch (error) {
|
|
if (_disposed || epoch != _subscriptionEpoch) {
|
|
return;
|
|
}
|
|
_errorMessage = _observerErrorMessage(error);
|
|
_emit(connection: ObserverConnectionState.error);
|
|
} finally {
|
|
if (epoch == _subscriptionEpoch) {
|
|
_startFuture = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
void _handleEvent(NostrEvent event) {
|
|
final agentPubkey = event.getTagValue('agent');
|
|
if (agentPubkey == null || event.getTagValue('frame') != 'telemetry') {
|
|
return;
|
|
}
|
|
|
|
final normalizedAgent = agentPubkey.toLowerCase();
|
|
if (event.pubkey.toLowerCase() != normalizedAgent) {
|
|
return;
|
|
}
|
|
|
|
final ownerPubkey = _ownerPubkey;
|
|
final privHex = _privHex;
|
|
if (ownerPubkey == null || privHex == null) {
|
|
return;
|
|
}
|
|
|
|
final pTag = event.getTagValue('p');
|
|
if (pTag?.toLowerCase() != ownerPubkey.toLowerCase()) {
|
|
return;
|
|
}
|
|
|
|
final frame = _decryptFrame(event, normalizedAgent, privHex);
|
|
if (frame == null) return;
|
|
|
|
final dedupeKey = '${frame.seq}:${frame.timestamp}';
|
|
final dedupeKeys = _dedupeKeysByAgent.putIfAbsent(
|
|
normalizedAgent,
|
|
() => <String>{},
|
|
);
|
|
if (!dedupeKeys.add(dedupeKey)) {
|
|
return;
|
|
}
|
|
|
|
final frames = _framesByAgent.putIfAbsent(
|
|
normalizedAgent,
|
|
() => <ObserverFrame>[],
|
|
);
|
|
frames.add(frame);
|
|
frames.sort(_compareObserverFrames);
|
|
|
|
if (frames.length > _maxObserverEvents) {
|
|
final removeCount = frames.length - _maxObserverEvents;
|
|
for (final removed in frames.take(removeCount)) {
|
|
dedupeKeys.remove('${removed.seq}:${removed.timestamp}');
|
|
}
|
|
frames.removeRange(0, removeCount);
|
|
}
|
|
|
|
_errorMessage = null;
|
|
_emit(connection: ObserverConnectionState.open);
|
|
}
|
|
|
|
ObserverFrame? _decryptFrame(
|
|
NostrEvent event,
|
|
String normalizedAgent,
|
|
String privHex,
|
|
) {
|
|
try {
|
|
final conversationKey = _conversationKeysByAgent.putIfAbsent(
|
|
normalizedAgent,
|
|
() => getConversationKey(privHex, normalizedAgent),
|
|
);
|
|
final plaintext = nip44Decrypt(conversationKey, event.content);
|
|
final json = jsonDecode(plaintext) as Map<String, dynamic>;
|
|
return ObserverFrame.fromJson(json);
|
|
} catch (error) {
|
|
_errorMessage = 'Observer event decrypt failed: $error';
|
|
_emit(connection: ObserverConnectionState.error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
void _emit({required ObserverConnectionState connection}) {
|
|
if (_disposed) return;
|
|
state = ObserverRelayState(
|
|
connection: connection,
|
|
framesByAgent: _snapshotFrames(),
|
|
errorMessage: _errorMessage,
|
|
);
|
|
}
|
|
|
|
Map<String, List<ObserverFrame>> _snapshotFrames() {
|
|
return Map<String, List<ObserverFrame>>.unmodifiable({
|
|
for (final entry in _framesByAgent.entries)
|
|
entry.key: List<ObserverFrame>.unmodifiable(entry.value),
|
|
});
|
|
}
|
|
|
|
void _reset() {
|
|
_subscriptionEpoch += 1;
|
|
_unsubscribe?.call();
|
|
_unsubscribe = null;
|
|
_startFuture = null;
|
|
_privHex = null;
|
|
_ownerPubkey = null;
|
|
_errorMessage = null;
|
|
_framesByAgent.clear();
|
|
_dedupeKeysByAgent.clear();
|
|
_conversationKeysByAgent.clear();
|
|
}
|
|
|
|
static String _decodePrivkey(String nsec) {
|
|
try {
|
|
final privHex = nostr.Nip19.decode(payload: nsec).data;
|
|
if (privHex.isEmpty) {
|
|
throw const FormatException('empty private key');
|
|
}
|
|
return privHex;
|
|
} catch (_) {
|
|
throw const FormatException('failed to decode private key');
|
|
}
|
|
}
|
|
|
|
static String _derivePubkey(String privHex) {
|
|
try {
|
|
return nostr.Keys(privHex).public;
|
|
} catch (_) {
|
|
throw const FormatException('failed to derive pubkey');
|
|
}
|
|
}
|
|
|
|
ObserverConnectionState _connectionForSession(SessionStatus status) {
|
|
if (_errorMessage != null && _unsubscribe == null && _startFuture == null) {
|
|
return ObserverConnectionState.error;
|
|
}
|
|
return switch (status) {
|
|
SessionStatus.connected =>
|
|
_unsubscribe == null
|
|
? ObserverConnectionState.connecting
|
|
: ObserverConnectionState.open,
|
|
SessionStatus.connecting ||
|
|
SessionStatus.reconnecting => ObserverConnectionState.connecting,
|
|
SessionStatus.disconnected => ObserverConnectionState.idle,
|
|
};
|
|
}
|
|
|
|
static String _observerErrorMessage(Object error) {
|
|
if (error is FormatException) {
|
|
return error.message;
|
|
}
|
|
return 'Observer subscription failed: $error';
|
|
}
|
|
|
|
static int _compareObserverFrames(ObserverFrame a, ObserverFrame b) {
|
|
final tsA = DateTime.tryParse(a.timestamp)?.millisecondsSinceEpoch ?? 0;
|
|
final tsB = DateTime.tryParse(b.timestamp)?.millisecondsSinceEpoch ?? 0;
|
|
if (tsA != tsB) return tsA.compareTo(tsB);
|
|
return a.seq.compareTo(b.seq);
|
|
}
|
|
}
|
|
|
|
final observerRelayProvider =
|
|
NotifierProvider<ObserverRelayNotifier, ObserverRelayState>(
|
|
ObserverRelayNotifier.new,
|
|
);
|
|
|
|
final observerSubscriptionProvider =
|
|
Provider.family<ObserverState, ObserverKey>((ref, key) {
|
|
final relayState = ref.watch(observerRelayProvider);
|
|
final normalizedAgent = key.agentPubkey.toLowerCase();
|
|
final frames = relayState.framesByAgent[normalizedAgent] ?? const [];
|
|
final channelFrames = [
|
|
for (final frame in frames)
|
|
if (frame.channelId == null || frame.channelId == key.channelId)
|
|
frame,
|
|
];
|
|
|
|
return ObserverState(
|
|
connection: relayState.connection,
|
|
transcript: buildTranscript(channelFrames),
|
|
errorMessage: relayState.errorMessage,
|
|
);
|
|
});
|