diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt index 0fb8442..69ac4ad 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt @@ -14,6 +14,7 @@ import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import su.reya.coop.coop.storage.SecretStore +import su.reya.coop.nostr.NostrManager import kotlin.system.exitProcess class MainActivity : ComponentActivity() { diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt index 82bcf57..76ebe5b 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/NostrForegroundService.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch +import su.reya.coop.nostr.NostrManager import java.io.File private const val GROUP_KEY_MESSAGES = "su.reya.coop.MESSAGES" @@ -65,17 +66,17 @@ class NostrForegroundService : Service() { } catch (e: Exception) { throw IllegalStateException("Failed to initialize Nostr Client", e) } - + // Connect to bootstrap relays nostr.connectBootstrapRelays() // Handle notifications nostr.handleNotifications( onMetadataUpdate = { pubkey, metadata -> - serviceScope.launch { nostr.emitMetadataUpdate(pubkey, metadata) } + serviceScope.launch { nostr.profiles.emitMetadataUpdate(pubkey, metadata) } }, onContactListUpdate = { contacts -> - serviceScope.launch { nostr.emitContactListUpdate(contacts) } + serviceScope.launch { nostr.profiles.emitContactListUpdate(contacts) } }, onNewMessage = { event -> serviceScope.launch { diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt deleted file mode 100644 index 9ec72f5..0000000 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ /dev/null @@ -1,1061 +0,0 @@ -package su.reya.coop - -import io.ktor.client.HttpClient -import io.ktor.client.call.body -import io.ktor.client.request.get -import io.ktor.client.statement.HttpResponse -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.supervisorScope -import rust.nostr.sdk.AckPolicy -import rust.nostr.sdk.Alphabet -import rust.nostr.sdk.AsyncNostrSigner -import rust.nostr.sdk.Client -import rust.nostr.sdk.ClientBuilder -import rust.nostr.sdk.ClientNotification -import rust.nostr.sdk.Contact -import rust.nostr.sdk.Event -import rust.nostr.sdk.EventBuilder -import rust.nostr.sdk.EventId -import rust.nostr.sdk.Filter -import rust.nostr.sdk.GossipConfig -import rust.nostr.sdk.Keys -import rust.nostr.sdk.Kind -import rust.nostr.sdk.KindStandard -import rust.nostr.sdk.LogLevel -import rust.nostr.sdk.Metadata -import rust.nostr.sdk.MetadataRecord -import rust.nostr.sdk.Nip05Address -import rust.nostr.sdk.Nip05Profile -import rust.nostr.sdk.NostrDatabase -import rust.nostr.sdk.NostrGossip -import rust.nostr.sdk.PublicKey -import rust.nostr.sdk.RelayCapabilities -import rust.nostr.sdk.RelayMessageEnum -import rust.nostr.sdk.RelayMetadata -import rust.nostr.sdk.RelayStatus -import rust.nostr.sdk.RelayUrl -import rust.nostr.sdk.ReqExitPolicy -import rust.nostr.sdk.ReqTarget -import rust.nostr.sdk.SendEventTarget -import rust.nostr.sdk.SignerAuthenticator -import rust.nostr.sdk.SingleLetterTag -import rust.nostr.sdk.SleepWhenIdle -import rust.nostr.sdk.SubscribeAutoCloseOptions -import rust.nostr.sdk.Tag -import rust.nostr.sdk.Timestamp -import rust.nostr.sdk.UnsignedEvent -import rust.nostr.sdk.extractRelayList -import rust.nostr.sdk.initLogger -import rust.nostr.sdk.nip17ExtractRelayList -import rust.nostr.sdk.nip59MakeGiftWrapAsync -import kotlin.time.Duration - -object NostrManager { - val instance = Nostr() - - val BOOTSTRAP_RELAYS = listOf( - "wss://relay.primal.net", - "wss://relay.ditto.pub", - "wss://user.kindpag.es", - ) - - val INDEXER_RELAY = listOf( - "wss://indexer.coracle.social", - ) - - val ALL_RELAYS = BOOTSTRAP_RELAYS + INDEXER_RELAY -} - -class Nostr { - var client: Client? = null - private set - var signer: UniversalSigner = UniversalSigner(Keys.generate()) - private set - var sentEvents: MutableMap> = mutableMapOf() - private set - var rumorMap: MutableMap = mutableMapOf() - private set - - private val isInitialized = MutableStateFlow(false) - - private val _newEvents = MutableSharedFlow(extraBufferCapacity = 100) - val newEvents = _newEvents.asSharedFlow() - - private val _metadataUpdates = - MutableSharedFlow>(extraBufferCapacity = 100) - val metadataUpdates = _metadataUpdates.asSharedFlow() - - private val _contactListUpdates = MutableSharedFlow>(extraBufferCapacity = 100) - val contactListUpdates = _contactListUpdates.asSharedFlow() - - private val _messageSyncState = MutableStateFlow(MessageSyncState()) - val messageSyncState = _messageSyncState.asStateFlow() - - suspend fun emitNewEvent(event: UnsignedEvent) { - _newEvents.emit(event) - } - - suspend fun emitMetadataUpdate(pubkey: PublicKey, metadata: Metadata) { - _metadataUpdates.emit(pubkey to metadata) - } - - suspend fun emitContactListUpdate(contacts: List) { - _contactListUpdates.emit(contacts) - } - - suspend fun init( - dbPath: String, - logLevel: LogLevel = LogLevel.WARN - ) { - try { - if (isInitialized.value) return - - // Initialize the logger for nostr client - initLogger(logLevel) - - // Initialize configurations for nostr client - val lmdb = NostrDatabase.lmdb(dbPath) - val gossip = NostrGossip.inMemory() - val authenticator = SignerAuthenticator(signer) - val idleTimeout = Duration.parse("5m") - - client = - ClientBuilder() - .authenticator(authenticator) - .database(lmdb) - .gossip(gossip) - .gossipConfig( - GossipConfig() - .noBackgroundRefresh() - .fetchTimeout(Duration.parse("2s")) - ) - .verifySubscriptions(false) - .sleepWhenIdle(SleepWhenIdle.Enabled(idleTimeout)) - .build() - - isInitialized.value = true - } catch (e: Exception) { - throw IllegalStateException("Failed to initialize Nostr client: ${e.message}", e) - } - } - - suspend fun waitUntilInitialized() { - isInitialized.first { it } - } - - suspend fun connectBootstrapRelays() { - NostrManager.BOOTSTRAP_RELAYS.forEach { url -> - client?.addRelay(RelayUrl.parse(url)) - } - NostrManager.INDEXER_RELAY.forEach { url -> - client?.addRelay( - url = RelayUrl.parse(url), - capabilities = RelayCapabilities.gossip() - ) - } - // Connect to all bootstrap relays - client?.connect() - } - - suspend fun reconnect() { - NostrManager.ALL_RELAYS.forEach { url -> - try { - client?.relay(RelayUrl.parse(url)).let { relay -> - if (relay != null) { - if (relay.status() != RelayStatus.CONNECTED) { - relay.connect() - } - } - } - } catch (e: Exception) { - println("Failed to reconnect relay: ${e.message}") - } - } - } - - suspend fun disconnect() { - NostrManager.ALL_RELAYS.forEach { url -> - try { - client?.disconnectRelay(RelayUrl.parse(url)) - } catch (e: Exception) { - println("Failed to disconnect relay: ${e.message}") - } - } - } - - suspend fun prune() { - try { - client?.database()?.wipe() - } catch (e: Exception) { - throw IllegalStateException("Failed to prune database: ${e.message}", e) - } - } - - suspend fun setSigner(new: AsyncNostrSigner) { - try { - signer.switch(new) - } catch (e: Exception) { - throw IllegalStateException("Failed to set signer: ${e.message}", e) - } - } - - fun isSignedByUser(event: Event): Boolean { - return try { - signer.currentUser == event.author() - } catch (e: Exception) { - println("Failed to check if event is signed by user: ${e.message}") - false - } - } - - suspend fun getUserMetadata() { - try { - val author = signer.currentUser ?: throw IllegalStateException("User not signed in") - - // Get the latest metadata event - val metadataFilter = - Filter().kind(Kind.fromStd(KindStandard.METADATA)).author(author).limit(1u) - - // Get the latest contact list event - val contactFilter = - Filter().kind(Kind.fromStd(KindStandard.CONTACT_LIST)).author(author).limit(1u) - - // Get the latest messaging relay list event - val msgRelayFilter = - Filter().kind(Kind.fromStd(KindStandard.INBOX_RELAYS)).author(author).limit(1u) - - // Construct a target that includes all filters - val target = ReqTarget.auto(listOf(metadataFilter, contactFilter, msgRelayFilter)) - val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) - - client?.subscribe(target = target, id = "user-metadata", closeOn = opts) - } catch (e: Exception) { - throw IllegalStateException("Failed to fetch user metadata: ${e.message}", e) - } - } - - suspend fun getUserMessages(msgRelayList: Event) { - try { - val author = signer.currentUser ?: throw IllegalStateException("User not signed in") - val relays = nip17ExtractRelayList(msgRelayList) - - // Ensure relay connections - relays.forEach { relay -> - client?.addRelay(relay) - client?.connectRelay(relay) - } - - // Construct a filter for gift wrap events - val filter = Filter().kind(Kind.fromStd(KindStandard.GIFT_WRAP)).pubkey(author) - val target = mutableMapOf>() - relays.forEach { relay -> - target[relay] = listOf(filter) - } - - client?.subscribe( - target = ReqTarget.manual(target), - id = "gift-wraps" - ) - } catch (e: Exception) { - throw IllegalStateException("Failed to fetch user messages: ${e.message}", e) - } - } - - @OptIn(ExperimentalCoroutinesApi::class) - suspend fun handleNotifications( - onMetadataUpdate: (PublicKey, Metadata) -> Unit, - onContactListUpdate: (List) -> Unit, - onNewMessage: (UnsignedEvent) -> Unit, - ) = supervisorScope { - val now = Timestamp.now() - val processedEvent = mutableSetOf() - val notifications = client?.notifications() ?: return@supervisorScope - - val giftWrapQueue = Channel(Channel.UNLIMITED) - var processedCount = 0 - var eoseReceived = false - - launch(Dispatchers.Default) { - for (event in giftWrapQueue) { - val rumor = extractRumor(event) - processedCount++ - - // Trigger new message notification - if (rumor != null) { - if (rumor.createdAt().asSecs() >= now.asSecs()) { - onNewMessage(rumor) - } - } - - // Update sync state - _messageSyncState.update { - it.copy( - processedCount = processedCount, - isSyncing = !eoseReceived || !giftWrapQueue.isEmpty - ) - } - } - } - - while (true) { - val notification = notifications.next() ?: continue - - when (notification) { - is ClientNotification.Message -> { - val relayUrl = notification.relayUrl - - when (val message = notification.message.asEnum()) { - is RelayMessageEnum.EventMsg -> { - val event = message.event - - // Prevent processing duplicate events - if (processedEvent.contains(event.id())) continue - processedEvent.add(event.id()) - - when (event.kind().asStd()) { - KindStandard.METADATA -> { - try { - val metadata = Metadata.fromJson(event.content()) - onMetadataUpdate(event.author(), metadata) - } catch (e: Exception) { - println("Failed to parse metadata: $e") - } - } - - KindStandard.CONTACT_LIST -> { - if (isSignedByUser(event = event)) { - val pubkeys = event.tags().publicKeys() - // Get mutual contacts - getMutualContacts(pubkeys) - // Emit contact list update - onContactListUpdate(pubkeys) - } - } - - KindStandard.INBOX_RELAYS -> { - // Get all gift wrap events for the current user - if (isSignedByUser(event = event)) { - getUserMessages(msgRelayList = event) - } - } - - KindStandard.GIFT_WRAP -> { - giftWrapQueue.send(event) - } - - else -> {} - } - } - - is RelayMessageEnum.EndOfStoredEvents -> { - if (message.subscriptionId == "gift-wraps") { - eoseReceived = true - if (giftWrapQueue.isEmpty) { - _messageSyncState.update { it.copy(isSyncing = false) } - } - } - } - - is RelayMessageEnum.Ok -> { - if (sentEvents.containsKey(message.eventId)) { - val currentRelays = sentEvents[message.eventId] ?: emptyList() - sentEvents[message.eventId] = currentRelays + relayUrl - } - } - - else -> { - /* Ignore other message types */ - } - } - } - - is ClientNotification.Shutdown -> { - break - } - - else -> { - /* Ignore other message types */ - } - } - } - } - - private suspend fun getMutualContacts(pubkeys: List) { - try { - val kind = Kind.fromStd(KindStandard.CONTACT_LIST) - val filter = Filter().kind(kind).authors(pubkeys).limit(pubkeys.size.toULong()) - val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) - - val target = mutableMapOf>() - NostrManager.BOOTSTRAP_RELAYS.forEach { relay -> - target[RelayUrl.parse(relay)] = listOf(filter) - } - - client?.subscribe( - target = ReqTarget.manual(target), - id = "mutual-contacts", - closeOn = opts, - ) - } catch (e: Exception) { - throw IllegalStateException("Failed to fetch mutual contacts: ${e.message}", e) - } - } - - private suspend fun getCachedRumor(giftId: EventId): UnsignedEvent? { - try { - val filter = Filter().identifier(giftId.toHex()) - val event = client?.database()?.query(filter)?.first() - - return event?.content()?.let { UnsignedEvent.fromJson(it) } - } catch (e: Throwable) { - throw IllegalStateException("Failed to get cached rumor: ${e.message}", e) - } - } - - private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) { - try { - // Construct reference tags - val tags = listOf( - Tag.identifier(giftId.toHex()), - Tag.publicKey(rumor.author()), - Tag.custom("r", listOf(rumor.roomId().toString())), - Tag.custom("k", listOf("14")) - ) - - // Set event kind - val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA) - - // Construct event - val event = EventBuilder(kind, rumor.asJson()) - .tags(tags) - .finalizeAsync(Keys.generate()) - - client?.database()?.saveEvent(event) - } catch (e: Throwable) { - println("Failed to set cached rumor: ${e.message}") - } - } - - private suspend fun extractRumor(event: Event): UnsignedEvent? { - try { - // Gift wrap must have at least one 'p' tag - if (event.tags().publicKeys().isEmpty()) { - println("No recipient tags found.") - return null - } - - // Event must be a gift wrap - if (event.kind().asStd().let { it != KindStandard.GIFT_WRAP }) { - println("Event is not a gift wrap.") - return null - } - - // Check if the rumor is already cached - val cachedRumor = getCachedRumor(event.id()) - if (cachedRumor != null) return cachedRumor - - // Decrypt the gift wrap event - val seal = signer.nip44DecryptAsync(event.author(), event.content()) - val sealEvent = Event.fromJson(seal) - - // Verify seal event - if (!sealEvent.verify()) { - println("Failed to verify seal event.") - return null - } - - // Decrypt the rumor - val rumor = signer.nip44DecryptAsync(sealEvent.author(), sealEvent.content()) - val unsignedEvent = UnsignedEvent.fromJson(rumor).ensureId() - - // Ensure the rumor author matches the seal - if (unsignedEvent.author() != sealEvent.author()) { - println("Author mismatch.") - return null - } - - // Cache the rumor for later use - setCachedRumor(event.id(), unsignedEvent) - - return unsignedEvent - } catch (e: Throwable) { - println("Failed to unwrap gift ${event.id().toHex()}: ${e.message}") - return null - } - } - - private suspend fun getDefaultRelayList(): Map { - // Construct a list of relays - val relayList = mapOf( - RelayUrl.parse("wss://relay.damus.io") to RelayMetadata.READ, - RelayUrl.parse("wss://relay.primal.net") to RelayMetadata.READ, - RelayUrl.parse("wss://relay.nostr.net") to RelayMetadata.WRITE, - RelayUrl.parse("wss://nostr.superfriends.online") to RelayMetadata.WRITE - ) - - // Ensure all relays are added and connected - relayList.forEach { (relay, metadata) -> - client?.addRelay( - url = relay, - capabilities = - when (metadata) { - RelayMetadata.READ -> RelayCapabilities.read() - RelayMetadata.WRITE -> RelayCapabilities.write() - } - ) - client?.connectRelay(relay) - } - - return relayList - } - - suspend fun getDefaultMsgRelayList(): List { - // Construct a list of messaging relays - val msgRelayList = listOf( - RelayUrl.parse("wss://auth.nostr1.com"), - RelayUrl.parse("wss://nip17.com"), - ) - - // Ensure all relays are added and connected - msgRelayList.forEach { relay -> - client?.addRelay(relay, RelayCapabilities.none()) - client?.connectRelay(relay) - } - - return msgRelayList - } - - suspend fun createIdentity(keys: Keys, name: String, bio: String?, picture: String?) { - // Send relay list event - val relayList = getDefaultRelayList() - val relayListEvent = EventBuilder.relayList(relayList).finalizeAsync(keys) - - client?.sendEvent( - event = relayListEvent, - target = SendEventTarget.broadcast(), - ackPolicy = AckPolicy.all(), - okTimeout = Duration.parse("3s") - ) - - // Send messaging relay list event - val msgRelayList = getDefaultMsgRelayList() - val msgRelayListEvent = EventBuilder.nip17RelayList(msgRelayList).finalizeAsync(keys) - - client?.sendEvent( - event = msgRelayListEvent, - target = SendEventTarget.toNip65(), - ackPolicy = AckPolicy.none() - ) - - // Send metadata event - val metadata = - Metadata.fromRecord(MetadataRecord(displayName = name, about = bio, picture = picture)) - val metadataEvent = EventBuilder.metadata(metadata).finalizeAsync(keys) - - client?.sendEvent( - event = metadataEvent, - target = SendEventTarget.broadcast(), - ackPolicy = AckPolicy.none() - ) - - // Send contact list event - val defaultContact = - Contact(PublicKey.parse("npub1j3rz3ndl902lya6ywxvy5c983lxs8mpukqnx4pa4lt5wrykwl5ys7wpw3x")) - val contactListEvent = EventBuilder.contactList(listOf(defaultContact)).finalizeAsync(keys) - - client?.sendEvent( - event = contactListEvent, - target = SendEventTarget.toNip65(), - ackPolicy = AckPolicy.none() - ) - - setSigner(keys) - } - - suspend fun updateProfile( - name: String? = null, - bio: String? = null, - picture: String? = null - ): Metadata { - val currentUser = signer.currentUser ?: throw IllegalStateException("User not signed in") - - try { - val record = getLatestMetadata(currentUser)?.asRecord() ?: MetadataRecord() - val newRecord = record.copy( - displayName = name ?: record.displayName, - about = bio ?: record.about, - picture = picture ?: record.picture - ) - val newMetadata = Metadata.fromRecord(newRecord) - val event = EventBuilder.metadata(newMetadata).finalizeAsync(signer) - - client?.sendEvent( - event = event, - target = SendEventTarget.broadcast(), - ackPolicy = AckPolicy.none() - ) - - return newMetadata - } catch (e: Exception) { - throw IllegalStateException("Failed to update identity: ${e.message}", e) - } - } - - private suspend fun getLatestMetadata(pubkey: PublicKey): Metadata? { - return try { - val kind = Kind.fromStd(KindStandard.METADATA) - val filter = Filter().kind(kind).author(pubkey).limit(1u) - val event = client?.database()?.query(filter)?.first() ?: return null - - Metadata.fromJson(event.content()) - } catch (e: Exception) { - println("Failed to get latest metadata: ${e.message}") - null - } - } - - suspend fun getAllCacheMetadata(): Map { - try { - val filter = Filter().kind(Kind.fromStd(KindStandard.METADATA)).limit(100u) - val events = client?.database()?.query(filter) - val results = mutableMapOf() - - events?.toVec()?.forEach { event -> - try { - val metadata = Metadata.fromJson(event.content()) - results[event.author()] = metadata - } catch (e: Exception) { - println("Failed to parse metadata: $e") - } - } - - return results - } catch (e: Exception) { - println("Failed to get all cache metadata: ${e.message}") - return emptyMap() - } - } - - suspend fun fetchMetadataBatch(keys: List) { - try { - val limit = keys.size.toULong() * 2u - val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) - - // Construct a filter for metadata events - val filter = Filter() - .kind(Kind.fromStd(KindStandard.METADATA)) - .authors(keys) - .limit(limit) - - // Construct request target - val target = mutableMapOf>() - NostrManager.BOOTSTRAP_RELAYS.forEach { relay -> - target[RelayUrl.parse(relay)] = listOf(filter) - } - - client?.subscribe(target = ReqTarget.manual(target), closeOn = opts) - } catch (e: Exception) { - throw IllegalStateException("Failed to fetch metadata batch: ${e.message}", e) - } - } - - suspend fun setMsgRelays(urls: List) { - try { - - - val event = EventBuilder.nip17RelayList(urls).finalizeAsync(signer) - - client?.sendEvent( - event = event, - target = SendEventTarget.toNip65(), - ackPolicy = AckPolicy.none(), - ) - - val currentUser = - signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in") - - val kind = Kind.fromStd(KindStandard.INBOX_RELAYS) - val filter = Filter().kind(kind).author(currentUser).limit(1u) - - val target = ReqTarget.auto(listOf(filter)) - val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) - - client?.subscribe(target = target, closeOn = opts) - } catch (e: Exception) { - throw IllegalStateException("Failed to set msg relays: ${e.message}", e) - } - } - - suspend fun getMsgRelays(publicKey: PublicKey): List { - try { - val kind = Kind.fromStd(KindStandard.INBOX_RELAYS) - val filter = Filter().kind(kind).author(publicKey).limit(1u) - val events = client?.database()?.query(filter) - - return nip17ExtractRelayList(events?.toVec()?.firstOrNull() ?: return emptyList()) - } catch (e: Exception) { - throw IllegalStateException("Failed to get msg relays: ${e.message}", e) - } - } - - suspend fun fetchMsgRelays(publicKey: PublicKey): List { - try { - val kind = Kind.fromStd(KindStandard.INBOX_RELAYS) - val filter = Filter().kind(kind).author(publicKey).limit(1u) - val target = ReqTarget.auto(listOf(filter)) - val events = client?.fetchEvents(target, timeout = Duration.parse("3s")) - - return nip17ExtractRelayList(events?.toVec()?.firstOrNull() ?: return emptyList()) - } catch (e: Exception) { - throw IllegalStateException("Failed to fetch msg relays: ${e.message}", e) - } - } - - suspend fun getRelayList(publicKey: PublicKey): Map { - try { - val kind = Kind.fromStd(KindStandard.RELAY_LIST) - val filter = Filter().kind(kind).author(publicKey).limit(1u) - val events = client?.database()?.query(filter) - - return extractRelayList(events?.toVec()?.firstOrNull() ?: return emptyMap()) - } catch (e: Exception) { - throw IllegalStateException("Failed to get relay list: ${e.message}", e) - } - } - - suspend fun setRelaylist(relays: Map) { - try { - val event = EventBuilder.relayList(relays).finalizeAsync(signer) - - client?.sendEvent( - event = event, - target = SendEventTarget.broadcast(), - ackPolicy = AckPolicy.none(), - ) - } catch (e: Exception) { - throw IllegalStateException("Failed to set msg relays: ${e.message}", e) - } - } - - suspend fun setContactList(contacts: List) { - try { - val contacts = contacts.map { Contact(it) } - val event = EventBuilder.contactList(contacts).finalizeAsync(signer) - - client?.sendEvent( - event = event, - target = SendEventTarget.broadcast(), - ackPolicy = AckPolicy.none(), - ) - } catch (e: Exception) { - throw IllegalStateException("Failed to set contact list: ${e.message}", e) - } - } - - suspend fun getChatRooms(): Set? { - try { - val userPubkey = - signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in") - - val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA) - val kTag = SingleLetterTag.lowercase(Alphabet.K) - - // Get all DM events - val filter = Filter().kind(kind).customTags(kTag, listOf("14", "dm")) - val events = client?.database()?.query(filter) - - // Collect rooms - val roomsMap: MutableMap = mutableMapOf() - - events - ?.toVec() - ?.map { UnsignedEvent.fromJson(it.content()) } - ?.filter { it.tags().publicKeys().isNotEmpty() } - ?.forEach { event -> - val newRoom = Room.new(rumor = event, userPubkey = userPubkey) - val existingRoom = roomsMap[newRoom.id] - - // Check if the room already exists - if (existingRoom == null || newRoom.createdAt.asSecs() > existingRoom.createdAt.asSecs()) { - val rTag = SingleLetterTag.lowercase(Alphabet.R) - val filter = Filter().kind(kind).pubkey(userPubkey) - .customTag(rTag, newRoom.id.toString()) - - // Determine if it's an ongoing room - val isOngoing = - client?.database()?.query(filter)?.toVec()?.isNotEmpty() ?: false - - // Append room to map - roomsMap[newRoom.id] = - if (isOngoing) newRoom.copy(kind = RoomKind.Ongoing) else newRoom - } - } - - return roomsMap.values.sortedByDescending { it.createdAt.asSecs() }.toSet() - } catch (e: Exception) { - println("Failed to get chat rooms: ${e.message}") - return null - } - } - - suspend fun getChatRoomMessages(roomId: Long): List { - try { - val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA) - val filter = Filter().kind(kind).reference(roomId.toString()) - val events = client?.database()?.query(filter) - - // Merge the events - return events - ?.toVec() - ?.map { UnsignedEvent.fromJson(it.content()).ensureId() } - // Filter out events without public keys (receivers) - ?.filter { it.tags().publicKeys().isNotEmpty() } - ?.sortedByDescending { it.createdAt().asSecs() } ?: emptyList() - } catch (e: Exception) { - throw IllegalStateException("Failed to get chat room messages: ${e.message}", e) - } - } - - suspend fun chatRoomConnect(members: List) { - try { - members.forEach { member -> - val kind = Kind.fromStd(KindStandard.INBOX_RELAYS) - val filter = Filter().kind(kind).author(member).limit(1u) - - val stream = client?.streamEvents( - target = ReqTarget.auto(listOf(filter)), - id = null, - timeout = Duration.parse("3s"), - policy = ReqExitPolicy.ExitOnEose - ) - - stream?.next()?.let { res -> - val event = res.event ?: return@let - connectMsgRelays(event) - } - } - } catch (e: Exception) { - throw IllegalStateException("Failed to fetch relays: ${e.message}", e) - } - } - - suspend fun connectMsgRelays(event: Event) { - try { - val urls = nip17ExtractRelayList(event) - for (url in urls) { - client?.addRelay(url, RelayCapabilities.gossip()) - client?.connectRelay(url) - } - } catch (e: Exception) { - throw IllegalStateException("Failed to connect to relays: ${e.message}", e) - } - } - - suspend fun sendMessage( - to: Set, - content: String, - subject: String? = null, - replies: List = emptyList(), - onRumorCreated: ((UnsignedEvent) -> Unit)? = null, - ) { - try { - val currentUser = - signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in") - - val tags = mutableListOf() - - // Add a subject tag if provided - if (subject != null) { - tags.add(Tag.custom("subject", listOf(subject))) - } - - // Add event tags for replies - if (replies.isNotEmpty()) { - replies.forEach { replyId -> - tags.add(Tag.event(replyId)) - } - } - - // Add public key tags for each recipient - to.forEach { pubkey -> - tags.add(Tag.publicKey(pubkey)) - } - - for (receiver in setOf(currentUser) + to) { - // Construct the rumor event - // NEVER SIGN this event with the current user signer - val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), content) - .tags(tags) - .finalizeUnsigned(currentUser) - .ensureId() - - // Emit the rumor to the chat screen - if (receiver == currentUser) { - onRumorCreated?.invoke(rumor) - } - - // Construct the gift wrap event - val gift = nip59MakeGiftWrapAsync( - signer = signer, - receiverPubkey = receiver, - rumor = rumor, - extraTags = listOf( - Tag.custom("k", listOf("14")) - ) - ) - - // Send the event to receiver's NIP-17 relays - val output = client?.sendEvent( - event = gift, - target = SendEventTarget.toNip17(), - ackPolicy = AckPolicy.none(), - authenticationTimeout = Duration.parse("2s") - ) - - if (output != null) { - // Keep track of sent events - sentEvents[output.id] = emptyList() - - // Keep track of rumor IDs - val id = rumor.id() ?: throw IllegalStateException("Rumor ID is null") - rumorMap[id] = output.id - - // Collect failed outputs - output.failed.forEach { (relayUrl, reason) -> - println("Failed to send event to relay $relayUrl: $reason") - } - } - } - } catch (e: Exception) { - throw IllegalStateException("Failed to send message: ${e.message}", e) - } - } - - suspend fun profileFromAddress(client: HttpClient, address: Nip05Address): Nip05Profile { - try { - val response: HttpResponse = client.get(address.url()) - val bodyString: String = response.body() - - return Nip05Profile.fromJson(address, bodyString) - } catch (e: Exception) { - throw IllegalStateException("Failed to fetch profile from address: ${e.message}", e) - } - } - - suspend fun searchByAddress(query: String): PublicKey { - try { - val address = Nip05Address.parse(query) - val profile = profileFromAddress(HttpClient(), address) - - return profile.publicKey() - } catch (e: Exception) { - throw IllegalStateException("Failed to search address: ${e.message}", e) - } - } - - suspend fun searchByNostr(query: String): List { - try { - // Add search relay - val searchRelay = RelayUrl.parse("wss://antiprimal.net") - if (client?.relay(searchRelay) == null) { - client?.addRelay(url = searchRelay, capabilities = RelayCapabilities.read()) - client?.connectRelay(searchRelay) - } - - val kinds = listOf(Kind.fromStd(KindStandard.METADATA)) - val filter = Filter().kinds(kinds).search(query).limit(10u) - val target = ReqTarget.manual(mapOf(searchRelay to listOf(filter))) - - val stream = client?.streamEvents( - target = target, - id = "search", - timeout = Duration.parse("3s"), - policy = ReqExitPolicy.ExitOnEose - ) - - // Collect the results - val results = mutableListOf() - - // Keep searching until the stream is closed or timeout - stream?.next()?.let { event -> - val event = event.event ?: return@let - results.add(event.author()) - } - - return results - } catch (e: Exception) { - throw IllegalStateException("Failed to search nostr: ${e.message}", e) - } - } - - suspend fun verifyActivity(pubkey: PublicKey): Timestamp? { - try { - val filter = Filter().author(pubkey).limit(3u) - val target = mutableMapOf>() - NostrManager.BOOTSTRAP_RELAYS.forEach { relay -> - target[RelayUrl.parse(relay)] = listOf(filter) - } - - val events = client?.fetchEvents( - target = ReqTarget.manual(target), - timeout = Duration.parse("3s") - ) - - return events?.first()?.createdAt() - } catch (e: Exception) { - throw IllegalStateException("Failed to get latest activity: ${e.message}", e) - } - } - - suspend fun verifyContact(pubkey: PublicKey): Boolean { - try { - val currentUser = - signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in") - - val kind = Kind.fromStd(KindStandard.CONTACT_LIST) - val filter = Filter().kind(kind).author(currentUser).limit(1u) - - val events = client?.database()?.query(filter) - val pubkeys = events?.first()?.tags()?.publicKeys() ?: listOf() - - return pubkeys.contains(pubkey) - } catch (e: Exception) { - throw IllegalStateException("Failed to get mutual contacts: ${e.message}", e) - } - } - - suspend fun mutualContacts(pubkey: PublicKey): Set { - try { - val currentUser = - signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in") - - val kind = Kind.fromStd(KindStandard.CONTACT_LIST) - val filter = Filter().kind(kind).pubkey(pubkey).limit(1u) - - val events = client?.database()?.query(filter) - val contacts = mutableSetOf() - - events?.toVec()?.filter { it.author() != currentUser }?.forEach { event -> - contacts.add(event.author()) - } - - return contacts.toSet() - } catch (e: Exception) { - throw IllegalStateException("Failed to get mutual contacts: ${e.message}", e) - } - } -} - -data class MessageSyncState( - val processedCount: Int = 0, - val isSyncing: Boolean = false -) diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index 87e01a5..0452ce6 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -41,6 +41,7 @@ import rust.nostr.sdk.Tag import rust.nostr.sdk.Timestamp import rust.nostr.sdk.UnsignedEvent import su.reya.coop.blossom.BlossomClient +import su.reya.coop.nostr.Nostr import su.reya.coop.storage.SecretStorage import kotlin.time.Clock import kotlin.time.Duration.Companion.milliseconds @@ -87,7 +88,7 @@ class NostrViewModel( private val seenPublicKeys = mutableSetOf() - val isSyncing = nostr.messageSyncState.map { it.isSyncing } + val isSyncing = nostr.messages.messageSyncState.map { it.isSyncing } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) init { @@ -157,7 +158,7 @@ class NostrViewModel( private suspend fun runObserver() = coroutineScope { // Observe message sync progress launch { - nostr.messageSyncState.collect { state -> + nostr.messages.messageSyncState.collect { state -> // When at least some messages are processed, allow UI to show the list if (state.processedCount > 0) { _isPartialProcessedGiftWrap.value = true @@ -192,14 +193,14 @@ class NostrViewModel( // Observe contact list updates launch { - nostr.contactListUpdates.collect { contacts -> + nostr.profiles.contactListUpdates.collect { contacts -> _contactList.value = contacts.toSet() } } // Observe metadata updates launch { - nostr.metadataUpdates.collect { (pubkey, metadata) -> + nostr.profiles.metadataUpdates.collect { (pubkey, metadata) -> updateMetadata(pubkey, metadata) } } @@ -237,7 +238,7 @@ class NostrViewModel( val keysToRequest = batch.toList() batch.clear() - nostr.fetchMetadataBatch(keysToRequest) + nostr.profiles.fetchMetadataBatch(keysToRequest) } } } @@ -248,7 +249,7 @@ class NostrViewModel( // Wait until the client is ready nostr.waitUntilInitialized() - val results = nostr.getAllCacheMetadata() + val results = nostr.profiles.getAllCacheMetadata() results.forEach { (pubkey, metadata) -> // Update the metadata state updateMetadata(pubkey, metadata) @@ -293,20 +294,20 @@ class NostrViewModel( if (pubkey != null) { // Get chat rooms - val rooms = nostr.getChatRooms() ?: emptySet() + val rooms = nostr.messages.getChatRooms() ?: emptySet() if (rooms.isNotEmpty()) { mergeChatRooms(rooms) _isPartialProcessedGiftWrap.value = true } // Get all metadata for the current user - nostr.getUserMetadata() + nostr.profiles.getUserMetadata() // Small delay to ensure all relays are connected delay(2.seconds) // Check if the relay list is empty - val relays = nostr.getMsgRelays(pubkey) + val relays = nostr.relays.getMsgRelays(pubkey) if (relays.isEmpty()) _isRelayListEmpty.value = true break @@ -435,7 +436,7 @@ class NostrViewModel( _isBusy.value = true try { val avatarUrl = picture?.let { blossomUpload(it, contentType ?: "image/jpeg") } - val newMetadata = nostr.updateProfile(name, bio, avatarUrl) + val newMetadata = nostr.profiles.updateProfile(name, bio, avatarUrl) val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") // Update the metadata state after successfully published updateMetadata(currentUser, newMetadata) @@ -460,7 +461,7 @@ class NostrViewModel( try { val avatarUrl = picture?.let { blossomUpload(it, contentType ?: "image/jpeg") } // Create identity - nostr.createIdentity(keys = keys, name = name, bio, picture = avatarUrl) + nostr.profiles.createIdentity(keys = keys, name = name, bio, picture = avatarUrl) // Persist the secret in the secret storage secretStore.set("user_signer", secret) // Update local states @@ -567,14 +568,14 @@ class NostrViewModel( } suspend fun refetchMsgRelays(pubkey: PublicKey) { - val relays = nostr.fetchMsgRelays(pubkey) + val relays = nostr.relays.fetchMsgRelays(pubkey) if (relays.isNotEmpty()) dismissRelayWarning() } suspend fun useDefaultMsgRelayList() { try { - val defaultRelays = nostr.getDefaultMsgRelayList() - nostr.setMsgRelays(defaultRelays) + val defaultRelays = nostr.relays.getDefaultMsgRelayList() + nostr.relays.setMsgRelays(defaultRelays) } catch (e: Exception) { showError("Error: ${e.message}") } @@ -583,7 +584,7 @@ class NostrViewModel( suspend fun currentUserRelayList(): Map { try { val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") - return nostr.getRelayList(currentUser) + return nostr.relays.getRelayList(currentUser) } catch (e: Exception) { showError("Error: ${e.message}") return emptyMap() @@ -596,7 +597,7 @@ class NostrViewModel( val relays = currentUserRelayList().toMutableMap() relays[relayUrl] = RelayMetadata.WRITE - nostr.setRelaylist(relays) + nostr.relays.setRelaylist(relays) } catch (e: Exception) { showError("Error: ${e.message}") } @@ -608,7 +609,7 @@ class NostrViewModel( val relays = currentUserRelayList().toMutableMap() relays[relayUrl] = RelayMetadata.READ - nostr.setRelaylist(relays) + nostr.relays.setRelaylist(relays) } catch (e: Exception) { showError("Error: ${e.message}") } @@ -620,7 +621,7 @@ class NostrViewModel( val relays = currentUserRelayList().toMutableMap() relays.remove(relayUrl) - nostr.setRelaylist(relays) + nostr.relays.setRelaylist(relays) } catch (e: Exception) { showError("Error: ${e.message}") } @@ -629,7 +630,7 @@ class NostrViewModel( suspend fun currentUserMsgRelayList(): List { try { val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") - return nostr.getMsgRelays(currentUser) + return nostr.relays.getMsgRelays(currentUser) } catch (e: Exception) { showError("Error: ${e.message}") return emptyList() @@ -642,7 +643,7 @@ class NostrViewModel( val relays = currentUserMsgRelayList().toMutableSet() relays.add(relayUrl) - nostr.setMsgRelays(relays.toList()) + nostr.relays.setMsgRelays(relays.toList()) } catch (e: Exception) { showError("Error: ${e.message}") } @@ -654,7 +655,7 @@ class NostrViewModel( val relays = currentUserMsgRelayList().toMutableSet() relays.remove(relayUrl) - nostr.setMsgRelays(relays.toList()) + nostr.relays.setMsgRelays(relays.toList()) } catch (e: Exception) { showError("Error: ${e.message}") } @@ -666,7 +667,7 @@ class NostrViewModel( try { val updated = contactList.value + publicKey // Publish new event - nostr.setContactList(updated.toList()) + nostr.profiles.setContactList(updated.toList()) // Optimistic local update _contactList.update { it + publicKey } } catch (e: Exception) { @@ -677,7 +678,7 @@ class NostrViewModel( suspend fun addContact(address: String): Boolean { val pubkey = try { if (address.contains("@")) { - nostr.searchByAddress(address) + nostr.profiles.searchByAddress(address) } else { PublicKey.parse(address) } @@ -699,7 +700,7 @@ class NostrViewModel( try { val updated = contactList.value - publicKey // Publish new event - nostr.setContactList(updated.toList()) + nostr.profiles.setContactList(updated.toList()) // Optimistic local update _contactList.update { it - publicKey } } catch (e: Exception) { @@ -761,14 +762,14 @@ class NostrViewModel( fun getChatRooms() { viewModelScope.launch { - val rooms = nostr.getChatRooms() ?: emptySet() + val rooms = nostr.messages.getChatRooms() ?: emptySet() mergeChatRooms(rooms) } } suspend fun refreshChatRooms() { try { - val rooms = nostr.getChatRooms() ?: emptySet() + val rooms = nostr.messages.getChatRooms() ?: emptySet() mergeChatRooms(rooms) } catch (e: Exception) { showError("Error: ${e.message}") @@ -777,7 +778,7 @@ class NostrViewModel( suspend fun getChatRoomMessages(roomId: Long): List { try { - return nostr.getChatRoomMessages(roomId) + return nostr.messages.getChatRoomMessages(roomId) } catch (e: Exception) { showError("Error: ${e.message}") } @@ -791,7 +792,7 @@ class NostrViewModel( val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found") val members = room.members - nostr.chatRoomConnect(members.toList()) + nostr.messages.chatRoomConnect(members.toList()) } catch (e: Exception) { showError("Error: ${e.message}") } @@ -805,7 +806,7 @@ class NostrViewModel( viewModelScope.launch { try { val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found") - nostr.sendMessage( + nostr.messages.sendMessage( to = room.members, content = message, subject = room.subject, @@ -822,10 +823,10 @@ class NostrViewModel( } fun isMessageSent(id: EventId): Boolean { - val giftWrapId = nostr.rumorMap[id] + val giftWrapId = nostr.messages.rumorMap[id] if (giftWrapId != null) { - val isSent = nostr.sentEvents[giftWrapId]?.isNotEmpty() ?: false + val isSent = nostr.messages.sentEvents[giftWrapId]?.isNotEmpty() ?: false return isSent } else { return false @@ -849,7 +850,7 @@ class NostrViewModel( suspend fun searchByAddress(query: String): PublicKey? { try { - return nostr.searchByAddress(query) + return nostr.profiles.searchByAddress(query) } catch (e: Exception) { showError("Error: ${e.message}") } @@ -858,7 +859,7 @@ class NostrViewModel( suspend fun searchByNostr(query: String): List { try { - return nostr.searchByNostr(query) + return nostr.profiles.searchByNostr(query) } catch (e: Exception) { showError("Error: ${e.message}") } @@ -867,7 +868,7 @@ class NostrViewModel( suspend fun verifyActivity(pubkey: PublicKey): Timestamp? { return try { - nostr.verifyActivity(pubkey) + nostr.profiles.verifyActivity(pubkey) } catch (e: Exception) { showError("Error: ${e.message}") null @@ -876,7 +877,7 @@ class NostrViewModel( suspend fun verifyContact(pubkey: PublicKey): Boolean { return try { - nostr.verifyContact(pubkey) + nostr.profiles.verifyContact(pubkey) } catch (e: Exception) { showError("Error: ${e.message}") false @@ -885,7 +886,7 @@ class NostrViewModel( suspend fun mutualContacts(pubkey: PublicKey): Set { return try { - nostr.mutualContacts(pubkey) + nostr.profiles.mutualContacts(pubkey) } catch (e: Exception) { showError("Error: ${e.message}") setOf() diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt new file mode 100644 index 0000000..f4a089f --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt @@ -0,0 +1,340 @@ +package su.reya.coop.nostr + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import rust.nostr.sdk.AckPolicy +import rust.nostr.sdk.Alphabet +import rust.nostr.sdk.Client +import rust.nostr.sdk.Event +import rust.nostr.sdk.EventBuilder +import rust.nostr.sdk.EventId +import rust.nostr.sdk.Filter +import rust.nostr.sdk.Keys +import rust.nostr.sdk.Kind +import rust.nostr.sdk.KindStandard +import rust.nostr.sdk.PublicKey +import rust.nostr.sdk.RelayCapabilities +import rust.nostr.sdk.RelayUrl +import rust.nostr.sdk.ReqExitPolicy +import rust.nostr.sdk.ReqTarget +import rust.nostr.sdk.SendEventTarget +import rust.nostr.sdk.SingleLetterTag +import rust.nostr.sdk.Tag +import rust.nostr.sdk.UnsignedEvent +import rust.nostr.sdk.nip17ExtractRelayList +import rust.nostr.sdk.nip59MakeGiftWrapAsync +import su.reya.coop.Room +import su.reya.coop.RoomKind +import su.reya.coop.UniversalSigner +import su.reya.coop.roomId +import kotlin.time.Duration + +data class MessageSyncState( + val processedCount: Int = 0, + val isSyncing: Boolean = false +) + +class MessageManager(private val nostr: Nostr) { + private val client: Client? get() = nostr.client + private val signer: UniversalSigner get() = nostr.signer + + val sentEvents: MutableMap> = mutableMapOf() + val rumorMap: MutableMap = mutableMapOf() + + private val _messageSyncState = MutableStateFlow(MessageSyncState()) + val messageSyncState = _messageSyncState.asStateFlow() + + fun updateSyncState(update: (MessageSyncState) -> MessageSyncState) { + _messageSyncState.update(update) + } + + suspend fun getUserMessages(msgRelayList: Event) { + try { + val author = signer.currentUser ?: throw IllegalStateException("User not signed in") + val relays = nip17ExtractRelayList(msgRelayList) + + // Ensure relay connections + relays.forEach { relay -> + client?.addRelay(relay) + client?.connectRelay(relay) + } + + // Construct a filter for gift wrap events + val filter = Filter().kind(Kind.fromStd(KindStandard.GIFT_WRAP)).pubkey(author) + val target = mutableMapOf>() + relays.forEach { relay -> + target[relay] = listOf(filter) + } + + client?.subscribe( + target = ReqTarget.manual(target), + id = "gift-wraps" + ) + } catch (e: Exception) { + throw IllegalStateException("Failed to fetch user messages: ${e.message}", e) + } + } + + suspend fun extractRumor(event: Event): UnsignedEvent? { + try { + // Gift wrap must have at least one 'p' tag + if (event.tags().publicKeys().isEmpty()) { + println("No recipient tags found.") + return null + } + + // Event must be a gift wrap + if (event.kind().asStd().let { it != KindStandard.GIFT_WRAP }) { + println("Event is not a gift wrap.") + return null + } + + // Check if the rumor is already cached + val cachedRumor = getCachedRumor(event.id()) + if (cachedRumor != null) return cachedRumor + + // Decrypt the gift wrap event + val seal = signer.nip44DecryptAsync(event.author(), event.content()) + val sealEvent = Event.fromJson(seal) + + // Verify seal event + if (!sealEvent.verify()) { + println("Failed to verify seal event.") + return null + } + + // Decrypt the rumor + val rumor = signer.nip44DecryptAsync(sealEvent.author(), sealEvent.content()) + val unsignedEvent = UnsignedEvent.fromJson(rumor).ensureId() + + // Ensure the rumor author matches the seal + if (unsignedEvent.author() != sealEvent.author()) { + println("Author mismatch.") + return null + } + + // Cache the rumor for later use + setCachedRumor(event.id(), unsignedEvent) + + return unsignedEvent + } catch (e: Throwable) { + println("Failed to unwrap gift ${event.id().toHex()}: ${e.message}") + return null + } + } + + private suspend fun getCachedRumor(giftId: EventId): UnsignedEvent? { + try { + val filter = Filter().identifier(giftId.toHex()) + val event = client?.database()?.query(filter)?.first() + + return event?.content()?.let { UnsignedEvent.fromJson(it) } + } catch (e: Throwable) { + throw IllegalStateException("Failed to get cached rumor: ${e.message}", e) + } + } + + private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) { + try { + // Construct reference tags + val tags = listOf( + Tag.identifier(giftId.toHex()), + Tag.publicKey(rumor.author()), + Tag.custom("r", listOf(rumor.roomId().toString())), + Tag.custom("k", listOf("14")) + ) + + // Set event kind + val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA) + + // Construct event + val event = EventBuilder(kind, rumor.asJson()) + .tags(tags) + .finalizeAsync(Keys.generate()) + + client?.database()?.saveEvent(event) + } catch (e: Throwable) { + println("Failed to set cached rumor: ${e.message}") + } + } + + suspend fun getChatRooms(): Set? { + try { + val userPubkey = + signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in") + + val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA) + val kTag = SingleLetterTag.lowercase(Alphabet.K) + + // Get all DM events + val filter = Filter().kind(kind).customTags(kTag, listOf("14", "dm")) + val events = client?.database()?.query(filter) + + // Collect rooms + val roomsMap: MutableMap = mutableMapOf() + + events + ?.toVec() + ?.map { UnsignedEvent.fromJson(it.content()) } + ?.filter { it.tags().publicKeys().isNotEmpty() } + ?.forEach { event -> + val newRoom = Room.new(rumor = event, userPubkey = userPubkey) + val existingRoom = roomsMap[newRoom.id] + + // Check if the room already exists + if (existingRoom == null || newRoom.createdAt.asSecs() > existingRoom.createdAt.asSecs()) { + val rTag = SingleLetterTag.lowercase(Alphabet.R) + val filter = Filter().kind(kind).pubkey(userPubkey) + .customTag(rTag, newRoom.id.toString()) + + // Determine if it's an ongoing room + val isOngoing = + client?.database()?.query(filter)?.toVec()?.isNotEmpty() ?: false + + // Append room to map + roomsMap[newRoom.id] = + if (isOngoing) newRoom.copy(kind = RoomKind.Ongoing) else newRoom + } + } + + return roomsMap.values.sortedByDescending { it.createdAt.asSecs() }.toSet() + } catch (e: Exception) { + println("Failed to get chat rooms: ${e.message}") + return null + } + } + + suspend fun getChatRoomMessages(roomId: Long): List { + try { + val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA) + val filter = Filter().kind(kind).reference(roomId.toString()) + val events = client?.database()?.query(filter) + + // Merge the events + return events + ?.toVec() + ?.map { UnsignedEvent.fromJson(it.content()).ensureId() } + // Filter out events without public keys (receivers) + ?.filter { it.tags().publicKeys().isNotEmpty() } + ?.sortedByDescending { it.createdAt().asSecs() } ?: emptyList() + } catch (e: Exception) { + throw IllegalStateException("Failed to get chat room messages: ${e.message}", e) + } + } + + suspend fun chatRoomConnect(members: List) { + try { + members.forEach { member -> + val kind = Kind.fromStd(KindStandard.INBOX_RELAYS) + val filter = Filter().kind(kind).author(member).limit(1u) + + val stream = client?.streamEvents( + target = ReqTarget.auto(listOf(filter)), + id = null, + timeout = Duration.parse("3s"), + policy = ReqExitPolicy.ExitOnEose + ) + + stream?.next()?.let { res -> + val event = res.event ?: return@let + connectMsgRelays(event) + } + } + } catch (e: Exception) { + throw IllegalStateException("Failed to fetch relays: ${e.message}", e) + } + } + + suspend fun connectMsgRelays(event: Event) { + try { + val urls = nip17ExtractRelayList(event) + for (url in urls) { + client?.addRelay(url, RelayCapabilities.gossip()) + client?.connectRelay(url) + } + } catch (e: Exception) { + throw IllegalStateException("Failed to connect to relays: ${e.message}", e) + } + } + + suspend fun sendMessage( + to: Set, + content: String, + subject: String? = null, + replies: List = emptyList(), + onRumorCreated: ((UnsignedEvent) -> Unit)? = null, + ) { + try { + val currentUser = + signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in") + + val tags = mutableListOf() + + // Add a subject tag if provided + if (subject != null) { + tags.add(Tag.custom("subject", listOf(subject))) + } + + // Add event tags for replies + if (replies.isNotEmpty()) { + replies.forEach { replyId -> + tags.add(Tag.event(replyId)) + } + } + + // Add public key tags for each recipient + to.forEach { pubkey -> + tags.add(Tag.publicKey(pubkey)) + } + + for (receiver in setOf(currentUser) + to) { + // Construct the rumor event + // NEVER SIGN this event with the current user signer + val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), content) + .tags(tags) + .finalizeUnsigned(currentUser) + .ensureId() + + // Emit the rumor to the chat screen + if (receiver == currentUser) { + onRumorCreated?.invoke(rumor) + } + + // Construct the gift wrap event + val gift = nip59MakeGiftWrapAsync( + signer = signer, + receiverPubkey = receiver, + rumor = rumor, + extraTags = listOf( + Tag.custom("k", listOf("14")) + ) + ) + + // Send the event to receiver's NIP-17 relays + val output = client?.sendEvent( + event = gift, + target = SendEventTarget.toNip17(), + ackPolicy = AckPolicy.none(), + authenticationTimeout = Duration.parse("2s") + ) + + if (output != null) { + // Keep track of sent events + sentEvents[output.id] = emptyList() + + // Keep track of rumor IDs + val id = rumor.id() ?: throw IllegalStateException("Rumor ID is null") + rumorMap[id] = output.id + + // Collect failed outputs + output.failed.forEach { (relayUrl, reason) -> + println("Failed to send event to relay $relayUrl: $reason") + } + } + } + } catch (e: Exception) { + throw IllegalStateException("Failed to send message: ${e.message}", e) + } + } +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt new file mode 100644 index 0000000..705d287 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt @@ -0,0 +1,266 @@ +package su.reya.coop.nostr + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope +import rust.nostr.sdk.AsyncNostrSigner +import rust.nostr.sdk.Client +import rust.nostr.sdk.ClientBuilder +import rust.nostr.sdk.ClientNotification +import rust.nostr.sdk.Event +import rust.nostr.sdk.EventId +import rust.nostr.sdk.GossipConfig +import rust.nostr.sdk.Keys +import rust.nostr.sdk.KindStandard +import rust.nostr.sdk.LogLevel +import rust.nostr.sdk.Metadata +import rust.nostr.sdk.NostrDatabase +import rust.nostr.sdk.NostrGossip +import rust.nostr.sdk.PublicKey +import rust.nostr.sdk.RelayMessageEnum +import rust.nostr.sdk.SignerAuthenticator +import rust.nostr.sdk.SleepWhenIdle +import rust.nostr.sdk.Timestamp +import rust.nostr.sdk.UnsignedEvent +import rust.nostr.sdk.initLogger +import su.reya.coop.UniversalSigner +import kotlin.time.Duration + +object NostrManager { + val instance = Nostr() + + val BOOTSTRAP_RELAYS = listOf( + "wss://relay.primal.net", + "wss://relay.ditto.pub", + "wss://user.kindpag.es", + ) + + val INDEXER_RELAY = listOf( + "wss://indexer.coracle.social", + ) + + val ALL_RELAYS = BOOTSTRAP_RELAYS + INDEXER_RELAY +} + +class Nostr { + var client: Client? = null + private set + var signer: UniversalSigner = UniversalSigner(Keys.generate()) + private set + + val messages = MessageManager(this) + val profiles = ProfileManager(this) + val relays = RelayManager(this) + + private val isInitialized = MutableStateFlow(false) + + private val _newEvents = MutableSharedFlow(extraBufferCapacity = 100) + val newEvents = _newEvents.asSharedFlow() + + suspend fun emitNewEvent(event: UnsignedEvent) { + _newEvents.emit(event) + } + + suspend fun init( + dbPath: String, + logLevel: LogLevel = LogLevel.WARN + ) { + try { + if (isInitialized.value) return + + // Initialize the logger for nostr client + initLogger(logLevel) + + // Initialize configurations for nostr client + val lmdb = NostrDatabase.lmdb(dbPath) + val gossip = NostrGossip.inMemory() + val authenticator = SignerAuthenticator(signer) + val idleTimeout = Duration.parse("5m") + + client = + ClientBuilder() + .authenticator(authenticator) + .database(lmdb) + .gossip(gossip) + .gossipConfig( + GossipConfig() + .noBackgroundRefresh() + .fetchTimeout(Duration.parse("2s")) + ) + .verifySubscriptions(false) + .sleepWhenIdle(SleepWhenIdle.Enabled(idleTimeout)) + .build() + + isInitialized.value = true + } catch (e: Exception) { + throw IllegalStateException("Failed to initialize Nostr client: ${e.message}", e) + } + } + + suspend fun waitUntilInitialized() { + isInitialized.first { it } + } + + suspend fun connectBootstrapRelays() { + relays.connectBootstrapRelays() + } + + suspend fun reconnect() { + relays.reconnect() + } + + suspend fun disconnect() { + relays.disconnect() + } + + suspend fun prune() { + try { + client?.database()?.wipe() + } catch (e: Exception) { + throw IllegalStateException("Failed to prune database: ${e.message}", e) + } + } + + suspend fun setSigner(new: AsyncNostrSigner) { + try { + signer.switch(new) + } catch (e: Exception) { + throw IllegalStateException("Failed to set signer: ${e.message}", e) + } + } + + fun isSignedByUser(event: Event): Boolean { + return try { + signer.currentUser == event.author() + } catch (e: Exception) { + println("Failed to check if event is signed by user: ${e.message}") + false + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + suspend fun handleNotifications( + onMetadataUpdate: (PublicKey, Metadata) -> Unit, + onContactListUpdate: (List) -> Unit, + onNewMessage: (UnsignedEvent) -> Unit, + ) = supervisorScope { + val now = Timestamp.now() + val processedEvent = mutableSetOf() + val notifications = client?.notifications() ?: return@supervisorScope + + val giftWrapQueue = Channel(Channel.UNLIMITED) + var processedCount = 0 + var eoseReceived = false + + launch(Dispatchers.Default) { + for (event in giftWrapQueue) { + val rumor = messages.extractRumor(event) + processedCount++ + + // Trigger new message notification + if (rumor != null) { + if (rumor.createdAt().asSecs() >= now.asSecs()) { + onNewMessage(rumor) + } + } + + // Update sync state + messages.updateSyncState { + it.copy( + processedCount = processedCount, + isSyncing = !eoseReceived || !giftWrapQueue.isEmpty + ) + } + } + } + + while (true) { + val notification = notifications.next() ?: continue + + when (notification) { + is ClientNotification.Message -> { + val relayUrl = notification.relayUrl + + when (val message = notification.message.asEnum()) { + is RelayMessageEnum.EventMsg -> { + val event = message.event + + // Prevent processing duplicate events + if (processedEvent.contains(event.id())) continue + processedEvent.add(event.id()) + + when (event.kind().asStd()) { + KindStandard.METADATA -> { + try { + val metadata = Metadata.fromJson(event.content()) + onMetadataUpdate(event.author(), metadata) + } catch (e: Exception) { + println("Failed to parse metadata: $e") + } + } + + KindStandard.CONTACT_LIST -> { + if (isSignedByUser(event = event)) { + val pubkeys = event.tags().publicKeys() + // Get mutual contacts + profiles.getMutualContacts(pubkeys) + // Emit contact list update + onContactListUpdate(pubkeys) + } + } + + KindStandard.INBOX_RELAYS -> { + // Get all gift wrap events for the current user + if (isSignedByUser(event = event)) { + messages.getUserMessages(msgRelayList = event) + } + } + + KindStandard.GIFT_WRAP -> { + giftWrapQueue.send(event) + } + + else -> {} + } + } + + is RelayMessageEnum.EndOfStoredEvents -> { + if (message.subscriptionId == "gift-wraps") { + eoseReceived = true + if (giftWrapQueue.isEmpty) { + messages.updateSyncState { it.copy(isSyncing = false) } + } + } + } + + is RelayMessageEnum.Ok -> { + if (messages.sentEvents.containsKey(message.eventId)) { + val currentRelays = + messages.sentEvents[message.eventId] ?: emptyList() + messages.sentEvents[message.eventId] = currentRelays + relayUrl + } + } + + else -> { + /* Ignore other message types */ + } + } + } + + is ClientNotification.Shutdown -> { + break + } + + else -> { + /* Ignore other message types */ + } + } + } + } +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt new file mode 100644 index 0000000..a1cf522 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt @@ -0,0 +1,360 @@ +package su.reya.coop.nostr + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.statement.HttpResponse +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import rust.nostr.sdk.AckPolicy +import rust.nostr.sdk.Client +import rust.nostr.sdk.Contact +import rust.nostr.sdk.EventBuilder +import rust.nostr.sdk.Filter +import rust.nostr.sdk.Keys +import rust.nostr.sdk.Kind +import rust.nostr.sdk.KindStandard +import rust.nostr.sdk.Metadata +import rust.nostr.sdk.MetadataRecord +import rust.nostr.sdk.Nip05Address +import rust.nostr.sdk.Nip05Profile +import rust.nostr.sdk.PublicKey +import rust.nostr.sdk.RelayCapabilities +import rust.nostr.sdk.RelayUrl +import rust.nostr.sdk.ReqExitPolicy +import rust.nostr.sdk.ReqTarget +import rust.nostr.sdk.SendEventTarget +import rust.nostr.sdk.SubscribeAutoCloseOptions +import rust.nostr.sdk.Timestamp +import su.reya.coop.UniversalSigner +import kotlin.time.Duration + +class ProfileManager(private val nostr: Nostr) { + private val client: Client? get() = nostr.client + private val signer: UniversalSigner get() = nostr.signer + + private val _metadataUpdates = + MutableSharedFlow>(extraBufferCapacity = 100) + val metadataUpdates = _metadataUpdates.asSharedFlow() + + private val _contactListUpdates = MutableSharedFlow>(extraBufferCapacity = 100) + val contactListUpdates = _contactListUpdates.asSharedFlow() + + suspend fun emitMetadataUpdate(pubkey: PublicKey, metadata: Metadata) { + _metadataUpdates.emit(pubkey to metadata) + } + + suspend fun emitContactListUpdate(contacts: List) { + _contactListUpdates.emit(contacts) + } + + suspend fun getUserMetadata() { + try { + val author = signer.currentUser ?: throw IllegalStateException("User not signed in") + + // Get the latest metadata event + val metadataFilter = + Filter().kind(Kind.fromStd(KindStandard.METADATA)).author(author).limit(1u) + + // Get the latest contact list event + val contactFilter = + Filter().kind(Kind.fromStd(KindStandard.CONTACT_LIST)).author(author).limit(1u) + + // Get the latest messaging relay list event + val msgRelayFilter = + Filter().kind(Kind.fromStd(KindStandard.INBOX_RELAYS)).author(author).limit(1u) + + // Construct a target that includes all filters + val target = ReqTarget.auto(listOf(metadataFilter, contactFilter, msgRelayFilter)) + val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) + + client?.subscribe(target = target, id = "user-metadata", closeOn = opts) + } catch (e: Exception) { + throw IllegalStateException("Failed to fetch user metadata: ${e.message}", e) + } + } + + suspend fun getMutualContacts(pubkeys: List) { + try { + val kind = Kind.fromStd(KindStandard.CONTACT_LIST) + val filter = Filter().kind(kind).authors(pubkeys).limit(pubkeys.size.toULong()) + val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) + + val target = mutableMapOf>() + NostrManager.BOOTSTRAP_RELAYS.forEach { relay -> + target[RelayUrl.parse(relay)] = listOf(filter) + } + + client?.subscribe( + target = ReqTarget.manual(target), + id = "mutual-contacts", + closeOn = opts, + ) + } catch (e: Exception) { + throw IllegalStateException("Failed to fetch mutual contacts: ${e.message}", e) + } + } + + suspend fun createIdentity(keys: Keys, name: String, bio: String?, picture: String?) { + // Send relay list event + val relayList = nostr.relays.getDefaultRelayList() + val relayListEvent = EventBuilder.relayList(relayList).finalizeAsync(keys) + + client?.sendEvent( + event = relayListEvent, + target = SendEventTarget.broadcast(), + ackPolicy = AckPolicy.all(), + okTimeout = Duration.parse("3s") + ) + + // Send messaging relay list event + val msgRelayList = nostr.relays.getDefaultMsgRelayList() + val msgRelayListEvent = EventBuilder.nip17RelayList(msgRelayList).finalizeAsync(keys) + + client?.sendEvent( + event = msgRelayListEvent, + target = SendEventTarget.toNip65(), + ackPolicy = AckPolicy.none() + ) + + // Send metadata event + val metadata = + Metadata.fromRecord(MetadataRecord(displayName = name, about = bio, picture = picture)) + val metadataEvent = EventBuilder.metadata(metadata).finalizeAsync(keys) + + client?.sendEvent( + event = metadataEvent, + target = SendEventTarget.broadcast(), + ackPolicy = AckPolicy.none() + ) + + // Send contact list event + val defaultContact = + Contact(PublicKey.parse("npub1j3rz3ndl902lya6ywxvy5c983lxs8mpukqnx4pa4lt5wrykwl5ys7wpw3x")) + val contactListEvent = EventBuilder.contactList(listOf(defaultContact)).finalizeAsync(keys) + + client?.sendEvent( + event = contactListEvent, + target = SendEventTarget.toNip65(), + ackPolicy = AckPolicy.none() + ) + + nostr.setSigner(keys) + } + + suspend fun updateProfile( + name: String? = null, + bio: String? = null, + picture: String? = null + ): Metadata { + val currentUser = signer.currentUser ?: throw IllegalStateException("User not signed in") + + try { + val record = getLatestMetadata(currentUser)?.asRecord() ?: MetadataRecord() + val newRecord = record.copy( + displayName = name ?: record.displayName, + about = bio ?: record.about, + picture = picture ?: record.picture + ) + val newMetadata = Metadata.fromRecord(newRecord) + val event = EventBuilder.metadata(newMetadata).finalizeAsync(signer) + + client?.sendEvent( + event = event, + target = SendEventTarget.broadcast(), + ackPolicy = AckPolicy.none() + ) + + return newMetadata + } catch (e: Exception) { + throw IllegalStateException("Failed to update identity: ${e.message}", e) + } + } + + private suspend fun getLatestMetadata(pubkey: PublicKey): Metadata? { + return try { + val kind = Kind.fromStd(KindStandard.METADATA) + val filter = Filter().kind(kind).author(pubkey).limit(1u) + val event = client?.database()?.query(filter)?.first() ?: return null + + Metadata.fromJson(event.content()) + } catch (e: Exception) { + println("Failed to get latest metadata: ${e.message}") + null + } + } + + suspend fun getAllCacheMetadata(): Map { + try { + val filter = Filter().kind(Kind.fromStd(KindStandard.METADATA)).limit(100u) + val events = client?.database()?.query(filter) + val results = mutableMapOf() + + events?.toVec()?.forEach { event -> + try { + val metadata = Metadata.fromJson(event.content()) + results[event.author()] = metadata + } catch (e: Exception) { + println("Failed to parse metadata: $e") + } + } + + return results + } catch (e: Exception) { + println("Failed to get all cache metadata: ${e.message}") + return emptyMap() + } + } + + suspend fun fetchMetadataBatch(keys: List) { + try { + val limit = keys.size.toULong() * 2u + val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) + + // Construct a filter for metadata events + val filter = Filter() + .kind(Kind.fromStd(KindStandard.METADATA)) + .authors(keys) + .limit(limit) + + // Construct request target + val target = mutableMapOf>() + NostrManager.BOOTSTRAP_RELAYS.forEach { relay -> + target[RelayUrl.parse(relay)] = listOf(filter) + } + + client?.subscribe(target = ReqTarget.manual(target), closeOn = opts) + } catch (e: Exception) { + throw IllegalStateException("Failed to fetch metadata batch: ${e.message}", e) + } + } + + suspend fun setContactList(contacts: List) { + try { + val contactList = contacts.map { Contact(it) } + val event = EventBuilder.contactList(contactList).finalizeAsync(signer) + + client?.sendEvent( + event = event, + target = SendEventTarget.broadcast(), + ackPolicy = AckPolicy.none(), + ) + } catch (e: Exception) { + throw IllegalStateException("Failed to set contact list: ${e.message}", e) + } + } + + suspend fun profileFromAddress(client: HttpClient, address: Nip05Address): Nip05Profile { + try { + val response: HttpResponse = client.get(address.url()) + val bodyString: String = response.body() + + return Nip05Profile.fromJson(address, bodyString) + } catch (e: Exception) { + throw IllegalStateException("Failed to fetch profile from address: ${e.message}", e) + } + } + + suspend fun searchByAddress(query: String): PublicKey { + try { + val address = Nip05Address.parse(query) + val profile = profileFromAddress(HttpClient(), address) + + return profile.publicKey() + } catch (e: Exception) { + throw IllegalStateException("Failed to search address: ${e.message}", e) + } + } + + suspend fun searchByNostr(query: String): List { + try { + // Add search relay + val searchRelay = RelayUrl.parse("wss://antiprimal.net") + if (client?.relay(searchRelay) == null) { + client?.addRelay(url = searchRelay, capabilities = RelayCapabilities.read()) + client?.connectRelay(searchRelay) + } + + val kinds = listOf(Kind.fromStd(KindStandard.METADATA)) + val filter = Filter().kinds(kinds).search(query).limit(10u) + val target = ReqTarget.manual(mapOf(searchRelay to listOf(filter))) + + val stream = client?.streamEvents( + target = target, + id = "search", + timeout = Duration.parse("3s"), + policy = ReqExitPolicy.ExitOnEose + ) + + // Collect the results + val results = mutableListOf() + + // Keep searching until the stream is closed or timeout + stream?.next()?.let { event -> + val event = event.event ?: return@let + results.add(event.author()) + } + + return results + } catch (e: Exception) { + throw IllegalStateException("Failed to search nostr: ${e.message}", e) + } + } + + suspend fun verifyActivity(pubkey: PublicKey): Timestamp? { + try { + val filter = Filter().author(pubkey).limit(3u) + val target = mutableMapOf>() + NostrManager.BOOTSTRAP_RELAYS.forEach { relay -> + target[RelayUrl.parse(relay)] = listOf(filter) + } + + val events = client?.fetchEvents( + target = ReqTarget.manual(target), + timeout = Duration.parse("3s") + ) + + return events?.first()?.createdAt() + } catch (e: Exception) { + throw IllegalStateException("Failed to get latest activity: ${e.message}", e) + } + } + + suspend fun verifyContact(pubkey: PublicKey): Boolean { + try { + val currentUser = + signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in") + + val kind = Kind.fromStd(KindStandard.CONTACT_LIST) + val filter = Filter().kind(kind).author(currentUser).limit(1u) + + val events = client?.database()?.query(filter) + val pubkeys = events?.first()?.tags()?.publicKeys() ?: listOf() + + return pubkeys.contains(pubkey) + } catch (e: Exception) { + throw IllegalStateException("Failed to get mutual contacts: ${e.message}", e) + } + } + + suspend fun mutualContacts(pubkey: PublicKey): Set { + try { + val currentUser = + signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in") + + val kind = Kind.fromStd(KindStandard.CONTACT_LIST) + val filter = Filter().kind(kind).pubkey(pubkey).limit(1u) + + val events = client?.database()?.query(filter) + val contacts = mutableSetOf() + + events?.toVec()?.filter { it.author() != currentUser }?.forEach { event -> + contacts.add(event.author()) + } + + return contacts.toSet() + } catch (e: Exception) { + throw IllegalStateException("Failed to get mutual contacts: ${e.message}", e) + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt new file mode 100644 index 0000000..bc9330f --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt @@ -0,0 +1,183 @@ +package su.reya.coop.nostr + +import rust.nostr.sdk.AckPolicy +import rust.nostr.sdk.Client +import rust.nostr.sdk.EventBuilder +import rust.nostr.sdk.Filter +import rust.nostr.sdk.Kind +import rust.nostr.sdk.KindStandard +import rust.nostr.sdk.PublicKey +import rust.nostr.sdk.RelayCapabilities +import rust.nostr.sdk.RelayMetadata +import rust.nostr.sdk.RelayStatus +import rust.nostr.sdk.RelayUrl +import rust.nostr.sdk.ReqExitPolicy +import rust.nostr.sdk.ReqTarget +import rust.nostr.sdk.SendEventTarget +import rust.nostr.sdk.SubscribeAutoCloseOptions +import rust.nostr.sdk.extractRelayList +import rust.nostr.sdk.nip17ExtractRelayList +import su.reya.coop.UniversalSigner +import kotlin.time.Duration + +class RelayManager(private val nostr: Nostr) { + private val client: Client? get() = nostr.client + private val signer: UniversalSigner get() = nostr.signer + + suspend fun connectBootstrapRelays() { + NostrManager.BOOTSTRAP_RELAYS.forEach { url -> + client?.addRelay(RelayUrl.parse(url)) + } + NostrManager.INDEXER_RELAY.forEach { url -> + client?.addRelay( + url = RelayUrl.parse(url), + capabilities = RelayCapabilities.gossip() + ) + } + // Connect to all bootstrap relays + client?.connect() + } + + suspend fun reconnect() { + NostrManager.ALL_RELAYS.forEach { url -> + try { + client?.relay(RelayUrl.parse(url)).let { relay -> + if (relay != null) { + if (relay.status() != RelayStatus.CONNECTED) { + relay.connect() + } + } + } + } catch (e: Exception) { + println("Failed to reconnect relay: ${e.message}") + } + } + } + + suspend fun disconnect() { + NostrManager.ALL_RELAYS.forEach { url -> + try { + client?.disconnectRelay(RelayUrl.parse(url)) + } catch (e: Exception) { + println("Failed to disconnect relay: ${e.message}") + } + } + } + + internal suspend fun getDefaultRelayList(): Map { + // Construct a list of relays + val relayList = mapOf( + RelayUrl.parse("wss://relay.damus.io") to RelayMetadata.READ, + RelayUrl.parse("wss://relay.primal.net") to RelayMetadata.READ, + RelayUrl.parse("wss://relay.nostr.net") to RelayMetadata.WRITE, + RelayUrl.parse("wss://nostr.superfriends.online") to RelayMetadata.WRITE + ) + + // Ensure all relays are added and connected + relayList.forEach { (relay, metadata) -> + client?.addRelay( + url = relay, + capabilities = + when (metadata) { + RelayMetadata.READ -> RelayCapabilities.read() + RelayMetadata.WRITE -> RelayCapabilities.write() + } + ) + client?.connectRelay(relay) + } + + return relayList + } + + internal suspend fun getDefaultMsgRelayList(): List { + // Construct a list of messaging relays + val msgRelayList = listOf( + RelayUrl.parse("wss://auth.nostr1.com"), + RelayUrl.parse("wss://nip17.com"), + ) + + // Ensure all relays are added and connected + msgRelayList.forEach { relay -> + client?.addRelay(relay, RelayCapabilities.none()) + client?.connectRelay(relay) + } + + return msgRelayList + } + + suspend fun setMsgRelays(urls: List) { + try { + val event = EventBuilder.nip17RelayList(urls).finalizeAsync(signer) + + client?.sendEvent( + event = event, + target = SendEventTarget.toNip65(), + ackPolicy = AckPolicy.none(), + ) + + val currentUser = + signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in") + + val kind = Kind.fromStd(KindStandard.INBOX_RELAYS) + val filter = Filter().kind(kind).author(currentUser).limit(1u) + + val target = ReqTarget.auto(listOf(filter)) + val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) + + client?.subscribe(target = target, closeOn = opts) + } catch (e: Exception) { + throw IllegalStateException("Failed to set msg relays: ${e.message}", e) + } + } + + suspend fun getMsgRelays(publicKey: PublicKey): List { + try { + val kind = Kind.fromStd(KindStandard.INBOX_RELAYS) + val filter = Filter().kind(kind).author(publicKey).limit(1u) + val events = client?.database()?.query(filter) + + return nip17ExtractRelayList(events?.toVec()?.firstOrNull() ?: return emptyList()) + } catch (e: Exception) { + throw IllegalStateException("Failed to get msg relays: ${e.message}", e) + } + } + + suspend fun fetchMsgRelays(publicKey: PublicKey): List { + try { + val kind = Kind.fromStd(KindStandard.INBOX_RELAYS) + val filter = Filter().kind(kind).author(publicKey).limit(1u) + val target = ReqTarget.auto(listOf(filter)) + val events = client?.fetchEvents(target, timeout = Duration.parse("3s")) + + return nip17ExtractRelayList(events?.toVec()?.firstOrNull() ?: return emptyList()) + } catch (e: Exception) { + throw IllegalStateException("Failed to fetch msg relays: ${e.message}", e) + } + } + + suspend fun getRelayList(publicKey: PublicKey): Map { + try { + val kind = Kind.fromStd(KindStandard.RELAY_LIST) + val filter = Filter().kind(kind).author(publicKey).limit(1u) + val events = client?.database()?.query(filter) + + return extractRelayList(events?.toVec()?.firstOrNull() ?: return emptyMap()) + } catch (e: Exception) { + throw IllegalStateException("Failed to get relay list: ${e.message}", e) + } + } + + suspend fun setRelaylist(relays: Map) { + try { + val event = EventBuilder.relayList(relays).finalizeAsync(signer) + + client?.sendEvent( + event = event, + target = SendEventTarget.broadcast(), + ackPolicy = AckPolicy.none(), + ) + } catch (e: Exception) { + throw IllegalStateException("Failed to set msg relays: ${e.message}", e) + } + } +} \ No newline at end of file