diff --git a/src/main/kotlin/at/hannibal2/skyhanni/discord/CommandListener.kt b/src/main/kotlin/at/hannibal2/skyhanni/discord/CommandListener.kt index 8e7ebdb..0f4ce8e 100644 --- a/src/main/kotlin/at/hannibal2/skyhanni/discord/CommandListener.kt +++ b/src/main/kotlin/at/hannibal2/skyhanni/discord/CommandListener.kt @@ -1,14 +1,12 @@ package at.hannibal2.skyhanni.discord +import at.hannibal2.skyhanni.discord.Utils.createHelpEmbed import at.hannibal2.skyhanni.discord.Utils.logAction 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 class CommandListener(bot: DiscordBot) { @@ -76,7 +74,7 @@ class CommandListener(bot: DiscordBot) { } } - // allows to use `! -help` instaed of `!help -` + // allows to use `! -help` instead of `!help -` if (args.size == 2) { if (args[1] == "-help") { sendUsageReply(literal) @@ -149,22 +147,6 @@ class CommandListener(bot: DiscordBot) { } fun existCommand(text: String): Boolean = commands.find { it.name.equals(text, ignoreCase = true) } != null - - private fun CommandData.createHelpEmbed(commandName: String): MessageEmbed { - val em = EmbedBuilder() - - em.setTitle("Usage: /$commandName <" + this.options.joinToString("> <") { it.name } + ">") - em.setDescription("📋 **${this.description}**") - em.setColor(Color.GREEN) - - for (option in this.options) { - em.addField(option.name, option.description, true) - em.addField("Required", if (option.required) "✅" else "❌", true) - em.addBlankField(true) - } - - return em.build() - } } class Command( diff --git a/src/main/kotlin/at/hannibal2/skyhanni/discord/CommandsData.kt b/src/main/kotlin/at/hannibal2/skyhanni/discord/CommandsData.kt index ba7074b..629b737 100644 --- a/src/main/kotlin/at/hannibal2/skyhanni/discord/CommandsData.kt +++ b/src/main/kotlin/at/hannibal2/skyhanni/discord/CommandsData.kt @@ -1,25 +1,45 @@ package at.hannibal2.skyhanni.discord +import net.dv8tion.jda.api.interactions.commands.OptionType + object CommandsData { private val commands = listOf( CommandData( name = "help", description = "Get help for all OR one specific command.", - options = listOf(Option("command", "Command you want to get help for.", required = false)), + options = listOf( + Option( + "command", + "Command you want to get help for.", + required = false, + autoComplete = true + ) + ), userCommand = true ), CommandData( name = "pr", description = "Displays useful information about a pull request on GitHub.", - options = listOf(Option("number", "Number of the pull request you want to display.")) + options = listOf( + Option( + "number", + "Number of the pull request you want to display.", + type = OptionType.NUMBER + ) + ), ), CommandData( name = "server", description = "Displays information about a server from our 'useful server list'.", options = listOf( - Option("keyword", "Keyword of the server you want to display."), - Option("debug", "Display even more useful information (-d to use).", required = false) + Option("keyword", "Keyword of the server you want to display.", autoComplete = true), + Option( + "debug", + "Display even more useful information (-d to use).", + required = false, + type = OptionType.BOOLEAN + ) ), userCommand = true ), @@ -79,7 +99,7 @@ object CommandsData { name = "tagedit", description = "Edits a tag in the database.", options = listOf( - Option("tag", "The tag you want to edit."), + Option("tag", "The tag you want to edit.", autoComplete = true), Option("response", "Response you want the tag to have.") ), aliases = listOf("tagchange") @@ -100,7 +120,7 @@ object CommandsData { CommandData( name = "tagdelete", description = "Deletes a tag from the database.", - options = listOf(Option("keyword", "Keyword of the tag you want to delete.")), + options = listOf(Option("keyword", "Keyword of the tag you want to delete.", autoComplete = true)), aliases = listOf("tagremove") ), CommandData( @@ -110,11 +130,14 @@ object CommandsData { ) private val commandMap = commands.associateBy { it.name } + - commands.flatMap { cmd -> cmd.aliases.map { alias -> alias to cmd } }.toMap() + commands.flatMap { cmd -> cmd.aliases.map { alias -> alias to cmd } } fun getCommand(nameOrAlias: String): CommandData? { return commandMap[nameOrAlias] } + + fun getCommands(): Map = + commandMap.filterKeys { it !in commands.flatMap { cmd -> cmd.aliases } } } data class CommandData( @@ -125,5 +148,11 @@ data class CommandData( val userCommand: Boolean = false ) -data class Option(val name: String, val description: String, val required: Boolean = true) +data class Option( + val name: String, + val description: String, + val required: Boolean = true, + val type: OptionType = OptionType.STRING, + val autoComplete: Boolean = false +) diff --git a/src/main/kotlin/at/hannibal2/skyhanni/discord/DiscordBot.kt b/src/main/kotlin/at/hannibal2/skyhanni/discord/DiscordBot.kt index ec56067..f806d9a 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.messageSend 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.LoggerFactory diff --git a/src/main/kotlin/at/hannibal2/skyhanni/discord/PullRequestCommands.kt b/src/main/kotlin/at/hannibal2/skyhanni/discord/PullRequestCommands.kt index dc8da06..255e1b2 100644 --- a/src/main/kotlin/at/hannibal2/skyhanni/discord/PullRequestCommands.kt +++ b/src/main/kotlin/at/hannibal2/skyhanni/discord/PullRequestCommands.kt @@ -165,7 +165,7 @@ class PullRequestCommands(config: BotConfig, commands: CommandListener) { return } val prNumber = args[1].toIntOrNull() ?: run { - reply("unknwon number $PLEADING_FACE (${args[1]})") + reply("unknown number $PLEADING_FACE (${args[1]})") return } @@ -195,7 +195,7 @@ class PullRequestCommands(config: BotConfig, commands: CommandListener) { val (_, downloadTime) = timeExecution { github.downloadArtifact(artifactId, fileRaw) } - reply("artifact downnloaded in ${downloadTime.format()}") + reply("artifact downloaded in ${downloadTime.format()}") Utils.unzipFile(fileRaw, fileUnzipped) fileRaw.delete() @@ -230,5 +230,4 @@ class PullRequestCommands(config: BotConfig, commands: CommandListener) { event.loadPrInfos(pr) return true } - -} +} \ No newline at end of file diff --git a/src/main/kotlin/at/hannibal2/skyhanni/discord/ServerCommands.kt b/src/main/kotlin/at/hannibal2/skyhanni/discord/ServerCommands.kt index 77dbab4..919bf9f 100644 --- a/src/main/kotlin/at/hannibal2/skyhanni/discord/ServerCommands.kt +++ b/src/main/kotlin/at/hannibal2/skyhanni/discord/ServerCommands.kt @@ -176,6 +176,41 @@ class ServerCommands(private val bot: DiscordBot, commands: CommandListener) { else server.keyword } reply("Server list:\n$list") + private fun MessageReceivedEvent.serverList(args: List) = reply(listServers()) +} + +fun Server.print(): String = with(this) { + buildString { + append("**$displayName**\n") + if (description.isNotEmpty()) { + append(description) + append("\n") + } + append(inviteLink) + } +} + +fun Server.printDebug(): String = with(this) { + buildString { + append("keyword: '$keyword'\n") + append("displayName: '$displayName'\n") + append("description: '$description'\n") + append("inviteLink: '<$inviteLink>'\n") + val aliases = Database.getServerAliases(keyword) + append("aliases: $aliases\n") + append("edit command:\n") + append("`!serveredit $keyword ${displayName.replace(" ", "_")} $inviteLink $description`") + } +} + +fun listServers(): String { + val servers = Database.listServers() + if (servers.isEmpty()) return "No servers found." + + return "Server list:\n" + servers.joinToString("\n") { server -> + val aliases = Database.getServerAliases(server.keyword) + if (aliases.isNotEmpty()) "${server.keyword} [${aliases.joinToString(", ")}]" + else server.keyword } private fun isDiscordInvite(message: String): Boolean = disordServerPattern.matcher(message).find() diff --git a/src/main/kotlin/at/hannibal2/skyhanni/discord/ServerSlashCommands.kt b/src/main/kotlin/at/hannibal2/skyhanni/discord/ServerSlashCommands.kt new file mode 100644 index 0000000..4ff6bf8 --- /dev/null +++ b/src/main/kotlin/at/hannibal2/skyhanni/discord/ServerSlashCommands.kt @@ -0,0 +1,126 @@ +package at.hannibal2.skyhanni.discord + +import at.hannibal2.skyhanni.discord.Utils.logAction +import at.hannibal2.skyhanni.discord.Utils.replyT +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent + +@Suppress("UNUSED_PARAMETER") +class ServerSlashCommands(private val config: BotConfig, commands: SlashCommandListener) { + init { + commands.add(SlashCommand("server", userCommand = true) { event -> event.serverCommand() }) + commands.add(SlashCommand("serverlist") { event -> event.serverList() }) + commands.add(SlashCommand("serveradd") { event -> event.serverAdd() }) + commands.add(SlashCommand("serveredit") { event -> event.serverEdit() }) + commands.add(SlashCommand("serveraddalias") { event -> event.serverAddAlias() }) + commands.add(SlashCommand("serveraliasdelete") { event -> event.serverAliasDelete() }) + commands.add(SlashCommand("serverdelete") { event -> event.serverDelete() }) + } + + private fun SlashCommandInteractionEvent.serverCommand() { + val keyword = getOption("keyword")?.asString ?: return + val debug = getOption("debug")?.asBoolean ?: false + + val server = Database.getServer(keyword) + if (server != null) { + if (debug) { + replyT(server.printDebug()) + } else { + replyT(server.print()) + } + } else { + replyT("Server with keyword '$keyword' not found.", ephemeral = true) + } + } + + private fun SlashCommandInteractionEvent.serverAdd() { + val keyword = getOption("keyword")?.asString ?: return + + if (Database.getServer(keyword) != null) { + replyT("❌ Server already exists. Use `!serveredit` instead.") + return + } + + val server = createServer(keyword) ?: return + if (Database.addServer(server)) { + val id = member?.id + replyT("✅ Server '$keyword' added by <@$id>:") + channel.sendMessage(server.print()).queue() + logAction("added server '$keyword'") + } else { + replyT("❌ Failed to add server.", ephemeral = true) + } + } + + private fun SlashCommandInteractionEvent.serverEdit() { + val keyword = getOption("keyword")?.asString ?: return + + if (Database.getServer(keyword) == null) { + replyT("❌ Server does not exist. Use `!serveradd` instead.") + return + } + + val server = createServer(keyword) ?: return + if (Database.addServer(server)) { + val id = member?.id + replyT("✅ Server '$keyword' edited by <@$id>:") + channel.sendMessage(server.print()).queue() + logAction("edited server '$keyword'") + } else { + replyT("❌ Failed to edit server.", ephemeral = true) + } + } + + private fun SlashCommandInteractionEvent.createServer(keyword: String): Server? { + val displayName = getOption("display_name")?.asString ?: return null + val inviteLink = getOption("invite_link")?.asString ?: return null + val description = getOption("description")?.asString ?: return null + + return Server(keyword = keyword, displayName = displayName, inviteLink = inviteLink, description = description) + } + + private fun SlashCommandInteractionEvent.serverAddAlias() { + val keyword = getOption("keyword")?.asString ?: return + val alias = getOption("alias")?.asString ?: return + + if (Database.getServer(alias) != null) { + replyT("❌ Alias already exists.", ephemeral = true) + return + } + if (Database.getServer(keyword) == null) { + replyT("❌ Server with keyword '$keyword' does not exist.", ephemeral = true) + return + } + if (Database.addServerAlias(keyword, alias)) { + replyT("✅ Alias '$alias' added for server '$keyword'") + logAction("added alias '$alias' for server '$keyword'") + } else { + replyT("❌ Failed to add alias.", ephemeral = true) + } + } + + private fun SlashCommandInteractionEvent.serverAliasDelete() { + val keyword = getOption("keyword")?.asString ?: return + val alias = getOption("alias")?.asString ?: return + + if (Database.deleteServerAlias(keyword, alias)) { + replyT("✅ Alias '$alias' deleted from server '$keyword'") + logAction("deleted alias '$alias' for server '$keyword'") + + } else { + replyT("❌ Failed to delete alias '$alias' for server '$keyword'.", ephemeral = true) + } + } + + private fun SlashCommandInteractionEvent.serverDelete() { + val keyword = getOption("keyword")?.asString ?: return + + if (Database.deleteServer(keyword)) { + replyT("✅ Server '$keyword' deleted!") + logAction("deleted server '$keyword'") + } else { + replyT("❌ Server with keyword '$keyword' not found or deletion failed.", ephemeral = true) + } + } + + private fun SlashCommandInteractionEvent.serverList() { replyT(listServers()) } +} diff --git a/src/main/kotlin/at/hannibal2/skyhanni/discord/SlashCommandListener.kt b/src/main/kotlin/at/hannibal2/skyhanni/discord/SlashCommandListener.kt new file mode 100644 index 0000000..cc81ec2 --- /dev/null +++ b/src/main/kotlin/at/hannibal2/skyhanni/discord/SlashCommandListener.kt @@ -0,0 +1,145 @@ +package at.hannibal2.skyhanni.discord + +import at.hannibal2.skyhanni.discord.Utils.createHelpEmbed +import at.hannibal2.skyhanni.discord.Utils.runDelayed +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.interactions.commands.Command +import net.dv8tion.jda.api.interactions.commands.build.Commands +import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData +import kotlin.time.Duration.Companion.seconds + +class SlashCommandListener(private val config: BotConfig) { + + private val commands = mutableSetOf() + private val commandsData = mutableSetOf() + + init { + ServerSlashCommands(config, this) + add(SlashCommand("help", userCommand = true) { event -> event.helpCommand() }) + } + + fun add(element: SlashCommand) { + commands.add(element) + } + + fun onCommand(event: SlashCommandInteractionEvent) { + val command = commands.firstOrNull { it.name == event.fullCommandName } ?: return + + command.consumer(event) + } + + fun onAutocomplete(event: CommandAutoCompleteInteractionEvent) { + when (event.fullCommandName) { + "help" -> { + if (event.focusedOption.name != "command") return + + event.replyChoiceStrings( + CommandsData.getCommands() + .filterKeys { key -> key.startsWith(event.focusedOption.value) }.keys + ).queue() + } + + "server" -> { + if (event.focusedOption.name != "keyword") return + + event.replyChoiceStrings(Database.listServers().map { it.keyword } + .filter { it.startsWith(event.focusedOption.value) }).queue() + } + } + } + + private fun SlashCommandInteractionEvent.inBotCommandChannel() = channel.id == config.botCommandChannelId + + private fun SlashCommandInteractionEvent.helpCommand() { + getOption("command")?.let { sendUsageReply(it.asString) } ?: run { + val commands = if (hasAdminPermissions() && inBotCommandChannel()) { + commands + } else { + commands.filter { it.userCommand } + } + val list = commands.joinToString(", !", prefix = "!") { it.name } + reply("Supported commands: $list").queue() + + if (hasAdminPermissions() && !inBotCommandChannel()) { + val id = config.botCommandChannelId + val botCommandChannel = "https://discord.com/channels/$id/$id" + reply("You wanna see the cool admin only commands? visit $botCommandChannel").queue() + } + } + } + + private fun SlashCommandInteractionEvent.sendUsageReply(commandName: String) { + val command = CommandsData.getCommand(commandName) ?: run { + reply("Unknown command `!$commandName` \uD83E\uDD7A") + return + } + + if (!command.userCommand && !hasAdminPermissions()) { + reply("No permissions for command `!$commandName` \uD83E\uDD7A") + return + } + + replyEmbeds(command.createHelpEmbed(commandName)) + .setEphemeral(true) + .queue() + } + + private fun SlashCommandInteractionEvent.hasAdminPermissions(): Boolean { + val member = member ?: return false + val allowedRoleIds = config.editPermissionRoleIds.values + return !member.roles.none { it.id in allowedRoleIds } + } + + fun deleteSlashCommand(name: String, guild: Guild) { + val command = commandsData.firstOrNull { it.name.equals(name, true) } ?: return + guild.deleteCommandById(command.id).queue() + } + + fun updateSlashCommand(name: String, guild: Guild) { + commandsData.firstOrNull { it.name.equals(name, true) } ?: return + val data = CommandsData.getCommand(name) ?: return + guild.upsertCommand(convertToData(data)).queue() + } + + fun createSlashCommands(guild: Guild) { + guild.retrieveCommands().queue { current -> + val commandData = CommandsData.getCommands() + .filterKeys { key -> current.none { it.name == key } }.values.map { value -> convertToData(value) } + + runDelayed(1.seconds) { + for (command in commandData) { + guild.upsertCommand(command).queue() + println("added ${command.name}") + Thread.sleep(5000) + } + } + } + + runDelayed(1.seconds) + { + guild.retrieveCommands().queue { commandsData.addAll(it) } + } + } + + private fun convertToData(old: CommandData): 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 + ) + } + } + } +} + +class SlashCommand( + val name: String, + val userCommand: Boolean = false, + val consumer: (SlashCommandInteractionEvent) -> Unit, +) \ No newline at end of file diff --git a/src/main/kotlin/at/hannibal2/skyhanni/discord/Utils.kt b/src/main/kotlin/at/hannibal2/skyhanni/discord/Utils.kt index 5893bae..7ffed05 100644 --- a/src/main/kotlin/at/hannibal2/skyhanni/discord/Utils.kt +++ b/src/main/kotlin/at/hannibal2/skyhanni/discord/Utils.kt @@ -6,6 +6,7 @@ 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 @@ -28,6 +29,14 @@ object Utils { message.messageReply(embed) } + fun SlashCommandInteractionEvent.replyT(text: String, ephemeral: Boolean = false) { + reply(text).setEphemeral(ephemeral).queue() + } + + fun SlashCommandInteractionEvent.replyE(embed: MessageEmbed, ephemeral: Boolean = false) { + replyEmbeds(embed).setEphemeral(ephemeral).queue() + } + fun MessageReceivedEvent.replyWithConsumer(text: String, consumer: (MessageReceivedEvent) -> Unit) { BotMessageHandler.log(text, consumer) reply(text) @@ -83,6 +92,21 @@ object Utils { logger.info("$id/$name$nickString $action$channelSuffix") } + fun SlashCommandInteractionEvent.logAction(action: String, raw: Boolean = false) { + if (raw) { + logger.info(action) + return + } + val channelName = channel.name + val name = member?.user?.name + val id = member?.id + + val nick = member?.nickname?.takeIf { it != "null" } + val nickString = nick?.let { " (`$nick`)" } ?: "" + + logger.info("$id/$name$nickString $action in channel '$channelName'") + } + fun runDelayed(duration: Duration, consumer: () -> Unit) { Thread { Thread.sleep(duration.inWholeMilliseconds) @@ -213,4 +237,22 @@ object Utils { return eb.build() } + + fun CommandData.createHelpEmbed(commandName: String): MessageEmbed { + val em = EmbedBuilder() + + val optionStr = if (this.options.isEmpty()) this.options.joinToString("> <", "<", ">") { it.name } else "" + + em.setTitle("Usage: /$commandName $optionStr") + em.setDescription("📋 **${this.description}**") + em.setColor(Color.GREEN) + + for (option in this.options) { + em.addField(option.name, option.description, true) + em.addField("Required", if (option.required) "✅" else "❌", true) + em.addBlankField(true) + } + + return em.build() + } } \ No newline at end of file