Merge pull request #2552 from vitorpamplona/claude/cli-profile-feed-commands-03lOu
Add profile and notes commands to CLI
This commit is contained in:
+1
-1
@@ -196,7 +196,7 @@ Amy-specific layer still needs its own coverage:
|
||||
| Argument parsing (`Args`, flag forms, `--data-dir=…` vs `--data-dir …`) | Plain JVM unit tests in `cli/src/test/kotlin/`. |
|
||||
| Error / exit-code contract (bad args → 2, await timeout → 124, runtime → 1) | Table-driven tests invoking `main(argv)` with captured stdout/stderr. |
|
||||
| JSON output shape (each command's keys and types) | Snapshot tests: run a command against a throwaway data-dir, assert the JSON matches a golden file. |
|
||||
| File layout on disk (`identity.json`, `relays.json`, `groups/*.mls`, `keypackages.bundle`) | Structural assertions after a command sequence. |
|
||||
| File layout on disk (`identity.json`, `relays.json`, `marmot/groups/*.mls`, `marmot/keypackages.bundle`) | Structural assertions after a command sequence. |
|
||||
| Round-trip between two data-dirs on a local relay | End-to-end shell harnesses under `cli/tests/`. Each harness spins up a local `nostr-rs-relay`, bootstraps two or more fresh identities in their own `--data-dir`s, and drives a scenario via `amy` (+ `wn` for Marmot interop against whitenoise-rs). Today there are two suites: `cli/tests/marmot/` (13 MLS scenarios vs whitenoise-rs) and `cli/tests/dm/` (NIP-17 DM round-trips between two `amy` clients). |
|
||||
| Interop with other clients | Covered by `cli/tests/marmot/marmot-interop-headless.sh` (drives Amy against whitenoise-rs `wn`/`wnd`). Add new scenarios there or start a new sibling under `cli/tests/`. |
|
||||
|
||||
|
||||
+10
-9
@@ -40,7 +40,7 @@ What every caller — user, script, agent, CI — can rely on:
|
||||
- **Data-dir is the whole world.** All state (identity, relays, MLS
|
||||
epochs, message archives, run cursors) lives under `--data-dir PATH`.
|
||||
Delete to reset; copy to move; `AMETHYST_CLI_DATA` env var overrides
|
||||
the default `./amethyst-cli-data`.
|
||||
the default `./amy`.
|
||||
|
||||
The rationale behind each of these lives in
|
||||
[DEVELOPMENT.md](./DEVELOPMENT.md). Breaking any of them is a breaking
|
||||
@@ -152,7 +152,7 @@ itself crashed".
|
||||
|
||||
### Global flags
|
||||
|
||||
- `--data-dir PATH` — defaults to `./amethyst-cli-data` or
|
||||
- `--data-dir PATH` — defaults to `./amy` or
|
||||
`$AMETHYST_CLI_DATA`. Always an absolute path after resolution.
|
||||
- `--help` / `-h` — usage summary.
|
||||
|
||||
@@ -189,13 +189,14 @@ events.
|
||||
|
||||
```
|
||||
<data-dir>/
|
||||
├── identity.json # nsec/npub/hex — the account
|
||||
├── relays.json # nip65 / inbox / key_package buckets
|
||||
├── state.json # sync cursors (giftWrapSince, groupSince)
|
||||
├── keypackages.bundle # MLS KeyPackage bundles (NostrSignerInternal)
|
||||
└── groups/
|
||||
├── <gid>.mls # MLS group state per group
|
||||
└── <gid>.log # decrypted inner events (one JSON per line)
|
||||
├── identity.json # nsec/npub/hex — the account
|
||||
├── relays.json # nip65 / inbox / key_package buckets
|
||||
├── state.json # sync cursors (giftWrapSince, groupSince)
|
||||
└── marmot/
|
||||
├── keypackages.bundle # MLS KeyPackage bundles (NostrSignerInternal)
|
||||
└── groups/
|
||||
├── <gid>.mls # MLS group state per group
|
||||
└── <gid>.log # decrypted inner events (one JSON per line)
|
||||
```
|
||||
|
||||
All files are plain JSON or framed binary — human-inspectable, easy to
|
||||
|
||||
@@ -132,7 +132,7 @@ data class RunState(
|
||||
|
||||
/**
|
||||
* Root of the on-disk layout. Any absolute path chosen by `--data-dir` (or
|
||||
* `$AMETHYST_CLI_DATA`) — defaults to `./amethyst-cli-data`.
|
||||
* `$AMETHYST_CLI_DATA`) — defaults to `./amy`.
|
||||
*/
|
||||
class DataDir(
|
||||
val root: File,
|
||||
@@ -140,8 +140,9 @@ class DataDir(
|
||||
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")
|
||||
val marmotDir = File(root, "marmot")
|
||||
val groupsDir = File(marmotDir, "groups")
|
||||
val keyPackageBundleFile = File(marmotDir, "keypackages.bundle")
|
||||
|
||||
init {
|
||||
root.mkdirs()
|
||||
@@ -169,7 +170,7 @@ class DataDir(
|
||||
companion object {
|
||||
fun resolve(flag: String?): DataDir {
|
||||
val envPath = System.getenv("AMETHYST_CLI_DATA")
|
||||
val path = flag ?: envPath ?: "./amethyst-cli-data"
|
||||
val path = flag ?: envPath ?: "./amy"
|
||||
return DataDir(File(path).absoluteFile)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +131,14 @@ private suspend fun dispatch(argv: Array<String>): Int {
|
||||
Commands.dm(dataDir, tail)
|
||||
}
|
||||
|
||||
"profile" -> {
|
||||
Commands.profile(dataDir, tail)
|
||||
}
|
||||
|
||||
"notes" -> {
|
||||
Commands.notes(dataDir, tail)
|
||||
}
|
||||
|
||||
else -> {
|
||||
System.err.println("unknown subcommand: $head")
|
||||
printUsage()
|
||||
@@ -197,6 +205,28 @@ private fun printUsage() {
|
||||
| relay list print configured relays
|
||||
| relay publish-lists publish kind:10002 + kind:10050
|
||||
|
|
||||
|Profile (NIP-01 kind:0):
|
||||
| profile show [USER] [--timeout SECS] fetch latest kind:0 metadata
|
||||
| (USER: npub|nprofile|hex|name@domain)
|
||||
| profile edit [--name NAME] patch kind:0; unset flags keep prior values,
|
||||
| [--display-name N] blank values delete the field
|
||||
| [--about TEXT]
|
||||
| [--picture URL] [--banner URL]
|
||||
| [--website URL] [--nip05 ID]
|
||||
| [--lud16 X] [--lud06 X]
|
||||
| [--pronouns P]
|
||||
| [--twitter H] [--mastodon H] [--github H]
|
||||
| [--timeout SECS]
|
||||
|
|
||||
|Notes (NIP-10 kind:1):
|
||||
| notes post TEXT [--relay URL] publish a kind:1 short text note
|
||||
| (--relay accepts comma-separated extras)
|
||||
| notes feed [--author USER] fetch kind:1 notes
|
||||
| [--following] (default: own; --author: one user;
|
||||
| [--limit N] --following: every contact-list pubkey)
|
||||
| [--since TS] [--until TS]
|
||||
| [--timeout SECS]
|
||||
|
|
||||
|Direct messages (NIP-17):
|
||||
| dm send RECIPIENT TEXT send a gift-wrapped DM
|
||||
| [--allow-fallback] (default: only deliver to recipient's kind:10050)
|
||||
|
||||
@@ -80,4 +80,14 @@ object Commands {
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int = DmCommands.dispatch(dataDir, tail)
|
||||
|
||||
suspend fun profile(
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int = ProfileCommands.dispatch(dataDir, tail)
|
||||
|
||||
suspend fun notes(
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int = NotesCommands.dispatch(dataDir, tail)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
/*
|
||||
* 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.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
|
||||
/**
|
||||
* `amy feed [--author USER] [--following] [--limit N] [--since TS] [--until TS]`
|
||||
*
|
||||
* Read-side counterpart to `amy post`. Three modes selected by flags:
|
||||
*
|
||||
* - default: kind:1 by the current account.
|
||||
* - `--author USER`: kind:1 by the given user (npub/nprofile/64-hex/NIP-05).
|
||||
* - `--following`: fetch the current account's NIP-02 contact list and pull
|
||||
* kind:1 from every author in it.
|
||||
*
|
||||
* Events are deduplicated by id, sorted newest-first, and capped at `--limit`
|
||||
* (default 50). The output is a JSON object with a `notes` array of
|
||||
* `{id, pubkey, created_at, content, tags}`.
|
||||
*/
|
||||
object FeedCommand {
|
||||
suspend fun run(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val author = args.flag("author")
|
||||
val following = args.bool("following")
|
||||
if (author != null && following) {
|
||||
return Json.error("bad_args", "feed: pass either --author or --following, not both")
|
||||
}
|
||||
val limit = args.intFlag("limit", 50)
|
||||
if (limit <= 0) return Json.error("bad_args", "feed: --limit must be > 0")
|
||||
val since = args.flag("since")?.toLongOrNull()
|
||||
val until = args.flag("until")?.toLongOrNull()
|
||||
val timeoutSecs = args.longFlag("timeout", 8L)
|
||||
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
|
||||
val (authors, mode) =
|
||||
when {
|
||||
following -> resolveFollowing(ctx, timeoutSecs * 1000) to "following"
|
||||
author != null -> listOf(ctx.requireUserHex(author)) to "author"
|
||||
else -> listOf(ctx.identity.pubKeyHex) to "self"
|
||||
}
|
||||
|
||||
if (authors.isEmpty()) {
|
||||
Json.writeLine(
|
||||
mapOf(
|
||||
"mode" to mode,
|
||||
"authors" to emptyList<String>(),
|
||||
"notes" to emptyList<Any>(),
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
val relays = relaysForReadingFeed(ctx, mode)
|
||||
if (relays.isEmpty()) {
|
||||
return Json.error("no_relays", "no relays available; run `amy relay add` first")
|
||||
}
|
||||
|
||||
val filter =
|
||||
Filter(
|
||||
kinds = listOf(TextNoteEvent.KIND),
|
||||
authors = authors,
|
||||
since = since,
|
||||
until = until,
|
||||
// Ask for some headroom over the requested limit because
|
||||
// each relay applies the cap independently and we then
|
||||
// merge + dedup across the union.
|
||||
limit = (limit * 2).coerceAtMost(500),
|
||||
)
|
||||
val filterMap = relays.associateWith { listOf(filter) }
|
||||
val received = ctx.drain(filterMap, timeoutSecs * 1000)
|
||||
|
||||
val notes =
|
||||
received
|
||||
.asSequence()
|
||||
.map { it.second }
|
||||
.filter { it.kind == TextNoteEvent.KIND }
|
||||
.filter { it.pubKey in authors.toSet() }
|
||||
.distinctBy { it.id }
|
||||
.sortedByDescending { it.createdAt }
|
||||
.take(limit)
|
||||
.map { ev ->
|
||||
mapOf(
|
||||
"id" to ev.id,
|
||||
"pubkey" to ev.pubKey,
|
||||
"created_at" to ev.createdAt,
|
||||
"content" to ev.content,
|
||||
"tags" to ev.tags.map { it.toList() },
|
||||
)
|
||||
}.toList()
|
||||
|
||||
Json.writeLine(
|
||||
mapOf(
|
||||
"mode" to mode,
|
||||
"authors" to authors,
|
||||
"queried_relays" to relays.map { it.url },
|
||||
"count" to notes.size,
|
||||
"notes" to notes,
|
||||
),
|
||||
)
|
||||
return 0
|
||||
} finally {
|
||||
ctx.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-side relay strategy: own / following feeds use our outbox set
|
||||
* (where we expect our follows to also publish, in NIP-65 spirit). For
|
||||
* an arbitrary `--author` we have no idea where they publish, so we
|
||||
* widen to the bootstrap union.
|
||||
*/
|
||||
private fun relaysForReadingFeed(
|
||||
ctx: Context,
|
||||
mode: String,
|
||||
): Set<NormalizedRelayUrl> =
|
||||
when (mode) {
|
||||
"author" -> ctx.bootstrapRelays()
|
||||
else -> ctx.outboxRelays().ifEmpty { ctx.bootstrapRelays() }
|
||||
}
|
||||
|
||||
private suspend fun resolveFollowing(
|
||||
ctx: Context,
|
||||
timeoutMs: Long,
|
||||
): List<HexKey> {
|
||||
val relays = ctx.outboxRelays().ifEmpty { ctx.bootstrapRelays() }
|
||||
if (relays.isEmpty()) return emptyList()
|
||||
val filter =
|
||||
Filter(
|
||||
kinds = listOf(ContactListEvent.KIND),
|
||||
authors = listOf(ctx.identity.pubKeyHex),
|
||||
limit = 1,
|
||||
)
|
||||
val filterMap = relays.associateWith { listOf(filter) }
|
||||
val received = ctx.drain(filterMap, timeoutMs)
|
||||
val latest =
|
||||
received
|
||||
.mapNotNull { (_, ev) -> ev as? ContactListEvent }
|
||||
.filter { it.pubKey == ctx.identity.pubKeyHex }
|
||||
.maxByOrNull { it.createdAt }
|
||||
return latest?.verifiedFollowKeySet()?.toList() ?: emptyList()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
/**
|
||||
* `amy notes <post|feed>` — NIP-10 kind:1 short text notes. Sits alongside
|
||||
* `profile` (kind:0) and `dm` (NIP-17) as a top-level verb group so the CLI
|
||||
* shape mirrors the Amethyst UI: one subcommand group per event family.
|
||||
*/
|
||||
object NotesCommands {
|
||||
suspend fun dispatch(
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int {
|
||||
if (tail.isEmpty()) return Json.error("bad_args", "notes <post|feed> …")
|
||||
val rest = tail.drop(1).toTypedArray()
|
||||
return when (tail[0]) {
|
||||
"post" -> PostCommand.run(dataDir, rest)
|
||||
"feed" -> FeedCommand.run(dataDir, rest)
|
||||
else -> Json.error("bad_args", "notes ${tail[0]}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* 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.nip10Notes.TextNoteEvent
|
||||
|
||||
/**
|
||||
* `amy post <text> [--relay URL …]` — publish a NIP-10 kind:1 short text note
|
||||
* to the user's outbox relays.
|
||||
*
|
||||
* Threading is intentionally out of scope here — `amy post` only handles new
|
||||
* top-level notes. Replies/quotes need richer event-hint plumbing and will get
|
||||
* their own verb when needed.
|
||||
*/
|
||||
object PostCommand {
|
||||
suspend fun run(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.isEmpty()) return Json.error("bad_args", "post <text> [--relay URL …]")
|
||||
val text = rest[0]
|
||||
if (text.isBlank()) return Json.error("bad_args", "post text must not be blank")
|
||||
|
||||
val args = Args(rest.drop(1).toTypedArray())
|
||||
val extraRelays =
|
||||
args.flags["relay"]
|
||||
?.split(',')
|
||||
?.map { it.trim() }
|
||||
?.filter { it.isNotEmpty() } ?: emptyList()
|
||||
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
val outbox = ctx.outboxRelays()
|
||||
val extraNormalized =
|
||||
extraRelays.mapNotNull {
|
||||
com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
.normalizeOrNull(it)
|
||||
}
|
||||
val targets = (outbox + extraNormalized).toSet()
|
||||
if (targets.isEmpty()) {
|
||||
return Json.error("no_relays", "no outbox relays configured; pass --relay or run `amy relay add`")
|
||||
}
|
||||
|
||||
val signed = ctx.signer.sign(TextNoteEvent.build(text))
|
||||
val ack = ctx.publish(signed, targets)
|
||||
|
||||
Json.writeLine(
|
||||
mapOf(
|
||||
"event_id" to signed.id,
|
||||
"kind" to signed.kind,
|
||||
"created_at" to signed.createdAt,
|
||||
"content" to signed.content,
|
||||
"published_to" to ack.filterValues { it }.keys.map { it.url },
|
||||
"rejected_by" to ack.filterValues { !it }.keys.map { it.url },
|
||||
),
|
||||
)
|
||||
return 0
|
||||
} finally {
|
||||
ctx.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
/*
|
||||
* 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.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
|
||||
/**
|
||||
* `amy profile <show|edit>` — read and update the current user's
|
||||
* NIP-01 kind:0 metadata event.
|
||||
*
|
||||
* `show` accepts an optional user identifier (npub/nprofile/64-hex/NIP-05); when
|
||||
* omitted, it reports the current account's profile. `edit` only mutates the
|
||||
* caller's own profile and patches whichever fields are supplied — unset flags
|
||||
* leave the existing values untouched, blank values delete the field (mirroring
|
||||
* [MetadataEvent.updateFromPast]).
|
||||
*/
|
||||
object ProfileCommands {
|
||||
suspend fun dispatch(
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int {
|
||||
if (tail.isEmpty()) return Json.error("bad_args", "profile <show|edit> …")
|
||||
val rest = tail.drop(1).toTypedArray()
|
||||
return when (tail[0]) {
|
||||
"show" -> show(dataDir, rest)
|
||||
"edit" -> edit(dataDir, rest)
|
||||
else -> Json.error("bad_args", "profile ${tail[0]}")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun show(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val timeoutSecs = args.longFlag("timeout", 8L)
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
val pubKey =
|
||||
args.positionalOrNull(0)?.let { ctx.requireUserHex(it) }
|
||||
?: ctx.identity.pubKeyHex
|
||||
val isSelf = pubKey == ctx.identity.pubKeyHex
|
||||
val relays = relaysForReadingProfile(ctx, isSelf)
|
||||
val event = fetchLatestMetadata(ctx, pubKey, relays, timeoutSecs * 1000)
|
||||
if (event == null) {
|
||||
Json.writeLine(
|
||||
mapOf(
|
||||
"pubkey" to pubKey,
|
||||
"found" to false,
|
||||
"queried_relays" to relays.map { it.url },
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
val metadata =
|
||||
try {
|
||||
Json.mapper.readTree(event.content)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
Json.writeLine(
|
||||
mapOf(
|
||||
"pubkey" to pubKey,
|
||||
"found" to true,
|
||||
"event_id" to event.id,
|
||||
"created_at" to event.createdAt,
|
||||
"metadata" to (metadata ?: emptyMap<String, Any?>()),
|
||||
"queried_relays" to relays.map { it.url },
|
||||
),
|
||||
)
|
||||
return 0
|
||||
} finally {
|
||||
ctx.close()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun edit(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val name = args.flag("name")
|
||||
val displayName = args.flag("display-name")
|
||||
val about = args.flag("about")
|
||||
val picture = args.flag("picture")
|
||||
val banner = args.flag("banner")
|
||||
val website = args.flag("website")
|
||||
val nip05 = args.flag("nip05")
|
||||
val lud16 = args.flag("lud16")
|
||||
val lud06 = args.flag("lud06")
|
||||
val pronouns = args.flag("pronouns")
|
||||
val twitter = args.flag("twitter")
|
||||
val mastodon = args.flag("mastodon")
|
||||
val github = args.flag("github")
|
||||
val timeoutSecs = args.longFlag("timeout", 8L)
|
||||
|
||||
val touched =
|
||||
listOf(name, displayName, about, picture, banner, website, nip05, lud16, lud06, pronouns, twitter, mastodon, github)
|
||||
.any { it != null }
|
||||
if (!touched) {
|
||||
return Json.error(
|
||||
"bad_args",
|
||||
"profile edit needs at least one of " +
|
||||
"--name --display-name --about --picture --banner --website " +
|
||||
"--nip05 --lud16 --lud06 --pronouns --twitter --mastodon --github",
|
||||
)
|
||||
}
|
||||
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
val targets = ctx.outboxRelays().ifEmpty { ctx.bootstrapRelays() }
|
||||
val latest =
|
||||
fetchLatestMetadata(
|
||||
ctx,
|
||||
ctx.identity.pubKeyHex,
|
||||
targets,
|
||||
timeoutSecs * 1000,
|
||||
)
|
||||
|
||||
val template =
|
||||
if (latest != null) {
|
||||
MetadataEvent.updateFromPast(
|
||||
latest = latest,
|
||||
name = name,
|
||||
displayName = displayName,
|
||||
picture = picture,
|
||||
banner = banner,
|
||||
website = website,
|
||||
about = about,
|
||||
nip05 = nip05,
|
||||
lnAddress = lud16,
|
||||
lnURL = lud06,
|
||||
pronouns = pronouns,
|
||||
twitter = twitter,
|
||||
mastodon = mastodon,
|
||||
github = github,
|
||||
)
|
||||
} else {
|
||||
MetadataEvent.createNew(
|
||||
name = name,
|
||||
displayName = displayName,
|
||||
picture = picture,
|
||||
banner = banner,
|
||||
website = website,
|
||||
about = about,
|
||||
nip05 = nip05,
|
||||
lnAddress = lud16,
|
||||
lnURL = lud06,
|
||||
pronouns = pronouns,
|
||||
twitter = twitter,
|
||||
mastodon = mastodon,
|
||||
github = github,
|
||||
)
|
||||
}
|
||||
|
||||
val signed = ctx.signer.sign(template)
|
||||
val ack = ctx.publish(signed, targets)
|
||||
|
||||
Json.writeLine(
|
||||
mapOf(
|
||||
"event_id" to signed.id,
|
||||
"kind" to signed.kind,
|
||||
"created_at" to signed.createdAt,
|
||||
"based_on" to latest?.id,
|
||||
"metadata" to Json.mapper.readTree(signed.content),
|
||||
"published_to" to ack.filterValues { it }.keys.map { it.url },
|
||||
"rejected_by" to ack.filterValues { !it }.keys.map { it.url },
|
||||
),
|
||||
)
|
||||
return 0
|
||||
} finally {
|
||||
ctx.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* For our own profile, our outbox relays are authoritative; for someone
|
||||
* else's profile, fall back to the bootstrap union so we still find a
|
||||
* kind:0 even when our relay set and theirs are disjoint.
|
||||
*/
|
||||
private fun relaysForReadingProfile(
|
||||
ctx: Context,
|
||||
isSelf: Boolean,
|
||||
): Set<NormalizedRelayUrl> =
|
||||
if (isSelf) {
|
||||
ctx.outboxRelays().ifEmpty { ctx.bootstrapRelays() }
|
||||
} else {
|
||||
ctx.bootstrapRelays()
|
||||
}
|
||||
|
||||
private suspend fun fetchLatestMetadata(
|
||||
ctx: Context,
|
||||
pubKey: HexKey,
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
timeoutMs: Long,
|
||||
): MetadataEvent? {
|
||||
if (relays.isEmpty()) return null
|
||||
val filter = Filter(kinds = listOf(MetadataEvent.KIND), authors = listOf(pubKey), limit = 1)
|
||||
val filterMap = relays.associateWith { listOf(filter) }
|
||||
val received = ctx.drain(filterMap, timeoutMs)
|
||||
return received
|
||||
.mapNotNull { (_, ev) -> ev as? MetadataEvent }
|
||||
.filter { it.pubKey == pubKey }
|
||||
.maxByOrNull { it.createdAt }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user