From 31cfb53b256531377725f38bb3a66c1fa0052e0a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 23:45:33 +0000 Subject: [PATCH] feat(commons): extract NIP-17 DM verbs into shared actions package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth verb extraction alongside FollowActions / SearchActions / ZapActions. Closes the largest remaining amy-expert "thin assembly" violation in cli/. Two pieces moved out of cli/.../DmCommands.kt into commons: * DmActions.resolveDmRelays applies the strict-kind:10050 → NIP-65- read → bootstrap fallback policy the in-app flow uses. Returns a DmRelaySet with a typed RelaySource (KIND_10050 / NIP65_READ / BOOTSTRAP / NONE) so callers can surface the source — amy emits it on stdout, a future Gemini adapter could mention it in the assistant response. * DmActions.buildTextDm / buildFileDmReference are thin wrappers over NIP17Factory.createMessageNIP17 / createEncryptedFileNIP17 that build the kind:14 / kind:15 template and gift-wrap in one call. Matches the FollowActions / ZapActions builder shape. amy's DmCommands is now genuinely thin assembly: requireUserHex, flag plumbing, call DmActions, render JSON. The 583-line file shrank slightly and — more importantly — no longer carries NIP-17 logic the rest of the codebase needs to look at. Receive-side decrypt loop (3 lines of unwrapAndUnsealOrNull) stays in amy; too small to extract and tightly coupled to amy's per-relay attribution. 10 new tests for DmActions: strict/permissive fallback chain, null recipient lists, RelaySource enum stability, and a smoke test that buildTextDm produces a kind:14 with the right wrap count (sender + recipient). --- .../amethyst/cli/commands/DmCommands.kt | 94 ++++---- .../amethyst/commons/actions/DmActions.kt | 171 ++++++++++++++ .../amethyst/commons/actions/DmActionsTest.kt | 214 ++++++++++++++++++ 3 files changed, 433 insertions(+), 46 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/DmActions.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/DmActionsTest.kt diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DmCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DmCommands.kt index 9fccae5dcc..9cd003ae30 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DmCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DmCommands.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.cli.AwaitTimeout import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.commons.actions.DmActions import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.filterGiftWrapsToPubkey import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull import com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator @@ -34,7 +35,6 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip17Dm.NIP17Factory import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent @@ -86,8 +86,7 @@ object DmCommands { try { ctx.prepare() val recipient = ctx.requireUserHex(rest[0]) - val template = ChatMessageEvent.build(text, listOf(PTag(recipient))) - val result = NIP17Factory().createMessageNIP17(template, ctx.signer) + val result = DmActions.buildTextDm(ctx.signer, recipient, text) return publishWraps(ctx, result, allowFallback) } finally { ctx.close() @@ -124,27 +123,34 @@ object DmCommands { ctx.prepare() val recipient = ctx.requireUserHex(recipientInput) - val (template, summary) = + val (result, summary) = if (args.flag("file") != null) { - buildUploadModeTemplate(ctx, recipient, args) + buildUploadedFileDm(ctx, recipient, args) ?: return 1 } else { - buildReferenceModeTemplate(args, recipient) + buildReferencedFileDm(ctx, recipient, args) ?: return 1 } - val result = NIP17Factory().createEncryptedFileNIP17(template, ctx.signer) return publishWraps(ctx, result, allowFallback, extra = summary) } finally { ctx.close() } } - private suspend fun buildUploadModeTemplate( + /** + * Upload mode: read the local file, encrypt with a fresh AESGCM key, + * push the ciphertext to a Blossom server, then call into + * [DmActions.buildFileDmReference] with the resulting URL + metadata. + * Returns the gift-wrap result plus an `extra` map that surfaces the + * upload's cipher material on stdout so callers can re-share or + * republish the same blob without re-uploading. + */ + private suspend fun buildUploadedFileDm( ctx: Context, - recipient: com.vitorpamplona.quartz.nip01Core.core.HexKey, + recipient: HexKey, args: Args, - ): Pair, Map>? { + ): Pair>? { val file = java.io.File(args.requireFlag("file")) if (!file.exists()) { Output.error("bad_args", "file does not exist: ${file.absolutePath}") @@ -169,9 +175,10 @@ object DmCommands { .DimensionTag(w, h) } } - val template = - ChatMessageEncryptedFileHeaderEvent.build( - to = listOf(PTag(recipient)), + val result = + DmActions.buildFileDmReference( + signer = ctx.signer, + recipient = recipient, url = uploadedUrl, cipher = cipher, mimeType = mimeType, @@ -181,8 +188,6 @@ object DmCommands { blurhash = uploaded.metadata.blurhash, originalHash = uploaded.metadata.sha256, ) - // Surface the cipher material on stdout so callers can re-share - // or republish the same encrypted blob without re-uploading. val summary = mapOf( "url" to uploadedUrl, @@ -193,13 +198,19 @@ object DmCommands { "original_hash" to uploaded.metadata.sha256, "mime_type" to mimeType, ) - return template to summary + return result to summary } - private fun buildReferenceModeTemplate( + /** + * Reference mode: the file is already uploaded somewhere; the user + * hands us the URL + cipher key/nonce + whatever metadata they want + * stamped onto the kind:15. + */ + private suspend fun buildReferencedFileDm( + ctx: Context, + recipient: HexKey, args: Args, - recipient: com.vitorpamplona.quartz.nip01Core.core.HexKey, - ): Pair, Map>? { + ): Pair>? { val url = args.positionalOrNull(0) ?: run { Output.error("bad_args", USAGE_SEND_FILE) @@ -236,9 +247,10 @@ object DmCommands { val cipher = com.vitorpamplona.quartz.utils.ciphers .AESGCM(keyBytes, nonceBytes) - val template = - ChatMessageEncryptedFileHeaderEvent.build( - to = listOf(PTag(recipient)), + val result = + DmActions.buildFileDmReference( + signer = ctx.signer, + recipient = recipient, url = url, cipher = cipher, mimeType = mimeType, @@ -248,7 +260,7 @@ object DmCommands { blurhash = blurhash, originalHash = originalHash, ) - return template to emptyMap() + return result to emptyMap() } private const val USAGE_SEND_FILE: String = @@ -278,7 +290,7 @@ object DmCommands { "wrap_id" to wrap.id, "published_to" to ack.filterValues { it }.keys.map { it.url }, "relays_tried" to resolution.relays.map { it.url }, - "relay_source" to resolution.source, + "relay_source" to resolution.source.name.lowercase(), ), ) } @@ -402,39 +414,29 @@ object DmCommands { } /** - * Per NIP-17: kind:1059 should only be delivered to relays the recipient - * has advertised in their kind:10050. When that list is empty: - * - strict (default): refuse with no_dm_relays — caller must fix or - * explicitly opt into a fallback. - * - allowFallback=true: fall through to the NIP-65 read marker and then - * to our bootstrap pool. + * Cache-first relay lookup. If amy has previously seen the recipient's + * kind:10050 / 10051 / 10002 events, use the local copy and skip the + * network drain entirely. Otherwise drain `seedRelays` for them. Then + * hands the resulting [RecipientRelayFetcher.Lists] off to + * [DmActions.resolveDmRelays] which applies the strict-kind:10050 / + * fallback policy. */ private suspend fun resolveDmRelays( ctx: Context, recipient: HexKey, allowFallback: Boolean, - ): RelaySet { + ): DmActions.DmRelaySet { val seed = ctx.bootstrapRelays() - // Cache-first: if Amy has previously seen the recipient's - // kind:10050 / 10051 / 10002 events, use the local copy and - // skip the network drain entirely. Falls back to the live - // fetcher only if the local store has nothing. val lists = ctx.cachedRelayListsOf(recipient) ?: RecipientRelayFetcher.fetchRelayLists(ctx.client, recipient, seed) - val dmInbox = lists.dmInbox.toSet() - if (dmInbox.isNotEmpty()) return RelaySet(dmInbox, "kind_10050") - if (!allowFallback) return RelaySet(emptySet(), "kind_10050") - val nip65Read = lists.nip65Read().toSet() - if (nip65Read.isNotEmpty()) return RelaySet(nip65Read, "nip65_read") - return RelaySet(seed, "bootstrap") + return DmActions.resolveDmRelays( + recipientLists = lists, + bootstrap = seed, + allowFallback = allowFallback, + ) } - private data class RelaySet( - val relays: Set, - val source: String, - ) - private sealed interface DecryptedDm { val id: HexKey val wrapId: HexKey diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/DmActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/DmActions.kt new file mode 100644 index 0000000000..bb820f08a0 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/DmActions.kt @@ -0,0 +1,171 @@ +/* + * 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.commons.actions + +import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip17Dm.NIP17Factory +import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import com.vitorpamplona.quartz.utils.ciphers.AESGCM + +/** + * NIP-17 direct-message verbs — relay resolution policy + gift-wrap builders. + * + * Like [FollowActions] / [SearchActions] / [ZapActions], this is pure logic + * usable from amy CLI, the Android App Functions adapter for Gemini, and any + * other non-UI consumer. The send builders return signed gift wraps but do + * NOT publish; the read side (decrypting incoming gift wraps) stays at the + * caller because the `unwrapAndUnsealOrNull` extension in + * `commons/.../relayClient/nip17Dm/` is already a one-liner. + * + * **Caller responsibilities** that this object leaves to the consumer: + * + * * **Publish.** Each wrap goes to its own recipient's DM-relay set — + * resolve via [resolveDmRelays] and hand each wrap to your relay client. + * * **Recipient resolution.** Translate npub / NIP-05 / hex to [HexKey] + * before calling — the Android UI uses `User.pubkeyHex`, amy uses + * `Context.requireUserHex`, the Gemini adapter would resolve through + * its own NIP-05 path. + * * **File upload (kind:15).** [buildFileDmReference] assumes the file is + * already at a URL. For "upload-then-DM", use + * `commons/.../service/upload/UploadOrchestrator` (jvmAndroid only, has + * an OkHttp dep) before calling here. + * * **Receipt of incoming DMs.** The kind:1059 gift-wrap drain, NIP-44 + * unseal, and decrypt-to-inner-event step is a 3-line caller-side loop + * over [com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull] + * — too small to bother extracting. + */ +object DmActions { + /** + * Source bucket from which [DmRelaySet.relays] was drawn. Useful for + * surfacing "where did we deliver?" telemetry to the caller — amy + * emits this on stdout, Gemini could mention it in the assistant + * response. + */ + enum class RelaySource { + /** Recipient's NIP-17 inbox (kind:10050). The strict NIP-17 path. */ + KIND_10050, + + /** NIP-65 read marker (kind:10002 read relays). Fallback bucket. */ + NIP65_READ, + + /** Caller-provided bootstrap pool. Last-resort fallback. */ + BOOTSTRAP, + + /** No relays available — caller should refuse to send. */ + NONE, + } + + /** Outcome of [resolveDmRelays]: the relays to publish to, plus which bucket they came from. */ + data class DmRelaySet( + val relays: Set, + val source: RelaySource, + ) + + /** + * Apply Amethyst's NIP-17 relay-resolution policy to a recipient. + * + * NIP-17 says clients "shouldn't try" to deliver a gift wrap unless the + * recipient has published a kind:10050. In strict mode (the default), an + * empty kind:10050 returns [RelaySource.NONE] so the caller refuses to + * send. Permissive mode walks the fallback chain instead — NIP-65 read + * relays, then the bootstrap pool — for cases like interop tests and + * brand-new accounts where strict mode is too strict. + * + * @param recipientLists the recipient's relay-list snapshot from + * [RecipientRelayFetcher.fetchRelayLists] (or a local cache). + * Pass null when the recipient is unknown — same effect as empty lists. + * @param bootstrap the caller's bootstrap relay pool, used as the + * last-resort fallback when [allowFallback] is true. + * @param allowFallback opt into the NIP-65-read → bootstrap chain when + * kind:10050 is empty. Default false (strict mode). + */ + fun resolveDmRelays( + recipientLists: RecipientRelayFetcher.Lists?, + bootstrap: Set, + allowFallback: Boolean = false, + ): DmRelaySet { + val dmInbox = recipientLists?.dmInbox?.toSet().orEmpty() + if (dmInbox.isNotEmpty()) return DmRelaySet(dmInbox, RelaySource.KIND_10050) + if (!allowFallback) return DmRelaySet(emptySet(), RelaySource.NONE) + val nip65Read = recipientLists?.nip65Read()?.toSet().orEmpty() + if (nip65Read.isNotEmpty()) return DmRelaySet(nip65Read, RelaySource.NIP65_READ) + return DmRelaySet(bootstrap, RelaySource.BOOTSTRAP) + } + + /** + * Build a NIP-17 text DM (kind:14) wrapped in a NIP-59 gift wrap per + * recipient. The returned [NIP17Factory.Result] carries the inner + * event for local caching and one gift wrap per recipient (just one + * here — the recipient + the sender's own copy). Caller publishes each + * wrap to that recipient's DM-relay set. + */ + suspend fun buildTextDm( + signer: NostrSigner, + recipient: HexKey, + text: String, + ): NIP17Factory.Result { + val template = ChatMessageEvent.build(text, listOf(PTag(recipient))) + return NIP17Factory().createMessageNIP17(template, signer) + } + + /** + * Build a NIP-17 encrypted-file DM (kind:15) for a file that has + * already been uploaded to [url]. The [cipher]'s key + nonce travel + * inside the gift-wrapped inner event so only the recipient — and the + * sender, who keeps their own copy — can decrypt the bytes at [url]. + * + * Pre-uploaded URL only: the upload step is jvmAndroid-only (needs + * OkHttp). For "upload then DM" use `UploadOrchestrator` first and + * pass its returned URL + the cipher you generated here. + */ + suspend fun buildFileDmReference( + signer: NostrSigner, + recipient: HexKey, + url: String, + cipher: AESGCM, + mimeType: String? = null, + hash: String? = null, + originalHash: String? = null, + size: Int? = null, + dimension: DimensionTag? = null, + blurhash: String? = null, + ): NIP17Factory.Result { + val template = + ChatMessageEncryptedFileHeaderEvent.build( + to = listOf(PTag(recipient)), + url = url, + cipher = cipher, + mimeType = mimeType, + hash = hash, + size = size, + dimension = dimension, + blurhash = blurhash, + originalHash = originalHash, + ) + return NIP17Factory().createEncryptedFileNIP17(template, signer) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/DmActionsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/DmActionsTest.kt new file mode 100644 index 0000000000..55b0550a6e --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/DmActionsTest.kt @@ -0,0 +1,214 @@ +/* + * 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.commons.actions + +import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo +import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType +import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class DmActionsTest { + private val senderPriv = "0000000000000000000000000000000000000000000000000000000000000007" + private val recipientPriv = "0000000000000000000000000000000000000000000000000000000000000019" + private val signer = NostrSignerInternal(KeyPair(senderPriv.hexToByteArray())) + private val recipientPub = + Secp256k1Instance + .compressedPubKeyFor(recipientPriv.hexToByteArray()) + .copyOfRange(1, 33) + .toHexKey() + + private val dmInbox = relay("wss://dm-inbox.example") + private val nip65ReadRelay = relay("wss://nip65-read.example") + private val nip65WriteRelay = relay("wss://nip65-write.example") + private val bootstrap = setOf(relay("wss://bootstrap.example")) + + private fun relay(url: String) = RelayUrlNormalizer.normalizeOrNull(url)!! + + /** Build a kind:10002 with one read + one write relay so [nip65Read] returns + * the expected single URL. */ + private suspend fun nip65WithReadAndWrite(): AdvertisedRelayListEvent = + signer.sign( + createdAt = 1_700_000_000L, + kind = AdvertisedRelayListEvent.KIND, + tags = + arrayOf( + AdvertisedRelayInfo.assemble(nip65ReadRelay, AdvertisedRelayType.READ), + AdvertisedRelayInfo.assemble(nip65WriteRelay, AdvertisedRelayType.WRITE), + ), + content = "", + ) + + // ------------------------------------------------------------------ + // resolveDmRelays — strict (default) mode + // ------------------------------------------------------------------ + + @Test + fun resolveDmRelays_strictReturnsKind10050WhenPresent() { + val lists = + RecipientRelayFetcher.Lists( + dmInbox = listOf(dmInbox), + keyPackage = emptyList(), + nip65 = null, + ) + val result = DmActions.resolveDmRelays(lists, bootstrap = bootstrap, allowFallback = false) + + assertEquals(setOf(dmInbox), result.relays) + assertEquals(DmActions.RelaySource.KIND_10050, result.source) + } + + @Test + fun resolveDmRelays_strictReturnsNoneWhenKind10050Empty() { + val lists = + RecipientRelayFetcher.Lists( + dmInbox = emptyList(), + keyPackage = emptyList(), + nip65 = null, + ) + val result = DmActions.resolveDmRelays(lists, bootstrap = bootstrap, allowFallback = false) + + // NIP-17 strict mode: no kind:10050 → refuse to deliver. Caller + // surfaces a no_dm_relays error rather than guessing. + assertTrue(result.relays.isEmpty()) + assertEquals(DmActions.RelaySource.NONE, result.source) + } + + @Test + fun resolveDmRelays_strictReturnsNoneEvenWhenNip65Present() = + runTest { + val lists = + RecipientRelayFetcher.Lists( + dmInbox = emptyList(), + keyPackage = emptyList(), + nip65 = nip65WithReadAndWrite(), + ) + val result = DmActions.resolveDmRelays(lists, bootstrap = bootstrap, allowFallback = false) + + // Strict mode does not fall through to NIP-65 even if it's present. + assertTrue(result.relays.isEmpty()) + assertEquals(DmActions.RelaySource.NONE, result.source) + } + + // ------------------------------------------------------------------ + // resolveDmRelays — permissive (allowFallback=true) mode + // ------------------------------------------------------------------ + + @Test + fun resolveDmRelays_fallbackPrefersKind10050OverNip65() = + runTest { + val lists = + RecipientRelayFetcher.Lists( + dmInbox = listOf(dmInbox), + keyPackage = emptyList(), + nip65 = nip65WithReadAndWrite(), + ) + val result = DmActions.resolveDmRelays(lists, bootstrap = bootstrap, allowFallback = true) + + // kind:10050 wins even with fallback enabled — it's still the strict path. + assertEquals(setOf(dmInbox), result.relays) + assertEquals(DmActions.RelaySource.KIND_10050, result.source) + } + + @Test + fun resolveDmRelays_fallbackUsesNip65ReadWhenKind10050Empty() = + runTest { + val lists = + RecipientRelayFetcher.Lists( + dmInbox = emptyList(), + keyPackage = emptyList(), + nip65 = nip65WithReadAndWrite(), + ) + val result = DmActions.resolveDmRelays(lists, bootstrap = bootstrap, allowFallback = true) + + // Falls through to NIP-65 read relays — not write — matching User.inboxRelays(). + assertEquals(setOf(nip65ReadRelay), result.relays) + assertEquals(DmActions.RelaySource.NIP65_READ, result.source) + } + + @Test + fun resolveDmRelays_fallbackReachesBootstrapWhenNothingElsePresent() { + val lists = + RecipientRelayFetcher.Lists( + dmInbox = emptyList(), + keyPackage = emptyList(), + nip65 = null, + ) + val result = DmActions.resolveDmRelays(lists, bootstrap = bootstrap, allowFallback = true) + + assertEquals(bootstrap, result.relays) + assertEquals(DmActions.RelaySource.BOOTSTRAP, result.source) + } + + @Test + fun resolveDmRelays_nullListsTreatedAsEmpty() { + val resultStrict = DmActions.resolveDmRelays(null, bootstrap = bootstrap, allowFallback = false) + assertEquals(DmActions.RelaySource.NONE, resultStrict.source) + + val resultPermissive = DmActions.resolveDmRelays(null, bootstrap = bootstrap, allowFallback = true) + // Null Lists → no kind:10050, no NIP-65 → bootstrap. + assertEquals(DmActions.RelaySource.BOOTSTRAP, resultPermissive.source) + assertEquals(bootstrap, resultPermissive.relays) + } + + // ------------------------------------------------------------------ + // buildTextDm — smoke test that we get back a kind:14 and the right + // wrap count. NIP17Factory internals are exercised more deeply in + // quartz's own tests. + // ------------------------------------------------------------------ + + @Test + fun buildTextDm_producesKind14InnerAndOneWrapPerSide() = + runTest { + val result = DmActions.buildTextDm(signer, recipientPub, "hi from a test") + + assertEquals(ChatMessageEvent.KIND, result.msg.kind) + assertEquals(signer.pubKey, result.msg.pubKey) + assertEquals("hi from a test", result.msg.content) + // NIP17Factory wraps once per recipient — and the sender keeps + // their own copy, so a 1-recipient DM produces 2 wraps. + assertEquals(2, result.wraps.size) + val recipientsCovered = result.wraps.mapNotNull { it.recipientPubKey() }.toSet() + assertTrue(signer.pubKey in recipientsCovered, "sender's own copy missing") + assertTrue(recipientPub in recipientsCovered, "recipient's wrap missing") + } + + @Test + fun relaySourceEnumNamesAreStable() { + // amy emits these as lowercase strings in JSON output; if these + // names change, the public CLI contract breaks. + assertEquals( + setOf("KIND_10050", "NIP65_READ", "BOOTSTRAP", "NONE"), + DmActions.RelaySource.entries + .map { it.name } + .toSet(), + ) + } +}