mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
feat(cli): NIP-17 file DMs (kind:15) on receive + send, strict 10050
Receive:
- decryptDms now accepts both ChatMessageEvent (kind:14) and
ChatMessageEncryptedFileHeaderEvent (kind:15). The result is a sealed
DecryptedDm hierarchy (TextDm / FileDm) with a `type: "text"|"file"`
discriminator in JSON. File messages emit url, mime_type,
encryption_algorithm, decryption_key, decryption_nonce, hash,
original_hash, size, dim, blurhash. `dm await --match X` searches the
text body for kind:14 and the URL for kind:15.
Send:
- New `dm send-file RECIPIENT URL --key HEX --nonce HEX [...]` verb.
Builds a kind:15 ChatMessageEncryptedFileHeaderEvent via the existing
ChatMessageEncryptedFileHeaderEvent.build + AESGCM(key, nonce), then
publishes through NIP17Factory.createEncryptedFileNIP17. The file is
expected to already be uploaded; carrying the upload itself in `amy`
is the next change.
Spec compliance (NIP-17 §6, "if no kind:10050, shouldn't try"):
- resolveDmRelays now returns kind:10050 only by default. If the
recipient has none, send/send-file exit with `no_dm_relays` and a
message recommending `--allow-fallback`. The fallback chain (kind:10002
read marker → bootstrap) is preserved behind that opt-in flag for
interop tests and brand-new accounts.
Each recipient row in the send response now includes `relay_source`
("kind_10050" / "nip65_read" / "bootstrap") so callers can tell where
the wraps actually went.
Note: Quartz already enforces the NIP-17 §7 step-3 impersonation guard
(seal pubkey is forced over the rumor's claimed pubkey inside
`Rumor.mergeWith`), so no extra check is needed in the CLI.
This commit is contained in:
+4
-3
@@ -137,9 +137,10 @@ Run `amy --help` for the canonical list. As of today:
|
||||
| `marmot await message GID --match TEXT` | Block until a message containing `TEXT` lands. |
|
||||
| `marmot await rename GID --name NAME` | Block until GID's name matches. |
|
||||
| `marmot await epoch GID --min N` | Block until GID's MLS epoch is ≥ N. |
|
||||
| `dm send RECIPIENT TEXT` | Send a NIP-17 gift-wrapped DM (kind:14 inside kind:1059). Publishes to the recipient's kind:10050 → kind:10002 read → our bootstrap pool. |
|
||||
| `dm list [--peer NPUB] [--since TS] [--limit N] [--timeout SECS]` | Drain and decrypt gift wraps on our inbox relays. With neither `--peer` nor `--since` the gift-wrap cursor in `state.json` is advanced to the newest message seen. |
|
||||
| `dm await --peer NPUB --match TEXT [--timeout SECS]` | Block until a DM from NPUB containing TEXT arrives. Timeout exits 124. |
|
||||
| `dm send RECIPIENT TEXT [--allow-fallback]` | Send a NIP-17 gift-wrapped text DM (kind:14 inside kind:1059). Default delivers only to the recipient's kind:10050 (per NIP-17); pass `--allow-fallback` to fall back to kind:10002 read marker → bootstrap pool. |
|
||||
| `dm send-file RECIPIENT URL --key HEX --nonce HEX [--mime-type M] [--hash H] [--original-hash H] [--size N] [--dim WxH] [--blurhash S] [--allow-fallback]` | Send a NIP-17 encrypted-file message (kind:15 inside kind:1059). The file must already be uploaded; `--key`/`--nonce` carry the AES-GCM material that recipients use to decrypt the bytes at `URL`. |
|
||||
| `dm list [--peer NPUB] [--since TS] [--limit N] [--timeout SECS]` | Drain and decrypt gift wraps on our inbox relays. Returns kind:14 (text) and kind:15 (file) messages with a `type` discriminator. With neither `--peer` nor `--since` the gift-wrap cursor in `state.json` is advanced to the newest message seen. |
|
||||
| `dm await --peer NPUB --match TEXT [--timeout SECS]` | Block until a DM from NPUB containing TEXT arrives (matches text content for kind:14, URL for kind:15). Timeout exits 124. |
|
||||
|
||||
All `await` verbs accept `--timeout SECS` (default 30). Timeout exits 124
|
||||
so scripts can distinguish "condition never happened" from "the command
|
||||
|
||||
@@ -28,28 +28,44 @@ import com.vitorpamplona.amethyst.cli.Json
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.filterGiftWrapsToPubkey
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull
|
||||
import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
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
|
||||
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* NIP-17 direct-message verbs. All three reuse the Quartz gift-wrap pipeline
|
||||
* NIP-17 direct-message verbs. All verbs reuse the Quartz gift-wrap pipeline
|
||||
* (NIP-17 chat message → NIP-59 seal → NIP-59 gift wrap via NIP-44) — this
|
||||
* command file is pure orchestration.
|
||||
* file is pure orchestration over `quartz/` and `commons/`.
|
||||
*
|
||||
* Both kind:14 (text) and kind:15 (encrypted file header) are recognised on
|
||||
* receive; `dm send-file` publishes kind:15 with the AES-GCM key + nonce
|
||||
* carried in tags per NIP-17.
|
||||
*
|
||||
* NIP-17 says clients "shouldn't try" to send when the recipient hasn't
|
||||
* published a kind:10050. By default we honour that: an empty kind:10050
|
||||
* is a hard error. `--allow-fallback` opts back into the kind:10002 read
|
||||
* marker → bootstrap chain for the cases (interop tests, brand-new
|
||||
* accounts) where strict mode is too strict.
|
||||
*/
|
||||
object DmCommands {
|
||||
suspend fun dispatch(
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int {
|
||||
if (tail.isEmpty()) return Json.error("bad_args", "dm <send|list|await> …")
|
||||
if (tail.isEmpty()) return Json.error("bad_args", "dm <send|send-file|list|await> …")
|
||||
val rest = tail.drop(1).toTypedArray()
|
||||
return when (tail[0]) {
|
||||
"send" -> send(dataDir, rest)
|
||||
"send-file" -> sendFile(dataDir, rest)
|
||||
"list" -> list(dataDir, rest)
|
||||
"await" -> await(dataDir, rest)
|
||||
else -> Json.error("bad_args", "dm ${tail[0]}")
|
||||
@@ -60,50 +76,124 @@ object DmCommands {
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.size < 2) return Json.error("bad_args", "dm send <recipient> <text>")
|
||||
if (rest.size < 2) return Json.error("bad_args", "dm send <recipient> <text> [--allow-fallback]")
|
||||
val text = rest[1]
|
||||
val args = Args(rest.drop(2).toTypedArray())
|
||||
val allowFallback = args.bool("allow-fallback")
|
||||
|
||||
val ctx = Context.open(dataDir)
|
||||
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 recipientsOut = mutableListOf<Map<String, Any?>>()
|
||||
for (wrap in result.wraps) {
|
||||
val target = wrap.recipientPubKey() ?: continue
|
||||
val relays = resolveDmRelays(ctx, target)
|
||||
if (relays.isEmpty()) {
|
||||
// A DM needs at least one relay that either the recipient
|
||||
// or we agree on. If we can't even fall back to bootstrap
|
||||
// we can't say "sent" honestly — surface it as an error.
|
||||
return Json.error("no_dm_relays", target)
|
||||
}
|
||||
val ack = ctx.publish(wrap, relays)
|
||||
recipientsOut.add(
|
||||
mapOf(
|
||||
"pubkey" to target,
|
||||
"wrap_id" to wrap.id,
|
||||
"published_to" to ack.filterValues { it }.keys.map { it.url },
|
||||
"relays_tried" to relays.map { it.url },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
Json.writeLine(
|
||||
mapOf(
|
||||
"event_id" to result.msg.id,
|
||||
"kind" to ChatMessageEvent.KIND,
|
||||
"recipients" to recipientsOut,
|
||||
),
|
||||
)
|
||||
return 0
|
||||
return publishWraps(ctx, result, allowFallback)
|
||||
} finally {
|
||||
ctx.close()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sendFile(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.size < 2) {
|
||||
return Json.error(
|
||||
"bad_args",
|
||||
"dm send-file <recipient> <url> --key <hex> --nonce <hex> [--mime-type <m>] [--hash <hex>] " +
|
||||
"[--original-hash <hex>] [--size <n>] [--dim <WxH>] [--blurhash <s>] [--allow-fallback]",
|
||||
)
|
||||
}
|
||||
val url = rest[1]
|
||||
val args = Args(rest.drop(2).toTypedArray())
|
||||
val keyHex = args.requireFlag("key")
|
||||
val nonceHex = args.requireFlag("nonce")
|
||||
val keyBytes =
|
||||
runCatching { keyHex.hexToByteArray() }.getOrElse {
|
||||
return Json.error("bad_args", "--key must be hex (got ${keyHex.length} chars)")
|
||||
}
|
||||
val nonceBytes =
|
||||
runCatching { nonceHex.hexToByteArray() }.getOrElse {
|
||||
return Json.error("bad_args", "--nonce must be hex (got ${nonceHex.length} chars)")
|
||||
}
|
||||
|
||||
val mimeType = args.flag("mime-type")
|
||||
val hash = args.flag("hash")
|
||||
val originalHash = args.flag("original-hash")
|
||||
val size = args.flags["size"]?.toIntOrNull()
|
||||
val blurhash = args.flag("blurhash")
|
||||
val dimension =
|
||||
args.flag("dim")?.let { raw ->
|
||||
val match =
|
||||
Regex("^(\\d+)x(\\d+)$").matchEntire(raw)
|
||||
?: return Json.error("bad_args", "--dim must be WxH (got '$raw')")
|
||||
com.vitorpamplona.quartz.nip94FileMetadata.tags
|
||||
.DimensionTag(match.groupValues[1].toInt(), match.groupValues[2].toInt())
|
||||
}
|
||||
val allowFallback = args.bool("allow-fallback")
|
||||
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
val recipient = ctx.requireUserHex(rest[0])
|
||||
val cipher =
|
||||
com.vitorpamplona.quartz.utils.ciphers
|
||||
.AESGCM(keyBytes, nonceBytes)
|
||||
val template =
|
||||
ChatMessageEncryptedFileHeaderEvent.build(
|
||||
to = listOf(PTag(recipient)),
|
||||
url = url,
|
||||
cipher = cipher,
|
||||
mimeType = mimeType,
|
||||
hash = hash,
|
||||
size = size,
|
||||
dimension = dimension,
|
||||
blurhash = blurhash,
|
||||
originalHash = originalHash,
|
||||
)
|
||||
val result = NIP17Factory().createEncryptedFileNIP17(template, ctx.signer)
|
||||
return publishWraps(ctx, result, allowFallback)
|
||||
} finally {
|
||||
ctx.close()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun publishWraps(
|
||||
ctx: Context,
|
||||
result: NIP17Factory.Result,
|
||||
allowFallback: Boolean,
|
||||
): Int {
|
||||
val recipientsOut = mutableListOf<Map<String, Any?>>()
|
||||
for (wrap in result.wraps) {
|
||||
val target = wrap.recipientPubKey() ?: continue
|
||||
val resolution = resolveDmRelays(ctx, target, allowFallback)
|
||||
if (resolution.relays.isEmpty()) {
|
||||
return Json.error(
|
||||
"no_dm_relays",
|
||||
"$target has no kind:10050; pass --allow-fallback to use NIP-65 read or bootstrap",
|
||||
)
|
||||
}
|
||||
val ack = ctx.publish(wrap, resolution.relays)
|
||||
recipientsOut.add(
|
||||
mapOf(
|
||||
"pubkey" to target,
|
||||
"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,
|
||||
),
|
||||
)
|
||||
}
|
||||
Json.writeLine(
|
||||
mapOf(
|
||||
"event_id" to result.msg.id,
|
||||
"kind" to result.msg.kind,
|
||||
"recipients" to recipientsOut,
|
||||
),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
private suspend fun list(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
@@ -126,10 +216,6 @@ object DmCommands {
|
||||
.ifEmpty { ctx.bootstrapRelays() }
|
||||
if (inbox.isEmpty()) return Json.error("no_inbox_relays", "configure relays or bootstrap defaults first")
|
||||
|
||||
// Stateless queries (either --since or --peer provided) don't
|
||||
// touch the cursor — the caller is asking a specific question.
|
||||
// A no-flag invocation is the "advance my cursor" path, matching
|
||||
// the Marmot syncIncoming convention.
|
||||
val advanceCursor = peerInput == null && sinceFlag == null
|
||||
val since = sinceFlag ?: ctx.state.giftWrapSince
|
||||
|
||||
@@ -141,7 +227,7 @@ object DmCommands {
|
||||
|
||||
val raw = ctx.drain(filters, timeoutMs = timeoutSecs * 1000)
|
||||
|
||||
val messages = decryptChatMessages(ctx, raw, peerHex)
|
||||
val messages = decryptDms(ctx, raw, peerHex)
|
||||
val out =
|
||||
messages
|
||||
.sortedBy { it.createdAt }
|
||||
@@ -187,10 +273,6 @@ object DmCommands {
|
||||
if (inbox.isEmpty()) return Json.error("no_inbox_relays", "configure relays or bootstrap defaults first")
|
||||
|
||||
val deadline = System.currentTimeMillis() + timeoutSecs * 1000
|
||||
// Remember how far we've already scanned on this invocation so
|
||||
// subsequent polls only pull newly-arrived wraps. The 2-day
|
||||
// lookback inside filterGiftWrapsToPubkey takes care of NIP-59's
|
||||
// randomised created_at.
|
||||
var since = ctx.state.giftWrapSince
|
||||
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
@@ -201,8 +283,11 @@ object DmCommands {
|
||||
.mapValues { (_, v) -> v.map { it.filter } }
|
||||
|
||||
val raw = ctx.drain(filters, timeoutMs = 3_000)
|
||||
val messages = decryptChatMessages(ctx, raw, peerHex)
|
||||
val hit = messages.firstOrNull { it.content.contains(match) }
|
||||
val messages = decryptDms(ctx, raw, peerHex)
|
||||
// Match against the text body for kind:14 and against the URL
|
||||
// for kind:15 — both are exposed as `searchText` so callers
|
||||
// can grep for either with one --match flag.
|
||||
val hit = messages.firstOrNull { match in it.searchText }
|
||||
if (hit != null) {
|
||||
Json.writeLine(hit.toJson())
|
||||
return 0
|
||||
@@ -217,28 +302,60 @@ object DmCommands {
|
||||
}
|
||||
}
|
||||
|
||||
/** Where to deliver a gift-wrap addressed to [recipient]. */
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
private suspend fun resolveDmRelays(
|
||||
ctx: Context,
|
||||
recipient: HexKey,
|
||||
): Set<NormalizedRelayUrl> {
|
||||
allowFallback: Boolean,
|
||||
): RelaySet {
|
||||
val seed = ctx.bootstrapRelays()
|
||||
val lists = RecipientRelayFetcher.fetchRelayLists(ctx.client, recipient, seed)
|
||||
// 10050 inbox → 10002 read fallback → bootstrap as final safety net.
|
||||
return lists.dmInboxOrFallback().toSet().ifEmpty { 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")
|
||||
}
|
||||
|
||||
private data class DecryptedDm(
|
||||
val id: HexKey,
|
||||
val wrapId: HexKey,
|
||||
val from: HexKey,
|
||||
val to: List<HexKey>,
|
||||
private data class RelaySet(
|
||||
val relays: Set<NormalizedRelayUrl>,
|
||||
val source: String,
|
||||
)
|
||||
|
||||
private sealed interface DecryptedDm {
|
||||
val id: HexKey
|
||||
val wrapId: HexKey
|
||||
val from: HexKey
|
||||
val to: List<HexKey>
|
||||
val createdAt: Long
|
||||
val relay: String
|
||||
val searchText: String
|
||||
|
||||
fun toJson(): Map<String, Any?>
|
||||
}
|
||||
|
||||
private data class TextDm(
|
||||
override val id: HexKey,
|
||||
override val wrapId: HexKey,
|
||||
override val from: HexKey,
|
||||
override val to: List<HexKey>,
|
||||
val content: String,
|
||||
val createdAt: Long,
|
||||
val relay: String,
|
||||
) {
|
||||
fun toJson(): Map<String, Any?> =
|
||||
override val createdAt: Long,
|
||||
override val relay: String,
|
||||
) : DecryptedDm {
|
||||
override val searchText: String get() = content
|
||||
|
||||
override fun toJson(): Map<String, Any?> =
|
||||
mapOf(
|
||||
"type" to "text",
|
||||
"id" to id,
|
||||
"wrap_id" to wrapId,
|
||||
"from" to from,
|
||||
@@ -249,31 +366,113 @@ object DmCommands {
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun decryptChatMessages(
|
||||
private data class FileDm(
|
||||
override val id: HexKey,
|
||||
override val wrapId: HexKey,
|
||||
override val from: HexKey,
|
||||
override val to: List<HexKey>,
|
||||
val url: String,
|
||||
val mimeType: String?,
|
||||
val encryptionAlgo: String?,
|
||||
val decryptionKey: String?,
|
||||
val decryptionNonce: String?,
|
||||
val hash: String?,
|
||||
val originalHash: String?,
|
||||
val size: Int?,
|
||||
val dimensions: String?,
|
||||
val blurhash: String?,
|
||||
override val createdAt: Long,
|
||||
override val relay: String,
|
||||
) : DecryptedDm {
|
||||
override val searchText: String get() = url
|
||||
|
||||
override fun toJson(): Map<String, Any?> {
|
||||
val out =
|
||||
mutableMapOf<String, Any?>(
|
||||
"type" to "file",
|
||||
"id" to id,
|
||||
"wrap_id" to wrapId,
|
||||
"from" to from,
|
||||
"to" to to,
|
||||
"url" to url,
|
||||
"created_at" to createdAt,
|
||||
"relay" to relay,
|
||||
)
|
||||
if (mimeType != null) out["mime_type"] = mimeType
|
||||
if (encryptionAlgo != null) out["encryption_algorithm"] = encryptionAlgo
|
||||
if (decryptionKey != null) out["decryption_key"] = decryptionKey
|
||||
if (decryptionNonce != null) out["decryption_nonce"] = decryptionNonce
|
||||
if (hash != null) out["hash"] = hash
|
||||
if (originalHash != null) out["original_hash"] = originalHash
|
||||
if (size != null) out["size"] = size
|
||||
if (dimensions != null) out["dim"] = dimensions
|
||||
if (blurhash != null) out["blurhash"] = blurhash
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun decryptDms(
|
||||
ctx: Context,
|
||||
raw: List<Pair<NormalizedRelayUrl, com.vitorpamplona.quartz.nip01Core.core.Event>>,
|
||||
raw: List<Pair<NormalizedRelayUrl, Event>>,
|
||||
peerHex: HexKey?,
|
||||
): List<DecryptedDm> {
|
||||
val seen = HashSet<HexKey>()
|
||||
val out = mutableListOf<DecryptedDm>()
|
||||
for ((relay, event) in raw) {
|
||||
if (event !is GiftWrapEvent) continue
|
||||
val inner = event.unwrapAndUnsealOrNull(ctx.signer) as? ChatMessageEvent ?: continue
|
||||
// Quartz's Rumor.mergeWith forces the inner event's pubkey to the
|
||||
// seal author's, so the NIP-17 §7 step-3 impersonation check is
|
||||
// already enforced upstream — no need to redo it here.
|
||||
val inner = event.unwrapAndUnsealOrNull(ctx.signer) ?: continue
|
||||
if (inner !is BaseDMGroupEvent) continue
|
||||
if (!seen.add(inner.id)) continue
|
||||
val members = inner.groupMembers()
|
||||
if (peerHex != null && peerHex !in members) continue
|
||||
out.add(
|
||||
DecryptedDm(
|
||||
if (peerHex != null && peerHex !in inner.groupMembers()) continue
|
||||
out.add(toDecrypted(inner, event.id, relay.url) ?: continue)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun toDecrypted(
|
||||
inner: BaseDMGroupEvent,
|
||||
wrapId: HexKey,
|
||||
relayUrl: String,
|
||||
): DecryptedDm? =
|
||||
when (inner) {
|
||||
is ChatMessageEvent -> {
|
||||
TextDm(
|
||||
id = inner.id,
|
||||
wrapId = event.id,
|
||||
wrapId = wrapId,
|
||||
from = inner.pubKey,
|
||||
to = inner.recipientsPubKey(),
|
||||
content = inner.content,
|
||||
createdAt = inner.createdAt,
|
||||
relay = relay.url,
|
||||
),
|
||||
)
|
||||
relay = relayUrl,
|
||||
)
|
||||
}
|
||||
|
||||
is ChatMessageEncryptedFileHeaderEvent -> {
|
||||
FileDm(
|
||||
id = inner.id,
|
||||
wrapId = wrapId,
|
||||
from = inner.pubKey,
|
||||
to = inner.recipientsPubKey(),
|
||||
url = inner.url(),
|
||||
mimeType = inner.mimeType(),
|
||||
encryptionAlgo = inner.algo(),
|
||||
decryptionKey = inner.key()?.toHexKey(),
|
||||
decryptionNonce = inner.nonce()?.toHexKey(),
|
||||
hash = inner.hash(),
|
||||
originalHash = inner.originalHash(),
|
||||
size = inner.size(),
|
||||
dimensions = inner.dimensions()?.let { "${it.width}x${it.height}" },
|
||||
blurhash = inner.blurhash(),
|
||||
createdAt = inner.createdAt,
|
||||
relay = relayUrl,
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
null
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user