diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index b722415..0000000 --- a/.editorconfig +++ /dev/null @@ -1,12 +0,0 @@ -[*] -charset = utf-8 -indent_style = space -insert_final_newline = true -trim_trailing_whitespace = true -indent_size = 4 - -[*.{json, yml}] -indent_size = 2 - -[*.md] -trim_trailing_whitespace = false diff --git a/.github/workflows/dockerhub.yml b/.github/workflows/dockerhub.yml index e55fb8d..82b7f38 100644 --- a/.github/workflows/dockerhub.yml +++ b/.github/workflows/dockerhub.yml @@ -9,19 +9,19 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 - name: Login to Docker Hub - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Build and push - uses: docker/build-push-action@v4 + uses: docker/build-push-action@v6 with: push: true platforms: linux/amd64,linux/arm64 diff --git a/.gitignore b/.gitignore index 87c7310..a9869cc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ -.gradle .idea build .DS_Store .env -bin \ No newline at end of file +logs \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index a99ab29..3cf4eea 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,21 @@ -FROM webhippie/temurin:18 +# Create a build image +FROM golang:1.23-alpine AS build WORKDIR /app -COPY gradle ./gradle -COPY src ./src -COPY build.gradle . -COPY gradlew . -COPY settings.gradle . +# Download dependencies +COPY go.mod go.sum . +RUN go mod download -RUN ./gradlew build +# Build bot +COPY . . +RUN go build -ldflags="-s -w" -o build/meteor-bot cmd/meteor-bot/main.go -ENTRYPOINT java $JAVA_OPTS -jar build/libs/meteor-bot-all.jar +# Create a runtime image +FROM alpine:latest + +WORKDIR /app + +COPY --from=build /app/build/meteor-bot . + +CMD ["./meteor-bot"] diff --git a/build.gradle b/build.gradle deleted file mode 100644 index ee8ae2b..0000000 --- a/build.gradle +++ /dev/null @@ -1,25 +0,0 @@ -plugins { - id "com.github.johnrengelman.shadow" version "8.1.1" - id "application" - id "java" -} - -sourceCompatibility = targetCompatibility = JavaVersion.VERSION_17 - -repositories { - mavenCentral() -} - -dependencies { - implementation "net.dv8tion:JDA:5.0.0-beta.19" - implementation "com.konghq:unirest-java:3.14.5:standalone" - implementation "ch.qos.logback:logback-classic:1.4.14" -} - -tasks.withType(JavaCompile).configureEach { - it.options.encoding = "UTF-8" -} - -shadowJar { - setMainClassName("org.meteordev.meteorbot.MeteorBot") -} diff --git a/cmd/meteor-bot/main.go b/cmd/meteor-bot/main.go new file mode 100644 index 0000000..5ea1e64 --- /dev/null +++ b/cmd/meteor-bot/main.go @@ -0,0 +1,55 @@ +package main + +import ( + "github.com/bwmarrin/discordgo" + "github.com/rs/zerolog/log" + "meteor-bot/internal/app/commands" + "meteor-bot/internal/app/common" + "meteor-bot/internal/app/config" + "meteor-bot/internal/app/events" + "os" + "os/signal" +) + +func main() { + // Initialize the logger + common.InitLogger() + defer common.CloseLogger() + + // Initialize config + config.Init() + + // Create a new Discord session + s, err := discordgo.New("Bot " + config.GlobalConfig.DiscordToken) + if err != nil { + log.Panic().Err(err).Msg("Error creating Discord session") + } + // Enable the intents required for the bot + s.Identify.Intents |= discordgo.IntentsGuildMessages | discordgo.IntentsGuildMembers + + // Initialize the events and register them to the Discord API + // Must be done BEFORE opening the session to make discordgo.Ready handlers work + events.Init(s) + + // Bot is ready, open the session + if err = s.Open(); err != nil { + log.Panic().Err(err).Msg("Error opening Discord session") + } + defer s.Close() + + // Initialize the commands and handlers, and register them to the Discord API + // Must be done AFTER opening the session to add the commands to the API + commands.Init(s) + + // Wait until the bot is stopped + stop := make(chan os.Signal, 1) + signal.Notify(stop, os.Interrupt) + log.Info().Msg("Press Ctrl+C to exit") + <-stop + + if config.GlobalConfig.RemoveCommands { + commands.RemoveCommands(s) + } + + log.Info().Msg("Gracefully shutting down.") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..bee175b --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module meteor-bot + +go 1.23 + +require ( + github.com/bwmarrin/discordgo v0.28.1 + github.com/joho/godotenv v1.5.1 + github.com/rs/zerolog v1.33.0 +) + +require ( + github.com/gorilla/websocket v1.4.2 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect + golang.org/x/sys v0.25.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2cc13b5 --- /dev/null +++ b/go.sum @@ -0,0 +1,30 @@ +github.com/bwmarrin/discordgo v0.28.1 h1:gXsuo2GBO7NbR6uqmrrBDplPUx2T3nzu775q/Rd1aG4= +github.com/bwmarrin/discordgo v0.28.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= +github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index 7454180..0000000 Binary files a/gradle/wrapper/gradle-wrapper.jar and /dev/null differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index db9a6b8..0000000 --- a/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,5 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-bin.zip -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew deleted file mode 100755 index 1b6c787..0000000 --- a/gradlew +++ /dev/null @@ -1,234 +0,0 @@ -#!/bin/sh - -# -# Copyright © 2015-2021 the original authors. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -############################################################################## -# -# Gradle start up script for POSIX generated by Gradle. -# -# Important for running: -# -# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is -# noncompliant, but you have some other compliant shell such as ksh or -# bash, then to run this script, type that shell name before the whole -# command line, like: -# -# ksh Gradle -# -# Busybox and similar reduced shells will NOT work, because this script -# requires all of these POSIX shell features: -# * functions; -# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», -# «${var#prefix}», «${var%suffix}», and «$( cmd )»; -# * compound commands having a testable exit status, especially «case»; -# * various built-in commands including «command», «set», and «ulimit». -# -# Important for patching: -# -# (2) This script targets any POSIX shell, so it avoids extensions provided -# by Bash, Ksh, etc; in particular arrays are avoided. -# -# The "traditional" practice of packing multiple parameters into a -# space-separated string is a well documented source of bugs and security -# problems, so this is (mostly) avoided, by progressively accumulating -# options in "$@", and eventually passing that to Java. -# -# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, -# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; -# see the in-line comments for details. -# -# There are tweaks for specific operating systems such as AIX, CygWin, -# Darwin, MinGW, and NonStop. -# -# (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt -# within the Gradle project. -# -# You can find Gradle at https://github.com/gradle/gradle/. -# -############################################################################## - -# Attempt to set APP_HOME - -# Resolve links: $0 may be a link -app_path=$0 - -# Need this for daisy-chained symlinks. -while - APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path - [ -h "$app_path" ] -do - ls=$( ls -ld "$app_path" ) - link=${ls#*' -> '} - case $link in #( - /*) app_path=$link ;; #( - *) app_path=$APP_HOME$link ;; - esac -done - -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit - -APP_NAME="Gradle" -APP_BASE_NAME=${0##*/} - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD=maximum - -warn () { - echo "$*" -} >&2 - -die () { - echo - echo "$*" - echo - exit 1 -} >&2 - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "$( uname )" in #( - CYGWIN* ) cygwin=true ;; #( - Darwin* ) darwin=true ;; #( - MSYS* | MINGW* ) msys=true ;; #( - NONSTOP* ) nonstop=true ;; -esac - -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar - - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD=$JAVA_HOME/jre/sh/java - else - JAVACMD=$JAVA_HOME/bin/java - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD=java - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." -fi - -# Increase the maximum file descriptors if we can. -if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then - case $MAX_FD in #( - max*) - MAX_FD=$( ulimit -H -n ) || - warn "Could not query maximum file descriptor limit" - esac - case $MAX_FD in #( - '' | soft) :;; #( - *) - ulimit -n "$MAX_FD" || - warn "Could not set maximum file descriptor limit to $MAX_FD" - esac -fi - -# Collect all arguments for the java command, stacking in reverse order: -# * args from the command line -# * the main class name -# * -classpath -# * -D...appname settings -# * --module-path (only if needed) -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. - -# For Cygwin or MSYS, switch paths to Windows format before running java -if "$cygwin" || "$msys" ; then - APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) - - JAVACMD=$( cygpath --unix "$JAVACMD" ) - - # Now convert the arguments - kludge to limit ourselves to /bin/sh - for arg do - if - case $arg in #( - -*) false ;; # don't mess with options #( - /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath - [ -e "$t" ] ;; #( - *) false ;; - esac - then - arg=$( cygpath --path --ignore --mixed "$arg" ) - fi - # Roll the args list around exactly as many times as the number of - # args, so each arg winds up back in the position where it started, but - # possibly modified. - # - # NB: a `for` loop captures its iteration list before it begins, so - # changing the positional parameters here affects neither the number of - # iterations, nor the values presented in `arg`. - shift # remove old arg - set -- "$@" "$arg" # push replacement arg - done -fi - -# Collect all arguments for the java command; -# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of -# shell script including quotes and variable substitutions, so put them in -# double quotes to make sure that they get re-expanded; and -# * put everything else in single quotes, so that it's not re-expanded. - -set -- \ - "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ - "$@" - -# Use "xargs" to parse quoted args. -# -# With -n1 it outputs one arg per line, with the quotes and backslashes removed. -# -# In Bash we could simply go: -# -# readarray ARGS < <( xargs -n1 <<<"$var" ) && -# set -- "${ARGS[@]}" "$@" -# -# but POSIX shell has neither arrays nor command substitution, so instead we -# post-process each arg (as a line of input to sed) to backslash-escape any -# character that might be a shell metacharacter, then use eval to reverse -# that process (while maintaining the separation between arguments), and wrap -# the whole thing up as a single "set" statement. -# -# This will of course break if any of these variables contains a newline or -# an unmatched quote. -# - -eval "set -- $( - printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | - xargs -n1 | - sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | - tr '\n' ' ' - )" '"$@"' - -exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat deleted file mode 100644 index 107acd3..0000000 --- a/gradlew.bat +++ /dev/null @@ -1,89 +0,0 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem - -@if "%DEBUG%" == "" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega diff --git a/internal/app/commands/commands.go b/internal/app/commands/commands.go new file mode 100644 index 0000000..73a3704 --- /dev/null +++ b/internal/app/commands/commands.go @@ -0,0 +1,80 @@ +package commands + +import ( + "github.com/bwmarrin/discordgo" + "github.com/rs/zerolog/log" + "meteor-bot/internal/app/commands/help" + "meteor-bot/internal/app/commands/moderation" + "meteor-bot/internal/app/commands/silly" + "meteor-bot/internal/app/common" +) + +var ( + commands []*discordgo.ApplicationCommand + commandHandlers map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate) + + registeredCommands []*discordgo.ApplicationCommand +) + +func Init(s *discordgo.Session) { + // Initialize the commands and handlers map + if commandHandlers == nil { + commandHandlers = make(map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate)) + } + + // Initialize the commands and handlers + initCommands( + moderation.NewBanCommand(), + moderation.NewMuteCommand(), + moderation.NewUnmuteCommand(), + moderation.NewCloseCommand(), + help.NewFaqCommand(), + help.NewInstallationCommand(), + help.NewLogsCommand(), + help.NewOldVersionCommand(), + silly.NewCapyCommand(), + silly.NewCatCommand(), + silly.NewDogCommand(), + silly.NewMonkeyCommand(), + silly.NewPandaCommand(), + NewLinkCommand(), + NewStatsCommand(), + ) + + // Add the handlers to the Discord session + s.AddHandler(func(s *discordgo.Session, i *discordgo.InteractionCreate) { + if h, ok := commandHandlers[i.ApplicationCommandData().Name]; ok { + h(s, i) + } + }) + + // Register the commands to the Discord API + registerCommands(s) +} + +func initCommands(cmds ...common.Command) { + for _, cmd := range cmds { + commands = append(commands, cmd.Build()) + commandHandlers[cmd.Name()] = cmd.Handle + } +} + +// registerCommands registers the commands to the Discord API +func registerCommands(s *discordgo.Session) { + var err error + registeredCommands, err = s.ApplicationCommandBulkOverwrite(s.State.User.ID, "", commands) + if err != nil { + log.Panic().Err(err).Msg("Cannot register commands") + } + + log.Info().Msgf("%d commands registered successfully", len(registeredCommands)) +} + +// RemoveCommands removes the commands from the Discord API +func RemoveCommands(s *discordgo.Session) { + log.Info().Msgf("Removing %d commands...", len(registeredCommands)) + _, err := s.ApplicationCommandBulkOverwrite(s.State.User.ID, "", nil) + if err != nil { + log.Panic().Err(err).Msg("Cannot remove commands") + } +} diff --git a/internal/app/commands/help/faq.go b/internal/app/commands/help/faq.go new file mode 100644 index 0000000..fe1330d --- /dev/null +++ b/internal/app/commands/help/faq.go @@ -0,0 +1,62 @@ +package help + +import ( + "fmt" + "github.com/bwmarrin/discordgo" + "meteor-bot/internal/app/common" +) + +type FaqCommand struct { + common.BaseCommand +} + +func NewFaqCommand() *FaqCommand { + return &FaqCommand{ + BaseCommand: *common.NewCommandBuilder(). + SetName("faq"). + SetDescription("Tells someone to read the FAQ"). + Build(), + } +} + +func (c *FaqCommand) Build() *discordgo.ApplicationCommand { + return &discordgo.ApplicationCommand{ + Name: c.Name(), + Description: c.Description(), + Options: []*discordgo.ApplicationCommandOption{ + { + Name: "member", + Description: "The member to tell to read the FAQ", + Type: discordgo.ApplicationCommandOptionUser, + Required: true, + }, + }, + } +} + +func (c *FaqCommand) Handle(s *discordgo.Session, i *discordgo.InteractionCreate) { + targetMember := i.ApplicationCommandData().Options[0].UserValue(s) + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{ + { + Title: "Read the FAQ", + Description: fmt.Sprintf("%s The FAQ answers your question, please read it.", targetMember.Mention()), + Color: common.EmbedColor, + }, + }, + Components: []discordgo.MessageComponent{ + discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + discordgo.Button{ + Label: "FAQ", + Style: discordgo.LinkButton, + URL: "https://meteorclient.com/faq", + }, + }, + }, + }, + }, + }) +} diff --git a/internal/app/commands/help/installation.go b/internal/app/commands/help/installation.go new file mode 100644 index 0000000..e9eb754 --- /dev/null +++ b/internal/app/commands/help/installation.go @@ -0,0 +1,62 @@ +package help + +import ( + "fmt" + "github.com/bwmarrin/discordgo" + "meteor-bot/internal/app/common" +) + +type InstallationCommand struct { + common.BaseCommand +} + +func NewInstallationCommand() *InstallationCommand { + return &InstallationCommand{ + BaseCommand: *common.NewCommandBuilder(). + SetName("installation"). + SetDescription("Tells someone to read the installation guide"). + Build(), + } +} + +func (c *InstallationCommand) Build() *discordgo.ApplicationCommand { + return &discordgo.ApplicationCommand{ + Name: c.Name(), + Description: c.Description(), + Options: []*discordgo.ApplicationCommandOption{ + { + Name: "member", + Description: "The member to tell to read the installation guide", + Type: discordgo.ApplicationCommandOptionUser, + Required: true, + }, + }, + } +} + +func (c *InstallationCommand) Handle(s *discordgo.Session, i *discordgo.InteractionCreate) { + targetMember := i.ApplicationCommandData().Options[0].UserValue(s) + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{ + { + Title: "Read the Installation Guide", + Description: fmt.Sprintf("%s The installation guide answers your question, please read it.", targetMember.Mention()), + Color: common.EmbedColor, + }, + }, + Components: []discordgo.MessageComponent{ + discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + discordgo.Button{ + Label: "Guide", + Style: discordgo.LinkButton, + URL: "https://meteorclient.com/faq/installation", + }, + }, + }, + }, + }, + }) +} diff --git a/internal/app/commands/help/logs.go b/internal/app/commands/help/logs.go new file mode 100644 index 0000000..b1b414e --- /dev/null +++ b/internal/app/commands/help/logs.go @@ -0,0 +1,62 @@ +package help + +import ( + "fmt" + "github.com/bwmarrin/discordgo" + "meteor-bot/internal/app/common" +) + +type LogsCommand struct { + common.BaseCommand +} + +func NewLogsCommand() *LogsCommand { + return &LogsCommand{ + BaseCommand: *common.NewCommandBuilder(). + SetName("logs"). + SetDescription("Tells someone how to find the Minecraft logs"). + Build(), + } +} + +func (c *LogsCommand) Build() *discordgo.ApplicationCommand { + return &discordgo.ApplicationCommand{ + Name: c.Name(), + Description: c.Description(), + Options: []*discordgo.ApplicationCommandOption{ + { + Name: "member", + Description: "The member to tell how to find the Minecraft logs", + Type: discordgo.ApplicationCommandOptionUser, + Required: true, + }, + }, + } +} + +func (c *LogsCommand) Handle(s *discordgo.Session, i *discordgo.InteractionCreate) { + targetMember := i.ApplicationCommandData().Options[0].UserValue(s) + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{ + { + Title: "Find the Minecraft Logs", + Description: fmt.Sprintf("%s The logs guide explains how to find and share your Minecraft logs, please read it.", targetMember.Mention()), + Color: common.EmbedColor, + }, + }, + Components: []discordgo.MessageComponent{ + discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + discordgo.Button{ + Label: "Guide", + Style: discordgo.LinkButton, + URL: "https://meteorclient.com/faq/getting-log", + }, + }, + }, + }, + }, + }) +} diff --git a/internal/app/commands/help/oldversion.go b/internal/app/commands/help/oldversion.go new file mode 100644 index 0000000..b1eb916 --- /dev/null +++ b/internal/app/commands/help/oldversion.go @@ -0,0 +1,62 @@ +package help + +import ( + "fmt" + "github.com/bwmarrin/discordgo" + "meteor-bot/internal/app/common" +) + +type OldVersionCommand struct { + common.BaseCommand +} + +func NewOldVersionCommand() *OldVersionCommand { + return &OldVersionCommand{ + BaseCommand: *common.NewCommandBuilder(). + SetName("old-versions"). + SetDescription("Tells someone how to play on older versions of Minecraft"). + Build(), + } +} + +func (c *OldVersionCommand) Build() *discordgo.ApplicationCommand { + return &discordgo.ApplicationCommand{ + Name: c.Name(), + Description: c.Description(), + Options: []*discordgo.ApplicationCommandOption{ + { + Name: "member", + Description: "The member to tell how to play on older versions of Minecraft", + Type: discordgo.ApplicationCommandOptionUser, + Required: true, + }, + }, + } +} + +func (c *OldVersionCommand) Handle(s *discordgo.Session, i *discordgo.InteractionCreate) { + targetMember := i.ApplicationCommandData().Options[0].UserValue(s) + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{ + { + Title: "Old Versions Guide", + Description: fmt.Sprintf("%s The old version guide explains how to play on older versions of Minecraft, please read it.", targetMember.Mention()), + Color: common.EmbedColor, + }, + }, + Components: []discordgo.MessageComponent{ + discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + discordgo.Button{ + Label: "Guide", + Style: discordgo.LinkButton, + URL: "https://meteorclient.com/faq/old-versions", + }, + }, + }, + }, + }, + }) +} diff --git a/internal/app/commands/link.go b/internal/app/commands/link.go new file mode 100644 index 0000000..2f60732 --- /dev/null +++ b/internal/app/commands/link.go @@ -0,0 +1,134 @@ +package commands + +import ( + "encoding/json" + "fmt" + "github.com/bwmarrin/discordgo" + "meteor-bot/internal/app/common" + "meteor-bot/internal/app/config" + "net/http" + "net/url" + "strings" +) + +type LinkCommand struct { + common.BaseCommand +} + +func NewLinkCommand() *LinkCommand { + return &LinkCommand{ + BaseCommand: *common.NewCommandBuilder(). + SetName("link"). + SetDescription("Links your Discord account to your Meteor account"). + Build(), + } +} + +func (c *LinkCommand) Build() *discordgo.ApplicationCommand { + return &discordgo.ApplicationCommand{ + Name: c.Name(), + Description: c.Description(), + Options: []*discordgo.ApplicationCommandOption{ + { + Name: "token", + Description: "The token generated on the Meteor website", + Type: discordgo.ApplicationCommandOptionString, + Required: true, + ChannelTypes: []discordgo.ChannelType{discordgo.ChannelTypeDM}, + }, + }, + } +} + +func (c *LinkCommand) Handle(s *discordgo.Session, i *discordgo.InteractionCreate) { + // If this is not a DM, respond with an ephemeral message + if i.GuildID != "" { + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "This command can only be used in DMs.", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + // Get the token from the options + token := i.ApplicationCommandData().Options[0].StringValue() + if len(token) == 0 { + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "You must provide a valid token.", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + // API request to link the Discord account + // TODO: make a util function for the request + userId := i.User.ID + req, err := http.NewRequest("POST", fmt.Sprintf("%s/account/linkDiscord", config.GlobalConfig.ApiBase), strings.NewReader(url.Values{ + "id": {userId}, + "token": {token}, + }.Encode())) + if err != nil { + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Failed to create request.", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + req.Header.Set("Authorization", config.GlobalConfig.BackendToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Failed to link your Discord account. Please try again later.", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + defer resp.Body.Close() + + // Decode the response + var jsonResponse map[string]any + if err = json.NewDecoder(resp.Body).Decode(&jsonResponse); err != nil { + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Failed to decode the response.", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + // Check for errors in response + if _, ok := jsonResponse["error"]; ok { + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Failed to link your Discord account. Try generating a new token by refreshing the account page and clicking the link button again.", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + // Respond to the interaction + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Successfully linked your Discord account.", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) +} diff --git a/internal/app/commands/moderation/ban.go b/internal/app/commands/moderation/ban.go new file mode 100644 index 0000000..252f4f4 --- /dev/null +++ b/internal/app/commands/moderation/ban.go @@ -0,0 +1,75 @@ +package moderation + +import ( + "fmt" + "github.com/bwmarrin/discordgo" + "meteor-bot/internal/app/common" +) + +type BanCommand struct { + common.BaseCommand +} + +func NewBanCommand() *BanCommand { + return &BanCommand{ + BaseCommand: *common.NewCommandBuilder(). + SetName("ban"). + SetDescription("Bans a member"). + Build(), + } +} + +func (c *BanCommand) Build() *discordgo.ApplicationCommand { + return &discordgo.ApplicationCommand{ + Name: c.Name(), + Description: c.Description(), + DefaultMemberPermissions: &common.BanMemberPermission, + Options: []*discordgo.ApplicationCommandOption{ + { + Name: "member", + Description: "The member to ban", + Type: discordgo.ApplicationCommandOptionUser, + Required: true, + }, + }, + } +} + +func (c *BanCommand) Handle(s *discordgo.Session, i *discordgo.InteractionCreate) { + if i.Member.Permissions&common.BanMemberPermission == 0 { + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "You do not have the required permissions to ban members.", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + targetMember := i.ApplicationCommandData().Options[0].UserValue(s) + if err := s.GuildBanCreate(i.GuildID, targetMember.ID, 0); err != nil { + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "An error occurred while banning the member.", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + // Respond to the interaction + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{ + { + Title: "Member Banned", + Description: fmt.Sprintf("Banned %s.", targetMember.Mention()), + Color: common.EmbedColor, + }, + }, + }, + }) +} diff --git a/internal/app/commands/moderation/close.go b/internal/app/commands/moderation/close.go new file mode 100644 index 0000000..7b24c39 --- /dev/null +++ b/internal/app/commands/moderation/close.go @@ -0,0 +1,95 @@ +package moderation + +import ( + "github.com/bwmarrin/discordgo" + "meteor-bot/internal/app/common" +) + +type CloseCommand struct { + common.BaseCommand +} + +func NewCloseCommand() *CloseCommand { + return &CloseCommand{ + BaseCommand: *common.NewCommandBuilder(). + SetName("close"). + SetDescription("Locks the current forum post"). + Build(), + } +} + +func (c *CloseCommand) Build() *discordgo.ApplicationCommand { + return &discordgo.ApplicationCommand{ + Name: c.Name(), + Description: c.Description(), + DefaultMemberPermissions: &common.ManageThreadsPermission, + } +} + +func (c *CloseCommand) Handle(s *discordgo.Session, i *discordgo.InteractionCreate) { + // Check if the command was used in a forum channel + channel, err := s.State.Channel(i.ChannelID) + if err != nil { + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "An error occurred while fetching the channel.", + }, + }) + return + } + if !channel.IsThread() { + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "This command can only be used in forum channels.", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + // Check if the user has the required permissions, or is the thread owner + if i.Member.Permissions&common.ManageThreadsPermission == 0 && i.Member.User.ID != channel.OwnerID { + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "You do not have the required permissions to close threads.", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + // Respond to the interaction + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{ + { + Title: "Thread Closed", + Description: "This thread is now locked.", + Color: common.EmbedColor, + }, + }, + }, + }) + + // Close the thread + locked := true + archived := true + _, err = s.ChannelEditComplex(i.ChannelID, &discordgo.ChannelEdit{ + Locked: &locked, + Archived: &archived, + }) + if err != nil { + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "An error occurred while closing the thread.", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } +} diff --git a/internal/app/commands/moderation/mute.go b/internal/app/commands/moderation/mute.go new file mode 100644 index 0000000..78e89cd --- /dev/null +++ b/internal/app/commands/moderation/mute.go @@ -0,0 +1,191 @@ +package moderation + +import ( + "errors" + "fmt" + "github.com/bwmarrin/discordgo" + "meteor-bot/internal/app/common" + "regexp" + "strconv" + "time" +) + +type MuteCommand struct { + common.BaseCommand +} + +func NewMuteCommand() *MuteCommand { + return &MuteCommand{ + BaseCommand: *common.NewCommandBuilder(). + SetName("mute"). + SetDescription("Mutes a member"). + Build(), + } +} + +func (c *MuteCommand) Build() *discordgo.ApplicationCommand { + return &discordgo.ApplicationCommand{ + Name: c.Name(), + Description: c.Description(), + DefaultMemberPermissions: &common.ModerateMembersPermission, + Options: []*discordgo.ApplicationCommandOption{ + { + Name: "member", + Description: "The member to mute", + Type: discordgo.ApplicationCommandOptionUser, + Required: true, + }, + { + Name: "duration", + Description: "The duration of the mute", + Type: discordgo.ApplicationCommandOptionString, + Required: true, + }, + { + Name: "reason", + Description: "The reason for the mute", + Type: discordgo.ApplicationCommandOptionString, + Required: false, + }, + }, + } +} + +func (c *MuteCommand) Handle(s *discordgo.Session, i *discordgo.InteractionCreate) { + if i.Member.Permissions&common.ModerateMembersPermission == 0 { + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "You do not have the required permissions to mute members.", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + targetMember := i.ApplicationCommandData().Options[0].UserValue(s) + durationStr := i.ApplicationCommandData().Options[1].StringValue() + duration, err := parseDuration(durationStr) + if err != nil { + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Invalid duration format. Please use the format `1s`, `1m`, `1h`, `1d`, or `1w`.", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + // Fetch optional arguments + reason := "Reason unspecified" + for _, opt := range i.ApplicationCommandData().Options { + if opt.Name == "reason" { + reason = opt.StringValue() + break + } + } + + targetGuildMember, ok := i.ApplicationCommandData().Resolved.Members[targetMember.ID] + if !ok { + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "An error occurred while fetching the member.", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + // Check if the member is already muted + if targetGuildMember.CommunicationDisabledUntil != nil && targetGuildMember.CommunicationDisabledUntil.After(time.Now()) { + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Member is already muted.", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + // Check if the target member cannot be muted + if targetGuildMember.Permissions&common.ModerateMembersPermission != 0 { + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "You do not have the required permissions to mute this member.", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + // Mute the member + muteUntil := time.Now().Add(duration) + err = s.GuildMemberTimeout(i.GuildID, targetMember.ID, &muteUntil, discordgo.WithAuditLogReason(reason)) + if err != nil { + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "An error occurred while muting the member.", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + // Respond to the interaction + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{ + { + Title: "Member Muted", + Description: fmt.Sprintf("Muted %s for %s.", targetMember.Mention(), durationStr), + Color: common.EmbedColor, + }, + }, + }, + }) + +} + +// parseDuration parses the duration string and return the time.Duration value +func parseDuration(durationStr string) (time.Duration, error) { + re := regexp.MustCompile(`^(\d+)([smhdw])$`) + matches := re.FindStringSubmatch(durationStr) + if matches == nil { + return 0, errors.New("invalid duration format") + } + + value, err := strconv.Atoi(matches[1]) + if err != nil || value <= 0 { + return 0, errors.New("invalid duration value") + } + + unit := matches[2] + var duration time.Duration + switch unit { + case "s": + duration = time.Duration(value) * time.Second + case "m": + duration = time.Duration(value) * time.Minute + case "h": + duration = time.Duration(value) * time.Hour + case "d": + duration = time.Duration(value) * 24 * time.Hour + case "w": + duration = time.Duration(value) * 7 * 24 * time.Hour + default: + return 0, errors.New("invalid duration unit") + } + + // Check if the duration is within the allowed range + if duration > 2419200*time.Second { + return 0, errors.New("duration exceeds the maximum allowed value of 4 weeks") + } + + return duration, nil +} diff --git a/internal/app/commands/moderation/unmute.go b/internal/app/commands/moderation/unmute.go new file mode 100644 index 0000000..c490d3b --- /dev/null +++ b/internal/app/commands/moderation/unmute.go @@ -0,0 +1,102 @@ +package moderation + +import ( + "fmt" + "github.com/bwmarrin/discordgo" + "meteor-bot/internal/app/common" + "time" +) + +type UnmuteCommand struct { + common.BaseCommand +} + +func NewUnmuteCommand() *UnmuteCommand { + return &UnmuteCommand{ + BaseCommand: *common.NewCommandBuilder(). + SetName("unmute"). + SetDescription("Unmutes a member"). + Build(), + } +} + +func (c *UnmuteCommand) Build() *discordgo.ApplicationCommand { + return &discordgo.ApplicationCommand{ + Name: c.Name(), + Description: c.Description(), + DefaultMemberPermissions: &common.ModerateMembersPermission, + Options: []*discordgo.ApplicationCommandOption{ + { + Name: "member", + Description: "The member to unmute", + Type: discordgo.ApplicationCommandOptionUser, + Required: true, + }, + }, + } +} + +func (c *UnmuteCommand) Handle(s *discordgo.Session, i *discordgo.InteractionCreate) { + if i.Member.Permissions&common.ModerateMembersPermission == 0 { + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "You do not have the required permissions to unmute members.", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + targetMember := i.ApplicationCommandData().Options[0].UserValue(s) + targetGuildMember, ok := i.ApplicationCommandData().Resolved.Members[targetMember.ID] + if !ok { + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "An error occurred while fetching the member.", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + // Check if the member is not muted, if so, return + if targetGuildMember.CommunicationDisabledUntil == nil || targetGuildMember.CommunicationDisabledUntil.Before(time.Now()) { + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Member is not muted.", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + // Unmute the member + err := s.GuildMemberTimeout(i.GuildID, targetMember.ID, nil) + if err != nil { + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "An error occurred while unmuting the member.", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + // Respond to the interaction + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{ + { + Title: "Member Unmuted", + Description: fmt.Sprintf("Unmuted %s.", targetMember.Mention()), + Color: common.EmbedColor, + }, + }, + }, + }) +} diff --git a/internal/app/commands/silly/capy.go b/internal/app/commands/silly/capy.go new file mode 100644 index 0000000..24af344 --- /dev/null +++ b/internal/app/commands/silly/capy.go @@ -0,0 +1,89 @@ +package silly + +import ( + "encoding/json" + "github.com/bwmarrin/discordgo" + "meteor-bot/internal/app/common" + "net/http" +) + +type CapyCommand struct { + common.BaseCommand +} + +func NewCapyCommand() *CapyCommand { + return &CapyCommand{ + BaseCommand: *common.NewCommandBuilder(). + SetName("capybara"). + SetDescription("pulls up"). + Build(), + } +} + +func (c *CapyCommand) Build() *discordgo.ApplicationCommand { + return &discordgo.ApplicationCommand{ + Name: c.Name(), + Description: c.Description(), + } +} + +func (c *CapyCommand) Handle(s *discordgo.Session, i *discordgo.InteractionCreate) { + // Fetch the capybara image + resp, err := http.Get("https://api.capy.lol/v1/capybara?json=true") + if err != nil { + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Failed to fetch capybara image", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + defer resp.Body.Close() + + // Decode the response + var jsonResponse map[string]any + if err = json.NewDecoder(resp.Body).Decode(&jsonResponse); err != nil { + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Failed to decode the response", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + // Extract the image URL + data, ok := jsonResponse["data"].(map[string]any) + if !ok { + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Failed to parse the response", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + url, ok := data["url"].(string) + if !ok { + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Failed to parse the response", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + // Respond to the interaction + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: url, + }, + }) +} diff --git a/internal/app/commands/silly/cat.go b/internal/app/commands/silly/cat.go new file mode 100644 index 0000000..4295150 --- /dev/null +++ b/internal/app/commands/silly/cat.go @@ -0,0 +1,78 @@ +package silly + +import ( + "encoding/json" + "github.com/bwmarrin/discordgo" + "meteor-bot/internal/app/common" + "net/http" +) + +type CatCommand struct { + common.BaseCommand +} + +func NewCatCommand() *CatCommand { + return &CatCommand{ + BaseCommand: *common.NewCommandBuilder(). + SetName("cat"). + SetDescription("gato"). + Build(), + } +} + +func (c *CatCommand) Build() *discordgo.ApplicationCommand { + return &discordgo.ApplicationCommand{ + Name: c.Name(), + Description: c.Description(), + } +} + +func (c *CatCommand) Handle(s *discordgo.Session, i *discordgo.InteractionCreate) { + // Fetch the cat image + resp, err := http.Get("https://some-random-api.com/img/cat") + if err != nil { + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Failed to fetch cat image", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + defer resp.Body.Close() + + // Decode the response + var jsonResponse map[string]any + if err = json.NewDecoder(resp.Body).Decode(&jsonResponse); err != nil { + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Failed to decode the response", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + // Extract the image URL + url, ok := jsonResponse["link"].(string) + if !ok { + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Failed to parse the response", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + // Respond to the interaction + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: url, + }, + }) +} diff --git a/internal/app/commands/silly/dog.go b/internal/app/commands/silly/dog.go new file mode 100644 index 0000000..7af67fe --- /dev/null +++ b/internal/app/commands/silly/dog.go @@ -0,0 +1,78 @@ +package silly + +import ( + "encoding/json" + "github.com/bwmarrin/discordgo" + "meteor-bot/internal/app/common" + "net/http" +) + +type DogCommand struct { + common.BaseCommand +} + +func NewDogCommand() *DogCommand { + return &DogCommand{ + BaseCommand: *common.NewCommandBuilder(). + SetName("dog"). + SetDescription("dawg"). + Build(), + } +} + +func (c *DogCommand) Build() *discordgo.ApplicationCommand { + return &discordgo.ApplicationCommand{ + Name: c.Name(), + Description: c.Description(), + } +} + +func (c *DogCommand) Handle(s *discordgo.Session, i *discordgo.InteractionCreate) { + // Fetch the dog image + resp, err := http.Get("https://some-random-api.com/img/dog") + if err != nil { + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Failed to fetch dog image", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + defer resp.Body.Close() + + // Decode the response + var jsonResponse map[string]any + if err = json.NewDecoder(resp.Body).Decode(&jsonResponse); err != nil { + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Failed to decode the response", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + // Extract the image URL + url, ok := jsonResponse["link"].(string) + if !ok { + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Failed to parse the response", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + // Respond to the interaction + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: url, + }, + }) +} diff --git a/internal/app/commands/silly/monkey.go b/internal/app/commands/silly/monkey.go new file mode 100644 index 0000000..71edb2e --- /dev/null +++ b/internal/app/commands/silly/monkey.go @@ -0,0 +1,42 @@ +package silly + +import ( + "fmt" + "github.com/bwmarrin/discordgo" + "math/rand" + "meteor-bot/internal/app/common" +) + +type MonkeyCommand struct { + common.BaseCommand +} + +func NewMonkeyCommand() *MonkeyCommand { + return &MonkeyCommand{ + BaseCommand: *common.NewCommandBuilder(). + SetName("monkey"). + SetDescription("monke"). + Build(), + } +} + +func (c *MonkeyCommand) Build() *discordgo.ApplicationCommand { + return &discordgo.ApplicationCommand{ + Name: c.Name(), + Description: c.Description(), + } +} + +func (c *MonkeyCommand) Handle(s *discordgo.Session, i *discordgo.InteractionCreate) { + w := rand.Intn(801) + 200 + h := rand.Intn(801) + 200 + + url := fmt.Sprintf("https://www.placemonkeys.com/%d/%d?random", w, h) + + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: url, + }, + }) +} diff --git a/internal/app/commands/silly/panda.go b/internal/app/commands/silly/panda.go new file mode 100644 index 0000000..0b02e5a --- /dev/null +++ b/internal/app/commands/silly/panda.go @@ -0,0 +1,84 @@ +package silly + +import ( + "encoding/json" + "github.com/bwmarrin/discordgo" + "math/rand" + "meteor-bot/internal/app/common" + "net/http" +) + +type PandaCommand struct { + common.BaseCommand +} + +func NewPandaCommand() *PandaCommand { + return &PandaCommand{ + BaseCommand: *common.NewCommandBuilder(). + SetName("panda"). + SetDescription("funny thing"). + Build(), + } +} + +func (c *PandaCommand) Build() *discordgo.ApplicationCommand { + return &discordgo.ApplicationCommand{ + Name: c.Name(), + Description: c.Description(), + } +} + +func (c *PandaCommand) Handle(s *discordgo.Session, i *discordgo.InteractionCreate) { + animal := "panda" + if rand.Intn(2) == 0 { + animal = "red_panda" + } + + // Fetch the panda image + resp, err := http.Get("https://some-random-api.com/img/" + animal) + if err != nil { + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Failed to fetch panda image", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + defer resp.Body.Close() + + // Decode the response + var jsonResponse map[string]any + if err = json.NewDecoder(resp.Body).Decode(&jsonResponse); err != nil { + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Failed to decode the response", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + // Extract the image URL + url, ok := jsonResponse["link"].(string) + if !ok { + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Failed to parse the response", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + // Respond to the interaction + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: url, + }, + }) +} diff --git a/internal/app/commands/stats.go b/internal/app/commands/stats.go new file mode 100644 index 0000000..989d20a --- /dev/null +++ b/internal/app/commands/stats.go @@ -0,0 +1,126 @@ +package commands + +import ( + "encoding/json" + "fmt" + "github.com/bwmarrin/discordgo" + "meteor-bot/internal/app/common" + "meteor-bot/internal/app/config" + "net/http" + "regexp" + "time" +) + +type StatsCommand struct { + common.BaseCommand +} + +func NewStatsCommand() *StatsCommand { + return &StatsCommand{ + BaseCommand: *common.NewCommandBuilder(). + SetName("stats"). + SetDescription("Shows various stats about Meteor"). + Build(), + } +} + +func (c *StatsCommand) Build() *discordgo.ApplicationCommand { + return &discordgo.ApplicationCommand{ + Name: c.Name(), + Description: c.Description(), + Options: []*discordgo.ApplicationCommandOption{ + { + Name: "date", + Description: "The date to fetch the stats for", + Type: discordgo.ApplicationCommandOptionString, + Required: false, + }, + }, + } +} + +func (c *StatsCommand) Handle(s *discordgo.Session, i *discordgo.InteractionCreate) { + // Fetch optional arguments + date := time.Now().Format("02-01-2006") + for _, opt := range i.ApplicationCommandData().Options { + if opt.Name == "date" { + dateValue := opt.StringValue() + if dateValue != "" { + if !regexp.MustCompile(`\d{2}-\d{2}-\d{4}`).MatchString(dateValue) { + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Invalid date format. Please use DD-MM-YYYY.", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + date = dateValue + } + break + } + } + + // Fetch the stats for the given date + // TODO: make a util function for the request + resp, err := http.Get(fmt.Sprintf("%s/stats?date=%s", config.GlobalConfig.ApiBase, date)) + if err != nil || resp.StatusCode != http.StatusOK { + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Failed to fetch stats for this date.", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + defer resp.Body.Close() + + // Decode the response + var jsonResponse map[string]any + if err = json.NewDecoder(resp.Body).Decode(&jsonResponse); err != nil { + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Failed to decode the response.", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + // Check for errors in response + if _, ok := jsonResponse["error"]; ok { + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Failed to fetch stats for this date.", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + // Parse the response + respDate := jsonResponse["date"].(string) + joins := int(jsonResponse["joins"].(float64)) + leaves := int(jsonResponse["leaves"].(float64)) + gained := joins - leaves + downloads := int(jsonResponse["downloads"].(float64)) + + // Respond to the interaction + content := fmt.Sprintf("**Date**: %s\n**Joins**: %d\n**Leaves**: %d\n**Gained**: %d\n**Downloads**: %d", respDate, joins, leaves, gained, downloads) + c.HandleInteractionRespond(s, i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{ + { + Title: "Meteor Stats", + Description: content, + Color: common.EmbedColor, + }, + }, + }, + }) +} diff --git a/internal/app/common/command.go b/internal/app/common/command.go new file mode 100644 index 0000000..6e2fa28 --- /dev/null +++ b/internal/app/common/command.go @@ -0,0 +1,50 @@ +package common + +import ( + dg "github.com/bwmarrin/discordgo" + "github.com/rs/zerolog/log" +) + +var ( + ModerateMembersPermission int64 = dg.PermissionModerateMembers + BanMemberPermission int64 = dg.PermissionBanMembers + ManageThreadsPermission int64 = dg.PermissionManageThreads + + EmbedColor = 0x913de2 +) + +// Command interface for all slash commands +type Command interface { + Name() string // Returns the name of the command + Description() string // Returns the description of the command + Build() *dg.ApplicationCommand // Builds the command + Handle(s *dg.Session, i *dg.InteractionCreate) // Func called when command is triggered + HandleInteractionRespond(s *dg.Session, i *dg.InteractionCreate, resp *dg.InteractionResponse) +} + +/* +BaseCommand struct that all commands should embed +This provides default implementations for the Command interface +*/ +type BaseCommand struct { + name string + description string +} + +func (c *BaseCommand) Name() string { + return c.name +} + +func (c *BaseCommand) Description() string { + return c.description +} + +/* +HandleInteractionRespond responds with the given response and logs the error if there is one +Wraps the discordgo.Session#InteractionRespond function +*/ +func (c *BaseCommand) HandleInteractionRespond(s *dg.Session, i *dg.InteractionCreate, resp *dg.InteractionResponse) { + if err := s.InteractionRespond(i.Interaction, resp); err != nil { + log.Error().Err(err).Msg("Error responding to interaction") + } +} diff --git a/internal/app/common/commandbuilder.go b/internal/app/common/commandbuilder.go new file mode 100644 index 0000000..74ba32b --- /dev/null +++ b/internal/app/common/commandbuilder.go @@ -0,0 +1,28 @@ +package common + +// CommandBuilder struct to build a BaseCommand +type CommandBuilder struct { + name string + description string +} + +func NewCommandBuilder() *CommandBuilder { + return &CommandBuilder{} +} + +func (b *CommandBuilder) SetName(name string) *CommandBuilder { + b.name = name + return b +} + +func (b *CommandBuilder) SetDescription(description string) *CommandBuilder { + b.description = description + return b +} + +func (b *CommandBuilder) Build() *BaseCommand { + return &BaseCommand{ + name: b.name, + description: b.description, + } +} diff --git a/internal/app/common/logger.go b/internal/app/common/logger.go new file mode 100644 index 0000000..01daaf7 --- /dev/null +++ b/internal/app/common/logger.go @@ -0,0 +1,50 @@ +package common + +import ( + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "meteor-bot/internal/app/config" + "os" + "path" + "time" +) + +// logFile is the file to write logs to +var logFile *os.File + +func InitLogger() { + zerolog.TimeFieldFormat = zerolog.TimeFormatUnix + + if config.GlobalConfig.EnableLogFile { + var err error + logFile, err = os.OpenFile( + path.Join("logs", "meteor-bot.log"), + os.O_APPEND|os.O_CREATE|os.O_WRONLY, + 0664, + ) + if err != nil { + log.Panic().Err(err).Msg("Failed to open log file") + } + + consoleWriter := zerolog.ConsoleWriter{Out: os.Stdout} + multi := zerolog.MultiLevelWriter(consoleWriter, logFile) + + // Set the default logger to write to both console and file + log.Logger = zerolog.New(multi).With().Timestamp().Logger() + } else { + log.Logger = log.Output(zerolog.ConsoleWriter{ + Out: os.Stdout, + TimeFormat: time.TimeOnly, + }) + } + + log.Info().Msg("Logger initialized") +} + +func CloseLogger() { + if logFile != nil { + if err := logFile.Close(); err != nil { + log.Error().Err(err).Msg("Failed to close log file") + } + } +} diff --git a/internal/app/config/config.go b/internal/app/config/config.go new file mode 100644 index 0000000..c43df20 --- /dev/null +++ b/internal/app/config/config.go @@ -0,0 +1,51 @@ +package config + +import ( + "errors" + "github.com/joho/godotenv" + "github.com/rs/zerolog/log" + "os" +) + +// GlobalConfig holds the environment variables required for the bot to run +var GlobalConfig Env + +type Env struct { + DiscordToken string // Discord bot token + ApiBase string // Base URL for the API + BackendToken string // Backend token + ApplicationId string // ID of the application + GuildId string // ID of the guild where the bot is running + CopeNnId string // ID of the CopeNN emoji + MemberCountId string // ID of the member count channel + DownloadCountId string // ID of the download count channel + UptimeUrl string // URL for the uptime monitor + EnableLogFile bool // Enable logging to a file + RemoveCommands bool // Whether to remove commands on exit +} + +func Init() { + if _, err := os.Stat(".env"); errors.Is(err, os.ErrNotExist) { + log.Warn().Msg(".env file not found, using current environment") + } else { + if err := godotenv.Load(); err != nil { + log.Panic().Err(err).Msg("Error loading .env file") + } + } + + GlobalConfig = Env{ + DiscordToken: os.Getenv("DISCORD_TOKEN"), + ApiBase: os.Getenv("API_BASE"), + BackendToken: os.Getenv("BACKEND_TOKEN"), + ApplicationId: os.Getenv("APPLICATION_ID"), + GuildId: os.Getenv("GUILD_ID"), + CopeNnId: os.Getenv("COPE_NN_ID"), + MemberCountId: os.Getenv("MEMBER_COUNT_ID"), + DownloadCountId: os.Getenv("DOWNLOAD_COUNT_ID"), + UptimeUrl: os.Getenv("UPTIME_URL"), + EnableLogFile: os.Getenv("ENABLE_LOG_FILE") == "true", + RemoveCommands: os.Getenv("REMOVE_COMMANDS") == "true", + } + + log.Info().Msg("Config initialized") +} diff --git a/internal/app/events/events.go b/internal/app/events/events.go new file mode 100644 index 0000000..26765c0 --- /dev/null +++ b/internal/app/events/events.go @@ -0,0 +1,357 @@ +package events + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "github.com/bwmarrin/discordgo" + "github.com/rs/zerolog/log" + "math" + "meteor-bot/internal/app/config" + "net/http" + "strconv" + "strings" + "sync/atomic" + "time" +) + +const ( + // infoChannelHandler update period + updatePeriod = 6 * time.Minute + + // uptimeReadyHandler request interval + uptimeInterval = 60 * time.Second +) + +var ( + // Can and must be set only in discordgo.Ready handlers + guild *discordgo.Guild + + // hello event + greetings = []string{"hi", "hello", "howdy", "bonjour", "ciao", "hej", "hola", "yo"} + copeEmoji *discordgo.Emoji + + // infoChannel event + suffixes = []string{"k", "m", "b", "t"} + delay int64 + + // metrics event + metricsServer *http.Server +) + +func Init(s *discordgo.Session) { + registerEventHandlers(s) +} + +// registerEventHandlers registers non-command event handlers to the Discord session +func registerEventHandlers(s *discordgo.Session) { + var err error + if config.GlobalConfig.CopeNnId != "" { + copeEmoji, err = applicationEmoji(s, config.GlobalConfig.ApplicationId, config.GlobalConfig.CopeNnId) + if err != nil { + log.Error().Err(err).Msg("Failed to get emoji") + } + } else { + log.Warn().Msg("CopeNnId is not set, skipping emoji fetch") + } + + s.AddHandler(helloHandler) + s.AddHandler(userJoinedHandler) + s.AddHandler(userLeftHandler) + s.AddHandler(botStartHandler) + s.AddHandler(uptimeReadyHandler) + s.AddHandler(infoChannelHandler) + s.AddHandler(metricsReadyHandler) + s.AddHandler(metricsDisconnectHandler) + + log.Info().Msg("Events registered successfully.") +} + +// helloHandler handles the event when the bot is mentioned +func helloHandler(s *discordgo.Session, m *discordgo.MessageCreate) { + // ignore self messages + if m.Author.ID == s.State.User.ID { + return + } + + // Check if the message is from a text channel and the bot is mentioned + if m.GuildID != config.GlobalConfig.GuildId || !strings.Contains(m.Content, s.State.User.Mention()) { + return + } + + // Check if the message contains a greeting + for _, greeting := range greetings { + if strings.Contains(strings.ToLower(m.Content), greeting) { + _, _ = s.ChannelMessageSendReply(m.ChannelID, greeting+" :)", m.Reference()) + return + } + } + + if strings.Contains(strings.ToLower(m.Content), "cope") && copeEmoji != nil { + _ = s.MessageReactionAdd(m.ChannelID, m.ID, copeEmoji.APIName()) + } else { + _ = s.MessageReactionAdd(m.ChannelID, m.ID, "👋") + } +} + +// userJoinedHandler handles the event when a user joins the server +func userJoinedHandler(_ *discordgo.Session, m *discordgo.GuildMemberAdd) { + if config.GlobalConfig.BackendToken == "" { + return + } + + // POST request to the backend + req, err := http.NewRequest("POST", config.GlobalConfig.ApiBase+"/discord/userJoined?id="+m.User.ID, nil) + if err != nil { + log.Error().Err(err).Msg("Failed to create request") + return + } + req.Header.Set("Authorization", config.GlobalConfig.BackendToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + log.Error().Err(err).Msg("Failed to send request") + return + } + defer resp.Body.Close() +} + +// userLeftHandler handles the event when a user leaves the server +func userLeftHandler(_ *discordgo.Session, m *discordgo.GuildMemberRemove) { + if config.GlobalConfig.BackendToken == "" { + return + } + + // POST request to the backend + req, err := http.NewRequest("POST", config.GlobalConfig.ApiBase+"/discord/userLeft?id="+m.User.ID, nil) + if err != nil { + log.Error().Err(err).Msg("Failed to create request") + return + } + req.Header.Set("Authorization", config.GlobalConfig.BackendToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + log.Error().Err(err).Msg("Failed to send request") + return + } + defer resp.Body.Close() +} + +// botStartHandler sets up the bot status +func botStartHandler(s *discordgo.Session, _ *discordgo.Ready) { + if err := s.UpdateGameStatus(0, "Meteor Client"); err != nil { + log.Warn().Err(err).Msg("Failed to set game status") + } + log.Info().Msgf("Logged in as: %v#%v", s.State.User.Username, s.State.User.Discriminator) +} + +// uptimeReadyHandler sends an uptime request to the configured URL every 60 seconds +func uptimeReadyHandler(s *discordgo.Session, _ *discordgo.Ready) { + if config.GlobalConfig.UptimeUrl == "" { + log.Warn().Msg("Uptime URL not set, uptime requests will not be made") + return + } + + // Send an uptime request every 60 seconds + ticker := time.NewTicker(uptimeInterval) + go func() { + for { + select { + case <-ticker.C: + url := config.GlobalConfig.UptimeUrl + if strings.HasSuffix(url, "ping=") { + url += fmt.Sprintf("%d", s.HeartbeatLatency().Milliseconds()) + } + + resp, err := http.Get(url) + if err != nil { + log.Error().Err(err).Msg("Failed to send uptime request") + } else { + resp.Body.Close() + } + } + } + }() + + log.Info().Msgf("Sending uptime requests every %.0f seconds", uptimeInterval.Seconds()) +} + +// infoChannelHandler updates the member count and download count channels +func infoChannelHandler(s *discordgo.Session, _ *discordgo.Ready) { + var err error + guild, err = s.Guild(config.GlobalConfig.GuildId) + if err != nil || guild == nil { + log.Warn().Msg("Guild not set, info channels will not be updated") + return + } + + if config.GlobalConfig.MemberCountId == "" || config.GlobalConfig.DownloadCountId == "" { + log.Warn().Msg("Member count or download count channel IDs not set, info channels will not be updated") + return + } + + memberCountChannel, err := s.Channel(config.GlobalConfig.MemberCountId) + if err != nil || memberCountChannel == nil { + log.Warn().Err(err).Msg("Failed to get member count channel") + return + } + + downloadCountChannel, err := s.Channel(config.GlobalConfig.DownloadCountId) + if err != nil || downloadCountChannel == nil { + log.Warn().Err(err).Msg("Failed to get download count channel") + return + } + + updateChannel(s, downloadCountChannel, func() int64 { + resp, err := http.Get(config.GlobalConfig.ApiBase + "/stats") + if err != nil { + log.Error().Err(err).Msg("Failed to fetch download stats") + return 0 + } + defer resp.Body.Close() + + var stats map[string]any + if err := json.NewDecoder(resp.Body).Decode(&stats); err != nil { + log.Error().Err(err).Msg("Failed to parse download stats") + return 0 + } + + downloads, ok := stats["downloads"].(float64) + if !ok { + log.Error().Msg("Failed to assert downloads as float64") + return 0 + } + + return int64(downloads) + }) + + updateChannel(s, memberCountChannel, func() int64 { + return int64(guildMemberCount(s, guild.ID)) + }) + + log.Info().Msgf("Updating info channels every %.0f seconds", updatePeriod.Seconds()) +} + +// metricsReadyHandler starts the metrics server +func metricsReadyHandler(s *discordgo.Session, _ *discordgo.Ready) { + var err error + guild, err = s.Guild(config.GlobalConfig.GuildId) + if err != nil || guild == nil { + log.Warn().Msg("Guild not set, metrics server will not be started") + return + } + + http.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) { + onRequest(s, w, r) + }) + metricsServer = &http.Server{Addr: ":9400"} + + go func() { + if err := metricsServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Panic().Err(err).Msg("Failed to start metrics server") + } + }() + + log.Info().Msg("Providing metrics on :9400/metrics") +} + +// metricsDisconnectHandler shuts down the metrics server when the bot disconnects +func metricsDisconnectHandler(_ *discordgo.Session, _ *discordgo.Disconnect) { + if metricsServer != nil { + if err := metricsServer.Shutdown(context.Background()); err != nil { + log.Error().Err(err).Msg("Failed to shutdown metrics server") + } else { + log.Info().Msg("Metrics server shutdown gracefully") + } + } +} + +// applicationEmoji fetches an emoji from the application +func applicationEmoji(s *discordgo.Session, applicationID string, emojiID string, options ...discordgo.RequestOption) (emoji *discordgo.Emoji, err error) { + var body []byte + body, err = s.RequestWithBucketID("GET", discordgo.EndpointApplication(applicationID)+"/emojis/"+emojiID, nil, discordgo.EndpointApplication(applicationID), options...) + if err != nil { + return + } + + err = discordgo.Unmarshal(body, &emoji) + return +} + +// updateChannel updates the channel name with the given supplier function +func updateChannel(s *discordgo.Session, channel *discordgo.Channel, supplier func() int64) { + atomic.AddInt64(&delay, int64(updatePeriod/2)) + + go func() { + ticker := time.NewTicker(updatePeriod) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + name := channel.Name + newName := fmt.Sprintf("%s %s", name[:strings.LastIndex(name, ":")+1], formatLong(supplier())) + _, err := s.ChannelEdit(channel.ID, &discordgo.ChannelEdit{Name: newName}) + if err != nil { + log.Error().Err(err).Msg("Failed to update channel name") + } + } + } + }() +} + +// formatLong formats a long number into a human-readable string +func formatLong(value int64) string { + if value < 1000 { + return strconv.FormatInt(value, 10) + } + + exponent := int(math.Log10(float64(value)) / 3) + if exponent > len(suffixes) { + exponent = len(suffixes) + } + + base := math.Pow(1000, float64(exponent)) + first := float64(value) / base + return fmt.Sprintf("%.2f%s", first, suffixes[exponent-1]) +} + +// onRequest handles the metrics request +func onRequest(s *discordgo.Session, w http.ResponseWriter, _ *http.Request) { + response := fmt.Sprintf( + `# HELP meteor_discord_users_total Total number of Discord users in our server +# TYPE meteor_discord_users_total gauge +meteor_discord_users_total %d`, + guildMemberCount(s, guild.ID)) + + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(response)) + if err != nil { + log.Error().Err(err).Msg("Failed to write response") + } +} + +// guildMemberCount returns the number of members in the guild +// NOTE: this is a workaround for discordgo.Session#GuildMembers returning a wrong structure +func guildMemberCount(s *discordgo.Session, guildID string) int { + count := 0 + after := "" + + for { + users, err := s.GuildMembers(guildID, after, 1000) + if err != nil { + log.Panic().Err(err).Msg("Error getting guild members") + } + usrCount := len(users) + if usrCount == 0 { + break + } + count += usrCount + after = users[usrCount-1].User.ID + } + + return count +} diff --git a/settings.gradle b/settings.gradle deleted file mode 100644 index fd572fe..0000000 --- a/settings.gradle +++ /dev/null @@ -1,2 +0,0 @@ -rootProject.name = 'meteor-bot' - diff --git a/src/main/java/org/meteordev/meteorbot/Env.java b/src/main/java/org/meteordev/meteorbot/Env.java deleted file mode 100644 index 08752fd..0000000 --- a/src/main/java/org/meteordev/meteorbot/Env.java +++ /dev/null @@ -1,18 +0,0 @@ -package org.meteordev.meteorbot; - -public enum Env { - DISCORD_TOKEN("DISCORD_TOKEN"), - API_BASE("API_BASE"), - BACKEND_TOKEN("BACKEND_TOKEN"), - GUILD_ID("GUILD_ID"), - COPE_NN_ID("COPE_NN_ID"), - MEMBER_COUNT_ID("MEMBER_COUNT_ID"), - DOWNLOAD_COUNT_ID("DOWNLOAD_COUNT_ID"), - UPTIME_URL("UPTIME_URL"); - - public final String value; - - Env(String key) { - this.value = System.getenv(key); - } -} diff --git a/src/main/java/org/meteordev/meteorbot/InfoChannels.java b/src/main/java/org/meteordev/meteorbot/InfoChannels.java deleted file mode 100644 index 77c551a..0000000 --- a/src/main/java/org/meteordev/meteorbot/InfoChannels.java +++ /dev/null @@ -1,49 +0,0 @@ -package org.meteordev.meteorbot; - -import net.dv8tion.jda.api.entities.Guild; -import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel; -import net.dv8tion.jda.api.events.session.ReadyEvent; -import net.dv8tion.jda.api.hooks.ListenerAdapter; - -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import java.util.function.LongSupplier; - -public class InfoChannels extends ListenerAdapter { - private static final int UPDATE_PERIOD = 6; - private static int delay; - - @Override - public void onReady(ReadyEvent event) { - Guild guild = event.getJDA().getGuildById(Env.GUILD_ID.value); - if (guild == null) { - MeteorBot.LOG.warn("Failed to fetch guild when initialising info channels."); - return; - } - - if (Env.MEMBER_COUNT_ID.value == null || Env.DOWNLOAD_COUNT_ID.value == null) { - MeteorBot.LOG.warn("Must define info channel id's for them to function."); - return; - } - - VoiceChannel memberCount = guild.getVoiceChannelById(Env.MEMBER_COUNT_ID.value); - VoiceChannel downloads = guild.getVoiceChannelById(Env.DOWNLOAD_COUNT_ID.value); - if (memberCount == null || downloads == null) { - MeteorBot.LOG.warn("Failed to fetch channels when initialising info channels."); - return; - } - - updateChannel(downloads, () -> Utils.apiGet("stats").asJson().getBody().getObject().getLong("downloads")); - updateChannel(memberCount, guild::getMemberCount); - } - - private static void updateChannel(VoiceChannel channel, LongSupplier supplier) { - Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> { - String name = channel.getName(); - name = "%s %s".formatted(name.substring(0, name.lastIndexOf(':') + 1), Utils.formatLong(supplier.getAsLong())); - channel.getManager().setName(name).complete(); - }, delay, UPDATE_PERIOD, TimeUnit.MINUTES); - - delay += UPDATE_PERIOD / 2; - } -} diff --git a/src/main/java/org/meteordev/meteorbot/MeteorBot.java b/src/main/java/org/meteordev/meteorbot/MeteorBot.java deleted file mode 100644 index 11d0917..0000000 --- a/src/main/java/org/meteordev/meteorbot/MeteorBot.java +++ /dev/null @@ -1,90 +0,0 @@ -package org.meteordev.meteorbot; - -import net.dv8tion.jda.api.JDA; -import net.dv8tion.jda.api.JDABuilder; -import net.dv8tion.jda.api.entities.Activity; -import net.dv8tion.jda.api.entities.Guild; -import net.dv8tion.jda.api.entities.channel.ChannelType; -import net.dv8tion.jda.api.entities.emoji.Emoji; -import net.dv8tion.jda.api.entities.emoji.RichCustomEmoji; -import net.dv8tion.jda.api.events.guild.member.GuildMemberJoinEvent; -import net.dv8tion.jda.api.events.guild.member.GuildMemberRemoveEvent; -import net.dv8tion.jda.api.events.message.MessageReceivedEvent; -import net.dv8tion.jda.api.events.session.ReadyEvent; -import net.dv8tion.jda.api.hooks.ListenerAdapter; -import net.dv8tion.jda.api.requests.GatewayIntent; -import net.dv8tion.jda.api.utils.cache.CacheFlag; -import net.dv8tion.jda.internal.utils.JDALogger; -import org.jetbrains.annotations.NotNull; -import org.meteordev.meteorbot.command.Commands; -import org.slf4j.Logger; - -public class MeteorBot extends ListenerAdapter { - private static final String[] HELLOS = {"hi", "hello", "howdy", "bonjour", "ciao", "hej", "hola", "yo"}; - - public static final Logger LOG = JDALogger.getLog("Meteor Bot"); - - private Guild server; - private RichCustomEmoji copeEmoji; - - public static void main(String[] args) { - String token = Env.DISCORD_TOKEN.value; - if (token == null) { - MeteorBot.LOG.error("Must specify discord bot token."); - return; - } - - JDABuilder.createDefault(token) - .enableIntents(GatewayIntent.GUILD_MEMBERS, GatewayIntent.MESSAGE_CONTENT) - .enableCache(CacheFlag.EMOJI) - .addEventListeners(new MeteorBot(), new Commands(), new Uptime(), new InfoChannels(), new Metrics()) - .build(); - } - - @Override - public void onReady(ReadyEvent event) { - JDA bot = event.getJDA(); - bot.getPresence().setActivity(Activity.playing("Meteor Client")); - - server = bot.getGuildById(Env.GUILD_ID.value); - if (server == null) { - MeteorBot.LOG.error("Couldn't find the specified server."); - System.exit(1); - } - - copeEmoji = server.getEmojiById(Env.COPE_NN_ID.value); - - LOG.info("Meteor Bot started"); - } - - @Override - public void onMessageReceived(MessageReceivedEvent event) { - if (!event.isFromType(ChannelType.TEXT) || !event.isFromGuild() || !event.getGuild().equals(server)) return; - - String content = event.getMessage().getContentRaw(); - if (!content.contains(event.getJDA().getSelfUser().getAsMention())) return; - - for (String hello : HELLOS) { - if (content.toLowerCase().contains(hello)) { - event.getMessage().reply(hello + " :)").queue(); - return; - } - } - - event.getMessage().addReaction(content.toLowerCase().contains("cope") ? copeEmoji : Emoji.fromUnicode("\uD83D\uDC4B")).queue(); - } - - @Override - public void onGuildMemberJoin(@NotNull GuildMemberJoinEvent event) { - if (Env.BACKEND_TOKEN.value == null) return; - - Utils.apiPost("discord/userJoined").queryString("id", event.getMember().getId()).asEmpty(); - } - - @Override - public void onGuildMemberRemove(@NotNull GuildMemberRemoveEvent event) { - if (Env.BACKEND_TOKEN.value == null) return; - - Utils.apiPost("discord/userLeft").asEmpty(); - } -} diff --git a/src/main/java/org/meteordev/meteorbot/Metrics.java b/src/main/java/org/meteordev/meteorbot/Metrics.java deleted file mode 100644 index 8463b40..0000000 --- a/src/main/java/org/meteordev/meteorbot/Metrics.java +++ /dev/null @@ -1,78 +0,0 @@ -package org.meteordev.meteorbot; - -import com.sun.net.httpserver.HttpExchange; -import com.sun.net.httpserver.HttpServer; -import net.dv8tion.jda.api.JDA; -import net.dv8tion.jda.api.entities.Guild; -import net.dv8tion.jda.api.events.session.ReadyEvent; -import net.dv8tion.jda.api.events.session.ShutdownEvent; -import net.dv8tion.jda.api.hooks.ListenerAdapter; - -import java.io.IOException; -import java.io.OutputStream; -import java.net.InetSocketAddress; -import java.nio.charset.StandardCharsets; - -public class Metrics extends ListenerAdapter { - private Guild guild; - private HttpServer server; - - @Override - public void onReady(ReadyEvent event) { - JDA bot = event.getJDA(); - - guild = bot.getGuildById(Env.GUILD_ID.value); - if (guild == null) { - MeteorBot.LOG.error("Couldn't find the specified server."); - System.exit(1); - } - - try { - server = HttpServer.create(new InetSocketAddress(9400), 0); - server.createContext("/metrics", this::onRequest); - server.start(); - } catch (IOException e) { - throw new RuntimeException(e); - } - - MeteorBot.LOG.info("Providing metrics on :9400/metrics"); - } - - @Override - public void onShutdown(ShutdownEvent event) { - server.stop(1000); - } - - private void onRequest(HttpExchange exchange) { - byte[] response = String.format(""" - # HELP meteor_discord_users_total Total number of Discord users in our server - # TYPE meteor_discord_users_total gauge - meteor_discord_users_total %d - """, guild.getMemberCount()).getBytes(StandardCharsets.UTF_8); - - try { - exchange.getResponseHeaders().set("Content-Type", "text/plain"); - exchange.sendResponseHeaders(200, response.length); - } catch (IOException e) { - throw new RuntimeException(e); - } - - OutputStream out = exchange.getResponseBody(); - copy(response, out); - } - - @SuppressWarnings("ThrowFromFinallyBlock") - private static void copy(byte[] in, OutputStream out) { - try { - out.write(in); - } catch (IOException e) { - throw new RuntimeException(e); - } finally { - try { - out.close(); - } catch (IOException e) { - throw new RuntimeException(e); - } - } - } -} diff --git a/src/main/java/org/meteordev/meteorbot/Uptime.java b/src/main/java/org/meteordev/meteorbot/Uptime.java deleted file mode 100644 index f7b8d64..0000000 --- a/src/main/java/org/meteordev/meteorbot/Uptime.java +++ /dev/null @@ -1,26 +0,0 @@ -package org.meteordev.meteorbot; - -import kong.unirest.Unirest; -import net.dv8tion.jda.api.events.session.ReadyEvent; -import net.dv8tion.jda.api.hooks.ListenerAdapter; -import org.jetbrains.annotations.NotNull; - -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; - -public class Uptime extends ListenerAdapter { - @Override - public void onReady(@NotNull ReadyEvent event) { - if (Env.UPTIME_URL.value == null) { - MeteorBot.LOG.warn("Uptime URL not configured, uptime requests will not be made"); - return; - } - - Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> { - String url = Env.UPTIME_URL.value; - if (url.endsWith("ping=")) url += event.getJDA().getGatewayPing(); - - Unirest.get(url).asEmpty(); - }, 0, 60, TimeUnit.SECONDS); - } -} diff --git a/src/main/java/org/meteordev/meteorbot/Utils.java b/src/main/java/org/meteordev/meteorbot/Utils.java deleted file mode 100644 index 24436a0..0000000 --- a/src/main/java/org/meteordev/meteorbot/Utils.java +++ /dev/null @@ -1,94 +0,0 @@ -package org.meteordev.meteorbot; - -import kong.unirest.GetRequest; -import kong.unirest.HttpRequestWithBody; -import kong.unirest.Unirest; -import net.dv8tion.jda.api.EmbedBuilder; - -import java.awt.*; -import java.text.NumberFormat; -import java.time.temporal.ChronoUnit; -import java.util.Locale; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -public abstract class Utils { - private static final Pattern TIME_PATTERN = Pattern.compile("^(\\d+)(ms|s|m|h|d|w)$"); - private static final Color COLOR = new Color(145, 61, 226); - private static final String[] SUFFIXES = {"k", "m", "b", "t"}; - - private Utils() { - } - - public static long parseAmount(String parse) { - Matcher matcher = TIME_PATTERN.matcher(parse); - if (!matcher.matches() || matcher.groupCount() != 2) return -1; - - try { - return Integer.parseInt(matcher.group(1)); - } catch (NumberFormatException ignored) { - return -1; - } - } - - public static ChronoUnit parseUnit(String parse) { - Matcher matcher = TIME_PATTERN.matcher(parse); - if (!matcher.matches() || matcher.groupCount() != 2) return null; - - return unitFromString(matcher.group(2)); - } - - public static ChronoUnit unitFromString(String string) { - for (ChronoUnit value : ChronoUnit.values()) { - String stringValue = unitToString(value); - if (stringValue == null) continue; - - if (stringValue.equalsIgnoreCase(string)) { - return value; - } - } - - return null; - } - - public static String unitToString(ChronoUnit unit) { - return switch (unit) { - case SECONDS -> "s"; - case MINUTES -> "m"; - case HOURS -> "h"; - case DAYS -> "d"; - case WEEKS -> "w"; - default -> null; - }; - } - - public static String formatLong(long value) { - String formatted = NumberFormat.getNumberInstance(Locale.UK).format(value); - String first = formatted, second = "", suffix = ""; - - if (formatted.contains(",")) { - int firstComma = formatted.indexOf(','); - first = formatted.substring(0, firstComma); - second = "." + formatted.substring(firstComma + 1, firstComma + 3); - suffix = SUFFIXES[formatted.replaceAll("[^\",\"]", "").length() - 1]; - } - - return first + second + suffix; - } - - public static EmbedBuilder embedTitle(String title, String format, Object... args) { - return new EmbedBuilder().setColor(COLOR).setTitle(title).setDescription(String.format(format, args)); - } - - public static EmbedBuilder embed(String format, Object... args) { - return embedTitle(null, format, args); - } - - public static GetRequest apiGet(String path) { - return Unirest.get(Env.API_BASE.value + path); - } - - public static HttpRequestWithBody apiPost(String path) { - return Unirest.post(Env.API_BASE.value + path).header("Authorization", Env.BACKEND_TOKEN.value); - } -} diff --git a/src/main/java/org/meteordev/meteorbot/command/Command.java b/src/main/java/org/meteordev/meteorbot/command/Command.java deleted file mode 100644 index af8b5d3..0000000 --- a/src/main/java/org/meteordev/meteorbot/command/Command.java +++ /dev/null @@ -1,32 +0,0 @@ -package org.meteordev.meteorbot.command; - -import net.dv8tion.jda.api.entities.Member; -import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; -import net.dv8tion.jda.api.interactions.commands.OptionMapping; -import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData; - -public abstract class Command { - public final String name, description; - - protected Command(String name, String description) { - this.name = name; - this.description = description; - } - - public abstract SlashCommandData build(SlashCommandData data); - - public abstract void run(SlashCommandInteractionEvent event); - - public static Member parseMember(SlashCommandInteractionEvent event) { - OptionMapping memberOption = event.getOption("member"); - if (memberOption == null || memberOption.getAsMember() == null) { - event.reply("Couldn't find that member.").setEphemeral(true).queue(); - return null; - } - - Member member = memberOption.getAsMember(); - if (member == null) event.reply("Couldn't find that member.").setEphemeral(true).queue(); - - return member; - } -} diff --git a/src/main/java/org/meteordev/meteorbot/command/Commands.java b/src/main/java/org/meteordev/meteorbot/command/Commands.java deleted file mode 100644 index addab27..0000000 --- a/src/main/java/org/meteordev/meteorbot/command/Commands.java +++ /dev/null @@ -1,76 +0,0 @@ -package org.meteordev.meteorbot.command; - -import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; -import net.dv8tion.jda.api.events.session.ReadyEvent; -import net.dv8tion.jda.api.hooks.ListenerAdapter; -import net.dv8tion.jda.api.interactions.commands.build.CommandData; -import net.dv8tion.jda.internal.interactions.CommandDataImpl; -import org.jetbrains.annotations.NotNull; -import org.meteordev.meteorbot.Env; -import org.meteordev.meteorbot.MeteorBot; -import org.meteordev.meteorbot.command.commands.LinkCommand; -import org.meteordev.meteorbot.command.commands.StatsCommand; -import org.meteordev.meteorbot.command.commands.help.FaqCommand; -import org.meteordev.meteorbot.command.commands.help.InstallationCommand; -import org.meteordev.meteorbot.command.commands.help.LogsCommand; -import org.meteordev.meteorbot.command.commands.help.OldVersionCommand; -import org.meteordev.meteorbot.command.commands.moderation.BanCommand; -import org.meteordev.meteorbot.command.commands.moderation.CloseCommand; -import org.meteordev.meteorbot.command.commands.moderation.MuteCommand; -import org.meteordev.meteorbot.command.commands.moderation.UnmuteCommand; -import org.meteordev.meteorbot.command.commands.silly.*; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -public class Commands extends ListenerAdapter { - private static final Map BOT_COMMANDS = new HashMap<>(); - - public static void add(Command command) { - BOT_COMMANDS.put(command.name, command); - } - - @Override - public void onReady(@NotNull ReadyEvent event) { - add(new FaqCommand()); - add(new InstallationCommand()); - add(new LogsCommand()); - add(new OldVersionCommand()); - - add(new BanCommand()); - add(new CloseCommand()); - add(new MuteCommand()); - add(new UnmuteCommand()); - - add(new CapyCommand()); - add(new CatCommand()); - add(new DogCommand()); - add(new MonkeyCommand()); - add(new PandaCommand()); - - add(new StatsCommand()); - if (Env.BACKEND_TOKEN.value != null) { - add(new LinkCommand()); - } - - List commandData = new ArrayList<>(); - - for (Command command : BOT_COMMANDS.values()) { - commandData.add(command.build(new CommandDataImpl(command.name, command.description))); - } - - event.getJDA().updateCommands().addCommands(commandData).complete(); - - MeteorBot.LOG.info("Loaded {} commands", BOT_COMMANDS.size()); - } - - @Override - public void onSlashCommandInteraction(@NotNull SlashCommandInteractionEvent event) { - Command command = BOT_COMMANDS.get(event.getName()); - if (command == null) return; - - command.run(event); - } -} diff --git a/src/main/java/org/meteordev/meteorbot/command/commands/LinkCommand.java b/src/main/java/org/meteordev/meteorbot/command/commands/LinkCommand.java deleted file mode 100644 index b6169c0..0000000 --- a/src/main/java/org/meteordev/meteorbot/command/commands/LinkCommand.java +++ /dev/null @@ -1,47 +0,0 @@ -package org.meteordev.meteorbot.command.commands; - -import kong.unirest.json.JSONObject; -import net.dv8tion.jda.api.entities.channel.ChannelType; -import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; -import net.dv8tion.jda.api.interactions.commands.OptionMapping; -import net.dv8tion.jda.api.interactions.commands.OptionType; -import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData; -import org.meteordev.meteorbot.Utils; -import org.meteordev.meteorbot.command.Command; - -public class LinkCommand extends Command { - public LinkCommand() { - super("link", "Links your Discord account to your Meteor account."); - } - - @Override - public SlashCommandData build(SlashCommandData data) { - return data.addOption(OptionType.STRING, "token", "The token generated on the Meteor website.", true); - } - - @Override - public void run(SlashCommandInteractionEvent event) { - if (!event.getChannel().getType().equals(ChannelType.PRIVATE)) { - event.reply("This action must be in DMs.").setEphemeral(true).queue(); - return; - } - - OptionMapping token = event.getOption("token"); - if (token == null || token.getAsString().isBlank()) { - event.reply("Must specify a valid token.").setEphemeral(true).queue(); - return; - } - - JSONObject json = Utils.apiPost("account/linkDiscord") - .queryString("id", event.getUser().getId()) - .queryString("token", token.getAsString()) - .asJson().getBody().getObject(); - - if (json.has("error")) { - event.reply("Failed to link your Discord account. Try generating a new token by refreshing the account page and clicking the link button again.").setEphemeral(true).queue(); - return; - } - - event.reply("Successfully linked your Discord account.").setEphemeral(true).queue(); - } -} diff --git a/src/main/java/org/meteordev/meteorbot/command/commands/StatsCommand.java b/src/main/java/org/meteordev/meteorbot/command/commands/StatsCommand.java deleted file mode 100644 index 93f595e..0000000 --- a/src/main/java/org/meteordev/meteorbot/command/commands/StatsCommand.java +++ /dev/null @@ -1,57 +0,0 @@ -package org.meteordev.meteorbot.command.commands; - -import kong.unirest.json.JSONObject; -import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; -import net.dv8tion.jda.api.interactions.commands.OptionMapping; -import net.dv8tion.jda.api.interactions.commands.OptionType; -import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData; -import org.meteordev.meteorbot.Utils; -import org.meteordev.meteorbot.command.Command; - -import java.util.Calendar; -import java.util.Date; -import java.util.TimeZone; -import java.util.regex.Pattern; - -public class StatsCommand extends Command { - private static final Pattern DATE_PATTERN = Pattern.compile("\\d{2}-\\d{2}-\\d{4}"); - - public StatsCommand() { - super("stats", "Shows various stats about Meteor."); - } - - @Override - public SlashCommandData build(SlashCommandData data) { - return data.addOption(OptionType.STRING, "date", "The date to fetch stats for."); - } - - @Override - public void run(SlashCommandInteractionEvent event) { - String date; - OptionMapping option = event.getOption("date"); - - if (option != null && DATE_PATTERN.matcher(option.getAsString()).matches()) { - date = option.getAsString(); - } else { - Calendar calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC")); - calendar.setTime(new Date()); - date = "%02d-%02d-%d".formatted(calendar.get(Calendar.DATE), calendar.get(Calendar.MONTH) + 1, calendar.get(Calendar.YEAR)); - } - - if (date == null) { - event.reply("Failed to determine date.").setEphemeral(true).queue(); - return; - } - - JSONObject json = Utils.apiGet("stats").queryString("date", date).asJson().getBody().getObject(); - - if (json == null || json.has("error")) { - event.reply("Failed to fetch stats for this date.").setEphemeral(true).queue(); - return; - } - - int joins = json.getInt("joins"), leaves = json.getInt("leaves"); - - event.replyEmbeds(Utils.embed("**Date**: %s\n**Joins**: %d\n**Leaves**: %d\n**Gained**: %d\n**Downloads**: %d".formatted(json.getString("date"), joins, leaves, joins - leaves, json.getInt("downloads"))).build()).queue(); - } -} diff --git a/src/main/java/org/meteordev/meteorbot/command/commands/help/FaqCommand.java b/src/main/java/org/meteordev/meteorbot/command/commands/help/FaqCommand.java deleted file mode 100644 index ff27591..0000000 --- a/src/main/java/org/meteordev/meteorbot/command/commands/help/FaqCommand.java +++ /dev/null @@ -1,14 +0,0 @@ -package org.meteordev.meteorbot.command.commands.help; - -import net.dv8tion.jda.api.interactions.components.buttons.Button; - -public class FaqCommand extends HelpCommand { - public FaqCommand() { - super( - "faq", - "Tells someone to read the faq", - "The FAQ answers your question, please read it.", - Button.link("https://meteorclient.com/faq", "FAQ") - ); - } -} diff --git a/src/main/java/org/meteordev/meteorbot/command/commands/help/HelpCommand.java b/src/main/java/org/meteordev/meteorbot/command/commands/help/HelpCommand.java deleted file mode 100644 index 73e7f98..0000000 --- a/src/main/java/org/meteordev/meteorbot/command/commands/help/HelpCommand.java +++ /dev/null @@ -1,33 +0,0 @@ -package org.meteordev.meteorbot.command.commands.help; - -import net.dv8tion.jda.api.entities.Member; -import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; -import net.dv8tion.jda.api.interactions.commands.OptionType; -import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData; -import net.dv8tion.jda.api.interactions.components.ItemComponent; -import org.meteordev.meteorbot.command.Command; - -public abstract class HelpCommand extends Command { - protected final ItemComponent[] components; - protected final String message; - - protected HelpCommand(String name, String description, String message, ItemComponent... components) { - super(name, description); - - this.message = message; - this.components = components == null ? new ItemComponent[0] : components; - } - - @Override - public SlashCommandData build(SlashCommandData data) { - return data.addOption(OptionType.USER, "member", "The member to ping.", true); - } - - @Override - public void run(SlashCommandInteractionEvent event) { - Member member = parseMember(event); - if (member == null) return; - - event.reply("%s %s".formatted(member.getAsMention(), message)).addActionRow(components).queue(); - } -} diff --git a/src/main/java/org/meteordev/meteorbot/command/commands/help/InstallationCommand.java b/src/main/java/org/meteordev/meteorbot/command/commands/help/InstallationCommand.java deleted file mode 100644 index 92ae33e..0000000 --- a/src/main/java/org/meteordev/meteorbot/command/commands/help/InstallationCommand.java +++ /dev/null @@ -1,14 +0,0 @@ -package org.meteordev.meteorbot.command.commands.help; - -import net.dv8tion.jda.api.interactions.components.buttons.Button; - -public class InstallationCommand extends HelpCommand { - public InstallationCommand() { - super( - "installation", - "Tells someone to read the installation guide", - "Please read the installation guide before asking more questions.", - Button.link("https://meteorclient.com/faq/installation", "Guide") - ); - } -} diff --git a/src/main/java/org/meteordev/meteorbot/command/commands/help/LogsCommand.java b/src/main/java/org/meteordev/meteorbot/command/commands/help/LogsCommand.java deleted file mode 100644 index 0c422a1..0000000 --- a/src/main/java/org/meteordev/meteorbot/command/commands/help/LogsCommand.java +++ /dev/null @@ -1,14 +0,0 @@ -package org.meteordev.meteorbot.command.commands.help; - -import net.dv8tion.jda.api.interactions.components.buttons.Button; - -public class LogsCommand extends HelpCommand { - public LogsCommand() { - super( - "logs", - "Tells someone how to find their logs", - "Please read the guide for sending logs before asking more questions.", - Button.link("https://meteorclient.com/faq/getting-log", "Guide") - ); - } -} diff --git a/src/main/java/org/meteordev/meteorbot/command/commands/help/OldVersionCommand.java b/src/main/java/org/meteordev/meteorbot/command/commands/help/OldVersionCommand.java deleted file mode 100644 index feca04e..0000000 --- a/src/main/java/org/meteordev/meteorbot/command/commands/help/OldVersionCommand.java +++ /dev/null @@ -1,14 +0,0 @@ -package org.meteordev.meteorbot.command.commands.help; - -import net.dv8tion.jda.api.interactions.components.buttons.Button; - -public class OldVersionCommand extends HelpCommand { - public OldVersionCommand() { - super( - "old-versions", - "Tells someone how to play on old versions", - "Please read the old versions guide before asking more questions.", - Button.link("https://meteorclient.com/faq/old-versions", "Guide") - ); - } -} diff --git a/src/main/java/org/meteordev/meteorbot/command/commands/moderation/BanCommand.java b/src/main/java/org/meteordev/meteorbot/command/commands/moderation/BanCommand.java deleted file mode 100644 index 19f573c..0000000 --- a/src/main/java/org/meteordev/meteorbot/command/commands/moderation/BanCommand.java +++ /dev/null @@ -1,39 +0,0 @@ -package org.meteordev.meteorbot.command.commands.moderation; - -import net.dv8tion.jda.api.Permission; -import net.dv8tion.jda.api.entities.Member; -import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; -import net.dv8tion.jda.api.interactions.commands.DefaultMemberPermissions; -import net.dv8tion.jda.api.interactions.commands.OptionType; -import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData; -import org.meteordev.meteorbot.command.Command; - -import java.util.concurrent.TimeUnit; - -public class BanCommand extends Command { - public BanCommand() { - super("ban", "Bans a member"); - } - - @Override - public SlashCommandData build(SlashCommandData data) { - return data - .addOption(OptionType.USER, "member", "The member to ban.", true) - .setDefaultPermissions(DefaultMemberPermissions.enabledFor(Permission.BAN_MEMBERS)); - } - - @Override - public void run(SlashCommandInteractionEvent event) { - if (!event.getMember().hasPermission(Permission.BAN_MEMBERS)) { - event.reply("You don't have permission to ban members.").setEphemeral(true).queue(); - return; - } - - Member member = parseMember(event); - if (member == null) return; - - member.ban(0, TimeUnit.NANOSECONDS).queue(a -> { - event.reply("Banned %s.".formatted(member.getAsMention())).queue(); - }); - } -} diff --git a/src/main/java/org/meteordev/meteorbot/command/commands/moderation/CloseCommand.java b/src/main/java/org/meteordev/meteorbot/command/commands/moderation/CloseCommand.java deleted file mode 100644 index 007aed8..0000000 --- a/src/main/java/org/meteordev/meteorbot/command/commands/moderation/CloseCommand.java +++ /dev/null @@ -1,36 +0,0 @@ -package org.meteordev.meteorbot.command.commands.moderation; - -import net.dv8tion.jda.api.Permission; -import net.dv8tion.jda.api.entities.channel.ChannelType; -import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; -import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData; -import org.meteordev.meteorbot.command.Command; - -public class CloseCommand extends Command { - public CloseCommand() { - super("close", "Locks the current forum post"); - } - - @Override - public SlashCommandData build(SlashCommandData data) { - return data; - } - - @Override - public void run(SlashCommandInteractionEvent event) { - if (event.getChannelType() != ChannelType.GUILD_PUBLIC_THREAD) { - event.reply("This command can only be used in a forum post.").setEphemeral(true).queue(); - return; - } - - if (!event.getMember().hasPermission(Permission.MANAGE_THREADS)) { - event.reply("You don't have permission to lock threads.").setEphemeral(true).queue(); - return; - } - - event.reply("This post is now locked.").queue(hook -> { - event.getChannel().asThreadChannel().getManager().setLocked(true).setArchived(true).queue(); - }); - - } -} diff --git a/src/main/java/org/meteordev/meteorbot/command/commands/moderation/MuteCommand.java b/src/main/java/org/meteordev/meteorbot/command/commands/moderation/MuteCommand.java deleted file mode 100644 index 9487d2a..0000000 --- a/src/main/java/org/meteordev/meteorbot/command/commands/moderation/MuteCommand.java +++ /dev/null @@ -1,70 +0,0 @@ -package org.meteordev.meteorbot.command.commands.moderation; - -import net.dv8tion.jda.api.Permission; -import net.dv8tion.jda.api.entities.Member; -import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; -import net.dv8tion.jda.api.interactions.commands.DefaultMemberPermissions; -import net.dv8tion.jda.api.interactions.commands.OptionMapping; -import net.dv8tion.jda.api.interactions.commands.OptionType; -import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData; -import net.dv8tion.jda.api.requests.restaction.AuditableRestAction; -import org.meteordev.meteorbot.Utils; -import org.meteordev.meteorbot.command.Command; - -import java.time.Duration; -import java.time.temporal.ChronoUnit; - -public class MuteCommand extends Command { - public MuteCommand() { - super("mute", "Mutes a user for a maximum of 27 days."); - } - - @Override - public SlashCommandData build(SlashCommandData data) { - return data - .addOption(OptionType.USER, "member", "The member to mute.", true) - .addOption(OptionType.STRING, "duration", "The duration of the mute.", true) - .addOption(OptionType.STRING, "reason", "The reason for the mute.") - .setDefaultPermissions(DefaultMemberPermissions.enabledFor(Permission.MODERATE_MEMBERS)); - } - - @Override - public void run(SlashCommandInteractionEvent event) { - Member member = parseMember(event); - if (member == null) return; - - if (member.isTimedOut()) { - event.reply("That user is already timed out.").setEphemeral(true).queue(); - return; - } - - if (member.hasPermission(Permission.MODERATE_MEMBERS)) { - event.reply("That member can't be muted.").setEphemeral(true).queue(); - return; - } - - String duration = event.getOption("duration").getAsString(); - long amount = Utils.parseAmount(duration); - ChronoUnit unit = Utils.parseUnit(duration); - - if (amount == -1 || unit == null) { - event.reply("Failed to parse mute duration.").setEphemeral(true).queue(); - return; - } - - if (unit == ChronoUnit.SECONDS && amount < 10) { - event.reply("Please input a minimum duration of 10 seconds.").setEphemeral(true).queue(); - return; - } - - String reason = ""; - OptionMapping reasonOption = event.getOption("reason"); - if (reasonOption != null) reason = reasonOption.getAsString(); - - AuditableRestAction action = member.timeoutFor(Duration.of(amount, unit)); - if (!reason.isBlank()) action.reason(reason); - action.queue(a -> { - event.reply("Timed out %s for %s.".formatted(member.getAsMention(), duration)).queue(); - }); - } -} diff --git a/src/main/java/org/meteordev/meteorbot/command/commands/moderation/UnmuteCommand.java b/src/main/java/org/meteordev/meteorbot/command/commands/moderation/UnmuteCommand.java deleted file mode 100644 index 6c57b60..0000000 --- a/src/main/java/org/meteordev/meteorbot/command/commands/moderation/UnmuteCommand.java +++ /dev/null @@ -1,36 +0,0 @@ -package org.meteordev.meteorbot.command.commands.moderation; - -import net.dv8tion.jda.api.Permission; -import net.dv8tion.jda.api.entities.Member; -import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; -import net.dv8tion.jda.api.interactions.commands.DefaultMemberPermissions; -import net.dv8tion.jda.api.interactions.commands.OptionType; -import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData; -import org.meteordev.meteorbot.command.Command; - -public class UnmuteCommand extends Command { - public UnmuteCommand() { - super("unmute", "Unmutes the specified member."); - } - - @Override - public SlashCommandData build(SlashCommandData data) { - return data.addOption(OptionType.USER, "member", "The member to unmute.", true) - .setDefaultPermissions(DefaultMemberPermissions.enabledFor(Permission.MODERATE_MEMBERS)); - } - - @Override - public void run(SlashCommandInteractionEvent event) { - Member member = parseMember(event); - if (member == null) return; - - if (!member.isTimedOut()) { - event.reply("Member isn't already muted.").setEphemeral(true).queue(); - return; - } - - member.removeTimeout().queue(a -> { - event.reply("Removed timeout for %s.".formatted(member.getAsMention())).queue(); - }); - } -} diff --git a/src/main/java/org/meteordev/meteorbot/command/commands/silly/CapyCommand.java b/src/main/java/org/meteordev/meteorbot/command/commands/silly/CapyCommand.java deleted file mode 100644 index 980380f..0000000 --- a/src/main/java/org/meteordev/meteorbot/command/commands/silly/CapyCommand.java +++ /dev/null @@ -1,24 +0,0 @@ -package org.meteordev.meteorbot.command.commands.silly; - -import kong.unirest.Unirest; -import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; -import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData; -import org.meteordev.meteorbot.command.Command; - -public class CapyCommand extends Command { - public CapyCommand() { - super("capybara", "pulls up"); - } - - @Override - public SlashCommandData build(SlashCommandData data) { - return data; - } - - @Override - public void run(SlashCommandInteractionEvent event) { - Unirest.get("https://api.capy.lol/v1/capybara?json=true").asJsonAsync(response -> { - event.reply(response.getBody().getObject().getJSONObject("data").getString("url")).queue(); - }); - } -} diff --git a/src/main/java/org/meteordev/meteorbot/command/commands/silly/CatCommand.java b/src/main/java/org/meteordev/meteorbot/command/commands/silly/CatCommand.java deleted file mode 100644 index 892204a..0000000 --- a/src/main/java/org/meteordev/meteorbot/command/commands/silly/CatCommand.java +++ /dev/null @@ -1,24 +0,0 @@ -package org.meteordev.meteorbot.command.commands.silly; - -import kong.unirest.Unirest; -import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; -import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData; -import org.meteordev.meteorbot.command.Command; - -public class CatCommand extends Command { - public CatCommand() { - super("cat", "gato"); - } - - @Override - public SlashCommandData build(SlashCommandData data) { - return data; - } - - @Override - public void run(SlashCommandInteractionEvent event) { - Unirest.get("https://some-random-api.com/img/cat").asJsonAsync(response -> { - event.reply(response.getBody().getObject().getString("link")).queue(); - }); - } -} diff --git a/src/main/java/org/meteordev/meteorbot/command/commands/silly/DogCommand.java b/src/main/java/org/meteordev/meteorbot/command/commands/silly/DogCommand.java deleted file mode 100644 index b8349ed..0000000 --- a/src/main/java/org/meteordev/meteorbot/command/commands/silly/DogCommand.java +++ /dev/null @@ -1,24 +0,0 @@ -package org.meteordev.meteorbot.command.commands.silly; - -import kong.unirest.Unirest; -import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; -import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData; -import org.meteordev.meteorbot.command.Command; - -public class DogCommand extends Command { - public DogCommand() { - super("dog", "dawg"); - } - - @Override - public SlashCommandData build(SlashCommandData data) { - return data; - } - - @Override - public void run(SlashCommandInteractionEvent event) { - Unirest.get("https://some-random-api.com/img/dog").asJsonAsync(response -> { - event.reply(response.getBody().getObject().getString("link")).queue(); - }); - } -} diff --git a/src/main/java/org/meteordev/meteorbot/command/commands/silly/MonkeyCommand.java b/src/main/java/org/meteordev/meteorbot/command/commands/silly/MonkeyCommand.java deleted file mode 100644 index 9f5e4cd..0000000 --- a/src/main/java/org/meteordev/meteorbot/command/commands/silly/MonkeyCommand.java +++ /dev/null @@ -1,26 +0,0 @@ -package org.meteordev.meteorbot.command.commands.silly; - -import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; -import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData; -import org.meteordev.meteorbot.command.Command; - -import java.util.concurrent.ThreadLocalRandom; - -public class MonkeyCommand extends Command { - public MonkeyCommand() { - super("monkey", "Sends a swag monkey."); - } - - @Override - public SlashCommandData build(SlashCommandData data) { - return data; - } - - @Override - public void run(SlashCommandInteractionEvent event) { - int w = ThreadLocalRandom.current().nextInt(200, 1001); - int h = ThreadLocalRandom.current().nextInt(200, 1001); - - event.reply("https://www.placemonkeys.com/" + w + "/" + h + "?random").queue(); - } -} diff --git a/src/main/java/org/meteordev/meteorbot/command/commands/silly/PandaCommand.java b/src/main/java/org/meteordev/meteorbot/command/commands/silly/PandaCommand.java deleted file mode 100644 index ec9f331..0000000 --- a/src/main/java/org/meteordev/meteorbot/command/commands/silly/PandaCommand.java +++ /dev/null @@ -1,28 +0,0 @@ -package org.meteordev.meteorbot.command.commands.silly; - -import kong.unirest.Unirest; -import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; -import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData; -import org.meteordev.meteorbot.command.Command; - -import java.util.concurrent.ThreadLocalRandom; - -public class PandaCommand extends Command { - public PandaCommand() { - super("panda", "funny thing"); - } - - @Override - public SlashCommandData build(SlashCommandData data) { - return data; - } - - @Override - public void run(SlashCommandInteractionEvent event) { - String animal = ThreadLocalRandom.current().nextBoolean() ? "red_panda" : "panda"; - - Unirest.get("https://some-random-api.com/img/" + animal).asJsonAsync(response -> { - event.reply(response.getBody().getObject().getString("link")).queue(); - }); - } -} diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml deleted file mode 100644 index c34c0c3..0000000 --- a/src/main/resources/logback.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - [%d{HH:mm:ss}] %boldCyan(%-26.-26thread) [%highlight(%-6level) - %boldGreen(%-15.-15logger{0})] %msg%n - - - - - - - \ No newline at end of file