feat(cli): amy dm send|list|await (NIP-17)
Adds three verbs wired into the top-level dispatch: - `amy dm send RECIPIENT TEXT` — builds a kind:14 ChatMessageEvent, runs it through NIP17Factory to seal and gift-wrap, then publishes each wrap to its recipient's DM inbox (kind:10050, fallback kind:10002 read, final fallback bootstrap). Emits one JSON line with the inner kind:14 id, per-recipient wrap ids, and per-relay ACK status. - `amy dm list [--peer NPUB] [--since TS] [--limit N] [--timeout SECS]` — drains gift wraps addressed to us from our inbox relays (or outbox / bootstrap fallbacks), unwraps+unseals each one, dedupes by inner id, and returns them oldest-first. With no flags it advances the giftWrapSince cursor in state.json (matches the Marmot syncIncoming convention); with --peer/--since it's a stateless query. - `amy dm await --peer NPUB --match TEXT [--timeout SECS]` — polls the same drain on a 2s tick until a DM from NPUB contains TEXT. Timeout exits 124 like the other await verbs. All three reuse Quartz entirely (NIP17Factory, GiftWrapEvent, SealedRumorEvent, RecipientRelayFetcher); the only shared piece we needed in commons — filterGiftWrapsToPubkey — was extracted in the previous commit. No business logic leaks into cli/. Plan: cli/plans/2026-04-23-nip17-dm.md.
This commit is contained in:
@@ -137,6 +137,9 @@ 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. |
|
||||
|
||||
All `await` verbs accept `--timeout SECS` (default 30). Timeout exits 124
|
||||
so scripts can distinguish "condition never happened" from "the command
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command ·
|
||||
| NIP-01 feed read (`amy feed home`, `amy feed hashtag #X`, `amy feed profile NPUB`) | 🆕 | Extract `FeedFilter` usage from `amethyst/ui/dal/` into `commons/` entry points. |
|
||||
| NIP-02 follow list add / remove / list | 🆕 | Logic in `amethyst/model/nip02FollowLists/`. |
|
||||
| NIP-09 event deletion | 🆕 | Builder exists in quartz. |
|
||||
| NIP-17 DMs send / list / read | 🆕 | Gift-wrap path is already in `commons/` via Marmot — generalise. |
|
||||
| NIP-17 DMs send / list / await | ✅ | `DmCommands` — reuses Quartz `NIP17Factory` + `RecipientRelayFetcher`; filter extracted to `commons/relayClient/nip17Dm/`. Plan: [`cli/plans/2026-04-23-nip17-dm.md`](./plans/2026-04-23-nip17-dm.md). |
|
||||
| NIP-18 reposts / quotes | 🆕 | |
|
||||
| NIP-25 reactions | 🆕 | |
|
||||
| NIP-51 lists (bookmarks, mute, follow sets) | 🆕 | `amethyst/model/nip51Lists/` |
|
||||
|
||||
@@ -127,6 +127,10 @@ private suspend fun dispatch(argv: Array<String>): Int {
|
||||
marmotDispatch(dataDir, tail)
|
||||
}
|
||||
|
||||
"dm" -> {
|
||||
Commands.dm(dataDir, tail)
|
||||
}
|
||||
|
||||
else -> {
|
||||
System.err.println("unknown subcommand: $head")
|
||||
printUsage()
|
||||
@@ -189,6 +193,13 @@ private fun printUsage() {
|
||||
| relay list print configured relays
|
||||
| relay publish-lists publish kind:10002 + kind:10050
|
||||
|
|
||||
|Direct messages (NIP-17):
|
||||
| dm send RECIPIENT TEXT send a gift-wrapped DM
|
||||
| dm list [--peer NPUB] [--since TS] list decrypted DMs (advances cursor
|
||||
| [--limit N] [--timeout SECS] only when no flags are passed)
|
||||
| dm await --peer NPUB --match TEXT wait for a matching DM
|
||||
| [--timeout SECS] (default 30s, exit 124 on timeout)
|
||||
|
|
||||
|Marmot (MLS group messaging):
|
||||
| marmot key-package publish publish a fresh KeyPackage
|
||||
| marmot key-package check NPUB fetch NPUB's KeyPackage from relays
|
||||
|
||||
@@ -70,4 +70,9 @@ object Commands {
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int = AwaitCommands.dispatch(dataDir, tail)
|
||||
|
||||
suspend fun dm(
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int = DmCommands.dispatch(dataDir, tail)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
/*
|
||||
* 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.cli.commands
|
||||
|
||||
import com.vitorpamplona.amethyst.cli.Args
|
||||
import com.vitorpamplona.amethyst.cli.AwaitTimeout
|
||||
import com.vitorpamplona.amethyst.cli.Context
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Json
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.filterGiftWrapsToPubkey
|
||||
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.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip17Dm.NIP17Factory
|
||||
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
|
||||
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 chat message → NIP-59 seal → NIP-59 gift wrap via NIP-44) — this
|
||||
* command file is pure orchestration.
|
||||
*/
|
||||
object DmCommands {
|
||||
suspend fun dispatch(
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int {
|
||||
if (tail.isEmpty()) return Json.error("bad_args", "dm <send|list|await> …")
|
||||
val rest = tail.drop(1).toTypedArray()
|
||||
return when (tail[0]) {
|
||||
"send" -> send(dataDir, rest)
|
||||
"list" -> list(dataDir, rest)
|
||||
"await" -> await(dataDir, rest)
|
||||
else -> Json.error("bad_args", "dm ${tail[0]}")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun send(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.size < 2) return Json.error("bad_args", "dm send <recipient> <text>")
|
||||
val text = rest[1]
|
||||
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
|
||||
} finally {
|
||||
ctx.close()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun list(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val peerInput = args.flag("peer")
|
||||
val sinceFlag = args.flags["since"]?.toLongOrNull()
|
||||
val limit = args.intFlag("limit", Int.MAX_VALUE)
|
||||
val timeoutSecs = args.longFlag("timeout", 8)
|
||||
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
val peerHex = peerInput?.let { ctx.requireUserHex(it) }
|
||||
|
||||
val inbox =
|
||||
ctx
|
||||
.inboxRelays()
|
||||
.ifEmpty { ctx.outboxRelays() }
|
||||
.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
|
||||
|
||||
val filters =
|
||||
inbox
|
||||
.flatMap { filterGiftWrapsToPubkey(it, ctx.signer.pubKey, since) }
|
||||
.groupBy { it.relay }
|
||||
.mapValues { (_, v) -> v.map { it.filter } }
|
||||
|
||||
val raw = ctx.drain(filters, timeoutMs = timeoutSecs * 1000)
|
||||
|
||||
val messages = decryptChatMessages(ctx, raw, peerHex)
|
||||
val out =
|
||||
messages
|
||||
.sortedBy { it.createdAt }
|
||||
.takeLast(limit)
|
||||
.map { it.toJson() }
|
||||
|
||||
if (advanceCursor) {
|
||||
val maxSeen = messages.maxOfOrNull { it.createdAt }
|
||||
if (maxSeen != null) ctx.state.giftWrapSince = maxSeen
|
||||
}
|
||||
|
||||
Json.writeLine(
|
||||
mapOf(
|
||||
"peer" to peerHex,
|
||||
"messages" to out,
|
||||
),
|
||||
)
|
||||
return 0
|
||||
} finally {
|
||||
ctx.close()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun await(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val peerInput = args.requireFlag("peer")
|
||||
val match = args.requireFlag("match")
|
||||
val timeoutSecs = args.longFlag("timeout", 30)
|
||||
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
val peerHex = ctx.requireUserHex(peerInput)
|
||||
|
||||
val inbox =
|
||||
ctx
|
||||
.inboxRelays()
|
||||
.ifEmpty { ctx.outboxRelays() }
|
||||
.ifEmpty { ctx.bootstrapRelays() }
|
||||
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) {
|
||||
val filters =
|
||||
inbox
|
||||
.flatMap { filterGiftWrapsToPubkey(it, ctx.signer.pubKey, since) }
|
||||
.groupBy { it.relay }
|
||||
.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) }
|
||||
if (hit != null) {
|
||||
Json.writeLine(hit.toJson())
|
||||
return 0
|
||||
}
|
||||
val maxSeen = messages.maxOfOrNull { it.createdAt }
|
||||
if (maxSeen != null && (since == null || maxSeen > since)) since = maxSeen
|
||||
delay(2_000)
|
||||
}
|
||||
throw AwaitTimeout("no DM from $peerHex matching '$match' within ${timeoutSecs}s")
|
||||
} finally {
|
||||
ctx.close()
|
||||
}
|
||||
}
|
||||
|
||||
/** Where to deliver a gift-wrap addressed to [recipient]. */
|
||||
private suspend fun resolveDmRelays(
|
||||
ctx: Context,
|
||||
recipient: HexKey,
|
||||
): Set<NormalizedRelayUrl> {
|
||||
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 }
|
||||
}
|
||||
|
||||
private data class DecryptedDm(
|
||||
val id: HexKey,
|
||||
val wrapId: HexKey,
|
||||
val from: HexKey,
|
||||
val to: List<HexKey>,
|
||||
val content: String,
|
||||
val createdAt: Long,
|
||||
val relay: String,
|
||||
) {
|
||||
fun toJson(): Map<String, Any?> =
|
||||
mapOf(
|
||||
"id" to id,
|
||||
"wrap_id" to wrapId,
|
||||
"from" to from,
|
||||
"to" to to,
|
||||
"content" to content,
|
||||
"created_at" to createdAt,
|
||||
"relay" to relay,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun decryptChatMessages(
|
||||
ctx: Context,
|
||||
raw: List<Pair<NormalizedRelayUrl, com.vitorpamplona.quartz.nip01Core.core.Event>>,
|
||||
peerHex: HexKey?,
|
||||
): List<DecryptedDm> {
|
||||
val seen = HashSet<HexKey>()
|
||||
val out = mutableListOf<DecryptedDm>()
|
||||
for ((relay, event) in raw) {
|
||||
if (event !is GiftWrapEvent) continue
|
||||
val sealed = event.unwrapOrNull(ctx.signer) as? SealedRumorEvent ?: continue
|
||||
val inner = sealed.unsealOrNull(ctx.signer) as? ChatMessageEvent ?: continue
|
||||
if (!seen.add(inner.id)) continue
|
||||
val members = inner.groupMembers()
|
||||
if (peerHex != null && peerHex !in members) continue
|
||||
out.add(
|
||||
DecryptedDm(
|
||||
id = inner.id,
|
||||
wrapId = event.id,
|
||||
from = inner.pubKey,
|
||||
to = inner.recipientsPubKey(),
|
||||
content = inner.content,
|
||||
createdAt = inner.createdAt,
|
||||
relay = relay.url,
|
||||
),
|
||||
)
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user