mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 08:47:33 +00:00
fix: load a Concord reply's parent off the channel plane, not by id
A kind-1111 Concord reply that arrives by push notification rendered its
reply-to slot as an unresolvable `nostr:nevent1…`: the parent kind-9 message
was never in the cache and the EventFinder could not fetch it.
A Concord chat message is never published as a bare event. It is an unsigned
rumor sealed inside a kind-1059 wrap authored by the channel's derived plane
key (CORD-03), and its id only comes into existence once that wrap decrypts
locally. So the loader's `{"ids":[<rumor id>]}` REQ could never be served by
any relay — and sending it published a private rumor id to public relays, the
exact thing Note.isPrivateRumor warns against.
- `LocalCache.getChannelToLoadFrom` resolves the room a note belongs to using
every clue available, not just the event tags `getAnyChannel` reads: the
gatherer the ingest path attached (the only complete source of a Concord
ConcordChannelId, whose community half isn't in the rumor), the channel index
keyed by the `channel` tag, and NIP-29/Buzz relay groups (keyed by host relay
+ group id, which is provenance, not a tag). It also answers for a note whose
own event never arrived, by borrowing the room from a consumed child — a reply
or reaction is always in the same room as what it points at.
- `filterMissingConcordRumors` fetches a missing Concord message the only way
that works: a bounded backward window on the channel plane (all held epochs,
so it crosses a CORD-06 Refounding), on the community's relays, topped at the
newest reference we hold. The wraps ingest and decrypt normally, which
populates the note. `filterMissingEvents` no longer emits ids for these.
- The event loaders now pick relays through `getChannelToLoadFrom`, so an
unloaded NIP-29/Buzz group message is asked for on the group's host relay
instead of the account's default set.
- `EventWatcherSubAssembler` skips private rumors: `#e=<rumor id>` can never
match a wrapped event, and asking leaks the id.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QuYJfe32fxJKUHDYuJuSNa
This commit is contained in:
@@ -138,6 +138,7 @@ import com.vitorpamplona.quartz.buzz.wpWorkspaceProfile.SetWorkspaceProfileEvent
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.concordChannel
|
||||
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
|
||||
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
|
||||
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
|
||||
@@ -1630,6 +1631,63 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* The Concord channel a Chat Plane rumor is bound to, found by its `["channel", <id>]` tag alone
|
||||
* (CORD-03). A rumor commits to only the channel half of a [ConcordChannelId] — the community
|
||||
* half lives on the session whose key opened the wrap — so the pair is recovered from the channel
|
||||
* index. Two joined communities would have to have drawn the same random 32-byte channel id for
|
||||
* this to be ambiguous, so a single match is the answer and anything else counts as unknown.
|
||||
*/
|
||||
fun getConcordChannelByChannelId(channelIdHex: HexKey): ConcordChannel? = concordChannels.filter { key, _ -> key.channelId == channelIdHex }.singleOrNull()
|
||||
|
||||
/**
|
||||
* The chat channel a [note] belongs to, using every clue available rather than only the event
|
||||
* tags [getAnyChannel] reads. This is the "where do I fetch this note from" resolver the event
|
||||
* loaders ask (see `potentialRelaysToFindEvent`), so it has to answer for the channel kinds whose
|
||||
* identity is *not* fully in the event:
|
||||
* - **Concord** — the rumor names its channel but not its community, so the complete
|
||||
* [ConcordChannelId] only exists on the gatherer the ingest path attached (or, failing that,
|
||||
* in the channel index keyed by channel id).
|
||||
* - **NIP-29 / Buzz relay group** — the group is keyed by (host relay, group id) and the host
|
||||
* relay is the note's provenance, not a tag, so it needs the [Note], not the [Event].
|
||||
*
|
||||
* It also answers for a note whose **own event never arrived** — a reply's unloaded parent, the
|
||||
* usual shape in a notification feed — by borrowing the room from a child that points at it.
|
||||
*/
|
||||
fun getChannelToLoadFrom(note: Note): Channel? {
|
||||
channelOfLoadedNote(note)?.let { return it }
|
||||
|
||||
// The event never arrived, so nothing about this note itself says where it lives. Everything
|
||||
// that points AT it, though, was posted in the same room: a Concord rumor is cryptographically
|
||||
// bound to its channel and a NIP-29 message carries the group's `h` tag, so a reply or a
|
||||
// reaction we did consume names the room its parent must be fetched from.
|
||||
if (note.event == null) {
|
||||
note.replies.firstNotNullOfOrNull { channelOfLoadedNote(it) }?.let { return it }
|
||||
note.reactions.values
|
||||
.firstNotNullOfOrNull { sameKind -> sameKind.firstNotNullOfOrNull { channelOfLoadedNote(it) } }
|
||||
?.let { return it }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/** The non-recursive half of [getChannelToLoadFrom]: resolves only from the note's own event/gatherers. */
|
||||
private fun channelOfLoadedNote(note: Note): Channel? {
|
||||
// The gatherer the ingest path attached is authoritative (see unlinkAndRemove) and is the only
|
||||
// complete source for a Concord channel.
|
||||
note.inGatherers?.firstNotNullOfOrNull { it as? Channel }?.let { return it }
|
||||
|
||||
val noteEvent = note.event ?: return null
|
||||
|
||||
getAnyChannel(noteEvent)?.let { return it }
|
||||
|
||||
noteEvent.tags.concordChannel()?.let { channelIdHex ->
|
||||
getConcordChannelByChannelId(channelIdHex)?.let { return it }
|
||||
}
|
||||
|
||||
return getRelayGroupChannelForContent(note)
|
||||
}
|
||||
|
||||
/**
|
||||
* NIP-09 delete of a single targeted event.
|
||||
*
|
||||
|
||||
+3
-3
@@ -38,12 +38,12 @@ fun potentialRelaysToFindAddress(note: AddressableNote): Set<NormalizedRelayUrl>
|
||||
|
||||
set.addAll(LocalCache.relayHints.hintsForAddress(note.idHex))
|
||||
|
||||
LocalCache.getAnyChannel(note)?.relays()?.let { set.addAll(it) }
|
||||
LocalCache.getChannelToLoadFrom(note)?.relays()?.let { set.addAll(it) }
|
||||
|
||||
note.replyTo?.forEach { parentNote ->
|
||||
set.addAll(parentNote.relays)
|
||||
|
||||
LocalCache.getAnyChannel(parentNote)?.relays()?.let { set.addAll(it) }
|
||||
LocalCache.getChannelToLoadFrom(parentNote)?.relays()?.let { set.addAll(it) }
|
||||
|
||||
parentNote.author?.inboxRelays()?.let { set.addAll(it) }
|
||||
}
|
||||
@@ -51,7 +51,7 @@ fun potentialRelaysToFindAddress(note: AddressableNote): Set<NormalizedRelayUrl>
|
||||
note.replies.forEach { childNote ->
|
||||
set.addAll(childNote.relays)
|
||||
|
||||
LocalCache.getAnyChannel(childNote)?.relays()?.let { set.addAll(it) }
|
||||
LocalCache.getChannelToLoadFrom(childNote)?.relays()?.let { set.addAll(it) }
|
||||
|
||||
childNote.author?.outboxRelays()?.let { set.addAll(it) }
|
||||
}
|
||||
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* 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.service.relayClient.reqCommand.event.loaders
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
|
||||
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* How many plane wraps one backfill window pulls. Sized like the Concord catch-up window
|
||||
* ([com.vitorpamplona.amethyst.commons.actions.ConcordSubscriptionPlanner.channelPreviewFilters]):
|
||||
* a thread reply answers something recent, so the message it replies to is a handful of wraps below
|
||||
* it. A reply to a much older message still opens fine from the message itself — the minichat and
|
||||
* the channel screen mount the channel's backward-history pager, which walks the whole plane.
|
||||
*/
|
||||
private const val CONCORD_BACKFILL_LIMIT = 50
|
||||
|
||||
/**
|
||||
* The Concord channel a missing [note] can only be fetched through, or null when it isn't Concord
|
||||
* content. Also the "never ask a relay for this by id" test [filterMissingEvents] applies.
|
||||
*/
|
||||
fun concordChannelToLoadFrom(note: Note): ConcordChannel? = LocalCache.getChannelToLoadFrom(note) as? ConcordChannel
|
||||
|
||||
/**
|
||||
* Re-requests the Concord Chat Plane a missing chat message rode in on.
|
||||
*
|
||||
* A Concord message is never published as a bare event: it is an unsigned *rumor* sealed inside a
|
||||
* kind-1059 wrap authored by the channel's derived plane key (CORD-03), and its id only comes into
|
||||
* existence once that wrap decrypts locally. So `{"ids":[<rumor id>]}` can never find one — no relay
|
||||
* indexes it — and asking would publish a private rumor id to public relays, exactly what
|
||||
* [Note.isPrivateRumor] warns against. [filterMissingEvents] therefore skips these ids, and this
|
||||
* fetches them the only way that works: a bounded backward window on the channel plane, on the
|
||||
* community's relays, ending at the newest thing we hold that references the message. The wraps land
|
||||
* through the normal ingest path (`concordSessions.ingest` → decrypt → `consumeConcordRumor`), which
|
||||
* populates the missing [Note] and lets the reply render its parent.
|
||||
*
|
||||
* This is what makes a **reply notification** render its parent: the notification's kind-1111 reply
|
||||
* arrived over the plane, so it carries the channel — and thus the plane keys and relays — even though
|
||||
* we have never seen the message it answers. The plane keys span every held epoch, so the window still
|
||||
* reaches a parent written before a CORD-06 Refounding.
|
||||
*/
|
||||
fun filterMissingConcordRumors(keys: List<EventFinderQueryState>): List<RelayBasedFilter> {
|
||||
if (keys.isEmpty()) return emptyList()
|
||||
|
||||
// Deduped by value: several visible replies to the same unloaded parent — or to parents in the same
|
||||
// channel whose newest reference lands on the same second — collapse into one REQ per relay instead
|
||||
// of one per note. [RelayBasedFilter]/[Filter] carry no value equality, hence the explicit key.
|
||||
val windows = LinkedHashSet<ConcordPlaneWindow>()
|
||||
|
||||
keys.forEach { key ->
|
||||
missingConcordNotes(key).forEach { (note, channel) ->
|
||||
val planePks =
|
||||
key.account.concordSessions
|
||||
.sessionFor(channel.channelId.communityId)
|
||||
?.channelPlaneAddressesAllEpochs(channel.channelId.channelId)
|
||||
?.sorted()
|
||||
|
||||
// Empty until the Control Plane folds the channel; the always-on plane preload gets us there.
|
||||
if (planePks.isNullOrEmpty()) return@forEach
|
||||
|
||||
val until = untilFor(note)
|
||||
channel.relays().forEach { relay -> windows.add(ConcordPlaneWindow(relay, planePks, until)) }
|
||||
}
|
||||
}
|
||||
|
||||
return windows.map { window ->
|
||||
RelayBasedFilter(
|
||||
relay = window.relay,
|
||||
filter =
|
||||
Filter(
|
||||
// Stored wraps only — the ephemeral 21059 is a typing heartbeat and carries no history.
|
||||
kinds = listOf(ConcordStreamEnvelope.KIND_WRAP),
|
||||
authors = window.planePks,
|
||||
until = window.until,
|
||||
limit = CONCORD_BACKFILL_LIMIT,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** One backfill window: the channel's plane keys across every held epoch, on one relay, up to [until]. */
|
||||
private data class ConcordPlaneWindow(
|
||||
val relay: NormalizedRelayUrl,
|
||||
val planePks: List<String>,
|
||||
val until: Long,
|
||||
)
|
||||
|
||||
/** The unloaded Concord notes this key needs: the note itself and whatever it replies to. */
|
||||
private fun missingConcordNotes(key: EventFinderQueryState): List<Pair<Note, ConcordChannel>> =
|
||||
buildList {
|
||||
addIfMissingConcord(key.note)
|
||||
key.note.replyTo?.forEach { addIfMissingConcord(it) }
|
||||
}
|
||||
|
||||
private fun MutableList<Pair<Note, ConcordChannel>>.addIfMissingConcord(note: Note) {
|
||||
if (note is AddressableNote || note.event != null) return
|
||||
concordChannelToLoadFrom(note)?.let { add(note to it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The top of the backfill window: the newest event we hold that points at [note]. A reply or a
|
||||
* reaction is necessarily younger than what it answers, so the wanted wrap sits at or below that
|
||||
* instant (`until` is inclusive). Falls back to now when nothing dates the reference — and stays a
|
||||
* pure function of the cache, so re-deriving the filters after EOSE produces the identical REQ
|
||||
* instead of churning the subscription.
|
||||
*/
|
||||
private fun untilFor(note: Note): Long {
|
||||
val newestReference =
|
||||
(
|
||||
note.replies.mapNotNull { it.createdAt() } +
|
||||
note.reactions.values
|
||||
.flatten()
|
||||
.mapNotNull { it.createdAt() }
|
||||
).maxOrNull()
|
||||
|
||||
return newestReference ?: TimeUtils.now()
|
||||
}
|
||||
+17
-5
@@ -38,12 +38,16 @@ fun potentialRelaysToFindEvent(note: Note): Set<NormalizedRelayUrl> {
|
||||
|
||||
note.author?.outboxRelays()?.let { set.addAll(it) }
|
||||
|
||||
LocalCache.getAnyChannel(note)?.relays()?.let { set.addAll(it) }
|
||||
// getChannelToLoadFrom, not getAnyChannel: a note we have never seen has no event to read a
|
||||
// channel off, and the two chat kinds keyed outside the event tags (Concord, NIP-29/Buzz relay
|
||||
// groups) only resolve through the gatherers/provenance it also consults. That is what aims a
|
||||
// missing group message at its host relay instead of the account's default relay set.
|
||||
LocalCache.getChannelToLoadFrom(note)?.relays()?.let { set.addAll(it) }
|
||||
|
||||
note.replyTo?.forEach { parentNote ->
|
||||
set.addAll(parentNote.relays)
|
||||
|
||||
LocalCache.getAnyChannel(parentNote)?.relays()?.let { set.addAll(it) }
|
||||
LocalCache.getChannelToLoadFrom(parentNote)?.relays()?.let { set.addAll(it) }
|
||||
|
||||
parentNote.author?.inboxRelays()?.let { set.addAll(it) }
|
||||
}
|
||||
@@ -51,7 +55,7 @@ fun potentialRelaysToFindEvent(note: Note): Set<NormalizedRelayUrl> {
|
||||
note.replies.forEach { childNote ->
|
||||
set.addAll(childNote.relays)
|
||||
|
||||
LocalCache.getAnyChannel(childNote)?.relays()?.let { set.addAll(it) }
|
||||
LocalCache.getChannelToLoadFrom(childNote)?.relays()?.let { set.addAll(it) }
|
||||
|
||||
childNote.author?.outboxRelays()?.let { set.addAll(it) }
|
||||
}
|
||||
@@ -97,13 +101,21 @@ fun potentialRelaysToFindEvent(note: Note): Set<NormalizedRelayUrl> {
|
||||
return set
|
||||
}
|
||||
|
||||
/**
|
||||
* True when [note] can only ever arrive inside a wrapped stream (today: a Concord Chat Plane rumor),
|
||||
* so an `{"ids":[…]}` REQ for it is futile *and* harmful — no relay indexes an id that only exists
|
||||
* after a local decrypt, and asking leaks a private rumor id. [filterMissingConcordRumors] fetches
|
||||
* these off the plane instead.
|
||||
*/
|
||||
private fun isFetchedByPlane(note: Note) = concordChannelToLoadFrom(note) != null
|
||||
|
||||
fun filterMissingEvents(keys: List<EventFinderQueryState>): List<RelayBasedFilter> {
|
||||
val eventsPerRelay =
|
||||
mapOfSet {
|
||||
keys.forEach { key ->
|
||||
val default = key.account.followPlusAllMineWithSearch.flow.value
|
||||
|
||||
if (key.note !is AddressableNote && key.note.event == null) {
|
||||
if (key.note !is AddressableNote && key.note.event == null && !isFetchedByPlane(key.note)) {
|
||||
potentialRelaysToFindEvent(key.note).ifEmpty { default }.forEach { relayUrl ->
|
||||
add(relayUrl, key.note.idHex)
|
||||
}
|
||||
@@ -115,7 +127,7 @@ fun filterMissingEvents(keys: List<EventFinderQueryState>): List<RelayBasedFilte
|
||||
|
||||
// loads threading that is event-based
|
||||
key.note.replyTo?.forEach { note ->
|
||||
if (note !is AddressableNote && note.event == null) {
|
||||
if (note !is AddressableNote && note.event == null && !isFetchedByPlane(note)) {
|
||||
potentialRelaysToFindEvent(note).ifEmpty { default }.forEach { relayUrl ->
|
||||
add(relayUrl, note.idHex)
|
||||
}
|
||||
|
||||
+3
@@ -32,6 +32,9 @@ class NoteEventLoaderSubAssembler(
|
||||
listOfNotNull(
|
||||
filterMissingEvents(keys),
|
||||
filterMissingAddressables(keys),
|
||||
// Concord chat messages have no fetchable id — they only exist inside a plane wrap — so
|
||||
// they need their own filter shape instead of riding the `ids` REQ above.
|
||||
filterMissingConcordRumors(keys),
|
||||
).flatten()
|
||||
|
||||
override fun distinct(key: EventFinderQueryState) = key.note
|
||||
|
||||
+8
-1
@@ -58,7 +58,14 @@ class EventWatcherSubAssembler(
|
||||
return null
|
||||
}
|
||||
|
||||
lastNotesOnFilter = keys.map { it.note }
|
||||
// Private rumors are excluded. A rumor (a Concord chat message, a gift-wrapped DM or private
|
||||
// reaction) is unsigned and exists only inside its wrap, so no relay indexes its id and
|
||||
// `#e=<rumor id>` can never return anything — while asking publishes a private rumor id to a
|
||||
// relay, exactly what Note.isPrivateRumor warns against. Their replies and reactions arrive over
|
||||
// the same wrapped plane the message itself did.
|
||||
lastNotesOnFilter = keys.mapNotNull { key -> key.note.takeIf { !it.isPrivateRumor() } }
|
||||
|
||||
if (lastNotesOnFilter.isEmpty()) return null
|
||||
|
||||
return groupByRelayPresence(lastNotesOnFilter, latestEOSEs)
|
||||
.map { group ->
|
||||
|
||||
+252
@@ -0,0 +1,252 @@
|
||||
/*
|
||||
* 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.service.relayClient.reqCommand.event.loaders
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunitySession
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
|
||||
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* A reply notification from a Concord community must be able to render the message it answers.
|
||||
*
|
||||
* The reported failure: a kind-1111 Concord reply arrives by push, its parent kind-9 message isn't in
|
||||
* the cache, and the reply-to slot stays an unresolvable `nostr:nevent1…`. The reason is that the
|
||||
* parent is a *rumor* — it only ever exists inside a kind-1059 Chat Plane wrap — so the loader's
|
||||
* `{"ids":[…]}` REQ can never find it (and leaks the private rumor id while trying). These tests pin
|
||||
* the two halves of the fix: the id REQ is not emitted, and a plane window aimed at the community's
|
||||
* relays is.
|
||||
*/
|
||||
class FilterMissingConcordRumorsTest {
|
||||
private val relay = RelayUrlNormalizer.normalizeOrNull("wss://relay.concord.example/")!!
|
||||
private val otherRelay = RelayUrlNormalizer.normalizeOrNull("wss://someone-elses-relay.example/")!!
|
||||
|
||||
private val communityId = "b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1"
|
||||
private val channelIdHex = "b4853b67e720d3bbb96afad899648d8403139698bbf3c68826df2c8d1bca4e23"
|
||||
private val planePk = "cc00cc00cc00cc00cc00cc00cc00cc00cc00cc00cc00cc00cc00cc00cc00cc00"
|
||||
private val priorEpochPlanePk = "aa00aa00aa00aa00aa00aa00aa00aa00aa00aa00aa00aa00aa00aa00aa00aa00"
|
||||
|
||||
/** The reply from the report, verbatim: a kind-1111 comment bound to the channel, unsigned (a rumor). */
|
||||
private val replyJson =
|
||||
"""
|
||||
{
|
||||
"id": "93da2ffc2efb228b7d956c51ec15f19fcf2a4a0ebb536b4330a16d899d862250",
|
||||
"pubkey": "5e759c2ca4a4e222ba7af89e6ff315e1d27843fe8bd0a3e7e61e4ba5b1c07326",
|
||||
"created_at": 1785382199,
|
||||
"kind": 1111,
|
||||
"tags": [
|
||||
["channel", "b4853b67e720d3bbb96afad899648d8403139698bbf3c68826df2c8d1bca4e23"],
|
||||
["epoch", "2"],
|
||||
["K", "9"],
|
||||
["E", "f514388dcfe5e4da8051c363545848e0ecbd55aa618be9658a5385df1344a007", "", "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"],
|
||||
["P", "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"],
|
||||
["k", "9"],
|
||||
["e", "f514388dcfe5e4da8051c363545848e0ecbd55aa618be9658a5385df1344a007", "", "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"],
|
||||
["p", "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"],
|
||||
["ms", "62"]
|
||||
],
|
||||
"content": "I saw you fixed it in today's update.",
|
||||
"sig": ""
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
private val parentId = "f514388dcfe5e4da8051c363545848e0ecbd55aa618be9658a5385df1344a007"
|
||||
private val replyId = "93da2ffc2efb228b7d956c51ec15f19fcf2a4a0ebb536b4330a16d899d862250"
|
||||
|
||||
/**
|
||||
* [LocalCache] is a singleton, so each test has to start from an empty pair of notes — otherwise a
|
||||
* previous test's channel gatherer (a different mock) is still attached to the reply and answers
|
||||
* for it.
|
||||
*/
|
||||
@Before
|
||||
fun dropCachedNotes() {
|
||||
LocalCache.notes.remove(parentId)
|
||||
LocalCache.notes.remove(replyId)
|
||||
}
|
||||
|
||||
/**
|
||||
* The channel a Concord rumor is attached to at ingest. Mocked rather than folded from a Control
|
||||
* Plane: all the loaders read off it is its id and its community's relays.
|
||||
*/
|
||||
private fun concordChannel() =
|
||||
mockk<ConcordChannel>(relaxed = true).also {
|
||||
every { it.channelId } returns ConcordChannelId(communityId, channelIdHex)
|
||||
every { it.relays() } returns setOf(relay)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reproduces the post-ingest cache state of the reported notification: the kind-1111 reply loaded
|
||||
* and attached to its Concord channel, its parent a bare id-only [Note]. Returns the parent.
|
||||
*/
|
||||
private fun landReplyWithUnloadedParent(channel: ConcordChannel?): Note {
|
||||
val replyEvent = Event.fromJson(replyJson)
|
||||
val parent = LocalCache.getOrCreateNote(parentId)
|
||||
val reply = LocalCache.getOrCreateNote(replyEvent.id)
|
||||
|
||||
reply.loadEvent(replyEvent, LocalCache.getOrCreateUser(replyEvent.pubKey), listOf(parent))
|
||||
// The wrap it rode in on was seen here, so this is the only relay the reply itself points at.
|
||||
reply.addRelay(relay)
|
||||
parent.addReply(reply)
|
||||
channel?.let { reply.addGatherer(it) }
|
||||
|
||||
return parent
|
||||
}
|
||||
|
||||
/**
|
||||
* An account with no relay lists of its own, so the only relays any filter can name are the ones
|
||||
* the notes/channel supply — which is what these assertions are about.
|
||||
*/
|
||||
private fun stubAccount(): Account {
|
||||
val account = mockk<Account>(relaxed = true)
|
||||
every { account.followPlusAllMineWithSearch.flow } returns MutableStateFlow(emptySet())
|
||||
every { account.searchRelayList.flow } returns MutableStateFlow(emptySet())
|
||||
return account
|
||||
}
|
||||
|
||||
/** An account whose Control Plane has folded this channel, so its plane keys are derivable. */
|
||||
private fun accountWithFoldedChannel(): Account {
|
||||
val account = stubAccount()
|
||||
val session = mockk<ConcordCommunitySession>(relaxed = true)
|
||||
every { account.concordSessions.sessionFor(communityId) } returns session
|
||||
// Every held epoch's plane, so the window reaches across a CORD-06 Refounding.
|
||||
every { session.channelPlaneAddressesAllEpochs(channelIdHex) } returns listOf(planePk, priorEpochPlanePk)
|
||||
return account
|
||||
}
|
||||
|
||||
private fun accountWithUnfoldedChannel(): Account {
|
||||
val account = stubAccount()
|
||||
every { account.concordSessions.sessionFor(any()) } returns null
|
||||
return account
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the channel of an unloaded parent is borrowed from the reply that arrived over its plane`() {
|
||||
val channel = concordChannel()
|
||||
val parent = landReplyWithUnloadedParent(channel)
|
||||
|
||||
assertSame("the parent's room comes from its consumed reply", channel, LocalCache.getChannelToLoadFrom(parent))
|
||||
assertSame(channel, concordChannelToLoadFrom(parent))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a missing Concord parent is never requested by id`() {
|
||||
val channel = concordChannel()
|
||||
val parent = landReplyWithUnloadedParent(channel)
|
||||
val account = accountWithFoldedChannel()
|
||||
val keys = listOf(EventFinderQueryState(parent, account))
|
||||
|
||||
val ids = filterMissingEvents(keys).flatMap { it.filter.ids.orEmpty() }
|
||||
assertTrue("a rumor id must never be sent to a relay, it cannot be served: $ids", ids.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a missing Concord parent is fetched as a plane window on the community relays`() {
|
||||
val channel = concordChannel()
|
||||
val parent = landReplyWithUnloadedParent(channel)
|
||||
val account = accountWithFoldedChannel()
|
||||
val keys = listOf(EventFinderQueryState(parent, account))
|
||||
|
||||
val filters = filterMissingConcordRumors(keys)
|
||||
assertEquals(1, filters.size)
|
||||
assertEquals(relay, filters[0].relay)
|
||||
|
||||
val filter = filters[0].filter
|
||||
assertEquals(listOf(ConcordStreamEnvelope.KIND_WRAP), filter.kinds)
|
||||
assertEquals(listOf(planePk, priorEpochPlanePk).sorted(), filter.authors)
|
||||
// The window tops out at the reply — the message it answers is necessarily at or below it.
|
||||
assertEquals(1785382199L, filter.until)
|
||||
assertEquals(50, filter.limit)
|
||||
assertNull("a plane window is never an ids REQ", filter.ids)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the reply's own key backfills its parent, which is how a notification card loads it`() {
|
||||
// The EventFinder is mounted on the note the card renders — the reply — not on the parent.
|
||||
val channel = concordChannel()
|
||||
val parent = landReplyWithUnloadedParent(channel)
|
||||
val reply = parent.replies.single()
|
||||
val keys = listOf(EventFinderQueryState(reply, accountWithFoldedChannel()))
|
||||
|
||||
assertEquals(1, filterMissingConcordRumors(keys).size)
|
||||
assertTrue(filterMissingEvents(keys).flatMap { it.filter.ids.orEmpty() }.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing is requested until the Control Plane folds the channel`() {
|
||||
val parent = landReplyWithUnloadedParent(concordChannel())
|
||||
val keys = listOf(EventFinderQueryState(parent, accountWithUnfoldedChannel()))
|
||||
|
||||
assertTrue(filterMissingConcordRumors(keys).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a plain missing parent is still fetched by id`() {
|
||||
// Guard against over-blocking: only Concord content skips the ids REQ.
|
||||
val parent = landReplyWithUnloadedParent(channel = null)
|
||||
val keys = listOf(EventFinderQueryState(parent, accountWithUnfoldedChannel()))
|
||||
|
||||
val byRelay = filterMissingEvents(keys)
|
||||
assertEquals(listOf(relay), byRelay.map { it.relay })
|
||||
assertEquals(listOf(listOf(parentId)), byRelay.map { it.filter.ids })
|
||||
assertTrue(filterMissingConcordRumors(keys).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two replies to the same unloaded parent collapse into one plane window`() {
|
||||
val channel = concordChannel()
|
||||
val parent = landReplyWithUnloadedParent(channel)
|
||||
val account = accountWithFoldedChannel()
|
||||
val reply = parent.replies.single()
|
||||
|
||||
val keys =
|
||||
listOf(
|
||||
EventFinderQueryState(parent, account),
|
||||
EventFinderQueryState(reply, account),
|
||||
)
|
||||
|
||||
assertEquals(1, filterMissingConcordRumors(keys).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unrelated relay is not asked for the plane`() {
|
||||
val channel = concordChannel()
|
||||
every { channel.relays() } returns setOf(relay)
|
||||
val parent = landReplyWithUnloadedParent(channel)
|
||||
val keys = listOf(EventFinderQueryState(parent, accountWithFoldedChannel()))
|
||||
|
||||
assertTrue(filterMissingConcordRumors(keys).none { it.relay == otherRelay })
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* 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.service.relayClient.reqCommand.event.loaders
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* A NIP-29 / Buzz group's history lives on ONE relay — its host — and nowhere else, so the id REQ for
|
||||
* an unloaded message in that group has to name that relay. The group is keyed by (host relay, group
|
||||
* id), which is not derivable from the event's tags alone, so the loader has to resolve it through the
|
||||
* channel the ingest path attached (`LocalCache.getChannelToLoadFrom`) rather than the tag-only
|
||||
* `getAnyChannel`. Without that, a message whose reply reached us from a mirror — or with no relay
|
||||
* provenance at all — is only ever asked for on the account's default relays, which don't carry the
|
||||
* group.
|
||||
*/
|
||||
class FilterMissingEventsChannelRelaysTest {
|
||||
private val hostRelay = RelayUrlNormalizer.normalizeOrNull("wss://buzz.host.example/")!!
|
||||
private val mirrorRelay = RelayUrlNormalizer.normalizeOrNull("wss://mirror.example/")!!
|
||||
|
||||
private val parentId = "1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a"
|
||||
private val replyId = "2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b"
|
||||
|
||||
/** A NIP-29 kind-9 group message answering [parentId], scoped to group `g1` by its `h` tag. */
|
||||
private val replyJson =
|
||||
"""
|
||||
{
|
||||
"id": "$replyId",
|
||||
"pubkey": "5e759c2ca4a4e222ba7af89e6ff315e1d27843fe8bd0a3e7e61e4ba5b1c07326",
|
||||
"created_at": 1785382199,
|
||||
"kind": 9,
|
||||
"tags": [
|
||||
["h", "g1"],
|
||||
["e", "$parentId", "", "reply"]
|
||||
],
|
||||
"content": "same question here",
|
||||
"sig": ""
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
@Before
|
||||
fun dropCachedNotes() {
|
||||
LocalCache.notes.remove(parentId)
|
||||
LocalCache.notes.remove(replyId)
|
||||
}
|
||||
|
||||
private fun stubAccount(): Account {
|
||||
val account = mockk<Account>(relaxed = true)
|
||||
every { account.followPlusAllMineWithSearch.flow } returns MutableStateFlow(emptySet())
|
||||
every { account.searchRelayList.flow } returns MutableStateFlow(emptySet())
|
||||
every { account.concordSessions.sessionFor(any()) } returns null
|
||||
return account
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unloaded group parent is asked for on the group's host relay, not only where the reply came from`() {
|
||||
val group = RelayGroupChannel(GroupId("g1", hostRelay))
|
||||
val replyEvent = Event.fromJson(replyJson)
|
||||
|
||||
val parent = LocalCache.getOrCreateNote(parentId)
|
||||
val reply = LocalCache.getOrCreateNote(replyId)
|
||||
reply.loadEvent(replyEvent, LocalCache.getOrCreateUser(replyEvent.pubKey), listOf(parent))
|
||||
// The reply reached us from a mirror; only the group's channel knows where the group lives.
|
||||
reply.addRelay(mirrorRelay)
|
||||
parent.addReply(reply)
|
||||
reply.addGatherer(group)
|
||||
|
||||
assertSame("the parent's group comes from its consumed reply", group, LocalCache.getChannelToLoadFrom(parent))
|
||||
|
||||
val filters = filterMissingEvents(listOf(EventFinderQueryState(parent, stubAccount())))
|
||||
assertEquals(setOf(hostRelay, mirrorRelay), filters.map { it.relay }.toSet())
|
||||
filters.forEach { assertEquals(listOf(parentId), it.filter.ids) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user