From 11695256666f20cbe47389156ec49e4a8d164fd5 Mon Sep 17 00:00:00 2001 From: sschr15 Date: Sat, 3 Feb 2024 12:09:42 -0600 Subject: [PATCH] Add a vc mute For when a timeout is too excessive but a user is disruptive in voice --- .../database/entities/ServerSettings.kt | 2 + .../database/entities/UserRestrictions.kt | 4 + .../database/migrations/AllMigrations.kt | 19 ++ .../quilt/extensions/moderation/Arguments.kt | 166 +++++++----------- .../moderation/ModerationExtension.kt | 162 ++++++++++++++++- 5 files changed, 243 insertions(+), 110 deletions(-) diff --git a/src/main/kotlin/org/quiltmc/community/database/entities/ServerSettings.kt b/src/main/kotlin/org/quiltmc/community/database/entities/ServerSettings.kt index 5cba5353..a852b13a 100644 --- a/src/main/kotlin/org/quiltmc/community/database/entities/ServerSettings.kt +++ b/src/main/kotlin/org/quiltmc/community/database/entities/ServerSettings.kt @@ -49,6 +49,8 @@ data class ServerSettings( val exemptUsers: MutableSet = mutableSetOf(), val exemptRoles: MutableSet = mutableSetOf(), + + var vcMuteRole: Snowflake? = null, ) : Entity { suspend fun save() { val collection = getKoin().get() diff --git a/src/main/kotlin/org/quiltmc/community/database/entities/UserRestrictions.kt b/src/main/kotlin/org/quiltmc/community/database/entities/UserRestrictions.kt index 18f7ea37..db9b47a2 100644 --- a/src/main/kotlin/org/quiltmc/community/database/entities/UserRestrictions.kt +++ b/src/main/kotlin/org/quiltmc/community/database/entities/UserRestrictions.kt @@ -25,6 +25,10 @@ data class UserRestrictions( var previousTimeouts: MutableList = mutableListOf(), + var isMuted: Boolean = false, + // Separate check to allow a timeout at the same time as a mute + var returningMuteTime: Instant? = null, + @Deprecated("No longer used, kept for migration purposes") var lastProgressiveTimeoutLength: Int = 0, ) : Entity { diff --git a/src/main/kotlin/org/quiltmc/community/database/migrations/AllMigrations.kt b/src/main/kotlin/org/quiltmc/community/database/migrations/AllMigrations.kt index 48669817..9455448a 100644 --- a/src/main/kotlin/org/quiltmc/community/database/migrations/AllMigrations.kt +++ b/src/main/kotlin/org/quiltmc/community/database/migrations/AllMigrations.kt @@ -335,4 +335,23 @@ object AllMigrations { ) } } + + suspend fun v28(db: CoroutineDatabase) { + with(db.getCollection(UserRestrictionsCollection.name)) { + updateMany( + UserRestrictions::isMuted exists false, + setValue(UserRestrictions::isMuted, false) + ) + + updateMany( + UserRestrictions::returningMuteTime exists false, + setValue(UserRestrictions::returningMuteTime, null) + ) + } + + db.getCollection(ServerSettingsCollection.name).updateMany( + ServerSettings::vcMuteRole exists false, + setValue(ServerSettings::vcMuteRole, null) + ) + } } diff --git a/src/main/kotlin/org/quiltmc/community/modes/quilt/extensions/moderation/Arguments.kt b/src/main/kotlin/org/quiltmc/community/modes/quilt/extensions/moderation/Arguments.kt index 71ff09f3..a17c6128 100644 --- a/src/main/kotlin/org/quiltmc/community/modes/quilt/extensions/moderation/Arguments.kt +++ b/src/main/kotlin/org/quiltmc/community/modes/quilt/extensions/moderation/Arguments.kt @@ -12,6 +12,7 @@ import com.kotlindiscord.kord.extensions.commands.application.slash.converters.C import com.kotlindiscord.kord.extensions.commands.application.slash.converters.impl.enumChoice import com.kotlindiscord.kord.extensions.commands.application.slash.converters.impl.stringChoice import com.kotlindiscord.kord.extensions.commands.converters.Validator +import com.kotlindiscord.kord.extensions.commands.converters.builders.ConverterBuilder import com.kotlindiscord.kord.extensions.commands.converters.impl.* import com.kotlindiscord.kord.extensions.utils.getKoin import com.kotlindiscord.kord.extensions.utils.suggestDoubleMap @@ -208,47 +209,23 @@ class BanArguments : RequiresReason("The user to ban") { description = "The length of the ban in seconds, 0 for indefinite, or -1 to end (default indefinite)" defaultValue = 0L - autoComplete { - val map = mapFrom( - "second" to 1, - "minute" to 60, - "hour" to 3600, - "day" to 86_400, - "week" to 604_800, - "month" to 2_629_746, - "year" to 31_557_600, - defaultMap = mapOf( - "1 minute" to 60, - "5 minutes" to 300, - "10 minutes" to 600, - "30 minutes" to 1_800, - "1 hour" to 3_600, - "2 hours" to 7_200, - "3 hours" to 10_800, - "6 hours" to 21_600, - "1 day" to 86_400, - "2 days" to 172_800, - "3 days" to 259_200, - "1 week" to 604_800, - "2 weeks" to 1_209_600, - "3 weeks" to 1_814_400, - "1 month" to 2_592_000, - "2 months" to 5_184_000, - "3 months" to 7_168_000, - "6 months" to 15_552_000, - "1 year" to 31_556_952, - "forever" to 0, - "unban" to -1 - ) - ) - - suggestLongMap(map) - } + restrictionLength(false, "Remove ban") } val daysToDelete by banDeleteDaySelector() } +class VcMuteArguments : RequiresReason("The user to mute") { + @Suppress("MagicNumber") + val length by defaultingLong { + name = "length" + description = "The length of the mute (default 30 minutes)" + defaultValue = 1_800 + + restrictionLength(false, "Remove mute") + } +} + class TimeoutArguments : RequiresReason("The user to timeout") { @Suppress("MagicNumber") val length by defaultingLong { @@ -256,35 +233,7 @@ class TimeoutArguments : RequiresReason("The user to timeout") { description = "The length of the timeout (default 5 minutes)" defaultValue = 300 - autoComplete { - val map = mapFrom( - "second" to 1L, - "minute" to 60L, - "hour" to 3600L, - "day" to 86_400L, - "week" to 604_800L, - defaultMap = mapOf( - "1 minute" to 60L, - "5 minutes" to 300L, - "10 minutes" to 600L, - "30 minutes" to 1_800L, - "1 hour" to 3_600L, - "2 hours" to 7_200L, - "3 hours" to 10_800L, - "6 hours" to 21_600L, - "1 day" to 86_400L, - "2 days" to 172_800L, - "3 days" to 259_200L, - "1 week" to 604_800L, - "2 weeks" to 1_209_600L, - "3 weeks" to 1_814_400L, - "1 month" to 2_592_000L, - "Remove timeout" to -1L - ) - ) - - suggestLongMap(map) - } + restrictionLength(true, "Remove timeout") validate { failIfNot("length must be between 0 and 28 days") { @@ -319,42 +268,7 @@ class ActionArguments : Arguments() { description = "The length of the action (default 1 month)" defaultValue = 2_629_746 - autoComplete { - val map = mapFrom( - "second" to 1, - "minute" to 60, - "hour" to 3600, - "day" to 86_400, - "week" to 604_800, - "month" to 2_629_746, - "year" to 31_557_600, - defaultMap = mapOf( - "1 minute" to 60, - "5 minutes" to 300, - "10 minutes" to 600, - "30 minutes" to 1_800, - "1 hour" to 3_600, - "2 hours" to 7_200, - "3 hours" to 10_800, - "6 hours" to 21_600, - "1 day" to 86_400, - "2 days" to 172_800, - "3 days" to 259_200, - "1 week" to 604_800, - "2 weeks" to 1_209_600, - "3 weeks" to 1_814_400, - "1 month" to 2_592_000, - "2 months" to 5_184_000, - "3 months" to 7_168_000, - "6 months" to 15_552_000, - "1 year" to 31_556_952, - "forever" to 0, - "remove" to -1 - ) - ) - - suggestLongMap(map) - } + restrictionLength(false, "Remove restriction") } val banDeleteDays by banDeleteDaySelector() @@ -403,7 +317,7 @@ internal fun Arguments.banDeleteDaySelector() = defaultingDecimal { } internal fun AutoCompleteInteraction.mapFrom( - vararg conversions: Pair, + conversions: List>, defaultMap: Map = mapOf(), ): Map { val specifiedLength = focusedOption.value.substringBefore(' ').toLongOrNull() @@ -418,3 +332,51 @@ internal fun AutoCompleteInteraction.mapFrom( defaultMap } } + +@Suppress("MagicNumber") +internal fun ConverterBuilder.restrictionLength(timeout: Boolean, removeName: String) { + val options = listOf( + "second" to 1, + "minute" to 60, + "hour" to 3600, + "day" to 86_400, + "week" to 604_800, + "month" to 2_629_746, + "year" to 31_557_600, + ).filter { !timeout || it.second <= 2_592_000 } + .map { (s, i) -> s to i.toLong() } + + val defaultMapValues = mapOf( + "1 minute" to 60, + "5 minutes" to 300, + "10 minutes" to 600, + "30 minutes" to 1_800, + "1 hour" to 3_600, + "2 hours" to 7_200, + "3 hours" to 10_800, + "6 hours" to 21_600, + "1 day" to 86_400, + "2 days" to 172_800, + "3 days" to 259_200, + "1 week" to 604_800, + "2 weeks" to 1_209_600, + "3 weeks" to 1_814_400, + "1 month" to 2_592_000, + "2 months" to 5_184_000, + "3 months" to 7_168_000, + "6 months" to 15_552_000, + "1 year" to 31_556_952, + "forever" to 0, + removeName to -1 + ).mapValues { it.value.toLong() } + + val defaultMap = if (timeout) { + defaultMapValues.filterKeys { it != "forever" }.filterValues { it <= 2_592_000 } + } else { + defaultMapValues + } + + autoComplete { + suggestLongMap(mapFrom(options, defaultMap)) + } +} diff --git a/src/main/kotlin/org/quiltmc/community/modes/quilt/extensions/moderation/ModerationExtension.kt b/src/main/kotlin/org/quiltmc/community/modes/quilt/extensions/moderation/ModerationExtension.kt index 3e4c3b11..107b33a9 100644 --- a/src/main/kotlin/org/quiltmc/community/modes/quilt/extensions/moderation/ModerationExtension.kt +++ b/src/main/kotlin/org/quiltmc/community/modes/quilt/extensions/moderation/ModerationExtension.kt @@ -450,6 +450,7 @@ class ModerationExtension( description = "Ban a user from the server for a specified amount of time." check { hasBaseModeratorRole() } + check { inLadysnakeGuild() } action(::beanUser) } @@ -458,9 +459,19 @@ class ModerationExtension( description = "Timeout a user from the server for a specified amount of time." check { hasBaseModeratorRole() } + check { inLadysnakeGuild() } action(::timeout) } + ephemeralSlashCommand(::VcMuteArguments) { + name = "vc-mute" + description = "Mute a user from voice channels. This applies alongside timeouts and does not override them." + + check { hasBaseModeratorRole() } + check { inLadysnakeGuild() } + + action(::mute) + } @Suppress("USELESS_CAST") // Kordex needs @OverloadResolutionByLambdaReturnType ephemeralSlashCommand({ RequiresReason("The user to kick") } as () -> RequiresReason) { name = "kick" @@ -690,7 +701,9 @@ class ModerationExtension( if (endTime == null) { val restriction = userRestrictions.get(arguments.user) if (restriction != null) { - userRestrictions.remove(arguments.user) + restriction.returningBanTime = null + restriction.isBanned = false + restriction.save() } guild!!.getMemberOrNull(arguments.user)?.removeTimeout(arguments.reason) @@ -752,16 +765,26 @@ class ModerationExtension( check { hasBaseModeratorRole() } - suspend fun remove(guild: Guild, user: User, isBan: Boolean) { + suspend fun remove(guild: Guild, user: User, isBan: Boolean, isMute: Boolean = false) { val restriction = userRestrictions.get(user.id) if (restriction != null) { - restriction.returningBanTime = null - restriction.isBanned = false + if (!isMute) { + restriction.returningBanTime = null + restriction.isBanned = false + } else { + restriction.returningMuteTime = null + restriction.isMuted = false + } + restriction.save() } if (isBan) { guild.unban(user.id) + } else if (isMute) { + val muteRole = guild.getSettings()?.vcMuteRole + ?: throw DiscordRelayedException("Mute role not found.") + user.asMemberOrNull(guild.id)?.removeRole(muteRole) } else { user.asMemberOrNull(guild.id)?.removeTimeout() } @@ -850,6 +873,27 @@ class ModerationExtension( } } } + + // why does this require the type to be specified? idea really hates me today + ephemeralSubCommand({ RequiredUser("The user to remove the mute from") }) { + name = "vc-mute" + description = "Remove a user's voice mute." + + check { hasBaseModeratorRole() } + check { inLadysnakeGuild() } + + action { + val user = arguments.user() + val guild = getGuild()?.asGuild() + ?: throw DiscordRelayedException("Guild not found. Are you running this in a DM?") + + remove(guild, user, false, true) + + respond { + content = "Removed voice mute for ${user.mention}." + } + } + } } event { @@ -962,7 +1006,9 @@ class ModerationExtension( @Suppress("MagicNumber") scheduler.schedule(5, repeat = true) { - val timedOutIds = userRestrictions.getAll() + val allCurrentRestrictions = userRestrictions.getAll() + + val timedOutIds = allCurrentRestrictions .filter { !it.isBanned && it.returningBanTime != null } .map { it to kord.getGuildOrNull(it.guildId)?.getMemberOrNull(it._id) } .filter { it.second != null } @@ -990,22 +1036,38 @@ class ModerationExtension( } } - val bannedUsers = userRestrictions.getAll() + val bannedUsers = allCurrentRestrictions .filter { it.isBanned && it.returningBanTime!! <= Clock.System.now() } bannedUsers.forEach { val userId = it._id val guild = kord.getGuildOrNull(it.guildId)!! + it.isBanned = false + it.returningBanTime = null + it.save() + // sanity check if (guild.getBanOrNull(userId) == null) { logger.warn { "User $userId was attempted to be unbanned, even though they already are" } - userRestrictions.remove(userId) // remove the restriction that shouldn't be there anyway return@forEach } guild.unban(userId) - userRestrictions.remove(userId) // remove the restriction + } + + allCurrentRestrictions.filter { it.isMuted }.forEach { + val guild = kord.getGuildOrNull(it.guildId) ?: return@forEach + val member = guild.getMemberOrNull(it._id) ?: return@forEach + + if (it.returningMuteTime!! >= Clock.System.now()) { + val muteRole = guild.getSettings()?.vcMuteRole ?: return@forEach + member.removeRole(muteRole) + + it.isMuted = false + it.returningMuteTime = null + it.save() + } } } } @@ -1300,6 +1362,90 @@ class ModerationExtension( } } + private suspend fun mute(context: EphemeralSlashCommandContext, ignored: ModalForm?) { + val muteRole = context.getGuild()?.getSettings()?.vcMuteRole + ?: throw DiscordRelayedException("No VC mute role is set up in this guild.") + + val user = context.arguments.user + val member = user.asMember(context.getGuild()!!.id) + + val reason = context.arguments.reason + val length = context.arguments.length + val endTime = Clock.System.now() + length.seconds + val removal = length < 0 + + if (!removal) { + member.addRole(muteRole, reason) + } else { + member.removeRole(muteRole, reason) + } + + val restriction = userRestrictions.get(user.id) ?: UserRestrictions( + member.id, + context.guild!!.id, + ) + + restriction.isMuted = !removal + restriction.returningMuteTime = endTime.takeIf { !removal } + restriction.save() + + val returnTime = endTime.toDiscord(TimestampType.Default) + + val action = if (!removal) "Muted" else "Unmuted" + + try { + user.dm { + content = "You have been muted in voice channels in ${context.guild!!.asGuild().name} " + + "until $returnTime for the following reason:\n\n" + + + context.arguments.reason + } + + reportToModChannel(context.guild?.asGuild()) { + title = "Voice mute" + description = "$action ${user.mention} in voice channels" + field { + name = "Reason" + value = context.arguments.reason + } + if (!removal) { + field { + name = "Length" + value = if (length == 0L) "Permanent" else "${length.seconds} (until $returnTime)" + } + } + field { + name = "Responsible moderator" + value = context.user.mention + } + } + } catch (e: RestRequestException) { + reportToModChannel(context.guild?.asGuild()) { + title = "Voice mute" + description = "$action ${user.mention} in voice channels" + field { + name = "Reason" + value = context.arguments.reason + } + if (!removal) { + field { + name = "Length" + value = if (length == 0L) "Permanent" else "${length.seconds} (until $returnTime)" + } + } + field { + name = "Responsible moderator" + value = context.user.mention + } + + field { + name = " " + value = "Failed to DM user." + } + } + } + } + private suspend fun User?.tryDM(guild: Guild? = null, builder: UserMessageCreateBuilder.() -> Unit) { if (this != null) { try {