Merge pull request #2552 from vitorpamplona/claude/cli-profile-feed-commands-03lOu

Add profile and notes commands to CLI
This commit is contained in:
Vitor Pamplona
2026-04-24 15:16:52 -04:00
committed by GitHub
9 changed files with 593 additions and 14 deletions
+1 -1
View File
@@ -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
View File
@@ -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 }
}
}