├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── build.gradle.kts ├── changelog.md ├── commands.md ├── docker └── Dockerfile ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── setup.md └── src └── main ├── kotlin └── me │ └── jakejmattson │ └── modmail │ ├── Main.kt │ ├── commands │ ├── ConfigurationCommands.kt │ ├── MacroCommands.kt │ ├── ReportCommands.kt │ └── ReportHelperCommands.kt │ ├── conversations │ └── GuildChoiceConversation.kt │ ├── extensions │ └── EntityExtensions.kt │ ├── listeners │ ├── ChannelDeletionListener.kt │ ├── EditListener.kt │ ├── GuildMigrationListener.kt │ └── ReportListener.kt │ ├── preconditions │ ├── PrefixPrecondition.kt │ └── ValidGuildPrecondition.kt │ └── services │ ├── Configuration.kt │ ├── FileService.kt │ ├── Locale.kt │ ├── LoggingService.kt │ ├── MacroService.kt │ ├── ModerationService.kt │ └── ReportService.kt └── resources └── bot.properties /.gitignore: -------------------------------------------------------------------------------- 1 | # IntelliJ 2 | .idea/ 3 | *.iml 4 | 5 | # Gradle 6 | .gradle/ 7 | build/ 8 | gradle.properties 9 | 10 | data/ 11 | velocity.log 12 | old-conf/ -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ### Branching scheme 2 | 3 | **master** -> This only receives fully fledged updates. E.g. 2.0.0, 2.1.1, etc 4 | 5 | **develop** -> This only receives merges of fully fleshed out feature branches, 6 | e.g. feature/autoclose, feature/autosetup 7 | 8 | **feature/** -> This is an example feature branch, any feature 9 | should be contained within a feature branch. 10 | 11 | 12 | ### Merge checklist 13 | 14 | - [ ] Branch is named appropriately, e.g. feature/pingcommand 15 | - [ ] Branch is based on develop 16 | - [ ] All tests in place (if any) pass 17 | - [ ] Any new dependencies added are adequately explained 18 | - [ ] All changes for the final change set are documented properly in your merge 19 | - [ ] The bot has been tested with the docker container with a newly generated config 20 | - [ ] If the functionality is complex, screenshots are supplied 21 | - [ ] If the version of kutils is being upgraded, all commands have been retested 22 | 23 | 24 | ### Issue Checklist 25 | - [ ] You have checked current issues 26 | - [ ] You have provided an adequate description of the issue 27 | - [ ] Current functionality is outlined clearly 28 | - [ ] Desired functionality is outlined clearly 29 | 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Fox 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | Kotlin 4 | 5 | 6 | DiscordKt 7 | 8 |
9 | 10 | Discord JakeyWakey#1569 11 | 12 |

13 | 14 | # ModMail 15 | 16 | ModMail is a Discord bot designed to provide a communication system between server staff and other members. 17 | 18 | ## Reports 19 | 20 | Reports are private text channels that allow the entire staff team to communicate with a single member. 21 | 22 | ![Reports](https://i.imgur.com/7vgwc9E.png) 23 | 24 | ### Creating Reports 25 | 26 | Reports will be opened automatically whenever a user messages the bot. 27 | The `/Open` command can be used to open a report manually, or `/Detain` if you want to mute them as well. 28 | 29 | ### Using a Report 30 | 31 | Once a report is opened, anyone with access to this private channel can talk with the user through the bot. 32 | The user only needs to talk with the bot like a normal DM. 33 | 34 | #### Closing a Report 35 | 36 | ##### From Discord 37 | 38 | * Delete the channel - ModMail will detect the event and close the report for you. 39 | 40 | ##### Using Commands 41 | 42 | * `/Close` - This has the same effect as deleting the channel. 43 | * `/Archive` - Transcribes the report to text, archives it, then closes the report. 44 | 45 | ## Setup 46 | 47 | Refer to the [setup](setup.md) instructions. 48 | 49 | ## Commands 50 | 51 | To see available commands, use `Help` or read the [commands](commands.md) documentation. -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | group = "me.jakejmattson" 2 | version = "4.0.0" 3 | description = "A report management bot" 4 | 5 | plugins { 6 | kotlin("jvm") version "1.7.20" 7 | kotlin("plugin.serialization") version "1.7.20" 8 | id("com.github.johnrengelman.shadow") version "7.1.2" 9 | id("com.github.ben-manes.versions") version "0.43.0" 10 | } 11 | 12 | repositories { 13 | mavenCentral() 14 | } 15 | 16 | dependencies { 17 | implementation("me.jakejmattson:DiscordKt:0.23.4") 18 | } 19 | 20 | tasks { 21 | compileKotlin { 22 | kotlinOptions.jvmTarget = "1.8" 23 | dependsOn("writeProperties") 24 | } 25 | 26 | register("writeProperties") { 27 | property("name", project.name) 28 | property("description", project.description.toString()) 29 | property("version", version.toString()) 30 | property("url", "https://github.com/JakeJMattson/ModMail") 31 | setOutputFile("src/main/resources/bot.properties") 32 | } 33 | 34 | shadowJar { 35 | archiveFileName.set("ModMail.jar") 36 | manifest { 37 | attributes("Main-Class" to "me.jakejmattson.modmail.MainKt") 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | # Version 3.0.2 2 | 3 | ### Misc Changes 4 | Upgraded from KUtils 0.9.17 to KUtils 0.13.0 and implemented the following upgrades 5 | ``` 6 | * Type inference - Command arguments are now type inferred; used destructuring where possible. 7 | * Improved Help - Removed ListCommands in favor of the improved KUtils help system. 8 | * Mention embed - Added a response on ping and replaced the following: Source, Author, BotInfo, Version 9 | * Permissions - Added an improved permissions system and replaced the duplicate preconditions. 10 | * Conversations - Conversations were completely re-written to match the new standard. 11 | ``` 12 | 13 | ### Bug Fixes 14 | ``` 15 | * ResetTags - Resetting tags will now work with users who have spaces in their names. 16 | ``` 17 | 18 | # Version 3.0.1 19 | 20 | ### New Commands 21 | ``` 22 | * IsReport - Check whether or not a channel is a valid report channel. 23 | * PeekHistory - Read the target user's DM history with the bot. 24 | ``` 25 | 26 | ### Misc Changes 27 | ``` 28 | * AuditLogPollingService - This feature was removed. Manual channel deletions now refer you to the audit log. 29 | * Logging service - The logging service will now log various errors as well as previous information. 30 | * Better archives - Default notes including the user's ID are now added to all archived reports. 31 | * Detain command - The detain command can now take an initial message and can no longer detain staff. 32 | * Macro persistence - Macros are now saved during runtime and loaded in on startup. 33 | * Docker persistence - Persistent data is now mapped to disk and recoverable outside a docker container. 34 | * Documentation service - Add in the DocumentationService for generating documentation at runtime. 35 | ``` 36 | 37 | ### Bug Fixes 38 | ``` 39 | * Improper unmute - Closing the report of a user who is not detained will no longer unmute them. 40 | * Migration embeds - Migration embeds are no longer triggered by user migration in a shared server. 41 | * Fail reaction - Fail reactions will now be added into reports, even with no shared server. 42 | * Archive logging - Archived reports will no longer trigger the manual deletion logging message. 43 | * Embed thumbnails - Fixed thumbnails in embeds where the user has a default avatar (no pfp set). 44 | ``` 45 | 46 | # Version 3.0.0 47 | 48 | ### New Features 49 | ``` 50 | * Auto setup - Automatically create required channels. If any of these channels exist, smart bind by name. 51 | * Category sync - The move command will now sync permissions with the new category. (Option to prevent sync) 52 | * Log commands - Remove more in-place embeds and with plain text logs sent to the logging channel. 53 | * JUnit tests - Added the backbone for adding tests and implemented for several command sets. 54 | * Detainment - Added commands and services that allow staff to mute users and begin a dialog. 55 | * De-activation - When a user leaves a server, their report is now deactivated. No messages are propagated. 56 | * Fail reaction - When a message is not delivered (currently due to deactivation), the bot will react with a red X. 57 | * Rejoin resume - When a user rejoins a server with an active report, it will be reactivated and the report will be notified. 58 | * Channel config - Staff channels are now configurable insterad of automatic. The automatic process was too fragile. 59 | * Macros - Add pre-configured messages that can be sent through reports. This prevents repeated re-typing. 60 | ``` 61 | 62 | ### New Commands 63 | ``` 64 | * SetPresence - Set the Discord presence of the bot. 65 | * Detain - Mute a user and open a report with them. 66 | * Release - Remove a user from the detainment list and unmute them. 67 | * AddStaffChannel - Whitelist a channel. The bot will now respond to commands in this channel. 68 | * RemoveStaffChannel - Unwhitelist a channel. The bot will no longer respond to commands in this channel. 69 | * ListStaffChannels - List the whitelisted channels (the channels where the bot will listen to commands). 70 | * SendMacro - Send a macro's message through a report channel. 71 | * AddMacro - Add a macro with a name and its response. 72 | * RemoveMacro - Removes a macro with the given name. 73 | * RenameMacro - Change a macro's name. 74 | * EditMacro - Change a macro's response. 75 | * ListMacros - List all of the currently available macros. 76 | * ListCommands - List all available commands. 77 | ``` 78 | 79 | ### Fixes 80 | ``` 81 | * Ignore (do not log) audit log events from self. 82 | * Remove all instances of hard-coded prefixes - config only. 83 | * Fix issue where the invocation of the archive command was being archived. 84 | ``` 85 | 86 | # Version 2.0.1 87 | 88 | ### New Commands 89 | ``` 90 | * SetLoggingChannel - Set the target logging channel during runtime. 91 | * Info - Access report data such as user ID's. 92 | * Move - Move a target report to a different category. 93 | * Tag - Prepend a tag to the name of this report channel. 94 | * ResetTags - Reset a report channel to its original name. 95 | ``` 96 | 97 | ### Misc Changes 98 | ``` 99 | * Added Docker deployment script and instructions for Windows. 100 | * Report open embeds and edit embeds now contain a user's avatar. 101 | * Message edits are now logged in the logging channel instead of in-place. 102 | * Added an argument to the archive command to allow leaving notes next to files. 103 | ``` 104 | 105 | ### Bug Fixes 106 | ``` 107 | * Logging service - Command events used in reports where users were no longer in the server would not log due to JDA. 108 | * Logging service - Reports closed by channel deletion would not log due to human oversight. 109 | * Archiving - Messages containing links were transcribed as empty embeds due to Discord's preview feature. 110 | * Editing - Edits were intentionally not being sanitized due to lack of ping risk. They are now sanitized. 111 | ``` 112 | 113 | # Version 2.0.0 114 | 115 | ### New Features 116 | ``` 117 | * Multi-guild - A single instance of this bot can now be used across multiple guilds. 118 | * Event propagation - User and staff events can now be forwarded through the bot. 119 | User typing events and message edits will be forwarded to the private channel. 120 | Staff edits and deletes will be forwarded to the user. 121 | * Report recovery - Reports can now be saved to disk and reloaded if the bot goes offline instead of losing reports. 122 | * Leave listener - Create an embed in a report channel if the user that owns this report leaves or is banned. 123 | * Logging - Log events such as channel creation / deletion (and other events) into a logging channel. 124 | * Whitelisting - Ignore commands in non-staff channels. Leave non-whitelisted servers or initialize setup. 125 | ``` 126 | 127 | ### New Commands 128 | ``` 129 | * Whitelist - Add a guild to the whitelist. 130 | * UnWhitelist - Remove a guild from the whitelist. 131 | * ShowWhitelist - Display all guilds in the whitelist. 132 | ``` 133 | ``` 134 | * SetStaffRole - Set the role required to use this bot. 135 | * SetReportCategory - Set the category where new reports will be opened. 136 | * SetArchiveChannel - Set the channel where reports will be archived. 137 | ``` 138 | ``` 139 | * CloseAll - Close all of the currently open reports on the server. 140 | * Open - Open a report with the target user. 141 | * Note - Send an embed in the invoked channel as a note. 142 | * Version - Display the version of this instance of the bot. 143 | * BotInfo - Display various bot information (author; contributors; source; version). 144 | * Uptime - Display time the bot has been online since startup. 145 | ``` 146 | 147 | ### Misc Changes 148 | ``` 149 | * The help system was upgraded through KUtils to be interactive. 150 | * New reports start with embeds instead of plain text. 151 | * The archive command can now handle embeds. 152 | * Docker now available for deployments 153 | ``` 154 | 155 | # Version 1.5.1 and earlier 156 | 157 | ### Features 158 | ``` 159 | * Basic report functionality 160 | ``` 161 | 162 | ### Commands 163 | ``` 164 | * Author - Display the author of the bot. 165 | * Source - Display the GitLab repository link. 166 | * Ping - Display the network status of the bot. 167 | * Help - Display a basic static help menu. 168 | * Close - Close the report channel this command was invoked in. 169 | * Archive - Archive the report channel this command was invoked in. This transcribes the report to a text document. 170 | ``` 171 | -------------------------------------------------------------------------------- /commands.md: -------------------------------------------------------------------------------- 1 | # Commands 2 | 3 | ## Key 4 | | Symbol | Meaning | 5 | |-------------|--------------------------------| 6 | | [Argument] | Argument is not required. | 7 | | /Category | This is a subcommand group. | 8 | 9 | ## /Macro 10 | | Commands | Arguments | Description | 11 | |----------|----------------|-------------------------------------------------------| 12 | | Add | Name, Content | Add a custom command to send text in a report. | 13 | | Edit | Macro, Content | Change a macro's response message. | 14 | | List | | List all of the currently available macros. | 15 | | Remove | Macro | Removes a macro with the given name. | 16 | | Rename | Macro, NewName | Change a macro's name, keeping the original response. | 17 | | Send | Macro | Send a macro to a user through a report. | 18 | 19 | ## Configuration 20 | | Commands | Arguments | Description | 21 | |----------------|------------------------------------------------|-----------------------------------------------------------| 22 | | ArchiveChannel | Channel | Set the channel where reports will be sent when archived. | 23 | | Configure | ReportCategory, ArchiveChannel, LoggingChannel | Configure the bot channels and settings. | 24 | | LoggingChannel | Channel | Set the channel where events will be logged. | 25 | | ReportCategory | Category | Set the category where new reports will be opened. | 26 | 27 | ## Report 28 | | Commands | Arguments | Description | 29 | |-----------|-----------|---------------------------------------------------| 30 | | Archive | [Info] | Archive the contents of this report as text. | 31 | | Close | | Delete a report channel and end this report. | 32 | | Note | Note | Add an embed note in this report channel. | 33 | | ResetTags | | Reset a report channel to its original name. | 34 | | Tag | Tag | Prepend a tag to the name of this report channel. | 35 | 36 | ## ReportHelpers 37 | | Commands | Arguments | Description | 38 | |----------|-----------|---------------------------------------------------| 39 | | Detain | User | Mute a user and open a report with them. | 40 | | History | User | Read the target user's DM history with the bot. | 41 | | ID | | Show the user ID of the user this report is with. | 42 | | Open | User | Open a report with the target user. | 43 | | Release | | Release a user from detainment and unmute them. | 44 | 45 | ## Utility 46 | | Commands | Arguments | Description | 47 | |----------|-----------|----------------------| 48 | | Help | [Command] | Display a help menu. | 49 | | info | | Bot info for ModMail | 50 | 51 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM gradle:6.7.0-jdk15 AS build 2 | COPY --chown=gradle:gradle . /modmail 3 | WORKDIR /modmail 4 | RUN gradle shadowJar --no-daemon 5 | 6 | FROM openjdk:8-jre-slim 7 | ENV BOT_TOKEN=UNSET 8 | RUN mkdir /config/ 9 | COPY --from=build /modmail/build/libs/*.jar /ModMail.jar 10 | 11 | ENTRYPOINT ["java", "-jar", "/ModMail.jar", "$BOT_TOKEN"] -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JakeJMattson/ModMail/854ffde1e15df82307f22251c322d9e16b56f7b3/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.5-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 | -------------------------------------------------------------------------------- /setup.md: -------------------------------------------------------------------------------- 1 | ## Discord Guide 2 | 3 | ### Server Setup 4 | 5 | If you don't already have one, create a Discord server for the bot to run on. 6 | 7 | ### Bot Account 8 | 9 | Create a bot account in the [developer](https://discordapp.com/developers/applications/me) section of the Discord website. 10 | 11 | - Create an application 12 | - Under "General Information" 13 | - Enter an app icon and a name. 14 | - You will need the client ID for later in this guide; copy it somewhere. 15 | - Under "Bot" 16 | - Create a bot. 17 | - Give it a username, app icon, and record the token for future use. 18 | - Note: This is a secret token, don't reveal it! 19 | - Uncheck the option to let it be a public bot so that only you can add it to servers. 20 | - Save changes 21 | 22 | ### Add Bot 23 | 24 | - Visit the [permissions](https://discordapi.com/permissions.html) page. 25 | - Under "OAth URL Generator" enter the bot's client ID that you got earlier. 26 | - Click the link to add it to your server. 27 | - It is recommended to place it at the top of your server so members can see it. 28 | 29 | ## Build Guide 30 | 31 | ### Build Options 32 | 33 | * [Java](https://openjdk.org/) to run a pre-built release from GitHub. 34 | * [Gradle](https://gradle.org/) to build it yourself from your terminal. 35 | * [Docker](https://www.docker.com/) to build it in a clean environment. 36 | * [IntelliJ](https://www.jetbrains.com/idea/) to make modifications to the code. 37 | 38 | ## Deploy Guide 39 | 40 | ### Windows 41 | 42 | #### Abridged version 43 | 44 | 1. Clone and cd into the root `cd /modmail` 45 | 2. `%CD%/scripts/deploy.bat ` 46 | 47 | #### Full version 48 | 49 | 1. Download and install the docker toolbox. 50 | 2. Clone this repository: `git clone https://github.com/JakeJMattson/ModMail.git` - 51 | you can also just download and extract the zip file. 52 | 3. Open the command prompt 53 | 4. `cd /modmail` - cd into the directory 54 | 5. `%CD%/scripts/deploy.bat ` 55 | - replace with a valid discord bot token. 56 | - replace with a path to where you want the bot configuration to be. 57 | 58 | **Important:** The paths required for a correct deployment on Windows are very specific. 59 | In order to mount correctly, the folder on your local machine must be within the shared folders of the VM. 60 | By default, the shared folder list is exclusively `C:\Users`. This includes all subdirectories. 61 | It also requires a very specific format - using forward slashes, instead of the traditional Windows format. 62 | It's recommended to make a folder with a similar path to this: `/c/Users/account/modmail` to store configurations. 63 | 64 | 6. Example run `%CD%/scripts/deploy.bat aokspdf.okwepofk.34p1o32kpo,pqo.sASDAwd /c/Users/account/modmail` 65 | *note: The token is fake :)* 66 | 67 | ## Linux 68 | 69 | #### Abridged version 70 | 71 | 1. Clone and cd into the root `cd /modmail` 72 | 2. `./scripts/deploy.sh ` 73 | 74 | #### Full version 75 | 76 | 1. Download and install docker. 77 | 2. Clone this repository: `git clone https://gitlab.com/jakejmattson/modmail.git` - 78 | you can also just download and extract the zip file. 79 | 3. Open a terminal or command prompt 80 | 4. `cd /modmail` - cd into the directory 81 | 5. `./scripts/deploy.sh ` 82 | - replace with a valid discord bot token. 83 | - replace with a path to where you want the bot configuration to be. 84 | It's recommended to just make a folder called `/home/me/config`. 85 | 6. Example run `./scripts/deploy.sh aokspdf.okwepofk.34p1o32kpo,pqo.sASDAwd /home/me/config` 86 | *note: The token is fake :)* 87 | 88 | ### Configuration 89 | 90 | Below, you can find an explanation of each configuration field. 91 | 92 | ```json 93 | { 94 | "prefix": "[Deprecated] The command prefix for this guild, e.g. !", 95 | "maxOpenReports": "The max number of reports that can be opened in any configured guild", 96 | "guildConfigurations": { 97 | "guildId": { 98 | "reportCategory": "ID of the category in which report channels will be created", 99 | "archiveChannel": "ID of channel where archived reports will be sent", 100 | "staffRoleId": "ID of the role required to use the bot", 101 | "loggingConfiguration": { 102 | "loggingChannel": "ID of channel where messages will be logged", 103 | "logEdits": "log user edits made in a report", 104 | "logCommands": "log staff command execution", 105 | "logOpen": "log when a report is opened", 106 | "logClose": "log when a report is closed" 107 | } 108 | } 109 | } 110 | } 111 | ``` -------------------------------------------------------------------------------- /src/main/kotlin/me/jakejmattson/modmail/Main.kt: -------------------------------------------------------------------------------- 1 | package me.jakejmattson.modmail 2 | 3 | import dev.kord.common.annotation.KordPreview 4 | import dev.kord.common.entity.Permission 5 | import dev.kord.common.entity.Permissions 6 | import dev.kord.gateway.Intent 7 | import dev.kord.gateway.PrivilegedIntent 8 | import me.jakejmattson.discordkt.dsl.bot 9 | import me.jakejmattson.discordkt.extensions.plus 10 | import me.jakejmattson.modmail.services.Configuration 11 | import me.jakejmattson.modmail.services.Locale 12 | import me.jakejmattson.modmail.services.configFile 13 | import java.awt.Color 14 | 15 | @KordPreview 16 | @PrivilegedIntent 17 | fun main(args: Array) { 18 | bot(args.firstOrNull()) { 19 | val configuration = data(configFile.path) { Configuration() } 20 | 21 | prefix { 22 | guild?.let { configuration[it]?.prefix } ?: " " 23 | } 24 | 25 | configure { 26 | commandReaction = null 27 | dualRegistry = false 28 | recommendCommands = false 29 | theme = Color(0x00BFFF) 30 | intents = Intent.Guilds + Intent.GuildMembers + Intent.GuildBans + Intent.DirectMessages + Intent.DirectMessageTyping + Intent.GuildMessageTyping 31 | defaultPermissions = Permissions(Permission.ManageMessages) 32 | } 33 | 34 | presence { 35 | playing(Locale.DISCORD_PRESENCE) 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /src/main/kotlin/me/jakejmattson/modmail/commands/ConfigurationCommands.kt: -------------------------------------------------------------------------------- 1 | package me.jakejmattson.modmail.commands 2 | 3 | import dev.kord.common.entity.Permission 4 | import dev.kord.common.entity.Permissions 5 | import dev.kord.core.entity.channel.Category 6 | import me.jakejmattson.discordkt.arguments.ChannelArg 7 | import me.jakejmattson.discordkt.commands.commands 8 | import me.jakejmattson.discordkt.dsl.edit 9 | import me.jakejmattson.modmail.services.* 10 | 11 | @Suppress("unused") 12 | fun configurationCommands(configuration: Configuration) = commands("Configuration", Permissions(Permission.All)) { 13 | slash("Configure", Locale.CONFIGURE_DESCRIPTION) { 14 | execute(ChannelArg("ReportCategory", "The category where new reports will be created"), 15 | ChannelArg("ArchiveChannel", "The channel where archived reports will be sent"), 16 | ChannelArg("LoggingChannel", "The channel where logging messages will be sent")) { 17 | val (reports, archive, logging) = args 18 | configuration.edit { this[guild] = GuildConfiguration("", reports.id, archive.id, LoggingConfiguration(logging.id)) } 19 | respond("${guild.name} configured.\n" + 20 | "Report Category: ${reports.mention}\n" + 21 | "Archive Channel: ${archive.mention}\n" + 22 | "Logging Channel: ${logging.mention}" 23 | ) 24 | } 25 | } 26 | 27 | slash("ReportCategory", Locale.REPORT_CATEGORY_DESCRIPTION) { 28 | execute(ChannelArg("Category", "The category where new reports will be created")) { 29 | val category = args.first 30 | configuration.edit { this[guild]!!.reportCategory = category.id } 31 | respond("Report Category updated to ${category.mention}") 32 | } 33 | } 34 | 35 | slash("ArchiveChannel", Locale.ARCHIVE_CHANNEL_DESCRIPTION) { 36 | execute(ChannelArg("Channel", "The channel where archived reports will be sent")) { 37 | val channel = args.first 38 | configuration.edit { this[guild]!!.archiveChannel = channel.id } 39 | respond("Archive Channel updated to ${channel.mention}") 40 | } 41 | } 42 | 43 | slash("LoggingChannel", Locale.LOGGING_CHANNEL_DESCRIPTION) { 44 | execute(ChannelArg("Channel", "The channel where logging messages will be sent")) { 45 | val channel = args.first 46 | configuration.edit { this[guild]!!.loggingConfiguration.loggingChannel = channel.id } 47 | respond("Logging Channel updated to ${channel.mention}") 48 | } 49 | } 50 | } -------------------------------------------------------------------------------- /src/main/kotlin/me/jakejmattson/modmail/commands/MacroCommands.kt: -------------------------------------------------------------------------------- 1 | package me.jakejmattson.modmail.commands 2 | 3 | import dev.kord.common.kColor 4 | import dev.kord.core.entity.interaction.GuildAutoCompleteInteraction 5 | import me.jakejmattson.discordkt.arguments.AnyArg 6 | import me.jakejmattson.discordkt.arguments.EveryArg 7 | import me.jakejmattson.discordkt.commands.subcommand 8 | import me.jakejmattson.discordkt.extensions.* 9 | import me.jakejmattson.modmail.services.Locale 10 | import me.jakejmattson.modmail.services.MacroService 11 | import me.jakejmattson.modmail.services.findReport 12 | import java.awt.Color 13 | 14 | @Suppress("unused") 15 | fun macroCommands(macroService: MacroService) = subcommand("Macro") { 16 | fun autoCompletingMacroArg() = AnyArg("Macro", "The name of a macro").autocomplete { 17 | val guild = (interaction as GuildAutoCompleteInteraction).getGuild() 18 | 19 | macroService.getGuildMacros(guild) 20 | .map { it.name } 21 | .filter { it.contains(input, true) } 22 | } 23 | 24 | sub("Send", Locale.SEND_MACRO_DESCRIPTION) { 25 | execute(autoCompletingMacroArg()) { 26 | val name = args.first 27 | val macro = macroService.findMacro(guild, name) 28 | val report = channel.findReport() 29 | 30 | if (macro == null) { 31 | respond("`$name` does not exist.") 32 | return@execute 33 | } 34 | 35 | if (report != null) 36 | report.liveMember(discord.kord)?.sendPrivateMessage(macro.message) 37 | 38 | respondPublic { 39 | description = macro.message 40 | color = if (report != null) Color.green.kColor else Color.red.kColor 41 | footer("Macro: ${macro.name}") 42 | } 43 | } 44 | } 45 | 46 | sub("Add", Locale.ADD_MACRO_DESCRIPTION) { 47 | execute(AnyArg("Name", "The name used to reference this macro"), 48 | EveryArg("Content", "The content displayed when this macro is sent")) { 49 | val (name, message) = args 50 | val wasAdded = macroService.addMacro(name, message, guild) 51 | 52 | if (wasAdded) 53 | respondPublic("Created macro: `$name`") 54 | else 55 | respond("`$name` already exists.") 56 | } 57 | } 58 | 59 | sub("Remove", Locale.REMOVE_MACRO_DESCRIPTION) { 60 | execute(autoCompletingMacroArg()) { 61 | val name = args.first 62 | val wasRemoved = macroService.removeMacro(name, guild) 63 | 64 | if (wasRemoved) 65 | respondPublic("Deleted macro: `${name}`") 66 | else 67 | respond("`${name}` does not exist.") 68 | } 69 | } 70 | 71 | sub("Rename", Locale.RENAME_MACRO_DESCRIPTION) { 72 | execute(autoCompletingMacroArg(), AnyArg("NewName", "The new name to give this macro")) { 73 | val (name, newName) = args 74 | val wasChanged = macroService.editName(name, newName, guild) 75 | 76 | if (wasChanged) 77 | respondPublic("Changed `$name` to `$newName`") 78 | else 79 | respond("`$newName` already exists.") 80 | } 81 | } 82 | 83 | sub("Edit", Locale.EDIT_MACRO_DESCRIPTION) { 84 | execute(autoCompletingMacroArg(), 85 | EveryArg("Content", "The new content of the macro")) { 86 | val (name, message) = args 87 | 88 | val wasEdited = macroService.editMessage(name, message, guild) 89 | 90 | if (wasEdited) 91 | respondPublic("Edited macro: $name") 92 | else 93 | respond("`$name` does not exist") 94 | } 95 | } 96 | 97 | sub("List", Locale.LIST_MACROS_DESCRIPTION) { 98 | execute { 99 | respond { 100 | addField("Macros", macroService.listMacros(guild)) 101 | } 102 | } 103 | } 104 | } -------------------------------------------------------------------------------- /src/main/kotlin/me/jakejmattson/modmail/commands/ReportCommands.kt: -------------------------------------------------------------------------------- 1 | package me.jakejmattson.modmail.commands 2 | 3 | import dev.kord.common.kColor 4 | import dev.kord.core.behavior.channel.createEmbed 5 | import dev.kord.core.behavior.channel.createMessage 6 | import dev.kord.core.behavior.channel.edit 7 | import dev.kord.core.entity.channel.TextChannel 8 | import me.jakejmattson.discordkt.arguments.AnyArg 9 | import me.jakejmattson.discordkt.arguments.EveryArg 10 | import me.jakejmattson.discordkt.commands.commands 11 | import me.jakejmattson.discordkt.extensions.author 12 | import me.jakejmattson.modmail.extensions.archiveString 13 | import me.jakejmattson.modmail.listeners.deletionQueue 14 | import me.jakejmattson.modmail.services.* 15 | import java.awt.Color 16 | 17 | @Suppress("unused") 18 | fun reportCommands(configuration: Configuration, loggingService: LoggingService) = commands("Report") { 19 | slash("Close", Locale.CLOSE_DESCRIPTION) { 20 | execute { 21 | val report = channel.findReport() 22 | 23 | if (report == null) { 24 | respond("This command must be run in a report channel") 25 | return@execute 26 | } 27 | 28 | report.release(discord.kord) 29 | deletionQueue.add(channel.id) 30 | channel.delete() 31 | 32 | respond("Report was closed.") 33 | loggingService.commandClose(guild, (channel as TextChannel).name, author) 34 | } 35 | } 36 | 37 | slash("Archive", Locale.ARCHIVE_DESCRIPTION) { 38 | execute(EveryArg("Info", "A message sent along side the archive file").optional("")) { 39 | val note = args.first 40 | val channel = channel as TextChannel 41 | val report = channel.findReport() 42 | 43 | if (report == null) { 44 | respond("This command must be run in a report channel") 45 | return@execute 46 | } 47 | 48 | val config = configuration[report.guildId] 49 | val archiveChannel = config?.getLiveArchiveChannel(channel.kord) 50 | 51 | if (archiveChannel == null) { 52 | respond("No archive channel available!") 53 | return@execute 54 | } 55 | 56 | val archiveMessage = "User ID: ${report.userId}\nAdditional Information: " + note.ifEmpty { "" } 57 | 58 | archiveChannel.createMessage { 59 | content = archiveMessage 60 | addFile("$${channel.name}.txt", channel.archiveString().toByteArray().inputStream()) 61 | 62 | report.release(discord.kord) 63 | deletionQueue.add(channel.id) 64 | channel.delete() 65 | } 66 | 67 | respond("Report was archived.") 68 | loggingService.archive(guild, channel.name, author) 69 | } 70 | } 71 | 72 | slash("Note", Locale.NOTE_DESCRIPTION) { 73 | execute(EveryArg("Note", "The note content")) { 74 | val report = channel.findReport() 75 | 76 | if (report == null) { 77 | respond("This command must be run in a report channel") 78 | return@execute 79 | } 80 | 81 | respondPublic { 82 | description = args.first 83 | color = Color.white.kColor 84 | } 85 | 86 | loggingService.command(this) 87 | } 88 | } 89 | 90 | slash("Tag", Locale.TAG_DESCRIPTION) { 91 | execute(AnyArg("Tag", "A prefix or emoji")) { 92 | val report = channel.findReport() 93 | 94 | if (report == null) { 95 | respond("This command must be run in a report channel") 96 | return@execute 97 | } 98 | 99 | val tag = args.first 100 | 101 | (channel as TextChannel).edit { 102 | name = "$tag-${(channel as TextChannel).name}" 103 | } 104 | 105 | loggingService.command(this, "Added tag :: $tag") 106 | respondPublic("Tag added.") 107 | } 108 | } 109 | 110 | slash("ResetTags", Locale.RESET_TAGS_DESCRIPTION) { 111 | execute { 112 | val report = channel.findReport() 113 | 114 | if (report == null) { 115 | respond("This command must be run in a report channel") 116 | return@execute 117 | } 118 | 119 | val user = discord.kord.getUser(report.userId) ?: return@execute 120 | val newName = user.username 121 | 122 | (channel as TextChannel).edit { 123 | name = newName 124 | } 125 | 126 | loggingService.command(this, "Channel is now $newName") 127 | respondPublic("Tags reset.") 128 | } 129 | } 130 | } -------------------------------------------------------------------------------- /src/main/kotlin/me/jakejmattson/modmail/commands/ReportHelperCommands.kt: -------------------------------------------------------------------------------- 1 | package me.jakejmattson.modmail.commands 2 | 3 | import dev.kord.common.exception.RequestException 4 | import dev.kord.common.kColor 5 | import dev.kord.core.behavior.channel.createEmbed 6 | import dev.kord.core.behavior.createTextChannel 7 | import dev.kord.core.behavior.interaction.respondPublic 8 | import dev.kord.core.entity.Member 9 | import dev.kord.rest.Image 10 | import me.jakejmattson.discordkt.arguments.UserArg 11 | import me.jakejmattson.discordkt.commands.CommandEvent 12 | import me.jakejmattson.discordkt.commands.commands 13 | import me.jakejmattson.discordkt.extensions.addField 14 | import me.jakejmattson.discordkt.extensions.thumbnail 15 | import me.jakejmattson.modmail.extensions.archiveString 16 | import me.jakejmattson.modmail.services.* 17 | import java.awt.Color 18 | import java.util.concurrent.ConcurrentHashMap 19 | 20 | @Suppress("unused") 21 | fun reportHelperCommands(configuration: Configuration, reportService: ReportService, loggingService: LoggingService) = commands("ReportHelpers") { 22 | 23 | suspend fun Member.openReport(event: CommandEvent<*>, detain: Boolean = false) { 24 | val guild = guild.asGuild() 25 | val reportCategory = configuration[guild]!!.getLiveReportCategory(guild.kord) 26 | 27 | getDmChannel().createEmbed { 28 | if (detain) { 29 | color = Color.red.kColor 30 | addField("You've have been detained by the staff of ${guild.name}!", Locale.USER_DETAIN_MESSAGE) 31 | } else { 32 | color = Color.green.kColor 33 | addField("Chatting with ${guild.name}!", Locale.BOT_DESCRIPTION) 34 | } 35 | 36 | thumbnail(guild.getIconUrl(Image.Format.JPEG) ?: "") 37 | } 38 | 39 | val reportChannel = guild.createTextChannel(username) { 40 | parentId = reportCategory?.id 41 | } 42 | 43 | reportOpenEmbed(reportChannel, event.author, detain) 44 | 45 | val newReport = Report(id, reportChannel.id, guild.id, ConcurrentHashMap()) 46 | reportService.addReport(newReport) 47 | 48 | if (detain) newReport.detain(guild.kord) 49 | 50 | loggingService.staffOpen(guild, reportChannel.name, event.author, detain) 51 | event.respond(reportChannel.mention) 52 | } 53 | 54 | user("Open a Report", "Open", Locale.OPEN_DESCRIPTION) { 55 | val targetMember = arg.asMemberOrNull(guild.id) 56 | 57 | if (targetMember == null) { 58 | println("User is no longer in this guild.") 59 | return@user 60 | } 61 | 62 | val openReport = targetMember.findReport() 63 | 64 | if (openReport != null) { 65 | respond("Open report: <#${openReport.channelId}>") 66 | return@user 67 | } 68 | 69 | try { 70 | targetMember.openReport(this, false) 71 | } catch (ex: RequestException) { 72 | respond("Unable to contact the target user. Direct messages are disabled or the bot is blocked.") 73 | return@user 74 | } 75 | } 76 | 77 | user("Detain this User", "Detain", Locale.DETAIN_DESCRIPTION) { 78 | val targetMember = arg.asMemberOrNull(guild.id) 79 | 80 | if (targetMember == null) { 81 | println("User is no longer in this guild.") 82 | return@user 83 | } 84 | 85 | if (targetMember.getPermissions().contains(discord.configuration.defaultPermissions)) { 86 | respond("You cannot detain another staff member.") 87 | return@user 88 | } 89 | 90 | if (targetMember.isDetained()) { 91 | respond("This member is already detained.") 92 | return@user 93 | } 94 | 95 | val openReport = targetMember.findReport() 96 | 97 | if (openReport != null) { 98 | openReport.detain(discord.kord) 99 | respond("Open report: <#${openReport.channelId}> (mute applied)") 100 | return@user 101 | } 102 | 103 | try { 104 | targetMember.openReport(this, true) 105 | } catch (ex: RequestException) { 106 | respond("Unable to contact the target user. " + 107 | "Direct messages are disabled or the bot is blocked. " + 108 | "Mute was not applied") 109 | 110 | return@user 111 | } 112 | 113 | targetMember.mute() 114 | } 115 | 116 | slash("Release", Locale.RELEASE_DESCRIPTION) { 117 | execute { 118 | val report = channel.findReport() 119 | 120 | if (report == null) { 121 | respond("This command must be run in a report channel") 122 | return@execute 123 | } 124 | 125 | val member = guild.getMemberOrNull(report.userId) 126 | 127 | if (member == null) { 128 | respond("This user is not in the server.") 129 | return@execute 130 | } 131 | 132 | if (!member.isDetained()) { 133 | respond("This member is not detained.") 134 | return@execute 135 | } 136 | 137 | report.release(discord.kord) 138 | respondPublic("${member.tag} has been released.") 139 | } 140 | } 141 | 142 | slash("ID", Locale.ID_DESCRIPTION) { 143 | execute { 144 | val report = channel.findReport() 145 | 146 | if (report == null) { 147 | respond("This command must be run in a report channel") 148 | return@execute 149 | } 150 | 151 | respond(report.userId) 152 | } 153 | } 154 | 155 | slash("History", Locale.HISTORY_DESCRIPTION) { 156 | execute(UserArg) { 157 | val user = args.first 158 | val history = user.getDmChannel().archiveString().toByteArray() 159 | 160 | if (history.isNotEmpty()) 161 | interaction?.respondPublic { addFile("$${user.id.value}.txt", history.inputStream()) } 162 | else { 163 | respond("No history available.") 164 | } 165 | } 166 | } 167 | } -------------------------------------------------------------------------------- /src/main/kotlin/me/jakejmattson/modmail/conversations/GuildChoiceConversation.kt: -------------------------------------------------------------------------------- 1 | package me.jakejmattson.modmail.conversations 2 | 3 | import dev.kord.core.entity.Message 4 | import kotlinx.coroutines.flow.toList 5 | import me.jakejmattson.discordkt.Discord 6 | import me.jakejmattson.discordkt.conversations.conversation 7 | import me.jakejmattson.discordkt.extensions.mutualGuilds 8 | import me.jakejmattson.discordkt.extensions.pfpUrl 9 | import me.jakejmattson.discordkt.extensions.thumbnail 10 | import me.jakejmattson.modmail.services.Configuration 11 | import me.jakejmattson.modmail.services.ReportService 12 | 13 | fun guildChoiceConversation(discord: Discord, message: Message) = conversation { 14 | val reportService = discord.getInjectionObjects() 15 | val config = discord.getInjectionObjects() 16 | val guilds = user.mutualGuilds.toList().filter { config.guildConfigurations[it.id] != null } 17 | 18 | val guild = promptButton { 19 | embed { 20 | title = "Select Server" 21 | description = "Select the server you want to contact." 22 | thumbnail(discord.kord.getSelf().pfpUrl) 23 | } 24 | 25 | guilds.toList().chunked(5).forEach { row -> 26 | buttons { 27 | row.forEach { guild -> 28 | button(guild.name, null, guild) 29 | } 30 | } 31 | } 32 | } 33 | 34 | with(reportService) { 35 | createReport(user, guild) 36 | receiveFromUser(message) 37 | } 38 | } -------------------------------------------------------------------------------- /src/main/kotlin/me/jakejmattson/modmail/extensions/EntityExtensions.kt: -------------------------------------------------------------------------------- 1 | package me.jakejmattson.modmail.extensions 2 | 3 | import dev.kord.core.entity.Embed 4 | import dev.kord.core.entity.Message 5 | import dev.kord.core.entity.User 6 | import dev.kord.core.entity.channel.MessageChannel 7 | import dev.kord.x.emoji.Emojis 8 | import dev.kord.x.emoji.toReaction 9 | import kotlinx.coroutines.flow.toList 10 | import me.jakejmattson.discordkt.Discord 11 | import me.jakejmattson.discordkt.extensions.containsURL 12 | import me.jakejmattson.discordkt.extensions.sanitiseMentions 13 | 14 | private const val embedNotation = "<---------- Embed ---------->" 15 | 16 | fun Message.fullContent() = content + "\n" + 17 | (attachments.takeIf { it.isNotEmpty() } 18 | ?.map { it.url } 19 | ?.reduce { a, b -> "$a\n $b" } 20 | ?: "") 21 | 22 | suspend fun Message.cleanContent(discord: Discord) = fullContent().trimEnd().sanitiseMentions(discord) 23 | 24 | fun Embed.toTextString() = 25 | buildString { 26 | appendLine(embedNotation) 27 | fields.forEach { append("${it.name}\n${it.value}\n") } 28 | appendLine(embedNotation) 29 | } 30 | 31 | suspend fun MessageChannel.archiveString() = messages.toList() 32 | .reversed() 33 | .joinToString("\n") { 34 | buildString { 35 | append("${it.author?.tag}: ") 36 | 37 | if (it.embeds.isNotEmpty() && !it.containsURL()) { 38 | it.embeds.forEach { embed -> 39 | appendLine() 40 | append(embed.toTextString()) 41 | } 42 | } else { 43 | append(it.fullContent()) 44 | } 45 | } 46 | } 47 | 48 | suspend fun Message.addFailReaction() = addReaction(Emojis.x.toReaction()) 49 | 50 | val User.fullname 51 | get() = "$username#$discriminator" -------------------------------------------------------------------------------- /src/main/kotlin/me/jakejmattson/modmail/listeners/ChannelDeletionListener.kt: -------------------------------------------------------------------------------- 1 | package me.jakejmattson.modmail.listeners 2 | 3 | import dev.kord.common.entity.Snowflake 4 | import dev.kord.core.event.channel.TextChannelDeleteEvent 5 | import me.jakejmattson.discordkt.dsl.listeners 6 | import me.jakejmattson.modmail.services.LoggingService 7 | import me.jakejmattson.modmail.services.close 8 | import me.jakejmattson.modmail.services.findReport 9 | 10 | val deletionQueue = ArrayList() 11 | 12 | @Suppress("unused") 13 | fun channelDeletion(loggingService: LoggingService) = listeners { 14 | on { 15 | val report = channel.findReport() ?: return@on 16 | 17 | report.close(channel.kord) 18 | 19 | if (channel.id in deletionQueue) { 20 | deletionQueue.remove(channel.id) 21 | return@on 22 | } 23 | 24 | loggingService.manualClose(channel.getGuild(), channel.name) 25 | } 26 | } -------------------------------------------------------------------------------- /src/main/kotlin/me/jakejmattson/modmail/listeners/EditListener.kt: -------------------------------------------------------------------------------- 1 | package me.jakejmattson.modmail.listeners 2 | 3 | import dev.kord.core.behavior.edit 4 | import dev.kord.core.event.channel.TypingStartEvent 5 | import dev.kord.core.event.message.MessageDeleteEvent 6 | import dev.kord.core.event.message.MessageUpdateEvent 7 | import me.jakejmattson.discordkt.Discord 8 | import me.jakejmattson.discordkt.dsl.listeners 9 | import me.jakejmattson.modmail.extensions.cleanContent 10 | import me.jakejmattson.modmail.services.LoggingService 11 | import me.jakejmattson.modmail.services.ReportService 12 | import me.jakejmattson.modmail.services.findReport 13 | 14 | @Suppress("unused") 15 | fun editListener(discord: Discord, reportService: ReportService, loggingService: LoggingService) = listeners { 16 | on { 17 | val message = this.message.asMessage() 18 | val author = message.author!! 19 | 20 | if (getMessage().getGuildOrNull() != null) { 21 | if (author.id == kord.getSelf().id) return@on 22 | 23 | val report = channel.findReport() ?: return@on 24 | val privateChannel = kord.getUser(report.userId)?.getDmChannel() ?: return@on 25 | 26 | val targetMessage = report.messages[messageId]!! 27 | 28 | privateChannel.getMessage(targetMessage).edit { 29 | content = new.content.value 30 | } 31 | } else { 32 | val report = author.findReport() ?: return@on 33 | val targetMessage = report.messages[messageId] ?: return@on 34 | val channel = report.liveChannel(kord) ?: return@on 35 | val guildMessage = channel.getMessage(targetMessage) 36 | val newContent = message.cleanContent(discord) 37 | 38 | loggingService.edit(report, guildMessage.cleanContent(discord), newContent) 39 | 40 | channel.getMessage(targetMessage).edit { 41 | content = newContent 42 | } 43 | } 44 | } 45 | 46 | on { 47 | getGuild() ?: return@on 48 | 49 | val report = channel.findReport() ?: return@on 50 | val targetMessage = report.messages[messageId] ?: return@on 51 | val privateChannel = kord.getUser(report.userId)?.getDmChannel() ?: return@on 52 | 53 | privateChannel.deleteMessage(targetMessage) 54 | report.messages.remove(messageId) 55 | 56 | reportService.writeReportToFile(report) 57 | } 58 | 59 | on { 60 | if (getGuild() != null) 61 | return@on 62 | 63 | user.findReport()?.liveChannel(kord)?.type() 64 | } 65 | } -------------------------------------------------------------------------------- /src/main/kotlin/me/jakejmattson/modmail/listeners/GuildMigrationListener.kt: -------------------------------------------------------------------------------- 1 | package me.jakejmattson.modmail.listeners 2 | 3 | import dev.kord.common.entity.Snowflake 4 | import dev.kord.common.kColor 5 | import dev.kord.core.behavior.channel.createEmbed 6 | import dev.kord.core.event.guild.* 7 | import dev.kord.rest.Image 8 | import kotlinx.coroutines.delay 9 | import me.jakejmattson.discordkt.dsl.listeners 10 | import me.jakejmattson.discordkt.extensions.addField 11 | import me.jakejmattson.discordkt.extensions.sendPrivateMessage 12 | import me.jakejmattson.discordkt.extensions.thumbnail 13 | import me.jakejmattson.modmail.services.* 14 | import java.awt.Color 15 | 16 | @Suppress("unused") 17 | fun guildMigration(configuration: Configuration) = listeners { 18 | val banQueue = mutableListOf>() 19 | 20 | on { 21 | val report = user.findReport() ?: return@on 22 | if (report.guildId != guild.id) return@on 23 | 24 | banQueue.add(report.userId to report.guildId) 25 | 26 | report.liveChannel(kord)?.createEmbed { 27 | color = Color.red.kColor 28 | addField("User Banned!", "Reason: ${this@on.getBanOrNull()?.reason ?: ""}") 29 | } 30 | } 31 | 32 | on { 33 | val report = user.findReport() ?: return@on 34 | if (report.guildId != guild.id) return@on 35 | 36 | delay(500) 37 | 38 | if (banQueue.contains(user.id to report.guildId)) { 39 | banQueue.remove(report.userId to report.guildId) 40 | return@on 41 | } 42 | 43 | report.liveChannel(kord)?.createEmbed { 44 | color = Color.orange.kColor 45 | addField("User Left!", "This user has left the server.") 46 | } 47 | } 48 | 49 | on { 50 | val report = member.asUser().findReport() ?: return@on 51 | if (report.guildId != guild.id) return@on 52 | 53 | report.liveChannel(kord)?.createEmbed { 54 | color = Color.green.kColor 55 | addField("User Joined!", "This report is now reactivated.") 56 | } 57 | 58 | if (member.isDetained()) 59 | member.mute() 60 | } 61 | 62 | on { 63 | if (configuration[guild] != null) return@on 64 | 65 | guild.owner.sendPrivateMessage { 66 | title = "Please configure ${guild.name}" 67 | description = "Please run the `/configure` command inside your server. You will be asked for a few things." 68 | guild.getIconUrl(Image.Format.PNG)?.let { thumbnail(it) } 69 | addField("Report Category", "Where new report channels will be created.") 70 | addField("Archive Channel", "Where reports can be archived as text.") 71 | addField("Logging Channel", "Where logging messages will be sent.") 72 | addField("Staff Role", "The role required to use this bot.") 73 | } 74 | } 75 | } -------------------------------------------------------------------------------- /src/main/kotlin/me/jakejmattson/modmail/listeners/ReportListener.kt: -------------------------------------------------------------------------------- 1 | package me.jakejmattson.modmail.listeners 2 | 3 | import dev.kord.core.event.message.MessageCreateEvent 4 | import kotlinx.coroutines.flow.toList 5 | import me.jakejmattson.discordkt.Discord 6 | import me.jakejmattson.discordkt.conversations.Conversations 7 | import me.jakejmattson.discordkt.dsl.listeners 8 | import me.jakejmattson.discordkt.extensions.mutualGuilds 9 | import me.jakejmattson.discordkt.extensions.sendPrivateMessage 10 | import me.jakejmattson.modmail.conversations.guildChoiceConversation 11 | import me.jakejmattson.modmail.extensions.addFailReaction 12 | import me.jakejmattson.modmail.extensions.fullContent 13 | import me.jakejmattson.modmail.services.Configuration 14 | import me.jakejmattson.modmail.services.ReportService 15 | import me.jakejmattson.modmail.services.findReport 16 | import kotlin.collections.set 17 | 18 | @Suppress("unused") 19 | fun reportListener(discord: Discord, config: Configuration, reportService: ReportService) = listeners { 20 | on { 21 | val user = message.author?.takeUnless { it.isBot } ?: return@on 22 | 23 | if (getGuild() == null) { 24 | if (Conversations.hasConversation(user, message.channel.asChannel())) return@on 25 | 26 | val validGuilds = user.mutualGuilds.toList().filter { config.guildConfigurations[it.id] != null } 27 | 28 | when { 29 | user.findReport() != null -> reportService.receiveFromUser(message) 30 | validGuilds.size > 1 -> { 31 | guildChoiceConversation(discord, message).startPrivately(discord, user) 32 | } 33 | 34 | else -> { 35 | val guild = validGuilds.firstOrNull() ?: return@on 36 | with(reportService) { 37 | createReport(user, guild) 38 | receiveFromUser(message) 39 | } 40 | } 41 | } 42 | } else { 43 | with(message) { 44 | val report = channel.findReport() ?: return@on 45 | val member = report.liveMember(kord) ?: return@on addFailReaction() 46 | val prefix = config[getGuild()]?.prefix ?: return@on 47 | val content = fullContent().takeUnless { it.isBlank() || it.startsWith(prefix) || it.startsWith("/") } 48 | 49 | if (content == null) { 50 | addFailReaction() 51 | return@on 52 | } 53 | 54 | val newMessage = member.sendPrivateMessage(content) 55 | 56 | report.messages[id] = newMessage.id 57 | } 58 | } 59 | } 60 | } -------------------------------------------------------------------------------- /src/main/kotlin/me/jakejmattson/modmail/preconditions/PrefixPrecondition.kt: -------------------------------------------------------------------------------- 1 | package me.jakejmattson.modmail.preconditions 2 | 3 | import me.jakejmattson.discordkt.dsl.precondition 4 | import me.jakejmattson.modmail.services.Configuration 5 | 6 | @Suppress("unused") 7 | fun prefixPrecondition(configuration: Configuration) = precondition { 8 | if (guild == null) return@precondition 9 | if (message == null) return@precondition 10 | if (author.isBot) return@precondition 11 | 12 | val guildConfig = configuration[guild!!] ?: return@precondition 13 | val content = message!!.content 14 | 15 | if (content.startsWith(guildConfig.prefix) || content.startsWith("/")) 16 | fail() 17 | } -------------------------------------------------------------------------------- /src/main/kotlin/me/jakejmattson/modmail/preconditions/ValidGuildPrecondition.kt: -------------------------------------------------------------------------------- 1 | package me.jakejmattson.modmail.preconditions 2 | 3 | import me.jakejmattson.discordkt.dsl.precondition 4 | import me.jakejmattson.modmail.services.Configuration 5 | import me.jakejmattson.modmail.services.Locale 6 | 7 | @Suppress("unused") 8 | fun validGuildPrecondition(configuration: Configuration) = precondition { 9 | if ("configure".equals(command?.name, true)) 10 | return@precondition 11 | 12 | guild?.let { configuration[it] } ?: fail(Locale.FAIL_GUILD_NOT_CONFIGURED) 13 | } -------------------------------------------------------------------------------- /src/main/kotlin/me/jakejmattson/modmail/services/Configuration.kt: -------------------------------------------------------------------------------- 1 | package me.jakejmattson.modmail.services 2 | 3 | import dev.kord.common.entity.Snowflake 4 | import dev.kord.core.Kord 5 | import dev.kord.core.entity.Guild 6 | import dev.kord.core.entity.channel.Category 7 | import dev.kord.core.entity.channel.TextChannel 8 | import kotlinx.serialization.Serializable 9 | import me.jakejmattson.discordkt.dsl.Data 10 | 11 | @Serializable 12 | data class LoggingConfiguration(var loggingChannel: Snowflake, 13 | val logEdits: Boolean = true, 14 | val logCommands: Boolean = true, 15 | val logOpen: Boolean = true, 16 | val logClose: Boolean = true) { 17 | suspend fun getLiveChannel(kord: Kord) = kord.getChannel(loggingChannel) as? TextChannel 18 | } 19 | 20 | @Serializable 21 | data class GuildConfiguration(var prefix: String, 22 | var reportCategory: Snowflake, 23 | var archiveChannel: Snowflake, 24 | val loggingConfiguration: LoggingConfiguration) { 25 | suspend fun getLiveReportCategory(kord: Kord) = kord.getChannel(reportCategory) as? Category 26 | suspend fun getLiveArchiveChannel(kord: Kord) = kord.getChannel(archiveChannel) as? TextChannel 27 | } 28 | 29 | @Serializable 30 | data class Configuration(val guildConfigurations: MutableMap = mutableMapOf()) : Data() { 31 | operator fun get(guild: Guild) = guildConfigurations[guild.id] 32 | operator fun get(guildId: Snowflake) = guildConfigurations[guildId] 33 | 34 | operator fun set(guild: Guild, configuration: GuildConfiguration) { 35 | guildConfigurations[guild.id] = configuration 36 | } 37 | } -------------------------------------------------------------------------------- /src/main/kotlin/me/jakejmattson/modmail/services/FileService.kt: -------------------------------------------------------------------------------- 1 | package me.jakejmattson.modmail.services 2 | 3 | import java.io.File 4 | 5 | private const val rootFolder = "data" 6 | private const val configFolder = "$rootFolder/config" 7 | private const val persistenceFolder = "$rootFolder/persistence" 8 | private const val macrosFolder = "$persistenceFolder/macros" 9 | val configFile = File("$configFolder/config.json") 10 | 11 | val reportsFolder = createDirectories("$persistenceFolder/reports") 12 | val macroFile = File(macrosFolder, "macros.json").createParentsAndFile() 13 | val messagesFile = File(configFolder, "messages.json").createParentsAndFile() 14 | 15 | private fun File.createParentsAndFile(): File { 16 | createDirectories(parent) 17 | return this 18 | } 19 | 20 | private fun createDirectories(parentPath: String) = File(parentPath).apply { mkdirs() } -------------------------------------------------------------------------------- /src/main/kotlin/me/jakejmattson/modmail/services/Locale.kt: -------------------------------------------------------------------------------- 1 | package me.jakejmattson.modmail.services 2 | 3 | import kotlinx.serialization.Serializable 4 | import kotlinx.serialization.decodeFromString 5 | import kotlinx.serialization.encodeToString 6 | import kotlinx.serialization.json.Json 7 | import kotlinx.serialization.modules.SerializersModule 8 | 9 | val Locale = run { 10 | val json = Json { 11 | ignoreUnknownKeys = true 12 | prettyPrint = true 13 | encodeDefaults = true 14 | serializersModule = SerializersModule { } 15 | } 16 | 17 | val messages = if (messagesFile.exists()) json.decodeFromString(messagesFile.readText()) else Messages() 18 | messagesFile.writeText(json.encodeToString(messages)) 19 | messages 20 | } 21 | 22 | @Serializable 23 | class Messages( 24 | //User-facing 25 | val DISCORD_PRESENCE: String = "DM to contact Staff", 26 | val BOT_DESCRIPTION: String = "This is a two-way communication medium between you and the entire staff team. Reply directly into this channel and your message will be forwarded to them.", 27 | val USER_DETAIN_MESSAGE: String = "You have been muted during this detainment period. Please use this time to converse with us. Send messages here to reply.", 28 | 29 | //Configuration commands descriptions 30 | val CONFIGURE_DESCRIPTION: String = "Configure the bot channels and settings.", 31 | val REPORT_CATEGORY_DESCRIPTION: String = "Set the category where new reports will be opened.", 32 | val ARCHIVE_CHANNEL_DESCRIPTION: String = "Set the channel where reports will be sent when archived.", 33 | val LOGGING_CHANNEL_DESCRIPTION: String = "Set the channel where events will be logged.", 34 | 35 | //Report commands descriptions 36 | val CLOSE_DESCRIPTION: String = "Delete a report channel and end this report.", 37 | val ARCHIVE_DESCRIPTION: String = "Archive the contents of this report as text.", 38 | val NOTE_DESCRIPTION: String = "Add an embed note in this report channel.", 39 | val TAG_DESCRIPTION: String = "Prepend a tag to the name of this report channel.", 40 | val RESET_TAGS_DESCRIPTION: String = "Reset a report channel to its original name.", 41 | 42 | //Report helper commands descriptions 43 | val OPEN_DESCRIPTION: String = "Open a report with the target user.", 44 | val DETAIN_DESCRIPTION: String = "Mute a user and open a report with them.", 45 | val RELEASE_DESCRIPTION: String = "Release a user from detainment and unmute them.", 46 | val ID_DESCRIPTION: String = "Show the user ID of the user this report is with.", 47 | val HISTORY_DESCRIPTION: String = "Read the target user's DM history with the bot.", 48 | 49 | //Macro commands descriptions 50 | val SEND_MACRO_DESCRIPTION: String = "Send a macro to a user through a report.", 51 | val ADD_MACRO_DESCRIPTION: String = "Add a custom command to send text in a report.", 52 | val REMOVE_MACRO_DESCRIPTION: String = "Removes a macro with the given name.", 53 | val RENAME_MACRO_DESCRIPTION: String = "Change a macro's name, keeping the original response.", 54 | val EDIT_MACRO_DESCRIPTION: String = "Change a macro's response message.", 55 | val LIST_MACROS_DESCRIPTION: String = "List all of the currently available macros.", 56 | 57 | //Fail message 58 | val FAIL_GUILD_NOT_CONFIGURED: String = "This guild is not configured for use.", 59 | ) 60 | -------------------------------------------------------------------------------- /src/main/kotlin/me/jakejmattson/modmail/services/LoggingService.kt: -------------------------------------------------------------------------------- 1 | package me.jakejmattson.modmail.services 2 | 3 | import dev.kord.common.kColor 4 | import dev.kord.core.behavior.channel.createMessage 5 | import dev.kord.core.entity.Guild 6 | import dev.kord.core.entity.User 7 | import dev.kord.core.entity.channel.TextChannel 8 | import dev.kord.rest.builder.message.EmbedBuilder 9 | import me.jakejmattson.discordkt.Discord 10 | import me.jakejmattson.discordkt.annotations.Service 11 | import me.jakejmattson.discordkt.commands.CommandEvent 12 | import me.jakejmattson.discordkt.extensions.addField 13 | import me.jakejmattson.discordkt.extensions.pfpUrl 14 | import me.jakejmattson.discordkt.extensions.thumbnail 15 | import java.awt.Color 16 | 17 | @Service 18 | class LoggingService(discord: Discord, private val config: Configuration) { 19 | private val kord = discord.kord 20 | 21 | suspend fun memberOpen(report: Report) { 22 | val config = config[report.guildId]!!.loggingConfiguration 23 | val message = "New report opened by ${report.liveMember(kord)?.tag}" 24 | 25 | if (config.logOpen) 26 | log(config, message) 27 | } 28 | 29 | suspend fun staffOpen(guild: Guild, channelName: String, staff: User, detain: Boolean) { 30 | val config = guild.logConfig 31 | val message = "Staff action :: ${staff.tag} ${if (detain) "detained" else "opened"} $channelName" 32 | 33 | if (config.logOpen) 34 | log(config, message) 35 | } 36 | 37 | suspend fun archive(guild: Guild, channelName: String, staff: User) { 38 | val config = guild.logConfig 39 | val message = "Staff action :: ${staff.tag} archived $channelName" 40 | 41 | if (config.logClose) 42 | log(config, message) 43 | } 44 | 45 | suspend fun commandClose(guild: Guild, channelName: String, staff: User) { 46 | val config = guild.logConfig 47 | val message = "Staff action :: ${staff.tag} closed $channelName" 48 | 49 | if (config.logClose) 50 | log(config, message) 51 | } 52 | 53 | suspend fun manualClose(guild: Guild, channelName: String) { 54 | val config = guild.logConfig 55 | val message = "Staff action :: $channelName was deleted. See the server audit log for more information." 56 | 57 | if (config.logClose) 58 | log(config, message) 59 | } 60 | 61 | suspend fun edit(report: Report, old: String, new: String) { 62 | val config = config[report.guildId]!!.loggingConfiguration 63 | 64 | if (config.logEdits) 65 | logEmbed(config, buildEditEmbed(report, old, new)) 66 | } 67 | 68 | suspend fun command(command: CommandEvent<*>, additionalInfo: String = "") = command.guild!!.logConfig.apply { 69 | val author = command.author.tag 70 | val commandName = command.command!!.names.first() 71 | val channelName = (command.channel as TextChannel).name 72 | 73 | if (logCommands) 74 | log(this, "$author invoked `${commandName}` in ${channelName}. $additionalInfo") 75 | } 76 | 77 | private val Guild.logConfig 78 | get() = config[this]!!.loggingConfiguration 79 | 80 | private suspend fun log(config: LoggingConfiguration, message: String) = config.getLiveChannel(kord)?.createMessage(message) 81 | private suspend fun logEmbed(config: LoggingConfiguration, embed: EmbedBuilder) = config.getLiveChannel(kord)?.createMessage { 82 | embeds.add(embed) 83 | } 84 | 85 | private suspend fun buildEditEmbed(report: Report, old: String, new: String) = EmbedBuilder().apply { 86 | fun createFields(title: String, message: String) = message.chunked(1024).mapIndexed { index, chunk -> 87 | field { 88 | name = if (index == 0) title else "(cont)" 89 | value = chunk 90 | inline = false 91 | } 92 | } 93 | 94 | color = Color.white.kColor 95 | thumbnail(report.liveMember(kord)?.pfpUrl ?: "") 96 | addField("Edit Detected!", "<@!${report.userId}> edited a message.") 97 | createFields("Old Content", old) 98 | createFields("New Content", new) 99 | } 100 | } -------------------------------------------------------------------------------- /src/main/kotlin/me/jakejmattson/modmail/services/MacroService.kt: -------------------------------------------------------------------------------- 1 | package me.jakejmattson.modmail.services 2 | 3 | import dev.kord.core.entity.Guild 4 | import kotlinx.serialization.Serializable 5 | import kotlinx.serialization.decodeFromString 6 | import kotlinx.serialization.encodeToString 7 | import kotlinx.serialization.json.Json 8 | import me.jakejmattson.discordkt.annotations.Service 9 | 10 | @Serializable 11 | data class Macro(var name: String, var message: String) 12 | 13 | @Service 14 | class MacroService { 15 | private val macroMap = loadMacros() 16 | 17 | fun findMacro(guild: Guild, name: String) = getGuildMacros(guild).firstOrNull { it.name.equals(name, true) } 18 | 19 | private fun MutableList.hasMacro(name: String) = this.any { it.name.equals(name, true) } 20 | 21 | fun getGuildMacros(guild: Guild) = macroMap.getOrPut(guild.id.toString()) { mutableListOf() } 22 | 23 | fun addMacro(name: String, message: String, guild: Guild): Boolean { 24 | val macroList = getGuildMacros(guild) 25 | 26 | if (macroList.hasMacro(name)) return false 27 | 28 | macroList.add(Macro(name, message)) 29 | macroMap.save() 30 | 31 | return true 32 | } 33 | 34 | fun removeMacro(name: String, guild: Guild): Boolean { 35 | val macro = findMacro(guild, name) ?: return false 36 | val wasRemoved = getGuildMacros(guild).remove(macro) 37 | macroMap.save() 38 | return wasRemoved 39 | } 40 | 41 | fun listMacros(guild: Guild) = getGuildMacros(guild) 42 | .map { it.name } 43 | .sorted() 44 | .joinToString() 45 | .takeIf { it.isNotEmpty() } 46 | ?: "" 47 | 48 | fun editName(name: String, newName: String, guild: Guild): Boolean { 49 | if (getGuildMacros(guild).hasMacro(newName)) return false 50 | val macro = findMacro(guild, name) ?: return false 51 | 52 | macro.name = newName 53 | macroMap.save() 54 | 55 | return true 56 | } 57 | 58 | fun editMessage(name: String, newMessage: String, guild: Guild): Boolean { 59 | val macro = findMacro(guild, name) ?: return false 60 | macro.message = newMessage 61 | macroMap.save() 62 | return true 63 | } 64 | } 65 | 66 | private fun Map>.save() = macroFile.writeText(Json.encodeToString(this)) 67 | 68 | private fun loadMacros() = 69 | if (macroFile.exists()) 70 | Json.decodeFromString(macroFile.readText()) 71 | else 72 | mutableMapOf>() -------------------------------------------------------------------------------- /src/main/kotlin/me/jakejmattson/modmail/services/ModerationService.kt: -------------------------------------------------------------------------------- 1 | package me.jakejmattson.modmail.services 2 | 3 | import dev.kord.core.Kord 4 | import dev.kord.core.behavior.GuildBehavior 5 | import dev.kord.core.entity.Member 6 | import kotlinx.coroutines.flow.firstOrNull 7 | import kotlinx.coroutines.flow.toList 8 | import java.util.* 9 | 10 | private val detainedReports = Vector() 11 | 12 | suspend fun Report.detain(kord: Kord) { 13 | val member = liveMember(kord) ?: return 14 | 15 | if (!member.isDetained()) { 16 | detainedReports.addElement(this) 17 | member.mute() 18 | } 19 | } 20 | 21 | suspend fun Report.release(kord: Kord): Boolean { 22 | val member = liveMember(kord) ?: return false 23 | 24 | if (member.isDetained()) { 25 | detainedReports.remove(this) 26 | member.unmute() 27 | } 28 | 29 | return true 30 | } 31 | 32 | fun Member.isDetained() = detainedReports.any { it.userId == id } 33 | 34 | suspend fun Member.mute(): Boolean { 35 | val mutedRole = guild.getMutedRole() ?: return false 36 | 37 | if (mutedRole !in roles.toList()) 38 | addRole(mutedRole.id) 39 | 40 | return true 41 | } 42 | 43 | suspend fun Member.unmute(): Boolean { 44 | val mutedRole = guild.getMutedRole() ?: return false 45 | 46 | if (mutedRole in roles.toList()) 47 | removeRole(mutedRole.id) 48 | 49 | return true 50 | } 51 | 52 | private suspend fun GuildBehavior.getMutedRole() = roles.firstOrNull { it.name.equals("muted", true) } -------------------------------------------------------------------------------- /src/main/kotlin/me/jakejmattson/modmail/services/ReportService.kt: -------------------------------------------------------------------------------- 1 | package me.jakejmattson.modmail.services 2 | 3 | import dev.kord.common.entity.Snowflake 4 | import dev.kord.common.kColor 5 | import dev.kord.core.Kord 6 | import dev.kord.core.behavior.UserBehavior 7 | import dev.kord.core.behavior.channel.MessageChannelBehavior 8 | import dev.kord.core.behavior.channel.createEmbed 9 | import dev.kord.core.behavior.channel.createMessage 10 | import dev.kord.core.behavior.createTextChannel 11 | import dev.kord.core.behavior.getChannelOf 12 | import dev.kord.core.entity.* 13 | import dev.kord.core.entity.channel.Category 14 | import dev.kord.core.entity.channel.GuildMessageChannel 15 | import dev.kord.core.entity.channel.TextChannel 16 | import dev.kord.rest.Image 17 | import dev.kord.rest.builder.message.create.allowedMentions 18 | import dev.kord.rest.builder.message.create.embed 19 | import kotlinx.coroutines.GlobalScope 20 | import kotlinx.coroutines.flow.count 21 | import kotlinx.coroutines.launch 22 | import kotlinx.datetime.toKotlinInstant 23 | import kotlinx.serialization.Serializable 24 | import kotlinx.serialization.decodeFromString 25 | import kotlinx.serialization.encodeToString 26 | import kotlinx.serialization.json.Json 27 | import me.jakejmattson.discordkt.Discord 28 | import me.jakejmattson.discordkt.annotations.Service 29 | import me.jakejmattson.discordkt.extensions.* 30 | import me.jakejmattson.modmail.extensions.addFailReaction 31 | import me.jakejmattson.modmail.extensions.fullContent 32 | import me.jakejmattson.modmail.extensions.fullname 33 | import java.awt.Color 34 | import java.io.File 35 | import java.time.Instant 36 | import java.util.* 37 | import java.util.concurrent.ConcurrentHashMap 38 | 39 | @Serializable 40 | data class Report(val userId: Snowflake, 41 | val channelId: Snowflake, 42 | val guildId: Snowflake, 43 | val messages: MutableMap = mutableMapOf()) { 44 | 45 | suspend fun liveMember(kord: Kord) = kord.getGuild(guildId)?.getMemberOrNull(userId) 46 | suspend fun liveChannel(kord: Kord) = kord.getChannelOf(channelId) 47 | } 48 | 49 | private val reports = Vector() 50 | 51 | fun UserBehavior.findReport() = reports.firstOrNull { it.userId == id } 52 | fun MessageChannelBehavior.findReport() = reports.firstOrNull { it.channelId == id } 53 | 54 | @Service 55 | class ReportService(private val config: Configuration, 56 | private val loggingService: LoggingService, 57 | private val discord: Discord) { 58 | init { 59 | GlobalScope.launch { 60 | reportsFolder.listFiles()?.forEach { 61 | val report = Json.decodeFromString(it.readText()) 62 | val channel = report.liveChannel(discord.kord) 63 | 64 | if (channel != null) reports.add(report) else it.delete() 65 | } 66 | } 67 | } 68 | 69 | suspend fun createReport(user: User, guild: Guild) { 70 | val member = user.asMemberOrNull(guild.id) ?: return 71 | 72 | if (guild.channels.count() >= 250) return 73 | 74 | val reportCategory = config[guild]?.getLiveReportCategory(guild.kord) ?: return 75 | 76 | createReportChannel(reportCategory, member, guild) 77 | } 78 | 79 | fun addReport(report: Report) { 80 | reports.add(report) 81 | writeReportToFile(report) 82 | } 83 | 84 | suspend fun receiveFromUser(message: Message) { 85 | val user = message.author!! 86 | val report = user.findReport() ?: return 87 | val liveChannel = report.liveChannel(message.kord) ?: return 88 | 89 | if (report.liveMember(message.kord) == null) { 90 | message.addFailReaction() 91 | return 92 | } 93 | 94 | val newMessage = liveChannel.createMessage { 95 | allowedMentions { } 96 | content = message.fullContent().takeIf { it.isNotBlank() } ?: "[STICKER:${message.stickers.first().name}]" 97 | } 98 | 99 | val snowflakeRegex = Regex("^\\d{17,21}$") 100 | val snowflakes = message.content.split(Regex("\\s+")).filter { it.matches(snowflakeRegex) }.toSet() 101 | val messageRegex = "^https://discord\\.com/channels/(\\d{17,21})/(\\d{17,21})/(\\d{17,21})$".toRegex() 102 | val messageLinks = message.content.split(Regex("\\s+")).filter { it.matches(messageRegex) } 103 | 104 | if (messageLinks.isNotEmpty()) { 105 | newMessage.replySilently { 106 | embed { 107 | color = Color.white.kColor 108 | 109 | messageLinks.map { it.unwrapMessageLink()!! }.forEach { (_, channelId, messageId) -> 110 | val linkedMessage = liveChannel.guild.getChannelOf(channelId).getMessage(messageId) 111 | addField(linkedMessage.author?.fullName ?: "Unknown Author", 112 | "[[$messageId]](${linkedMessage.jumpLink()})\n${linkedMessage.fullContent()}") 113 | } 114 | } 115 | } 116 | } else if (snowflakes.isNotEmpty()) { 117 | newMessage.replySilently { 118 | content = "Potential users:\n" + snowflakes.joinToString("\n") { "`$it` = <@!$it>" } 119 | } 120 | } 121 | 122 | report.messages[message.id] = newMessage.id 123 | } 124 | 125 | fun writeReportToFile(report: Report) = 126 | File("$reportsFolder/${report.channelId.value}.json").writeText(Json.encodeToString(report)) 127 | 128 | private suspend fun createReportChannel(category: Category, member: Member, guild: Guild) { 129 | val reportChannel = guild.createTextChannel(member.username) { 130 | parentId = category.id 131 | } 132 | 133 | member.reportOpenEmbed(reportChannel) 134 | 135 | val newReport = Report(member.id, reportChannel.id, guild.id, ConcurrentHashMap()) 136 | addReport(newReport) 137 | 138 | member.sendPrivateMessage { 139 | color = Color.GREEN.kColor 140 | 141 | field { 142 | name = "Report Opened" 143 | value = "Someone will respond shortly, please be patient." 144 | } 145 | 146 | author { 147 | name = guild.name 148 | icon = guild.getIconUrl(Image.Format.PNG) 149 | } 150 | } 151 | 152 | loggingService.memberOpen(newReport) 153 | } 154 | } 155 | 156 | suspend fun Report.close(kord: Kord) { 157 | release(kord) 158 | removeReport(this) 159 | sendReportClosedEmbed(this, kord) 160 | } 161 | 162 | private fun removeReport(report: Report) { 163 | reports.remove(report) 164 | reportsFolder.listFiles()?.firstOrNull { it.name.startsWith(report.channelId.toString()) }?.delete() 165 | } 166 | 167 | suspend fun Member.reportOpenEmbed(channel: TextChannel, opener: User? = null, detain: Boolean = false) = channel.createEmbed { 168 | addInlineField("User", mention) 169 | addInlineField("Name", fullname) 170 | addField("User ID", id.toString()) 171 | thumbnail(pfpUrl) 172 | if (opener != null) footer("Opened by ${opener.fullname}", opener.pfpUrl) 173 | color = if (detain) Color.RED.kColor else Color.GREEN.kColor 174 | timestamp = Instant.now().toKotlinInstant() 175 | } 176 | 177 | private suspend fun sendReportClosedEmbed(report: Report, kord: Kord) { 178 | val guild = kord.getGuild(report.guildId) ?: return 179 | val user = kord.getUser(report.userId) ?: return 180 | 181 | user.sendPrivateMessage { 182 | color = Color.RED.kColor 183 | 184 | field { 185 | name = "Report Closed" 186 | value = "Any response will create a new report." 187 | } 188 | 189 | author { 190 | name = guild.name 191 | icon = guild.getIconUrl(Image.Format.PNG) 192 | } 193 | } 194 | } -------------------------------------------------------------------------------- /src/main/resources/bot.properties: -------------------------------------------------------------------------------- 1 | description=A report management bot 2 | name=ModMail 3 | url=https\://github.com/JakeJMattson/ModMail 4 | version=4.0.0-RC4 5 | --------------------------------------------------------------------------------