diff --git a/cli/README.md b/cli/README.md index 036071d29..14450c09d 100644 --- a/cli/README.md +++ b/cli/README.md @@ -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 diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index fe42a3f40..466cd8410 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -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/` | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index d9cf303bb..dd8d4dec4 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -127,6 +127,10 @@ private suspend fun dispatch(argv: Array): 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 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt index 4b287ce96..aacf74d04 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt @@ -70,4 +70,9 @@ object Commands { dataDir: DataDir, tail: Array, ): Int = AwaitCommands.dispatch(dataDir, tail) + + suspend fun dm( + dataDir: DataDir, + tail: Array, + ): Int = DmCommands.dispatch(dataDir, tail) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DmCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DmCommands.kt new file mode 100644 index 000000000..287d9c29a --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DmCommands.kt @@ -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, + ): Int { + if (tail.isEmpty()) return Json.error("bad_args", "dm …") + 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, + ): Int { + if (rest.size < 2) return Json.error("bad_args", "dm send ") + 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>() + 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, + ): 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, + ): 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 { + 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, + val content: String, + val createdAt: Long, + val relay: String, + ) { + fun toJson(): Map = + 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>, + peerHex: HexKey?, + ): List { + val seen = HashSet() + val out = mutableListOf() + 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 + } +}