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:
Claude
2026-07-07 19:45:26 +00:00
parent da2f2ad5ab
commit 2620ce9a50
7 changed files with 499 additions and 6 deletions
@@ -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
@@ -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()
}
}
@@ -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)
@@ -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())
}
}