Merge branch 'main' of https://github.com/vitorpamplona/amethyst into upstream-main

# Conflicts:
#	gradle/libs.versions.toml
#	quartz/build.gradle.kts
#	quartz/src/androidHostTest/kotlin/com/vitorpamplona/quartz/TestResourceLoader.android.kt
#	quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/GZip.ios.kt
#	quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/TestResourceLoader.kt
This commit is contained in:
KotlinGeekDev
2026-03-04 00:37:10 +01:00
581 changed files with 36083 additions and 4749 deletions
+42 -32
View File
@@ -16,7 +16,6 @@ plugins {
kotlin {
compilerOptions {
freeCompilerArgs.add("-Xstring-concat=inline")
freeCompilerArgs.add("-Xexpect-actual-classes")
}
jvm {
@@ -25,10 +24,16 @@ kotlin {
}
}
androidLibrary {
android {
namespace = "com.vitorpamplona.quartz"
compileSdk = libs.versions.android.compileSdk.get().toInt()
minSdk = libs.versions.android.minSdk.get().toInt()
compileSdk =
libs.versions.android.compileSdk
.get()
.toInt()
minSdk =
libs.versions.android.minSdk
.get()
.toInt()
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
@@ -145,43 +150,50 @@ kotlin {
}
// Must be defined before androidMain and jvmMain
val jvmAndroid = create("jvmAndroid") {
dependsOn(commonMain.get())
val jvmAndroid =
create("jvmAndroid") {
dependsOn(commonMain.get())
dependencies {
// Normalizes URLs
api(libs.rfc3986.normalizer)
dependencies {
// Normalizes URLs
api(libs.rfc3986.normalizer)
// Performant Parser of JSONs into Events
api(libs.jackson.module.kotlin)
// Performant Parser of JSONs into Events
api(libs.jackson.module.kotlin)
// Parses URLs from Text:
api(libs.url.detector)
// Parses URLs from Text:
api(libs.url.detector)
// Websockets API
implementation(libs.okhttp)
implementation(libs.okhttpCoroutines)
// Websockets API
implementation(libs.okhttp)
implementation(libs.okhttpCoroutines)
// Chess engine for move validation and legal move generation
// NOTE: 1.0.4+ uses Java 21's removeLast() which crashes on Android API < 34
// TODO: Test if 1.0.0 works, or fork library to fix
implementation(libs.kchesslib)
}
}
}
// Must be defined before androidMain and jvmMain
val jvmAndroidTest = create("jvmAndroidTest") {
dependsOn(commonTest.get())
dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
val jvmAndroidTest =
create("jvmAndroidTest") {
dependsOn(commonTest.get())
dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
}
}
}
jvmMain {
dependsOn(jvmAndroid)
dependencies {
// Bitcoin secp256k1 bindings
implementation (libs.secp256k1.kmp.jni.jvm)
implementation(libs.secp256k1.kmp.jni.jvm)
// LibSodium for ChaCha encryption (NIP-44)
implementation (libs.lazysodium.java)
implementation (libs.jna)
implementation(libs.lazysodium.java)
implementation(libs.jna)
}
}
@@ -202,8 +214,8 @@ kotlin {
api(libs.secp256k1.kmp.jni.android)
// LibSodium for ChaCha encryption (NIP-44)
implementation ("com.goterl:lazysodium-android:5.2.0@aar")
implementation ("net.java.dev.jna:jna:5.18.1@aar")
implementation("com.goterl:lazysodium-android:5.2.0@aar")
implementation("net.java.dev.jna:jna:5.18.1@aar")
}
}
@@ -249,7 +261,6 @@ kotlin {
iosTest {
dependsOn(commonTest.get())
dependencies {
}
}
@@ -267,20 +278,19 @@ kotlin {
}
}
mavenPublishing {
// sources publishing is always enabled by the Kotlin Multiplatform plugin
configure(
KotlinMultiplatform(
// whether to publish a sources jar
sourcesJar = true,
)
),
)
coordinates(
groupId = "com.vitorpamplona.quartz",
artifactId = "quartz",
version = "1.05.1"
version = "1.05.1",
)
// Configure publishing to Maven Central
@@ -23,7 +23,7 @@ package com.vitorpamplona.quartz.bloom
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.sha256.Sha256Hasher
import org.junit.Assert.assertEquals
@@ -135,7 +135,7 @@ class BloomFilterTest {
var failureCounter = 0
for (seed in 0..1000000) {
if (bloomFilter.mightContains(Nip01.pubKeyCreate(Nip01.privKeyCreate()))) {
if (bloomFilter.mightContains(Nip01Crypto.pubKeyCreate(Nip01Crypto.privKeyCreate()))) {
failureCounter++
}
}
@@ -23,7 +23,7 @@ package com.vitorpamplona.quartz.nip01Core
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
import com.vitorpamplona.quartz.utils.Secp256k1Instance
import com.vitorpamplona.quartz.utils.sha256.sha256
import org.junit.Assert.assertEquals
@@ -33,14 +33,14 @@ import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class Nip01Test {
class Nip01CryptoTest {
private val privateKey = "f410f88bcec6cbfda04d6a273c7b1dd8bba144cd45b71e87109cfa11dd7ed561".hexToByteArray()
@Test
fun testGetPublicFromPrivateKey() {
assertEquals(
"7d4b8806f1fd713c287235411bf95aa81b7242ead892733ec84b3f2719845be6",
Nip01.pubKeyCreate(privateKey).toHexKey(),
Nip01Crypto.pubKeyCreate(privateKey).toHexKey(),
)
}
@@ -66,7 +66,7 @@ class Nip01Test {
fun testDeterministicSign() {
assertEquals(
"1484d0e0bd62165e822e31f1f4cc8e1ce8e20c30a060e24fb0ecd7baf7c624f661fb7a3e4f0ddb43018e5f0b4892c929af64d8b7a86021aa081ec8231e3dfa37",
Nip01.sign(sha256("Test".toByteArray()), privateKey, null).toHexKey(),
Nip01Crypto.sign(sha256("Test".toByteArray()), privateKey, null).toHexKey(),
)
}
@@ -81,10 +81,10 @@ class Nip01Test {
@Test
fun testDeterministicVerify() {
assertTrue(
Nip01.verify(
Nip01Crypto.verify(
"1484d0e0bd62165e822e31f1f4cc8e1ce8e20c30a060e24fb0ecd7baf7c624f661fb7a3e4f0ddb43018e5f0b4892c929af64d8b7a86021aa081ec8231e3dfa37".hexToByteArray(),
sha256("Test".toByteArray()),
Nip01.pubKeyCreate(privateKey),
Nip01Crypto.pubKeyCreate(privateKey),
),
)
}
@@ -93,18 +93,18 @@ class Nip01Test {
fun testNonDeterministicSign() {
assertNotEquals(
"1484d0e0bd62165e822e31f1f4cc8e1ce8e20c30a060e24fb0ecd7baf7c624f661fb7a3e4f0ddb43018e5f0b4892c929af64d8b7a86021aa081ec8231e3dfa37",
Nip01.sign(sha256("Test".toByteArray()), privateKey).toHexKey(),
Nip01Crypto.sign(sha256("Test".toByteArray()), privateKey).toHexKey(),
)
}
@Test
fun testNonDeterministicSignVerify() {
val signature = Nip01.sign(sha256("Test".toByteArray()), privateKey)
val signature = Nip01Crypto.sign(sha256("Test".toByteArray()), privateKey)
assertTrue(
Nip01.verify(
Nip01Crypto.verify(
signature,
sha256("Test".toByteArray()),
Nip01.pubKeyCreate(privateKey),
Nip01Crypto.pubKeyCreate(privateKey),
),
)
}
@@ -33,7 +33,7 @@ class Nip01CryptoTest {
fun testGetPublicFromPrivateKey() {
val privateKey =
"f410f88bcec6cbfda04d6a273c7b1dd8bba144cd45b71e87109cfa11dd7ed561".hexToByteArray()
val publicKey = Nip01.pubKeyCreate(privateKey).toHexKey()
val publicKey = Nip01Crypto.pubKeyCreate(privateKey).toHexKey()
assertEquals("7d4b8806f1fd713c287235411bf95aa81b7242ead892733ec84b3f2719845be6", publicKey)
}
}
@@ -22,7 +22,7 @@ package com.vitorpamplona.quartz.nip04Dm
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
import com.vitorpamplona.quartz.nip04Dm.crypto.EncryptedInfo
import com.vitorpamplona.quartz.nip04Dm.crypto.Encryption
import org.junit.Assert.assertEquals
@@ -34,8 +34,8 @@ class EncryptionTest {
val sk1 = "91ba716fa9e7ea2fcbad360cf4f8e0d312f73984da63d90f524ad61a6a1e7dbe".hexToByteArray()
val sk2 = "96f6fa197aa07477ab88f6981118466ae3a982faab8ad5db9d5426870c73d220".hexToByteArray()
val pk1 = Nip01.pubKeyCreate(sk1)
val pk2 = Nip01.pubKeyCreate(sk2)
val pk1 = Nip01Crypto.pubKeyCreate(sk1)
val pk2 = Nip01Crypto.pubKeyCreate(sk2)
val expectedShared = "7ce22696eb0e303ddaa491bdf2a56b79d249f2d861b8e012a933e01dc4beba81"
@@ -21,7 +21,7 @@
package com.vitorpamplona.quartz.nip04Dm
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
import com.vitorpamplona.quartz.nip04Dm.crypto.Nip04
import org.junit.Assert.assertEquals
import org.junit.Test
@@ -33,8 +33,8 @@ class Nip04Test {
fun encryptDecryptNIP4Test() {
val msg = "Hi"
val privateKey = Nip01.privKeyCreate()
val publicKey = Nip01.pubKeyCreate(privateKey)
val privateKey = Nip01Crypto.privKeyCreate()
val publicKey = Nip01Crypto.pubKeyCreate(privateKey)
val encrypted = Nip04.encrypt(msg, privateKey, publicKey)
val decrypted = Nip04.decrypt(encrypted, privateKey, publicKey)
@@ -46,8 +46,8 @@ class Nip04Test {
fun encryptSharedSecretDecryptNIP4Test() {
val msg = "Hi"
val privateKey = Nip01.privKeyCreate()
val publicKey = Nip01.pubKeyCreate(privateKey)
val privateKey = Nip01Crypto.privKeyCreate()
val publicKey = Nip01Crypto.pubKeyCreate(privateKey)
val encrypted = Nip04.encrypt(msg, privateKey, publicKey)
val decrypted = Nip04.decrypt(encrypted, privateKey, publicKey)
@@ -24,7 +24,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@@ -68,8 +68,8 @@ class Nip44v1Test {
fun encryptDecrypt() {
val msg = "Hi"
val privateKey = Nip01.privKeyCreate()
val publicKey = Nip01.pubKeyCreate(privateKey)
val privateKey = Nip01Crypto.privKeyCreate()
val publicKey = Nip01Crypto.pubKeyCreate(privateKey)
val encrypted = nip44v1.encrypt(msg, privateKey, publicKey)
val decrypted = nip44v1.decrypt(encrypted, privateKey, publicKey)
@@ -81,8 +81,8 @@ class Nip44v1Test {
fun encryptDecryptSharedSecret() {
val msg = "Hi"
val privateKey = Nip01.privKeyCreate()
val publicKey = Nip01.pubKeyCreate(privateKey)
val privateKey = Nip01Crypto.privKeyCreate()
val publicKey = Nip01Crypto.pubKeyCreate(privateKey)
val sharedSecret = nip44v1.getSharedSecret(privateKey, publicKey)
@@ -25,7 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.sha256.sha256
import junit.framework.TestCase.assertNotNull
@@ -68,8 +68,8 @@ class Nip44v2Test {
val privateKeyA = "f410f88bcec6cbfda04d6a273c7b1dd8bba144cd45b71e87109cfa11dd7ed561".hexToByteArray()
val privateKeyB = "65f039136f8da8d3e87b4818746b53318d5481e24b2673f162815144223a0b5a".hexToByteArray()
val publicKeyA = Nip01.pubKeyCreate(privateKeyA)
val publicKeyB = Nip01.pubKeyCreate(privateKeyB)
val publicKeyA = Nip01Crypto.pubKeyCreate(privateKeyA)
val publicKeyB = Nip01Crypto.pubKeyCreate(privateKeyB)
assertEquals(
nip44v2.getConversationKey(privateKeyA, publicKeyB).toHexKey(),
@@ -82,8 +82,8 @@ class Nip44v2Test {
val privateKeyA = "f410f88bcec6cbfda04d6a273c7b1dd8bba144cd45b71e87109cfa11dd7ed561".hexToByteArray()
val privateKeyB = "e6159851715b4aa6190c22b899b0c792847de0a4435ac5b678f35738351c43b0".hexToByteArray()
val publicKeyA = Nip01.pubKeyCreate(privateKeyA)
val publicKeyB = Nip01.pubKeyCreate(privateKeyB)
val publicKeyA = Nip01Crypto.pubKeyCreate(privateKeyA)
val publicKeyB = Nip01Crypto.pubKeyCreate(privateKeyB)
assertEquals(
nip44v2.getConversationKey(privateKeyA, publicKeyB).toHexKey(),
@@ -24,7 +24,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@@ -37,7 +37,7 @@ class SignStringTest {
val message = "8e58c8251bb406b6ded69e9eb14f55282a9a53bdab16fc49a3218c2ad3abc887".hexToByteArray()
val keyPair = KeyPair("a5ab474552c8f9c46c2eda5a0b68f27430ad81f96cb405e0cb4e34bf0c6494a2".hexToByteArray())
val signedMessage = Nip01.sign(message, keyPair.privKey!!, random).toHexKey()
val signedMessage = Nip01Crypto.sign(message, keyPair.privKey!!, random).toHexKey()
val expectedValue = "0f9be7e01ba53d5ee6874b9180c7956269fda7a5be424634c3d17b5cfcea6da001be89183876415ba08b7dafa6cff4555e393dc228fb8769b384344e9a27b77c"
assertEquals(expectedValue, signedMessage)
@@ -23,7 +23,9 @@ package com.vitorpamplona.quartz.nip01Core.core
import android.os.Parcel
import android.os.Parcelable
import androidx.compose.runtime.Stable
import kotlinx.serialization.Serializable
@Serializable
@Stable
actual data class Address actual constructor(
actual val kind: Kind,
@@ -20,7 +20,7 @@
*/
package com.vitorpamplona.quartz.nip55AndroidSigner
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.sha256.sha256
@@ -28,4 +28,4 @@ fun signString(
message: String,
privKey: ByteArray,
nonce: ByteArray = RandomInstance.bytes(32),
): ByteArray = Nip01.sign(sha256(message.toByteArray()), privKey, nonce)
): ByteArray = Nip01Crypto.sign(sha256(message.toByteArray()), privKey, nonce)
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.quartz.experimental.interactiveStories
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.experimental.interactiveStories.tags.StoryOptionTag
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -27,6 +28,7 @@ import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag
import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag
import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag
@Stable
open class InteractiveStoryBaseEvent(
id: HexKey,
pubKey: HexKey,
@@ -28,7 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.core.builder
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
@@ -83,17 +83,16 @@ class InteractiveStoryReadingStateEvent(
fun update(
base: InteractiveStoryReadingStateEvent,
currentScene: InteractiveStoryBaseEvent,
currentSceneRelay: NormalizedRelayUrl?,
currentScene: EventHintBundle<InteractiveStoryBaseEvent>,
createdAt: Long = TimeUtils.now(),
): EventTemplate<InteractiveStoryReadingStateEvent> {
val rootTag = base.dTag()
val sceneTag = currentScene.aTag(currentSceneRelay)
val sceneTag = ATag(currentScene.event.address(), currentScene.relay)
val status =
if (rootTag == sceneTag.toTag()) {
ReadStatusTag.STATUS.NEW
} else if (currentScene.options().isEmpty()) {
} else if (currentScene.event.options().isEmpty()) {
ReadStatusTag.STATUS.DONE
} else {
ReadStatusTag.STATUS.READING
@@ -109,34 +108,32 @@ class InteractiveStoryReadingStateEvent(
}
fun build(
root: InteractiveStoryBaseEvent,
rootRelay: NormalizedRelayUrl?,
currentScene: InteractiveStoryBaseEvent,
currentSceneRelay: NormalizedRelayUrl?,
root: EventHintBundle<InteractiveStoryBaseEvent>,
currentScene: EventHintBundle<InteractiveStoryBaseEvent>,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<InteractiveStoryReadingStateEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
val rootTag = root.aTag(rootRelay)
val sceneTag = currentScene.aTag(currentSceneRelay)
val rootTag = ATag(root.event.address(), root.relay)
val sceneTag = ATag(currentScene.event.address(), currentScene.relay)
val status =
if (rootTag == sceneTag) {
ReadStatusTag.STATUS.NEW
} else if (currentScene.options().isEmpty()) {
} else if (currentScene.event.options().isEmpty()) {
ReadStatusTag.STATUS.DONE
} else {
ReadStatusTag.STATUS.READING
}
dTag(rootTag.toTag())
alt(root.title()?.let { ALT2 + it } ?: ALT1)
alt(root.event.title()?.let { ALT2 + it } ?: ALT1)
rootScene(rootTag)
currentScene(sceneTag)
status(status)
root.title()?.let { storyTitle(it) }
root.summary()?.let { storyImage(it) }
root.image()?.let { storySummary(it) }
root.event.title()?.let { storyTitle(it) }
root.event.summary()?.let { storyImage(it) }
root.event.image()?.let { storySummary(it) }
initializer()
}
@@ -32,7 +32,6 @@ import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint
import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
@@ -71,8 +70,6 @@ class NipTextEvent(
override fun dTag() = tags.dTag()
override fun aTag(relayHint: NormalizedRelayUrl?) = ATag(kind, pubKey, dTag(), relayHint)
override fun address() = Address(kind, pubKey, dTag())
override fun addressTag() = Address.assemble(kind, pubKey, dTag())
@@ -21,15 +21,11 @@
package com.vitorpamplona.quartz.nip01Core.core
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
@Immutable
interface AddressableEvent : IEvent {
fun dTag(): String
fun aTag(relayHint: NormalizedRelayUrl? = null): ATag
fun address(): Address
fun addressTag(): String
@@ -21,8 +21,6 @@
package com.vitorpamplona.quartz.nip01Core.core
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
@Immutable
@@ -38,8 +36,6 @@ open class BaseAddressableEvent(
AddressableEvent {
override fun dTag() = tags.dTag()
override fun aTag(relayHint: NormalizedRelayUrl?) = ATag(kind, pubKey, dTag(), relayHint)
override fun address() = Address(kind, pubKey, dTag())
/**
@@ -21,8 +21,6 @@
package com.vitorpamplona.quartz.nip01Core.core
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
@Immutable
open class BaseReplaceableEvent(
@@ -33,11 +31,10 @@ open class BaseReplaceableEvent(
tags: TagArray,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) {
) : Event(id, pubKey, createdAt, kind, tags, content, sig),
AddressableEvent {
override fun dTag() = FIXED_D_TAG
override fun aTag(relayHint: NormalizedRelayUrl?) = ATag(kind, pubKey, FIXED_D_TAG, relayHint)
override fun address(): Address = Address(kind, pubKey, dTag())
/**
@@ -26,29 +26,27 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.utils.EventFactory
import com.vitorpamplona.quartz.utils.RandomInstance
class EventAssembler {
companion object {
fun <T : Event> hashAndSign(
pubKey: HexKey,
createdAt: Long,
kind: Int,
tags: Array<Array<String>>,
content: String,
privKey: ByteArray,
nonce: ByteArray? = RandomInstance.bytes(32),
): T {
val id = EventHasher.hashIdBytes(pubKey, createdAt, kind, tags, content)
val sig = Nip01.sign(id, privKey, nonce).toHexKey()
object EventAssembler {
fun <T : Event> hashAndSign(
pubKey: HexKey,
createdAt: Long,
kind: Int,
tags: Array<Array<String>>,
content: String,
privKey: ByteArray,
nonce: ByteArray? = RandomInstance.bytes(32),
): T {
val id = EventHasher.hashIdBytes(pubKey, createdAt, kind, tags, content)
val sig = Nip01Crypto.sign(id, privKey, nonce).toHexKey()
return EventFactory.create(
id.toHexKey(),
pubKey,
createdAt,
kind,
tags,
content,
sig,
)
}
return EventFactory.create(
id.toHexKey(),
pubKey,
createdAt,
kind,
tags,
content,
sig,
)
}
}
@@ -33,7 +33,7 @@ fun Event.verifyId(): Boolean {
fun Event.verifySignature(): Boolean {
if (id.isEmpty() || sig.isEmpty()) return false
return Nip01.verify(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey))
return Nip01Crypto.verify(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey))
}
/** Checks if the ID is correct and then if the pubKey's secret key signed the event. */
@@ -24,33 +24,29 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.utils.sha256.sha256
class EventHasher {
companion object {
fun hashIdBytes(
pubKey: HexKey,
createdAt: Long,
kind: Int,
tags: Array<Array<String>>,
content: String,
): ByteArray = sha256(EventHasherSerializer.fastMakeJsonForId(pubKey, createdAt, kind, tags, content))
object EventHasher {
fun hashIdBytes(
pubKey: HexKey,
createdAt: Long,
kind: Int,
tags: Array<Array<String>>,
content: String,
): ByteArray = sha256(EventHasherSerializer.fastMakeJsonForId(pubKey, createdAt, kind, tags, content))
fun hashId(serializedJsonAsBytes: ByteArray): String = sha256(serializedJsonAsBytes).toHexKey()
fun hashId(
pubKey: HexKey,
createdAt: Long,
kind: Int,
tags: Array<Array<String>>,
content: String,
): String = hashIdBytes(pubKey, createdAt, kind, tags, content).toHexKey()
fun hashId(
pubKey: HexKey,
createdAt: Long,
kind: Int,
tags: Array<Array<String>>,
content: String,
): String = hashIdBytes(pubKey, createdAt, kind, tags, content).toHexKey()
fun hashIdCheck(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
kind: Int,
tags: Array<Array<String>>,
content: String,
): Boolean = EventHasherSerializer.makeJsonForIdHashAndCheck(id, pubKey, createdAt, kind, tags, content)
}
fun hashIdCheck(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
kind: Int,
tags: Array<Array<String>>,
content: String,
): Boolean = EventHasherSerializer.makeJsonForIdHashAndCheck(id, pubKey, createdAt, kind, tags, content)
}
@@ -34,8 +34,8 @@ class KeyPair(
if (privKey == null) {
if (pubKey == null) {
// create new, random keys
this.privKey = Nip01.privKeyCreate()
this.pubKey = Nip01.pubKeyCreate(this.privKey)
this.privKey = Nip01Crypto.privKeyCreate()
this.pubKey = Nip01Crypto.pubKeyCreate(this.privKey)
} else {
// this is a read-only account
check(pubKey.size == 32)
@@ -46,7 +46,7 @@ class KeyPair(
// as private key is provided, ignore the public key and set keys according to private key
this.privKey = privKey
if (pubKey == null || forceReplacePubkey) {
this.pubKey = Nip01.pubKeyCreate(privKey)
this.pubKey = Nip01Crypto.pubKeyCreate(privKey)
} else {
this.pubKey = pubKey
}
@@ -23,7 +23,7 @@ package com.vitorpamplona.quartz.nip01Core.crypto
import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.Secp256k1Instance
object Nip01 {
object Nip01Crypto {
fun privKeyCreate() = RandomInstance.bytes(32)
fun pubKeyCreate(privKey: ByteArray) = Secp256k1Instance.compressedPubKeyFor(privKey).copyOfRange(1, 33)
@@ -26,7 +26,6 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.core.builder
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent.Companion.updateOrDeleteTagNames
import com.vitorpamplona.quartz.nip01Core.metadata.tags.AboutTag
import com.vitorpamplona.quartz.nip01Core.metadata.tags.BannerTag
import com.vitorpamplona.quartz.nip01Core.metadata.tags.DisplayNameTag
@@ -54,7 +53,6 @@ import com.vitorpamplona.quartz.utils.text
import kotlinx.coroutines.CancellationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull.content
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
@@ -45,7 +45,18 @@ suspend fun INostrClient.sendAndWaitForResponse(
event: Event,
relayList: Set<NormalizedRelayUrl>,
timeoutInSeconds: Long = 15,
): Boolean {
): Boolean = sendAndWaitForResponseDetailed(event, relayList, timeoutInSeconds).any { it.value }
/**
* Sends an event to the given relays and waits for OK responses.
* Returns per-relay results: relay URL -> accepted (true/false).
*/
@OptIn(DelicateCoroutinesApi::class)
suspend fun INostrClient.sendAndWaitForResponseDetailed(
event: Event,
relayList: Set<NormalizedRelayUrl>,
timeoutInSeconds: Long = 15,
): Map<NormalizedRelayUrl, Boolean> {
val resultChannel = Channel<Result>(UNLIMITED)
Log.d("sendAndWaitForResponse", "Waiting for ${relayList.size} responses")
@@ -124,5 +135,5 @@ suspend fun INostrClient.sendAndWaitForResponse(
Log.d("sendAndWaitForResponse", "Finished with ${receivedResults.size} results")
return receivedResults.any { it.value }
return receivedResults
}
@@ -20,16 +20,17 @@
*/
package com.vitorpamplona.quartz.nip01Core.relay.client.auth
import androidx.collection.LruCache
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
class RelayAuthStatus {
// Keeps track of auth responses to update the relay with all filters
// after the authentication happen
private val authResponseWatcher: MutableMap<HexKey, AuthEventReceiptStatus> = mutableMapOf()
private val authResponseWatcher: LruCache<HexKey, AuthEventReceiptStatus> = LruCache(10)
// Avoids sending multiple replies for each auth.
private val uniqueAuthChallengesSent: MutableSet<ChallengePair> = mutableSetOf()
private val uniqueAuthChallengesSent: LruCache<ChallengePair, ChallengePair> = LruCache(10)
enum class AuthEventReceiptStatus {
AUTHENTICATING,
@@ -43,15 +44,14 @@ class RelayAuthStatus {
)
fun saveAuthSubmission(authEvent: RelayAuthEvent): Boolean {
val challenge = authEvent.challenge()
if (challenge == null) return false
val challenge = authEvent.challenge() ?: return false
val challengePair = ChallengePair(authEvent.pubKey, challenge)
// only send replies to new challenges to avoid infinite loop:
return if (challengePair !in uniqueAuthChallengesSent) {
authResponseWatcher[authEvent.id] = AuthEventReceiptStatus.AUTHENTICATING
uniqueAuthChallengesSent.add(challengePair)
return if (uniqueAuthChallengesSent[challengePair] == null) {
authResponseWatcher.put(authEvent.id, AuthEventReceiptStatus.AUTHENTICATING)
uniqueAuthChallengesSent.put(challengePair, challengePair)
true
} else {
false
@@ -61,10 +61,9 @@ class RelayAuthStatus {
fun checkAuthResults(
eventId: HexKey,
success: Boolean,
): Boolean =
if (authResponseWatcher.containsKey(eventId)) {
val wasAlreadyAuthenticated = authResponseWatcher[eventId]
): Boolean {
val wasAlreadyAuthenticated = authResponseWatcher[eventId]
return if (wasAlreadyAuthenticated != null) {
if (success) {
authResponseWatcher.put(eventId, AuthEventReceiptStatus.AUTHENTICATED)
} else {
@@ -75,6 +74,7 @@ class RelayAuthStatus {
} else {
false
}
}
fun hasFinishedAllAuths() = authResponseWatcher.all { it.value != AuthEventReceiptStatus.AUTHENTICATING }
fun hasFinishedAllAuths() = authResponseWatcher.snapshot().all { it.value != AuthEventReceiptStatus.AUTHENTICATING }
}
@@ -66,7 +66,7 @@ class RelayAuthenticator(
}
override fun onConnecting(relay: IRelayClient) {
authStatus.put(relay.url, RelayAuthStatus())
authStatus[relay.url] = RelayAuthStatus()
}
override fun onDisconnected(relay: IRelayClient) {
@@ -0,0 +1,92 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.relay.client.reqs
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.utils.RandomInstance
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
/**
* 1. Subscribes to a req while the flow is active
* 2. Emits accumulated events as a list on each new arrival
* 3. Closes the flow (completes) when the relay sends EOSE,
* cancelling the subscription automatically via [awaitClose].
*/
fun INostrClient.reqUntilEoseAsFlow(
relay: String,
filters: List<Filter>,
) = reqUntilEoseAsFlow(RelayUrlNormalizer.normalize(relay), filters)
fun INostrClient.reqUntilEoseAsFlow(
relay: String,
filter: Filter,
) = reqUntilEoseAsFlow(RelayUrlNormalizer.normalize(relay), listOf(filter))
fun INostrClient.reqUntilEoseAsFlow(
relay: NormalizedRelayUrl,
filter: Filter,
) = reqUntilEoseAsFlow(relay, listOf(filter))
fun INostrClient.reqUntilEoseAsFlow(
relay: NormalizedRelayUrl,
filters: List<Filter>,
): Flow<List<Event>> =
callbackFlow {
val subId = RandomInstance.randomChars(10)
val eventIds = mutableSetOf<HexKey>()
var currentEvents = listOf<Event>()
val listener =
object : IRequestListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (event.id !in eventIds) {
currentEvents = currentEvents + event
eventIds.add(event.id)
trySend(currentEvents)
}
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
close()
}
}
openReqSubscription(subId, mapOf(relay to filters), listener)
awaitClose {
close(subId)
}
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.quartz.nip01Core.relay.filters
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -46,6 +47,7 @@ import com.vitorpamplona.quartz.utils.Log
* This class performs validation on construction to ensure all string-based identifiers
* follow Nostr requirements (64-char hex, onion addresses) and logs errors for invalid inputs.
*/
@Stable
class Filter(
val ids: List<HexKey>? = null,
val authors: List<HexKey>? = null,
@@ -191,3 +191,7 @@ class RelayUrlNormalizer {
}
}
}
fun String.normalizeRelayUrl() = RelayUrlNormalizer.normalize(this)
fun String.normalizeRelayUrlOrNull() = RelayUrlNormalizer.normalizeOrNull(this)
@@ -20,11 +20,20 @@
*/
package com.vitorpamplona.quartz.nip01Core.tags.aTag
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
fun <T : Event> TagArrayBuilder<T>.aTag(tag: ATag) = add(tag.toATagArray())
fun <T : Event> TagArrayBuilder<T>.aTag(
address: Address,
relayHint: NormalizedRelayUrl? = null,
) = add(ATag.assemble(address, relayHint))
fun <T : Event> TagArrayBuilder<T>.aTags(tag: List<ATag>) = addAll(tag.map { it.toATagArray() })
fun <T : Event> TagArrayBuilder<T>.removeATag(tag: ATag) = this.removeIf(ATag::isSameAddress, tag.toATagArray())
fun <T : Event> TagArrayBuilder<T>.removeAddress(address: Address) = this.removeIf(ATag::isSameAddress, ATag.assemble(address, null))
@@ -35,7 +35,6 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.serialization.json.JsonNull.content
@Stable
class ContactListEvent(
@@ -89,7 +89,7 @@ class DeletionEvent(
deleteEvents.forEach {
eTag(ETag(it.id))
if (it is AddressableEvent) {
aTag(it.aTag())
aTag(it.address())
}
}
@@ -108,7 +108,7 @@ class DeletionEvent(
deleteEvents.forEach {
if (it is AddressableEvent) {
aTag(it.aTag())
aTag(it.address())
}
}
@@ -26,9 +26,9 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
@@ -38,7 +38,6 @@ import com.vitorpamplona.quartz.nip01Core.tags.events.eTag
import com.vitorpamplona.quartz.nip01Core.tags.kinds.kind
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip01Core.tags.people.pTag
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.lastNotNullOfOrNull
@@ -93,45 +92,28 @@ class GenericRepostEvent(
const val ALT = "Generic repost"
fun build(
boostedPost: Event,
eventSourceRelay: NormalizedRelayUrl?,
authorHomeRelay: NormalizedRelayUrl?,
eventHint: EventHintBundle<Event>,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<GenericRepostEvent>.() -> Unit = {},
) = eventTemplate(KIND, boostedPost.toJson(), createdAt) {
) = eventTemplate(KIND, eventHint.event.toJson(), createdAt) {
val boostedPost = eventHint.event
alt(ALT)
kind(boostedPost.kind)
pTag(PTag(boostedPost.pubKey, authorHomeRelay))
eTag(ETag(boostedPost.id, eventSourceRelay, boostedPost.pubKey))
pTag(PTag(boostedPost.pubKey, eventHint.authorHomeRelay))
eTag(ETag(boostedPost.id, eventHint.relay, boostedPost.pubKey))
if (boostedPost is AddressableEvent) {
aTag(boostedPost.aTag(eventSourceRelay))
aTag(boostedPost.address(), eventHint.relay)
}
initializer()
}
suspend fun create(
boostedPost: Event,
eventHint: EventHintBundle<Event>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): GenericRepostEvent {
val content = boostedPost.toJson()
val tags =
mutableListOf(
arrayOf("e", boostedPost.id),
arrayOf("p", boostedPost.pubKey),
)
if (boostedPost is AddressableEvent) {
tags.add(arrayOf("a", boostedPost.aTag().toTag()))
}
tags.add(arrayOf("k", "${boostedPost.kind}"))
tags.add(AltTag.assemble(ALT))
return signer.sign(createdAt, KIND, tags.toTypedArray(), content)
}
): GenericRepostEvent = signer.sign(build(eventHint, createdAt))
}
}
@@ -26,9 +26,10 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.aTag.aTag
@@ -82,7 +83,7 @@ class RepostEvent(
fun containedPost() =
try {
fromJson(content)
} catch (e: Exception) {
} catch (_: Exception) {
null
}
@@ -91,22 +92,28 @@ class RepostEvent(
const val ALT = "Repost event"
fun build(
boostedPost: Event,
eventSourceRelay: NormalizedRelayUrl?,
authorHomeRelay: NormalizedRelayUrl?,
eventHint: EventHintBundle<Event>,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<RepostEvent>.() -> Unit = {},
) = eventTemplate(KIND, boostedPost.toJson(), createdAt) {
) = eventTemplate(KIND, eventHint.event.toJson(), createdAt) {
val boostedPost = eventHint.event
alt(ALT)
pTag(PTag(boostedPost.pubKey, authorHomeRelay))
eTag(ETag(boostedPost.id, eventSourceRelay, boostedPost.pubKey))
pTag(PTag(boostedPost.pubKey, eventHint.authorHomeRelay))
eTag(ETag(boostedPost.id, eventHint.relay, boostedPost.pubKey))
if (boostedPost is AddressableEvent) {
aTag(boostedPost.aTag(eventSourceRelay))
aTag(boostedPost.address(), eventHint.relay)
}
kind(boostedPost.kind)
initializer()
}
suspend fun create(
eventHint: EventHintBundle<Event>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): RepostEvent = signer.sign(build(eventHint, createdAt))
}
}
@@ -24,7 +24,6 @@ import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01
import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes
import com.vitorpamplona.quartz.nip19Bech32.entities.Entity
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
@@ -175,7 +174,7 @@ object Nip19Parser {
fun decodePublicKey(key: String): ByteArray =
when (val parsed = Nip19Parser.uriToRoute(key)?.entity) {
is NSec -> Nip01.pubKeyCreate(parsed.hex.hexToByteArray())
is NSec -> parsed.toPubKey()
is NPub -> parsed.hex.hexToByteArray()
is NProfile -> parsed.hex.hexToByteArray()
else -> Hex.decode(key) // crashes on purpose
@@ -202,7 +201,7 @@ fun decodePrivateKeyAsHexOrNull(key: String): HexKey? =
fun decodePublicKeyAsHexOrNull(key: String): HexKey? =
try {
when (val parsed = Nip19Parser.uriToRoute(key)?.entity) {
is NSec -> Nip01.pubKeyCreate(parsed.hex.hexToByteArray()).toHexKey()
is NSec -> parsed.toPubKeyHex()
is NPub -> parsed.hex
is NProfile -> parsed.hex
is NNote -> null
@@ -23,13 +23,15 @@ package com.vitorpamplona.quartz.nip19Bech32.entities
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
@Immutable
data class NSec(
val hex: String,
) : Entity {
fun toPubKeyHex() = Nip01.pubKeyCreate(hex.hexToByteArray()).toHexKey()
fun toPubKey() = Nip01Crypto.pubKeyCreate(hex.hexToByteArray())
fun toPubKeyHex() = toPubKey().toHexKey()
companion object {
fun parse(bytes: ByteArray): NSec? {
@@ -31,9 +31,7 @@ import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint
import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint
import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
@@ -119,8 +117,6 @@ class LongTextNoteEvent(
override fun dTag() = tags.dTag()
override fun aTag(relayHint: NormalizedRelayUrl?) = ATag(kind, pubKey, dTag(), relayHint)
override fun address() = Address(kind, pubKey, dTag())
override fun addressTag() = Address.assemble(kind, pubKey, dTag())
@@ -91,7 +91,7 @@ class ReactionEvent(
) = eventTemplate<ReactionEvent>(KIND, reaction, createdAt) {
eTag(reactedTo.toETag())
if (reactedTo.event is AddressableEvent) {
aTag(reactedTo.event.aTag(reactedTo.relay))
aTag(reactedTo.event.address(), reactedTo.relay)
}
pTag(reactedTo.event.pubKey, reactedTo.relay)
kind(reactedTo.event.kind)
@@ -104,7 +104,7 @@ class ReactionEvent(
) = eventTemplate<ReactionEvent>(KIND, reaction.toContentEncode(), createdAt) {
eTag(reactedTo.toETag())
if (reactedTo.event is AddressableEvent) {
aTag(reactedTo.event.aTag(reactedTo.relay))
aTag(reactedTo.event.address(), reactedTo.relay)
}
pTag(reactedTo.event.pubKey, reactedTo.relay)
kind(reactedTo.event.kind)
@@ -78,7 +78,7 @@ class EmojiPackSelectionEvent(
createdAt: Long = TimeUtils.now(),
updater: TagArrayBuilder<EmojiPackSelectionEvent>.() -> Unit = {},
) = eventUpdate(currentSelection, createdAt) {
removePack(packToRemove.aTag(null))
removePack(packToRemove.address())
updater()
}
@@ -20,14 +20,15 @@
*/
package com.vitorpamplona.quartz.nip30CustomEmoji.selection
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.aTag.aTag
import com.vitorpamplona.quartz.nip01Core.tags.aTag.aTags
import com.vitorpamplona.quartz.nip01Core.tags.aTag.removeATag
import com.vitorpamplona.quartz.nip01Core.tags.aTag.removeAddress
fun TagArrayBuilder<EmojiPackSelectionEvent>.pack(tag: ATag) = aTag(tag)
fun TagArrayBuilder<EmojiPackSelectionEvent>.packs(tags: List<ATag>) = aTags(tags)
fun TagArrayBuilder<EmojiPackSelectionEvent>.removePack(tag: ATag) = removeATag(tag)
fun TagArrayBuilder<EmojiPackSelectionEvent>.removePack(address: Address) = removeAddress(address)
@@ -24,16 +24,17 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
fun MetadataEvent.identityClaims() = tags.mapNotNull(IdentityClaimTag::parse)
fun MetadataEvent.replaceClaims(
fun ExternalIdentitiesEvent.identityClaims() = tags.mapNotNull(IdentityClaimTag::parse)
fun List<IdentityClaimTag>.replaceClaims(
twitter: String?,
mastodon: String?,
github: String?,
): List<IdentityClaimTag> {
var claims = identityClaims()
var claims = this
// null leave as is. blank deletes it.
if (twitter != null) {
// delete twitter
claims = claims.filter { it !is TwitterIdentity }
if (twitter.isNotBlank()) {
TwitterIdentity.parseProofUrl(twitter)?.let { claims = claims + it }
@@ -42,7 +43,6 @@ fun MetadataEvent.replaceClaims(
// null leave as is. blank deletes it.
if (github != null) {
// delete github
claims = claims.filter { it !is GitHubIdentity }
if (github.isNotBlank()) {
GitHubIdentity.parseProofUrl(github)?.let { claims = claims + it }
@@ -59,3 +59,15 @@ fun MetadataEvent.replaceClaims(
return claims
}
fun MetadataEvent.replaceClaims(
twitter: String?,
mastodon: String?,
github: String?,
): List<IdentityClaimTag> = identityClaims().replaceClaims(twitter, mastodon, github)
fun ExternalIdentitiesEvent.replaceClaims(
twitter: String?,
mastodon: String?,
github: String?,
): List<IdentityClaimTag> = identityClaims().replaceClaims(twitter, mastodon, github)
@@ -0,0 +1,86 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip39ExtIdentities
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.core.builder
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class ExternalIdentitiesEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
companion object {
const val KIND = 10011
const val ALT_DESCRIPTION = "External Identities"
const val FIXED_D_TAG = ""
fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, FIXED_D_TAG)
fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey, FIXED_D_TAG)
fun createNew(
twitter: String? = null,
mastodon: String? = null,
github: String? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<ExternalIdentitiesEvent>.() -> Unit = {},
): EventTemplate<ExternalIdentitiesEvent> =
eventTemplate(KIND, "", createdAt) {
alt(ALT_DESCRIPTION)
twitter?.let { twitterClaim(it) }
mastodon?.let { mastodonClaim(it) }
github?.let { githubClaim(it) }
initializer()
}
fun updateFromPast(
latest: ExternalIdentitiesEvent,
twitter: String? = null,
mastodon: String? = null,
github: String? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<ExternalIdentitiesEvent>.() -> Unit = {},
): EventTemplate<ExternalIdentitiesEvent> {
val tags =
latest.tags.builder {
alt(ALT_DESCRIPTION)
val newClaims = latest.replaceClaims(twitter, mastodon, github)
remove(IdentityClaimTag.TAG_NAME)
claims(newClaims)
initializer()
}
return EventTemplate(createdAt, KIND, tags, "")
}
}
}
@@ -20,19 +20,19 @@
*/
package com.vitorpamplona.quartz.nip39ExtIdentities
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
fun TagArrayBuilder<MetadataEvent>.claims(identities: List<IdentityClaimTag>) = addAll(identities.map { it.toTagArray() })
fun <T : Event> TagArrayBuilder<T>.claims(identities: List<IdentityClaimTag>) = addAll(identities.map { it.toTagArray() })
fun TagArrayBuilder<MetadataEvent>.twitterClaim(twitter: TwitterIdentity) = add(twitter.toTagArray())
fun <T : Event> TagArrayBuilder<T>.twitterClaim(twitter: TwitterIdentity) = add(twitter.toTagArray())
fun TagArrayBuilder<MetadataEvent>.mastodonClaim(mastodon: MastodonIdentity) = add(mastodon.toTagArray())
fun <T : Event> TagArrayBuilder<T>.mastodonClaim(mastodon: MastodonIdentity) = add(mastodon.toTagArray())
fun TagArrayBuilder<MetadataEvent>.githubClaim(github: GitHubIdentity) = add(github.toTagArray())
fun <T : Event> TagArrayBuilder<T>.githubClaim(github: GitHubIdentity) = add(github.toTagArray())
fun TagArrayBuilder<MetadataEvent>.twitterClaim(twitterUrl: String) = TwitterIdentity.parseProofUrl(twitterUrl)?.let { twitterClaim(it) }
fun <T : Event> TagArrayBuilder<T>.twitterClaim(twitterUrl: String) = TwitterIdentity.parseProofUrl(twitterUrl)?.let { twitterClaim(it) }
fun TagArrayBuilder<MetadataEvent>.mastodonClaim(mastodonUrl: String) = MastodonIdentity.parseProofUrl(mastodonUrl)?.let { mastodonClaim(it) }
fun <T : Event> TagArrayBuilder<T>.mastodonClaim(mastodonUrl: String) = MastodonIdentity.parseProofUrl(mastodonUrl)?.let { mastodonClaim(it) }
fun TagArrayBuilder<MetadataEvent>.githubClaim(githubUrl: String) = GitHubIdentity.parseProofUrl(githubUrl)?.let { githubClaim(it) }
fun <T : Event> TagArrayBuilder<T>.githubClaim(githubUrl: String) = GitHubIdentity.parseProofUrl(githubUrl)?.let { githubClaim(it) }
@@ -0,0 +1,120 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip51Lists.relayLists
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent
import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent
import com.vitorpamplona.quartz.nip51Lists.encryption.signNip51List
import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.RelayTag
import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.favoriteRelays
import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.relays
import com.vitorpamplona.quartz.nip51Lists.remove
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class FavoriteRelayListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
fun publicRelays() = tags.relays()
suspend fun decryptPrivateRelays(signer: NostrSigner) = privateTags(signer)?.relays()
suspend fun decryptRelays(signer: NostrSigner): List<NormalizedRelayUrl> = publicRelays() + (decryptPrivateRelays(signer) ?: emptyList())
companion object {
const val KIND = 10012
const val ALT = "Favorite Relays"
val TAGS = arrayOf(AltTag.assemble(ALT))
fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, "")
fun createAddressATag(pubKey: HexKey): ATag = ATag(KIND, pubKey, "", null)
fun createAddressTag(pubKey: HexKey): String = Address.Companion.assemble(KIND, pubKey, "")
suspend fun updateRelayList(
earlierVersion: FavoriteRelayListEvent,
relays: List<NormalizedRelayUrl>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): FavoriteRelayListEvent {
val newRelayList = relays.map { RelayTag.assemble(it) }
val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException()
val publicTags = earlierVersion.tags.remove(RelayTag::match)
val newPrivateTags = privateTags.remove(RelayTag::match).plus(newRelayList)
return signer.signNip51List(createdAt, KIND, publicTags, newPrivateTags)
}
suspend fun create(
relays: List<NormalizedRelayUrl>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): FavoriteRelayListEvent {
val privateTagArray = relays.map { RelayTag.assemble(it) }.toTypedArray()
return signer.signNip51List(createdAt, KIND, TAGS, privateTagArray)
}
fun create(
relays: List<NormalizedRelayUrl>,
signer: NostrSignerSync,
createdAt: Long = TimeUtils.now(),
): FavoriteRelayListEvent {
val privateTagArray = relays.map { RelayTag.assemble(it) }.toTypedArray()
return signer.signNip51List(createdAt, KIND, TAGS, privateTagArray)
}
suspend fun build(
publicRelays: List<NormalizedRelayUrl> = emptyList(),
privateRelays: List<NormalizedRelayUrl> = emptyList(),
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<FavoriteRelayListEvent>.() -> Unit = {},
) = eventTemplate<FavoriteRelayListEvent>(
kind = KIND,
description = PrivateTagsInContent.encryptNip44(privateRelays.map { RelayTag.assemble(it) }.toTypedArray(), signer),
createdAt = createdAt,
) {
alt(ALT)
favoriteRelays(publicRelays)
initializer()
}
}
}
@@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
@@ -50,3 +51,5 @@ fun TagArrayBuilder<ProxyRelayListEvent>.proxyRelays(relays: List<NormalizedRela
fun TagArrayBuilder<PrivateOutboxRelayListEvent>.privateRelays(relays: List<NormalizedRelayUrl>) = addAll(relays.map { RelayTag.assemble(it) })
fun TagArrayBuilder<RelaySetEvent>.relaySet(relays: List<NormalizedRelayUrl>) = addAll(relays.map { RelayTag.assemble(it) })
fun TagArrayBuilder<FavoriteRelayListEvent>.favoriteRelays(relays: List<NormalizedRelayUrl>) = addAll(relays.map { RelayTag.assemble(it) })
@@ -34,7 +34,6 @@ import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint
import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint
import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
@@ -129,8 +128,6 @@ class WikiNoteEvent(
override fun dTag() = tags.dTag()
override fun aTag(relayHint: NormalizedRelayUrl?) = ATag(kind, pubKey, dTag(), relayHint)
override fun address() = Address(kind, pubKey, dTag())
override fun addressTag() = Address.assemble(kind, pubKey, dTag())
@@ -104,7 +104,7 @@ class LnZapRequestEvent(
AltTag.assemble(ALT),
)
if (zappedEvent is AddressableEvent) {
tags = tags + listOf(arrayOf("a", zappedEvent.aTag().toTag()))
tags = tags + listOf(ATag.assemble(zappedEvent.address(), null))
}
if (pollOption != null && pollOption >= 0) {
tags = tags + listOf(arrayOf(PollOptionTag.TAG_NAME, pollOption.toString()))
@@ -25,7 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
@@ -106,7 +106,7 @@ class PrivateZapRequestBuilder {
try {
if (altPubkeyToUse != null) {
val altPubKeyFromPrivate = Nip01.pubKeyCreate(myPrivateKeyForThisEvent).toHexKey()
val altPubKeyFromPrivate = Nip01Crypto.pubKeyCreate(myPrivateKeyForThisEvent).toHexKey()
if (altPubKeyFromPrivate == event.pubKey) {
// the sender is logged in.
@@ -0,0 +1,139 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip64Chess
import androidx.compose.runtime.Immutable
/**
* Platform-agnostic chess engine interface for move validation and generation
* Implemented using expect/actual for JVM/Android platforms
*/
expect class ChessEngine() {
/**
* Get the current position as FEN string
*/
fun getFen(): String
/**
* Load a position from FEN notation
*/
fun loadFen(fen: String)
/**
* Reset to starting position
*/
fun reset()
/**
* Make a move using Standard Algebraic Notation (SAN)
* @param san Move in SAN format (e.g., "Nf3", "e4", "O-O")
* @return MoveResult with success status and resulting position
*/
fun makeMove(san: String): MoveResult
/**
* Make a move from source to destination square
* @param from Source square in algebraic notation (e.g., "e2")
* @param to Destination square in algebraic notation (e.g., "e4")
* @param promotion Optional promotion piece type for pawn promotion
* @return MoveResult with success status and resulting position
*/
fun makeMove(
from: String,
to: String,
promotion: PieceType? = null,
): MoveResult
/**
* Get all legal moves from the current position
* @return List of legal moves in SAN notation
*/
fun getLegalMoves(): List<String>
/**
* Get all legal moves for a specific square
* @param square Square in algebraic notation (e.g., "e2")
* @return List of destination squares that are legal
*/
fun getLegalMovesFrom(square: String): List<String>
/**
* Check if a move is legal in the current position
* @param san Move in SAN format
* @return true if the move is legal
*/
fun isLegalMove(san: String): Boolean
/**
* Check if a move from-to is legal
*/
fun isLegalMove(
from: String,
to: String,
promotion: PieceType? = null,
): Boolean
/**
* Check if current position is checkmate
*/
fun isCheckmate(): Boolean
/**
* Check if current position is stalemate
*/
fun isStalemate(): Boolean
/**
* Check if current position is in check
*/
fun isInCheck(): Boolean
/**
* Undo the last move
*/
fun undoMove()
/**
* Get the current position as ChessPosition
*/
fun getPosition(): ChessPosition
/**
* Get side to move
*/
fun getSideToMove(): Color
/**
* Get move history in SAN notation
*/
fun getMoveHistory(): List<String>
}
/**
* Result of attempting a chess move
*/
@Immutable
data class MoveResult(
val success: Boolean,
val san: String? = null, // Move in SAN notation if successful
val position: ChessPosition? = null, // Resulting position if successful
val error: String? = null, // Error message if unsuccessful
)
@@ -0,0 +1,84 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip64Chess
import androidx.compose.runtime.Immutable
/**
* Represents a complete chess game parsed from PGN
* Per NIP-64, content must be valid PGN format
*/
@Immutable
data class ChessGame(
val metadata: Map<String, String>,
val moves: List<ChessMove>,
val positions: List<ChessPosition>,
val result: GameResult,
) {
init {
require(positions.isNotEmpty()) { "Game must have at least starting position" }
require(positions.size == moves.size + 1) { "Position count must be moves + 1" }
}
/**
* Standard PGN tag accessors
*/
val event: String? get() = metadata["Event"]
val site: String? get() = metadata["Site"]
val date: String? get() = metadata["Date"]
val round: String? get() = metadata["Round"]
val white: String? get() = metadata["White"]
val black: String? get() = metadata["Black"]
/**
* Get position after move N (0 = starting position)
*/
fun positionAt(moveIndex: Int): ChessPosition = positions.getOrElse(moveIndex) { positions.last() }
/**
* Check if game has required metadata
*/
fun hasRequiredMetadata(): Boolean =
metadata.containsKey("Event") &&
metadata.containsKey("Site") &&
metadata.containsKey("Date") &&
metadata.containsKey("Round") &&
metadata.containsKey("White") &&
metadata.containsKey("Black") &&
metadata.containsKey("Result")
}
/**
* Game result per PGN specification
*/
enum class GameResult(
val notation: String,
) {
WHITE_WINS("1-0"),
BLACK_WINS("0-1"),
DRAW("1/2-1/2"),
IN_PROGRESS("*"),
;
companion object {
fun parse(notation: String): GameResult = entries.find { it.notation == notation } ?: IN_PROGRESS
}
}
@@ -0,0 +1,86 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip64Chess
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* NIP-64: Chess Game Event
*
* Kind 64 events contain chess games in PGN (Portable Game Notation) format.
* Per NIP-64 specification:
* - Content must be valid PGN format (PGN-database)
* - Clients should accept "import format" (human-created, flexible)
* - Clients should publish in "export format" (machine-generated, strict)
* - Moves must comply with chess rules
* - Clients should display content as rendered chessboard
*
* @see <a href="https://github.com/nostr-protocol/nips/blob/master/64.md">NIP-64</a>
*/
@Immutable
class ChessGameEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String, // PGN database format
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
companion object {
const val KIND = 64
const val ALT_DESCRIPTION = "Chess Game"
/**
* Create a chess game event from PGN content
* Per NIP-64, clients should publish in strict "export format"
*
* @param pgnContent Valid PGN format string
* @param altDescription Alt text for non-supporting clients (NIP-31)
* @param createdAt Event timestamp
* @param initializer Additional tag builder operations
*/
fun build(
pgnContent: String,
altDescription: String = ALT_DESCRIPTION,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<ChessGameEvent>.() -> Unit = {},
) = eventTemplate(KIND, pgnContent, createdAt) {
alt(altDescription)
initializer()
}
}
/**
* Get PGN content from event
*/
fun pgn(): String = content
/**
* Get alt text for non-supporting clients (NIP-31)
*/
fun altText(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "alt" }?.get(1)
}
@@ -0,0 +1,234 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip64Chess
import kotlin.random.Random
/**
* Generates human-readable chess game names.
*
* Produces memorable names like "Bold Knight", "Swift Rook", etc.
*/
object ChessGameNameGenerator {
// Chess-themed adjectives
private val adjectives =
listOf(
"Bold",
"Swift",
"Cunning",
"Royal",
"Noble",
"Shadow",
"Silent",
"Golden",
"Silver",
"Iron",
"Fierce",
"Wise",
"Dark",
"Bright",
"Ancient",
"Brave",
"Mystic",
"Grand",
"Crimson",
"Azure",
"Ivory",
"Obsidian",
"Emerald",
"Sapphire",
"Ruby",
"Phantom",
"Eternal",
"Secret",
"Hidden",
"Blazing",
)
// Chess pieces and related nouns
private val chessNouns =
listOf(
"King",
"Queen",
"Rook",
"Bishop",
"Knight",
"Pawn",
"Castle",
"Crown",
"Throne",
"Gambit",
"Defense",
"Attack",
"Checkmate",
"Fortress",
"Tower",
"Endgame",
"Opening",
"Sacrifice",
"Pin",
"Fork",
"Strategy",
"Victory",
"Battle",
"Duel",
"Match",
"Challenge",
)
// Famous chess-related animals/themes
private val animals =
listOf(
"Dragon",
"Phoenix",
"Griffin",
"Eagle",
"Lion",
"Wolf",
"Tiger",
"Falcon",
"Hawk",
"Raven",
"Bear",
"Stallion",
"Serpent",
"Panther",
"Cobra",
)
/**
* Generate a random chess game name.
*
* @param includeNumber Whether to append a random number (1-99)
* @return A name like "Bold Knight" or "Swift Dragon 42"
*/
fun generate(includeNumber: Boolean = false): String {
val adjective = adjectives.random()
val noun = if (Random.nextBoolean()) chessNouns.random() else animals.random()
val base = "$adjective $noun"
return if (includeNumber) {
val num = Random.nextInt(1, 100)
"$base $num"
} else {
base
}
}
/**
* Generate a name based on two player display names.
*
* Creates a name that incorporates elements from both players
* for a more personalized game name.
*
* @param playerName First player's display name (can be npub/pubkey)
* @param opponentName Second player's display name (can be npub/pubkey, or null for open challenge)
* @return A personalized game name
*/
fun generateFromPlayers(
playerName: String?,
opponentName: String?,
): String {
// Extract first letter or use adjective if name is a pubkey/npub
val p1Initial = extractInitial(playerName)
val p2Initial = opponentName?.let { extractInitial(it) }
// If we have good initials, use them in the name
val adjective =
if (p1Initial != null) {
// Try to find an adjective starting with that letter
adjectives.find { it.startsWith(p1Initial, ignoreCase = true) }
?: adjectives.random()
} else {
adjectives.random()
}
val noun =
if (p2Initial != null) {
// Try to find a noun starting with opponent's initial
(chessNouns + animals).find { it.startsWith(p2Initial, ignoreCase = true) }
?: if (Random.nextBoolean()) chessNouns.random() else animals.random()
} else {
// Open challenge - use "Challenge" or similar
listOf("Challenge", "Gambit", "Opening", "Battle", "Duel").random()
}
return "$adjective $noun"
}
/**
* Extract a usable initial from a name.
* Returns null if the name looks like a pubkey/npub.
*/
private fun extractInitial(name: String?): Char? {
if (name.isNullOrBlank()) return null
// Skip if it looks like a hex pubkey or npub
if (name.length > 20 && name.all { it.isLetterOrDigit() }) return null
if (name.startsWith("npub") || name.startsWith("nprofile")) return null
// Get first letter of the display name
val firstChar = name.firstOrNull { it.isLetter() }
return firstChar?.uppercaseChar()
}
/**
* Generate a game ID that includes a readable component.
*
* @param timestamp Unix timestamp
* @return Game ID like "chess-1234567890-bold-knight"
*/
fun generateGameId(timestamp: Long): String {
val name =
generate(includeNumber = false)
.lowercase()
.replace(" ", "-")
return "chess-$timestamp-$name"
}
/**
* Extract the human-readable name from a game ID.
*
* @param gameId Game ID like "chess-1234567890-bold-knight"
* @return Display name like "Bold Knight", or null if not a named game ID
*/
fun extractDisplayName(gameId: String): String? {
// Pattern: chess-timestamp-name-parts
if (!gameId.startsWith("chess-")) return null
val parts = gameId.removePrefix("chess-").split("-")
if (parts.size < 2) return null
// First part is timestamp, rest is the name
val nameParts = parts.drop(1)
if (nameParts.isEmpty()) return null
// Check if it looks like a UUID (8 hex chars) - old format
if (nameParts.size == 1 && nameParts[0].length == 8 && nameParts[0].all { it.isLetterOrDigit() }) {
return null
}
// Convert to title case
return nameParts.joinToString(" ") { part ->
part.replaceFirstChar { it.uppercaseChar() }
}
}
}
@@ -0,0 +1,78 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip64Chess
import androidx.compose.runtime.Immutable
/**
* Represents a single chess move in Standard Algebraic Notation (SAN)
* Per NIP-64, moves must comply with chess rules
*/
@Immutable
data class ChessMove(
val san: String, // Standard Algebraic Notation, e.g., "Nf3", "e4", "O-O", "Qxe5+"
val moveNumber: Int, // Which move in the game (1-based)
val color: Color, // Who made the move
val piece: PieceType = PieceType.PAWN,
val fromSquare: String? = null, // Source square if known
val toSquare: String, // Destination square
val isCapture: Boolean = false,
val isCheck: Boolean = false,
val isCheckmate: Boolean = false,
val isCastling: Boolean = false,
val promotion: PieceType? = null,
) {
override fun toString(): String = san
}
/**
* Chess piece types per standard notation
*/
enum class PieceType(
val symbol: Char,
) {
KING('K'),
QUEEN('Q'),
ROOK('R'),
BISHOP('B'),
KNIGHT('N'),
PAWN('P'),
;
companion object {
fun fromSymbol(c: Char): PieceType? = entries.find { it.symbol == c.uppercaseChar() }
}
}
/**
* Player color
*/
enum class Color {
WHITE,
BLACK,
;
fun opposite(): Color =
when (this) {
WHITE -> BLACK
BLACK -> WHITE
}
}
@@ -0,0 +1,215 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip64Chess
import androidx.compose.runtime.Immutable
/**
* Represents a chess position using 8x8 board array
* Coordinates: file (a-h) = columns 0-7, rank (1-8) = rows 0-7
* a1 = [0,0], h1 = [7,0], a8 = [0,7], h8 = [7,7]
*/
@Immutable
data class ChessPosition(
private val board: Array<Array<ChessPiece?>>,
val activeColor: Color,
val moveNumber: Int,
val castlingRights: CastlingRights = CastlingRights(),
val enPassantSquare: String? = null,
val halfMoveClock: Int = 0,
) {
init {
require(board.size == 8) { "Board must be 8x8, got ${board.size} ranks" }
require(board.all { it.size == 8 }) { "Board must be 8x8, some ranks are not 8 files wide" }
}
/**
* Get piece at square using algebraic notation (e.g., "e4")
*/
operator fun get(square: String): ChessPiece? {
val (file, rank) = parseSquare(square)
return board[rank][file]
}
/**
* Get piece at coordinates (file: 0-7, rank: 0-7)
*/
fun pieceAt(
file: Int,
rank: Int,
): ChessPiece? = board.getOrNull(rank)?.getOrNull(file)
/**
* Create new position with a piece moved
*/
fun makeMove(
from: String,
to: String,
promotion: PieceType? = null,
): ChessPosition {
val (fromFile, fromRank) = parseSquare(from)
val (toFile, toRank) = parseSquare(to)
val newBoard = board.map { it.copyOf() }.toTypedArray()
val piece = newBoard[fromRank][fromFile] ?: throw IllegalArgumentException("No piece at $from")
// Handle promotion
val finalPiece =
if (promotion != null && piece.type == PieceType.PAWN) {
piece.copy(type = promotion)
} else {
piece
}
newBoard[toRank][toFile] = finalPiece
newBoard[fromRank][fromFile] = null
return copy(
board = newBoard,
activeColor = activeColor.opposite(),
moveNumber = if (activeColor == Color.BLACK) moveNumber + 1 else moveNumber,
)
}
/**
* Create new position with castling
*/
fun makeCastlingMove(kingSide: Boolean): ChessPosition {
val rank = if (activeColor == Color.WHITE) 0 else 7
val newBoard = board.map { it.copyOf() }.toTypedArray()
if (kingSide) {
// King-side castling (O-O)
val king = newBoard[rank][4]
val rook = newBoard[rank][7]
newBoard[rank][6] = king
newBoard[rank][5] = rook
newBoard[rank][4] = null
newBoard[rank][7] = null
} else {
// Queen-side castling (O-O-O)
val king = newBoard[rank][4]
val rook = newBoard[rank][0]
newBoard[rank][2] = king
newBoard[rank][3] = rook
newBoard[rank][4] = null
newBoard[rank][0] = null
}
return copy(
board = newBoard,
activeColor = activeColor.opposite(),
moveNumber = if (activeColor == Color.BLACK) moveNumber + 1 else moveNumber,
)
}
private fun parseSquare(square: String): Pair<Int, Int> {
require(square.length == 2) { "Invalid square notation: $square" }
val file = square[0].lowercaseChar() - 'a' // 0-7
val rank = square[1] - '1' // 0-7
require(file in 0..7 && rank in 0..7) { "Square out of bounds: $square" }
return file to rank
}
companion object {
/**
* Standard starting position per FIDE rules
*/
fun initial(): ChessPosition {
val board = Array(8) { Array<ChessPiece?>(8) { null } }
// White pieces (ranks 0-1)
board[0] =
arrayOf(
ChessPiece(PieceType.ROOK, Color.WHITE),
ChessPiece(PieceType.KNIGHT, Color.WHITE),
ChessPiece(PieceType.BISHOP, Color.WHITE),
ChessPiece(PieceType.QUEEN, Color.WHITE),
ChessPiece(PieceType.KING, Color.WHITE),
ChessPiece(PieceType.BISHOP, Color.WHITE),
ChessPiece(PieceType.KNIGHT, Color.WHITE),
ChessPiece(PieceType.ROOK, Color.WHITE),
)
board[1] = Array(8) { ChessPiece(PieceType.PAWN, Color.WHITE) }
// Black pieces (ranks 6-7)
board[6] = Array(8) { ChessPiece(PieceType.PAWN, Color.BLACK) }
board[7] =
arrayOf(
ChessPiece(PieceType.ROOK, Color.BLACK),
ChessPiece(PieceType.KNIGHT, Color.BLACK),
ChessPiece(PieceType.BISHOP, Color.BLACK),
ChessPiece(PieceType.QUEEN, Color.BLACK),
ChessPiece(PieceType.KING, Color.BLACK),
ChessPiece(PieceType.BISHOP, Color.BLACK),
ChessPiece(PieceType.KNIGHT, Color.BLACK),
ChessPiece(PieceType.ROOK, Color.BLACK),
)
return ChessPosition(
board = board,
activeColor = Color.WHITE,
moveNumber = 1,
)
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is ChessPosition) return false
return board.contentDeepEquals(other.board) &&
activeColor == other.activeColor &&
moveNumber == other.moveNumber &&
castlingRights == other.castlingRights &&
enPassantSquare == other.enPassantSquare &&
halfMoveClock == other.halfMoveClock
}
override fun hashCode(): Int {
var result = board.contentDeepHashCode()
result = 31 * result + activeColor.hashCode()
result = 31 * result + moveNumber
result = 31 * result + castlingRights.hashCode()
result = 31 * result + (enPassantSquare?.hashCode() ?: 0)
result = 31 * result + halfMoveClock
return result
}
}
/**
* Represents a chess piece
*/
@Immutable
data class ChessPiece(
val type: PieceType,
val color: Color,
)
/**
* Castling availability
*/
@Immutable
data class CastlingRights(
val whiteKingSide: Boolean = true,
val whiteQueenSide: Boolean = true,
val blackKingSide: Boolean = true,
val blackQueenSide: Boolean = true,
)
@@ -0,0 +1,304 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip64Chess
import androidx.compose.runtime.Immutable
/**
* Deterministic chess state reconstruction from Jester protocol events.
*
* This is the single source of truth for converting a collection of Jester events
* into a consistent game state. Both Android and Desktop platforms must use this
* algorithm to ensure identical state from the same events.
*
* Jester Protocol Reconstruction:
* 1. Find start event (content.kind=0) → establishes startEventId, challenger color, players
* 2. Find move with longest history → this is the current state
* 3. Replay moves from history to build engine state
* 4. Check for result/termination in latest move → mark finished if present
*
* Key Jester differences:
* - No separate accept event (acceptance is implicit)
* - Full move history in every move event
* - Can reconstruct from any single move (has complete history)
* - startEventId is the game identifier (event ID of start event)
*
* Guarantees:
* - Same events always produce same state (deterministic)
* - Uses longest history for authoritative state
* - Invalid moves are skipped without error
*/
object ChessStateReconstructor {
/**
* Reconstruct game state from a collection of Jester events.
*
* @param events The collected Jester events for a single game
* @param viewerPubkey The pubkey of the user viewing the game (determines perspective)
* @return ReconstructionResult or error if reconstruction fails
*/
fun reconstruct(
events: JesterGameEvents,
viewerPubkey: String,
): ReconstructionResult {
// Step 1: Get start event (required for player info)
val startEvent =
events.startEvent
?: return ReconstructionResult.Error("No start event found")
val startEventId = startEvent.id
val challengerPubkey = startEvent.pubKey
val challengerColor = startEvent.playerColor() ?: Color.WHITE
val challengedPubkey = startEvent.opponentPubkey()
// In Jester, we determine opponent from the p-tag or from move events
// For open challenges, we need to find who made the first move
val opponentFromMoves =
events.moves
.firstOrNull { it.pubKey != challengerPubkey }
?.pubKey
val actualOpponent = challengedPubkey ?: opponentFromMoves
// Determine players based on challenger's color choice
val (whitePubkey, blackPubkey) =
if (challengerColor == Color.WHITE) {
challengerPubkey to actualOpponent
} else {
actualOpponent to challengerPubkey
}
// Determine viewer's role
val viewerRole =
when (viewerPubkey) {
whitePubkey -> ViewerRole.WHITE_PLAYER
blackPubkey -> ViewerRole.BLACK_PLAYER
else -> ViewerRole.SPECTATOR
}
val playerColor =
when (viewerRole) {
ViewerRole.WHITE_PLAYER -> Color.WHITE
ViewerRole.BLACK_PLAYER -> Color.BLACK
ViewerRole.SPECTATOR -> Color.WHITE // Spectators see from white's perspective
}
val opponentPubkey =
when (viewerRole) {
ViewerRole.WHITE_PLAYER -> blackPubkey ?: ""
ViewerRole.BLACK_PLAYER -> whitePubkey ?: ""
ViewerRole.SPECTATOR -> blackPubkey ?: "" // For spectators, "opponent" is black
}
// Check if game is pending (no moves yet AND viewer is the challenger)
// If viewer accepted someone else's challenge, the game is NOT pending
val isChallenger = viewerPubkey == startEvent.pubKey
val isPendingChallenge = events.moves.isEmpty() && isChallenger
// Step 2: Create engine and apply moves from the longest history
val engine = ChessEngine()
val latestMove = events.latestMove()
val history = latestMove?.history() ?: emptyList()
// Track applied moves
val appliedMoveNumbers = mutableSetOf<Int>()
var isDesynced = false
// Apply moves from history
for ((index, san) in history.withIndex()) {
val moveNumber = index + 1
val result = engine.makeMove(san)
if (result.success) {
appliedMoveNumbers.add(moveNumber)
} else {
// Move failed - game might be desynced
isDesynced = true
// Try to recover by loading the FEN from latest move if available
latestMove?.fen()?.let { fen ->
engine.loadFen(fen)
}
break
}
}
// Verify final position matches if we have FEN
latestMove?.fen()?.let { expectedFen ->
val currentFen = engine.getFen()
if (!fenPositionsMatch(currentFen, expectedFen)) {
isDesynced = true
engine.loadFen(expectedFen)
}
}
// Step 3: Check game end status
val gameResult = latestMove?.result()
val gameStatus =
when {
gameResult != null -> {
val result = parseGameResult(gameResult)
GameStatus.Finished(result)
}
engine.isCheckmate() -> {
val winner = engine.getSideToMove().opposite()
GameStatus.Finished(
if (winner == Color.WHITE) GameResult.WHITE_WINS else GameResult.BLACK_WINS,
)
}
engine.isStalemate() -> {
GameStatus.Finished(GameResult.DRAW)
}
else -> {
GameStatus.InProgress
}
}
// Get the head event ID (for linking next move)
val headEventId = latestMove?.id ?: startEventId
// Build the reconstructed state
val state =
ReconstructedGameState(
startEventId = startEventId,
headEventId = headEventId,
whitePubkey = whitePubkey,
blackPubkey = blackPubkey,
viewerRole = viewerRole,
playerColor = playerColor,
opponentPubkey = opponentPubkey,
isPendingChallenge = isPendingChallenge,
currentPosition = engine.getPosition(),
moveHistory = engine.getMoveHistory(),
gameStatus = gameStatus,
isDesynced = isDesynced,
pendingDrawOffer = null, // Jester doesn't have explicit draw offers
appliedMoveNumbers = appliedMoveNumbers,
challengeCreatedAt = startEvent.createdAt,
timeControl = null, // Not supported in Jester
)
return ReconstructionResult.Success(state, engine)
}
/**
* Compare FEN positions, ignoring halfmove clock and fullmove number.
* This provides a more lenient comparison that focuses on the actual board state.
*/
private fun fenPositionsMatch(
fen1: String,
fen2: String,
): Boolean {
// FEN format: position activeColor castling enPassant halfmove fullmove
// Compare only first 4 parts (position, activeColor, castling, enPassant)
val parts1 = fen1.split(" ")
val parts2 = fen2.split(" ")
if (parts1.size < 4 || parts2.size < 4) {
return fen1 == fen2 // Fallback to exact match
}
return parts1[0] == parts2[0] && // Board position
parts1[1] == parts2[1] && // Active color
parts1[2] == parts2[2] && // Castling rights
parts1[3] == parts2[3] // En passant
}
private fun parseGameResult(result: String?): GameResult =
when (result) {
"1-0" -> GameResult.WHITE_WINS
"0-1" -> GameResult.BLACK_WINS
"1/2-1/2" -> GameResult.DRAW
else -> GameResult.IN_PROGRESS
}
}
/**
* The viewer's role in the game.
*/
enum class ViewerRole {
WHITE_PLAYER,
BLACK_PLAYER,
SPECTATOR,
}
/**
* Result of game state reconstruction.
*/
sealed class ReconstructionResult {
data class Success(
val state: ReconstructedGameState,
val engine: ChessEngine,
) : ReconstructionResult()
data class Error(
val message: String,
) : ReconstructionResult()
fun isSuccess(): Boolean = this is Success
fun getOrNull(): ReconstructedGameState? = (this as? Success)?.state
fun getEngineOrNull(): ChessEngine? = (this as? Success)?.engine
}
/**
* Reconstructed game state from Jester events.
* This is an immutable snapshot of the game at the time of reconstruction.
*/
@Immutable
data class ReconstructedGameState(
/** The start event ID - this is the game identifier in Jester protocol */
val startEventId: String,
/** The current head event ID - used for linking the next move */
val headEventId: String,
val whitePubkey: String?,
val blackPubkey: String?,
val viewerRole: ViewerRole,
val playerColor: Color,
val opponentPubkey: String,
val isPendingChallenge: Boolean,
val currentPosition: ChessPosition,
val moveHistory: List<String>,
val gameStatus: GameStatus,
val isDesynced: Boolean,
val pendingDrawOffer: String?,
val appliedMoveNumbers: Set<Int>,
val challengeCreatedAt: Long,
val timeControl: String?,
) {
/** Legacy alias for startEventId */
@Deprecated("Use startEventId instead", ReplaceWith("startEventId"))
val gameId: String get() = startEventId
fun isPlayerTurn(): Boolean =
when (viewerRole) {
ViewerRole.SPECTATOR -> false
ViewerRole.WHITE_PLAYER -> currentPosition.activeColor == Color.WHITE
ViewerRole.BLACK_PLAYER -> currentPosition.activeColor == Color.BLACK
}
fun isFinished(): Boolean = gameStatus is GameStatus.Finished
fun hasOpponentDrawOffer(playerPubkey: String): Boolean = pendingDrawOffer != null && pendingDrawOffer != playerPubkey
fun hasOurDrawOffer(playerPubkey: String): Boolean = pendingDrawOffer == playerPubkey
}
@@ -0,0 +1,381 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip64Chess
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
/**
* Jester Protocol Implementation
*
* Compatible with jesterui (https://github.com/jesterui/jesterui)
*
* Key differences from previous implementation:
* - Single event kind (30) for all chess messages
* - Content is JSON with: version, kind, fen, move, history, nonce
* - Event linking via e-tags: [startId] or [startId, headId]
* - Full move history included in every move event
*
* Content kind values:
* - 0: Game start (challenge)
* - 1: Move
* - 2: Chat (not implemented)
*
* Reference: https://github.com/jesterui/jesterui/blob/devel/FLOW.md
*/
object JesterProtocol {
/** Jester uses kind 30 for all chess events */
const val KIND = 30
/** SHA256 of starting FEN position - used as reference for game discovery */
const val START_POSITION_HASH = "b1791d7fc9ae3d38966568c257ffb3a02cbf8394cdb4805bc70f64fc3c0b6879"
/** Standard starting FEN */
const val FEN_START = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
/** Content kind for game start */
const val CONTENT_KIND_START = 0
/** Content kind for move */
const val CONTENT_KIND_MOVE = 1
/** Content kind for chat */
const val CONTENT_KIND_CHAT = 2
}
/**
* JSON content structure for Jester events
*/
@Serializable
data class JesterContent(
val version: String = "0",
val kind: Int,
val fen: String = JesterProtocol.FEN_START,
val move: String? = null,
val history: List<String> = emptyList(),
val nonce: String? = null,
// Extended fields for Amethyst (backward compatible - jesterui ignores unknown fields)
val playerColor: String? = null, // "white" or "black" - challenger's color choice
val result: String? = null, // "1-0", "0-1", "1/2-1/2" for game end
val termination: String? = null, // "checkmate", "resignation", "draw_agreement", etc.
)
private val json =
Json {
ignoreUnknownKeys = true
encodeDefaults = false
}
/**
* Jester Chess Event (Kind 30)
*
* Unified event class for all Jester chess messages.
* The content.kind field determines the event type:
* - 0: Game start/challenge
* - 1: Move
* - 2: Chat
*/
@Immutable
class JesterEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, JesterProtocol.KIND, tags, content, sig) {
private val parsedContent: JesterContent? by lazy {
try {
json.decodeFromString<JesterContent>(content)
} catch (e: Exception) {
null
}
}
/** Get the content kind (0=start, 1=move, 2=chat) */
fun contentKind(): Int? = parsedContent?.kind
/** Check if this is a game start event */
fun isStartEvent(): Boolean = parsedContent?.kind == JesterProtocol.CONTENT_KIND_START
/** Check if this is a move event */
fun isMoveEvent(): Boolean = parsedContent?.kind == JesterProtocol.CONTENT_KIND_MOVE
/** Get the FEN position */
fun fen(): String? = parsedContent?.fen
/** Get the latest move (SAN notation) */
fun move(): String? = parsedContent?.move
/** Get the full move history */
fun history(): List<String> = parsedContent?.history ?: emptyList()
/** Get the nonce (for start events) */
fun nonce(): String? = parsedContent?.nonce
/** Get the player color choice (for start events) */
fun playerColor(): Color? =
parsedContent?.playerColor?.let {
if (it == "white") Color.WHITE else Color.BLACK
}
/** Get the game result (for end events) */
fun result(): String? = parsedContent?.result
/** Get the termination reason (for end events) */
fun termination(): String? = parsedContent?.termination
/** Get the start event ID (first e-tag) */
fun startEventId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "e" }?.get(1)
/** Get the head/parent move ID (second e-tag, for moves) */
fun headEventId(): String? = tags.filter { it.size >= 2 && it[0] == "e" }.getOrNull(1)?.get(1)
/** Get opponent pubkey (p-tag, for private games) */
fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1)
/** Get all e-tags */
fun eTags(): List<String> = tags.filter { it.size >= 2 && it[0] == "e" }.map { it[1] }
companion object {
const val KIND = JesterProtocol.KIND
/**
* Build a game start event (open challenge)
*
* @param playerColor Color the challenger wants to play
* @param nonce Unique nonce for this game
* @param createdAt Event timestamp
*/
fun buildStart(
playerColor: Color,
nonce: String = generateNonce(),
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<JesterEvent>.() -> Unit = {},
): EventTemplate<JesterEvent> {
val content =
JesterContent(
kind = JesterProtocol.CONTENT_KIND_START,
fen = JesterProtocol.FEN_START,
history = emptyList(),
nonce = nonce,
playerColor = if (playerColor == Color.WHITE) "white" else "black",
)
return eventTemplate(KIND, json.encodeToString(content), createdAt) {
// Reference the standard start position for game discovery
add(arrayOf("e", JesterProtocol.START_POSITION_HASH))
initializer()
}
}
/**
* Build a private game start event (direct challenge)
*
* @param opponentPubkey Pubkey of the challenged player
* @param playerColor Color the challenger wants to play
* @param nonce Unique nonce for this game
* @param createdAt Event timestamp
*/
fun buildPrivateStart(
opponentPubkey: String,
playerColor: Color,
nonce: String = generateNonce(),
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<JesterEvent>.() -> Unit = {},
): EventTemplate<JesterEvent> {
val content =
JesterContent(
kind = JesterProtocol.CONTENT_KIND_START,
fen = JesterProtocol.FEN_START,
history = emptyList(),
nonce = nonce,
playerColor = if (playerColor == Color.WHITE) "white" else "black",
)
return eventTemplate(KIND, json.encodeToString(content), createdAt) {
// Reference the standard start position
add(arrayOf("e", JesterProtocol.START_POSITION_HASH))
// Tag the opponent for notifications (only if valid pubkey)
if (opponentPubkey.isNotEmpty() && opponentPubkey.length == 64) {
add(arrayOf("p", opponentPubkey))
}
initializer()
}
}
/**
* Build a move event
*
* @param startEventId ID of the game start event
* @param headEventId ID of the previous move (or start event for first move)
* @param move The move in SAN notation (e.g., "e4", "Nf3")
* @param fen Resulting board position in FEN
* @param history Complete move history including this move
* @param opponentPubkey Opponent's pubkey for notifications
* @param createdAt Event timestamp
*/
fun buildMove(
startEventId: String,
headEventId: String,
move: String,
fen: String,
history: List<String>,
opponentPubkey: String,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<JesterEvent>.() -> Unit = {},
): EventTemplate<JesterEvent> {
val content =
JesterContent(
kind = JesterProtocol.CONTENT_KIND_MOVE,
fen = fen,
move = move,
history = history,
)
return eventTemplate(KIND, json.encodeToString(content), createdAt) {
// e-tags: [startId, headId]
add(arrayOf("e", startEventId))
add(arrayOf("e", headEventId))
// Tag opponent for notifications (only if valid pubkey)
if (opponentPubkey.isNotEmpty() && opponentPubkey.length == 64) {
add(arrayOf("p", opponentPubkey))
}
initializer()
}
}
/**
* Build a game end move (includes result in content)
*
* This is a move event with additional result/termination fields.
* Used when the game ends (checkmate, resignation, draw).
*/
fun buildEndMove(
startEventId: String,
headEventId: String,
move: String?,
fen: String,
history: List<String>,
opponentPubkey: String,
result: GameResult,
termination: GameTermination,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<JesterEvent>.() -> Unit = {},
): EventTemplate<JesterEvent> {
val content =
JesterContent(
kind = JesterProtocol.CONTENT_KIND_MOVE,
fen = fen,
move = move,
history = history,
result = result.notation,
termination = termination.name.lowercase(),
)
return eventTemplate(KIND, json.encodeToString(content), createdAt) {
add(arrayOf("e", startEventId))
add(arrayOf("e", headEventId))
// Tag opponent for notifications (only if valid pubkey)
if (opponentPubkey.isNotEmpty() && opponentPubkey.length == 64) {
add(arrayOf("p", opponentPubkey))
}
initializer()
}
}
private fun generateNonce(): String {
val chars = "abcdefghijklmnopqrstuvwxyz0123456789"
return (1..8).map { chars.random() }.joinToString("")
}
}
}
/**
* Helper to check if an event is a Jester chess event
*/
fun Event.isJesterEvent(): Boolean = kind == JesterProtocol.KIND
/**
* Helper to check if an event is a Jester start event
*/
fun Event.isJesterStartEvent(): Boolean {
if (kind != JesterProtocol.KIND) return false
return try {
val content = json.decodeFromString<JesterContent>(this.content)
content.kind == JesterProtocol.CONTENT_KIND_START && content.history.isEmpty()
} catch (e: Exception) {
false
}
}
/**
* Helper to check if an event is a Jester move event
*/
fun Event.isJesterMoveEvent(): Boolean {
if (kind != JesterProtocol.KIND) return false
return try {
val content = json.decodeFromString<JesterContent>(this.content)
content.kind == JesterProtocol.CONTENT_KIND_MOVE &&
content.history.isNotEmpty() &&
tags.count { it.size >= 2 && it[0] == "e" } == 2
} catch (e: Exception) {
false
}
}
/**
* Convert a raw Event to JesterEvent if valid
*/
fun Event.toJesterEvent(): JesterEvent? {
if (kind != JesterProtocol.KIND) return null
return JesterEvent(id, pubKey, createdAt, tags, content, sig)
}
/**
* Game events container for Jester protocol
*/
data class JesterGameEvents(
val startEvent: JesterEvent?,
val moves: List<JesterEvent>,
) {
companion object {
fun empty() = JesterGameEvents(null, emptyList())
}
/** Get the latest move (with longest history) */
fun latestMove(): JesterEvent? = moves.maxByOrNull { it.history().size }
/** Get the current FEN position */
fun currentFen(): String = latestMove()?.fen() ?: startEvent?.fen() ?: JesterProtocol.FEN_START
/** Get the complete move history */
fun fullHistory(): List<String> = latestMove()?.history() ?: emptyList()
/** Check if game has ended */
fun isEnded(): Boolean = latestMove()?.result() != null
/** Get the game result if ended */
fun result(): String? = latestMove()?.result()
}
@@ -0,0 +1,224 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip64Chess
/*
* Live chess game state and move events
*
* Uses Jester protocol (kind 30) for compatibility with jesterui.
* See: https://github.com/jesterui/jesterui/blob/devel/FLOW.md
*
* Key features of Jester protocol:
* - Single event kind (30) for all chess messages
* - Content is JSON with: version, kind (0=start, 1=move), fen, move, history
* - Full move history included in every move event
* - Event linking via e-tags: [startId] or [startId, headId]
*
* This enables:
* - Easy game reconstruction from any single move event
* - Tolerance for missing intermediate moves
* - Compatibility with jesterui and other Jester clients
*/
/**
* Game start/challenge data for publishing
*
* Jester protocol (kind 30) start event:
* - e-tag: reference to standard start position hash
* - p-tag: opponent pubkey (for private/direct challenges)
* - Content JSON with: version, kind=0, fen, history=[], nonce, playerColor
*
* @param opponentPubkey Opponent's pubkey for direct challenge (null = open challenge)
* @param playerColor Color the challenger wants to play
* @param nonce Unique identifier for this game
*/
data class ChessGameChallenge(
val opponentPubkey: String? = null, // null = open challenge
val playerColor: Color,
val nonce: String = generateNonce(),
) {
companion object {
private fun generateNonce(): String {
val chars = "abcdefghijklmnopqrstuvwxyz0123456789"
return (1..8).map { chars.random() }.joinToString("")
}
}
// Legacy compatibility - gameId is now derived from start event ID after signing
@Deprecated("gameId is determined after event creation", ReplaceWith("startEventId"))
val gameId: String get() = nonce
}
/**
* Game acceptance data
*
* In Jester protocol, accepting a game is implicit - the opponent
* simply makes the first move (if challenger chose white) or waits
* for challenger's first move (if challenger chose black).
*
* This data class is kept for internal state tracking.
*
* @param startEventId ID of the game start event to accept
* @param challengerPubkey Pubkey of the challenger
*/
data class ChessGameAccept(
val startEventId: String,
val challengerPubkey: String,
) {
// Legacy compatibility
@Deprecated("Use startEventId instead", ReplaceWith("startEventId"))
val gameId: String get() = startEventId
@Deprecated("Use startEventId instead", ReplaceWith("startEventId"))
val challengeEventId: String get() = startEventId
}
/**
* Chess move data for publishing
*
* Jester protocol (kind 30) requires:
* - Full move history in every move event
* - e-tags linking: [startEventId, headEventId]
* - Content JSON with: version, kind=1, fen, move, history
*
* @param startEventId ID of the game start event
* @param headEventId ID of the previous move (or startEventId for first move)
* @param san Move in Standard Algebraic Notation (e.g., "e4", "Nf3")
* @param fen Resulting board position in FEN notation
* @param history Complete move history including this move
* @param opponentPubkey Opponent's pubkey for p-tag notification
*/
data class ChessMoveEvent(
val startEventId: String,
val headEventId: String,
val san: String,
val fen: String,
val history: List<String>,
val opponentPubkey: String,
) {
/** Move number (1-based, derived from history length) */
val moveNumber: Int get() = history.size
// Legacy compatibility - some code still uses gameId
@Deprecated("Use startEventId instead", ReplaceWith("startEventId"))
val gameId: String get() = startEventId
}
/**
* Game end data for publishing
*
* In Jester protocol, game end is a move event with result/termination
* fields in the content JSON.
*
* @param startEventId ID of the game start event
* @param headEventId ID of the previous move
* @param lastMove The final move (null for resignation without move)
* @param fen Final board position
* @param history Complete move history
* @param result Game result (1-0, 0-1, 1/2-1/2)
* @param termination How the game ended
* @param opponentPubkey Opponent's pubkey for notification
*/
data class ChessGameEnd(
val startEventId: String,
val headEventId: String,
val lastMove: String?,
val fen: String,
val history: List<String>,
val result: GameResult,
val termination: GameTermination,
val opponentPubkey: String,
) {
// Legacy compatibility
@Deprecated("Use startEventId instead", ReplaceWith("startEventId"))
val gameId: String get() = startEventId
val winnerPubkey: String? get() = null // Determined from result
}
/**
* Draw offer data
*
* In Jester protocol, draw offers can be communicated via:
* - Chat message (kind=2 in content)
* - Special field in move content
*
* Opponent can:
* - Accept by sending a game end with DRAW_AGREEMENT
* - Decline implicitly by making their next move
*
* @param startEventId ID of the game start event
* @param headEventId ID of the current head move
* @param opponentPubkey Opponent's pubkey for notification
* @param message Optional message with the draw offer
*/
data class ChessDrawOffer(
val startEventId: String,
val headEventId: String,
val opponentPubkey: String,
val message: String? = null,
) {
// Legacy compatibility
@Deprecated("Use startEventId instead", ReplaceWith("startEventId"))
val gameId: String get() = startEventId
}
/**
* Game termination reason
*/
enum class GameTermination {
CHECKMATE,
RESIGNATION,
DRAW_AGREEMENT,
STALEMATE,
TIMEOUT,
ABANDONMENT,
}
/**
* Event kind constants for live chess (Jester protocol)
*
* Jester uses a single kind (30) for all chess events.
* The event type is determined by the content.kind field:
* - 0: Game start/challenge
* - 1: Move
* - 2: Chat
*/
object LiveChessEventKinds {
/** Jester protocol uses kind 30 for all chess events */
const val JESTER = 30
// Legacy constants - deprecated, use JESTER instead
@Deprecated("Jester uses single kind 30", ReplaceWith("JESTER"))
const val GAME_CHALLENGE = 30
@Deprecated("Jester uses single kind 30", ReplaceWith("JESTER"))
const val GAME_ACCEPT = 30
@Deprecated("Jester uses single kind 30", ReplaceWith("JESTER"))
const val CHESS_MOVE = 30
@Deprecated("Jester uses single kind 30", ReplaceWith("JESTER"))
const val GAME_END = 30
@Deprecated("Jester uses single kind 30", ReplaceWith("JESTER"))
const val DRAW_OFFER = 30
}
@@ -0,0 +1,295 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip64Chess
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* Live Chess Game Challenge Event (Kind 30064)
*
* Challenge another player to a chess game or create an open challenge.
* This is a parameterized replaceable event.
*
* Tags:
* - d: game_id (unique identifier for this game)
* - p: opponent pubkey (optional, for direct challenges)
* - player_color: "white" or "black"
* - time_control: optional time control (e.g., "10+0", "5+3")
*/
@Immutable
class LiveChessGameChallengeEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
companion object {
const val KIND = 30064
fun build(
gameId: String,
playerColor: Color,
opponentPubkey: String? = null,
timeControl: String? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<LiveChessGameChallengeEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
add(arrayOf("d", gameId))
add(arrayOf("player_color", if (playerColor == Color.WHITE) "white" else "black"))
opponentPubkey?.let { add(arrayOf("p", it)) }
timeControl?.let { add(arrayOf("time_control", it)) }
alt("Chess game challenge")
initializer()
}
}
fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1)
fun playerColor(): Color? =
tags
.firstOrNull { it.size >= 2 && it[0] == "player_color" }
?.get(1)
?.let { if (it == "white") Color.WHITE else Color.BLACK }
fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1)
fun timeControl(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "time_control" }?.get(1)
}
/**
* Live Chess Game Accept Event (Kind 30065)
*
* Accept a chess game challenge
*
* Tags:
* - d: game_id (same as challenge)
* - e: challenge event ID
* - p: challenger pubkey
*/
@Immutable
class LiveChessGameAcceptEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
companion object {
const val KIND = 30065
fun build(
gameId: String,
challengeEventId: String,
challengerPubkey: String,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<LiveChessGameAcceptEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
add(arrayOf("d", gameId))
add(arrayOf("e", challengeEventId))
add(arrayOf("p", challengerPubkey))
alt("Chess game acceptance")
initializer()
}
}
fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1)
fun challengeEventId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "e" }?.get(1)
fun challengerPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1)
}
/**
* Live Chess Move Event (Kind 30066)
*
* Individual move in a live chess game
*
* Tags:
* - d: game_id
* - move_number: move number (1-based)
* - san: move in Standard Algebraic Notation
* - fen: resulting position in FEN notation
* - p: opponent pubkey
*
* Content: Optional move comment
*/
@Immutable
class LiveChessMoveEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
companion object {
const val KIND = 30066
fun build(
gameId: String,
moveNumber: Int,
san: String,
fen: String,
opponentPubkey: String,
comment: String = "",
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<LiveChessMoveEvent>.() -> Unit = {},
) = eventTemplate(KIND, comment, createdAt) {
add(arrayOf("d", "$gameId-$moveNumber"))
add(arrayOf("game_id", gameId))
add(arrayOf("move_number", moveNumber.toString()))
add(arrayOf("san", san))
add(arrayOf("fen", fen))
add(arrayOf("p", opponentPubkey))
alt("Chess move: $san")
initializer()
}
}
fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "game_id" }?.get(1)
fun moveNumber(): Int? =
tags
.firstOrNull { it.size >= 2 && it[0] == "move_number" }
?.get(1)
?.toIntOrNull()
fun san(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "san" }?.get(1)
fun fen(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "fen" }?.get(1)
fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1)
fun comment(): String = content
}
/**
* Live Chess Game End Event (Kind 30067)
*
* Game result and termination
*
* Tags:
* - d: game_id
* - result: "1-0"|"0-1"|"1/2-1/2"
* - termination: reason for game end
* - winner: pubkey of winner (if applicable)
* - p: opponent pubkey
*
* Content: Optional PGN of complete game
*/
@Immutable
class LiveChessGameEndEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
companion object {
const val KIND = 30067
fun build(
gameId: String,
result: GameResult,
termination: GameTermination,
winnerPubkey: String? = null,
opponentPubkey: String,
pgn: String = "",
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<LiveChessGameEndEvent>.() -> Unit = {},
) = eventTemplate(KIND, pgn, createdAt) {
add(arrayOf("d", gameId))
add(arrayOf("result", result.notation))
add(arrayOf("termination", termination.name.lowercase()))
winnerPubkey?.let { add(arrayOf("winner", it)) }
add(arrayOf("p", opponentPubkey))
alt("Chess game ended: ${result.notation}")
initializer()
}
}
fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1)
fun result(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "result" }?.get(1)
fun termination(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "termination" }?.get(1)
fun winnerPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "winner" }?.get(1)
fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1)
fun pgn(): String = content
}
/**
* Live Chess Draw Offer Event (Kind 30068)
*
* Offer a draw to opponent. Opponent can accept by sending a game end event
* with DRAW_AGREEMENT termination, or decline/ignore by making their next move.
*
* Tags:
* - d: game_id
* - p: opponent pubkey
*
* Content: Optional message
*/
@Immutable
class LiveChessDrawOfferEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
companion object {
const val KIND = 30068
fun build(
gameId: String,
opponentPubkey: String,
message: String = "",
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<LiveChessDrawOfferEvent>.() -> Unit = {},
) = eventTemplate(KIND, message, createdAt) {
add(arrayOf("d", gameId))
add(arrayOf("p", opponentPubkey))
alt("Chess draw offer")
initializer()
}
}
fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1)
fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1)
fun message(): String = content
}
@@ -0,0 +1,548 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip64Chess
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Manages state for a live chess game
*
* This class coordinates between:
* - ChessEngine (move validation, board state)
* - Nostr events (publishing moves, receiving opponent moves)
* - UI (game state, turn management)
*
* Jester Protocol Notes:
* - startEventId: ID of the game start event (used as game identifier)
* - headEventId: ID of the current head move (updated after each move)
* - Full move history is included in every published move event
*/
class LiveChessGameState(
/** Start event ID - the game identifier in Jester protocol */
val startEventId: String,
val playerPubkey: String,
val opponentPubkey: String,
val playerColor: Color,
val engine: ChessEngine,
val createdAt: Long = TimeUtils.now(),
val isSpectator: Boolean = false,
val isPendingChallenge: Boolean = false,
/** Initial head event ID - same as startEventId for new games */
initialHeadEventId: String? = null,
) {
/** Legacy alias for startEventId */
@Deprecated("Use startEventId instead", ReplaceWith("startEventId"))
val gameId: String get() = startEventId
/** Current head event ID - tracks the most recent move for e-tag linking */
private val _headEventId = MutableStateFlow(initialHeadEventId ?: startEventId)
val headEventId: StateFlow<String> = _headEventId.asStateFlow()
companion object {
// Abandon timeout: 24 hours without a move
const val ABANDON_TIMEOUT_SECONDS = 24 * 60 * 60L
// Inactivity warning: 1 hour without a move
const val INACTIVITY_WARNING_SECONDS = 60 * 60L
}
private val _gameStatus = MutableStateFlow<GameStatus>(GameStatus.InProgress)
val gameStatus: StateFlow<GameStatus> = _gameStatus.asStateFlow()
private val _currentPosition = MutableStateFlow(engine.getPosition())
val currentPosition: StateFlow<ChessPosition> = _currentPosition.asStateFlow()
// Initialize from engine's history so freshly loaded games show all moves
private val _moveHistory = MutableStateFlow(engine.getMoveHistory())
val moveHistory: StateFlow<List<String>> = _moveHistory.asStateFlow()
private val _lastError = MutableStateFlow<String?>(null)
val lastError: StateFlow<String?> = _lastError.asStateFlow()
// Track when last activity occurred (move made or received)
private val _lastActivityAt = MutableStateFlow(createdAt)
val lastActivityAt: StateFlow<Long> = _lastActivityAt.asStateFlow()
// Track received move numbers to detect duplicates and out-of-order
private val receivedMoveNumbers = mutableSetOf<Int>()
// Pending moves waiting for earlier moves (move number -> move data)
private val pendingMoves = mutableMapOf<Int, Pair<String, String>>()
// Desync detection
private val _isDesynced = MutableStateFlow(false)
val isDesynced: StateFlow<Boolean> = _isDesynced.asStateFlow()
// Draw offer tracking - pubkey of who offered the draw (null = no pending offer)
private val _pendingDrawOffer = MutableStateFlow<String?>(null)
val pendingDrawOffer: StateFlow<String?> = _pendingDrawOffer.asStateFlow()
/**
* Check if there's a pending draw offer from opponent
*/
fun hasOpponentDrawOffer(): Boolean = _pendingDrawOffer.value == opponentPubkey
/**
* Check if we have an outgoing draw offer
*/
fun hasOurDrawOffer(): Boolean = _pendingDrawOffer.value == playerPubkey
/**
* Check if it's the player's turn
* Spectators never have a turn
*/
fun isPlayerTurn(): Boolean = !isSpectator && engine.getSideToMove() == playerColor
/**
* Make a move (called when player makes a move on the board)
* Returns the move event data to be published to Nostr
*
* Jester Protocol: Move events include full history and link to previous event
*/
fun makeMove(
from: String,
to: String,
promotion: PieceType? = null,
): ChessMoveEvent? {
if (!isPlayerTurn()) {
_lastError.value = "Not your turn"
return null
}
if (_gameStatus.value != GameStatus.InProgress) {
_lastError.value = "Game is not in progress"
return null
}
val result = engine.makeMove(from, to, promotion)
if (result.success && result.san != null && result.position != null) {
_currentPosition.value = result.position
_moveHistory.value = engine.getMoveHistory()
_lastActivityAt.value = TimeUtils.now()
_lastError.value = null
// Making a move implicitly declines any pending draw offer
_pendingDrawOffer.value = null
// Check for game end conditions
checkGameEnd()
// Return move event data with full history for Jester protocol
return ChessMoveEvent(
startEventId = startEventId,
headEventId = _headEventId.value,
san = result.san,
fen = engine.getFen(),
history = _moveHistory.value, // Full history for Jester
opponentPubkey = opponentPubkey,
)
} else {
_lastError.value = result.error ?: "Invalid move"
return null
}
}
/**
* Update the head event ID after a move is successfully published.
* Call this after the move event is signed and broadcast.
*
* @param newHeadEventId The ID of the newly published move event
*/
fun updateHeadEventId(newHeadEventId: String) {
_headEventId.value = newHeadEventId
}
/**
* Undo the last move made.
* Used to revert a move when publishing fails.
*
* @return true if the move was successfully undone, false if there was nothing to undo
*/
fun undoLastMove(): Boolean {
if (_moveHistory.value.isEmpty()) return false
engine.undoMove()
_currentPosition.value = engine.getPosition()
_moveHistory.value = engine.getMoveHistory()
_lastError.value = null
// Reset game status in case checkmate was detected
if (_gameStatus.value is GameStatus.Finished) {
_gameStatus.value = GameStatus.InProgress
}
return true
}
/**
* Apply opponent's move (called when receiving move event from Nostr)
*/
fun applyOpponentMove(
san: String,
fen: String,
moveNumber: Int? = null,
): Boolean {
// Check for duplicate move
if (moveNumber != null && receivedMoveNumbers.contains(moveNumber)) {
// Already processed this move, ignore
return true
}
// Check for out-of-order move
val expectedMoveNumber = _moveHistory.value.size + 1
if (moveNumber != null && moveNumber > expectedMoveNumber) {
// Store for later processing
pendingMoves[moveNumber] = san to fen
return true
}
if (isPlayerTurn()) {
_lastError.value = "Received move but it's not opponent's turn"
return false
}
// Verify the move is valid by trying to make it
val result = engine.makeMove(san)
if (result.success) {
// Mark as received
if (moveNumber != null) {
receivedMoveNumbers.add(moveNumber)
}
// Verify the resulting FEN matches what opponent sent
val currentFen = engine.getFen()
if (currentFen != fen) {
// Positions don't match - desync detected
_isDesynced.value = true
_lastError.value = "Position mismatch - syncing to opponent's position"
// Load the opponent's FEN to stay in sync
engine.loadFen(fen)
} else {
_isDesynced.value = false
}
_currentPosition.value = engine.getPosition()
_moveHistory.value = engine.getMoveHistory()
_lastActivityAt.value = TimeUtils.now()
_lastError.value = null
// Opponent making a move declines any pending draw offer
_pendingDrawOffer.value = null
// Check for game end conditions
checkGameEnd()
// Process any pending moves that are now valid
processPendingMoves()
return true
} else {
_lastError.value = "Invalid opponent move: $san"
return false
}
}
/**
* Process any pending out-of-order moves
*/
private fun processPendingMoves() {
val nextMoveNumber = _moveHistory.value.size + 1
val pendingMove = pendingMoves.remove(nextMoveNumber)
if (pendingMove != null) {
val (san, fen) = pendingMove
applyOpponentMove(san, fen, nextMoveNumber)
}
}
/**
* Force resync to a specific FEN (manual recovery)
*/
fun forceResync(fen: String) {
engine.loadFen(fen)
_currentPosition.value = engine.getPosition()
_moveHistory.value = engine.getMoveHistory()
_isDesynced.value = false
_lastError.value = null
}
/**
* Apply moves from another game state while preserving this state's participant info.
* This is used when relay reconstruction incorrectly marks us as spectator but has newer moves.
*
* @param other The other game state to take moves from
*/
fun applyMovesFrom(other: LiveChessGameState) {
val currentMoves = _moveHistory.value
val otherMoves = other.moveHistory.value
if (otherMoves.size <= currentMoves.size) return // Nothing new to apply
// Apply only the new moves
for (i in currentMoves.size until otherMoves.size) {
val san = otherMoves[i]
val result = engine.makeMove(san)
if (!result.success) {
// Try to resync from the other engine's FEN
val otherFen = other.engine.getFen()
engine.loadFen(otherFen)
break
}
}
// Update state flows
_currentPosition.value = engine.getPosition()
_moveHistory.value = engine.getMoveHistory()
_lastActivityAt.value = other.lastActivityAt.value
_isDesynced.value = false
_lastError.value = null
// Update head event ID from other state
_headEventId.value = other.headEventId.value
// Mark new moves as received
val newMoveNumbers = (currentMoves.size + 1..otherMoves.size).toSet()
receivedMoveNumbers.addAll(newMoveNumbers)
// Check for game end conditions
checkGameEnd()
}
/**
* Mark move numbers as already received.
* Used when loading a game from cache to prevent duplicate move application during refresh.
*
* @param moveNumbers Set of move numbers that have been loaded
*/
fun markMovesAsReceived(moveNumbers: Set<Int>) {
receivedMoveNumbers.addAll(moveNumbers)
}
/**
* Check if game appears abandoned (opponent hasn't moved in a long time)
*/
fun isAbandoned(): Boolean {
if (_gameStatus.value != GameStatus.InProgress) return false
if (isPlayerTurn()) return false // Only check when waiting for opponent
val elapsed = TimeUtils.now() - _lastActivityAt.value
return elapsed > ABANDON_TIMEOUT_SECONDS
}
/**
* Check if opponent is inactive (warning threshold)
*/
fun isOpponentInactive(): Boolean {
if (_gameStatus.value != GameStatus.InProgress) return false
if (isPlayerTurn()) return false
val elapsed = TimeUtils.now() - _lastActivityAt.value
return elapsed > INACTIVITY_WARNING_SECONDS
}
/**
* Claim victory due to abandonment
*/
fun claimAbandonmentVictory(): ChessGameEnd? {
if (!isAbandoned()) return null
_gameStatus.value = GameStatus.Finished(GameResult.getResultForWinner(playerColor))
return ChessGameEnd(
startEventId = startEventId,
headEventId = _headEventId.value,
lastMove = null,
fen = engine.getFen(),
history = _moveHistory.value,
result = GameResult.getResultForWinner(playerColor),
termination = GameTermination.ABANDONMENT,
opponentPubkey = opponentPubkey,
)
}
/**
* Offer resignation (player resigns)
*/
fun resign(): ChessGameEnd {
_gameStatus.value = GameStatus.Finished(GameResult.getResultForWinner(playerColor.opposite()))
return ChessGameEnd(
startEventId = startEventId,
headEventId = _headEventId.value,
lastMove = null,
fen = engine.getFen(),
history = _moveHistory.value,
result = GameResult.getResultForWinner(playerColor.opposite()),
termination = GameTermination.RESIGNATION,
opponentPubkey = opponentPubkey,
)
}
/**
* Offer a draw to opponent.
* Returns the draw offer data to be published to Nostr.
*/
fun offerDraw(): ChessDrawOffer {
_pendingDrawOffer.value = playerPubkey
return ChessDrawOffer(
startEventId = startEventId,
headEventId = _headEventId.value,
opponentPubkey = opponentPubkey,
)
}
/**
* Handle receiving a draw offer from opponent (via Nostr event)
*/
fun receiveDrawOffer(fromPubkey: String): Boolean {
if (fromPubkey != opponentPubkey) return false
_pendingDrawOffer.value = opponentPubkey
return true
}
/**
* Accept opponent's draw offer.
* Returns game end data to be published to Nostr.
*/
fun acceptDraw(): ChessGameEnd? {
if (_pendingDrawOffer.value != opponentPubkey) {
_lastError.value = "No draw offer to accept"
return null
}
_pendingDrawOffer.value = null
_gameStatus.value = GameStatus.Finished(GameResult.DRAW)
return ChessGameEnd(
startEventId = startEventId,
headEventId = _headEventId.value,
lastMove = null,
fen = engine.getFen(),
history = _moveHistory.value,
result = GameResult.DRAW,
termination = GameTermination.DRAW_AGREEMENT,
opponentPubkey = opponentPubkey,
)
}
/**
* Decline a draw offer (explicit decline, also happens implicitly on move)
*/
fun declineDraw() {
_pendingDrawOffer.value = null
}
/**
* Check if game has ended (checkmate, stalemate, etc.)
*/
private fun checkGameEnd() {
when {
engine.isCheckmate() -> {
val winner = engine.getSideToMove().opposite()
_gameStatus.value = GameStatus.Finished(GameResult.getResultForWinner(winner))
// In a real implementation, you'd publish GameEnd event here
}
engine.isStalemate() -> {
_gameStatus.value = GameStatus.Finished(GameResult.DRAW)
// In a real implementation, you'd publish GameEnd event here
}
}
}
/**
* Generate PGN for the current game
*/
private fun generatePGN(): String {
val moves = _moveHistory.value
val movePairs = moves.chunked(2)
val moveText =
movePairs
.mapIndexed { index, pair ->
val moveNum = index + 1
when (pair.size) {
2 -> "$moveNum. ${pair[0]} ${pair[1]}"
1 -> "$moveNum. ${pair[0]}"
else -> ""
}
}.joinToString(" ")
val result =
when (val status = _gameStatus.value) {
is GameStatus.Finished -> status.result.notation
else -> "*"
}
return """
[Event "Live Chess Game"]
[Site "Nostr"]
[White "${if (playerColor == Color.WHITE) playerPubkey else opponentPubkey}"]
[Black "${if (playerColor == Color.BLACK) playerPubkey else opponentPubkey}"]
[Result "$result"]
$moveText $result
""".trimIndent()
}
/**
* Mark game as finished with the given result.
* Used when loading a game from cache that already has an end event.
*/
fun markAsFinished(result: GameResult) {
_gameStatus.value = GameStatus.Finished(result)
}
/**
* Reset game to initial position
*/
fun reset() {
engine.reset()
_currentPosition.value = engine.getPosition()
_moveHistory.value = emptyList()
_gameStatus.value = GameStatus.InProgress
_lastError.value = null
}
}
/**
* Game status
*/
sealed class GameStatus {
data object InProgress : GameStatus()
data class Finished(
val result: GameResult,
) : GameStatus()
}
/**
* Extension to get result for a winning color
*/
private fun GameResult.Companion.getResultForWinner(winner: Color): GameResult =
when (winner) {
Color.WHITE -> GameResult.WHITE_WINS
Color.BLACK -> GameResult.BLACK_WINS
}
@@ -0,0 +1,325 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip64Chess
/**
* Parse PGN (Portable Game Notation) format per NIP-64 specification
*
* Accepts "import format" (human-created, flexible) as per PGN spec
* Handles:
* - Tag pairs: [TagName "TagValue"]
* - Move text in Standard Algebraic Notation (SAN)
* - Comments: {...}
* - Variations: (...)
* - Result markers: 1-0, 0-1, 1/2-1/2, *
*/
object PGNParser {
/**
* Parse PGN content into ChessGame
*
* @param pgn PGN format string
* @return Result containing ChessGame or error
*/
fun parse(pgn: String): Result<ChessGame> =
runCatching {
val lines = pgn.lines().map { it.trim() }
// Extract metadata tags [Key "Value"]
val metadata = parseMetadata(lines)
// Extract move text (everything after metadata)
val moveText =
lines
.dropWhile { it.startsWith("[") || it.isEmpty() }
.joinToString(" ")
val (moves, result) = parseMoves(moveText)
// Generate positions by replaying moves
val positions = generatePositions(moves)
ChessGame(
metadata = metadata,
moves = moves,
positions = positions,
result = result,
)
}
/**
* Extract PGN tag pairs from lines
* Format: [TagName "TagValue"]
*/
private fun parseMetadata(lines: List<String>): Map<String, String> {
val tagRegex = """\[(\w+)\s+"([^"]*)"\]""".toRegex()
return lines
.mapNotNull { tagRegex.matchEntire(it) }
.associate { it.groupValues[1] to it.groupValues[2] }
}
/**
* Parse move text into list of moves and game result
*/
private fun parseMoves(moveText: String): Pair<List<ChessMove>, GameResult> {
// Remove comments {...} and variations (...)
val cleaned =
moveText
.replace(Regex("""\{[^}]*\}"""), "") // Remove comments
.replace(Regex("""\([^)]*\)"""), "") // Remove variations
.replace(Regex("""\$\d+"""), "") // Remove NAG annotations
.trim()
// Extract result marker
val result =
when {
cleaned.contains("1-0") -> GameResult.WHITE_WINS
cleaned.contains("0-1") -> GameResult.BLACK_WINS
cleaned.contains("1/2-1/2") -> GameResult.DRAW
else -> GameResult.IN_PROGRESS
}
// Remove result marker from move text
val movesOnly =
cleaned
.replace("1-0", "")
.replace("0-1", "")
.replace("1/2-1/2", "")
.replace("*", "")
.replace(Regex("""\s+"""), " ") // Normalize whitespace
.trim()
// Parse move pairs: "1. e4 e5 2. Nf3 Nc6"
val moveRegex = """(\d+)\.\s*([^\s]+)(?:\s+([^\s]+))?""".toRegex()
val moves = mutableListOf<ChessMove>()
moveRegex.findAll(movesOnly).forEach { match ->
val moveNum = match.groupValues[1].toIntOrNull() ?: return@forEach
val whiteMove = match.groupValues[2]
val blackMove = match.groupValues.getOrNull(3)?.takeIf { it.isNotEmpty() }
// Skip if this is just a result marker
if (!isResultMarker(whiteMove)) {
moves.add(parseMove(whiteMove, moveNum, Color.WHITE))
}
blackMove?.let {
if (!isResultMarker(it)) {
moves.add(parseMove(it, moveNum, Color.BLACK))
}
}
}
return moves to result
}
/**
* Check if text is a game result marker
* Valid results: 1-0, 0-1, 1/2-1/2, *
*/
private fun isResultMarker(text: String): Boolean = text == "1-0" || text == "0-1" || text == "1/2-1/2" || text == "*"
/**
* Parse a single move in Standard Algebraic Notation (SAN)
*
* Examples:
* - e4 (pawn move)
* - Nf3 (knight to f3)
* - Bxe5 (bishop captures on e5)
* - O-O (kingside castling)
* - O-O-O (queenside castling)
* - e8=Q (pawn promotion to queen)
* - Nbd2 (knight from b-file to d2)
* - R1a3 (rook from rank 1 to a3)
* - Qh4+ (queen to h4 with check)
* - Qh4# (queen to h4 with checkmate)
*/
private fun parseMove(
san: String,
moveNumber: Int,
color: Color,
): ChessMove {
// Clean up annotations
var text = san.replace(Regex("[!?]+"), "").trim()
val isCheck = text.contains("+")
val isCheckmate = text.contains("#")
val isCapture = text.contains("x")
// Remove check/checkmate markers
text = text.replace("+", "").replace("#", "")
// Castling
if (text == "O-O" || text == "0-0") {
return ChessMove(
san = san,
moveNumber = moveNumber,
color = color,
piece = PieceType.KING,
toSquare = if (color == Color.WHITE) "g1" else "g8",
isCheck = isCheck,
isCheckmate = isCheckmate,
isCastling = true,
)
}
if (text == "O-O-O" || text == "0-0-0") {
return ChessMove(
san = san,
moveNumber = moveNumber,
color = color,
piece = PieceType.KING,
toSquare = if (color == Color.WHITE) "c1" else "c8",
isCheck = isCheck,
isCheckmate = isCheckmate,
isCastling = true,
)
}
// Remove capture marker
text = text.replace("x", "")
// Check for promotion (e.g., e8=Q)
val promotionRegex = """([a-h][18])=([QRBN])""".toRegex()
val promotionMatch = promotionRegex.find(text)
val promotion = promotionMatch?.groupValues?.get(2)?.let { PieceType.fromSymbol(it[0]) }
if (promotionMatch != null) {
text = text.replace(promotionRegex, promotionMatch.groupValues[1])
}
// Determine piece type (first char if uppercase, otherwise pawn)
val piece =
if (text.isNotEmpty() && text[0].isUpperCase()) {
PieceType.fromSymbol(text[0]) ?: PieceType.PAWN
} else {
PieceType.PAWN
}
// Remove piece symbol if present
if (piece != PieceType.PAWN && text.isNotEmpty()) {
text = text.substring(1)
}
// Extract destination square (last 2 chars should be file+rank)
val squareRegex = """([a-h][1-8])$""".toRegex()
val squareMatch = squareRegex.find(text)
val toSquare = squareMatch?.value ?: ""
// Extract disambiguation (file or rank between piece and destination)
val fromSquare = text.replace(toSquare, "").takeIf { it.isNotEmpty() }
return ChessMove(
san = san,
moveNumber = moveNumber,
color = color,
piece = piece,
fromSquare = fromSquare,
toSquare = toSquare,
isCapture = isCapture,
isCheck = isCheck,
isCheckmate = isCheckmate,
promotion = promotion,
)
}
/**
* Generate list of positions by replaying moves from starting position
*
* Note: This implementation uses simplified move application.
* Full legal move validation would require complete chess engine logic.
*/
private fun generatePositions(moves: List<ChessMove>): List<ChessPosition> {
val positions = mutableListOf(ChessPosition.initial())
var current = ChessPosition.initial()
moves.forEach { move ->
try {
current =
when {
move.isCastling -> {
// Castling
val kingSide = move.toSquare.contains("g")
current.makeCastlingMove(kingSide)
}
move.fromSquare != null -> {
// Move with disambiguation (we can infer full source)
// For now, just use simplified move
makeSimplifiedMove(current, move)
}
else -> {
// Regular move
makeSimplifiedMove(current, move)
}
}
positions.add(current)
} catch (e: Exception) {
// If move application fails, keep previous position
positions.add(current)
}
}
return positions
}
/**
* Simplified move application without full legal move validation
* Finds piece that can move to destination and creates new position
*/
private fun makeSimplifiedMove(
position: ChessPosition,
move: ChessMove,
): ChessPosition {
// For MVP: Try to find a piece of the right type that could move to destination
// This is simplified and doesn't validate full chess rules
val targetSquare = move.toSquare
// Find all pieces of the moving color and type
val candidates = mutableListOf<String>()
for (rank in 0..7) {
for (file in 0..7) {
val piece = position.pieceAt(file, rank)
if (piece != null &&
piece.color == move.color &&
piece.type == move.piece
) {
val square = "${'a' + file}${rank + 1}"
candidates.add(square)
}
}
}
// Use disambiguation if provided
val fromSquare =
if (move.fromSquare != null) {
candidates.firstOrNull { it.contains(move.fromSquare) }
} else {
candidates.firstOrNull()
} ?: ""
return if (fromSquare.isNotEmpty()) {
position.makeMove(fromSquare, targetSquare, move.promotion)
} else {
// If we can't find source, return current position unchanged
position
}
}
}
@@ -0,0 +1,110 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip66RelayMonitor.discovery
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashes
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.RttType
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* NIP-66 Kind 30166: Relay Discovery Event
*
* Published by relay monitors to document the state of a specific relay.
* The `d` tag holds the normalized relay URL, making each relay URL
* addressable per monitor pubkey.
*
* Carries metrics and metadata observed during monitoring:
* - Round-trip times (rtt-open, rtt-read, rtt-write)
* - Network type (n), relay type (T)
* - Supported NIPs (N), accepted kinds (k)
* - Requirements (R: auth, payment, pow, writes)
* - Topics (t) and geohash (g)
*/
@Immutable
class RelayDiscoveryEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
fun relay(): NormalizedRelayUrl? = RelayUrlNormalizer.normalizeOrNull(dTag())
fun rtt() = tags.rtts()
fun rttOpen() = tags.rtt(RttType.OPEN)
fun rttRead() = tags.rtt(RttType.READ)
fun rttWrite() = tags.rtt(RttType.WRITE)
fun networkTypes() = tags.networkTypes()
fun relayTypes() = tags.relayTypes()
fun supportedNips() = tags.supportedNips()
fun requirements() = tags.requirements()
fun acceptedKinds() = tags.acceptedKinds()
fun topics() = tags.topics()
fun geohashes() = tags.geohashes()
companion object {
const val KIND = 30166
const val ALT_DESCRIPTION = "Relay discovery"
fun build(
relayUrl: NormalizedRelayUrl,
content: String = "",
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<RelayDiscoveryEvent>.() -> Unit = {},
) = eventTemplate<RelayDiscoveryEvent>(KIND, content, createdAt) {
alt(ALT_DESCRIPTION)
dTag(relayUrl.url)
initializer()
}
fun build(
relayUrl: String,
content: String = "",
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<RelayDiscoveryEvent>.() -> Unit = {},
) = build(
relayUrl = RelayUrlNormalizer.normalize(relayUrl),
content = content,
createdAt = createdAt,
initializer = initializer,
)
}
}
@@ -0,0 +1,62 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip66RelayMonitor.discovery
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.AcceptedKindTag
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.NetworkType
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.NetworkTypeTag
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.RelayTypeTag
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.RequirementTag
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.RttTag
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.RttType
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.SupportedNipTag
fun TagArrayBuilder<RelayDiscoveryEvent>.rtt(
type: RttType,
milliseconds: Long,
) = addUnique(RttTag.assemble(type, milliseconds))
fun TagArrayBuilder<RelayDiscoveryEvent>.networkType(network: NetworkType) = addUnique(NetworkTypeTag.assemble(network))
fun TagArrayBuilder<RelayDiscoveryEvent>.networkTypes(networks: List<NetworkType>) = addAll(NetworkTypeTag.assemble(networks))
fun TagArrayBuilder<RelayDiscoveryEvent>.relayType(type: String) = addUnique(RelayTypeTag.assemble(type))
fun TagArrayBuilder<RelayDiscoveryEvent>.relayTypes(types: List<String>) = addAll(RelayTypeTag.assemble(types))
fun TagArrayBuilder<RelayDiscoveryEvent>.supportedNip(nip: Int) = add(SupportedNipTag.assemble(nip))
fun TagArrayBuilder<RelayDiscoveryEvent>.supportedNips(nips: List<Int>) = addAll(SupportedNipTag.assemble(nips))
fun TagArrayBuilder<RelayDiscoveryEvent>.requirement(
value: String,
negated: Boolean = false,
) = add(RequirementTag.assemble(value, negated))
fun TagArrayBuilder<RelayDiscoveryEvent>.requirements(reqs: List<RequirementTag>) = addAll(RequirementTag.assemble(reqs))
fun TagArrayBuilder<RelayDiscoveryEvent>.acceptedKind(
kind: Int,
negated: Boolean = false,
) = add(AcceptedKindTag.assemble(kind, negated))
fun TagArrayBuilder<RelayDiscoveryEvent>.acceptedKinds(kinds: List<AcceptedKindTag>) = addAll(AcceptedKindTag.assemble(kinds))
@@ -0,0 +1,47 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip66RelayMonitor.discovery
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.AcceptedKindTag
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.NetworkTypeTag
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.RelayTypeTag
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.RequirementTag
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.RttTag
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.RttType
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.SupportedNipTag
fun TagArray.rtts() = mapNotNull(RttTag::parse)
fun TagArray.rtt(type: RttType) = mapNotNull(RttTag::parse).firstOrNull { it.type == type }?.milliseconds
fun TagArray.networkTypes() = mapNotNull(NetworkTypeTag::parse)
fun TagArray.relayTypes() = mapNotNull(RelayTypeTag::parse)
fun TagArray.supportedNips() = mapNotNull(SupportedNipTag::parse)
fun TagArray.requirements() = mapNotNull(RequirementTag::parse)
fun TagArray.acceptedKinds() = mapNotNull(AcceptedKindTag::parse)
fun TagArray.topics() = hashtags()
@@ -0,0 +1,67 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* ["k", "<kind>"] - an event kind accepted by this relay.
*
* A leading "!" negates, meaning the relay does NOT accept that kind.
* Examples: "1" (accepts kind 1), "!1" (rejects kind 1)
*/
@Stable
class AcceptedKindTag(
val kind: Int,
val negated: Boolean,
) {
companion object {
const val TAG_NAME = "k"
private const val NEGATION_PREFIX = "!"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME
fun parse(tag: Array<String>): AcceptedKindTag? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
val raw = tag[1]
return if (raw.startsWith(NEGATION_PREFIX)) {
val kind = raw.removePrefix(NEGATION_PREFIX).toIntOrNull() ?: return null
AcceptedKindTag(kind = kind, negated = true)
} else {
val kind = raw.toIntOrNull() ?: return null
AcceptedKindTag(kind = kind, negated = false)
}
}
fun assemble(
kind: Int,
negated: Boolean = false,
) = arrayOf(TAG_NAME, if (negated) "$NEGATION_PREFIX$kind" else kind.toString())
fun assemble(entry: AcceptedKindTag) = assemble(entry.kind, entry.negated)
fun assemble(kinds: List<AcceptedKindTag>) = kinds.map { assemble(it) }
}
}
@@ -0,0 +1,54 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/** Network accessibility types for relay connections */
enum class NetworkType(
val code: String,
) {
CLEARNET("clearnet"),
TOR("tor"),
I2P("i2p"),
LOKI("loki"),
}
/** ["n", "<network-type>"] - the network type the relay is accessible on */
class NetworkTypeTag {
companion object {
const val TAG_NAME = "n"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME
fun parse(tag: Array<String>): NetworkType? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return NetworkType.entries.find { it.code == tag[1] }
}
fun assemble(network: NetworkType) = arrayOf(TAG_NAME, network.code)
fun assemble(networks: List<NetworkType>) = networks.map { assemble(it) }
}
}
@@ -0,0 +1,47 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* ["T", "<relay-type>"] - the type of relay in PascalCase (e.g. PrivateInbox, PublicOutbox).
* Values are arbitrary strings defined by relay operators; not an enum.
*/
class RelayTypeTag {
companion object {
const val TAG_NAME = "T"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(relayType: String) = arrayOf(TAG_NAME, relayType)
fun assemble(relayTypes: List<String>) = relayTypes.map { assemble(it) }
}
}
@@ -0,0 +1,65 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* ["R", "<requirement>"] - access requirement enforced by the relay.
*
* A leading "!" negates the requirement, meaning the relay does NOT enforce it.
* Examples: "auth", "payment", "!payment" (no payment required), "pow", "writes"
*/
@Stable
class RequirementTag(
val value: String,
val negated: Boolean,
) {
companion object {
const val TAG_NAME = "R"
private const val NEGATION_PREFIX = "!"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME
fun parse(tag: Array<String>): RequirementTag? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
val raw = tag[1]
return if (raw.startsWith(NEGATION_PREFIX)) {
RequirementTag(value = raw.removePrefix(NEGATION_PREFIX), negated = true)
} else {
RequirementTag(value = raw, negated = false)
}
}
fun assemble(
value: String,
negated: Boolean = false,
) = arrayOf(TAG_NAME, if (negated) "$NEGATION_PREFIX$value" else value)
fun assemble(req: RequirementTag) = assemble(req.value, req.negated)
fun assemble(requirements: List<RequirementTag>) = requirements.map { assemble(it) }
}
}
@@ -0,0 +1,65 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/** Round-trip time measurement types */
enum class RttType(
val tagName: String,
) {
OPEN("rtt-open"),
READ("rtt-read"),
WRITE("rtt-write"),
}
/**
* Round-trip time tags measured in milliseconds.
*
* ["rtt-open", "<milliseconds>"]
* ["rtt-read", "<milliseconds>"]
* ["rtt-write", "<milliseconds>"]
*/
@Stable
class RttTag(
val type: RttType,
val milliseconds: Long,
) {
companion object {
fun isTag(tag: Array<String>) = tag.has(1) && RttType.entries.any { it.tagName == tag[0] }
fun parse(tag: Array<String>): RttTag? {
ensure(tag.has(1)) { return null }
val type = RttType.entries.find { it.tagName == tag[0] } ?: return null
val ms = tag[1].toLongOrNull() ?: return null
return RttTag(type, ms)
}
fun assemble(
type: RttType,
milliseconds: Long,
) = arrayOf(type.tagName, milliseconds.toString())
fun assemble(rtt: RttTag) = assemble(rtt.type, rtt.milliseconds)
}
}
@@ -0,0 +1,44 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/** ["N", "<nip-number>"] - a NIP number supported by this relay (repeated per supported NIP) */
class SupportedNipTag {
companion object {
const val TAG_NAME = "N"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME
fun parse(tag: Array<String>): Int? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1].toIntOrNull()
}
fun assemble(nip: Int) = arrayOf(TAG_NAME, nip.toString())
fun assemble(nips: List<Int>) = nips.map { assemble(it) }
}
}
@@ -0,0 +1,84 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip66RelayMonitor.monitor
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashes
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.tags.CheckType
import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.tags.TimeoutTag
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* NIP-66 Kind 10166: Relay Monitor Announcement
*
* Published by monitoring services to advertise themselves. Declares:
* - How frequently the monitor publishes results (frequency tag, required)
* - Which types of checks it performs (c tags)
* - Timeout values used for each check type (timeout tags)
* - Geographical location of the monitor (g tags)
*
* Monitors should also publish complementary kind 0 (metadata) and
* kind 10002 (relay list) events for full discoverability.
*/
@Immutable
class RelayMonitorEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
fun frequency() = tags.frequency()
fun checks() = tags.checks()
fun timeouts() = tags.timeouts()
fun geohashes() = tags.geohashes()
companion object {
const val KIND = 10166
const val ALT_DESCRIPTION = "Relay monitor announcement"
fun build(
frequencySeconds: Long,
checks: List<CheckType> = emptyList(),
timeouts: List<TimeoutTag> = emptyList(),
geohash: String? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<RelayMonitorEvent>.() -> Unit = {},
) = eventTemplate<RelayMonitorEvent>(KIND, "", createdAt) {
alt(ALT_DESCRIPTION)
frequency(frequencySeconds)
checks(checks)
timeouts(timeouts)
geohash?.let { geohash(it) }
initializer()
}
}
}
@@ -0,0 +1,33 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip66RelayMonitor.monitor
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.tags.CheckTag
import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.tags.CheckType
import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.tags.FrequencyTag
import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.tags.TimeoutTag
fun TagArrayBuilder<RelayMonitorEvent>.frequency(seconds: Long) = addUnique(FrequencyTag.assemble(seconds))
fun TagArrayBuilder<RelayMonitorEvent>.checks(checks: List<CheckType>) = addAll(CheckTag.assemble(checks))
fun TagArrayBuilder<RelayMonitorEvent>.timeouts(timeouts: List<TimeoutTag>) = addAll(TimeoutTag.assemble(timeouts))
@@ -0,0 +1,32 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip66RelayMonitor.monitor
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.tags.CheckTag
import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.tags.FrequencyTag
import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.tags.TimeoutTag
fun TagArray.frequency() = firstNotNullOfOrNull(FrequencyTag::parse)
fun TagArray.checks() = mapNotNull(CheckTag::parse)
fun TagArray.timeouts() = mapNotNull(TimeoutTag::parse)
@@ -0,0 +1,57 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip66RelayMonitor.monitor.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/** Types of checks a relay monitor can perform */
enum class CheckType(
val code: String,
) {
OPEN("open"),
READ("read"),
WRITE("write"),
AUTH("auth"),
NIP11("nip11"),
DNS("dns"),
GEO("geo"),
}
/** ["c", "<check-type>"] - a capability check conducted by this monitor */
class CheckTag {
companion object {
const val TAG_NAME = "c"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME
fun parse(tag: Array<String>): CheckType? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return CheckType.entries.find { it.code == tag[1] }
}
fun assemble(check: CheckType) = arrayOf(TAG_NAME, check.code)
fun assemble(checks: List<CheckType>) = checks.map { assemble(it) }
}
}
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip66RelayMonitor.monitor.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/** ["frequency", "<seconds>"] - how often this monitor publishes check results */
class FrequencyTag {
companion object {
const val TAG_NAME = "frequency"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME
fun parse(tag: Array<String>): Long? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1].toLongOrNull()
}
fun assemble(seconds: Long) = arrayOf(TAG_NAME, seconds.toString())
}
}
@@ -0,0 +1,73 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip66RelayMonitor.monitor.tags
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* Timeout tag for relay monitors.
*
* General form: ["timeout", "<milliseconds>"]
* Per-check form: ["timeout", "<check-type>", "<milliseconds>"]
*
* Where check-type is one of: open, read, write, nip11
*/
@Stable
class TimeoutTag(
val checkType: String?,
val milliseconds: Long,
) {
companion object {
const val TAG_NAME = "timeout"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME
fun parse(tag: Array<String>): TimeoutTag? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
return if (tag.has(2)) {
val ms = tag[2].toLongOrNull() ?: return null
TimeoutTag(checkType = tag[1], milliseconds = ms)
} else {
val ms = tag[1].toLongOrNull() ?: return null
TimeoutTag(checkType = null, milliseconds = ms)
}
}
fun assemble(milliseconds: Long) = arrayOf(TAG_NAME, milliseconds.toString())
fun assemble(
checkType: String,
milliseconds: Long,
) = arrayOf(TAG_NAME, checkType, milliseconds.toString())
fun assemble(timeout: TimeoutTag) =
if (timeout.checkType != null) {
assemble(timeout.checkType, timeout.milliseconds)
} else {
assemble(timeout.milliseconds)
}
fun assemble(timeouts: List<TimeoutTag>) = timeouts.map { assemble(it) }
}
}
@@ -20,6 +20,9 @@
*/
package com.vitorpamplona.quartz.nip73ExternalIds
import androidx.compose.runtime.Stable
@Stable
interface ExternalId {
fun toScope(): String
@@ -20,9 +20,11 @@
*/
package com.vitorpamplona.quartz.nip73ExternalIds.location
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId
import com.vitorpamplona.quartz.utils.ensure
@Stable
class GeohashId(
val geohash: String,
val hint: String? = null,
@@ -20,10 +20,12 @@
*/
package com.vitorpamplona.quartz.nip73ExternalIds.movies
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId
import com.vitorpamplona.quartz.utils.ensure
import kotlin.math.min
@Stable
class MovieId(
val isan: String,
val hint: String? = null,
@@ -20,9 +20,11 @@
*/
package com.vitorpamplona.quartz.nip73ExternalIds.papers
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId
import com.vitorpamplona.quartz.utils.ensure
@Stable
class PaperId(
val doi: String,
val hint: String? = null,
@@ -20,9 +20,11 @@
*/
package com.vitorpamplona.quartz.nip73ExternalIds.podcasts
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId
import com.vitorpamplona.quartz.utils.ensure
@Stable
class PodcastEpisodeId(
val guid: String,
val hint: String? = null,
@@ -20,9 +20,11 @@
*/
package com.vitorpamplona.quartz.nip73ExternalIds.podcasts
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId
import com.vitorpamplona.quartz.utils.ensure
@Stable
class PodcastFeedId(
val guid: String,
val hint: String? = null,
@@ -20,9 +20,11 @@
*/
package com.vitorpamplona.quartz.nip73ExternalIds.podcasts
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId
import com.vitorpamplona.quartz.utils.ensure
@Stable
class PodcastPublisherId(
val guid: String,
val hint: String? = null,
@@ -20,9 +20,11 @@
*/
package com.vitorpamplona.quartz.nip73ExternalIds.topics
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId
import com.vitorpamplona.quartz.utils.ensure
@Stable
class HashtagId(
val topic: String,
val hint: String? = null,
@@ -20,10 +20,12 @@
*/
package com.vitorpamplona.quartz.nip73ExternalIds.urls
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId
import com.vitorpamplona.quartz.utils.Rfc3986
import com.vitorpamplona.quartz.utils.ensure
@Stable
class UrlId(
val url: String,
val hint: String? = null,
@@ -0,0 +1,78 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip88Polls.poll
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip22Comments.RootScope
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag
import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class PollEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig),
RootScope {
fun options() = tags.options()
fun relays() = tags.relays()
fun pollType() = tags.pollType()
fun endsAt() = tags.endsAt()
fun hasEnded() = tags.hasEnded()
companion object {
const val KIND = 1068
const val ALT_DESCRIPTION = "Poll"
fun build(
description: String,
options: List<OptionTag>,
endsAt: Long?,
relays: List<NormalizedRelayUrl>,
pollType: PollType = PollType.SINGLE_CHOICE,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<PollEvent>.() -> Unit = {},
) = eventTemplate(KIND, description, createdAt) {
alt(ALT_DESCRIPTION)
poolType(pollType)
options(options)
relays(relays)
endsAt?.let {
endsAt(it)
}
initializer()
}
}
}
@@ -0,0 +1,37 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip88Polls.poll
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip88Polls.poll.tags.EndsAtTag
import com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag
import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType
import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollTypeTag
import com.vitorpamplona.quartz.nip88Polls.poll.tags.RelayTag
fun TagArrayBuilder<PollEvent>.poolType(type: PollType) = addUnique(PollTypeTag.assemble(type))
fun TagArrayBuilder<PollEvent>.endsAt(timestamp: Long) = addUnique(EndsAtTag.assemble(timestamp))
fun TagArrayBuilder<PollEvent>.relays(relays: List<NormalizedRelayUrl>) = addAll(RelayTag.assemble(relays))
fun TagArrayBuilder<PollEvent>.options(options: List<OptionTag>) = addAll(OptionTag.assemble(options))
@@ -0,0 +1,39 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip88Polls.poll
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip88Polls.poll.tags.EndsAtTag
import com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag
import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType
import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollTypeTag
import com.vitorpamplona.quartz.nip88Polls.poll.tags.RelayTag
import com.vitorpamplona.quartz.utils.TimeUtils
fun TagArray.options() = mapNotNull(OptionTag::parse)
fun TagArray.relays() = mapNotNull(RelayTag::parse)
fun TagArray.pollType() = firstNotNullOfOrNull(PollTypeTag::parse) ?: PollType.SINGLE_CHOICE
fun TagArray.endsAt() = firstNotNullOfOrNull(EndsAtTag::parse)
fun TagArray.hasEnded() = endsAt()?.let { it < TimeUtils.now() } ?: false
@@ -18,23 +18,24 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.crypto
package com.vitorpamplona.quartz.nip88Polls.poll.tags
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.arrayOfNotNull
import com.vitorpamplona.quartz.utils.ensure
class DeterministicSigner(
val key: KeyPair,
val pubKey: HexKey = key.pubKey.toHexKey(),
) {
fun <T : Event> sign(
createdAt: Long,
kind: Int,
tags: Array<Array<String>>,
content: String,
): T = EventAssembler.hashAndSign(pubKey, createdAt, kind, tags, content, key.privKey!!, nonce = null)
class EndsAtTag {
companion object {
const val TAG_NAME = "endsAt"
fun <T : Event> sign(ev: EventTemplate<T>): T = sign(ev.createdAt, ev.kind, ev.tags, ev.content)
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): Long? {
ensure(tag.has(1) && tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1].toLongOrNull()
}
fun assemble(time: Long) = arrayOfNotNull(TAG_NAME, time.toString())
}
}
@@ -0,0 +1,54 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip88Polls.poll.tags
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
@Stable
class OptionTag(
val code: String,
val label: String,
) {
companion object {
const val TAG_NAME = "option"
fun isTag(tag: Array<String>) = tag.has(2) && tag[0] == TAG_NAME && tag[1].isNotEmpty() && tag[2].isNotEmpty()
fun parse(tag: Array<String>): OptionTag? {
ensure(tag.has(2)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
ensure(tag[2].isNotEmpty()) { return null }
return OptionTag(tag[1], tag[2])
}
fun assemble(
code: String,
label: String,
) = arrayOf(TAG_NAME, code, label)
fun assemble(option: OptionTag) = assemble(option.code, option.label)
fun assemble(labels: List<OptionTag>) = labels.map { opt -> assemble(opt) }
}
}
@@ -0,0 +1,53 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip88Polls.poll.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
enum class PollType(
val code: String,
) {
SINGLE_CHOICE("singlechoice"),
MULTI_CHOICE("multiplechoice"),
}
class PollTypeTag {
companion object {
const val TAG_NAME = "polltype"
fun isTag(tag: Array<String>) = tag.has(2) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): PollType? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return when (tag[1]) {
PollType.SINGLE_CHOICE.code -> PollType.SINGLE_CHOICE
PollType.MULTI_CHOICE.code -> PollType.MULTI_CHOICE
else -> null
}
}
fun assemble(type: PollType) = arrayOf(TAG_NAME, type.code)
}
}
@@ -0,0 +1,48 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip88Polls.poll.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.utils.ensure
class RelayTag {
companion object {
const val TAG_NAME = "relay"
fun match(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun notMatch(tag: Array<String>) = !(tag.has(0) && tag[0] == TAG_NAME)
fun parse(tag: Array<String>): NormalizedRelayUrl? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return RelayUrlNormalizer.normalizeOrNull(tag[1])
}
fun assemble(relay: NormalizedRelayUrl) = arrayOf(TAG_NAME, relay.url)
fun assemble(relays: List<NormalizedRelayUrl>) = relays.map { assemble(it) }
}
}
@@ -0,0 +1,79 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip88Polls.response
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip22Comments.RootScope
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.response.tags.PollTag
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class PollResponseEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig),
EventHintProvider,
PubKeyHintProvider,
RootScope {
override fun eventHints() = tags.mapNotNull(PollTag::parseAsHint)
override fun linkedEventIds() = tags.mapNotNull(PollTag::parseId)
override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint)
override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey)
fun responses() = tags.responses()
fun poll() = tags.poll()
companion object {
const val KIND = 1018
const val ALT_DESCRIPTION = "Poll Response"
fun build(
poll: EventHintBundle<PollEvent>,
responses: Set<String>,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<PollResponseEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
alt(ALT_DESCRIPTION)
poll(poll)
notifyAuthor(poll)
responses(responses)
initializer()
}
}
}
@@ -0,0 +1,34 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip88Polls.response
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.tags.people.toPTag
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.response.tags.PollTag
import com.vitorpamplona.quartz.nip88Polls.response.tags.ResponseTag
fun TagArrayBuilder<PollResponseEvent>.poll(pollHint: EventHintBundle<PollEvent>) = addUnique(PollTag.assemble(pollHint))
fun TagArrayBuilder<PollResponseEvent>.responses(responses: Set<String>) = addAll(ResponseTag.assemble(responses))
fun TagArrayBuilder<PollResponseEvent>.notifyAuthor(pollHint: EventHintBundle<PollEvent>) = add(pollHint.toPTag().toTagArray())
@@ -0,0 +1,29 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip88Polls.response
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip88Polls.response.tags.PollTag
import com.vitorpamplona.quartz.nip88Polls.response.tags.ResponseTag
fun TagArray.responses() = mapNotNull(ResponseTag::parse)
fun TagArray.poll() = firstNotNullOfOrNull(PollTag::parse)
@@ -0,0 +1,105 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip88Polls.response.tags
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.tags.events.GenericETag
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.utils.arrayOfNotNull
import com.vitorpamplona.quartz.utils.ensure
@Immutable
data class PollTag(
override val eventId: HexKey,
) : GenericETag {
override var relay: NormalizedRelayUrl? = null
override var author: HexKey? = null
constructor(eventId: HexKey, relayHint: NormalizedRelayUrl? = null, authorPubKeyHex: HexKey? = null) : this(eventId) {
this.relay = relayHint
this.author = authorPubKeyHex
}
fun toNEvent(): String = NEvent.create(eventId, author, null, relay)
override fun toTagArray() = assemble(eventId, relay, author)
companion object {
const val TAG_NAME = "e"
fun isTagged(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].length == 64
fun isTagged(
tag: Array<String>,
eventId: HexKey,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == eventId
fun isTagged(
tag: Array<String>,
eventIds: Set<HexKey>,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in eventIds
fun parse(tag: Array<String>): PollTag? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
val hint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) }
return PollTag(tag[1], hint, tag.getOrNull(3))
}
fun parseId(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
return tag[1]
}
fun parseAsHint(tag: Array<String>): EventIdHint? {
ensure(tag.has(2)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
ensure(tag[2].isNotEmpty()) { return null }
val hint = RelayUrlNormalizer.normalizeOrNull(tag[2])
ensure(hint != null) { return null }
return EventIdHint(tag[1], hint)
}
fun assemble(
eventId: HexKey,
relay: NormalizedRelayUrl?,
author: HexKey?,
) = arrayOfNotNull(TAG_NAME, eventId, relay?.url, author)
fun assemble(eventHint: EventHintBundle<PollEvent>) = assemble(eventHint.event.id, eventHint.relay, eventHint.event.pubKey)
}
}
@@ -0,0 +1,43 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip88Polls.response.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class ResponseTag {
companion object {
const val TAG_NAME = "response"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(code: String) = arrayOf(TAG_NAME, code)
fun assemble(responses: Set<String>) = responses.map { code -> assemble(code) }
}
}
@@ -0,0 +1,100 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipC0CodeSnippets
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip22Comments.RootScope
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* NIP-C0: Code Snippet event (kind:1337).
*
* The `.content` field holds the raw code text. All metadata is stored as optional tags.
*/
@Immutable
class CodeSnippetEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig),
RootScope {
/** Programming language, lowercase (e.g. "python"). */
fun language() = tags.language()
/** File extension without the leading dot (e.g. "py"). */
fun extension() = tags.snippetExtension()
/** Snippet filename (e.g. "hello-world.py"). */
fun snippetName() = tags.snippetName()
/** Brief description of the snippet's purpose. */
fun snippetDescription() = tags.snippetDescription()
/** Execution runtime (e.g. "node v18.15.0"). */
fun runtime() = tags.runtime()
/** SPDX license identifier (e.g. "MIT"). */
fun license() = tags.license()
/** List of required dependencies. */
fun deps() = tags.deps()
/** Repository URL or NIP-34 Git announcement reference. */
fun repo() = tags.repo()
companion object {
const val KIND = 1337
const val ALT_DESCRIPTION = "Code snippet"
fun build(
code: String,
language: String? = null,
extension: String? = null,
name: String? = null,
description: String? = null,
runtime: String? = null,
license: String? = null,
deps: List<String> = emptyList(),
repo: String? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<CodeSnippetEvent>.() -> Unit = {},
) = eventTemplate(KIND, code, createdAt) {
alt(ALT_DESCRIPTION)
language?.let { language(it) }
extension?.let { extension(it) }
name?.let { snippetName(it) }
description?.let { snippetDescription(it) }
runtime?.let { runtime(it) }
license?.let { license(it) }
if (deps.isNotEmpty()) deps(deps)
repo?.let { repo(it) }
initializer()
}
}
}
@@ -0,0 +1,47 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipC0CodeSnippets
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nipC0CodeSnippets.tags.DepTag
import com.vitorpamplona.quartz.nipC0CodeSnippets.tags.ExtensionTag
import com.vitorpamplona.quartz.nipC0CodeSnippets.tags.LanguageTag
import com.vitorpamplona.quartz.nipC0CodeSnippets.tags.LicenseTag
import com.vitorpamplona.quartz.nipC0CodeSnippets.tags.RepoTag
import com.vitorpamplona.quartz.nipC0CodeSnippets.tags.RuntimeTag
import com.vitorpamplona.quartz.nipC0CodeSnippets.tags.SnippetDescriptionTag
import com.vitorpamplona.quartz.nipC0CodeSnippets.tags.SnippetNameTag
fun TagArrayBuilder<CodeSnippetEvent>.language(language: String) = addUnique(LanguageTag.assemble(language))
fun TagArrayBuilder<CodeSnippetEvent>.extension(extension: String) = addUnique(ExtensionTag.assemble(extension))
fun TagArrayBuilder<CodeSnippetEvent>.snippetName(name: String) = addUnique(SnippetNameTag.assemble(name))
fun TagArrayBuilder<CodeSnippetEvent>.snippetDescription(description: String) = addUnique(SnippetDescriptionTag.assemble(description))
fun TagArrayBuilder<CodeSnippetEvent>.runtime(runtime: String) = addUnique(RuntimeTag.assemble(runtime))
fun TagArrayBuilder<CodeSnippetEvent>.license(license: String) = addUnique(LicenseTag.assemble(license))
fun TagArrayBuilder<CodeSnippetEvent>.deps(deps: List<String>) = addAll(DepTag.assemble(deps))
fun TagArrayBuilder<CodeSnippetEvent>.repo(repo: String) = addUnique(RepoTag.assemble(repo))
@@ -0,0 +1,47 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipC0CodeSnippets
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nipC0CodeSnippets.tags.DepTag
import com.vitorpamplona.quartz.nipC0CodeSnippets.tags.ExtensionTag
import com.vitorpamplona.quartz.nipC0CodeSnippets.tags.LanguageTag
import com.vitorpamplona.quartz.nipC0CodeSnippets.tags.LicenseTag
import com.vitorpamplona.quartz.nipC0CodeSnippets.tags.RepoTag
import com.vitorpamplona.quartz.nipC0CodeSnippets.tags.RuntimeTag
import com.vitorpamplona.quartz.nipC0CodeSnippets.tags.SnippetDescriptionTag
import com.vitorpamplona.quartz.nipC0CodeSnippets.tags.SnippetNameTag
fun TagArray.language() = firstNotNullOfOrNull(LanguageTag::parse)
fun TagArray.snippetExtension() = firstNotNullOfOrNull(ExtensionTag::parse)
fun TagArray.snippetName() = firstNotNullOfOrNull(SnippetNameTag::parse)
fun TagArray.snippetDescription() = firstNotNullOfOrNull(SnippetDescriptionTag::parse)
fun TagArray.runtime() = firstNotNullOfOrNull(RuntimeTag::parse)
fun TagArray.license() = firstNotNullOfOrNull(LicenseTag::parse)
fun TagArray.deps() = mapNotNull(DepTag::parse)
fun TagArray.repo() = firstNotNullOfOrNull(RepoTag::parse)
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipC0CodeSnippets.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/** NIP-C0: `dep` tag — a dependency required to run the snippet (repeatable) */
class DepTag {
companion object {
const val TAG_NAME = "dep"
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(dep: String) = arrayOf(TAG_NAME, dep)
fun assemble(deps: List<String>) = deps.map { assemble(it) }
}
}

Some files were not shown because too many files have changed in this diff Show More