From 1edbe81b6087eafe5c658ce94c8795df6539868b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Apr 2026 17:20:57 +0000 Subject: [PATCH] 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. --- cli/plans/2026-04-23-nip17-dm.md | 252 +++++++++++++++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 cli/plans/2026-04-23-nip17-dm.md diff --git a/cli/plans/2026-04-23-nip17-dm.md b/cli/plans/2026-04-23-nip17-dm.md new file mode 100644 index 000000000..9d69cfaa2 --- /dev/null +++ b/cli/plans/2026-04-23-nip17-dm.md @@ -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 [--peer ...] +amy dm list [--peer ] [--since ] [--limit N] [--timeout SECS] +amy dm await --peer --match [--timeout SECS] +``` + +`` 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":""}`. 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": , "messages": [ + { "id": , "wrap_id": <1059 id>, + "from": , "to": [...], + "content": , "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.