diff --git a/src/main/kotlin/at/hannibal2/skyhanni/discord/CommandListener.kt b/src/main/kotlin/at/hannibal2/skyhanni/discord/CommandListener.kt index 5fe8c84..d3c2eba 100644 --- a/src/main/kotlin/at/hannibal2/skyhanni/discord/CommandListener.kt +++ b/src/main/kotlin/at/hannibal2/skyhanni/discord/CommandListener.kt @@ -3,14 +3,21 @@ package at.hannibal2.skyhanni.discord 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.BaseCommand import at.hannibal2.skyhanni.discord.command.HelpCommand -import at.hannibal2.skyhanni.discord.command.PullRequestCommand +import at.hannibal2.skyhanni.discord.command.BaseCommand +import at.hannibal2.skyhanni.discord.command.MessageEvent +import at.hannibal2.skyhanni.discord.command.SlashCommandEvent import at.hannibal2.skyhanni.discord.command.ServerCommands +import at.hannibal2.skyhanni.discord.command.PullRequestCommand.isPullRequest +import at.hannibal2.skyhanni.discord.command.ServerCommands.isKnownServerUrl import at.hannibal2.skyhanni.discord.command.TagCommands +import at.hannibal2.skyhanni.discord.command.TagCommands.handleTag import at.hannibal2.skyhanni.discord.command.TagUndo -import net.dv8tion.jda.api.events.message.MessageReceivedEvent +import net.dv8tion.jda.api.entities.Guild +import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent +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 @@ -23,42 +30,51 @@ object CommandListener { fun init() { loadCommands() + + BOT.jda.awaitReady() + + BOT.jda.getGuildById(BOT.config.allowedServerId)?.let { createCommands(it) } } - fun onMessage(bot: DiscordBot, event: MessageReceivedEvent) { + fun onMessage(bot: DiscordBot, event: MessageEvent) { event.onMessage(bot) } - private fun MessageReceivedEvent.onMessage(bot: DiscordBot) { + fun onInteraction(bot: DiscordBot, event: SlashCommandEvent) { + event.onInteraction(bot) + } + + fun onAutocomplete(event: CommandAutoCompleteInteractionEvent) { + event.onCompletion() + } + + private fun MessageEvent.onMessage(bot: DiscordBot) { val message = message.contentRaw.trim() if (!isFromGuild) { logAction("private dm: '$message'") return } - if (guild.id != bot.config.allowedServerId) return + if (event.guild.id != bot.config.allowedServerId) return - if (this.author.isBot) { - if (this.author.id == BOT_ID) { - BotMessageHandler.handle(this) + if (author.isBot) { + if (author.id == BOT_ID) { + BotMessageHandler.handle(event) } return } if (TagUndo.getAllNames().none { "!$it" == message }) { - TagCommands.lastMessages.remove(this.author.id) + TagCommands.lastMessages.remove(author.id) } - if (ServerCommands.isKnownServerUrl(this, message)) return - if (PullRequestCommand.isPullRequest(this, message)) return - - if (!isCommand(message)) return + if (isKnownServerUrl(message) || isPullRequest(message) || !isCommand(message)) return val split = message.substring(1).split(" ") val literal = split.first().lowercase() val args = split.drop(1) val command = getCommand(literal) ?: run { - TagCommands.handleTag(this) + handleTag() return } @@ -74,13 +90,22 @@ 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 } + // allows to use `! -help` instead of `!help -` + if (args.size == 1) { + if (args.first() == "-help") { + with(HelpCommand) { + sendUsageReply(literal) + } + return + } + } try { with(command) { execute(args) @@ -90,6 +115,74 @@ object CommandListener { } } + private fun SlashCommandEvent.onInteraction(bot: DiscordBot) { + if (event.guild?.id != bot.config.allowedServerId || author.isBot) return + + val command = getCommand(event.fullCommandName) ?: return + + if (!command.userCommand) { + if (!hasAdminPermissions()) { + reply("No permissions $PLEADING_FACE") + return + } + + if (!inBotCommandChannel()) { + reply("Wrong channel $PLEADING_FACE") + return + } + } + + try { + with(command) { + execute(listOf()) + } + } catch (e: Exception) { + reply("Error: ${e.message}") + } + } + + private fun CommandAutoCompleteInteractionEvent.onCompletion() { + when (fullCommandName) { + "help" -> { + if (focusedOption.name != "command") return + + replyChoiceStrings( + commands.filter { it.name.startsWith(focusedOption.value) } + .map { it.name } + .take(25) + ).queue() + } + + "server" -> { + if (focusedOption.name != "keyword") return + + replyChoiceStrings( + ServerCommands.servers.filter { it.name.startsWith(focusedOption.value, true) } + .map { it.name } + .take(25) + ).queue() + } + + "tagedit" -> { + if (focusedOption.name != "keyword") return + + replyChoiceStrings( + Database.listTags().filter { it.keyword.startsWith(focusedOption.value, true) }.map { it.keyword } + .take(25) + ).queue() + } + + "tagdelete" -> { + if (focusedOption.name != "keyword") return + + replyChoiceStrings( + Database.listTags().filter { it.keyword.startsWith(focusedOption.value, true) }.map { it.keyword } + .take(25) + ).queue() + } + } + } + private val commandPattern = "^!(?!!)[\\s\\S]+".toPattern() // ensures the command starts with ! while ignoring !! @@ -121,6 +214,34 @@ object CommandListener { this.commands = commands this.commandsMap = commandsMap } + + private fun createCommands(guild: Guild) { + guild.retrieveCommands().queue { + val commandData = commands.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 +data 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 346f830..b5b20fa 100644 --- a/src/main/kotlin/at/hannibal2/skyhanni/discord/DiscordBot.kt +++ b/src/main/kotlin/at/hannibal2/skyhanni/discord/DiscordBot.kt @@ -1,8 +1,12 @@ package at.hannibal2.skyhanni.discord import at.hannibal2.skyhanni.discord.Utils.sendMessageToBotChannel +import at.hannibal2.skyhanni.discord.command.MessageEvent +import at.hannibal2.skyhanni.discord.command.SlashCommandEvent 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.hooks.ListenerAdapter import net.dv8tion.jda.api.requests.GatewayIntent @@ -71,12 +75,24 @@ 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) + CommandListener.onMessage(bot, MessageEvent(event)) + } + } + val slashCommandListener = object : ListenerAdapter() { + override fun onSlashCommandInteraction(event: SlashCommandInteractionEvent) { + CommandListener.onInteraction(bot, SlashCommandEvent(event)) + } + + override fun onCommandAutoCompleteInteraction(event: CommandAutoCompleteInteractionEvent) { + CommandListener.onAutocomplete(event) } } + 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 1d7b0b7..2b6a2ef 100644 --- a/src/main/kotlin/at/hannibal2/skyhanni/discord/Utils.kt +++ b/src/main/kotlin/at/hannibal2/skyhanni/discord/Utils.kt @@ -1,5 +1,6 @@ package at.hannibal2.skyhanni.discord +import at.hannibal2.skyhanni.discord.command.CommandEvent import net.dv8tion.jda.api.EmbedBuilder import net.dv8tion.jda.api.entities.Message import net.dv8tion.jda.api.entities.MessageEmbed @@ -20,28 +21,6 @@ object Utils { private inline val logger get() = BOT.logger - fun MessageReceivedEvent.reply(text: String) { - message.messageReply(text) - } - - fun MessageReceivedEvent.userError(text: String) { - message.messageReply("❌ $text") - } - - fun MessageReceivedEvent.sendError(text: String) { - message.messageReply("❌ An error occurred: $text") - logAction("Error: $text") - } - - fun MessageReceivedEvent.reply(embed: MessageEmbed) { - message.messageReply(embed) - } - - fun MessageReceivedEvent.replyWithConsumer(text: String, consumer: (MessageReceivedEvent) -> Unit) { - BotMessageHandler.log(text, consumer) - reply(text) - } - fun Message.messageDelete() { delete().queue() } @@ -82,31 +61,29 @@ object Utils { BOT.jda.getTextChannelById(BOT.config.botCommandChannelId)?.messageSend(text, instantly) } - fun MessageReceivedEvent.logAction(action: String, raw: Boolean = false) { + fun CommandEvent.logAction(action: String, raw: Boolean = false) { 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 = isFromGuild + 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 { + fun CommandEvent.hasAdminPermissions(): Boolean { val member = member ?: return false + val allowedRoleIds = BOT.config.editPermissionRoleIds.values return !member.roles.none { it.id in allowedRoleIds } } - fun MessageReceivedEvent.inBotCommandChannel() = channel.id == BOT.config.botCommandChannelId + fun CommandEvent.inBotCommandChannel(): Boolean = channel.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 d789707..a8051bf 100644 --- a/src/main/kotlin/at/hannibal2/skyhanni/discord/command/BaseCommand.kt +++ b/src/main/kotlin/at/hannibal2/skyhanni/discord/command/BaseCommand.kt @@ -1,8 +1,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 +14,9 @@ abstract class BaseCommand { protected open val aliases: List = emptyList() - abstract fun MessageReceivedEvent.execute(args: List) + abstract fun CommandEvent.execute(args: List) - protected fun MessageReceivedEvent.wrongUsage(args: String) { + protected fun CommandEvent.wrongUsage(args: String) { userError("Usage: `!$name $args`") } diff --git a/src/main/kotlin/at/hannibal2/skyhanni/discord/command/CommandEvent.kt b/src/main/kotlin/at/hannibal2/skyhanni/discord/command/CommandEvent.kt new file mode 100644 index 0000000..427c013 --- /dev/null +++ b/src/main/kotlin/at/hannibal2/skyhanni/discord/command/CommandEvent.kt @@ -0,0 +1,114 @@ +package at.hannibal2.skyhanni.discord.command + +import at.hannibal2.skyhanni.discord.BotMessageHandler +import at.hannibal2.skyhanni.discord.Utils.messageReply +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.User +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 + +abstract class CommandEvent { + + abstract val member: Member? + + abstract val author: User + + abstract val channel: MessageChannelUnion + + abstract val message: Message? + + abstract val isFromGuild: Boolean + + abstract fun reply(text: String, ephemeral: Boolean = false) + + abstract fun reply(embed: MessageEmbed, ephemeral: Boolean = false) + + abstract fun userError(text: String) + + abstract fun sendError(text: String) + + fun replyWithConsumer(text: String, consumer: (MessageReceivedEvent) -> Unit) { + BotMessageHandler.log(text, consumer) + reply(text) + } + + fun doWhen( + isMessage: (MessageReceivedEvent) -> T?, + isSlashCommand: (SlashCommandInteractionEvent) -> T? + ): T? { + return when (this) { + is MessageEvent -> isMessage(this.event) + is SlashCommandEvent -> isSlashCommand(this.event) + else -> null + } + } +} + +class MessageEvent(val event: MessageReceivedEvent) : CommandEvent() { + override val member: Member? + get() = event.member + override val author: User + get() = event.author + override val channel: MessageChannelUnion + get() = event.channel + override val message: Message + get() = event.message + override val isFromGuild: Boolean + get() = event.isFromGuild + + override fun reply(text: String, ephemeral: Boolean) { + event.message.messageReply(text) + } + + override fun reply(embed: MessageEmbed, ephemeral: Boolean) { + event.message.messageReply(embed) + } + + override fun userError(text: String) { + event.message.messageReply("❌ $text") + } + + override fun sendError(text: String) { + event.message.messageReply("❌ An error occurred: $text") + } +} + +class SlashCommandEvent(val event: SlashCommandInteractionEvent) : CommandEvent() { + override val member: Member? + get() = event.member + override val author: User + get() = event.user + override val channel: MessageChannelUnion + get() = event.channel + override val message: Message? + get() = null + override val isFromGuild: Boolean + get() = event.isFromGuild + + fun reply(text: String) { + reply(text, false) + } + + fun reply(embed: MessageEmbed) { + reply(embed, false) + } + + override fun reply(text: String, ephemeral: Boolean) { + event.reply(text).setEphemeral(ephemeral).queue() + } + + override fun reply(embed: MessageEmbed, ephemeral: Boolean) { + event.replyEmbeds(embed).setEphemeral(ephemeral).queue() + } + + override fun userError(text: String) { + reply("❌ $text", ephemeral = true) + } + + override fun sendError(text: String) { + reply("❌ An error occurred: $text", ephemeral = true) + } +} \ 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 241196a..bd49fb4 100644 --- a/src/main/kotlin/at/hannibal2/skyhanni/discord/command/HelpCommand.kt +++ b/src/main/kotlin/at/hannibal2/skyhanni/discord/command/HelpCommand.kt @@ -1,18 +1,15 @@ package at.hannibal2.skyhanni.discord.command import at.hannibal2.skyhanni.discord.BOT +import at.hannibal2.skyhanni.discord.PLEADING_FACE 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.hasAdminPermissions import at.hannibal2.skyhanni.discord.Utils.inBotCommandChannel import at.hannibal2.skyhanni.discord.Utils.messageDelete -import at.hannibal2.skyhanni.discord.Utils.reply -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 @@ -20,15 +17,20 @@ object HelpCommand : BaseCommand() { override val name: String = "help" override val description: String = "Get help for all OR one specific command." override val options: List