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 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) } } }