From 7f5133a08f0fe4172d5cf9bc708a7725603eb42d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 22:45:01 +0000 Subject: [PATCH] feat(quartz): AdminCommandEvent for audio-room kick (kind 4312) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the ephemeral host-issued admin command event nostrnests uses for kick (and future moderation actions like mute/ban): AdminCommandEvent — kind 4312. Carries one Action (currently just KICK) in `content`, an `a`-tag pointing at the room (kind-30312 address), and a `p`-tag for the target. The verb-in-content shape lets us extend with new actions without a wire schema bump. AdminCommandEvent.kick(roomATag, target) — builder. AdminCommandEvent.action() / .targetPubkey() / .room() — accessors for the recipient side. Unknown actions return null (forward-compat with verbs not yet in the enum). EventFactory — registered so LocalCache + Filter.match can decode incoming events properly. Authority enforcement is the CLIENT'S job — the relay just stores and forwards. Recipients filter on `kinds=[4312], #a=[room], #p=[me]` and only honour commands whose signer is a participant marked HOST or MODERATOR on the active kind-30312. The next commit wires that gating + the disconnect side effect into AudioRoomViewModel. Tests: * Build a kick template and verify the action/address/target tags * Round-trip parse exposes room, target pubkey and action * Unknown verb in `content` returns null from action() * Missing tags return null from accessors (no throw) --- .../audiorooms/admin/AdminCommandEvent.kt | 99 +++++++++++++++++++ .../quartz/utils/EventFactory.kt | 2 + .../audiorooms/admin/AdminCommandEventTest.kt | 92 +++++++++++++++++ 3 files changed, 193 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/audiorooms/admin/AdminCommandEvent.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/audiorooms/admin/AdminCommandEventTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/audiorooms/admin/AdminCommandEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/audiorooms/admin/AdminCommandEvent.kt new file mode 100644 index 000000000..e5e7b9a7c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/audiorooms/admin/AdminCommandEvent.kt @@ -0,0 +1,99 @@ +/* + * 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.experimental.audiorooms.admin + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip01Core.tags.aTag.aTag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Ephemeral host-issued admin command for nostrnests audio rooms + * (kind 4312). Carries one of [Action] targeting a single + * [PTag] within the room identified by [ATag]. + * + * Recipients filter on `kinds=[4312], #a=[room], #p=[me]` and only + * honour commands whose signer is a participant marked HOST or + * MODERATOR on the active kind-30312 — relays don't enforce this, + * the client must. + * + * The tag layout matches the action / role-mutation events + * nostrnests emits today; the action keyword goes in `content` + * rather than as a separate tag so we can extend it with new + * verbs without re-versioning the spec. + */ +@Immutable +class AdminCommandEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + /** The room this command applies to, if a single `a`-tag is present. */ + fun room(): String? = tags.firstOrNull { it.firstOrNull() == "a" }?.getOrNull(1) + + /** The pubkey the host is acting on. */ + fun targetPubkey(): HexKey? = tags.firstOrNull { it.firstOrNull() == "p" }?.getOrNull(1) + + /** + * The verb (e.g. "kick"). Returned uppercase so callers can + * `when (event.action()) { Action.KICK -> ... }` cleanly; the + * raw content is preserved on the event so a future verb can + * be inspected as a string. + */ + fun action(): Action? = Action.fromCode(content) + + enum class Action( + val code: String, + ) { + KICK("kick"), + ; + + companion object { + fun fromCode(code: String): Action? = entries.firstOrNull { it.code.equals(code, ignoreCase = true) } + } + } + + companion object { + const val KIND = 4312 + const val ALT = "Audio room admin command" + + /** + * Build a kick command tagged for [room] and [target]. The + * content carries the verb so the same kind can grow new + * actions (mute, ban, …) without a wire schema bump. + */ + fun kick( + room: ATag, + target: HexKey, + createdAt: Long = TimeUtils.now(), + ) = eventTemplate(KIND, Action.KICK.code, createdAt) { + aTag(room) + add(PTag(target).toTagArray()) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 4493df89d..86136e116 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.experimental.attestations.recommendation.Attesto import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.audiorooms.admin.AdminCommandEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent @@ -456,6 +457,7 @@ class EventFactory { MeetingRoomEvent.KIND -> MeetingRoomEvent(id, pubKey, createdAt, tags, content, sig) MeetingRoomPresenceEvent.KIND -> MeetingRoomPresenceEvent(id, pubKey, createdAt, tags, content, sig) MeetingSpaceEvent.KIND -> MeetingSpaceEvent(id, pubKey, createdAt, tags, content, sig) + AdminCommandEvent.KIND -> AdminCommandEvent(id, pubKey, createdAt, tags, content, sig) MintRecommendationEvent.KIND -> MintRecommendationEvent(id, pubKey, createdAt, tags, content, sig) MediaFollowListEvent.KIND -> MediaFollowListEvent(id, pubKey, createdAt, tags, content, sig) MediaStarterPackEvent.KIND -> MediaStarterPackEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/audiorooms/admin/AdminCommandEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/audiorooms/admin/AdminCommandEventTest.kt new file mode 100644 index 000000000..5a7f83687 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/audiorooms/admin/AdminCommandEventTest.kt @@ -0,0 +1,92 @@ +/* + * 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.experimental.audiorooms.admin + +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class AdminCommandEventTest { + private val host = "a".repeat(64) + private val target = "b".repeat(64) + private val roomATag = ATag(kind = 30312, pubKeyHex = host, dTag = "rt-1", relay = null) + + @Test + fun kickTemplateCarriesActionContentAddressAndTarget() { + val template = AdminCommandEvent.kick(roomATag, target, createdAt = 100L) + assertEquals(AdminCommandEvent.KIND, template.kind) + assertEquals(AdminCommandEvent.Action.KICK.code, template.content) + // Single `a` tag pointing at the room. + val aTag = template.tags.firstOrNull { it.firstOrNull() == "a" } + assertEquals(roomATag.toTag(), aTag?.getOrNull(1)) + // Single `p` tag pointing at the kicked user. + val pTag = template.tags.firstOrNull { it.firstOrNull() == "p" } + assertEquals(target, pTag?.getOrNull(1)) + } + + @Test + fun parseRoundTripExposesRoomTargetAction() { + val template = AdminCommandEvent.kick(roomATag, target, createdAt = 100L) + val event = + AdminCommandEvent( + id = "0".repeat(64), + pubKey = host, + createdAt = template.createdAt, + tags = template.tags, + content = template.content, + sig = "0".repeat(128), + ) + assertEquals(AdminCommandEvent.Action.KICK, event.action()) + assertEquals(target, event.targetPubkey()) + assertTrue(event.room()!!.startsWith("30312:")) + } + + @Test + fun unknownActionReturnsNull() { + val event = + AdminCommandEvent( + id = "0".repeat(64), + pubKey = host, + createdAt = 100L, + tags = arrayOf(arrayOf("a", "30312:host:rt"), arrayOf("p", target)), + content = "haunt", + sig = "0".repeat(128), + ) + assertNull(event.action()) + } + + @Test + fun missingTagsReturnNullAccessors() { + val event = + AdminCommandEvent( + id = "0".repeat(64), + pubKey = host, + createdAt = 100L, + tags = arrayOf(), + content = "kick", + sig = "0".repeat(128), + ) + assertNull(event.targetPubkey()) + assertNull(event.room()) + } +}