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:
Claude
2026-07-08 01:41:15 +00:00
parent 7ffc38f55d
commit d85bac1f55
8 changed files with 726 additions and 4 deletions
@@ -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
}
}
@@ -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
}
}
}
@@ -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(),
)
}