mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
feat(cli): amy relaygroup — NIP-29 relay groups in the CLI
Adds a first-class `amy relaygroup` verb group so the CLI can drive the same NIP-29 relay groups as the app. Thin assembly over quartz builders + Context.publish/drain — no protocol logic in cli/. Verbs: - list / browse RELAY / info RELAY GID — reads (joined kind:10009 list with private-item decryption; a relay's 39000-39003 directory; one group's metadata + roster). - create / join / leave / message — lifecycle. join/leave also maintain the caller's kind:10009 list (add/remove private item) so `list` reflects them, mirroring the app's follow/unfollow. - edit / invite / put-user / remove-user — moderation (9002/9009/9000/9001). All writes pin to the group's single host relay. Output follows amy's text/--json contract with snake_case keys. Verified end-to-end against an embedded relay (amy serve) with a new self-contained harness, cli/tests/relaygroup/relaygroup-headless.sh: create/message/join/list/browse all pass (5/5). browse/info return empty against geode since it doesn't sign 39000-39003 — a relay capability, not a client issue. README command table + ROADMAP updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
This commit is contained in:
@@ -412,6 +412,27 @@ HTTP endpoint. Reuses quartz's `Nip86Client` and the shared `Nip86Retriever`
|
||||
| `amy marmot message react GID EVENT_ID EMOJI` | Publish a kind:7 reaction. |
|
||||
| `amy marmot message delete GID EVENT_ID …` | Publish a kind:5 deletion. |
|
||||
|
||||
### Relay groups (NIP-29)
|
||||
|
||||
Relay-based groups (à la Armada / relay29). A group lives on one host relay,
|
||||
addressed by `(relay, group id)`; every read and write is pinned there. Distinct
|
||||
from Marmot/MLS above — these are the NIP-29 groups Amethyst's "Relay Groups"
|
||||
screen speaks.
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `amy relaygroup list` | Your joined groups, from your kind:10009 list (public + private). |
|
||||
| `amy relaygroup browse RELAY` | Every group a relay hosts (its 39000-39003 directory). |
|
||||
| `amy relaygroup info RELAY GID` | A group's metadata + admin/member roster. |
|
||||
| `amy relaygroup create RELAY --name X [--about A] [--private] [--closed]` | Create a group (publishes 9007 + 9002); prints the new `group_id`. |
|
||||
| `amy relaygroup join RELAY GID [--code CODE]` | Request to join (9021) and add it to your kind:10009 list. |
|
||||
| `amy relaygroup leave RELAY GID` | Leave (9022) and drop it from your kind:10009 list. |
|
||||
| `amy relaygroup message RELAY GID TEXT` | Post a kind:9 chat message into the group. |
|
||||
| `amy relaygroup edit RELAY GID [--name X] [--about A] [--private] [--closed]` | Edit metadata (9002, admin only). |
|
||||
| `amy relaygroup invite RELAY GID --code CODE` | Mint an invite code (9009, moderator). |
|
||||
| `amy relaygroup put-user RELAY GID PUBKEY [--role admin\|moderator]` | Add or promote a user (9000, moderator). |
|
||||
| `amy relaygroup remove-user RELAY GID PUBKEY` | Kick a user (9001, moderator). |
|
||||
|
||||
### CLINK Offers
|
||||
|
||||
| Command | What it does |
|
||||
|
||||
+4
-4
@@ -120,10 +120,10 @@ nak has 34 functional commands (introspected from `nak --help`). Coverage:
|
||||
- **Partial / adapted (3):** `key` (no `expand`/`combine`(MuSig2)/`default`),
|
||||
`git` (NIP-34 events only — no packfile transport), `outbox` (NIP-65 model vs
|
||||
nak's local hints DB).
|
||||
- **Missing (7):** `dekey` (NIP-4E), `mcp`, `curl` (NIP-98), `fs` (FUSE),
|
||||
`spell` (MuSig2/FROST), `validate` (event-schema validation), and
|
||||
`group`/`nip29` (NIP-29 — amy ships MLS/Marmot instead, an intentional
|
||||
divergence rather than a gap).
|
||||
- **Missing (6):** `dekey` (NIP-4E), `mcp`, `curl` (NIP-98), `fs` (FUSE),
|
||||
`spell` (MuSig2/FROST), and `validate` (event-schema validation).
|
||||
`relaygroup` (NIP-29) now ships alongside MLS/Marmot — the two group models
|
||||
are offered side by side rather than one substituting for the other.
|
||||
|
||||
**Design differences (not gaps):** amy is a *stateful client* (accounts,
|
||||
`~/.amy/`, shared event store) with a stable JSON contract; nak is a *stateless*
|
||||
|
||||
@@ -58,6 +58,7 @@ import com.vitorpamplona.amethyst.cli.commands.PodcastCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.ProfileCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.PublishCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.RelayCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.RelayGroupCommands
|
||||
import com.vitorpamplona.amethyst.cli.commands.SearchCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.ServeCommand
|
||||
import com.vitorpamplona.amethyst.cli.commands.StoreCommands
|
||||
@@ -206,6 +207,7 @@ private suspend fun dispatch(argv: Array<String>): Int {
|
||||
"whoami" -> InitCommands.whoami(dataDir)
|
||||
"relay" -> RelayCommands.dispatch(dataDir, tail)
|
||||
"marmot" -> marmotDispatch(dataDir, tail)
|
||||
"relaygroup" -> RelayGroupCommands.dispatch(dataDir, tail)
|
||||
"dm" -> DmCommands.dispatch(dataDir, tail)
|
||||
"profile" -> ProfileCommands.dispatch(dataDir, tail)
|
||||
"notes" -> NotesCommands.dispatch(dataDir, tail)
|
||||
@@ -567,6 +569,22 @@ private fun printUsage() {
|
||||
| dm await --peer NPUB --match TEXT wait for a matching DM
|
||||
| [--timeout SECS] (default 30s, exit 124 on timeout)
|
||||
|
|
||||
|Relay groups (NIP-29):
|
||||
| relaygroup list joined groups (from kind:10009)
|
||||
| relaygroup browse RELAY every group a relay hosts
|
||||
| relaygroup info RELAY GID a group's metadata + roster
|
||||
| relaygroup create RELAY --name NAME create a group (publishes 9007+9002)
|
||||
| [--about A] [--private] [--closed]
|
||||
| relaygroup join RELAY GID [--code CODE] request to join (kind 9021)
|
||||
| relaygroup leave RELAY GID leave (kind 9022)
|
||||
| relaygroup message RELAY GID TEXT post a kind-9 chat to the group
|
||||
| relaygroup edit RELAY GID [--name N] edit metadata (kind 9002, admin)
|
||||
| [--about A] [--private] [--closed]
|
||||
| relaygroup invite RELAY GID --code CODE mint an invite code (kind 9009)
|
||||
| relaygroup put-user RELAY GID PUBKEY add/promote a user (kind 9000)
|
||||
| [--role admin|moderator]
|
||||
| relaygroup remove-user RELAY GID PUBKEY kick a user (kind 9001)
|
||||
|
|
||||
|Marmot (MLS group messaging):
|
||||
| marmot key-package publish publish a fresh KeyPackage
|
||||
| marmot key-package check NPUB fetch NPUB's KeyPackage from relays
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
/*
|
||||
* 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.amethyst.cli.commands
|
||||
|
||||
import com.vitorpamplona.amethyst.cli.Args
|
||||
import com.vitorpamplona.amethyst.cli.Context
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.hTag
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateGroupEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.GroupTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
|
||||
/**
|
||||
* `amy relaygroup …` — NIP-29 relay-based groups. Every group lives on exactly
|
||||
* one host relay and is addressed by (relay, group id); all reads and writes are
|
||||
* pinned to that relay. This object owns the lifecycle verbs (create/join/leave/
|
||||
* message); reads live in [RelayGroupReadCommands], moderation in
|
||||
* [RelayGroupModerationCommands].
|
||||
*/
|
||||
object RelayGroupCommands {
|
||||
suspend fun dispatch(
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int =
|
||||
route(
|
||||
"relaygroup",
|
||||
tail,
|
||||
"relaygroup <list|browse|info|create|join|leave|message|edit|invite|put-user|remove-user> …",
|
||||
mapOf(
|
||||
"list" to { rest -> RelayGroupReadCommands.list(dataDir, rest) },
|
||||
"browse" to { rest -> RelayGroupReadCommands.browse(dataDir, rest) },
|
||||
"info" to { rest -> RelayGroupReadCommands.info(dataDir, rest) },
|
||||
"create" to { rest -> create(dataDir, rest) },
|
||||
"join" to { rest -> join(dataDir, rest) },
|
||||
"leave" to { rest -> leave(dataDir, rest) },
|
||||
"message" to { rest -> message(dataDir, rest) },
|
||||
"edit" to { rest -> RelayGroupModerationCommands.edit(dataDir, rest) },
|
||||
"invite" to { rest -> RelayGroupModerationCommands.invite(dataDir, rest) },
|
||||
"put-user" to { rest -> RelayGroupModerationCommands.putUser(dataDir, rest) },
|
||||
"remove-user" to { rest -> RelayGroupModerationCommands.removeUser(dataDir, rest) },
|
||||
),
|
||||
)
|
||||
|
||||
/** `relaygroup create RELAY --name NAME [--about A] [--private] [--closed]` → publishes 9007 + 9002. */
|
||||
private suspend fun create(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", "relaygroup create RELAY --name NAME [--about A] [--private] [--closed]")
|
||||
val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl")
|
||||
val name = args.flag("name") ?: return Output.error("bad_args", "relaygroup create requires --name")
|
||||
val about = args.flag("about")
|
||||
val isPrivate = args.bool("private")
|
||||
val isClosed = args.bool("closed")
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val groupId = RandomInstance.bytes(8).toHexKey()
|
||||
val target = setOf(relay)
|
||||
|
||||
val createAck = ctx.publish(ctx.signer.sign(CreateGroupEvent.build(groupId)), target)
|
||||
val status = groupStatus(isPrivate, isClosed)
|
||||
val edit = EditMetadataEvent.build(groupId, name = name, about = about, status = status)
|
||||
val editAck = ctx.publish(ctx.signer.sign(edit), target)
|
||||
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"group_id" to groupId,
|
||||
"relay" to relay.url,
|
||||
"name" to name,
|
||||
"private" to isPrivate,
|
||||
"closed" to isClosed,
|
||||
"published" to (createAck.values.any { it } && editAck.values.any { it }),
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `relaygroup join RELAY GROUP_ID [--code CODE] [--reason R]` — publishes the
|
||||
* 9021 join request to the host relay AND adds the group to the caller's
|
||||
* kind-10009 list (private item), so `relaygroup list` reflects it.
|
||||
*/
|
||||
private suspend fun join(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val usage = "relaygroup join RELAY GROUP_ID [--code CODE]"
|
||||
val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage)
|
||||
val groupId = args.positionalOrNull(1) ?: return Output.error("bad_args", usage)
|
||||
val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl")
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val join = JoinRequestEvent.build(groupId, reason = args.flag("reason") ?: "", inviteCode = args.flag("code"))
|
||||
val ack = ctx.publish(ctx.signer.sign(join), setOf(relay))
|
||||
val listed = updateGroupList(ctx, relay, groupId, add = true)
|
||||
Output.emit(
|
||||
mapOf("group_id" to groupId, "relay" to relay.url, "published" to ack.values.any { it }, "listed" to listed),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `relaygroup leave RELAY GROUP_ID` — publishes the 9022 leave request to the
|
||||
* host relay AND removes the group from the caller's kind-10009 list.
|
||||
*/
|
||||
private suspend fun leave(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val usage = "relaygroup leave RELAY GROUP_ID"
|
||||
val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage)
|
||||
val groupId = args.positionalOrNull(1) ?: return Output.error("bad_args", usage)
|
||||
val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl")
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val ack = ctx.publish(ctx.signer.sign(LeaveRequestEvent.build(groupId)), setOf(relay))
|
||||
val listed = updateGroupList(ctx, relay, groupId, add = false)
|
||||
Output.emit(
|
||||
mapOf("group_id" to groupId, "relay" to relay.url, "published" to ack.values.any { it }, "listed" to listed),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/** `relaygroup message RELAY GROUP_ID <text>` → publishes a kind-9 chat with an `h` tag. */
|
||||
private suspend fun message(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val text = args.positionalOrNull(2) ?: return Output.error("bad_args", "relaygroup message RELAY GROUP_ID <text>")
|
||||
if (text.isBlank()) return Output.error("bad_args", "message text must not be blank")
|
||||
return publishScoped(dataDir, rest, "relaygroup message RELAY GROUP_ID <text>") { _, groupId, _ ->
|
||||
ChatEvent.build(text) { hTag(groupId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalize a user-supplied relay URL for a group, or null if unparseable. */
|
||||
internal fun normalizeGroupRelay(url: String): NormalizedRelayUrl? = RelayUrlNormalizer.normalizeOrNull(url.trim())
|
||||
|
||||
/**
|
||||
* Add or remove a group from the caller's kind-10009 "simple groups" list (private
|
||||
* item) and publish the new version to their outbox relays. Mirrors the Android
|
||||
* follow/unfollow. Returns true when the updated list was published to ≥1 relay.
|
||||
*/
|
||||
private suspend fun updateGroupList(
|
||||
ctx: Context,
|
||||
relay: NormalizedRelayUrl,
|
||||
groupId: String,
|
||||
add: Boolean,
|
||||
): Boolean {
|
||||
val outbox = ctx.outboxRelays()
|
||||
if (outbox.isEmpty()) return false
|
||||
|
||||
val filter = Filter(kinds = listOf(SimpleGroupListEvent.KIND), authors = listOf(ctx.identity.pubKeyHex), limit = 1)
|
||||
val current =
|
||||
ctx
|
||||
.drain(outbox.associateWith { listOf(filter) }, 5_000)
|
||||
.map { it.second }
|
||||
.filterIsInstance<SimpleGroupListEvent>()
|
||||
.maxByOrNull { it.createdAt }
|
||||
|
||||
val tag = GroupTag(groupId, relay.url, null)
|
||||
val updated =
|
||||
when {
|
||||
add && current == null -> SimpleGroupListEvent.create(privateGroups = listOf(tag), signer = ctx.signer)
|
||||
add -> SimpleGroupListEvent.add(current!!, tag, isPrivate = true, signer = ctx.signer)
|
||||
current == null -> return false // nothing to remove from
|
||||
else -> SimpleGroupListEvent.remove(current, tag, signer = ctx.signer)
|
||||
}
|
||||
|
||||
return ctx.publish(updated, outbox).values.any { it }
|
||||
}
|
||||
|
||||
/** The NIP-29 status flag set for the given visibility toggles. */
|
||||
internal fun groupStatus(
|
||||
isPrivate: Boolean,
|
||||
isClosed: Boolean,
|
||||
): Set<GroupMetadataEvent.GroupStatus> =
|
||||
buildSet {
|
||||
add(if (isPrivate) GroupMetadataEvent.GroupStatus.PRIVATE else GroupMetadataEvent.GroupStatus.PUBLIC)
|
||||
add(if (isClosed) GroupMetadataEvent.GroupStatus.CLOSED else GroupMetadataEvent.GroupStatus.OPEN)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared skeleton for the group-scoped write verbs: parse RELAY + GROUP_ID from
|
||||
* positionals 0/1, build a template pinned to that group, sign, publish to the
|
||||
* host relay, and emit the ack. The [build] lambda returns the event template.
|
||||
*/
|
||||
internal suspend fun publishScoped(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
usage: String,
|
||||
build: (relay: NormalizedRelayUrl, groupId: String, args: Args) -> EventTemplate<out Event>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage)
|
||||
val groupId = args.positionalOrNull(1) ?: return Output.error("bad_args", usage)
|
||||
val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl")
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val signed = ctx.signer.sign(build(relay, groupId, args))
|
||||
val ack = ctx.publish(signed, setOf(relay))
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"event_id" to signed.id,
|
||||
"kind" to signed.kind,
|
||||
"group_id" to groupId,
|
||||
"relay" to relay.url,
|
||||
"published" to ack.values.any { it },
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
* 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.amethyst.cli.commands
|
||||
|
||||
import com.vitorpamplona.amethyst.cli.Args
|
||||
import com.vitorpamplona.amethyst.cli.Context
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateInviteEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.PutUserEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent
|
||||
|
||||
/**
|
||||
* Moderator/admin write verbs for a relay group (the relay is the final authority
|
||||
* and rejects a request from someone lacking the role). All pinned to the group's
|
||||
* host relay via [publishScoped] or a direct publish.
|
||||
*/
|
||||
object RelayGroupModerationCommands {
|
||||
/** `relaygroup edit RELAY GROUP_ID [--name N] [--about A] [--private] [--closed]` → 9002. */
|
||||
suspend fun edit(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int =
|
||||
publishScoped(dataDir, rest, "relaygroup edit RELAY GROUP_ID [--name N] [--about A] [--private] [--closed]") { _, groupId, args ->
|
||||
// Only touch visibility when the user actually passed a flag — otherwise
|
||||
// leave name/about edits without asserting an (unknown) status.
|
||||
val status =
|
||||
if (args.bool("private") || args.bool("closed")) {
|
||||
groupStatus(args.bool("private"), args.bool("closed"))
|
||||
} else {
|
||||
emptySet()
|
||||
}
|
||||
EditMetadataEvent.build(groupId, name = args.flag("name"), about = args.flag("about"), status = status)
|
||||
}
|
||||
|
||||
/** `relaygroup invite RELAY GROUP_ID --code CODE` → 9009. */
|
||||
suspend fun invite(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val code = Args(rest).flag("code") ?: return Output.error("bad_args", "relaygroup invite RELAY GROUP_ID --code CODE")
|
||||
return publishScoped(dataDir, rest, "relaygroup invite RELAY GROUP_ID --code CODE") { _, groupId, _ ->
|
||||
CreateInviteEvent.build(groupId, code)
|
||||
}
|
||||
}
|
||||
|
||||
/** `relaygroup put-user RELAY GROUP_ID PUBKEY [--role admin|moderator]` → 9000. */
|
||||
suspend fun putUser(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val usage = "relaygroup put-user RELAY GROUP_ID PUBKEY [--role admin|moderator]"
|
||||
val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage)
|
||||
val groupId = args.positionalOrNull(1) ?: return Output.error("bad_args", usage)
|
||||
val user = args.positionalOrNull(2) ?: return Output.error("bad_args", usage)
|
||||
val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl")
|
||||
val roles =
|
||||
args
|
||||
.flag("role")
|
||||
?.split(',')
|
||||
?.map { it.trim() }
|
||||
?.filter { it.isNotEmpty() } ?: emptyList()
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val pubkey = ctx.requireUserHex(user)
|
||||
val signed = ctx.signer.sign(PutUserEvent.build(groupId, listOf(pubkey to roles)))
|
||||
val ack = ctx.publish(signed, setOf(relay))
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"event_id" to signed.id,
|
||||
"group_id" to groupId,
|
||||
"relay" to relay.url,
|
||||
"pubkey" to pubkey,
|
||||
"roles" to roles,
|
||||
"published" to ack.values.any { it },
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/** `relaygroup remove-user RELAY GROUP_ID PUBKEY` → 9001. */
|
||||
suspend fun removeUser(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val usage = "relaygroup remove-user RELAY GROUP_ID PUBKEY"
|
||||
val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", usage)
|
||||
val groupId = args.positionalOrNull(1) ?: return Output.error("bad_args", usage)
|
||||
val user = args.positionalOrNull(2) ?: return Output.error("bad_args", usage)
|
||||
val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl")
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val pubkey = ctx.requireUserHex(user)
|
||||
val signed = ctx.signer.sign(RemoveUserEvent.build(groupId, listOf(pubkey)))
|
||||
val ack = ctx.publish(signed, setOf(relay))
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"event_id" to signed.id,
|
||||
"group_id" to groupId,
|
||||
"relay" to relay.url,
|
||||
"pubkey" to pubkey,
|
||||
"published" to ack.values.any { it },
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* 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.amethyst.cli.commands
|
||||
|
||||
import com.vitorpamplona.amethyst.cli.Args
|
||||
import com.vitorpamplona.amethyst.cli.Context
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
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.nip51Lists.simpleGroupList.SimpleGroupListEvent
|
||||
|
||||
/**
|
||||
* Read verbs for relay groups: the user's joined list (kind 10009), a relay's
|
||||
* whole hosted directory, and a single group's metadata + roster. All reads drain
|
||||
* a one-shot subscription and parse with Quartz's NIP-29 events.
|
||||
*/
|
||||
object RelayGroupReadCommands {
|
||||
/** `relaygroup list` — the caller's joined groups from their kind-10009 list (public + private). */
|
||||
suspend fun list(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val timeoutSecs = args.longFlag("timeout", 8L)
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val relays = ctx.outboxRelays()
|
||||
if (relays.isEmpty()) return Output.error("no_relays", "no relays configured; run `amy relay add`")
|
||||
|
||||
val filter = Filter(kinds = listOf(SimpleGroupListEvent.KIND), authors = listOf(ctx.identity.pubKeyHex), limit = 1)
|
||||
val latest =
|
||||
ctx
|
||||
.drain(relays.associateWith { listOf(filter) }, timeoutSecs * 1000)
|
||||
.map { it.second }
|
||||
.filterIsInstance<SimpleGroupListEvent>()
|
||||
.maxByOrNull { it.createdAt }
|
||||
|
||||
val groups =
|
||||
latest?.let { event ->
|
||||
val pub = event.publicGroups()
|
||||
val priv = event.privateGroups(ctx.signer) ?: emptyList()
|
||||
(pub + priv).distinctBy { it.groupId to it.relayUrl }
|
||||
} ?: emptyList()
|
||||
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"count" to groups.size,
|
||||
"groups" to
|
||||
groups.map {
|
||||
mapOf("group_id" to it.groupId, "relay" to it.relayUrl, "name" to it.name)
|
||||
},
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/** `relaygroup browse RELAY [--timeout S]` — every group the relay hosts (its 39000 directory). */
|
||||
suspend fun browse(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", "relaygroup browse RELAY")
|
||||
val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl")
|
||||
val timeoutSecs = args.longFlag("timeout", 8L)
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val filter = Filter(kinds = listOf(GroupMetadataEvent.KIND), limit = 500)
|
||||
val metas =
|
||||
ctx
|
||||
.drain(mapOf(relay to listOf(filter)), timeoutSecs * 1000)
|
||||
.map { it.second }
|
||||
.filterIsInstance<GroupMetadataEvent>()
|
||||
.distinctBy { it.groupId() }
|
||||
.sortedBy { it.name()?.lowercase() ?: it.groupId() }
|
||||
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"relay" to relay.url,
|
||||
"count" to metas.size,
|
||||
"groups" to metas.map(::metaSummary),
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/** `relaygroup info RELAY GROUP_ID [--timeout S]` — one group's metadata + admin/member roster. */
|
||||
suspend fun info(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val relayUrl = args.positionalOrNull(0) ?: return Output.error("bad_args", "relaygroup info RELAY GROUP_ID")
|
||||
val groupId = args.positionalOrNull(1) ?: return Output.error("bad_args", "relaygroup info RELAY GROUP_ID")
|
||||
val relay = normalizeGroupRelay(relayUrl) ?: return Output.error("bad_args", "invalid relay url: $relayUrl")
|
||||
val timeoutSecs = args.longFlag("timeout", 8L)
|
||||
|
||||
Context.open(dataDir).use { ctx ->
|
||||
ctx.prepare()
|
||||
val filter =
|
||||
Filter(
|
||||
kinds = listOf(GroupMetadataEvent.KIND, GroupAdminsEvent.KIND, GroupMembersEvent.KIND),
|
||||
tags = mapOf("d" to listOf(groupId)),
|
||||
limit = 10,
|
||||
)
|
||||
val events = ctx.drain(mapOf(relay to listOf(filter)), timeoutSecs * 1000).map { it.second }
|
||||
|
||||
val meta = events.filterIsInstance<GroupMetadataEvent>().maxByOrNull { it.createdAt }
|
||||
val admins = events.filterIsInstance<GroupAdminsEvent>().maxByOrNull { it.createdAt }?.admins() ?: emptyList()
|
||||
val members = events.filterIsInstance<GroupMembersEvent>().maxByOrNull { it.createdAt }?.members() ?: emptyList()
|
||||
val memberCount = (members + admins.map { it.pubKey }).distinct().size
|
||||
|
||||
if (meta == null && admins.isEmpty() && members.isEmpty()) {
|
||||
return Output.error("not_found", "no group $groupId found on ${relay.url}")
|
||||
}
|
||||
|
||||
Output.emit(
|
||||
mapOf(
|
||||
"group_id" to groupId,
|
||||
"relay" to relay.url,
|
||||
"name" to meta?.name(),
|
||||
"about" to meta?.about(),
|
||||
"picture" to meta?.picture(),
|
||||
"private" to (meta?.isPrivate() ?: false),
|
||||
"closed" to (meta?.isClosed() ?: false),
|
||||
"member_count" to memberCount,
|
||||
"admins" to admins.map { mapOf("pubkey" to it.pubKey, "roles" to it.roles) },
|
||||
"members" to members,
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
private fun metaSummary(meta: GroupMetadataEvent) =
|
||||
mapOf(
|
||||
"group_id" to meta.groupId(),
|
||||
"name" to meta.name(),
|
||||
"about" to meta.about(),
|
||||
"private" to meta.isPrivate(),
|
||||
"closed" to meta.isClosed(),
|
||||
)
|
||||
}
|
||||
@@ -3,3 +3,4 @@ marmot/state-headless/
|
||||
dm/state-dm-headless/
|
||||
nests/state/
|
||||
clink/state-clink-headless/
|
||||
relaygroup/state-relaygroup-headless/
|
||||
|
||||
Executable
+127
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# relaygroup-headless.sh — end-to-end check of `amy relaygroup` (NIP-29) against
|
||||
# an embedded relay (`amy serve`, which boots :geode). No external relay binary.
|
||||
#
|
||||
# What it proves:
|
||||
# 1. relaygroup create publishes 9007 + 9002 and returns a group_id.
|
||||
# 2. relaygroup message publishes a kind-9 chat scoped to the group.
|
||||
# 3. relaygroup join publishes 9021 AND adds the group to the kind:10009 list.
|
||||
# 4. relaygroup list reads that list back (private NIP-44 item decrypts).
|
||||
# 5. relaygroup browse emits well-formed JSON.
|
||||
#
|
||||
# geode is not a NIP-29 relay (it does not sign 39000-39003), so browse/info
|
||||
# return empty — that's a relay capability, not a client bug, and is out of
|
||||
# scope here. We assert the client's publish + list-maintenance round-trip.
|
||||
#
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)"
|
||||
TESTS_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
|
||||
STATE_DIR="$SCRIPT_DIR/state-relaygroup-headless"
|
||||
LOG_DIR="$STATE_DIR/logs"
|
||||
LOG_FILE="$LOG_DIR/run.log"
|
||||
RESULTS_FILE="$STATE_DIR/results"
|
||||
AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy"
|
||||
PORT="${PORT:-7466}"
|
||||
NO_BUILD=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--port) PORT="$2"; shift ;;
|
||||
--no-build) NO_BUILD=1 ;;
|
||||
-h|--help) sed -n '3,17p' "${BASH_SOURCE[0]}" | sed 's/^# \?//'; exit 0 ;;
|
||||
*) printf 'unknown flag: %s\n' "$1" >&2; exit 2 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
rm -rf "$STATE_DIR"
|
||||
mkdir -p "$STATE_DIR" "$LOG_DIR"
|
||||
: >"$LOG_FILE"
|
||||
: >"$RESULTS_FILE"
|
||||
|
||||
# shellcheck source=../lib.sh
|
||||
source "$TESTS_DIR/lib.sh"
|
||||
|
||||
amy_a() { HOME="$STATE_DIR" "$AMY_BIN" --account A --secret-backend plaintext --json "$@"; }
|
||||
|
||||
SERVE_PID=""
|
||||
cleanup() {
|
||||
[[ -n "$SERVE_PID" ]] && kill "$SERVE_PID" 2>/dev/null
|
||||
wait "$SERVE_PID" 2>/dev/null
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
if [[ "$NO_BUILD" -eq 0 ]]; then
|
||||
step "building :cli:installDist"
|
||||
( cd "$REPO_ROOT" && ./gradlew -q :cli:installDist ) >>"$LOG_FILE" 2>&1 \
|
||||
|| { fail_msg "installDist failed (see $LOG_FILE)"; exit 1; }
|
||||
fi
|
||||
[[ -x "$AMY_BIN" ]] || { fail_msg "amy binary missing at $AMY_BIN"; exit 1; }
|
||||
|
||||
banner "relaygroup headless (embedded relay on port $PORT)"
|
||||
|
||||
step "init account A"
|
||||
amy_a init >>"$LOG_FILE" 2>&1 || { fail_msg "init failed"; exit 1; }
|
||||
|
||||
step "boot embedded relay (amy serve)"
|
||||
HOME="$STATE_DIR" "$AMY_BIN" --account A --secret-backend plaintext serve --port "$PORT" >"$LOG_DIR/serve.log" 2>&1 &
|
||||
SERVE_PID=$!
|
||||
for _ in $(seq 1 30); do grep -q "listening" "$LOG_DIR/serve.log" 2>/dev/null && break; sleep 0.5; done
|
||||
grep -q "listening" "$LOG_DIR/serve.log" || { fail_msg "relay did not start"; exit 1; }
|
||||
RELAY="ws://127.0.0.1:$PORT"
|
||||
|
||||
amy_a relay add "$RELAY" >>"$LOG_FILE" 2>&1 || { fail_msg "relay add failed"; exit 1; }
|
||||
|
||||
# 1. create
|
||||
step "relaygroup create"
|
||||
CREATE=$(amy_a relaygroup create "$RELAY" --name "Headless Group" --about "hi" --closed 2>>"$LOG_FILE")
|
||||
printf '%s\n' "$CREATE" >>"$LOG_FILE"
|
||||
GID=$(printf '%s' "$CREATE" | jq -r '.group_id // empty')
|
||||
if [[ -n "$GID" && "$(printf '%s' "$CREATE" | jq -r '.published')" == "true" ]]; then
|
||||
pass_msg "create → $GID"; record_result create pass
|
||||
else
|
||||
fail_msg "create did not publish"; record_result create fail
|
||||
fi
|
||||
|
||||
# 2. message
|
||||
step "relaygroup message"
|
||||
MSG=$(amy_a relaygroup message "$RELAY" "$GID" "gm from the harness" 2>>"$LOG_FILE")
|
||||
if [[ "$(printf '%s' "$MSG" | jq -r '.published')" == "true" && "$(printf '%s' "$MSG" | jq -r '.kind')" == "9" ]]; then
|
||||
pass_msg "message → kind 9 published"; record_result message pass
|
||||
else
|
||||
fail_msg "message did not publish"; record_result message fail
|
||||
fi
|
||||
|
||||
# 3. join (updates kind:10009)
|
||||
step "relaygroup join"
|
||||
JOIN=$(amy_a relaygroup join "$RELAY" "$GID" 2>>"$LOG_FILE")
|
||||
if [[ "$(printf '%s' "$JOIN" | jq -r '.published')" == "true" && "$(printf '%s' "$JOIN" | jq -r '.listed')" == "true" ]]; then
|
||||
pass_msg "join → published + listed"; record_result join pass
|
||||
else
|
||||
fail_msg "join did not publish/list"; record_result join fail
|
||||
fi
|
||||
|
||||
# 4. list (reads kind:10009 back, decrypting the private item)
|
||||
step "relaygroup list"
|
||||
LIST=$(amy_a relaygroup list 2>>"$LOG_FILE")
|
||||
if printf '%s' "$LIST" | jq -e --arg g "$GID" '.groups[] | select(.group_id == $g)' >/dev/null 2>&1; then
|
||||
pass_msg "list contains the joined group"; record_result list pass
|
||||
else
|
||||
fail_msg "list missing the joined group: $LIST"; record_result list fail
|
||||
fi
|
||||
|
||||
# 5. browse emits well-formed JSON (empty against geode)
|
||||
step "relaygroup browse"
|
||||
BROWSE=$(amy_a relaygroup browse "$RELAY" --timeout 3 2>>"$LOG_FILE")
|
||||
if printf '%s' "$BROWSE" | jq -e '.count != null' >/dev/null 2>&1; then
|
||||
pass_msg "browse emitted well-formed JSON"; record_result browse pass
|
||||
else
|
||||
fail_msg "browse JSON malformed: $BROWSE"; record_result browse fail
|
||||
fi
|
||||
|
||||
print_summary
|
||||
# results are "<id>\t<pass|fail|skip>"; any fail row → nonzero exit.
|
||||
awk -F'\t' '$2=="fail"{f=1} END{exit f}' "$RESULTS_FILE" && exit 0 || exit 1
|
||||
Reference in New Issue
Block a user