From 2595d6fa2a977aaf147e5dac42285189ce0f64ed Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 21 Feb 2023 09:42:41 -0500 Subject: [PATCH] Only adds user to Hidden Users after 5 duplicated messages. --- .../vitorpamplona/amethyst/model/Account.kt | 10 +- .../amethyst/model/AntiSpamFilter.kt | 92 +++++++++++++++++++ .../amethyst/model/LocalCache.kt | 38 ++------ 3 files changed, 106 insertions(+), 34 deletions(-) create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index ce8e592b3..c07a0c031 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -420,13 +420,13 @@ class Account( reconnectIfRelaysHaveChanged() } } - LocalCache.liveSpam.observeForever { + LocalCache.antiSpam.liveSpam.observeForever { GlobalScope.launch(Dispatchers.IO) { - LocalCache.spamMessages.snapshot().values.forEach { - if (it !in hiddenUsers) { - val userToBlock = LocalCache.getOrCreateUser(it) + it.cache.spamMessages.snapshot().values.forEach { + if (it.pubkeyHex !in transientHiddenUsers && it.duplicatedMessages > 5) { + val userToBlock = LocalCache.getOrCreateUser(it.pubkeyHex) if (userToBlock != userProfile() && userToBlock !in userProfile().follows) { - transientHiddenUsers = transientHiddenUsers + it + transientHiddenUsers = transientHiddenUsers + it.pubkeyHex } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt new file mode 100644 index 000000000..2c3ca4d1c --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt @@ -0,0 +1,92 @@ +package com.vitorpamplona.amethyst.model + +import android.util.Log +import android.util.LruCache +import androidx.lifecycle.LiveData +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import nostr.postr.events.Event +import nostr.postr.toHex + +class AntiSpamFilter { + val recentMessages = LruCache(1000) + val spamMessages = LruCache(1000) + + @Synchronized + fun isSpam(event: Event): Boolean { + val idHex = event.id.toHexKey() + + // if already processed, ok + if (LocalCache.notes[idHex] != null) return false + + // if short message, ok + if (event.content.length < 50) return false + + // double list strategy: + // 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. + val hash = (event.content + event.tags.flatten().joinToString(",")).hashCode() + + if ((recentMessages[hash] != null && recentMessages[hash] != idHex) || spamMessages[hash] != null) { + Log.w("Potential SPAM Message", "${event.id.toHex()} ${recentMessages[hash]} ${spamMessages[hash] != null} ${event.content.replace("\n", " | ")}") + + // Log down offenders + if (spamMessages.get(hash) == null) { + spamMessages.put(hash, Spammer(event.pubKey.toHexKey(), 2)) + liveSpam.invalidateData() + } else { + spamMessages.get(hash).duplicatedMessages++ + liveSpam.invalidateData() + } + + return true + } + + recentMessages.put(hash, idHex) + + return false + } + + val liveSpam: AntiSpamLiveData = AntiSpamLiveData(this) +} + + +class AntiSpamLiveData(val cache: AntiSpamFilter): LiveData(AntiSpamState(cache)) { + + // Refreshes observers in batches. + var handlerWaiting = AtomicBoolean() + + @Synchronized + fun invalidateData() { + if (!hasActiveObservers()) return + if (handlerWaiting.getAndSet(true)) return + + handlerWaiting.set(true) + val scope = CoroutineScope(Job() + Dispatchers.Main) + scope.launch { + try { + delay(100) + refresh() + } finally { + withContext(NonCancellable) { + handlerWaiting.set(false) + } + } + } + } + + private fun refresh() { + postValue(AntiSpamState(cache)) + } +} + +class AntiSpamState(val cache: AntiSpamFilter) { + +} \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index f34b3fc67..c0fcbce7e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -41,21 +41,22 @@ import nostr.postr.events.PrivateDmEvent import nostr.postr.events.RecommendRelayEvent import nostr.postr.events.TextNoteEvent import nostr.postr.toHex +import nostr.postr.toNpub +data class Spammer(val pubkeyHex: HexKey, var duplicatedMessages: Int) object LocalCache { val metadataParser = jacksonObjectMapper() .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) .readerFor(UserMetadata::class.java) + val antiSpam = AntiSpamFilter() + val users = ConcurrentHashMap() val notes = ConcurrentHashMap() val channels = ConcurrentHashMap() - val recentMessages = LruCache(1000) - val spamMessages = LruCache(1000) - - fun checkGetOrCreateUser(key: HexKey): User? { + fun checkGetOrCreateUser(key: String): User? { return try { val checkHex = Hex.decode(key) // Checks if this is a valid Hex getOrCreateUser(key) @@ -74,7 +75,7 @@ object LocalCache { } } - fun checkGetOrCreateNote(key: HexKey): Note? { + fun checkGetOrCreateNote(key: String): Note? { return try { val checkHex = Hex.decode(key) // Checks if this is a valid Hex getOrCreateNote(key) @@ -93,7 +94,7 @@ object LocalCache { } } - fun checkGetOrCreateChannel(key: HexKey): Channel? { + fun checkGetOrCreateChannel(key: String): Channel? { return try { val checkHex = Hex.decode(key) // Checks if this is a valid Hex getOrCreateChannel(key) @@ -141,29 +142,9 @@ object LocalCache { .format(DateTimeFormatter.ofPattern("uuuu MMM d hh:mm a")) } - @Synchronized - fun isRepeatedMessageSpam(event: Event): Boolean { - val idHex = event.id.toHexKey() - // if already processed, return - if (notes[idHex] != null) return false - - // double list strategy: - // if duplicated, it goes into spam. 1000 spam messages are saved into the spam list. - val hash = (event.content + event.tags.flatten().joinToString(",")).hashCode() - if (event.content.length > 50 && ((recentMessages[hash] != null && recentMessages[hash] != idHex) || spamMessages[hash] != null)) { - Log.w("Potential SPAM Message", "${event.id.toHex()} ${recentMessages[hash]} ${spamMessages[hash] != null} ${event.content.replace("\n", " | ")}") - if (spamMessages.get(hash) == null) { - spamMessages.put(hash, event.pubKey.toHexKey()) - liveSpam.invalidateData() - } - return true - } - recentMessages.put(hash, idHex) - return false - } fun consume(event: TextNoteEvent, relay: Relay? = null) { - if (isRepeatedMessageSpam(event)) return + if (antiSpam.isSpam(event)) return val note = getOrCreateNote(event.id.toHex()) val author = getOrCreateUser(event.pubKey.toHexKey()) @@ -450,7 +431,7 @@ object LocalCache { fun consume(event: ChannelMessageEvent, relay: Relay?) { if (event.channel.isNullOrBlank()) return - if (isRepeatedMessageSpam(event)) return + if (antiSpam.isSpam(event)) return val channel = checkGetOrCreateChannel(event.channel) ?: return @@ -667,7 +648,6 @@ object LocalCache { // Observers line up here. val live: LocalCacheLiveData = LocalCacheLiveData(this) - val liveSpam: LocalCacheLiveData = LocalCacheLiveData(this) private fun refreshObservers() { live.invalidateData()