refactor(buzz): typed thread/p tags in composer + dialect bootstrap + live join/leave proof

Review feedback: the 40002 composer branch hand-rolled raw arrayOf tags. Now:

- New shared `buzz/threading` package: `buzzThread(root, parent)` builder verb
  emitting Buzz's exact thread_tags wire form (["e",root,"","root"] +
  ["e",parent,"","reply"], collapsing when parent==root; the empty relay slot
  is deliberate — MarkedETag.assemble's arrayOfNotNull would slide the marker
  into the relay slot) and `buzzThreadRoot()/buzzThreadReply()` positional
  readers. The forum verbs now delegate to it (streams and forum comments share
  thread_tags in buzz-sdk), and the composer uses buzzThread + the typed
  pTag(PTag(...)) verb instead of raw arrays.

- Dialect bootstrap fix: the single-group open-channel REQ now always includes
  the Buzz timeline kinds. Without this, an undiscovered Buzz relay was a
  chicken-and-egg: fleet subs only widen after BuzzRelayDialect marks the
  relay, but the mark comes from consuming a Buzz kind no filter asked for.
  Opening a channel is explicit one-group intent, so the wider ask is cheap and
  matches nothing on vanilla relays; fleet-wide subs stay dialect-gated
  (both behaviors pinned in BuzzTimelineKindsTest). The live relay's NIP-11
  ("Buzz Relay", supported_extensions=[nip-er,nip-pl]) is a future
  connect-time marker.

- Live proof of the full workspace lifecycle against the running Buzz relay
  (BuzzRelayLiveInteropTest, 3/3 green): discover the channel via its
  relay-signed 39000 (queryable by #d), join with NIP-29 kind-9021 from a
  second member, post a 40002 after joining, and leave with kind-9022 — the
  exact Quartz events Amethyst's group UI sends.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
This commit is contained in:
Claude
2026-07-21 22:16:39 +00:00
parent 54ecae50af
commit c85f51c40a
8 changed files with 207 additions and 42 deletions
@@ -180,7 +180,15 @@ fun buildRelayGroupJoinedChatTailFilters(
)
}
/** The recent-chat live tail for a single open group, `#h`-scoped on its host relay. */
/**
* The recent-chat live tail for a single open group, `#h`-scoped on its host relay.
*
* Always requests the FULL kind set (NIP-29 + Buzz): this single-group REQ is the
* dialect-detection bootstrap. Fleet-wide subs only widen once [BuzzRelayDialect] marks
* the relay, but the mark comes from consuming a Buzz kind — which never arrives if no
* filter asks for one. Opening a channel is explicit user intent on one group, so the
* wider ask is cheap, and on a vanilla relay the extra kinds simply match nothing.
*/
fun buildRelayGroupOpenChatTailFilter(
groupId: GroupId,
sinceEpoch: Long,
@@ -189,7 +197,7 @@ fun buildRelayGroupOpenChatTailFilter(
relay = groupId.relayUrl,
filter =
Filter(
kinds = relayGroupTimelineKinds(groupId.relayUrl),
kinds = RELAY_GROUP_TIMELINE_KINDS + BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS,
tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)),
since = sinceEpoch,
),
@@ -70,6 +70,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadS
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.buzz.threading.buzzThread
import com.vitorpamplona.quartz.buzz.threading.buzzThreadRoot
import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent
@@ -87,6 +89,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash
import com.vitorpamplona.quartz.nip01Core.tags.geohash.getGeoHash
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip01Core.tags.people.pTag
import com.vitorpamplona.quartz.nip01Core.tags.people.toPTag
import com.vitorpamplona.quartz.nip01Core.tags.references.references
@@ -688,16 +691,13 @@ open class ChannelNewMessageViewModel :
// clients send. Checked BEFORE RelayGroupChannel: Buzz is a subclass.
StreamMessageV2Event.build(channel.groupId.id, tagger.message) {
replyTo.value?.let { parent ->
val parentRoot =
parent.event
?.tags
?.firstOrNull { it.size >= 4 && it[0] == "e" && it[3] == "root" }
?.get(1)
if (parentRoot != null && parentRoot != parent.idHex) {
add(arrayOf("e", parentRoot, "", "root"))
}
add(arrayOf("e", parent.idHex, "", "reply"))
parent.author?.pubkeyHex?.let { add(arrayOf("p", it)) }
// Replying inside the parent's thread: the parent's own root
// marker (when it is itself a reply) becomes our root, else the
// parent starts the thread. buzzThread collapses to a single
// "reply" marker when root == parent, per thread_tags.
val root = parent.event?.tags?.buzzThreadRoot() ?: parent.idHex
buzzThread(root, parent.idHex)
parent.author?.pubkeyHex?.let { pTag(PTag(it)) }
}
hashtags(findHashtags(tagger.message))
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.GroupTag
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -67,13 +68,29 @@ class BuzzTimelineKindsTest {
}
@Test
fun openChatTailFilterCarriesBuzzKindsOnlyOnBuzzRelays() {
fun openChatTailAlwaysCarriesBuzzKindsAsDialectBootstrap() {
// The single-group open-channel REQ is what DISCOVERS the dialect: with no
// filter asking for a Buzz kind, none ever arrives and the relay is never
// marked. It therefore always includes the Buzz kinds, even unmarked.
val vanillaFilter = buildRelayGroupOpenChatTailFilter(GroupId("g1", vanillaRelay), sinceEpoch = 0L)
assertTrue(vanillaFilter.filter.kinds!!.contains(StreamMessageV2Event.KIND))
}
@Test
fun fleetWideJoinedTailWidensOnlyOnMarkedRelays() {
BuzzRelayDialect.mark(buzzRelay)
val buzzFilter = buildRelayGroupOpenChatTailFilter(GroupId("g1", buzzRelay), sinceEpoch = 0L)
val vanillaFilter = buildRelayGroupOpenChatTailFilter(GroupId("g1", vanillaRelay), sinceEpoch = 0L)
val filters =
buildRelayGroupJoinedChatTailFilters(
listOf(
GroupTag("g1", buzzRelay.url),
GroupTag("g2", vanillaRelay.url),
),
sinceEpoch = 0L,
)
assertTrue(buzzFilter.filter.kinds!!.contains(StreamMessageV2Event.KIND))
assertFalse(vanillaFilter.filter.kinds!!.contains(StreamMessageV2Event.KIND))
val byRelay = filters.associateBy { it.relay }
assertTrue(byRelay[buzzRelay]!!.filter.kinds!!.contains(StreamMessageV2Event.KIND))
assertFalse(byRelay[vanillaRelay]!!.filter.kinds!!.contains(StreamMessageV2Event.KIND))
}
}
@@ -20,12 +20,12 @@
*/
package com.vitorpamplona.quartz.buzz.forum
import com.vitorpamplona.quartz.buzz.threading.buzzThread
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
/** The `h` channel tag (NIP-29) scoping a Buzz forum event to its channel UUID. */
@@ -38,19 +38,11 @@ fun <T : Event> TagArrayBuilder<T>.forumMentions(mentions: List<HexKey>) = menti
fun <T : Event> TagArrayBuilder<T>.forumVoteTarget(targetEventId: HexKey) = addUnique(ETag.assemble(targetEventId, null, null))
/**
* NIP-10 thread e-tags for a forum comment, matching `thread_tags` in `builders.rs`:
* - direct reply (`root == parent`): one `["e", root, "", "reply"]`
* - nested reply (`root != parent`): `["e", root, "", "root"]` + `["e", parent, "", "reply"]`
*
* The empty relay slot is emitted verbatim so the marker stays at index 3, matching the
* reference wire form.
* NIP-10 thread e-tags for a forum comment. Buzz threads streams and forum comments
* identically (`thread_tags` in `builders.rs`), so this delegates to the shared
* [buzzThread] verb.
*/
fun <T : Event> TagArrayBuilder<T>.forumThread(
rootEventId: HexKey,
parentEventId: HexKey,
) = if (rootEventId == parentEventId) {
add(arrayOf(MarkedETag.TAG_NAME, rootEventId, "", MarkedETag.MARKER.REPLY.code))
} else {
add(arrayOf(MarkedETag.TAG_NAME, rootEventId, "", MarkedETag.MARKER.ROOT.code))
add(arrayOf(MarkedETag.TAG_NAME, parentEventId, "", MarkedETag.MARKER.REPLY.code))
}
) = buzzThread(rootEventId, parentEventId)
@@ -20,12 +20,13 @@
*/
package com.vitorpamplona.quartz.buzz.forum
import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply
import com.vitorpamplona.quartz.buzz.threading.buzzThreadRoot
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
/** The `h` channel UUID this forum event belongs to. */
@@ -37,17 +38,8 @@ fun TagArray.forumMentions(): List<HexKey> = mapNotNull(PTag::parseKey)
/** The `e` target event of a forum vote (`kind:45002`). */
fun TagArray.forumVoteTarget(): HexKey? = firstNotNullOfOrNull(ETag::parseId)
// Reads the NIP-10 marker positionally: the Buzz wire form keeps an explicit empty
// relay slot (`["e", id, "", "reply"]`), so the marker sits at index 3. A relay-less
// 3-element form (`["e", id, "reply"]`) is tolerated with the marker at index 2.
private fun Array<String>.markedEventId(marker: MarkedETag.MARKER): HexKey? {
if (size < 3 || this[0] != MarkedETag.TAG_NAME || this[1].length != 64) return null
val code = marker.code
return if ((size >= 4 && this[3] == code) || this[2] == code) this[1] else null
}
/** The NIP-10 `root`-marked thread event id of a forum comment (null for a direct reply). */
fun TagArray.forumThreadRoot(): HexKey? = firstNotNullOfOrNull { it.markedEventId(MarkedETag.MARKER.ROOT) }
fun TagArray.forumThreadRoot(): HexKey? = buzzThreadRoot()
/** The NIP-10 `reply`-marked immediate-parent event id of a forum comment. */
fun TagArray.forumThreadReply(): HexKey? = firstNotNullOfOrNull { it.markedEventId(MarkedETag.MARKER.REPLY) }
fun TagArray.forumThreadReply(): HexKey? = buzzThreadReply()
@@ -0,0 +1,46 @@
/*
* 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.buzz.threading
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag
/**
* Buzz's NIP-10 thread e-tags, shared by stream messages (40002) and forum comments
* (45003) — both mirror `thread_tags` in `buzz-sdk/src/builders.rs`:
* - direct reply (`root == parent`): one `["e", root, "", "reply"]`
* - nested reply (`root != parent`): `["e", root, "", "root"]` + `["e", parent, "", "reply"]`
*
* The empty relay slot is emitted verbatim so the marker stays at index 3, matching the
* reference wire form. ([MarkedETag.assemble] cannot be used here: its `arrayOfNotNull`
* drops the null relay slot, sliding the marker into index 2.)
*/
fun <T : Event> TagArrayBuilder<T>.buzzThread(
rootEventId: HexKey,
parentEventId: HexKey,
) = if (rootEventId == parentEventId) {
add(arrayOf(MarkedETag.TAG_NAME, rootEventId, "", MarkedETag.MARKER.REPLY.code))
} else {
add(arrayOf(MarkedETag.TAG_NAME, rootEventId, "", MarkedETag.MARKER.ROOT.code))
add(arrayOf(MarkedETag.TAG_NAME, parentEventId, "", MarkedETag.MARKER.REPLY.code))
}
@@ -0,0 +1,43 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.buzz.threading
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag
/**
* Positional reader for Buzz's thread e-tags (`["e", id, "", "root"|"reply"]`).
* [MarkedETag.parse] rejects the empty relay slot Buzz emits, so the marker is read
* positionally: index 3 in the canonical 4-element form, tolerating the relay-less
* 3-element form (`["e", id, "reply"]`) with the marker at index 2.
*/
private fun Array<String>.buzzMarkedEventId(marker: MarkedETag.MARKER): HexKey? {
if (size < 3 || this[0] != MarkedETag.TAG_NAME || this[1].length != 64) return null
val code = marker.code
return if ((size >= 4 && this[3] == code) || this[2] == code) this[1] else null
}
/** The thread root this event replies under, from its marked `root` e-tag. */
fun TagArray.buzzThreadRoot(): HexKey? = firstNotNullOfOrNull { it.buzzMarkedEventId(MarkedETag.MARKER.ROOT) }
/** The direct parent this event replies to, from its marked `reply` e-tag. */
fun TagArray.buzzThreadReply(): HexKey? = firstNotNullOfOrNull { it.buzzMarkedEventId(MarkedETag.MARKER.REPLY) }
@@ -29,6 +29,8 @@ import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateGroupEvent
import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent
import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
@@ -192,6 +194,71 @@ class BuzzRelayLiveInteropTest {
}
}
/**
* The workspace lifecycle Amethyst's group UI drives: discover the channel via its
* relay-signed 39000 metadata, join it with a NIP-29 kind-9021 request from a
* different member, post into it, then leave with kind-9022 — all against the real
* relay, using the exact Quartz events the UI sends.
*/
@Test
fun discoverJoinPostAndLeave() {
if (!envReady() || ownerSk == null) {
println("SKIP: BUZZ_RELAY_WS / BUZZ_MEMBER_SK / BUZZ_OWNER_SK not set")
return
}
runBlocking {
val creator = NostrSignerInternal(KeyPair(memberSk!!.hexToByteArray()))
val (ws, frames) = authedSocket(creator)
val channelId = UUID.randomUUID().toString()
val create =
creator.sign(
CreateGroupEvent.build(channelId) {
add(arrayOf("name", "amethyst-lifecycle"))
},
)
ws.send("""["EVENT",${create.toJson()}]""")
assertTrue("true" in frames.nextOf("OK"))
// SEE: the relay-signed kind-39000 discovery metadata must be queryable —
// this is what materializes the channel in Amethyst's group directory.
ws.send("""["REQ","meta",{"kinds":[39000],"#d":["$channelId"]}]""")
val metaFrame = frames.nextOf("EVENT")
val meta = Event.fromJson(metaFrame.substringAfter(",\"meta\",").dropLast(1))
assertEquals(39000, meta.kind)
assertTrue(
meta.tags.any { it.size > 1 && it[0] == "d" && it[1] == channelId },
"39000 must be addressed by the channel id",
)
frames.nextOf("EOSE")
// JOIN: a different workspace member requests membership with kind 9021 —
// the same event Amethyst's NIP-29 Join button sends.
val joiner = NostrSignerInternal(KeyPair(ownerSk.hexToByteArray()))
val (ws2, frames2) = authedSocket(joiner)
val join =
joiner.sign(
JoinRequestEvent.build(channelId),
)
ws2.send("""["EVENT",${join.toJson()}]""")
val joinOk = frames2.nextOf("OK")
assertTrue("true" in joinOk, "join (9021) should be accepted: $joinOk")
// POST: the joiner can now publish a 40002 into the channel.
val msg = joiner.sign(StreamMessageV2Event.build(channelId, "joined and posting"))
ws2.send("""["EVENT",${msg.toJson()}]""")
assertTrue("true" in frames2.nextOf("OK"), "post after join should be accepted")
// LEAVE: kind 9022, the NIP-29 leave request.
val leave = joiner.sign(LeaveRequestEvent.build(channelId))
ws2.send("""["EVENT",${leave.toJson()}]""")
assertTrue("true" in frames2.nextOf("OK"), "leave (9022) should be accepted")
ws.close(1000, "done")
ws2.close(1000, "done")
}
}
@Test
fun agentAuthenticatesViaOwnerAttestation() {
if (!envReady() || ownerSk == null) {