Skip to content

Commit

Permalink
Add shadowban command (#754)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
violine1101 authored Dec 9, 2021
1 parent 82fa74c commit b4be4c3
Show file tree
Hide file tree
Showing 21 changed files with 790 additions and 58 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@ arisa.yml
# Files and folders created when running Arisa
/logs/
last-run
lastrun.json
helper-messages.json
mc-mappings/
12 changes: 12 additions & 0 deletions docs/Commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,3 +155,15 @@ Keys and type are case insensitive

Reopens tickets resolved as Awaiting Response

## $ARISA_SHADOWBAN
| Entry | Value |
| ----------- | ----------------------------- |
| Syntax | `$ARISA_SHADOWBAN <username>` |
| 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".
27 changes: 27 additions & 0 deletions docs/Modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| ----- | ----------------------------------------------------------------------------------- |
Expand Down
10 changes: 9 additions & 1 deletion src/main/kotlin/io/github/mojira/arisa/ExecutionTimeframe.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import java.time.temporal.ChronoUnit
class ExecutionTimeframe(
val lastRunTime: Instant,
val currentRunTime: Instant,
val shadowbans: Map<String, Shadowban>,
private val openEnded: Boolean
) {
companion object {
Expand All @@ -35,7 +36,12 @@ class ExecutionTimeframe(
endOfMaxTimeframe
}

return ExecutionTimeframe(lastRun.time, currentRunTime, runOpenEnded)
return ExecutionTimeframe(
lastRun.time,
currentRunTime,
lastRun.getShadowbannedUsers(),
runOpenEnded
)
}
}

Expand All @@ -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.
*
Expand Down
2 changes: 1 addition & 1 deletion src/main/kotlin/io/github/mojira/arisa/Executor.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
}
}

Expand Down
57 changes: 36 additions & 21 deletions src/main/kotlin/io/github/mojira/arisa/LastRun.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,29 @@ 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

/**
* 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)
}
}
)
Expand All @@ -34,28 +33,44 @@ class LastRun(

var time: Instant
var failedTickets: Set<String>
private var shadowbans: MutableList<Shadowban>

/**
* 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<String>) {
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<String, Shadowban> =
// 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
}
}
111 changes: 111 additions & 0 deletions src/main/kotlin/io/github/mojira/arisa/LastRunFile.kt
Original file line number Diff line number Diff line change
@@ -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<String>? = emptySet(),
val shadowbans: List<Shadowban>? = 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<LastRunFile>(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())
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -450,7 +450,7 @@ fun markAsFixedWithSpecificVersion(context: Lazy<IssueUpdateContext>, fixVersion
}

fun changeReporter(context: Lazy<IssueUpdateContext>, 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<CommandSource> =
::getCommandDispatcher.partially2(attachmentUtils)
Expand Down Expand Up @@ -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
}

Expand Down
8 changes: 8 additions & 0 deletions src/main/kotlin/io/github/mojira/arisa/modules/Module.kt
Original file line number Diff line number Diff line change
@@ -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<ModuleError, ModuleResponse>

// In case more details than just `lastRun` are needed, this function can be overridden
operator fun invoke(
issue: Issue,
timeframe: ExecutionTimeframe
): Either<ModuleError, ModuleResponse> =
invoke(issue, timeframe.lastRunTime)
}

typealias ModuleResponse = Unit
Expand Down
Loading

0 comments on commit b4be4c3

Please sign in to comment.