forked from reya/coop-mobile
605 lines
22 KiB
Kotlin
605 lines
22 KiB
Kotlin
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<PublicKey, List<RelayUrl>> = emptyMap()
|
|
private set
|
|
var contactList: List<PublicKey> = 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<RelayUrl, List<Filter>>()
|
|
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<EventId>()
|
|
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<RelayUrl, RelayMetadata> {
|
|
// Construct a list of relays
|
|
val relayList = mapOf<RelayUrl, RelayMetadata>(
|
|
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<RelayUrl> {
|
|
// 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<PublicKey>) {
|
|
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<Room>? {
|
|
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<Room> = 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<UnsignedEvent> {
|
|
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<PublicKey>) {
|
|
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<PublicKey>,
|
|
content: String,
|
|
subject: String? = null,
|
|
replies: List<EventId> = emptyList()
|
|
) {
|
|
try {
|
|
val currentUser =
|
|
signer.currentUser ?: throw IllegalStateException("User not signed in")
|
|
|
|
val tags = mutableListOf<Tag>()
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|