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:
+42
-32
@@ -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++
|
||||
}
|
||||
}
|
||||
|
||||
+10
-10
@@ -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),
|
||||
),
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -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)
|
||||
|
||||
+5
-5
@@ -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)
|
||||
|
||||
|
||||
+5
-5
@@ -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(),
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+2
-2
@@ -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)
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+13
-16
@@ -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()
|
||||
}
|
||||
|
||||
-3
@@ -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())
|
||||
|
||||
-4
@@ -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
|
||||
|
||||
-4
@@ -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())
|
||||
|
||||
/**
|
||||
|
||||
+2
-5
@@ -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())
|
||||
|
||||
/**
|
||||
|
||||
+21
-23
@@ -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. */
|
||||
|
||||
+23
-27
@@ -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
|
||||
}
|
||||
|
||||
+1
-1
@@ -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)
|
||||
-2
@@ -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
|
||||
|
||||
|
||||
+13
-2
@@ -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
|
||||
}
|
||||
|
||||
+12
-12
@@ -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 }
|
||||
}
|
||||
|
||||
+1
-1
@@ -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) {
|
||||
|
||||
+92
@@ -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)
|
||||
}
|
||||
}
|
||||
+2
@@ -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,
|
||||
|
||||
+4
@@ -191,3 +191,7 @@ class RelayUrlNormalizer {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun String.normalizeRelayUrl() = RelayUrlNormalizer.normalize(this)
|
||||
|
||||
fun String.normalizeRelayUrlOrNull() = RelayUrlNormalizer.normalizeOrNull(this)
|
||||
|
||||
+9
@@ -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))
|
||||
|
||||
-1
@@ -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(
|
||||
|
||||
+2
-2
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-28
@@ -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? {
|
||||
|
||||
-4
@@ -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())
|
||||
|
||||
+2
-2
@@ -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)
|
||||
|
||||
+1
-1
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -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)
|
||||
|
||||
+16
-4
@@ -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)
|
||||
|
||||
+86
@@ -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, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-8
@@ -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) }
|
||||
|
||||
+120
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
@@ -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())
|
||||
|
||||
+1
-1
@@ -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()))
|
||||
|
||||
+2
-2
@@ -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)
|
||||
}
|
||||
+234
@@ -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,
|
||||
)
|
||||
+304
@@ -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
|
||||
}
|
||||
+295
@@ -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
|
||||
}
|
||||
+548
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+110
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+62
@@ -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))
|
||||
+47
@@ -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()
|
||||
+67
@@ -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) }
|
||||
}
|
||||
}
|
||||
+54
@@ -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) }
|
||||
}
|
||||
}
|
||||
+47
@@ -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) }
|
||||
}
|
||||
}
|
||||
+65
@@ -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) }
|
||||
}
|
||||
}
|
||||
+65
@@ -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)
|
||||
}
|
||||
}
|
||||
+44
@@ -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) }
|
||||
}
|
||||
}
|
||||
+84
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
@@ -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))
|
||||
+32
@@ -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)
|
||||
+57
@@ -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) }
|
||||
}
|
||||
}
|
||||
+42
@@ -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())
|
||||
}
|
||||
}
|
||||
+73
@@ -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
|
||||
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+2
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
@@ -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
|
||||
+17
-16
@@ -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())
|
||||
}
|
||||
}
|
||||
+54
@@ -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) }
|
||||
}
|
||||
}
|
||||
+53
@@ -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)
|
||||
}
|
||||
}
|
||||
+48
@@ -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) }
|
||||
}
|
||||
}
|
||||
+79
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -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())
|
||||
+29
@@ -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)
|
||||
+105
@@ -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)
|
||||
}
|
||||
}
|
||||
+43
@@ -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) }
|
||||
}
|
||||
}
|
||||
+100
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+47
@@ -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))
|
||||
+47
@@ -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)
|
||||
+42
@@ -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
Reference in New Issue
Block a user