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 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 rename GID --name NAME` | Block until GID's name matches. |
|
||||||
| `marmot await epoch GID --min N` | Block until GID's MLS epoch is ≥ N. |
|
| `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
|
All `await` verbs accept `--timeout SECS` (default 30). Timeout exits 124
|
||||||
so scripts can distinguish "condition never happened" from "the command
|
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-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-02 follow list add / remove / list | 🆕 | Logic in `amethyst/model/nip02FollowLists/`. |
|
||||||
| NIP-09 event deletion | 🆕 | Builder exists in quartz. |
|
| 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-18 reposts / quotes | 🆕 | |
|
||||||
| NIP-25 reactions | 🆕 | |
|
| NIP-25 reactions | 🆕 | |
|
||||||
| NIP-51 lists (bookmarks, mute, follow sets) | 🆕 | `amethyst/model/nip51Lists/` |
|
| 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)
|
marmotDispatch(dataDir, tail)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"dm" -> {
|
||||||
|
Commands.dm(dataDir, tail)
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
System.err.println("unknown subcommand: $head")
|
System.err.println("unknown subcommand: $head")
|
||||||
printUsage()
|
printUsage()
|
||||||
@@ -189,6 +193,13 @@ private fun printUsage() {
|
|||||||
| relay list print configured relays
|
| relay list print configured relays
|
||||||
| relay publish-lists publish kind:10002 + kind:10050
|
| 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 (MLS group messaging):
|
||||||
| marmot key-package publish publish a fresh KeyPackage
|
| marmot key-package publish publish a fresh KeyPackage
|
||||||
| marmot key-package check NPUB fetch NPUB's KeyPackage from relays
|
| marmot key-package check NPUB fetch NPUB's KeyPackage from relays
|
||||||
|
|||||||
@@ -70,4 +70,9 @@ object Commands {
|
|||||||
dataDir: DataDir,
|
dataDir: DataDir,
|
||||||
tail: Array<String>,
|
tail: Array<String>,
|
||||||
): Int = AwaitCommands.dispatch(dataDir, tail)
|
): 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