Merge pull request #4233 from vitorpamplona/fix/marmot-reactions-in-group

fix(marmot): send reactions and deletions inside the group, not as NIP-17 DMs
This commit is contained in:
Vitor Pamplona
2026-09-27 15:48:53 -04:00
committed by GitHub
6 changed files with 210 additions and 10 deletions
@@ -1538,6 +1538,12 @@ class Account(
note: Note,
reaction: String,
) {
// A Marmot message reacts inside its MLS group; see AccountMarmotActions.reactToMarmotMessage.
marmot.marmotGroupOf(note)?.let { groupId ->
marmot.reactToMarmotMessage(groupId, note, reaction)
return
}
// Reactions to NIP-17 groups and unsealed rumors are gift-wrapped: the
// inner kind-7 only ever travels as ciphertext, so mining it is pure
// waste — those targets skip the queue and sign with the plain signer.
@@ -1703,7 +1709,14 @@ class Account(
suspend fun delete(notes: List<Note>) {
if (!isWriteable()) return
val myNotes = notes.filter { it.author == userProfile() && it.event != null }
// Marmot messages are retracted inside their group. A public NIP-09 here would e-tag
// the group's private rumor ids onto public relays.
val (marmotNotes, otherNotes) = notes.partition { marmot.marmotGroupOf(it) != null }
marmotNotes.groupBy { marmot.marmotGroupOf(it)!! }.forEach { (groupId, groupNotes) ->
marmot.deleteMarmotMessages(groupId, groupNotes)
}
val myNotes = otherNotes.filter { it.author == userProfile() && it.event != null }
if (myNotes.isNotEmpty()) {
// chunks in 200 elements to avoid going over the 65KB limit for events.
myNotes.chunked(200).forEach { chunkedList ->
@@ -1733,6 +1746,12 @@ class Account(
if (!isWriteable()) return
val targetEvent = target.event ?: return
// In a Marmot group the deletion goes to the group, not to the target's author as a DM.
marmot.marmotGroupOf(target)?.let { groupId ->
marmot.deleteMarmotMessages(groupId, notes)
return
}
val myRumors = notes.filter { it.author == userProfile() }.mapNotNull { it.event }
if (myRumors.isEmpty()) return
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.quartz.marmot.appComponents.BlobStoreEndpointV2
import com.vitorpamplona.quartz.marmot.appComponents.EncryptedMediaPolicyV2
import com.vitorpamplona.quartz.marmot.appComponents.GroupAvatarUrlV1
@@ -246,6 +247,54 @@ class AccountMarmotActions(
manager.persistDecryptedMessage(nostrGroupId, innerEvent.toJson())
}
/**
* The Marmot group [note] was received or sent in, or null when it is not a
* Marmot message.
*
* Only chat rows are indexed, so pass the MESSAGE a reaction or deletion is
* about, not the reaction itself.
*/
fun marmotGroupOf(note: Note): HexKey? = account.marmotGroupList.groupIdForNote(note.idHex)
/**
* React to a Marmot message inside its group.
*
* A Marmot message is an unsigned rumor, which the generic reaction path
* handles as a NIP-17 private note: it gift-wrapped the kind:7 to the
* author as a DM. That never reached the group, so no other client showed
* it, and it moved group activity out of the group's channel. The reaction
* is an ordinary inner kind:7 (MIP-03), encrypted to the group like any
* message.
*/
suspend fun reactToMarmotMessage(
nostrGroupId: HexKey,
target: Note,
reaction: String,
) {
val manager = account.marmotManager ?: return
val targetEvent = target.event ?: return
if (target.hasReacted(account.userProfile(), reaction)) return
val rumor = manager.buildReactionRumor(targetEvent, reaction)
sendMarmotGroupMessage(nostrGroupId, rumor, marmotGroupRelays(nostrGroupId))
}
/**
* Retract our own messages or reactions in a Marmot group with an inner
* kind:5, for the same reason [reactToMarmotMessage] exists: the generic
* private path sent a gift-wrapped NIP-09 to the target's author, so the
* other members never saw the deletion.
*/
suspend fun deleteMarmotMessages(
nostrGroupId: HexKey,
notes: List<Note>,
) {
val manager = account.marmotManager ?: return
val mine = notes.filter { it.author == account.userProfile() }.mapNotNull { it.event }
if (mine.isEmpty()) return
val rumor = manager.buildDeletionRumor(mine)
sendMarmotGroupMessage(nostrGroupId, rumor, marmotGroupRelays(nostrGroupId))
}
/**
* Fetch a user's KeyPackage from relays and add them to a Marmot group.
* Returns a status message describing the outcome.
@@ -210,6 +210,7 @@ ALL_TESTS=(
test_27_deletion_wn_to_amy
test_28_retention_wn_to_amy
test_29_disband_amy_to_wn
test_31_reaction_materializes_on_wn
)
# --tests runs a subset in the order given. Most tests read state a previous
+36
View File
@@ -250,3 +250,39 @@ test_17_group_image_commit() {
record_result "$id" fail "wn could not decrypt A's post-image message — image commit not applied"
fi
}
# A reaction White Noise can SEE. Test 09 only proves wn's raw event log holds a
# kind:7; the app renders the materialized timeline, which attaches a reaction
# to its target by its own rules. An amy reaction the raw log kept but the
# timeline dropped read as a pass there and as "no reaction" in the app.
test_31_reaction_materializes_on_wn() {
banner "Test 31 — amy's reaction shows in wn's materialized timeline"
local id="31 reaction materialized"
local out gid mls_gid b_gid anchor_id
out=$(amy_json marmot group create --name "Interop-31") || { record_result "$id" fail "amy group create failed"; return; }
gid=$(printf '%s' "$out" | jq -r '.group_id')
mls_gid=$(printf '%s' "$out" | jq -r '.mls_group_id')
amy_json marmot group add "$gid" "$B_NPUB" >/dev/null || { record_result "$id" fail "amy could not invite wn"; return; }
b_gid=$(wait_for_invite B 60) || { record_result "$id" fail "wn never received the Welcome"; return; }
wn_b groups accept "$b_gid" >/dev/null 2>&1 || true
wn_group_field_becomes "$mls_gid" '.group.group_id // empty' "$mls_gid" 120 || { record_result "$id" fail "wn never surfaced the group"; return; }
wn_b messages send "$mls_gid" "31 anchor" >/dev/null 2>&1 || true
amy_json marmot await message "$gid" --match "31 anchor" --timeout 90 >/dev/null || { record_result "$id" fail "amy never got the anchor"; return; }
anchor_id=$(amy_json marmot message list "$gid" --limit 50 2>/dev/null | jq_list messages \
| jq -r 'select((.plaintext // .content // "") == "31 anchor") | (.message_id // .event_id)' | head -n 1)
[[ -n "$anchor_id" && "$anchor_id" != "null" ]] || { record_result "$id" fail "no anchor id in amy's log"; return; }
amy_json marmot message react "$gid" "$anchor_id" "🍕" >/dev/null || { record_result "$id" fail "amy react failed"; return; }
local deadline=$(( $(date +%s) + 90 )) tl=""
while [[ $(date +%s) -lt $deadline ]]; do
tl=$(wn_b --json messages timeline list "$mls_gid" --limit 50 2>/dev/null || true)
if printf '%s' "$tl" | grep -q '🍕'; then
record_result "$id" pass; return
fi
sleep 3
done
printf '%s\n' "$tl" >> "$LOG_FILE"
record_result "$id" fail "wn's timeline never showed amy's reaction (timeline JSON in the log)"
}
@@ -87,6 +87,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.pTags
import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent
import com.vitorpamplona.quartz.nip18Reposts.quotes.QEventTag
import com.vitorpamplona.quartz.nip18Reposts.quotes.quote
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -570,13 +571,20 @@ class MarmotManager(
targetEvent: Event,
reaction: String,
): Event {
val hint =
com.vitorpamplona.quartz.nip01Core.hints
.EventHintBundle(targetEvent)
// A custom-emoji reaction (":name:url") carries its image in an emoji
// tag, exactly as the public NIP-25 path builds it.
val emojiUrl = if (reaction.startsWith(":")) EmojiUrlTag.decode(reaction) else null
val template =
com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
.build(
reaction,
com.vitorpamplona.quartz.nip01Core.hints
.EventHintBundle(targetEvent),
)
if (emojiUrl != null) {
com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
.build(emojiUrl, hint)
} else {
com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
.build(reaction, hint)
}
return com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
.assembleRumor<com.vitorpamplona.quartz.nip25Reactions.ReactionEvent>(
signer.pubKey,
@@ -739,14 +747,18 @@ class MarmotManager(
targetEvents: List<Event>,
persistOwn: Boolean = true,
): TextMessageBundle {
require(targetEvents.isNotEmpty()) { "buildDeletionMessage: targetEvents must not be empty" }
val template = DeletionRequestEvent.build(targetEvents)
val innerEvent = RumorAssembler.assembleRumor<DeletionRequestEvent>(signer.pubKey, template)
val innerEvent = buildDeletionRumor(targetEvents)
val outbound = buildGroupMessage(nostrGroupId, innerEvent)
if (persistOwn) persistDecryptedMessage(nostrGroupId, innerEvent.toJson())
return TextMessageBundle(outbound = outbound, innerEvent = innerEvent)
}
/** The inner kind:5 deletion rumor alone. See [buildTextRumor]. */
suspend fun buildDeletionRumor(targetEvents: List<Event>): DeletionRequestEvent {
require(targetEvents.isNotEmpty()) { "buildDeletionRumor: targetEvents must not be empty" }
return RumorAssembler.assembleRumor(signer.pubKey, DeletionRequestEvent.build(targetEvents))
}
/**
* A single invitee for [addMemberInvites]: whose KeyPackage is consumed, the bare
* KeyPackage bytes as published, and the id of the event that carried them
@@ -0,0 +1,83 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.marmot
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* The inner rumors the app sends for reactions and deletions in a Marmot group.
* Both used to leave the group as NIP-17 gift wraps to the target's author; they
* are now plain MIP-03 inner events, so their shape is what peers validate.
*/
class MarmotInnerRumorBuildersTest {
private val signer = NostrSignerInternal(KeyPair())
private val manager = MarmotManager(signer, SnapshotStateStore())
private val target =
Event(
id = "b".repeat(64),
pubKey = "c".repeat(64),
createdAt = 1_790_000_000,
kind = 9,
tags = emptyArray(),
content = "react to me",
sig = "",
)
@Test
fun aReactionNamesItsTargetFirstAndIsAnUnsignedRumor() =
runBlocking<Unit> {
val rumor = manager.buildReactionRumor(target, "🎉")
assertEquals(ReactionEvent.KIND, rumor.kind)
assertEquals("🎉", rumor.content)
assertEquals(signer.pubKey, rumor.pubKey)
assertTrue(rumor.sig.isEmpty(), "MIP-03 inner events are unsigned rumors")
// MDK attaches a reaction to the FIRST e tag.
assertEquals(target.id, rumor.tags.first { it[0] == "e" }[1])
}
@Test
fun aCustomEmojiReactionCarriesItsImage() =
runBlocking<Unit> {
val rumor = manager.buildReactionRumor(target, ":soapbox:https://example.com/soapbox.png")
assertEquals(":soapbox:", rumor.content)
val emoji = rumor.tags.first { it[0] == "emoji" }
assertEquals(listOf("emoji", "soapbox", "https://example.com/soapbox.png"), emoji.take(3))
}
@Test
fun aDeletionTargetsEachRetractedEvent() =
runBlocking<Unit> {
val rumor = manager.buildDeletionRumor(listOf(target))
assertEquals(DeletionRequestEvent.KIND, rumor.kind)
assertEquals(signer.pubKey, rumor.pubKey)
assertTrue(rumor.sig.isEmpty())
assertEquals(listOf(target.id), rumor.tags.filter { it[0] == "e" }.map { it[1] })
}
}