From 401c43424fdf99d76918e19a232d04ea2d2434b5 Mon Sep 17 00:00:00 2001 From: ILike2WatchMemes Date: Sun, 9 Mar 2025 23:30:44 +0100 Subject: [PATCH 1/9] add slash commands --- .../skyhanni/discord/CommandListener.kt | 116 +++++++++- .../hannibal2/skyhanni/discord/DiscordBot.kt | 23 +- .../at/hannibal2/skyhanni/discord/Utils.kt | 99 +++++++-- .../skyhanni/discord/command/BaseCommand.kt | 7 +- .../skyhanni/discord/command/HelpCommand.kt | 84 ++++--- .../discord/command/PullRequestCommand.kt | 206 +++++++++--------- .../discord/command/ServerCommands.kt | 77 ++++--- .../skyhanni/discord/command/TagCommands.kt | 198 ++++++++++------- 8 files changed, 539 insertions(+), 271 deletions(-) diff --git a/src/main/kotlin/at/hannibal2/skyhanni/discord/CommandListener.kt b/src/main/kotlin/at/hannibal2/skyhanni/discord/CommandListener.kt index 92210f3..f2b43a1 100644 --- a/src/main/kotlin/at/hannibal2/skyhanni/discord/CommandListener.kt +++ b/src/main/kotlin/at/hannibal2/skyhanni/discord/CommandListener.kt @@ -4,8 +4,18 @@ import at.hannibal2.skyhanni.discord.Utils.hasAdminPermissions import at.hannibal2.skyhanni.discord.Utils.inBotCommandChannel import at.hannibal2.skyhanni.discord.Utils.logAction import at.hannibal2.skyhanni.discord.Utils.reply -import at.hannibal2.skyhanni.discord.command.* +import at.hannibal2.skyhanni.discord.command.BaseCommand +import at.hannibal2.skyhanni.discord.command.PullRequestCommand +import at.hannibal2.skyhanni.discord.command.ServerCommands +import at.hannibal2.skyhanni.discord.command.TagCommands +import at.hannibal2.skyhanni.discord.command.HelpCommand +import net.dv8tion.jda.api.entities.Guild +import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent import net.dv8tion.jda.api.events.message.MessageReceivedEvent +import net.dv8tion.jda.api.interactions.commands.OptionType +import net.dv8tion.jda.api.interactions.commands.build.Commands +import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData import org.reflections.Reflections import java.lang.reflect.Modifier @@ -20,16 +30,25 @@ object CommandListener { fun init() { loadCommands() loadAliases() + BOT.jda.getGuildById(BOT.config.allowedServerId)?.let { createCommands(it) } } fun onMessage(bot: DiscordBot, event: MessageReceivedEvent) { event.onMessage(bot) } + fun onInteraction(bot: DiscordBot, event: SlashCommandInteractionEvent) { + event.onInteraction(bot) + } + + fun onAutocomplete(event: CommandAutoCompleteInteractionEvent) { + event.onCompletion() + } + private fun MessageReceivedEvent.onMessage(bot: DiscordBot) { val message = message.contentRaw.trim() if (!isFromGuild) { - logAction("private dm: '$message'") + logAction("private dm: '$message'", this) return } if (guild.id != bot.config.allowedServerId) return @@ -59,35 +78,83 @@ object CommandListener { } if (!command.userCommand) { - if (!hasAdminPermissions()) { - reply("No permissions $PLEADING_FACE") + if (!hasAdminPermissions(this)) { + reply("No permissions $PLEADING_FACE", this) return } - if (!inBotCommandChannel()) { - reply("Wrong channel $PLEADING_FACE") + if (!inBotCommandChannel(this)) { + reply("Wrong channel $PLEADING_FACE", this) return } } - // allows to use `! -help` instaed of `!help -` + // allows to use `! -help` instead of `!help -` if (args.size == 1) { if (args.first() == "-help") { with(HelpCommand) { - this@onMessage.sendUsageReply(literal) + this.sendUsageReply(literal, this@onMessage) } return } } try { with(command) { - this@onMessage.execute(args) + execute(args, this@onMessage) + } + } catch (e: Exception) { + reply("Error: ${e.message}", this) + } + } + + private fun SlashCommandInteractionEvent.onInteraction(bot: DiscordBot) { + if (guild?.id != bot.config.allowedServerId || this.user.isBot) return + + val command = getCommand(this.fullCommandName) ?: return + + if (!command.userCommand) { + if (!hasAdminPermissions(this)) { + reply("No permissions $PLEADING_FACE") + return + } + + if (!inBotCommandChannel(this)) { + reply("Wrong channel $PLEADING_FACE") + return + } + } + + try { + with(command) { + execute(listOf(), this@onInteraction) } } catch (e: Exception) { reply("Error: ${e.message}") } } + private fun CommandAutoCompleteInteractionEvent.onCompletion() { + when (fullCommandName) { + "help" -> { + if (focusedOption.name != "command") return + + replyChoiceStrings( + commands.filterKeys { key -> key.startsWith(focusedOption.value) }.keys + ).queue() + } + + "server" -> { + if (focusedOption.name != "keyword") return + + replyChoiceStrings( + ServerCommands.servers.filter { it.name.startsWith(focusedOption.value, true) } + .map { it.name } + .take(25) + ).queue() + } + } + } + private val commandPattern = "^!(?!!).+".toPattern() // ensures the command starts with ! while ignoring !! @@ -114,7 +181,6 @@ object CommandListener { } } - private fun loadAliases() { for (command in commands.values) { for (alias in command.aliases) { @@ -124,6 +190,34 @@ object CommandListener { } } } + + fun createCommands(guild: Guild) { + guild.retrieveCommands().queue { + val commandData = commands.values.map { value -> convertToData(value) } + + guild.updateCommands().addCommands(commandData).queue() + } + } + + private fun convertToData(old: BaseCommand): SlashCommandData { + return Commands.slash(old.name, old.description).apply { + old.options.forEach { option -> + addOption( + option.type, + option.name.replace(" ", "_"), + option.description, + option.required, + option.autoComplete + ) + } + } + } } -data class Option(val name: String, val description: String, val required: Boolean = true) \ No newline at end of file +open class Option( + val name: String, + val description: String, + val required: Boolean = true, + val type: OptionType = OptionType.STRING, + val autoComplete: Boolean = false +) \ No newline at end of file diff --git a/src/main/kotlin/at/hannibal2/skyhanni/discord/DiscordBot.kt b/src/main/kotlin/at/hannibal2/skyhanni/discord/DiscordBot.kt index a243737..9a941f4 100644 --- a/src/main/kotlin/at/hannibal2/skyhanni/discord/DiscordBot.kt +++ b/src/main/kotlin/at/hannibal2/skyhanni/discord/DiscordBot.kt @@ -3,7 +3,10 @@ package at.hannibal2.skyhanni.discord import at.hannibal2.skyhanni.discord.Utils.sendMessageToBotChannel import net.dv8tion.jda.api.JDA import net.dv8tion.jda.api.JDABuilder +import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent import net.dv8tion.jda.api.events.message.MessageReceivedEvent +import net.dv8tion.jda.api.events.session.ReadyEvent import net.dv8tion.jda.api.hooks.ListenerAdapter import net.dv8tion.jda.api.requests.GatewayIntent import org.slf4j.Logger @@ -68,12 +71,30 @@ private fun startBot(): DiscordBot { }.build() val bot = DiscordBot(jda, config) - jda.awaitReady() val messageListener = object : ListenerAdapter() { override fun onMessageReceived(event: MessageReceivedEvent) { CommandListener.onMessage(bot, event) } } + val slashCommandListener = object : ListenerAdapter() { + override fun onSlashCommandInteraction(event: SlashCommandInteractionEvent) { + CommandListener.onInteraction(bot, event) + } + + override fun onCommandAutoCompleteInteraction(event: CommandAutoCompleteInteractionEvent) { + CommandListener.onAutocomplete(event) + } + + override fun onReady(event: ReadyEvent) { + event.jda.getGuildById(config.allowedServerId)?.let { + CommandListener.createCommands(it) + } + } + } + jda.addEventListener(slashCommandListener) jda.addEventListener(messageListener) + + jda.awaitReady() + return bot } diff --git a/src/main/kotlin/at/hannibal2/skyhanni/discord/Utils.kt b/src/main/kotlin/at/hannibal2/skyhanni/discord/Utils.kt index f92cf27..d928ae5 100644 --- a/src/main/kotlin/at/hannibal2/skyhanni/discord/Utils.kt +++ b/src/main/kotlin/at/hannibal2/skyhanni/discord/Utils.kt @@ -1,14 +1,15 @@ package at.hannibal2.skyhanni.discord import net.dv8tion.jda.api.EmbedBuilder +import net.dv8tion.jda.api.entities.Member import net.dv8tion.jda.api.entities.Message import net.dv8tion.jda.api.entities.MessageEmbed import net.dv8tion.jda.api.entities.channel.concrete.TextChannel import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel import net.dv8tion.jda.api.entities.channel.unions.MessageChannelUnion +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent import net.dv8tion.jda.api.events.message.MessageReceivedEvent import net.dv8tion.jda.api.utils.FileUpload -import org.slf4j.LoggerFactory import java.awt.Color import java.io.File import java.util.zip.ZipFile @@ -20,26 +21,43 @@ object Utils { private inline val logger get() = BOT.logger - fun MessageReceivedEvent.reply(text: String) { - message.messageReply(text) + fun reply(text: String, event: Any, ephemeral: Boolean = false) { + doWhen(event, { + it.message.messageReply(text) + }, { + it.reply(text).setEphemeral(ephemeral).queue() + }) } - fun MessageReceivedEvent.userError(text: String) { - message.messageReply("❌ $text") + fun reply(embed: MessageEmbed, event: Any, ephemeral: Boolean = false) { + doWhen(event, { + it.message.messageReply(embed) + }, { + it.replyEmbeds(embed).setEphemeral(ephemeral).queue() + }) } - fun MessageReceivedEvent.sendError(text: String) { - message.messageReply("❌ An error occurred: $text") - logAction("Error: $text") + fun userError(text: String, event: Any, ephemeral: Boolean = true) { + doWhen(event, { + it.message.messageReply("❌ $text") + }, { + it.reply("❌ $text").setEphemeral(ephemeral).queue() + }) } - fun MessageReceivedEvent.reply(embed: MessageEmbed) { - message.messageReply(embed) + fun sendError(text: String, event: Any, ephemeral: Boolean = true) { + doWhen(event, { + it.message.messageReply("❌ $text") + }, { + it.reply("❌ $text").setEphemeral(ephemeral).queue() + }) + + logAction("Error: $text", event) } fun MessageReceivedEvent.replyWithConsumer(text: String, consumer: (MessageReceivedEvent) -> Unit) { BotMessageHandler.log(text, consumer) - reply(text) + reply(text, this) } fun Message.messageDelete() { @@ -78,31 +96,68 @@ object Utils { BOT.jda.getTextChannelById(BOT.config.botCommandChannelId)?.messageSend(text) } - fun MessageReceivedEvent.logAction(action: String, raw: Boolean = false) { + fun logAction(action: String, event: Any, raw: Boolean = false) { + val author = when (event) { + is MessageReceivedEvent -> event.author + is SlashCommandInteractionEvent -> event.user + else -> throw IllegalArgumentException("Unknown event type") + } + + val member = when (event) { + is MessageReceivedEvent -> event.member + is SlashCommandInteractionEvent -> event.member + else -> throw IllegalArgumentException("Unknown event type") + } + + val channel = when (event) { + is MessageReceivedEvent -> event.channel + is SlashCommandInteractionEvent -> event.channel + else -> throw IllegalArgumentException("Unknown event type") + } + if (raw) { logger.info(action) return } + val name = author.name val id = author.id + val nickString = member?.nickname?.takeIf { it != "null" }?.let { " (`$it`)" } ?: "" + val isFromGuild = + (event as? MessageReceivedEvent)?.isFromGuild ?: (event as? SlashCommandInteractionEvent)?.isFromGuild + ?: false + val channelSuffix = if (isFromGuild) " in channel '${channel.name}'" else "" - val nick = member?.nickname?.takeIf { it != "null" } - val nickString = nick?.let { " (`$nick`)" } ?: "" - - val channelSuffix = if (isFromGuild) { - val channelName = channel.name - " in channel '$channelName'" - } else "" logger.info("$id/$name$nickString $action$channelSuffix") } - fun MessageReceivedEvent.hasAdminPermissions(): Boolean { - val member = member ?: return false + fun hasAdminPermissions(event: Any): Boolean { + val member = doWhen( + event, + { it.member }, + { it.member } + ) as Member + val allowedRoleIds = BOT.config.editPermissionRoleIds.values return !member.roles.none { it.id in allowedRoleIds } } - fun MessageReceivedEvent.inBotCommandChannel() = channel.id == BOT.config.botCommandChannelId + fun doWhen( + event: Any, + consumer: (MessageReceivedEvent) -> T?, + consumer2: (SlashCommandInteractionEvent) -> T? + ): T? { + return when (event) { + is MessageReceivedEvent -> consumer(event) + is SlashCommandInteractionEvent -> consumer2(event) + else -> null + } + } + + fun inBotCommandChannel(event: Any): Boolean { + val id = doWhen(event, { it.channel.id }, { it.channel.id }) + return id == BOT.config.botCommandChannelId + } fun runDelayed(duration: Duration, consumer: () -> Unit) { Thread { 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 ff89aba..fbacf3d 100644 --- a/src/main/kotlin/at/hannibal2/skyhanni/discord/command/BaseCommand.kt +++ b/src/main/kotlin/at/hannibal2/skyhanni/discord/command/BaseCommand.kt @@ -2,7 +2,6 @@ package at.hannibal2.skyhanni.discord.command import at.hannibal2.skyhanni.discord.Option import at.hannibal2.skyhanni.discord.Utils.userError -import net.dv8tion.jda.api.events.message.MessageReceivedEvent abstract class BaseCommand { @@ -16,9 +15,9 @@ abstract class BaseCommand { open val aliases: List = emptyList() - abstract fun MessageReceivedEvent.execute(args: List) + abstract fun execute(args: List, event: Any) - protected fun MessageReceivedEvent.wrongUsage(args: String) { - userError("Usage: `!$name $args`") + protected fun wrongUsage(args: String, event: Any) { + userError("Usage: `!$name $args`", event) } } \ No newline at end of file diff --git a/src/main/kotlin/at/hannibal2/skyhanni/discord/command/HelpCommand.kt b/src/main/kotlin/at/hannibal2/skyhanni/discord/command/HelpCommand.kt index 95e0da0..661d1dc 100644 --- a/src/main/kotlin/at/hannibal2/skyhanni/discord/command/HelpCommand.kt +++ b/src/main/kotlin/at/hannibal2/skyhanni/discord/command/HelpCommand.kt @@ -1,6 +1,10 @@ package at.hannibal2.skyhanni.discord.command -import at.hannibal2.skyhanni.discord.* +import at.hannibal2.skyhanni.discord.BOT +import at.hannibal2.skyhanni.discord.CommandListener +import at.hannibal2.skyhanni.discord.Option +import at.hannibal2.skyhanni.discord.PLEADING_FACE +import at.hannibal2.skyhanni.discord.Utils.doWhen import at.hannibal2.skyhanni.discord.Utils.hasAdminPermissions import at.hannibal2.skyhanni.discord.Utils.inBotCommandChannel import at.hannibal2.skyhanni.discord.Utils.messageDelete @@ -9,7 +13,6 @@ import at.hannibal2.skyhanni.discord.Utils.replyWithConsumer import at.hannibal2.skyhanni.discord.Utils.runDelayed import net.dv8tion.jda.api.EmbedBuilder import net.dv8tion.jda.api.entities.MessageEmbed -import net.dv8tion.jda.api.events.message.MessageReceivedEvent import java.awt.Color import kotlin.time.Duration.Companion.seconds @@ -17,68 +20,85 @@ object HelpCommand : BaseCommand() { override val name: String = "help" override val description: String = "Get help for all OR one specific command." override val options: List