package su.reya.coop import io.ktor.client.HttpClient import io.ktor.client.plugins.websocket.WebSockets import kotlinx.coroutines.Job import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch 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.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.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.SleepWhenIdle import rust.nostr.sdk.SubscribeAutoCloseOptions import rust.nostr.sdk.Tag import rust.nostr.sdk.TagKind import rust.nostr.sdk.Timestamp import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.UnwrappedGift import rust.nostr.sdk.initLogger import rust.nostr.sdk.makePrivateMsgAsync import rust.nostr.sdk.nip17ExtractRelayList import kotlin.time.Duration class Nostr { var client: Client? = null private set var signer: UniversalSigner = UniversalSigner(Keys.generate()) private set var deviceSigner: AsyncNostrSigner? = null private set var msgRelayList: Map> = emptyMap() private set var contactList: List = emptyList() private set suspend fun init(dbPath: String) { try { // Initialize the logger for nostr client initLogger(LogLevel.DEBUG) val lmdb = NostrDatabase.lmdb(dbPath) val gossip = NostrGossip.inMemory() val idleTimeout = Duration.parse("5m") val httpClient = HttpClient { install(WebSockets) } client = ClientBuilder() .signer(signer) .websocketTransport(CoopWebSocketClient(httpClient)) .database(lmdb) .gossip(gossip) .gossipConfig(GossipConfig().noBackgroundRefresh()) .verifySubscriptions(false) .automaticAuthentication(true) .sleepWhenIdle(SleepWhenIdle.Enabled(idleTimeout)) .build() // Bootstrap relays client?.addRelay(RelayUrl.parse("wss://relay.primal.net")) client?.addRelay(RelayUrl.parse("wss://user.kindpag.es")) // Indexer relay for NIP-65 discovery client?.addRelay( url = RelayUrl.parse("wss://indexer.coracle.social"), capabilities = RelayCapabilities.gossip() ) // Connect to all bootstrap relays and wait for all connections to be established client?.connect(Duration.parse("3s")) } catch (e: Exception) { throw IllegalStateException("Failed to initialize Nostr client: ${e.message}", e) } } suspend fun disconnect() { client?.shutdown() } suspend fun exit() { signer.switch(Keys.generate()) deviceSigner = null contactList = emptyList() } suspend fun setSigner(keys: AsyncNostrSigner) { try { signer.switch(keys) // Fetch metadata for current user getUserMetadata() } 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 = "messages" ) } catch (e: Exception) { throw IllegalStateException("Failed to fetch user messages: ${e.message}", e) } } suspend fun handleNotifications( onMetadataUpdate: (PublicKey, Metadata) -> Unit, onNewMessage: (UnsignedEvent) -> Unit, onEose: () -> Unit, ) = coroutineScope { val now = Timestamp.now() val processedEvent = mutableSetOf() val notifications = client?.notifications() ?: return@coroutineScope var eoseTrackerJob: Job? = null 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()) if (event.kind().asStd()?.equals(KindStandard.METADATA) == true) { try { val metadata = Metadata.fromJson(event.content()) onMetadataUpdate(event.author(), metadata) } catch (e: Exception) { println("Failed to parse metadata: $e") } } if (event.kind().asStd()?.equals(KindStandard.INBOX_RELAYS) == true) { if (isSignedByUser(event = event)) { getUserMessages(msgRelayList = event) } // Cache the relay list for future use setMsgRelay(pubkey = event.author(), event = event) } if (event.kind().asStd()?.equals(KindStandard.GIFT_WRAP) == true) { try { val rumor = extractRumor(event) // Logic to notify UI after processing // Cancel previous tracker if it exists eoseTrackerJob?.cancel() // Start a new tracker eoseTrackerJob = launch { delay(10000) // Wait for 10 seconds onEose() } // Handle new message rumor?.createdAt()?.asSecs()?.let { if (it >= now.asSecs()) { onNewMessage(rumor) } } } catch (e: Exception) { println("Failed to extract rumor: $e") } } } is RelayMessageEnum.EndOfStoredEvents -> { val subscriptionId = message.subscriptionId if (subscriptionId == "messages") { onEose() } } else -> { /* Ignore other message types */ } } } is ClientNotification.NewEvent -> { // TODO: Handle new event } is ClientNotification.Shutdown -> { break } } } } private fun setMsgRelay(pubkey: PublicKey, event: Event) { val relays = nip17ExtractRelayList(event) msgRelayList = msgRelayList + (pubkey to relays) } 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: Exception) { throw IllegalStateException("Failed to get cached rumor: ${e.message}", e) } } private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) { try { val currentUser = signer.currentUser ?: throw IllegalStateException("User not signed in") // Ensure the rumor ID is set val rumor = rumor.ensureId() val roomId = rumor.roomId() // Construct reference tags val tags = listOf( Tag.identifier(giftId.toHex()), Tag.event(rumor.id()!!), Tag.reference(roomId.toString()), Tag.custom(TagKind.Unknown("k"), listOf("dm")) ) // Set event kind val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA); val event = EventBuilder(kind, rumor.asJson()) .tags(tags) .build(currentUser) .signWithKeys(Keys.generate()) client?.database()?.saveEvent(event) } catch (e: Exception) { println("Failed to set cached rumor: ${e.message}") } } private suspend fun extractRumor(event: Event): UnsignedEvent? { // Check if the rumor is already cached val cachedRumor = getCachedRumor(event.id()) if (cachedRumor != null) return cachedRumor // Get all signers val signers = listOfNotNull(signer, deviceSigner) if (signers.isEmpty()) return null // Try to unwrap the gift with each signer for (signer in signers) { try { // TODO: custom unwrapping logic val gift = UnwrappedGift.fromGiftWrapAsync(signer = signer, giftWrap = event) val rumor = gift.rumor() // Save the rumor to the database setCachedRumor(event.id(), rumor) // Return the rumor return rumor } catch (e: Exception) { println("Failed to unwrap gift: ${e.message}") continue } } 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 = if (metadata == RelayMetadata.READ) RelayCapabilities.read() else if (metadata == RelayMetadata.WRITE) RelayCapabilities.write() else RelayCapabilities.none() ) client?.connectRelay(relay) } return relayList } private suspend fun getMsgRelayList(): List { // Construct a list of messaging relays val msgRelayList = listOf( RelayUrl.parse("wss://relay.0xchat.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).signWithKeys(keys); client?.sendEvent( event = relayListEvent, target = SendEventTarget.broadcast(), ackPolicy = AckPolicy.all(), okTimeout = Duration.parse("3s") ) // Send messaging relay list event val msgRelayList = getMsgRelayList() val msgRelayListEvent = EventBuilder.nip17RelayList(msgRelayList).signWithKeys(keys) client?.sendEvent( event = msgRelayListEvent, target = SendEventTarget.toNip65(), ackPolicy = AckPolicy.none() ) // Send metadata event val metadata = Metadata.fromRecord(MetadataRecord(name = name, about = bio, picture = picture)) val metadataEvent = EventBuilder.metadata(metadata).signWithKeys(keys) client?.sendEvent( event = metadataEvent, target = SendEventTarget.toNip65(), ackPolicy = AckPolicy.none() ) // Send contact list event val defaultContact = listOf(Contact(publicKey = PublicKey.parse("npub1j3rz3ndl902lya6ywxvy5c983lxs8mpukqnx4pa4lt5wrykwl5ys7wpw3x"))) val contactListEvent = EventBuilder.contactList(defaultContact).signWithKeys(keys) client?.sendEvent( event = contactListEvent, target = SendEventTarget.toNip65(), ackPolicy = AckPolicy.none() ) setSigner(keys) } suspend fun fetchMetadataBatch(keys: List) { try { val limit = keys.size.toULong(); 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 a target that includes all filters val target = ReqTarget.manual( mapOf( RelayUrl.parse("wss://user.kindpag.es") to listOf(filter), RelayUrl.parse("wss://relay.primal.net") to listOf(filter) ) ) client?.subscribe(target = target, id = "metadata-reqs", closeOn = opts) } catch (e: Exception) { throw IllegalStateException("Failed to fetch metadata batch: ${e.message}", e) } } suspend fun getChatRooms(): Set? { try { val userPubkey = signer.currentUser ?: throw IllegalStateException("User not signed in") val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA) val kTag = SingleLetterTag.lowercase(Alphabet.K) // Get all events sent by the user val filter = Filter().kind(kind).author(userPubkey).customTag(kTag, "dm") val events = client?.database()?.query(filter) // Collect rooms val rooms: MutableSet = mutableSetOf() events ?.toVec() ?.map { UnsignedEvent.fromJson(it.content()) } ?.filter { it.tags().publicKeys().isNotEmpty() } ?.sortedByDescending { it.createdAt().asSecs() } ?.forEach { event -> val room = Room.new(rumor = event, userPubkey = userPubkey) // Check if the room already exists if (rooms.contains(room)) { room.setCreatedAt(room.createdAt) room.setLastMessage(room.lastMessage) } val filter = Filter().kind(kind).author(userPubkey).pubkeys(room.members.toList()); // Check if the user is interacting with the room's members val isInteracting = client?.database()?.query(filter)?.isEmpty() == false; // Check if the room's members are in the contact list val isContact = contactList.containsAll(room.members) // Set the room kind based on interaction status if (isInteracting || isContact) { room.setKind(RoomKind.Ongoing) } rooms.add(room) } return rooms } 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()) } ?.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 opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) client?.subscribe( target = ReqTarget.auto(listOf(filter)), closeOn = opts ) } } catch (e: Exception) { throw IllegalStateException("Failed to connect to chat room: ${e.message}", e) } } suspend fun sendMessage( to: List, content: String, subject: String? = null, replies: List = emptyList() ) { try { val currentUser = signer.currentUser ?: throw IllegalStateException("User not signed in") val tags = mutableListOf() // Add a subject tag if provided if (subject != null) { tags.add(Tag.custom(TagKind.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 -> if (pubkey != currentUser) { tags.add(Tag.publicKey(pubkey)) } } for (receiver in to.plus(currentUser)) { // Construct the gift wrap event val event = makePrivateMsgAsync( signer = signer, receiver = receiver, message = content, rumorExtraTags = tags ) println("Sending message to: ${receiver.toBech32()}") // Send the event to receiver's NIP-17 relays client?.sendEvent( event = event, target = SendEventTarget.toNip17(), ackPolicy = AckPolicy.none(), authenticationTimeout = Duration.parse("2s") ) } } catch (e: Exception) { throw IllegalStateException("Failed to send message: ${e.message}", e) } } }