mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 11:18:24 +00:00
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:
@@ -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
|
||||
|
||||
+171
@@ -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)
|
||||
}
|
||||
}
|
||||
+214
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user