From b4be4c306d88ae7ede5208d68eb0f2fea40baca5 Mon Sep 17 00:00:00 2001 From: violine1101 Date: Thu, 9 Dec 2021 19:18:08 +0100 Subject: [PATCH] Add shadowban command (#754) * Rewrite lastrun file logic Changed lastrun file from a custom format to a JSON file. If a `lastrun.json` file doesn't exist yet, the old format will be automatically migrated. * Add shadowban command The command does nothing but add the shadowban to the lastrun file. * Add shadowban module This module actually does all the work for the shadowban, removing all comments, attachments, and bug reports from shadowbanned users. I've modified the `ExecutionTimeframe` so that it also stores all shadowbans; as a consequence, the module needs to be able to access the entire timeframe instead of just the `lastRunTime`. I've chosen to add an alternative way to implement modules if more details are needed; it might make sense to migrate to this for all modules in the future; or alternatively do a refactor so that a module can access anything non-static it needs from its invocation arguments. * Add debug option to not ignore commands sent by bot user --- .gitignore | 1 + docs/Commands.md | 12 + docs/Modules.md | 27 ++ .../github/mojira/arisa/ExecutionTimeframe.kt | 10 +- .../kotlin/io/github/mojira/arisa/Executor.kt | 2 +- .../kotlin/io/github/mojira/arisa/LastRun.kt | 57 ++-- .../io/github/mojira/arisa/LastRunFile.kt | 111 ++++++++ .../infrastructure/config/ArisaConfig.kt | 7 + .../arisa/infrastructure/jira/Functions.kt | 2 +- .../mojira/arisa/modules/CommandModule.kt | 3 +- .../io/github/mojira/arisa/modules/Module.kt | 8 + .../mojira/arisa/modules/ShadowbanModule.kt | 85 ++++++ .../modules/commands/CommandDispatcher.kt | 14 + .../modules/commands/ShadowbanCommand.kt | 16 ++ .../arisa/registry/InstantModuleRegistry.kt | 7 + .../mojira/arisa/registry/ModuleRegistry.kt | 8 +- .../mojira/arisa/ExecutionTimeframeTest.kt | 4 +- .../io/github/mojira/arisa/ExecutorTest.kt | 2 +- .../io/github/mojira/arisa/LastRunTest.kt | 224 ++++++++++++++-- .../mojira/arisa/modules/CommandModuleTest.kt | 4 +- .../arisa/modules/ShadowbanModuleTest.kt | 244 ++++++++++++++++++ 21 files changed, 790 insertions(+), 58 deletions(-) create mode 100644 src/main/kotlin/io/github/mojira/arisa/LastRunFile.kt create mode 100644 src/main/kotlin/io/github/mojira/arisa/modules/ShadowbanModule.kt create mode 100644 src/main/kotlin/io/github/mojira/arisa/modules/commands/ShadowbanCommand.kt create mode 100644 src/test/kotlin/io/github/mojira/arisa/modules/ShadowbanModuleTest.kt diff --git a/.gitignore b/.gitignore index 29029bd66..63599cb63 100644 --- a/.gitignore +++ b/.gitignore @@ -14,5 +14,6 @@ arisa.yml # Files and folders created when running Arisa /logs/ last-run +lastrun.json helper-messages.json mc-mappings/ diff --git a/docs/Commands.md b/docs/Commands.md index 92b02e325..cae655ff4 100644 --- a/docs/Commands.md +++ b/docs/Commands.md @@ -155,3 +155,15 @@ Keys and type are case insensitive Reopens tickets resolved as Awaiting Response +## $ARISA_SHADOWBAN +| Entry | Value | +| ----------- | ----------------------------- | +| Syntax | `$ARISA_SHADOWBAN ` | +| Permissions | Mod+ | + +Shadowbans a user for the next 24 hours. + +Note: The user's previous actions still need to be reverted manually (or e.g. via `$ARISA_REMOVE_CONTENT` and bulk change), +as running this command will only cause the bot auto-undo actions done by the shadowbanned user in the future. + +For more information on how shadowbans work, see "Modules → Shadowban". diff --git a/docs/Modules.md b/docs/Modules.md index 9068e75e5..a74d5563f 100644 --- a/docs/Modules.md +++ b/docs/Modules.md @@ -430,6 +430,33 @@ instead. - At most `maxImagesCount` as specified in the [config](../config/config.yml) (defaults to 10) will be processed per issue description respectively per comment +## Shadowban +| Entry | Value | +| ----- | ---------------------------------------------------------------------------- | +| Name | `Shadowban` | +| Class | [Link](../src/main/kotlin/io/github/mojira/arisa/modules/ShadowbanModule.kt) | + +Revert all bug reports, comments, and attachments that have been +created by shadowbanned users (via `$ARISA_SHADOWBAN`). + +- Bug reports are resolved as invalid, are made private, and the reporter is changed to `SpamBin`. +- Comments are restricted to `staff`. +- Attachments are immediately deleted. + +### Checks +- The bug report + - was reported by someone who is currently shadowbanned + - was not posted by someone in the `helper`, `staff`, or `global-moderators` group +- The comment + - was created by someone who is currently shadowbanned + - has been created while that user was banned + - is not restricted already + - was not posted by someone in the `helper`, `staff`, or `global-moderators` group +- The attachment + - was uploaded by someone who is currently shadowbanned + - has been uploaded while that user was banned + - was not uploaded by someone in the `helper`, `staff`, or `global-moderators` group + ## TransferLinks | Entry | Value | | ----- | ----------------------------------------------------------------------------------- | diff --git a/src/main/kotlin/io/github/mojira/arisa/ExecutionTimeframe.kt b/src/main/kotlin/io/github/mojira/arisa/ExecutionTimeframe.kt index e6de74683..429fc9bf2 100644 --- a/src/main/kotlin/io/github/mojira/arisa/ExecutionTimeframe.kt +++ b/src/main/kotlin/io/github/mojira/arisa/ExecutionTimeframe.kt @@ -10,6 +10,7 @@ import java.time.temporal.ChronoUnit class ExecutionTimeframe( val lastRunTime: Instant, val currentRunTime: Instant, + val shadowbans: Map, private val openEnded: Boolean ) { companion object { @@ -35,7 +36,12 @@ class ExecutionTimeframe( endOfMaxTimeframe } - return ExecutionTimeframe(lastRun.time, currentRunTime, runOpenEnded) + return ExecutionTimeframe( + lastRun.time, + currentRunTime, + lastRun.getShadowbannedUsers(), + runOpenEnded + ) } } @@ -56,6 +62,8 @@ class ExecutionTimeframe( return "updated > ${checkStart.toEpochMilli()} AND updated <= ${checkEnd.toEpochMilli()}" } + fun contains(instant: Instant): Boolean = instant in lastRunTime..currentRunTime + /** * Adds a cap to a JQL query if this time frame is not open. * diff --git a/src/main/kotlin/io/github/mojira/arisa/Executor.kt b/src/main/kotlin/io/github/mojira/arisa/Executor.kt index 87cb3576c..eb7a599a7 100644 --- a/src/main/kotlin/io/github/mojira/arisa/Executor.kt +++ b/src/main/kotlin/io/github/mojira/arisa/Executor.kt @@ -64,7 +64,7 @@ class Executor( registry.getEnabledModules().forEach { (moduleName, _, execute, moduleExecutor) -> log.debug("Executing module $moduleName") - moduleExecutor.executeModule(issues, addFailedTicket) { issue -> execute(issue, timeframe.lastRunTime) } + moduleExecutor.executeModule(issues, addFailedTicket) { issue -> execute(issue, timeframe) } } } diff --git a/src/main/kotlin/io/github/mojira/arisa/LastRun.kt b/src/main/kotlin/io/github/mojira/arisa/LastRun.kt index e5a1b84a8..2c4006305 100644 --- a/src/main/kotlin/io/github/mojira/arisa/LastRun.kt +++ b/src/main/kotlin/io/github/mojira/arisa/LastRun.kt @@ -2,7 +2,7 @@ package io.github.mojira.arisa import com.uchuhimo.konf.Config import io.github.mojira.arisa.infrastructure.config.Arisa -import java.io.File +import io.github.mojira.arisa.modules.commands.ShadowbanCommand import java.time.Instant import java.time.temporal.ChronoUnit @@ -10,22 +10,21 @@ import java.time.temporal.ChronoUnit * Stores information about the previous run (start time, and tickets that failed during the run) */ class LastRun( - private val readFromFile: () -> String, - private val writeToFile: (String) -> Unit + private val readFromFile: () -> LastRunFile, + private val writeToFile: (LastRunFile) -> Unit ) { companion object { const val DEFAULT_START_TIME_MINUTES_BEFORE_NOW = 5L + const val SHADOWBAN_DURATION_IN_HOURS = 24L + + private val lastRunFileService = LastRunFileService("lastrun.json", "last-run") fun getLastRun(config: Config): LastRun { - val lastRunFile = File("last-run") return LastRun( - readFromFile = { - if (lastRunFile.exists()) lastRunFile.readText() - else "" - }, - writeToFile = { contents -> + readFromFile = { lastRunFileService.getLastRunFile() }, + writeToFile = { file -> if (config[Arisa.Debug.updateLastRun]) { - lastRunFile.writeText(contents) + lastRunFileService.writeLastRunFile(file) } } ) @@ -34,28 +33,44 @@ class LastRun( var time: Instant var failedTickets: Set + private var shadowbans: MutableList /** - * Updates last run and writes it to the `last-run` file + * Updates last run and writes it to the `lastrun.json` file */ fun update(newTime: Instant, newFailedTickets: Set) { time = newTime failedTickets = newFailedTickets + shadowbans.removeIf { it.until.isBefore(newTime) } - val failedString = failedTickets.joinToString("") { ",$it" } // even first entry should start with a comma + writeToFile(LastRunFile(time, failedTickets, shadowbans)) + } - writeToFile("${time.toEpochMilli()}$failedString") + fun addShadowbannedUser(userName: String) { + shadowbans.add( + Shadowban( + user = userName, + since = time, + until = time.plus(SHADOWBAN_DURATION_IN_HOURS, ChronoUnit.HOURS) + ) + ) } - init { - val lastRunFileComponents = readFromFile().trim().split(',') + fun getShadowbannedUsers(): Map = + // We want the earliest applicable ban frame for a particular user. + // Since `associateBy` always picks the last one, we'll reverse the list. + // Shadowbans that are no longer active get removed through `update`, so we don't need to worry about those. + shadowbans + .reversed() + .associateBy { it.user } - time = if (lastRunFileComponents[0].isNotEmpty()) { - Instant.ofEpochMilli(lastRunFileComponents[0].toLong()) - } else { - Instant.now().minus(DEFAULT_START_TIME_MINUTES_BEFORE_NOW, ChronoUnit.MINUTES) - } + init { + val file = readFromFile() + time = file.time ?: LastRunFile.defaultTime() + failedTickets = file.failedTickets ?: emptySet() + shadowbans = file.shadowbans?.toMutableList() ?: mutableListOf() - failedTickets = lastRunFileComponents.subList(1, lastRunFileComponents.size).toSet() + // Initialize shadowban command + ShadowbanCommand.addShadowbannedUser = this::addShadowbannedUser } } diff --git a/src/main/kotlin/io/github/mojira/arisa/LastRunFile.kt b/src/main/kotlin/io/github/mojira/arisa/LastRunFile.kt new file mode 100644 index 000000000..15c7e237f --- /dev/null +++ b/src/main/kotlin/io/github/mojira/arisa/LastRunFile.kt @@ -0,0 +1,111 @@ +package io.github.mojira.arisa + +import com.beust.klaxon.Converter +import com.beust.klaxon.JsonValue +import com.beust.klaxon.Klaxon +import com.beust.klaxon.KlaxonException +import java.io.File +import java.time.Instant +import java.time.temporal.ChronoUnit + +class EpochMilliInstantConverter : Converter { + override fun canConvert(cls: Class<*>) = cls == Instant::class.java + override fun toJson(value: Any) = '"' + (value as Instant).toEpochMilli().toString() + '"' + override fun fromJson(jv: JsonValue): Instant = jv.string?.let { str -> + Instant.ofEpochMilli(str.toLong()) + } ?: run { + println("Can't read $jv from json") + LastRunFile.defaultTime() + } +} + +val instantConverter = EpochMilliInstantConverter() + +data class Shadowban( + val user: String, + val since: Instant, + val until: Instant +) { + fun banTimeContains(instant: Instant): Boolean = instant in since..until +} + +data class LastRunFile( + val time: Instant? = defaultTime(), + val failedTickets: Set? = emptySet(), + val shadowbans: List? = emptyList() +) { + companion object { + fun defaultTime(): Instant = + Instant.now().minus(LastRun.DEFAULT_START_TIME_MINUTES_BEFORE_NOW, ChronoUnit.MINUTES) + + fun read(readFromFile: () -> String): LastRunFile { + val default = LastRunFile( + time = defaultTime(), + failedTickets = setOf(), + shadowbans = listOf() + ) + + @SuppressWarnings("SwallowedException") + val result = try { + Klaxon().converter(instantConverter).parse(readFromFile()) + } catch (e: KlaxonException) { + default + } + + return result ?: default + } + } + + fun write(writeToFile: (String) -> Unit) { + val result = Klaxon().converter(instantConverter).toJsonString(this) + writeToFile(result) + } +} + +class LastRunFileService( + private val fileName: String, + private val legacyFileName: String +) { + fun writeLastRunFile(file: LastRunFile) { + val lastRunFile = File(fileName) + file.write(lastRunFile::writeText) + } + + fun getLastRunFile(): LastRunFile { + val lastRunFile = File(fileName) + if (!lastRunFile.exists()) migrateLegacyFile() + + return LastRunFile.read(lastRunFile::readText) + } + + // Migrate old last-run file + private fun migrateLegacyFile() { + val legacyFile = File(legacyFileName) + + val legacyContents = if (legacyFile.exists()) legacyFile.readText() else "" + + val newFileContents = convertLegacyFile(legacyContents) + newFileContents.write { + val newFile = File(fileName) + newFile.writeText(it) + } + + legacyFile.delete() + } + + companion object { + fun convertLegacyFile(legacyContents: String): LastRunFile { + val fileComponents = legacyContents.trim().split(',') + + val time = if (fileComponents[0].isNotEmpty()) { + Instant.ofEpochMilli(fileComponents[0].toLong()) + } else { + LastRunFile.defaultTime() + } + + val failedTickets = fileComponents.subList(1, fileComponents.size).toSet() + + return LastRunFile(time, failedTickets, shadowbans = emptyList()) + } + } +} diff --git a/src/main/kotlin/io/github/mojira/arisa/infrastructure/config/ArisaConfig.kt b/src/main/kotlin/io/github/mojira/arisa/infrastructure/config/ArisaConfig.kt index da820364a..092c71c4d 100644 --- a/src/main/kotlin/io/github/mojira/arisa/infrastructure/config/ArisaConfig.kt +++ b/src/main/kotlin/io/github/mojira/arisa/infrastructure/config/ArisaConfig.kt @@ -74,6 +74,11 @@ object Arisa : ConfigSpec() { description = "Whether or not the lastRun file should be saved after each run. " + "Do not disable this unless you've enabled the ticket whitelist." ) + + val ignoreOwnCommands by optional( + true, + description = "Whether to ignore commands from the bot user itself." + ) } object Modules : ConfigSpec() { @@ -402,6 +407,8 @@ object Arisa : ConfigSpec() { description = "The key to search for in a bot comment to trigger the removal of the comment" ) } + + object Shadowban : ModuleConfigSpec() } } diff --git a/src/main/kotlin/io/github/mojira/arisa/infrastructure/jira/Functions.kt b/src/main/kotlin/io/github/mojira/arisa/infrastructure/jira/Functions.kt index f58d79a48..cc06605a6 100644 --- a/src/main/kotlin/io/github/mojira/arisa/infrastructure/jira/Functions.kt +++ b/src/main/kotlin/io/github/mojira/arisa/infrastructure/jira/Functions.kt @@ -450,7 +450,7 @@ fun markAsFixedWithSpecificVersion(context: Lazy, fixVersion } fun changeReporter(context: Lazy, reporter: String) { - context.value.update.field(Field.REPORTER, reporter) + context.value.edit.field(Field.REPORTER, reporter) } // Allows some basic Jira formatting characters to be used by helper message arguments; diff --git a/src/main/kotlin/io/github/mojira/arisa/modules/CommandModule.kt b/src/main/kotlin/io/github/mojira/arisa/modules/CommandModule.kt index 1ed893cdd..643eb51f7 100644 --- a/src/main/kotlin/io/github/mojira/arisa/modules/CommandModule.kt +++ b/src/main/kotlin/io/github/mojira/arisa/modules/CommandModule.kt @@ -25,6 +25,7 @@ private val log: Logger = LoggerFactory.getLogger("CommandModule") class CommandModule( private val prefix: String, private val botUserName: String, + private val ignoreOwnCommands: Boolean, attachmentUtils: AttachmentUtils, private val getDispatcher: (String) -> CommandDispatcher = ::getCommandDispatcher.partially2(attachmentUtils) @@ -157,7 +158,7 @@ class CommandModule( private fun userIsVolunteer(comment: Comment): Boolean { // Ignore comments from the bot itself to prevent accidental infinite recursion and command // injection by malicious user - if (comment.author.name == botUserName) { + if (ignoreOwnCommands && comment.author.name == botUserName) { return false } diff --git a/src/main/kotlin/io/github/mojira/arisa/modules/Module.kt b/src/main/kotlin/io/github/mojira/arisa/modules/Module.kt index 95a20ba13..b21e115c8 100644 --- a/src/main/kotlin/io/github/mojira/arisa/modules/Module.kt +++ b/src/main/kotlin/io/github/mojira/arisa/modules/Module.kt @@ -1,11 +1,19 @@ package io.github.mojira.arisa.modules import arrow.core.Either +import io.github.mojira.arisa.ExecutionTimeframe import io.github.mojira.arisa.domain.Issue import java.time.Instant interface Module { operator fun invoke(issue: Issue, lastRun: Instant): Either + + // In case more details than just `lastRun` are needed, this function can be overridden + operator fun invoke( + issue: Issue, + timeframe: ExecutionTimeframe + ): Either = + invoke(issue, timeframe.lastRunTime) } typealias ModuleResponse = Unit diff --git a/src/main/kotlin/io/github/mojira/arisa/modules/ShadowbanModule.kt b/src/main/kotlin/io/github/mojira/arisa/modules/ShadowbanModule.kt new file mode 100644 index 000000000..22da5bf3b --- /dev/null +++ b/src/main/kotlin/io/github/mojira/arisa/modules/ShadowbanModule.kt @@ -0,0 +1,85 @@ +package io.github.mojira.arisa.modules + +import arrow.core.Either +import arrow.core.extensions.fx +import arrow.core.right +import io.github.mojira.arisa.ExecutionTimeframe +import io.github.mojira.arisa.domain.Comment +import io.github.mojira.arisa.domain.Issue +import io.github.mojira.arisa.domain.User +import org.slf4j.LoggerFactory +import java.time.Instant + +class ShadowbanModule : Module { + private val log = LoggerFactory.getLogger("ShadowbanModule") + + override fun invoke(issue: Issue, lastRun: Instant): Either { + // This implementation should never be run, but if it is anyway, just print to log and move on + log.error("Tried to invoke ShadowbanModule without details about shadowbans") + return Either.left(OperationNotNeededModuleResponse) + } + + override fun invoke( + issue: Issue, + timeframe: ExecutionTimeframe + ): Either = with(issue) { + Either.fx { + val bugReportRemoved = checkReporterForShadowban(timeframe) + val removedComments = checkCommentsForShadowban(timeframe) + val removedAttachments = checkAttachmentsForShadowban(timeframe) + + if (bugReportRemoved) log.info("[ShadowbanModule] Put $key into the spam bin") + if (removedComments > 0) log.info("[ShadowbanModule] Removed $removedComments comments from $key") + if (removedAttachments > 0) log.info("[ShadowbanModule] Removed $removedAttachments attachments from $key") + + val actionTaken = bugReportRemoved || removedComments > 0 || removedAttachments > 0 + + assertTrue(actionTaken).bind() + + ModuleResponse.right().bind() + } + } + + private fun Issue.checkReporterForShadowban(timeframe: ExecutionTimeframe): Boolean { + val reporterIsShadowbanned = reporter?.let { user -> + user.isNotVolunteer() && (timeframe.shadowbans[user.name]?.banTimeContains(created) ?: false) + } ?: false + + if (reporterIsShadowbanned) putInSpamBin() + + return reporterIsShadowbanned + } + + private fun Issue.checkCommentsForShadowban(timeframe: ExecutionTimeframe): Int = + comments + .filter { it.isNotStaffRestricted() } + .filter { it.author.isNotVolunteer() } + .filter { + timeframe.shadowbans[it.author.name]?.banTimeContains(it.created) ?: false + } + .map { it.restrict("${ it.body }\n\n_Removed by Arisa -- User is shadowbanned_") } + .size + + private fun Issue.checkAttachmentsForShadowban(timeframe: ExecutionTimeframe): Int = + attachments + .filter { it.uploader?.isNotVolunteer() ?: false } + .filter { + it.uploader?.let { uploader -> + timeframe.shadowbans[uploader.name]?.banTimeContains(it.created) + } ?: false + } + .map { it.remove() } + .size + + private fun User.isNotVolunteer() = + getGroups()?.none { it -> listOf("helper", "global-moderators", "staff").contains(it) } ?: true + + private fun Comment.isNotStaffRestricted() = + visibilityType != "group" || visibilityValue != "staff" + + private fun Issue.putInSpamBin() { + changeReporter("SpamBin") + setPrivate() + resolveAsInvalid() + } +} diff --git a/src/main/kotlin/io/github/mojira/arisa/modules/commands/CommandDispatcher.kt b/src/main/kotlin/io/github/mojira/arisa/modules/commands/CommandDispatcher.kt index bf0af1537..de1100f09 100644 --- a/src/main/kotlin/io/github/mojira/arisa/modules/commands/CommandDispatcher.kt +++ b/src/main/kotlin/io/github/mojira/arisa/modules/commands/CommandDispatcher.kt @@ -49,6 +49,7 @@ fun getCommandDispatcher( }, { Thread(it).start() } ) + val shadowbanCommand = ShadowbanCommand() return CommandDispatcher().apply { val addLinksCommandNode = @@ -258,6 +259,18 @@ fun getCommandDispatcher( ) } + val shadowbanCommandNode = + literal("${prefix}_SHADOWBAN") + .requires(::sentByModerator) + .then( + argument("username", greedyString()) + .executes { + shadowbanCommand( + it.getString("username") + ) + } + ) + register(addLinksCommandNode) register(addVersionCommandNode) register(clearProjectCacheCommandNode) @@ -273,6 +286,7 @@ fun getCommandDispatcher( register(removeLinksCommandNode) register(removeContentCommandNode) register(reopenCommandNode) + register(shadowbanCommandNode) } } diff --git a/src/main/kotlin/io/github/mojira/arisa/modules/commands/ShadowbanCommand.kt b/src/main/kotlin/io/github/mojira/arisa/modules/commands/ShadowbanCommand.kt new file mode 100644 index 000000000..b4d3c629c --- /dev/null +++ b/src/main/kotlin/io/github/mojira/arisa/modules/commands/ShadowbanCommand.kt @@ -0,0 +1,16 @@ +package io.github.mojira.arisa.modules.commands + +class ShadowbanCommand { + companion object { + // This function gets set at initialization of the `LastRun` class. + // Horrible hack but threading this through to the very top where `lastRun` is accessible would be difficult. + var addShadowbannedUser: (String) -> Unit = { } + } + + operator fun invoke( + userName: String + ): Int { + addShadowbannedUser(userName) + return 1 + } +} diff --git a/src/main/kotlin/io/github/mojira/arisa/registry/InstantModuleRegistry.kt b/src/main/kotlin/io/github/mojira/arisa/registry/InstantModuleRegistry.kt index 39279e119..63466a227 100644 --- a/src/main/kotlin/io/github/mojira/arisa/registry/InstantModuleRegistry.kt +++ b/src/main/kotlin/io/github/mojira/arisa/registry/InstantModuleRegistry.kt @@ -35,6 +35,7 @@ import io.github.mojira.arisa.modules.ThumbnailModule import io.github.mojira.arisa.modules.TransferLinksModule import io.github.mojira.arisa.modules.TransferVersionsModule import io.github.mojira.arisa.modules.RemoveBotCommentModule +import io.github.mojira.arisa.modules.ShadowbanModule /** * This class is the registry for modules that get executed immediately after a ticket has been updated. @@ -230,6 +231,7 @@ class InstantModuleRegistry(config: Config) : ModuleRegistry(config) { CommandModule( config[Arisa.Modules.Command.commandPrefix], config[Arisa.Credentials.username], + config[Arisa.Debug.ignoreOwnCommands], attachmentUtils ) ) @@ -251,5 +253,10 @@ class InstantModuleRegistry(config: Config) : ModuleRegistry(config) { config[Arisa.Modules.RemoveBotComment.removalTag] ) ) + + register( + Arisa.Modules.Shadowban, + ShadowbanModule() + ) } } diff --git a/src/main/kotlin/io/github/mojira/arisa/registry/ModuleRegistry.kt b/src/main/kotlin/io/github/mojira/arisa/registry/ModuleRegistry.kt index e1489b18e..c50318d64 100644 --- a/src/main/kotlin/io/github/mojira/arisa/registry/ModuleRegistry.kt +++ b/src/main/kotlin/io/github/mojira/arisa/registry/ModuleRegistry.kt @@ -12,7 +12,6 @@ import io.github.mojira.arisa.modules.FailedModuleResponse import io.github.mojira.arisa.modules.Module import io.github.mojira.arisa.modules.ModuleError import io.github.mojira.arisa.modules.ModuleResponse -import java.time.Instant // All defined module registries val getModuleRegistries = { config: Config -> @@ -27,7 +26,7 @@ abstract class ModuleRegistry(protected val config: Config) { data class Entry( val name: String, val config: ModuleConfigSpec, - val execute: (issue: Issue, lastRun: Instant) -> Pair>, + val execute: (issue: Issue, timeframe: ExecutionTimeframe) -> Pair>, val executor: ModuleExecutor ) @@ -60,8 +59,9 @@ abstract class ModuleRegistry(protected val config: Config) { ) } - private fun getModuleResult(moduleName: String, module: Module) = { issue: Issue, lastRun: Instant -> - moduleName to tryExecuteModule { module(issue, lastRun) } + private fun getModuleResult(moduleName: String, module: Module) = { + issue: Issue, timeframe: ExecutionTimeframe -> + moduleName to tryExecuteModule { module(issue, timeframe) } } private fun getJqlWithDebug(timeframe: ExecutionTimeframe): String { diff --git a/src/test/kotlin/io/github/mojira/arisa/ExecutionTimeframeTest.kt b/src/test/kotlin/io/github/mojira/arisa/ExecutionTimeframeTest.kt index 2eeecf874..7a357e1c5 100644 --- a/src/test/kotlin/io/github/mojira/arisa/ExecutionTimeframeTest.kt +++ b/src/test/kotlin/io/github/mojira/arisa/ExecutionTimeframeTest.kt @@ -16,7 +16,7 @@ class ExecutionTimeframeTest : StringSpec({ .truncatedTo(ChronoUnit.MILLIS) val lastRun = LastRun( - { lastRunTime.toEpochMilli().toString() }, + { LastRunFile(lastRunTime, emptySet(), emptyList()) }, { } ) @@ -48,7 +48,7 @@ class ExecutionTimeframeTest : StringSpec({ .truncatedTo(ChronoUnit.MILLIS) val lastRun = LastRun( - { lastRunTime.toEpochMilli().toString() }, + { LastRunFile(lastRunTime, emptySet(), emptyList()) }, { } ) diff --git a/src/test/kotlin/io/github/mojira/arisa/ExecutorTest.kt b/src/test/kotlin/io/github/mojira/arisa/ExecutorTest.kt index 27334b6e7..28a977df5 100644 --- a/src/test/kotlin/io/github/mojira/arisa/ExecutorTest.kt +++ b/src/test/kotlin/io/github/mojira/arisa/ExecutorTest.kt @@ -23,7 +23,7 @@ private val failedModuleRegistryMock = mockk() private val moduleExecutorMock = mockk() private val failedModuleExecutorMock = mockk() -private val dummyTimeframe = ExecutionTimeframe(Instant.now(), Instant.now(), true) +private val dummyTimeframe = ExecutionTimeframe(Instant.now(), Instant.now(), mapOf(), true) class ExecutorTest : StringSpec({ every { moduleRegistryMock.getEnabledModules() } returns listOf( diff --git a/src/test/kotlin/io/github/mojira/arisa/LastRunTest.kt b/src/test/kotlin/io/github/mojira/arisa/LastRunTest.kt index 920e9fdd4..e71155a38 100644 --- a/src/test/kotlin/io/github/mojira/arisa/LastRunTest.kt +++ b/src/test/kotlin/io/github/mojira/arisa/LastRunTest.kt @@ -4,65 +4,138 @@ import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.collections.shouldBeEmpty import io.kotest.matchers.collections.shouldContainExactly import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldNotBeEmpty import java.time.Instant import java.time.temporal.ChronoUnit class LastRunTest : StringSpec({ "should use default time before now if input file is empty" { - val lastRun = LastRun( - { "" }, - { } - ) + val lastRunFile = LastRunFile.read { "" } - val time = Instant.now().minus(LastRun.DEFAULT_START_TIME_MINUTES_BEFORE_NOW, ChronoUnit.MINUTES) + val time = LastRunFile.defaultTime() - lastRun.time.isAfter(time) shouldBe false - lastRun.failedTickets.shouldBeEmpty() + lastRunFile.time?.isAfter(time) shouldBe false + lastRunFile.failedTickets.shouldBeEmpty() + } + + "should use default time before now if input file is invalid JSON" { + val lastRunFile = LastRunFile.read { "{ hey this is invalid json }" } + + val time = LastRunFile.defaultTime() + + lastRunFile.time?.isAfter(time) shouldBe false + lastRunFile.failedTickets.shouldBeEmpty() } "should read time from input file" { val time = Instant.ofEpochMilli(123456789) - val lastRun = LastRun( - { time.toEpochMilli().toString() }, - { } - ) + val lastRunFile = LastRunFile.read { """{"time": "123456789"}""" } - lastRun.time shouldBe time - lastRun.failedTickets.shouldBeEmpty() + lastRunFile.time shouldBe time + lastRunFile.failedTickets.shouldBeEmpty() } "should read failed tickets from input file" { val time = Instant.now().truncatedTo(ChronoUnit.MILLIS) val tickets = listOf("MC-1234", "MCL-5678", "MCPE-9012") - val lastRun = LastRun( - { "${ time.toEpochMilli() },${ tickets.joinToString(",") }" }, - { } - ) + val lastRun = LastRunFile.read { + """{"time": "${ time.toEpochMilli() }", "failedTickets": ["MC-1234", "MCL-5678", "MCPE-9012"]}""" + } lastRun.time shouldBe time lastRun.failedTickets shouldContainExactly tickets } + "should read shadowbans from input file" { + val time = Instant.ofEpochMilli(222222222222) + + val shadowbans = listOf( + Shadowban( + user = "Spammer Mc Spamface", + since = Instant.ofEpochMilli(1000000), + until = Instant.ofEpochMilli(2000000) + ), + Shadowban( + user = "Monty Python", + since = Instant.ofEpochMilli(3000000), + until = Instant.ofEpochMilli(4000000) + ), + Shadowban( + user = "Nigerian Prince", + since = Instant.ofEpochMilli(5000000), + until = Instant.ofEpochMilli(6000000) + ) + ) + + val lastRun = LastRunFile.read { + """{ + "time": "222222222222", + "shadowbans": [ + {"user": "Spammer Mc Spamface", "since": "1000000", "until": "2000000"}, + {"user": "Monty Python", "since": "3000000", "until": "4000000"}, + {"user": "Nigerian Prince", "since": "5000000", "until": "6000000"} + ] + }""".trimMargin() + } + + lastRun.time shouldBe time + lastRun.shadowbans shouldContainExactly shadowbans + } + + "should write to file properly" { + val shadowbans = listOf( + Shadowban( + user = "Spammer Mc Spamface", + since = Instant.ofEpochMilli(1000000), + until = Instant.ofEpochMilli(2000000) + ), + Shadowban( + user = "Monty Python", + since = Instant.ofEpochMilli(3000000), + until = Instant.ofEpochMilli(4000000) + ), + Shadowban( + user = "Nigerian Prince", + since = Instant.ofEpochMilli(5000000), + until = Instant.ofEpochMilli(6000000) + ) + ) + + val lastRunFile = LastRunFile( + time = Instant.ofEpochMilli(123456789), + failedTickets = setOf("MC-1234", "WEB-12345", "MCPE-123"), + shadowbans = shadowbans + ) + + var written = "" + lastRunFile.write { written = it } + + written.shouldNotBeEmpty() + + val newLastRunFile = LastRunFile.read { written } + newLastRunFile shouldBe lastRunFile + } + "should update time and failed tickets" { val time = Instant.now() - val tickets = listOf("MC-1234", "MCL-5678", "MCPE-9012") + val tickets = setOf("MC-1234", "MCL-5678", "MCPE-9012") var writtenToFile = false - var fileContents = "${ time.toEpochMilli() },${ tickets.joinToString(",") }" + var file = LastRunFile(time, tickets, emptyList()) val lastRun = LastRun( - { fileContents }, - { contents -> + { file }, + { newFile -> writtenToFile = true - fileContents = contents + file = newFile } ) val newTime = Instant.now() val newTickets = setOf("MC-4", "MCPE-9012") - val newFileContents = "${ newTime.toEpochMilli() },${ newTickets.joinToString(",") }" + val newFile = LastRunFile(newTime, newTickets, emptyList()) lastRun.update(newTime, newTickets) @@ -70,6 +143,109 @@ class LastRunTest : StringSpec({ lastRun.failedTickets shouldContainExactly newTickets writtenToFile shouldBe true - fileContents shouldBe newFileContents + file shouldBe newFile + } + + "should update shadowbans" { + val time = Instant.ofEpochMilli(0) + + var shadowbans: List? = null + + val lastRun = LastRun( + { + LastRunFile(time, emptySet(), listOf( + Shadowban( + user = "Spammer Mc Spamface", + since = Instant.ofEpochMilli(1000000), + until = Instant.ofEpochMilli(2000000) + ), + Shadowban( + user = "Monty Python", + since = Instant.ofEpochMilli(4000000), + until = Instant.ofEpochMilli(6000000) + ) + )) + }, + { newFile -> shadowbans = newFile.shadowbans } + ) + + val newTime = Instant.ofEpochMilli(5000000) + lastRun.update(newTime, emptySet()) + + lastRun.getShadowbannedUsers() shouldBe mapOf( + "Monty Python" to Shadowban( + user = "Monty Python", + since = Instant.ofEpochMilli(4000000), + until = Instant.ofEpochMilli(6000000) + ) + ) + shadowbans shouldBe listOf(Shadowban( + user = "Monty Python", + since = Instant.ofEpochMilli(4000000), + until = Instant.ofEpochMilli(6000000) + )) + } + + "should add shadowbans" { + val time = Instant.ofEpochMilli(0) + + var shadowbans: List? = null + + val lastRun = LastRun( + { + LastRunFile(time, emptySet(), listOf( + Shadowban( + user = "Spammer Mc Spamface", + since = Instant.ofEpochMilli(1000000), + until = Instant.ofEpochMilli(2000000) + ) + )) + }, + { newFile -> shadowbans = newFile.shadowbans } + ) + + lastRun.addShadowbannedUser("Nigerian Prince") + val aDayLater = time.plus(24, ChronoUnit.HOURS) + + lastRun.update(Instant.ofEpochMilli(0), emptySet()) + + lastRun.getShadowbannedUsers() shouldBe mapOf( + "Spammer Mc Spamface" to Shadowban( + user = "Spammer Mc Spamface", + since = Instant.ofEpochMilli(1000000), + until = Instant.ofEpochMilli(2000000) + ), + "Nigerian Prince" to Shadowban( + user = "Nigerian Prince", + since = time, + until = aDayLater + ) + ) + + shadowbans shouldContainExactly listOf( + Shadowban( + user = "Spammer Mc Spamface", + since = Instant.ofEpochMilli(1000000), + until = Instant.ofEpochMilli(2000000) + ), + Shadowban( + user = "Nigerian Prince", + since = time, + until = aDayLater + ) + ) + } + + "legacy last-run file upgrade should work properly" { + LastRunFileService.convertLegacyFile("123456789,MC-1234,WEB-234") shouldBe LastRunFile( + Instant.ofEpochMilli(123456789), setOf("MC-1234", "WEB-234"), emptyList() + ) + + LastRunFileService.convertLegacyFile("123456789") shouldBe LastRunFile( + Instant.ofEpochMilli(123456789), emptySet(), emptyList() + ) + + val now = Instant.now() + LastRunFileService.convertLegacyFile("").time?.isBefore(now) shouldBe true } }) diff --git a/src/test/kotlin/io/github/mojira/arisa/modules/CommandModuleTest.kt b/src/test/kotlin/io/github/mojira/arisa/modules/CommandModuleTest.kt index 404d55ecd..7d8faad12 100644 --- a/src/test/kotlin/io/github/mojira/arisa/modules/CommandModuleTest.kt +++ b/src/test/kotlin/io/github/mojira/arisa/modules/CommandModuleTest.kt @@ -26,7 +26,7 @@ private const val BOT_USER_NAME = "botName" class CommandModuleTest : StringSpec({ val attachmentUtils = AttachmentUtils(emptyList(), CrashReader(), BOT_USER_NAME) - val module = CommandModule(PREFIX, BOT_USER_NAME, attachmentUtils, ::getDispatcher) + val module = CommandModule(PREFIX, BOT_USER_NAME, true, attachmentUtils, ::getDispatcher) "should return OperationNotNeededModuleResponse when no comments" { val issue = mockIssue() @@ -200,7 +200,7 @@ class CommandModuleTest : StringSpec({ "should work for other prefixes" { var updatedComment = "" @Suppress("NAME_SHADOWING") - val module = CommandModule("TESTING_COMMAND", "userName", attachmentUtils, ::getDispatcher) + val module = CommandModule("TESTING_COMMAND", "userName", true, attachmentUtils, ::getDispatcher) val comment = getComment( author = mockUser( name = "SPTesting" diff --git a/src/test/kotlin/io/github/mojira/arisa/modules/ShadowbanModuleTest.kt b/src/test/kotlin/io/github/mojira/arisa/modules/ShadowbanModuleTest.kt new file mode 100644 index 000000000..15709a212 --- /dev/null +++ b/src/test/kotlin/io/github/mojira/arisa/modules/ShadowbanModuleTest.kt @@ -0,0 +1,244 @@ +package io.github.mojira.arisa.modules + +import io.github.mojira.arisa.ExecutionTimeframe +import io.github.mojira.arisa.Shadowban +import io.github.mojira.arisa.utils.mockAttachment +import io.github.mojira.arisa.utils.mockComment +import io.github.mojira.arisa.utils.mockIssue +import io.github.mojira.arisa.utils.mockUser +import io.kotest.assertions.arrow.either.shouldBeLeft +import io.kotest.assertions.arrow.either.shouldBeRight +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import java.time.Instant + +class ShadowbanModuleTest : StringSpec({ + val module = ShadowbanModule() + + val shadowbannedUser = mockUser(name = "shadowbanned") + val noLongerShadowbannedUser = mockUser(name = "nolongerbanned") + + val timeframe = ExecutionTimeframe( + lastRunTime = Instant.ofEpochMilli(0), + currentRunTime = Instant.ofEpochMilli(1000), + shadowbans = mapOf( + ( + "shadowbanned" to Shadowban( + user = "shadowbanned", + since = Instant.ofEpochMilli(500), + until = Instant.ofEpochMilli(1500) + ) + ), + ( + "nolongerbanned" to Shadowban( + user = "nolongerbanned", + since = Instant.ofEpochMilli(0), + until = Instant.ofEpochMilli(500) + ) + ) + ), + openEnded = false + ) + + "should remove bug reports created during shadowban" { + var reporter = "shadowbanned" + var isPrivate = false + var resolution = "Unresolved" + + val issue = mockIssue( + reporter = shadowbannedUser, + changeReporter = { reporter = it }, + setPrivate = { isPrivate = true }, + resolveAsInvalid = { resolution = "Invalid" }, + created = Instant.ofEpochMilli(800) + ) + + val result = module(issue, timeframe) + result.shouldBeRight(ModuleResponse) + reporter shouldBe "SpamBin" + isPrivate shouldBe true + resolution shouldBe "Invalid" + } + + "should not remove bug reports created outside of shadowban duration" { + var reporter = "shadowbanned" + var isPrivate = false + var resolution = "Unresolved" + + val issue = mockIssue( + reporter = shadowbannedUser, + changeReporter = { reporter = it }, + setPrivate = { isPrivate = true }, + resolveAsInvalid = { resolution = "Invalid" }, + created = Instant.ofEpochMilli(200) + ) + + val result = module(issue, timeframe) + result.shouldBeLeft(OperationNotNeededModuleResponse) + reporter shouldBe "shadowbanned" + isPrivate shouldBe false + resolution shouldBe "Unresolved" + } + + "should not remove bug reports from unbanned users" { + var reporter = "nolongerbanned" + var isPrivate = false + var resolution = "Unresolved" + + val issue = mockIssue( + reporter = noLongerShadowbannedUser, + changeReporter = { reporter = it }, + setPrivate = { isPrivate = true }, + resolveAsInvalid = { resolution = "Invalid" }, + created = Instant.ofEpochMilli(800) + ) + + val result = module(issue, timeframe) + result.shouldBeLeft(OperationNotNeededModuleResponse) + reporter shouldBe "nolongerbanned" + isPrivate shouldBe false + resolution shouldBe "Unresolved" + } + + "should remove comments created during shadowban" { + var commentRestricted = false + var otherCommentRestricted = false + + val shadowbannedComment = mockComment( + author = shadowbannedUser, + created = Instant.ofEpochMilli(800), + restrict = { commentRestricted = true } + ) + + val otherComment = mockComment( + created = Instant.ofEpochMilli(900), + restrict = { otherCommentRestricted = true } + ) + + val issue = mockIssue( + comments = listOf(shadowbannedComment, otherComment) + ) + + val result = module(issue, timeframe) + result.shouldBeRight(ModuleResponse) + commentRestricted shouldBe true + otherCommentRestricted shouldBe false + } + + "should not restrict restricted comments" { + var commentRestricted = false + + val shadowbannedComment = mockComment( + author = shadowbannedUser, + created = Instant.ofEpochMilli(800), + restrict = { commentRestricted = true }, + visibilityType = "group", + visibilityValue = "staff" + ) + + val issue = mockIssue( + comments = listOf(shadowbannedComment) + ) + + val result = module(issue, timeframe) + result.shouldBeLeft(OperationNotNeededModuleResponse) + commentRestricted shouldBe false + } + + "should not remove comments created outside of shadowban duration" { + var commentRestricted = false + + val shadowbannedComment = mockComment( + author = shadowbannedUser, + created = Instant.ofEpochMilli(200), + restrict = { commentRestricted = true } + ) + + val issue = mockIssue( + comments = listOf(shadowbannedComment) + ) + + val result = module(issue, timeframe) + result.shouldBeLeft(OperationNotNeededModuleResponse) + commentRestricted shouldBe false + } + + "should not remove comments created by unbanned users" { + var commentRestricted = false + + val shadowbannedComment = mockComment( + author = noLongerShadowbannedUser, + created = Instant.ofEpochMilli(800), + restrict = { commentRestricted = true } + ) + + val issue = mockIssue( + comments = listOf(shadowbannedComment) + ) + + val result = module(issue, timeframe) + result.shouldBeLeft(OperationNotNeededModuleResponse) + commentRestricted shouldBe false + } + + "should remove attachments created during shadowban" { + var attachmentRemoved = false + var otherAttachmentRemoved = false + + val shadowbannedAttachment = mockAttachment( + uploader = shadowbannedUser, + created = Instant.ofEpochMilli(800), + remove = { attachmentRemoved = true } + ) + + val otherAttachment = mockAttachment( + created = Instant.ofEpochMilli(900), + remove = { otherAttachmentRemoved = true } + ) + + val issue = mockIssue( + attachments = listOf(shadowbannedAttachment, otherAttachment) + ) + + val result = module(issue, timeframe) + result.shouldBeRight(ModuleResponse) + attachmentRemoved shouldBe true + otherAttachmentRemoved shouldBe false + } + + "should not remove attachments created outside of shadowban duration" { + var attachmentRemoved = false + + val shadowbannedAttachment = mockAttachment( + uploader = shadowbannedUser, + created = Instant.ofEpochMilli(200), + remove = { attachmentRemoved = true } + ) + + val issue = mockIssue( + attachments = listOf(shadowbannedAttachment) + ) + + val result = module(issue, timeframe) + result.shouldBeLeft(OperationNotNeededModuleResponse) + attachmentRemoved shouldBe false + } + + "should not remove attachments created by unbanned users" { + var attachmentRemoved = false + + val shadowbannedAttachment = mockAttachment( + uploader = noLongerShadowbannedUser, + created = Instant.ofEpochMilli(800), + remove = { attachmentRemoved = true } + ) + + val issue = mockIssue( + attachments = listOf(shadowbannedAttachment) + ) + + val result = module(issue, timeframe) + result.shouldBeLeft(OperationNotNeededModuleResponse) + attachmentRemoved shouldBe false + } +})