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:
Claude
2026-04-23 18:23:12 +00:00
parent c43a69b083
commit 15205c24b5
5 changed files with 300 additions and 1 deletions
+3
View File
@@ -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
View File
@@ -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
}
}