diff --git a/cli/DEVELOPMENT.md b/cli/DEVELOPMENT.md index 6b855761a..63f04477b 100644 --- a/cli/DEVELOPMENT.md +++ b/cli/DEVELOPMENT.md @@ -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/`. | diff --git a/cli/README.md b/cli/README.md index abc214147..22879c325 100644 --- a/cli/README.md +++ b/cli/README.md @@ -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. ``` / -├── 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/ - ├── .mls # MLS group state per group - └── .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/ + ├── .mls # MLS group state per group + └── .log # decrypted inner events (one JSON per line) ``` All files are plain JSON or framed binary — human-inspectable, easy to diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt index 989338e54..85a077591 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt @@ -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) } } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index b6fbb7646..5eafa600a 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -131,6 +131,14 @@ private suspend fun dispatch(argv: Array): 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) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt index 135cb186d..af1779377 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt @@ -80,4 +80,14 @@ object Commands { dataDir: DataDir, tail: Array, ): Int = DmCommands.dispatch(dataDir, tail) + + suspend fun profile( + dataDir: DataDir, + tail: Array, + ): Int = ProfileCommands.dispatch(dataDir, tail) + + suspend fun notes( + dataDir: DataDir, + tail: Array, + ): Int = NotesCommands.dispatch(dataDir, tail) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FeedCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FeedCommand.kt new file mode 100644 index 000000000..06ab61b3c --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FeedCommand.kt @@ -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, + ): 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(), + "notes" to emptyList(), + ), + ) + 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 = + when (mode) { + "author" -> ctx.bootstrapRelays() + else -> ctx.outboxRelays().ifEmpty { ctx.bootstrapRelays() } + } + + private suspend fun resolveFollowing( + ctx: Context, + timeoutMs: Long, + ): List { + 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() + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NotesCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NotesCommands.kt new file mode 100644 index 000000000..98fd115b1 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NotesCommands.kt @@ -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 ` — 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, + ): Int { + if (tail.isEmpty()) return Json.error("bad_args", "notes …") + 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]}") + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PostCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PostCommand.kt new file mode 100644 index 000000000..a3a3be77c --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PostCommand.kt @@ -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 [--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, + ): Int { + if (rest.isEmpty()) return Json.error("bad_args", "post [--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() + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ProfileCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ProfileCommands.kt new file mode 100644 index 000000000..742696c9b --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ProfileCommands.kt @@ -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 ` — 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, + ): Int { + if (tail.isEmpty()) return Json.error("bad_args", "profile …") + 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, + ): 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()), + "queried_relays" to relays.map { it.url }, + ), + ) + return 0 + } finally { + ctx.close() + } + } + + private suspend fun edit( + dataDir: DataDir, + rest: Array, + ): 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 = + if (isSelf) { + ctx.outboxRelays().ifEmpty { ctx.bootstrapRelays() } + } else { + ctx.bootstrapRelays() + } + + private suspend fun fetchLatestMetadata( + ctx: Context, + pubKey: HexKey, + relays: Set, + 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 } + } +}