fix(quartz): align NIP-53 Live Activities with spec
- MeetingRoomPresenceEvent (kind 10312) now extends BaseReplaceableEvent
instead of BaseAddressableEvent. NIP-53 states this kind is a regular
replaceable event ("presence can only be indicated in one room at a
time"), and LocalCache already routes it through consumeBaseReplaceable.
createAddress/createAddressATag/createAddressTag now take only pubKey.
- Add TagArrayBuilder extensions for streaming (30311), meeting space
(30312) and meeting room (30313) tags, plus build() helpers that assemble
the required tags per NIP-53.
- Add ProofOfAgreement utility to sign and verify the schnorr proof that
NIP-53 places in the 5th element of a participant p-tag
(SHA256 of kind:pubkey:dTag signed by the participant's private key).
Includes LiveActivitiesEvent.hasValidProof() convenience.
This commit is contained in:
+25
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces
|
|||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
|
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.any
|
import com.vitorpamplona.quartz.nip01Core.core.any
|
||||||
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
|
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
|
||||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
|
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
|
||||||
@@ -30,10 +31,13 @@ import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
|
|||||||
import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint
|
import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint
|
||||||
import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint
|
import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint
|
||||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
|
||||||
import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag
|
import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag
|
||||||
import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag
|
import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag
|
||||||
import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag
|
import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag
|
||||||
import com.vitorpamplona.quartz.nip31Alts.AltTag
|
import com.vitorpamplona.quartz.nip31Alts.AltTag
|
||||||
|
import com.vitorpamplona.quartz.nip31Alts.alt
|
||||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.MeetingSpaceTag
|
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.MeetingSpaceTag
|
||||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.CurrentParticipantsTag
|
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.CurrentParticipantsTag
|
||||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.EndsTag
|
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.EndsTag
|
||||||
@@ -46,6 +50,8 @@ import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag
|
|||||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StreamingTag
|
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StreamingTag
|
||||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.TotalParticipantsTag
|
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.TotalParticipantsTag
|
||||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
|
import kotlin.uuid.ExperimentalUuidApi
|
||||||
|
import kotlin.uuid.Uuid
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
class MeetingRoomEvent(
|
class MeetingRoomEvent(
|
||||||
@@ -157,5 +163,24 @@ class MeetingRoomEvent(
|
|||||||
val tags = arrayOf(AltTag.assemble(ALT))
|
val tags = arrayOf(AltTag.assemble(ALT))
|
||||||
return signer.sign(createdAt, KIND, tags, "")
|
return signer.sign(createdAt, KIND, tags, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalUuidApi::class)
|
||||||
|
fun build(
|
||||||
|
meetingSpace: MeetingSpaceTag,
|
||||||
|
title: String,
|
||||||
|
starts: Long,
|
||||||
|
status: StatusTag.STATUS,
|
||||||
|
dTag: String = Uuid.random().toString(),
|
||||||
|
createdAt: Long = TimeUtils.now(),
|
||||||
|
initializer: TagArrayBuilder<MeetingRoomEvent>.() -> Unit = {},
|
||||||
|
) = eventTemplate(KIND, "", createdAt) {
|
||||||
|
dTag(dTag)
|
||||||
|
meetingSpace(meetingSpace)
|
||||||
|
title(title)
|
||||||
|
starts(starts)
|
||||||
|
status(status)
|
||||||
|
alt(ALT)
|
||||||
|
initializer()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
@file:JvmName("MeetingRoomTagArrayBuilderExt")
|
||||||
|
|
||||||
|
package com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces
|
||||||
|
|
||||||
|
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.nip23LongContent.tags.ImageTag
|
||||||
|
import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag
|
||||||
|
import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag
|
||||||
|
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.MeetingSpaceTag
|
||||||
|
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.CurrentParticipantsTag
|
||||||
|
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.EndsTag
|
||||||
|
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag
|
||||||
|
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StartsTag
|
||||||
|
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag
|
||||||
|
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.TotalParticipantsTag
|
||||||
|
import kotlin.jvm.JvmName
|
||||||
|
|
||||||
|
fun TagArrayBuilder<MeetingRoomEvent>.meetingSpace(rep: MeetingSpaceTag) = addUnique(rep.toTagArray())
|
||||||
|
|
||||||
|
fun TagArrayBuilder<MeetingRoomEvent>.title(title: String) = addUnique(TitleTag.assemble(title))
|
||||||
|
|
||||||
|
fun TagArrayBuilder<MeetingRoomEvent>.summary(summary: String) = addUnique(SummaryTag.assemble(summary))
|
||||||
|
|
||||||
|
fun TagArrayBuilder<MeetingRoomEvent>.image(imageUrl: String) = addUnique(ImageTag.assemble(imageUrl))
|
||||||
|
|
||||||
|
fun TagArrayBuilder<MeetingRoomEvent>.starts(timestamp: Long) = addUnique(StartsTag.assemble(timestamp))
|
||||||
|
|
||||||
|
fun TagArrayBuilder<MeetingRoomEvent>.ends(timestamp: Long) = addUnique(EndsTag.assemble(timestamp))
|
||||||
|
|
||||||
|
fun TagArrayBuilder<MeetingRoomEvent>.status(status: StatusTag.STATUS) = addUnique(StatusTag.assemble(status))
|
||||||
|
|
||||||
|
fun TagArrayBuilder<MeetingRoomEvent>.currentParticipants(count: Int) = addUnique(CurrentParticipantsTag.assemble(count))
|
||||||
|
|
||||||
|
fun TagArrayBuilder<MeetingRoomEvent>.totalParticipants(count: Int) = addUnique(TotalParticipantsTag.assemble(count))
|
||||||
|
|
||||||
|
fun TagArrayBuilder<MeetingRoomEvent>.participant(
|
||||||
|
pubKey: HexKey,
|
||||||
|
relayHint: NormalizedRelayUrl? = null,
|
||||||
|
role: String? = null,
|
||||||
|
proof: HexKey? = null,
|
||||||
|
) = add(ParticipantTag.assemble(pubKey, relayHint?.url, role, proof))
|
||||||
|
|
||||||
|
fun TagArrayBuilder<MeetingRoomEvent>.participants(participants: List<ParticipantTag>) =
|
||||||
|
participants.forEach {
|
||||||
|
participant(it.pubKey, it.relayHint, it.role, it.proof)
|
||||||
|
}
|
||||||
+25
@@ -23,12 +23,16 @@ package com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces
|
|||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
|
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.any
|
import com.vitorpamplona.quartz.nip01Core.core.any
|
||||||
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
|
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
|
||||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
|
||||||
import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag
|
import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag
|
||||||
import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag
|
import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag
|
||||||
import com.vitorpamplona.quartz.nip31Alts.AltTag
|
import com.vitorpamplona.quartz.nip31Alts.AltTag
|
||||||
|
import com.vitorpamplona.quartz.nip31Alts.alt
|
||||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.EndpointUrlTag
|
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.EndpointUrlTag
|
||||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.RelayListTag
|
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.RelayListTag
|
||||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.RoomNameTag
|
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.RoomNameTag
|
||||||
@@ -36,6 +40,8 @@ import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.ServiceUr
|
|||||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag
|
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag
|
||||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag
|
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag
|
||||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
|
import kotlin.uuid.ExperimentalUuidApi
|
||||||
|
import kotlin.uuid.Uuid
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
class MeetingSpaceEvent(
|
class MeetingSpaceEvent(
|
||||||
@@ -93,5 +99,24 @@ class MeetingSpaceEvent(
|
|||||||
val tags = arrayOf(AltTag.assemble(ALT))
|
val tags = arrayOf(AltTag.assemble(ALT))
|
||||||
return signer.sign(createdAt, KIND, tags, "")
|
return signer.sign(createdAt, KIND, tags, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalUuidApi::class)
|
||||||
|
fun build(
|
||||||
|
room: String,
|
||||||
|
status: StatusTag.STATUS,
|
||||||
|
service: String,
|
||||||
|
host: ParticipantTag,
|
||||||
|
dTag: String = Uuid.random().toString(),
|
||||||
|
createdAt: Long = TimeUtils.now(),
|
||||||
|
initializer: TagArrayBuilder<MeetingSpaceEvent>.() -> Unit = {},
|
||||||
|
) = eventTemplate(KIND, "", createdAt) {
|
||||||
|
dTag(dTag)
|
||||||
|
room(room)
|
||||||
|
status(status)
|
||||||
|
service(service)
|
||||||
|
participant(host.pubKey, host.relayHint, host.role, host.proof)
|
||||||
|
alt(ALT)
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
@file:JvmName("MeetingSpaceTagArrayBuilderExt")
|
||||||
|
|
||||||
|
package com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces
|
||||||
|
|
||||||
|
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.nip23LongContent.tags.ImageTag
|
||||||
|
import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag
|
||||||
|
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.EndpointUrlTag
|
||||||
|
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.RelayListTag
|
||||||
|
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.RoomNameTag
|
||||||
|
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.ServiceUrlTag
|
||||||
|
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag
|
||||||
|
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag
|
||||||
|
import kotlin.jvm.JvmName
|
||||||
|
|
||||||
|
fun TagArrayBuilder<MeetingSpaceEvent>.room(name: String) = addUnique(RoomNameTag.assemble(name))
|
||||||
|
|
||||||
|
fun TagArrayBuilder<MeetingSpaceEvent>.summary(summary: String) = addUnique(SummaryTag.assemble(summary))
|
||||||
|
|
||||||
|
fun TagArrayBuilder<MeetingSpaceEvent>.image(imageUrl: String) = addUnique(ImageTag.assemble(imageUrl))
|
||||||
|
|
||||||
|
fun TagArrayBuilder<MeetingSpaceEvent>.status(status: StatusTag.STATUS) = addUnique(StatusTag.assemble(status))
|
||||||
|
|
||||||
|
fun TagArrayBuilder<MeetingSpaceEvent>.service(url: String) = addUnique(ServiceUrlTag.assemble(url))
|
||||||
|
|
||||||
|
fun TagArrayBuilder<MeetingSpaceEvent>.endpoint(url: String) = addUnique(EndpointUrlTag.assemble(url))
|
||||||
|
|
||||||
|
fun TagArrayBuilder<MeetingSpaceEvent>.relays(urls: List<NormalizedRelayUrl>) = addUnique(RelayListTag.assemble(urls))
|
||||||
|
|
||||||
|
fun TagArrayBuilder<MeetingSpaceEvent>.participant(
|
||||||
|
pubKey: HexKey,
|
||||||
|
relayHint: NormalizedRelayUrl? = null,
|
||||||
|
role: String? = null,
|
||||||
|
proof: HexKey? = null,
|
||||||
|
) = add(ParticipantTag.assemble(pubKey, relayHint?.url, role, proof))
|
||||||
|
|
||||||
|
fun TagArrayBuilder<MeetingSpaceEvent>.participants(participants: List<ParticipantTag>) =
|
||||||
|
participants.forEach {
|
||||||
|
participant(it.pubKey, it.relayHint, it.role, it.proof)
|
||||||
|
}
|
||||||
+5
-14
@@ -22,7 +22,7 @@ package com.vitorpamplona.quartz.nip53LiveActivities.presence
|
|||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
|
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||||
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
|
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
|
||||||
@@ -43,7 +43,7 @@ class MeetingRoomPresenceEvent(
|
|||||||
tags: Array<Array<String>>,
|
tags: Array<Array<String>>,
|
||||||
content: String,
|
content: String,
|
||||||
sig: HexKey,
|
sig: HexKey,
|
||||||
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
|
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||||
AddressHintProvider {
|
AddressHintProvider {
|
||||||
override fun addressHints(): List<AddressHint> = tags.mapNotNull(MeetingSpaceTag::parseAsHint)
|
override fun addressHints(): List<AddressHint> = tags.mapNotNull(MeetingSpaceTag::parseAsHint)
|
||||||
|
|
||||||
@@ -57,20 +57,11 @@ class MeetingRoomPresenceEvent(
|
|||||||
const val KIND = 10312
|
const val KIND = 10312
|
||||||
const val ALT = "Room Presence tag"
|
const val ALT = "Room Presence tag"
|
||||||
|
|
||||||
fun createAddress(
|
fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, FIXED_D_TAG)
|
||||||
pubKey: HexKey,
|
|
||||||
dtag: String,
|
|
||||||
): Address = Address(KIND, pubKey, dtag)
|
|
||||||
|
|
||||||
fun createAddressATag(
|
fun createAddressATag(pubKey: HexKey): ATag = ATag(KIND, pubKey, FIXED_D_TAG, null)
|
||||||
pubKey: HexKey,
|
|
||||||
dtag: String,
|
|
||||||
): ATag = ATag(KIND, pubKey, dtag, null)
|
|
||||||
|
|
||||||
fun createAddressTag(
|
fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey, FIXED_D_TAG)
|
||||||
pubKey: HexKey,
|
|
||||||
dtag: String,
|
|
||||||
): String = Address.assemble(KIND, pubKey, dtag)
|
|
||||||
|
|
||||||
fun build(
|
fun build(
|
||||||
root: MeetingRoomEvent,
|
root: MeetingRoomEvent,
|
||||||
|
|||||||
+17
@@ -23,15 +23,19 @@ package com.vitorpamplona.quartz.nip53LiveActivities.streaming
|
|||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
|
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.any
|
import com.vitorpamplona.quartz.nip01Core.core.any
|
||||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
|
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
|
||||||
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
|
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
|
||||||
import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint
|
import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint
|
||||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
|
||||||
import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag
|
import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag
|
||||||
import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag
|
import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag
|
||||||
import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag
|
import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag
|
||||||
import com.vitorpamplona.quartz.nip31Alts.AltTag
|
import com.vitorpamplona.quartz.nip31Alts.AltTag
|
||||||
|
import com.vitorpamplona.quartz.nip31Alts.alt
|
||||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.CurrentParticipantsTag
|
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.CurrentParticipantsTag
|
||||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.EndsTag
|
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.EndsTag
|
||||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag
|
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag
|
||||||
@@ -43,6 +47,8 @@ import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag
|
|||||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StreamingTag
|
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StreamingTag
|
||||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.TotalParticipantsTag
|
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.TotalParticipantsTag
|
||||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
|
import kotlin.uuid.ExperimentalUuidApi
|
||||||
|
import kotlin.uuid.Uuid
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
class LiveActivitiesEvent(
|
class LiveActivitiesEvent(
|
||||||
@@ -154,5 +160,16 @@ class LiveActivitiesEvent(
|
|||||||
val tags = arrayOf(AltTag.assemble(ALT))
|
val tags = arrayOf(AltTag.assemble(ALT))
|
||||||
return signer.sign(createdAt, KIND, tags, "")
|
return signer.sign(createdAt, KIND, tags, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalUuidApi::class)
|
||||||
|
fun build(
|
||||||
|
dTag: String = Uuid.random().toString(),
|
||||||
|
createdAt: Long = TimeUtils.now(),
|
||||||
|
initializer: TagArrayBuilder<LiveActivitiesEvent>.() -> Unit = {},
|
||||||
|
) = eventTemplate(KIND, "", createdAt) {
|
||||||
|
dTag(dTag)
|
||||||
|
alt(ALT)
|
||||||
|
initializer()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
/*
|
||||||
|
* 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.nip53LiveActivities.streaming
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
|
||||||
|
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag
|
||||||
|
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NIP-53 Proof of Agreement to Participate.
|
||||||
|
*
|
||||||
|
* The proof is a schnorr signature by the participant's private key over the
|
||||||
|
* SHA256 of the event's addressable `a` tag value (`kind:pubkey:dTag`), encoded as hex.
|
||||||
|
*
|
||||||
|
* See: https://github.com/nostr-protocol/nips/blob/master/53.md#proof-of-agreement-to-participate
|
||||||
|
*/
|
||||||
|
object ProofOfAgreement {
|
||||||
|
/**
|
||||||
|
* Computes the SHA256 digest over the canonical `kind:pubkey:dTag` string.
|
||||||
|
* This is the message that the participant's private key signs.
|
||||||
|
*/
|
||||||
|
fun digest(
|
||||||
|
eventKind: Int,
|
||||||
|
eventAuthor: HexKey,
|
||||||
|
eventDTag: String,
|
||||||
|
): ByteArray = sha256(Address.assemble(eventKind, eventAuthor, eventDTag).encodeToByteArray())
|
||||||
|
|
||||||
|
fun digest(address: Address): ByteArray = digest(address.kind, address.pubKeyHex, address.dTag)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signs the proof using the participant's private key (schnorr over the SHA256
|
||||||
|
* digest of the event's `a` tag value). Returns the hex-encoded signature.
|
||||||
|
*
|
||||||
|
* Only usable by the participant — requires access to their raw private key
|
||||||
|
* (e.g. from a local `NostrSignerInternal`). External signers (NIP-46, NIP-55)
|
||||||
|
* do not expose a raw-bytes sign API so they cannot produce this proof.
|
||||||
|
*/
|
||||||
|
fun sign(
|
||||||
|
participantPrivKey: ByteArray,
|
||||||
|
eventKind: Int,
|
||||||
|
eventAuthor: HexKey,
|
||||||
|
eventDTag: String,
|
||||||
|
): HexKey = Nip01Crypto.sign(digest(eventKind, eventAuthor, eventDTag), participantPrivKey).toHexKey()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies that [proof] is a valid schnorr signature by [participantPubKey] over
|
||||||
|
* SHA256(`kind:pubkey:dTag`). Returns `false` if the proof is malformed, the
|
||||||
|
* pubkey is invalid, or the signature doesn't match.
|
||||||
|
*/
|
||||||
|
fun verify(
|
||||||
|
proof: HexKey,
|
||||||
|
participantPubKey: HexKey,
|
||||||
|
eventKind: Int,
|
||||||
|
eventAuthor: HexKey,
|
||||||
|
eventDTag: String,
|
||||||
|
): Boolean {
|
||||||
|
val signature = proof.hexToByteArrayOrNull() ?: return false
|
||||||
|
if (signature.size != 64) return false
|
||||||
|
val pubKey = participantPubKey.hexToByteArrayOrNull() ?: return false
|
||||||
|
if (pubKey.size != 32) return false
|
||||||
|
|
||||||
|
return runCatching {
|
||||||
|
Nip01Crypto.verify(signature, digest(eventKind, eventAuthor, eventDTag), pubKey)
|
||||||
|
}.getOrDefault(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun verify(
|
||||||
|
proof: HexKey,
|
||||||
|
participantPubKey: HexKey,
|
||||||
|
address: Address,
|
||||||
|
): Boolean = verify(proof, participantPubKey, address.kind, address.pubKeyHex, address.dTag)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies a participant tag's proof against its owning [LiveActivitiesEvent].
|
||||||
|
* Returns `false` if the tag has no proof or the proof is invalid.
|
||||||
|
*/
|
||||||
|
fun verify(
|
||||||
|
tag: ParticipantTag,
|
||||||
|
event: LiveActivitiesEvent,
|
||||||
|
): Boolean {
|
||||||
|
val proof = tag.proof ?: return false
|
||||||
|
return verify(proof, tag.pubKey, event.address())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience: check whether a participant's proof of agreement is valid for this event.
|
||||||
|
* Useful for UI that wants to distinguish "verified" participants from "invited" ones
|
||||||
|
* (per NIP-53: "Clients MAY only display participants if the proof is available or
|
||||||
|
* MAY display participants as 'invited' if the proof is not available").
|
||||||
|
*/
|
||||||
|
fun LiveActivitiesEvent.hasValidProof(participant: ParticipantTag): Boolean = ProofOfAgreement.verify(participant, this)
|
||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
/*
|
||||||
|
* 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.nip53LiveActivities.streaming
|
||||||
|
|
||||||
|
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.nip23LongContent.tags.ImageTag
|
||||||
|
import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag
|
||||||
|
import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag
|
||||||
|
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.CurrentParticipantsTag
|
||||||
|
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.EndsTag
|
||||||
|
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag
|
||||||
|
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.PinnedEventTag
|
||||||
|
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.RecordingTag
|
||||||
|
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.RelayListTag
|
||||||
|
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StartsTag
|
||||||
|
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag
|
||||||
|
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StreamingTag
|
||||||
|
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.TotalParticipantsTag
|
||||||
|
|
||||||
|
fun TagArrayBuilder<LiveActivitiesEvent>.title(title: String) = addUnique(TitleTag.assemble(title))
|
||||||
|
|
||||||
|
fun TagArrayBuilder<LiveActivitiesEvent>.summary(summary: String) = addUnique(SummaryTag.assemble(summary))
|
||||||
|
|
||||||
|
fun TagArrayBuilder<LiveActivitiesEvent>.image(imageUrl: String) = addUnique(ImageTag.assemble(imageUrl))
|
||||||
|
|
||||||
|
fun TagArrayBuilder<LiveActivitiesEvent>.streaming(url: String) = addUnique(StreamingTag.assemble(url))
|
||||||
|
|
||||||
|
fun TagArrayBuilder<LiveActivitiesEvent>.recording(url: String) = addUnique(RecordingTag.assemble(url))
|
||||||
|
|
||||||
|
fun TagArrayBuilder<LiveActivitiesEvent>.starts(timestamp: Long) = addUnique(StartsTag.assemble(timestamp))
|
||||||
|
|
||||||
|
fun TagArrayBuilder<LiveActivitiesEvent>.ends(timestamp: Long) = addUnique(EndsTag.assemble(timestamp))
|
||||||
|
|
||||||
|
fun TagArrayBuilder<LiveActivitiesEvent>.status(status: StatusTag.STATUS) = addUnique(StatusTag.assemble(status))
|
||||||
|
|
||||||
|
fun TagArrayBuilder<LiveActivitiesEvent>.currentParticipants(count: Int) = addUnique(CurrentParticipantsTag.assemble(count))
|
||||||
|
|
||||||
|
fun TagArrayBuilder<LiveActivitiesEvent>.totalParticipants(count: Int) = addUnique(TotalParticipantsTag.assemble(count))
|
||||||
|
|
||||||
|
fun TagArrayBuilder<LiveActivitiesEvent>.participant(
|
||||||
|
pubKey: HexKey,
|
||||||
|
relayHint: NormalizedRelayUrl? = null,
|
||||||
|
role: String? = null,
|
||||||
|
proof: HexKey? = null,
|
||||||
|
) = add(ParticipantTag.assemble(pubKey, relayHint?.url, role, proof))
|
||||||
|
|
||||||
|
fun TagArrayBuilder<LiveActivitiesEvent>.participants(participants: List<ParticipantTag>) =
|
||||||
|
participants.forEach {
|
||||||
|
participant(it.pubKey, it.relayHint, it.role, it.proof)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun TagArrayBuilder<LiveActivitiesEvent>.relays(urls: List<NormalizedRelayUrl>) = addUnique(RelayListTag.assemble(urls))
|
||||||
|
|
||||||
|
fun TagArrayBuilder<LiveActivitiesEvent>.pinned(eventId: HexKey) = add(PinnedEventTag.assemble(eventId))
|
||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
/*
|
||||||
|
* 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.nip53LiveActivities.meetingSpaces
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||||
|
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.MeetingSpaceTag
|
||||||
|
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag
|
||||||
|
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag as StreamStatusTag
|
||||||
|
|
||||||
|
class MeetingSpaceEventBuildTest {
|
||||||
|
private val host = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun meetingSpaceBuildAssemblesRequiredTags() {
|
||||||
|
val template =
|
||||||
|
MeetingSpaceEvent.build(
|
||||||
|
room = "Main Hall",
|
||||||
|
status = StatusTag.STATUS.OPEN,
|
||||||
|
service = "https://meet.example.com/hall",
|
||||||
|
host = ParticipantTag(host, null, "Host", null),
|
||||||
|
dTag = "main-hall",
|
||||||
|
createdAt = 1_700_000_000L,
|
||||||
|
) {
|
||||||
|
summary("The big room")
|
||||||
|
image("https://example.com/hall.png")
|
||||||
|
endpoint("https://api.example.com/hall")
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(MeetingSpaceEvent.KIND, template.kind)
|
||||||
|
val byName = template.tags.groupBy { it[0] }
|
||||||
|
|
||||||
|
assertEquals("main-hall", byName["d"]?.single()?.get(1))
|
||||||
|
assertEquals("Main Hall", byName["room"]?.single()?.get(1))
|
||||||
|
assertEquals("open", byName["status"]?.single()?.get(1))
|
||||||
|
assertEquals("https://meet.example.com/hall", byName["service"]?.single()?.get(1))
|
||||||
|
assertEquals("https://api.example.com/hall", byName["endpoint"]?.single()?.get(1))
|
||||||
|
assertEquals("The big room", byName["summary"]?.single()?.get(1))
|
||||||
|
assertEquals("https://example.com/hall.png", byName["image"]?.single()?.get(1))
|
||||||
|
assertEquals(MeetingSpaceEvent.ALT, byName["alt"]?.single()?.get(1))
|
||||||
|
|
||||||
|
val hostTag = byName["p"]?.single()
|
||||||
|
assertNotNull(hostTag)
|
||||||
|
assertEquals(host, hostTag[1])
|
||||||
|
assertEquals("Host", hostTag[3])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun meetingRoomBuildReferencesParentSpace() {
|
||||||
|
val spaceAddress = Address(MeetingSpaceEvent.KIND, host, "main-hall")
|
||||||
|
val template =
|
||||||
|
MeetingRoomEvent.build(
|
||||||
|
meetingSpace = MeetingSpaceTag(spaceAddress),
|
||||||
|
title = "Annual Meeting",
|
||||||
|
starts = 1_700_000_000L,
|
||||||
|
status = StreamStatusTag.STATUS.PLANNED,
|
||||||
|
dTag = "annual-2026",
|
||||||
|
createdAt = 1_700_000_000L,
|
||||||
|
) {
|
||||||
|
ends(1_700_003_600L)
|
||||||
|
currentParticipants(10)
|
||||||
|
totalParticipants(100)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(MeetingRoomEvent.KIND, template.kind)
|
||||||
|
val byName = template.tags.groupBy { it[0] }
|
||||||
|
|
||||||
|
assertEquals("annual-2026", byName["d"]?.single()?.get(1))
|
||||||
|
assertEquals("Annual Meeting", byName["title"]?.single()?.get(1))
|
||||||
|
assertEquals("1700000000", byName["starts"]?.single()?.get(1))
|
||||||
|
assertEquals("1700003600", byName["ends"]?.single()?.get(1))
|
||||||
|
assertEquals("planned", byName["status"]?.single()?.get(1))
|
||||||
|
assertEquals("10", byName["current_participants"]?.single()?.get(1))
|
||||||
|
assertEquals("100", byName["total_participants"]?.single()?.get(1))
|
||||||
|
assertEquals(MeetingRoomEvent.ALT, byName["alt"]?.single()?.get(1))
|
||||||
|
|
||||||
|
val aTag = byName["a"]?.single()
|
||||||
|
assertNotNull(aTag)
|
||||||
|
assertEquals("${MeetingSpaceEvent.KIND}:$host:main-hall", aTag[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
/*
|
||||||
|
* 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.nip53LiveActivities.presence
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
class MeetingRoomPresenceEventTest {
|
||||||
|
private val participant = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun kind10312IsReplaceable() {
|
||||||
|
val event =
|
||||||
|
MeetingRoomPresenceEvent(
|
||||||
|
id = "0".repeat(64),
|
||||||
|
pubKey = participant,
|
||||||
|
createdAt = 1_700_000_000L,
|
||||||
|
tags =
|
||||||
|
arrayOf(
|
||||||
|
arrayOf("a", "30313:${"b".repeat(64)}:annual-2026"),
|
||||||
|
arrayOf("hand", "1"),
|
||||||
|
),
|
||||||
|
content = "",
|
||||||
|
sig = "0".repeat(128),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Per NIP-53, kind 10312 is a regular replaceable event: "presence can only
|
||||||
|
// be indicated in one room at a time". Replaceable events have a fixed empty
|
||||||
|
// d-tag regardless of what's in the event tags.
|
||||||
|
assertEquals("", event.dTag())
|
||||||
|
assertEquals("${MeetingRoomPresenceEvent.KIND}:$participant:", event.addressTag())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun createAddressKeysByPubKeyOnly() {
|
||||||
|
val a = MeetingRoomPresenceEvent.createAddress(participant)
|
||||||
|
assertEquals(MeetingRoomPresenceEvent.KIND, a.kind)
|
||||||
|
assertEquals(participant, a.pubKeyHex)
|
||||||
|
assertEquals("", a.dTag)
|
||||||
|
|
||||||
|
val aTag = MeetingRoomPresenceEvent.createAddressATag(participant)
|
||||||
|
assertEquals(MeetingRoomPresenceEvent.KIND, aTag.kind)
|
||||||
|
assertEquals(participant, aTag.pubKeyHex)
|
||||||
|
assertEquals("", aTag.dTag)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"${MeetingRoomPresenceEvent.KIND}:$participant:",
|
||||||
|
MeetingRoomPresenceEvent.createAddressTag(participant),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+97
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
* 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.nip53LiveActivities.streaming
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertContentEquals
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class LiveActivitiesEventBuildTest {
|
||||||
|
private val host = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
|
private val guest = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildAssemblesAllRequiredAndOptionalTags() {
|
||||||
|
val template =
|
||||||
|
LiveActivitiesEvent.build(dTag = "my-stream", createdAt = 1_700_000_000L) {
|
||||||
|
title("My Stream")
|
||||||
|
summary("A live event")
|
||||||
|
image("https://example.com/img.png")
|
||||||
|
streaming("https://example.com/stream.m3u8")
|
||||||
|
starts(1_700_000_000L)
|
||||||
|
status(StatusTag.STATUS.LIVE)
|
||||||
|
currentParticipants(42)
|
||||||
|
totalParticipants(100)
|
||||||
|
participant(host, role = "Host")
|
||||||
|
participant(guest, role = "Speaker")
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(LiveActivitiesEvent.KIND, template.kind)
|
||||||
|
assertEquals("", template.content)
|
||||||
|
|
||||||
|
val tagsByName = template.tags.groupBy { it[0] }
|
||||||
|
|
||||||
|
assertEquals("my-stream", tagsByName["d"]?.single()?.get(1))
|
||||||
|
assertEquals("My Stream", tagsByName["title"]?.single()?.get(1))
|
||||||
|
assertEquals("A live event", tagsByName["summary"]?.single()?.get(1))
|
||||||
|
assertEquals("https://example.com/img.png", tagsByName["image"]?.single()?.get(1))
|
||||||
|
assertEquals("https://example.com/stream.m3u8", tagsByName["streaming"]?.single()?.get(1))
|
||||||
|
assertEquals("1700000000", tagsByName["starts"]?.single()?.get(1))
|
||||||
|
assertEquals("live", tagsByName["status"]?.single()?.get(1))
|
||||||
|
assertEquals("42", tagsByName["current_participants"]?.single()?.get(1))
|
||||||
|
assertEquals("100", tagsByName["total_participants"]?.single()?.get(1))
|
||||||
|
assertEquals(LiveActivitiesEvent.ALT, tagsByName["alt"]?.single()?.get(1))
|
||||||
|
|
||||||
|
// Per NIP-53, role lives at index 3 of the p-tag; a missing relay must be
|
||||||
|
// represented as an empty string placeholder, not dropped.
|
||||||
|
val pTags = tagsByName["p"] ?: emptyList()
|
||||||
|
assertEquals(2, pTags.size)
|
||||||
|
assertContentEquals(arrayOf("p", host, "", "Host"), pTags[0])
|
||||||
|
assertContentEquals(arrayOf("p", guest, "", "Speaker"), pTags[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildAllowsMultiplePinnedAndHashtagsWithoutClobbering() {
|
||||||
|
val template =
|
||||||
|
LiveActivitiesEvent.build(dTag = "my-stream") {
|
||||||
|
title("title")
|
||||||
|
pinned("1".repeat(64))
|
||||||
|
pinned("2".repeat(64))
|
||||||
|
}
|
||||||
|
|
||||||
|
val pinned = template.tags.filter { it[0] == "pinned" }
|
||||||
|
assertEquals(2, pinned.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildUsesRandomDTagWhenNotProvided() {
|
||||||
|
val a = LiveActivitiesEvent.build { title("a") }
|
||||||
|
val b = LiveActivitiesEvent.build { title("b") }
|
||||||
|
val aDTag = a.tags.first { it[0] == "d" }[1]
|
||||||
|
val bDTag = b.tags.first { it[0] == "d" }[1]
|
||||||
|
assertNotNull(aDTag)
|
||||||
|
assertTrue(aDTag.isNotEmpty())
|
||||||
|
assertTrue(aDTag != bDTag, "random dTag should differ between builds")
|
||||||
|
}
|
||||||
|
}
|
||||||
+204
@@ -0,0 +1,204 @@
|
|||||||
|
/*
|
||||||
|
* 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.nip53LiveActivities.streaming
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||||
|
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class ProofOfAgreementTest {
|
||||||
|
private val dummySig = "0".repeat(128)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun roundTripSignAndVerify() {
|
||||||
|
val host = KeyPair()
|
||||||
|
val participant = KeyPair()
|
||||||
|
val hostHex = host.pubKey.toHexKey()
|
||||||
|
val participantHex = participant.pubKey.toHexKey()
|
||||||
|
val dTag = "my-stream"
|
||||||
|
|
||||||
|
val proof =
|
||||||
|
ProofOfAgreement.sign(
|
||||||
|
participantPrivKey = participant.privKey!!,
|
||||||
|
eventKind = LiveActivitiesEvent.KIND,
|
||||||
|
eventAuthor = hostHex,
|
||||||
|
eventDTag = dTag,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
ProofOfAgreement.verify(
|
||||||
|
proof = proof,
|
||||||
|
participantPubKey = participantHex,
|
||||||
|
eventKind = LiveActivitiesEvent.KIND,
|
||||||
|
eventAuthor = hostHex,
|
||||||
|
eventDTag = dTag,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun verifyFailsForDifferentAddress() {
|
||||||
|
val host = KeyPair()
|
||||||
|
val participant = KeyPair()
|
||||||
|
val hostHex = host.pubKey.toHexKey()
|
||||||
|
val participantHex = participant.pubKey.toHexKey()
|
||||||
|
|
||||||
|
val proof =
|
||||||
|
ProofOfAgreement.sign(
|
||||||
|
participantPrivKey = participant.privKey!!,
|
||||||
|
eventKind = LiveActivitiesEvent.KIND,
|
||||||
|
eventAuthor = hostHex,
|
||||||
|
eventDTag = "one",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertFalse(
|
||||||
|
ProofOfAgreement.verify(
|
||||||
|
proof = proof,
|
||||||
|
participantPubKey = participantHex,
|
||||||
|
eventKind = LiveActivitiesEvent.KIND,
|
||||||
|
eventAuthor = hostHex,
|
||||||
|
eventDTag = "another",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun verifyFailsForWrongParticipantKey() {
|
||||||
|
val host = KeyPair()
|
||||||
|
val participant = KeyPair()
|
||||||
|
val imposter = KeyPair()
|
||||||
|
val hostHex = host.pubKey.toHexKey()
|
||||||
|
|
||||||
|
val proof =
|
||||||
|
ProofOfAgreement.sign(
|
||||||
|
participantPrivKey = participant.privKey!!,
|
||||||
|
eventKind = LiveActivitiesEvent.KIND,
|
||||||
|
eventAuthor = hostHex,
|
||||||
|
eventDTag = "stream",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertFalse(
|
||||||
|
ProofOfAgreement.verify(
|
||||||
|
proof = proof,
|
||||||
|
participantPubKey = imposter.pubKey.toHexKey(),
|
||||||
|
eventKind = LiveActivitiesEvent.KIND,
|
||||||
|
eventAuthor = hostHex,
|
||||||
|
eventDTag = "stream",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun verifyFailsForMalformedProof() {
|
||||||
|
assertFalse(
|
||||||
|
ProofOfAgreement.verify(
|
||||||
|
proof = "not-hex",
|
||||||
|
participantPubKey = "a".repeat(64),
|
||||||
|
eventKind = LiveActivitiesEvent.KIND,
|
||||||
|
eventAuthor = "a".repeat(64),
|
||||||
|
eventDTag = "x",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertFalse(
|
||||||
|
ProofOfAgreement.verify(
|
||||||
|
proof = "ab",
|
||||||
|
participantPubKey = "a".repeat(64),
|
||||||
|
eventKind = LiveActivitiesEvent.KIND,
|
||||||
|
eventAuthor = "a".repeat(64),
|
||||||
|
eventDTag = "x",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun verifyAgainstLiveActivitiesEvent() {
|
||||||
|
val host = KeyPair()
|
||||||
|
val participant = KeyPair()
|
||||||
|
val hostHex = host.pubKey.toHexKey()
|
||||||
|
val participantHex = participant.pubKey.toHexKey()
|
||||||
|
val dTag = "event-d"
|
||||||
|
|
||||||
|
val proof =
|
||||||
|
ProofOfAgreement.sign(
|
||||||
|
participantPrivKey = participant.privKey!!,
|
||||||
|
eventKind = LiveActivitiesEvent.KIND,
|
||||||
|
eventAuthor = hostHex,
|
||||||
|
eventDTag = dTag,
|
||||||
|
)
|
||||||
|
|
||||||
|
val event =
|
||||||
|
LiveActivitiesEvent(
|
||||||
|
id = "0".repeat(64),
|
||||||
|
pubKey = hostHex,
|
||||||
|
createdAt = 1_700_000_000L,
|
||||||
|
tags =
|
||||||
|
arrayOf(
|
||||||
|
arrayOf("d", dTag),
|
||||||
|
arrayOf("p", participantHex, "", "Speaker", proof),
|
||||||
|
arrayOf("p", "c".repeat(64), "", "Speaker"),
|
||||||
|
),
|
||||||
|
content = "",
|
||||||
|
sig = dummySig,
|
||||||
|
)
|
||||||
|
|
||||||
|
val verifiedTag = event.participants().first { it.pubKey == participantHex }
|
||||||
|
val unverifiedTag = event.participants().first { it.pubKey == "c".repeat(64) }
|
||||||
|
|
||||||
|
assertTrue(event.hasValidProof(verifiedTag))
|
||||||
|
assertFalse(event.hasValidProof(unverifiedTag), "missing proof must not be treated as valid")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun digestMatchesAddressForm() {
|
||||||
|
// digest(kind, pubkey, dtag) must agree with digest(Address)
|
||||||
|
val hostHex = "a".repeat(64)
|
||||||
|
val d1 = ProofOfAgreement.digest(LiveActivitiesEvent.KIND, hostHex, "abc")
|
||||||
|
val d2 = ProofOfAgreement.digest(Address(LiveActivitiesEvent.KIND, hostHex, "abc"))
|
||||||
|
assertTrue(d1.contentEquals(d2))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tagWithoutProofFailsVerification() {
|
||||||
|
val host = KeyPair()
|
||||||
|
val participant = KeyPair()
|
||||||
|
|
||||||
|
val event =
|
||||||
|
LiveActivitiesEvent(
|
||||||
|
id = "0".repeat(64),
|
||||||
|
pubKey = host.pubKey.toHexKey(),
|
||||||
|
createdAt = 1_700_000_000L,
|
||||||
|
tags = arrayOf(arrayOf("d", "d"), arrayOf("p", participant.pubKey.toHexKey())),
|
||||||
|
content = "",
|
||||||
|
sig = dummySig,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertFalse(
|
||||||
|
ProofOfAgreement.verify(
|
||||||
|
tag = ParticipantTag(participant.pubKey.toHexKey(), null, null, null),
|
||||||
|
event = event,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user