Improves the performance and logging structures for the anti-spam filter.

This commit is contained in:
Vitor Pamplona
2025-07-02 19:36:33 -04:00
parent ad481e1928
commit 1fa2592563
4 changed files with 93 additions and 38 deletions
@@ -112,7 +112,7 @@ fun debugState(context: Context) {
Log.d( Log.d(
"STATE DUMP", "STATE DUMP",
"Spam: " + "Spam: " +
LocalCache.antiSpam.spamMessages.size() + " / " + LocalCache.antiSpam.recentMessages.size(), LocalCache.antiSpam.spamMessages.size() + " / " + LocalCache.antiSpam.recentEventIds.size() + " / " + LocalCache.antiSpam.recentAddressables.size(),
) )
Log.d( Log.d(
@@ -2265,7 +2265,7 @@ class Account(
scope.launch(Dispatchers.Default) { scope.launch(Dispatchers.Default) {
cache.antiSpam.flowSpam.collect { cache.antiSpam.flowSpam.collect {
it.cache.spamMessages.snapshot().values.forEach { spammer -> it.cache.spamMessages.snapshot().values.forEach { spammer ->
if (!hiddenUsers.isHidden(spammer.pubkeyHex) && spammer.duplicatedMessages.size >= 5) { if (!hiddenUsers.isHidden(spammer.pubkeyHex) && spammer.shouldHide()) {
if (spammer.pubkeyHex != userProfile().pubkeyHex && spammer.pubkeyHex !in followingKeySet()) { if (spammer.pubkeyHex != userProfile().pubkeyHex && spammer.pubkeyHex !in followingKeySet()) {
hiddenUsers.hideUser(spammer.pubkeyHex) hiddenUsers.hideUser(spammer.pubkeyHex)
} }
@@ -22,75 +22,114 @@ package com.vitorpamplona.amethyst.model
import android.util.Log import android.util.Log
import android.util.LruCache import android.util.LruCache
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.note.njumpLink import com.vitorpamplona.amethyst.ui.note.njumpLink
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
data class Spammer( data class Spammer(
val pubkeyHex: HexKey, val pubkeyHex: HexKey,
var duplicatedMessages: Set<HexKey>, var duplicatedEventIds: Set<HexKey>,
) var duplicatedEventAddresses: Set<Address>,
) {
fun shouldHide() = duplicatedEventIds.size >= 5 || duplicatedEventAddresses.size >= 5
}
class AntiSpamFilter { class AntiSpamFilter {
val recentMessages = LruCache<Int, String>(1000) val recentEventIds = LruCache<Int, String>(2000)
val recentAddressables = LruCache<Int, Address>(2000)
val spamMessages = LruCache<Int, Spammer>(1000) val spamMessages = LruCache<Int, Spammer>(1000)
var active: Boolean = true var active: Boolean = true
fun spamHashCode(event: Event): Int = 31 * event.content.hashCode() + event.tags.contentDeepHashCode()
fun isSpam( fun isSpam(
event: Event, event: Event,
relay: NormalizedRelayUrl?, relay: NormalizedRelayUrl?,
): Boolean { ): Boolean {
checkNotInMainThread()
if (!active) return false if (!active) return false
val idHex = event.id
// if short message, ok // if short message, ok
// The idea here is to avoid considering repeated "GM" messages spam. // The idea here is to avoid considering repeated "GM" messages spam.
if (event.content.length < 50) return false if (event.content.length < 60) return false
// if the message is actually short but because it cites a user/event, the nostr: string is // if the message is actually short but because it cites a user/event, the nostr: string is
// really long, make it ok. // really long, make it ok.
// The idea here is to avoid considering repeated "@Bot, command" messages spam, while still // The idea here is to avoid considering repeated "@Bot, command" messages spam, while still
// blocking repeated "lnbc..." invoices or fishing urls // blocking repeated "lnbc..." invoices or fishing urls
if (event.content.length < 180 && Nip19Parser.nip19regex.matcher(event.content).find()) return false if (event.content.length < 180 && event.content.startsWith("nostr:") && Nip19Parser.nip19regex.matcher(event.content).find()) return false
// double list strategy: // double list strategy:
// if duplicated, it goes into spam. 1000 spam messages are saved into the spam list. // if duplicated, it goes into spam. 1000 spam messages are saved into the spam list.
// Considers tags so that same replies to different people don't count. // Considers tags so that same replies to different people don't count.
val hash = (event.content + event.tags.flatten().joinToString(",")).hashCode() val hash = spamHashCode(event)
if ( // ignores multiple versions of the same addressable.
(recentMessages[hash] != null && recentMessages[hash] != idHex) || spamMessages[hash] != null if (event is AddressableEvent) {
) { val address = event.address()
Log.w(
"Potential SPAM Message",
"${event.id} ${recentMessages[hash]} ${spamMessages[hash] != null} ${relay?.url} ${event.content.replace("\n", " | ")}",
)
// Log down offenders // normal event
logOffender(hash, event) if (
(recentAddressables[hash] != null && recentAddressables[hash] != address) ||
(spamMessages[hash] != null && !spamMessages[hash].duplicatedEventAddresses.contains(address))
) {
val existingAddress = recentAddressables[hash]
if (relay != null) { val link1 = njumpLink(NAddress.create(existingAddress.kind, existingAddress.pubKeyHex, existingAddress.dTag, relay))
RelayStats.newSpam(relay, njumpLink(NEvent.create(event.id, event.pubKey, event.kind, relay))) val link2 = njumpLink(NAddress.create(event.kind, event.pubKey, event.dTag(), relay))
Log.w("Duplicated/SPAM", "${relay?.url} $link1 $link2")
// Log down offenders
val spammer = logOffender(hash, event)
if (spammer.shouldHide() && relay != null) {
RelayStats.newSpam(relay, "$link1 $link2")
}
flowSpam.tryEmit(AntiSpamState(this))
return true
} }
flowSpam.tryEmit(AntiSpamState(this)) recentAddressables.put(hash, address)
} else {
// normal event
if (
(recentEventIds[hash] != null && recentEventIds[hash] != event.id) ||
(spamMessages[hash] != null && !spamMessages[hash].duplicatedEventIds.contains(event.id))
) {
val existingEvent = recentEventIds[hash]
return true val link1 = njumpLink(NEvent.create(existingEvent, null, null, relay))
val link2 = njumpLink(NEvent.create(event.id, null, null, relay))
Log.w("Duplicated/SPAM", "${relay?.url} $link1 $link2")
// Log down offenders
val spammer = logOffender(hash, event)
if (spammer.shouldHide() && relay != null) {
RelayStats.newSpam(relay, "$link1 $link2")
}
flowSpam.tryEmit(AntiSpamState(this))
return true
}
recentEventIds.put(hash, event.id)
} }
recentMessages.put(hash, idHex)
return false return false
} }
@@ -98,12 +137,33 @@ class AntiSpamFilter {
private fun logOffender( private fun logOffender(
hashCode: Int, hashCode: Int,
event: Event, event: Event,
) { ): Spammer {
if (spamMessages.get(hashCode) == null) { val spammer = spamMessages.get(hashCode)
spamMessages.put(hashCode, Spammer(event.pubKey, setOf(recentMessages[hashCode], event.id)))
if (spammer == null) {
val newSpammer =
if (event is AddressableEvent) {
Spammer(
pubkeyHex = event.pubKey,
duplicatedEventIds = setOf(),
duplicatedEventAddresses = setOf(recentAddressables[hashCode], event.address()),
)
} else {
Spammer(
pubkeyHex = event.pubKey,
duplicatedEventIds = setOf(recentEventIds[hashCode], event.id),
duplicatedEventAddresses = setOf(),
)
}
spamMessages.put(hashCode, newSpammer)
return newSpammer
} else { } else {
val spammer = spamMessages.get(hashCode) if (event is AddressableEvent) {
spammer.duplicatedMessages += event.id spammer.duplicatedEventAddresses += event.address()
} else {
spammer.duplicatedEventIds += event.id
}
return spammer
} }
} }
@@ -20,7 +20,6 @@
*/ */
package com.vitorpamplona.quartz.nip01Core.relay.client package com.vitorpamplona.quartz.nip01Core.relay.client
import android.util.Log
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayState import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayState
@@ -115,10 +114,6 @@ class NostrClient(
subscriptionId: String = newSubId(), subscriptionId: String = newSubId(),
filters: List<RelayBasedFilter> = listOf(), filters: List<RelayBasedFilter> = listOf(),
) { ) {
filters.forEach {
Log.d("NostrClient", "${it.relay.url} ${it.filter.toJson()}")
}
activeSubscriptions.addOrUpdate(subscriptionId, filters) activeSubscriptions.addOrUpdate(subscriptionId, filters)
relayPool.sendRequest(subscriptionId, filters) relayPool.sendRequest(subscriptionId, filters)
} }