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
@@ -211,6 +211,8 @@ private fun printUsage() {
|
| marmot message send GID TEXT publish kind:9 inner event into the group
| marmot message list GID [--limit N] dump decrypted inner events
| marmot message react GID EVENT_ID EMOJI publish kind:7 reaction targeting an inner event
| marmot message delete GID EVENT_ID… publish kind:5 deletion targeting inner events
|
| marmot await key-package NPUB (all await verbs take --timeout SECS, default 30;
| marmot await group --name NAME exit 124 on timeout)
@@ -25,17 +25,20 @@ import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Json
import com.vitorpamplona.quartz.nip01Core.core.Event
object MessageCommands {
suspend fun dispatch(
dataDir: DataDir,
tail: Array<String>,
): Int {
if (tail.isEmpty()) return Json.error("bad_args", "message <send|list> …")
if (tail.isEmpty()) return Json.error("bad_args", "message <send|list|react|delete> …")
val rest = tail.drop(1).toTypedArray()
return when (tail[0]) {
"send" -> send(dataDir, rest)
"list" -> list(dataDir, rest)
"react" -> react(dataDir, rest)
"delete" -> delete(dataDir, rest)
else -> Json.error("bad_args", "message ${tail[0]}")
}
}
@@ -111,4 +114,96 @@ object MessageCommands {
ctx.close()
}
}
private suspend fun react(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.size < 3) return Json.error("bad_args", "message react <gid> <target_event_id> <emoji>")
val targetId = rest[1]
val emoji = rest[2]
val ctx = Context.open(dataDir)
try {
ctx.prepare()
val gid = ctx.resolveGroupId(rest[0])
ctx.syncIncoming()
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
val target = findStoredInnerEvent(ctx, gid, targetId) ?: return Json.error("not_found", targetId)
val bundle = ctx.marmot.buildReactionMessage(gid, target, emoji)
val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
val ack = ctx.publish(bundle.outbound.signedEvent, targets)
Json.writeLine(
mapOf(
"group_id" to gid,
"inner_event_id" to bundle.innerEvent.id,
"outer_event_id" to bundle.outbound.signedEvent.id,
"kind" to bundle.innerEvent.kind,
"target_event_id" to target.id,
"reaction" to emoji,
"published_to" to ack.filterValues { it }.keys.map { it.url },
),
)
return 0
} finally {
ctx.close()
}
}
private suspend fun delete(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.size < 2) return Json.error("bad_args", "message delete <gid> <target_event_id> [target_event_id ...]")
val ctx = Context.open(dataDir)
try {
ctx.prepare()
val gid = ctx.resolveGroupId(rest[0])
ctx.syncIncoming()
if (!ctx.marmot.isMember(gid)) return Json.error("not_member", gid)
val targetIds = rest.drop(1)
val targets =
targetIds.map { id ->
findStoredInnerEvent(ctx, gid, id) ?: return Json.error("not_found", id)
}
val bundle = ctx.marmot.buildDeletionMessage(gid, targets)
val relays = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() }
val ack = ctx.publish(bundle.outbound.signedEvent, relays)
Json.writeLine(
mapOf(
"group_id" to gid,
"inner_event_id" to bundle.innerEvent.id,
"outer_event_id" to bundle.outbound.signedEvent.id,
"kind" to bundle.innerEvent.kind,
"target_event_ids" to targets.map { it.id },
"published_to" to ack.filterValues { it }.keys.map { it.url },
),
)
return 0
} finally {
ctx.close()
}
}
/**
* Scan the group's persisted inner-message log for an event matching
* [targetId] and reconstruct the full `Event`. Required by `react` and
* `delete` because the NIP-25 / NIP-09 templates need the target's
* `pubKey` + `kind` for p-tag / k-tag, not just the id.
*/
private suspend fun findStoredInnerEvent(
ctx: Context,
nostrGroupId: String,
targetId: String,
): Event? {
for (line in ctx.marmot.loadStoredMessages(nostrGroupId)) {
val parsed = Event.fromJsonOrNull(line) ?: continue
if (parsed.id == targetId) return parsed
}
return null
}
}
@@ -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].
*
+21 -4
View File
@@ -37,13 +37,30 @@ test_09_reply_react_unreact() {
amy_json marmot message send "$gid" "replying via amy" >/dev/null || {
record_result "$id" fail "amy send reply failed"; return
}
if wait_for_message B "$mls_gid" "replying via amy" 90; then
if ! wait_for_message B "$mls_gid" "replying via amy" 90; then
record_result "$id" fail "B didn't receive reply"; return
fi
# amy react — look up the anchor id from amy's own decrypted log (B's kind:9
# "anchor for reactions" was delivered + persisted during wait_for_message),
# then hand that id to the new react verb.
sleep 3
local a_anchor_id
a_anchor_id=$(amy_json marmot message list "$gid" --limit 50 2>/dev/null \
| jq -r '[.messages[]? | select((.content // "") == "anchor for reactions")][0].id // empty')
if [[ -z "$a_anchor_id" || "$a_anchor_id" == "null" ]]; then
record_result "$id" fail "amy couldn't find anchor message in local log"; return
fi
if ! amy_json marmot message react "$gid" "$a_anchor_id" "🍕" >/dev/null; then
record_result "$id" fail "amy marmot message react failed"; return
fi
# Round-trip: B should surface amy's kind:7 reaction in its messages stream.
if wait_for_message B "$mls_gid" "🍕" 90; then
record_result "$id" pass
else
record_result "$id" fail "B didn't receive reply"
record_result "$id" fail "B didn't receive amy's reaction"
fi
# NB: react/unreact round-trip verification requires a CLI verb we don't have
# yet (amy marmot message react). Once we add it, expand this test.
}
test_10_concurrent_commits() {