mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
|
||||
+21
-9
@@ -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
|
||||
|
||||
+83
@@ -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] })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user