├── .gitignore ├── LICENSE ├── README.md ├── build.gradle ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── jitpack.yml ├── settings.gradle └── src └── main └── kotlin └── me └── devoxin └── flight ├── FlightInfo.kt ├── api ├── CommandClient.kt ├── CommandClientBuilder.kt ├── CommandFunction.kt ├── DefaultHelpCommandConfig.kt ├── SubCommandFunction.kt ├── annotations │ ├── Autocomplete.kt │ ├── Choices.kt │ ├── Command.kt │ ├── Cooldown.kt │ ├── Describe.kt │ ├── Greedy.kt │ ├── GuildIds.kt │ ├── Name.kt │ ├── Range.kt │ ├── SubCommand.kt │ ├── Tentative.kt │ └── choice │ │ ├── DoubleChoice.kt │ │ ├── LongChoice.kt │ │ └── StringChoice.kt ├── arguments │ └── types │ │ ├── Emoji.kt │ │ ├── Invite.kt │ │ └── Snowflake.kt ├── context │ ├── Context.kt │ ├── ContextType.kt │ ├── MessageContext.kt │ └── SlashContext.kt ├── entities │ ├── Attachment.kt │ ├── BucketType.kt │ ├── CheckType.kt │ ├── Cog.kt │ ├── CommandRegistry.kt │ ├── CooldownProvider.kt │ ├── DSLMessageCreateBuilder.kt │ ├── DefaultCooldownProvider.kt │ ├── DefaultHelpCommand.kt │ ├── DefaultPrefixProvider.kt │ ├── ObjectStorage.kt │ └── PrefixProvider.kt ├── exceptions │ ├── BadArgument.kt │ └── ParserNotRegistered.kt └── hooks │ ├── CommandEventAdapter.kt │ └── DefaultCommandEventAdapter.kt └── internal ├── arguments ├── ArgParser.kt └── Argument.kt ├── entities ├── Executable.kt ├── Jar.kt └── WaitingEvent.kt ├── parsers ├── BooleanParser.kt ├── DoubleParser.kt ├── EmojiParser.kt ├── FloatParser.kt ├── IntParser.kt ├── InviteParser.kt ├── LongParser.kt ├── MemberParser.kt ├── Parser.kt ├── RoleParser.kt ├── SnowflakeParser.kt ├── StringParser.kt ├── TextChannelParser.kt ├── UrlParser.kt ├── UserParser.kt └── VoiceChannelParser.kt └── utils ├── Indexer.kt ├── Scheduler.kt ├── TextUtils.kt └── Tuple.kt /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | /build/ 3 | 4 | # Ignore Gradle GUI config 5 | gradle-app.setting 6 | 7 | # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) 8 | !gradle-wrapper.jar 9 | 10 | # Cache of project 11 | .gradletasknamecache 12 | 13 | # # Work around https://youtrack.jetbrains.com/issue/IDEA-116898 14 | # gradle/wrapper/gradle-wrapper.properties 15 | 16 | .idea/ 17 | out/ 18 | src/test/* 19 | flight.txt 20 | *.log -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Flight 2 | [![Release](https://jitpack.io/v/devoxin/flight.svg)](https://jitpack.io/#devoxin/flight) 3 | [![](https://jitci.com/gh/devoxin/flight/svg)](https://jitci.com/gh/devoxin/flight) 4 | 5 | Flight is a lightweight yet powerful command handler and arg parser for JDA, in Kotlin. It offers numerous utilities and functions for assisting, and speeding up Discord Bot development, allowing you to spend less time on the boring bits, and more on the important bits. 6 | 7 | ## Quick Start 8 | Using Flight is easy. The first thing you need to do before you can start diving in to bot making is to import it into your project. 9 | ```gradle 10 | repositories { 11 | maven { url 'https://jitpack.io' } 12 | } 13 | 14 | dependencies { 15 | compile 'com.github.Devoxin:Flight:VERSION' 16 | } 17 | ``` 18 | Make sure to replace `VERSION` with the latest Flight version. You can find a list of versions in the releases tab. 19 | 20 | ### Command Client 21 | Now that importing Flight is out of the way, we can begin constructing our command client. 22 | ```kotlin 23 | val commandClient = CommandClientBuilder() 24 | .setPrefixes("!", "?") // You can use multiple prefixes with Flight. 25 | .registerDefaultParsers() // This registers all default parsers that ship with Flight. This allows us to make use of Flight's arg-parsing capabilities. 26 | .build() // There is a lot more we can configure, but the default values are fine for our needs. 27 | 28 | // Each builder option returns the Builder instance. This makes chaining calls easy. 29 | ``` 30 | 31 | Now that we have our command client, we need to create our JDA client to connect our bot to Discord so we can start receiving and processing events. 32 | ```kotlin 33 | val jda = JDABuilder(yourBotToken) 34 | .addEventListeners(commandClient) // It's necessary to register our command client as an event listener so that it can process commands. This is also necessary for event waiting. 35 | .build() 36 | ``` 37 | 38 | ### Commands 39 | Now for the part you've been waiting for -- commands! 40 | Commands need to be contained inside a `Cog`. The cog serves not only as the container, but the category too -- so you can group commands based on what they do, for example; moderation. However we'll just be sticking with the basic `ping` command for now. 41 | ```kotlin 42 | package my.bot.commands // Your package name most likely won't be this, but this is included as it's necessary for registering commands with the command client. 43 | 44 | class YourCog : Cog { // "YourCog" will be used as the category name for commands within this cog. 45 | 46 | @Command(description = "Ping, pong!") 47 | fun ping(ctx: Context) { 48 | ctx.send("Pong!") 49 | } 50 | 51 | } 52 | ``` 53 | 54 | #### Subcommands 55 | Flight supports subcommands out-of-the-box, which can simplify command structure and parsing. Subcommands are functionally similar to commands, however there are a few restrictions. A subcommand's parent Cog **must** contain exactly **1** top-level command. Subcommands also inherit the permission requirements of their parent command. Subcommands **cannot** be attached to other subcommands. 56 | Top-level commands support an infinite number of subcommands. 57 | ```kotlin 58 | package my.bot.commands 59 | 60 | class SubcommandDemo : Cog { 61 | 62 | // The permission requirement seen below is enforced for the top-level command, and any subcommands it may have. 63 | @Command(description = "Configure bot settings", userPermissions = [Permission.MANAGE_SERVER]) 64 | fun settings(ctx: Context) { 65 | // This is the top-level command. It will only be invoked when a user does not specify a (valid) subcommand. 66 | ctx.send("Hey! You must specify a subcommand.\nAvailable subcommands:\n`prefix` `locale`") 67 | } 68 | 69 | @Subcommand(description = "Set the server prefix") 70 | fun prefix(ctx: Context, newPrefix: String) { 71 | Database.updatePrefix(ctx.guild!!.idLong, newPrefix) 72 | ctx.send("The prefix was changed to `$newPrefix`.") 73 | } 74 | 75 | @Subcommand(description = "Set the language of the bot for the server.") 76 | fun locale(ctx: Context, newLocale: String) { 77 | val selectedLocale = validateLocale(newLocale) 78 | ?: return ctx.send("That doesn't appear to be a valid locale.") 79 | Database.updateLocale(ctx.guild!!.idLong, selectedLocale) 80 | ctx.send("The locale was changed to ${selectedLocale.flag} `${selectedLocale.asIso}`.") 81 | } 82 | 83 | } 84 | ``` 85 | To execute our new subcommand, we would need to type `!settings prefix`. To pass a prefix, we just need to provide a second argument: `!settings prefix ?`. 86 | Change the `!` as necessary to match your bot's prefix. 87 | 88 | #### Registering 89 | You now have your first cog and command, all that's left to do is register it! This is as easy as the following: 90 | ```kotlin 91 | commandClient.registerCommands("my.bot.commands") 92 | ``` 93 | 94 | You will need to change `my.bot.commands` to reflect the actual package name of where your commands are stored. If done correctly, all cogs and commands contained within the `my.bot.commands` package will be scanned and registered ready for use. 95 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.github.johnrengelman.shadow' version '7.1.2' 3 | id 'org.jetbrains.kotlin.jvm' version '1.9.23' 4 | id 'maven-publish' 5 | } 6 | 7 | group 'me.devoxin' 8 | version '4.2.1' 9 | 10 | repositories { 11 | maven { 12 | url 'https://m2.dv8tion.net/releases' 13 | name 'm2-dv8tion' 14 | } 15 | mavenCentral() 16 | jcenter() 17 | maven { url 'https://jitpack.io' } 18 | } 19 | 20 | dependencies { 21 | def kotlinVersion = '1.9.23' 22 | def coroutinesVersion = '1.6.4' 23 | def jdaVersion = '5.3.2' 24 | 25 | implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion" 26 | implementation "org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:$coroutinesVersion" 27 | implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion" 28 | implementation 'org.reflections:reflections:0.10.2' 29 | compileOnly "net.dv8tion:JDA:$jdaVersion" 30 | api 'org.slf4j:slf4j-api:1.7.36' 31 | 32 | testImplementation "net.dv8tion:JDA:$jdaVersion" 33 | } 34 | 35 | compileKotlin { 36 | kotlinOptions.jvmTarget = '1.8' 37 | } 38 | 39 | String getBuildVersion() { 40 | def gitVersion = new ByteArrayOutputStream() 41 | exec { 42 | commandLine('git', 'rev-parse', '--short', 'HEAD') 43 | standardOutput = gitVersion 44 | } 45 | return "$version\n${gitVersion.toString().trim()}" 46 | } 47 | 48 | task writeVersion() { 49 | def resourcePath = sourceSets.main.resources.srcDirs[0] 50 | def resources = file(resourcePath) 51 | 52 | if (!resources.exists()) { 53 | resources.mkdirs() 54 | } 55 | 56 | file("$resourcePath/flight.txt").text = getBuildVersion() 57 | } 58 | 59 | publishing { 60 | publications { 61 | mavenJava(MavenPublication) { 62 | from components.java 63 | } 64 | } 65 | repositories { 66 | mavenLocal() 67 | } 68 | } 69 | 70 | task mavenPublishTask { 71 | doLast { 72 | publishToMavenLocal 73 | } 74 | } 75 | 76 | build { 77 | dependsOn writeVersion 78 | finalizedBy(mavenPublishTask) 79 | } 80 | 81 | shadowJar { 82 | dependsOn writeVersion 83 | finalizedBy(mavenPublishTask) 84 | } -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devoxin/Flight/f726485ab09034099e869e20fa67b7409f9f4f5b/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit 84 | 85 | APP_NAME="Gradle" 86 | APP_BASE_NAME=${0##*/} 87 | 88 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 89 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | 142 | # Increase the maximum file descriptors if we can. 143 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 144 | case $MAX_FD in #( 145 | max*) 146 | MAX_FD=$( ulimit -H -n ) || 147 | warn "Could not query maximum file descriptor limit" 148 | esac 149 | case $MAX_FD in #( 150 | '' | soft) :;; #( 151 | *) 152 | ulimit -n "$MAX_FD" || 153 | warn "Could not set maximum file descriptor limit to $MAX_FD" 154 | esac 155 | fi 156 | 157 | # Collect all arguments for the java command, stacking in reverse order: 158 | # * args from the command line 159 | # * the main class name 160 | # * -classpath 161 | # * -D...appname settings 162 | # * --module-path (only if needed) 163 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 164 | 165 | # For Cygwin or MSYS, switch paths to Windows format before running java 166 | if "$cygwin" || "$msys" ; then 167 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 168 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 169 | 170 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 171 | 172 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 173 | for arg do 174 | if 175 | case $arg in #( 176 | -*) false ;; # don't mess with options #( 177 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 178 | [ -e "$t" ] ;; #( 179 | *) false ;; 180 | esac 181 | then 182 | arg=$( cygpath --path --ignore --mixed "$arg" ) 183 | fi 184 | # Roll the args list around exactly as many times as the number of 185 | # args, so each arg winds up back in the position where it started, but 186 | # possibly modified. 187 | # 188 | # NB: a `for` loop captures its iteration list before it begins, so 189 | # changing the positional parameters here affects neither the number of 190 | # iterations, nor the values presented in `arg`. 191 | shift # remove old arg 192 | set -- "$@" "$arg" # push replacement arg 193 | done 194 | fi 195 | 196 | # Collect all arguments for the java command; 197 | # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of 198 | # shell script including quotes and variable substitutions, so put them in 199 | # double quotes to make sure that they get re-expanded; and 200 | # * put everything else in single quotes, so that it's not re-expanded. 201 | 202 | set -- \ 203 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 204 | -classpath "$CLASSPATH" \ 205 | org.gradle.wrapper.GradleWrapperMain \ 206 | "$@" 207 | 208 | # Use "xargs" to parse quoted args. 209 | # 210 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 211 | # 212 | # In Bash we could simply go: 213 | # 214 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 215 | # set -- "${ARGS[@]}" "$@" 216 | # 217 | # but POSIX shell has neither arrays nor command substitution, so instead we 218 | # post-process each arg (as a line of input to sed) to backslash-escape any 219 | # character that might be a shell metacharacter, then use eval to reverse 220 | # that process (while maintaining the separation between arguments), and wrap 221 | # the whole thing up as a single "set" statement. 222 | # 223 | # This will of course break if any of these variables contains a newline or 224 | # an unmatched quote. 225 | # 226 | 227 | eval "set -- $( 228 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 229 | xargs -n1 | 230 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 231 | tr '\n' ' ' 232 | )" '"$@"' 233 | 234 | exec "$JAVACMD" "$@" 235 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /jitpack.yml: -------------------------------------------------------------------------------- 1 | install: 2 | - ./gradlew clean :shadowJar publishToMavenLocal 3 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'flight' 2 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/FlightInfo.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight 2 | 3 | import java.io.InputStreamReader 4 | 5 | object FlightInfo { 6 | val VERSION: String 7 | val GIT_REVISION: String 8 | 9 | init { 10 | val stream = FlightInfo::class.java.classLoader.getResourceAsStream("flight.txt")!! 11 | val reader = InputStreamReader(stream).readText() 12 | val (buildVersion, buildRevision) = reader.split('\n') 13 | 14 | VERSION = buildVersion 15 | GIT_REVISION = buildRevision 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/api/CommandClient.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.api 2 | 3 | import me.devoxin.flight.api.annotations.GuildIds 4 | import me.devoxin.flight.api.context.Context 5 | import me.devoxin.flight.api.context.ContextType 6 | import me.devoxin.flight.api.context.MessageContext 7 | import me.devoxin.flight.api.context.SlashContext 8 | import me.devoxin.flight.api.entities.BucketType 9 | import me.devoxin.flight.api.entities.CheckType 10 | import me.devoxin.flight.internal.arguments.ArgParser 11 | import me.devoxin.flight.api.exceptions.BadArgument 12 | import me.devoxin.flight.internal.entities.WaitingEvent 13 | import me.devoxin.flight.api.entities.CooldownProvider 14 | import me.devoxin.flight.api.hooks.CommandEventAdapter 15 | import me.devoxin.flight.api.entities.PrefixProvider 16 | import me.devoxin.flight.api.entities.CommandRegistry 17 | import net.dv8tion.jda.api.entities.Message 18 | import net.dv8tion.jda.api.entities.channel.middleman.StandardGuildMessageChannel 19 | import net.dv8tion.jda.api.events.Event 20 | import net.dv8tion.jda.api.events.GenericEvent 21 | import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent 22 | import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent 23 | import net.dv8tion.jda.api.events.message.MessageReceivedEvent 24 | import net.dv8tion.jda.api.events.session.ReadyEvent 25 | import net.dv8tion.jda.api.hooks.EventListener 26 | import org.slf4j.LoggerFactory 27 | import java.util.concurrent.* 28 | import kotlin.collections.HashMap 29 | import kotlin.collections.HashSet 30 | import kotlin.reflect.KParameter 31 | 32 | class CommandClient( 33 | private val prefixProvider: PrefixProvider, 34 | val cooldownProvider: CooldownProvider, 35 | private val ignoreBots: Boolean, 36 | private val eventListeners: List, 37 | private val commandExecutor: ExecutorService?, 38 | val ownerIds: MutableSet 39 | ) : EventListener { 40 | private val waiterScheduler = Executors.newSingleThreadScheduledExecutor() 41 | private val pendingEvents = hashMapOf, HashSet>>() 42 | val commands = CommandRegistry() 43 | 44 | /** 45 | * Checks whether the provided [message] is a command. 46 | * 47 | * @param message 48 | * The message to check. 49 | * @return True, if the message is a command. 50 | */ 51 | fun isCommand(message: Message): Boolean { 52 | val prefixes = prefixProvider.provide(message) 53 | val trigger = prefixes.firstOrNull(message.contentRaw::startsWith) // This will break for repetitive prefixes 54 | ?: return false 55 | 56 | if (trigger.length == message.contentRaw.length) { 57 | return false 58 | } 59 | 60 | val args = message.contentRaw.substring(trigger.length).split(" +".toRegex()).toMutableList() 61 | val command = args.removeAt(0).lowercase() 62 | 63 | return (commands[command] ?: commands.findCommandByAlias(command)) != null 64 | } 65 | 66 | private fun onMessageReceived(event: MessageReceivedEvent) { 67 | if (ignoreBots && (event.author.isBot || event.isWebhookMessage)) { 68 | return 69 | } 70 | 71 | val prefixes = prefixProvider.provide(event.message) 72 | val trigger = prefixes.firstOrNull(event.message.contentRaw::startsWith) // This will break for repetitive prefixes 73 | ?: return 74 | 75 | if (trigger.length == event.message.contentRaw.length) { 76 | return 77 | } 78 | 79 | val args = event.message.contentRaw.substring(trigger.length).split(" +".toRegex()).toMutableList() 80 | val command = args.removeAt(0).lowercase() 81 | 82 | val cmd = commands[command] 83 | ?: commands.values.firstOrNull { it.properties.aliases.contains(command) } 84 | ?: return dispatchSafely { it.onUnknownCommand(event, command, args) } 85 | 86 | val subcommand = args.firstOrNull()?.lowercase().let { cmd.subcommands[it] } 87 | val invoked = subcommand ?: cmd 88 | 89 | if (subcommand != null) { 90 | args.removeAt(0) 91 | } 92 | 93 | val ctx = MessageContext(this, event, trigger, invoked) 94 | 95 | if (isOnCooldown(cmd, ctx)) { // This function dispatches the event. 96 | return 97 | } 98 | 99 | if (!shouldExecuteCommand(ctx, cmd)) { 100 | return 101 | } 102 | 103 | val arguments: HashMap 104 | 105 | try { 106 | arguments = ArgParser.parseArguments(invoked, ctx, args, cmd.properties.argDelimiter) 107 | } catch (e: BadArgument) { 108 | return dispatchSafely { it.onBadArgument(ctx, cmd, e) } 109 | } catch (e: Throwable) { 110 | return dispatchSafely { it.onParseError(ctx, cmd, e) } 111 | } 112 | 113 | val cb = { success: Boolean, err: Throwable? -> 114 | if (err != null) { 115 | val handled = cmd.cog.onCommandError(ctx, cmd, err) 116 | 117 | if (!handled) { 118 | dispatchSafely { it.onCommandError(ctx, cmd, err) } 119 | } 120 | } 121 | 122 | dispatchSafely { it.onCommandPostInvoke(ctx, cmd, !success) } 123 | } 124 | 125 | setCooldown(cmd, ctx) 126 | invoked.execute(ctx, arguments, cb, commandExecutor) 127 | } 128 | 129 | private fun onSlashCommand(event: SlashCommandInteractionEvent) { 130 | val cmd = commands[event.name] ?: return 131 | val subcommand = event.subcommandName?.let { cmd.subcommands[it] ?: return } 132 | val invoked = subcommand ?: cmd 133 | val ctx = SlashContext(this, event, invoked) 134 | 135 | if (isOnCooldown(cmd, ctx)) { 136 | return 137 | } 138 | 139 | if (!shouldExecuteCommand(ctx, cmd)) { 140 | return 141 | } 142 | 143 | val arguments = invoked.resolveArguments(event.options) 144 | val cb = { success: Boolean, err: Throwable? -> 145 | if (err != null) { 146 | val handled = cmd.cog.onCommandError(ctx, cmd, err) 147 | 148 | if (!handled) { 149 | dispatchSafely { it.onCommandError(ctx, cmd, err) } 150 | } 151 | } 152 | 153 | dispatchSafely { it.onCommandPostInvoke(ctx, cmd, !success) } 154 | } 155 | 156 | setCooldown(cmd, ctx) 157 | invoked.execute(ctx, arguments, cb, null) 158 | } 159 | 160 | private fun onAutocomplete(event: CommandAutoCompleteInteractionEvent) { 161 | val commandName = event.name 162 | val subcommandName = event.subcommandName 163 | 164 | val command = commands[commandName] 165 | ?: return 166 | 167 | val subcommand = subcommandName?.let { command.subcommands[subcommandName] ?: return } 168 | 169 | val executable = subcommand ?: command 170 | val argument = executable.arguments.find { it.slashFriendlyName == event.focusedOption.name } 171 | ?: return 172 | 173 | val cb = { err: Throwable? -> 174 | if (err != null) { 175 | dispatchSafely { it.onAutocompleteError(event, err) } 176 | } 177 | } 178 | 179 | argument.executeAutocomplete(event, cb, commandExecutor) 180 | } 181 | 182 | 183 | // +-------------------+ 184 | // | Execution-Related | 185 | // +-------------------+ 186 | override fun onEvent(event: GenericEvent) { 187 | onGenericEvent(event) 188 | 189 | try { 190 | when (event) { 191 | is ReadyEvent -> onReady(event) 192 | is MessageReceivedEvent -> onMessageReceived(event) 193 | is SlashCommandInteractionEvent -> onSlashCommand(event) 194 | is CommandAutoCompleteInteractionEvent -> onAutocomplete(event) 195 | //else -> println(event) 196 | } 197 | } catch (e: Throwable) { 198 | dispatchSafely { it.onInternalError(e) } 199 | } 200 | } 201 | 202 | private fun onReady(event: ReadyEvent) { 203 | if (ownerIds.isEmpty()) { 204 | event.jda.retrieveApplicationInfo().queue { 205 | ownerIds.add(it.owner.idLong) 206 | } 207 | } 208 | } 209 | 210 | private fun onGenericEvent(event: GenericEvent) { 211 | val events = pendingEvents[event::class.java] ?: return 212 | val passed = events.filter { it.check(event) } 213 | 214 | events.removeAll(passed) 215 | passed.forEach { it.accept(event) } 216 | } 217 | 218 | inline fun waitFor(noinline predicate: (T) -> Boolean, timeout: Long): CompletableFuture { 219 | return waitFor(T::class.java, predicate, timeout) 220 | } 221 | 222 | fun waitFor(event: Class, predicate: (T) -> Boolean, timeout: Long): CompletableFuture { 223 | val future = CompletableFuture() 224 | val we = WaitingEvent(event, predicate, future) 225 | 226 | val set = pendingEvents.computeIfAbsent(event) { hashSetOf() } 227 | set.add(we) 228 | 229 | if (timeout > 0) { 230 | waiterScheduler.schedule({ 231 | if (!future.isDone) { 232 | future.completeExceptionally(TimeoutException()) 233 | set.remove(we) 234 | } 235 | }, timeout, TimeUnit.MILLISECONDS) 236 | } 237 | 238 | return future 239 | } 240 | 241 | private fun dispatchSafely(invoker: (CommandEventAdapter) -> Unit) { 242 | try { 243 | eventListeners.forEach(invoker) 244 | } catch (e: Throwable) { 245 | try { 246 | eventListeners.forEach { it.onInternalError(e) } 247 | } catch (inner: Throwable) { 248 | log.error("An uncaught exception occurred during event dispatch!", inner) 249 | } 250 | } 251 | } 252 | 253 | private fun shouldExecuteCommand(ctx: Context, cmd: CommandFunction): Boolean { 254 | val props = cmd.properties 255 | 256 | val contextType = ctx.contextType 257 | if (cmd.contextType != contextType && cmd.contextType != ContextType.MESSAGE_OR_SLASH) { 258 | dispatchSafely { it.onCheckFailed(ctx, cmd, CheckType.EXECUTION_CONTEXT) } 259 | return false 260 | } 261 | 262 | if (props.developerOnly && !ownerIds.contains(ctx.author.idLong)) { 263 | dispatchSafely { it.onCheckFailed(ctx, cmd, CheckType.DEVELOPER_CHECK) } 264 | return false 265 | } 266 | 267 | if (ctx.isFromGuild) { 268 | if (props.userPermissions.isNotEmpty()) { 269 | val userCheck = props.userPermissions.filterNot { ctx.member!!.hasPermission(ctx.guildChannel!!, it) } 270 | 271 | if (userCheck.isNotEmpty()) { 272 | dispatchSafely { it.onUserMissingPermissions(ctx, cmd, userCheck) } 273 | return false 274 | } 275 | } 276 | 277 | if (props.botPermissions.isNotEmpty()) { 278 | val botCheck = props.botPermissions.filterNot { ctx.guild!!.selfMember.hasPermission(ctx.guildChannel!!, it) } 279 | 280 | if (botCheck.isNotEmpty()) { 281 | dispatchSafely { it.onBotMissingPermissions(ctx, cmd, botCheck) } 282 | return false 283 | } 284 | } 285 | 286 | if (props.nsfw && (ctx.guildChannel as? StandardGuildMessageChannel)?.isNSFW != true) { 287 | dispatchSafely { it.onCheckFailed(ctx, cmd, CheckType.NSFW_CHECK) } 288 | return false 289 | } 290 | } else { 291 | val subcommandProperties = (ctx.invokedCommand as? SubCommandFunction)?.properties 292 | 293 | if (props.guildOnly || subcommandProperties?.guildOnly == true) { 294 | dispatchSafely { it.onCheckFailed(ctx, cmd, CheckType.GUILD_CHECK) } 295 | return false 296 | } 297 | } 298 | 299 | return eventListeners.all { it.onCommandPreInvoke(ctx, cmd) } 300 | && cmd.cog.localCheck(ctx, cmd) 301 | } 302 | 303 | private fun isOnCooldown(cmd: CommandFunction, ctx: Context): Boolean { 304 | if (cmd.cooldown != null) { 305 | val entityId = when (cmd.cooldown.bucket) { 306 | BucketType.USER -> ctx.author.idLong 307 | BucketType.GUILD -> ctx.guild?.idLong //?: ctx.messageChannel.idLong 308 | BucketType.GLOBAL -> -1 309 | } 310 | 311 | if (entityId != null) { 312 | if (cooldownProvider.isOnCooldown(entityId, cmd.cooldown.bucket, cmd)) { 313 | val time = cooldownProvider.getCooldownTime(entityId, cmd.cooldown.bucket, cmd) 314 | dispatchSafely { it.onCommandCooldown(ctx, cmd, time) } 315 | 316 | return true 317 | } 318 | } 319 | } 320 | 321 | return false 322 | } 323 | 324 | private fun setCooldown(cmd: CommandFunction, ctx: Context) { 325 | if (cmd.cooldown != null && cmd.cooldown.duration > 0) { 326 | val entityId = when (cmd.cooldown.bucket) { 327 | BucketType.USER -> ctx.author.idLong 328 | BucketType.GUILD -> ctx.guild?.idLong 329 | BucketType.GLOBAL -> -1 330 | } 331 | 332 | if (entityId != null) { 333 | val time = cmd.cooldown.timeUnit.toMillis(cmd.cooldown.duration) 334 | cooldownProvider.setCooldown(entityId, cmd.cooldown.bucket, time, cmd) 335 | } 336 | } 337 | } 338 | 339 | companion object { 340 | private val log = LoggerFactory.getLogger(CommandClient::class.java) 341 | 342 | fun builder() = CommandClientBuilder() 343 | 344 | fun create(config: CommandClientBuilder.() -> Unit): CommandClient { 345 | return CommandClientBuilder().apply(config).build() 346 | } 347 | } 348 | } 349 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/api/CommandClientBuilder.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.api 2 | 3 | import me.devoxin.flight.api.arguments.types.Emoji 4 | import me.devoxin.flight.api.arguments.types.Invite 5 | import me.devoxin.flight.api.arguments.types.Snowflake 6 | import me.devoxin.flight.api.entities.* 7 | import me.devoxin.flight.api.hooks.CommandEventAdapter 8 | import me.devoxin.flight.api.hooks.DefaultCommandEventAdapter 9 | import me.devoxin.flight.internal.arguments.ArgParser 10 | import me.devoxin.flight.internal.parsers.* 11 | import me.devoxin.flight.internal.parsers.TextChannelParser 12 | import me.devoxin.flight.internal.parsers.VoiceChannelParser 13 | import net.dv8tion.jda.api.entities.Member 14 | import net.dv8tion.jda.api.entities.Role 15 | import net.dv8tion.jda.api.entities.User 16 | import net.dv8tion.jda.api.entities.channel.concrete.TextChannel 17 | import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel 18 | import java.net.URL 19 | import java.util.concurrent.ExecutorService 20 | 21 | class CommandClientBuilder { 22 | private var prefixes: List = emptyList() 23 | private var allowMentionPrefix: Boolean = true 24 | private var helpCommandConfig: DefaultHelpCommandConfig = DefaultHelpCommandConfig() 25 | private var ignoreBots: Boolean = true 26 | private var prefixProvider: PrefixProvider? = null 27 | private var cooldownProvider: CooldownProvider? = null 28 | private var eventListeners: MutableList = mutableListOf() 29 | private var commandExecutor: ExecutorService? = null 30 | private val ownerIds: MutableSet = mutableSetOf() 31 | 32 | 33 | /** 34 | * Strings that messages must start with to trigger the bot. 35 | * 36 | * @return The builder instance. Useful for chaining. 37 | */ 38 | fun setPrefixes(prefixes: List): CommandClientBuilder { 39 | this.prefixes = prefixes 40 | return this 41 | } 42 | 43 | /** 44 | * Strings that messages must start with to trigger the bot. 45 | * 46 | * @return The builder instance. Useful for chaining. 47 | */ 48 | fun setPrefixes(vararg prefixes: String): CommandClientBuilder { 49 | this.prefixes = prefixes.toList() 50 | return this 51 | } 52 | 53 | /** 54 | * Sets the provider used for obtaining prefixes 55 | */ 56 | fun setPrefixProvider(provider: PrefixProvider): CommandClientBuilder { 57 | this.prefixProvider = provider 58 | return this 59 | } 60 | 61 | /** 62 | * Sets the provider used for cool-downs. 63 | */ 64 | fun setCooldownProvider(provider: CooldownProvider): CommandClientBuilder { 65 | this.cooldownProvider = provider 66 | return this 67 | } 68 | 69 | /** 70 | * Whether the bot will allow mentions to be used as a prefix. 71 | * 72 | * @return The builder instance. Useful for chaining. 73 | */ 74 | fun setAllowMentionPrefix(allowMentionPrefix: Boolean): CommandClientBuilder { 75 | this.allowMentionPrefix = allowMentionPrefix 76 | return this 77 | } 78 | 79 | /** 80 | * Whether the default help command should be used or not. 81 | * 82 | * @return The builder instance. Useful for chaining. 83 | */ 84 | fun configureDefaultHelpCommand(config: DefaultHelpCommandConfig.() -> Unit): CommandClientBuilder { 85 | config(helpCommandConfig) 86 | return this 87 | } 88 | 89 | /** 90 | * Whether bots and webhooks should be ignored. The recommended option is true to prevent feedback loops. 91 | * 92 | * @return The builder instance. Useful for chaining. 93 | */ 94 | fun setIgnoreBots(ignoreBots: Boolean): CommandClientBuilder { 95 | this.ignoreBots = ignoreBots 96 | return this 97 | } 98 | 99 | /** 100 | * Uses the given list of IDs as the owners. Any users with the given IDs 101 | * are then able to use commands marked with `developerOnly`. 102 | * 103 | * @return The builder instance. Useful for chaining. 104 | */ 105 | fun setOwnerIds(vararg ids: Long): CommandClientBuilder { 106 | this.ownerIds.clear() 107 | this.ownerIds.addAll(ids.toTypedArray()) 108 | return this 109 | } 110 | 111 | /** 112 | * Uses the given list of IDs as the owners. Any users with the given IDs 113 | * are then able to use commands marked with `developerOnly`. 114 | * 115 | * @return The builder instance. Useful for chaining. 116 | */ 117 | fun setOwnerIds(vararg ids: String): CommandClientBuilder { 118 | this.ownerIds.clear() 119 | this.ownerIds.addAll(ids.map(String::toLong)) 120 | return this 121 | } 122 | 123 | /** 124 | * Registers the provided listeners to make use of hooks 125 | * 126 | * @return The builder instance. Useful for chaining. 127 | */ 128 | fun addEventListeners(vararg listeners: CommandEventAdapter): CommandClientBuilder { 129 | this.eventListeners.addAll(listeners) 130 | return this 131 | } 132 | 133 | /** 134 | * Registers an argument parser to the given class. 135 | * 136 | * @return The builder instance. Useful for chaining. 137 | */ 138 | fun addCustomParser(klass: Class<*>, parser: Parser<*>): CommandClientBuilder { 139 | // This is kinda unsafe. Would use T, but nullable/boxed types revert 140 | // to their java.lang counterparts. E.g. Int? becomes java.lang.Integer, 141 | // but Int remains kotlin.Int. 142 | // See https://youtrack.jetbrains.com/issue/KT-35423 143 | 144 | ArgParser.parsers[klass] = parser 145 | return this 146 | } 147 | 148 | inline fun addCustomParser(parser: Parser) = addCustomParser(T::class.java, parser) 149 | 150 | /** 151 | * Registers all default argument parsers. 152 | * 153 | * @return The builder instance. Useful for chaining. 154 | */ 155 | fun registerDefaultParsers(): CommandClientBuilder { 156 | // Kotlin types and primitives 157 | val booleanParser = BooleanParser() 158 | ArgParser.parsers[Boolean::class.java] = booleanParser 159 | ArgParser.parsers[java.lang.Boolean::class.java] = booleanParser 160 | 161 | val doubleParser = DoubleParser() 162 | ArgParser.parsers[Double::class.java] = doubleParser 163 | ArgParser.parsers[java.lang.Double::class.java] = doubleParser 164 | 165 | val floatParser = FloatParser() 166 | ArgParser.parsers[Float::class.java] = floatParser 167 | ArgParser.parsers[java.lang.Float::class.java] = floatParser 168 | 169 | val intParser = IntParser() 170 | ArgParser.parsers[Int::class.java] = intParser 171 | ArgParser.parsers[java.lang.Integer::class.java] = intParser 172 | 173 | val longParser = LongParser() 174 | ArgParser.parsers[Long::class.java] = longParser 175 | ArgParser.parsers[java.lang.Long::class.java] = longParser 176 | 177 | // JDA entities 178 | val inviteParser = InviteParser() 179 | ArgParser.parsers[Invite::class.java] = inviteParser 180 | ArgParser.parsers[net.dv8tion.jda.api.entities.Invite::class.java] = inviteParser 181 | 182 | ArgParser.parsers[Member::class.java] = MemberParser() 183 | ArgParser.parsers[Role::class.java] = RoleParser() 184 | ArgParser.parsers[TextChannel::class.java] = TextChannelParser() 185 | ArgParser.parsers[User::class.java] = UserParser() 186 | ArgParser.parsers[VoiceChannel::class.java] = VoiceChannelParser() 187 | 188 | // Custom entities 189 | ArgParser.parsers[Emoji::class.java] = EmojiParser() 190 | ArgParser.parsers[String::class.java] = StringParser() 191 | ArgParser.parsers[Snowflake::class.java] = SnowflakeParser() 192 | ArgParser.parsers[URL::class.java] = UrlParser() 193 | 194 | return this 195 | } 196 | 197 | /** 198 | * Sets the thread pool used for executing commands. 199 | * 200 | * @param executorPool 201 | * The pool to use. If null is given, commands will be executed on the WebSocket thread. 202 | * 203 | * @return The builder instance, useful for chaining. 204 | */ 205 | fun setExecutionThreadPool(executorPool: ExecutorService?): CommandClientBuilder { 206 | this.commandExecutor = executorPool 207 | return this 208 | } 209 | 210 | /** 211 | * Builds a new CommandClient instance 212 | * 213 | * @return a CommandClient instance 214 | */ 215 | fun build(): CommandClient { 216 | if (eventListeners.isEmpty()) { 217 | eventListeners.add(DefaultCommandEventAdapter()) 218 | } 219 | 220 | val prefixProvider = this.prefixProvider ?: DefaultPrefixProvider(prefixes, allowMentionPrefix) 221 | val cooldownProvider = this.cooldownProvider ?: DefaultCooldownProvider() 222 | val commandClient = CommandClient(prefixProvider, cooldownProvider, ignoreBots, eventListeners.toList(), 223 | commandExecutor, ownerIds) 224 | 225 | if (helpCommandConfig.enabled) { 226 | commandClient.commands.register(DefaultHelpCommand(helpCommandConfig.showParameterTypes)) 227 | } 228 | 229 | return commandClient 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/api/CommandFunction.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.api 2 | 3 | import me.devoxin.flight.api.annotations.Command 4 | import me.devoxin.flight.api.annotations.Cooldown 5 | import me.devoxin.flight.api.context.ContextType 6 | import me.devoxin.flight.api.context.MessageContext 7 | import me.devoxin.flight.api.context.SlashContext 8 | import me.devoxin.flight.internal.arguments.Argument 9 | import me.devoxin.flight.internal.entities.Jar 10 | import me.devoxin.flight.api.entities.Cog 11 | import me.devoxin.flight.internal.entities.Executable 12 | import kotlin.reflect.KFunction 13 | import kotlin.reflect.KParameter 14 | import kotlin.reflect.full.* 15 | 16 | class CommandFunction( 17 | name: String, 18 | val category: String, 19 | val properties: Command, 20 | val cooldown: Cooldown?, 21 | val jar: Jar?, 22 | 23 | subCmds: List, 24 | // Executable properties 25 | method: KFunction<*>, 26 | cog: Cog, 27 | arguments: List, 28 | contextParameter: KParameter 29 | ) : Executable(name, method, cog, arguments, contextParameter) { 30 | val contextType: ContextType 31 | val subcommands = hashMapOf() 32 | 33 | @Deprecated("Use #subcommands with a mapping/filter function.") 34 | val subcommandAliases: Map 35 | get() = subcommands.values.flatMap { it.properties.aliases.map { a -> a to it } } 36 | .associateBy({ it.first }) { it.second } 37 | 38 | init { 39 | val jvmCtx = contextParameter.type 40 | 41 | contextType = when { 42 | jvmCtx.isSubtypeOf(SlashContext::class.starProjectedType) -> ContextType.SLASH 43 | jvmCtx.isSubtypeOf(MessageContext::class.starProjectedType) -> ContextType.MESSAGE 44 | else -> ContextType.MESSAGE_OR_SLASH 45 | } 46 | 47 | for (sc in subCmds) { 48 | subcommands[sc.name] = sc 49 | 50 | for (trigger in sc.properties.aliases) { 51 | val existing = subcommands[trigger] 52 | 53 | if (existing != null) { 54 | throw IllegalStateException("The trigger '$trigger' for sub-command '${sc.name}' within command '$name' is already assigned to '${existing.name}'!") 55 | } 56 | 57 | subcommands[trigger] = sc 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/api/DefaultHelpCommandConfig.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.api 2 | 3 | data class DefaultHelpCommandConfig( 4 | var enabled: Boolean = true, 5 | var showParameterTypes: Boolean = false 6 | ) -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/api/SubCommandFunction.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.api 2 | 3 | import me.devoxin.flight.api.annotations.SubCommand 4 | import me.devoxin.flight.api.entities.Cog 5 | import me.devoxin.flight.internal.arguments.Argument 6 | import me.devoxin.flight.internal.entities.Executable 7 | import kotlin.reflect.KFunction 8 | import kotlin.reflect.KParameter 9 | 10 | class SubCommandFunction( 11 | name: String, 12 | val properties: SubCommand, 13 | // Executable properties 14 | method: KFunction<*>, 15 | cog: Cog, 16 | arguments: List, 17 | contextParameter: KParameter 18 | ) : Executable(name, method, cog, arguments, contextParameter) 19 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/api/annotations/Autocomplete.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.api.annotations 2 | 3 | @Retention(AnnotationRetention.RUNTIME) 4 | @Target(AnnotationTarget.VALUE_PARAMETER) 5 | annotation class Autocomplete( 6 | /** 7 | * AUTOCOMPLETE HANDLERS MUST HAVE THE PARAMETER: 8 | * event: CommandAutoCompleteInteractionEvent 9 | */ 10 | 11 | // The name of the method (in the same cog) that will handle autocomplete requests 12 | // for this parameter. 13 | val method: String 14 | ) 15 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/api/annotations/Choices.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.api.annotations 2 | 3 | import me.devoxin.flight.api.annotations.choice.DoubleChoice 4 | import me.devoxin.flight.api.annotations.choice.LongChoice 5 | import me.devoxin.flight.api.annotations.choice.StringChoice 6 | 7 | /** 8 | * The choices for the argument. 9 | * Fill in the required type as needed. All parameters are mutually exclusive, 10 | * and only one may be specified per argument. 11 | * Example: 12 | * @Choices(string = [StringChoice("Test", "hello world")]) 13 | */ 14 | @Retention(AnnotationRetention.RUNTIME) 15 | @Target(AnnotationTarget.VALUE_PARAMETER) 16 | annotation class Choices( 17 | val long: Array = [], 18 | val double: Array = [], 19 | val string: Array = [] 20 | ) 21 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/api/annotations/Command.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.api.annotations 2 | 3 | import net.dv8tion.jda.api.Permission 4 | 5 | /** 6 | * Marks a function as a command. This should be used to annotate methods within a cog 7 | * as commands, so that the scanner can detect them, and register them. 8 | */ 9 | @Retention(AnnotationRetention.RUNTIME) 10 | @Target(AnnotationTarget.FUNCTION) 11 | annotation class Command( 12 | // The character to use to delimit arguments. 13 | val argDelimiter: Char = ' ', 14 | // Any alternative triggers for the command. The name of the command need not be listed here. 15 | val aliases: Array = [], 16 | // The command description. This will be shown in the help command. 17 | val description: String = "No description available", 18 | // Whether this command can only be invoked by developers (IDs listed in CommandClient.ownerIds) 19 | val developerOnly: Boolean = false, 20 | // Any permissions the user needs to execute this command. 21 | val userPermissions: Array = [], 22 | // Any permissions the bot needs to execute this command. 23 | val botPermissions: Array = [], 24 | // Whether this command is NSFW or not. 25 | val nsfw: Boolean = false, 26 | // Whether this command should only be executed within guilds. 27 | val guildOnly: Boolean = false, 28 | // Whether to show this command in the help menu. 29 | val hidden: Boolean = false 30 | ) 31 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/api/annotations/Cooldown.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.api.annotations 2 | 3 | import me.devoxin.flight.api.entities.BucketType 4 | import java.util.concurrent.TimeUnit 5 | 6 | /** 7 | * Sets a cooldown on the command. 8 | */ 9 | @Retention(AnnotationRetention.RUNTIME) 10 | @Target(AnnotationTarget.FUNCTION) 11 | annotation class Cooldown( 12 | /** How long the cool-down lasts. */ 13 | val duration: Long, 14 | /** The time unit of the duration. */ 15 | val timeUnit: TimeUnit = TimeUnit.MILLISECONDS, 16 | /** The bucket this cool-down applies to. */ 17 | val bucket: BucketType 18 | ) 19 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/api/annotations/Describe.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.api.annotations 2 | 3 | /** 4 | * Describes an argument for a command. 5 | * This is only used by [me.devoxin.flight.internal.entities.CommandRegistry.toDiscordCommands]. 6 | */ 7 | @Retention(AnnotationRetention.RUNTIME) 8 | @Target(AnnotationTarget.VALUE_PARAMETER) 9 | annotation class Describe( 10 | val value: String = "" 11 | ) 12 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/api/annotations/Greedy.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.api.annotations 2 | 3 | /** 4 | * Marks an argument as greedy. 5 | * By default, arguments are split by spaces, and are consumed on a single-word basis. 6 | * If quotations are present, then the content within the arguments is parsed and used instead. 7 | * 8 | * This annotation tells the parser to consume all remaining arguments. This can be useful in situations 9 | * where you want to parse members, or a welcome message (for example) and don't want users to have to quote arguments. 10 | * 11 | * For example: 12 | * fun welcomemessage(ctx: Context, message: String) 13 | * 14 | * "!welcomemessage hello there" 15 | * The content of "message" will be "hello". 16 | * 17 | * fun welcomemessage(ctx: Context, @Greedy message: String) 18 | * 19 | * "!welcomemessage hello there" 20 | * The content of "message" will be "hello there". 21 | */ 22 | @Retention(AnnotationRetention.RUNTIME) 23 | @Target(AnnotationTarget.VALUE_PARAMETER) 24 | annotation class Greedy 25 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/api/annotations/GuildIds.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.api.annotations 2 | 3 | /** 4 | * The GuildIds that this command may be run within. 5 | * For slash commands, this will assign the command as a guild command. 6 | * For message commands, this will be enforced via a check before a command is executed. 7 | */ 8 | @Retention(AnnotationRetention.RUNTIME) 9 | @Target(AnnotationTarget.FUNCTION) 10 | annotation class GuildIds( 11 | val value: LongArray 12 | ) 13 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/api/annotations/Name.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.api.annotations 2 | 3 | /** 4 | * Sets the name of a command argument. 5 | * This is redundant as Flight should pick up argument names automatically. This should only 6 | * be used as a last resort, or if a different name for the argument is needed. 7 | */ 8 | @Retention(AnnotationRetention.RUNTIME) 9 | @Target(AnnotationTarget.VALUE_PARAMETER) 10 | annotation class Name( 11 | val value: String 12 | ) 13 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/api/annotations/Range.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.api.annotations 2 | 3 | /** 4 | * The required range for the argument. 5 | * Fill in the required type as needed. All parameters are mutually exclusive, 6 | * and only one may be specified per argument. 7 | * Example: 8 | * @Range(double = [0.0, 5.0]) 9 | * The first number represents the MINIMUM range. The second represents the MAXIMUM range. 10 | * If a maximum range is not needed, specify [ number ] 11 | */ 12 | @Retention(AnnotationRetention.RUNTIME) 13 | @Target(AnnotationTarget.VALUE_PARAMETER) 14 | annotation class Range( 15 | val long: LongArray = [], // e.g. 0, 5 16 | val double: DoubleArray = [], // e.g. 0.0, 5.0 17 | val string: IntArray = [] 18 | ) 19 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/api/annotations/SubCommand.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.api.annotations 2 | 3 | /** 4 | * Marks a function as subcommand. 5 | * 6 | * Subcommands cannot co-exist with multiple parent commands (marked with @Command). 7 | * If a cog contains multiple parent commands, and any subcommands, an exception will be thrown. 8 | * 9 | * Ideally, commands that have subcommands should be separated into their own cogs. 10 | */ 11 | @Retention(AnnotationRetention.RUNTIME) 12 | @Target(AnnotationTarget.FUNCTION) 13 | annotation class SubCommand( 14 | val aliases: Array = [], 15 | val description: String = "No description available", 16 | val guildOnly: Boolean = false 17 | ) 18 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/api/annotations/Tentative.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.api.annotations 2 | 3 | /** 4 | * Marks an argument as tentative. 5 | * When parsing fails, parsing/execution will continue rather than throwing. 6 | * Additionally, the default value, or null, will be passed in place of the value. 7 | * 8 | * Arguments utilising this annotation should be marked nullable, or have a default specified. 9 | * 10 | * Example: 11 | * fun ban(ctx: Context, member: Member, @Tentative deleteDays: Int = 7, @Greedy reason: String) 12 | * 13 | * This usage style allows command invocations such as: 14 | * !ban user#0000 Violated rule 3. 15 | * !ban user#0000 0 Violated rule 3. 16 | * 17 | * This annotation is redundant for String parameters, due to the way argument parsing works. 18 | * It's for this reason, that such arguments should be placed after other types if possible. 19 | */ 20 | @Retention(AnnotationRetention.RUNTIME) 21 | @Target(AnnotationTarget.VALUE_PARAMETER) 22 | annotation class Tentative 23 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/api/annotations/choice/DoubleChoice.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.api.annotations.choice 2 | 3 | annotation class DoubleChoice( 4 | /** The string shown to the user. **/ 5 | val key: String, 6 | /** The received value. **/ 7 | val value: Double 8 | ) 9 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/api/annotations/choice/LongChoice.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.api.annotations.choice 2 | 3 | annotation class LongChoice( 4 | /** The string shown to the user. **/ 5 | val key: String, 6 | /** The received value. **/ 7 | val value: Long 8 | ) 9 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/api/annotations/choice/StringChoice.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.api.annotations.choice 2 | 3 | annotation class StringChoice( 4 | /** The string shown to the user. **/ 5 | val key: String, 6 | /** The received value. **/ 7 | val value: String 8 | ) 9 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/api/arguments/types/Emoji.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.api.arguments.types 2 | 3 | import net.dv8tion.jda.api.entities.emoji.CustomEmoji 4 | 5 | class Emoji(val name: String, val id: Long, val animated: Boolean) { 6 | val url: String 7 | get() { 8 | val extension = if (animated) "gif" else "png" 9 | return CustomEmoji.ICON_URL.format(id, extension) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/api/arguments/types/Invite.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.api.arguments.types 2 | 3 | import kotlinx.coroutines.future.await 4 | import net.dv8tion.jda.api.JDA 5 | import net.dv8tion.jda.api.entities.Invite 6 | 7 | class Invite( 8 | private val jda: JDA, 9 | val url: String, 10 | val code: String 11 | ) { 12 | fun resolve(withCounts: Boolean = false) = Invite.resolve(jda, code, withCounts) 13 | 14 | suspend fun resolveAsync(withCounts: Boolean = false) = Invite.resolve(jda, code, withCounts).submit().await() 15 | } 16 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/api/arguments/types/Snowflake.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.api.arguments.types 2 | 3 | class Snowflake(val resolved: Long) 4 | // Exists solely for the snowflake parser. 5 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/api/context/Context.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.api.context 2 | 3 | import me.devoxin.flight.api.CommandClient 4 | import me.devoxin.flight.api.entities.DSLMessageCreateBuilder 5 | import me.devoxin.flight.internal.entities.Executable 6 | import net.dv8tion.jda.api.JDA 7 | import net.dv8tion.jda.api.entities.Guild 8 | import net.dv8tion.jda.api.entities.Member 9 | import net.dv8tion.jda.api.entities.User 10 | import net.dv8tion.jda.api.entities.channel.middleman.GuildMessageChannel 11 | import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel 12 | import net.dv8tion.jda.api.events.Event 13 | import net.dv8tion.jda.api.utils.FileUpload 14 | import net.dv8tion.jda.api.utils.messages.MessageCreateData 15 | import java.util.concurrent.CompletableFuture 16 | 17 | interface Context { 18 | val invokedCommand: Executable 19 | val contextType: ContextType 20 | 21 | /** 22 | * The current Context instance as a MessageContext instance, 23 | * if contextType is [ContextType.MESSAGE], otherwise null. 24 | */ 25 | val asMessageContext: MessageContext? 26 | get() = this as? MessageContext 27 | /** 28 | * The current Context instance as a SlashContext instance, 29 | * if contextType is [ContextType.SLASH], otherwise null. 30 | */ 31 | val asSlashContext: SlashContext? 32 | get() = this as? SlashContext 33 | 34 | val commandClient: CommandClient 35 | val jda: JDA 36 | val author: User 37 | val guild: Guild? 38 | val member: Member? 39 | val messageChannel: MessageChannel 40 | val guildChannel: GuildMessageChannel? 41 | val isFromGuild: Boolean 42 | 43 | /** 44 | * Sends "Bot is thinking..." for slash commands, or a typing indicator for message commands. 45 | * 46 | * @param ephemeral 47 | * Whether the response should only be seen by the invoking user. 48 | * This only applies to slash commands. 49 | */ 50 | fun think(ephemeral: Boolean = false): CompletableFuture<*> { 51 | return asSlashContext?.defer0(ephemeral) 52 | ?: messageChannel.sendTyping().submit() 53 | } 54 | 55 | /** 56 | * Convenience method for replying to either a slash command event, or a message event. 57 | * This will acknowledge, and correctly respond to slash command events, if applicable. 58 | * 59 | * @param content 60 | * The response content to send. 61 | */ 62 | fun respond(content: String): CompletableFuture<*> { 63 | return asSlashContext?.respond0(MessageCreateData.fromContent(content)) 64 | ?: messageChannel.sendMessage(content).submit() 65 | } 66 | 67 | /** 68 | * Convenience method for replying to either a slash command event, or a message event. 69 | * This will acknowledge, and correctly respond to slash command events, if applicable. 70 | * 71 | * @param file 72 | * The file to send. 73 | */ 74 | fun respond(file: FileUpload): CompletableFuture<*> { 75 | return asSlashContext?.respond0(MessageCreateData.fromFiles(file)) 76 | ?: messageChannel.sendFiles(file).submit() 77 | } 78 | 79 | /** 80 | * Convenience method for replying to either a slash command event, or a message event. 81 | * This will acknowledge, and correctly respond to slash command events, if applicable. 82 | * 83 | * @param message 84 | * The message data to send. 85 | */ 86 | fun respond(message: MessageCreateData): CompletableFuture<*> { 87 | return asSlashContext?.respond0(message) 88 | ?: messageChannel.sendMessage(message).submit() 89 | } 90 | 91 | /** 92 | * Convenience method for replying to either a slash command event, or a message event. 93 | * This will acknowledge, and correctly respond to slash command events, if applicable. 94 | * 95 | * @param messageBuilder 96 | * The options to apply when creating a response. 97 | */ 98 | fun respond(messageBuilder: DSLMessageCreateBuilder.() -> Unit): CompletableFuture<*> { 99 | val built = DSLMessageCreateBuilder().apply(messageBuilder).build() 100 | 101 | return asSlashContext?.respond0(built) 102 | ?: messageChannel.sendMessage(built).submit() 103 | } 104 | 105 | /** 106 | * Sends a message to the channel. This has no special handling, and could cause 107 | * problems with slash command events, so use with caution. 108 | * 109 | * @param content 110 | * The response content to send. 111 | */ 112 | fun send(content: String) { 113 | messageChannel.sendMessage(content).submit() 114 | } 115 | 116 | /** 117 | * Sends the message author a direct message. 118 | * 119 | * @param content 120 | * The content of the message. 121 | */ 122 | fun sendPrivate(content: String): CompletableFuture<*> { 123 | return author.openPrivateChannel() 124 | .submit() 125 | .thenCompose { it.sendMessage(content).submit() } 126 | .thenCompose { it.channel.asPrivateChannel().delete().submit() } 127 | } 128 | 129 | /** 130 | * Sends the message author a direct message. 131 | * This is intended as a lower level function (compared to the other send methods) 132 | * to offer more functionality when needed. 133 | * 134 | * @param message 135 | * The message to send. 136 | * 137 | * @return The message that was sent. 138 | */ 139 | fun sendPrivate(message: MessageCreateData): CompletableFuture<*> { 140 | return author.openPrivateChannel() 141 | .submit() 142 | .thenCompose { it.sendMessage(message).submit() } 143 | .thenCompose { it.channel.asPrivateChannel().delete().submit() } 144 | } 145 | 146 | /** 147 | * Waits for the given event. Only events that pass the given predicate will be returned. 148 | * If the timeout is exceeded with no results then null will be returned. 149 | * 150 | * @param predicate 151 | * A function that must return a boolean denoting whether the event meets the given criteria. 152 | * 153 | * @param timeout 154 | * How long to wait, in milliseconds, for the given event type before expiring. 155 | * 156 | * @throws java.util.concurrent.TimeoutException 157 | */ 158 | fun waitFor(event: Class, predicate: (T) -> Boolean, timeout: Long): CompletableFuture { 159 | return commandClient.waitFor(event, predicate, timeout) 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/api/context/ContextType.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.api.context 2 | 3 | enum class ContextType { 4 | MESSAGE, 5 | SLASH, 6 | // This is not used in Context, but rather within 7 | // @Command to denote what contexts the command should execute in. 8 | MESSAGE_OR_SLASH 9 | } 10 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/api/context/MessageContext.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.api.context 2 | 3 | import kotlinx.coroutines.future.await 4 | import me.devoxin.flight.api.CommandClient 5 | import me.devoxin.flight.internal.entities.Executable 6 | import me.devoxin.flight.internal.utils.Scheduler 7 | import net.dv8tion.jda.api.EmbedBuilder 8 | import net.dv8tion.jda.api.JDA 9 | import net.dv8tion.jda.api.entities.* 10 | import net.dv8tion.jda.api.entities.channel.concrete.PrivateChannel 11 | import net.dv8tion.jda.api.entities.channel.concrete.TextChannel 12 | import net.dv8tion.jda.api.events.Event 13 | import net.dv8tion.jda.api.events.message.MessageReceivedEvent 14 | import net.dv8tion.jda.api.requests.RestAction 15 | import net.dv8tion.jda.api.utils.FileUpload 16 | import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder 17 | import net.dv8tion.jda.api.utils.messages.MessageCreateData 18 | import java.util.concurrent.CompletableFuture 19 | import java.util.regex.Pattern 20 | 21 | class MessageContext( 22 | override val commandClient: CommandClient, 23 | event: MessageReceivedEvent, 24 | val trigger: String, 25 | override val invokedCommand: Executable 26 | ) : Context { 27 | override val contextType = ContextType.MESSAGE 28 | override val jda: JDA = event.jda 29 | override val author = event.author 30 | override val guild = event.takeIf { it.isFromGuild }?.guild 31 | override val member = event.member 32 | override val messageChannel = event.channel 33 | override val guildChannel = event.takeIf { it.isFromGuild }?.guildChannel 34 | override val isFromGuild = event.isFromGuild 35 | 36 | val message: Message = event.message 37 | 38 | val textChannel: TextChannel? = messageChannel as? TextChannel 39 | val privateChannel: PrivateChannel? = messageChannel as? PrivateChannel 40 | 41 | /** 42 | * Sends a message embed to the channel the Context was created from. 43 | * 44 | * @param content 45 | * The content of the message. 46 | */ 47 | override fun send(content: String) { 48 | send0({ setContent(content) }).queue() 49 | } 50 | 51 | /** 52 | * Sends a file to the channel the Context was created from. 53 | * 54 | * @param attachment 55 | * The attachment to send. 56 | */ 57 | fun send(attachment: FileUpload) { 58 | send0(null, attachment).queue() 59 | } 60 | 61 | /** 62 | * Sends a message embed to the channel the Context was created from. 63 | * 64 | * @param embed 65 | * Options to apply to the message embed. 66 | */ 67 | fun send(embed: EmbedBuilder.() -> Unit) { 68 | send0({ setEmbeds(EmbedBuilder().apply(embed).build()) }).queue() 69 | } 70 | 71 | /** 72 | * Sends a message to the channel the Context was created from. 73 | * This is intended as a lower level function (compared to the other send methods) 74 | * to offer more functionality when needed. 75 | * 76 | * @param message 77 | * The message to send. 78 | */ 79 | fun send(message: MessageCreateData) { 80 | messageChannel.sendMessage(message).queue() 81 | } 82 | 83 | /** 84 | * Sends a message embed to the channel the Context was created from. 85 | * 86 | * @param content 87 | * The content of the message. 88 | * 89 | * @return The sent message. 90 | */ 91 | suspend fun sendAsync(content: String): Message { 92 | return send0({ setContent(content) }).submit().await() 93 | } 94 | 95 | /** 96 | * Sends a file to the channel the Context was created from. 97 | * 98 | * @param attachment 99 | * The attachment to send. 100 | * 101 | * @return The message that was sent. 102 | */ 103 | suspend fun sendAsync(attachment: FileUpload): Message { 104 | return send0(null, attachment).submit().await() 105 | } 106 | 107 | /** 108 | * Sends a message embed to the channel the Context was created from. 109 | * 110 | * @param embed 111 | * Options to apply to the message embed. 112 | * 113 | * @return The message that was sent. 114 | */ 115 | suspend fun sendAsync(embed: EmbedBuilder.() -> Unit): Message { 116 | return send0({ setEmbeds(EmbedBuilder().apply(embed).build()) }).submit().await() 117 | } 118 | 119 | /** 120 | * Sends a message to the channel the Context was created from. 121 | * This is intended as a lower level function (compared to the other send methods) 122 | * to offer more functionality when needed. 123 | * 124 | * @param message 125 | * The message to send. 126 | * 127 | * @return The message that was sent. 128 | */ 129 | suspend fun sendAsync(message: MessageCreateData): Message { 130 | return messageChannel.sendMessage(message).submit().await() 131 | } 132 | 133 | private fun send0(messageOpts: (MessageCreateBuilder.() -> Unit)? = null, vararg files: FileUpload): RestAction { 134 | if (messageOpts == null && files.isEmpty()) { 135 | throw IllegalArgumentException("Cannot send a message with no options or attachments!") 136 | } 137 | 138 | val builder = MessageCreateBuilder() 139 | messageOpts?.let(builder::apply) 140 | 141 | files.takeIf { it.isNotEmpty() }?.let { 142 | builder.addFiles(*it) 143 | } 144 | 145 | return messageChannel.sendMessage(builder.build()) 146 | } 147 | 148 | /** 149 | * Sends a typing status within the channel until the provided function is exited. 150 | * 151 | * @param block 152 | * The code that should be executed while the typing status is active. 153 | */ 154 | fun typing(block: () -> Unit) { 155 | messageChannel.sendTyping().queue { 156 | val task = Scheduler.every(5000) { 157 | messageChannel.sendTyping().queue() 158 | } 159 | block() 160 | task.cancel(true) 161 | } 162 | } 163 | 164 | /** 165 | * Sends a typing status within the channel until the provided function is exited. 166 | * 167 | * @param block 168 | * The code that should be executed while the typing status is active. 169 | */ 170 | suspend fun typingAsync(block: suspend () -> Unit) { 171 | messageChannel.sendTyping().submit().await() 172 | val task = Scheduler.every(5000) { messageChannel.sendTyping().queue() } 173 | 174 | try { 175 | block() 176 | } finally { 177 | task.cancel(true) 178 | } 179 | } 180 | 181 | /** 182 | * Cleans a string, sanitizing all forms of mentions (role, channel and user), replacing them with 183 | * their display-equivalent where possible (For example, <@123456789123456789> becomes @User). 184 | * 185 | * For cases where the mentioned entity is not cached by the bot, the mention will be replaced 186 | * with @invalid-. 187 | * 188 | * It's recommended that you use this only for sending responses back to a user. 189 | * 190 | * @param str 191 | * The string to clean. 192 | * 193 | * @returns The sanitized string. 194 | */ 195 | fun cleanContent(str: String): String { 196 | var content = str.replace("e", "е") 197 | // We use a Cyrillic "e" instead of \u200b as it keeps character count the same. 198 | val matcher = mentionPattern.matcher(str) 199 | 200 | while (matcher.find()) { 201 | val entityType = matcher.group("type") 202 | val entityId = matcher.group("id").toLong() 203 | val fullEntity = matcher.group("mention") 204 | 205 | when (entityType) { 206 | "@", "@!" -> { 207 | val entity = guild?.getMemberById(entityId)?.effectiveName 208 | ?: jda.getUserById(entityId)?.name 209 | ?: "invalid-user" 210 | content = content.replace(fullEntity, "@$entity") 211 | } 212 | "@&" -> { 213 | val entity = jda.getRoleById(entityId)?.name ?: "invalid-role" 214 | content = content.replace(fullEntity, "@$entity") 215 | } 216 | "#" -> { 217 | val entity = jda.getTextChannelById(entityId)?.name ?: "invalid-channel" 218 | content = content.replace(fullEntity, "#$entity") 219 | } 220 | } 221 | } 222 | 223 | for (emote in message.mentions.customEmojis) { 224 | content = content.replace(emote.asMention, ":${emote.name}:") 225 | } 226 | 227 | return content 228 | } 229 | 230 | companion object { 231 | private val mentionPattern = Pattern.compile("(?<(?@!?|@&|#)(?[0-9]{17,21})>)") 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/api/context/SlashContext.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.api.context 2 | 3 | import kotlinx.coroutines.future.await 4 | import me.devoxin.flight.api.CommandClient 5 | import me.devoxin.flight.api.entities.DSLMessageCreateBuilder 6 | import me.devoxin.flight.internal.entities.Executable 7 | import net.dv8tion.jda.api.JDA 8 | import net.dv8tion.jda.api.entities.Message 9 | import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent 10 | import net.dv8tion.jda.api.interactions.InteractionHook 11 | import net.dv8tion.jda.api.interactions.modals.Modal 12 | import net.dv8tion.jda.api.utils.messages.MessageCreateData 13 | import net.dv8tion.jda.api.utils.messages.MessageEditData 14 | import java.util.concurrent.CompletableFuture 15 | 16 | class SlashContext( 17 | override val commandClient: CommandClient, 18 | val event: SlashCommandInteractionEvent, 19 | override val invokedCommand: Executable 20 | ) : Context { 21 | override val contextType = ContextType.SLASH 22 | override val jda: JDA = event.jda 23 | override val author = event.user 24 | override val guild = event.guild 25 | override val member = event.member 26 | override val messageChannel = event.channel 27 | override val guildChannel = event.takeIf { it.isFromGuild }?.guildChannel 28 | override val isFromGuild = event.isFromGuild 29 | 30 | var replied = false 31 | private set 32 | var deferred = false 33 | private set 34 | 35 | fun defer(ephemeral: Boolean = false) { 36 | defer0(ephemeral) 37 | } 38 | 39 | suspend fun deferAsync(ephemeral: Boolean = false): InteractionHook { 40 | return defer0(ephemeral).await() 41 | } 42 | 43 | /** 44 | * This will only call [SlashCommandInteractionEvent.reply] with no special handling. 45 | * Use [respond] or [respondAsync] to handle things such as deferral or already acknowledged events. 46 | */ 47 | fun reply(content: String, ephemeral: Boolean = false) { 48 | event.reply(content).setEphemeral(ephemeral).queue { replied = true } 49 | } 50 | 51 | /** 52 | * This will only call [SlashCommandInteractionEvent.reply] with no special handling. 53 | * Use [respond] or [respondAsync] to handle things such as deferral or already acknowledged events. 54 | */ 55 | fun reply(modal: Modal) { 56 | if (replied) { 57 | throw IllegalStateException("Cannot respond with a Modal to an acknowledged interaction!") 58 | } 59 | 60 | event.replyModal(modal).queue { replied = true } 61 | } 62 | 63 | /** 64 | * This will only call [SlashCommandInteractionEvent.reply] with no special handling. 65 | * Use [respond] or [respondAsync] to handle things such as deferral or already acknowledged events. 66 | */ 67 | fun reply(message: MessageCreateData, ephemeral: Boolean = false) { 68 | event.reply(message).setEphemeral(ephemeral).queue { replied = true } 69 | } 70 | 71 | /** 72 | * This will only call [SlashCommandInteractionEvent.reply] with no special handling. 73 | * Use [respond] or [respondAsync] to handle things such as deferral or already acknowledged events. 74 | */ 75 | suspend fun replyAsync(content: String, ephemeral: Boolean = false): InteractionHook { 76 | return event.reply(content).setEphemeral(ephemeral).submit().thenApply { replied = true; it }.await() 77 | } 78 | 79 | /** 80 | * This will only call [SlashCommandInteractionEvent.reply] with no special handling. 81 | * Use [respond] or [respondAsync] to handle things such as deferral or already acknowledged events. 82 | */ 83 | suspend fun replyAsync(modal: Modal) { 84 | if (replied) { 85 | throw IllegalStateException("Cannot respond with a Modal to an acknowledged interaction!") 86 | } 87 | 88 | event.replyModal(modal).submit().thenAccept { replied = true }.await() 89 | } 90 | 91 | /** 92 | * This will only call [SlashCommandInteractionEvent.reply] with no special handling. 93 | * Use [respond] or [respondAsync] to handle things such as deferral or already acknowledged events. 94 | */ 95 | suspend fun replyAsync(message: MessageCreateData, ephemeral: Boolean = false): InteractionHook { 96 | return event.reply(message).setEphemeral(ephemeral).submit().thenApply { replied = true; it }.await() 97 | } 98 | 99 | /** 100 | * This will only call [InteractionHook.sendMessage] with no special handling. 101 | */ 102 | fun send(message: MessageCreateData, ephemeral: Boolean = false) { 103 | event.hook.sendMessage(message).setEphemeral(ephemeral).queue() 104 | } 105 | 106 | /** 107 | * This will only call [InteractionHook.sendMessage] with no special handling. 108 | */ 109 | suspend fun sendAsync(message: MessageCreateData, ephemeral: Boolean = false): Message { 110 | return event.hook.sendMessage(message).setEphemeral(ephemeral).submit().await() 111 | } 112 | 113 | /** 114 | * Convenience method which handles replying the correct way for you. 115 | * 116 | * The [ephemeral] setting is ignored if the interaction is deferred. 117 | * Instead, the ephemeral setting when deferring is used. This is a Discord limitation. 118 | */ 119 | fun respond(message: MessageCreateData, ephemeral: Boolean = false) { 120 | respond0(message, ephemeral) 121 | } 122 | 123 | /** 124 | * Convenience method which handles replying the correct way for you. 125 | * 126 | * The [ephemeral] setting is ignored if the interaction is deferred. 127 | * Instead, the ephemeral setting when deferring is used. This is a Discord limitation. 128 | */ 129 | fun respond(builder: DSLMessageCreateBuilder.() -> Unit, ephemeral: Boolean = false) { 130 | val built = DSLMessageCreateBuilder().apply(builder).build() 131 | respond0(built, ephemeral) 132 | } 133 | 134 | /** 135 | * Convenience method which handles replying the correct way for you. 136 | * 137 | * The [ephemeral] setting is ignored if the interaction is deferred. 138 | * Instead, the ephemeral setting when deferring is used. This is a Discord limitation. 139 | */ 140 | suspend fun respondAsync(message: MessageCreateData, ephemeral: Boolean = false) { 141 | respond0(message, ephemeral).await() 142 | } 143 | 144 | /** 145 | * Convenience method which handles replying the correct way for you. 146 | * 147 | * The [ephemeral] setting is ignored if the interaction is deferred. 148 | * Instead, the ephemeral setting when deferring is used. This is a Discord limitation. 149 | */ 150 | suspend fun respondAsync(builder: DSLMessageCreateBuilder.() -> Unit, ephemeral: Boolean = false) { 151 | val built = DSLMessageCreateBuilder().apply(builder).build() 152 | respond0(built, ephemeral).await() 153 | } 154 | 155 | internal fun defer0(ephemeral: Boolean): CompletableFuture { 156 | if (!deferred) { // Idempotency handling 157 | return event.deferReply(ephemeral).submit() 158 | .thenApply { deferred = true; it } 159 | } 160 | 161 | return CompletableFuture.completedFuture(event.hook) 162 | } 163 | 164 | internal fun respond0(message: MessageCreateData, ephemeral: Boolean = false): CompletableFuture<*> { 165 | return when { 166 | replied -> event.hook.sendMessage(message).setEphemeral(ephemeral).submit() 167 | deferred -> event.hook.editOriginal(MessageEditData.fromCreateData(message)).submit().thenApply { replied = true; it } 168 | else -> event.reply(message).setEphemeral(ephemeral).submit().thenApply { replied = true; it } 169 | } 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/api/entities/Attachment.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.api.entities 2 | 3 | import net.dv8tion.jda.api.utils.FileUpload 4 | import java.io.ByteArrayInputStream 5 | import java.io.File 6 | import java.io.FileInputStream 7 | import java.io.InputStream 8 | 9 | @Deprecated("Please use JDA's FileUpload class") 10 | class Attachment(stream: InputStream, filename: String) : FileUpload(stream, filename) { 11 | companion object { 12 | fun from(inputStream: InputStream, filename: String): FileUpload { 13 | return fromData(inputStream, filename) 14 | } 15 | 16 | fun from(byteArray: ByteArray, filename: String): FileUpload { 17 | return fromData(ByteArrayInputStream(byteArray), filename) 18 | } 19 | 20 | fun from(file: File, filename: String? = null): FileUpload { 21 | val name = filename ?: file.name 22 | return fromData(FileInputStream(file), name) 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/api/entities/BucketType.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.api.entities 2 | 3 | enum class BucketType { 4 | USER, 5 | GUILD, 6 | GLOBAL 7 | } 8 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/api/entities/CheckType.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.api.entities 2 | 3 | enum class CheckType { 4 | // Emitted when a command wasn't used in the right context (e.g. Slash command as a Message command). 5 | EXECUTION_CONTEXT, 6 | // Emitted when a command is marked guildOnly, but isn't used in a guild. 7 | GUILD_CHECK, 8 | // Emitted when a command is marked NSFW, but isn't used in an NSFW channel. 9 | NSFW_CHECK, 10 | // Emitted when a command is marked developerOnly, but isn't used by a developer. 11 | DEVELOPER_CHECK 12 | } 13 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/api/entities/Cog.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.api.entities 2 | 3 | import me.devoxin.flight.api.CommandFunction 4 | import me.devoxin.flight.api.context.Context 5 | import me.devoxin.flight.api.context.MessageContext 6 | 7 | interface Cog { 8 | 9 | fun name(): String? = null 10 | 11 | /** 12 | * Invoked when an error occurs during command execution. 13 | * This is local to the cog, allowing for per-cog error handling. 14 | * 15 | * @return Whether the error was handled or not. If it wasn't, 16 | * the error will be passed back to the registered 17 | * CommandClientAdapter for handling. 18 | */ 19 | fun onCommandError(ctx: Context, command: CommandFunction, error: Throwable): Boolean = false 20 | 21 | /** 22 | * Invoked before a command is executed. This check is local to 23 | * all commands inside the cog. 24 | * 25 | * @return Whether the command execution should continue or not. 26 | */ 27 | fun localCheck(ctx: Context, command: CommandFunction): Boolean = true 28 | 29 | 30 | /** 31 | * Invoked when this Cog gets unloaded, usually through [CommandRegistry.unload]. 32 | * This can be used as a last-ditch attempt to clean up, or shut down any resources. 33 | */ 34 | fun unload(): Unit = Unit 35 | 36 | } 37 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/api/entities/CommandRegistry.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.api.entities 2 | 3 | import me.devoxin.flight.api.CommandFunction 4 | import me.devoxin.flight.api.annotations.GuildIds 5 | import me.devoxin.flight.api.context.ContextType.SLASH 6 | import me.devoxin.flight.internal.arguments.Argument 7 | import me.devoxin.flight.internal.entities.Jar 8 | import me.devoxin.flight.internal.utils.Indexer 9 | import net.dv8tion.jda.api.JDA 10 | import net.dv8tion.jda.api.entities.Guild 11 | import net.dv8tion.jda.api.interactions.commands.build.CommandData 12 | import net.dv8tion.jda.api.interactions.commands.build.Commands 13 | import net.dv8tion.jda.api.interactions.commands.build.OptionData 14 | import net.dv8tion.jda.api.interactions.commands.build.SubcommandData 15 | import net.dv8tion.jda.api.sharding.ShardManager 16 | import org.slf4j.LoggerFactory 17 | import kotlin.reflect.full.findAnnotation 18 | import kotlin.reflect.full.hasAnnotation 19 | 20 | class CommandRegistry : HashMap() { 21 | val objectStorage = ObjectStorage() 22 | 23 | /** 24 | * Returns a list of all registered slash commands as [CommandData]. 25 | * If [includeGuildSpecific] is true, this will include any commands annotated with 26 | * [me.devoxin.flight.api.annotations.GuildIds]. 27 | */ 28 | fun toDiscordCommands(includeGuildSpecific: Boolean = true): List { 29 | return values.filter { it.contextType >= SLASH } 30 | .filter { includeGuildSpecific || !it.method.hasAnnotation() } 31 | .map(::toCommandData) 32 | .toList() 33 | } 34 | 35 | fun toCommandData(command: CommandFunction): CommandData { 36 | if (command.contextType < SLASH) { 37 | throw IllegalArgumentException("${command.contextType}-type command cannot be used as a slash command!") 38 | } 39 | 40 | val data = Commands.slash(command.name, command.properties.description) 41 | .setGuildOnly(command.properties.guildOnly) 42 | .setNSFW(command.properties.nsfw) 43 | 44 | if (command.subcommands.isNotEmpty()) { 45 | for (sc in command.subcommands.values.toSet()) { 46 | val scData = SubcommandData(sc.name, sc.properties.description) 47 | 48 | if (sc.arguments.isNotEmpty()) { 49 | scData.addOptions(sc.arguments.map(Argument::asSlashCommandType)) 50 | } 51 | 52 | data.addSubcommands(scData) 53 | } 54 | } else if (command.arguments.isNotEmpty()) { 55 | data.addOptions(command.arguments.map(Argument::asSlashCommandType)) 56 | } 57 | 58 | return data 59 | } 60 | 61 | override fun clear() { 62 | val cogs = values.map(CommandFunction::cog) 63 | super.clear() 64 | doUnload(cogs) 65 | } 66 | 67 | fun findCommandByName(name: String): CommandFunction? { 68 | return this[name] 69 | } 70 | 71 | fun findCommandByAlias(alias: String): CommandFunction? { 72 | return values.firstOrNull { alias in it.properties.aliases } 73 | } 74 | 75 | fun findCogByName(name: String): Cog? { 76 | return values.firstOrNull { it.cog.name() == name || it.cog::class.simpleName == name }?.cog 77 | } 78 | 79 | fun findCommandsByCog(cog: Cog): List { 80 | return values.filter { it.cog == cog } 81 | } 82 | 83 | fun unload(commandFunction: CommandFunction) { 84 | values.remove(commandFunction) 85 | doUnload(commandFunction.cog) 86 | } 87 | 88 | fun unload(cog: Cog) { 89 | val commands = values.filter { it.cog == cog } 90 | values.removeAll(commands) 91 | 92 | commands.map(CommandFunction::cog).let(::doUnload) 93 | 94 | val jar = commands.firstOrNull { it.jar != null }?.jar 95 | ?: return // No commands loaded from jar, thus no classloader to close. 96 | 97 | val canCloseLoader = values.none { it.jar == jar } 98 | 99 | // No other commands were loaded from the jar, so it's safe to close the loader. 100 | if (canCloseLoader) { 101 | jar.close() 102 | } 103 | } 104 | 105 | fun unload(jar: Jar) { 106 | val commands = values.filter { it.jar == jar } 107 | values.removeAll(commands) 108 | 109 | commands.map(CommandFunction::cog).let(::doUnload) 110 | 111 | jar.close() 112 | } 113 | 114 | fun register(packageName: String) { 115 | val indexer = Indexer(packageName) 116 | 117 | for (cog in indexer.getCogs(objectStorage)) { 118 | register(cog, indexer) 119 | } 120 | } 121 | 122 | fun register(jarPath: String, packageName: String) { 123 | val indexer = Indexer(packageName, jarPath) 124 | 125 | for (cog in indexer.getCogs(objectStorage)) { 126 | register(cog, indexer) 127 | } 128 | } 129 | 130 | fun register(cog: Cog, indexer: Indexer? = null) { 131 | val i = indexer ?: Indexer(cog::class.java.`package`.name) 132 | val commands = i.getCommands(cog) 133 | 134 | for (command in commands) { 135 | val cmd = i.loadCommand(command, cog) 136 | 137 | if (containsKey(cmd.name)) { 138 | throw RuntimeException("Cannot register command ${cmd.name} as the trigger has already been registered.") 139 | } 140 | 141 | this[cmd.name] = cmd 142 | } 143 | } 144 | 145 | private fun doUnload(cogs: Iterable) { 146 | val uniqueCogs = cogs.distinctBy(Cog::name) 147 | 148 | for (cog in uniqueCogs) { 149 | doUnload(cog) 150 | } 151 | } 152 | 153 | private fun doUnload(cog: Cog) { 154 | try { 155 | cog.unload() 156 | } catch (t: Throwable) { 157 | log.error("An error occurred whilst unloading cog \"{}\"", cog.name() ?: cog::class.java.simpleName, t) 158 | } 159 | } 160 | 161 | companion object { 162 | private val log = LoggerFactory.getLogger(CommandRegistry::class.java) 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/api/entities/CooldownProvider.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.api.entities 2 | 3 | import me.devoxin.flight.api.CommandFunction 4 | 5 | interface CooldownProvider { 6 | 7 | /** 8 | * Checks whether the entity associated with the provided ID is on cool-down. 9 | * When BucketType is `GUILD` and the command was invoked in a private context, this 10 | * method won't be called. 11 | * 12 | * @param id 13 | * The ID of the entity. If the bucket type is USER, this will be a user ID. 14 | * If the bucket type is GUILD, this will be the guild id. 15 | * If the bucket type is GLOBAL, this will be -1. 16 | * 17 | * @param bucket 18 | * The type of bucket the cool-down belongs to. 19 | * For example, one bucket for each entity type; USER, GUILD, GLOBAL. 20 | * If this parameter is GUILD, theoretically you would do `bucket[type].get(id) != null` 21 | * 22 | * @param command 23 | * The command that was invoked. 24 | * 25 | * @returns True, if the entity associated with the ID is on cool-down and the command should 26 | * not be executed. 27 | */ 28 | fun isOnCooldown(id: Long, bucket: BucketType, command: CommandFunction): Boolean 29 | 30 | /** 31 | * Gets the remaining time of the cool-down in milliseconds. 32 | * This may either return 0L, or throw an exception if an entry isn't present, however 33 | * this should not happen as `isOnCooldown` should be called prior to this. 34 | * 35 | * @param id 36 | * The ID of the entity. The ID could belong to a user or guild, or be -1 if the bucket is GLOBAL. 37 | * 38 | * @param bucket 39 | * The type of bucket to check the cool-down of. 40 | * 41 | * @param command 42 | * The command to get the cool-down time of. 43 | */ 44 | fun getCooldownTime(id: Long, bucket: BucketType, command: CommandFunction): Long 45 | 46 | /** 47 | * Adds a cool-down for the given entity ID. 48 | * It is up to you whether this passively, or actively removes expired cool-downs. 49 | * When [bucket] is [BucketType.GUILD] and the command was invoked in a private context, this 50 | * method won't be called. 51 | * 52 | * @param id 53 | * The ID of the entity, that the cool-down should be associated with. 54 | * This ID could belong to a user or guild. If bucket is BucketType.GLOBAL, this will be -1. 55 | * 56 | * @param bucket 57 | * The type of bucket the cool-down belongs to. 58 | * 59 | * @param time 60 | * How long the cool-down should last for, in milliseconds. 61 | * 62 | * @param command 63 | * The command to set cool-down for. 64 | */ 65 | fun setCooldown(id: Long, bucket: BucketType, time: Long, command: CommandFunction) 66 | 67 | /** 68 | * Remove a cool-down for a command by entity ID and bucket type, if it exists. 69 | * 70 | * @param id 71 | * The ID of the entity associated with the cool-down to be removed. 72 | * This ID could belong to a user or guild. If bucket is BucketType.GLOBAL, this will be -1. 73 | * 74 | * @param bucket 75 | * The type of bucket the cool-down belongs to. 76 | * 77 | * @param command 78 | * The command to remove the cool-down for. 79 | */ 80 | fun removeCooldown(id: Long, bucket: BucketType, command: CommandFunction) 81 | 82 | /** 83 | * Removes all cool-downs for a single command. 84 | * 85 | * @param command 86 | * The command to remove the cool-downs for. 87 | */ 88 | fun clearCooldowns(command: CommandFunction) 89 | 90 | /** 91 | * Removes all cool-downs for the given entity ID and bucket type, if there are any. 92 | * 93 | * @param id 94 | * The ID of the entity associated with the cool-down to be removed. 95 | * This ID could belong to a user or guild. If bucket is BucketType.GLOBAL, this will be -1. 96 | * 97 | * @param bucket 98 | * The type of bucket the cool-down belongs to. 99 | */ 100 | fun clearCooldowns(id: Long, bucket: BucketType) 101 | 102 | /** 103 | * Clears all cool-downs stored in this provider. 104 | */ 105 | fun clearCooldowns() 106 | } 107 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/api/entities/DSLMessageCreateBuilder.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.api.entities 2 | 3 | import net.dv8tion.jda.api.EmbedBuilder 4 | import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder 5 | 6 | class DSLMessageCreateBuilder : MessageCreateBuilder() { 7 | fun embed(builder: EmbedBuilder.() -> Unit) { 8 | addEmbeds(EmbedBuilder().apply(builder).build()) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/api/entities/DefaultCooldownProvider.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.api.entities 2 | 3 | import me.devoxin.flight.api.CommandFunction 4 | import java.util.concurrent.ConcurrentHashMap 5 | import java.util.concurrent.Executors 6 | import java.util.concurrent.TimeUnit 7 | import kotlin.math.abs 8 | 9 | class DefaultCooldownProvider : CooldownProvider { 10 | private val buckets = ConcurrentHashMap() 11 | 12 | override fun isOnCooldown(id: Long, bucket: BucketType, command: CommandFunction): Boolean { 13 | return buckets[bucket]?.isOnCooldown(id, command.name) ?: false 14 | } 15 | 16 | override fun getCooldownTime(id: Long, bucket: BucketType, command: CommandFunction): Long { 17 | return buckets[bucket]?.getCooldownRemainingTime(id, command.name) ?: 0 18 | } 19 | 20 | override fun setCooldown(id: Long, bucket: BucketType, time: Long, command: CommandFunction) { 21 | buckets.computeIfAbsent(bucket) { Bucket() }.setCooldown(id, time, command.name) 22 | } 23 | 24 | override fun removeCooldown(id: Long, bucket: BucketType, command: CommandFunction) { 25 | buckets[bucket]?.removeCooldown(id, command.name) 26 | } 27 | 28 | override fun clearCooldowns(command: CommandFunction) { 29 | buckets.values.forEach { it.clearCooldown(command.name) } 30 | } 31 | 32 | override fun clearCooldowns(id: Long, bucket: BucketType) { 33 | buckets[bucket]?.clearCooldowns(id) 34 | } 35 | 36 | override fun clearCooldowns() { 37 | buckets.values.forEach { it.empty() } 38 | } 39 | 40 | 41 | inner class Bucket { 42 | private val sweeperThread = Executors.newSingleThreadScheduledExecutor() 43 | private val cooldowns = ConcurrentHashMap>() // EntityID => [Commands...] 44 | 45 | fun isOnCooldown(id: Long, commandName: String): Boolean { 46 | return getCooldownRemainingTime(id, commandName) > 0 47 | } 48 | 49 | fun getCooldownRemainingTime(id: Long, commandName: String): Long { 50 | val cd = cooldowns[id]?.firstOrNull { it.name == commandName } 51 | ?: return 0 52 | 53 | return abs(cd.expires - System.currentTimeMillis()) 54 | } 55 | 56 | fun setCooldown(id: Long, time: Long, commandName: String) { 57 | val cds = cooldowns.computeIfAbsent(id) { mutableSetOf() } 58 | val cooldown = Cooldown(commandName, System.currentTimeMillis() + time) 59 | cds.add(cooldown) 60 | 61 | sweeperThread.schedule({ cds.remove(cooldown) }, time, TimeUnit.MILLISECONDS) 62 | } 63 | 64 | fun removeCooldown(id: Long, commandName: String) { 65 | cooldowns[id]?.removeIf { it.name == commandName } 66 | } 67 | 68 | fun clearCooldown(commandName: String) { 69 | cooldowns.values.forEach { it.removeIf { cd -> cd.name == commandName } } 70 | } 71 | 72 | fun clearCooldowns(id: Long) { 73 | cooldowns.remove(id) 74 | } 75 | 76 | fun empty() { 77 | cooldowns.clear() 78 | } 79 | } 80 | 81 | inner class Cooldown(val name: String, val expires: Long) { 82 | override fun equals(other: Any?): Boolean { 83 | if (this === other) return true 84 | if (javaClass != other?.javaClass) return false 85 | 86 | other as Cooldown 87 | 88 | return name == other.name 89 | } 90 | 91 | override fun hashCode(): Int { 92 | return 31 * name.hashCode() 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/api/entities/DefaultHelpCommand.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.api.entities 2 | 3 | import me.devoxin.flight.api.annotations.Command 4 | import me.devoxin.flight.api.CommandFunction 5 | import me.devoxin.flight.api.context.MessageContext 6 | import me.devoxin.flight.internal.utils.TextUtils 7 | 8 | open class DefaultHelpCommand(private val showParameterTypes: Boolean) : Cog { 9 | override fun name() = "No Category" 10 | 11 | @Command(aliases = ["commands", "cmds"], description = "Displays bot help.") 12 | open suspend fun help(ctx: MessageContext, command: String?) { 13 | val pages = command?.let { 14 | val commands = ctx.commandClient.commands 15 | val cmd = commands.findCommandByName(it) 16 | ?: commands.findCommandByAlias(it) 17 | 18 | when { 19 | cmd != null -> buildCommandHelp(ctx, cmd) 20 | else -> commands.findCogByName(command)?.let { cog -> buildCogHelp(ctx, cog) } 21 | } ?: return ctx.send("No commands or cogs found with that name.") 22 | 23 | } ?: buildCommandList(ctx) 24 | 25 | sendPages(ctx, pages) 26 | } 27 | 28 | open fun buildCommandList(ctx: MessageContext): List { 29 | val helpMenu = StringBuilder() 30 | val commands = ctx.commandClient.commands.values.filter { !it.properties.hidden } 31 | val padLength = ctx.commandClient.commands.values.maxOf { it.name.length } 32 | val categories = commands.groupBy { it.category.lowercase() }.mapValues { it.value.toSet() } 33 | 34 | for (entry in categories.entries.sortedBy { it.key }) { 35 | helpMenu.append(TextUtils.toTitleCase(entry.key)).append("\n") 36 | 37 | for (cmd in entry.value.sortedBy { it.name }) { 38 | val description = cmd.properties.description 39 | 40 | helpMenu.apply { 41 | append(" ") 42 | append(cmd.name.padEnd(padLength + 1, ' ')) 43 | append(" ") 44 | append(TextUtils.truncate(description, 100)) 45 | append("\n") 46 | } 47 | } 48 | } 49 | 50 | return TextUtils.split(helpMenu.toString().trim(), 1990) 51 | } 52 | 53 | open fun buildCommandHelp(ctx: MessageContext, command: CommandFunction): List { 54 | val builder = StringBuilder() 55 | 56 | val trigger = if (ctx.trigger.matches("<@!?${ctx.jda.selfUser.id}> ".toRegex())) "@${ctx.jda.selfUser.name} " else ctx.trigger 57 | builder.append(trigger) 58 | 59 | val properties = command.properties 60 | 61 | if (properties.aliases.isNotEmpty()) { 62 | builder.append("[") 63 | .append(command.name) 64 | .append(properties.aliases.joinToString("|", prefix = "|")) 65 | .append("] ") 66 | } else { 67 | builder.append(command.name).append(" ") 68 | } 69 | 70 | for (arg in command.arguments) { 71 | builder.append(arg.format(showParameterTypes)).append(" ") 72 | } 73 | 74 | builder.append("\n\n").append(properties.description) 75 | return listOf(builder.toString()) 76 | } 77 | 78 | open fun buildCogHelp(ctx: MessageContext, cog: Cog): List { 79 | val builder = StringBuilder("Commands in ${cog::class.simpleName}\n") 80 | val commands = ctx.commandClient.commands.findCommandsByCog(cog).filter { !it.properties.hidden } 81 | val padLength = ctx.commandClient.commands.values.maxOf { it.name.length } 82 | 83 | for (command in commands) { 84 | builder.apply { 85 | append(" ") 86 | append(command.name.padEnd(padLength + 1, ' ')) 87 | append(TextUtils.truncate(command.properties.description, 100)) 88 | append("\n") 89 | } 90 | } 91 | 92 | return TextUtils.split(builder.toString(), 1990) 93 | } 94 | 95 | // TODO: Subcommand help 96 | 97 | open suspend fun sendPages(ctx: MessageContext, pages: Collection) { 98 | for (page in pages) { 99 | ctx.sendAsync("```\n$page```") 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/api/entities/DefaultPrefixProvider.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.api.entities 2 | 3 | import net.dv8tion.jda.api.entities.Message 4 | 5 | class DefaultPrefixProvider( 6 | private val prefixes: List, 7 | private val allowMentionPrefix: Boolean 8 | ) : PrefixProvider { 9 | 10 | override fun provide(message: Message): List { 11 | val prefixes = mutableListOf().apply { 12 | addAll(this@DefaultPrefixProvider.prefixes) 13 | } 14 | 15 | if (allowMentionPrefix) { 16 | val selfUserId = message.jda.selfUser.id 17 | prefixes.add("<@$selfUserId> ") 18 | prefixes.add("<@!$selfUserId> ") 19 | } 20 | 21 | return prefixes.toList() 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/api/entities/ObjectStorage.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.api.entities 2 | 3 | import java.util.concurrent.ConcurrentHashMap 4 | import kotlin.collections.MutableMap.MutableEntry 5 | 6 | class ObjectStorage : MutableMap { 7 | private val map = ConcurrentHashMap() 8 | 9 | override val size: Int 10 | get() = map.size 11 | 12 | override val entries: MutableSet> 13 | get() = map.entries 14 | 15 | override val keys: MutableSet 16 | get() = map.keys 17 | 18 | override val values: MutableCollection 19 | get() = map.values 20 | 21 | operator fun set(key: String, value: Any) { 22 | map[key] = value 23 | } 24 | 25 | override operator fun get(key: String): Any? { 26 | return map[key] 27 | } 28 | 29 | fun get(key: String, cls: Class): T? { 30 | val value = map[key] 31 | 32 | if (cls.isInstance(value)) { 33 | return cls.cast(value) 34 | } 35 | 36 | return null 37 | } 38 | 39 | @Suppress("UNCHECKED_CAST") 40 | fun computeIfAbsent(key: String, initializer: (String) -> T): T { 41 | return map.computeIfAbsent(key, initializer) as? T 42 | ?: throw IllegalStateException("Computed object is not of type T!") 43 | } 44 | 45 | override fun clear() { 46 | map.clear() 47 | } 48 | 49 | override fun isEmpty() = map.isEmpty() 50 | 51 | override fun remove(key: String): Any? = map.remove(key) 52 | 53 | override fun putAll(from: Map) = map.putAll(from) 54 | 55 | override fun put(key: String, value: Any): Any? = map.put(key, value) 56 | 57 | override fun containsValue(value: Any) = map.containsValue(value) 58 | 59 | override fun containsKey(key: String) = map.containsKey(key) 60 | } 61 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/api/entities/PrefixProvider.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.api.entities 2 | 3 | import net.dv8tion.jda.api.entities.Message 4 | 5 | interface PrefixProvider { 6 | 7 | fun provide(message: Message): List 8 | 9 | } 10 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/api/exceptions/BadArgument.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.api.exceptions 2 | 3 | import me.devoxin.flight.internal.arguments.Argument 4 | 5 | class BadArgument( 6 | val argument: Argument, 7 | val providedArgument: String, 8 | @Deprecated("original is deprecated", ReplaceWith("cause")) 9 | val original: Throwable? = null 10 | ) : Throwable("`${argument.name}` must be a `${argument.type.simpleName}`", original) 11 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/api/exceptions/ParserNotRegistered.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.api.exceptions 2 | 3 | class ParserNotRegistered(msg: String) : Throwable(msg) 4 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/api/hooks/CommandEventAdapter.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.api.hooks 2 | 3 | import me.devoxin.flight.api.CommandFunction 4 | import me.devoxin.flight.api.context.Context 5 | import me.devoxin.flight.api.context.MessageContext 6 | import me.devoxin.flight.api.entities.CheckType 7 | import me.devoxin.flight.api.exceptions.BadArgument 8 | import net.dv8tion.jda.api.Permission 9 | import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent 10 | import net.dv8tion.jda.api.events.message.MessageReceivedEvent 11 | 12 | interface CommandEventAdapter { 13 | /** 14 | * Invoked when a pre-execution check fails. 15 | */ 16 | fun onCheckFailed(ctx: Context, command: CommandFunction, checkType: CheckType) 17 | 18 | /** 19 | * Invoked when an invalid argument is passed. 20 | */ 21 | fun onBadArgument(ctx: Context, command: CommandFunction, error: BadArgument) 22 | 23 | /** 24 | * Invoked when the parser encounters an internal error. 25 | */ 26 | fun onParseError(ctx: Context, command: CommandFunction, error: Throwable) 27 | 28 | /** 29 | * Invoked when an internal error occurs within Flight 30 | */ 31 | fun onInternalError(error: Throwable) 32 | 33 | /** 34 | * Invoked when a command was not found for the input provided by the user. 35 | * This will only be triggered upon successful prefix match, but unsuccessful command label match. 36 | * 37 | * @param ctx 38 | * The command context. 39 | * @param command 40 | * The command label that the user provided. 41 | * @param args 42 | * Any additional arguments provided by the user. 43 | */ 44 | fun onUnknownCommand(event: MessageReceivedEvent, command: String, args: List) 45 | 46 | /** 47 | * Invoked before a command is executed. Useful for logging command usage etc. 48 | * 49 | * @return True, if the command should still be executed 50 | */ 51 | fun onCommandPreInvoke(ctx: Context, command: CommandFunction): Boolean 52 | 53 | /** 54 | * Invoked after a command has executed, regardless of whether the command execution encountered an error 55 | * 56 | * @param ctx 57 | * The command context. 58 | * @param command 59 | * The command that finished processing. 60 | * @param failed 61 | * Whether the command encountered an error or not. You can use `onCommandError` to retrieve the error. 62 | */ 63 | fun onCommandPostInvoke(ctx: Context, command: CommandFunction, failed: Boolean) 64 | 65 | /** 66 | * Invoked when a command encounters an error during execution. 67 | */ 68 | fun onCommandError(ctx: Context, command: CommandFunction, error: Throwable) 69 | 70 | /** 71 | * Invoked when a command is executed while on cool-down. 72 | * 73 | * @param ctx 74 | * The command context. 75 | * @param command 76 | * The command that encountered the cool-down. 77 | * @param cooldown 78 | * The remaining time of the cool-down, in milliseconds. 79 | */ 80 | fun onCommandCooldown(ctx: Context, command: CommandFunction, cooldown: Long) 81 | 82 | /** 83 | * Invoked when an autocomplete handler encounters an error during execution. 84 | */ 85 | fun onAutocompleteError(event: CommandAutoCompleteInteractionEvent, error: Throwable) 86 | 87 | /** 88 | * Invoked when a user lacks permissions to execute a command 89 | */ 90 | fun onUserMissingPermissions(ctx: Context, command: CommandFunction, permissions: List) 91 | 92 | /** 93 | * Invoked when the bot lacks permissions to execute a command 94 | */ 95 | fun onBotMissingPermissions(ctx: Context, command: CommandFunction, permissions: List) 96 | } 97 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/api/hooks/DefaultCommandEventAdapter.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.api.hooks 2 | 3 | import me.devoxin.flight.api.CommandFunction 4 | import me.devoxin.flight.api.context.Context 5 | import me.devoxin.flight.api.context.MessageContext 6 | import me.devoxin.flight.api.entities.CheckType 7 | import me.devoxin.flight.api.exceptions.BadArgument 8 | import net.dv8tion.jda.api.Permission 9 | import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent 10 | import net.dv8tion.jda.api.events.message.MessageReceivedEvent 11 | 12 | open class DefaultCommandEventAdapter : CommandEventAdapter { 13 | override fun onCheckFailed(ctx: Context, command: CommandFunction, checkType: CheckType) = Unit 14 | 15 | override fun onBadArgument(ctx: Context, command: CommandFunction, error: BadArgument) { 16 | error.printStackTrace() 17 | } 18 | 19 | override fun onCommandError(ctx: Context, command: CommandFunction, error: Throwable) { 20 | error.printStackTrace() 21 | } 22 | 23 | override fun onCommandPostInvoke(ctx: Context, command: CommandFunction, failed: Boolean) = Unit 24 | 25 | override fun onCommandPreInvoke(ctx: Context, command: CommandFunction) = true 26 | 27 | override fun onParseError(ctx: Context, command: CommandFunction, error: Throwable) { 28 | error.printStackTrace() 29 | } 30 | 31 | override fun onInternalError(error: Throwable) { 32 | error.printStackTrace() 33 | } 34 | 35 | override fun onCommandCooldown(ctx: Context, command: CommandFunction, cooldown: Long) = Unit 36 | 37 | override fun onAutocompleteError(event: CommandAutoCompleteInteractionEvent, error: Throwable) { 38 | error.printStackTrace() 39 | } 40 | 41 | override fun onBotMissingPermissions(ctx: Context, command: CommandFunction, permissions: List) = Unit 42 | 43 | override fun onUserMissingPermissions(ctx: Context, command: CommandFunction, permissions: List) = Unit 44 | 45 | override fun onUnknownCommand(event: MessageReceivedEvent, command: String, args: List) = Unit 46 | } 47 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/internal/arguments/ArgParser.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.internal.arguments 2 | 3 | import me.devoxin.flight.api.context.MessageContext 4 | import me.devoxin.flight.api.exceptions.BadArgument 5 | import me.devoxin.flight.api.exceptions.ParserNotRegistered 6 | import me.devoxin.flight.internal.entities.Executable 7 | import me.devoxin.flight.internal.parsers.Parser 8 | import me.devoxin.flight.internal.utils.TextUtils 9 | import me.devoxin.flight.internal.utils.Tuple 10 | import java.util.* 11 | import kotlin.reflect.KParameter 12 | 13 | class ArgParser( 14 | private val ctx: MessageContext, 15 | private val delimiter: Char, 16 | commandArgs: List 17 | ) { 18 | private val delimiterStr = delimiter.toString() 19 | private var args = commandArgs.toMutableList() 20 | 21 | private fun take(amount: Int) = args.take(amount).onEach { args.removeAt(0) } 22 | private fun restore(argList: List) = args.addAll(0, argList) 23 | 24 | private fun parseQuoted(): Pair> { 25 | val iterator = args.joinToString(delimiterStr).iterator() 26 | val original = StringBuilder() 27 | val argument = StringBuilder("\"") 28 | var quoting = false 29 | var escaping = false 30 | 31 | while (iterator.hasNext()) { 32 | val char = iterator.nextChar() 33 | original.append(char) 34 | 35 | when { 36 | escaping -> { 37 | argument.append(char) 38 | escaping = false 39 | } 40 | char == '\\' -> escaping = true 41 | quoting && char == '"' -> quoting = false // accept other quote chars 42 | !quoting && char == '"' -> quoting = true // accept other quote chars 43 | !quoting && char == delimiter -> { 44 | // Maybe this should throw? !test blah -- Extraneous whitespace is ignored. 45 | if (argument.isEmpty()) continue 46 | else break 47 | } 48 | else -> argument.append(char) 49 | } 50 | } 51 | 52 | argument.append('"') 53 | 54 | val remainingArgs = StringBuilder().apply { 55 | iterator.forEachRemaining(this::append) 56 | } 57 | 58 | args = remainingArgs.toString().split(delimiter).toMutableList() 59 | return argument.toString() to original.split(delimiterStr) 60 | } 61 | 62 | /** 63 | * @returns a Pair of the parsed argument, and the original args. 64 | */ 65 | private fun getNextArgument(greedy: Boolean): Pair> { 66 | val (argument, original) = when { 67 | args.isEmpty() -> "" to emptyList() 68 | greedy -> { 69 | val args = take(args.size) 70 | args.joinToString(delimiterStr) to args 71 | } 72 | args[0].startsWith('"') && delimiter == ' ' -> parseQuoted() // accept other quote chars 73 | else -> { 74 | val taken = take(1) 75 | taken.joinToString(delimiterStr) to taken 76 | } 77 | } 78 | 79 | var unquoted = argument.trim() 80 | 81 | if (!greedy) { 82 | unquoted = unquoted.removeSurrounding("\"") 83 | } 84 | 85 | return unquoted to original 86 | } 87 | 88 | fun parse(arg: Argument): Any? { 89 | val parser = parsers[arg.type] 90 | ?: throw ParserNotRegistered("No parsers registered for `${arg.type}`") 91 | 92 | val (argument, original) = getNextArgument(arg.greedy) 93 | val (choiceCheck, choiceResolved, choiceMessage) = checkChoices(arg, argument) 94 | 95 | var result: Any? = null 96 | 97 | if (choiceResolved != null) { 98 | // use the resolved value if available 99 | result = choiceResolved 100 | } else if (choiceMessage == null) { 101 | // otherwise try and parse into the required type 102 | // this mustn't try and parse if choiceMessage is not null as it indicates 103 | // this argument has choices, so we should try and use those choices first 104 | result = argument.takeIf { it.isNotEmpty() }?.let { 105 | try { 106 | parser.parse(ctx, argument) 107 | } catch (e: Throwable) { 108 | throw BadArgument(arg, argument, e) 109 | } 110 | } 111 | } 112 | 113 | val canSubstitute = arg.isTentative || arg.isNullable || (arg.optional && argument.isEmpty()) 114 | val (rangeCheck, rangeMessage) = checkRange(arg, result) 115 | 116 | if (result == null || !rangeCheck || !choiceCheck) { 117 | if (!canSubstitute) { // canSubstitute -> Whether we can pass null or the default value. 118 | val cause = (rangeMessage ?: choiceMessage)?.let(::IllegalArgumentException) 119 | // This should throw if the result is not present, and one of the following is not true: 120 | // - The arg is marked tentative (isTentative) 121 | // - The arg can use null (isNullable) 122 | // - The arg has a default (isOptional) and no value was specified for it (argument.isEmpty()) 123 | 124 | //!arg.isNullable && (!arg.optional || argument.isNotEmpty())) { 125 | throw BadArgument(arg, argument, cause) 126 | } 127 | 128 | if (arg.isTentative) { 129 | restore(original) 130 | } 131 | } 132 | 133 | return result.takeIf { rangeCheck && choiceCheck } 134 | } 135 | 136 | private fun checkRange(arg: Argument, res: T): Pair { 137 | arg.range ?: return true to null 138 | 139 | if (res !is Number && res !is String) { 140 | return false to null 141 | } 142 | 143 | val double = arg.range.double 144 | val long = arg.range.long 145 | val string = arg.range.string 146 | 147 | @Suppress("KotlinConstantConditions") 148 | when { 149 | res is Number -> when { 150 | long.isNotEmpty() -> { 151 | val n = res.toLong() 152 | 153 | return when (long.size) { 154 | 1 -> (n >= long[0]) to "`${arg.name}` must be at least ${long[0]} or bigger" 155 | 2 -> (n >= long[0] && n <= long[1]) to "`${arg.name}` must be within range ${long.joinToString("-")}" 156 | else -> false to "Invalid long range for `${arg.name}`" 157 | } 158 | } 159 | double.isNotEmpty() -> { 160 | val n = res.toDouble() 161 | 162 | return when (double.size) { 163 | 1 -> (n >= double[0]) to "`${arg.name}` must be at least ${double[0]} or bigger." 164 | 2 -> (n >= double[0] && n <= double[1]) to "`${arg.name}` must be within range ${double.joinToString("-")}" 165 | else -> false to "Invalid double range for `${arg.name}`" 166 | } 167 | } 168 | } 169 | res is String && string.isNotEmpty() -> { 170 | val n = res.length 171 | 172 | return when (string.size) { 173 | 1 -> (n >= string[0]) to "`${arg.name}` must be at least ${string[0]} character${TextUtils.plural(string[0])} or longer." 174 | 2 -> (n >= string[0] && n <= string[1]) to "`${arg.name}` must be within the range of ${string.joinToString("-")} characters." 175 | else -> false to "Invalid string range for `${arg.name}`" 176 | } 177 | } 178 | } 179 | 180 | return true to null 181 | } 182 | 183 | /** 184 | * Checks whether provided [res] is a valid choice. 185 | * 186 | * This will return any of the following: 187 | * [true, null, null] - if there are no choices for this argument. 188 | * [true, Any, null] - if the choice was resolved. Any will be the choice value. 189 | * [false, null, null] - if there are choices, but [res] is not an applicable type. 190 | * [false, null, String] - if there are choices, but [res] matched none of them. String will be an error message. 191 | */ 192 | private fun checkChoices(arg: Argument, res: String?): Tuple { 193 | arg.choices ?: return Tuple(true, null, null) 194 | 195 | if (res !is String) { 196 | return Tuple(false, null, null) 197 | } 198 | 199 | val double = arg.choices.double 200 | val long = arg.choices.long 201 | val string = arg.choices.string 202 | 203 | val (resolved, error) = when { 204 | long.isNotEmpty() -> long.find { it.key == res }?.let { it.value to null } 205 | ?: (null to long.joinToString("`, `", prefix = "`", postfix = "`") { it.key }) 206 | double.isNotEmpty() -> double.find { it.key == res }?.let { it.value to null } 207 | ?: (null to double.joinToString("`, `", prefix = "`", postfix = "`") { it.key }) 208 | string.isNotEmpty() -> string.find { it.key == res }?.let { it.value to null } 209 | ?: (null to string.joinToString("`, `", prefix = "`", postfix = "`") { it.key }) 210 | else -> null to null 211 | } 212 | 213 | if (error != null) { 214 | return Tuple(false, null, "Invalid choice for `${arg.name}`.\nValid choices are: $error") 215 | } 216 | 217 | if (resolved != null) { 218 | return Tuple(true, resolved, null) 219 | } 220 | 221 | return Tuple(true, null, null) 222 | } 223 | 224 | companion object { 225 | val parsers = hashMapOf, Parser<*>>() 226 | 227 | fun parseArguments(cmd: Executable, ctx: MessageContext, args: List, delimiter: Char): HashMap { 228 | if (cmd.arguments.isEmpty()) { 229 | return hashMapOf() 230 | } 231 | 232 | val commandArgs = if (delimiter == ' ') args else args.joinToString(" ").split(delimiter).toMutableList() 233 | val parser = ArgParser(ctx, delimiter, commandArgs) 234 | val resolvedArgs = hashMapOf() 235 | 236 | for (arg in cmd.arguments) { 237 | val res = parser.parse(arg) 238 | val useValue = res != null || (arg.isNullable && !arg.optional) || (arg.isTentative && arg.isNullable) 239 | 240 | if (useValue) { 241 | //This will only place the argument into the map if the value is null, 242 | // or if the parameter requires a value (i.e. marked nullable). 243 | //Commands marked optional already have a parameter, so they don't need user-provided values 244 | // unless the argument was successfully resolved for that parameter. 245 | resolvedArgs[arg.parameter] = res 246 | } 247 | } 248 | 249 | return resolvedArgs 250 | } 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/internal/arguments/Argument.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.internal.arguments 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.Dispatchers 5 | import kotlinx.coroutines.launch 6 | import me.devoxin.flight.api.annotations.Choices 7 | import me.devoxin.flight.api.annotations.Describe 8 | import me.devoxin.flight.api.annotations.Range 9 | import me.devoxin.flight.api.entities.Cog 10 | import net.dv8tion.jda.api.entities.Member 11 | import net.dv8tion.jda.api.entities.Message 12 | import net.dv8tion.jda.api.entities.Role 13 | import net.dv8tion.jda.api.entities.User 14 | import net.dv8tion.jda.api.entities.channel.concrete.TextChannel 15 | import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel 16 | import net.dv8tion.jda.api.entities.channel.middleman.GuildChannel 17 | import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent 18 | import net.dv8tion.jda.api.interactions.commands.Command.Choice 19 | import net.dv8tion.jda.api.interactions.commands.OptionMapping 20 | import net.dv8tion.jda.api.interactions.commands.OptionType 21 | import net.dv8tion.jda.api.interactions.commands.build.OptionData 22 | import java.util.concurrent.ExecutorService 23 | import kotlin.reflect.KFunction 24 | import kotlin.reflect.KParameter 25 | import kotlin.reflect.full.callSuspend 26 | 27 | class Argument( 28 | /** The argument's parameter name */ 29 | val name: String, 30 | /** The argument's description, as given in the [Describe] annotation */ 31 | val description: String, 32 | val range: Range?, 33 | val choices: Choices?, 34 | /** The parameter type for this argument **/ 35 | val type: Class<*>, 36 | val greedy: Boolean, 37 | val optional: Boolean, // Denotes that a parameter has a default value. 38 | val isNullable: Boolean, 39 | val isTentative: Boolean, 40 | val autocompleteHandler: KFunction<*>?, 41 | internal val cog: Cog, 42 | val parameter: KParameter 43 | ) { 44 | val slashFriendlyName = name.replace(SLASH_NAME_REGEX, "_$1").lowercase() 45 | val autocompleteSupported = autocompleteHandler != null 46 | 47 | /** 48 | * Returns this argument as a [Pair]<[OptionType], [Boolean]>. 49 | * The [OptionType] represents the type of this argument. 50 | * The [Boolean] represents whether the argument is required. True if it is, false otherwise. 51 | */ 52 | fun asSlashCommandType(): OptionData { 53 | val optionType = OPTION_TYPE_MAPPING[type] 54 | ?: throw IllegalStateException("Unable to find OptionType for type ${type.simpleName}") 55 | 56 | val option = OptionData(optionType, slashFriendlyName, description, !isNullable && !optional, autocompleteSupported) 57 | 58 | range?.let { 59 | it.double.takeIf(DoubleArray::isNotEmpty)?.let { range -> 60 | option.setMinValue(range[0]) 61 | range.elementAtOrNull(1)?.let(option::setMaxValue) 62 | } 63 | 64 | it.long.takeIf(LongArray::isNotEmpty)?.let { range -> 65 | option.setMinValue(range[0]) 66 | range.elementAtOrNull(1)?.let(option::setMaxValue) 67 | } 68 | 69 | it.string.takeIf(IntArray::isNotEmpty)?.let { range -> 70 | option.setMinLength(range[0]) 71 | range.elementAtOrNull(1)?.let(option::setMaxLength) 72 | } 73 | } 74 | 75 | choices?.let { choices -> 76 | choices.double.takeIf { it.isNotEmpty() }?.let { option.addChoices(it.map { c -> Choice(c.key, c.value) }) } 77 | choices.long.takeIf { it.isNotEmpty() }?.let { option.addChoices(it.map { c -> Choice(c.key, c.value) }) } 78 | choices.string.takeIf { it.isNotEmpty() }?.let { option.addChoices(it.map { c -> Choice(c.key, c.value) }) } 79 | } 80 | 81 | return option 82 | } 83 | 84 | @Suppress("IMPLICIT_CAST_TO_ANY") 85 | fun getEntityFromOptionMapping(mapping: OptionMapping): Pair { 86 | val mappingType = when (mapping.type) { 87 | OptionType.STRING -> mapping.asString 88 | OptionType.INTEGER -> mapping.asLong 89 | OptionType.BOOLEAN -> mapping.asBoolean 90 | OptionType.USER -> { 91 | when (type) { 92 | Member::class.java -> mapping.asMember 93 | User::class.java -> mapping.asUser 94 | else -> throw IllegalStateException("OptionType is user but argument type is ${type.simpleName}") 95 | } 96 | } 97 | OptionType.CHANNEL -> mapping.asChannel 98 | OptionType.ROLE -> mapping.asRole 99 | OptionType.NUMBER -> when (type) { 100 | Float::class.java, java.lang.Float::class.java -> mapping.asDouble.toFloat() 101 | else -> mapping.asDouble 102 | } 103 | else -> throw IllegalStateException("Unsupported OptionType ${mapping.type.name}") 104 | } 105 | 106 | return parameter to mappingType 107 | } 108 | 109 | fun format(withType: Boolean): String { 110 | return buildString { 111 | if (optional || isNullable) { 112 | append('[') 113 | } else { 114 | append('<') 115 | } 116 | 117 | append(name) 118 | 119 | if (withType) { 120 | append(": ") 121 | append(type.simpleName) 122 | } 123 | 124 | if (optional || isNullable) { 125 | append(']') 126 | } else { 127 | append('>') 128 | } 129 | } 130 | } 131 | 132 | fun executeAutocomplete(event: CommandAutoCompleteInteractionEvent, callback: (Throwable?) -> Unit, executor: ExecutorService?) { 133 | if (autocompleteHandler == null) { 134 | return callback(IllegalStateException("Cannot process autocomplete event as $name does not have a registered handler!")) 135 | } 136 | 137 | if (autocompleteHandler.isSuspend) { 138 | DEFAULT_DISPATCHER.launch { 139 | executeAutocompleteAsync(event, callback) 140 | } 141 | } else { 142 | executor?.execute { executeAutocompleteSync(event, callback) } 143 | ?: executeAutocompleteSync(event, callback) 144 | } 145 | } 146 | 147 | private fun executeAutocompleteSync(event: CommandAutoCompleteInteractionEvent, callback: (Throwable?) -> Unit) { 148 | try { 149 | autocompleteHandler?.call(cog, event) 150 | callback(null) 151 | } catch (e: Throwable) { 152 | callback(e) 153 | } 154 | } 155 | 156 | private suspend fun executeAutocompleteAsync(event: CommandAutoCompleteInteractionEvent, callback: (Throwable?) -> Unit) { 157 | try { 158 | autocompleteHandler?.callSuspend(cog, event) 159 | callback(null) 160 | } catch (e: Throwable) { 161 | callback(e) 162 | } 163 | } 164 | 165 | companion object { 166 | private val DEFAULT_DISPATCHER = CoroutineScope(Dispatchers.Default) 167 | 168 | val SLASH_NAME_REGEX = "((?<=[a-z])[A-Z]|[A-Z](?=[a-z]))".toRegex() 169 | 170 | val OPTION_TYPE_MAPPING = mapOf( 171 | String::class.java to OptionType.STRING, 172 | 173 | Integer::class.java to OptionType.INTEGER, 174 | java.lang.Integer::class.java to OptionType.INTEGER, 175 | 176 | Long::class.java to OptionType.INTEGER, 177 | java.lang.Long::class.java to OptionType.INTEGER, 178 | 179 | Double::class.java to OptionType.NUMBER, 180 | java.lang.Double::class.java to OptionType.NUMBER, 181 | Float::class.java to OptionType.NUMBER, 182 | java.lang.Float::class.java to OptionType.NUMBER, 183 | 184 | Boolean::class.java to OptionType.BOOLEAN, 185 | java.lang.Boolean::class.java to OptionType.BOOLEAN, 186 | 187 | Member::class.java to OptionType.USER, 188 | User::class.java to OptionType.USER, 189 | GuildChannel::class.java to OptionType.CHANNEL, 190 | TextChannel::class.java to OptionType.CHANNEL, 191 | VoiceChannel::class.java to OptionType.CHANNEL, 192 | Role::class.java to OptionType.ROLE, 193 | Message.Attachment::class.java to OptionType.ATTACHMENT 194 | ) 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/internal/entities/Executable.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.internal.entities 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.Dispatchers 5 | import kotlinx.coroutines.launch 6 | import me.devoxin.flight.api.context.Context 7 | import me.devoxin.flight.api.entities.Cog 8 | import me.devoxin.flight.internal.arguments.Argument 9 | import net.dv8tion.jda.api.interactions.commands.OptionMapping 10 | import net.dv8tion.jda.api.interactions.commands.OptionType 11 | import java.util.concurrent.ExecutorService 12 | import kotlin.reflect.KFunction 13 | import kotlin.reflect.KParameter 14 | import kotlin.reflect.full.* 15 | 16 | abstract class Executable( 17 | val name: String, 18 | val method: KFunction<*>, 19 | val cog: Cog, 20 | val arguments: List, 21 | val contextParameter: KParameter 22 | ) { 23 | fun resolveArguments(options: List): HashMap { 24 | val mapping = hashMapOf() 25 | 26 | for (argument in arguments) { 27 | val option = options.firstOrNull { it.name == argument.slashFriendlyName } 28 | 29 | if (option == null) { 30 | if (argument.isNullable && !argument.optional) { 31 | mapping += argument.parameter to null 32 | continue 33 | } 34 | 35 | if (argument.optional) { 36 | continue 37 | } 38 | 39 | throw IllegalStateException("Missing option for argument ${argument.name}") 40 | } 41 | 42 | mapping += if (option.type == OptionType.INTEGER && (argument.type == Integer::class.java || argument.type == java.lang.Integer::class.java)) { 43 | val (param, value) = argument.getEntityFromOptionMapping(option) 44 | @Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN") 45 | param to (value as java.lang.Long).toInt() 46 | } else { 47 | argument.getEntityFromOptionMapping(option) 48 | } 49 | } 50 | 51 | return mapping 52 | } 53 | 54 | open fun execute(ctx: Context, args: HashMap, complete: (Boolean, Throwable?) -> Unit, executor: ExecutorService?) { 55 | method.instanceParameter?.let { args[it] = cog } 56 | args[contextParameter] = ctx 57 | 58 | if (method.isSuspend) { 59 | DEFAULT_DISPATCHER.launch { 60 | executeAsync(args, complete) 61 | } 62 | } else { 63 | executor?.execute { executeSync(args, complete) } 64 | ?: executeSync(args, complete) 65 | } 66 | } 67 | 68 | /** 69 | * Calls the related method with the given args. 70 | */ 71 | private fun executeSync(args: HashMap, complete: (Boolean, Throwable?) -> Unit) { 72 | try { 73 | method.callBy(args) 74 | complete(true, null) 75 | } catch (e: Throwable) { 76 | complete(false, e.cause ?: e) 77 | } 78 | } 79 | 80 | /** 81 | * Calls the related method with the given args, except in an async manner. 82 | */ 83 | private suspend fun executeAsync(args: HashMap, complete: (Boolean, Throwable?) -> Unit) { 84 | try { 85 | method.callSuspendBy(args) 86 | complete(true, null) 87 | } catch (e: Throwable) { 88 | complete(false, e.cause ?: e) 89 | } 90 | } 91 | 92 | companion object { 93 | private val DEFAULT_DISPATCHER = CoroutineScope(Dispatchers.Default) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/internal/entities/Jar.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.internal.entities 2 | 3 | import java.net.URLClassLoader 4 | 5 | class Jar( 6 | val name: String, 7 | val location: String, 8 | val packageName: String, 9 | private val classLoader: URLClassLoader 10 | ) { 11 | 12 | internal fun close() { 13 | classLoader.close() 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/internal/entities/WaitingEvent.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.internal.entities 2 | 3 | import net.dv8tion.jda.api.events.GenericEvent 4 | import java.util.concurrent.CompletableFuture 5 | 6 | @Suppress("UNCHECKED_CAST") 7 | class WaitingEvent( 8 | private val eventClass: Class<*>, 9 | private val predicate: (T) -> Boolean, 10 | private val future: CompletableFuture 11 | ) { 12 | 13 | fun check(event: GenericEvent) = eventClass.isAssignableFrom(event::class.java) && predicate(event as T) 14 | 15 | fun accept(event: GenericEvent) = future.complete(event as T) 16 | 17 | } 18 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/internal/parsers/BooleanParser.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.internal.parsers 2 | 3 | import me.devoxin.flight.api.context.MessageContext 4 | import java.util.* 5 | 6 | class BooleanParser : Parser { 7 | override fun parse(ctx: MessageContext, param: String): Boolean? { 8 | return when (param) { 9 | in trueExpr -> true 10 | in falseExpr -> false 11 | else -> null 12 | } 13 | } 14 | 15 | companion object { 16 | val trueExpr = listOf("yes", "y", "true", "t", "1", "enable", "on") 17 | val falseExpr = listOf("no", "n", "false", "f", "0", "disable", "off") 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/internal/parsers/DoubleParser.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.internal.parsers 2 | 3 | import me.devoxin.flight.api.context.MessageContext 4 | import java.util.* 5 | 6 | class DoubleParser : Parser { 7 | override fun parse(ctx: MessageContext, param: String): Double? { 8 | return param.toDoubleOrNull() 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/internal/parsers/EmojiParser.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.internal.parsers 2 | 3 | import me.devoxin.flight.api.context.MessageContext 4 | import me.devoxin.flight.api.arguments.types.Emoji 5 | import java.util.* 6 | 7 | class EmojiParser : Parser { 8 | // TODO: Support unicode emoji? 9 | override fun parse(ctx: MessageContext, param: String): Emoji? { 10 | val match = EMOJI_PATTERN.matcher(param) 11 | 12 | if (match.find()) { 13 | val isAnimated = match.group(1) != null 14 | val name = match.group(2) 15 | val id = match.group(3).toLong() 16 | 17 | return Emoji(name, id, isAnimated) 18 | } 19 | 20 | return null 21 | } 22 | 23 | companion object { 24 | val EMOJI_PATTERN = "<(a)?:(\\w+):(\\d{17,21})".toPattern() 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/internal/parsers/FloatParser.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.internal.parsers 2 | 3 | import me.devoxin.flight.api.context.MessageContext 4 | import java.util.* 5 | 6 | class FloatParser : Parser { 7 | override fun parse(ctx: MessageContext, param: String): Float? { 8 | return param.toFloatOrNull() 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/internal/parsers/IntParser.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.internal.parsers 2 | 3 | import me.devoxin.flight.api.context.MessageContext 4 | import java.util.* 5 | 6 | class IntParser : Parser { 7 | override fun parse(ctx: MessageContext, param: String): Int? { 8 | return param.toIntOrNull() 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/internal/parsers/InviteParser.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.internal.parsers 2 | 3 | import me.devoxin.flight.api.context.MessageContext 4 | import me.devoxin.flight.api.arguments.types.Invite 5 | import java.util.* 6 | 7 | class InviteParser : Parser { 8 | override fun parse(ctx: MessageContext, param: String): Invite? { 9 | val match = INVITE_PATTERN.matcher(param) 10 | 11 | if (match.find()) { 12 | val code = match.group(1) 13 | return Invite(ctx.jda, match.group(), code) 14 | } 15 | 16 | return null 17 | } 18 | 19 | companion object { 20 | val INVITE_PATTERN = "discord(?:(?:app)?\\.com/invite|\\.gg)/([a-zA-Z\\d]{1,16})".toPattern() 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/internal/parsers/LongParser.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.internal.parsers 2 | 3 | import me.devoxin.flight.api.context.MessageContext 4 | import java.util.* 5 | 6 | class LongParser : Parser { 7 | override fun parse(ctx: MessageContext, param: String): Long? { 8 | return param.toLongOrNull() 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/internal/parsers/MemberParser.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.internal.parsers 2 | 3 | import me.devoxin.flight.api.context.MessageContext 4 | import net.dv8tion.jda.api.entities.Member 5 | import java.util.* 6 | 7 | class MemberParser : Parser { 8 | override fun parse(ctx: MessageContext, param: String): Member? { 9 | val snowflake = SnowflakeParser.INSTANCE.parse(ctx, param)?.resolved 10 | 11 | val member = when { 12 | snowflake != null -> ctx.message.mentions.members.firstOrNull { it.user.idLong == snowflake } ?: ctx.guild?.getMemberById(snowflake) 13 | param.length > 5 && param[param.length - 5] == '#' -> { 14 | val tag = param.split("#") 15 | ctx.guild?.memberCache?.find { (it.user.discriminator != "0000" && it.user.name == tag[0]) || it.user.asTag == param } 16 | } 17 | else -> ctx.guild?.memberCache?.firstOrNull { it.user.name == param } 18 | } 19 | 20 | return member 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/internal/parsers/Parser.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.internal.parsers 2 | 3 | import me.devoxin.flight.api.context.MessageContext 4 | import java.util.* 5 | 6 | interface Parser { 7 | fun parse(ctx: MessageContext, param: String): T? 8 | } 9 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/internal/parsers/RoleParser.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.internal.parsers 2 | 3 | import me.devoxin.flight.api.context.MessageContext 4 | import net.dv8tion.jda.api.entities.Role 5 | import java.util.* 6 | 7 | class RoleParser : Parser { 8 | override fun parse(ctx: MessageContext, param: String): Role? { 9 | val snowflake = SnowflakeParser.INSTANCE.parse(ctx, param)?.resolved 10 | 11 | return when { 12 | snowflake != null -> ctx.guild?.getRoleById(snowflake) 13 | else -> if (param == "everyone") ctx.guild?.publicRole else ctx.guild?.roleCache?.firstOrNull { it.name == param } 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/internal/parsers/SnowflakeParser.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.internal.parsers 2 | 3 | import me.devoxin.flight.api.context.MessageContext 4 | import me.devoxin.flight.api.arguments.types.Snowflake 5 | import java.util.* 6 | 7 | class SnowflakeParser : Parser { 8 | override fun parse(ctx: MessageContext, param: String): Snowflake? { 9 | val match = SNOWFLAKE_PATTERN.matcher(param) 10 | 11 | if (match.matches()) { 12 | val id = match.group("sid") ?: match.group("id") 13 | return Snowflake(id.toLong()) 14 | } 15 | 16 | return null 17 | } 18 | 19 | companion object { 20 | internal val INSTANCE = SnowflakeParser() 21 | val SNOWFLAKE_PATTERN = "^(?:<(?:@!?|@&|#)(?\\d{17,21})>|(?\\d{17,21}))$".toPattern() 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/internal/parsers/StringParser.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.internal.parsers 2 | 3 | import me.devoxin.flight.api.context.MessageContext 4 | import java.util.* 5 | 6 | class StringParser : Parser { 7 | override fun parse(ctx: MessageContext, param: String): String? { 8 | return param.takeIf { it.isNotEmpty() && it.isNotBlank() } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/internal/parsers/TextChannelParser.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.internal.parsers 2 | 3 | import me.devoxin.flight.api.context.MessageContext 4 | import net.dv8tion.jda.api.entities.channel.concrete.TextChannel 5 | import java.util.* 6 | 7 | class TextChannelParser : Parser { 8 | override fun parse(ctx: MessageContext, param: String): TextChannel? { 9 | val snowflake = SnowflakeParser.INSTANCE.parse(ctx, param)?.resolved 10 | 11 | return when { 12 | snowflake != null -> ctx.guild?.getTextChannelById(snowflake) 13 | else -> ctx.guild?.textChannelCache?.firstOrNull { it.name == param } 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/internal/parsers/UrlParser.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.internal.parsers 2 | 3 | import me.devoxin.flight.api.context.MessageContext 4 | import java.net.URL 5 | import java.util.* 6 | 7 | class UrlParser : Parser { 8 | override fun parse(ctx: MessageContext, param: String): URL? { 9 | return try { 10 | URL(param) 11 | } catch (e: Throwable) { 12 | null 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/internal/parsers/UserParser.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.internal.parsers 2 | 3 | import me.devoxin.flight.api.context.MessageContext 4 | import net.dv8tion.jda.api.entities.User 5 | import java.util.* 6 | 7 | class UserParser : Parser { 8 | override fun parse(ctx: MessageContext, param: String): User? { 9 | val snowflake = snowflakeParser.parse(ctx, param)?.resolved 10 | 11 | val user = when { 12 | snowflake != null -> ctx.message.mentions.users.firstOrNull { it.idLong == snowflake } ?: ctx.jda.getUserById(snowflake) 13 | param.length > 5 && param[param.length - 5] == '#' -> { 14 | val tag = param.split("#") 15 | ctx.jda.userCache.find { (it.discriminator != "0000" && it.name == tag[0]) || it.asTag == param } 16 | } 17 | else -> ctx.jda.userCache.find { it.name == param } 18 | } 19 | 20 | return user 21 | } 22 | 23 | companion object { 24 | private val snowflakeParser = SnowflakeParser() // We can reuse this 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/internal/parsers/VoiceChannelParser.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.internal.parsers 2 | 3 | import me.devoxin.flight.api.context.MessageContext 4 | import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel 5 | import java.util.* 6 | 7 | class VoiceChannelParser : Parser { 8 | override fun parse(ctx: MessageContext, param: String): VoiceChannel? { 9 | val snowflake = SnowflakeParser.INSTANCE.parse(ctx, param)?.resolved 10 | 11 | return when { 12 | snowflake != null -> ctx.guild?.getVoiceChannelById(snowflake) 13 | else -> ctx.guild?.voiceChannelCache?.firstOrNull { it.name == param } 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/internal/utils/Indexer.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.internal.utils 2 | 3 | import me.devoxin.flight.api.CommandFunction 4 | import me.devoxin.flight.api.context.Context 5 | import me.devoxin.flight.api.SubCommandFunction 6 | import me.devoxin.flight.api.annotations.* 7 | import me.devoxin.flight.internal.arguments.Argument 8 | import me.devoxin.flight.internal.entities.Jar 9 | import me.devoxin.flight.api.entities.Cog 10 | import me.devoxin.flight.api.entities.ObjectStorage 11 | import org.reflections.Reflections 12 | import org.reflections.scanners.MethodParameterNamesScanner 13 | import org.reflections.scanners.Scanners 14 | import org.slf4j.LoggerFactory 15 | import java.io.File 16 | import java.lang.reflect.Constructor 17 | import java.lang.reflect.Modifier 18 | import java.net.URL 19 | import java.net.URLClassLoader 20 | import kotlin.reflect.KFunction 21 | import kotlin.reflect.KParameter 22 | import kotlin.reflect.full.* 23 | import kotlin.reflect.jvm.javaMethod 24 | import kotlin.reflect.jvm.jvmErasure 25 | 26 | class Indexer { 27 | private val jar: Jar? 28 | private val packageName: String 29 | private val reflections: Reflections 30 | private val classLoader: URLClassLoader? 31 | 32 | constructor(packageName: String) { 33 | this.packageName = packageName 34 | this.classLoader = null 35 | this.jar = null 36 | reflections = Reflections(packageName, MethodParameterNamesScanner(), Scanners.SubTypes) 37 | } 38 | 39 | constructor(packageName: String, jarPath: String) { 40 | this.packageName = packageName 41 | 42 | val commandJar = File(jarPath) 43 | check(commandJar.exists()) { "jarPath points to a non-existent file." } 44 | check(commandJar.extension == "jar") { "jarPath leads to a file which is not a jar." } 45 | 46 | val path = URL("jar:file:${commandJar.absolutePath}!/") 47 | this.classLoader = URLClassLoader.newInstance(arrayOf(path)) 48 | this.jar = Jar(commandJar.nameWithoutExtension, commandJar.absolutePath, packageName, classLoader) 49 | reflections = Reflections(packageName, this.classLoader, MethodParameterNamesScanner(), Scanners.SubTypes) 50 | } 51 | 52 | fun getCogs(objectStorage: ObjectStorage): List { 53 | val cogs = reflections.getSubTypesOf(Cog::class.java) 54 | log.debug("Discovered ${cogs.size} cogs in $packageName") 55 | 56 | return cogs 57 | .filter { !Modifier.isAbstract(it.modifiers) && !it.isInterface && Cog::class.java.isAssignableFrom(it) } 58 | .map { construct(it, objectStorage) } 59 | } 60 | 61 | fun getCommands(cog: Cog): List> { 62 | log.debug("Scanning ${cog::class.simpleName} for commands...") 63 | 64 | val cogClass = cog::class 65 | val commands = cogClass.members 66 | .filterIsInstance>() 67 | .filter { it.hasAnnotation() } 68 | 69 | log.debug("Found ${commands.size} commands in cog ${cog::class.simpleName}") 70 | return commands.toList() 71 | } 72 | 73 | fun loadCommand(meth: KFunction<*>, cog: Cog): CommandFunction { 74 | require(meth.javaMethod!!.declaringClass == cog::class.java) { "${meth.name} is not from ${cog::class.simpleName}" } 75 | require(meth.hasAnnotation()) { "${meth.name} is not annotated with Command!" } 76 | 77 | val categoryOriginal = cog.name() 78 | ?: cog::class.java.`package`.name.split('.').last().replace('_', ' ') 79 | val category = TextUtils.capitalise(categoryOriginal) 80 | val name = meth.name.lowercase() 81 | val properties = meth.findAnnotation()!! 82 | val cooldown = meth.findAnnotation() 83 | val ctxParam = meth.valueParameters.firstOrNull { it.type.isSubtypeOf(Context::class.starProjectedType) } 84 | 85 | require(ctxParam != null) { "${meth.name} is missing the Context parameter!" } 86 | 87 | val parameters = meth.valueParameters.filter { it != ctxParam } 88 | val arguments = loadParameters(cog, parameters) 89 | val subcommands = getSubCommands(cog) 90 | 91 | val cogParentCommands = cog::class.functions.filter { m -> m.annotations.any { it is Command } } 92 | 93 | if (subcommands.isNotEmpty() && cogParentCommands.size > 1) { 94 | throw IllegalStateException("Sub-commands are present within ${cog::class.simpleName} however there are multiple top-level commands!") 95 | } 96 | 97 | return CommandFunction(name, category, properties, cooldown, jar, subcommands, meth, cog, arguments, ctxParam) 98 | } 99 | 100 | fun getSubCommands(cog: Cog): List { 101 | log.debug("Scanning ${cog::class.simpleName} for sub-commands...") 102 | 103 | val cogClass = cog::class 104 | val subcommands = cogClass.members 105 | .filterIsInstance>() 106 | .filter { it.hasAnnotation() } 107 | .map { loadSubCommand(it, cog) } 108 | 109 | log.debug("Found ${subcommands.size} sub-commands in cog ${cog::class.simpleName}") 110 | return subcommands.toList() 111 | } 112 | 113 | private fun loadSubCommand(meth: KFunction<*>, cog: Cog): SubCommandFunction { 114 | require(meth.javaMethod!!.declaringClass == cog::class.java) { "${meth.name} is not from ${cog::class.simpleName}" } 115 | require(meth.hasAnnotation()) { "${meth.name} is not annotated with SubCommand!" } 116 | 117 | val name = meth.name.lowercase() 118 | val properties = meth.findAnnotation()!! 119 | val ctxParam = meth.valueParameters.firstOrNull { it.type.isSubtypeOf(Context::class.starProjectedType) } 120 | 121 | require(ctxParam != null) { "${meth.name} is missing the Context parameter!" } 122 | 123 | val parameters = meth.valueParameters.filter { it != ctxParam } 124 | val arguments = loadParameters(cog, parameters) 125 | 126 | return SubCommandFunction(name, properties, meth, cog, arguments, ctxParam) 127 | } 128 | 129 | private fun loadParameters(cog: Cog, parameters: List): List { 130 | val arguments = mutableListOf() 131 | 132 | for (p in parameters) { 133 | val name = p.findAnnotation()?.value ?: p.name ?: p.index.toString() 134 | val description = p.findAnnotation()?.value ?: "No description available." 135 | val range = p.findAnnotation() 136 | val choices = p.findAnnotation() 137 | val type = p.type.jvmErasure.javaObjectType 138 | val isGreedy = p.hasAnnotation() 139 | val isOptional = p.isOptional 140 | val isNullable = p.type.isMarkedNullable 141 | val isTentative = p.hasAnnotation() 142 | val autocomplete = p.findAnnotation() 143 | val autocompleteMethod = autocomplete?.method?.let { cog::class.functions.find { f -> f.name == it } } 144 | 145 | if (isTentative && !(isNullable || isOptional)) { 146 | throw IllegalStateException("${p.name} is marked as tentative, but does not have a default value and is not marked nullable!") 147 | } 148 | 149 | if (autocomplete != null && autocompleteMethod == null) { 150 | throw IllegalStateException("Couldn't find autocompleteMethod with name ${autocomplete.method} for parameter ${p.name}") 151 | } 152 | 153 | arguments.add(Argument(name, description, range, choices, type, isGreedy, isOptional, isNullable, isTentative, autocompleteMethod, cog, p)) 154 | } 155 | 156 | return arguments 157 | } 158 | 159 | private fun construct(cls: Class, objectStorage: ObjectStorage): Cog { 160 | return try { 161 | cls.getDeclaredConstructor(ObjectStorage::class.java).newInstance(objectStorage) 162 | } catch (t: NoSuchMethodException) { 163 | cls.getDeclaredConstructor().newInstance() 164 | } 165 | } 166 | 167 | companion object { 168 | private val log = LoggerFactory.getLogger(Indexer::class.java) 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/internal/utils/Scheduler.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.internal.utils 2 | 3 | import java.util.concurrent.Executors 4 | import java.util.concurrent.ScheduledFuture 5 | import java.util.concurrent.TimeUnit 6 | 7 | object Scheduler { 8 | private val schd = Executors.newSingleThreadScheduledExecutor() 9 | 10 | fun every(milliseconds: Long, block: () -> Unit): ScheduledFuture<*> { 11 | return schd.scheduleAtFixedRate(block, milliseconds, milliseconds, TimeUnit.MILLISECONDS) 12 | } 13 | } -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/internal/utils/TextUtils.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.internal.utils 2 | 3 | object TextUtils { 4 | fun split(content: String, limit: Int = 2000): List { 5 | if (content.isEmpty()) { 6 | return emptyList() 7 | } else if (content.length < limit) { 8 | return listOf(content) 9 | } 10 | 11 | val pages = mutableListOf() 12 | 13 | val lines = content.trim().split("\n").dropLastWhile { it.isEmpty() } 14 | val chunk = StringBuilder(limit) 15 | 16 | for (line in lines) { 17 | if (chunk.isNotEmpty() && chunk.length + line.length > limit) { 18 | pages.add(chunk.toString()) 19 | chunk.setLength(0) 20 | } 21 | 22 | if (line.length > limit) { 23 | val lineChunks = line.length / limit 24 | 25 | for (i in 0 until lineChunks) { 26 | val start = limit * i 27 | val end = start + limit 28 | pages.add(line.substring(start, end)) 29 | } 30 | } else { 31 | chunk.append(line).append("\n") 32 | } 33 | } 34 | 35 | if (chunk.isNotEmpty()) { 36 | pages.add(chunk.toString()) 37 | } 38 | 39 | return pages.toList() 40 | } 41 | 42 | fun capitalise(s: String): String = s.lowercase().replaceFirstChar { it.uppercase() } 43 | 44 | fun plural(num: Number): String = if (num == 1) "" else "s" 45 | 46 | fun truncate(s: String, maxLength: Int) = s.takeIf { it.length <= maxLength } ?: (s.take(maxLength - 3) + "...") 47 | 48 | fun toTitleCase(s: String) = s.split(" +".toRegex()).joinToString(" ", transform = ::capitalise) 49 | } 50 | -------------------------------------------------------------------------------- /src/main/kotlin/me/devoxin/flight/internal/utils/Tuple.kt: -------------------------------------------------------------------------------- 1 | package me.devoxin.flight.internal.utils 2 | 3 | data class Tuple(val first: A, val second: B, val third: C) 4 | --------------------------------------------------------------------------------