# NIP-17 DMs in `amy`

> **Status:** shipped — `DmCommands.kt` ships send/list/await and `FilterGiftWrapsToPubkey` is extracted to `commons/.../relayClient/nip17Dm/`.
> _Audited 2026-06-30._

**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) | `SealEvent` | `quartz/.../nip59Giftwrap/seals/SealEvent.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`, `SealEvent`, `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? SealEvent ?: 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.
