docs(cli): plan NIP-17 DM send/list/await verbs

Design doc for `amy dm send|list|await`, reusing the existing Quartz
gift-wrap (NIP-17/NIP-59/NIP-44) pipeline. Single file under
cli/plans/; extracts FilterGiftWrapsToPubkey from amethyst/ to commons/
as a prerequisite commit so the CLI can depend on it without pulling
in :amethyst.
This commit is contained in:
Claude
2026-04-23 17:20:57 +00:00
parent a84f2f1466
commit 1edbe81b60
+252
View File
@@ -0,0 +1,252 @@
# NIP-17 DMs in `amy`
**Status:** plan · **Date:** 2026-04-23 · **Roadmap row:**
`cli/ROADMAP.md:55` ("NIP-17 DMs send / list / read") · **Order:**
step 5 in `cli/ROADMAP.md:91`.
Adds three verbs:
```
amy dm send <recipient> <text> [--peer <npub>...]
amy dm list [--peer <npub>] [--since <ts>] [--limit N] [--timeout SECS]
amy dm await --peer <npub> --match <text> [--timeout SECS]
```
`<recipient>` accepts everything `Context.requireUserHex` does:
`npub`, `nprofile`, 64-hex, `name@domain.tld`. `--peer` may repeat
on `send` to start a multi-recipient chat (NIP-17 supports group
DMs); on `list`/`await` it filters by counter-party.
---
## Guiding principles
1. **Thin assembly only.** Every protocol piece — sealing, wrapping,
NIP-44 — already lives in `quartz/`. The CLI just orchestrates.
2. **One JSON line out, snake_case keys, exit codes 0/1/2/124.**
3. **Reuse Marmot's gift-wrap plumbing** where it already exists in
`Context` (cursor, drain, publish-and-confirm).
4. **No business logic in `cli/`.** The single piece of Android-side
code we depend on (`filterGiftWrapsToPubkey`) is extracted into
`commons/` first, in its own commit.
---
## API surface we reuse (no changes needed)
| Concern | Class | File |
|---|---|---|
| Build `kind:14` chat message template | `ChatMessageEvent.build(msg, to, …)` | `quartz/.../nip17Dm/messages/ChatMessageEvent.kt:51` |
| Sign + seal + wrap (one wrap per recipient incl. self) | `NIP17Factory().createMessageNIP17(template, signer)` | `quartz/.../nip17Dm/NIP17Factory.kt:37` |
| `GiftWrapEvent.create / unwrapOrNull / recipientPubKey` | `GiftWrapEvent` (kind 1059) | `quartz/.../nip59Giftwrap/wraps/GiftWrapEvent.kt` |
| Inner seal layer (kind 13) | `SealedRumorEvent` | `quartz/.../nip59Giftwrap/seals/SealedRumorEvent.kt` |
| NIP-44 v2 encrypt/decrypt (called from wrap/seal) | `Nip44` singleton | `quartz/.../nip44Encryption/Nip44.kt` |
| Resolve recipient relay lists (10050 / 10051 / 10002) | `RecipientRelayFetcher.fetchRelayLists` | `quartz/.../marmot/RecipientRelayFetcher.kt:80` |
| Publish + per-relay ACK | `Context.publish(event, relays)` | `cli/.../Context.kt:154` |
| Drain a subscription until EOSE/timeout | `Context.drain(filters, timeoutMs)` | `cli/.../Context.kt:168` |
| Resolve identifiers | `Context.requireUserHex(input)` | `cli/.../Context.kt:117` |
| In-memory signer (NIP-44 ops included) | `NostrSignerInternal` (`Context.signer`) | `cli/.../Context.kt:68` |
Everything above is already on the JVM classpath of the `cli` module.
---
## Extraction matrix
| Piece | Status | Current | Target | Notes |
|---|---|---|---|---|
| `NIP17Factory`, `GiftWrapEvent`, `SealedRumorEvent`, `Nip44`, `ChatMessageEvent` | ✅ Reuse | `quartz/` | — | Pure protocol; CLI calls directly. |
| `RecipientRelayFetcher.fetchRelayLists` | ✅ Reuse | `quartz/.../marmot/` | — | Already used by `AwaitCommands.awaitKeyPackage`; same call shape. |
| `filterGiftWrapsToPubkey` | 📦 Extract | `amethyst/.../service/relayClient/reqCommand/account/nip59GiftWraps/FilterGiftWrapsToPubkey.kt:31` | `commons/.../relayClient/nip17Dm/FilterGiftWrapsToPubkey.kt` | Pure function, no Android deps. Update Android caller (`AccountGiftWrapsEoseManager.kt:55`) in the same commit. |
| Per-recipient relay routing (kind:10050 → 10002 read → bootstrap) | 🆕 New (in CLI) | inline inside `Account.computeRelayListToBroadcast` (`amethyst/.../model/Account.kt:908`) | `cli/.../commands/DmCommands.kt` (private helper) | Tiny — three-line fallback. Promoting it to commons can wait until a second consumer appears. |
| Send command | 🆕 New | — | `cli/.../commands/DmCommands.kt :: send()` | |
| List command | 🆕 New | — | `cli/.../commands/DmCommands.kt :: list()` | |
| Await command | 🆕 New | — | `cli/.../commands/DmCommands.kt :: await()` | |
| Cursor on disk | ✅ Reuse | `RunState.giftWrapSince` (already used by Marmot `Context.syncIncoming`) | — | Don't fork. DM drain advances the same cursor only when used by `dm list` without `--peer/--since`. |
| Routing in `Main.kt` / `Commands.kt` | 🆕 New | — | `Main.kt` dispatch + `Commands.dm(...)` | One new branch each. |
| Account-bootstrap of own DM inbox (kind:10050) | ⚠️ Not in this plan | `amethyst/.../model/AccountSettings.kt:668` | future `amy relay publish-lists --dm` | Out of scope; we read existing inbox from disk and warn if missing. |
**Rule check (from `amy-expert`):**
- *Rule 1 (thin)*: ✅ — only one new file in `cli/commands/`, < 200 lines.
- *Rule 2 (JSON)*: ✅ — see Output Contract below.
- *Rule 3 (non-interactive)*: ✅ — `--timeout` on the wait verb, no prompts.
- *Rule 4 (data-dir is the world)*: ✅ — relays + signer + cursor all reload from `--data-dir`.
- *Rule 5 (extract first)*: applies once, to `filterGiftWrapsToPubkey` (separate commit).
---
## Send flow (`amy dm send`)
```
parse argv → Context.open → ctx.prepare()
recipients = (positional + --peer) → requireUserHex each
template = ChatMessageEvent.build(text, recipients.map { PTag(it) })
result = NIP17Factory().createMessageNIP17(template, ctx.signer)
─ signs the inner kind:14
─ produces one GiftWrapEvent (1059) per recipient INCLUDING self
for each wrap in result.wraps:
relayList = recipientDmRelays(wrap.recipientPubKey())
─ 1. RecipientRelayFetcher.fetchRelayLists(client, recipient, ctx.bootstrapRelays())
─ 2. .dmInboxOrFallback() (kind:10050 → kind:10002 read marker)
─ 3. fallback to ctx.bootstrapRelays() if both empty
ack = ctx.publish(wrap, relayList)
record { recipient, wrap_id, published_to: ack.true-keys }
emit one JSON line:
{ "event_id": result.msg.id, # inner kind:14 id (stable identity of the message)
"kind": 14,
"recipients": [ {"pubkey":…, "wrap_id":…, "published_to":[…], "relays_tried":[…]} … ] }
```
Notes:
- `result.wraps` already includes a self-addressed wrap; we publish it
the same way as the others so `amy dm list` works as a sanity check
immediately after sending.
- We do NOT add the wrap to any local cache — there is no `LocalCache`
in the CLI; on next `amy dm list` we re-pull from relays.
- If `recipientDmRelays` returns empty for a recipient, exit code `1`
with `{"error":"no_dm_relays","detail":"<npub>"}`. Do not silently drop.
---
## List flow (`amy dm list`)
```
parse argv → Context.open → ctx.prepare()
inbox = ctx.relays.normalized("inbox")
.ifEmpty { ctx.outboxRelays() }
.ifEmpty { ctx.bootstrapRelays() }
since = --since OR (cursor advance mode: state.giftWrapSince OR null)
filters = inbox.flatMap { filterGiftWrapsToPubkey(it, ctx.signer.pubKey, since) }
.groupBy { it.relay }.mapValues { it.value.map(RelayBasedFilter::filter) }
raw = ctx.drain(filters, timeoutMs = --timeout * 1000 ?: 8_000)
messages = raw.mapNotNull { (relay, event) ->
if (event !is GiftWrapEvent) return@mapNotNull null
val sealed = event.unwrapOrNull(ctx.signer) as? SealedRumorEvent ?: return@mapNotNull null
val inner = sealed.unsealOrNull(ctx.signer) as? ChatMessageEvent ?: return@mapNotNull null
DecryptedMsg(inner, relay, event.id)
}.dedupBy { it.inner.id }
.filter { peerHex == null || peerHex in it.inner.groupMembers() }
.sortedBy { it.inner.createdAt }
.takeLast(limit)
emit:
{ "peer": <hex or null>, "messages": [
{ "id": <kind14 id>, "wrap_id": <1059 id>,
"from": <pubkey>, "to": [<pubkey>...],
"content": <plaintext>, "created_at": <unix>,
"relay": <where wrap was found> }, … ] }
```
Cursor handling: only when `--since` and `--peer` are both absent do we
advance `state.giftWrapSince` to `now()` after a successful drain.
Otherwise the call is a stateless query (parity with how Marmot reads
behave). `GIFT_WRAP_LOOKBACK_SECS` (already defined in `Context.kt`) is
applied automatically by `filterGiftWrapsToPubkey` (it does
`since - twoDays()` itself).
---
## Await flow (`amy dm await`)
Mirror `AwaitCommands.awaitMessage` line-for-line
(`cli/.../commands/AwaitCommands.kt`). Difference: matches against the
unwrapped `ChatMessageEvent.content` instead of a Marmot inner event.
Polls every 2 s, decrypts what arrived, returns the first match or
exits `124`.
```json
{ "id": "<kind14 id>", "from": "<pubkey>", "content": "<text>",
"created_at": <unix>, "wrap_id": "<1059 id>", "relay": "<url>" }
```
---
## Output contract (stable JSON keys)
| Verb | Success keys |
|---|---|
| `dm send` | `event_id` (kind 14), `kind`, `recipients[]{pubkey, wrap_id, published_to[], relays_tried[]}` |
| `dm list` | `peer` (hex or null), `messages[]{id, wrap_id, from, to[], content, created_at, relay}` |
| `dm await` | `id, from, content, created_at, wrap_id, relay` |
Errors (stderr, single-line):
- `bad_args` — missing positional / unparseable `npub`
- `no_dm_relays` — recipient has no kind:10050 and no NIP-65 read
- `no_inbox_relays` — local user has no inbox configured AND no bootstrap
- `runtime` — anything else from `Context`/quartz
- `timeout` (124) — only from `dm await`
Adding a new key later is non-breaking; renaming/removing requires a
note in the commit message.
---
## File layout after the change
```
cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/
├── Main.kt # + "dm" branch → Commands.dm(…)
├── commands/
│ ├── Commands.kt # + suspend fun dm(dataDir, tail)
│ └── DmCommands.kt # NEW: object DmCommands { dispatch, send, list, await }
└── (nothing else)
commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/
└── FilterGiftWrapsToPubkey.kt # NEW (extracted from amethyst/)
amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/
├── AccountGiftWrapsEoseManager.kt # import-only update to point at commons
└── FilterGiftWrapsToPubkey.kt # DELETED in the same commit
```
`README.md` table + `ROADMAP.md` row update in the same PR.
---
## Implementation sequence
Each bullet = one commit.
1. **Extract** `filterGiftWrapsToPubkey` from `amethyst/` to
`commons/.../relayClient/nip17Dm/` and re-point the Android caller.
Run `./gradlew :amethyst:compileDebugKotlin :commons:build`.
2. **Add `amy dm send`** — wires `NIP17Factory` + per-recipient relay
resolution + publish. Manual smoke test: send to self, then to a
second test identity in a separate `--data-dir`.
3. **Add `amy dm list`** — drain + unwrap + JSON. Smoke test reads back
the message published in step 2.
4. **Add `amy dm await`** — polling variant, copy of
`AwaitCommands.awaitMessage` shape.
5. **Docs** — add three rows to `cli/README.md` command table; flip
`cli/ROADMAP.md:55` from 🆕 → ✅; record the JSON shape in the
README's "JSON output" section.
6. **`./gradlew spotlessApply`** before each commit.
---
## Open questions / non-goals
- **Publishing our own kind:10050.** Out of scope; assume the user has
one already (created via Amethyst, or via a future
`amy relay publish-lists --dm`). If missing we still send (using
bootstrap as fallback) but do not auto-publish a list.
- **Reactions / replies / encrypted file attachments (kind 32 with
NIP-17 attachment metadata).** Out of scope — separate plan, if ever.
- **NIP-04 legacy DMs (kind 4).** Not supported. NIP-17-only is a
deliberate choice consistent with `nostr-expert` guidance.
- **Persistent local cache of decrypted messages.** Not in this plan.
Each `amy dm list` re-decrypts; identity-equivalence is by inner
kind:14 id so it remains idempotent. If this becomes a perf issue we
add a `dms/<peer>.log` store mirroring Marmot's pattern — separate
commit.
- **Live subscription (`--watch`).** Out of scope; CLI is one-shot by
Rule 3.