diff --git a/README.md b/README.md index c304239..5c37baa 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,12 @@ Bridges chat between Discord and Minecraft (Bukkit/Spigot). ## Installation -<> + +1. Download the latest release from GitHub +2. Add it to your plugins folder +3. Either run Bukkit/Spigot once to generate DiscordBridge/config.yml or create it using the guide below. +4. All done! + ## Configuration @@ -32,8 +37,13 @@ username: 'DiscordBridge' # Use '&' in place of the formatting symbol. username-color: '' +# (Optional) Define an alternate prefix for all of the bot's commands. These will work in addition to @mentions. +# Will also work in Minecraft if the sender has the required permission for the command they try. +# Leave blank to only allow @mentions to prefix commands +command-prefix: '' + # (Optional) Set this value with a valid Cleverbot API key to enable chatting with Cleverbot -# Look at https://www.cleverbot.com/JDA/ for more info +# Look at https://www.cleverbot.com/api/ for more info cleverbot-key: '' # If true, prints verbose log messages to the server console for every action @@ -59,22 +69,23 @@ if-vanished: player-leave: false player-death: false -# Set the templates for relayed messages -# %u - The sender's username -# %m - The sender's message -# %w - The name of the world the sender is in (Multiverse alias compatible) -# %r - The death message (Death event only) -# Use '&' in place of the formatting symbol to apply formatting codes. +# Set the templates for various message types +# %u - The username of the one who sent the message or invoked a command, if applicable +# %m - The raw message that would normally display, if applicable +# %w - The name of the world the sender is in +# - Applicable to messages from Minecraft only +# - Multiverse alias compatible +# Use '&' in place of the formatting symbol to apply formatting codes to messages sent to Minecraft templates: discord: chat-message: '<**%u**> %m' player-join: '**%u** joined the server' player-leave: '**%u** left the server' - player-death: '%r' + player-death: '%m' server-start: 'Server started!' server-stop: 'Shutting down...' minecraft: - chat-message: '[&b&lDiscord&r]<%u> %m' + chat-message: '[&b&l%w&r]<%u> %m' ``` * `token` is the access token for the Discord bot. Without this, the bot will not function at all. @@ -91,51 +102,64 @@ templates: **Templates** -- `%u` will be replaced with the username -- `%d` will be replaced with the user's display name +- `%u` will be replaced with the player/user's username - `%m` will be replaced with the message - `%w` will be replaced with the world name -- `%r` will be replaced with Minecraft's standard death message - Color codes, prefixed with `&`, will be translated on the Minecraft end ## Features * Anything said in Minecraft chat will be sent to your chosen Discord channel -* Anything said in your chosen Discord channel will be sent to your Minecraft chat (with a `(discord)` suffix added to usernames) +* If Multiverse-Core is installed and the `%w` tag is specified in your relay message syntax, the alias assigned to your Multiverse worlds will be displayed +* Anything said in your chosen Discord channel will be sent to your Minecraft chat (if the `%w` tag is used in your relay message syntax, Discord messages will display `Discord`) +* If Dynmap is installed, anything said over Dynmap chat will be relayed to your chosen Discord channel (if the `%w` tag is used in your relay messag syntax, Dynmap messages will display `Dynmap`) * You can link Minecraft accounts to Discord accounts and the bot will translate display names to match where the message appears * Join / leave messages can be sent to Discord * Death messages can be sent to Discord * Server start and stop messages can be sent to Discord * All of the above messages can be toggled if you don't want them to appear -* Message templates are customized -* Prefixing usernames with `@` in the Minecraft chat will be converted to mentions in the Discord chat if the user exists (you can use their Discord display name with spaces removed, or their Minecraft username if the accounts are linked) -* Image attachments sent in the Discord channel will relay their URLs to Minecraft -* Add scripted responses for the bot to say when it detects a trigger phrase +* Message templates are customizeable +* Prefixing usernames with `@` in the Minecraft chat will be converted to tag mentions in the Discord chat if the user exists (you can use their Discord display name with spaces removed, or their Minecraft username if the accounts are linked) +* Add customizeable scripted responses for the bot to say when it detects a trigger phrase * A handful of fun and shitposting commands for the full Discord Bot experience both in and out of game -* Cleverbot integration - chat with the bot using `@`. Works in Discord AND Minecraft! +* Cleverbot integration - chat with the bot using `@` or `/talk`. Works in Discord AND Minecraft! (requires Cleverbot API key) * The bot can use any of its commands in any channel it can read (including DMs!) allowing it to function as a general-purpose Discord bot on the side +* Command permissions affect both Minecraft slash command and Minecraft in-chat commands ## Permissions -- `discordbridge.cleverbot` - ability to talk to Cleverbot -- `discordbridge.f` - ability to pay respects with /f -- `discordbridge.reload` - ability to reload data and reconnect the Discord connection -- `discordbridge.eightball` - ability to consult the Magic 8-Ball with /8ball -- `discordbridge.rate` - ability to ask for an out-of-10 rating with /rate -- `discordbridge.insult` - ability to make the bot insult something with /insult +- `discordbridge.discord` - ability to use any command in of the /discord subcommand tree +- `discordbridge.discord.reload` - ability to reload configs and JDA +- `discordbridge.discord.listmembers` - abiliyt to receive a list of members in the Discord channel +- `discordbridge.discord.linkalias` - abiliy to send a request to a Discord member to set up alias translation +- `discordbridge.talk` - ability to talk to Cleverbot +- `discordbridge.f` - ability to use the f command +- `discordbridge.rate` - ability to use the rate command +- `discordbridge.eightball` - ability to use the 8ball command +- `discordbridge.insult` - ability to use the insult command +- `discordbridge.choose` - ability to use the choose command +- `discordbridge.roll` - ability to use the roll command ## Commands -- `/discord reload` - reloads data and reconnects to Discord -- `/discord get online` - provides a list of all Discord users in the relay channel who are Online, Do Not Disturb, and Idle -- `/discord get ids` - provides a list of the Discord IDs of all users in the relay channel, which is useful for... -- `/discord register ` - this command will send a DM to the corresponding user asking if that user wishes to link their Discord account with the Minecraft user who issued the command -- `/f` - press F to pay respects -- `/8ball ` - consults the Magic 8-Ball to answer your yes/no questions (messages configurable in `botmemory.yml`) -- `/rate ` - asks the bot to rate something on a 0-10 scale -- `/insult ` - makes the bot insult something (messages configurable in `insults.yml`) (*WARNING: The supplied insults are quite offensive! Remove permissions for this command or replace the insults if you intend to use this bot on cleaner servers!*) +- `8ball ` - consult the Magic 8-Ball to answer your yes/no questions (messages configurable in `botmemory.yml`) +- `discord reload` - refreshes the JDA connection and reloads configs +- `discord linkalias` - sends a request to a specified Discord user to link aliases for username translation +- `discord listmembers all` - lists all the members in the Discord relay channel +- `discord listmembers online` - lists all the members in the Discord relay channel who are online along with their statuses +- `discord unlinkalias ` - silently breaks an alias link with a Discord user, if one exists +- `f` - press F to pay respects (messages configurable in `f.yml`) +- `rate ` - have the bot rate something for you (rating scale and messages configurable in `rate.yml`) +- `insult ` - makes the bot insult something (messages configurable in `insults.yml`) (*WARNING: The supplied insults are quite offensive! Remove permissions for this command or replace the insults if you intend to use this bot on cleaner servers!*) +- `choose ` - have the bot make a choice for you +- `roll ` - roll a die with a specified number of sides (up to 100) +- `talk = mutableListOf() - // Detects if Multiverse-Core is installed + /** + * Returns whether Multiverse-Core is installed + */ val isMultiverseInstalled: Boolean get() = server.pluginManager.getPlugin("Multiverse-Core") != null + /** + * Runs at plugin startup + */ override fun onEnable() { - + // Register data class types to the config deserializer ConfigurationSerialization.registerClass(Respect::class.java, "Respect") ConfigurationSerialization.registerClass(Rating::class.java, "Rating") ConfigurationSerialization.registerClass(Script::class.java, "Script") @@ -68,6 +76,9 @@ class Plugin : JavaPlugin() { getCommand("roll").executor = CommandListener(this) } + /** + * Runs cleanup when the plugin is disabled + */ override fun onDisable() { if (Config.ANNOUNCE_SERVER_START_STOP) Connection.send(Config.TEMPLATES_DISCORD_SERVER_STOP, Connection.getRelayChannel()) @@ -81,13 +92,22 @@ class Plugin : JavaPlugin() { Messaging Functions ===================================== */ - // Send a message to Discord + /** + * Sends a message to the specified Discord channel + * + * @param message the message to send + * @param channel the channel to send the message to + */ fun sendToDiscord(message: String, channel: MessageChannel?) { logDebug("Sending message to Discord - $message") Connection.send(message, channel) } - // Send a message to Minecraft + /** + * Broadcast a message on the Minecraft server + * + * @param message the message to send + */ fun sendToMinecraft(message: String) { server.broadcastMessage(message) } @@ -96,7 +116,9 @@ class Plugin : JavaPlugin() { Util ===========================================*/ - // Reloads everything + /** + * Reloads all configs and the JDA connection + */ fun reload(callback: Runnable) { reloadConfig() users.reloadConfig() @@ -111,7 +133,9 @@ class Plugin : JavaPlugin() { Connection.reconnect(callback) } - // Save default config + /** + * Saves all default configs where configs do not exist and reloads data from file into memory + */ fun updateConfig(version: String) { this.saveDefaultConfig() config.options().copyDefaults(true) @@ -129,13 +153,17 @@ class Plugin : JavaPlugin() { UserAliasConfig.load(this) } - // Log only if debug config is true + /** + * Sends a log message to console if the DEBUG flag in config.yml is true + */ fun logDebug(msg: String) { if (!Config.DEBUG) return - logger.info(msg) + logger.info("[DiscordBridge] $msg") } - // Get a list of usernames of players who are online + /** + * @return a list of names of all players currently on the Minecraft server + */ fun getOnlinePlayers(): List { val names: MutableList = mutableListOf() val players = server.onlinePlayers.toTypedArray() @@ -143,7 +171,13 @@ class Plugin : JavaPlugin() { return names.toList() } - // Open a request to link a Minecraft user with a Discord user + /** + * Opens an alias link request and sends it to the target Discord user + * + * @param player the Minecraft player that initiated the request + * @param discriminator the Discord username+discriminator of the target Discord user + * @return a Discord Member object, or null if no matching member was found + */ fun registerUserRequest(player: Player, discriminator: String): Member? { val users = Connection.listUsers() val found: Member = users.find { it.user.name + "#" + it.user.discriminator == discriminator } ?: return null @@ -158,7 +192,9 @@ class Plugin : JavaPlugin() { return found } - // Return a formatted string listing the Discord IDs of all Discord users in the relay channel + /** + * @return a formatted string listing the Discord IDs of all Discord users in the relay channel + */ fun getDiscordMembersAll(): String { val users = Connection.listUsers() @@ -173,7 +209,9 @@ class Plugin : JavaPlugin() { return response.trim() } - // Return a formatted string listing all Discord users in the relay channel who are visibly available + /** + * @return a formatted string listing all Discord users in the relay channel who are online along with their statuses + */ fun getDiscordMembersOnline(): String { val onlineUsers = Connection.listOnline() if (onlineUsers.isEmpty()) @@ -210,8 +248,18 @@ class Plugin : JavaPlugin() { Message Formatting Functions ===================================== */ - // Converts attempted @mentions to real ones wherever possible - // Mentionable names MUST NOT contain spaces! + /** + * Attempts to convert all instances of "@name" into Discord @tag mentions + * + * This should work for "@", "@" (if an alias is linked), + * and "@" + * + * NOTE: If the Discord name contains spaces, that name must be typed in this string without spaces. + * e.g. a member named "Discord Bridge" must be tagged as "@DiscordBridge" + * + * @param message the message to format + * @return the formatted message + */ fun convertAtMentions(message: String): String { var newMessage = message @@ -239,6 +287,12 @@ class Plugin : JavaPlugin() { return newMessage } + /** + * Attempts to de-convert all instances of Discord @tag mentions back into simple "@name" syntax + * + * @param message the message to format + * @return the formatted message + */ fun deconvertAtMentions(message: String): String { var modifiedMessage = message for (match in Regex("""<@!(\d+)>|<@(\d+)>""").findAll(message)) { @@ -248,8 +302,13 @@ class Plugin : JavaPlugin() { return modifiedMessage } - // Scans the string for occurrences of Minecraft names and attempts to translate them - // to registered Discord names, if they exist + /** + * Scans the input string for occurrences of Minecraft names in the alias registry and replaces them with + * their corresponding Discord aliases + * + * @param message the message to format + * @return the formatted message + */ fun translateAliasesToDiscord(message: String): String { var modifiedMessage = message for ((mcUuid, discordId) in UserAliasConfig.aliases) { @@ -261,8 +320,13 @@ class Plugin : JavaPlugin() { return modifiedMessage } - // Scans the string for occurrences of Discord names and attempts to translate them - // to registered Minecraft names, if they exist + /** + * Scans the input string for occurrences of Discord names in the alias registry and replaces them with + * their corresponding Minecraft aliases + * + * @param message the message to format + * @return the formatted message + */ fun translateAliasesToMinecraft(message: String): String { var modifiedMessage = message for ((mcUuid, discordId) in UserAliasConfig.aliases) { diff --git a/src/main/kotlin/gg/obsidian/discordbridge/UserAliasConfig.kt b/src/main/kotlin/gg/obsidian/discordbridge/UserAliasConfig.kt index 4186c39..2398afa 100644 --- a/src/main/kotlin/gg/obsidian/discordbridge/UserAliasConfig.kt +++ b/src/main/kotlin/gg/obsidian/discordbridge/UserAliasConfig.kt @@ -2,9 +2,15 @@ package gg.obsidian.discordbridge import gg.obsidian.discordbridge.utils.UserAlias +/** + * An accessor object for the users config file + */ object UserAliasConfig { var aliases: List = mutableListOf() + /** + * Load the stored aliases from file into memory + */ fun load(plugin: Plugin) { val list = plugin.users.data.getList("aliases") if (list != null) aliases = list.checkItemsAre() ?: @@ -12,6 +18,9 @@ object UserAliasConfig { else mutableListOf() } + /** + * Adds a new alias to the list and saves the updated list to file + */ fun add(plugin: Plugin, ua: UserAlias) { aliases = aliases.plus(ua) plugin.users.data.set("aliases", aliases) @@ -19,6 +28,9 @@ object UserAliasConfig { plugin.users.reloadConfig() } + /** + * Removes an alias from the list and saves the updated list to file + */ fun remove(plugin: Plugin, ua: UserAlias) { aliases = aliases.minus(ua) plugin.users.data.set("aliases", aliases) @@ -26,6 +38,9 @@ object UserAliasConfig { plugin.users.reloadConfig() } + /** + * A function to assert that all the items in a given list are of a specific type + */ @Suppress("UNCHECKED_CAST") inline fun List<*>.checkItemsAre() = if (all { it is T }) this as List else null } diff --git a/src/main/kotlin/gg/obsidian/discordbridge/commands/AsyncPlayerChatEventWrapper.kt b/src/main/kotlin/gg/obsidian/discordbridge/commands/AsyncPlayerChatEventWrapper.kt index 4d36576..9166b38 100644 --- a/src/main/kotlin/gg/obsidian/discordbridge/commands/AsyncPlayerChatEventWrapper.kt +++ b/src/main/kotlin/gg/obsidian/discordbridge/commands/AsyncPlayerChatEventWrapper.kt @@ -4,19 +4,48 @@ import gg.obsidian.discordbridge.discord.Connection import net.dv8tion.jda.core.entities.MessageChannel import org.bukkit.event.player.AsyncPlayerChatEvent +/** + * A wrapper for Bukkit's AsyncPlayerChatEvent class + * + * @param event the underlying AsyncPlayerChatEvent instance + */ class AsyncPlayerChatEventWrapper(val event: AsyncPlayerChatEvent) : IEventWrapper { + /** + * The Minecraft username of the event author + */ override val senderName: String get() = event.player.name + /** + * The message of this event + */ override val message: String get() = event.message + /** + * The raw message of this event + * + * This is identical to the message property for this wrapper type + */ override val rawMessage: String get() = event.message + /** + * The Minecraft username of the sender in "@name" format + */ override val senderAsMention: String get() = "@" + event.player.name + /** + * Returns the value at Connection.getRelayChannel() + * @see Connection.getRelayChannel + */ override val channel: MessageChannel get() = Connection.getRelayChannel()!! + /** + * The message author's Minecraft UUID + */ override val senderId: String get() = event.player.uniqueId.toString() + /** + * Always returns true for this wrapper type + */ override val isFromRelayChannel: Boolean get() = true diff --git a/src/main/kotlin/gg/obsidian/discordbridge/commands/IBotController.kt b/src/main/kotlin/gg/obsidian/discordbridge/commands/IBotController.kt index 52756b7..2acf55e 100644 --- a/src/main/kotlin/gg/obsidian/discordbridge/commands/IBotController.kt +++ b/src/main/kotlin/gg/obsidian/discordbridge/commands/IBotController.kt @@ -1,5 +1,11 @@ package gg.obsidian.discordbridge.commands +/** + * Controls a number of commands that can be bulk applied to a BotControllerManager + */ interface IBotController { + /** + * @return a short description of this IBotController's methods as seen in the Help command + */ fun getDescription(): String } diff --git a/src/main/kotlin/gg/obsidian/discordbridge/commands/IEventWrapper.kt b/src/main/kotlin/gg/obsidian/discordbridge/commands/IEventWrapper.kt index 8f586e3..5116f7c 100644 --- a/src/main/kotlin/gg/obsidian/discordbridge/commands/IEventWrapper.kt +++ b/src/main/kotlin/gg/obsidian/discordbridge/commands/IEventWrapper.kt @@ -2,12 +2,63 @@ package gg.obsidian.discordbridge.commands import net.dv8tion.jda.core.entities.MessageChannel +/** + * Interface for wrappers of various message event types + */ interface IEventWrapper { + /** + * The name of the event author + */ val senderName : String + /** + * The message in the event + * + * For MessageWrapper instances, this calls getContent() + * + * Otherwise, this is identical to rawMessage + */ val message : String + /** + * The raw message in the event + * + * For MessageWrapper instances, this calls getRawContent() + * + * Otherwise, this is identical to message + */ val rawMessage : String + /** + * The name of the author of the event in @tag format + * + * For MessageWrapper instances, this will return a mention tag in the form <@##########> + * + * Otherwise, this will return the player's username prefixed with '@' + */ val senderAsMention : String + /** + * The originating channel of the message + * + * For MessageWrapper instances, this returns the origin channel or private channel of the message + * + * Otherwise, this returns Connection.getRelayChannel() + * @see gg.obsidian.discordbridge.discord.Connection.getRelayChannel + */ val channel : MessageChannel + /** + * The ID string of the message author + * + * For MessageWrapper instances, this returns the author's Discord ID + * + * Otherwise, this returns the author's Minecraft UUID + */ val senderId : String + /** + * Whether this message is from the channel that is relayed to Minecraft + * + * For MessageWrapper instances, this is true if the inner event's getChannel() is equal to + * Connection.getRelayChannel(), and false otherwise + * @see gg.obsidian.discordbridge.discord.Connection.getRelayChannel + * + * Otherwise, this always returns true + */ val isFromRelayChannel: Boolean } diff --git a/src/main/kotlin/gg/obsidian/discordbridge/commands/MessageWrapper.kt b/src/main/kotlin/gg/obsidian/discordbridge/commands/MessageWrapper.kt index 65be83e..c00ff01 100644 --- a/src/main/kotlin/gg/obsidian/discordbridge/commands/MessageWrapper.kt +++ b/src/main/kotlin/gg/obsidian/discordbridge/commands/MessageWrapper.kt @@ -5,29 +5,63 @@ import net.dv8tion.jda.core.entities.ChannelType import net.dv8tion.jda.core.entities.Message import net.dv8tion.jda.core.entities.MessageChannel +/** + * A wrapper for JDA's Message class + * + * @param originalMessage the underlying Message instance + */ class MessageWrapper(val originalMessage: Message) : IEventWrapper { + /** + * Returns a formatted mention tag in the form <@##########> + */ override val senderAsMention: String get() = originalMessage.author.asMention + /** + * Returns the channel in which this event was sent + */ override val channel: MessageChannel get() = originalMessage.channel + /** + * Whether this message was sent from the relay channel + * + * Returns true if the underlying message's getChannel() is equal to + * Connection.getRelayChannel(), false otherwise + * @see gg.obsidian.discordbridge.discord.Connection.getRelayChannel + */ override val isFromRelayChannel: Boolean get() = if (originalMessage.isFromType(ChannelType.PRIVATE)) false else originalMessage.guild.id == Config.SERVER_ID && originalMessage.isFromType(ChannelType.TEXT) && originalMessage.textChannel.name.equals(Config.CHANNEL, true) + /** + * The message of this event + * + * This is equivalent to Message.getContent() + */ override val message: String get() = originalMessage.content + /** + * The raw message of this event + * + * This is equivalent to Message.getRawContent() + */ override val rawMessage: String get() = originalMessage.rawContent + /** + * The visible server name of the author of the event + */ override val senderName: String get() = originalMessage.author.name + /** + * The Discord ID of the author + */ override val senderId: String get() = originalMessage.author.id diff --git a/src/main/kotlin/gg/obsidian/discordbridge/commands/MinecraftCommandWrapper.kt b/src/main/kotlin/gg/obsidian/discordbridge/commands/MinecraftCommandWrapper.kt index 9a863ca..141817b 100644 --- a/src/main/kotlin/gg/obsidian/discordbridge/commands/MinecraftCommandWrapper.kt +++ b/src/main/kotlin/gg/obsidian/discordbridge/commands/MinecraftCommandWrapper.kt @@ -6,20 +6,55 @@ import org.bukkit.command.Command import org.bukkit.command.CommandSender import org.bukkit.entity.Player +/** + * A wrapper for the parameters passed to onCommand() in Bukkit's CommandExecutor class + * + * @param sender the sender of the command + * @param command the command that was invoked + * @param args an array of argument strings passed to the command + */ class MinecraftCommandWrapper(val sender: CommandSender, val command: Command, val args: Array) : IEventWrapper { + /** + * The Minecraft username of the command sender + * + * Returns "Console" if the command was sent from the server console + */ override val senderName: String get() = if (sender is Player) sender.name else "Console" + /** + * Returns a space-delimited string of all the arguments passed with the command + * + * This is identical to rawMessage + */ override val message: String get() = args.joinToString(separator = " ") + /** + * Returns a space-delimited string of all the arguments passed with the command + * + * This is identical to message + */ override val rawMessage: String get() = args.joinToString(separator = " ") + /** + * The Minecraft username of the command sender in "@name" format + */ override val senderAsMention: String get() = "@${sender.name}" + /** + * Returns the value at Connection.getRelayChannel() + * @see Connection.getRelayChannel + */ override val channel: MessageChannel get() = Connection.getRelayChannel()!! + /** + * The command sender's Minecraft UUID + */ override val senderId: String get() = (sender as? Player)?.uniqueId?.toString() ?: "" + /** + * Always returns true for this wrapper type + */ override val isFromRelayChannel: Boolean get() = true diff --git a/src/main/kotlin/gg/obsidian/discordbridge/commands/controllers/FunCommandsController.kt b/src/main/kotlin/gg/obsidian/discordbridge/commands/controllers/FunCommandsController.kt index a63f777..1214aa5 100644 --- a/src/main/kotlin/gg/obsidian/discordbridge/commands/controllers/FunCommandsController.kt +++ b/src/main/kotlin/gg/obsidian/discordbridge/commands/controllers/FunCommandsController.kt @@ -11,11 +11,24 @@ import gg.obsidian.discordbridge.utils.Rating import gg.obsidian.discordbridge.utils.Respect import java.util.* +/** + * Controller for fun commands that have no purpose outside of amusement + * + * @param plugin a reference to the base Plugin object + */ class FunCommandsController(val plugin: Plugin) : IBotController { + /** + * @return a description of this class of commands, used in the Help command + */ override fun getDescription(): String = ":balloon: **FUN** - Every bot has to have them!" - // 8BALL - consult the Magic 8-Ball to answer your yes or no questions + /** + * Answers a yes/no question + * + * @param event the incoming event object + * @return the response string + */ @BotCommand(name = "8ball", usage = "", description = "Consult the Magic 8 Ball") @TaggedResponse private fun eightBall(event: IEventWrapper): String { @@ -25,7 +38,13 @@ class FunCommandsController(val plugin: Plugin) : IBotController { return responses[rand] } - // CHOOSE - chooses between some number of options + /** + * Makes a selection among an arbitrary number of supplied choices + * + * @param event the incoming event object + * @param query a delimited string of the options to choose from + * @return the response string + */ @BotCommand(name = "choose", usage = "