feat(cli): add amy marmot message react / delete verbs

Adds two inner-event verbs on top of the existing kind:9 text send path:
  amy marmot message react  GID TARGET_EVENT_ID EMOJI
  amy marmot message delete GID TARGET_EVENT_ID...

Both build an unsigned rumor per MIP-03 (RumorAssembler) and wrap it in
the usual kind:445 outer via MarmotOutboundProcessor — the same shape as
text messages, just a different inner kind. The target event is looked
up in the account's decrypted inner-message log (persisted by the
inbound pipeline) so the NIP-25 / NIP-09 templates can populate the
target's pubkey and kind tags without making the caller re-derive them.

Test 09 previously noted that react round-trip verification was blocked
on a missing CLI verb. Expanded it to react via amy and confirm B sees
the kind:7 surface in its messages stream.

https://claude.ai/code/session_01Unm6uLHGLj9UcBY7hWfJVW
This commit is contained in:
Claude
2026-04-23 20:50:47 +00:00
parent e37899a115
commit 310cae0a64
4 changed files with 174 additions and 5 deletions
@@ -191,6 +191,61 @@ class MarmotManager(
return TextMessageBundle(outbound = outbound, innerEvent = innerEvent)
}
/**
* Build a kind:7 reaction inner event targeting another inner event in
* the same group. Like [buildTextMessage], the inner event is an
* unsigned rumor (MIP-03). The reaction uses NIP-25 conventions: content
* is the emoji / `+` / `-`; e-tag + p-tag + k-tag reference the target.
*/
suspend fun buildReactionMessage(
nostrGroupId: HexKey,
targetEvent: Event,
reaction: String,
persistOwn: Boolean = true,
): TextMessageBundle {
val template =
com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
.build(
reaction,
com.vitorpamplona.quartz.nip01Core.hints
.EventHintBundle(targetEvent),
)
val innerEvent =
com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
.assembleRumor<com.vitorpamplona.quartz.nip25Reactions.ReactionEvent>(
signer.pubKey,
template,
)
val outbound = buildGroupMessage(nostrGroupId, innerEvent)
if (persistOwn) persistDecryptedMessage(nostrGroupId, innerEvent.toJson())
return TextMessageBundle(outbound = outbound, innerEvent = innerEvent)
}
/**
* Build a kind:5 deletion inner event targeting one or more prior inner
* events in the same group. Unsigned rumor (MIP-03); e-tag + k-tag for
* each target per NIP-09.
*/
suspend fun buildDeletionMessage(
nostrGroupId: HexKey,
targetEvents: List<Event>,
persistOwn: Boolean = true,
): TextMessageBundle {
require(targetEvents.isNotEmpty()) { "buildDeletionMessage: targetEvents must not be empty" }
val template =
com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
.build(targetEvents)
val innerEvent =
com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
.assembleRumor<com.vitorpamplona.quartz.nip09Deletions.DeletionEvent>(
signer.pubKey,
template,
)
val outbound = buildGroupMessage(nostrGroupId, innerEvent)
if (persistOwn) persistDecryptedMessage(nostrGroupId, innerEvent.toJson())
return TextMessageBundle(outbound = outbound, innerEvent = innerEvent)
}
/**
* Add a member to a group by consuming their published [KeyPackageEvent].
*