feat(commons): extract NIP-17 DM verbs into shared actions package

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).
This commit is contained in:
Claude
2026-05-24 23:45:33 +00:00
parent 86434ea550
commit 31cfb53b25
3 changed files with 433 additions and 46 deletions
@@ -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<com.vitorpamplona.quartz.nip01Core.signers.EventTemplate<ChatMessageEncryptedFileHeaderEvent>, Map<String, Any?>>? {
): Pair<NIP17Factory.Result, Map<String, Any?>>? {
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<com.vitorpamplona.quartz.nip01Core.signers.EventTemplate<ChatMessageEncryptedFileHeaderEvent>, Map<String, Any?>>? {
): Pair<NIP17Factory.Result, Map<String, Any?>>? {
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<NormalizedRelayUrl>,
val source: String,
)
private sealed interface DecryptedDm {
val id: HexKey
val wrapId: HexKey
@@ -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<NormalizedRelayUrl>,
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<NormalizedRelayUrl>,
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)
}
}
@@ -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(),
)
}
}