mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 19:53:08 +00:00
feat(quartz): make NIP-29 event layer a verified superset of Armada
Audited Quartz's nip29RelayGroups package against every event and tag Armada (gitlab.com/soapbox-pub/armada) creates and reads, and closed the gaps so Quartz can round-trip all of them. - GroupMetadataEvent (39000): fix flag parsing. `private`/`restricted`/ `hidden`/`closed`/`livekit` are single-element presence tags, but the old accessors used hasTagWithContent (requires a value) AND conflated restricted<->closed and hidden<->private with wrong defaults (an open public group parsed as private+restricted). Now each flag is a plain presence check. Add hasLivekit() and supportedKinds(), extend GroupStatus with RESTRICTED/HIDDEN/LIVEKIT, and support supported_kinds in build(). - Add GroupParticipantsEvent (kind 39004, LiveKit AV presence) and register it in EventFactory + KindNames. - Add TagArray.hasTagName() presence helper in nip01Core. - Add GroupScope.kt: hTag() builder + Event.groupId()/isGroupScoped() readers so kind 9 (chat), 11 (thread), 7 (reaction), 1068, 1111 etc. can be group-scoped via the `h` tag and read back — NIP-29 reuses these carriers rather than defining new content kinds. - Add Nip29ArmadaInteropTest covering parse + build for every event with Armada's exact tag shapes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
This commit is contained in:
@@ -96,6 +96,7 @@ import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupParticipantsEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.SupportedRolesEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateGroupEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateInviteEvent
|
||||
@@ -611,6 +612,7 @@ object KindNames {
|
||||
GroupAdminsEvent.KIND to KindName("Group Admins", "29"),
|
||||
GroupMembersEvent.KIND to KindName("Group Members", "29"),
|
||||
SupportedRolesEvent.KIND to KindName("Group Roles", "29"),
|
||||
GroupParticipantsEvent.KIND to KindName("Group Participants", "29"),
|
||||
MediaStarterPackEvent.KIND to KindName("Media Starter Pack", "51"),
|
||||
)
|
||||
|
||||
|
||||
@@ -97,6 +97,13 @@ fun TagArray.anyTagWithValueStartingWith(
|
||||
*/
|
||||
fun TagArray.hasTagWithContent(tagName: String) = this.fastAny { it.size > 1 && it[0] == tagName }
|
||||
|
||||
/**
|
||||
* Returns `true` if at least one tag has the given name, regardless of whether it
|
||||
* carries a value. Use this for presence/flag tags like `["private"]` or `["closed"]`
|
||||
* (NIP-29 status flags) where the tag name alone is the signal.
|
||||
*/
|
||||
fun TagArray.hasTagName(tagName: String) = this.fastAny { it.isNotEmpty() && it[0] == tagName }
|
||||
|
||||
/**
|
||||
* Returns a list containing only the non-null results of applying the tag value to the given [transform] function
|
||||
* to each tag that matches the [tagName]
|
||||
|
||||
@@ -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.nip29RelayGroups
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
|
||||
|
||||
/*
|
||||
* NIP-29 group scoping via the `h` tag.
|
||||
*
|
||||
* NIP-29 does not define new event kinds for content: a group chat message is a
|
||||
* regular kind-9 [com.vitorpamplona.quartz.nipC7Chats.ChatEvent], a thread is a
|
||||
* kind-11 [com.vitorpamplona.quartz.nip7DThreads.ThreadEvent], and reactions,
|
||||
* polls, comments and deletions keep their own kinds. What makes any of them
|
||||
* "belong" to a group is a single `["h", "<groupId>"]` tag that tells the
|
||||
* relay to route and authorize the write. These helpers add and read that tag on
|
||||
* ANY event, so the existing carrier classes can be reused unchanged:
|
||||
*
|
||||
* ```
|
||||
* val msg = ChatEvent.build("gm") { hTag(groupId) } // kind 9 in a group
|
||||
* val react = ReactionEvent.build("🔥", target) { hTag(gid) } // reaction in a group
|
||||
* val gid = someEvent.groupId() // which group is this in?
|
||||
* ```
|
||||
*/
|
||||
|
||||
/** Adds the `["h", groupId]` NIP-29 group tag (deduplicated). */
|
||||
fun <T : Event> TagArrayBuilder<T>.hTag(groupId: String) = addUnique(GroupIdTag.assemble(groupId))
|
||||
|
||||
/** The group id (`h` tag value) that scopes this tag array to a NIP-29 group, or null. */
|
||||
fun TagArray.hTag(): String? = firstTagValue(GroupIdTag.TAG_NAME)
|
||||
|
||||
/** The group id (`h` tag value) that scopes this event to a NIP-29 group, or null. */
|
||||
fun Event.groupId(): String? = tags.hTag()
|
||||
|
||||
/** True when this event carries a NIP-29 group (`h`) tag. */
|
||||
fun Event.isGroupScoped(): Boolean = groupId() != null
|
||||
+30
-6
@@ -25,7 +25,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.firstTagValue
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hasTagWithContent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hasTagName
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
|
||||
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
|
||||
@@ -51,18 +51,35 @@ class GroupMetadataEvent(
|
||||
|
||||
fun picture() = tags.firstTagValue("picture")
|
||||
|
||||
fun isPrivate() = tags.hasTagWithContent("private") || !tags.hasTagWithContent("public")
|
||||
/** Only members can read. Presence of the `private` flag; absent = public read. */
|
||||
fun isPrivate() = tags.hasTagName("private")
|
||||
|
||||
fun isRestricted() = tags.hasTagWithContent("closed") || !tags.hasTagWithContent("open")
|
||||
/** Only members can write. Presence of the `restricted` flag; absent = open write. */
|
||||
fun isRestricted() = tags.hasTagName("restricted")
|
||||
|
||||
fun isHidden() = tags.hasTagWithContent("private")
|
||||
/** Metadata hidden from non-members. Presence of the `hidden` flag. */
|
||||
fun isHidden() = tags.hasTagName("hidden")
|
||||
|
||||
fun isClosed() = tags.hasTagWithContent("closed")
|
||||
/** Join requests are ignored (invite-only). Presence of the `closed` flag; absent = open join. */
|
||||
fun isClosed() = tags.hasTagName("closed")
|
||||
|
||||
/** Group supports LiveKit-powered live audio/video. Presence of the `livekit` flag. */
|
||||
fun hasLivekit() = tags.hasTagName("livekit")
|
||||
|
||||
/**
|
||||
* The kinds this group accepts, when constrained, e.g. `["supported_kinds", "9", "11"]`.
|
||||
* `null` (tag absent) means all kinds are accepted.
|
||||
*/
|
||||
fun supportedKinds(): List<Int>? =
|
||||
tags
|
||||
.firstOrNull { it.isNotEmpty() && it[0] == "supported_kinds" }
|
||||
?.drop(1)
|
||||
?.mapNotNull { it.toIntOrNull() }
|
||||
|
||||
fun statusTags(): Set<GroupStatus> {
|
||||
val statuses = mutableSetOf<GroupStatus>()
|
||||
GroupStatus.entries.forEach { status ->
|
||||
if (tags.hasTagWithContent(status.code)) {
|
||||
if (tags.hasTagName(status.code)) {
|
||||
statuses.add(status)
|
||||
}
|
||||
}
|
||||
@@ -74,8 +91,11 @@ class GroupMetadataEvent(
|
||||
) {
|
||||
PRIVATE("private"),
|
||||
PUBLIC("public"),
|
||||
RESTRICTED("restricted"),
|
||||
OPEN("open"),
|
||||
CLOSED("closed"),
|
||||
HIDDEN("hidden"),
|
||||
LIVEKIT("livekit"),
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -87,6 +107,7 @@ class GroupMetadataEvent(
|
||||
about: String? = null,
|
||||
picture: String? = null,
|
||||
status: Set<GroupStatus> = emptySet(),
|
||||
supportedKinds: List<Int>? = null,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<GroupMetadataEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, "", createdAt) {
|
||||
@@ -95,6 +116,9 @@ class GroupMetadataEvent(
|
||||
about?.let { add(arrayOf("about", it)) }
|
||||
picture?.let { add(arrayOf("picture", it)) }
|
||||
status.forEach { add(arrayOf(it.code)) }
|
||||
supportedKinds?.let { kinds ->
|
||||
add((listOf("supported_kinds") + kinds.map { it.toString() }).toTypedArray())
|
||||
}
|
||||
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.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip29RelayGroups.metadata
|
||||
|
||||
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.core.mapValueTagged
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* Relay-signed live audio/video room presence (kind 39004). A group that
|
||||
* advertises the `livekit` metadata flag publishes this addressable event
|
||||
* (`d` = group id) listing the pubkeys currently connected to the AV room, one
|
||||
* per `["participant", "<pubkey>"]` tag. Updated by the relay as users join and
|
||||
* leave. This is an extension used by LiveKit-backed deployments (e.g. Armada);
|
||||
* it is not part of NIP-29 proper, but is parsed here for interop.
|
||||
*/
|
||||
@Immutable
|
||||
class GroupParticipantsEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
fun groupId() = dTag()
|
||||
|
||||
fun participants(): List<HexKey> = tags.mapValueTagged(TAG_NAME) { it }
|
||||
|
||||
companion object {
|
||||
const val KIND = 39004
|
||||
const val TAG_NAME = "participant"
|
||||
|
||||
fun build(
|
||||
groupId: String,
|
||||
participantPubKeys: List<HexKey>,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<GroupParticipantsEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, "", createdAt) {
|
||||
dTag(groupId)
|
||||
participantPubKeys.forEach { add(arrayOf(TAG_NAME, it)) }
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,7 @@ import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupParticipantsEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.SupportedRolesEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateGroupEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateInviteEvent
|
||||
@@ -396,6 +397,7 @@ class EventFactory {
|
||||
GroupAdminsEvent.KIND -> GroupAdminsEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
GroupMembersEvent.KIND -> GroupMembersEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
SupportedRolesEvent.KIND -> SupportedRolesEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
GroupParticipantsEvent.KIND -> GroupParticipantsEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ChessGameEvent.KIND -> ChessGameEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
CodeSnippetEvent.KIND -> CodeSnippetEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
RelayFeedsListEvent.KIND -> RelayFeedsListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
/*
|
||||
* 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.nip29RelayGroups
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupParticipantsEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.SupportedRolesEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateGroupEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateInviteEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.DeleteEventEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.DeleteGroupEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.PutUserEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent
|
||||
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
import com.vitorpamplona.quartz.utils.EventFactory
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Interop tests asserting that Quartz can PARSE every NIP-29 event Armada
|
||||
* (gitlab.com/soapbox-pub/armada) produces — with the exact tag shapes Armada
|
||||
* emits — and BUILD events whose tags match what Armada expects. Tag shapes are
|
||||
* taken verbatim from Armada's `client/src/lib/nip29.ts` and its hooks.
|
||||
*/
|
||||
class Nip29ArmadaInteropTest {
|
||||
private val relaySelf = "aa".repeat(32)
|
||||
private val alice = "bb".repeat(32)
|
||||
private val bob = "cc".repeat(32)
|
||||
private val gid = "0123456789abcdef"
|
||||
|
||||
private fun parse(
|
||||
kind: Int,
|
||||
tags: Array<Array<String>>,
|
||||
content: String = "",
|
||||
pubKey: String = relaySelf,
|
||||
): Event =
|
||||
EventFactory.create(
|
||||
id = "00".repeat(32),
|
||||
pubKey = pubKey,
|
||||
createdAt = 1_700_000_000L,
|
||||
kind = kind,
|
||||
tags = tags,
|
||||
content = content,
|
||||
sig = "22".repeat(64),
|
||||
)
|
||||
|
||||
// ── Relay-signed metadata (39000-39004) ──────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun parsesGroupMetadataWithEveryFlag() {
|
||||
// Armada relay29 emits presence flags as single-element tags.
|
||||
val e =
|
||||
parse(
|
||||
GroupMetadataEvent.KIND,
|
||||
arrayOf(
|
||||
arrayOf("d", gid),
|
||||
arrayOf("name", "General"),
|
||||
arrayOf("about", "the main room"),
|
||||
arrayOf("picture", "https://x/y.png"),
|
||||
arrayOf("private"),
|
||||
arrayOf("restricted"),
|
||||
arrayOf("hidden"),
|
||||
arrayOf("closed"),
|
||||
arrayOf("livekit"),
|
||||
arrayOf("supported_kinds", "9", "11"),
|
||||
),
|
||||
)
|
||||
assertTrue(e is GroupMetadataEvent)
|
||||
assertEquals(gid, e.groupId())
|
||||
assertEquals("General", e.name())
|
||||
assertEquals("the main room", e.about())
|
||||
assertEquals("https://x/y.png", e.picture())
|
||||
assertTrue(e.isPrivate())
|
||||
assertTrue(e.isRestricted())
|
||||
assertTrue(e.isHidden())
|
||||
assertTrue(e.isClosed())
|
||||
assertTrue(e.hasLivekit())
|
||||
assertEquals(listOf(9, 11), e.supportedKinds())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesOpenPublicGroupAsUnrestricted() {
|
||||
// Regression: a group with NO flag tags is public/open/visible — the old
|
||||
// accessors defaulted several of these to `true`.
|
||||
val e = parse(GroupMetadataEvent.KIND, arrayOf(arrayOf("d", gid), arrayOf("name", "Open"))) as GroupMetadataEvent
|
||||
assertFalse(e.isPrivate())
|
||||
assertFalse(e.isRestricted())
|
||||
assertFalse(e.isHidden())
|
||||
assertFalse(e.isClosed())
|
||||
assertFalse(e.hasLivekit())
|
||||
assertNull(e.supportedKinds())
|
||||
assertEquals("Open", e.name())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fallsBackToGroupIdWhenNoName() {
|
||||
val e = parse(GroupMetadataEvent.KIND, arrayOf(arrayOf("d", gid))) as GroupMetadataEvent
|
||||
assertNull(e.name())
|
||||
assertEquals(gid, e.groupId())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesGroupAdminsWithRoles() {
|
||||
// Armada: ["p", pubkey, "role1", "role2"]
|
||||
val e =
|
||||
parse(
|
||||
GroupAdminsEvent.KIND,
|
||||
arrayOf(
|
||||
arrayOf("d", gid),
|
||||
arrayOf("p", alice, "admin"),
|
||||
arrayOf("p", bob, "moderator", "ceo"),
|
||||
),
|
||||
) as GroupAdminsEvent
|
||||
val admins = e.admins()
|
||||
assertEquals(2, admins.size)
|
||||
assertEquals(alice, admins[0].pubKey)
|
||||
assertEquals(listOf("admin"), admins[0].roles)
|
||||
assertEquals(listOf("moderator", "ceo"), admins[1].roles)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesGroupMembers() {
|
||||
val e =
|
||||
parse(
|
||||
GroupMembersEvent.KIND,
|
||||
arrayOf(arrayOf("d", gid), arrayOf("p", alice), arrayOf("p", bob)),
|
||||
) as GroupMembersEvent
|
||||
assertEquals(listOf(alice, bob), e.members())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesGroupRoles() {
|
||||
val e =
|
||||
parse(
|
||||
SupportedRolesEvent.KIND,
|
||||
arrayOf(
|
||||
arrayOf("role", "admin", "Full control"),
|
||||
arrayOf("role", "moderator"),
|
||||
),
|
||||
) as SupportedRolesEvent
|
||||
val roles = e.roles()
|
||||
assertEquals("admin", roles[0].name)
|
||||
assertEquals("Full control", roles[0].description)
|
||||
assertEquals("moderator", roles[1].name)
|
||||
assertNull(roles[1].description)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesGroupParticipants() {
|
||||
// Armada AV: ["participant", pubkey]
|
||||
val e =
|
||||
parse(
|
||||
GroupParticipantsEvent.KIND,
|
||||
arrayOf(arrayOf("d", gid), arrayOf("participant", alice), arrayOf("participant", bob)),
|
||||
)
|
||||
assertTrue(e is GroupParticipantsEvent)
|
||||
assertEquals(gid, e.groupId())
|
||||
assertEquals(listOf(alice, bob), e.participants())
|
||||
}
|
||||
|
||||
// ── User moderation/request events (9xxx) ────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun parsesModerationEvents() {
|
||||
assertTrue(parse(CreateGroupEvent.KIND, arrayOf(arrayOf("h", gid)), pubKey = alice) is CreateGroupEvent)
|
||||
assertTrue(parse(DeleteGroupEvent.KIND, arrayOf(arrayOf("h", gid)), pubKey = alice) is DeleteGroupEvent)
|
||||
|
||||
val put = parse(PutUserEvent.KIND, arrayOf(arrayOf("h", gid), arrayOf("p", bob, "moderator")), pubKey = alice) as PutUserEvent
|
||||
assertEquals(gid, put.groupId())
|
||||
assertEquals(listOf(bob), put.userPubKeys())
|
||||
|
||||
val remove = parse(RemoveUserEvent.KIND, arrayOf(arrayOf("h", gid), arrayOf("p", bob)), content = "spam", pubKey = alice) as RemoveUserEvent
|
||||
assertEquals(listOf(bob), remove.userPubKeys())
|
||||
assertEquals("spam", remove.content)
|
||||
|
||||
val del = parse(DeleteEventEvent.KIND, arrayOf(arrayOf("h", gid), arrayOf("e", "de".repeat(32))), pubKey = alice) as DeleteEventEvent
|
||||
assertEquals(listOf("de".repeat(32)), del.deletedEventIds())
|
||||
|
||||
val invite = parse(CreateInviteEvent.KIND, arrayOf(arrayOf("h", gid), arrayOf("code", "abc123")), pubKey = alice) as CreateInviteEvent
|
||||
assertEquals("abc123", invite.code())
|
||||
|
||||
val edit = parse(EditMetadataEvent.KIND, arrayOf(arrayOf("h", gid), arrayOf("name", "N"), arrayOf("public"), arrayOf("open")), pubKey = alice) as EditMetadataEvent
|
||||
assertEquals("N", edit.name())
|
||||
assertEquals(gid, edit.groupId())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesJoinAndLeaveRequests() {
|
||||
val join = parse(JoinRequestEvent.KIND, arrayOf(arrayOf("h", gid), arrayOf("code", "inv1")), content = "let me in", pubKey = alice) as JoinRequestEvent
|
||||
assertEquals(gid, join.groupId())
|
||||
assertEquals("inv1", join.inviteCode())
|
||||
assertEquals("let me in", join.content)
|
||||
|
||||
// Armada also sends a join with no code.
|
||||
val joinNoCode = parse(JoinRequestEvent.KIND, arrayOf(arrayOf("h", gid)), pubKey = alice) as JoinRequestEvent
|
||||
assertNull(joinNoCode.inviteCode())
|
||||
|
||||
val leave = parse(LeaveRequestEvent.KIND, arrayOf(arrayOf("h", gid)), pubKey = alice) as LeaveRequestEvent
|
||||
assertEquals(gid, leave.groupId())
|
||||
}
|
||||
|
||||
// ── Group-scoped content (kind 9/11/7) via the `h` tag ───────────────────
|
||||
|
||||
@Test
|
||||
fun parsesGroupChatMessageWithHTag() {
|
||||
// A NIP-29 chat message is a kind-9 ChatEvent carrying an `h` tag.
|
||||
val e = parse(ChatEvent.KIND, arrayOf(arrayOf("h", gid), arrayOf("t", "gm")), content = "gm", pubKey = alice)
|
||||
assertTrue(e is ChatEvent)
|
||||
assertEquals(gid, e.groupId())
|
||||
assertTrue(e.isGroupScoped())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesGroupThreadAndReactionWithHTag() {
|
||||
val thread = parse(ThreadEvent.KIND, arrayOf(arrayOf("h", gid), arrayOf("title", "T")), content = "body", pubKey = alice)
|
||||
assertTrue(thread is ThreadEvent)
|
||||
assertEquals(gid, thread.groupId())
|
||||
|
||||
val react = parse(ReactionEvent.KIND, arrayOf(arrayOf("h", gid), arrayOf("e", "ee".repeat(32))), content = "🔥", pubKey = alice)
|
||||
assertEquals(gid, react.groupId())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ungroupedEventHasNoGroupId() {
|
||||
val e = parse(ChatEvent.KIND, arrayOf(arrayOf("t", "hi")), content = "hi", pubKey = alice)
|
||||
assertNull(e.groupId())
|
||||
assertFalse(e.isGroupScoped())
|
||||
}
|
||||
|
||||
// ── kind 10009 user group list (NIP-51 simple groups) ────────────────────
|
||||
|
||||
@Test
|
||||
fun parsesUserGroupListPublicGroups() {
|
||||
// Armada: ["group", id, relay]
|
||||
val relay = "wss://relay.example.com"
|
||||
val e =
|
||||
parse(
|
||||
SimpleGroupListEvent.KIND,
|
||||
arrayOf(arrayOf("group", gid, relay), arrayOf("r", relay)),
|
||||
pubKey = alice,
|
||||
) as SimpleGroupListEvent
|
||||
val groups = e.publicGroups()
|
||||
assertEquals(1, groups.size)
|
||||
assertEquals(gid, groups[0].groupId)
|
||||
assertEquals(relay, groups[0].relayUrl)
|
||||
}
|
||||
|
||||
// ── Build side: tags match what Armada / relay29 expect ──────────────────
|
||||
|
||||
@Test
|
||||
fun buildsGroupMetadataWithFlagsAndSupportedKinds() {
|
||||
val tags =
|
||||
GroupMetadataEvent
|
||||
.build(
|
||||
groupId = gid,
|
||||
name = "General",
|
||||
about = "hi",
|
||||
status = setOf(GroupMetadataEvent.GroupStatus.PRIVATE, GroupMetadataEvent.GroupStatus.CLOSED),
|
||||
supportedKinds = listOf(9, 11),
|
||||
).tags
|
||||
assertTrue(tags.any { it[0] == "d" && it[1] == gid })
|
||||
assertTrue(tags.any { it[0] == "name" && it[1] == "General" })
|
||||
assertTrue(tags.any { it.size == 1 && it[0] == "private" })
|
||||
assertTrue(tags.any { it.size == 1 && it[0] == "closed" })
|
||||
assertTrue(tags.any { it[0] == "supported_kinds" && it.drop(1) == listOf("9", "11") })
|
||||
|
||||
// And it round-trips back through the parser.
|
||||
val parsed = parse(GroupMetadataEvent.KIND, tags) as GroupMetadataEvent
|
||||
assertTrue(parsed.isPrivate())
|
||||
assertTrue(parsed.isClosed())
|
||||
assertFalse(parsed.isRestricted())
|
||||
assertEquals(listOf(9, 11), parsed.supportedKinds())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildsCreateAndModerationEvents() {
|
||||
assertTrue(CreateGroupEvent.build(gid).tags.any { it[0] == "h" && it[1] == gid })
|
||||
assertTrue(CreateInviteEvent.build(gid, "code42").tags.any { it[0] == "code" && it[1] == "code42" })
|
||||
|
||||
val put = PutUserEvent.build(gid, listOf(bob to listOf("moderator"))).tags
|
||||
assertTrue(put.any { it[0] == "p" && it[1] == bob && it.contains("moderator") })
|
||||
|
||||
val remove = RemoveUserEvent.build(gid, listOf(bob)).tags
|
||||
assertTrue(remove.any { it[0] == "p" && it[1] == bob })
|
||||
|
||||
// relay29 forks are avoided by NOT emitting `previous` when none provided.
|
||||
assertFalse(put.any { it[0] == "previous" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildsJoinLeaveAndGroupScopedChat() {
|
||||
val join = JoinRequestEvent.build(gid, reason = "hi", inviteCode = "inv9")
|
||||
assertEquals("hi", join.content)
|
||||
assertTrue(join.tags.any { it[0] == "h" && it[1] == gid })
|
||||
assertTrue(join.tags.any { it[0] == "code" && it[1] == "inv9" })
|
||||
|
||||
assertTrue(LeaveRequestEvent.build(gid).tags.any { it[0] == "h" && it[1] == gid })
|
||||
|
||||
// Group-scope a plain kind-9 chat message via the shared `hTag` helper.
|
||||
val chat = ChatEvent.build("gm") { hTag(gid) }
|
||||
assertEquals(gid, chat.tags.hTag())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user