mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 08:47:33 +00:00
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:
+10
-2
@@ -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,
|
||||
),
|
||||
|
||||
+10
-10
@@ -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))
|
||||
|
||||
+22
-5
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+5
-13
@@ -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()
|
||||
|
||||
+46
@@ -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) }
|
||||
+67
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user