mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
feat(nip56): declare pointer hints on report, chat, classifieds and channel events
Quartz's PubKeyHintProvider / EventHintProvider / AddressHintProvider are the kind-agnostic answer to "what does this event point at" — they let a caller walk an event's references without knowing which tag name a given NIP chose (`p` vs `P` vs `member` vs `moderator`). Measured against the 248k-event corpus in commonTest, 84 of 403 event classes implement one, covering ~95% of all pointer edges. This closes the four largest remaining gaps. ReportEvent (1984) carried the most undeclared edges of any kind — 14,244 — and they are the negative trust signal that a web-of-trust projection most needs. Its tag classes also predate the modern layout, so they are brought up to the structure used by e.g. NIP-88 polls: - ReportedAuthorTag now implements PubKeyReferenceTag, ReportedEventTag implements GenericETag, and all three tags carry a relay hint. - Adds parseKey / parseId / parseAddressId / parseAsHint companions. Fixes a latent bug while doing so. NIP-56 predates the convention that slot 2 of a pointer tag is a relay hint — it put the report type there — so both layouts are in the wild. The old reader passed slot 2 straight to ReportType.parseOrNull, which despite its name never returns null and maps anything unrecognized to OTHER. A modern `["p", <pubkey>, "wss://relay/"]` tag therefore became an OTHER report and masked the event-level default. The new shared ReportTagLayout disambiguates by shape (a slot that parses as a relay URL is a hint, never a type) and falls back to the event-level default when a tag names no type of its own. Emitted tags are unchanged: assemble() still writes the legacy `[name, id, type]` form unless a relay hint is supplied, since many clients still read the report type out of slot 2. Also renames ReportedAuthorTag.pubkey to pubKey to satisfy PubKeyReferenceTag, updating the four call sites. Coverage over the corpus goes from ~95.3% to ~98.5% of pointer edges. What remains is GiftWrapEvent's recipient p-tag (deliberate — it is the store owner key and handled separately) and PrivateDmEvent's e-tags.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
@@ -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 {
|
||||
|
||||
+7
@@ -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
|
||||
|
||||
+7
-3
@@ -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,
|
||||
|
||||
+75
@@ -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
|
||||
}
|
||||
}
|
||||
+41
-11
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+48
-13
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+46
-13
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+21
@@ -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)
|
||||
|
||||
+187
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user