Files
amethyst/cli/plans/2026-04-23-nip17-dm.md
T
Claude 1edbe81b60 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.
2026-04-23 17:20:57 +00:00

12 KiB

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.

{ "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.