Merge pull request #3896 from vitorpamplona/claude/neo4j-quartz-graph-schema-z04j8o

Declare pointer hints on Report, ChatMessage, Classifieds and ChannelCreate events
This commit is contained in:
Vitor Pamplona
2026-08-11 16:35:14 -04:00
committed by GitHub
14 changed files with 462 additions and 46 deletions
@@ -395,7 +395,7 @@ class CachePruner(
if (noteEvent is ReportEvent) {
noteEvent.reportedAuthor().forEach {
cache.getUserIfExists(it.pubkey)?.reportsOrNull()?.let { reports ->
cache.getUserIfExists(it.pubKey)?.reportsOrNull()?.let { reports ->
reports.removeReport(note)
reports.removeReportNamingUser(note)
}
@@ -1864,7 +1864,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
val new = consumeRegularEvent(event, relay, wasVerified)
if (new) {
val authorsReported = event.reportedAuthor().mapNotNull { checkGetOrCreateUser(it.pubkey) }
val authorsReported = event.reportedAuthor().mapNotNull { checkGetOrCreateUser(it.pubKey) }
val eventsReported =
event.reportedPost().mapNotNull { checkGetOrCreateNote(it.eventId) } +
event.reportedAddresses().map { getOrCreateAddressableNote(it.address) }
@@ -1885,7 +1885,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
// report can `p`-tag an incidentally-mentioned third party with no type of its own,
// and there is no threshold here to absorb that noise the way
// `receivedReportsByAuthor`'s hide path does.
val explicitlyTyped = event.reportedAuthorsWithOwnType().mapTo(mutableSetOf()) { it.pubkey }
val explicitlyTyped = event.reportedAuthorsWithOwnType().mapTo(mutableSetOf()) { it.pubKey }
authorsReported.forEach { author ->
if (author.pubkeyHex in explicitlyTyped) author.reports().addReportNamingUser(note)
}
@@ -303,7 +303,7 @@ object GrapeRankScore {
dropped++
continue
}
builder.addReports(r.pubKey, r.reportedAuthor().map { it.pubkey })
builder.addReports(r.pubKey, r.reportedAuthor().map { it.pubKey })
}
if (dropped > 0) System.err.println("[graperank] dropped $dropped retracted reports (NIP-09 deletions)")
return dropped
@@ -85,7 +85,7 @@ fun dmReportWarningFor(
(report.event as? ReportEvent)?.reportedAuthor()?.forEach {
// A single report can name several people; only this counterpart's reason belongs here.
if (it.pubkey == counterpart.pubkeyHex) it.type?.let(types::add)
if (it.pubKey == counterpart.pubkeyHex) it.type?.let(types::add)
}
}
@@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
@@ -41,10 +42,15 @@ class ChatMessageEvent(
content: String,
sig: HexKey,
) : BaseDMGroupEvent(id, pubKey, createdAt, KIND, tags, content, sig),
EventHintProvider,
SearchableEvent {
// content is the decrypted (plaintext) direct-message body.
override fun indexableContent() = content
override fun eventHints() = tags.mapNotNull(ETag::parseAsHint)
override fun linkedEventIds() = tags.mapNotNull(ETag::parseId)
fun replyTo() = tags.mapNotNull(ETag::parseId)
companion object {
@@ -25,10 +25,12 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelData
import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelDataNorm
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
@@ -46,9 +48,14 @@ class ChannelCreateEvent(
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig),
EventHintProvider,
AddressHintProvider,
SearchableEvent {
override fun indexableContent() = channelInfo().let { listOfNotNull(it.name, it.about, it.picture).joinToString(" ") }
override fun addressHints() = tags.mapNotNull(ATag::parseAsHint)
override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId)
@kotlinx.serialization.Transient
@kotlin.jvm.Transient
var cache: ChannelDataNorm? = null
@@ -26,6 +26,9 @@ import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip56Reports.tags.DefaultReportTag
import com.vitorpamplona.quartz.nip56Reports.tags.ReportedAddressTag
@@ -42,7 +45,22 @@ class ReportEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
) : Event(id, pubKey, createdAt, KIND, tags, content, sig),
PubKeyHintProvider,
EventHintProvider,
AddressHintProvider {
override fun pubKeyHints() = tags.mapNotNull(ReportedAuthorTag::parseAsHint)
override fun linkedPubKeys() = tags.mapNotNull(ReportedAuthorTag::parseKey)
override fun eventHints() = tags.mapNotNull(ReportedEventTag::parseAsHint)
override fun linkedEventIds() = tags.mapNotNull(ReportedEventTag::parseId)
override fun addressHints() = tags.mapNotNull(ReportedAddressTag::parseAsHint)
override fun linkedAddressIds() = tags.mapNotNull(ReportedAddressTag::parseAddressId)
@kotlinx.serialization.Transient
@kotlin.jvm.Transient
private var defaultType: ReportType? = null
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.nip56Reports
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip56Reports.tags.HashSha256Tag
import com.vitorpamplona.quartz.nip56Reports.tags.ReportedAddressTag
import com.vitorpamplona.quartz.nip56Reports.tags.ReportedAuthorTag
@@ -32,17 +33,20 @@ import com.vitorpamplona.quartz.nip56Reports.tags.ServerTag
fun TagArrayBuilder<ReportEvent>.event(
eventId: HexKey,
reportType: ReportType,
) = addUnique(ReportedEventTag.assemble(eventId, reportType))
relay: NormalizedRelayUrl? = null,
) = addUnique(ReportedEventTag.assemble(eventId, relay, reportType))
fun TagArrayBuilder<ReportEvent>.address(
address: Address,
reportType: ReportType,
) = addUnique(ReportedAddressTag.assemble(address, reportType))
relay: NormalizedRelayUrl? = null,
) = addUnique(ReportedAddressTag.assemble(address, relay, reportType))
fun TagArrayBuilder<ReportEvent>.user(
pubkey: HexKey,
reportType: ReportType,
) = addUnique(ReportedAuthorTag.assemble(pubkey, reportType))
relay: NormalizedRelayUrl? = null,
) = addUnique(ReportedAuthorTag.assemble(pubkey, relay, reportType))
fun TagArrayBuilder<ReportEvent>.hash(
x: String,
@@ -0,0 +1,75 @@
/*
* 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.quartz.nip56Reports.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip56Reports.ReportType
/**
* Positional layout shared by the NIP-56 pointer tags (`p`, `e`, `a`).
*
* NIP-56 predates the convention that slot 2 of a pointer tag is a relay
* hint: it put the report type there instead. Both layouts are in the wild,
* so every reader has to disambiguate:
*
* ```
* ["p", "<pubkey>", "nudity"] // legacy: type at 2
* ["p", "<pubkey>", "wss://relay/", "nudity"] // modern: hint at 2, type at 3
* ["p", "<pubkey>", "wss://relay/"] // modern, no per-tag type
* ["p", "<pubkey>", "", "nudity"] // empty hint slot
* ```
*
* Disambiguation is by *shape*, not by tag length: a slot that parses as a
* relay URL is a hint, never a type. No report-type code is a relay URL, so
* the two spaces cannot collide.
*/
internal object ReportTagLayout {
/** The relay hint at slot 2, or null under the legacy layout. */
fun relayHint(tag: Array<String>): NormalizedRelayUrl? {
if (tag.has(2) && tag[2].length > 7 && RelayUrlNormalizer.isRelayUrl(tag[2])) {
return RelayUrlNormalizer.normalizeOrNull(tag[2])
}
return null
}
/**
* The report type this tag carries, falling back to [default] (the
* event-level type) when the tag does not name one of its own.
*
* Only a slot that is neither blank nor a relay URL is offered to
* [ReportType.parseOrNull] which despite its name never returns null
* and maps anything unrecognized to [ReportType.OTHER]. Feeding it a
* relay URL would turn every hint-carrying tag into an `OTHER` report
* and mask the event-level default.
*/
fun reportType(
tag: Array<String>,
default: ReportType?,
): ReportType? {
val slot = if (tag.has(2) && (tag[2].isBlank() || relayHint(tag) != null)) 3 else 2
if (!tag.has(slot) || tag[slot].isBlank()) return default
return ReportType.parseOrNull(tag[slot], tag) ?: default
}
}
@@ -23,6 +23,8 @@ package com.vitorpamplona.quartz.nip56Reports.tags
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip56Reports.ReportType
import com.vitorpamplona.quartz.utils.arrayOfNotNull
import com.vitorpamplona.quartz.utils.ensure
@@ -30,9 +32,10 @@ import com.vitorpamplona.quartz.utils.ensure
@Immutable
class ReportedAddressTag(
val address: Address,
val relay: NormalizedRelayUrl? = null,
override val type: ReportType? = null,
) : BaseReportTag {
fun toTagArray() = assemble(address, type)
fun toTagArray() = assemble(address, relay, type)
companion object {
const val TAG_NAME = "a"
@@ -49,21 +52,48 @@ class ReportedAddressTag(
ensure(address != null) { return null }
val type =
if (tag.size == 2) {
defaultReportType
} else if (tag.size == 3) {
ReportType.parseOrNull(tag[2], tag) ?: defaultReportType
} else {
ReportType.parseOrNull(tag[3], tag) ?: defaultReportType
}
return ReportedAddressTag(
address,
ReportTagLayout.relayHint(tag),
ReportTagLayout.reportType(tag, defaultReportType),
)
}
return ReportedAddressTag(address, type)
fun parseAddressId(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return Address.parse(tag[1])?.toValue()
}
fun parseAsHint(tag: Array<String>): AddressHint? {
ensure(tag.has(2)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
val address = Address.parse(tag[1])
ensure(address != null) { return null }
val hint = ReportTagLayout.relayHint(tag)
ensure(hint != null) { return null }
return AddressHint(address.toValue(), hint)
}
/** See [ReportedAuthorTag.assemble] for why the layout is conditional. */
fun assemble(
address: Address,
relay: NormalizedRelayUrl?,
type: ReportType?,
) = if (relay != null) {
arrayOfNotNull(TAG_NAME, address.toValue(), relay.url, type?.code)
} else {
arrayOfNotNull(TAG_NAME, address.toValue(), type?.code)
}
fun assemble(
address: Address,
type: ReportType? = null,
) = arrayOfNotNull(TAG_NAME, address.toValue(), type?.code)
) = assemble(address, null, type)
}
}
@@ -23,16 +23,21 @@ package com.vitorpamplona.quartz.nip56Reports.tags
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.people.PubKeyReferenceTag
import com.vitorpamplona.quartz.nip56Reports.ReportType
import com.vitorpamplona.quartz.utils.arrayOfNotNull
import com.vitorpamplona.quartz.utils.ensure
@Immutable
class ReportedAuthorTag(
val pubkey: HexKey,
override val pubKey: HexKey,
override val relayHint: NormalizedRelayUrl? = null,
override val type: ReportType? = null,
) : BaseReportTag {
fun toTagArray() = assemble(pubkey, type)
) : BaseReportTag,
PubKeyReferenceTag {
fun toTagArray() = assemble(pubKey, relayHint, type)
companion object {
const val TAG_NAME = "p"
@@ -45,21 +50,51 @@ class ReportedAuthorTag(
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
val type =
if (tag.size == 2) {
defaultReportType
} else if (tag.size == 3) {
ReportType.parseOrNull(tag[2], tag) ?: defaultReportType
} else {
ReportType.parseOrNull(tag[3], tag) ?: defaultReportType
}
return ReportedAuthorTag(
tag[1],
ReportTagLayout.relayHint(tag),
ReportTagLayout.reportType(tag, defaultReportType),
)
}
return ReportedAuthorTag(tag[1], type)
fun parseKey(tag: Array<String>): HexKey? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
return tag[1]
}
fun parseAsHint(tag: Array<String>): PubKeyHint? {
ensure(tag.has(2)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
val hint = ReportTagLayout.relayHint(tag)
ensure(hint != null) { return null }
return PubKeyHint(tag[1], hint)
}
/**
* Emits the legacy `[name, id, type]` layout unless a relay hint is
* supplied. Padding slot 2 with `""` to always reach the modern
* layout would be well-formed here but unreadable to the many
* clients that still read the report type out of slot 2.
*/
fun assemble(
pubkey: HexKey,
relayHint: NormalizedRelayUrl?,
type: ReportType?,
) = if (relayHint != null) {
arrayOfNotNull(TAG_NAME, pubkey, relayHint.url, type?.code)
} else {
arrayOfNotNull(TAG_NAME, pubkey, type?.code)
}
fun assemble(
pubkey: HexKey,
type: ReportType? = null,
) = arrayOfNotNull(TAG_NAME, pubkey, type?.code)
) = assemble(pubkey, null, type)
}
}
@@ -23,16 +23,24 @@ package com.vitorpamplona.quartz.nip56Reports.tags
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.events.GenericETag
import com.vitorpamplona.quartz.nip56Reports.ReportType
import com.vitorpamplona.quartz.utils.arrayOfNotNull
import com.vitorpamplona.quartz.utils.ensure
@Immutable
class ReportedEventTag(
val eventId: HexKey,
override val eventId: HexKey,
override val relay: NormalizedRelayUrl? = null,
override val type: ReportType? = null,
) : BaseReportTag {
fun toTagArray() = assemble(eventId, type)
) : BaseReportTag,
GenericETag {
/** NIP-56 `e` tags carry no author slot — slot 3 is the report type. */
override val author: HexKey? = null
override fun toTagArray() = assemble(eventId, relay, type)
companion object {
const val TAG_NAME = "e"
@@ -45,21 +53,46 @@ class ReportedEventTag(
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
val type =
if (tag.size == 2) {
defaultReportType
} else if (tag.size == 3) {
ReportType.parseOrNull(tag[2], tag) ?: defaultReportType
} else {
ReportType.parseOrNull(tag[3], tag) ?: defaultReportType
}
return ReportedEventTag(
tag[1],
ReportTagLayout.relayHint(tag),
ReportTagLayout.reportType(tag, defaultReportType),
)
}
return ReportedEventTag(tag[1], type)
fun parseId(tag: Array<String>): HexKey? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
return tag[1]
}
fun parseAsHint(tag: Array<String>): EventIdHint? {
ensure(tag.has(2)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].length == 64) { return null }
val hint = ReportTagLayout.relayHint(tag)
ensure(hint != null) { return null }
return EventIdHint(tag[1], hint)
}
/** See [ReportedAuthorTag.assemble] for why the layout is conditional. */
fun assemble(
eventId: HexKey,
relay: NormalizedRelayUrl?,
type: ReportType?,
) = if (relay != null) {
arrayOfNotNull(TAG_NAME, eventId, relay.url, type?.code)
} else {
arrayOfNotNull(TAG_NAME, eventId, type?.code)
}
fun assemble(
eventId: HexKey,
type: ReportType? = null,
) = arrayOfNotNull(TAG_NAME, eventId, type?.code)
) = assemble(eventId, null, type)
}
}
@@ -25,9 +25,15 @@ import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.core.containsAllTagNamesWithValues
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip01Core.tags.publishedAt.PublishedAtProvider
import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag
import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag
@@ -53,9 +59,24 @@ class ClassifiedsEvent(
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
PublishedAtProvider,
PubKeyHintProvider,
EventHintProvider,
AddressHintProvider,
SearchableEvent {
override fun indexableContent() = listOfNotNull(title(), summary(), content).joinToString("\n")
override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint)
override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey)
override fun eventHints() = tags.mapNotNull(ETag::parseAsHint)
override fun linkedEventIds() = tags.mapNotNull(ETag::parseId)
override fun addressHints() = tags.mapNotNull(ATag::parseAsHint)
override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId)
fun title() = tags.firstNotNullOfOrNull(TitleTag::parse)
fun image() = tags.firstNotNullOfOrNull(ImageTag::parse)
@@ -0,0 +1,187 @@
/*
* 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.quartz.nip56Reports
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip56Reports.tags.ReportedAddressTag
import com.vitorpamplona.quartz.nip56Reports.tags.ReportedAuthorTag
import com.vitorpamplona.quartz.nip56Reports.tags.ReportedEventTag
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class ReportHintProviderTest {
private val pubkey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
private val other = "99bb5591c9116600f845107d31f9b59e2f7c7e09a1ff802e84f1d43da557ca64"
private val eventId = "43575072239da152afe3d7b5c70ed2beb48db2b10e60c60da45229c09c877d2a"
private val addressId = "30402:$other:7e5fec50-9add-48c6-985c-eb593e6c14cd"
private val relay = "wss://relay.damus.io/"
private fun report(vararg tags: Array<String>) =
ReportEvent(
id = "00".repeat(32),
pubKey = pubkey,
createdAt = 1700000000,
tags = arrayOf(*tags),
content = "",
sig = "00".repeat(64),
)
// ---------------------------------------------------------------
// Layout disambiguation: legacy [name, id, type] vs modern
// [name, id, relay, type]. See ReportTagLayout.
// ---------------------------------------------------------------
@Test
fun legacyLayoutReadsTypeFromSlotTwo() {
val tag = ReportedAuthorTag.parse(arrayOf("p", other, "nudity"))!!
assertEquals(other, tag.pubKey)
assertEquals(ReportType.NUDITY, tag.type)
assertNull(tag.relayHint)
}
@Test
fun modernLayoutReadsHintFromTwoAndTypeFromThree() {
val tag = ReportedAuthorTag.parse(arrayOf("p", other, relay, "impersonation"))!!
assertEquals(other, tag.pubKey)
assertEquals(ReportType.IMPERSONATION, tag.type)
assertEquals(relay, tag.relayHint?.url)
}
/**
* Regression: a relay URL used to be fed to ReportType.parseOrNull,
* which maps anything unrecognized to OTHER so a hint-carrying tag
* with no per-tag type silently became an `OTHER` report and masked
* the event-level default.
*/
@Test
fun relayHintWithoutTypeFallsBackToDefaultNotOther() {
val tag = ReportedAuthorTag.parse(arrayOf("p", other, relay), ReportType.SPAM)!!
assertEquals(relay, tag.relayHint?.url)
assertEquals(ReportType.SPAM, tag.type)
}
@Test
fun emptyHintSlotStillReadsTypeFromSlotThree() {
val tag = ReportedAuthorTag.parse(arrayOf("p", other, "", "nudity"))!!
assertEquals(ReportType.NUDITY, tag.type)
assertNull(tag.relayHint)
}
@Test
fun blankTypeSlotFallsBackToDefault() {
val tag = ReportedAuthorTag.parse(arrayOf("p", other, ""), ReportType.MALWARE)!!
assertEquals(ReportType.MALWARE, tag.type)
}
@Test
fun eventAndAddressTagsShareTheSameLayoutRules() {
val e = ReportedEventTag.parse(arrayOf("e", eventId, relay, "illegal"))!!
assertEquals(eventId, e.eventId)
assertEquals(relay, e.relay?.url)
assertEquals(ReportType.ILLEGAL, e.type)
assertNull(e.author)
val a = ReportedAddressTag.parse(arrayOf("a", addressId, relay, "spam"))!!
assertEquals(addressId, a.address.toValue())
assertEquals(relay, a.relay?.url)
assertEquals(ReportType.SPAM, a.type)
}
// ---------------------------------------------------------------
// Round trip
// ---------------------------------------------------------------
@Test
fun assembleRoundTripsBothLayouts() {
val legacy = ReportedAuthorTag.assemble(other, ReportType.SPAM)
assertEquals(listOf("p", other, "spam"), legacy.toList())
assertEquals(ReportType.SPAM, ReportedAuthorTag.parse(legacy)!!.type)
val modern = ReportedAuthorTag(other, RelayUrlNormalizer.normalizeOrNull(relay), ReportType.SPAM).toTagArray()
assertEquals(listOf("p", other, relay, "spam"), modern.toList())
val reparsed = ReportedAuthorTag.parse(modern)!!
assertEquals(ReportType.SPAM, reparsed.type)
assertEquals(relay, reparsed.relayHint?.url)
}
// ---------------------------------------------------------------
// Hint providers
// ---------------------------------------------------------------
@Test
fun reportEventExposesEveryPointerItCarries() {
val event =
report(
arrayOf("p", other, relay, "impersonation"),
arrayOf("e", eventId, "nudity"),
arrayOf("a", addressId, relay, "spam"),
)
assertEquals(listOf(other), event.linkedPubKeys())
assertEquals(listOf(eventId), event.linkedEventIds())
assertEquals(listOf(addressId), event.linkedAddressIds())
// hints only where a relay is actually present
assertEquals(listOf(other), event.pubKeyHints().map { it.pubkey })
assertEquals(listOf(relay), event.pubKeyHints().map { it.relay.url })
assertTrue(event.eventHints().isEmpty())
assertEquals(listOf(addressId), event.addressHints().map { it.addressId })
}
@Test
fun linkedPubKeysMatchesTypedAccessorsSoTheGraphCannotDrift() {
val event =
report(
arrayOf("p", other, "nudity"),
arrayOf("p", pubkey, relay, "spam"),
arrayOf("e", eventId, "nudity"),
)
// The completeness invariant: every pubkey the typed accessor
// reports is also reported by the generic hint provider.
assertEquals(
event.reportedAuthor().map { it.pubKey }.toSet(),
event.linkedPubKeys().toSet(),
)
assertEquals(
event.reportedPost().map { it.eventId }.toSet(),
event.linkedEventIds().toSet(),
)
}
@Test
fun malformedPointersAreDropped() {
val event =
report(
arrayOf("p", "tooshort", "nudity"),
arrayOf("p"),
arrayOf("e", eventId),
arrayOf("a", "not-an-address"),
)
assertTrue(event.linkedPubKeys().isEmpty())
assertEquals(listOf(eventId), event.linkedEventIds())
assertTrue(event.linkedAddressIds().isEmpty())
}
}