feat(cli): drop relays.json — kind:10002/10050/10051 in the store ARE the config

The local relay configuration was redundant once the event store
became the source of truth: every account already publishes signed
kind:10002 (NIP-65), kind:10050 (DM inbox), and kind:10051
(KeyPackage relays) events, and Context now reads each set straight
out of the local store.

Removed:
  - data class RelayConfig (nip65/inbox/keyPackage buckets)
  - DataDir.relaysFile / loadRelays() / saveRelays()
  - Context.relays parameter — Context.open() no longer reads JSON
  - CreateCommand.defaultRelayConfig() helper
  - CreateCommand's saveRelays() call (redundant: ctx.publish persists
    the bootstrap events into the store anyway)

Context now serves the four relay accessors from the store with sane
fallbacks:
  outboxRelays()      = relaysOf(self)?.writeRelaysNorm() ?: DefaultNIP65RelaySet
  inboxRelays()       = dmInboxOf(self)?.relays() ?: DefaultDMRelayList.toSet()
  keyPackageRelays()  = keyPackageRelaysOf(self)?.relays() ?: outboxRelays()
  anyRelays()         = union of the three
  bootstrapRelays()   = anyRelays() ∪ DefaultNIP65RelaySet ∪ DefaultDMRelayList

RelayCommands rewritten:
  - `relay add URL --type T` — read existing relay-list event from the
    store, append URL, build + sign + ingest a new event of the
    matching kind. The replaceable slot mechanism replaces the old
    winner atomically.
  - `relay list` — dump URLs from the latest event in each bucket.
  - `relay publish-lists` — broadcast whichever events are present in
    the store; errors out cleanly when none exist (suggests `relay add`
    or `create`).

No migration: existing data dirs that still have `relays.json` will
ignore it. Run `amy relay add` or `amy create` to repopulate. Per
discussion this is a clean break, not a backwards-compat shim.

README + DEVELOPMENT updated to reflect the new on-disk layout (no
more `relays.json`; events-store/ is the relay configuration).
This commit is contained in:
Claude
2026-04-25 03:45:12 +00:00
parent b2bf874c15
commit 37a4f89178
6 changed files with 177 additions and 134 deletions
+2 -2
View File
@@ -43,7 +43,7 @@ cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/
├── Main.kt # argv → subcommand dispatch ├── Main.kt # argv → subcommand dispatch
├── Args.kt # tiny flag parser (no framework) ├── Args.kt # tiny flag parser (no framework)
├── Json.kt # single-line stdout + error printer ├── Json.kt # single-line stdout + error printer
├── Config.kt # Identity, RelayConfig, RunState, DataDir ├── Config.kt # Identity, RunState, DataDir
├── Context.kt # per-run wiring: signer + NostrClient + ├── Context.kt # per-run wiring: signer + NostrClient +
│ # MarmotManager + publish/drain/sync helpers │ # MarmotManager + publish/drain/sync helpers
├── stores/FileStores.kt # File-backed MLS / KP / message stores ├── stores/FileStores.kt # File-backed MLS / KP / message stores
@@ -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/`. | | 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. | | 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. | | 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`, `marmot/groups/*.mls`, `marmot/keypackages.bundle`) | Structural assertions after a command sequence. | | File layout on disk (`identity.json`, `events-store/…`, `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). | | 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/`. | | 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/`. |
+18 -5
View File
@@ -260,8 +260,13 @@ events.
``` ```
<data-dir>/ <data-dir>/
├── identity.json # nsec/npub/hex — the account ├── identity.json # nsec/npub/hex — the account
├── relays.json # nip65 / inbox / key_package buckets
├── state.json # sync cursors (giftWrapSince, groupSince) ├── state.json # sync cursors (giftWrapSince, groupSince)
├── events-store/ # FsEventStore — every observed Nostr event
│ ├── events/<aa>/<bb>/… # canonical kind:0 / 3 / 10002 / 10050 / 10051 / 1 / 5 / 1059 / …
│ ├── replaceable/<k>/… # one slot per (kind, pubkey) for kind:0/3/10000-19999
│ ├── addressable/… # one slot per (kind, pubkey, d-tag) for kind:30000-39999
│ ├── idx/ # hardlink indexes (kind / author / owner / tag / fts / expires_at)
│ └── tombstones/ # NIP-09 / NIP-62 enforcement
└── marmot/ └── marmot/
├── keypackages.bundle # MLS KeyPackage bundles (NostrSignerInternal) ├── keypackages.bundle # MLS KeyPackage bundles (NostrSignerInternal)
└── groups/ └── groups/
@@ -272,6 +277,13 @@ events.
All files are plain JSON or framed binary — human-inspectable, easy to All files are plain JSON or framed binary — human-inspectable, easy to
diff across two data-dirs in a test run. diff across two data-dirs in a test run.
The local relay configuration (kind:10002 / 10050 / 10051) is **not** a
separate file — it lives in `events-store/` as signed events.
`amy relay add` builds + signs + ingests a new relay-list event;
`amy relay list` reads URLs straight out of the latest event for each
kind; `amy relay publish-lists` broadcasts those events to upstream
relays. There is no `relays.json`.
--- ---
## Troubleshooting ## Troubleshooting
@@ -281,9 +293,10 @@ diff across two data-dirs in a test run.
- **`not_member`** — the group GID is unknown to this data-dir. Run - **`not_member`** — the group GID is unknown to this data-dir. Run
`marmot group list` to confirm, or `marmot await group --name …` to `marmot group list` to confirm, or `marmot await group --name …` to
wait for an invite to arrive. wait for an invite to arrive.
- **Hang on a network verb** — Amy connects to the relays in - **Hang on a network verb** — Amy connects to the relays advertised
`relays.json`; verify with `amy relay list`. Every network-bound in your local kind:10002 / 10050 / 10051 events; inspect with
operation has a timeout — use `--timeout` for `await`, or wrap the `amy relay list`. Every network-bound operation has a timeout — use
whole command in `timeout(1)` if you're scripting. `--timeout` for `await`, or wrap the whole command in `timeout(1)`
if you're scripting.
- **Nothing seems to publish** — inspect stderr; each publish prints - **Nothing seems to publish** — inspect stderr; each publish prints
per-relay `OK` / `REJECT` via the `[cli] …` traces. per-relay `OK` / `REJECT` via the `[cli] …` traces.
@@ -24,8 +24,6 @@ import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair 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.bech32.bechToBytes
import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip19Bech32.toNsec import com.vitorpamplona.quartz.nip19Bech32.toNsec
@@ -81,49 +79,6 @@ data class Identity(
} }
} }
/**
* 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. */ /** Opaque per-run state (subscription cursors, etc). Stored alongside identity. */
data class RunState( data class RunState(
var giftWrapSince: Long? = null, var giftWrapSince: Long? = null,
@@ -138,7 +93,6 @@ class DataDir(
val root: File, val root: File,
) { ) {
val identityFile = File(root, "identity.json") val identityFile = File(root, "identity.json")
val relaysFile = File(root, "relays.json")
val stateFile = File(root, "state.json") val stateFile = File(root, "state.json")
val marmotDir = File(root, "marmot") val marmotDir = File(root, "marmot")
val groupsDir = File(marmotDir, "groups") val groupsDir = File(marmotDir, "groups")
@@ -158,12 +112,6 @@ class DataDir(
identityFile.writeText(Json.mapper.writeValueAsString(id)) 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 loadRunState(): RunState = if (stateFile.exists()) Json.mapper.readValue(stateFile.readText()) else RunState()
fun saveRunState(s: RunState) { fun saveRunState(s: RunState) {
@@ -89,7 +89,6 @@ import okhttp3.OkHttpClient
class Context( class Context(
val dataDir: DataDir, val dataDir: DataDir,
val identity: Identity, val identity: Identity,
val relays: RelayConfig,
val state: RunState, val state: RunState,
) : AutoCloseable { ) : AutoCloseable {
val signer = NostrSignerInternal(identity.keyPair()) val signer = NostrSignerInternal(identity.keyPair())
@@ -156,13 +155,39 @@ class Context(
.resolveUserHexOrNull(input, nip05Client) .resolveUserHexOrNull(input, nip05Client)
?: throw IllegalArgumentException("Could not resolve user: '$input' (accepts npub, nprofile, 64-hex, or name@domain.tld)") ?: throw IllegalArgumentException("Could not resolve user: '$input' (accepts npub, nprofile, 64-hex, or name@domain.tld)")
fun outboxRelays(): Set<NormalizedRelayUrl> = relays.normalized("nip65") /**
* Outbox / NIP-65 write relays for this account. Read from the
* local kind:10002 (after `amy create` or `amy relay publish-lists`
* has written one); falls back to [DefaultNIP65RelaySet] when no
* advertised list exists yet.
*
* Returns *write*-marked URLs only — the semantic is "where I
* publish from", which mirrors `User.outboxRelays()` in the
* Android app.
*/
fun outboxRelays(): Set<NormalizedRelayUrl> =
relaysOf(identity.pubKeyHex)?.writeRelaysNorm()?.takeIf { it.isNotEmpty() }?.toSet()
?: DefaultNIP65RelaySet
fun inboxRelays(): Set<NormalizedRelayUrl> = relays.normalized("inbox") /**
* DM inbox relays (NIP-17 kind:10050) for this account. Falls back
* to [DefaultDMRelayList] when no kind:10050 has been seen.
*/
fun inboxRelays(): Set<NormalizedRelayUrl> =
dmInboxOf(identity.pubKeyHex)?.relays()?.takeIf { it.isNotEmpty() }?.toSet()
?: DefaultDMRelayList.toSet()
fun keyPackageRelays(): Set<NormalizedRelayUrl> = relays.normalized("key_package") /**
* KeyPackage relays (MIP-00 kind:10051) for this account. Falls
* back to [outboxRelays] when no kind:10051 has been seen — same
* fallback the Android app uses for KeyPackage discovery.
*/
fun keyPackageRelays(): Set<NormalizedRelayUrl> =
keyPackageRelaysOf(identity.pubKeyHex)?.relays()?.takeIf { it.isNotEmpty() }?.toSet()
?: outboxRelays()
fun anyRelays(): Set<NormalizedRelayUrl> = relays.normalized("all") /** Union of all three buckets. */
fun anyRelays(): Set<NormalizedRelayUrl> = outboxRelays() + inboxRelays() + keyPackageRelays()
/** /**
* Seed relays for "look up someone we know nothing about" queries — * Seed relays for "look up someone we know nothing about" queries —
@@ -578,7 +603,6 @@ class Context(
return Context( return Context(
dataDir = dataDir, dataDir = dataDir,
identity = identity, identity = identity,
relays = dataDir.loadRelays(),
state = dataDir.loadRunState(), state = dataDir.loadRunState(),
) )
} }
@@ -25,7 +25,6 @@ import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Identity import com.vitorpamplona.amethyst.cli.Identity
import com.vitorpamplona.amethyst.cli.Json import com.vitorpamplona.amethyst.cli.Json
import com.vitorpamplona.amethyst.cli.RelayConfig
import com.vitorpamplona.amethyst.commons.account.bootstrapAccountEvents import com.vitorpamplona.amethyst.commons.account.bootstrapAccountEvents
import com.vitorpamplona.amethyst.commons.defaults.DefaultDMRelayList import com.vitorpamplona.amethyst.commons.defaults.DefaultDMRelayList
import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65List import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65List
@@ -34,9 +33,14 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
/** /**
* `amy create [--name NAME]` — provision a brand-new Nostr account with the * `amy create [--name NAME]` — provision a brand-new Nostr account with the
* same defaults Amethyst uses, publish the nine bootstrap events to the * same defaults Amethyst uses and publish the nine bootstrap events to the
* default NIP-65 relay set, and seed this data-dir's relay config so * default NIP-65 relay set.
* subsequent `amy marmot …` commands immediately target the right relays. *
* The bootstrap events include kind:10002 / 10050 / 10051, which are
* persisted into [Context.store] as a side effect of [Context.publish].
* From that point on, [Context.outboxRelays], [Context.inboxRelays], and
* [Context.keyPackageRelays] read straight from the local store — no
* separate `relays.json` is needed.
* *
* The heavy lifting (which events to sign, with which defaults) lives in * The heavy lifting (which events to sign, with which defaults) lives in
* `commons/.../AccountBootstrap.kt` so this command stays assembly-thin * `commons/.../AccountBootstrap.kt` so this command stays assembly-thin
@@ -53,10 +57,9 @@ object CreateCommand {
val args = Args(rest) val args = Args(rest)
val name = args.flag("name") val name = args.flag("name")
// 1. Mint identity + seed relay config. // 1. Mint identity.
val identity = Identity.create() val identity = Identity.create()
dataDir.saveIdentity(identity) dataDir.saveIdentity(identity)
dataDir.saveRelays(defaultRelayConfig())
// 2. Build the nine signed bootstrap events via the shared helper. // 2. Build the nine signed bootstrap events via the shared helper.
val signer = NostrSignerSync(identity.keyPair()) val signer = NostrSignerSync(identity.keyPair())
@@ -64,6 +67,10 @@ object CreateCommand {
// 3. Open a Context so we reuse publishAndConfirmDetailed + the // 3. Open a Context so we reuse publishAndConfirmDetailed + the
// NostrClient plumbing instead of reinventing a second client. // NostrClient plumbing instead of reinventing a second client.
// Each ctx.publish call also persists the event into the local
// store via verifyAndStore — so kind:10002 / 10050 / 10051 are
// immediately readable by outboxRelays() / inboxRelays() /
// keyPackageRelays() without a separate config file.
val ctx = Context.open(dataDir) val ctx = Context.open(dataDir)
val accepted = mutableMapOf<String, List<String>>() val accepted = mutableMapOf<String, List<String>>()
try { try {
@@ -95,20 +102,4 @@ object CreateCommand {
) )
return 0 return 0
} }
/**
* Mirror of what Amethyst's in-app defaults would write on disk — NIP-65
* outbox, DM inbox (kind:10050), and KeyPackage host relays (kind:10051).
* Keeping these in sync with the signed events above is load-bearing:
* `amy marmot key-package publish` later reads `relays.json` to decide
* where to publish, and if those diverge from the advertised kind:10051
* nobody will find the KPs.
*/
private fun defaultRelayConfig(): RelayConfig {
val cfg = RelayConfig()
DefaultNIP65List.forEach { cfg.add("nip65", it.relayUrl.url) }
DefaultDMRelayList.forEach { cfg.add("inbox", it.url) }
DefaultNIP65RelaySet.forEach { cfg.add("key_package", it.url) }
return cfg
}
} }
@@ -25,11 +25,28 @@ import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Json import com.vitorpamplona.amethyst.cli.Json
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType
/**
* `amy relay <add|list|publish-lists>` — manage this account's relay sets.
*
* Source of truth is the local event store (`<data-dir>/events-store/`)
* via Context.relaysOf / dmInboxOf / keyPackageRelaysOf. There is no
* `relays.json` any more — the kind:10002 / 10050 / 10051 events ARE
* the relay configuration.
*
* - `relay add URL --type T` builds + signs + ingests a new relay-
* list event for the given bucket. No
* broadcast yet — call `publish-lists`.
* - `relay list` dumps the URLs from the local store.
* - `relay publish-lists` broadcasts the three events to every
* configured relay (union).
*/
object RelayCommands { object RelayCommands {
suspend fun dispatch( suspend fun dispatch(
dataDir: DataDir, dataDir: DataDir,
@@ -46,78 +63,128 @@ object RelayCommands {
} }
} }
private fun add( private suspend fun add(
dataDir: DataDir, dataDir: DataDir,
args: Args, args: Args,
): Int { ): Int {
val url = args.positional(0, "url") val rawUrl = args.positional(0, "url")
val type = args.flag("type", "all") ?: "all" val type = args.flag("type", "all") ?: "all"
val cfg = dataDir.loadRelays() val normalized =
val addedTo = mutableListOf<String>() rawUrl.normalizeRelayUrlOrNull()
?: return Json.error("bad_args", "invalid relay url: $rawUrl")
val targets = if (type == "all") listOf("nip65", "inbox", "key_package") else listOf(type) val targets = if (type == "all") listOf("nip65", "inbox", "key_package") else listOf(type)
for (t in targets) { val ctx = Context.open(dataDir)
if (cfg.add(t, url)) addedTo.add(t) try {
val addedTo = mutableListOf<String>()
val alreadyPresent = mutableListOf<String>()
for (t in targets) {
if (addToBucket(ctx, t, normalized)) addedTo.add(t) else alreadyPresent.add(t)
}
Json.writeLine(
mapOf(
"url" to rawUrl,
"added_to" to addedTo,
"already_present" to alreadyPresent,
),
)
return 0
} finally {
ctx.close()
}
}
/**
* Append [url] to the relay-list event for [type] (creating one if
* absent). Returns `true` when a new event was inserted, `false`
* when [url] was already present in the existing event.
*/
private suspend fun addToBucket(
ctx: Context,
type: String,
url: NormalizedRelayUrl,
): Boolean {
val self = ctx.identity.pubKeyHex
return when (type) {
"nip65" -> {
val existing = ctx.relaysOf(self)?.relays().orEmpty()
if (existing.any { it.relayUrl.url == url.url }) return false
val combined = existing + AdvertisedRelayInfo(url, AdvertisedRelayType.BOTH)
val event = AdvertisedRelayListEvent.create(combined, ctx.signer)
ctx.verifyAndStore(event)
true
}
"inbox" -> {
val existing = ctx.dmInboxOf(self)?.relays().orEmpty()
if (url in existing) return false
val event = ChatMessageRelayListEvent.create(existing + url, ctx.signer)
ctx.verifyAndStore(event)
true
}
"key_package", "keyPackage" -> {
val existing = ctx.keyPackageRelaysOf(self)?.relays().orEmpty()
if (url in existing) return false
val event = KeyPackageRelayListEvent.create(existing + url, ctx.signer)
ctx.verifyAndStore(event)
true
}
else -> {
throw IllegalArgumentException("unknown relay type: $type")
}
} }
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 { private fun list(dataDir: DataDir): Int {
val cfg = dataDir.loadRelays() val ctx = Context.open(dataDir)
Json.writeLine( try {
mapOf( val self = ctx.identity.pubKeyHex
"nip65" to cfg.nip65, Json.writeLine(
"inbox" to cfg.inbox, mapOf(
"key_package" to cfg.keyPackage, "nip65" to (ctx.relaysOf(self)?.relaysNorm()?.map { it.url } ?: emptyList()),
), "inbox" to (ctx.dmInboxOf(self)?.relays()?.map { it.url } ?: emptyList()),
) "key_package" to (ctx.keyPackageRelaysOf(self)?.relays()?.map { it.url } ?: emptyList()),
return 0 ),
)
return 0
} finally {
ctx.close()
}
} }
private suspend fun publishLists(dataDir: DataDir): Int { private suspend fun publishLists(dataDir: DataDir): Int {
val ctx = Context.open(dataDir) val ctx = Context.open(dataDir)
try { try {
ctx.prepare() ctx.prepare()
val nip65Relays = ctx.relays.normalized("nip65").toList() val self = ctx.identity.pubKeyHex
val inboxRelays = ctx.relays.normalized("inbox").toList() val nip65Event = ctx.relaysOf(self)
// MIP-00: other clients discover our KeyPackages by querying the val inboxEvent = ctx.dmInboxOf(self)
// relays advertised in our kind:10051 event. If no key_package val keyPackageEvent = ctx.keyPackageRelaysOf(self)
// bucket is configured, fall back to the NIP-65 set so we always
// publish a non-empty list — an empty 10051 would make us
// undiscoverable by other Marmot clients.
val keyPackageRelays =
ctx.relays
.normalized("key_package")
.ifEmpty { ctx.outboxRelays() }
.toList()
val nip65Infos = nip65Relays.map { AdvertisedRelayInfo(it, AdvertisedRelayType.BOTH) } if (nip65Event == null && inboxEvent == null && keyPackageEvent == null) {
val nip65Event = AdvertisedRelayListEvent.create(nip65Infos, ctx.signer) return Json.error(
val inboxEvent = ChatMessageRelayListEvent.create(inboxRelays, ctx.signer) "no_relays",
val keyPackageListEvent = KeyPackageRelayListEvent.create(keyPackageRelays, ctx.signer) "no relay lists in the local store; run `amy relay add` first or `amy create` to bootstrap defaults",
)
}
val targets = ctx.anyRelays() val targets = ctx.anyRelays()
val nip65Result = ctx.publish(nip65Event, targets) val nip65Result = nip65Event?.let { ctx.publish(it, targets) }.orEmpty()
val inboxResult = ctx.publish(inboxEvent, targets) val inboxResult = inboxEvent?.let { ctx.publish(it, targets) }.orEmpty()
val keyPackageListResult = ctx.publish(keyPackageListEvent, targets) val keyPackageResult = keyPackageEvent?.let { ctx.publish(it, targets) }.orEmpty()
Json.writeLine( Json.writeLine(
mapOf( mapOf(
"nip65_event_id" to nip65Event.id, "nip65_event_id" to nip65Event?.id,
"inbox_event_id" to inboxEvent.id, "inbox_event_id" to inboxEvent?.id,
"key_package_list_event_id" to keyPackageListEvent.id, "key_package_list_event_id" to keyPackageEvent?.id,
"accepted_by" to "accepted_by" to
mapOf( mapOf(
"nip65" to nip65Result.filterValues { it }.keys.map { it.url }, "nip65" to nip65Result.filterValues { it }.keys.map { it.url },
"inbox" to inboxResult.filterValues { it }.keys.map { it.url }, "inbox" to inboxResult.filterValues { it }.keys.map { it.url },
"key_package_list" to keyPackageListResult.filterValues { it }.keys.map { it.url }, "key_package_list" to keyPackageResult.filterValues { it }.keys.map { it.url },
), ),
), ),
) )