From d85bac1f55c73013d527b3c7f40db6782fa2e5de Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 01:41:15 +0000 Subject: [PATCH] =?UTF-8?q?feat(cli):=20amy=20relaygroup=20=E2=80=94=20NIP?= =?UTF-8?q?-29=20relay=20groups=20in=20the=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- cli/README.md | 21 ++ cli/ROADMAP.md | 8 +- .../com/vitorpamplona/amethyst/cli/Main.kt | 18 ++ .../cli/commands/RelayGroupCommands.kt | 256 ++++++++++++++++++ .../commands/RelayGroupModerationCommands.kt | 132 +++++++++ .../cli/commands/RelayGroupReadCommands.kt | 167 ++++++++++++ cli/tests/.gitignore | 1 + cli/tests/relaygroup/relaygroup-headless.sh | 127 +++++++++ 8 files changed, 726 insertions(+), 4 deletions(-) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupCommands.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupModerationCommands.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupReadCommands.kt create mode 100755 cli/tests/relaygroup/relaygroup-headless.sh diff --git a/cli/README.md b/cli/README.md index 13da5dd96d..f3d07048a6 100644 --- a/cli/README.md +++ b/cli/README.md @@ -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 | diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index 6e5a7cd979..d34696864a 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -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* diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index fc85fa7494..a67f2a5fe1 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -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): 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 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupCommands.kt new file mode 100644 index 0000000000..52da4563e4 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupCommands.kt @@ -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, + ): Int = + route( + "relaygroup", + tail, + "relaygroup …", + 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, + ): 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, + ): 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, + ): 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 ` → publishes a kind-9 chat with an `h` tag. */ + private suspend fun message( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val text = args.positionalOrNull(2) ?: return Output.error("bad_args", "relaygroup message RELAY GROUP_ID ") + if (text.isBlank()) return Output.error("bad_args", "message text must not be blank") + return publishScoped(dataDir, rest, "relaygroup message RELAY GROUP_ID ") { _, 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() + .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 = + 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, + usage: String, + build: (relay: NormalizedRelayUrl, groupId: String, args: Args) -> EventTemplate, +): 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 + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupModerationCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupModerationCommands.kt new file mode 100644 index 0000000000..4808a79c40 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupModerationCommands.kt @@ -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, + ): 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, + ): 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, + ): 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, + ): 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 + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupReadCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupReadCommands.kt new file mode 100644 index 0000000000..e03a885e37 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupReadCommands.kt @@ -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, + ): 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() + .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, + ): 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() + .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, + ): 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().maxByOrNull { it.createdAt } + val admins = events.filterIsInstance().maxByOrNull { it.createdAt }?.admins() ?: emptyList() + val members = events.filterIsInstance().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(), + ) +} diff --git a/cli/tests/.gitignore b/cli/tests/.gitignore index 3d5dc27cf2..bad5982cf2 100644 --- a/cli/tests/.gitignore +++ b/cli/tests/.gitignore @@ -3,3 +3,4 @@ marmot/state-headless/ dm/state-dm-headless/ nests/state/ clink/state-clink-headless/ +relaygroup/state-relaygroup-headless/ diff --git a/cli/tests/relaygroup/relaygroup-headless.sh b/cli/tests/relaygroup/relaygroup-headless.sh new file mode 100755 index 0000000000..b8a9121d8f --- /dev/null +++ b/cli/tests/relaygroup/relaygroup-headless.sh @@ -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 "\t"; any fail row → nonzero exit. +awk -F'\t' '$2=="fail"{f=1} END{exit f}' "$RESULTS_FILE" && exit 0 || exit 1