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:
@@ -211,6 +211,8 @@ private fun printUsage() {
|
|||||||
|
|
|
|
||||||
| marmot message send GID TEXT publish kind:9 inner event into the group
|
| 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 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 key-package NPUB (all await verbs take --timeout SECS, default 30;
|
||||||
| marmot await group --name NAME exit 124 on timeout)
|
| 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.Context
|
||||||
import com.vitorpamplona.amethyst.cli.DataDir
|
import com.vitorpamplona.amethyst.cli.DataDir
|
||||||
import com.vitorpamplona.amethyst.cli.Json
|
import com.vitorpamplona.amethyst.cli.Json
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
|
|
||||||
object MessageCommands {
|
object MessageCommands {
|
||||||
suspend fun dispatch(
|
suspend fun dispatch(
|
||||||
dataDir: DataDir,
|
dataDir: DataDir,
|
||||||
tail: Array<String>,
|
tail: Array<String>,
|
||||||
): Int {
|
): 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()
|
val rest = tail.drop(1).toTypedArray()
|
||||||
return when (tail[0]) {
|
return when (tail[0]) {
|
||||||
"send" -> send(dataDir, rest)
|
"send" -> send(dataDir, rest)
|
||||||
"list" -> list(dataDir, rest)
|
"list" -> list(dataDir, rest)
|
||||||
|
"react" -> react(dataDir, rest)
|
||||||
|
"delete" -> delete(dataDir, rest)
|
||||||
else -> Json.error("bad_args", "message ${tail[0]}")
|
else -> Json.error("bad_args", "message ${tail[0]}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,4 +114,96 @@ object MessageCommands {
|
|||||||
ctx.close()
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+55
@@ -191,6 +191,61 @@ class MarmotManager(
|
|||||||
return TextMessageBundle(outbound = outbound, innerEvent = innerEvent)
|
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].
|
* Add a member to a group by consuming their published [KeyPackageEvent].
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -37,13 +37,30 @@ test_09_reply_react_unreact() {
|
|||||||
amy_json marmot message send "$gid" "replying via amy" >/dev/null || {
|
amy_json marmot message send "$gid" "replying via amy" >/dev/null || {
|
||||||
record_result "$id" fail "amy send reply failed"; return
|
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
|
record_result "$id" pass
|
||||||
else
|
else
|
||||||
record_result "$id" fail "B didn't receive reply"
|
record_result "$id" fail "B didn't receive amy's reaction"
|
||||||
fi
|
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() {
|
test_10_concurrent_commits() {
|
||||||
|
|||||||
Reference in New Issue
Block a user