├── .env.example ├── .github └── workflows │ ├── build.yml │ └── docker.yaml ├── .gitignore ├── Dockerfile ├── LICENSE.md ├── README.md ├── build.gradle.kts ├── data └── .gitkeep ├── docker-compose.yaml ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── src └── main ├── kotlin └── me │ └── markhc │ └── hangoutbot │ ├── Main.kt │ ├── commands │ ├── BotConfigCommands.kt │ ├── ColorCommands.kt │ ├── FunCommands.kt │ ├── InformationCommands.kt │ ├── Moderation.kt │ ├── RoleCommands.kt │ └── UtilityCommands.kt │ ├── dataclasses │ └── Configuration.kt │ ├── listeners │ └── RoleMigration.kt │ ├── preconditions │ ├── CommandLogger.kt │ ├── SanitiseInvites.kt │ └── SetupPrecondition.kt │ └── services │ ├── ColorService.kt │ └── MuteService.kt └── resources ├── bot.properties └── simplelogger.properties /.env.example: -------------------------------------------------------------------------------- 1 | BOT_TOKEN= 2 | BOT_PREFIX=++ 3 | BOT_OWNER= -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v2 12 | - name: Set up JDK 13 | uses: actions/setup-java@v1 14 | with: 15 | java-version: 11 16 | - name: Build with Gradle 17 | run: ./gradlew build 18 | -------------------------------------------------------------------------------- /.github/workflows/docker.yaml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | 7 | jobs: 8 | docker: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - 12 | name: Checkout code 13 | uses: actions/checkout@v2 14 | - 15 | name: Set up Docker Buildx 16 | uses: docker/setup-buildx-action@v1 17 | - 18 | name: Login to DockerHub 19 | uses: docker/login-action@v1 20 | with: 21 | username: ${{ secrets.DOCKERHUB_USERNAME }} 22 | password: ${{ secrets.DOCKERHUB_ACCESS_TOKEN }} 23 | 24 | - 25 | name: Build and push 26 | uses: docker/build-push-action@v2 27 | with: 28 | context: . 29 | pull: true 30 | push: true 31 | tags: theprogrammershangout/hangoutbot:latest -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IntelliJ # 2 | .idea/ 3 | **.iml 4 | 5 | # Environment file # 6 | .env 7 | 8 | 9 | # Gradle # 10 | .gradle 11 | build 12 | 13 | # Project # 14 | data/* 15 | config/* 16 | !config/config.json.example -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM gradle:7.0-jdk16 AS build 2 | COPY --chown=gradle:gradle . /home/gradle/src 3 | WORKDIR /home/gradle/src 4 | RUN gradle shadowJar --no-daemon 5 | 6 | FROM openjdk:11.0.8-jre-slim 7 | RUN mkdir /home/app 8 | ENV HOME=/home/app 9 | WORKDIR $HOME 10 | RUN mkdir /data/ 11 | 12 | COPY --from=build /home/gradle/src/build/libs/*.jar $HOME/hangoutbot.jar 13 | 14 | ENTRYPOINT [ "java", "-jar", "hangoutbot.jar" ] 15 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 The Programmers Hangout 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![CI](https://github.com/the-programmers-hangout/HangoutBot/workflows/CI/badge.svg) 2 | 3 | # HangoutBot 4 | 5 | A bot for miscellaneous commands and functionality required in the TheProgrammersHangout Discord server. 6 | 7 | Join us at https://discord.gg/programming 8 | 9 | 10 | The Programmers Hangout 11 | 12 | 13 | ## Installation 14 | 15 | The bot makes use of Docker and Docker Compose to setup the required environment. 16 | 17 | ```console 18 | $ git clone https://github.com/the-programmers-hangout/HangoutBot.git 19 | $ cd HangoutBot 20 | 21 | # Setup the environment file with the necessary fields 22 | $ cp .env.example .env 23 | $ vim .env 24 | 25 | # Launch the bot 26 | $ docker-compose up --build --detach 27 | ``` 28 | 29 | The .env file is used to configure the bot token, prefix and owner id, in the following format: 30 | 31 | ``` 32 | BOT_TOKEN= 33 | BOT_PREFIX=++ 34 | BOT_OWNER= 35 | ``` 36 | 37 | ## License 38 | 39 | ``` 40 | MIT License 41 | 42 | Copyright (c) 2020 The Programmers Hangout 43 | 44 | Permission is hereby granted, free of charge, to any person obtaining a copy 45 | of this software and associated documentation files (the "Software"), to deal 46 | in the Software without restriction, including without limitation the rights 47 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 48 | copies of the Software, and to permit persons to whom the Software is 49 | furnished to do so, subject to the following conditions: 50 | 51 | The above copyright notice and this permission notice shall be included in all 52 | copies or substantial portions of the Software. 53 | 54 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 55 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 56 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 57 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 58 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 59 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 60 | SOFTWARE. 61 | ``` 62 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | group = "me.markhc" 2 | version = "4.0.0-RC1" 3 | description = "A misc feature bot for TheProgrammersHangout" 4 | 5 | plugins { 6 | kotlin("jvm") version "1.7.10" 7 | kotlin("plugin.serialization") version "1.7.10" 8 | id("com.github.johnrengelman.shadow") version "7.1.2" 9 | } 10 | 11 | repositories { 12 | mavenCentral() 13 | } 14 | 15 | dependencies { 16 | implementation("me.jakejmattson:DiscordKt:0.23.4") 17 | } 18 | 19 | tasks { 20 | compileKotlin { 21 | kotlinOptions.jvmTarget = "1.8" 22 | dependsOn("writeProperties") 23 | } 24 | 25 | register("writeProperties") { 26 | property("name", project.name) 27 | property("description", project.description.toString()) 28 | property("version", version.toString()) 29 | property("url", "https://github.com/the-programmers-hangout/HangoutBot") 30 | setOutputFile("src/main/resources/bot.properties") 31 | } 32 | 33 | shadowJar { 34 | archiveFileName.set("HangoutBot.jar") 35 | manifest { 36 | attributes("Main-Class" to "me.markhc.hangoutbot.MainKt") 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /data/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-programmers-hangout/HangoutBot/09374938967309f77619e5bd9168f52f6c84339b/data/.gitkeep -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | bot: 5 | container_name: HangoutBot 6 | image: theprogrammershangout/hangoutbot:latest 7 | volumes: 8 | - type: bind 9 | source: ./data 10 | target: /home/app/data 11 | restart: unless-stopped 12 | env_file: 13 | - .env 14 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-programmers-hangout/HangoutBot/09374938967309f77619e5bd9168f52f6c84339b/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 | -------------------------------------------------------------------------------- /src/main/kotlin/me/markhc/hangoutbot/Main.kt: -------------------------------------------------------------------------------- 1 | package me.markhc.hangoutbot 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.markhc.hangoutbot.dataclasses.Configuration 11 | import me.markhc.hangoutbot.services.MuteService 12 | import java.awt.Color 13 | 14 | @OptIn(KordPreview::class) 15 | @PrivilegedIntent 16 | suspend fun main(args: Array) { 17 | val token = args.firstOrNull() 18 | ?: System.getenv("BOT_TOKEN") 19 | ?: throw IllegalArgumentException("Missing bot token.") 20 | 21 | bot(token) { 22 | val configuration = data("data/guilds.json") { Configuration() } 23 | 24 | prefix { "/" } 25 | 26 | configure { 27 | mentionAsPrefix = true 28 | documentCommands = false 29 | commandReaction = null 30 | recommendCommands = false 31 | theme = Color.CYAN 32 | intents = Intent.GuildMembers + Intent.Guilds + Intent.GuildMessages 33 | defaultPermissions = Permissions(Permission.UseApplicationCommands) 34 | } 35 | 36 | onStart { 37 | val muteService = getInjectionObjects() 38 | muteService.launchTimers() 39 | 40 | configuration.reminders.removeIf { it.endTime < System.currentTimeMillis() } 41 | configuration.reminders.forEach { it.launch(this, configuration) } 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /src/main/kotlin/me/markhc/hangoutbot/commands/BotConfigCommands.kt: -------------------------------------------------------------------------------- 1 | package me.markhc.hangoutbot.commands 2 | 3 | import dev.kord.common.entity.Permission 4 | import dev.kord.common.entity.Permissions 5 | import me.jakejmattson.discordkt.arguments.ChannelArg 6 | import me.jakejmattson.discordkt.arguments.RoleArg 7 | import me.jakejmattson.discordkt.commands.commands 8 | import me.jakejmattson.discordkt.dsl.edit 9 | import me.markhc.hangoutbot.dataclasses.Configuration 10 | import me.markhc.hangoutbot.dataclasses.GuildConfiguration 11 | 12 | fun botConfigCommands(configuration: Configuration) = commands("Configuration", Permissions(Permission.Administrator)) { 13 | slash("Configure", "Configure guild properties.", Permissions(Permission.ManageGuild)) { 14 | execute(RoleArg("Mute", "The role applied when someone is muted"), 15 | RoleArg("SoftMute", "The role applied to soft-mute a user"), 16 | ChannelArg("Logging", "The channel where logging messages will be sent")) { 17 | 18 | val (muteRole, softMuteRole, loggingChannel) = args 19 | configuration.edit { this[guild.id] = GuildConfiguration(muteRole.id, softMuteRole.id, loggingChannel.id) } 20 | respond("${guild.name} configured!") 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/main/kotlin/me/markhc/hangoutbot/commands/ColorCommands.kt: -------------------------------------------------------------------------------- 1 | package me.markhc.hangoutbot.commands 2 | 3 | import dev.kord.common.entity.Permission 4 | import dev.kord.common.entity.Permissions 5 | import me.jakejmattson.discordkt.arguments.* 6 | import me.jakejmattson.discordkt.commands.subcommand 7 | import me.jakejmattson.discordkt.extensions.stringify 8 | import me.markhc.hangoutbot.dataclasses.Configuration 9 | import me.markhc.hangoutbot.services.ColorService 10 | import java.awt.Color 11 | 12 | fun colorCommands(configuration: Configuration, colorService: ColorService) = subcommand("colors", Permissions(Permission.ManageMessages)) { 13 | sub("create", "Create a color role.") { 14 | execute(AnyArg("Name"), HexColorArg) { 15 | val (name, color) = args 16 | val member = getMember()!! 17 | val existingRole = colorService.findRole(name, color, guild) 18 | 19 | if (existingRole != null) { 20 | respond("Similar role already exists: ${existingRole.name} - ${stringify(existingRole.color)}") 21 | } else { 22 | val newRole = colorService.createRole(name, color, guild) 23 | colorService.setMemberColor(member, newRole) 24 | respondPublic("Role created: ${newRole.mention}") 25 | } 26 | } 27 | } 28 | 29 | sub("apply", "Apply a color role.") { 30 | execute(RoleArg) { 31 | val role = args.first 32 | val member = author.asMember(guild.id) 33 | val wasApplied = colorService.setMemberColor(member, role) 34 | 35 | if (wasApplied) 36 | respondPublic("Color applied: ${role.name}") 37 | else 38 | respond("Not a valid color role.") 39 | } 40 | } 41 | 42 | sub("clear", "Clears your color role.") { 43 | execute { 44 | val member = author.asMember(guild.id) 45 | colorService.removeColorRole(member) 46 | colorService.deleteUnusedRoles(configuration[guild], guild) 47 | respond("Cleared user color") 48 | } 49 | } 50 | 51 | sub("list", "List all color roles.") { 52 | execute { 53 | val colorRoles = configuration[guild].assignedColorRoles.keys 54 | 55 | if (colorRoles.isEmpty()) { 56 | respond("No color roles") 57 | return@execute 58 | } 59 | 60 | val colorInfo = colorRoles.map { 61 | it.let { guild.getRole(it) } 62 | }.sortedBy { 63 | val rgb = it.color 64 | val hsv = Color.RGBtoHSB(rgb.red, rgb.green, rgb.blue, null) 65 | hsv[0] 66 | }.joinToString("\n") { it.mention } 67 | 68 | respond { 69 | title = "Currently used colors" 70 | description = "Run `/create color ` to create some" 71 | color = discord.configuration.theme 72 | 73 | field { 74 | name = "Colors" 75 | value = colorInfo 76 | } 77 | } 78 | } 79 | } 80 | } -------------------------------------------------------------------------------- /src/main/kotlin/me/markhc/hangoutbot/commands/FunCommands.kt: -------------------------------------------------------------------------------- 1 | package me.markhc.hangoutbot.commands 2 | 3 | import kotlinx.coroutines.Dispatchers 4 | import kotlinx.coroutines.withContext 5 | import me.jakejmattson.discordkt.arguments.SplitterArg 6 | import me.jakejmattson.discordkt.commands.commands 7 | import java.net.HttpURLConnection 8 | import java.net.URL 9 | 10 | fun produceFunCommands() = commands("Fun") { 11 | slash("flip", "Choose one of the given choices.") { 12 | execute(SplitterArg(";", "Choices")) { 13 | val response = listOf( 14 | "Hmm, I'd say %choice%.", 15 | "%choice%, no doubt.", 16 | "Perhaps... %choice%.", 17 | "%choice% sounds good to me.", 18 | "If it were up to me, I'd go with %choice%", 19 | "East or west, %choice% is the best." 20 | ).random().replace("%choice%", args.first.random()) 21 | 22 | respondPublic(response) 23 | } 24 | } 25 | 26 | slash("dadjoke", "Returns a random dad joke.") { 27 | execute { 28 | val connection = URL("https://icanhazdadjoke.com/").openConnection() as HttpURLConnection 29 | connection.setRequestProperty( 30 | "User-Agent", 31 | "HangoutBot (https://github.com/the-programmers-hangout/HangoutBot/)" 32 | ) 33 | connection.setRequestProperty("Accept", "text/plain") 34 | connection.setRequestProperty("Accept-Language", "en-US") 35 | connection.setRequestProperty("Connection", "close") 36 | respondPublic(withContext(Dispatchers.IO) { 37 | String(connection.inputStream.readAllBytes()) 38 | }) 39 | } 40 | } 41 | 42 | slash("stupid") { 43 | execute { 44 | respond("${this.author.avatar?.url}") 45 | } 46 | } 47 | 48 | slash("getadmin") { 49 | execute { 50 | respond("You can't see me but I'm laughing at you right now for trying that.") 51 | } 52 | } 53 | } -------------------------------------------------------------------------------- /src/main/kotlin/me/markhc/hangoutbot/commands/InformationCommands.kt: -------------------------------------------------------------------------------- 1 | package me.markhc.hangoutbot.commands 2 | 3 | import dev.kord.core.entity.channel.TextChannel 4 | import dev.kord.core.entity.channel.VoiceChannel 5 | import dev.kord.rest.Image 6 | import kotlinx.coroutines.flow.count 7 | import kotlinx.coroutines.flow.filterIsInstance 8 | import kotlinx.coroutines.flow.toList 9 | import kotlinx.datetime.toJavaInstant 10 | import me.jakejmattson.discordkt.arguments.RoleArg 11 | import me.jakejmattson.discordkt.arguments.UserArg 12 | import me.jakejmattson.discordkt.commands.subcommand 13 | import me.jakejmattson.discordkt.extensions.* 14 | 15 | fun produceInformationCommands() = subcommand("details") { 16 | sub("server", "Display a message giving basic server information.") { 17 | execute { 18 | respondPublic { 19 | title = guild.name 20 | description = guild.description 21 | color = discord.configuration.theme 22 | timestamp = guild.id.timestamp 23 | 24 | author(guild.getOwner()) 25 | footer("Guild creation") 26 | thumbnail(guild.getIconUrl(Image.Format.PNG) ?: "") 27 | 28 | field { 29 | name = "Count" 30 | value = """ 31 | ``` 32 | Users: ${guild.members.count()} 33 | Roles: ${guild.roles.count()} 34 | Boost: ${guild.premiumTier.value} 35 | Emoji: ${guild.emojis.count()} 36 | Voice: ${guild.channels.filterIsInstance().count()} 37 | Text : ${guild.channels.filterIsInstance().count()}``` 38 | """.trimIndent() 39 | } 40 | 41 | addInlineField("Vanity URL", guild.vanityUrl ?: "None") 42 | } 43 | } 44 | } 45 | 46 | sub("user", "Displays information about the given user.") { 47 | execute(UserArg("User", "The user to see more information about").optional { it.author }) { 48 | val (user) = args 49 | val member = guild.getMemberOrNull(user.id) 50 | 51 | respondPublic { 52 | title = "User information" 53 | color = discord.configuration.theme 54 | thumbnail(user.pfpUrl) 55 | addInlineField("Username", user.tag) 56 | addInlineField("ID", user.id.toString()) 57 | addInlineField("Created", TimeStamp.at(user.id.timestamp.toJavaInstant(), TimeStyle.RELATIVE)) 58 | 59 | if (member != null) { 60 | addInlineField("Nickname", member.displayName) 61 | addInlineField("Avatar", "[Link](${member.pfpUrl}?size=512)") 62 | addInlineField("Joined", TimeStamp.at(member.joinedAt.toJavaInstant(), TimeStyle.RELATIVE)) 63 | 64 | addField("Roles", member.roles.toList().joinToString { it.name }) 65 | } 66 | } 67 | } 68 | } 69 | 70 | sub("role", "Displays information about the given role.") { 71 | execute(RoleArg("Role", "The role to see information about")) { 72 | val role = args.first 73 | 74 | respond { 75 | title = "Role information" 76 | color = role.color 77 | 78 | addInlineField("Name", role.name) 79 | addInlineField("ID", role.id.toString()) 80 | addInlineField("Color", stringify(role.color)) 81 | addInlineField("Hoisted", role.hoisted.toString()) 82 | addInlineField("Managed", role.managed.toString()) 83 | addInlineField("Mentionable", role.mentionable.toString()) 84 | addField("Members", "${guild.members.toList().filter { role in it.roles.toList() }.size} members") 85 | } 86 | } 87 | } 88 | 89 | sub("avatar", "Gets the avatar from the given user") { 90 | execute(UserArg("User", "The user to see the avatar of")) { 91 | val user = args.first 92 | respond("${user.pfpUrl}?size=512") 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/main/kotlin/me/markhc/hangoutbot/commands/Moderation.kt: -------------------------------------------------------------------------------- 1 | package me.markhc.hangoutbot.commands 2 | 3 | import dev.kord.common.entity.Permission 4 | import dev.kord.common.entity.Permissions 5 | import dev.kord.core.entity.channel.GuildMessageChannel 6 | import dev.kord.core.entity.channel.TextChannel 7 | import kotlinx.coroutines.flow.map 8 | import kotlinx.coroutines.flow.toList 9 | import me.jakejmattson.discordkt.arguments.ChannelArg 10 | import me.jakejmattson.discordkt.arguments.EveryArg 11 | import me.jakejmattson.discordkt.commands.commands 12 | 13 | fun moderationCommands() = commands("Moderation", Permissions(Permission.ManageMessages)) { 14 | slash("echo", "Echo a message to a channel.") { 15 | execute(EveryArg("Message"), ChannelArg.optional { it.channel as TextChannel }) { 16 | val (message, channel) = args 17 | channel.createMessage(message) 18 | } 19 | } 20 | 21 | message("Nuke Messages After", "NukeAfter", "Delete all message following this one") { 22 | val delete = channel.getMessagesAfter(arg.id).map { it.id }.toList() 23 | 24 | if (delete.size >= 100) { 25 | respond("Cannot nuke more than 100 messages") 26 | return@message 27 | } 28 | 29 | (channel as GuildMessageChannel).bulkDelete(delete) 30 | respondPublic("Deleted ${delete.size} messages.") 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main/kotlin/me/markhc/hangoutbot/commands/RoleCommands.kt: -------------------------------------------------------------------------------- 1 | package me.markhc.hangoutbot.commands 2 | 3 | import dev.kord.common.entity.Permission 4 | import dev.kord.common.entity.Permissions 5 | import me.jakejmattson.discordkt.arguments.MemberArg 6 | import me.jakejmattson.discordkt.arguments.RoleArg 7 | import me.jakejmattson.discordkt.commands.commands 8 | import me.jakejmattson.discordkt.commands.subcommand 9 | import me.markhc.hangoutbot.dataclasses.Configuration 10 | 11 | fun grantableRoles(configuration: Configuration) = subcommand("GrantableRoles", Permissions(Permission.ManageGuild)) { 12 | sub("Add", "Add a role to the list of grantable roles") { 13 | execute(RoleArg) { 14 | val role = args.first 15 | val grantableRoles = configuration[guild].grantableRoles 16 | 17 | if (role.id in grantableRoles) { 18 | respond("Role is already grantable") 19 | } else { 20 | grantableRoles.add(role.id) 21 | respond("Added \"${role.name}\" to grantable roles.") 22 | } 23 | } 24 | } 25 | 26 | sub("Remove", "Remove a role from the list of grantable roles") { 27 | execute(RoleArg) { 28 | val role = args.first 29 | val grantableRoles = configuration[guild].grantableRoles 30 | 31 | if (role.id !in grantableRoles) { 32 | respond("Role ${role.name} is not a grantable role.") 33 | return@execute 34 | } 35 | 36 | grantableRoles.remove(role.id) 37 | respond("Removed \"${role.name}\" from the list of grantable roles.") 38 | } 39 | } 40 | 41 | sub("List", "List all grantable roles") { 42 | execute { 43 | val grantableRoles = configuration[guild].grantableRoles 44 | 45 | if (grantableRoles.isEmpty()) { 46 | respond("No roles set") 47 | return@execute 48 | } 49 | 50 | respond { 51 | color = discord.configuration.theme 52 | 53 | field { 54 | name = "Grantable roles" 55 | value = grantableRoles.map { id -> 56 | guild.getRoleOrNull(id)?.name ?: id 57 | }.joinToString("\n") 58 | } 59 | } 60 | } 61 | } 62 | } 63 | 64 | fun roleCommands(configuration: Configuration) = commands("Roles", Permissions(Permission.ManageMessages)) { 65 | slash("grant", "Grants a role to a lower ranked member or yourself") { 66 | execute(MemberArg("Member"), RoleArg("GrantableRole")) { 67 | val (member, role) = args 68 | val roles = configuration[guild].grantableRoles 69 | 70 | if (roles.any { r -> r == role.id }) { 71 | member.addRole(role.id) 72 | respond("Granted ${role.name} to ${member.tag}") 73 | } else { 74 | respond("${role.name} is not a grantable role") 75 | } 76 | } 77 | } 78 | 79 | slash("revoke", "Revokes a role from a lower ranked member or yourself") { 80 | execute(MemberArg("Member"), RoleArg("GrantableRole")) { 81 | val (member, role) = args 82 | val isGrantable = role.id in configuration[guild].grantableRoles 83 | 84 | if (isGrantable) { 85 | member.removeRole(role.id) 86 | respond("Revoked ${role.name} from ${member.tag}") 87 | } else { 88 | respond("${role.name} is not a grantable role") 89 | } 90 | } 91 | } 92 | } -------------------------------------------------------------------------------- /src/main/kotlin/me/markhc/hangoutbot/commands/UtilityCommands.kt: -------------------------------------------------------------------------------- 1 | package me.markhc.hangoutbot.commands 2 | 3 | import me.jakejmattson.discordkt.arguments.EveryArg 4 | import me.jakejmattson.discordkt.arguments.TimeArg 5 | import me.jakejmattson.discordkt.commands.commands 6 | import me.jakejmattson.discordkt.dsl.edit 7 | import me.jakejmattson.discordkt.extensions.TimeStamp 8 | import me.jakejmattson.discordkt.extensions.TimeStyle 9 | import me.markhc.hangoutbot.dataclasses.Configuration 10 | import me.markhc.hangoutbot.dataclasses.Reminder 11 | import me.markhc.hangoutbot.services.MuteService 12 | import java.time.Instant 13 | import java.util.concurrent.TimeUnit 14 | import kotlin.math.roundToLong 15 | 16 | fun produceUtilityCommands(muteService: MuteService, configuration: Configuration) = commands("Selfmute") { 17 | slash("selfmute", "Mute yourself for the given amount of time.") { 18 | execute(TimeArg.optional(3600.0)) { 19 | val (timeInSeconds) = args 20 | 21 | if (timeInSeconds < 5) { 22 | respond("You cannot mute yourself for less than 5 seconds.") 23 | return@execute 24 | } 25 | if (timeInSeconds > TimeUnit.HOURS.toSeconds(24)) { 26 | respond("You cannot mute yourself for more than 24 hours.") 27 | return@execute 28 | } 29 | 30 | val member = author.asMember(guild.id) 31 | val millis = timeInSeconds.roundToLong() * 1000 32 | 33 | muteService.addMutedMember(this, member, millis, soft = false) 34 | } 35 | } 36 | 37 | slash("productivemute", "Hide social channels for a given amount of time.") { 38 | execute(TimeArg.optional(3600.0)) { 39 | val (timeInSeconds) = args 40 | 41 | if (timeInSeconds < 5) { 42 | respond("You cannot mute yourself for less than 5 seconds.") 43 | return@execute 44 | } 45 | if (timeInSeconds > TimeUnit.HOURS.toSeconds(24)) { 46 | respond("You cannot mute yourself for more than 24 hours.") 47 | return@execute 48 | } 49 | 50 | val member = author.asMember(guild.id) 51 | val millis = timeInSeconds.roundToLong() * 1000 52 | 53 | muteService.addMutedMember(this, member, millis, soft = true) 54 | } 55 | } 56 | 57 | slash("remindme", "A command that'll remind you about something after the specified time.") { 58 | execute(TimeArg, EveryArg) { 59 | val (seconds, message) = args 60 | 61 | if (seconds < 5) { 62 | respond("You cannot set a reminder for less than 5 seconds.") 63 | return@execute 64 | } 65 | if (seconds > TimeUnit.DAYS.toSeconds(90)) { 66 | respond("You cannot set a reminder more than 90 days into the future.") 67 | return@execute 68 | } 69 | 70 | val ms = seconds.roundToLong() * 1000 71 | val until = Instant.now().plusMillis(ms) 72 | val reminder = Reminder(author.id, until.toEpochMilli(), message) 73 | 74 | configuration.edit { reminders.add(reminder) } 75 | reminder.launch(discord, configuration) 76 | 77 | respond("Got it, I'll remind you ${TimeStamp.at(until, TimeStyle.RELATIVE)}") 78 | } 79 | } 80 | } -------------------------------------------------------------------------------- /src/main/kotlin/me/markhc/hangoutbot/dataclasses/Configuration.kt: -------------------------------------------------------------------------------- 1 | package me.markhc.hangoutbot.dataclasses 2 | 3 | import dev.kord.common.entity.Snowflake 4 | import dev.kord.core.behavior.GuildBehavior 5 | import dev.kord.core.entity.Guild 6 | import kotlinx.coroutines.GlobalScope 7 | import kotlinx.coroutines.delay 8 | import kotlinx.coroutines.launch 9 | import kotlinx.serialization.Serializable 10 | import me.jakejmattson.discordkt.Discord 11 | import me.jakejmattson.discordkt.dsl.Data 12 | import me.jakejmattson.discordkt.dsl.edit 13 | import me.jakejmattson.discordkt.extensions.sendPrivateMessage 14 | 15 | @Serializable 16 | data class Configuration(val guildConfigurations: MutableMap = mutableMapOf(), 17 | val reminders: MutableList = mutableListOf()) : Data() { 18 | operator fun get(guild: GuildBehavior) = guildConfigurations[guild.id]!! 19 | 20 | operator fun set(id: Snowflake, value: GuildConfiguration) { 21 | guildConfigurations[id] = value 22 | } 23 | 24 | fun hasGuildConfig(guild: Guild) = guildConfigurations[guild.id] != null 25 | } 26 | 27 | @Serializable 28 | data class GuildConfiguration(var muteRole: Snowflake, 29 | var softMuteRole: Snowflake, 30 | var loggingChannel: Snowflake, 31 | val grantableRoles: MutableSet = mutableSetOf(), 32 | val assignedColorRoles: MutableMap> = mutableMapOf(), 33 | val mutedUsers: MutableList = mutableListOf()) 34 | 35 | @Serializable 36 | data class MuteEntry(val user: Snowflake, val endTime: Long, val isSoft: Boolean = false) 37 | 38 | @Serializable 39 | data class Reminder(val user: Snowflake, val endTime: Long, val message: String) { 40 | fun launch(discord: Discord, configuration: Configuration) { 41 | GlobalScope.launch { 42 | delay(endTime - System.currentTimeMillis()) 43 | 44 | discord.kord.getUser(user)?.sendPrivateMessage { 45 | title = "Reminder" 46 | description = message 47 | color = discord.configuration.theme 48 | } 49 | 50 | configuration.edit { reminders.remove(this@Reminder) } 51 | } 52 | } 53 | } -------------------------------------------------------------------------------- /src/main/kotlin/me/markhc/hangoutbot/listeners/RoleMigration.kt: -------------------------------------------------------------------------------- 1 | package me.markhc.hangoutbot.listeners 2 | 3 | import dev.kord.common.entity.Snowflake 4 | import dev.kord.core.event.guild.MemberUpdateEvent 5 | import dev.kord.core.event.role.RoleDeleteEvent 6 | import kotlinx.coroutines.flow.toList 7 | import me.jakejmattson.discordkt.dsl.listeners 8 | import me.markhc.hangoutbot.dataclasses.Configuration 9 | 10 | fun roleMigration(configuration: Configuration) = listeners { 11 | on { 12 | val config = configuration[getGuild()] 13 | 14 | val roleId = role!!.id 15 | config.grantableRoles.remove(roleId) 16 | 17 | config.assignedColorRoles.entries.removeIf { it.key == roleId } 18 | 19 | if (config.muteRole == roleId) { 20 | config.muteRole = Snowflake(0) 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /src/main/kotlin/me/markhc/hangoutbot/preconditions/CommandLogger.kt: -------------------------------------------------------------------------------- 1 | package me.markhc.hangoutbot.preconditions 2 | 3 | import dev.kord.core.behavior.getChannelOf 4 | import dev.kord.core.entity.channel.TextChannel 5 | import me.jakejmattson.discordkt.dsl.precondition 6 | import me.jakejmattson.discordkt.extensions.sanitiseMentions 7 | import me.markhc.hangoutbot.dataclasses.Configuration 8 | 9 | fun commandLogger(configuration: Configuration) = precondition { 10 | command ?: return@precondition 11 | 12 | val args = rawInputs.commandArgs.joinToString() 13 | 14 | if (guild != null) { 15 | val guild = guild!! 16 | 17 | if (!configuration.hasGuildConfig(guild)) 18 | return@precondition 19 | 20 | val loggingChannel = configuration[guild].loggingChannel 21 | val channel = channel as TextChannel 22 | 23 | val message = 24 | "${author.tag} :: ${author.id} :: " + 25 | "Invoked `${command!!.names.first()}` in #${channel.name}." + 26 | if (args.isEmpty()) "" else " Args: ${args.sanitiseMentions(discord)}" 27 | 28 | guild.getChannelOf(loggingChannel).createMessage(message) 29 | } 30 | 31 | return@precondition 32 | } 33 | -------------------------------------------------------------------------------- /src/main/kotlin/me/markhc/hangoutbot/preconditions/SanitiseInvites.kt: -------------------------------------------------------------------------------- 1 | package me.markhc.hangoutbot.preconditions 2 | 3 | import me.jakejmattson.discordkt.dsl.precondition 4 | 5 | fun sanitiseInvites() = precondition { 6 | if (rawInputs.commandArgs.any { it.matches(""".*discord\.gg.*""".toRegex()) }) 7 | return@precondition fail() 8 | 9 | return@precondition 10 | } 11 | -------------------------------------------------------------------------------- /src/main/kotlin/me/markhc/hangoutbot/preconditions/SetupPrecondition.kt: -------------------------------------------------------------------------------- 1 | package me.markhc.hangoutbot.preconditions 2 | 3 | import me.jakejmattson.discordkt.dsl.precondition 4 | import me.markhc.hangoutbot.dataclasses.Configuration 5 | 6 | fun setupPrecondition(configuration: Configuration) = precondition { 7 | val guild = guild ?: return@precondition fail() 8 | 9 | if (configuration.hasGuildConfig(guild)) return@precondition 10 | 11 | if (rawInputs.commandName.lowercase() != "configure") 12 | fail("This guild is not setup. You must run `/configure` first.") 13 | } -------------------------------------------------------------------------------- /src/main/kotlin/me/markhc/hangoutbot/services/ColorService.kt: -------------------------------------------------------------------------------- 1 | package me.markhc.hangoutbot.services 2 | 3 | import dev.kord.common.entity.Permission 4 | import dev.kord.common.kColor 5 | import dev.kord.core.behavior.GuildBehavior 6 | import dev.kord.core.behavior.createRole 7 | import dev.kord.core.entity.Guild 8 | import dev.kord.core.entity.Member 9 | import dev.kord.core.entity.Role 10 | import dev.kord.rest.request.RestRequestException 11 | import kotlinx.coroutines.flow.firstOrNull 12 | import kotlinx.coroutines.flow.toList 13 | import kotlinx.coroutines.runBlocking 14 | import me.jakejmattson.discordkt.annotations.Service 15 | import me.jakejmattson.discordkt.dsl.edit 16 | import me.jakejmattson.discordkt.extensions.stringify 17 | import me.markhc.hangoutbot.dataclasses.Configuration 18 | import me.markhc.hangoutbot.dataclasses.GuildConfiguration 19 | import java.awt.Color 20 | 21 | @Service 22 | class ColorService(private val configuration: Configuration) { 23 | suspend fun setMemberColor(member: Member, role: Role): Boolean { 24 | if (role.id !in configuration[member.guild].assignedColorRoles.keys) 25 | return false 26 | 27 | removeColorRole(member) 28 | assignColorRole(member, role) 29 | deleteUnusedRoles(configuration[member.guild], member.guild) 30 | return true 31 | } 32 | 33 | suspend fun findRole(name: String, color: Color, guild: Guild) = 34 | configuration[guild].assignedColorRoles.keys 35 | .mapNotNull { guild.getRoleOrNull(it) } 36 | .find { it.name.equals(name, true) || it.color == color.kColor } 37 | 38 | suspend fun removeColorRole(member: Member) { 39 | val guildConfiguration = configuration[member.guild] 40 | guildConfiguration.assignedColorRoles.entries 41 | .filter { it.value.contains(member.id) } 42 | .map { it.key } 43 | .forEach { role -> 44 | member.roles.toList().find { it.id == role }?.let { roleToRemove -> 45 | member.removeRole(roleToRemove.id) 46 | configuration.edit { 47 | guildConfiguration.assignedColorRoles[role]?.remove(member.id) 48 | } 49 | } 50 | } 51 | } 52 | 53 | suspend fun deleteUnusedRoles(config: GuildConfiguration, guild: GuildBehavior) { 54 | config.assignedColorRoles.entries 55 | .filter { it.value.isEmpty() } 56 | .forEach { 57 | it.key.let { guild.getRoleOrNull(it) }?.delete() 58 | } 59 | configuration.edit { 60 | config.assignedColorRoles.entries.removeIf { 61 | it.value.isEmpty() 62 | } 63 | } 64 | } 65 | 66 | suspend fun createRole(name: String, color: Color, guild: Guild): Role { 67 | val separator = getSeparatorRole(guild) 68 | ?: throw Exception("Could not find separator role. The guild needs a role named \"Colors\" that marks where the new roles should be placed.") 69 | 70 | val role = guild.createRole { 71 | this.name = name 72 | this.color = color.kColor 73 | hoist = false 74 | mentionable = false 75 | permissions = separator.permissions 76 | } 77 | 78 | println("${role.id} - $name - ${stringify(color)}") 79 | 80 | configuration[guild].assignedColorRoles[role.id] = mutableListOf() 81 | 82 | try { 83 | role.changePosition(separator.rawPosition - 1) 84 | return role 85 | } catch (ex: RestRequestException) { 86 | role.delete() 87 | throw Exception("Failed to reorder roles. This is likely due to hierarchy issues, try moving the bot role higher.") 88 | } 89 | } 90 | 91 | private suspend fun assignColorRole(member: Member, role: Role) { 92 | member.addRole(role.id) 93 | configuration.edit { this[member.guild].assignedColorRoles.getOrPut(role.id) { mutableListOf() }.add(member.id) } 94 | } 95 | } 96 | 97 | private suspend fun getSeparatorRole(guild: Guild) = 98 | guild.roles.firstOrNull { it.name.equals("Colors", true) } 99 | 100 | private suspend fun isValidName(member: Member, roleName: String): Boolean { 101 | // If user permissions are lower than Administrator, only allow 102 | // role names with ASCII characters 103 | if (!member.getPermissions().contains(Permission.Administrator)) { 104 | if (!Regex("^[\\x20-\\x7F]+$").matches(roleName)) { 105 | return false 106 | } 107 | } 108 | return true 109 | } -------------------------------------------------------------------------------- /src/main/kotlin/me/markhc/hangoutbot/services/MuteService.kt: -------------------------------------------------------------------------------- 1 | package me.markhc.hangoutbot.services 2 | 3 | import dev.kord.common.entity.Snowflake 4 | import dev.kord.core.entity.Member 5 | import dev.kord.core.entity.Role 6 | import dev.kord.rest.Image 7 | import kotlinx.coroutines.GlobalScope 8 | import kotlinx.coroutines.delay 9 | import kotlinx.coroutines.launch 10 | import me.jakejmattson.discordkt.Discord 11 | import me.jakejmattson.discordkt.annotations.Service 12 | import me.jakejmattson.discordkt.commands.GuildSlashCommandEvent 13 | import me.jakejmattson.discordkt.extensions.* 14 | import me.markhc.hangoutbot.dataclasses.Configuration 15 | import me.markhc.hangoutbot.dataclasses.GuildConfiguration 16 | import me.markhc.hangoutbot.dataclasses.MuteEntry 17 | import java.time.Instant 18 | 19 | @Service 20 | class MuteService(private val configuration: Configuration, private val discord: Discord) { 21 | suspend fun addMutedMember(event: GuildSlashCommandEvent<*>, member: Member, ms: Long, soft: Boolean) { 22 | val guild = event.guild 23 | val config = configuration[guild] 24 | val muteRoleId = with(config) { if (soft) softMuteRole else muteRole } 25 | val muteRole = guild.getRole(muteRoleId) 26 | 27 | if (muteRole.id in member.roleIds) { 28 | event.respond("You're already muted!") 29 | return 30 | } 31 | 32 | if (config.mutedUsers.any { muted -> muted.user == member.id }) { 33 | event.respond("Sorry, you already have an active mute!") 34 | return 35 | } 36 | 37 | val end = Instant.now().plusMillis(ms) 38 | config.mutedUsers.add(MuteEntry(member.id, end.toEpochMilli(), soft)) 39 | applyMute(member, muteRole, ms) 40 | 41 | member.sendPrivateMessage { 42 | author { 43 | name = guild.name 44 | icon = guild.getIconUrl(Image.Format.PNG) 45 | } 46 | title = "Self-Muted" 47 | description = "Your mute will expire on\n${TimeStamp.at(end)} (${TimeStamp.at(end, TimeStyle.RELATIVE)})" 48 | color = discord.configuration.theme 49 | } 50 | 51 | event.respond("Mute applied. See DM for info.") 52 | } 53 | 54 | suspend fun launchTimers() { 55 | configuration.guildConfigurations.forEach { (guild, config) -> 56 | startMuteTimers(guild, config) 57 | } 58 | } 59 | 60 | private suspend fun startMuteTimers(guildId: Snowflake, config: GuildConfiguration) { 61 | if (config.mutedUsers.isEmpty()) return 62 | 63 | val guild = discord.kord.getGuild(guildId) ?: return 64 | val muteRole = guild.getRoleOrNull(config.muteRole) 65 | val softMuteRole = guild.getRoleOrNull(config.softMuteRole) 66 | 67 | val now = System.currentTimeMillis() 68 | 69 | config.mutedUsers.removeIf { it.endTime < now } 70 | 71 | config.mutedUsers.forEach { entry -> 72 | val timeRemaining = entry.endTime - System.currentTimeMillis() 73 | val member = entry.user.let { guild.getMemberOrNull(it) } 74 | 75 | if (member != null) { 76 | if (entry.isSoft && softMuteRole != null) { 77 | applyMute(member, softMuteRole, timeRemaining) 78 | } else if (muteRole != null) { 79 | applyMute(member, muteRole, timeRemaining) 80 | } 81 | } 82 | } 83 | } 84 | 85 | private suspend fun applyMute(member: Member, role: Role, ms: Long) { 86 | member.addRole(role.id) 87 | startUnmuteTimer(member.guild.id, member.id, role.id, ms) 88 | } 89 | 90 | private fun startUnmuteTimer(guildId: Snowflake, memberId: Snowflake, roleId: Snowflake, millis: Long) { 91 | GlobalScope.launch { 92 | delay(millis) 93 | 94 | val guild = discord.kord.getGuild(guildId) ?: return@launch 95 | val member = guild.getMemberOrNull(memberId) ?: return@launch 96 | val role = guild.getRoleOrNull(roleId) ?: return@launch 97 | 98 | configuration[guild].mutedUsers.removeIf { member.id == it.user } 99 | member.removeRole(role.id) 100 | } 101 | } 102 | } -------------------------------------------------------------------------------- /src/main/resources/bot.properties: -------------------------------------------------------------------------------- 1 | description=A misc feature bot for TheProgrammersHangout 2 | name=HangoutBot 3 | url=https\://github.com/the-programmers-hangout/HangoutBot 4 | version=4.0.0-RC1 5 | -------------------------------------------------------------------------------- /src/main/resources/simplelogger.properties: -------------------------------------------------------------------------------- 1 | org.slf4j.simpleLogger.defaultLogLevel=error 2 | org.slf4j.simpleLogger.showDateTime=true --------------------------------------------------------------------------------