feat(cli): add amy CLI with Marmot subcommand surface
New :cli JVM module producing an `amy` binary that drives Amethyst functionality headlessly. Identity and relay configuration sit at the root (`amy init`, `amy whoami`, `amy relay …`); Marmot/MLS lives under `amy marmot …` so future verbs (dm, feed, profile) can slot in cleanly. Marmot surface covered: - key-package publish / check - group create / list / show / members / admins / add / rename / promote / demote / remove / leave - message send / list (kind:9 inner events) - await key-package / group / member / admin / message / rename / epoch (non-interactive polling with --timeout; exit 124 on timeout) Wiring: - NostrClient + BasicOkHttpWebSocket.Builder, reusing the existing publishAndConfirmDetailed and fetchFirst accessories - MarmotManager from :commons with file-backed MlsGroupStateStore, KeyPackageBundleStore, and MarmotMessageStore (unencrypted — scratch harness use only) - each invocation is one-shot: prepare → syncIncoming → run command → persist → disconnect All commands emit one JSON object on stdout; diagnostics on stderr. Designed so shell harnesses can pipe through jq without bespoke parsing. https://claude.ai/code/session_01M6dCKAF5Y1VyHGZPjzwDXq
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.jetbrainsKotlinJvm)
|
||||
application
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_21)
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main {
|
||||
kotlin.srcDir("src/main/kotlin")
|
||||
resources.srcDir("src/main/resources")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":quartz"))
|
||||
implementation(project(":commons"))
|
||||
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.okhttpCoroutines)
|
||||
implementation(libs.jackson.module.kotlin)
|
||||
implementation(libs.slf4j.nop)
|
||||
}
|
||||
|
||||
application {
|
||||
mainClass.set("com.vitorpamplona.amethyst.cli.MainKt")
|
||||
applicationName = "amy"
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
/**
|
||||
* Minimal argv parser. Splits flags (--key value or --key=value) from positional args.
|
||||
* Boolean flags are those whose next token starts with "--" or is absent.
|
||||
*
|
||||
* The parser is intentionally tiny — this CLI is driven by shell scripts, not humans,
|
||||
* so we don't need subcommand groups, short flags, or help text generation.
|
||||
*/
|
||||
class Args(
|
||||
argv: Array<String>,
|
||||
) {
|
||||
val flags: Map<String, String>
|
||||
val booleans: Set<String>
|
||||
val positional: List<String>
|
||||
|
||||
init {
|
||||
val f = mutableMapOf<String, String>()
|
||||
val b = mutableSetOf<String>()
|
||||
val p = mutableListOf<String>()
|
||||
var i = 0
|
||||
while (i < argv.size) {
|
||||
val a = argv[i]
|
||||
if (a.startsWith("--")) {
|
||||
val eq = a.indexOf('=')
|
||||
if (eq >= 0) {
|
||||
f[a.substring(2, eq)] = a.substring(eq + 1)
|
||||
i++
|
||||
} else {
|
||||
val key = a.substring(2)
|
||||
val next = argv.getOrNull(i + 1)
|
||||
if (next == null || next.startsWith("--")) {
|
||||
b.add(key)
|
||||
i++
|
||||
} else {
|
||||
f[key] = next
|
||||
i += 2
|
||||
}
|
||||
}
|
||||
} else {
|
||||
p.add(a)
|
||||
i++
|
||||
}
|
||||
}
|
||||
flags = f
|
||||
booleans = b
|
||||
positional = p
|
||||
}
|
||||
|
||||
fun flag(
|
||||
name: String,
|
||||
default: String? = null,
|
||||
): String? = flags[name] ?: default
|
||||
|
||||
fun intFlag(
|
||||
name: String,
|
||||
default: Int,
|
||||
): Int = flags[name]?.toIntOrNull() ?: default
|
||||
|
||||
fun longFlag(
|
||||
name: String,
|
||||
default: Long,
|
||||
): Long = flags[name]?.toLongOrNull() ?: default
|
||||
|
||||
fun requireFlag(name: String): String =
|
||||
flags[name] ?: run {
|
||||
System.err.println("missing required flag: --$name")
|
||||
throw IllegalArgumentException("missing flag $name")
|
||||
}
|
||||
|
||||
fun bool(name: String): Boolean = name in booleans
|
||||
|
||||
fun positional(
|
||||
index: Int,
|
||||
name: String,
|
||||
): String =
|
||||
positional.getOrNull(index) ?: run {
|
||||
System.err.println("missing positional arg: $name (index $index)")
|
||||
throw IllegalArgumentException("missing positional $name")
|
||||
}
|
||||
|
||||
fun positionalOrNull(index: Int): String? = positional.getOrNull(index)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes
|
||||
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
||||
import com.vitorpamplona.quartz.nip19Bech32.toNsec
|
||||
import java.io.File
|
||||
|
||||
/** Persisted identity (hex keys + cached bech32 forms for convenience). */
|
||||
data class Identity(
|
||||
val privKeyHex: String,
|
||||
val pubKeyHex: String,
|
||||
val nsec: String,
|
||||
val npub: String,
|
||||
) {
|
||||
fun keyPair(): KeyPair = KeyPair(privKey = privKeyHex.hexToByteArray(), pubKey = pubKeyHex.hexToByteArray())
|
||||
|
||||
companion object {
|
||||
fun create(): Identity {
|
||||
val kp = KeyPair()
|
||||
val priv = kp.privKey!!
|
||||
val pub = kp.pubKey
|
||||
return Identity(
|
||||
privKeyHex = priv.toHexKey(),
|
||||
pubKeyHex = pub.toHexKey(),
|
||||
nsec = priv.toNsec(),
|
||||
npub = pub.toNpub(),
|
||||
)
|
||||
}
|
||||
|
||||
fun fromNsec(nsec: String): Identity {
|
||||
val priv = nsec.bechToBytes()
|
||||
val kp = KeyPair(privKey = priv)
|
||||
return Identity(
|
||||
privKeyHex = priv.toHexKey(),
|
||||
pubKeyHex = kp.pubKey.toHexKey(),
|
||||
nsec = priv.toNsec(),
|
||||
npub = kp.pubKey.toNpub(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* On-disk relay configuration, bucketed by purpose (mirrors `wn relays add --type`).
|
||||
*
|
||||
* - `nip65`: advertised read/write (kind:10002)
|
||||
* - `inbox`: DM inbox / gift-wrap delivery (kind:10050)
|
||||
* - `keyPackage`: where this account's KeyPackages (kind:30443) live
|
||||
*/
|
||||
data class RelayConfig(
|
||||
val nip65: MutableList<String> = mutableListOf(),
|
||||
val inbox: MutableList<String> = mutableListOf(),
|
||||
val keyPackage: MutableList<String> = mutableListOf(),
|
||||
) {
|
||||
fun all(): Set<String> = (nip65 + inbox + keyPackage).toSet()
|
||||
|
||||
fun add(
|
||||
type: String,
|
||||
url: String,
|
||||
): Boolean {
|
||||
val list =
|
||||
when (type) {
|
||||
"nip65" -> nip65
|
||||
"inbox" -> inbox
|
||||
"key_package", "keyPackage" -> keyPackage
|
||||
else -> throw IllegalArgumentException("unknown relay type: $type")
|
||||
}
|
||||
if (list.contains(url)) return false
|
||||
list.add(url)
|
||||
return true
|
||||
}
|
||||
|
||||
fun normalized(kind: String): Set<NormalizedRelayUrl> {
|
||||
val src =
|
||||
when (kind) {
|
||||
"nip65" -> nip65
|
||||
"inbox" -> inbox
|
||||
"key_package" -> keyPackage
|
||||
"all" -> all().toList()
|
||||
else -> throw IllegalArgumentException("unknown relay selector: $kind")
|
||||
}
|
||||
return src.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet()
|
||||
}
|
||||
}
|
||||
|
||||
/** Opaque per-run state (subscription cursors, etc). Stored alongside identity. */
|
||||
data class RunState(
|
||||
var giftWrapSince: Long? = null,
|
||||
val groupSince: MutableMap<String, Long> = mutableMapOf(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Root of the on-disk layout. Any absolute path chosen by `--data-dir` (or
|
||||
* `$AMETHYST_CLI_DATA`) — defaults to `./amethyst-cli-data`.
|
||||
*/
|
||||
class DataDir(
|
||||
val root: File,
|
||||
) {
|
||||
val identityFile = File(root, "identity.json")
|
||||
val relaysFile = File(root, "relays.json")
|
||||
val stateFile = File(root, "state.json")
|
||||
val groupsDir = File(root, "groups")
|
||||
val keyPackageBundleFile = File(root, "keypackages.bundle")
|
||||
|
||||
init {
|
||||
root.mkdirs()
|
||||
groupsDir.mkdirs()
|
||||
}
|
||||
|
||||
fun loadIdentityOrNull(): Identity? = if (identityFile.exists()) Json.mapper.readValue<Identity>(identityFile.readText()) else null
|
||||
|
||||
fun saveIdentity(id: Identity) {
|
||||
identityFile.writeText(Json.mapper.writeValueAsString(id))
|
||||
}
|
||||
|
||||
fun loadRelays(): RelayConfig = if (relaysFile.exists()) Json.mapper.readValue(relaysFile.readText()) else RelayConfig()
|
||||
|
||||
fun saveRelays(r: RelayConfig) {
|
||||
relaysFile.writeText(Json.mapper.writeValueAsString(r))
|
||||
}
|
||||
|
||||
fun loadRunState(): RunState = if (stateFile.exists()) Json.mapper.readValue(stateFile.readText()) else RunState()
|
||||
|
||||
fun saveRunState(s: RunState) {
|
||||
stateFile.writeText(Json.mapper.writeValueAsString(s))
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun resolve(flag: String?): DataDir {
|
||||
val envPath = System.getenv("AMETHYST_CLI_DATA")
|
||||
val path = flag ?: envPath ?: "./amethyst-cli-data"
|
||||
return DataDir(File(path).absoluteFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import com.vitorpamplona.amethyst.cli.stores.FileKeyPackageBundleStore
|
||||
import com.vitorpamplona.amethyst.cli.stores.FileMarmotMessageStore
|
||||
import com.vitorpamplona.amethyst.cli.stores.FileMlsGroupStateStore
|
||||
import com.vitorpamplona.amethyst.commons.marmot.MarmotManager
|
||||
import com.vitorpamplona.quartz.marmot.MarmotFilters
|
||||
import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent
|
||||
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirmDetailed
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
|
||||
import kotlinx.coroutines.selects.select
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
/**
|
||||
* Per-invocation wiring. Each CLI run constructs a Context, does its work,
|
||||
* and then closes it — no daemon.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - load identity + relay + run-state from the data-dir,
|
||||
* - wire up a [NostrClient] pointing at those relays,
|
||||
* - wire up the [MarmotManager] pipeline with file-backed stores,
|
||||
* - expose helpers that every command needs (sync, publish-and-confirm,
|
||||
* process-incoming, etc).
|
||||
*
|
||||
* Closing flushes run-state to disk and disconnects the client.
|
||||
*/
|
||||
class Context(
|
||||
val dataDir: DataDir,
|
||||
val identity: Identity,
|
||||
val relays: RelayConfig,
|
||||
val state: RunState,
|
||||
) : AutoCloseable {
|
||||
val signer = NostrSignerInternal(identity.keyPair())
|
||||
|
||||
private val okhttp = OkHttpClient.Builder().build()
|
||||
|
||||
val client: NostrClient =
|
||||
NostrClient(
|
||||
websocketBuilder = BasicOkHttpWebSocket.Builder { okhttp },
|
||||
)
|
||||
|
||||
private val mlsStore = FileMlsGroupStateStore(dataDir.groupsDir)
|
||||
private val keyPackageStore = FileKeyPackageBundleStore(dataDir.keyPackageBundleFile)
|
||||
private val messageStore = FileMarmotMessageStore(dataDir.groupsDir)
|
||||
|
||||
/** Fully-wired manager. Call [prepare] once before use to load persisted state. */
|
||||
val marmot: MarmotManager = MarmotManager(signer, mlsStore, messageStore, keyPackageStore)
|
||||
|
||||
private var prepared = false
|
||||
|
||||
/**
|
||||
* Hydrate MarmotManager from disk (groups + KeyPackage bundles) and
|
||||
* connect to relays. Safe to call multiple times — subsequent calls are
|
||||
* no-ops.
|
||||
*/
|
||||
suspend fun prepare() {
|
||||
if (prepared) return
|
||||
marmot.restoreAll()
|
||||
client.connect()
|
||||
prepared = true
|
||||
}
|
||||
|
||||
fun outboxRelays(): Set<NormalizedRelayUrl> = relays.normalized("nip65")
|
||||
|
||||
fun inboxRelays(): Set<NormalizedRelayUrl> = relays.normalized("inbox")
|
||||
|
||||
fun keyPackageRelays(): Set<NormalizedRelayUrl> = relays.normalized("key_package")
|
||||
|
||||
fun anyRelays(): Set<NormalizedRelayUrl> = relays.normalized("all")
|
||||
|
||||
/**
|
||||
* Publish an event to the given relays and wait for OK confirmations.
|
||||
*
|
||||
* Returns the set of relays that ACK'd `true`. Does not throw on rejection —
|
||||
* callers inspect the map and decide.
|
||||
*/
|
||||
suspend fun publish(
|
||||
event: Event,
|
||||
relayList: Set<NormalizedRelayUrl>,
|
||||
timeoutSecs: Long = 15,
|
||||
): Map<NormalizedRelayUrl, Boolean> {
|
||||
if (relayList.isEmpty()) return emptyMap()
|
||||
return client.publishAndConfirmDetailed(event, relayList, timeoutSecs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to the given filters across the given relays, drain all events
|
||||
* until either every relay has sent EOSE or the timeout elapses, and
|
||||
* return them. Used for one-shot catch-up queries — not live subscriptions.
|
||||
*/
|
||||
suspend fun drain(
|
||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
timeoutMs: Long = 8_000,
|
||||
): List<Pair<NormalizedRelayUrl, Event>> {
|
||||
if (filters.isEmpty()) return emptyList()
|
||||
val eventChannel = Channel<Pair<NormalizedRelayUrl, Event>>(UNLIMITED)
|
||||
val doneChannel = Channel<NormalizedRelayUrl>(UNLIMITED)
|
||||
val remaining = filters.keys.toMutableSet()
|
||||
val subId = newSubId()
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
eventChannel.trySend(relay to event)
|
||||
}
|
||||
|
||||
override fun onEose(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
doneChannel.trySend(relay)
|
||||
}
|
||||
|
||||
override fun onClosed(
|
||||
message: String,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
doneChannel.trySend(relay)
|
||||
}
|
||||
|
||||
override fun onCannotConnect(
|
||||
relay: NormalizedRelayUrl,
|
||||
message: String,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
doneChannel.trySend(relay)
|
||||
}
|
||||
}
|
||||
val collected = mutableListOf<Pair<NormalizedRelayUrl, Event>>()
|
||||
try {
|
||||
client.subscribe(subId, filters, listener)
|
||||
withTimeoutOrNull(timeoutMs) {
|
||||
while (remaining.isNotEmpty()) {
|
||||
select {
|
||||
eventChannel.onReceive { collected.add(it) }
|
||||
doneChannel.onReceive { r -> remaining.remove(r) }
|
||||
}
|
||||
}
|
||||
// Drain any events that landed after EOSE but before cancel
|
||||
while (true) {
|
||||
val r = eventChannel.tryReceive()
|
||||
if (!r.isSuccess) break
|
||||
collected.add(r.getOrThrow())
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
client.unsubscribe(subId)
|
||||
eventChannel.close()
|
||||
doneChannel.close()
|
||||
}
|
||||
return collected
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull down everything needed to bring local Marmot state current:
|
||||
* - kind:1059 gift wraps on inbox relays → try to unwrap Welcomes
|
||||
* - kind:445 group events per active group → feed into inbound processor
|
||||
*
|
||||
* Incrementally advances the `since` cursors in [state] so the next run
|
||||
* only asks relays for newer events.
|
||||
*/
|
||||
suspend fun syncIncoming(timeoutMs: Long = 8_000) {
|
||||
val inbox = inboxRelays().ifEmpty { anyRelays() }
|
||||
val gwSince = state.giftWrapSince
|
||||
val gwFilter =
|
||||
if (gwSince != null) {
|
||||
MarmotFilters.giftWrapsForUserSince(identity.pubKeyHex, gwSince)
|
||||
} else {
|
||||
MarmotFilters.giftWrapsForUser(identity.pubKeyHex)
|
||||
}
|
||||
|
||||
val activeGroupIds = marmot.subscriptionManager.activeGroupIdsSnapshot().toList()
|
||||
val perGroupFilters: Map<HexKey, Filter> =
|
||||
activeGroupIds.associateWith { gid ->
|
||||
val since = state.groupSince[gid]
|
||||
if (since != null) {
|
||||
MarmotFilters.groupEventsByGroupIdSince(gid, since)
|
||||
} else {
|
||||
MarmotFilters.groupEventsByGroupId(gid)
|
||||
}
|
||||
}
|
||||
|
||||
// Group filters go to each group's configured relays, not the user's
|
||||
// inbox — kind:445 is delivered to the group's relay set advertised in
|
||||
// its MIP-01 metadata (falls back to our outbox if the group never
|
||||
// stamped any).
|
||||
val filterMap = mutableMapOf<NormalizedRelayUrl, MutableList<Filter>>()
|
||||
for (r in inbox) filterMap.getOrPut(r) { mutableListOf() }.add(gwFilter)
|
||||
for ((gid, filter) in perGroupFilters) {
|
||||
val groupRelays = marmotGroupRelays(gid).ifEmpty { outboxRelays() }
|
||||
for (r in groupRelays) filterMap.getOrPut(r) { mutableListOf() }.add(filter)
|
||||
}
|
||||
if (filterMap.isEmpty()) return
|
||||
|
||||
val events = drain(filterMap, timeoutMs)
|
||||
val now = System.currentTimeMillis() / 1000
|
||||
|
||||
var maxGwSeen = gwSince ?: 0L
|
||||
val maxGroupSeen = perGroupFilters.keys.associateWith { state.groupSince[it] ?: 0L }.toMutableMap()
|
||||
|
||||
for ((relay, event) in events) {
|
||||
when (event.kind) {
|
||||
GiftWrapEvent.KIND -> {
|
||||
// kind:1059 — try to unwrap. If it's a Welcome, let the
|
||||
// inbound processor apply it; anything else is ignored.
|
||||
val gw = event as? GiftWrapEvent ?: continue
|
||||
try {
|
||||
val inner = gw.unwrapOrNull(signer) ?: continue
|
||||
if (inner.kind == WelcomeEvent.KIND && inner is WelcomeEvent) {
|
||||
val hint = inner.nostrGroupId()
|
||||
val res = marmot.processWelcome(inner, hint)
|
||||
System.err.println("[cli] Welcome via $relay → $res")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
System.err.println("[cli] failed to unwrap giftwrap ${event.id.take(8)}: ${e.message}")
|
||||
}
|
||||
if (event.createdAt > maxGwSeen) maxGwSeen = event.createdAt
|
||||
}
|
||||
|
||||
GroupEvent.KIND -> {
|
||||
val ge = event as? GroupEvent ?: continue
|
||||
val gid = ge.groupId() ?: continue
|
||||
try {
|
||||
val res = marmot.processGroupEvent(ge)
|
||||
// Mirror DecryptAndIndexProcessor on Amethyst: application
|
||||
// messages get persisted at decrypt-time, because MLS ratchet
|
||||
// advancement makes the ciphertext un-re-decryptable later.
|
||||
if (res is com.vitorpamplona.quartz.marmot.GroupEventResult.ApplicationMessage) {
|
||||
marmot.persistDecryptedMessage(res.groupId, res.innerEventJson)
|
||||
}
|
||||
System.err.println("[cli] GroupEvent ${event.id.take(8)} → ${res::class.simpleName}")
|
||||
} catch (e: Exception) {
|
||||
System.err.println("[cli] processGroupEvent failed: ${e.message}")
|
||||
}
|
||||
val prev = maxGroupSeen[gid] ?: 0L
|
||||
if (event.createdAt > prev) maxGroupSeen[gid] = event.createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.giftWrapSince = if (maxGwSeen > 0) maxGwSeen else now
|
||||
for ((gid, seen) in maxGroupSeen) {
|
||||
state.groupSince[gid] = if (seen > 0) seen else now
|
||||
}
|
||||
}
|
||||
|
||||
fun marmotGroupRelays(nostrGroupId: HexKey): Set<NormalizedRelayUrl> {
|
||||
val m = marmot.groupMetadata(nostrGroupId) ?: return emptySet()
|
||||
return m.relays
|
||||
.mapNotNull {
|
||||
com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
.normalizeOrNull(it)
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
dataDir.saveRunState(state)
|
||||
try {
|
||||
client.close()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Build a Context but require an identity to already exist — most commands can't run without one. */
|
||||
fun open(dataDir: DataDir): Context {
|
||||
val identity =
|
||||
dataDir.loadIdentityOrNull()
|
||||
?: run {
|
||||
System.err.println("No identity found at ${dataDir.identityFile}. Run `amethyst-cli init` first.")
|
||||
throw IllegalStateException("no identity")
|
||||
}
|
||||
return Context(
|
||||
dataDir = dataDir,
|
||||
identity = identity,
|
||||
relays = dataDir.loadRelays(),
|
||||
state = dataDir.loadRunState(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
|
||||
object Json {
|
||||
val mapper: ObjectMapper = jacksonObjectMapper()
|
||||
|
||||
fun writeLine(obj: Any) {
|
||||
println(mapper.writeValueAsString(obj))
|
||||
}
|
||||
|
||||
fun error(
|
||||
code: String,
|
||||
detail: String? = null,
|
||||
): Int {
|
||||
val payload = mutableMapOf<String, Any>("error" to code)
|
||||
if (detail != null) payload["detail"] = detail
|
||||
System.err.println(mapper.writeValueAsString(payload))
|
||||
return 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import com.vitorpamplona.amethyst.cli.commands.Commands
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
/**
|
||||
* amy — non-interactive command-line interface to Amethyst.
|
||||
*
|
||||
* Today this covers the Marmot/MLS surface (`amy marmot …`) plus identity
|
||||
* and relay configuration at the root level. The layout is intentionally
|
||||
* extensible — future verbs (`amy dm`, `amy feed`, `amy profile`) slot in
|
||||
* as new top-level subcommands.
|
||||
*
|
||||
* Usage: amy --data-dir PATH SUBCOMMAND ARGS
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 — success
|
||||
* 1 — runtime error (printed as JSON on stderr: {"error": "...", "detail": "..."})
|
||||
* 2 — invalid arguments
|
||||
* 124 — await timeout
|
||||
*
|
||||
* Every command that succeeds prints exactly one JSON object to stdout.
|
||||
* Diagnostic logs go to stderr and are safe to discard.
|
||||
*/
|
||||
fun main(argv: Array<String>) {
|
||||
val code =
|
||||
try {
|
||||
runBlocking { dispatch(argv) }
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Json.error("bad_args", e.message)
|
||||
2
|
||||
} catch (e: AwaitTimeout) {
|
||||
Json.error("timeout", e.message)
|
||||
124
|
||||
} catch (e: Exception) {
|
||||
Json.error("runtime", "${e::class.simpleName}: ${e.message}")
|
||||
1
|
||||
}
|
||||
exitProcess(code)
|
||||
}
|
||||
|
||||
class AwaitTimeout(
|
||||
message: String,
|
||||
) : RuntimeException(message)
|
||||
|
||||
private suspend fun dispatch(argv: Array<String>): Int {
|
||||
if (argv.isEmpty() || argv[0] == "--help" || argv[0] == "-h") {
|
||||
printUsage()
|
||||
return 0
|
||||
}
|
||||
|
||||
// Pull --data-dir out of argv before subcommand parsing so subcommands see
|
||||
// only their own args.
|
||||
val filteredArgs = mutableListOf<String>()
|
||||
var dataDirFlag: String? = null
|
||||
var i = 0
|
||||
while (i < argv.size) {
|
||||
when (val a = argv[i]) {
|
||||
"--data-dir" -> {
|
||||
dataDirFlag = argv.getOrNull(i + 1)
|
||||
i += 2
|
||||
}
|
||||
|
||||
else -> {
|
||||
if (a.startsWith("--data-dir=")) {
|
||||
dataDirFlag = a.removePrefix("--data-dir=")
|
||||
i++
|
||||
} else {
|
||||
filteredArgs.add(a)
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (filteredArgs.isEmpty()) {
|
||||
printUsage()
|
||||
return 2
|
||||
}
|
||||
|
||||
val dataDir = DataDir.resolve(dataDirFlag)
|
||||
val head = filteredArgs[0]
|
||||
val tail = filteredArgs.drop(1).toTypedArray()
|
||||
|
||||
return when (head) {
|
||||
"init" -> {
|
||||
Commands.init(dataDir, Args(tail))
|
||||
}
|
||||
|
||||
"whoami" -> {
|
||||
Commands.whoami(dataDir)
|
||||
}
|
||||
|
||||
"relay" -> {
|
||||
Commands.relay(dataDir, tail)
|
||||
}
|
||||
|
||||
"marmot" -> {
|
||||
marmotDispatch(dataDir, tail)
|
||||
}
|
||||
|
||||
else -> {
|
||||
System.err.println("unknown subcommand: $head")
|
||||
printUsage()
|
||||
2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun marmotDispatch(
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int {
|
||||
if (tail.isEmpty()) {
|
||||
printUsage()
|
||||
return 2
|
||||
}
|
||||
val head = tail[0]
|
||||
val rest = tail.drop(1).toTypedArray()
|
||||
return when (head) {
|
||||
"key-package" -> {
|
||||
Commands.keyPackage(dataDir, rest)
|
||||
}
|
||||
|
||||
"group" -> {
|
||||
Commands.group(dataDir, rest)
|
||||
}
|
||||
|
||||
"message" -> {
|
||||
Commands.message(dataDir, rest)
|
||||
}
|
||||
|
||||
"await" -> {
|
||||
Commands.await(dataDir, rest)
|
||||
}
|
||||
|
||||
else -> {
|
||||
System.err.println("unknown marmot subcommand: $head")
|
||||
printUsage()
|
||||
2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun printUsage() {
|
||||
System.err.println(
|
||||
"""
|
||||
|amy — Amethyst command-line interface
|
||||
|
|
||||
|Usage:
|
||||
| amy [--data-dir PATH] <cmd> [args...]
|
||||
|
|
||||
|Identity:
|
||||
| init [--nsec NSEC] create or import identity
|
||||
| whoami print current identity
|
||||
|
|
||||
|Relays:
|
||||
| relay add URL [--type T] T=nip65|inbox|key_package|all (default all)
|
||||
| relay list print configured relays
|
||||
| relay publish-lists publish kind:10002 + kind:10050
|
||||
|
|
||||
|Marmot (MLS group messaging):
|
||||
| marmot key-package publish publish a fresh KeyPackage
|
||||
| marmot key-package check NPUB fetch NPUB's KeyPackage from relays
|
||||
|
|
||||
| marmot group create [--name NAME] create an empty group (self-only)
|
||||
| marmot group list list joined groups
|
||||
| marmot group show GID print full group details
|
||||
| marmot group members GID print members
|
||||
| marmot group admins GID print admins
|
||||
| marmot group add GID NPUB [NPUB...] fetch KPs and invite
|
||||
| marmot group rename GID NAME commit a rename
|
||||
| marmot group promote GID NPUB add admin
|
||||
| marmot group demote GID NPUB remove admin
|
||||
| marmot group remove GID NPUB remove member
|
||||
| marmot group leave GID self-remove
|
||||
|
|
||||
| marmot message send GID TEXT publish kind:9 inner event into the group
|
||||
| marmot message list GID [--limit N] dump decrypted inner events
|
||||
|
|
||||
| marmot await key-package NPUB (all await verbs take --timeout SECS, default 30;
|
||||
| marmot await group --name NAME exit 124 on timeout)
|
||||
| marmot await member GID NPUB
|
||||
| marmot await admin GID NPUB
|
||||
| marmot await message GID --match TEXT
|
||||
| marmot await rename GID --name NAME
|
||||
| marmot await epoch GID --min N
|
||||
""".trimMargin(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
/*
|
||||
* 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.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.vitorpamplona.amethyst.cli.Args
|
||||
import com.vitorpamplona.amethyst.cli.AwaitTimeout
|
||||
import com.vitorpamplona.amethyst.cli.Context
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Json
|
||||
import com.vitorpamplona.amethyst.cli.util.Npubs
|
||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* `await` subcommands. Each polls until a condition is met or the timeout elapses;
|
||||
* on timeout we throw [AwaitTimeout] which maps to exit code 124.
|
||||
*/
|
||||
object AwaitCommands {
|
||||
suspend fun dispatch(
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int {
|
||||
if (tail.isEmpty()) return Json.error("bad_args", "await <key-package|group|member|admin|message|rename|epoch>")
|
||||
val rest = tail.drop(1).toTypedArray()
|
||||
return when (tail[0]) {
|
||||
"key-package" -> awaitKeyPackage(dataDir, rest)
|
||||
"group" -> awaitGroup(dataDir, rest)
|
||||
"member" -> awaitMember(dataDir, rest)
|
||||
"admin" -> awaitAdmin(dataDir, rest)
|
||||
"message" -> awaitMessage(dataDir, rest)
|
||||
"rename" -> awaitRename(dataDir, rest)
|
||||
"epoch" -> awaitEpoch(dataDir, rest)
|
||||
else -> Json.error("bad_args", "await ${tail[0]}")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun awaitKeyPackage(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.isEmpty()) return Json.error("bad_args", "await key-package <npub>")
|
||||
val target = Npubs.resolveToHex(rest[0])
|
||||
val args = Args(rest.drop(1).toTypedArray())
|
||||
val timeoutSecs = args.longFlag("timeout", 30)
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
val filter = ctx.marmot.subscriptionManager.keyPackageFilter(target)
|
||||
val deadline = System.currentTimeMillis() + timeoutSecs * 1000
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
val relays = ctx.anyRelays()
|
||||
if (relays.isNotEmpty()) {
|
||||
val event =
|
||||
ctx.client.fetchFirst(
|
||||
filters = relays.associateWith { listOf(filter) },
|
||||
timeoutMs = 3_000,
|
||||
)
|
||||
if (event is KeyPackageEvent) {
|
||||
Json.writeLine(mapOf("event_id" to event.id, "author" to event.pubKey))
|
||||
return 0
|
||||
}
|
||||
}
|
||||
delay(2_000)
|
||||
}
|
||||
throw AwaitTimeout("no KeyPackage for $target within ${timeoutSecs}s")
|
||||
} finally {
|
||||
ctx.close()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun awaitGroup(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val wantedName = args.flag("name")
|
||||
val timeoutSecs = args.longFlag("timeout", 30)
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
val deadline = System.currentTimeMillis() + timeoutSecs * 1000
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
ctx.syncIncoming(timeoutMs = 3_000)
|
||||
val match =
|
||||
ctx.marmot.activeGroupIds().firstOrNull { gid ->
|
||||
wantedName == null || ctx.marmot.groupMetadata(gid)?.name == wantedName
|
||||
}
|
||||
if (match != null) {
|
||||
Json.writeLine(
|
||||
mapOf(
|
||||
"group_id" to match,
|
||||
"name" to (ctx.marmot.groupMetadata(match)?.name ?: ""),
|
||||
"epoch" to ctx.marmot.groupEpoch(match),
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
delay(1_500)
|
||||
}
|
||||
throw AwaitTimeout("no group with name=$wantedName within ${timeoutSecs}s")
|
||||
} finally {
|
||||
ctx.close()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun awaitMember(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int =
|
||||
pollCondition(dataDir, rest, "await member <gid> <npub>", targetIdx = 1) { ctx, rawArgs ->
|
||||
val gid = rawArgs[0]
|
||||
val target = Npubs.resolveToHex(rawArgs[1])
|
||||
if (!ctx.marmot.isMember(gid)) {
|
||||
null
|
||||
} else if (ctx.marmot.memberPubkeys(gid).any { it.pubkey == target }) {
|
||||
mapOf("group_id" to gid, "pubkey" to target, "epoch" to ctx.marmot.groupEpoch(gid))
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun awaitAdmin(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int =
|
||||
pollCondition(dataDir, rest, "await admin <gid> <npub>", targetIdx = 1) { ctx, rawArgs ->
|
||||
val gid = rawArgs[0]
|
||||
val target = Npubs.resolveToHex(rawArgs[1])
|
||||
if (!ctx.marmot.isMember(gid)) {
|
||||
null
|
||||
} else if (ctx.marmot
|
||||
.groupMetadata(gid)
|
||||
?.adminPubkeys
|
||||
?.contains(target) == true
|
||||
) {
|
||||
mapOf("group_id" to gid, "pubkey" to target, "epoch" to ctx.marmot.groupEpoch(gid))
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun awaitRename(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.isEmpty()) return Json.error("bad_args", "await rename <gid> --name <name>")
|
||||
val gid = rest[0]
|
||||
val args = Args(rest.drop(1).toTypedArray())
|
||||
val wantedName = args.requireFlag("name")
|
||||
val timeoutSecs = args.longFlag("timeout", 30)
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
val deadline = System.currentTimeMillis() + timeoutSecs * 1000
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
ctx.syncIncoming(timeoutMs = 3_000)
|
||||
val name = ctx.marmot.groupMetadata(gid)?.name
|
||||
if (name == wantedName) {
|
||||
Json.writeLine(mapOf("group_id" to gid, "name" to name, "epoch" to ctx.marmot.groupEpoch(gid)))
|
||||
return 0
|
||||
}
|
||||
delay(1_500)
|
||||
}
|
||||
throw AwaitTimeout("group $gid never renamed to $wantedName within ${timeoutSecs}s")
|
||||
} finally {
|
||||
ctx.close()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun awaitEpoch(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.isEmpty()) return Json.error("bad_args", "await epoch <gid> --min N")
|
||||
val gid = rest[0]
|
||||
val args = Args(rest.drop(1).toTypedArray())
|
||||
val min = args.longFlag("min", 1)
|
||||
val timeoutSecs = args.longFlag("timeout", 30)
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
val deadline = System.currentTimeMillis() + timeoutSecs * 1000
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
ctx.syncIncoming(timeoutMs = 3_000)
|
||||
val epoch = ctx.marmot.groupEpoch(gid)
|
||||
if (epoch != null && epoch >= min) {
|
||||
Json.writeLine(mapOf("group_id" to gid, "epoch" to epoch))
|
||||
return 0
|
||||
}
|
||||
delay(1_500)
|
||||
}
|
||||
throw AwaitTimeout("group $gid epoch never reached $min within ${timeoutSecs}s")
|
||||
} finally {
|
||||
ctx.close()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun awaitMessage(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.isEmpty()) return Json.error("bad_args", "await message <gid> --match STRING")
|
||||
val gid = rest[0]
|
||||
val args = Args(rest.drop(1).toTypedArray())
|
||||
val needle = args.requireFlag("match")
|
||||
val timeoutSecs = args.longFlag("timeout", 30)
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
val deadline = System.currentTimeMillis() + timeoutSecs * 1000
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
ctx.syncIncoming(timeoutMs = 3_000)
|
||||
val msgs = ctx.marmot.loadStoredMessages(gid)
|
||||
for (line in msgs.asReversed()) {
|
||||
val obj =
|
||||
try {
|
||||
Json.mapper.readValue<Map<String, Any?>>(line)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: continue
|
||||
val content = obj["content"]?.toString() ?: continue
|
||||
if (content.contains(needle)) {
|
||||
Json.writeLine(
|
||||
mapOf(
|
||||
"group_id" to gid,
|
||||
"id" to obj["id"],
|
||||
"author" to obj["pubkey"],
|
||||
"content" to content,
|
||||
"kind" to obj["kind"],
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
delay(1_500)
|
||||
}
|
||||
throw AwaitTimeout("no message matching '$needle' in $gid within ${timeoutSecs}s")
|
||||
} finally {
|
||||
ctx.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic poll loop used by [awaitMember] / [awaitAdmin] — both take the
|
||||
* same `<gid> <npub>` positional shape and differ only in the predicate.
|
||||
*/
|
||||
private suspend fun pollCondition(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
usage: String,
|
||||
targetIdx: Int,
|
||||
check: suspend (Context, Array<String>) -> Map<String, Any?>?,
|
||||
): Int {
|
||||
if (rest.size <= targetIdx) return Json.error("bad_args", usage)
|
||||
val args = Args(rest.drop(targetIdx + 1).toTypedArray())
|
||||
val timeoutSecs = args.longFlag("timeout", 30)
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
val deadline = System.currentTimeMillis() + timeoutSecs * 1000
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
ctx.syncIncoming(timeoutMs = 3_000)
|
||||
val hit = check(ctx, rest)
|
||||
if (hit != null) {
|
||||
Json.writeLine(hit)
|
||||
return 0
|
||||
}
|
||||
delay(1_500)
|
||||
}
|
||||
throw AwaitTimeout("condition never satisfied within ${timeoutSecs}s ($usage)")
|
||||
} finally {
|
||||
ctx.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* 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.DataDir
|
||||
|
||||
/**
|
||||
* Tiny dispatcher over the per-verb command groups. Each top-level subcommand
|
||||
* (`init`, `relay`, `group`, `message`, …) gets its own file so no single
|
||||
* file is too big to edit safely.
|
||||
*/
|
||||
object Commands {
|
||||
suspend fun init(
|
||||
dataDir: DataDir,
|
||||
args: Args,
|
||||
): Int = InitCommands.init(dataDir, args)
|
||||
|
||||
suspend fun whoami(dataDir: DataDir): Int = InitCommands.whoami(dataDir)
|
||||
|
||||
suspend fun relay(
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int = RelayCommands.dispatch(dataDir, tail)
|
||||
|
||||
suspend fun keyPackage(
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int = KeyPackageCommands.dispatch(dataDir, tail)
|
||||
|
||||
suspend fun group(
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int = GroupCommands.dispatch(dataDir, tail)
|
||||
|
||||
suspend fun message(
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int = MessageCommands.dispatch(dataDir, tail)
|
||||
|
||||
suspend fun await(
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int = AwaitCommands.dispatch(dataDir, tail)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* 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.Context
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Json
|
||||
import com.vitorpamplona.amethyst.cli.util.Npubs
|
||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
/**
|
||||
* `group add <group_id> <npub> [<npub> ...]` — fetch each invitee's
|
||||
* KeyPackage from the union of (our relays + any known KeyPackage relays
|
||||
* for them) and run the full add-member flow for each one: build commit,
|
||||
* publish commit to the group's relays, then wrap + publish the Welcome
|
||||
* gift wrap.
|
||||
*/
|
||||
object GroupAddMemberCommand {
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
suspend fun run(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.size < 2) return Json.error("bad_args", "group add <group_id> <npub> [<npub> ...]")
|
||||
val gid = rest[0]
|
||||
val invitees = rest.drop(1).map { Npubs.resolveToHex(it) }
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
ctx.syncIncoming()
|
||||
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
|
||||
|
||||
val groupRelays = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
|
||||
val report = mutableListOf<Map<String, Any?>>()
|
||||
|
||||
for (pub in invitees) {
|
||||
val filter = ctx.marmot.subscriptionManager.keyPackageFilter(pub)
|
||||
val relays = ctx.anyRelays()
|
||||
val filters = relays.associateWith { listOf(filter) }
|
||||
val kpEvent = ctx.client.fetchFirst(filters = filters, timeoutMs = 10_000)
|
||||
if (kpEvent == null || kpEvent !is KeyPackageEvent) {
|
||||
report.add(mapOf("pubkey" to pub, "status" to "no_key_package"))
|
||||
continue
|
||||
}
|
||||
val kpBytes = Base64.decode(kpEvent.keyPackageBase64())
|
||||
|
||||
val (commitEvent, welcomeDelivery) =
|
||||
ctx.marmot.addMember(
|
||||
nostrGroupId = gid,
|
||||
memberPubKey = pub,
|
||||
keyPackageBytes = kpBytes,
|
||||
keyPackageEventId = kpEvent.id,
|
||||
relays = groupRelays.toList(),
|
||||
)
|
||||
|
||||
// Order matters: commit first (so invitee doesn't join at a future epoch),
|
||||
// then welcome.
|
||||
val commitAck = ctx.publish(commitEvent.signedEvent, groupRelays)
|
||||
val welcomeAck =
|
||||
if (welcomeDelivery != null) {
|
||||
val inbox = ctx.inboxRelays().ifEmpty { ctx.outboxRelays() }
|
||||
ctx.publish(welcomeDelivery.giftWrapEvent, inbox)
|
||||
} else {
|
||||
emptyMap()
|
||||
}
|
||||
|
||||
report.add(
|
||||
mapOf(
|
||||
"pubkey" to pub,
|
||||
"status" to "invited",
|
||||
"key_package_event_id" to kpEvent.id,
|
||||
"commit_event_id" to commitEvent.signedEvent.id,
|
||||
"welcome_event_id" to welcomeDelivery?.giftWrapEvent?.id,
|
||||
"commit_accepted_by" to commitAck.filterValues { it }.keys.map { it.url },
|
||||
"welcome_accepted_by" to welcomeAck.filterValues { it }.keys.map { it.url },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
Json.writeLine(
|
||||
mapOf(
|
||||
"group_id" to gid,
|
||||
"epoch" to ctx.marmot.groupEpoch(gid),
|
||||
"results" to report,
|
||||
),
|
||||
)
|
||||
return 0
|
||||
} finally {
|
||||
ctx.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* 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.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Json
|
||||
|
||||
object GroupCommands {
|
||||
suspend fun dispatch(
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int {
|
||||
if (tail.isEmpty()) return Json.error("bad_args", "group <create|list|show|…>")
|
||||
val rest = tail.drop(1).toTypedArray()
|
||||
return when (tail[0]) {
|
||||
"create" -> GroupCreateCommand.run(dataDir, rest)
|
||||
"list" -> GroupReadCommands.list(dataDir)
|
||||
"show" -> GroupReadCommands.show(dataDir, rest)
|
||||
"members" -> GroupReadCommands.members(dataDir, rest)
|
||||
"admins" -> GroupReadCommands.admins(dataDir, rest)
|
||||
"add" -> GroupAddMemberCommand.run(dataDir, rest)
|
||||
"rename" -> GroupMetadataCommands.rename(dataDir, rest)
|
||||
"promote" -> GroupMetadataCommands.promote(dataDir, rest)
|
||||
"demote" -> GroupMetadataCommands.demote(dataDir, rest)
|
||||
"remove" -> GroupMembershipCommands.remove(dataDir, rest)
|
||||
"leave" -> GroupMembershipCommands.leave(dataDir, rest)
|
||||
else -> Json.error("bad_args", "group ${tail[0]}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* 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.Json
|
||||
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
|
||||
object GroupCreateCommand {
|
||||
suspend fun run(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val name = args.flag("name", "")!!
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
val gid = RandomInstance.bytes(32).toHexKey()
|
||||
|
||||
ctx.marmot.createGroup(gid)
|
||||
|
||||
// Stamp initial metadata: name + our outbox relays + self as admin.
|
||||
// Mirrors CreateGroupScreen.proceedWithCreate() on the Amethyst side.
|
||||
val outboxUrls = ctx.outboxRelays().map { it.url }
|
||||
val metadata =
|
||||
MarmotGroupData(
|
||||
nostrGroupId = gid,
|
||||
name = name,
|
||||
description = "",
|
||||
adminPubkeys = listOf(ctx.identity.pubKeyHex),
|
||||
relays = outboxUrls,
|
||||
)
|
||||
val commit = ctx.marmot.updateGroupMetadata(gid, metadata)
|
||||
|
||||
// Group relays == what the metadata carries, which on first commit is our outbox.
|
||||
val targets = ctx.outboxRelays()
|
||||
val ack = ctx.publish(commit.signedEvent, targets)
|
||||
|
||||
Json.writeLine(
|
||||
mapOf(
|
||||
"group_id" to gid,
|
||||
"name" to name,
|
||||
"epoch" to ctx.marmot.groupEpoch(gid),
|
||||
"commit_event_id" to commit.signedEvent.id,
|
||||
"published_to" to ack.filterValues { it }.keys.map { it.url },
|
||||
),
|
||||
)
|
||||
return 0
|
||||
} finally {
|
||||
ctx.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.cli.commands
|
||||
|
||||
import com.vitorpamplona.amethyst.cli.Context
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Json
|
||||
import com.vitorpamplona.amethyst.cli.util.Npubs
|
||||
|
||||
object GroupMembershipCommands {
|
||||
suspend fun remove(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.size < 2) return Json.error("bad_args", "group remove <gid> <npub>")
|
||||
val gid = rest[0]
|
||||
val target = Npubs.resolveToHex(rest[1])
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
ctx.syncIncoming()
|
||||
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
|
||||
|
||||
val member =
|
||||
ctx.marmot.memberPubkeys(gid).firstOrNull { it.pubkey == target }
|
||||
?: return Json.error("not_in_group", target)
|
||||
|
||||
val outbound =
|
||||
ctx.marmot.removeMember(nostrGroupId = gid, targetLeafIndex = member.leafIndex)
|
||||
val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
|
||||
val ack = ctx.publish(outbound.signedEvent, targets)
|
||||
Json.writeLine(
|
||||
mapOf(
|
||||
"group_id" to gid,
|
||||
"removed" to target,
|
||||
"leaf_index" to member.leafIndex,
|
||||
"epoch" to ctx.marmot.groupEpoch(gid),
|
||||
"commit_event_id" to outbound.signedEvent.id,
|
||||
"published_to" to ack.filterValues { it }.keys.map { it.url },
|
||||
),
|
||||
)
|
||||
return 0
|
||||
} finally {
|
||||
ctx.close()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun leave(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.isEmpty()) return Json.error("bad_args", "group leave <gid>")
|
||||
val gid = rest[0]
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
|
||||
|
||||
val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
|
||||
val outbound = ctx.marmot.leaveGroup(gid)
|
||||
val ack = ctx.publish(outbound.signedEvent, targets)
|
||||
Json.writeLine(
|
||||
mapOf(
|
||||
"group_id" to gid,
|
||||
"proposal_event_id" to outbound.signedEvent.id,
|
||||
"published_to" to ack.filterValues { it }.keys.map { it.url },
|
||||
),
|
||||
)
|
||||
return 0
|
||||
} finally {
|
||||
ctx.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* 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.Context
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Json
|
||||
import com.vitorpamplona.amethyst.cli.util.Npubs
|
||||
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
|
||||
/**
|
||||
* Metadata-only commits: rename, promote/demote. Each loads current metadata,
|
||||
* edits the right field, publishes a GCE commit to the group relays.
|
||||
*/
|
||||
object GroupMetadataCommands {
|
||||
suspend fun rename(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.size < 2) return Json.error("bad_args", "group rename <gid> <name>")
|
||||
return edit(dataDir, rest[0]) { it.copy(name = rest[1]) }
|
||||
}
|
||||
|
||||
suspend fun promote(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.size < 2) return Json.error("bad_args", "group promote <gid> <npub>")
|
||||
val newAdmin = Npubs.resolveToHex(rest[1])
|
||||
return edit(dataDir, rest[0]) { cur ->
|
||||
val admins = cur.adminPubkeys.toMutableList()
|
||||
if (newAdmin !in admins) admins.add(newAdmin)
|
||||
cur.copy(adminPubkeys = admins)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun demote(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.size < 2) return Json.error("bad_args", "group demote <gid> <npub>")
|
||||
val target = Npubs.resolveToHex(rest[1])
|
||||
return edit(dataDir, rest[0]) { cur ->
|
||||
val admins = cur.adminPubkeys.filter { it != target }
|
||||
cur.copy(adminPubkeys = admins)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun edit(
|
||||
dataDir: DataDir,
|
||||
gid: HexKey,
|
||||
mutate: (MarmotGroupData) -> MarmotGroupData,
|
||||
): Int {
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
ctx.syncIncoming()
|
||||
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
|
||||
val cur =
|
||||
ctx.marmot.groupMetadata(gid)
|
||||
?: MarmotGroupData(
|
||||
nostrGroupId = gid,
|
||||
name = "",
|
||||
description = "",
|
||||
adminPubkeys = listOf(ctx.identity.pubKeyHex),
|
||||
relays = ctx.outboxRelays().map { it.url },
|
||||
)
|
||||
val updated = mutate(cur)
|
||||
|
||||
val commit = ctx.marmot.updateGroupMetadata(gid, updated)
|
||||
val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
|
||||
val ack = ctx.publish(commit.signedEvent, targets)
|
||||
|
||||
Json.writeLine(
|
||||
mapOf(
|
||||
"group_id" to gid,
|
||||
"name" to updated.name,
|
||||
"admins" to updated.adminPubkeys,
|
||||
"epoch" to ctx.marmot.groupEpoch(gid),
|
||||
"commit_event_id" to commit.signedEvent.id,
|
||||
"published_to" to ack.filterValues { it }.keys.map { it.url },
|
||||
),
|
||||
)
|
||||
return 0
|
||||
} finally {
|
||||
ctx.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* 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.Context
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Json
|
||||
|
||||
/**
|
||||
* Read-only queries. None of these publish; they all sync-then-report.
|
||||
*/
|
||||
object GroupReadCommands {
|
||||
suspend fun list(dataDir: DataDir): Int {
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
ctx.syncIncoming()
|
||||
val ids = ctx.marmot.activeGroupIds()
|
||||
val items =
|
||||
ids.map { id ->
|
||||
val m = ctx.marmot.groupMetadata(id)
|
||||
mapOf(
|
||||
"group_id" to id,
|
||||
"name" to (m?.name ?: ""),
|
||||
"members" to ctx.marmot.memberCount(id),
|
||||
"epoch" to ctx.marmot.groupEpoch(id),
|
||||
)
|
||||
}
|
||||
Json.writeLine(mapOf("groups" to items))
|
||||
return 0
|
||||
} finally {
|
||||
ctx.close()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun show(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.isEmpty()) return Json.error("bad_args", "group show <group_id>")
|
||||
val gid = rest[0]
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
ctx.syncIncoming()
|
||||
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
|
||||
val meta = ctx.marmot.groupMetadata(gid)
|
||||
val members =
|
||||
ctx.marmot.memberPubkeys(gid).map {
|
||||
mapOf("pubkey" to it.pubkey, "leaf_index" to it.leafIndex)
|
||||
}
|
||||
Json.writeLine(
|
||||
mapOf(
|
||||
"group_id" to gid,
|
||||
"name" to (meta?.name ?: ""),
|
||||
"description" to (meta?.description ?: ""),
|
||||
"epoch" to ctx.marmot.groupEpoch(gid),
|
||||
"admins" to (meta?.adminPubkeys ?: emptyList()),
|
||||
"relays" to (meta?.relays ?: emptyList()),
|
||||
"members" to members,
|
||||
"is_admin" to (meta?.adminPubkeys?.contains(ctx.identity.pubKeyHex) == true),
|
||||
),
|
||||
)
|
||||
return 0
|
||||
} finally {
|
||||
ctx.close()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun members(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.isEmpty()) return Json.error("bad_args", "group members <group_id>")
|
||||
val gid = rest[0]
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
ctx.syncIncoming()
|
||||
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
|
||||
val members =
|
||||
ctx.marmot.memberPubkeys(gid).map {
|
||||
mapOf("pubkey" to it.pubkey, "leaf_index" to it.leafIndex)
|
||||
}
|
||||
Json.writeLine(mapOf("group_id" to gid, "members" to members))
|
||||
return 0
|
||||
} finally {
|
||||
ctx.close()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun admins(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.isEmpty()) return Json.error("bad_args", "group admins <group_id>")
|
||||
val gid = rest[0]
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
ctx.syncIncoming()
|
||||
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
|
||||
val m = ctx.marmot.groupMetadata(gid)
|
||||
Json.writeLine(mapOf("group_id" to gid, "admins" to (m?.adminPubkeys ?: emptyList())))
|
||||
return 0
|
||||
} finally {
|
||||
ctx.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* 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.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Identity
|
||||
import com.vitorpamplona.amethyst.cli.Json
|
||||
|
||||
object InitCommands {
|
||||
suspend fun init(
|
||||
dataDir: DataDir,
|
||||
args: Args,
|
||||
): Int {
|
||||
val existing = dataDir.loadIdentityOrNull()
|
||||
val id =
|
||||
existing ?: run {
|
||||
val nsec = args.flag("nsec")
|
||||
val created = if (nsec != null) Identity.fromNsec(nsec) else Identity.create()
|
||||
dataDir.saveIdentity(created)
|
||||
created
|
||||
}
|
||||
Json.writeLine(
|
||||
mapOf(
|
||||
"npub" to id.npub,
|
||||
"hex" to id.pubKeyHex,
|
||||
"nsec" to id.nsec,
|
||||
"existing" to (existing != null),
|
||||
"data_dir" to dataDir.root.absolutePath,
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
suspend fun whoami(dataDir: DataDir): Int {
|
||||
val id = dataDir.loadIdentityOrNull()
|
||||
if (id == null) {
|
||||
return Json.error("no_identity", "No identity at ${dataDir.identityFile}. Run `init` first.")
|
||||
}
|
||||
Json.writeLine(
|
||||
mapOf(
|
||||
"npub" to id.npub,
|
||||
"hex" to id.pubKeyHex,
|
||||
"data_dir" to dataDir.root.absolutePath,
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* 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.Context
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Json
|
||||
import com.vitorpamplona.amethyst.cli.util.Npubs
|
||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
|
||||
|
||||
object KeyPackageCommands {
|
||||
suspend fun dispatch(
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int {
|
||||
if (tail.isEmpty()) return Json.error("bad_args", "key-package <publish|check> …")
|
||||
return when (tail[0]) {
|
||||
"publish" -> publish(dataDir)
|
||||
"check" -> check(dataDir, tail.drop(1).toTypedArray())
|
||||
else -> Json.error("bad_args", "key-package ${tail[0]}")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun publish(dataDir: DataDir): Int {
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
val relays = ctx.keyPackageRelays().ifEmpty { ctx.outboxRelays() }.ifEmpty { ctx.anyRelays() }
|
||||
if (relays.isEmpty()) return Json.error("no_relays", "configure relays first")
|
||||
|
||||
val event = ctx.marmot.generateKeyPackageEvent(relays.toList())
|
||||
val ack = ctx.publish(event, relays)
|
||||
Json.writeLine(
|
||||
mapOf(
|
||||
"event_id" to event.id,
|
||||
"kind" to event.kind,
|
||||
"accepted_by" to ack.filterValues { it }.keys.map { it.url },
|
||||
"rejected_by" to ack.filterValues { !it }.keys.map { it.url },
|
||||
),
|
||||
)
|
||||
return 0
|
||||
} finally {
|
||||
ctx.close()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun check(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.isEmpty()) return Json.error("bad_args", "key-package check <npub>")
|
||||
val targetHex = Npubs.resolveToHex(rest[0])
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
val filter = ctx.marmot.subscriptionManager.keyPackageFilter(targetHex)
|
||||
val relays = ctx.anyRelays()
|
||||
if (relays.isEmpty()) return Json.error("no_relays", "configure relays first")
|
||||
val filtersByRelay = relays.associateWith { listOf(filter) }
|
||||
val event = ctx.client.fetchFirst(filters = filtersByRelay, timeoutMs = 10_000)
|
||||
if (event == null || event !is KeyPackageEvent) {
|
||||
return Json.error("not_found", "no KeyPackage for $targetHex on ${relays.size} relay(s)")
|
||||
}
|
||||
Json.writeLine(
|
||||
mapOf(
|
||||
"event_id" to event.id,
|
||||
"author" to event.pubKey,
|
||||
"kind" to event.kind,
|
||||
"created_at" to event.createdAt,
|
||||
"has_content" to event.content.isNotBlank(),
|
||||
),
|
||||
)
|
||||
return 0
|
||||
} finally {
|
||||
ctx.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* 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.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.vitorpamplona.amethyst.cli.Args
|
||||
import com.vitorpamplona.amethyst.cli.Context
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Json
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
|
||||
object MessageCommands {
|
||||
suspend fun dispatch(
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int {
|
||||
if (tail.isEmpty()) return Json.error("bad_args", "message <send|list> …")
|
||||
val rest = tail.drop(1).toTypedArray()
|
||||
return when (tail[0]) {
|
||||
"send" -> send(dataDir, rest)
|
||||
"list" -> list(dataDir, rest)
|
||||
else -> Json.error("bad_args", "message ${tail[0]}")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun send(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.size < 2) return Json.error("bad_args", "message send <gid> <text>")
|
||||
val gid = rest[0]
|
||||
val text = rest[1]
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
ctx.syncIncoming()
|
||||
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
|
||||
|
||||
val template =
|
||||
eventTemplate<com.vitorpamplona.quartz.nip01Core.core.Event>(kind = 9, description = text)
|
||||
val innerEvent = ctx.signer.sign<com.vitorpamplona.quartz.nip01Core.core.Event>(template)
|
||||
val outbound = ctx.marmot.buildGroupMessage(gid, innerEvent)
|
||||
|
||||
// Persist our own outbound inner event so `message list` includes it
|
||||
// alongside remote messages.
|
||||
ctx.marmot.persistDecryptedMessage(gid, innerEvent.toJson())
|
||||
|
||||
val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
|
||||
val ack = ctx.publish(outbound.signedEvent, targets)
|
||||
|
||||
Json.writeLine(
|
||||
mapOf(
|
||||
"group_id" to gid,
|
||||
"inner_event_id" to innerEvent.id,
|
||||
"outer_event_id" to outbound.signedEvent.id,
|
||||
"kind" to innerEvent.kind,
|
||||
"published_to" to ack.filterValues { it }.keys.map { it.url },
|
||||
),
|
||||
)
|
||||
return 0
|
||||
} finally {
|
||||
ctx.close()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun list(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.isEmpty()) return Json.error("bad_args", "message list <gid>")
|
||||
val gid = rest[0]
|
||||
val args = Args(rest.drop(1).toTypedArray())
|
||||
val limit = args.intFlag("limit", Int.MAX_VALUE)
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
ctx.syncIncoming()
|
||||
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
|
||||
|
||||
val raw = ctx.marmot.loadStoredMessages(gid)
|
||||
val items =
|
||||
raw
|
||||
.map { line ->
|
||||
try {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val obj = Json.mapper.readValue<Map<String, Any?>>(line)
|
||||
mapOf(
|
||||
"id" to obj["id"],
|
||||
"author" to obj["pubkey"],
|
||||
"kind" to obj["kind"],
|
||||
"content" to obj["content"],
|
||||
"created_at" to obj["created_at"],
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
mapOf("raw" to line)
|
||||
}
|
||||
}.takeLast(limit)
|
||||
|
||||
Json.writeLine(mapOf("group_id" to gid, "messages" to items))
|
||||
return 0
|
||||
} finally {
|
||||
ctx.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* 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.Json
|
||||
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo
|
||||
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType
|
||||
|
||||
object RelayCommands {
|
||||
suspend fun dispatch(
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int {
|
||||
if (tail.isEmpty()) return Json.error("bad_args", "relay <add|list|publish-lists> …")
|
||||
val sub = tail[0]
|
||||
val rest = tail.drop(1).toTypedArray()
|
||||
return when (sub) {
|
||||
"add" -> add(dataDir, Args(rest))
|
||||
"list" -> list(dataDir)
|
||||
"publish-lists" -> publishLists(dataDir)
|
||||
else -> Json.error("bad_args", "relay $sub")
|
||||
}
|
||||
}
|
||||
|
||||
private fun add(
|
||||
dataDir: DataDir,
|
||||
args: Args,
|
||||
): Int {
|
||||
val url = args.positional(0, "url")
|
||||
val type = args.flag("type", "all") ?: "all"
|
||||
val cfg = dataDir.loadRelays()
|
||||
val addedTo = mutableListOf<String>()
|
||||
val targets = if (type == "all") listOf("nip65", "inbox", "key_package") else listOf(type)
|
||||
for (t in targets) {
|
||||
if (cfg.add(t, url)) addedTo.add(t)
|
||||
}
|
||||
dataDir.saveRelays(cfg)
|
||||
Json.writeLine(
|
||||
mapOf(
|
||||
"url" to url,
|
||||
"added_to" to addedTo,
|
||||
"already_present" to (targets - addedTo.toSet()),
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
private fun list(dataDir: DataDir): Int {
|
||||
val cfg = dataDir.loadRelays()
|
||||
Json.writeLine(
|
||||
mapOf(
|
||||
"nip65" to cfg.nip65,
|
||||
"inbox" to cfg.inbox,
|
||||
"key_package" to cfg.keyPackage,
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
private suspend fun publishLists(dataDir: DataDir): Int {
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
val nip65Relays = ctx.relays.normalized("nip65").toList()
|
||||
val inboxRelays = ctx.relays.normalized("inbox").toList()
|
||||
|
||||
val nip65Infos = nip65Relays.map { AdvertisedRelayInfo(it, AdvertisedRelayType.BOTH) }
|
||||
val nip65Event = AdvertisedRelayListEvent.create(nip65Infos, ctx.signer)
|
||||
val inboxEvent = ChatMessageRelayListEvent.create(inboxRelays, ctx.signer)
|
||||
|
||||
val targets = ctx.anyRelays()
|
||||
val nip65Result = ctx.publish(nip65Event, targets)
|
||||
val inboxResult = ctx.publish(inboxEvent, targets)
|
||||
|
||||
Json.writeLine(
|
||||
mapOf(
|
||||
"nip65_event_id" to nip65Event.id,
|
||||
"inbox_event_id" to inboxEvent.id,
|
||||
"accepted_by" to
|
||||
mapOf(
|
||||
"nip65" to nip65Result.filterValues { it }.keys.map { it.url },
|
||||
"inbox" to inboxResult.filterValues { it }.keys.map { it.url },
|
||||
),
|
||||
),
|
||||
)
|
||||
return 0
|
||||
} finally {
|
||||
ctx.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* 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.stores
|
||||
|
||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore
|
||||
import com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore
|
||||
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Test-harness file stores. **Unencrypted** — the production interfaces
|
||||
* document that implementations MUST encrypt at rest, but this CLI is a
|
||||
* throwaway interop driver running against scratch keys and local scratch
|
||||
* state. Do not point it at real account material.
|
||||
*/
|
||||
|
||||
class FileMlsGroupStateStore(
|
||||
private val dir: File,
|
||||
) : MlsGroupStateStore {
|
||||
init {
|
||||
dir.mkdirs()
|
||||
}
|
||||
|
||||
private fun stateFile(id: String) = File(dir, "$id.state")
|
||||
|
||||
private fun retainedFile(id: String) = File(dir, "$id.retained")
|
||||
|
||||
override suspend fun save(
|
||||
nostrGroupId: String,
|
||||
state: ByteArray,
|
||||
) {
|
||||
stateFile(nostrGroupId).writeBytes(state)
|
||||
}
|
||||
|
||||
override suspend fun load(nostrGroupId: String): ByteArray? = stateFile(nostrGroupId).takeIf { it.exists() }?.readBytes()
|
||||
|
||||
override suspend fun delete(nostrGroupId: String) {
|
||||
stateFile(nostrGroupId).delete()
|
||||
retainedFile(nostrGroupId).delete()
|
||||
}
|
||||
|
||||
override suspend fun listGroups(): List<String> =
|
||||
dir
|
||||
.listFiles { f -> f.name.endsWith(".state") }
|
||||
?.map { it.name.removeSuffix(".state") }
|
||||
?: emptyList()
|
||||
|
||||
override suspend fun saveRetainedEpochs(
|
||||
nostrGroupId: String,
|
||||
retainedSecrets: List<ByteArray>,
|
||||
) {
|
||||
// Layout: [u32 count][(u32 len, bytes) …] — tiny framing so readers
|
||||
// can recover independent byte arrays without TLS plumbing.
|
||||
val f = retainedFile(nostrGroupId)
|
||||
f.outputStream().use { out ->
|
||||
val buf = java.nio.ByteBuffer.allocate(4)
|
||||
buf.putInt(retainedSecrets.size)
|
||||
out.write(buf.array())
|
||||
for (secret in retainedSecrets) {
|
||||
buf.clear()
|
||||
buf.putInt(secret.size)
|
||||
out.write(buf.array())
|
||||
out.write(secret)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun loadRetainedEpochs(nostrGroupId: String): List<ByteArray> {
|
||||
val f = retainedFile(nostrGroupId)
|
||||
if (!f.exists()) return emptyList()
|
||||
val bytes = f.readBytes()
|
||||
if (bytes.size < 4) return emptyList()
|
||||
val buf = java.nio.ByteBuffer.wrap(bytes)
|
||||
val count = buf.int
|
||||
val result = ArrayList<ByteArray>(count)
|
||||
repeat(count) {
|
||||
val len = buf.int
|
||||
val arr = ByteArray(len)
|
||||
buf.get(arr)
|
||||
result.add(arr)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
class FileKeyPackageBundleStore(
|
||||
private val file: File,
|
||||
) : KeyPackageBundleStore {
|
||||
override suspend fun save(snapshot: ByteArray) {
|
||||
file.parentFile?.mkdirs()
|
||||
file.writeBytes(snapshot)
|
||||
}
|
||||
|
||||
override suspend fun load(): ByteArray? = file.takeIf { it.exists() }?.readBytes()
|
||||
|
||||
override suspend fun delete() {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
class FileMarmotMessageStore(
|
||||
private val dir: File,
|
||||
) : MarmotMessageStore {
|
||||
init {
|
||||
dir.mkdirs()
|
||||
}
|
||||
|
||||
private fun file(id: String) = File(dir, "$id.messages")
|
||||
|
||||
override suspend fun appendMessage(
|
||||
nostrGroupId: String,
|
||||
innerEventJson: String,
|
||||
) {
|
||||
// Each line is one inner event JSON. The store doc tolerates duplicates
|
||||
// so we don't bother deduping here — readers can do it.
|
||||
file(nostrGroupId).appendText(innerEventJson.replace("\n", " ") + "\n")
|
||||
}
|
||||
|
||||
override suspend fun loadMessages(nostrGroupId: String): List<String> = file(nostrGroupId).takeIf { it.exists() }?.readLines()?.filter { it.isNotBlank() } ?: emptyList()
|
||||
|
||||
override suspend fun delete(nostrGroupId: String) {
|
||||
file(nostrGroupId).delete()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* 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.util
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes
|
||||
|
||||
/** Accept either an `npub1…` or a raw 64-hex pubkey; always return hex. */
|
||||
object Npubs {
|
||||
fun resolveToHex(input: String): String {
|
||||
val trimmed = input.trim()
|
||||
// Already hex?
|
||||
if (trimmed.length == 64 && trimmed.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' }) {
|
||||
return trimmed.lowercase()
|
||||
}
|
||||
if (trimmed.startsWith("npub1") || trimmed.startsWith("npub")) {
|
||||
return trimmed.bechToBytes().toHexKey()
|
||||
}
|
||||
throw IllegalArgumentException("expected npub or 64-hex pubkey, got: $input")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user