diff --git a/build.gradle.kts b/build.gradle.kts index f5f441b..b9324cd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -28,6 +28,11 @@ dependencies { runtimeOnly("com.google.code.gson:gson:2.11.0") // Json implementation("org.reflections:reflections:0.10.2") // Reflections implementation("org.jetbrains.kotlin:kotlin-reflect") // Kotlin Reflection + + // Coroutines + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") { + exclude(group = "org.jetbrains.kotlin") + } } tasks.test { diff --git a/src/main/kotlin/at/hannibal2/skyhanni/discord/BotConfig.kt b/src/main/kotlin/at/hannibal2/skyhanni/discord/BotConfig.kt index 63b0650..348b4e9 100644 --- a/src/main/kotlin/at/hannibal2/skyhanni/discord/BotConfig.kt +++ b/src/main/kotlin/at/hannibal2/skyhanni/discord/BotConfig.kt @@ -1,4 +1,5 @@ package at.hannibal2.skyhanni.discord + import com.google.gson.GsonBuilder import org.slf4j.LoggerFactory import java.io.File @@ -7,6 +8,7 @@ import kotlin.system.exitProcess data class BotConfig( val token: String, val botCommandChannelId: String, + val moderationChannelId: String, val allowedServerId: String, val githubTokenOwn: String, val githubTokenPullRequests: String, @@ -15,24 +17,29 @@ data class BotConfig( object ConfigLoader { private val gson = GsonBuilder().setPrettyPrinting().create() - private val logger = LoggerFactory.getLogger(ConfigLoader::class.java) + private val logger = LoggerFactory.getLogger(ConfigLoader::class.java) private val exampleConfig = BotConfig( - "TODO: discord token", - "TODO: staff channel id", - "TODO: allowed server id", - "TODO: github token with sh bot repo access", - "TODO: github token with sh mod repo access", - mapOf( - "user friendly (non important) name" to "TODO: role id" - ) - ) - fun load(filePath: String): BotConfig { - try { - val json = File(filePath).readText() - return gson.fromJson(json, BotConfig::class.java) - } catch (ex: Exception) { - logger.error("Could not load config. Below is an example config:\n```json\n${gson.toJson(exampleConfig)}\n```", ex) - exitProcess(1) - } + "TODO: discord token", + "TODO: staff channel id", + "TODO: moderation channel id", + "TODO: allowed server id", + "TODO: github token with sh bot repo access", + "TODO: github token with sh mod repo access", + mapOf( + "user friendly (non important) name" to "TODO: role id" + ) + ) + + fun load(filePath: String): BotConfig { + try { + val json = File(filePath).readText() + return gson.fromJson(json, BotConfig::class.java) + } catch (ex: Exception) { + logger.error( + "Could not load config. Below is an example config:\n```json\n${gson.toJson(exampleConfig)}\n```", + ex + ) + exitProcess(1) + } } } \ No newline at end of file diff --git a/src/main/kotlin/at/hannibal2/skyhanni/discord/CommandListener.kt b/src/main/kotlin/at/hannibal2/skyhanni/discord/CommandListener.kt index 5fe8c84..8b20394 100644 --- a/src/main/kotlin/at/hannibal2/skyhanni/discord/CommandListener.kt +++ b/src/main/kotlin/at/hannibal2/skyhanni/discord/CommandListener.kt @@ -74,13 +74,27 @@ object CommandListener { } } - // allows to use `! -help` instaed of `!help -` + // allows to use `! -help` instead of `!help -` if (args.size == 1 && args.first() == "-help") { with(HelpCommand) { sendUsageReply(literal) } return } + + if (command.async) { + Utils.launchIOCoroutine { + execute(command, args) + } + } else { + execute(command, args) + } + } + + private fun MessageReceivedEvent.execute( + command: BaseCommand, + args: List + ) { try { with(command) { execute(args) diff --git a/src/main/kotlin/at/hannibal2/skyhanni/discord/DiscordBot.kt b/src/main/kotlin/at/hannibal2/skyhanni/discord/DiscordBot.kt index 346f830..c6d9c10 100644 --- a/src/main/kotlin/at/hannibal2/skyhanni/discord/DiscordBot.kt +++ b/src/main/kotlin/at/hannibal2/skyhanni/discord/DiscordBot.kt @@ -67,6 +67,7 @@ private fun startBot(): DiscordBot { val jda = JDABuilder.createDefault(token).also { builder -> builder.enableIntents(GatewayIntent.MESSAGE_CONTENT) + builder.enableIntents(GatewayIntent.GUILD_MEMBERS) builder.setEnableShutdownHook(false) }.build() diff --git a/src/main/kotlin/at/hannibal2/skyhanni/discord/Utils.kt b/src/main/kotlin/at/hannibal2/skyhanni/discord/Utils.kt index 1d7b0b7..606ed3f 100644 --- a/src/main/kotlin/at/hannibal2/skyhanni/discord/Utils.kt +++ b/src/main/kotlin/at/hannibal2/skyhanni/discord/Utils.kt @@ -1,6 +1,8 @@ package at.hannibal2.skyhanni.discord +import kotlinx.coroutines.* import net.dv8tion.jda.api.EmbedBuilder +import net.dv8tion.jda.api.entities.Guild import net.dv8tion.jda.api.entities.Message import net.dv8tion.jda.api.entities.MessageEmbed import net.dv8tion.jda.api.entities.channel.concrete.TextChannel @@ -11,6 +13,8 @@ import net.dv8tion.jda.api.utils.FileUpload import java.awt.Color import java.awt.Toolkit.getDefaultToolkit import java.io.File +import java.time.Instant +import java.time.format.DateTimeFormatter import java.util.zip.ZipFile import kotlin.time.Duration import kotlin.time.Duration.Companion.nanoseconds @@ -68,6 +72,10 @@ object Utils { } } + fun MessageChannel.embedSend(embed: MessageEmbed) { + sendMessageEmbeds(embed).queue() + } + fun Message.replyWithConsumer(text: String, consumer: (MessageReceivedEvent) -> Unit) { BotMessageHandler.log(text, consumer) messageReply(text) @@ -165,6 +173,21 @@ object Utils { } + fun parseToUnixTime(isoTimestamp: String): Long = + Instant.from(DateTimeFormatter.ISO_INSTANT.parse(isoTimestamp)).epochSecond + + fun passedSince(stringTime: String): String = "" + + private val mentionRegex = Regex("\\d+)>?") + + fun String.getId(guild: Guild?): String? { + return mentionRegex.matchEntire(this)?.let { result -> + result.groups["id"]?.value + } ?: guild?.let { + guild.retrieveMembersByPrefix(this, 1).get().first().id + } + } + fun String.linkTo(link: String): String = "[$this](<$link>)" // keep comments as docs @@ -242,4 +265,31 @@ object Utils { fun readStringFromClipboard(): String? = runCatching { getDefaultToolkit().systemClipboard.getData(java.awt.datatransfer.DataFlavor.stringFlavor) as String }.getOrNull() + + private val globalJob: Job = Job(null) + val coroutineScope = CoroutineScope( + CoroutineName("SkyBot") + SupervisorJob(globalJob), + ) + + fun launchIOCoroutine(block: suspend CoroutineScope.() -> Unit) { + launchCoroutine { + withContext(Dispatchers.IO) { + try { + block() + } catch (e: Exception) { + e.printStackTrace() + } + } + } + } + + fun launchCoroutine(function: suspend () -> Unit) { + coroutineScope.launch { + try { + function() + } catch (e: Exception) { + e.printStackTrace() + } + } + } } \ No newline at end of file diff --git a/src/main/kotlin/at/hannibal2/skyhanni/discord/command/BaseCommand.kt b/src/main/kotlin/at/hannibal2/skyhanni/discord/command/BaseCommand.kt index d789707..ff87e28 100644 --- a/src/main/kotlin/at/hannibal2/skyhanni/discord/command/BaseCommand.kt +++ b/src/main/kotlin/at/hannibal2/skyhanni/discord/command/BaseCommand.kt @@ -14,6 +14,8 @@ abstract class BaseCommand { open val userCommand: Boolean = false + open val async: Boolean = false + protected open val aliases: List = emptyList() abstract fun MessageReceivedEvent.execute(args: List) diff --git a/src/main/kotlin/at/hannibal2/skyhanni/discord/command/ManageCommands.kt b/src/main/kotlin/at/hannibal2/skyhanni/discord/command/ManageCommands.kt new file mode 100644 index 0000000..b986c5c --- /dev/null +++ b/src/main/kotlin/at/hannibal2/skyhanni/discord/command/ManageCommands.kt @@ -0,0 +1,146 @@ +package at.hannibal2.skyhanni.discord.command + + +import at.hannibal2.skyhanni.discord.BOT +import at.hannibal2.skyhanni.discord.CHECK_MARK +import at.hannibal2.skyhanni.discord.Option +import at.hannibal2.skyhanni.discord.SimpleTimeMark +import at.hannibal2.skyhanni.discord.Utils.embed +import at.hannibal2.skyhanni.discord.Utils.embedSend +import at.hannibal2.skyhanni.discord.Utils.getId +import at.hannibal2.skyhanni.discord.Utils.passedSince +import at.hannibal2.skyhanni.discord.Utils.reply +import at.hannibal2.skyhanni.discord.Utils.userError +import at.hannibal2.skyhanni.discord.command.ManageCommands.punishmentReply +import at.hannibal2.skyhanni.discord.command.ManageCommands.sendDM +import net.dv8tion.jda.api.EmbedBuilder +import net.dv8tion.jda.api.entities.Member +import net.dv8tion.jda.api.entities.MessageEmbed +import net.dv8tion.jda.api.entities.User +import net.dv8tion.jda.api.events.message.MessageReceivedEvent +import net.dv8tion.jda.api.exceptions.ErrorResponseException +import net.dv8tion.jda.api.requests.ErrorResponse +import java.awt.Color +import java.util.concurrent.TimeUnit + +object ManageCommands { + fun MessageReceivedEvent.punishmentReply( + action: String, + target: User, + executor: Member, + reason: String, + dmSent: Boolean + ) { + val modChannel = guild.getTextChannelById(BOT.config.moderationChannelId) ?: channel + + reply("$CHECK_MARK $action ${target.asMention} ${if (dmSent) "with" else "without"} dm!") + modChannel.embedSend(createModerationEmbed(action, target, executor, reason)) + } + + private fun createModerationEmbed( + action: String, + target: User, + moderator: Member, + reason: String, + ): MessageEmbed { + return EmbedBuilder() + .setAuthor("${target.name} was $action", null, target.avatarUrl) + .addField("Mention", target.asMention, false) + .addField("Moderator", moderator.asMention, false) + .addField("Reason", reason, false) + .addField("Time", passedSince(SimpleTimeMark.now().toString()), false) + .setColor(if (action == "unbanned") Color.GREEN else Color.RED) + .build() + } + + fun User.sendDM(action: String, reason: String, color: Color = Color.RED): Boolean { + val dm = openPrivateChannel().complete() + val embed = embed("You were $action!", "**Reason:** $reason", color) + + try { + dm.sendMessageEmbeds(embed).complete() + } catch (t: ErrorResponseException) { + if (t.errorResponse == ErrorResponse.CANNOT_SEND_TO_USER) return false + } + + return true + } +} + +@Suppress("unused") +class KickCommand : BaseCommand() { + override val name: String = "kick" + override val description: String = "Kicks a member from the server." + override val options: List