diff --git a/LICENSE b/LICENSE index 9bca104..2c60978 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2015 Jacob Gillespie +Copyright Jacob Gillespie & Adam Hart (c) 2015-2017. Permission is hereby granted, free of charge, to any person ob- taining a copy of this software and associated documentation diff --git a/LICENSE-THIRD-PARTY.txt b/LICENSE-THIRD-PARTY.txt new file mode 100644 index 0000000..670dc41 --- /dev/null +++ b/LICENSE-THIRD-PARTY.txt @@ -0,0 +1,207 @@ +--------------------------------------------------------------------------------------- + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + applies to: + - Dynmap-API, Copyright (C) 2016 mikeprimm + - pegdown, Copyright (C) 2010-2011 Mathias Doenitz +--------------------------------------------------------------------------------------- + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and +limitations under the License. \ No newline at end of file diff --git a/NOTICE-THIRD-PARTY.txt b/NOTICE-THIRD-PARTY.txt new file mode 100644 index 0000000..218c5b3 --- /dev/null +++ b/NOTICE-THIRD-PARTY.txt @@ -0,0 +1,5 @@ +pegdown +Copyright (C) 2010-2011 Mathias Doenitz + +Based on peg-markdown - markdown in c, implemented using PEG grammar +Copyright (c) 2008 John MacFarlane (http://github.com/jgm/peg-markdown) \ No newline at end of file diff --git a/README.md b/README.md index 950f068..5c37baa 100644 --- a/README.md +++ b/README.md @@ -5,55 +5,96 @@ Bridges chat between Discord and Minecraft (Bukkit/Spigot). ## Requirements * Java 8 +* Spigot 1.11.2 ## Installation -1. Download the [latest release](https://github.com/the-obsidian/DiscordBridge/releases) from GitHub -1. Add it to your `plugins` folder -1. Either run Bukkit/Spigot once to generate `DiscordBridge/config.yml` or create it using the guide below. -1. All done! -## Configuration +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! + -**Note:** To use with the official Discord API and a bot user, add a `token: 'your-bot-token-here'` line alongside `email` and `password` (so it will have two spaces of indentation). If a `token` setting is present, it will ignore `email` and `password`. A more user-friendly version of this will be released shortly. +## Configuration DiscordBridge has several options that can be configured in the `config.yml` file: ```yaml -settings: - server-id: '00000000' - channel: 'test' - username: 'username' - email: 'email@example.com' - password: 'password' - debug: false - relay_cancelled_messages: true - messages: - chat: true - join: true - leave: true - death: false - if_vanished: - chat: false - join: false - leave: false - death: false - templates: - discord: - chat_message: '<%u> %m' - player_join: '%u joined the server' - player_leave: '%u left the server' - player_death: '%r' - minecraft: - chat_message: '<%u&b(discord)&r> %m' +# DiscordBridge Config + +# The bot's Discord API access token +token: '' + +# These values will control which channel the bot will watch to relay messages to and from the server. +server-id: '00000000' +channel: 'channel-name' + +# The bot's Discord username +username: 'DiscordBridge' + +# (Optional) Apply formatting codes to the bot's name in Minecraft chat. +# 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/api/ for more info +cleverbot-key: '' + +# If true, prints verbose log messages to the server console for every action +debug: false + +# If true, Minecraft chat events that are cancelled will still be relayed to Discord. +relay-cancelled-messages: true + +# Controls which events in general are relayed to Discord +messages: + player-chat: true + player-join: true + player-leave: true + player-death: false + server-start: true + server-stop: true + +# Controls which events caused by vanished players are relayed to Discord +# NOTE: If set to true, it will only have effect if the corresponding message above is also set to true +if-vanished: + player-chat: false + player-join: false + player-leave: false + player-death: false + +# 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: '%m' + server-start: 'Server started!' + server-stop: 'Shutting down...' + minecraft: + chat-message: '[&b&l%w&r]<%u> %m' ``` -* `server-id` is the ID of your Discord server. This can be found under *Server Settings > Widget > Server ID* +* `token` is the access token for the Discord bot. Without this, the bot will not function at all. +* `server-id` is the ID of the Discord server with the channel you want to bridge. This can be found under *Server Settings > Widget > Server ID* * `channel` is the Discord channel name you would like to bridge with your Minecraft server -* `username` is the Discord username of your bot user -* `email` is the Discord email address of your bot user -* `password` is the Discord password of your bot user -* `debug` enables more verbose logging +* `username` is the Discord username of your bot +* `username_color` is for adding formatting codes to the name of your bot when it speaks in Minecraft's chat (optional) +* `cleverbot-key` (optional) an access key necessary to chat with Cleverbot's API +* `debug` enables verbose logging * `relay_cancelled_messages` will relay chat messages even if they are cancelled * `messages` enables or disables certain kinds of messages * `if_vanished` enables or disables messages if the user is vanished (applies after `messages`) @@ -61,31 +102,64 @@ settings: **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 the death reason - 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) -* Join / leave messages are sent to Discord -* Death messages can optionally be sent to Discord -* Message templates are customized +* 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 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 `@` 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.reload` - ability to reload config and reconnect the Discord connection +- `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 config and reconnects to Discord +- `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 ?): Boolean { - if (player is Player && !Permissions.reload.has(player)) return true - - val isConsole = (player is Player) - - if (cmd.name != "discord") return true - - if (args == null || args.size != 1 || !args[0].equals("reload")) { - sendMessage("&eUsage: /discord reload", player, isConsole) - return true - } - - sendMessage("&eReloading Discord Bridge...", player, isConsole) - plugin.reload() - return true - } - - private fun sendMessage(message: String, player: CommandSender, isConsole: Boolean) { - val formattedMessage = ChatColor.translateAlternateColorCodes('&', message) - if (isConsole) { - plugin.server.consoleSender.sendMessage(formattedMessage) - } else { - player.sendMessage(formattedMessage) - } - } -} diff --git a/src/main/kotlin/gg/obsidian/discordbridge/Config.kt b/src/main/kotlin/gg/obsidian/discordbridge/Config.kt new file mode 100644 index 0000000..abdd864 --- /dev/null +++ b/src/main/kotlin/gg/obsidian/discordbridge/Config.kt @@ -0,0 +1,87 @@ +package gg.obsidian.discordbridge + +import gg.obsidian.discordbridge.utils.UtilFunctions.noSpace +import org.bukkit.ChatColor + +/** + * Caches primary config information + */ +object Config { + + var SERVER_ID: String = "" + var CHANNEL: String = "" + var USERNAME: String = "" + var USERNAME_COLOR: String = "" + var TOKEN: String = "" + var COMMAND_PREFIX: String = "" + var CLEVERBOT_KEY: String = "" + var DEBUG: Boolean = false + var RELAY_CANCELLED_MESSAGES = true + var ANNOUNCE_SERVER_START_STOP = true + + // Toggle message types + var MESSAGES_CHAT = true + var MESSAGES_JOIN = true + var MESSAGES_LEAVE = true + var MESSAGES_DEATH = false + + // What to do if player is vanished + var IF_VANISHED_CHAT = false + var IF_VANISHED_JOIN = false + var IF_VANISHED_LEAVE = false + var IF_VANISHED_DEATH = false + + // Discord message templates + var TEMPLATES_DISCORD_CHAT_MESSAGE = "" + var TEMPLATES_DISCORD_PLAYER_JOIN = "" + var TEMPLATES_DISCORD_PLAYER_LEAVE = "" + var TEMPLATES_DISCORD_PLAYER_DEATH = "" + var TEMPLATES_DISCORD_SERVER_START = "" + var TEMPLATES_DISCORD_SERVER_STOP = "" + + // Minecraft message templates + var TEMPLATES_MINECRAFT_CHAT_MESSAGE = "" + + // misc + var BOT_MC_USERNAME = "" + + /** + * Load values into memory from the config file + * @param plugin the base Plugin object + */ + fun load(plugin: Plugin) { + plugin.reloadConfig() + + SERVER_ID = plugin.config.getString("server-id") + CHANNEL = plugin.config.getString("channel") + USERNAME = plugin.config.getString("username") + USERNAME_COLOR = plugin.config.getString("username-color") + TOKEN = plugin.config.getString("token", "") + COMMAND_PREFIX = plugin.config.getString("command-prefix", "") + CLEVERBOT_KEY = plugin.config.getString("cleverbot-key", "") + DEBUG = plugin.config.getBoolean("debug", false) + RELAY_CANCELLED_MESSAGES = plugin.config.getBoolean("relay-cancelled-messages", true) + ANNOUNCE_SERVER_START_STOP = plugin.config.getBoolean("announce-server-start-stop", true) + + MESSAGES_CHAT = plugin.config.getBoolean("messages.chat", true) + MESSAGES_JOIN = plugin.config.getBoolean("messages.join", true) + MESSAGES_LEAVE = plugin.config.getBoolean("messages.leave", true) + MESSAGES_DEATH = plugin.config.getBoolean("messages.death", false) + + IF_VANISHED_CHAT = plugin.config.getBoolean("if-vanished.chat", false) + IF_VANISHED_JOIN = plugin.config.getBoolean("if-vanished.join", false) + IF_VANISHED_LEAVE = plugin.config.getBoolean("if-vanished.leave", false) + IF_VANISHED_DEATH = plugin.config.getBoolean("if-vanished.death", false) + + TEMPLATES_DISCORD_CHAT_MESSAGE = plugin.config.getString("templates.discord.chat-message", "<**%u**> %m") + TEMPLATES_DISCORD_PLAYER_JOIN = plugin.config.getString("templates.discord.player-join", "**%u** joined the server") + TEMPLATES_DISCORD_PLAYER_LEAVE = plugin.config.getString("templates.discord.player-leave", "**%u** left the server") + TEMPLATES_DISCORD_PLAYER_DEATH = plugin.config.getString("templates.discord.player-death", "%m") + TEMPLATES_DISCORD_SERVER_START = plugin.config.getString("templates.discord.server-start", "Server started!") + TEMPLATES_DISCORD_SERVER_STOP = plugin.config.getString("templates.discord.server_stop", "Shutting down...") + + TEMPLATES_MINECRAFT_CHAT_MESSAGE = plugin.config.getString("templates.minecraft.chat-message", "[&b&lDiscord&r]<%u> %m") + + BOT_MC_USERNAME = ChatColor.translateAlternateColorCodes('&', USERNAME_COLOR + USERNAME.noSpace() + "&r") + } +} diff --git a/src/main/kotlin/gg/obsidian/discordbridge/Configuration.kt b/src/main/kotlin/gg/obsidian/discordbridge/Configuration.kt deleted file mode 100644 index 5110715..0000000 --- a/src/main/kotlin/gg/obsidian/discordbridge/Configuration.kt +++ /dev/null @@ -1,64 +0,0 @@ -package gg.obsidian.discordbridge - -class Configuration(val plugin: Plugin) { - - var SERVER_ID: String = "" - var CHANNEL: String = "" - var USERNAME: String = "" - var EMAIL: String = "" - var PASSWORD: String = "" - var TOKEN: String = "" - var DEBUG: Boolean = false - var RELAY_CANCELLED_MESSAGES = true - - // Toggle message types - var MESSAGES_CHAT = true - var MESSAGES_JOIN = true - var MESSAGES_LEAVE = true - var MESSAGES_DEATH = false - - // What to do if player is vanished - var IF_VANISHED_CHAT = false - var IF_VANISHED_JOIN = false - var IF_VANISHED_LEAVE = false - var IF_VANISHED_DEATH = false - - // Discord message templates - var TEMPLATES_DISCORD_CHAT_MESSAGE = "" - var TEMPLATES_DISCORD_PLAYER_JOIN = "" - var TEMPLATES_DISCORD_PLAYER_LEAVE = "" - var TEMPLATES_DISCORD_PLAYER_DEATH = "" - - // Minecraft message templates - var TEMPLATES_MINECRAFT_CHAT_MESSAGE = "" - - fun load() { - plugin.reloadConfig() - - SERVER_ID = plugin.config.getString("settings.server-id") - CHANNEL = plugin.config.getString("settings.channel") - USERNAME = plugin.config.getString("settings.username") - EMAIL = plugin.config.getString("settings.email", "") - PASSWORD = plugin.config.getString("settings.password", "") - TOKEN = plugin.config.getString("settings.token", "") - DEBUG = plugin.config.getBoolean("settings.debug", false) - RELAY_CANCELLED_MESSAGES = plugin.config.getBoolean("settings.relay_cancelled_messages", true) - - MESSAGES_CHAT = plugin.config.getBoolean("settings.messages.chat", true) - MESSAGES_JOIN = plugin.config.getBoolean("settings.messages.join", true) - MESSAGES_LEAVE = plugin.config.getBoolean("settings.messages.leave", true) - MESSAGES_DEATH = plugin.config.getBoolean("settings.messages.death", false) - - IF_VANISHED_CHAT = plugin.config.getBoolean("settings.if_vanished.chat", false) - IF_VANISHED_JOIN = plugin.config.getBoolean("settings.if_vanished.join", false) - IF_VANISHED_LEAVE = plugin.config.getBoolean("settings.if_vanished.leave", false) - IF_VANISHED_DEATH = plugin.config.getBoolean("settings.if_vanished.death", false) - - TEMPLATES_DISCORD_CHAT_MESSAGE = plugin.config.getString("settings.templates.discord.chat_message", "<%u> %m") - TEMPLATES_DISCORD_PLAYER_JOIN = plugin.config.getString("settings.templates.discord.player_join", "%u joined the server") - TEMPLATES_DISCORD_PLAYER_LEAVE = plugin.config.getString("settings.templates.discord.player_leave", "%u left the server") - TEMPLATES_DISCORD_PLAYER_DEATH = plugin.config.getString("settings.templates.discord.player_death", "%r") - - TEMPLATES_MINECRAFT_CHAT_MESSAGE = plugin.config.getString("settings.templates.minecraft.chat_message", "<%u&b(discord)&r> %m") - } -} diff --git a/src/main/kotlin/gg/obsidian/discordbridge/DataConfigAccessor.kt b/src/main/kotlin/gg/obsidian/discordbridge/DataConfigAccessor.kt new file mode 100644 index 0000000..e3004e6 --- /dev/null +++ b/src/main/kotlin/gg/obsidian/discordbridge/DataConfigAccessor.kt @@ -0,0 +1,100 @@ +package gg.obsidian.discordbridge + +/* +* Copyright (C) 2012 SagaciousZed +* +* Permission is hereby granted, free of charge, to any person obtaining a copy +* of this software and associated documentation files (the "Software"), to deal +* in the Software without restriction, including without limitation the rights +* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +* copies of the Software, and to permit persons to whom the Software is +* furnished to do so, subject to the following conditions: +* +* The above copyright notice and this permission notice shall be included in all +* copies or substantial portions of the Software. +* +* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +* OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT.IN NO EVENT SHALL THE +* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +* SOFTWARE. +* +* NOTE: This code has been modified from Java to Kotlin. The functionality is largely identical. +*/ + +import org.bukkit.configuration.file.FileConfiguration +import org.bukkit.configuration.file.YamlConfiguration +import java.io.File +import java.io.IOException +import java.io.InputStreamReader +import java.util.logging.Level + +/** + * Acts as an interface between the plugin and a target config file + * + * @param plugin a reference to the base Plugin object + * @param filepath the path to the config file + * @param fileName the name of the config file plus its extension + */ +class DataConfigAccessor(val plugin: Plugin, filepath: File, private val fileName: String) { + + private val configFile: File? + private var fileConfiguration: FileConfiguration? = null + + init { + plugin.dataFolder ?: throw IllegalStateException() + this.configFile = File(filepath, fileName) + } + + /** + * Reloads the data from the config file into memory + * + * If the file is null, the default file from within the jar file is loaded instead + */ + fun reloadConfig() { + try { + fileConfiguration = YamlConfiguration.loadConfiguration(configFile) + } catch (e: IllegalArgumentException) { + // Look for defaults in the jar + if (plugin.getResource(fileName) == null) + plugin.logger.log(Level.SEVERE, "$fileName cannot be found for some reason") + val defConfigReader = InputStreamReader(plugin.getResource(fileName)) + val defConfig = YamlConfiguration.loadConfiguration(defConfigReader) + fileConfiguration!!.defaults = defConfig + } + } + + val data: FileConfiguration + get() { + if (fileConfiguration == null) + this.reloadConfig() + return fileConfiguration!! + } + + /** + * Writes the current memory contents of the config back to file + */ + fun saveConfig() { + if (fileConfiguration == null || configFile == null) + return + else { + try { + data.save(configFile) + } catch (ex: IOException) { + plugin.logger.log(Level.SEVERE, "Could not save data to $fileName", ex) + } + } + } + + /** + * Saves the default config file within the jar to its own file, if it does not already exist + */ + @Suppress("unused") + fun saveDefaultConfig() { + if (!configFile!!.exists()) + plugin.saveResource(fileName, false) + } + +} diff --git a/src/main/kotlin/gg/obsidian/discordbridge/DiscordConnection.kt b/src/main/kotlin/gg/obsidian/discordbridge/DiscordConnection.kt deleted file mode 100644 index d509d36..0000000 --- a/src/main/kotlin/gg/obsidian/discordbridge/DiscordConnection.kt +++ /dev/null @@ -1,68 +0,0 @@ -package gg.obsidian.discordbridge - -import net.dv8tion.jda.JDA -import net.dv8tion.jda.JDABuilder -import net.dv8tion.jda.entities.Guild -import net.dv8tion.jda.entities.TextChannel - -class DiscordConnection(val plugin: Plugin) : Runnable { - var api: JDA? = null - var listener: DiscordListener? = null - var server: Guild? = null - var channel: TextChannel? = null - - override fun run() { - try { - connect() - } catch (e: Exception) { - plugin.logger.severe("Error connecting to Discord: " + e) - } - - } - - fun send(message: String) { - server = if (server == null) getServerById(plugin.configuration.SERVER_ID) else server - if (server == null) return - - channel = if (channel == null) getGroupByName(server!!, plugin.configuration.CHANNEL) else channel - if (channel == null) return - - channel!!.sendMessage(message) - } - - fun reconnect() { - disconnect() - connect() - } - - private fun disconnect() { - api?.removeEventListener(listener) - api?.shutdown(false) - } - - private fun connect() { - var builder = JDABuilder().setAudioEnabled(false) - if (plugin.configuration.TOKEN != "") { - builder = builder.setBotToken(plugin.configuration.TOKEN) - } else { - builder = builder.setEmail(plugin.configuration.EMAIL).setPassword(plugin.configuration.PASSWORD) - } - api = builder.buildBlocking() - listener = DiscordListener(plugin, api as JDA, this) - api!!.addEventListener(listener) - } - - private fun getServerById(id: String): Guild? { - for (server in api!!.guilds) - if (server.id.equals(id, true)) - return server - return null - } - - private fun getGroupByName(server: Guild, name: String): TextChannel? { - for (group in server.textChannels) - if (group.name.equals(name)) - return group - return null - } -} diff --git a/src/main/kotlin/gg/obsidian/discordbridge/DiscordListener.kt b/src/main/kotlin/gg/obsidian/discordbridge/DiscordListener.kt deleted file mode 100644 index 9b2ae72..0000000 --- a/src/main/kotlin/gg/obsidian/discordbridge/DiscordListener.kt +++ /dev/null @@ -1,44 +0,0 @@ -package gg.obsidian.discordbridge - -import com.neovisionaries.ws.client.WebSocket -import com.neovisionaries.ws.client.WebSocketException -import com.neovisionaries.ws.client.WebSocketFrame -import net.dv8tion.jda.JDA -import net.dv8tion.jda.events.message.MessageReceivedEvent -import net.dv8tion.jda.hooks.ListenerAdapter - -class DiscordListener(val plugin: Plugin, val api: JDA, val connection: DiscordConnection) : ListenerAdapter() { - - override fun onMessageReceived(event: MessageReceivedEvent) { - plugin.logDebug("Received message ${event.message.id} from Discord") - - if (!event.guild.id.equals(plugin.configuration.SERVER_ID)) { - plugin.logDebug("Ignoring message ${event.message.id} from Discord: server does not match") - return - } - - if (!event.textChannel.name.equals(plugin.configuration.CHANNEL, true)) { - plugin.logDebug("Ignoring message ${event.message.id} from Discord: channel does not match") - return - } - - val username: String = event.author.username - - if (username.equals(plugin.configuration.USERNAME, true)) { - plugin.logDebug("Ignoring message ${event.message.id} from Discord: it matches the server's username") - return - } - - plugin.logDebug("Broadcasting message ${event.message.id} from Discord as user $username") - plugin.sendToMinecraft(username, event.message.content) - } - - fun onUnexpectedError(ws: WebSocket, wse: WebSocketException) { - plugin.logger.severe("Unexpected error from DiscordBridge: ${wse.message}") - } - - fun onDisconnected(webSocket: WebSocket, serverCloseFrame: WebSocketFrame, clientCloseFrame: WebSocketFrame, closedByServer: Boolean) { - plugin.logDebug("Discord disconnected - attempting to reconnect") - connection.reconnect() - } -} diff --git a/src/main/kotlin/gg/obsidian/discordbridge/EventListener.kt b/src/main/kotlin/gg/obsidian/discordbridge/EventListener.kt deleted file mode 100644 index 37078d8..0000000 --- a/src/main/kotlin/gg/obsidian/discordbridge/EventListener.kt +++ /dev/null @@ -1,115 +0,0 @@ -package gg.obsidian.discordbridge - -import org.bukkit.ChatColor -import org.bukkit.event.EventHandler -import org.bukkit.event.EventPriority -import org.bukkit.event.Listener -import org.bukkit.event.entity.PlayerDeathEvent -import org.bukkit.event.player.AsyncPlayerChatEvent -import org.bukkit.event.player.PlayerJoinEvent -import org.bukkit.event.player.PlayerQuitEvent - -class EventListener(val plugin: Plugin): Listener { - - @EventHandler(priority = EventPriority.MONITOR) - fun onChat(event: AsyncPlayerChatEvent) { - plugin.logDebug("Received a chat event from ${event.player.name}: ${event.message}") - - - if (!plugin.configuration.MESSAGES_CHAT) return - if (event.isCancelled && !plugin.configuration.RELAY_CANCELLED_MESSAGES) return - - // Check for vanished - val player = event.player; - if (player.hasMetadata("vanished") && - player.getMetadata("vanished")[0].asBoolean() && - !plugin.configuration.IF_VANISHED_CHAT) return - - val username = ChatColor.stripColor(event.player.name) - val formattedMessage = Util.formatMessage( - plugin.configuration.TEMPLATES_DISCORD_CHAT_MESSAGE, - mapOf( - "%u" to username, - "%m" to ChatColor.stripColor(event.message), - "%d" to ChatColor.stripColor(player.displayName), - "%w" to player.world.name - ) - ) - - plugin.sendToDiscord(formattedMessage) - } - - @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) - fun onPlayerJoin(event: PlayerJoinEvent) { - if (!plugin.configuration.MESSAGES_JOIN) return - - // Check for vanished - val player = event.player; - if (player.hasMetadata("vanished") && - player.getMetadata("vanished")[0].asBoolean() && - !plugin.configuration.IF_VANISHED_JOIN) return - - val username = ChatColor.stripColor(player.name) - plugin.logDebug("Received a join event for $username") - - val formattedMessage = Util.formatMessage( - plugin.configuration.TEMPLATES_DISCORD_PLAYER_JOIN, - mapOf( - "%u" to username, - "%d" to ChatColor.stripColor(player.displayName) - ) - ) - - plugin.sendToDiscord(formattedMessage) - } - - @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) - fun onPlayerQuit(event: PlayerQuitEvent) { - if (!plugin.configuration.MESSAGES_LEAVE) return - - // Check for vanished - val player = event.player; - if (player.hasMetadata("vanished") && - player.getMetadata("vanished")[0].asBoolean() && - !plugin.configuration.IF_VANISHED_LEAVE) return - - val username = ChatColor.stripColor(event.player.name) - plugin.logDebug("Received a leave event for $username") - - val formattedMessage = Util.formatMessage( - plugin.configuration.TEMPLATES_DISCORD_PLAYER_LEAVE, - mapOf( - "%u" to username, - "%d" to ChatColor.stripColor(event.player.displayName) - ) - ) - - plugin.sendToDiscord(formattedMessage) - } - - @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) - fun onPlayerDeath(event: PlayerDeathEvent) { - if (!plugin.configuration.MESSAGES_DEATH) return - - // Check for vanished - val player = event.entity; - if (player.hasMetadata("vanished") && - player.getMetadata("vanished")[0].asBoolean() && - !plugin.configuration.IF_VANISHED_DEATH) return - - val username = ChatColor.stripColor(event.entity.name) - plugin.logDebug("Received a death event for $username") - - val formattedMessage = Util.formatMessage( - plugin.configuration.TEMPLATES_DISCORD_PLAYER_DEATH, - mapOf( - "%u" to username, - "%d" to ChatColor.stripColor(event.entity.displayName), - "%r" to event.deathMessage, - "%w" to event.entity.world.name - ) - ) - - plugin.sendToDiscord(formattedMessage) - } -} diff --git a/src/main/kotlin/gg/obsidian/discordbridge/Permissions.kt b/src/main/kotlin/gg/obsidian/discordbridge/Permissions.kt deleted file mode 100644 index 4fa57d1..0000000 --- a/src/main/kotlin/gg/obsidian/discordbridge/Permissions.kt +++ /dev/null @@ -1,11 +0,0 @@ -package gg.obsidian.discordbridge - -import org.bukkit.entity.Player - -enum class Permissions(val node: String) { - reload("discordbridge.reload"); - - fun has(player: Player): Boolean { - return player.hasPermission(node) - } -} diff --git a/src/main/kotlin/gg/obsidian/discordbridge/Plugin.kt b/src/main/kotlin/gg/obsidian/discordbridge/Plugin.kt index bd96815..adf5411 100644 --- a/src/main/kotlin/gg/obsidian/discordbridge/Plugin.kt +++ b/src/main/kotlin/gg/obsidian/discordbridge/Plugin.kt @@ -1,60 +1,341 @@ package gg.obsidian.discordbridge +import gg.obsidian.discordbridge.discord.Connection +import gg.obsidian.discordbridge.minecraft.CommandListener +import gg.obsidian.discordbridge.minecraft.EventListener +import gg.obsidian.discordbridge.utils.Rating +import gg.obsidian.discordbridge.utils.Respect +import gg.obsidian.discordbridge.utils.Script +import gg.obsidian.discordbridge.utils.UserAlias +import gg.obsidian.discordbridge.utils.UtilFunctions.noSpace +import net.dv8tion.jda.core.OnlineStatus +import net.dv8tion.jda.core.entities.Member +import net.dv8tion.jda.core.entities.MessageChannel +import org.bukkit.configuration.serialization.ConfigurationSerialization +import org.bukkit.entity.Player import org.bukkit.plugin.java.JavaPlugin +import org.pegdown.PegDownProcessor +import java.io.File +import java.util.logging.Level +import org.bukkit.ChatColor as CC +/** + * The primary Plugin class that maintains the plugin's connection with Bukkit + */ class Plugin : JavaPlugin() { - val configuration = Configuration(this) - var connection: DiscordConnection? = null + // Configs + var users: DataConfigAccessor = DataConfigAccessor(this, dataFolder, "usernames.yml") + var eightball: DataConfigAccessor = DataConfigAccessor(this, dataFolder, "8ball.yml") + var insult: DataConfigAccessor = DataConfigAccessor(this, dataFolder, "insult.yml") + var f: DataConfigAccessor = DataConfigAccessor(this, dataFolder, "f.yml") + var rate: DataConfigAccessor = DataConfigAccessor(this, dataFolder, "rate.yml") + var script: DataConfigAccessor = DataConfigAccessor(this, dataFolder, "script.yml") + var worlds: DataConfigAccessor? = null + // Markdown deserializer + var pegDownProc = PegDownProcessor() + + // Temporary storage for alias linking requests + var requests: MutableList = mutableListOf() + + /** + * Returns whether Multiverse-Core is installed + */ + val isMultiverseInstalled: Boolean + get() = server.pluginManager.getPlugin("Multiverse-Core") != null + + /** + * Runs at plugin startup + */ override fun onEnable() { - updateConfig(description.version) + // 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") + ConfigurationSerialization.registerClass(UserAlias::class.java, "UserAlias") - this.connection = DiscordConnection(this) + // Load configs + updateConfig(description.version) + if (isMultiverseInstalled) worlds = DataConfigAccessor(this, File("plugins/Multiverse-Core"), "worlds.yml") - server.scheduler.runTaskAsynchronously(this, connection) + // Connect to Discord + Connection.plugin = this //TODO: enforce this better + server.scheduler.runTaskAsynchronously(this, Connection) server.pluginManager.registerEvents(EventListener(this), this) - getCommand("discord").executor = CommandHandler(this) + + // Register commands + //TODO: automate this? + getCommand("discord").executor = CommandListener(this) + getCommand("f").executor = CommandListener(this) + getCommand("rate").executor = CommandListener(this) + getCommand("8ball").executor = CommandListener(this) + getCommand("insult").executor = CommandListener(this) + getCommand("choose").executor = CommandListener(this) + getCommand("talk").executor = CommandListener(this) + getCommand("roll").executor = CommandListener(this) } - fun reload() { - reloadConfig() - configuration.load() - connection?.reconnect() + /** + * 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()) + + // Pretend like this does anything + logger.log(Level.INFO, "Attempting to cancel tasks") + server.scheduler.cancelTasks(this) } - // Message senders + /*====================================== + Messaging Functions + ===================================== */ - fun sendToDiscord(message: String) { + /** + * 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) + Connection.send(message, channel) } - fun sendToMinecraft(username: String, message: String) { - val formattedMessage = Util.formatMessage( - configuration.TEMPLATES_MINECRAFT_CHAT_MESSAGE, - mapOf( - "%u" to username, - "%m" to message - ), - colors = true - ) - - server.broadcastMessage(formattedMessage) + /** + * Broadcast a message on the Minecraft server + * + * @param message the message to send + */ + fun sendToMinecraft(message: String) { + server.broadcastMessage(message) } - // Utilities + /*=========================================== + Util + ===========================================*/ + /** + * Reloads all configs and the JDA connection + */ + fun reload(callback: Runnable) { + reloadConfig() + users.reloadConfig() + eightball.reloadConfig() + insult.reloadConfig() + f.reloadConfig() + rate.reloadConfig() + script.reloadConfig() + if (isMultiverseInstalled) worlds!!.reloadConfig() + Config.load(this) + UserAliasConfig.load(this) + Connection.reconnect(callback) + } + + /** + * 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) config.set("version", version) saveConfig() - configuration.load() + + users.saveDefaultConfig() + eightball.saveDefaultConfig() + insult.saveDefaultConfig() + f.saveDefaultConfig() + rate.saveDefaultConfig() + script.saveDefaultConfig() + + Config.load(this) + UserAliasConfig.load(this) } + /** + * Sends a log message to console if the DEBUG flag in config.yml is true + */ fun logDebug(msg: String) { - if (!configuration.DEBUG) return; - logger.info(msg) + if (!Config.DEBUG) return + logger.info("[DiscordBridge] $msg") + } + + /** + * @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() + players.mapTo(names) { it.name } + return names.toList() + } + + /** + * 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 + + val ua: UserAlias = UserAlias(player.uniqueId, found.user.id) + requests.add(ua) + val msg = "Minecraft user '${server.getOfflinePlayer(ua.mcUuid).name}' has requested to become associated with your Discord" + + " account. If this is you, respond '${Connection.JDA.selfUser.asMention} confirm'. If this is not" + + " you, respond ${Connection.JDA.selfUser.asMention} deny'." + val member = Connection.JDA.getUserById(ua.discordId) + member.openPrivateChannel().queue({p -> p.sendMessage(msg).queue()}) + return found + } + + /** + * @return a formatted string listing the Discord IDs of all Discord users in the relay channel + */ + fun getDiscordMembersAll(): String { + val users = Connection.listUsers() + + if (users.isEmpty()) + return "${CC.YELLOW}No Discord members could be found. Either server is empty or an error has occurred." + + var response = "${CC.YELLOW}Discord users:" + for (user in users) { + if (user.user.isBot) response += "\n${CC.GOLD}- ${user.effectiveName} (Bot) | ${user.user.name}#${user.user.discriminator}${CC.RESET}" + else response += "\n${CC.YELLOW}- ${user.effectiveName} | ${user.user.name}#${user.user.discriminator}${CC.RESET}" + } + return response.trim() + } + + /** + * @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()) + return "${CC.YELLOW}No Discord members could be found. Either server is empty or an error has occurred." + + var response = "" + if (onlineUsers.filter { it.onlineStatus == OnlineStatus.ONLINE }.isNotEmpty()) { + response += "\n${CC.DARK_GREEN}Online:${CC.RESET}" + for (user in onlineUsers.filter { it.onlineStatus == OnlineStatus.ONLINE }) { + if (user.user.isBot) response += "\n${CC.DARK_GREEN}- ${user.effectiveName} (Bot)${CC.RESET}" + else response += "\n${CC.DARK_GREEN}- ${user.effectiveName}${CC.RESET}" + } + } + if (onlineUsers.filter { it.onlineStatus == OnlineStatus.IDLE }.isNotEmpty()) { + response += "\n${CC.YELLOW}Idle:${CC.RESET}" + for (user in onlineUsers.filter { it.onlineStatus == OnlineStatus.IDLE }) { + if (user.user.isBot) response += "\n${CC.YELLOW}- ${user.effectiveName} (Bot)${CC.RESET}" + else response += "\n${CC.YELLOW}- ${user.effectiveName}${CC.RESET}" + } + } + if (onlineUsers.filter { it.onlineStatus == OnlineStatus.DO_NOT_DISTURB }.isNotEmpty()) { + response += "\n${CC.RED}Do Not Disturb:${CC.RESET}" + for (user in onlineUsers.filter { it.onlineStatus == OnlineStatus.DO_NOT_DISTURB }) { + if (user.user.isBot) response += "\n${CC.RED}- ${user.effectiveName} (Bot)${CC.RESET}" + else response += "\n${CC.RED}- ${user.effectiveName}${CC.RESET}" + } + } + + response.replaceFirst("\n", "") + return response.trim() + } + + /*====================================== + Message Formatting Functions + ===================================== */ + + /** + * 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 + + val discordusers = Connection.listUsers() + val discordaliases: MutableList> = mutableListOf() + + for (du in discordusers) + for ((mcUuid, discordId) in UserAliasConfig.aliases) + if (discordId == du.user.id) discordaliases.add(Pair(server.getOfflinePlayer(mcUuid).name, du)) + + for (match in Regex("""(?:^| )@(\w+)""").findAll(message)) { + val found: Member? = discordusers.firstOrNull { + it.user.name.noSpace().toLowerCase() == match.groupValues[1].toLowerCase() || + it.user.name + "#" + it.user.discriminator == match.groupValues[1].toLowerCase() || + it.effectiveName.noSpace().toLowerCase() == match.groupValues[1].toLowerCase() + } + if (found != null) newMessage = newMessage.replaceFirst("@${match.groupValues[1]}", found.asMention) + + val found2: Pair? = discordaliases.firstOrNull { + it.first.toLowerCase() == match.groupValues[1].toLowerCase() + } + if (found2 != null) newMessage = newMessage.replaceFirst("@${match.groupValues[1]}", found2.second.asMention) + } + + 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)) { + val discordUser = Connection.listUsers().firstOrNull { it.user.id == match.groupValues[1] || it.user.id == match.groupValues[2] } + if (discordUser != null) modifiedMessage = modifiedMessage.replace(match.value, "@"+discordUser.effectiveName) + } + return modifiedMessage + } + + /** + * 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) { + val nameMC = server.getOfflinePlayer(mcUuid).name + val discordUser = Connection.listUsers().firstOrNull{it.user.id == discordId } + val nameDis = if (discordUser != null) discordUser.effectiveName else Connection.JDA.getUserById(discordId).name + modifiedMessage = modifiedMessage.replace(nameMC, nameDis) + } + return modifiedMessage + } + + /** + * 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) { + val nameMC = server.getOfflinePlayer(mcUuid).name + val nameDis = Connection.JDA.getUserById(discordId).name + modifiedMessage = modifiedMessage.replace(nameDis, nameMC) + val discordUser = Connection.listUsers().firstOrNull{it.user.id == discordId} + if (discordUser != null) modifiedMessage = modifiedMessage.replace(discordUser.effectiveName, nameMC) + } + return modifiedMessage } } diff --git a/src/main/kotlin/gg/obsidian/discordbridge/UserAliasConfig.kt b/src/main/kotlin/gg/obsidian/discordbridge/UserAliasConfig.kt new file mode 100644 index 0000000..2398afa --- /dev/null +++ b/src/main/kotlin/gg/obsidian/discordbridge/UserAliasConfig.kt @@ -0,0 +1,46 @@ +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() ?: + throw IllegalStateException("usernames.yml could not be read - list items are not properly formatted") + 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) + plugin.users.saveConfig() + 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) + plugin.users.saveConfig() + 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/Util.kt b/src/main/kotlin/gg/obsidian/discordbridge/Util.kt deleted file mode 100644 index 788828d..0000000 --- a/src/main/kotlin/gg/obsidian/discordbridge/Util.kt +++ /dev/null @@ -1,17 +0,0 @@ -package gg.obsidian.discordbridge - -import org.bukkit.ChatColor - -object Util { - fun formatMessage(message: String, replacements: Map, colors: Boolean = false): String { - var formattedString = message - - if (colors) formattedString = ChatColor.translateAlternateColorCodes('&', formattedString) - - for ((token, replacement) in replacements) { - formattedString = formattedString.replace(token, replacement) - } - - return formattedString - } -} diff --git a/src/main/kotlin/gg/obsidian/discordbridge/commands/AsyncPlayerChatEventWrapper.kt b/src/main/kotlin/gg/obsidian/discordbridge/commands/AsyncPlayerChatEventWrapper.kt new file mode 100644 index 0000000..9166b38 --- /dev/null +++ b/src/main/kotlin/gg/obsidian/discordbridge/commands/AsyncPlayerChatEventWrapper.kt @@ -0,0 +1,52 @@ +package gg.obsidian.discordbridge.commands + +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 new file mode 100644 index 0000000..2acf55e --- /dev/null +++ b/src/main/kotlin/gg/obsidian/discordbridge/commands/IBotController.kt @@ -0,0 +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 new file mode 100644 index 0000000..5116f7c --- /dev/null +++ b/src/main/kotlin/gg/obsidian/discordbridge/commands/IEventWrapper.kt @@ -0,0 +1,64 @@ +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 new file mode 100644 index 0000000..c00ff01 --- /dev/null +++ b/src/main/kotlin/gg/obsidian/discordbridge/commands/MessageWrapper.kt @@ -0,0 +1,68 @@ +package gg.obsidian.discordbridge.commands + +import gg.obsidian.discordbridge.Config +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 new file mode 100644 index 0000000..141817b --- /dev/null +++ b/src/main/kotlin/gg/obsidian/discordbridge/commands/MinecraftCommandWrapper.kt @@ -0,0 +1,61 @@ +package gg.obsidian.discordbridge.commands + +import gg.obsidian.discordbridge.discord.Connection +import net.dv8tion.jda.core.entities.MessageChannel +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/annotations/BotCommand.kt b/src/main/kotlin/gg/obsidian/discordbridge/commands/annotations/BotCommand.kt new file mode 100644 index 0000000..ed1de34 --- /dev/null +++ b/src/main/kotlin/gg/obsidian/discordbridge/commands/annotations/BotCommand.kt @@ -0,0 +1,15 @@ +package gg.obsidian.discordbridge.commands.annotations + +/** + * Annotates a function as a command the bot can run. + * + * @param usage a short string that describes the command's parameter syntax + * @param description a short string that describes the command's function + * @param name an optional field to override the command's access name if it is not the same as the method name + * @param relayTriggerMessage whether the message used to trigger this command should be relayed + * @param ignoreExcessArguments if false, this command will fail if the invoker provides too many arguments + */ +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class BotCommand(val usage: String, val description: String, val name: String = "", + val relayTriggerMessage: Boolean = true, val ignoreExcessArguments: Boolean = true) diff --git a/src/main/kotlin/gg/obsidian/discordbridge/commands/annotations/ChatExclusiveCommand.kt b/src/main/kotlin/gg/obsidian/discordbridge/commands/annotations/ChatExclusiveCommand.kt new file mode 100644 index 0000000..3b6a510 --- /dev/null +++ b/src/main/kotlin/gg/obsidian/discordbridge/commands/annotations/ChatExclusiveCommand.kt @@ -0,0 +1,10 @@ +package gg.obsidian.discordbridge.commands.annotations + +/** + * Annotates a BotCommand as a command that is exposed to Discord chat and Minecraft chat + * + * @see BotCommand + */ +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class ChatExclusiveCommand diff --git a/src/main/kotlin/gg/obsidian/discordbridge/commands/annotations/DiscordExclusiveCommand.kt b/src/main/kotlin/gg/obsidian/discordbridge/commands/annotations/DiscordExclusiveCommand.kt new file mode 100644 index 0000000..f9e8f1a --- /dev/null +++ b/src/main/kotlin/gg/obsidian/discordbridge/commands/annotations/DiscordExclusiveCommand.kt @@ -0,0 +1,10 @@ +package gg.obsidian.discordbridge.commands.annotations + +/** + * Annotates a BotCommand as a command that is only exposed to Discord chat + * + * @see BotCommand + */ +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class DiscordExclusiveCommand diff --git a/src/main/kotlin/gg/obsidian/discordbridge/commands/annotations/MinecraftExclusiveCommand.kt b/src/main/kotlin/gg/obsidian/discordbridge/commands/annotations/MinecraftExclusiveCommand.kt new file mode 100644 index 0000000..9611d52 --- /dev/null +++ b/src/main/kotlin/gg/obsidian/discordbridge/commands/annotations/MinecraftExclusiveCommand.kt @@ -0,0 +1,10 @@ +package gg.obsidian.discordbridge.commands.annotations + +/** + * Annotates a BotCommand as a command that is only exposed as a Minecraft console command + * + * @see BotCommand + */ +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class MinecraftExclusiveCommand diff --git a/src/main/kotlin/gg/obsidian/discordbridge/commands/annotations/PrivateResponse.kt b/src/main/kotlin/gg/obsidian/discordbridge/commands/annotations/PrivateResponse.kt new file mode 100644 index 0000000..fe5d10d --- /dev/null +++ b/src/main/kotlin/gg/obsidian/discordbridge/commands/annotations/PrivateResponse.kt @@ -0,0 +1,17 @@ +package gg.obsidian.discordbridge.commands.annotations + +/** + * Annotates a BotCommand as a command that will return its output privately to the invoker using + * whatever medium is appropriate for the invocation source + * + * Discord chat commands will return in a DM to the invoker + * + * Minecraft chat commands will return as a Minecraft PM to the invoker + * + * Minecraft console commands will return as a Minecraft PM to the invoker + * + * @see BotCommand + */ +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class PrivateResponse \ No newline at end of file diff --git a/src/main/kotlin/gg/obsidian/discordbridge/commands/annotations/TaggedResponse.kt b/src/main/kotlin/gg/obsidian/discordbridge/commands/annotations/TaggedResponse.kt new file mode 100644 index 0000000..ebcb073 --- /dev/null +++ b/src/main/kotlin/gg/obsidian/discordbridge/commands/annotations/TaggedResponse.kt @@ -0,0 +1,12 @@ +package gg.obsidian.discordbridge.commands.annotations + +/** + * Annotates a BotCommand as a command where the response will be prepended with "@invokerName | " + * + * In Discord, "@invoker" will be converted to a tag mention if that user exists in the relay channel + * + * @see BotCommand + */ +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class TaggedResponse \ No newline at end of file diff --git a/src/main/kotlin/gg/obsidian/discordbridge/commands/controllers/BotControllerManager.kt b/src/main/kotlin/gg/obsidian/discordbridge/commands/controllers/BotControllerManager.kt new file mode 100644 index 0000000..2a40453 --- /dev/null +++ b/src/main/kotlin/gg/obsidian/discordbridge/commands/controllers/BotControllerManager.kt @@ -0,0 +1,602 @@ +package gg.obsidian.discordbridge.commands.controllers + +import gg.obsidian.discordbridge.Config +import gg.obsidian.discordbridge.Plugin +import gg.obsidian.discordbridge.commands.* +import gg.obsidian.discordbridge.commands.annotations.* +import gg.obsidian.discordbridge.discord.Connection +import gg.obsidian.discordbridge.utils.MarkdownToMinecraftSeralizer +import gg.obsidian.discordbridge.utils.Script +import gg.obsidian.discordbridge.utils.UtilFunctions.noSpace +import gg.obsidian.discordbridge.utils.UtilFunctions.stripColor +import gg.obsidian.discordbridge.utils.UtilFunctions.toDiscordChatMessage +import gg.obsidian.discordbridge.utils.UtilFunctions.toMinecraftChatMessage +import java.lang.reflect.Method +import java.util.* +import java.util.logging.Level +import gg.obsidian.discordbridge.Config as cfg +import org.bukkit.ChatColor as CC + +/** + * A class that manages an assortment of IBotControllers and allows dynamic access to a configurable assortment + * of their commands + * + * @param plugin a reference to the base Plugin object + * @see IBotController + */ +class BotControllerManager(val plugin: Plugin) { + + private val commands: MutableMap = mutableMapOf() + private val controllers: MutableMap, IBotController> = mutableMapOf() + + /** + * Adds an IBotController to the manager. + * + * @param controller the IBotController to add + * @param discordExclusive (optional) whether this BotControllerManager instance should have access to this IBotController's + * methods annotated by DiscordExclusiveCommand (defaults false) + * @param minecraftExclusive (optional) whether this BotControllerManager instance should have access to this IBotController's + * methods annotated by MinecraftExclusiveCommand (defaults false) + * @param chatExclusive (optional) whether this BotControllerManager instance should have access to this IBotController's + * methods annotated by ChatExclusiveCommand (defaults false) + */ + fun registerController(controller: IBotController, discordExclusive: Boolean = false, + minecraftExclusive: Boolean = false, chatExclusive: Boolean = false) { + controllers.put(controller.javaClass, controller) + val controllerClass = controller.javaClass + + for (method in controllerClass.declaredMethods) { + val annotation = method.getAnnotation(BotCommand::class.java) + if (annotation != null + && (discordExclusive || method.getAnnotation(DiscordExclusiveCommand::class.java) == null) + && (minecraftExclusive || method.getAnnotation(MinecraftExclusiveCommand::class.java) == null) + && (chatExclusive || method.getAnnotation(ChatExclusiveCommand::class.java) == null)) { + registerControllerMethod(controllerClass, method, annotation) + } + } + } + + /** + * Registers an individual method from the IBotController + * + * @param controllerClass the Java class of the IBotController + * @param method the method to register + * @param annotation the BotCommand annotation object of the method + */ + private fun registerControllerMethod(controllerClass: Class<*>, method: Method, annotation: BotCommand) { + val commandName = if (annotation.name.isEmpty()) method.name.toLowerCase() else annotation.name + val usage = annotation.usage + val methodParameters = method.parameters + + if (methodParameters.isEmpty() || !methodParameters[0].type.isAssignableFrom(IEventWrapper::class.java)) return + + method.isAccessible = true + val parameters = (1..methodParameters.size - 1).mapTo(ArrayList>()) { methodParameters[it].type } + val isTagged: Boolean = method.getAnnotation(TaggedResponse::class.java) != null + val isPrivate: Boolean = method.getAnnotation(PrivateResponse::class.java) != null + val command = Command(commandName, usage, annotation.description, parameters, annotation.relayTriggerMessage, + annotation.ignoreExcessArguments, isTagged, isPrivate, controllerClass, method) + commands.put(command.name, command) + } + + /** + * Reads an incoming message and attempts to parse and execute a command. + * + * @param event the incoming event object + * @return false if a command execution attempt failed, true otherwise + */ + fun dispatchMessage(event: IEventWrapper): Boolean { + + // Short circuit if event was a Minecraft command + if (event is MinecraftCommandWrapper) { + val command = commands[event.command.name] + if (command == null) { + commandNotFound(event, event.command.name) + return true + } + val inputArguments = event.rawMessage.split("\\s+".toRegex(), command.parameters.size).toTypedArray() + return invokeCommand(command, controllers, event, inputArguments) + } + + // Short circuit scripted responses + if (scriptedResponse(event)) return true + + val args = event.rawMessage.trim().split("\\s+".toRegex(), 2).toTypedArray() + + // command + if (Config.COMMAND_PREFIX.isNotBlank() && args[0].startsWith(Config.COMMAND_PREFIX)) { + val commandName = args[0].substring(Config.COMMAND_PREFIX.length).toLowerCase() + if (commandName == "") return true + val command = commands[commandName] + + if (command == null) { + commandNotFound(event, commandName) + return false + } + + val inputArguments = if (args.size == 1) arrayOf() + else args[1].split("\\s+".toRegex(), command.parameters.size).toTypedArray() + + return invokeCommand(command, controllers, event, inputArguments) + } + + // @ command + if ((event is AsyncPlayerChatEventWrapper && args[0] == "@"+Config.USERNAME.noSpace() || + args[0] == Connection.JDA.selfUser.asMention) && args.count() == 2) { + val args2 = args[1].split("\\s+".toRegex(), 2).toTypedArray() + val commandName = args2[0].toLowerCase() + if (commandName == "") return true + var params = if (args2.size > 1) args2[1] else "" + var command = commands[commandName] + + if (command == null) { + // Assume user wants to talk to Cleverbot + command = commands["talk"] + if (command == null) { + commandNotFound(event, commandName) + return false + } + params = args[1] + } + + val inputArguments = if (params == "") arrayOf() + else params.split("\\s+".toRegex(), command.parameters.size).toTypedArray() + + return invokeCommand(command, controllers, event, inputArguments) + } + + // Just relay the message if it is neither + relay(event, true) + return true + } + + /** + * Looks for a scripted trigger and returns a respective response + * + * @param event the incoming event object + * @return true if a trigger was found and successfully responded to, false otherwise + */ + private fun scriptedResponse(event: IEventWrapper): Boolean { + val responses = plugin.script.data.getList("responses").checkItemsAre