package su.reya.coop.nostr import kotlinx.coroutines.CancellationException 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.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 private val _messageSyncState = MutableStateFlow(MessageSyncState()) val messageSyncState = _messageSyncState.asStateFlow() val rumorMap: MutableMap = mutableMapOf() fun updateSyncState(update: (MessageSyncState) -> MessageSyncState) { _messageSyncState.update(update) } suspend fun getUserMessages(msgRelayList: Event) { try { val author = signer.getPublicKeyAsync() ?: 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: CancellationException) { throw e } 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: CancellationException) { throw e } catch (e: Exception) { 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).ensureId() } } catch (e: CancellationException) { throw e } catch (e: Exception) { 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: CancellationException) { throw e } catch (e: Exception) { 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)?.toVec() ?: return null // Collect rooms val roomsMap: MutableMap = mutableMapOf() events .map { UnsignedEvent.fromJson(it.content()) } .filter { it.tags().publicKeys().isNotEmpty() } .forEach { rumor -> val id = rumor.roomId() val isFromMe = rumor.author() == userPubkey val existing = roomsMap[id] val createdAt = rumor.createdAt() // If the room is new or the current rumor is newer than the existing one if (existing == null || createdAt.asSecs() > existing.createdAt.asSecs()) { // A room is "Ongoing" if it was already marked as such or if the current rumor is from the user val isOngoing = (existing?.kind == RoomKind.Ongoing) || isFromMe val room = Room.new(rumor = rumor, userPubkey = userPubkey, id = id) roomsMap[id] = if (isOngoing) room.copy(kind = RoomKind.Ongoing) else room } else if (isFromMe && existing.kind != RoomKind.Ongoing) { // If it's an older rumor but sent by the user, mark the room as Ongoing roomsMap[id] = existing.copy(kind = RoomKind.Ongoing) } } return roomsMap.values.sortedByDescending { it.createdAt.asSecs() }.toSet() } catch (e: CancellationException) { throw e } 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: CancellationException) { throw e } 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: CancellationException) { throw e } 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 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: CancellationException) { throw e } catch (e: Exception) { throw IllegalStateException("Failed to send message: ${e.message}", e) } } }