diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index dcac7da55..450364f4b 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -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) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/MessageCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/MessageCommands.kt index 5cc5bf9d1..1ed952258 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/MessageCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/MessageCommands.kt @@ -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, ): Int { - if (tail.isEmpty()) return Json.error("bad_args", "message …") + if (tail.isEmpty()) return Json.error("bad_args", "message …") 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, + ): Int { + if (rest.size < 3) return Json.error("bad_args", "message react ") + 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, + ): Int { + if (rest.size < 2) return Json.error("bad_args", "message delete [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 + } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt index aa3a561ff..5aa55bb1d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt @@ -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( + 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, + 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( + 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]. * diff --git a/tools/marmot-interop/headless/tests-extras.sh b/tools/marmot-interop/headless/tests-extras.sh index 6e8495856..fd13abfa2 100644 --- a/tools/marmot-interop/headless/tests-extras.sh +++ b/tools/marmot-interop/headless/tests-extras.sh @@ -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() {