From a06aa1406f7f86fccbe7bbb39b834b018a2c7fa7 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Fri, 29 Dec 2023 16:50:34 +0100 Subject: [PATCH 001/242] Java 21, create docker image --- .github/workflows/{gradle.yml => build.yml} | 10 +- .github/workflows/publish.yml | 49 ++++ .gitignore | 7 +- Dockerfile | 11 + build.gradle.kts | 8 +- gradle/wrapper/gradle-wrapper.jar | Bin 59203 -> 43462 bytes gradle/wrapper/gradle-wrapper.properties | 4 +- gradlew | 282 ++++++++++++-------- gradlew.bat | 15 +- 9 files changed, 255 insertions(+), 131 deletions(-) rename .github/workflows/{gradle.yml => build.yml} (85%) create mode 100644 .github/workflows/publish.yml create mode 100644 Dockerfile diff --git a/.github/workflows/gradle.yml b/.github/workflows/build.yml similarity index 85% rename from .github/workflows/gradle.yml rename to .github/workflows/build.yml index 69f7ffc42..e11283fa4 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/build.yml @@ -1,19 +1,13 @@ # This workflow will build a Java project with Gradle # For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-gradle -name: Java CI with Gradle +name: Build JavaBot -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] +on: [push, pull_request] jobs: build: - runs-on: ubuntu-latest - steps: - uses: actions/checkout@v2 - name: Set up JDK 17 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 000000000..e4f1777ce --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,49 @@ +# This workflow will build a Java project with Gradle +# For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-gradle + +name: Build Docker image + +on: + push: + branches: [ main ] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set up JDK 17 + uses: actions/setup-java@v2 + with: + java-version: '17' + distribution: 'temurin' + - name: Grant execute permission for gradlew + run: chmod +x gradlew + - name: Build with Gradle + run: ./gradlew build + - name: Test with Gradle + run: ./gradlew test + - name: Build JAR + run: ./gradlew shadowJar + - name: Build Docker image + run: docker build -t javabot . + - name: Tag docker image + run: | + docker tag javabot ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_REPONAME }}:${{ github.sha }} + docker tag javabot ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_REPONAME }}:latest + env: + dockerhub_username: ${{ secrets.DOCKERHUB_USERNAME }} + if: env.dockerhub_username != null + - name: Login to DockerHub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + env: + dockerhub_username: ${{ secrets.DOCKERHUB_USERNAME }} + if: env.dockerhub_username != null + - name: Push to Docker Hub + run: docker push --all-tags ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_REPONAME }} + env: + dockerhub_username: ${{ secrets.DOCKERHUB_USERNAME }} + if: env.dockerhub_username != null \ No newline at end of file diff --git a/.gitignore b/.gitignore index 7252d6ddc..37a12370e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ build/ /config /purgeArchives /logs +/db # Eclipse settings .classpath @@ -14,6 +15,6 @@ build/ bin/ # H2 Database -/*.mv.db -/*.trace.db -/*.lock.db +*.mv.db +*.trace.db +*.lock.db diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..cb92850f9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM eclipse-temurin:21-jre +RUN mkdir /work +COPY build/libs/JavaBot-1.0.0-SNAPSHOT-all.jar /work/bot.jar +WORKDIR /work +RUN chown 1000:1000 /work +VOLUME "/work/config" +VOLUME "/work/logs" +VOLUME "/work/db" +VOLUME "/work/purgeArchives" +USER 1000 +ENTRYPOINT [ "java", "-jar", "bot.jar" ] \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 77227bf66..1f11ff97c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -53,10 +53,10 @@ dependencies { implementation("com.github.DynxstyGIT:discord-webhooks:74301a46a0") // Lombok Annotations - compileOnly("org.projectlombok:lombok:1.18.24") - annotationProcessor("org.projectlombok:lombok:1.18.24") - testCompileOnly("org.projectlombok:lombok:1.18.24") - testAnnotationProcessor("org.projectlombok:lombok:1.18.24") + compileOnly("org.projectlombok:lombok:1.18.30") + annotationProcessor("org.projectlombok:lombok:1.18.30") + testCompileOnly("org.projectlombok:lombok:1.18.30") + testAnnotationProcessor("org.projectlombok:lombok:1.18.30") // Sentry implementation("io.sentry:sentry:6.3.0") diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index e708b1c023ec8b20f512888fe07c5bd3ff77bb8f..d64cd4917707c1f8861d8cb53dd15194d4248596 100644 GIT binary patch literal 43462 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 literal 59203 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

K7UpUK$>l@ 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 E2O(~oYXATM diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index aa991fcea..1af9e0930 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 4f906e0c8..1aa94a426 100644 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ -#!/usr/bin/env sh +#!/bin/sh # -# Copyright 2015 the original author or authors. +# 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. @@ -17,67 +17,99 @@ # ############################################################################## -## -## Gradle start up script for UN*X -## +# +# 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/HEAD/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 -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +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 -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$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"' +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +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 - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +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 @@ -87,9 +119,9 @@ CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 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" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -98,88 +130,120 @@ 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. + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + 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 fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac fi -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -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" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi +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 - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + 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 - i=`expr $i + 1` + # 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 - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +# 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"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# 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 index 107acd32c..93e3f59f1 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -14,7 +14,7 @@ @rem limitations under the License. @rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +25,8 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,7 +41,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. @@ -75,13 +76,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 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 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal From 20d2306223c094b193c9a2612610f348dd92a28a Mon Sep 17 00:00:00 2001 From: danthe1st Date: Sun, 31 Dec 2023 09:17:45 +0100 Subject: [PATCH 002/242] merge workflows for build and publish --- .github/workflows/build.yml | 40 ++++++++++++++++++++++++++++ .github/workflows/publish.yml | 49 ----------------------------------- 2 files changed, 40 insertions(+), 49 deletions(-) delete mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e11283fa4..3b8fbb945 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -21,3 +21,43 @@ jobs: run: ./gradlew build - name: Test with Gradle run: ./gradlew test + + publish: + runs-on: ubuntu-latest + needs: build + env: + dockerhub_username: ${{ secrets.DOCKERHUB_USERNAME }} + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && env.super_secret != '' }} + steps: + - uses: actions/checkout@v2 + - name: Set up JDK 17 + uses: actions/setup-java@v2 + with: + java-version: '17' + distribution: 'temurin' + - name: Grant execute permission for gradlew + run: chmod +x gradlew + - name: Build with Gradle + run: ./gradlew build + - name: Build JAR + run: ./gradlew shadowJar + - name: Build Docker image + run: docker build -t javabot . + - name: Tag docker image + run: | + docker tag javabot ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_REPONAME }}:${{ github.sha }} + docker tag javabot ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_REPONAME }}:latest + env: + dockerhub_username: ${{ secrets.DOCKERHUB_USERNAME }} + if: env.dockerhub_username != null + - name: Login to DockerHub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + if: env.dockerhub_username != null + - name: Push to Docker Hub + run: docker push --all-tags ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_REPONAME }} + env: + dockerhub_username: ${{ secrets.DOCKERHUB_USERNAME }} + if: env.dockerhub_username != null \ No newline at end of file diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index e4f1777ce..000000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,49 +0,0 @@ -# This workflow will build a Java project with Gradle -# For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-gradle - -name: Build Docker image - -on: - push: - branches: [ main ] - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Set up JDK 17 - uses: actions/setup-java@v2 - with: - java-version: '17' - distribution: 'temurin' - - name: Grant execute permission for gradlew - run: chmod +x gradlew - - name: Build with Gradle - run: ./gradlew build - - name: Test with Gradle - run: ./gradlew test - - name: Build JAR - run: ./gradlew shadowJar - - name: Build Docker image - run: docker build -t javabot . - - name: Tag docker image - run: | - docker tag javabot ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_REPONAME }}:${{ github.sha }} - docker tag javabot ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_REPONAME }}:latest - env: - dockerhub_username: ${{ secrets.DOCKERHUB_USERNAME }} - if: env.dockerhub_username != null - - name: Login to DockerHub - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - env: - dockerhub_username: ${{ secrets.DOCKERHUB_USERNAME }} - if: env.dockerhub_username != null - - name: Push to Docker Hub - run: docker push --all-tags ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_REPONAME }} - env: - dockerhub_username: ${{ secrets.DOCKERHUB_USERNAME }} - if: env.dockerhub_username != null \ No newline at end of file From d4ccc7359bedba52492081d191545eb960d5b10c Mon Sep 17 00:00:00 2001 From: danthe1st Date: Sun, 31 Dec 2023 09:21:39 +0100 Subject: [PATCH 003/242] do not check secret job-level --- .github/workflows/build.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3b8fbb945..c6a25450f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -21,13 +21,10 @@ jobs: run: ./gradlew build - name: Test with Gradle run: ./gradlew test - publish: runs-on: ubuntu-latest needs: build - env: - dockerhub_username: ${{ secrets.DOCKERHUB_USERNAME }} - if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && env.super_secret != '' }} + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} steps: - uses: actions/checkout@v2 - name: Set up JDK 17 @@ -55,6 +52,8 @@ jobs: with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + env: + dockerhub_username: ${{ secrets.DOCKERHUB_USERNAME }} if: env.dockerhub_username != null - name: Push to Docker Hub run: docker push --all-tags ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_REPONAME }} From b79d642c2146e71ba036f4289d6b09e1d42fb628 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Sun, 31 Dec 2023 09:25:35 +0100 Subject: [PATCH 004/242] remove extra build step --- .github/workflows/build.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c6a25450f..2824bfa2d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -34,8 +34,6 @@ jobs: distribution: 'temurin' - name: Grant execute permission for gradlew run: chmod +x gradlew - - name: Build with Gradle - run: ./gradlew build - name: Build JAR run: ./gradlew shadowJar - name: Build Docker image @@ -59,4 +57,4 @@ jobs: run: docker push --all-tags ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_REPONAME }} env: dockerhub_username: ${{ secrets.DOCKERHUB_USERNAME }} - if: env.dockerhub_username != null \ No newline at end of file + if: env.dockerhub_username != null From d3ebadf028720d6919e451928f1db0ea3c15f647 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Sun, 31 Dec 2023 09:38:03 +0100 Subject: [PATCH 005/242] update GitHub Actions --- .github/workflows/build.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2824bfa2d..30bdda71a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,9 +9,9 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up JDK 17 - uses: actions/setup-java@v2 + uses: actions/setup-java@v4 with: java-version: '17' distribution: 'temurin' @@ -26,9 +26,9 @@ jobs: needs: build if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up JDK 17 - uses: actions/setup-java@v2 + uses: actions/setup-java@v4 with: java-version: '17' distribution: 'temurin' @@ -46,7 +46,7 @@ jobs: dockerhub_username: ${{ secrets.DOCKERHUB_USERNAME }} if: env.dockerhub_username != null - name: Login to DockerHub - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} From f60f002429e37a934bfbd1590bdd120cb8ae0ec3 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Sun, 31 Dec 2023 11:12:19 +0100 Subject: [PATCH 006/242] block job/projects posts if other recent posts exist --- .../JobChannelCloseOldPostsListener.java | 46 ++++++++++++++----- 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/listener/JobChannelCloseOldPostsListener.java b/src/main/java/net/discordjug/javabot/listener/JobChannelCloseOldPostsListener.java index 0950c2a6a..fbfa9c749 100644 --- a/src/main/java/net/discordjug/javabot/listener/JobChannelCloseOldPostsListener.java +++ b/src/main/java/net/discordjug/javabot/listener/JobChannelCloseOldPostsListener.java @@ -1,9 +1,12 @@ package net.discordjug.javabot.listener; import java.awt.Color; +import java.util.List; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import net.discordjug.javabot.data.config.BotConfig; +import net.discordjug.javabot.data.config.guild.ModerationConfig; import net.discordjug.javabot.util.InteractionUtils; import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.entities.channel.ChannelType; @@ -24,19 +27,41 @@ public void onChannelCreate(ChannelCreateEvent event) { if (event.getChannel().getType() != ChannelType.GUILD_PUBLIC_THREAD) { return; } + ThreadChannel post = event.getChannel().asThreadChannel(); - if (post.getParentChannel().getIdLong() != - botConfig.get(event.getGuild()).getModerationConfig().getJobChannelId()) { + ModerationConfig moderationConfig = botConfig.get(event.getGuild()).getModerationConfig(); + long jobChannelId = moderationConfig.getJobChannelId(); + long projectChannelId = moderationConfig.getProjectChannelId(); + long parentChannelId = post.getParentChannel().getIdLong(); + + if (parentChannelId != jobChannelId && parentChannelId != projectChannelId) { return; } - - boolean postClosed = false; - - for (ThreadChannel otherPost : post.getParentChannel().getThreadChannels()) { - if (otherPost.getOwnerIdLong() == post.getOwnerIdLong() && - otherPost.getIdLong() != post.getIdLong() && - !otherPost.isPinned()) { + List threadChannels = post.getParentChannel() + .getThreadChannels() + .stream() + .filter(c -> c.getOwnerIdLong() == post.getOwnerIdLong()) + .filter(otherPost -> otherPost.getIdLong() != post.getIdLong()) + .filter(c -> !c.isPinned()) + .collect(Collectors.toList()); + + for (ThreadChannel otherPost : threadChannels) { + if(otherPost.getTimeCreated().plusDays(7).isAfter(post.getTimeCreated())) { + post.sendMessageEmbeds( + new EmbedBuilder() + .setTitle("Post closed") + .setDescription("This post has been closed because you have created other recent posts.\nPlease do not spam posts.") + .build()) + .setContent(post.getOwner().getAsMention()) + .flatMap(msg -> post.getManager().setArchived(true).setLocked(true)) + .queue(); + return; + } + } + + if(parentChannelId == jobChannelId && !threadChannels.isEmpty()) { + for (ThreadChannel otherPost : threadChannels) { otherPost.sendMessageEmbeds( new EmbedBuilder() .setTitle("Post closed") @@ -45,10 +70,7 @@ public void onChannelCreate(ChannelCreateEvent event) { .build()) .flatMap(msg -> otherPost.getManager().setArchived(true).setLocked(true)) .queue(); - postClosed = true; } - } - if (postClosed) { post.sendMessageEmbeds( new EmbedBuilder() .setTitle("Posts closed") From f20233c5b3344549f9006af7aa0950a28187e434 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Sun, 31 Dec 2023 11:13:57 +0100 Subject: [PATCH 007/242] change wording --- .../javabot/listener/JobChannelCloseOldPostsListener.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/net/discordjug/javabot/listener/JobChannelCloseOldPostsListener.java b/src/main/java/net/discordjug/javabot/listener/JobChannelCloseOldPostsListener.java index fbfa9c749..22832902b 100644 --- a/src/main/java/net/discordjug/javabot/listener/JobChannelCloseOldPostsListener.java +++ b/src/main/java/net/discordjug/javabot/listener/JobChannelCloseOldPostsListener.java @@ -51,7 +51,7 @@ public void onChannelCreate(ChannelCreateEvent event) { post.sendMessageEmbeds( new EmbedBuilder() .setTitle("Post closed") - .setDescription("This post has been closed because you have created other recent posts.\nPlease do not spam posts.") + .setDescription("This post has been blocked because you have created other recent posts.\nPlease do not spam posts.") .build()) .setContent(post.getOwner().getAsMention()) .flatMap(msg -> post.getManager().setArchived(true).setLocked(true)) From a047d2fc196be9afa9b9439ba3955e05128c468b Mon Sep 17 00:00:00 2001 From: danthe1st Date: Sun, 31 Dec 2023 11:36:27 +0100 Subject: [PATCH 008/242] filter out audit log entries in moderation log earlier --- .../moderation/DiscordModerationLogListener.java | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/systems/moderation/DiscordModerationLogListener.java b/src/main/java/net/discordjug/javabot/systems/moderation/DiscordModerationLogListener.java index 6dc9d3dda..77f1e7aa2 100644 --- a/src/main/java/net/discordjug/javabot/systems/moderation/DiscordModerationLogListener.java +++ b/src/main/java/net/discordjug/javabot/systems/moderation/DiscordModerationLogListener.java @@ -2,12 +2,15 @@ import java.time.Duration; import java.time.ZonedDateTime; +import java.util.EnumSet; import java.util.concurrent.ExecutorService; import lombok.RequiredArgsConstructor; import net.discordjug.javabot.data.config.BotConfig; import net.discordjug.javabot.systems.moderation.warn.dao.WarnRepository; import net.discordjug.javabot.systems.notification.NotificationService; +import net.discordjug.javabot.util.ExceptionLogger; +import net.dv8tion.jda.api.audit.ActionType; import net.dv8tion.jda.api.audit.AuditLogChange; import net.dv8tion.jda.api.audit.AuditLogEntry; import net.dv8tion.jda.api.events.guild.GuildAuditLogEntryCreateEvent; @@ -26,7 +29,6 @@ public class DiscordModerationLogListener extends ListenerAdapter{ @Override public void onGuildAuditLogEntryCreate(GuildAuditLogEntryCreateEvent event) { - ModerationService moderationService = new ModerationService(notificationService, botConfig.get(event.getGuild()), warnRepository, asyncPool); AuditLogEntry entry = event.getEntry(); @@ -35,6 +37,9 @@ public void onGuildAuditLogEntryCreate(GuildAuditLogEntryCreateEvent event) { if (moderatorUserId == event.getJDA().getSelfUser().getIdLong()) { return; } + if (!EnumSet.of(ActionType.KICK, ActionType.BAN, ActionType.UNBAN, ActionType.MEMBER_UPDATE).contains(entry.getType())) { + return; + } event.getJDA().retrieveUserById(targetUserId).queue(targetUser -> { event.getGuild().retrieveMemberById(moderatorUserId).queue(moderator -> { String reason = entry.getReason(); @@ -56,11 +61,9 @@ public void onGuildAuditLogEntryCreate(GuildAuditLogEntryCreateEvent event) { } } } - default -> {} + default -> ExceptionLogger.capture(new IllegalStateException("Unexpected audit log entry: "+entry.getType()), getClass().getName()); } - }); - }); - + }, e -> ExceptionLogger.capture(e, getClass().getName())); + }, e -> ExceptionLogger.capture(e, getClass().getName())); } - } From e1ab890c6413902d484ed68374cf5aa64a882b09 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Sun, 31 Dec 2023 16:41:01 +0100 Subject: [PATCH 009/242] use moderator in resolved-by ping of reports when action was taken --- .../java/net/discordjug/javabot/util/InteractionUtils.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/util/InteractionUtils.java b/src/main/java/net/discordjug/javabot/util/InteractionUtils.java index dd2060b2c..65f579d48 100644 --- a/src/main/java/net/discordjug/javabot/util/InteractionUtils.java +++ b/src/main/java/net/discordjug/javabot/util/InteractionUtils.java @@ -130,7 +130,7 @@ private void kick(ModalInteraction interaction, @NotNull Guild guild, String mem user -> { service.kick(user, reason, interaction.getMember(), interaction.getMessageChannel(), false); interaction.getMessage().editMessageComponents(ActionRow.of(Button.danger(interaction.getModalId(), "Kicked by " + UserUtils.getUserTag(interaction.getUser())).asDisabled())).queue(); - resolveIfInReport(interaction.getChannel(), user); + resolveIfInReport(interaction.getChannel(), interaction.getUser()); }, error -> Responses.error(interaction.getHook(), "Could not find member: " + error.getMessage()).queue() ); } @@ -145,7 +145,7 @@ private void warn(ModalInteraction interaction, @NotNull Guild guild, String mem user -> { service.warn(user, severity, reason, interaction.getMember(), interaction.getMessageChannel(), false); interaction.getHook().editOriginalComponents(ActionRow.of(Button.primary(interaction.getModalId(), "Warned by " + UserUtils.getUserTag(interaction.getUser())).asDisabled())).queue(); - resolveIfInReport(interaction.getChannel(), user); + resolveIfInReport(interaction.getChannel(), interaction.getUser()); }, error -> Responses.error(interaction.getHook(), "Could not find member: " + error.getMessage()).queue() ); } @@ -160,7 +160,7 @@ private void ban(ModalInteraction interaction, @NotNull Guild guild, String memb user -> { service.ban(user, reason, interaction.getMember(), interaction.getMessageChannel(), false); interaction.getMessage().editMessageComponents(ActionRow.of(Button.danger(interaction.getModalId(), "Banned by " + UserUtils.getUserTag(interaction.getUser())).asDisabled())).queue(); - resolveIfInReport(interaction.getChannel(), user); + resolveIfInReport(interaction.getChannel(), interaction.getUser()); }, error -> Responses.error(interaction.getHook(), "Could not find member: " + error.getMessage()).queue() ); } From 802c571f6f755449c738e7aa27a549c5412c2897 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Mon, 1 Jan 2024 14:03:12 +0100 Subject: [PATCH 010/242] fix pinning of QOTW --- .../qotw/jobs/QOTWCloseSubmissionsJob.java | 38 ++++++++++++++++--- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/systems/qotw/jobs/QOTWCloseSubmissionsJob.java b/src/main/java/net/discordjug/javabot/systems/qotw/jobs/QOTWCloseSubmissionsJob.java index 80fc0842e..c4c4e00a0 100644 --- a/src/main/java/net/discordjug/javabot/systems/qotw/jobs/QOTWCloseSubmissionsJob.java +++ b/src/main/java/net/discordjug/javabot/systems/qotw/jobs/QOTWCloseSubmissionsJob.java @@ -27,6 +27,8 @@ import net.dv8tion.jda.api.interactions.components.buttons.Button; import net.dv8tion.jda.api.interactions.components.selections.SelectOption; import net.dv8tion.jda.api.interactions.components.selections.StringSelectMenu; +import net.dv8tion.jda.api.managers.channel.concrete.ThreadChannelManager; +import net.dv8tion.jda.api.requests.RestAction; import net.dv8tion.jda.api.requests.restaction.ForumPostAction; import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder; import net.dv8tion.jda.api.utils.messages.MessageCreateData; @@ -36,6 +38,7 @@ import org.springframework.stereotype.Service; import xyz.dynxsty.dih4jda.util.ComponentIdBuilder; +import javax.annotation.CheckReturnValue; import javax.annotation.Nonnull; import java.sql.SQLException; @@ -113,18 +116,41 @@ public void execute() throws SQLException { QOTWQuestion question = questionOptional.get(); try (MessageCreateData data = new MessageCreateBuilder() .setEmbeds(buildQuestionEmbed(question)).build()) { - ForumPostAction action = qotwConfig.getSubmissionsForumChannel() - .createForumPost(String.format("Week %s — %s", question.getQuestionNumber(), question.getText().replace("*", "")), data); - if (qotwConfig.getSubmissionsForumOngoingReviewTag() != null) { - action.setTags(qotwConfig.getSubmissionsForumOngoingReviewTag()); - } - action.queue(f -> f.getThreadChannel().getManager().setPinned(true).queue()); + createPinnedAnswerForum(qotwConfig, question, data); } } }); } } + private void createPinnedAnswerForum(QOTWConfig qotwConfig, QOTWQuestion question, MessageCreateData data) { + ForumPostAction createAnswerForumAction = qotwConfig.getSubmissionsForumChannel() + .createForumPost(String.format("Week %s — %s", question.getQuestionNumber(), question.getText().replace("*", "")), data); + if (qotwConfig.getSubmissionsForumOngoingReviewTag() != null) { + createAnswerForumAction.setTags(qotwConfig.getSubmissionsForumOngoingReviewTag()); + } + + createAnswerForumAction.flatMap(f -> changeForumPin(f.getThreadChannel())).queue(); + } + + /** + * Removes the current pin in a forum channel and pins a new post. + * @param toPin the forum channel to pin + * @return a {@link RestAction} performing the pin change + */ + @CheckReturnValue + private RestAction changeForumPin(ThreadChannel toPin) { + ThreadChannelManager setPinned = toPin.getManager().setPinned(true); + return toPin.getParentChannel() + .getThreadChannels() + .stream() + .filter(ThreadChannel::isPinned) + .findAny() + .map(c->c.getManager().setPinned(false)) + .map(a -> a.flatMap(result -> setPinned)) + .orElse(setPinned); + } + private @Nonnull StringSelectMenu buildSubmissionSelectMenu(JDA jda, long threadId) { final SystemsConfig.EmojiConfig emojiConfig = botConfig.getSystems().getEmojiConfig(); return StringSelectMenu.create(ComponentIdBuilder.build("qotw-submission-select", "review", threadId)) From b11679236d74f4d5b01b03fdf4e3f5de1ddfbf7d Mon Sep 17 00:00:00 2001 From: danthe1st Date: Wed, 3 Jan 2024 14:17:37 +0100 Subject: [PATCH 011/242] automatically clear old help notifications --- .../help/ClearOldHelpNotificationJob.java | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 src/main/java/net/discordjug/javabot/systems/help/ClearOldHelpNotificationJob.java diff --git a/src/main/java/net/discordjug/javabot/systems/help/ClearOldHelpNotificationJob.java b/src/main/java/net/discordjug/javabot/systems/help/ClearOldHelpNotificationJob.java new file mode 100644 index 000000000..0e18298bb --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/help/ClearOldHelpNotificationJob.java @@ -0,0 +1,73 @@ +package net.discordjug.javabot.systems.help; + +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.List; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import net.discordjug.javabot.data.config.BotConfig; +import net.discordjug.javabot.util.ExceptionLogger; +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.MessageHistory; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; + +/** + * Automatically delete old help notifications. + * Once a day, this class deletes old messages of the bot in the help notification channel. + */ +@Service +@RequiredArgsConstructor +public class ClearOldHelpNotificationJob { + private final BotConfig botConfig; + private final JDA jda; + + /** + * Runs the message deletion. + */ + @Scheduled(cron="0 0 0 * * *")//00:00 UTC + public void execute() { + for (Guild guild : jda.getGuilds()) { + TextChannel helpNotificationChannel = botConfig.get(guild).getHelpConfig().getHelpNotificationChannel(); + if(helpNotificationChannel != null) { + MessageHistory history = helpNotificationChannel.getHistory(); + deleteOldMessagesInChannel(helpNotificationChannel, history, new ArrayList<>()); + } + } + } + + private void deleteOldMessagesInChannel(TextChannel helpNotificationChannel, MessageHistory history, List foundSoFar) { + history.retrievePast(50).queue(msgs -> { + boolean deleteMore = addMessagesToDelete(foundSoFar, msgs); + if (deleteMore) { + deleteOldMessagesInChannel(helpNotificationChannel, history, foundSoFar); + }else { + helpNotificationChannel.purgeMessages(foundSoFar); + } + }, e -> { + ExceptionLogger.capture(e, getClass().getName()); + helpNotificationChannel.purgeMessages(foundSoFar); + }); + } + + private boolean addMessagesToDelete(List toDelete, List msgs) { + for (Message message : msgs) { + if (message.getAuthor().getIdLong() == message.getJDA().getSelfUser().getIdLong() && + //only delete messages older than 5 days + message.getTimeCreated().isBefore(OffsetDateTime.now().minusDays(5))) { + + //only messages sent within the past two weeks can be deleted + //stop when messages near that are found + if (message.getTimeCreated().isBefore(OffsetDateTime.now().minusDays(13))) { + return false; + } + toDelete.add(message); + } + } + return true; + } +} From bef8fc4487fd8fe0c59d6af2d263f6cee46d4ac7 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Wed, 3 Jan 2024 14:44:23 +0100 Subject: [PATCH 012/242] send warning when deleting many messages --- .../help/ClearOldHelpNotificationJob.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/main/java/net/discordjug/javabot/systems/help/ClearOldHelpNotificationJob.java b/src/main/java/net/discordjug/javabot/systems/help/ClearOldHelpNotificationJob.java index 0e18298bb..c17b0ba4b 100644 --- a/src/main/java/net/discordjug/javabot/systems/help/ClearOldHelpNotificationJob.java +++ b/src/main/java/net/discordjug/javabot/systems/help/ClearOldHelpNotificationJob.java @@ -1,8 +1,10 @@ package net.discordjug.javabot.systems.help; +import java.nio.charset.StandardCharsets; import java.time.OffsetDateTime; import java.util.ArrayList; import java.util.List; +import java.util.stream.Collectors; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; @@ -15,6 +17,7 @@ import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.MessageHistory; import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import net.dv8tion.jda.api.utils.FileUpload; /** * Automatically delete old help notifications. @@ -46,6 +49,16 @@ private void deleteOldMessagesInChannel(TextChannel helpNotificationChannel, Mes if (deleteMore) { deleteOldMessagesInChannel(helpNotificationChannel, history, foundSoFar); }else { + if (foundSoFar.size() > 50) { + String messageInfo = foundSoFar + .stream() + .map(msg -> convertMessageToString(msg)) + .collect(Collectors.joining("\n\n===============\n\n")); + botConfig.get(helpNotificationChannel.getGuild()).getModerationConfig().getLogChannel() + .sendMessageFormat("Warning: deleting %d messages in help notification channel, this might be due to help notification spam", foundSoFar.size()) + .addFiles(FileUpload.fromData(messageInfo.getBytes(StandardCharsets.UTF_8), "messages.txt")) + .queue(); + } helpNotificationChannel.purgeMessages(foundSoFar); } }, e -> { @@ -54,6 +67,11 @@ private void deleteOldMessagesInChannel(TextChannel helpNotificationChannel, Mes }); } + private String convertMessageToString(Message msg) { + return msg.getContentRaw()+"\n"+ + msg.getEmbeds().stream().map(e->e.toData().toString()).collect(Collectors.joining("\n")); + } + private boolean addMessagesToDelete(List toDelete, List msgs) { for (Message message : msgs) { if (message.getAuthor().getIdLong() == message.getJDA().getSelfUser().getIdLong() && From f7492f15d73c3d9f487f6aba3a7388e71d1dbdbb Mon Sep 17 00:00:00 2001 From: danthe1st Date: Wed, 3 Jan 2024 15:40:50 +0100 Subject: [PATCH 013/242] don't stop deleting messages --- .../help/ClearOldHelpNotificationJob.java | 27 ++++++------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/systems/help/ClearOldHelpNotificationJob.java b/src/main/java/net/discordjug/javabot/systems/help/ClearOldHelpNotificationJob.java index c17b0ba4b..2cca9e90c 100644 --- a/src/main/java/net/discordjug/javabot/systems/help/ClearOldHelpNotificationJob.java +++ b/src/main/java/net/discordjug/javabot/systems/help/ClearOldHelpNotificationJob.java @@ -45,8 +45,14 @@ public void execute() { private void deleteOldMessagesInChannel(TextChannel helpNotificationChannel, MessageHistory history, List foundSoFar) { history.retrievePast(50).queue(msgs -> { - boolean deleteMore = addMessagesToDelete(foundSoFar, msgs); - if (deleteMore) { + foundSoFar.addAll( + msgs + .stream() + .filter(msg -> msg.getAuthor().getIdLong() == msg.getJDA().getSelfUser().getIdLong()) + .filter(msg -> msg.getTimeCreated().isBefore(OffsetDateTime.now().minusDays(5))) + .toList() + ); + if (!msgs.isEmpty()) { deleteOldMessagesInChannel(helpNotificationChannel, history, foundSoFar); }else { if (foundSoFar.size() > 50) { @@ -71,21 +77,4 @@ private String convertMessageToString(Message msg) { return msg.getContentRaw()+"\n"+ msg.getEmbeds().stream().map(e->e.toData().toString()).collect(Collectors.joining("\n")); } - - private boolean addMessagesToDelete(List toDelete, List msgs) { - for (Message message : msgs) { - if (message.getAuthor().getIdLong() == message.getJDA().getSelfUser().getIdLong() && - //only delete messages older than 5 days - message.getTimeCreated().isBefore(OffsetDateTime.now().minusDays(5))) { - - //only messages sent within the past two weeks can be deleted - //stop when messages near that are found - if (message.getTimeCreated().isBefore(OffsetDateTime.now().minusDays(13))) { - return false; - } - toDelete.add(message); - } - } - return true; - } } From 06175ba4feb8c625b38ecf0f667812ce516d98bc Mon Sep 17 00:00:00 2001 From: danthe1st Date: Wed, 3 Jan 2024 15:47:10 +0100 Subject: [PATCH 014/242] improve formatting --- .../javabot/systems/help/ClearOldHelpNotificationJob.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/net/discordjug/javabot/systems/help/ClearOldHelpNotificationJob.java b/src/main/java/net/discordjug/javabot/systems/help/ClearOldHelpNotificationJob.java index 2cca9e90c..73d198668 100644 --- a/src/main/java/net/discordjug/javabot/systems/help/ClearOldHelpNotificationJob.java +++ b/src/main/java/net/discordjug/javabot/systems/help/ClearOldHelpNotificationJob.java @@ -46,7 +46,7 @@ public void execute() { private void deleteOldMessagesInChannel(TextChannel helpNotificationChannel, MessageHistory history, List foundSoFar) { history.retrievePast(50).queue(msgs -> { foundSoFar.addAll( - msgs + msgs .stream() .filter(msg -> msg.getAuthor().getIdLong() == msg.getJDA().getSelfUser().getIdLong()) .filter(msg -> msg.getTimeCreated().isBefore(OffsetDateTime.now().minusDays(5))) From 0016fd1160144e1cdab76cb6a3edb705544e04c9 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Wed, 3 Jan 2024 18:49:15 +0100 Subject: [PATCH 015/242] add command for running scheduled tasks --- .../RunScheduledTaskCommand.java | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 src/main/java/net/discordjug/javabot/systems/staff_commands/RunScheduledTaskCommand.java diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/RunScheduledTaskCommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/RunScheduledTaskCommand.java new file mode 100644 index 000000000..f80b712ec --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/RunScheduledTaskCommand.java @@ -0,0 +1,81 @@ +package net.discordjug.javabot.systems.staff_commands; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.scheduling.config.ScheduledTaskHolder; + +import net.discordjug.javabot.data.config.BotConfig; +import net.discordjug.javabot.util.Responses; +import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.AutoCompleteQuery; +import net.dv8tion.jda.api.interactions.commands.Command; +import net.dv8tion.jda.api.interactions.commands.Command.Choice; +import net.dv8tion.jda.api.interactions.commands.DefaultMemberPermissions; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.Commands; +import xyz.dynxsty.dih4jda.interactions.AutoCompletable; +import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand; +import xyz.dynxsty.dih4jda.util.AutoCompleteUtils; + +/** + * This command allows manually executing scheduled tasks. + */ +public class RunScheduledTaskCommand extends SlashCommand implements AutoCompletable{ + + private ScheduledTaskHolder taskHolder; + + /** + * The constructor of this class, which sets the corresponding {@link SubcommandData}. + * @param botConfig the configuration of the bot + * @param taskHolder A Spring object managing scheduled tasks + */ + public RunScheduledTaskCommand(BotConfig botConfig, ScheduledTaskHolder taskHolder) { + setCommandData(Commands.slash("run-task", "(ADMIN ONLY) Run scheduled tasks") + .setDefaultPermissions(DefaultMemberPermissions.enabledFor(Permission.ADMINISTRATOR)) + .setGuildOnly(true) + .addOption(OptionType.STRING, "name", "Class name of the task", true, true) + ); + setRequiredUsers(botConfig.getSystems().getAdminConfig().getAdminUsers()); + + this.taskHolder = taskHolder; + + + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + String name = event.getOption("name").getAsString(); + taskHolder + .getScheduledTasks() + .stream() + .filter(r -> name.equals(r.toString())) + .findAny() + .ifPresentOrElse(r -> { + event.deferReply(true).queue(); + try { + r.getTask().getRunnable().run(); + Responses.success(event.getHook(), "Task successful", "Task was executed successfully").queue(); + //CHECKSTYLE:OFF This is a handler for all sort of failures that could possibly happen + }catch (RuntimeException e) { + //CHECKSTYLE:ON + Responses.error(event, "Task failed with an exception", e.getClass().getName() + (e.getMessage() == null ? "" : ": "+e.getMessage())); + } + }, () -> { + Responses.error(event, "Cannot find task `%s`", name).queue(); + }); + } + + @Override + public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { + List choices = taskHolder + .getScheduledTasks() + .stream() + .map(r -> new Command.Choice(r.toString(), r.toString())) + .collect(Collectors.toCollection(ArrayList::new)); + event.replyChoices(AutoCompleteUtils.filterChoices(event, choices)).queue(); + } +} From e6f2c43c0a44926bab998eb9c7c391b0cf1d5182 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Thu, 4 Jan 2024 12:22:08 +0100 Subject: [PATCH 016/242] delete posts older than 3 days --- .../javabot/systems/help/ClearOldHelpNotificationJob.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/net/discordjug/javabot/systems/help/ClearOldHelpNotificationJob.java b/src/main/java/net/discordjug/javabot/systems/help/ClearOldHelpNotificationJob.java index 73d198668..4e71df634 100644 --- a/src/main/java/net/discordjug/javabot/systems/help/ClearOldHelpNotificationJob.java +++ b/src/main/java/net/discordjug/javabot/systems/help/ClearOldHelpNotificationJob.java @@ -49,7 +49,7 @@ private void deleteOldMessagesInChannel(TextChannel helpNotificationChannel, Mes msgs .stream() .filter(msg -> msg.getAuthor().getIdLong() == msg.getJDA().getSelfUser().getIdLong()) - .filter(msg -> msg.getTimeCreated().isBefore(OffsetDateTime.now().minusDays(5))) + .filter(msg -> msg.getTimeCreated().isBefore(OffsetDateTime.now().minusDays(3))) .toList() ); if (!msgs.isEmpty()) { From 5791bfeba3abaf80d4cec352b796a561d12321f9 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Fri, 5 Jan 2024 12:16:05 +0100 Subject: [PATCH 017/242] only acknowledged help notifications --- .../javabot/systems/help/ClearOldHelpNotificationJob.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/net/discordjug/javabot/systems/help/ClearOldHelpNotificationJob.java b/src/main/java/net/discordjug/javabot/systems/help/ClearOldHelpNotificationJob.java index 4e71df634..ec1fa6c85 100644 --- a/src/main/java/net/discordjug/javabot/systems/help/ClearOldHelpNotificationJob.java +++ b/src/main/java/net/discordjug/javabot/systems/help/ClearOldHelpNotificationJob.java @@ -50,6 +50,10 @@ private void deleteOldMessagesInChannel(TextChannel helpNotificationChannel, Mes .stream() .filter(msg -> msg.getAuthor().getIdLong() == msg.getJDA().getSelfUser().getIdLong()) .filter(msg -> msg.getTimeCreated().isBefore(OffsetDateTime.now().minusDays(3))) + .filter(msg -> msg + .getButtons() + .stream() + .anyMatch(button -> "Mark as unacknowledged".equals(button.getLabel()))) .toList() ); if (!msgs.isEmpty()) { From 8e025ff003c06c5edb599c39c0b5771fa1b07df9 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Fri, 5 Jan 2024 12:18:47 +0100 Subject: [PATCH 018/242] move help ping logic to its own package --- .../discordjug/javabot/systems/help/commands/HelpCommand.java | 1 + .../help/{ => commands/notify}/ClearOldHelpNotificationJob.java | 2 +- .../systems/help/commands/{ => notify}/HelpPingSubcommand.java | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) rename src/main/java/net/discordjug/javabot/systems/help/{ => commands/notify}/ClearOldHelpNotificationJob.java (98%) rename src/main/java/net/discordjug/javabot/systems/help/commands/{ => notify}/HelpPingSubcommand.java (99%) diff --git a/src/main/java/net/discordjug/javabot/systems/help/commands/HelpCommand.java b/src/main/java/net/discordjug/javabot/systems/help/commands/HelpCommand.java index 069026bb6..86c9e6304 100644 --- a/src/main/java/net/discordjug/javabot/systems/help/commands/HelpCommand.java +++ b/src/main/java/net/discordjug/javabot/systems/help/commands/HelpCommand.java @@ -1,5 +1,6 @@ package net.discordjug.javabot.systems.help.commands; +import net.discordjug.javabot.systems.help.commands.notify.HelpPingSubcommand; import net.dv8tion.jda.api.interactions.commands.build.Commands; import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand; diff --git a/src/main/java/net/discordjug/javabot/systems/help/ClearOldHelpNotificationJob.java b/src/main/java/net/discordjug/javabot/systems/help/commands/notify/ClearOldHelpNotificationJob.java similarity index 98% rename from src/main/java/net/discordjug/javabot/systems/help/ClearOldHelpNotificationJob.java rename to src/main/java/net/discordjug/javabot/systems/help/commands/notify/ClearOldHelpNotificationJob.java index ec1fa6c85..7d94c3f5d 100644 --- a/src/main/java/net/discordjug/javabot/systems/help/ClearOldHelpNotificationJob.java +++ b/src/main/java/net/discordjug/javabot/systems/help/commands/notify/ClearOldHelpNotificationJob.java @@ -1,4 +1,4 @@ -package net.discordjug.javabot.systems.help; +package net.discordjug.javabot.systems.help.commands.notify; import java.nio.charset.StandardCharsets; import java.time.OffsetDateTime; diff --git a/src/main/java/net/discordjug/javabot/systems/help/commands/HelpPingSubcommand.java b/src/main/java/net/discordjug/javabot/systems/help/commands/notify/HelpPingSubcommand.java similarity index 99% rename from src/main/java/net/discordjug/javabot/systems/help/commands/HelpPingSubcommand.java rename to src/main/java/net/discordjug/javabot/systems/help/commands/notify/HelpPingSubcommand.java index 8e767504c..e9b8bf133 100644 --- a/src/main/java/net/discordjug/javabot/systems/help/commands/HelpPingSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/help/commands/notify/HelpPingSubcommand.java @@ -1,4 +1,4 @@ -package net.discordjug.javabot.systems.help.commands; +package net.discordjug.javabot.systems.help.commands.notify; import net.discordjug.javabot.annotations.AutoDetectableComponentHandler; import net.discordjug.javabot.data.config.BotConfig; From 6d6abbc6b82558ea254c90aa84c84dc4a48b8312 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Fri, 5 Jan 2024 12:22:56 +0100 Subject: [PATCH 019/242] delete messages quicker --- .../notify/ClearOldHelpNotificationJob.java | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/systems/help/commands/notify/ClearOldHelpNotificationJob.java b/src/main/java/net/discordjug/javabot/systems/help/commands/notify/ClearOldHelpNotificationJob.java index 7d94c3f5d..3eeb9b0df 100644 --- a/src/main/java/net/discordjug/javabot/systems/help/commands/notify/ClearOldHelpNotificationJob.java +++ b/src/main/java/net/discordjug/javabot/systems/help/commands/notify/ClearOldHelpNotificationJob.java @@ -45,17 +45,17 @@ public void execute() { private void deleteOldMessagesInChannel(TextChannel helpNotificationChannel, MessageHistory history, List foundSoFar) { history.retrievePast(50).queue(msgs -> { - foundSoFar.addAll( - msgs + List toDelete = msgs + .stream() + .filter(msg -> msg.getAuthor().getIdLong() == msg.getJDA().getSelfUser().getIdLong()) + .filter(msg -> msg.getTimeCreated().isBefore(OffsetDateTime.now().minusDays(3))) + .filter(msg -> msg + .getButtons() .stream() - .filter(msg -> msg.getAuthor().getIdLong() == msg.getJDA().getSelfUser().getIdLong()) - .filter(msg -> msg.getTimeCreated().isBefore(OffsetDateTime.now().minusDays(3))) - .filter(msg -> msg - .getButtons() - .stream() - .anyMatch(button -> "Mark as unacknowledged".equals(button.getLabel()))) - .toList() - ); + .anyMatch(button -> "Mark as unacknowledged".equals(button.getLabel()))) + .toList(); + helpNotificationChannel.purgeMessages(toDelete); + foundSoFar.addAll(toDelete); if (!msgs.isEmpty()) { deleteOldMessagesInChannel(helpNotificationChannel, history, foundSoFar); }else { @@ -69,7 +69,6 @@ private void deleteOldMessagesInChannel(TextChannel helpNotificationChannel, Mes .addFiles(FileUpload.fromData(messageInfo.getBytes(StandardCharsets.UTF_8), "messages.txt")) .queue(); } - helpNotificationChannel.purgeMessages(foundSoFar); } }, e -> { ExceptionLogger.capture(e, getClass().getName()); From c6ad144205463642e52b1b0aa4fb5c79255d0e0c Mon Sep 17 00:00:00 2001 From: danthe1st Date: Sun, 7 Jan 2024 16:34:17 +0100 Subject: [PATCH 020/242] use java codeblock when autoformatting code in help channels --- .../discordjug/javabot/systems/help/AutoCodeFormatter.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/systems/help/AutoCodeFormatter.java b/src/main/java/net/discordjug/javabot/systems/help/AutoCodeFormatter.java index 28f83d1fc..78f0720e9 100644 --- a/src/main/java/net/discordjug/javabot/systems/help/AutoCodeFormatter.java +++ b/src/main/java/net/discordjug/javabot/systems/help/AutoCodeFormatter.java @@ -131,11 +131,11 @@ private void sendFormatHint(MessageReceivedEvent event) { private void replaceUnformattedCode(String msg, int codeStartIndex, int codeEndIndex, MessageReceivedEvent event) { // default case: a "normal", non-ping containing, non first message of a forum-thread containing "{" and "}". // user must also have set their preferences to allow this. - if (msg.length() > 1992) { // can't exceed discord's char limit + if (msg.length() > 1988) { // can't exceed discord's char limit sendFormatHint(event); return; } - String messageContent = msg.substring(0, codeStartIndex) + " ```" + + String messageContent = msg.substring(0, codeStartIndex) + " ```java" + msg.substring(codeStartIndex, codeEndIndex) + " ```" + msg.substring(codeEndIndex); EmbedBuilder autoformatInfo = new EmbedBuilder().setDescription(botConfig.get(event.getGuild()) .getHelpConfig() From c40b82fec83920afdf103f692f5b851b64047aac Mon Sep 17 00:00:00 2001 From: danthe1st Date: Mon, 8 Jan 2024 14:04:57 +0100 Subject: [PATCH 021/242] add null check --- .../systems/staff_commands/RunScheduledTaskCommand.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/RunScheduledTaskCommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/RunScheduledTaskCommand.java index f80b712ec..5eee719f4 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/RunScheduledTaskCommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/RunScheduledTaskCommand.java @@ -15,6 +15,7 @@ import net.dv8tion.jda.api.interactions.commands.Command; import net.dv8tion.jda.api.interactions.commands.Command.Choice; 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.Commands; import xyz.dynxsty.dih4jda.interactions.AutoCompletable; @@ -48,7 +49,11 @@ public RunScheduledTaskCommand(BotConfig botConfig, ScheduledTaskHolder taskHold @Override public void execute(SlashCommandInteractionEvent event) { - String name = event.getOption("name").getAsString(); + String name = event.getOption("name", "", OptionMapping::getAsString); + if (name.isEmpty()) { + Responses.replyMissingArguments(event).queue(); + return; + } taskHolder .getScheduledTasks() .stream() From a9006c3fb82744959a36d53a849feb9a211a5854 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Mon, 8 Jan 2024 14:07:50 +0100 Subject: [PATCH 022/242] use JDA constant for message length --- .../net/discordjug/javabot/systems/help/AutoCodeFormatter.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/net/discordjug/javabot/systems/help/AutoCodeFormatter.java b/src/main/java/net/discordjug/javabot/systems/help/AutoCodeFormatter.java index 78f0720e9..f9776e6bc 100644 --- a/src/main/java/net/discordjug/javabot/systems/help/AutoCodeFormatter.java +++ b/src/main/java/net/discordjug/javabot/systems/help/AutoCodeFormatter.java @@ -10,6 +10,7 @@ import net.discordjug.javabot.util.WebhookUtil; import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.MessageEmbed; import net.dv8tion.jda.api.events.message.MessageReceivedEvent; @@ -131,7 +132,7 @@ private void sendFormatHint(MessageReceivedEvent event) { private void replaceUnformattedCode(String msg, int codeStartIndex, int codeEndIndex, MessageReceivedEvent event) { // default case: a "normal", non-ping containing, non first message of a forum-thread containing "{" and "}". // user must also have set their preferences to allow this. - if (msg.length() > 1988) { // can't exceed discord's char limit + if (msg.length() > Message.MAX_CONTENT_LENGTH - 12) { // can't exceed discord's char limit sendFormatHint(event); return; } From ed1c7bb65d498783edda3d2938f446a7dab57054 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Mon, 8 Jan 2024 14:09:43 +0100 Subject: [PATCH 023/242] create constants for codeblock prefix and suffix --- .../javabot/systems/help/AutoCodeFormatter.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/systems/help/AutoCodeFormatter.java b/src/main/java/net/discordjug/javabot/systems/help/AutoCodeFormatter.java index f9776e6bc..fd717693a 100644 --- a/src/main/java/net/discordjug/javabot/systems/help/AutoCodeFormatter.java +++ b/src/main/java/net/discordjug/javabot/systems/help/AutoCodeFormatter.java @@ -28,6 +28,8 @@ @RequiredArgsConstructor @Component public class AutoCodeFormatter { + private static final String CODEBLOCK_PREFIX = " ```java"; + private static final String CODEBLOCK_SUFFIX = " ```"; private final AutoMod autoMod; private final BotConfig botConfig; private final UserPreferenceService preferenceService; @@ -129,15 +131,16 @@ private void sendFormatHint(MessageReceivedEvent event) { ).queue(); } + private void replaceUnformattedCode(String msg, int codeStartIndex, int codeEndIndex, MessageReceivedEvent event) { // default case: a "normal", non-ping containing, non first message of a forum-thread containing "{" and "}". // user must also have set their preferences to allow this. - if (msg.length() > Message.MAX_CONTENT_LENGTH - 12) { // can't exceed discord's char limit + if (msg.length() > Message.MAX_CONTENT_LENGTH - CODEBLOCK_PREFIX.length() - CODEBLOCK_SUFFIX.length()) { // can't exceed discord's char limit sendFormatHint(event); return; } - String messageContent = msg.substring(0, codeStartIndex) + " ```java" + - msg.substring(codeStartIndex, codeEndIndex) + " ```" + msg.substring(codeEndIndex); + String messageContent = msg.substring(0, codeStartIndex) + CODEBLOCK_PREFIX + + msg.substring(codeStartIndex, codeEndIndex) + CODEBLOCK_SUFFIX + msg.substring(codeEndIndex); EmbedBuilder autoformatInfo = new EmbedBuilder().setDescription(botConfig.get(event.getGuild()) .getHelpConfig() .getAutoFormatInfoMessage()); From 2eb19712e0da4055d73e890cef6045533242a194 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Tue, 9 Jan 2024 18:13:47 +0100 Subject: [PATCH 024/242] restrict closing of posts by non-original posters --- .../javabot/systems/help/HelpListener.java | 11 ++++------- .../javabot/systems/help/HelpManager.java | 14 ++++++++++++++ .../systems/help/commands/UnreserveCommand.java | 7 ++++++- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/systems/help/HelpListener.java b/src/main/java/net/discordjug/javabot/systems/help/HelpListener.java index d59aa949c..bef125a9c 100644 --- a/src/main/java/net/discordjug/javabot/systems/help/HelpListener.java +++ b/src/main/java/net/discordjug/javabot/systems/help/HelpListener.java @@ -245,7 +245,6 @@ private boolean isInvalidHelpForumChannel(@NotNull ForumChannel forum) { private void handleHelpThanksInteraction(@NotNull ButtonInteractionEvent event, @NotNull HelpManager manager, String @NotNull [] id) { ThreadChannel post = manager.getPostThread(); - HelpConfig config = botConfig.get(event.getGuild()).getHelpConfig(); if (event.getUser().getIdLong() != post.getOwnerIdLong()) { Responses.warning( event, @@ -301,16 +300,14 @@ private void handleReplyGuidelines(@NotNull IReplyCallback callback, @NotNull Fo } private void handlePostClose(ButtonInteractionEvent event, @NotNull HelpManager manager) { - if (manager.isForumEligibleToBeUnreserved(event)) { - manager.close(event, event.getUser().getIdLong() == manager.getPostThread() - .getOwnerIdLong(), null); - } else { + if (event.getUser().getIdLong() != manager.getPostThread().getOwnerIdLong()) { Responses.warning( event, - "Could not close this post", - "You're not allowed to close this post." + "Sorry, but only the original poster can close this post using these buttons." ) .queue(); + return; } + manager.close(event, true, null); } } \ No newline at end of file diff --git a/src/main/java/net/discordjug/javabot/systems/help/HelpManager.java b/src/main/java/net/discordjug/javabot/systems/help/HelpManager.java index ca21a5abe..217f4406a 100644 --- a/src/main/java/net/discordjug/javabot/systems/help/HelpManager.java +++ b/src/main/java/net/discordjug/javabot/systems/help/HelpManager.java @@ -117,9 +117,23 @@ public void close(IReplyCallback callback, boolean withHelpers, @Nullable String .queue(s -> postThread.getManager().setLocked(true).setArchived(true).queue()); if (callback.getMember().getIdLong() != postThread.getOwnerIdLong() && Boolean.parseBoolean(preferenceService.getOrCreate(postThread.getOwnerIdLong(), Preference.PRIVATE_CLOSE_NOTIFICATIONS).getState())) { + postThread.getOwner().getUser().openPrivateChannel() .flatMap(c -> createDMCloseInfoEmbed(callback.getMember(), postThread, reason, c)) .queue(success -> {}, failure -> {}); + + botConfig.get(callback.getGuild()) + .getModerationConfig() + .getLogChannel() + .sendMessageEmbeds(new EmbedBuilder() + .setTitle("Post closed by non-original poster") + .setDescription("The post " + postThread.getAsMention() + + " has been closed by " + callback.getMember().getAsMention() + ".\n\n" + + "[Post link](" + postThread.getJumpUrl() + ")") + .addField("Reason", reason, false) + .setAuthor(callback.getMember().getEffectiveName(), null, callback.getMember().getEffectiveAvatarUrl()) + .build()) + .queue(); } } diff --git a/src/main/java/net/discordjug/javabot/systems/help/commands/UnreserveCommand.java b/src/main/java/net/discordjug/javabot/systems/help/commands/UnreserveCommand.java index 95b7225a6..6c53a97c5 100644 --- a/src/main/java/net/discordjug/javabot/systems/help/commands/UnreserveCommand.java +++ b/src/main/java/net/discordjug/javabot/systems/help/commands/UnreserveCommand.java @@ -62,9 +62,14 @@ public void execute(@NotNull SlashCommandInteractionEvent event) { } HelpManager manager = new HelpManager(postThread, dbActions, botConfig, helpAccountRepository, helpTransactionRepository, preferenceService); if (manager.isForumEligibleToBeUnreserved(event.getInteraction())) { + String reason = event.getOption("reason", null, OptionMapping::getAsString); + if (event.getUser().getIdLong() != postThread.getOwnerIdLong() && reason == null) { + Responses.warning(event, "Could not close this post", "Closing a post of another user requires a reason to be set.").queue(); + return; + } manager.close(event, event.getUser().getIdLong() == manager.getPostThread().getOwnerIdLong(), - event.getOption("reason", null, OptionMapping::getAsString)); + reason); } else { Responses.warning(event, "Could not close this post", "You're not allowed to close this post.").queue(); } From 54a23ab92c00c964fd2208d069b26c9121f5ad3b Mon Sep 17 00:00:00 2001 From: danthe1st Date: Tue, 9 Jan 2024 19:31:12 +0100 Subject: [PATCH 025/242] use correct componentId element on delete button --- .../java/net/discordjug/javabot/util/InteractionUtils.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/util/InteractionUtils.java b/src/main/java/net/discordjug/javabot/util/InteractionUtils.java index dd2060b2c..e387b7d34 100644 --- a/src/main/java/net/discordjug/javabot/util/InteractionUtils.java +++ b/src/main/java/net/discordjug/javabot/util/InteractionUtils.java @@ -98,8 +98,8 @@ private InteractionCallbackAction delete(@NotNull ButtonInteraction interacti String authorId = ""; - if (componentId.length>1) { - authorId = componentId[1]; + if (componentId.length>2) { + authorId = componentId[2]; } if (authorId.equals(member.getUser().getId()) || From f91cfc382d8a3bde4483439b549412ed2548b0f2 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Sat, 13 Jan 2024 10:53:05 +0100 Subject: [PATCH 026/242] formatting --- src/main/java/net/discordjug/javabot/util/InteractionUtils.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/net/discordjug/javabot/util/InteractionUtils.java b/src/main/java/net/discordjug/javabot/util/InteractionUtils.java index e387b7d34..1e5fcf2be 100644 --- a/src/main/java/net/discordjug/javabot/util/InteractionUtils.java +++ b/src/main/java/net/discordjug/javabot/util/InteractionUtils.java @@ -98,7 +98,7 @@ private InteractionCallbackAction delete(@NotNull ButtonInteraction interacti String authorId = ""; - if (componentId.length>2) { + if (componentId.length > 2) { authorId = componentId[2]; } From eb002e5bcc352aa234f9d110332085570d0ca03d Mon Sep 17 00:00:00 2001 From: danthe1st Date: Sat, 13 Jan 2024 11:40:42 +0100 Subject: [PATCH 027/242] add modal for reason when closing --- src/main/java/net/discordjug/javabot/Bot.java | 10 ++- .../help/commands/UnreserveCommand.java | 90 +++++++++++++++---- 2 files changed, 77 insertions(+), 23 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/Bot.java b/src/main/java/net/discordjug/javabot/Bot.java index 8b75b1c85..29f478169 100644 --- a/src/main/java/net/discordjug/javabot/Bot.java +++ b/src/main/java/net/discordjug/javabot/Bot.java @@ -112,10 +112,12 @@ private void registerComponentHandlers(@NotNull ApplicationContext ctx) { List> stringSelectMappings = new ArrayList<>(); for (Object handler : interactionHandlers.values()) { AutoDetectableComponentHandler annotation = handler.getClass().getAnnotation(AutoDetectableComponentHandler.class); - String[] keys = annotation.value(); - addComponentHandler(buttonMappings, keys, handler, ButtonHandler.class); - addComponentHandler(modalMappings, keys, handler, ModalHandler.class); - addComponentHandler(stringSelectMappings, keys, handler, StringSelectMenuHandler.class); + if (annotation != null) {//superclasses are annotated, ignore + String[] keys = annotation.value(); + addComponentHandler(buttonMappings, keys, handler, ButtonHandler.class); + addComponentHandler(modalMappings, keys, handler, ModalHandler.class); + addComponentHandler(stringSelectMappings, keys, handler, StringSelectMenuHandler.class); + } } dih4jda.addButtonMappings(buttonMappings.toArray(IdMapping[]::new)); dih4jda.addModalMappings(modalMappings.toArray(IdMapping[]::new)); diff --git a/src/main/java/net/discordjug/javabot/systems/help/commands/UnreserveCommand.java b/src/main/java/net/discordjug/javabot/systems/help/commands/UnreserveCommand.java index 6c53a97c5..746192467 100644 --- a/src/main/java/net/discordjug/javabot/systems/help/commands/UnreserveCommand.java +++ b/src/main/java/net/discordjug/javabot/systems/help/commands/UnreserveCommand.java @@ -1,28 +1,45 @@ package net.discordjug.javabot.systems.help.commands; +import java.util.List; + +import org.jetbrains.annotations.NotNull; + +import net.discordjug.javabot.annotations.AutoDetectableComponentHandler; import net.discordjug.javabot.data.config.BotConfig; import net.discordjug.javabot.data.h2db.DbActions; import net.discordjug.javabot.systems.help.HelpManager; import net.discordjug.javabot.systems.help.dao.HelpAccountRepository; import net.discordjug.javabot.systems.help.dao.HelpTransactionRepository; import net.discordjug.javabot.systems.user_preferences.UserPreferenceService; +import net.discordjug.javabot.util.ExceptionLogger; import net.discordjug.javabot.util.Responses; import net.dv8tion.jda.api.entities.channel.ChannelType; import net.dv8tion.jda.api.entities.channel.concrete.ThreadChannel; +import net.dv8tion.jda.api.entities.channel.unions.MessageChannelUnion; +import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; -import net.dv8tion.jda.api.interactions.commands.CommandInteraction; +import net.dv8tion.jda.api.interactions.Interaction; +import net.dv8tion.jda.api.interactions.callbacks.IReplyCallback; import net.dv8tion.jda.api.interactions.commands.OptionMapping; import net.dv8tion.jda.api.interactions.commands.OptionType; import net.dv8tion.jda.api.interactions.commands.build.Commands; - -import org.jetbrains.annotations.NotNull; +import net.dv8tion.jda.api.interactions.components.ActionRow; +import net.dv8tion.jda.api.interactions.components.text.TextInput; +import net.dv8tion.jda.api.interactions.components.text.TextInputStyle; +import net.dv8tion.jda.api.interactions.modals.Modal; +import net.dv8tion.jda.api.interactions.modals.ModalMapping; import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand; +import xyz.dynxsty.dih4jda.interactions.components.ModalHandler; +import xyz.dynxsty.dih4jda.util.ComponentIdBuilder; /** * A simple command that can be used inside reserved help channels to * immediately unreserve them, instead of waiting for a timeout. */ -public class UnreserveCommand extends SlashCommand { +@AutoDetectableComponentHandler(UnreserveCommand.UNRESERVE_ID) +public class UnreserveCommand extends SlashCommand implements ModalHandler { + private static final String UNRESERVE_ID = "unreserve"; + private static final String REASON_ID = "reason"; private final BotConfig botConfig; private final DbActions dbActions; private final HelpAccountRepository helpAccountRepository; @@ -43,40 +60,75 @@ public UnreserveCommand(BotConfig botConfig, DbActions dbActions, HelpTransactio this.helpAccountRepository = helpAccountRepository; this.helpTransactionRepository = helpTransactionRepository; this.preferenceService = preferenceService; - setCommandData(Commands.slash("unreserve", "Unreserves this post marking your question/issue as resolved.") + setCommandData(Commands.slash(UNRESERVE_ID, "Unreserves this post marking your question/issue as resolved.") .setGuildOnly(true) - .addOption(OptionType.STRING, "reason", "The reason why you're unreserving this channel", false) + .addOption(OptionType.STRING, REASON_ID, "The reason why you're unreserving this channel", false) ); } @Override public void execute(@NotNull SlashCommandInteractionEvent event) { + String reason = event.getOption(REASON_ID, null, OptionMapping::getAsString); + onCloseRequest(event, event, event.getChannel(), reason, ()->{ + TextInput reasonInput = TextInput + .create(REASON_ID, "Reason", TextInputStyle.SHORT) + .setRequiredRange(11, 100) + .setRequired(true) + .setPlaceholder("Please enter the reason you are closing this post here") + .build(); + Modal modal = Modal + .create(ComponentIdBuilder.build(UNRESERVE_ID), "Close post") + .addComponents(ActionRow.of( + reasonInput)) + .build(); + event.replyModal(modal).queue(); + }); + } + + @Override + public void handleModal(ModalInteractionEvent event, List values) { + values + .stream() + .filter(mapping -> REASON_ID.equals(mapping.getId())) + .map(mapping -> mapping.getAsString()) + .findAny() + .ifPresentOrElse(reason -> { + onCloseRequest(event, event, event.getChannel(), reason, ()->{ + Responses.error(event, "An error occured - The reason field is missing.").queue(); + ExceptionLogger.capture(new IllegalStateException("A reason was expected but not present"), getClass().getName()); + }); + }, () -> Responses.warning(event, "A reason must be provided").queue()); + + } + + private void onCloseRequest(Interaction interaction, IReplyCallback replyCallback, MessageChannelUnion channel, String reason, Runnable noReasonHandler) { + ChannelType channelType = channel.getType(); // check whether the channel type is either text or thread (possible forum post?) - if (event.getChannelType() != ChannelType.TEXT && event.getChannelType() != ChannelType.GUILD_PUBLIC_THREAD) { - replyInvalidChannel(event); + if (channelType != ChannelType.TEXT && channelType != ChannelType.GUILD_PUBLIC_THREAD) { + replyInvalidChannel(replyCallback); return; } - ThreadChannel postThread = event.getChannel().asThreadChannel(); + ThreadChannel postThread = channel.asThreadChannel(); if (postThread.getParentChannel().getType() != ChannelType.FORUM) { - replyInvalidChannel(event); + replyInvalidChannel(replyCallback); + return; } HelpManager manager = new HelpManager(postThread, dbActions, botConfig, helpAccountRepository, helpTransactionRepository, preferenceService); - if (manager.isForumEligibleToBeUnreserved(event.getInteraction())) { - String reason = event.getOption("reason", null, OptionMapping::getAsString); - if (event.getUser().getIdLong() != postThread.getOwnerIdLong() && reason == null) { - Responses.warning(event, "Could not close this post", "Closing a post of another user requires a reason to be set.").queue(); + if (manager.isForumEligibleToBeUnreserved(interaction)) { + if (replyCallback.getUser().getIdLong() != postThread.getOwnerIdLong() && reason == null) { + noReasonHandler.run(); return; } - manager.close(event, - event.getUser().getIdLong() == manager.getPostThread().getOwnerIdLong(), + manager.close(replyCallback, + replyCallback.getUser().getIdLong() == manager.getPostThread().getOwnerIdLong(), reason); } else { - Responses.warning(event, "Could not close this post", "You're not allowed to close this post.").queue(); + Responses.warning(replyCallback, "Could not close this post", "You're not allowed to close this post.").queue(); } } - private void replyInvalidChannel(CommandInteraction interaction) { - Responses.warning(interaction, "Invalid Channel", + private void replyInvalidChannel(IReplyCallback replyCallback) { + Responses.warning(replyCallback, "Invalid Channel", "This command may only be used in either the text-channel-based help system, or in our new forum help system.") .queue(); } From fd30a49d2e024146c368b228e4ecc0a1cb6a58a1 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Sat, 13 Jan 2024 11:47:38 +0100 Subject: [PATCH 028/242] make constant package private for annotation --- .../javabot/systems/help/commands/UnreserveCommand.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/net/discordjug/javabot/systems/help/commands/UnreserveCommand.java b/src/main/java/net/discordjug/javabot/systems/help/commands/UnreserveCommand.java index 746192467..619f60cc2 100644 --- a/src/main/java/net/discordjug/javabot/systems/help/commands/UnreserveCommand.java +++ b/src/main/java/net/discordjug/javabot/systems/help/commands/UnreserveCommand.java @@ -38,7 +38,7 @@ */ @AutoDetectableComponentHandler(UnreserveCommand.UNRESERVE_ID) public class UnreserveCommand extends SlashCommand implements ModalHandler { - private static final String UNRESERVE_ID = "unreserve"; + static final String UNRESERVE_ID = "unreserve"; private static final String REASON_ID = "reason"; private final BotConfig botConfig; private final DbActions dbActions; From 94252f37a975d759b07a14ee871bdcf94d028e39 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Sat, 13 Jan 2024 11:55:13 +0100 Subject: [PATCH 029/242] add reason size limit to command --- .../systems/help/commands/UnreserveCommand.java | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/systems/help/commands/UnreserveCommand.java b/src/main/java/net/discordjug/javabot/systems/help/commands/UnreserveCommand.java index 619f60cc2..19c522df1 100644 --- a/src/main/java/net/discordjug/javabot/systems/help/commands/UnreserveCommand.java +++ b/src/main/java/net/discordjug/javabot/systems/help/commands/UnreserveCommand.java @@ -39,6 +39,7 @@ @AutoDetectableComponentHandler(UnreserveCommand.UNRESERVE_ID) public class UnreserveCommand extends SlashCommand implements ModalHandler { static final String UNRESERVE_ID = "unreserve"; + private static final int MINIMUM_REASON_LENGTH = 11; private static final String REASON_ID = "reason"; private final BotConfig botConfig; private final DbActions dbActions; @@ -72,9 +73,9 @@ public void execute(@NotNull SlashCommandInteractionEvent event) { onCloseRequest(event, event, event.getChannel(), reason, ()->{ TextInput reasonInput = TextInput .create(REASON_ID, "Reason", TextInputStyle.SHORT) - .setRequiredRange(11, 100) + .setRequiredRange(MINIMUM_REASON_LENGTH, 100) .setRequired(true) - .setPlaceholder("Please enter the reason you are closing this post here") + .setPlaceholder(reason == null ? "Please enter the reason you are closing this post here" : reason) .build(); Modal modal = Modal .create(ComponentIdBuilder.build(UNRESERVE_ID), "Close post") @@ -91,13 +92,14 @@ public void handleModal(ModalInteractionEvent event, List values) .stream() .filter(mapping -> REASON_ID.equals(mapping.getId())) .map(mapping -> mapping.getAsString()) + .filter(reason -> !isReasonInvalid(reason)) .findAny() .ifPresentOrElse(reason -> { onCloseRequest(event, event, event.getChannel(), reason, ()->{ - Responses.error(event, "An error occured - The reason field is missing.").queue(); + Responses.error(event, "The provided reason is missing or not valid").queue(); ExceptionLogger.capture(new IllegalStateException("A reason was expected but not present"), getClass().getName()); }); - }, () -> Responses.warning(event, "A reason must be provided").queue()); + }, () -> Responses.warning(event, "A valid reason must be provided").queue()); } @@ -115,7 +117,7 @@ private void onCloseRequest(Interaction interaction, IReplyCallback replyCallbac } HelpManager manager = new HelpManager(postThread, dbActions, botConfig, helpAccountRepository, helpTransactionRepository, preferenceService); if (manager.isForumEligibleToBeUnreserved(interaction)) { - if (replyCallback.getUser().getIdLong() != postThread.getOwnerIdLong() && reason == null) { + if (replyCallback.getUser().getIdLong() != postThread.getOwnerIdLong() && isReasonInvalid(reason)) { noReasonHandler.run(); return; } @@ -127,6 +129,10 @@ private void onCloseRequest(Interaction interaction, IReplyCallback replyCallbac } } + private boolean isReasonInvalid(String reason) { + return reason == null || reason.length() < MINIMUM_REASON_LENGTH; + } + private void replyInvalidChannel(IReplyCallback replyCallback) { Responses.warning(replyCallback, "Invalid Channel", "This command may only be used in either the text-channel-based help system, or in our new forum help system.") From 8969ae6676898bafbce72413fdf8b8ab824346e2 Mon Sep 17 00:00:00 2001 From: Finn Behrend <76592300+liquiddevelopmentnet@users.noreply.github.com> Date: Tue, 16 Jan 2024 15:30:16 +0100 Subject: [PATCH 030/242] change minimum xp for daily removal to 1 --- .../net/discordjug/javabot/systems/help/HelpExperienceJob.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/net/discordjug/javabot/systems/help/HelpExperienceJob.java b/src/main/java/net/discordjug/javabot/systems/help/HelpExperienceJob.java index 8ba137ce7..34b5b43a3 100644 --- a/src/main/java/net/discordjug/javabot/systems/help/HelpExperienceJob.java +++ b/src/main/java/net/discordjug/javabot/systems/help/HelpExperienceJob.java @@ -33,7 +33,7 @@ public void execute() { try { helpAccountRepository.removeExperienceFromAllAccounts( // just get the config for the first guild the bot is in, as it's not designed to work in multiple guilds anyway - botConfig.get(jda.getGuilds().get(0)).getHelpConfig().getDailyExperienceSubtraction(), 5, 50); + botConfig.get(jda.getGuilds().get(0)).getHelpConfig().getDailyExperienceSubtraction(), 1, 50); } catch (DataAccessException e) { ExceptionLogger.capture(e, DbHelper.class.getSimpleName()); } From 68817391a1d328ae9379180f88b7f2f1d3e63baa Mon Sep 17 00:00:00 2001 From: danthe1st Date: Sat, 20 Jan 2024 19:08:33 +0100 Subject: [PATCH 031/242] preserve attachments of logged messages via HTTP request --- .../data/h2db/message_cache/MessageCache.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/main/java/net/discordjug/javabot/data/h2db/message_cache/MessageCache.java b/src/main/java/net/discordjug/javabot/data/h2db/message_cache/MessageCache.java index 318615a32..b3a75da55 100644 --- a/src/main/java/net/discordjug/javabot/data/h2db/message_cache/MessageCache.java +++ b/src/main/java/net/discordjug/javabot/data/h2db/message_cache/MessageCache.java @@ -20,6 +20,10 @@ import java.io.ByteArrayInputStream; import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse.BodyHandlers; import java.nio.charset.StandardCharsets; import java.time.Instant; import java.time.ZoneOffset; @@ -137,9 +141,23 @@ public void sendDeletedMessageToLog(Guild guild, MessageChannel channel, CachedM action.addFiles(FileUpload.fromData(buildDeletedMessageFile(author, message), message.getMessageId() + ".txt")); } action.queue(); + requestMessageAttachments(message); }); } + /** + * Requests each attachment from Discord's CDN. + * This is done in order to prevent Discord from deleting the attachment too quickly. + * @param message the cached message + */ + private void requestMessageAttachments(CachedMessage message) { + HttpClient client = HttpClient.newHttpClient(); + for (String attachment : message.getAttachments()) { + HttpRequest request = HttpRequest.newBuilder(URI.create(attachment)).build(); + client.sendAsync(request, BodyHandlers.discarding()); + } + } + private EmbedBuilder buildMessageCacheEmbed(MessageChannel channel, User author, CachedMessage before) { long epoch = IdCalculatorCommand.getTimestampFromId(before.getMessageId()) / 1000; return new EmbedBuilder() From c87c5364282bfeb2b5a7efd234b699eb2efee644 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Sat, 20 Jan 2024 19:57:37 +0100 Subject: [PATCH 032/242] Fix offset in monthly XP leaderboard --- .../javabot/systems/help/dao/HelpTransactionRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/net/discordjug/javabot/systems/help/dao/HelpTransactionRepository.java b/src/main/java/net/discordjug/javabot/systems/help/dao/HelpTransactionRepository.java index e4892b966..b7210d093 100644 --- a/src/main/java/net/discordjug/javabot/systems/help/dao/HelpTransactionRepository.java +++ b/src/main/java/net/discordjug/javabot/systems/help/dao/HelpTransactionRepository.java @@ -134,7 +134,7 @@ public int getNumberOfUsersWithHelpXPInLastMonth() { public List> getTotalTransactionWeightsInLastMonth(int page, int pageSize) { return jdbcTemplate.query("SELECT recipient, SUM(weight) experience FROM help_transaction WHERE created_at >= ? GROUP BY recipient ORDER BY experience DESC LIMIT ? OFFSET ?", (rs, row) -> new Pair<>(rs.getLong(1), rs.getInt(2)), - LocalDateTime.now().minusDays(30), pageSize, page); + LocalDateTime.now().minusDays(30), pageSize, Math.max(0, (page * pageSize) - pageSize)); } /** From 3e16648f7b691604dd140058a18a0f8961df3a0d Mon Sep 17 00:00:00 2001 From: danthe1st Date: Fri, 26 Jan 2024 11:45:32 +0100 Subject: [PATCH 033/242] configurable limits for XP subtraction --- .../javabot/data/config/guild/HelpConfig.java | 12 +++++++++++- .../javabot/systems/help/HelpExperienceJob.java | 8 ++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/data/config/guild/HelpConfig.java b/src/main/java/net/discordjug/javabot/data/config/guild/HelpConfig.java index f68c36ee7..9fbbf4a96 100644 --- a/src/main/java/net/discordjug/javabot/data/config/guild/HelpConfig.java +++ b/src/main/java/net/discordjug/javabot/data/config/guild/HelpConfig.java @@ -116,7 +116,17 @@ public class HelpConfig extends GuildConfigItem { private double thankExperience = 3; /** - * The amount that should be subtracted from every Help Account each day. + * The minimum total amount of XP that should be subtracted from every Help Account each day. + */ + private int minDailyExperienceSubtraction = 1; + + /** + * The maximum total amount of XP that should be subtracted from every Help Account each day. + */ + private int maxDailyExperienceSubtraction = 50; + + /** + * The percentage of XP that should be subtracted from every Help Account each day. */ private double dailyExperienceSubtraction = 5; diff --git a/src/main/java/net/discordjug/javabot/systems/help/HelpExperienceJob.java b/src/main/java/net/discordjug/javabot/systems/help/HelpExperienceJob.java index 34b5b43a3..43dbbe8d6 100644 --- a/src/main/java/net/discordjug/javabot/systems/help/HelpExperienceJob.java +++ b/src/main/java/net/discordjug/javabot/systems/help/HelpExperienceJob.java @@ -2,6 +2,7 @@ import lombok.RequiredArgsConstructor; import net.discordjug.javabot.data.config.BotConfig; +import net.discordjug.javabot.data.config.guild.HelpConfig; import net.discordjug.javabot.data.h2db.DbHelper; import net.discordjug.javabot.systems.help.dao.HelpAccountRepository; import net.discordjug.javabot.util.ExceptionLogger; @@ -31,9 +32,12 @@ public class HelpExperienceJob { public void execute() { asyncPool.execute(() -> { try { + // just get the config for the first guild the bot is in, as it's not designed to work in multiple guilds anyway + HelpConfig helpConfig = botConfig.get(jda.getGuilds().get(0)).getHelpConfig(); helpAccountRepository.removeExperienceFromAllAccounts( - // just get the config for the first guild the bot is in, as it's not designed to work in multiple guilds anyway - botConfig.get(jda.getGuilds().get(0)).getHelpConfig().getDailyExperienceSubtraction(), 1, 50); + helpConfig.getDailyExperienceSubtraction(), + helpConfig.getMinDailyExperienceSubtraction(), + helpConfig.getMaxDailyExperienceSubtraction()); } catch (DataAccessException e) { ExceptionLogger.capture(e, DbHelper.class.getSimpleName()); } From 8c66fb1dff42ce1ad9fac61fb04028ccd11c5c72 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Fri, 26 Jan 2024 11:46:44 +0100 Subject: [PATCH 034/242] ensure precision in XP subtraction calculation --- .../javabot/systems/help/dao/HelpAccountRepository.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/systems/help/dao/HelpAccountRepository.java b/src/main/java/net/discordjug/javabot/systems/help/dao/HelpAccountRepository.java index 2521b7266..518be7244 100644 --- a/src/main/java/net/discordjug/javabot/systems/help/dao/HelpAccountRepository.java +++ b/src/main/java/net/discordjug/javabot/systems/help/dao/HelpAccountRepository.java @@ -2,7 +2,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import net.discordjug.javabot.data.config.BotConfig; import net.discordjug.javabot.systems.help.model.HelpAccount; import org.jetbrains.annotations.NotNull; @@ -26,7 +25,6 @@ @Repository public class HelpAccountRepository { private final JdbcTemplate jdbcTemplate; - private final BotConfig botConfig; /** * Inserts a new {@link HelpAccount}. @@ -105,7 +103,7 @@ public int getTotalAccounts() throws DataAccessException { * @throws DataAccessException If an error occurs. */ public void removeExperienceFromAllAccounts(double change, int min, int max) throws DataAccessException { - long rows = jdbcTemplate.execute("UPDATE help_account SET experience = GREATEST(experience - LEAST(GREATEST(experience * (? / 100), ?), ?), 0)",new CallableStatementCallback() { + long rows = jdbcTemplate.execute("UPDATE help_account SET experience = GREATEST(experience - LEAST(GREATEST((experience * ?) / 100, ?), ?), 0)",new CallableStatementCallback() { @Override public Long doInCallableStatement(CallableStatement cs) throws SQLException, DataAccessException { From 427c8ad9b5acf3544afe4dfa2ccf40cba1f5b8eb Mon Sep 17 00:00:00 2001 From: danthe1st Date: Fri, 26 Jan 2024 11:46:59 +0100 Subject: [PATCH 035/242] tests for XP subtraction --- .../javabot/data/h2db/DbHelper.java | 8 +- .../help/HelpExperienceSubtractionTest.java | 115 ++++++++++++++++++ 2 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 src/test/java/net/discordjug/javabot/systems/help/HelpExperienceSubtractionTest.java diff --git a/src/main/java/net/discordjug/javabot/data/h2db/DbHelper.java b/src/main/java/net/discordjug/javabot/data/h2db/DbHelper.java index 858334480..e351dc98d 100644 --- a/src/main/java/net/discordjug/javabot/data/h2db/DbHelper.java +++ b/src/main/java/net/discordjug/javabot/data/h2db/DbHelper.java @@ -99,7 +99,13 @@ private static boolean shouldInitSchema(String jdbcUrl) { return shouldInitSchema; } - private static void initializeSchema(HikariDataSource dataSource) throws IOException, SQLException { + /** + * Initializes the schema of the database by running all SQL statements from the schema.sql script. + * @param dataSource the {@link DataSource} to connect to the DB + * @throws IOException if an error happened while loading the schema.sql + * @throws SQLException if any SQL error happened + */ + public static void initializeSchema(DataSource dataSource) throws IOException, SQLException { try (InputStream is = DbHelper.class.getClassLoader().getResourceAsStream("database/schema.sql")) { if (is == null) throw new IOException("Could not load schema.sql."); List queries = Arrays.stream(new String(is.readAllBytes()).split(";")) diff --git a/src/test/java/net/discordjug/javabot/systems/help/HelpExperienceSubtractionTest.java b/src/test/java/net/discordjug/javabot/systems/help/HelpExperienceSubtractionTest.java new file mode 100644 index 000000000..919d2d6c6 --- /dev/null +++ b/src/test/java/net/discordjug/javabot/systems/help/HelpExperienceSubtractionTest.java @@ -0,0 +1,115 @@ +package net.discordjug.javabot.systems.help; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.IOException; +import java.sql.SQLException; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.jdbc.DataSourceBuilder; +import org.springframework.jdbc.core.JdbcTemplate; + +import com.zaxxer.hikari.HikariDataSource; + +import net.discordjug.javabot.data.h2db.DbHelper; +import net.discordjug.javabot.systems.help.dao.HelpAccountRepository; +import net.discordjug.javabot.systems.help.model.HelpAccount; + +/** + * Tests functionality of automated experience subtraction. + */ +public class HelpExperienceSubtractionTest { + + private HikariDataSource dataSource; + private HelpAccountRepository repo; + + @BeforeEach + void setUp() throws IOException, SQLException { + dataSource = DataSourceBuilder.create() + .type(HikariDataSource.class) + .url("jdbc:h2:mem:test") + .username("test") + .password("") + .build(); + + DbHelper.initializeSchema(dataSource); + + JdbcTemplate template = new JdbcTemplate(dataSource); + repo = new HelpAccountRepository(template); + } + + @AfterEach + void cleanUp() { + dataSource.close(); + } + + /** + * If a user has less XP than the minimum experience subtraction, the user should lose all XP. + */ + @Test + void testUserHasLessThanMinimum() { + repo.insert(new HelpAccount(1, 1)); + repo.removeExperienceFromAllAccounts(50, 2, 10); + assertEquals(0, repo.getByUserId(1).get().getExperience()); + } + + /** + * If the XP to subtract is less than the minimum, the minimum XP should be subtracted. + */ + @Test + void testSubtractMinimum() { + repo.insert(new HelpAccount(1, 6));//6XP + //would remove 50% i.e. 3XP + //the minimum is 4XP hence it should subtract 4XP + repo.removeExperienceFromAllAccounts(50, 4, 10); + assertEquals(2, repo.getByUserId(1).get().getExperience()); + } + + @Test + void testSubtractMaximum() { + repo.insert(new HelpAccount(1, 100)); + //tries to subtract 50% which is 50XP + //but maximum is 10XP hence it should subtract 10XP + repo.removeExperienceFromAllAccounts(50, 1, 10); + assertEquals(90, repo.getByUserId(1).get().getExperience()); + } + + @Test + void testFraction() { + repo.insert(new HelpAccount(1, 100)); + //subtract 10% i.e. 10XP + //should be inside [1,50] hence neither minimum nor maximum is active + repo.removeExperienceFromAllAccounts(10, 1, 50); + assertEquals(90, repo.getByUserId(1).get().getExperience()); + //subtract 10% i.e. 9XP + //should be inside [1,50] hence neither minimum nor maximum is active + repo.removeExperienceFromAllAccounts(10, 1, 50); + assertEquals(81, repo.getByUserId(1).get().getExperience()); + } + + @Test + void testMultipleUsers() { + repo.insert(new HelpAccount(79, 79));//below min + repo.insert(new HelpAccount(80, 80));//exactly min + repo.insert(new HelpAccount(100, 100));//within bounds + repo.insert(new HelpAccount(2_000, 2_000));//within bounds + repo.insert(new HelpAccount(3_999, 3_999));//close to upper bound + repo.insert(new HelpAccount(4_000, 4_000));//exactly upper bound + repo.insert(new HelpAccount(4_001, 4_001));//exceeds upper bound + repo.insert(new HelpAccount(10_000, 10_000));//significantly exceeds upper bound + + repo.removeExperienceFromAllAccounts(1.25, 1, 50); + + double delta = 0.0001;//required precision + assertEquals(78, repo.getByUserId(79).get().getExperience(), delta); + assertEquals(79, repo.getByUserId(80).get().getExperience(), delta); + assertEquals(98.75, repo.getByUserId(100).get().getExperience(), delta); + assertEquals(1975, repo.getByUserId(2_000).get().getExperience(), delta); + assertEquals(3949.0125, repo.getByUserId(3_999).get().getExperience(), delta); + assertEquals(3950, repo.getByUserId(4_000).get().getExperience(), delta); + assertEquals(3951, repo.getByUserId(4_001).get().getExperience(), delta); + assertEquals(9_950, repo.getByUserId(10_000).get().getExperience(), delta); + } +} From 50914ce366db9c43289545844b541801626fcace Mon Sep 17 00:00:00 2001 From: danthe1st Date: Fri, 26 Jan 2024 11:56:54 +0100 Subject: [PATCH 036/242] add half-life test --- .../help/HelpExperienceSubtractionTest.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/test/java/net/discordjug/javabot/systems/help/HelpExperienceSubtractionTest.java b/src/test/java/net/discordjug/javabot/systems/help/HelpExperienceSubtractionTest.java index 919d2d6c6..6eee5fdbf 100644 --- a/src/test/java/net/discordjug/javabot/systems/help/HelpExperienceSubtractionTest.java +++ b/src/test/java/net/discordjug/javabot/systems/help/HelpExperienceSubtractionTest.java @@ -1,6 +1,8 @@ package net.discordjug.javabot.systems.help; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.IOException; import java.sql.SQLException; @@ -89,6 +91,21 @@ void testFraction() { assertEquals(81, repo.getByUserId(1).get().getExperience()); } + @Test + void testXPHalfLife() { + int startXP = 1_000; + int half = startXP/2; + repo.insert(new HelpAccount(1, startXP)); + for (int i = 0; i < 55; i++) { + repo.removeExperienceFromAllAccounts(1.25, 0, 1_000); + double actualXP = repo.getByUserId(1).get().getExperience(); + assertTrue(actualXP > half, "In iteration "+i+", XP have decayed by more than 50%, user has "+actualXP+"XP after iteration"); + } + repo.removeExperienceFromAllAccounts(1.25, 0, 1_000); + double actualXP = repo.getByUserId(1).get().getExperience(); + assertFalse(actualXP > half, "After all iterations, XP have not decayed by more than 50%, user has "+actualXP+"XP at the end"); + } + @Test void testMultipleUsers() { repo.insert(new HelpAccount(79, 79));//below min From 3876a4081b575e352beeafd2e221be684cbd1ded Mon Sep 17 00:00:00 2001 From: danthe1st Date: Mon, 12 Feb 2024 16:31:59 +0100 Subject: [PATCH 037/242] warn decay --- .../user_profile/UserProfileController.java | 6 +- .../data/config/guild/ModerationConfig.java | 12 +++- .../systems/moderation/ModerationService.java | 57 +++++++++++++++++-- .../moderation/warn/WarnsListCommand.java | 39 +++++++++---- .../moderation/warn/WarnsListContext.java | 12 ++-- .../moderation/warn/dao/WarnRepository.java | 24 +------- 6 files changed, 106 insertions(+), 44 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/api/routes/user_profile/UserProfileController.java b/src/main/java/net/discordjug/javabot/api/routes/user_profile/UserProfileController.java index 2f30fd2f5..bf76f7f7d 100644 --- a/src/main/java/net/discordjug/javabot/api/routes/user_profile/UserProfileController.java +++ b/src/main/java/net/discordjug/javabot/api/routes/user_profile/UserProfileController.java @@ -10,6 +10,7 @@ import net.discordjug.javabot.data.config.BotConfig; import net.discordjug.javabot.systems.help.HelpExperienceService; import net.discordjug.javabot.systems.help.model.HelpAccount; +import net.discordjug.javabot.systems.moderation.ModerationService; import net.discordjug.javabot.systems.moderation.warn.dao.WarnRepository; import net.discordjug.javabot.systems.qotw.QOTWPointsService; import net.discordjug.javabot.systems.qotw.model.QOTWAccount; @@ -29,7 +30,6 @@ import java.sql.Connection; import java.sql.SQLException; -import java.time.LocalDateTime; import java.util.concurrent.TimeUnit; import javax.sql.DataSource; @@ -108,8 +108,8 @@ public ResponseEntity getUserProfile( HelpAccount helpAccount = helpExperienceService.getOrCreateAccount(user.getIdLong()); data.setHelpAccount(HelpAccountData.of(botConfig, helpAccount, guild)); // User Warns - LocalDateTime cutoff = LocalDateTime.now().minusDays(botConfig.get(guild).getModerationConfig().getWarnTimeoutDays()); - data.setWarns(warnRepository.getActiveWarnsByUserId(user.getIdLong(), cutoff)); + ModerationService moderationService = new ModerationService(null, botConfig.get(guild), warnRepository, null); + data.setWarns(moderationService.getTotalSeverityWeight(user.getIdLong()).contributingWarns()); // Insert into cache getCache().put(new Pair<>(guild.getIdLong(), user.getIdLong()), data); } diff --git a/src/main/java/net/discordjug/javabot/data/config/guild/ModerationConfig.java b/src/main/java/net/discordjug/javabot/data/config/guild/ModerationConfig.java index 507d4f5bb..47113d703 100644 --- a/src/main/java/net/discordjug/javabot/data/config/guild/ModerationConfig.java +++ b/src/main/java/net/discordjug/javabot/data/config/guild/ModerationConfig.java @@ -52,7 +52,17 @@ public class ModerationConfig extends GuildConfigItem { * being removed from the server. Warnings older than this are still kept, * but ignored. */ - private int warnTimeoutDays = 30; + private int maxWarnValidityDays = 30; + + /** + * The number of days it takes to remove a certain amount of severity weights from the warns of a user. + */ + private int warnDecayDays = maxWarnValidityDays; + + /** + * The total severity weight removed from a user after {@link ModerationConfig#warnDecayDays} passed. + */ + private int warnDecayAmount = 0; /** * The maximum total severity that a user can accrue from warnings before diff --git a/src/main/java/net/discordjug/javabot/systems/moderation/ModerationService.java b/src/main/java/net/discordjug/javabot/systems/moderation/ModerationService.java index 42e435bdd..f24b9b154 100644 --- a/src/main/java/net/discordjug/javabot/systems/moderation/ModerationService.java +++ b/src/main/java/net/discordjug/javabot/systems/moderation/ModerationService.java @@ -24,6 +24,7 @@ import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneOffset; +import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.concurrent.ExecutorService; @@ -88,7 +89,7 @@ public void warn(User user, WarnSeverity severity, String reason, Member warnedB asyncPool.execute(() -> { try { warnRepository.insert(new Warn(user.getIdLong(), warnedBy.getIdLong(), severity, reason)); - int totalSeverity = warnRepository.getTotalSeverityWeight(user.getIdLong(), LocalDateTime.now().minusDays(moderationConfig.getWarnTimeoutDays())); + long totalSeverity = getTotalSeverityWeight(user.getIdLong()).totalSeverity(); MessageEmbed warnEmbed = buildWarnEmbed(user, warnedBy, severity, totalSeverity, reason); notificationService.withUser(user, warnedBy.getGuild()).sendDirectMessage(c -> c.sendMessageEmbeds(warnEmbed)); notificationService.withGuild(moderationConfig.getGuild()).sendToModerationLog(c -> c.sendMessageEmbeds(warnEmbed)); @@ -106,7 +107,46 @@ public void warn(User user, WarnSeverity severity, String reason, Member warnedB } }); } - + + /** + * Gets the total warn severity weight of a given user. + * @implSpec + * Only warns from the last {@link ModerationConfig#getMaxWarnValidityDays()} days are checked. + * Every {@link ModerationConfig#getWarnDecayDays()} days, {@link ModerationConfig#getWarnDecayAmount()} of severity are subtracted from the total severity of the user. + * This method does not allow negative severities at any point in time. + * The oldest considered warn decides on the amount of severity to subtract. + * Hence, all warns that would (together) not increase the severity due to being too old are ignored. + * @implNote + * The total severity is calculated per warn by considering the severity of all warns after the given warn and subtracting the discount subtrahend corresponding to the given (oldest) warn from the severity. + * Then, the maximum discounted severity over all warns is chosen. + * @param userId the ID of the user to check + * @return the accumulated warn severity weight of the user along with all warns contributing to it. + * @see SeverityInformation + */ + public SeverityInformation getTotalSeverityWeight(long userId) { + LocalDateTime now = LocalDateTime.now(); + List activeWarns = warnRepository.getActiveWarnsByUserId(userId, now.minusDays(moderationConfig.getMaxWarnValidityDays())); + int accumulatedUndiscountedSeverity = 0; + long maxSeverity = 0; + long usedSeverityDiscount = 0; + List contributingWarns = Collections.emptyList(); + + for (int i = 0; i < activeWarns.size(); i++) { + Warn warn = activeWarns.get(i); + accumulatedUndiscountedSeverity += warn.getSeverityWeight(); + long daysSinceWarn = Duration.between(warn.getCreatedAt(), now).toDays(); + long discountAmount = moderationConfig.getWarnDecayAmount() * (daysSinceWarn / moderationConfig.getWarnDecayDays()); + long currentSeverity = accumulatedUndiscountedSeverity - discountAmount; + if (currentSeverity > maxSeverity) { + maxSeverity = currentSeverity; + contributingWarns = activeWarns.subList(0, i + 1); + usedSeverityDiscount = discountAmount; + } + } + + return new SeverityInformation(maxSeverity, usedSeverityDiscount, Collections.unmodifiableList(contributingWarns)); + } + /** * Clears warns from the given user by discarding all warns. * @@ -157,7 +197,7 @@ public boolean discardWarnById(long id, User clearedBy) { public List getWarns(long userId) { try { WarnRepository repo = warnRepository; - LocalDateTime cutoff = LocalDateTime.now().minusDays(moderationConfig.getWarnTimeoutDays()); + LocalDateTime cutoff = LocalDateTime.now().minusDays(moderationConfig.getMaxWarnValidityDays()); return repo.getActiveWarnsByUserId(userId, cutoff); } catch (DataAccessException e) { ExceptionLogger.capture(e, getClass().getSimpleName()); @@ -356,7 +396,7 @@ private void sendGuildNotification(Guild guild, MessageEmbed embed) { .build(); } - private @NotNull MessageEmbed buildWarnEmbed(User user, Member warnedBy, @NotNull WarnSeverity severity, int totalSeverity, String reason) { + private @NotNull MessageEmbed buildWarnEmbed(User user, Member warnedBy, @NotNull WarnSeverity severity, long totalSeverity, String reason) { return buildModerationEmbed(user, warnedBy, reason) .setTitle(String.format("Warn Added (%d/%d)", totalSeverity, moderationConfig.getMaxWarnSeverity())) .setColor(Responses.Type.WARN.getColor()) @@ -407,4 +447,13 @@ private void sendGuildNotification(Guild guild, MessageEmbed embed) { .setColor(Responses.Type.SUCCESS.getColor()) .build(); } + + /** + * Records information about the total severity of a user as well as the warns contributing to it. + * @param totalSeverity the total severity of the user + * @param severityDiscount the amount the severity was reduced due to warn decay + * @param contributingWarns the (active) warns contributing to that severity + * @see ModerationService#getTotalSeverityWeight(long) + */ + public record SeverityInformation(long totalSeverity, long severityDiscount, List contributingWarns) {} } \ No newline at end of file diff --git a/src/main/java/net/discordjug/javabot/systems/moderation/warn/WarnsListCommand.java b/src/main/java/net/discordjug/javabot/systems/moderation/warn/WarnsListCommand.java index 53ba5e8f6..8e2bb3ec9 100644 --- a/src/main/java/net/discordjug/javabot/systems/moderation/warn/WarnsListCommand.java +++ b/src/main/java/net/discordjug/javabot/systems/moderation/warn/WarnsListCommand.java @@ -1,7 +1,6 @@ package net.discordjug.javabot.systems.moderation.warn; import java.time.Instant; -import java.time.LocalDateTime; import java.time.ZoneOffset; import java.util.List; import java.util.concurrent.ExecutorService; @@ -13,8 +12,10 @@ import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand; import net.discordjug.javabot.data.config.BotConfig; +import net.discordjug.javabot.systems.moderation.ModerationService; import net.discordjug.javabot.systems.moderation.warn.dao.WarnRepository; import net.discordjug.javabot.systems.moderation.warn.model.Warn; +import net.discordjug.javabot.systems.notification.NotificationService; import net.discordjug.javabot.util.ExceptionLogger; import net.discordjug.javabot.util.Responses; import net.discordjug.javabot.util.UserUtils; @@ -34,17 +35,20 @@ public class WarnsListCommand extends SlashCommand { private final BotConfig botConfig; private final WarnRepository warnRepository; private final ExecutorService asyncPool; + private final NotificationService notificationService; /** * The constructor of this class, which sets the corresponding {@link net.dv8tion.jda.api.interactions.commands.build.SlashCommandData}. * @param botConfig The main configuration of the bot * @param asyncPool The main thread pool for asynchronous operations * @param warnRepository DAO for interacting with the set of {@link Warn} objects. + * @param notificationService service object for notifying users */ - public WarnsListCommand(BotConfig botConfig, ExecutorService asyncPool, WarnRepository warnRepository) { + public WarnsListCommand(BotConfig botConfig, ExecutorService asyncPool, WarnRepository warnRepository, NotificationService notificationService) { this.botConfig = botConfig; this.warnRepository = warnRepository; this.asyncPool = asyncPool; + this.notificationService = notificationService; setCommandData(Commands.slash("warns", "Shows a list of all recent warning.") .addOption(OptionType.USER, "user", "If given, shows the recent warns of the given user instead.", false) .setGuildOnly(true) @@ -54,22 +58,35 @@ public WarnsListCommand(BotConfig botConfig, ExecutorService asyncPool, WarnRepo /** * Builds an {@link MessageEmbed} which contains all recents warnings of a user. * - * @param warns A {@link List} with all {@link Warn}s. + * @param severityInformation object containing information about active warns and their severities * @param user The corresponding {@link User}. * @return The fully-built {@link MessageEmbed}. */ - protected static @NotNull MessageEmbed buildWarnsEmbed(@Nonnull List warns, @Nonnull User user) { + protected static @NotNull MessageEmbed buildWarnsEmbed(@Nonnull ModerationService.SeverityInformation severityInformation, @Nonnull User user) { + List warns = severityInformation.contributingWarns(); EmbedBuilder builder = new EmbedBuilder() .setAuthor(UserUtils.getUserTag(user), null, user.getEffectiveAvatarUrl()) .setTitle("Recent Warns") .setDescription(String.format("%s has `%s` active warns with a total of `%s` severity.\n", - user.getAsMention(), warns.size(), warns.stream().mapToInt(Warn::getSeverityWeight).sum())) + user.getAsMention(), warns.size(), severityInformation.totalSeverity())) .setColor(Responses.Type.WARN.getColor()) .setTimestamp(Instant.now()); - warns.forEach(w -> builder.getDescriptionBuilder().append( - String.format("\n`%s` \nWarned by: <@%s>\nSeverity: `%s (%s)`\nReason: %s\n", - w.getId(), w.getCreatedAt().toInstant(ZoneOffset.UTC).getEpochSecond(), - w.getWarnedBy(), w.getSeverity(), w.getSeverityWeight(), w.getReason()))); + + if (severityInformation.severityDiscount() > 0) { + builder.appendDescription("(Due to warn decay, the total severity is `"+severityInformation.severityDiscount()+"` lower than the sum of all warn severities.)\n"); + } + + warns.forEach(w -> { + String text = String.format("\n`%s` \nWarned by: <@%s>\nSeverity: `%s (%s)`\nReason: %s\n", + w.getId(), w.getCreatedAt().toInstant(ZoneOffset.UTC).getEpochSecond(), + w.getWarnedBy(), w.getSeverity(), w.getSeverityWeight(), w.getReason()); + StringBuilder descriptionBuilder = builder.getDescriptionBuilder(); + if (descriptionBuilder.length() + text.length() < MessageEmbed.DESCRIPTION_MAX_LENGTH) { + descriptionBuilder.append(text); + } else { + builder.setFooter("Some warns have been omitted due to length limitations. Contact staff in case you want to see an exhaustive list of warns."); + } + }); return builder.build(); } @@ -81,10 +98,10 @@ public void execute(@NotNull SlashCommandInteractionEvent event) { return; } event.deferReply(false).queue(); - LocalDateTime cutoff = LocalDateTime.now().minusDays(botConfig.get(event.getGuild()).getModerationConfig().getWarnTimeoutDays()); + ModerationService moderationService = new ModerationService(notificationService, botConfig, event, warnRepository, asyncPool); asyncPool.execute(() -> { try { - event.getHook().sendMessageEmbeds(buildWarnsEmbed(warnRepository.getActiveWarnsByUserId(user.getIdLong(), cutoff), user)).queue(); + event.getHook().sendMessageEmbeds(buildWarnsEmbed(moderationService.getTotalSeverityWeight(user.getIdLong()), user)).queue(); } catch (DataAccessException e) { ExceptionLogger.capture(e, WarnsListCommand.class.getSimpleName()); } diff --git a/src/main/java/net/discordjug/javabot/systems/moderation/warn/WarnsListContext.java b/src/main/java/net/discordjug/javabot/systems/moderation/warn/WarnsListContext.java index 5f4990f33..bea804a60 100644 --- a/src/main/java/net/discordjug/javabot/systems/moderation/warn/WarnsListContext.java +++ b/src/main/java/net/discordjug/javabot/systems/moderation/warn/WarnsListContext.java @@ -2,13 +2,14 @@ import xyz.dynxsty.dih4jda.interactions.commands.application.ContextCommand; import net.discordjug.javabot.data.config.BotConfig; +import net.discordjug.javabot.systems.moderation.ModerationService; import net.discordjug.javabot.systems.moderation.warn.dao.WarnRepository; +import net.discordjug.javabot.systems.notification.NotificationService; import net.discordjug.javabot.util.ExceptionLogger; import net.discordjug.javabot.util.Responses; import net.dv8tion.jda.api.events.interaction.command.UserContextInteractionEvent; import net.dv8tion.jda.api.interactions.commands.build.Commands; -import java.time.LocalDateTime; import java.util.concurrent.ExecutorService; import org.springframework.dao.DataAccessException; @@ -21,17 +22,20 @@ public class WarnsListContext extends ContextCommand.User { private final BotConfig botConfig; private final ExecutorService asyncPool; private final WarnRepository warnRepository; + private final NotificationService notificationService; /** * The constructor of this class, which sets the corresponding {@link net.dv8tion.jda.api.interactions.commands.build.CommandData}. * @param botConfig The main configuration of the bot * @param asyncPool The main thread pool for asynchronous operations * @param warnRepository DAO for interacting with the set of {@link net.discordjug.javabot.systems.moderation.warn.model.Warn} objects. + * @param notificationService service object for notifying users */ - public WarnsListContext(BotConfig botConfig, ExecutorService asyncPool, WarnRepository warnRepository) { + public WarnsListContext(BotConfig botConfig, ExecutorService asyncPool, WarnRepository warnRepository, NotificationService notificationService) { this.botConfig = botConfig; this.asyncPool = asyncPool; this.warnRepository = warnRepository; + this.notificationService = notificationService; setCommandData(Commands.user("Show Warns") .setGuildOnly(true) ); @@ -44,10 +48,10 @@ public void execute(UserContextInteractionEvent event) { return; } event.deferReply(false).queue(); - LocalDateTime cutoff = LocalDateTime.now().minusDays(botConfig.get(event.getGuild()).getModerationConfig().getWarnTimeoutDays()); + ModerationService moderationService = new ModerationService(notificationService, botConfig.get(event.getGuild()), warnRepository, asyncPool); asyncPool.execute(() -> { try { - event.getHook().sendMessageEmbeds(WarnsListCommand.buildWarnsEmbed(warnRepository.getActiveWarnsByUserId(event.getTarget().getIdLong(), cutoff), event.getTarget())).queue(); + event.getHook().sendMessageEmbeds(WarnsListCommand.buildWarnsEmbed(moderationService.getTotalSeverityWeight(event.getTarget().getIdLong()), event.getTarget())).queue(); } catch (DataAccessException e) { ExceptionLogger.capture(e, WarnsListContext.class.getSimpleName()); } diff --git a/src/main/java/net/discordjug/javabot/systems/moderation/warn/dao/WarnRepository.java b/src/main/java/net/discordjug/javabot/systems/moderation/warn/dao/WarnRepository.java index b339d1d4e..61a144171 100644 --- a/src/main/java/net/discordjug/javabot/systems/moderation/warn/dao/WarnRepository.java +++ b/src/main/java/net/discordjug/javabot/systems/moderation/warn/dao/WarnRepository.java @@ -61,25 +61,7 @@ public Optional findById(long id) throws DataAccessException { return Optional.empty(); } } - - /** - * Gets the total severity weight of all warns for the given user, which - * were created after the given cutoff, and haven't been discarded. - * - * @param userId The id of the user. - * @param cutoff The time after which to look for warns. - * @return The total weight of all warn severities. - * @throws SQLException If an error occurs. - */ - public int getTotalSeverityWeight(long userId, LocalDateTime cutoff) throws DataAccessException { - try { - return jdbcTemplate.queryForObject("SELECT SUM(severity_weight) FROM warn WHERE user_id = ? AND discarded = FALSE AND created_at > ?", (rs, rows)->rs.getInt(1), - userId, Timestamp.valueOf(cutoff)); - }catch (EmptyResultDataAccessException e) { - return 0; - } - } - + /** * Discards all warnings that have been issued to a given user. * @@ -135,12 +117,12 @@ private Warn read(ResultSet rs) throws SQLException { * @return A List with all Warns. */ public List getActiveWarnsByUserId(long userId, LocalDateTime cutoff) { - return jdbcTemplate.query("SELECT * FROM warn WHERE user_id = ? AND discarded = FALSE AND created_at > ?",(rs, row)->this.read(rs), + return jdbcTemplate.query("SELECT * FROM warn WHERE user_id = ? AND discarded = FALSE AND created_at > ? ORDER BY created_at DESC",(rs, row)->this.read(rs), userId, Timestamp.valueOf(cutoff)); } public List getAllWarnsByUserId(long userId) { - return jdbcTemplate.query("SELECT * FROM warn WHERE user_id = ?",(rs, row)->this.read(rs), + return jdbcTemplate.query("SELECT * FROM warn WHERE user_id = ?",(rs, row) -> this.read(rs), userId); } } From 548a7a9097afcd5109e057ac651d2c4a23071c4a Mon Sep 17 00:00:00 2001 From: danthe1st Date: Mon, 12 Feb 2024 17:04:06 +0100 Subject: [PATCH 038/242] make ModerationService a spring bean --- .../user_profile/UserProfileController.java | 12 ++-- .../javabot/systems/moderation/AutoMod.java | 17 ++--- .../systems/moderation/BanCommand.java | 21 ++---- .../DiscordModerationLogListener.java | 12 +--- .../systems/moderation/KickCommand.java | 21 ++---- .../systems/moderation/ModerationService.java | 66 +++++++------------ .../systems/moderation/UnbanCommand.java | 21 ++---- .../timeout/AddTimeoutSubcommand.java | 21 ++---- .../timeout/RemoveTimeoutSubcommand.java | 21 ++---- .../warn/DiscardAllWarnsSubcommand.java | 20 ++---- .../warn/DiscardWarnByIdSubCommand.java | 21 ++---- .../moderation/warn/WarnAddSubcommand.java | 21 ++---- .../moderation/warn/WarnExportSubcommand.java | 20 ++---- .../moderation/warn/WarnsListCommand.java | 20 ++---- .../moderation/warn/WarnsListContext.java | 20 ++---- .../systems/user_commands/ProfileCommand.java | 19 ++---- .../javabot/util/InteractionUtils.java | 19 ++---- 17 files changed, 102 insertions(+), 270 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/api/routes/user_profile/UserProfileController.java b/src/main/java/net/discordjug/javabot/api/routes/user_profile/UserProfileController.java index bf76f7f7d..1c56cf37a 100644 --- a/src/main/java/net/discordjug/javabot/api/routes/user_profile/UserProfileController.java +++ b/src/main/java/net/discordjug/javabot/api/routes/user_profile/UserProfileController.java @@ -11,7 +11,6 @@ import net.discordjug.javabot.systems.help.HelpExperienceService; import net.discordjug.javabot.systems.help.model.HelpAccount; import net.discordjug.javabot.systems.moderation.ModerationService; -import net.discordjug.javabot.systems.moderation.warn.dao.WarnRepository; import net.discordjug.javabot.systems.qotw.QOTWPointsService; import net.discordjug.javabot.systems.qotw.model.QOTWAccount; import net.discordjug.javabot.util.Pair; @@ -44,7 +43,7 @@ public class UserProfileController extends CaffeineCache, UserP private final DataSource dataSource; private final BotConfig botConfig; private final HelpExperienceService helpExperienceService; - private final WarnRepository warnRepository; + private final ModerationService moderationService; /** * The constructor of this class which initializes the {@link Caffeine} cache. @@ -54,10 +53,10 @@ public class UserProfileController extends CaffeineCache, UserP * @param botConfig The main configuration of the bot * @param dataSource A factory for connections to the main database * @param helpExperienceService Service object that handles Help Experience Transactions. - * @param warnRepository DAO for interacting with the set of {@link Warn} objects. + * @param moderationService Service object for moderating members */ @Autowired - public UserProfileController(final JDA jda, QOTWPointsService qotwPointsService, BotConfig botConfig, DataSource dataSource, HelpExperienceService helpExperienceService, WarnRepository warnRepository) { + public UserProfileController(final JDA jda, QOTWPointsService qotwPointsService, BotConfig botConfig, DataSource dataSource, HelpExperienceService helpExperienceService, ModerationService moderationService) { super(Caffeine.newBuilder() .expireAfterWrite(10, TimeUnit.MINUTES) .build() @@ -67,7 +66,7 @@ public UserProfileController(final JDA jda, QOTWPointsService qotwPointsService, this.dataSource = dataSource; this.botConfig = botConfig; this.helpExperienceService = helpExperienceService; - this.warnRepository = warnRepository; + this.moderationService = moderationService; } /** @@ -108,8 +107,7 @@ public ResponseEntity getUserProfile( HelpAccount helpAccount = helpExperienceService.getOrCreateAccount(user.getIdLong()); data.setHelpAccount(HelpAccountData.of(botConfig, helpAccount, guild)); // User Warns - ModerationService moderationService = new ModerationService(null, botConfig.get(guild), warnRepository, null); - data.setWarns(moderationService.getTotalSeverityWeight(user.getIdLong()).contributingWarns()); + data.setWarns(moderationService.getTotalSeverityWeight(guild, user.getIdLong()).contributingWarns()); // Insert into cache getCache().put(new Pair<>(guild.getIdLong(), user.getIdLong()), data); } diff --git a/src/main/java/net/discordjug/javabot/systems/moderation/AutoMod.java b/src/main/java/net/discordjug/javabot/systems/moderation/AutoMod.java index ce76e93f0..ec1948b09 100644 --- a/src/main/java/net/discordjug/javabot/systems/moderation/AutoMod.java +++ b/src/main/java/net/discordjug/javabot/systems/moderation/AutoMod.java @@ -2,7 +2,6 @@ import lombok.extern.slf4j.Slf4j; import net.discordjug.javabot.data.config.BotConfig; -import net.discordjug.javabot.systems.moderation.warn.dao.WarnRepository; import net.discordjug.javabot.systems.moderation.warn.model.WarnSeverity; import net.discordjug.javabot.systems.notification.NotificationService; import net.discordjug.javabot.util.ExceptionLogger; @@ -27,7 +26,6 @@ import java.util.List; import java.util.Objects; import java.util.Scanner; -import java.util.concurrent.ExecutorService; import java.util.concurrent.TimeUnit; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -48,21 +46,18 @@ public class AutoMod extends ListenerAdapter { private final NotificationService notificationService; private final BotConfig botConfig; private List spamUrls; - private final WarnRepository warnRepository; - private final ExecutorService asyncPool; + private final ModerationService moderationService; /** * Constructor of the class, that creates a list of strings with potential spam/scam urls. * @param notificationService The {@link QOTWPointsService} * @param botConfig The main configuration of the bot - * @param asyncPool The main thread pool for asynchronous operations - * @param warnRepository The main thread pool for asynchronous operations + * @param moderationService Service object for moderating members */ - public AutoMod(NotificationService notificationService, BotConfig botConfig, ExecutorService asyncPool, WarnRepository warnRepository) { + public AutoMod(NotificationService notificationService, BotConfig botConfig, ModerationService moderationService) { this.notificationService = notificationService; this.botConfig = botConfig; - this.warnRepository = warnRepository; - this.asyncPool = asyncPool; + this.moderationService = moderationService; try(Scanner scan = new Scanner(new URL("https://raw.githubusercontent.com/DevSpen/scam-links/master/src/links.txt").openStream()).useDelimiter("\\A")) { String response = scan.next(); spamUrls = List.of(response.split("\n")); @@ -138,7 +133,7 @@ private void checkContentAutomod(@Nonnull Message message) { private void doAutomodActions(Message message, String reason) { notificationService.withGuild(message.getGuild()).sendToModerationLog(c -> c.sendMessageFormat("Message by %s: `%s`", message.getAuthor().getAsMention(), message.getContentRaw())); - new ModerationService(notificationService, botConfig.get(message.getGuild()), warnRepository, asyncPool) + moderationService .warn( message.getAuthor(), WarnSeverity.MEDIUM, @@ -163,7 +158,7 @@ private void handleSpam(@Nonnull Message msg, Member member) { if (!msg.getAttachments().isEmpty() && msg.getAttachments().stream().allMatch(a -> Objects.equals(a.getFileExtension(), "java"))) { return; } - new ModerationService(notificationService, botConfig.get(member.getGuild()), warnRepository, asyncPool) + moderationService .timeout( member.getUser(), "Automod: Spam", diff --git a/src/main/java/net/discordjug/javabot/systems/moderation/BanCommand.java b/src/main/java/net/discordjug/javabot/systems/moderation/BanCommand.java index 7544e4313..6f8087ab8 100644 --- a/src/main/java/net/discordjug/javabot/systems/moderation/BanCommand.java +++ b/src/main/java/net/discordjug/javabot/systems/moderation/BanCommand.java @@ -1,8 +1,6 @@ package net.discordjug.javabot.systems.moderation; import net.discordjug.javabot.data.config.BotConfig; -import net.discordjug.javabot.systems.moderation.warn.dao.WarnRepository; -import net.discordjug.javabot.systems.notification.NotificationService; import net.discordjug.javabot.util.Checks; import net.discordjug.javabot.util.Responses; import net.dv8tion.jda.api.Permission; @@ -14,8 +12,6 @@ import net.dv8tion.jda.api.interactions.commands.build.Commands; import net.dv8tion.jda.api.requests.restaction.WebhookMessageCreateAction; -import java.util.concurrent.ExecutorService; - import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -23,22 +19,16 @@ * Command that allows staff-members to ban guild members. */ public class BanCommand extends ModerateUserCommand { - private final NotificationService notificationService; - private final WarnRepository warnRepository; - private final ExecutorService asyncPool; + private final ModerationService moderationService; /** * The constructor of this class, which sets the corresponding {@link net.dv8tion.jda.api.interactions.commands.build.SlashCommandData}. - * @param notificationService The {@link NotificationService} * @param botConfig The main configuration of the bot - * @param asyncPool The main thread pool for asynchronous operations - * @param warnRepository DAO for interacting with the set of {@link Warn} objects.s + * @param moderationService Service object for moderating members */ - public BanCommand(NotificationService notificationService, BotConfig botConfig, ExecutorService asyncPool, WarnRepository warnRepository) { + public BanCommand(BotConfig botConfig, ModerationService moderationService) { super(botConfig); - this.notificationService = notificationService; - this.warnRepository = warnRepository; - this.asyncPool = asyncPool; + this.moderationService = moderationService; setModerationSlashCommandData(Commands.slash("ban", "Ban a user.") .addOption(OptionType.USER, "user", "The user to ban.", true) .addOption(OptionType.STRING, "reason", "The reason for banning this user.", true) @@ -52,8 +42,7 @@ protected WebhookMessageCreateAction handleModerationUserCommand(@Nonnu return Responses.replyInsufficientPermissions(event.getHook(), Permission.BAN_MEMBERS); } boolean quiet = isQuiet(event); - ModerationService service = new ModerationService(notificationService, botConfig, event.getInteraction(), warnRepository, asyncPool); - service.ban(target, reason, commandUser, event.getChannel(), quiet); + moderationService.ban(target, reason, commandUser, event.getChannel(), quiet); return Responses.success(event.getHook(), "User Banned", "%s has been banned.", target.getAsMention()); } } \ No newline at end of file diff --git a/src/main/java/net/discordjug/javabot/systems/moderation/DiscordModerationLogListener.java b/src/main/java/net/discordjug/javabot/systems/moderation/DiscordModerationLogListener.java index 77f1e7aa2..d356e4b37 100644 --- a/src/main/java/net/discordjug/javabot/systems/moderation/DiscordModerationLogListener.java +++ b/src/main/java/net/discordjug/javabot/systems/moderation/DiscordModerationLogListener.java @@ -3,14 +3,11 @@ import java.time.Duration; import java.time.ZonedDateTime; import java.util.EnumSet; -import java.util.concurrent.ExecutorService; import lombok.RequiredArgsConstructor; -import net.discordjug.javabot.data.config.BotConfig; -import net.discordjug.javabot.systems.moderation.warn.dao.WarnRepository; -import net.discordjug.javabot.systems.notification.NotificationService; import net.discordjug.javabot.util.ExceptionLogger; import net.dv8tion.jda.api.audit.ActionType; + import net.dv8tion.jda.api.audit.AuditLogChange; import net.dv8tion.jda.api.audit.AuditLogEntry; import net.dv8tion.jda.api.events.guild.GuildAuditLogEntryCreateEvent; @@ -22,15 +19,10 @@ @RequiredArgsConstructor public class DiscordModerationLogListener extends ListenerAdapter{ - private final NotificationService notificationService; - private final BotConfig botConfig; - private final WarnRepository warnRepository; - private final ExecutorService asyncPool; + private final ModerationService moderationService; @Override public void onGuildAuditLogEntryCreate(GuildAuditLogEntryCreateEvent event) { - ModerationService moderationService = new ModerationService(notificationService, botConfig.get(event.getGuild()), warnRepository, asyncPool); - AuditLogEntry entry = event.getEntry(); long targetUserId = entry.getTargetIdLong(); long moderatorUserId = entry.getUserIdLong(); diff --git a/src/main/java/net/discordjug/javabot/systems/moderation/KickCommand.java b/src/main/java/net/discordjug/javabot/systems/moderation/KickCommand.java index 73a615a47..690dd953f 100644 --- a/src/main/java/net/discordjug/javabot/systems/moderation/KickCommand.java +++ b/src/main/java/net/discordjug/javabot/systems/moderation/KickCommand.java @@ -1,8 +1,6 @@ package net.discordjug.javabot.systems.moderation; import net.discordjug.javabot.data.config.BotConfig; -import net.discordjug.javabot.systems.moderation.warn.dao.WarnRepository; -import net.discordjug.javabot.systems.notification.NotificationService; import net.discordjug.javabot.util.Checks; import net.discordjug.javabot.util.Responses; import net.dv8tion.jda.api.Permission; @@ -14,8 +12,6 @@ import net.dv8tion.jda.api.interactions.commands.build.Commands; import net.dv8tion.jda.api.requests.restaction.WebhookMessageCreateAction; -import java.util.concurrent.ExecutorService; - import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -23,22 +19,16 @@ *

This class represents the /kick command.

*/ public class KickCommand extends ModerateUserCommand { - private final NotificationService notificationService; - private final WarnRepository warnRepository; - private final ExecutorService asyncPool; + private final ModerationService moderationService; /** * The constructor of this class, which sets the corresponding {@link net.dv8tion.jda.api.interactions.commands.build.SlashCommandData}. - * @param notificationService The {@link NotificationService} * @param botConfig The main configuration of the bot - * @param asyncPool The main thread pool for asynchronous operations - * @param warnRepository DAO for interacting with the set of {@link Warn} objects. + * @param moderationService Service object for moderating members */ - public KickCommand(NotificationService notificationService, BotConfig botConfig, ExecutorService asyncPool, WarnRepository warnRepository) { + public KickCommand(BotConfig botConfig, ModerationService moderationService) { super(botConfig); - this.notificationService = notificationService; - this.warnRepository = warnRepository; - this.asyncPool = asyncPool; + this.moderationService = moderationService; setModerationSlashCommandData(Commands.slash("kick", "Kicks a member") .addOption(OptionType.USER, "user", "The user to kick.", true) .addOption(OptionType.STRING, "reason", "The reason for kicking this user.", true) @@ -52,8 +42,7 @@ protected WebhookMessageCreateAction handleModerationUserCommand(@Nonnu return Responses.replyInsufficientPermissions(event.getHook(), Permission.KICK_MEMBERS); } boolean quiet = isQuiet(event); - ModerationService service = new ModerationService(notificationService, botConfig, event.getInteraction(), warnRepository, asyncPool); - service.kick(target, reason, event.getMember(), event.getChannel(), quiet); + moderationService.kick(target, reason, event.getMember(), event.getChannel(), quiet); return Responses.success(event.getHook(), "User Kicked", "%s has been kicked.", target.getAsMention()); } } \ No newline at end of file diff --git a/src/main/java/net/discordjug/javabot/systems/moderation/ModerationService.java b/src/main/java/net/discordjug/javabot/systems/moderation/ModerationService.java index f24b9b154..509fab8a5 100644 --- a/src/main/java/net/discordjug/javabot/systems/moderation/ModerationService.java +++ b/src/main/java/net/discordjug/javabot/systems/moderation/ModerationService.java @@ -1,7 +1,6 @@ package net.discordjug.javabot.systems.moderation; import net.discordjug.javabot.data.config.BotConfig; -import net.discordjug.javabot.data.config.GuildConfig; import net.discordjug.javabot.data.config.guild.ModerationConfig; import net.discordjug.javabot.systems.moderation.warn.dao.WarnRepository; import net.discordjug.javabot.systems.moderation.warn.model.Warn; @@ -13,11 +12,13 @@ import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.entities.*; import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; -import net.dv8tion.jda.api.interactions.Interaction; import net.dv8tion.jda.api.utils.MarkdownUtil; import org.jetbrains.annotations.NotNull; import org.springframework.dao.DataAccessException; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; import javax.annotation.Nonnull; import java.time.Duration; @@ -34,47 +35,16 @@ * This service provides methods for performing moderation actions, like banning * or warning users. */ +@Service +@RequiredArgsConstructor public class ModerationService { private static final int BAN_DELETE_DAYS = 7; private final NotificationService notificationService; - private final ModerationConfig moderationConfig; + private final BotConfig botConfig; private final WarnRepository warnRepository; private final ExecutorService asyncPool; - /** - * Constructs the service. - * - * @param config The {@link GuildConfig} to use. - * @param notificationService The {@link NotificationService} - * @param warnRepository DAO for interacting with the set of {@link Warn} objects. - * @param asyncPool The main thread pool for asynchronous operations - */ - public ModerationService(NotificationService notificationService,@NotNull GuildConfig config, WarnRepository warnRepository, ExecutorService asyncPool) { - this.notificationService = notificationService; - this.moderationConfig = config.getModerationConfig(); - this.warnRepository = warnRepository; - this.asyncPool = asyncPool; - } - - /** - * Constructs the service using information obtained from an interaction. - * - * @param interaction The interaction to use. - * @param notificationService The {@link NotificationService} - * @param botConfig The main configuration of the bot - * @param warnRepository DAO for interacting with the set of {@link Warn} objects. - * @param asyncPool The main thread pool for asynchronous operations - */ - public ModerationService(NotificationService notificationService,BotConfig botConfig, @NotNull Interaction interaction, WarnRepository warnRepository, ExecutorService asyncPool) { - this( - notificationService, - botConfig.get(interaction.getGuild()), - warnRepository, - asyncPool - ); - } - /** * Issues a warning for the given user. * @@ -88,8 +58,9 @@ public ModerationService(NotificationService notificationService,BotConfig botCo public void warn(User user, WarnSeverity severity, String reason, Member warnedBy, MessageChannel channel, boolean quiet) { asyncPool.execute(() -> { try { + ModerationConfig moderationConfig = getModerationConfig(warnedBy); warnRepository.insert(new Warn(user.getIdLong(), warnedBy.getIdLong(), severity, reason)); - long totalSeverity = getTotalSeverityWeight(user.getIdLong()).totalSeverity(); + long totalSeverity = getTotalSeverityWeight(warnedBy.getGuild(), user.getIdLong()).totalSeverity(); MessageEmbed warnEmbed = buildWarnEmbed(user, warnedBy, severity, totalSeverity, reason); notificationService.withUser(user, warnedBy.getGuild()).sendDirectMessage(c -> c.sendMessageEmbeds(warnEmbed)); notificationService.withGuild(moderationConfig.getGuild()).sendToModerationLog(c -> c.sendMessageEmbeds(warnEmbed)); @@ -119,11 +90,13 @@ public void warn(User user, WarnSeverity severity, String reason, Member warnedB * @implNote * The total severity is calculated per warn by considering the severity of all warns after the given warn and subtracting the discount subtrahend corresponding to the given (oldest) warn from the severity. * Then, the maximum discounted severity over all warns is chosen. + * @param guild The guild of the user * @param userId the ID of the user to check * @return the accumulated warn severity weight of the user along with all warns contributing to it. * @see SeverityInformation */ - public SeverityInformation getTotalSeverityWeight(long userId) { + public SeverityInformation getTotalSeverityWeight(Guild guild, long userId) { + ModerationConfig moderationConfig = botConfig.get(guild).getModerationConfig(); LocalDateTime now = LocalDateTime.now(); List activeWarns = warnRepository.getActiveWarnsByUserId(userId, now.minusDays(moderationConfig.getMaxWarnValidityDays())); int accumulatedUndiscountedSeverity = 0; @@ -159,7 +132,7 @@ public void discardAllWarns(User user, Member clearedBy) { warnRepository.discardAll(user.getIdLong()); MessageEmbed embed = buildClearWarnsEmbed(user, clearedBy.getUser()); notificationService.withUser(user, clearedBy.getGuild()).sendDirectMessage(c -> c.sendMessageEmbeds(embed)); - notificationService.withGuild(moderationConfig.getGuild()).sendToModerationLog(c -> c.sendMessageEmbeds(embed)); + notificationService.withGuild(clearedBy.getGuild()).sendToModerationLog(c -> c.sendMessageEmbeds(embed)); } catch (DataAccessException e) { ExceptionLogger.capture(e, ModerationService.class.getSimpleName()); } @@ -173,13 +146,13 @@ public void discardAllWarns(User user, Member clearedBy) { * @param clearedBy The user who cleared the warn. * @return Whether the Warn was discarded or not. */ - public boolean discardWarnById(long id, User clearedBy) { + public boolean discardWarnById(long id, Member clearedBy) { try { Optional warnOptional = warnRepository.findById(id); if (warnOptional.isPresent()) { Warn warn = warnOptional.get(); warnRepository.discardById(warn.getId()); - notificationService.withGuild(moderationConfig.getGuild()).sendToModerationLog(c -> c.sendMessageEmbeds(buildClearWarnsByIdEmbed(warn, clearedBy))); + notificationService.withGuild(clearedBy.getGuild()).sendToModerationLog(c -> c.sendMessageEmbeds(buildClearWarnsByIdEmbed(warn, clearedBy.getUser()))); return true; } } catch (DataAccessException e) { @@ -191,11 +164,13 @@ public boolean discardWarnById(long id, User clearedBy) { /** * Gets warns based on the user id. * + * @param guild The guild to check * @param userId The user's id. * @return A {@link List} with all warns. */ - public List getWarns(long userId) { + public List getWarns(Guild guild, long userId) { try { + ModerationConfig moderationConfig = botConfig.get(guild).getModerationConfig(); WarnRepository repo = warnRepository; LocalDateTime cutoff = LocalDateTime.now().minusDays(moderationConfig.getMaxWarnValidityDays()); return repo.getActiveWarnsByUserId(userId, cutoff); @@ -269,6 +244,7 @@ public void removeTimeout(Member member, String reason, Member removedBy, Messag */ public void ban(User user, String reason, Member bannedBy, MessageChannel channel, boolean quiet) { MessageEmbed banEmbed = buildBanEmbed(user, bannedBy, reason); + ModerationConfig moderationConfig = getModerationConfig(bannedBy); user.openPrivateChannel().flatMap(privateChannel -> privateChannel.sendMessageEmbeds(banEmbed).setContent(moderationConfig.getBanMessageText())).queue(success -> { banAndSendGuildNotifications(user, reason, bannedBy, channel, quiet, banEmbed); }, err-> { @@ -298,6 +274,7 @@ private void banAndSendGuildNotifications(User user, String reason, Member banne public boolean unban(long userId, String reason, Member bannedBy, MessageChannel channel, boolean quiet) { MessageEmbed unbanEmbed = this.buildUnbanEmbed(userId, reason, bannedBy); boolean isBanned = isBanned(bannedBy.getGuild(), userId); + ModerationConfig moderationConfig = getModerationConfig(bannedBy); if (isBanned) { bannedBy.getGuild().unban(User.fromId(userId)).queue(s -> { moderationConfig.getLogChannel().sendMessageEmbeds(unbanEmbed).queue(); @@ -397,6 +374,7 @@ private void sendGuildNotification(Guild guild, MessageEmbed embed) { } private @NotNull MessageEmbed buildWarnEmbed(User user, Member warnedBy, @NotNull WarnSeverity severity, long totalSeverity, String reason) { + ModerationConfig moderationConfig = getModerationConfig(warnedBy); return buildModerationEmbed(user, warnedBy, reason) .setTitle(String.format("Warn Added (%d/%d)", totalSeverity, moderationConfig.getMaxWarnSeverity())) .setColor(Responses.Type.WARN.getColor()) @@ -448,6 +426,10 @@ private void sendGuildNotification(Guild guild, MessageEmbed embed) { .build(); } + private ModerationConfig getModerationConfig(Member member) { + return botConfig.get(member.getGuild()).getModerationConfig(); + } + /** * Records information about the total severity of a user as well as the warns contributing to it. * @param totalSeverity the total severity of the user diff --git a/src/main/java/net/discordjug/javabot/systems/moderation/UnbanCommand.java b/src/main/java/net/discordjug/javabot/systems/moderation/UnbanCommand.java index f671d51ad..4d71b4211 100644 --- a/src/main/java/net/discordjug/javabot/systems/moderation/UnbanCommand.java +++ b/src/main/java/net/discordjug/javabot/systems/moderation/UnbanCommand.java @@ -1,8 +1,6 @@ package net.discordjug.javabot.systems.moderation; import net.discordjug.javabot.data.config.BotConfig; -import net.discordjug.javabot.systems.moderation.warn.dao.WarnRepository; -import net.discordjug.javabot.systems.notification.NotificationService; import net.discordjug.javabot.util.Checks; import net.discordjug.javabot.util.Responses; import net.dv8tion.jda.api.entities.Member; @@ -12,8 +10,6 @@ import net.dv8tion.jda.api.interactions.commands.build.Commands; import net.dv8tion.jda.api.requests.restaction.interactions.ReplyCallbackAction; -import java.util.concurrent.ExecutorService; - import org.jetbrains.annotations.NotNull; /** @@ -21,22 +17,16 @@ * This Command allows staff-members to unban users from the current guild by their id. */ public class UnbanCommand extends ModerateCommand { - private final NotificationService notificationService; - private final WarnRepository warnRepository; - private final ExecutorService asyncPool; + private final ModerationService moderationService; /** * The constructor of this class, which sets the corresponding {@link net.dv8tion.jda.api.interactions.commands.build.SlashCommandData}. - * @param notificationService The {@link NotificationService} * @param botConfig The main configuration of the bot - * @param asyncPool The main thread pool for asynchronous operations - * @param warnRepository DAO for interacting with the set of {@link Warn} objects. + * @param moderationService Service object for moderating members */ - public UnbanCommand(NotificationService notificationService, BotConfig botConfig, ExecutorService asyncPool, WarnRepository warnRepository) { + public UnbanCommand(BotConfig botConfig, ModerationService moderationService) { super(botConfig); - this.notificationService = notificationService; - this.warnRepository = warnRepository; - this.asyncPool = asyncPool; + this.moderationService = moderationService; setModerationSlashCommandData(Commands.slash("unban", "Unbans a member") .addOption(OptionType.STRING, "id", "The id of the user you want to unban", true) .addOption(OptionType.STRING, "reason", "The reason for unbanning this user", true) @@ -53,8 +43,7 @@ protected ReplyCallbackAction handleModerationCommand(@NotNull SlashCommandInter } long id = idOption.getAsLong(); boolean quiet = ModerateUserCommand.isQuiet(botConfig, event); - ModerationService service = new ModerationService(notificationService, botConfig, event.getInteraction(), warnRepository, asyncPool); - if (service.unban(id, reasonOption.getAsString(), event.getMember(), event.getChannel(), quiet)) { + if (moderationService.unban(id, reasonOption.getAsString(), event.getMember(), event.getChannel(), quiet)) { return Responses.success(event, "User Unbanned", "User with id `%s` has been unbanned.", id); } else { return Responses.warning(event, "Could not find banned User with id `%s`", id); diff --git a/src/main/java/net/discordjug/javabot/systems/moderation/timeout/AddTimeoutSubcommand.java b/src/main/java/net/discordjug/javabot/systems/moderation/timeout/AddTimeoutSubcommand.java index 2d53d18a6..3d9ca9cfa 100644 --- a/src/main/java/net/discordjug/javabot/systems/moderation/timeout/AddTimeoutSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/moderation/timeout/AddTimeoutSubcommand.java @@ -1,11 +1,8 @@ package net.discordjug.javabot.systems.moderation.timeout; - import net.discordjug.javabot.data.config.BotConfig; import net.discordjug.javabot.systems.moderation.ModerateUserCommand; import net.discordjug.javabot.systems.moderation.ModerationService; -import net.discordjug.javabot.systems.moderation.warn.dao.WarnRepository; -import net.discordjug.javabot.systems.notification.NotificationService; import net.discordjug.javabot.util.Responses; import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; @@ -20,26 +17,21 @@ import java.time.Duration; import java.time.temporal.ChronoUnit; -import java.util.concurrent.ExecutorService; /** * Subcommand that allows staff-members to add timeouts to a single users. */ public class AddTimeoutSubcommand extends TimeoutSubcommand { - private final NotificationService notificationService; private final BotConfig botConfig; - private final WarnRepository warnRepository; - private final ExecutorService asyncPool; + private final ModerationService moderationService; /** * The constructor of this class, which sets the corresponding {@link SubcommandData}. - * @param notificationService The {@link NotificationService} * @param botConfig The main configuration of the bot - * @param asyncPool The main thread pool for asynchronous operations - * @param warnRepository DAO for interacting with the set of {@link net.discordjug.javabot.systems.moderation.warn.model.Warn} objects. + * @param moderationService Service object for moderating members */ - public AddTimeoutSubcommand(NotificationService notificationService, BotConfig botConfig, ExecutorService asyncPool, WarnRepository warnRepository) { + public AddTimeoutSubcommand(BotConfig botConfig, ModerationService moderationService) { setCommandData(new SubcommandData("add", "Adds a timeout to the specified server member.") .addOptions( new OptionData(OptionType.USER, "member", "The member that should be timed out.", true), @@ -54,10 +46,8 @@ public AddTimeoutSubcommand(NotificationService notificationService, BotConfig b new OptionData(OptionType.BOOLEAN, "quiet", "If true, don't send a message in the server channel where the timeout is issued.", false) ) ); - this.notificationService=notificationService; this.botConfig = botConfig; - this.warnRepository = warnRepository; - this.asyncPool = asyncPool; + this.moderationService = moderationService; } @Override @@ -81,8 +71,7 @@ protected ReplyCallbackAction handleTimeoutCommand(@NotNull SlashCommandInteract if (member.isTimedOut()) { return Responses.error(event, "Could not timeout %s; they're already timed out.", member.getAsMention()); } - ModerationService service = new ModerationService(notificationService, botConfig, event.getInteraction(), warnRepository, asyncPool); - service.timeout(member.getUser(), reasonOption.getAsString(), event.getMember(), duration, channel, quiet); + moderationService.timeout(member.getUser(), reasonOption.getAsString(), event.getMember(), duration, channel, quiet); return Responses.success(event, "User Timed Out", "%s has been timed out.", member.getAsMention()); } } \ No newline at end of file diff --git a/src/main/java/net/discordjug/javabot/systems/moderation/timeout/RemoveTimeoutSubcommand.java b/src/main/java/net/discordjug/javabot/systems/moderation/timeout/RemoveTimeoutSubcommand.java index bd34aafe8..9ed28d3bd 100644 --- a/src/main/java/net/discordjug/javabot/systems/moderation/timeout/RemoveTimeoutSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/moderation/timeout/RemoveTimeoutSubcommand.java @@ -3,8 +3,6 @@ import net.discordjug.javabot.data.config.BotConfig; import net.discordjug.javabot.systems.moderation.ModerateUserCommand; import net.discordjug.javabot.systems.moderation.ModerationService; -import net.discordjug.javabot.systems.moderation.warn.dao.WarnRepository; -import net.discordjug.javabot.systems.notification.NotificationService; import net.discordjug.javabot.util.Responses; import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; @@ -15,8 +13,6 @@ import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; import net.dv8tion.jda.api.requests.restaction.interactions.ReplyCallbackAction; -import java.util.concurrent.ExecutorService; - import org.jetbrains.annotations.NotNull; /** @@ -24,23 +20,17 @@ * This Subcommand allows staff-members to manually remove a timeout. */ public class RemoveTimeoutSubcommand extends TimeoutSubcommand { - private final NotificationService notificationService; private final BotConfig botConfig; - private final WarnRepository warnRepository; - private final ExecutorService asyncPool; + private final ModerationService moderationService; /** * The constructor of this class, which sets the corresponding {@link SubcommandData}. - * @param notificationService The {@link NotificationService} * @param botConfig The main configuration of the bot - * @param asyncPool The main thread pool for asynchronous operations - * @param warnRepository The main thread pool for asynchronous operations + * @param moderationService Service object for moderating members */ - public RemoveTimeoutSubcommand(NotificationService notificationService, BotConfig botConfig, ExecutorService asyncPool, WarnRepository warnRepository) { - this.notificationService = notificationService; + public RemoveTimeoutSubcommand(BotConfig botConfig, ModerationService moderationService) { this.botConfig = botConfig; - this.warnRepository = warnRepository; - this.asyncPool = asyncPool; + this.moderationService = moderationService; setCommandData(new SubcommandData("remove", "Removes a timeout from the specified server member.") .addOptions( new OptionData(OptionType.USER, "member", "The member whose timeout should be removed.", true), @@ -64,8 +54,7 @@ protected ReplyCallbackAction handleTimeoutCommand(@NotNull SlashCommandInteract if (!member.isTimedOut()) { return Responses.error(event, "Could not remove timeout from member %s; they're not timed out.", member.getAsMention()); } - ModerationService service = new ModerationService(notificationService, botConfig.get(event.getGuild()), warnRepository, asyncPool); - service.removeTimeout(member, reasonOption.getAsString(), event.getMember(), channel, quiet); + moderationService.removeTimeout(member, reasonOption.getAsString(), event.getMember(), channel, quiet); return Responses.success(event, "Timeout Removed", "%s's timeout has been removed.", member.getAsMention()); } } diff --git a/src/main/java/net/discordjug/javabot/systems/moderation/warn/DiscardAllWarnsSubcommand.java b/src/main/java/net/discordjug/javabot/systems/moderation/warn/DiscardAllWarnsSubcommand.java index 37e56ac3b..6847e442f 100644 --- a/src/main/java/net/discordjug/javabot/systems/moderation/warn/DiscardAllWarnsSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/moderation/warn/DiscardAllWarnsSubcommand.java @@ -3,8 +3,6 @@ import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand; import net.discordjug.javabot.data.config.BotConfig; import net.discordjug.javabot.systems.moderation.ModerationService; -import net.discordjug.javabot.systems.moderation.warn.dao.WarnRepository; -import net.discordjug.javabot.systems.notification.NotificationService; import net.discordjug.javabot.util.Checks; import net.discordjug.javabot.util.Responses; import net.discordjug.javabot.util.UserUtils; @@ -14,31 +12,23 @@ import net.dv8tion.jda.api.interactions.commands.OptionType; import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; -import java.util.concurrent.ExecutorService; - import org.jetbrains.annotations.NotNull; /** *

This class represents the /warn discard-all command.

*/ public class DiscardAllWarnsSubcommand extends SlashCommand.Subcommand { - private final NotificationService notificationService; private final BotConfig botConfig; - private final WarnRepository warnRepository; - private final ExecutorService asyncPool; + private final ModerationService moderationService; /** * The constructor of this class, which sets the corresponding {@link SubcommandData}. - * @param notificationService The {@link NotificationService} * @param botConfig The main configuration of the bot - * @param warnRepository DAO for interacting with the set of {@link net.discordjug.javabot.systems.moderation.warn.model.Warn} objects. - * @param asyncPool The main thread pool for asynchronous operations + * @param moderationService Service object for moderating members */ - public DiscardAllWarnsSubcommand(NotificationService notificationService, BotConfig botConfig, WarnRepository warnRepository, ExecutorService asyncPool) { - this.notificationService = notificationService; + public DiscardAllWarnsSubcommand(BotConfig botConfig, ModerationService moderationService) { this.botConfig = botConfig; - this.warnRepository = warnRepository; - this.asyncPool = asyncPool; + this.moderationService = moderationService; setCommandData(new SubcommandData("discard-all", "Discards all warns from a single user.") .addOption(OptionType.USER, "user", "The user which warns should be discarded.", true) ); @@ -60,7 +50,7 @@ public void execute(@NotNull SlashCommandInteractionEvent event) { return; } User target = userMapping.getAsUser(); - new ModerationService(notificationService, botConfig, event.getInteraction(), warnRepository, asyncPool).discardAllWarns(target, event.getMember()); + moderationService.discardAllWarns(target, event.getMember()); Responses.success(event, "Warns Discarded", "Successfully discarded all warns from **%s**.", UserUtils.getUserTag(target)).queue(); } } diff --git a/src/main/java/net/discordjug/javabot/systems/moderation/warn/DiscardWarnByIdSubCommand.java b/src/main/java/net/discordjug/javabot/systems/moderation/warn/DiscardWarnByIdSubCommand.java index 1658f4129..b2a389db4 100644 --- a/src/main/java/net/discordjug/javabot/systems/moderation/warn/DiscardWarnByIdSubCommand.java +++ b/src/main/java/net/discordjug/javabot/systems/moderation/warn/DiscardWarnByIdSubCommand.java @@ -3,8 +3,6 @@ import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand; import net.discordjug.javabot.data.config.BotConfig; import net.discordjug.javabot.systems.moderation.ModerationService; -import net.discordjug.javabot.systems.moderation.warn.dao.WarnRepository; -import net.discordjug.javabot.systems.notification.NotificationService; import net.discordjug.javabot.util.Checks; import net.discordjug.javabot.util.Responses; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; @@ -12,31 +10,23 @@ import net.dv8tion.jda.api.interactions.commands.OptionType; import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; -import java.util.concurrent.ExecutorService; - import org.jetbrains.annotations.NotNull; /** *

This class represents the /warn discard-by-id command.

*/ public class DiscardWarnByIdSubCommand extends SlashCommand.Subcommand { - private final NotificationService notificationService; private final BotConfig botConfig; - private final WarnRepository warnRepository; - private final ExecutorService asyncPool; + private final ModerationService moderationService; /** * The constructor of this class, which sets the corresponding {@link SubcommandData}. - * @param notificationService The {@link NotificationService} * @param botConfig The main configuration of the bot - * @param warnRepository DAO for interacting with the set of {@link net.discordjug.javabot.systems.moderation.warn.model.Warn} objects. - * @param asyncPool The main thread pool for asynchronous operations + * @param moderationService Service object for moderating members */ - public DiscardWarnByIdSubCommand(NotificationService notificationService, BotConfig botConfig, WarnRepository warnRepository, ExecutorService asyncPool) { - this.notificationService = notificationService; + public DiscardWarnByIdSubCommand(BotConfig botConfig, ModerationService moderationService) { this.botConfig = botConfig; - this.warnRepository = warnRepository; - this.asyncPool = asyncPool; + this.moderationService = moderationService; setCommandData(new SubcommandData("discard-by-id", "Discards a single warn, based on its id.") .addOption(OptionType.INTEGER, "id", "The warn's unique identifier.", true) ); @@ -58,8 +48,7 @@ public void execute(@NotNull SlashCommandInteractionEvent event) { return; } int id = idMapping.getAsInt(); - ModerationService service = new ModerationService(notificationService, botConfig, event, warnRepository, asyncPool); - if (service.discardWarnById(id, event.getUser())) { + if (moderationService.discardWarnById(id, event.getMember())) { Responses.success(event, "Warn Discarded", "Successfully discarded the specified warn with id `%s`", id).queue(); } else { Responses.error(event, "Could not find and/or discard warn with id `%s`", id).queue(); diff --git a/src/main/java/net/discordjug/javabot/systems/moderation/warn/WarnAddSubcommand.java b/src/main/java/net/discordjug/javabot/systems/moderation/warn/WarnAddSubcommand.java index 96660c52f..6ade358a5 100644 --- a/src/main/java/net/discordjug/javabot/systems/moderation/warn/WarnAddSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/moderation/warn/WarnAddSubcommand.java @@ -4,9 +4,7 @@ import net.discordjug.javabot.data.config.BotConfig; import net.discordjug.javabot.systems.moderation.ModerateUserCommand; import net.discordjug.javabot.systems.moderation.ModerationService; -import net.discordjug.javabot.systems.moderation.warn.dao.WarnRepository; import net.discordjug.javabot.systems.moderation.warn.model.WarnSeverity; -import net.discordjug.javabot.systems.notification.NotificationService; import net.discordjug.javabot.util.Checks; import net.discordjug.javabot.util.Responses; import net.dv8tion.jda.api.entities.User; @@ -16,8 +14,6 @@ import net.dv8tion.jda.api.interactions.commands.build.OptionData; import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; -import java.util.concurrent.ExecutorService; - import org.jetbrains.annotations.NotNull; /** @@ -25,23 +21,17 @@ * This Subcommand allows staff-members to add a single warn to any user. */ public class WarnAddSubcommand extends SlashCommand.Subcommand { - private final NotificationService notificationService; private final BotConfig botConfig; - private final WarnRepository warnRepository; - private final ExecutorService asyncPool; + private final ModerationService moderationService; /** * The constructor of this class, which sets the corresponding {@link SubcommandData}. - * @param notificationService The {@link NotificationService} * @param botConfig The main configuration of the bot - * @param asyncPool The main thread pool for asynchronous operations - * @param warnRepository DAO for interacting with the set of {@link net.discordjug.javabot.systems.moderation.warn.model.Warn} objects. + * @param moderationService Service object for moderating members */ - public WarnAddSubcommand(NotificationService notificationService, BotConfig botConfig, ExecutorService asyncPool, WarnRepository warnRepository) { - this.notificationService = notificationService; + public WarnAddSubcommand(BotConfig botConfig, ModerationService moderationService) { this.botConfig = botConfig; - this.warnRepository = warnRepository; - this.asyncPool = asyncPool; + this.moderationService = moderationService; setCommandData(new SubcommandData("add", "Sends a warning to a user, and increases their warn severity rating.") .addOptions( new OptionData(OptionType.USER, "user", "The user to warn.", true), @@ -79,8 +69,7 @@ public void execute(@NotNull SlashCommandInteractionEvent event) { return; } boolean quiet = ModerateUserCommand.isQuiet(botConfig, event); - ModerationService service = new ModerationService(notificationService, botConfig, event, warnRepository, asyncPool); - service.warn(target, severity, reasonMapping.getAsString(), event.getMember(), event.getChannel(), quiet); + moderationService.warn(target, severity, reasonMapping.getAsString(), event.getMember(), event.getChannel(), quiet); Responses.success(event, "User Warned", "%s has been successfully warned.", target.getAsMention()).queue(); } } diff --git a/src/main/java/net/discordjug/javabot/systems/moderation/warn/WarnExportSubcommand.java b/src/main/java/net/discordjug/javabot/systems/moderation/warn/WarnExportSubcommand.java index becf322e0..9d5bc1f4d 100644 --- a/src/main/java/net/discordjug/javabot/systems/moderation/warn/WarnExportSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/moderation/warn/WarnExportSubcommand.java @@ -3,9 +3,7 @@ import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand; import net.discordjug.javabot.data.config.BotConfig; import net.discordjug.javabot.systems.moderation.ModerationService; -import net.discordjug.javabot.systems.moderation.warn.dao.WarnRepository; import net.discordjug.javabot.systems.moderation.warn.model.Warn; -import net.discordjug.javabot.systems.notification.NotificationService; import net.discordjug.javabot.util.Checks; import net.discordjug.javabot.util.ExceptionLogger; import net.discordjug.javabot.util.Responses; @@ -28,7 +26,6 @@ import java.nio.charset.StandardCharsets; import java.util.Iterator; import java.util.List; -import java.util.concurrent.ExecutorService; import org.jetbrains.annotations.NotNull; @@ -37,23 +34,17 @@ * This Subcommand allows staff-members to add a single warn to any user. */ public class WarnExportSubcommand extends SlashCommand.Subcommand { - private final NotificationService notificationService; private final BotConfig botConfig; - private final WarnRepository warnRepository; - private final ExecutorService asyncPool; + private final ModerationService moderationService; /** * The constructor of this class, which sets the corresponding {@link SubcommandData}. - * @param notificationService The {@link NotificationService} * @param botConfig The main configuration of the bot - * @param asyncPool The main thread pool for asynchronous operations - * @param warnRepository DAO for interacting with the set of {@link net.discordjug.javabot.systems.moderation.warn.model.Warn} objects. + * @param moderationService Service object for moderating members */ - public WarnExportSubcommand(NotificationService notificationService, BotConfig botConfig, ExecutorService asyncPool, WarnRepository warnRepository) { - this.notificationService = notificationService; + public WarnExportSubcommand(BotConfig botConfig, ModerationService moderationService) { this.botConfig = botConfig; - this.warnRepository = warnRepository; - this.asyncPool = asyncPool; + this.moderationService = moderationService; setCommandData(new SubcommandData("export", "Exports a list of all warns of a user") .addOptions( new OptionData(OptionType.USER, "user", "The user to warn.", true) @@ -77,8 +68,7 @@ public void execute(@NotNull SlashCommandInteractionEvent event) { return; } User target = userMapping.getAsUser(); - ModerationService service = new ModerationService(notificationService, botConfig, event, warnRepository, asyncPool); - List warns = service.getAllWarns(target.getIdLong()); + List warns = moderationService.getAllWarns(target.getIdLong()); PipedInputStream pis=new PipedInputStream(); try(PrintWriter pw=new PrintWriter(new OutputStreamWriter(new BufferedOutputStream(new PipedOutputStream(pis)), StandardCharsets.UTF_8))){ event.replyEmbeds(new EmbedBuilder() diff --git a/src/main/java/net/discordjug/javabot/systems/moderation/warn/WarnsListCommand.java b/src/main/java/net/discordjug/javabot/systems/moderation/warn/WarnsListCommand.java index 8e2bb3ec9..74fad45d2 100644 --- a/src/main/java/net/discordjug/javabot/systems/moderation/warn/WarnsListCommand.java +++ b/src/main/java/net/discordjug/javabot/systems/moderation/warn/WarnsListCommand.java @@ -11,11 +11,8 @@ import org.springframework.dao.DataAccessException; import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand; -import net.discordjug.javabot.data.config.BotConfig; import net.discordjug.javabot.systems.moderation.ModerationService; -import net.discordjug.javabot.systems.moderation.warn.dao.WarnRepository; import net.discordjug.javabot.systems.moderation.warn.model.Warn; -import net.discordjug.javabot.systems.notification.NotificationService; import net.discordjug.javabot.util.ExceptionLogger; import net.discordjug.javabot.util.Responses; import net.discordjug.javabot.util.UserUtils; @@ -32,23 +29,17 @@ * This Command allows users to see all their active warns. */ public class WarnsListCommand extends SlashCommand { - private final BotConfig botConfig; - private final WarnRepository warnRepository; private final ExecutorService asyncPool; - private final NotificationService notificationService; + private final ModerationService moderationService; /** * The constructor of this class, which sets the corresponding {@link net.dv8tion.jda.api.interactions.commands.build.SlashCommandData}. - * @param botConfig The main configuration of the bot * @param asyncPool The main thread pool for asynchronous operations - * @param warnRepository DAO for interacting with the set of {@link Warn} objects. - * @param notificationService service object for notifying users + * @param moderationService Service object for moderating members */ - public WarnsListCommand(BotConfig botConfig, ExecutorService asyncPool, WarnRepository warnRepository, NotificationService notificationService) { - this.botConfig = botConfig; - this.warnRepository = warnRepository; + public WarnsListCommand(ExecutorService asyncPool, ModerationService moderationService) { this.asyncPool = asyncPool; - this.notificationService = notificationService; + this.moderationService = moderationService; setCommandData(Commands.slash("warns", "Shows a list of all recent warning.") .addOption(OptionType.USER, "user", "If given, shows the recent warns of the given user instead.", false) .setGuildOnly(true) @@ -98,10 +89,9 @@ public void execute(@NotNull SlashCommandInteractionEvent event) { return; } event.deferReply(false).queue(); - ModerationService moderationService = new ModerationService(notificationService, botConfig, event, warnRepository, asyncPool); asyncPool.execute(() -> { try { - event.getHook().sendMessageEmbeds(buildWarnsEmbed(moderationService.getTotalSeverityWeight(user.getIdLong()), user)).queue(); + event.getHook().sendMessageEmbeds(buildWarnsEmbed(moderationService.getTotalSeverityWeight(event.getGuild(), user.getIdLong()), user)).queue(); } catch (DataAccessException e) { ExceptionLogger.capture(e, WarnsListCommand.class.getSimpleName()); } diff --git a/src/main/java/net/discordjug/javabot/systems/moderation/warn/WarnsListContext.java b/src/main/java/net/discordjug/javabot/systems/moderation/warn/WarnsListContext.java index bea804a60..6067d8769 100644 --- a/src/main/java/net/discordjug/javabot/systems/moderation/warn/WarnsListContext.java +++ b/src/main/java/net/discordjug/javabot/systems/moderation/warn/WarnsListContext.java @@ -1,10 +1,7 @@ package net.discordjug.javabot.systems.moderation.warn; import xyz.dynxsty.dih4jda.interactions.commands.application.ContextCommand; -import net.discordjug.javabot.data.config.BotConfig; import net.discordjug.javabot.systems.moderation.ModerationService; -import net.discordjug.javabot.systems.moderation.warn.dao.WarnRepository; -import net.discordjug.javabot.systems.notification.NotificationService; import net.discordjug.javabot.util.ExceptionLogger; import net.discordjug.javabot.util.Responses; import net.dv8tion.jda.api.events.interaction.command.UserContextInteractionEvent; @@ -19,23 +16,17 @@ * This Command allows users to see all their active warns. */ public class WarnsListContext extends ContextCommand.User { - private final BotConfig botConfig; private final ExecutorService asyncPool; - private final WarnRepository warnRepository; - private final NotificationService notificationService; + private final ModerationService moderationService; /** * The constructor of this class, which sets the corresponding {@link net.dv8tion.jda.api.interactions.commands.build.CommandData}. - * @param botConfig The main configuration of the bot * @param asyncPool The main thread pool for asynchronous operations - * @param warnRepository DAO for interacting with the set of {@link net.discordjug.javabot.systems.moderation.warn.model.Warn} objects. - * @param notificationService service object for notifying users + * @param moderationService Service object for moderating members */ - public WarnsListContext(BotConfig botConfig, ExecutorService asyncPool, WarnRepository warnRepository, NotificationService notificationService) { - this.botConfig = botConfig; + public WarnsListContext(ExecutorService asyncPool, ModerationService moderationService) { this.asyncPool = asyncPool; - this.warnRepository = warnRepository; - this.notificationService = notificationService; + this.moderationService = moderationService; setCommandData(Commands.user("Show Warns") .setGuildOnly(true) ); @@ -48,10 +39,9 @@ public void execute(UserContextInteractionEvent event) { return; } event.deferReply(false).queue(); - ModerationService moderationService = new ModerationService(notificationService, botConfig.get(event.getGuild()), warnRepository, asyncPool); asyncPool.execute(() -> { try { - event.getHook().sendMessageEmbeds(WarnsListCommand.buildWarnsEmbed(moderationService.getTotalSeverityWeight(event.getTarget().getIdLong()), event.getTarget())).queue(); + event.getHook().sendMessageEmbeds(WarnsListCommand.buildWarnsEmbed(moderationService.getTotalSeverityWeight(event.getGuild(), event.getTarget().getIdLong()), event.getTarget())).queue(); } catch (DataAccessException e) { ExceptionLogger.capture(e, WarnsListContext.class.getSimpleName()); } diff --git a/src/main/java/net/discordjug/javabot/systems/user_commands/ProfileCommand.java b/src/main/java/net/discordjug/javabot/systems/user_commands/ProfileCommand.java index e1e4a3f39..ed93f035c 100644 --- a/src/main/java/net/discordjug/javabot/systems/user_commands/ProfileCommand.java +++ b/src/main/java/net/discordjug/javabot/systems/user_commands/ProfileCommand.java @@ -5,9 +5,7 @@ import net.discordjug.javabot.data.config.GuildConfig; import net.discordjug.javabot.systems.help.HelpExperienceService; import net.discordjug.javabot.systems.moderation.ModerationService; -import net.discordjug.javabot.systems.moderation.warn.dao.WarnRepository; import net.discordjug.javabot.systems.moderation.warn.model.Warn; -import net.discordjug.javabot.systems.notification.NotificationService; import net.discordjug.javabot.systems.qotw.QOTWPointsService; import net.discordjug.javabot.util.ExceptionLogger; import net.discordjug.javabot.util.Responses; @@ -26,35 +24,28 @@ import java.time.Instant; import java.util.List; import java.util.Optional; -import java.util.concurrent.ExecutorService; /** *

This class represents the /profile command.

*/ public class ProfileCommand extends SlashCommand { private final QOTWPointsService qotwPointsService; - private final NotificationService notificationService; private final BotConfig botConfig; private final HelpExperienceService helpExperienceService; - private final WarnRepository warnRepository; - private final ExecutorService asyncPool; + private final ModerationService moderationService; /** * The constructor of this class, which sets the corresponding {@link net.dv8tion.jda.api.interactions.commands.build.SlashCommandData}. * @param qotwPointsService The {@link QOTWPointsService} - * @param notificationService The {@link NotificationService} * @param botConfig The main configuration of the bot * @param helpExperienceService Service object that handles Help Experience Transactions. - * @param warnRepository DAO for interacting with the set of {@link Warn} objects. - * @param asyncPool The main thread pool for asynchronous operations + * @param moderationService Service object for moderating members */ - public ProfileCommand(QOTWPointsService qotwPointsService, NotificationService notificationService, BotConfig botConfig, HelpExperienceService helpExperienceService, WarnRepository warnRepository, ExecutorService asyncPool) { + public ProfileCommand(QOTWPointsService qotwPointsService, BotConfig botConfig, HelpExperienceService helpExperienceService, ModerationService moderationService) { this.qotwPointsService = qotwPointsService; - this.notificationService = notificationService; this.botConfig = botConfig; this.helpExperienceService = helpExperienceService; - this.warnRepository = warnRepository; - this.asyncPool = asyncPool; + this.moderationService = moderationService; setCommandData(Commands.slash("profile", "Shows your server profile.") .addOption(OptionType.USER, "user", "If given, shows the profile of the user instead.", false) .setGuildOnly(true) @@ -81,7 +72,7 @@ public void execute(@NotNull SlashCommandInteractionEvent event) { private @NotNull MessageEmbed buildProfileEmbed(@NotNull Member member) throws SQLException { GuildConfig config = botConfig.get(member.getGuild()); - List warns = new ModerationService(notificationService, config, warnRepository, asyncPool).getWarns(member.getIdLong()); + List warns = moderationService.getWarns(member.getGuild(), member.getIdLong()); long points = qotwPointsService.getPoints(member.getIdLong()); List roles = member.getRoles(); String status = member.getOnlineStatus().name(); diff --git a/src/main/java/net/discordjug/javabot/util/InteractionUtils.java b/src/main/java/net/discordjug/javabot/util/InteractionUtils.java index 65f579d48..a356eefb1 100644 --- a/src/main/java/net/discordjug/javabot/util/InteractionUtils.java +++ b/src/main/java/net/discordjug/javabot/util/InteractionUtils.java @@ -10,9 +10,7 @@ import net.discordjug.javabot.data.config.GuildConfig; import net.discordjug.javabot.systems.moderation.ModerationService; import net.discordjug.javabot.systems.moderation.report.ReportManager; -import net.discordjug.javabot.systems.moderation.warn.dao.WarnRepository; import net.discordjug.javabot.systems.moderation.warn.model.WarnSeverity; -import net.discordjug.javabot.systems.notification.NotificationService; import net.dv8tion.jda.api.Permission; import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.Member; @@ -38,7 +36,6 @@ import java.util.Arrays; import java.util.List; -import java.util.concurrent.ExecutorService; import javax.annotation.CheckReturnValue; import javax.annotation.Nonnull; @@ -73,11 +70,9 @@ public class InteractionUtils implements ButtonHandler, ModalHandler, StringSele */ private static final String DELETE_ORIGINAL_TEMPLATE = "utils:delete:%d"; - private final NotificationService notificationService; private final BotConfig botConfig; - private final WarnRepository warnRepository; - private final ExecutorService asyncPool; private final ReportManager reportManager; + private final ModerationService moderationService; /** * Deletes a message, only if the person deleting the message is the author @@ -125,10 +120,9 @@ private void kick(ModalInteraction interaction, @NotNull Guild guild, String mem Responses.error(interaction.getHook(), "Missing permissions").queue(); return; } - ModerationService service = new ModerationService(notificationService, botConfig, interaction, warnRepository, asyncPool); guild.getJDA().retrieveUserById(memberId).queue( user -> { - service.kick(user, reason, interaction.getMember(), interaction.getMessageChannel(), false); + moderationService.kick(user, reason, interaction.getMember(), interaction.getMessageChannel(), false); interaction.getMessage().editMessageComponents(ActionRow.of(Button.danger(interaction.getModalId(), "Kicked by " + UserUtils.getUserTag(interaction.getUser())).asDisabled())).queue(); resolveIfInReport(interaction.getChannel(), interaction.getUser()); }, error -> Responses.error(interaction.getHook(), "Could not find member: " + error.getMessage()).queue() @@ -140,10 +134,9 @@ private void warn(ModalInteraction interaction, @NotNull Guild guild, String mem Responses.error(interaction.getHook(), "Missing permissions").queue(); return; } - ModerationService service = new ModerationService(notificationService, botConfig, interaction, warnRepository, asyncPool); guild.getJDA().retrieveUserById(memberId).queue( user -> { - service.warn(user, severity, reason, interaction.getMember(), interaction.getMessageChannel(), false); + moderationService.warn(user, severity, reason, interaction.getMember(), interaction.getMessageChannel(), false); interaction.getHook().editOriginalComponents(ActionRow.of(Button.primary(interaction.getModalId(), "Warned by " + UserUtils.getUserTag(interaction.getUser())).asDisabled())).queue(); resolveIfInReport(interaction.getChannel(), interaction.getUser()); }, error -> Responses.error(interaction.getHook(), "Could not find member: " + error.getMessage()).queue() @@ -155,10 +148,9 @@ private void ban(ModalInteraction interaction, @NotNull Guild guild, String memb Responses.error(interaction.getHook(), "Missing permissions").queue(); return; } - ModerationService service = new ModerationService(notificationService, botConfig, interaction, warnRepository, asyncPool); guild.getJDA().retrieveUserById(memberId).queue( user -> { - service.ban(user, reason, interaction.getMember(), interaction.getMessageChannel(), false); + moderationService.ban(user, reason, interaction.getMember(), interaction.getMessageChannel(), false); interaction.getMessage().editMessageComponents(ActionRow.of(Button.danger(interaction.getModalId(), "Banned by " + UserUtils.getUserTag(interaction.getUser())).asDisabled())).queue(); resolveIfInReport(interaction.getChannel(), interaction.getUser()); }, error -> Responses.error(interaction.getHook(), "Could not find member: " + error.getMessage()).queue() @@ -181,8 +173,7 @@ private void unban(ModalInteraction interaction, long memberId, String reason) { Responses.error(interaction.getHook(), "Missing permissions").queue(); return; } - ModerationService service = new ModerationService(notificationService, botConfig, interaction, warnRepository, asyncPool); - service.unban(memberId, reason, interaction.getMember(), interaction.getMessageChannel(), false); + moderationService.unban(memberId, reason, interaction.getMember(), interaction.getMessageChannel(), false); interaction.getMessage().editMessageComponents(ActionRow.of(Button.secondary(interaction.getModalId(), "Unbanned by " + UserUtils.getUserTag(interaction.getUser())).asDisabled())).queue(); } From 44de33d366b73f37a3e7af8af7743720166e00a9 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Mon, 12 Feb 2024 19:31:08 +0100 Subject: [PATCH 039/242] tests for warn decay --- .../systems/moderation/ModerationService.java | 9 +- .../systems/moderation/WarnDecayTest.java | 157 ++++++++++++++++++ 2 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 src/test/java/net/discordjug/javabot/systems/moderation/WarnDecayTest.java diff --git a/src/main/java/net/discordjug/javabot/systems/moderation/ModerationService.java b/src/main/java/net/discordjug/javabot/systems/moderation/ModerationService.java index 509fab8a5..26f854960 100644 --- a/src/main/java/net/discordjug/javabot/systems/moderation/ModerationService.java +++ b/src/main/java/net/discordjug/javabot/systems/moderation/ModerationService.java @@ -97,13 +97,18 @@ public void warn(User user, WarnSeverity severity, String reason, Member warnedB */ public SeverityInformation getTotalSeverityWeight(Guild guild, long userId) { ModerationConfig moderationConfig = botConfig.get(guild).getModerationConfig(); - LocalDateTime now = LocalDateTime.now(); - List activeWarns = warnRepository.getActiveWarnsByUserId(userId, now.minusDays(moderationConfig.getMaxWarnValidityDays())); + List activeWarns = warnRepository.getActiveWarnsByUserId(userId, LocalDateTime.now().minusDays(moderationConfig.getMaxWarnValidityDays())); + return calculateSeverityWeight(moderationConfig, activeWarns); + } + + static SeverityInformation calculateSeverityWeight(ModerationConfig moderationConfig, List activeWarns) { int accumulatedUndiscountedSeverity = 0; long maxSeverity = 0; long usedSeverityDiscount = 0; List contributingWarns = Collections.emptyList(); + LocalDateTime now = LocalDateTime.now(); + for (int i = 0; i < activeWarns.size(); i++) { Warn warn = activeWarns.get(i); accumulatedUndiscountedSeverity += warn.getSeverityWeight(); diff --git a/src/test/java/net/discordjug/javabot/systems/moderation/WarnDecayTest.java b/src/test/java/net/discordjug/javabot/systems/moderation/WarnDecayTest.java new file mode 100644 index 000000000..63b11b356 --- /dev/null +++ b/src/test/java/net/discordjug/javabot/systems/moderation/WarnDecayTest.java @@ -0,0 +1,157 @@ +package net.discordjug.javabot.systems.moderation; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import net.discordjug.javabot.data.config.guild.ModerationConfig; +import net.discordjug.javabot.systems.moderation.ModerationService.SeverityInformation; +import net.discordjug.javabot.systems.moderation.warn.model.Warn; +import net.discordjug.javabot.systems.moderation.warn.model.WarnSeverity; + +/** + * Tests for warn decay. + */ +public class WarnDecayTest { + @Test + void testWithNoWarns() { + ModerationConfig config = new ModerationConfig(); + SeverityInformation result = ModerationService.calculateSeverityWeight(config, Collections.emptyList()); + assertEquals(new SeverityInformation(0, 0, Collections.emptyList()), result); + } + + @Test + void testWithNewWarn() { + ModerationConfig config = new ModerationConfig(); + List warns = List.of(createWarn(LocalDateTime.now())); + SeverityInformation result = ModerationService.calculateSeverityWeight(config, warns); + assertEquals(new SeverityInformation(20, 0, warns), result); + } + + @Test + void testWithOnceDiscountedWarn() { + ModerationConfig config = new ModerationConfig(); + config.setWarnDecayAmount(5); + config.setWarnDecayDays(1); + + List warns = List.of(createWarn(LocalDateTime.now().minusDays(1))); + SeverityInformation result = ModerationService.calculateSeverityWeight(config, warns); + assertEquals(new SeverityInformation(15, 5, warns), result); + } + + @Test + void testWithMultipleTimesDiscountedWarn() { + ModerationConfig config = new ModerationConfig(); + config.setWarnDecayAmount(5); + config.setWarnDecayDays(1); + + List warns = List.of(createWarn(LocalDateTime.now().minusDays(2))); + SeverityInformation result = ModerationService.calculateSeverityWeight(config, warns); + assertEquals(new SeverityInformation(10, 10, warns), result); + } + + @Test + void testWithMultipleDiscountedWarns() { + ModerationConfig config = new ModerationConfig(); + config.setWarnDecayAmount(5); + config.setWarnDecayDays(1); + + List warns = List.of( + createWarn(LocalDateTime.now().minusDays(2)), + createWarn(LocalDateTime.now().minusDays(5), WarnSeverity.HIGH) + ); + SeverityInformation result = ModerationService.calculateSeverityWeight(config, warns); + assertEquals(new SeverityInformation(75, 25, warns), result); + } + + @Test + void testWithTooOldWarn() { + ModerationConfig config = new ModerationConfig(); + config.setWarnDecayAmount(5); + config.setWarnDecayDays(1); + + List warns = List.of(createWarn(LocalDateTime.now().minusDays(10))); + SeverityInformation result = ModerationService.calculateSeverityWeight(config, warns); + assertEquals(new SeverityInformation(0, 0, Collections.emptyList()), result); + } + + @Test + void testWithTooOldAndRecentWarns() { + ModerationConfig config = new ModerationConfig(); + config.setWarnDecayAmount(5); + config.setWarnDecayDays(1); + + Warn recentWarn = createWarn(LocalDateTime.now().minusDays(2)); + List warns = List.of( + recentWarn, + createWarn(LocalDateTime.now().minusDays(8)) + ); + SeverityInformation result = ModerationService.calculateSeverityWeight(config, warns); + assertEquals(new SeverityInformation(10, 10, List.of(recentWarn)), result); + } + + @Test + void testWarnReenablingFutureWarns() { + ModerationConfig config = new ModerationConfig(); + config.setWarnDecayAmount(5); + config.setWarnDecayDays(1); + + List warns = List.of( + createWarn(LocalDateTime.now().minusDays(2)), + createWarn(LocalDateTime.now().minusDays(8)), + createWarn(LocalDateTime.now().minusDays(10), WarnSeverity.HIGH) + ); + SeverityInformation result = ModerationService.calculateSeverityWeight(config, warns); + assertEquals(new SeverityInformation(70, 50, warns), result); + } + + @Test + void testWithMultipleRecentAndOldWarns() { + ModerationConfig config = new ModerationConfig(); + config.setWarnDecayAmount(5); + config.setWarnDecayDays(1); + + List expected = List.of( + createWarn(LocalDateTime.now().minusDays(2)),//20 + createWarn(LocalDateTime.now().minusDays(5), WarnSeverity.HIGH)//80, -25 + ); + List warns = new ArrayList<>(expected); + warns.add(createWarn(LocalDateTime.now().minusDays(10)));//ignored + + SeverityInformation result = ModerationService.calculateSeverityWeight(config, warns); + assertEquals(new SeverityInformation(75, 25, expected), result); + } + + @Test + void testMultipleOldWarns() { + ModerationConfig config = new ModerationConfig(); + config.setWarnDecayAmount(5); + config.setWarnDecayDays(1); + + List warns = List.of( + //would all be ignored on their own but outweigh decay together + createWarn(LocalDateTime.now().minusDays(5)), + createWarn(LocalDateTime.now().minusDays(5)), + createWarn(LocalDateTime.now().minusDays(5)), + createWarn(LocalDateTime.now().minusDays(5)), + createWarn(LocalDateTime.now().minusDays(5)) + ); + SeverityInformation result = ModerationService.calculateSeverityWeight(config, warns); + assertEquals(new SeverityInformation(75, 25, warns), result); + } + + private Warn createWarn(LocalDateTime creationTimestamp) { + return createWarn(creationTimestamp, WarnSeverity.LOW); + } + + private Warn createWarn(LocalDateTime creationTimestamp, WarnSeverity severity) { + Warn warn = new Warn(0, 0, severity, "a"); + warn.setCreatedAt(creationTimestamp); + return warn; + } +} From 09037aa5592d036b65b2ea8e5d0a16afeddac0d2 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Tue, 13 Feb 2024 10:16:50 +0100 Subject: [PATCH 040/242] change /warn discard-all to only discard active warns --- .../javabot/systems/moderation/ModerationService.java | 11 ++++++++--- .../systems/moderation/warn/dao/WarnRepository.java | 10 ++++++---- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/systems/moderation/ModerationService.java b/src/main/java/net/discordjug/javabot/systems/moderation/ModerationService.java index 26f854960..f2cf8f5e4 100644 --- a/src/main/java/net/discordjug/javabot/systems/moderation/ModerationService.java +++ b/src/main/java/net/discordjug/javabot/systems/moderation/ModerationService.java @@ -97,7 +97,7 @@ public void warn(User user, WarnSeverity severity, String reason, Member warnedB */ public SeverityInformation getTotalSeverityWeight(Guild guild, long userId) { ModerationConfig moderationConfig = botConfig.get(guild).getModerationConfig(); - List activeWarns = warnRepository.getActiveWarnsByUserId(userId, LocalDateTime.now().minusDays(moderationConfig.getMaxWarnValidityDays())); + List activeWarns = warnRepository.getActiveWarnsByUserId(userId, getEarliestActiveWarnTimestamp(moderationConfig)); return calculateSeverityWeight(moderationConfig, activeWarns); } @@ -132,9 +132,10 @@ static SeverityInformation calculateSeverityWeight(ModerationConfig moderationCo * @param clearedBy The user who cleared the warns. */ public void discardAllWarns(User user, Member clearedBy) { + ModerationConfig moderationConfig = getModerationConfig(clearedBy); asyncPool.execute(() -> { try { - warnRepository.discardAll(user.getIdLong()); + warnRepository.discardAll(user.getIdLong(), getEarliestActiveWarnTimestamp(moderationConfig)); MessageEmbed embed = buildClearWarnsEmbed(user, clearedBy.getUser()); notificationService.withUser(user, clearedBy.getGuild()).sendDirectMessage(c -> c.sendMessageEmbeds(embed)); notificationService.withGuild(clearedBy.getGuild()).sendToModerationLog(c -> c.sendMessageEmbeds(embed)); @@ -144,6 +145,10 @@ public void discardAllWarns(User user, Member clearedBy) { }); } + private LocalDateTime getEarliestActiveWarnTimestamp(ModerationConfig moderationConfig) { + return LocalDateTime.now().minusDays(moderationConfig.getMaxWarnValidityDays()); + } + /** * Clears a warn by discarding the Warn with the corresponding id. * @@ -177,7 +182,7 @@ public List getWarns(Guild guild, long userId) { try { ModerationConfig moderationConfig = botConfig.get(guild).getModerationConfig(); WarnRepository repo = warnRepository; - LocalDateTime cutoff = LocalDateTime.now().minusDays(moderationConfig.getMaxWarnValidityDays()); + LocalDateTime cutoff = getEarliestActiveWarnTimestamp(moderationConfig); return repo.getActiveWarnsByUserId(userId, cutoff); } catch (DataAccessException e) { ExceptionLogger.capture(e, getClass().getSimpleName()); diff --git a/src/main/java/net/discordjug/javabot/systems/moderation/warn/dao/WarnRepository.java b/src/main/java/net/discordjug/javabot/systems/moderation/warn/dao/WarnRepository.java index 61a144171..866ef7ca2 100644 --- a/src/main/java/net/discordjug/javabot/systems/moderation/warn/dao/WarnRepository.java +++ b/src/main/java/net/discordjug/javabot/systems/moderation/warn/dao/WarnRepository.java @@ -63,16 +63,18 @@ public Optional findById(long id) throws DataAccessException { } /** - * Discards all warnings that have been issued to a given user. + * Discards all warnings that have been issued to a given user after a given timestamp. * * @param userId The id of the user to discard warnings for. + * @param cutoff timestamp specifying which warns should be discarded. Only warns after the cutoff are discarded. * @throws SQLException If an error occurs. */ - public void discardAll(long userId) throws DataAccessException { + public void discardAll(long userId, LocalDateTime cutoff) throws DataAccessException { jdbcTemplate.update(""" UPDATE warn SET discarded = TRUE - WHERE user_id = ?""", - userId); + WHERE user_id = ? + AND created_at > ?""", + userId, Timestamp.valueOf(cutoff)); } /** From f450c9d44beb6e935ff09797fc1f4de286be0a00 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Sat, 24 Feb 2024 11:52:13 +0100 Subject: [PATCH 041/242] address some SpotBugs warnings --- .../h2db/commands/ExportSchemaSubcommand.java | 8 ++++---- .../h2db/commands/ExportTableSubcommand.java | 8 ++++---- .../commands/MessageCacheInfoSubcommand.java | 2 +- .../data/h2db/message_cache/MessageCache.java | 4 +++- .../qotw/submissions/SubmissionManager.java | 6 ++---- .../RunScheduledTaskCommand.java | 5 ++++- .../tags/commands/TagsSubcommand.java | 2 +- .../javabot/util/IndentationHelper.java | 2 +- .../net/discordjug/javabot/util/Plotter.java | 4 ---- .../javabot/util/StringResourceCache.java | 18 +++++++++--------- .../javabot/util/IndentationHelperTest.java | 18 ++++-------------- 11 files changed, 33 insertions(+), 44 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/data/h2db/commands/ExportSchemaSubcommand.java b/src/main/java/net/discordjug/javabot/data/h2db/commands/ExportSchemaSubcommand.java index adc833c9d..1140df895 100644 --- a/src/main/java/net/discordjug/javabot/data/h2db/commands/ExportSchemaSubcommand.java +++ b/src/main/java/net/discordjug/javabot/data/h2db/commands/ExportSchemaSubcommand.java @@ -14,8 +14,8 @@ import java.nio.file.Files; import java.nio.file.Path; import java.sql.Connection; -import java.sql.PreparedStatement; import java.sql.SQLException; +import java.sql.Statement; import java.util.concurrent.ExecutorService; import javax.sql.DataSource; @@ -51,9 +51,9 @@ public void execute(SlashCommandInteractionEvent event) { boolean includeData = event.getOption("include-data", false, OptionMapping::getAsBoolean); event.deferReply(false).queue(); asyncPool.submit(() -> { - try (Connection con = dataSource.getConnection()) { - PreparedStatement stmt = con.prepareStatement(String.format("SCRIPT %s TO '%s';", includeData ? "" : "NODATA", SCHEMA_FILE)); - boolean success = stmt.execute(); + try (Connection con = dataSource.getConnection(); + Statement stmt = con.createStatement()) { + boolean success = stmt.execute(String.format("SCRIPT %s TO '%s';", includeData ? "" : "NODATA", SCHEMA_FILE)); if (!success) { event.getHook().sendMessage("Exporting the schema was not successful.").queue(); } else { diff --git a/src/main/java/net/discordjug/javabot/data/h2db/commands/ExportTableSubcommand.java b/src/main/java/net/discordjug/javabot/data/h2db/commands/ExportTableSubcommand.java index 776798014..ab111f902 100644 --- a/src/main/java/net/discordjug/javabot/data/h2db/commands/ExportTableSubcommand.java +++ b/src/main/java/net/discordjug/javabot/data/h2db/commands/ExportTableSubcommand.java @@ -16,8 +16,8 @@ import java.nio.file.Files; import java.nio.file.Path; import java.sql.Connection; -import java.sql.PreparedStatement; import java.sql.SQLException; +import java.sql.Statement; import java.util.concurrent.ExecutorService; import javax.sql.DataSource; @@ -71,9 +71,9 @@ public void execute(SlashCommandInteractionEvent event) { } event.deferReply(false).queue(); asyncPool.submit(() -> { - try (Connection con = dataSource.getConnection()) { - PreparedStatement stmt = con.prepareStatement(String.format("SCRIPT %s TO '%s' TABLE %s;", includeData ? "COLUMNS" : "NODATA", TABLE_FILE, tableOption.getAsString())); - boolean success = stmt.execute(); + try (Connection con = dataSource.getConnection(); + Statement stmt = con.createStatement()) { + boolean success = stmt.execute(String.format("SCRIPT %s TO '%s' TABLE %s;", includeData ? "COLUMNS" : "NODATA", TABLE_FILE, tableOption.getAsString())); if (!success) { event.getHook().sendMessage("Exporting the table was not successful.").queue(); } else { diff --git a/src/main/java/net/discordjug/javabot/data/h2db/commands/MessageCacheInfoSubcommand.java b/src/main/java/net/discordjug/javabot/data/h2db/commands/MessageCacheInfoSubcommand.java index f50b27377..5086883a2 100644 --- a/src/main/java/net/discordjug/javabot/data/h2db/commands/MessageCacheInfoSubcommand.java +++ b/src/main/java/net/discordjug/javabot/data/h2db/commands/MessageCacheInfoSubcommand.java @@ -50,7 +50,7 @@ private MessageEmbed buildInfoEmbed(GuildConfig config, User author) { .setTitle("Message Cache Info") .setColor(Responses.Type.DEFAULT.getColor()) .addField("Table Size", dbActions.getLogicalSize("message_cache") + " bytes", false) - .addField("Message Count", String.valueOf(messageCache.messageCount), true) + .addField("Message Count", String.valueOf(messageCache.getMessageCount()), true) .addField("Cached (Memory)", String.format("%s/%s (%.2f%%)", messageCache.cache.size(), maxMessages, ((float) messageCache.cache.size() / maxMessages) * 100), true) .addField("Cached (Database)", String.format("%s/%s (%.2f%%)", messages, maxMessages, ((float) messages / maxMessages) * 100), true) .build(); diff --git a/src/main/java/net/discordjug/javabot/data/h2db/message_cache/MessageCache.java b/src/main/java/net/discordjug/javabot/data/h2db/message_cache/MessageCache.java index 318615a32..579fd32e3 100644 --- a/src/main/java/net/discordjug/javabot/data/h2db/message_cache/MessageCache.java +++ b/src/main/java/net/discordjug/javabot/data/h2db/message_cache/MessageCache.java @@ -1,5 +1,6 @@ package net.discordjug.javabot.data.h2db.message_cache; +import lombok.Getter; import lombok.extern.slf4j.Slf4j; import net.discordjug.javabot.data.config.BotConfig; import net.discordjug.javabot.data.config.guild.MessageCacheConfig; @@ -48,7 +49,8 @@ public class MessageCache { * If a certain threshold is reached, messages will be synchronized to reduce the chances of loosing * messages during an unexpected shutdown. */ - public int messageCount = 0; + @Getter + private int messageCount = 0; private final ExecutorService asyncPool; private final BotConfig botConfig; diff --git a/src/main/java/net/discordjug/javabot/systems/qotw/submissions/SubmissionManager.java b/src/main/java/net/discordjug/javabot/systems/qotw/submissions/SubmissionManager.java index d8f11d57e..2e6d2e04c 100644 --- a/src/main/java/net/discordjug/javabot/systems/qotw/submissions/SubmissionManager.java +++ b/src/main/java/net/discordjug/javabot/systems/qotw/submissions/SubmissionManager.java @@ -83,10 +83,8 @@ public WebhookMessageCreateAction handleSubmission(@NotNull ButtonInteraction thread.sendMessage(member.getAsMention()) .setEmbeds(buildSubmissionThreadEmbed(event.getUser(), questionOptional.get(), config)) .setComponents(ActionRow.of(Button.danger("qotw-submission:delete", "Delete Submission"))) - .queue(s -> { - }, err -> ExceptionLogger.capture(err, getClass().getSimpleName())); - QOTWSubmission submission = new QOTWSubmission(thread); - submission.setAuthor(member.getUser()); + .queue(s -> {}, + err -> ExceptionLogger.capture(err, getClass().getSimpleName())); } else { thread.sendMessage("Could not retrieve current QOTW Question. Please contact an Administrator if you think that this is a mistake.") .queue(); diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/RunScheduledTaskCommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/RunScheduledTaskCommand.java index 5eee719f4..c7ece3996 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/RunScheduledTaskCommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/RunScheduledTaskCommand.java @@ -67,7 +67,10 @@ public void execute(SlashCommandInteractionEvent event) { //CHECKSTYLE:OFF This is a handler for all sort of failures that could possibly happen }catch (RuntimeException e) { //CHECKSTYLE:ON - Responses.error(event, "Task failed with an exception", e.getClass().getName() + (e.getMessage() == null ? "" : ": "+e.getMessage())); + Responses.error(event, + "Task failed with an exception: %s", + e.getClass().getName() + (e.getMessage() == null ? "" : ": "+e.getMessage())) + .queue(); } }, () -> { Responses.error(event, "Cannot find task `%s`", name).queue(); diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/tags/commands/TagsSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/tags/commands/TagsSubcommand.java index 14563667e..4b6703890 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/tags/commands/TagsSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/tags/commands/TagsSubcommand.java @@ -37,7 +37,7 @@ public void execute(@NotNull SlashCommandInteractionEvent event) { handleCustomTagsSubcommand(event).queue(); } catch (SQLException e) { ExceptionLogger.capture(e, getClass().getSimpleName()); - Responses.error(event, "An error occurred while executing this command."); + Responses.error(event, "An error occurred while executing this command.").queue(); } } diff --git a/src/main/java/net/discordjug/javabot/util/IndentationHelper.java b/src/main/java/net/discordjug/javabot/util/IndentationHelper.java index 458d29b89..72bbbef79 100644 --- a/src/main/java/net/discordjug/javabot/util/IndentationHelper.java +++ b/src/main/java/net/discordjug/javabot/util/IndentationHelper.java @@ -199,6 +199,6 @@ private static boolean isEscaped(StringBuilder builder, int index) { for (--index;index > 0 && index < builder.length() && builder.charAt(index) == '\\'; index--) { numberOfCharacters++; } - return numberOfCharacters % 2 == 1; + return numberOfCharacters % 2 != 0; } } \ No newline at end of file diff --git a/src/main/java/net/discordjug/javabot/util/Plotter.java b/src/main/java/net/discordjug/javabot/util/Plotter.java index 924946928..59ed2279e 100644 --- a/src/main/java/net/discordjug/javabot/util/Plotter.java +++ b/src/main/java/net/discordjug/javabot/util/Plotter.java @@ -3,9 +3,7 @@ import java.awt.Color; import java.awt.Graphics2D; import java.awt.image.BufferedImage; -import java.util.HashMap; import java.util.List; -import java.util.Map; /** * Creates diagrams. @@ -75,8 +73,6 @@ private void plotEntries(Graphics2D g2d, int x, int y, int width, int height) { Bar bar = entry.second(); int totalBarHeight = 0; - Map colors = new HashMap<>(); - double barSum = 0; for (Pair barSquares : bar.elements()) { diff --git a/src/main/java/net/discordjug/javabot/util/StringResourceCache.java b/src/main/java/net/discordjug/javabot/util/StringResourceCache.java index 3aaffae7b..1e827fa75 100644 --- a/src/main/java/net/discordjug/javabot/util/StringResourceCache.java +++ b/src/main/java/net/discordjug/javabot/util/StringResourceCache.java @@ -24,18 +24,18 @@ private StringResourceCache() { * @return The resources' content as a String. */ public static String load(String resourceName) { - String sql = CACHE.get(resourceName); - if (sql == null) { - InputStream is = StringResourceCache.class.getResourceAsStream(resourceName); - if (is == null) throw new RuntimeException("Could not load " + resourceName); - try { - sql = new String(is.readAllBytes(), StandardCharsets.UTF_8); - } catch (IOException e) { + String content = CACHE.get(resourceName); + if (content == null) { + try(InputStream is = StringResourceCache.class.getResourceAsStream(resourceName)){ + if (is == null) throw new RuntimeException("Could not load " + resourceName); + content = new String(is.readAllBytes(), StandardCharsets.UTF_8); + }catch (IOException e) { ExceptionLogger.capture(e, StringResourceCache.class.getSimpleName()); throw new UncheckedIOException(e); } - CACHE.put(resourceName, sql); + + CACHE.put(resourceName, content); } - return sql; + return content; } } diff --git a/src/test/java/net/discordjug/javabot/util/IndentationHelperTest.java b/src/test/java/net/discordjug/javabot/util/IndentationHelperTest.java index 4d753246e..bfbbd351f 100644 --- a/src/test/java/net/discordjug/javabot/util/IndentationHelperTest.java +++ b/src/test/java/net/discordjug/javabot/util/IndentationHelperTest.java @@ -3,12 +3,8 @@ import org.junit.jupiter.api.Test; import java.io.IOException; -import java.net.URISyntaxException; -import java.nio.file.Files; -import java.nio.file.Path; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.fail; /** * Test for the {@link IndentationHelper} class. @@ -16,19 +12,14 @@ public class IndentationHelperTest { /** * Tests the {@link IndentationHelper#formatIndentation(String, IndentationHelper.IndentationType)} method. + * @throws IOException if any I/O error occurs indicating an issue with the test */ @Test - public void testFormatIndentation() { + public void testFormatIndentation() throws IOException { String[] unformatted = null; String[] formatted = null; - try { - unformatted = Files.readString(Path.of(IndentationHelper.class.getResource("/Unformatted Strings.txt").toURI())).split("----"); - formatted = Files.readString(Path.of(IndentationHelper.class.getResource("/Formatted Strings.txt").toURI())).split("----"); - } catch (NullPointerException | URISyntaxException e) { - fail("Files to run the test not present", e); - } catch (IOException e) { - e.printStackTrace(); - } + unformatted = StringResourceCache.load("/Unformatted Strings.txt").split("----"); + formatted = StringResourceCache.load("/Formatted Strings.txt").split("----"); for (int i = 0, k = 0; i < unformatted.length; i++) { assertEquals(formatted[k++], IndentationHelper.formatIndentation(unformatted[i], IndentationHelper.IndentationType.FOUR_SPACES), "Method failed to format a text with four spaces correctly"); @@ -36,6 +27,5 @@ public void testFormatIndentation() { assertEquals(formatted[k++], IndentationHelper.formatIndentation(unformatted[i], IndentationHelper.IndentationType.TABS), "Method failed to format a text with tabs correctly."); assertEquals(formatted[k++], IndentationHelper.formatIndentation(unformatted[i], IndentationHelper.IndentationType.NULL), "Method returned a String not matching the input"); } - } } From dc24ebf7572b882583ead708f6c41ff3ff8ab2f4 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Mon, 4 Mar 2024 10:52:12 +0100 Subject: [PATCH 042/242] allow users to create threads for their reports --- .../data/config/guild/ModerationConfig.java | 5 ++ .../moderation/report/ReportManager.java | 75 ++++++++++++++++--- 2 files changed, 70 insertions(+), 10 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/data/config/guild/ModerationConfig.java b/src/main/java/net/discordjug/javabot/data/config/guild/ModerationConfig.java index 507d4f5bb..a0d637f78 100644 --- a/src/main/java/net/discordjug/javabot/data/config/guild/ModerationConfig.java +++ b/src/main/java/net/discordjug/javabot/data/config/guild/ModerationConfig.java @@ -16,6 +16,7 @@ @EqualsAndHashCode(callSuper = true) public class ModerationConfig extends GuildConfigItem { private long reportChannelId = 0; + private long reportUserThreadHolderId = 0; private long applicationChannelId = 0; private long logChannelId = 0; private long suggestionChannelId = 0; @@ -90,6 +91,10 @@ public class ModerationConfig extends GuildConfigItem { public TextChannel getReportChannel() { return this.getGuild().getTextChannelById(this.reportChannelId); } + + public TextChannel getReportUserThreadHolder() { + return this.getGuild().getTextChannelById(this.reportUserThreadHolderId); + } public TextChannel getApplicationChannel() { return this.getGuild().getTextChannelById(this.applicationChannelId); diff --git a/src/main/java/net/discordjug/javabot/systems/moderation/report/ReportManager.java b/src/main/java/net/discordjug/javabot/systems/moderation/report/ReportManager.java index 6414a8c01..2db102f10 100644 --- a/src/main/java/net/discordjug/javabot/systems/moderation/report/ReportManager.java +++ b/src/main/java/net/discordjug/javabot/systems/moderation/report/ReportManager.java @@ -17,6 +17,7 @@ import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.entities.*; import net.dv8tion.jda.api.entities.channel.Channel; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; import net.dv8tion.jda.api.entities.channel.concrete.ThreadChannel; import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent; @@ -43,14 +44,59 @@ */ @Slf4j @RequiredArgsConstructor -@AutoDetectableComponentHandler({"resolve-report","report"}) +@AutoDetectableComponentHandler({"resolve-report", ReportManager.REPORT_INTERACTION_NAME}) public class ReportManager implements ButtonHandler, ModalHandler { + static final String REPORT_INTERACTION_NAME = "report"; private final BotConfig botConfig; @Override public void handleButton(@NotNull ButtonInteractionEvent event, Button button) { event.deferReply(true).queue(); String[] id = ComponentIdBuilder.split(event.getComponentId()); + if ("resolve-report".equals(id[0])) { + handleResolveReportButton(event, id); + } else if (REPORT_INTERACTION_NAME.equals(id[0])&&"create-thread".equals(id[1])) { + createReportUserThread(event, id); + }else { + Responses.error(event.getHook(), "Unexpected button").queue(); + } + } + + private void createReportUserThread(ButtonInteractionEvent event, String[] id) { + TextChannel reportUserChannel = botConfig.get(event.getGuild()) + .getModerationConfig() + .getReportUserThreadHolder(); + ThreadChannel reportThread = event.getGuild().getThreadChannelById(id[2]); + List reportEmbeds = event.getMessage().getEmbeds(); + String title; + if (reportEmbeds.isEmpty()) { + title = "report information"; + } else { + title = reportEmbeds.get(0).getTitle(); + } + reportUserChannel + .createThreadChannel(title, true) + .queue(reporterThread -> { + reporterThread.getManager().setInvitable(false).queue(); + reporterThread + .sendMessage(event.getUser().getAsMention() + + "\nYou can provide additional information regarding your report here.\n" + + "Messages sent in this thread can be seen by staff members but not other users.") + .addEmbeds(reportEmbeds) + .queue(); + reportThread.addThreadMember(event.getUser()).queue(); + reportThread + .sendMessageEmbeds( + new EmbedBuilder() + .setTitle("Additional information from reporter") + .setDescription("The reporter created a thread for additional information: " + reporterThread.getAsMention() + "\n\n[thread link](" + reporterThread.getJumpUrl() + ")") + .build()) + .queue(); + Responses.info(event.getHook(), "Information thread created", "The thread "+reporterThread.getAsMention()+" has been created for you. You can provide additional details related to your report there.").queue(); + }); + } + + private void handleResolveReportButton(ButtonInteractionEvent event, String[] id) { ThreadChannel thread = event.getGuild().getThreadChannelById(id[1]); if (thread == null) { Responses.error(event.getHook(), "Could not find the corresponding thread channel.").queue(); @@ -64,7 +110,7 @@ public void handleButton(@NotNull ButtonInteractionEvent event, Button button) { /** * Resolves a report thread. * This closes the current thread. - * Tis method does not check whether the current thread is actually a report thread. + * This method does not check whether the current thread is actually a report thread. * @param resolver the {@link User} responsible for resolving the report * @param reportThread the thread of the report to resolve */ @@ -99,7 +145,7 @@ protected Modal buildUserReportModal(@NotNull UserContextInteractionEvent event) .setMaxLength(MessageEmbed.VALUE_MAX_LENGTH) .build(); String title = "Report " + UserUtils.getUserTag(event.getTarget()); - return Modal.create(ComponentIdBuilder.build("report", "user", event.getTarget().getId()), title.substring(0, Math.min(title.length(), Modal.MAX_TITLE_LENGTH))) + return Modal.create(ComponentIdBuilder.build(REPORT_INTERACTION_NAME, "user", event.getTarget().getId()), title.substring(0, Math.min(title.length(), Modal.MAX_TITLE_LENGTH))) .addActionRow(messageInput) .build(); } @@ -120,7 +166,7 @@ protected Modal buildMessageReportModal(@NotNull MessageContextInteractionEvent TextInput messageInput = TextInput.create("reason", "Report Description", TextInputStyle.PARAGRAPH) .setMaxLength(MessageEmbed.VALUE_MAX_LENGTH) .build(); - return Modal.create(ComponentIdBuilder.build("report", "message", event.getTarget().getId()), title.substring(0, Math.min(title.length(), Modal.MAX_TITLE_LENGTH))) + return Modal.create(ComponentIdBuilder.build(REPORT_INTERACTION_NAME, "message", event.getTarget().getId()), title.substring(0, Math.min(title.length(), Modal.MAX_TITLE_LENGTH))) .addActionRow(messageInput) .build(); } @@ -133,7 +179,7 @@ protected Modal buildMessageReportModal(@NotNull MessageContextInteractionEvent * @param targetId The targeted user's id. * @return The {@link WebhookMessageCreateAction}. */ - protected WebhookMessageCreateAction handleUserReport(InteractionHook hook, @NotNull String reason, String targetId) { + WebhookMessageCreateAction handleUserReport(InteractionHook hook, @NotNull String reason, String targetId) { if (reason.isBlank()) { return Responses.error(hook, "No report reason was provided."); } @@ -147,9 +193,10 @@ protected WebhookMessageCreateAction handleUserReport(InteractionHook h return; } reportChannel.sendMessageEmbeds(embed.build()) - .queue(m -> this.createReportThread(m, target.getIdLong(), config.getModerationConfig())); - embed.setDescription("Successfully reported " + "`" + UserUtils.getUserTag(target) + "`!\nYour report has been send to our Moderators"); - hook.sendMessageEmbeds(embed.build()).queue(); + .queue(m -> this.createReportThread(m, target.getIdLong(), config.getModerationConfig(), + reportThread -> { + sendReportResponse(hook, target, embed, reportThread); + })); }, failure -> { Responses.error(hook, "The user to report seems not to exist any more.").queue(); log.warn("Cannot retrieve user {} when reporting them", targetId, failure); @@ -157,6 +204,15 @@ protected WebhookMessageCreateAction handleUserReport(InteractionHook h return null; } + private void sendReportResponse(InteractionHook hook, User targetUser, EmbedBuilder reportEmbed, ThreadChannel reportThread) { + reportEmbed.setDescription("Successfully reported " + "`" + UserUtils.getUserTag(targetUser) + "`!\nYour report has been send to our Moderators.\nIn case you want to supply additional details, please use the \"Create thread\" button below."); + hook.sendMessageEmbeds(reportEmbed.build()) + .addActionRow(Button.secondary( + ComponentIdBuilder.build(REPORT_INTERACTION_NAME, "create-thread",reportThread.getId()), + "Create thread")) + .queue(); + } + private void handleMessageReport(ModalInteractionEvent event, String messageId) { String reason = event.getValue("reason").getAsString(); if (reason.isBlank()) { @@ -170,12 +226,11 @@ private void handleMessageReport(ModalInteractionEvent event, String messageId) embed.addField("Message", String.format("[Jump to Message](%s)", target.getJumpUrl()), false); MessageChannel reportChannel = config.getModerationConfig().getReportChannel(); reportChannel.sendMessageEmbeds(embed.build()).queue(m -> createReportThread(m, target.getAuthor().getIdLong(), config.getModerationConfig(), thread->{ + sendReportResponse(event.getHook(), target.getAuthor(), embed, thread); WebhookUtil.ensureWebhookExists(thread.getParentChannel().asStandardGuildMessageChannel(), wh->{ WebhookUtil.mirrorMessageToWebhook(wh, target, target.getContentRaw(), thread.getIdLong(), null, null); }); })); - embed.setDescription("Successfully reported " + "`" + UserUtils.getUserTag(target.getAuthor()) + "`!\nYour report has been send to our Moderators"); - event.getHook().sendMessageEmbeds(embed.build()).queue(); }, failure -> { Responses.error(event.getHook(), "The author of the message to report seems not to exist any more.").queue(); log.info("Cannot retrieve reported message {} in channel {} - the message might have been deleted", messageId, event.getChannel(), failure); From b27436d27d771b2eff612db60257623a2652cba8 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Mon, 4 Mar 2024 11:03:16 +0100 Subject: [PATCH 043/242] disable create thread component after use --- .../systems/moderation/report/ReportManager.java | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/systems/moderation/report/ReportManager.java b/src/main/java/net/discordjug/javabot/systems/moderation/report/ReportManager.java index 2db102f10..ff4ecd9e9 100644 --- a/src/main/java/net/discordjug/javabot/systems/moderation/report/ReportManager.java +++ b/src/main/java/net/discordjug/javabot/systems/moderation/report/ReportManager.java @@ -51,14 +51,13 @@ public class ReportManager implements ButtonHandler, ModalHandler { @Override public void handleButton(@NotNull ButtonInteractionEvent event, Button button) { - event.deferReply(true).queue(); String[] id = ComponentIdBuilder.split(event.getComponentId()); if ("resolve-report".equals(id[0])) { handleResolveReportButton(event, id); } else if (REPORT_INTERACTION_NAME.equals(id[0])&&"create-thread".equals(id[1])) { createReportUserThread(event, id); }else { - Responses.error(event.getHook(), "Unexpected button").queue(); + Responses.error(event, "Unexpected button").queue(); } } @@ -67,6 +66,10 @@ private void createReportUserThread(ButtonInteractionEvent event, String[] id) { .getModerationConfig() .getReportUserThreadHolder(); ThreadChannel reportThread = event.getGuild().getThreadChannelById(id[2]); + if(reportThread==null) { + Responses.error(event, "This report has been handled already.").queue(); + return; + } List reportEmbeds = event.getMessage().getEmbeds(); String title; if (reportEmbeds.isEmpty()) { @@ -84,7 +87,7 @@ private void createReportUserThread(ButtonInteractionEvent event, String[] id) { + "Messages sent in this thread can be seen by staff members but not other users.") .addEmbeds(reportEmbeds) .queue(); - reportThread.addThreadMember(event.getUser()).queue(); + reporterThread.addThreadMember(event.getUser()).queue(); reportThread .sendMessageEmbeds( new EmbedBuilder() @@ -92,11 +95,15 @@ private void createReportUserThread(ButtonInteractionEvent event, String[] id) { .setDescription("The reporter created a thread for additional information: " + reporterThread.getAsMention() + "\n\n[thread link](" + reporterThread.getJumpUrl() + ")") .build()) .queue(); - Responses.info(event.getHook(), "Information thread created", "The thread "+reporterThread.getAsMention()+" has been created for you. You can provide additional details related to your report there.").queue(); + event.editComponents(ActionRow.of(event.getComponent().asDisabled())).queue(success -> { + Responses.info(event.getHook(), "Information thread created", "The thread "+reporterThread.getAsMention()+" has been created for you. You can provide additional details related to your report there.").queue(); + }); + }); } private void handleResolveReportButton(ButtonInteractionEvent event, String[] id) { + event.deferReply(true).queue(); ThreadChannel thread = event.getGuild().getThreadChannelById(id[1]); if (thread == null) { Responses.error(event.getHook(), "Could not find the corresponding thread channel.").queue(); From f7027e83692a88b88b59702dacb8059214eec33d Mon Sep 17 00:00:00 2001 From: danthe1st Date: Mon, 4 Mar 2024 11:08:33 +0100 Subject: [PATCH 044/242] remove unused method and useless whitespace --- .../javabot/systems/moderation/report/ReportManager.java | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/systems/moderation/report/ReportManager.java b/src/main/java/net/discordjug/javabot/systems/moderation/report/ReportManager.java index ff4ecd9e9..dd5c7ea0a 100644 --- a/src/main/java/net/discordjug/javabot/systems/moderation/report/ReportManager.java +++ b/src/main/java/net/discordjug/javabot/systems/moderation/report/ReportManager.java @@ -254,10 +254,6 @@ private ActionRow setComponents(long targetId, long threadId) { ); } - private void createReportThread(Message message, long targetId, ModerationConfig config) { - createReportThread(message, targetId, config, thread->{}); - } - private void createReportThread(Message message, long targetId, ModerationConfig config, Consumer onSuccess) { message.createThreadChannel(message.getEmbeds().get(0).getTitle()).queue( thread -> { @@ -269,10 +265,6 @@ private void createReportThread(Message message, long targetId, ModerationConfig ); } - - - - private EmbedBuilder buildReportEmbed(User reported, User reportedBy, String reason, Channel channel) { return new EmbedBuilder() .setAuthor(UserUtils.getUserTag(reported), null, reported.getEffectiveAvatarUrl()) From 956cc96d157e619c743c4255b512ae0e0b8db822 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Sun, 31 Mar 2024 13:56:13 +0200 Subject: [PATCH 045/242] change button text --- .../javabot/systems/moderation/report/ReportManager.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/systems/moderation/report/ReportManager.java b/src/main/java/net/discordjug/javabot/systems/moderation/report/ReportManager.java index dd5c7ea0a..f8e739a86 100644 --- a/src/main/java/net/discordjug/javabot/systems/moderation/report/ReportManager.java +++ b/src/main/java/net/discordjug/javabot/systems/moderation/report/ReportManager.java @@ -215,8 +215,8 @@ private void sendReportResponse(InteractionHook hook, User targetUser, EmbedBuil reportEmbed.setDescription("Successfully reported " + "`" + UserUtils.getUserTag(targetUser) + "`!\nYour report has been send to our Moderators.\nIn case you want to supply additional details, please use the \"Create thread\" button below."); hook.sendMessageEmbeds(reportEmbed.build()) .addActionRow(Button.secondary( - ComponentIdBuilder.build(REPORT_INTERACTION_NAME, "create-thread",reportThread.getId()), - "Create thread")) + ComponentIdBuilder.build(REPORT_INTERACTION_NAME, "create-thread", reportThread.getId()), + "Create thread for providing further details")) .queue(); } From c18b3d2ef15900f83ffcf292a0919285a2c940b5 Mon Sep 17 00:00:00 2001 From: MoonTM Date: Wed, 3 Apr 2024 20:59:37 +0200 Subject: [PATCH 046/242] Added manual trigger to build workflow --- .github/workflows/build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 30bdda71a..7ed835aa5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -3,7 +3,7 @@ name: Build JavaBot -on: [push, pull_request] +on: [push, pull_request, workflow_dispatch] jobs: build: @@ -24,7 +24,7 @@ jobs: publish: runs-on: ubuntu-latest needs: build - if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + if: ${{ (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch' }} steps: - uses: actions/checkout@v4 - name: Set up JDK 17 From bf41eac05168434e0b8d49d67609f64957d3bceb Mon Sep 17 00:00:00 2001 From: danthe1st Date: Tue, 23 Apr 2024 12:24:16 +0200 Subject: [PATCH 047/242] automatically clear old unresolved help notifications --- .../notify/ClearOldHelpNotificationJob.java | 28 ++++++++++++++++--- .../commands/notify/HelpPingSubcommand.java | 6 ++-- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/systems/help/commands/notify/ClearOldHelpNotificationJob.java b/src/main/java/net/discordjug/javabot/systems/help/commands/notify/ClearOldHelpNotificationJob.java index 3eeb9b0df..768868600 100644 --- a/src/main/java/net/discordjug/javabot/systems/help/commands/notify/ClearOldHelpNotificationJob.java +++ b/src/main/java/net/discordjug/javabot/systems/help/commands/notify/ClearOldHelpNotificationJob.java @@ -49,10 +49,9 @@ private void deleteOldMessagesInChannel(TextChannel helpNotificationChannel, Mes .stream() .filter(msg -> msg.getAuthor().getIdLong() == msg.getJDA().getSelfUser().getIdLong()) .filter(msg -> msg.getTimeCreated().isBefore(OffsetDateTime.now().minusDays(3))) - .filter(msg -> msg - .getButtons() - .stream() - .anyMatch(button -> "Mark as unacknowledged".equals(button.getLabel()))) + .filter(msg -> + isOldUnresolvedNotification(msg) || + isResolvedNotification(msg)) .toList(); helpNotificationChannel.purgeMessages(toDelete); foundSoFar.addAll(toDelete); @@ -76,6 +75,27 @@ private void deleteOldMessagesInChannel(TextChannel helpNotificationChannel, Mes }); } + private boolean isOldUnresolvedNotification(Message msg) { + return getLastInteractionTimestamp(msg) + .isBefore(OffsetDateTime.now().minusDays(7)) && + hasButtonWithText(msg, HelpPingSubcommand.MARK_ACKNOWLEDGED_BUTTON_TEXT); + } + + private OffsetDateTime getLastInteractionTimestamp(Message msg) { + OffsetDateTime timeEdited = msg.getTimeEdited(); + return timeEdited == null ? msg.getTimeCreated() : timeEdited; + } + + private boolean isResolvedNotification(Message msg) { + return hasButtonWithText(msg, HelpPingSubcommand.MARK_UNACKNOWLEDGED_BUTTON_TEXT); + } + + private boolean hasButtonWithText(Message msg, String expectedText) { + return msg.getButtons() + .stream() + .anyMatch(button -> expectedText.equals(button.getLabel())); + } + private String convertMessageToString(Message msg) { return msg.getContentRaw()+"\n"+ msg.getEmbeds().stream().map(e->e.toData().toString()).collect(Collectors.joining("\n")); diff --git a/src/main/java/net/discordjug/javabot/systems/help/commands/notify/HelpPingSubcommand.java b/src/main/java/net/discordjug/javabot/systems/help/commands/notify/HelpPingSubcommand.java index e9b8bf133..2f5b3cac5 100644 --- a/src/main/java/net/discordjug/javabot/systems/help/commands/notify/HelpPingSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/help/commands/notify/HelpPingSubcommand.java @@ -48,6 +48,8 @@ */ @AutoDetectableComponentHandler("help-ping") public class HelpPingSubcommand extends SlashCommand.Subcommand implements ButtonHandler { + static final String MARK_UNACKNOWLEDGED_BUTTON_TEXT = "Mark as unacknowledged"; + static final String MARK_ACKNOWLEDGED_BUTTON_TEXT = "Mark as acknowledged"; private static final String WRONG_CHANNEL_MSG = "This command can only be used in **help forum posts**"; private static final long CACHE_CLEANUP_DELAY = 60L; @@ -161,11 +163,11 @@ private void appendComment(EmbedBuilder eb, String comment) { } private Button createAcknowledgementButton(String postId) { - return Button.of(ButtonStyle.SECONDARY, ComponentIdBuilder.build("help-ping", "acknowledge", postId), "Mark as acknowledged"); + return Button.of(ButtonStyle.SECONDARY, ComponentIdBuilder.build("help-ping", "acknowledge", postId), MARK_ACKNOWLEDGED_BUTTON_TEXT); } private Button createUndoAcknowledgementButton(String postId) { - return Button.of(ButtonStyle.SECONDARY, ComponentIdBuilder.build("help-ping", "unacknowledge", postId), "Mark as unacknowledged"); + return Button.of(ButtonStyle.SECONDARY, ComponentIdBuilder.build("help-ping", "unacknowledge", postId), MARK_UNACKNOWLEDGED_BUTTON_TEXT); } /** From 79c34e02500656fa711740e9f811e3167499dfb3 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Wed, 15 May 2024 13:25:45 +0200 Subject: [PATCH 048/242] add newline after autoformat java codeblock --- .../net/discordjug/javabot/systems/help/AutoCodeFormatter.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/net/discordjug/javabot/systems/help/AutoCodeFormatter.java b/src/main/java/net/discordjug/javabot/systems/help/AutoCodeFormatter.java index fd717693a..64b483ffd 100644 --- a/src/main/java/net/discordjug/javabot/systems/help/AutoCodeFormatter.java +++ b/src/main/java/net/discordjug/javabot/systems/help/AutoCodeFormatter.java @@ -28,7 +28,7 @@ @RequiredArgsConstructor @Component public class AutoCodeFormatter { - private static final String CODEBLOCK_PREFIX = " ```java"; + private static final String CODEBLOCK_PREFIX = " ```java\n"; private static final String CODEBLOCK_SUFFIX = " ```"; private final AutoMod autoMod; private final BotConfig botConfig; From 0fc5cbc6d0ff366ce9066965ff3c4e16f1440584 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Wed, 15 May 2024 13:43:14 +0200 Subject: [PATCH 049/242] use requestor as message link author instead of linked message author --- .../net/discordjug/javabot/listener/MessageLinkListener.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/net/discordjug/javabot/listener/MessageLinkListener.java b/src/main/java/net/discordjug/javabot/listener/MessageLinkListener.java index 425c99385..86c4cb29b 100644 --- a/src/main/java/net/discordjug/javabot/listener/MessageLinkListener.java +++ b/src/main/java/net/discordjug/javabot/listener/MessageLinkListener.java @@ -48,7 +48,7 @@ public void onMessageReceived(@NotNull MessageReceivedEvent event) { messageChannel.getType().isThread() ? messageChannel.getIdLong() : 0, List.of(ActionRow.of( Button.link(m.getJumpUrl(), "Jump to Message"), - Button.secondary(InteractionUtils.createDeleteInteractionId(m.getAuthor().getIdLong()), "\uD83D\uDDD1️"))), + Button.secondary(InteractionUtils.createDeleteInteractionId(event.getAuthor().getIdLong()), "\uD83D\uDDD1️"))), null)); }, e -> ExceptionLogger.capture(e, getClass().getSimpleName()))); } From 0bbd7b745eb985a65fe76d5a78f9143bf1b29c52 Mon Sep 17 00:00:00 2001 From: Tanish Azad Date: Tue, 28 May 2024 11:45:02 +0530 Subject: [PATCH 050/242] made the gradlew file executable --- gradlew | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 gradlew diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 From 1d52f5515d598f958cc6e27f92914d0c2c3eae5a Mon Sep 17 00:00:00 2001 From: Tanish Azad Date: Tue, 28 May 2024 11:54:35 +0530 Subject: [PATCH 051/242] removed step from workflow --- .factorypath | 3 +++ .github/workflows/build.yml | 2 -- 2 files changed, 3 insertions(+), 2 deletions(-) create mode 100644 .factorypath diff --git a/.factorypath b/.factorypath new file mode 100644 index 000000000..2bb2739d3 --- /dev/null +++ b/.factorypath @@ -0,0 +1,3 @@ + + + diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7ed835aa5..f480b1d74 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -32,8 +32,6 @@ jobs: with: java-version: '17' distribution: 'temurin' - - name: Grant execute permission for gradlew - run: chmod +x gradlew - name: Build JAR run: ./gradlew shadowJar - name: Build Docker image From 427187df3a3c2b106223844f059245b0880be48c Mon Sep 17 00:00:00 2001 From: Tanish Azad Date: Tue, 28 May 2024 12:35:46 +0530 Subject: [PATCH 052/242] removed factorypath --- .factorypath | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 .factorypath diff --git a/.factorypath b/.factorypath deleted file mode 100644 index 2bb2739d3..000000000 --- a/.factorypath +++ /dev/null @@ -1,3 +0,0 @@ - - - From 49eb37430a1c810f4c0af51304de16bd6559644f Mon Sep 17 00:00:00 2001 From: danthe1st Date: Tue, 28 May 2024 09:24:23 +0200 Subject: [PATCH 053/242] allow deletion by both original author and linker --- .../javabot/listener/MessageLinkListener.java | 2 +- .../javabot/util/InteractionUtils.java | 31 +++++++++++++------ 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/listener/MessageLinkListener.java b/src/main/java/net/discordjug/javabot/listener/MessageLinkListener.java index 86c4cb29b..e2ac13951 100644 --- a/src/main/java/net/discordjug/javabot/listener/MessageLinkListener.java +++ b/src/main/java/net/discordjug/javabot/listener/MessageLinkListener.java @@ -48,7 +48,7 @@ public void onMessageReceived(@NotNull MessageReceivedEvent event) { messageChannel.getType().isThread() ? messageChannel.getIdLong() : 0, List.of(ActionRow.of( Button.link(m.getJumpUrl(), "Jump to Message"), - Button.secondary(InteractionUtils.createDeleteInteractionId(event.getAuthor().getIdLong()), "\uD83D\uDDD1️"))), + Button.secondary(InteractionUtils.createDeleteInteractionId(event.getAuthor().getIdLong(), m.getAuthor().getIdLong()), "\uD83D\uDDD1️"))), null)); }, e -> ExceptionLogger.capture(e, getClass().getSimpleName()))); } diff --git a/src/main/java/net/discordjug/javabot/util/InteractionUtils.java b/src/main/java/net/discordjug/javabot/util/InteractionUtils.java index da74973a3..9c84e4e21 100644 --- a/src/main/java/net/discordjug/javabot/util/InteractionUtils.java +++ b/src/main/java/net/discordjug/javabot/util/InteractionUtils.java @@ -72,6 +72,11 @@ public class InteractionUtils implements ButtonHandler, ModalHandler, StringSele * Template Interaction ID for deleting the original Message. */ private static final String DELETE_ORIGINAL_TEMPLATE = "utils:delete:%d"; + + /** + * Template Interaction ID for deleting the original Message. + */ + private static final String DELETE_ORIGINAL_TEMPLATE_MULTIPLE_DELETERS = "utils:delete:%d:%d"; private final NotificationService notificationService; private final BotConfig botConfig; @@ -93,18 +98,10 @@ private InteractionCallbackAction delete(@NotNull ButtonInteraction interacti if (member == null) { return Responses.warning(interaction, "Could not get member."); } - GuildConfig config = botConfig.get(interaction.getGuild()); + Message msg = interaction.getMessage(); - String authorId = ""; - - if (componentId.length > 2) { - authorId = componentId[2]; - } - - if (authorId.equals(member.getUser().getId()) || - member.getRoles().contains(config.getModerationConfig().getStaffRole()) || - member.isOwner()) { + if (canDeleteUsingButton(member, componentId)) { msg.delete().queue(); return interaction.deferEdit(); } else { @@ -112,6 +109,16 @@ private InteractionCallbackAction delete(@NotNull ButtonInteraction interacti } } + private boolean canDeleteUsingButton(Member member, String[] componentId) { + for(int i=2; i < componentId.length; i++) { + if(componentId[i].equals(member.getUser().getId())) { + return true; + } + } + GuildConfig config = botConfig.get(member.getGuild()); + return member.getRoles().contains(config.getModerationConfig().getStaffRole()) || member.isOwner(); + } + public static Button createDeleteButton(long senderId) { return Button.secondary(createDeleteInteractionId(senderId), "\uD83D\uDDD1️"); } @@ -119,6 +126,10 @@ public static Button createDeleteButton(long senderId) { public static String createDeleteInteractionId(long senderId) { return DELETE_ORIGINAL_TEMPLATE.formatted(senderId); } + + public static String createDeleteInteractionId(long senderId, long secondDeleterId) { + return DELETE_ORIGINAL_TEMPLATE_MULTIPLE_DELETERS.formatted(senderId, secondDeleterId); + } private void kick(ModalInteraction interaction, @NotNull Guild guild, String memberId, String reason) { if(!interaction.getMember().hasPermission(Permission.KICK_MEMBERS)) { From 09982475a706e89c81583358cd2872c1e03db737 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Mon, 3 Jun 2024 08:43:54 +0200 Subject: [PATCH 054/242] sort tags by name --- .../systems/staff_commands/tags/dao/CustomTagRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/tags/dao/CustomTagRepository.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/tags/dao/CustomTagRepository.java index b6521a74c..4106b9d52 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/tags/dao/CustomTagRepository.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/tags/dao/CustomTagRepository.java @@ -117,7 +117,7 @@ public Optional findById(long id) throws DataAccessException { * @return A List with all custom commands. */ public List getCustomTagsByGuildId(long guildId) { - return jdbcTemplate.query("SELECT * FROM custom_tags WHERE guild_id = ?", (rs, row)->this.read(rs), + return jdbcTemplate.query("SELECT * FROM custom_tags WHERE guild_id = ? ORDER BY name", (rs, row)->this.read(rs), guildId); } From 003ac1d4b438804be844ed542078075118c43e0a Mon Sep 17 00:00:00 2001 From: danthe1st Date: Mon, 3 Jun 2024 09:13:39 +0200 Subject: [PATCH 055/242] add /tag search command --- .../staff_commands/tags/CustomTagManager.java | 4 +- .../tags/commands/TagListSubcommand.java | 9 ++- .../tags/commands/TagSearchSubcommand.java | 70 +++++++++++++++++++ .../tags/commands/TagViewSubcommand.java | 11 ++- .../tags/commands/TagsCommand.java | 5 +- .../tags/commands/TagsSubcommand.java | 4 +- .../tags/dao/CustomTagRepository.java | 14 ++++ 7 files changed, 103 insertions(+), 14 deletions(-) create mode 100644 src/main/java/net/discordjug/javabot/systems/staff_commands/tags/commands/TagSearchSubcommand.java diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/tags/CustomTagManager.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/tags/CustomTagManager.java index ac917716d..4c2f731dc 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/tags/CustomTagManager.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/tags/CustomTagManager.java @@ -84,13 +84,13 @@ public class CustomTagManager { Set> actions = new HashSet<>(); if (tag.isEmbed()) { if (tag.isReply()) { - actions.add(event.getHook().sendMessageEmbeds(tag.toEmbed())); + actions.add(event.replyEmbeds(tag.toEmbed())); } else { actions.add(event.getChannel().sendMessageEmbeds(tag.toEmbed())); } } else { if (tag.isReply()) { - actions.add(event.getHook().sendMessage(tag.getResponse()).setAllowedMentions(List.of())); + actions.add(event.reply(tag.getResponse()).setAllowedMentions(List.of())); } else { actions.add(event.getChannel().sendMessage(tag.getResponse()).setAllowedMentions(List.of())); } diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/tags/commands/TagListSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/tags/commands/TagListSubcommand.java index 8a26655da..051b546d2 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/tags/commands/TagListSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/tags/commands/TagListSubcommand.java @@ -18,7 +18,7 @@ import java.util.stream.Collectors; /** - *

This class represents the /tags command.

+ *

This class represents the /tag list command.

*/ public class TagListSubcommand extends TagsSubcommand { private final ExecutorService asyncPool; @@ -43,11 +43,16 @@ public ReplyCallbackAction handleCustomTagsSubcommand(@NotNull SlashCommandInter asyncPool.execute(()->{ try { List tags = customTagRepository.getCustomTagsByGuildId(event.getGuild().getIdLong()); - String tagList = tags.stream().map(CustomTag::getName).map(MarkdownUtil::monospace).collect(Collectors.joining(", ")); + String tagList = tags + .stream() + .map(CustomTag::getName) + .map(MarkdownUtil::monospace) + .collect(Collectors.joining(", ")); Responses.info(event.getHook(), "Custom Tag List", String.format(tagList.length() > 0 ? tagList : "No Custom Tags created yet.")).queue(); } catch (DataAccessException e) { ExceptionLogger.capture(e, TagListSubcommand.class.getSimpleName()); + Responses.error(event.getHook(), "An error occured trying to list tags").queue(); } }); return event.deferReply(false); diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/tags/commands/TagSearchSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/tags/commands/TagSearchSubcommand.java new file mode 100644 index 000000000..9461dcac2 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/tags/commands/TagSearchSubcommand.java @@ -0,0 +1,70 @@ +package net.discordjug.javabot.systems.staff_commands.tags.commands; + +import java.sql.SQLException; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.stream.Collectors; + +import net.discordjug.javabot.data.config.BotConfig; +import net.discordjug.javabot.systems.staff_commands.tags.dao.CustomTagRepository; +import net.discordjug.javabot.systems.staff_commands.tags.model.CustomTag; +import net.discordjug.javabot.util.ExceptionLogger; +import net.discordjug.javabot.util.Responses; +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.SubcommandData; +import net.dv8tion.jda.api.requests.restaction.interactions.InteractionCallbackAction; +import net.dv8tion.jda.api.utils.MarkdownUtil; +import org.jetbrains.annotations.NotNull; +import org.springframework.dao.DataAccessException; + +/** + * The /tag search command which lists all tags matching a query. + */ +public class TagSearchSubcommand extends TagsSubcommand { + + private final ExecutorService asyncPool; + private final CustomTagRepository customTagRepository; + + /** + * The constructor of this class, which sets the corresponding {@link net.dv8tion.jda.api.interactions.commands.build.SlashCommandData}. + * @param botConfig The main configuration of the bot. + * @param asyncPool Thread pool for asynchronous operations. + * @param customTagRepository The repository for accessing tags in the database. + */ + public TagSearchSubcommand(BotConfig botConfig, ExecutorService asyncPool, CustomTagRepository customTagRepository) { + super(botConfig); + this.asyncPool = asyncPool; + this.customTagRepository = customTagRepository; + setCommandData( + new SubcommandData("search", "Searches for tags using a query") + .addOption(OptionType.STRING, "query", "The search query", true)); + setRequiredStaff(false); + } + + @Override + protected InteractionCallbackAction handleCustomTagsSubcommand(@NotNull SlashCommandInteractionEvent event) + throws SQLException { + + String query = event.getOption("query", "", OptionMapping::getAsString); + + asyncPool.execute(()->{ + try { + List tags = customTagRepository.search(event.getGuild().getIdLong(), query); + String tagList = tags + .stream() + .map(CustomTag::getName) + .map(MarkdownUtil::monospace) + .collect(Collectors.joining(", ")); + Responses.info(event.getHook(), "Custom tags containing \"" + query + "\"", + String.format(tagList.length() > 0 ? tagList : "No Custom Tags have been found.")).queue(); + } catch (DataAccessException e) { + ExceptionLogger.capture(e, TagListSubcommand.class.getSimpleName()); + Responses.error(event.getHook(), "An error occured trying to search for tags").queue(); + } + }); + return event.deferReply(false); + } + +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/tags/commands/TagViewSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/tags/commands/TagViewSubcommand.java index d6c0304d0..a961c85e2 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/tags/commands/TagViewSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/tags/commands/TagViewSubcommand.java @@ -11,14 +11,14 @@ import net.dv8tion.jda.api.interactions.commands.OptionMapping; import net.dv8tion.jda.api.interactions.commands.OptionType; import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; -import net.dv8tion.jda.api.requests.restaction.interactions.ReplyCallbackAction; +import net.dv8tion.jda.api.requests.RestAction; import org.jetbrains.annotations.NotNull; import java.util.Optional; /** - *

This class represents the /tag command.

+ *

This class represents the /tag view command.

*/ public class TagViewSubcommand extends TagsSubcommand implements AutoCompletable { private final CustomTagManager tagManager; @@ -38,18 +38,17 @@ public TagViewSubcommand(CustomTagManager tagManager, BotConfig botConfig) { } @Override - public ReplyCallbackAction handleCustomTagsSubcommand(@NotNull SlashCommandInteractionEvent event) { + public RestAction handleCustomTagsSubcommand(@NotNull SlashCommandInteractionEvent event) { OptionMapping nameMapping = event.getOption("name"); if (nameMapping == null) { return Responses.replyMissingArguments(event); } Optional tagOptional = tagManager.getByName(event.getGuild().getIdLong(), nameMapping.getAsString()); if (tagOptional.isPresent()) { - CustomTagManager.handleCustomTag(event, tagOptional.get()).queue(); + return CustomTagManager.handleCustomTag(event, tagOptional.get()); } else { - Responses.error(event.getHook(), "Could not find Custom Tag with name `%s`.", nameMapping.getAsString()).queue(); + return Responses.error(event, "Could not find Custom Tag with name `%s`.", nameMapping.getAsString()); } - return event.deferReply(false); } @Override diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/tags/commands/TagsCommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/tags/commands/TagsCommand.java index 4cfe929b4..501e73d3b 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/tags/commands/TagsCommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/tags/commands/TagsCommand.java @@ -13,11 +13,12 @@ public class TagsCommand extends SlashCommand { * adds the corresponding {@link net.dv8tion.jda.api.interactions.commands.Command.Subcommand}s. * @param tagViewSubcommand /tag view * @param tagListSubcommand /tag list + * @param tagSearchSubcommand /tag search */ - public TagsCommand(TagViewSubcommand tagViewSubcommand, TagListSubcommand tagListSubcommand) { + public TagsCommand(TagViewSubcommand tagViewSubcommand, TagListSubcommand tagListSubcommand, TagSearchSubcommand tagSearchSubcommand) { setCommandData(Commands.slash("tag", "Commands for interacting with Custom Tags.") .setGuildOnly(true) ); - addSubcommands(tagViewSubcommand, tagListSubcommand); + addSubcommands(tagViewSubcommand, tagListSubcommand, tagSearchSubcommand); } } \ No newline at end of file diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/tags/commands/TagsSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/tags/commands/TagsSubcommand.java index 4b6703890..c2fadde10 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/tags/commands/TagsSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/tags/commands/TagsSubcommand.java @@ -8,7 +8,7 @@ import net.discordjug.javabot.util.ExceptionLogger; import net.discordjug.javabot.util.Responses; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; -import net.dv8tion.jda.api.requests.restaction.interactions.InteractionCallbackAction; +import net.dv8tion.jda.api.requests.RestAction; import org.jetbrains.annotations.NotNull; @@ -45,5 +45,5 @@ protected void setRequiredStaff(boolean requireStaff) { this.requireStaff = requireStaff; } - protected abstract InteractionCallbackAction handleCustomTagsSubcommand(@NotNull SlashCommandInteractionEvent event) throws SQLException; + protected abstract RestAction handleCustomTagsSubcommand(@NotNull SlashCommandInteractionEvent event) throws SQLException; } diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/tags/dao/CustomTagRepository.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/tags/dao/CustomTagRepository.java index 4106b9d52..9c7f4bc11 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/tags/dao/CustomTagRepository.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/tags/dao/CustomTagRepository.java @@ -120,6 +120,20 @@ public List getCustomTagsByGuildId(long guildId) { return jdbcTemplate.query("SELECT * FROM custom_tags WHERE guild_id = ? ORDER BY name", (rs, row)->this.read(rs), guildId); } + + /** + * Gets all custom commands for the given guild matching a specified query. + * A tag matches the query if the name or reply contains the query. + * + * @param guildId The id of the guild. + * @param query The search query. + * @return A List with all custom commands. + */ + public List search(long guildId, String query) { + String enhancedQuery = "%" + query + "%"; + return jdbcTemplate.query("SELECT * FROM custom_tags WHERE guild_id = ? AND (name LIKE ? OR response LIKE ?) ORDER BY name", (rs, row)->this.read(rs), + guildId, enhancedQuery, enhancedQuery); + } /** * Reads the given {@link ResultSet} and constructs a new {@link CustomTag} object. From ab74291aa54c0b23c8a1b0dc67a54c038659e8b3 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Tue, 9 Jul 2024 11:56:00 +0200 Subject: [PATCH 056/242] update to JDA 5.0.0 --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index b4cff8cdc..33b9ced43 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -31,7 +31,7 @@ dependencies { // DIH4JDA (Command Framework) & JDA implementation("com.github.DynxstyGIT:DIH4JDA:120a15ad2e") - implementation("net.dv8tion:JDA:5.0.0-beta.19") { + implementation("net.dv8tion:JDA:5.0.0") { exclude(module = "opus-java") } From b70b5c9a07ffc6fdafed97bda27c9618b63fedb5 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Wed, 21 Aug 2024 20:53:48 +0200 Subject: [PATCH 057/242] fix message links --- .../net/discordjug/javabot/listener/MessageLinkListener.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/net/discordjug/javabot/listener/MessageLinkListener.java b/src/main/java/net/discordjug/javabot/listener/MessageLinkListener.java index e2ac13951..5083a2871 100644 --- a/src/main/java/net/discordjug/javabot/listener/MessageLinkListener.java +++ b/src/main/java/net/discordjug/javabot/listener/MessageLinkListener.java @@ -31,7 +31,7 @@ */ public class MessageLinkListener extends ListenerAdapter { - private static final Pattern MESSAGE_URL_PATTERN = Pattern.compile("https://((?:canary|ptb)\\.)?discord.com/channels/[0-9]+/[0-9]+/[0-9]+"); + private static final Pattern MESSAGE_URL_PATTERN = Pattern.compile("https://((?:canary|ptb)\\.)?discord(app)?.com/channels/[0-9]+/[0-9]+/[0-9]+"); @Override public void onMessageReceived(@NotNull MessageReceivedEvent event) { From fb230428b2f6824a1397f4b190e1ce4d3656679a Mon Sep 17 00:00:00 2001 From: danthe1st Date: Fri, 23 Aug 2024 14:46:34 +0200 Subject: [PATCH 058/242] fix purges deleting too much, purge cancellation --- .../systems/moderation/PurgeCommand.java | 89 ++++++++++++++----- 1 file changed, 67 insertions(+), 22 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/systems/moderation/PurgeCommand.java b/src/main/java/net/discordjug/javabot/systems/moderation/PurgeCommand.java index f259ca635..aa20185bb 100644 --- a/src/main/java/net/discordjug/javabot/systems/moderation/PurgeCommand.java +++ b/src/main/java/net/discordjug/javabot/systems/moderation/PurgeCommand.java @@ -29,10 +29,15 @@ import java.time.LocalDateTime; import java.time.OffsetDateTime; import java.time.format.DateTimeFormatter; +import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Map.Entry; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.ExecutorService; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Collectors; /** @@ -42,6 +47,8 @@ public class PurgeCommand extends ModerateCommand { private static final Path ARCHIVE_DIR = Path.of("purgeArchives"); private final ExecutorService asyncPool; + + private final Map> currentPurges = Collections.synchronizedMap(new HashMap<>()); /** * The constructor of this class, which sets the corresponding {@link net.dv8tion.jda.api.interactions.commands.build.SlashCommandData}. @@ -51,8 +58,8 @@ public class PurgeCommand extends ModerateCommand { public PurgeCommand(BotConfig botConfig, ExecutorService asyncPool) { super(botConfig); this.asyncPool = asyncPool; - setModerationSlashCommandData(Commands.slash("purge", "Deletes messages from a channel.") - .addOption(OptionType.INTEGER, "amount", "Number of messages to remove.", true) + setModerationSlashCommandData(Commands.slash("purge", "Bulk-deletes messages from a channel. Use /purge 0 to stop all purges.") + .addOption(OptionType.INTEGER, "amount", "Number of messages to remove. Set this to 0 in order to stop all running purges.", true) .addOption(OptionType.USER, "user", "The user whose messages to remove. If left blank, messages from any user are removed.", false) .addOption(OptionType.BOOLEAN, "archive", "Whether the removed messages should be saved in an archive. This defaults to true, if left blank.", false) ); @@ -69,10 +76,34 @@ protected ReplyCallbackAction handleModerationCommand(@NotNull SlashCommandInter Long amount = (amountOption == null) ? 1 : amountOption.getAsLong(); User user = (userOption == null) ? null : userOption.getAsUser(); int maxAmount = config.getPurgeMaxMessageCount(); - if (amount == null || amount < 1 || amount > maxAmount) { + if (amount == null || amount > maxAmount) { return Responses.warning(event, "Invalid amount. Should be between 1 and " + maxAmount + ", inclusive."); } - asyncPool.submit(() -> this.purge(amount, user, event.getUser(), archive, event.getChannel(), config.getLogChannel())); + if (amount == 0) { + List purges = currentPurges.get(event.getGuild().getIdLong()); + if (purges == null) { + return Responses.warning(event, "Cannot stop purge as no purge is currently running."); + } else { + int count = 0; + for (RunningPurge purge : purges) { + if (purge.cancelled().compareAndSet(false, true)) { + count++; + } + } + return Responses.success(event, "Purge stopped", count + " purge(s) have been stopped."); + } + } + RunningPurge runningPurge = new RunningPurge(event.getIdLong(), new AtomicBoolean()); + CompletableFuture future = CompletableFuture.runAsync( + () -> this.purge(amount, user, event.getUser(), archive, event.getChannel(), config.getLogChannel(), runningPurge.cancelled()), + asyncPool); + currentPurges + .computeIfAbsent(event.getGuild().getIdLong(), l -> new CopyOnWriteArrayList<>()) + .add(runningPurge); + future.whenComplete((success, failure) -> + currentPurges.get(event.getGuild().getIdLong()) + .remove(runningPurge) + ); StringBuilder sb = new StringBuilder(); sb.append(amount > 1 ? "Up to " + amount + " messages " : "1 message "); if (user != null) { @@ -92,20 +123,42 @@ protected ReplyCallbackAction handleModerationCommand(@NotNull SlashCommandInter * @param archive Whether to create an archive file for the purge. * @param channel The channel to remove messages from. * @param logChannel The channel to write log messages to during the purge. + * @param cancelled {@code true} indicates the purge is cancelled, else {@code false} */ - private void purge(long amount, @Nullable User user, User initiatedBy, boolean archive, MessageChannel channel, TextChannel logChannel) { + private void purge(long amount, @Nullable User user, User initiatedBy, boolean archive, MessageChannel channel, TextChannel logChannel, AtomicBoolean cancelled) { MessageHistory history = channel.getHistory(); String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss")); String file = String.format("purge_%s_%s.txt", channel.getName(), timestamp); PrintWriter archiveWriter = archive ? createArchiveWriter(channel, logChannel, file) : null; - List messages; OffsetDateTime startTime = OffsetDateTime.now(); long count = 0; logChannel.sendMessageFormat("Starting purge of channel %s, initiated by %s", channel.getAsMention(), initiatedBy.getAsMention()) .queue(); + count = performDeletion(amount, user, channel, logChannel, history, archiveWriter, count, cancelled); + if (archiveWriter != null) { + archiveWriter.close(); + } + MessageCreateAction action = logChannel.sendMessage(String.format( + "Purge of channel %s has completed. %d messages have been removed, and the purge took %s.", + channel.getAsMention(), + count, + new TimeUtils().formatDurationToNow(startTime) + )); + if (archive) { + action.addFiles(FileUpload.fromData(ARCHIVE_DIR.resolve(file).toFile())); + } + action.queue(); + } + + private long performDeletion(long amount, User user, MessageChannel channel, TextChannel logChannel, + MessageHistory history, PrintWriter archiveWriter, long count, AtomicBoolean cancelled) { int lastEmptyIterations = 0; + List messages; do { messages = history.retrievePast((int) Math.min(100, user==null ? amount : Math.max(amount, 10))).complete(); + if(cancelled.get()) { + return count; + } if (!messages.isEmpty()) { int messagesRemoved = removeMessages(messages, user, archiveWriter, amount - count); count += messagesRemoved; @@ -114,27 +167,15 @@ private void purge(long amount, @Nullable User user, User initiatedBy, boolean a messagesRemoved, channel.getAsMention(), count - )).queue(); + )).complete(); if (messagesRemoved == 0) { lastEmptyIterations++; }else { lastEmptyIterations = 0; } } - } while (!messages.isEmpty() && amount > count && lastEmptyIterations <= 20); - if (archiveWriter != null) { - archiveWriter.close(); - } - MessageCreateAction action = logChannel.sendMessage(String.format( - "Purge of channel %s has completed. %d messages have been removed, and the purge took %s.", - channel.getAsMention(), - count, - new TimeUtils().formatDurationToNow(startTime) - )); - if (archive) { - action.addFiles(FileUpload.fromData(ARCHIVE_DIR.resolve(file).toFile())); - } - action.queue(); + } while (!cancelled.get() && !messages.isEmpty() && amount > count && lastEmptyIterations <= 20); + return count; } /** @@ -162,7 +203,7 @@ private int removeMessages(List messages, @Nullable User user, @Nullabl for (Message msg : msgs) { archiveMessage(archiveWriter, msg); } - entry.getKey().purgeMessages(messages); + entry.getKey().purgeMessages(msgs); } } return count; @@ -208,4 +249,8 @@ private void archiveMessage(PrintWriter writer, Message message) { message.getContentRaw() ); } + + private record RunningPurge(long id, AtomicBoolean cancelled) { + + } } \ No newline at end of file From 9089bd0c80faaedf8f5f72ef459f08ae13ad8347 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Fri, 23 Aug 2024 14:52:36 +0200 Subject: [PATCH 059/242] add custom hashCode --- .../discordjug/javabot/systems/moderation/PurgeCommand.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/net/discordjug/javabot/systems/moderation/PurgeCommand.java b/src/main/java/net/discordjug/javabot/systems/moderation/PurgeCommand.java index aa20185bb..2af2f1fbe 100644 --- a/src/main/java/net/discordjug/javabot/systems/moderation/PurgeCommand.java +++ b/src/main/java/net/discordjug/javabot/systems/moderation/PurgeCommand.java @@ -251,6 +251,9 @@ private void archiveMessage(PrintWriter writer, Message message) { } private record RunningPurge(long id, AtomicBoolean cancelled) { - + @Override + public final int hashCode() { + return (int) id; + } } } \ No newline at end of file From d1848e20348de4c0a224997b80cae37e57ae301e Mon Sep 17 00:00:00 2001 From: danthe1st Date: Fri, 23 Aug 2024 14:53:37 +0200 Subject: [PATCH 060/242] use sets --- .../javabot/systems/moderation/PurgeCommand.java | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/systems/moderation/PurgeCommand.java b/src/main/java/net/discordjug/javabot/systems/moderation/PurgeCommand.java index 2af2f1fbe..5fab74220 100644 --- a/src/main/java/net/discordjug/javabot/systems/moderation/PurgeCommand.java +++ b/src/main/java/net/discordjug/javabot/systems/moderation/PurgeCommand.java @@ -31,11 +31,12 @@ import java.time.format.DateTimeFormatter; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Map.Entry; +import java.util.Set; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.ExecutorService; import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Collectors; @@ -48,7 +49,7 @@ public class PurgeCommand extends ModerateCommand { private static final Path ARCHIVE_DIR = Path.of("purgeArchives"); private final ExecutorService asyncPool; - private final Map> currentPurges = Collections.synchronizedMap(new HashMap<>()); + private final Map> currentPurges = Collections.synchronizedMap(new HashMap<>()); /** * The constructor of this class, which sets the corresponding {@link net.dv8tion.jda.api.interactions.commands.build.SlashCommandData}. @@ -80,7 +81,7 @@ protected ReplyCallbackAction handleModerationCommand(@NotNull SlashCommandInter return Responses.warning(event, "Invalid amount. Should be between 1 and " + maxAmount + ", inclusive."); } if (amount == 0) { - List purges = currentPurges.get(event.getGuild().getIdLong()); + Set purges = currentPurges.get(event.getGuild().getIdLong()); if (purges == null) { return Responses.warning(event, "Cannot stop purge as no purge is currently running."); } else { @@ -98,7 +99,7 @@ protected ReplyCallbackAction handleModerationCommand(@NotNull SlashCommandInter () -> this.purge(amount, user, event.getUser(), archive, event.getChannel(), config.getLogChannel(), runningPurge.cancelled()), asyncPool); currentPurges - .computeIfAbsent(event.getGuild().getIdLong(), l -> new CopyOnWriteArrayList<>()) + .computeIfAbsent(event.getGuild().getIdLong(), l -> Collections.synchronizedSet(new HashSet<>())) .add(runningPurge); future.whenComplete((success, failure) -> currentPurges.get(event.getGuild().getIdLong()) From 5150b924b52d0ea2f64cdab914d83ecf01d57c86 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Sat, 24 Aug 2024 09:44:25 +0200 Subject: [PATCH 061/242] fix pingable name listener --- .../listener/PingableNameListener.java | 23 +++++++------------ 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/listener/PingableNameListener.java b/src/main/java/net/discordjug/javabot/listener/PingableNameListener.java index 3a2c3d1db..55dcee007 100644 --- a/src/main/java/net/discordjug/javabot/listener/PingableNameListener.java +++ b/src/main/java/net/discordjug/javabot/listener/PingableNameListener.java @@ -15,6 +15,7 @@ import java.util.List; import java.util.Random; import java.util.Scanner; +import java.util.regex.Pattern; import java.util.stream.Collectors; /** @@ -25,6 +26,7 @@ public class PingableNameListener extends ListenerAdapter { private static final String ADJECTIVES_URL = "https://gist.githubusercontent.com/karlbright/f91229b8c5ac6f4291dc/raw/4a69c2c50b88ee4559b021c443fee899535adc60/adjectives.txt"; private static final String NOUNS_URL = "https://raw.githubusercontent.com/hugsy/stuff/main/random-word/english-nouns.txt"; + private static final Pattern PINGABLE_NAME_PATTERN = Pattern.compile("(\\W*\\w){3,}\\W*"); private static final Random random = new Random(); private final NotificationService notificationService; private final List nouns; @@ -58,7 +60,7 @@ public void onGuildMemberUpdateNickname(GuildMemberUpdateNicknameEvent event) { * @param nickname The {@link Member}'s new Nickname, null if that does not exist. */ private void checkNickname(Member member, String nickname) { - if (!(nickname==null||isPingable(nickname)) && !isPingable(member.getUser().getName()) && !canBypassCheck(member)) { + if (!isPingable(member.getEffectiveName()) && !canBypassCheck(member)) { changeName(member); } } @@ -72,9 +74,9 @@ private void changeName(Member member) { String newName = generateRandomName(); member.modifyNickname(newName.substring(0, Math.min(31, newName.length()))).queue(); member.getUser().openPrivateChannel() - .flatMap(channel -> channel.sendMessageFormat("Your nickname has been set to `%s` since both your user- and nickname's first three characters were deemed as not-pingable.", newName)) + .flatMap(channel -> channel.sendMessageFormat("Your display name in %s has been set to `%s` since your previous name was deemed as non-pingable.", member.getGuild().getName(), newName)) .queue(); - notificationService.withGuild(member.getGuild()).sendToModerationLog(c -> c.sendMessageFormat("Changed %s's nickname from `%s` to `%s`.", member.getAsMention(), oldName, newName)); + notificationService.withGuild(member.getGuild()).sendToMessageLog(c -> c.sendMessageFormat("Changed %s's nickname from `%s` to `%s`.", member.getAsMention(), oldName, newName)); } /** @@ -83,15 +85,7 @@ private void changeName(Member member) { * @return True if first three characters contain invalid characters, False if not. */ private boolean isPingable(String name) { - if (name == null) return true; - char[] nameChars = name.toCharArray(); - for (int i = 0; i < Math.min(2,name.length()); i++) { - char c = nameChars[i]; - if (c < 32 || c > 126) { - return false; - } - } - return true; + return PINGABLE_NAME_PATTERN.matcher(name).matches(); } /** @@ -101,8 +95,7 @@ private boolean isPingable(String name) { private String generateRandomName() { String noun = nouns.get(random.nextInt(nouns.size())); String adjective = adjectives.get(random.nextInt(adjectives.size())); - int number = random.nextInt(10000); - return StringUtils.capitalize(adjective) + StringUtils.capitalize(noun) + number; + return StringUtils.capitalize(adjective) + StringUtils.capitalize(noun); } /** @@ -130,6 +123,6 @@ private static List readStrings(String url) { * @return Whether the Member can bypass name-checks or not. */ private static boolean canBypassCheck(Member member) { - return member.getUser().isBot() || member.getUser().isSystem() || member.getGuild().getSelfMember().canInteract(member); + return member.getUser().isBot() || member.getUser().isSystem() || !member.getGuild().getSelfMember().canInteract(member); } } From f1d294b68fc8c206d21d6ae464d7ad95b9e827e2 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Sun, 15 Sep 2024 13:56:55 +0200 Subject: [PATCH 062/242] add custom voice channels --- .../data/config/guild/ModerationConfig.java | 10 +++ .../custom_vc/CustomVCButtonHandler.java | 73 ++++++++++++++++ .../systems/custom_vc/CustomVCListener.java | 82 ++++++++++++++++++ .../systems/custom_vc/CustomVCRepository.java | 63 ++++++++++++++ .../commands/CustomVCAddMemberSubcommand.java | 31 +++++++ .../CustomVCChangeMembersSubcommand.java | 86 +++++++++++++++++++ .../commands/CustomVCControlCommand.java | 21 +++++ .../CustomVCRemoveMemberSubcommand.java | 53 ++++++++++++ .../migrations/09-15-2024_custom_vcs.sql | 4 + 9 files changed, 423 insertions(+) create mode 100644 src/main/java/net/discordjug/javabot/systems/custom_vc/CustomVCButtonHandler.java create mode 100644 src/main/java/net/discordjug/javabot/systems/custom_vc/CustomVCListener.java create mode 100644 src/main/java/net/discordjug/javabot/systems/custom_vc/CustomVCRepository.java create mode 100644 src/main/java/net/discordjug/javabot/systems/custom_vc/commands/CustomVCAddMemberSubcommand.java create mode 100644 src/main/java/net/discordjug/javabot/systems/custom_vc/commands/CustomVCChangeMembersSubcommand.java create mode 100644 src/main/java/net/discordjug/javabot/systems/custom_vc/commands/CustomVCControlCommand.java create mode 100644 src/main/java/net/discordjug/javabot/systems/custom_vc/commands/CustomVCRemoveMemberSubcommand.java create mode 100644 src/main/resources/database/migrations/09-15-2024_custom_vcs.sql diff --git a/src/main/java/net/discordjug/javabot/data/config/guild/ModerationConfig.java b/src/main/java/net/discordjug/javabot/data/config/guild/ModerationConfig.java index 6bc915ee8..88f369205 100644 --- a/src/main/java/net/discordjug/javabot/data/config/guild/ModerationConfig.java +++ b/src/main/java/net/discordjug/javabot/data/config/guild/ModerationConfig.java @@ -6,6 +6,7 @@ import net.dv8tion.jda.api.entities.Role; import net.dv8tion.jda.api.entities.channel.concrete.ForumChannel; import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel; import java.util.List; @@ -92,6 +93,11 @@ public class ModerationConfig extends GuildConfigItem { * Invite links AutoMod should exclude. */ private List automodInviteExcludes = List.of(); + + /** + * The ID of the voice channel template that lets users create their own voice channels. + */ + private long customVoiceChannelId; /** * Text that is sent to users when they're banned. @@ -149,4 +155,8 @@ public Role getExpertRole() { public TextChannel getNotificationThreadChannel() { return this.getGuild().getTextChannelById(this.notificationThreadChannelId); } + + public VoiceChannel getCustomVoiceChannel() { + return this.getGuild().getVoiceChannelById(customVoiceChannelId); + } } diff --git a/src/main/java/net/discordjug/javabot/systems/custom_vc/CustomVCButtonHandler.java b/src/main/java/net/discordjug/javabot/systems/custom_vc/CustomVCButtonHandler.java new file mode 100644 index 000000000..0ae2ebb09 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/custom_vc/CustomVCButtonHandler.java @@ -0,0 +1,73 @@ +package net.discordjug.javabot.systems.custom_vc; + +import java.util.function.BiConsumer; + +import lombok.RequiredArgsConstructor; +import net.discordjug.javabot.annotations.AutoDetectableComponentHandler; +import net.discordjug.javabot.util.Responses; +import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; +import net.dv8tion.jda.api.interactions.components.buttons.Button; +import net.dv8tion.jda.api.requests.restaction.PermissionOverrideAction; +import xyz.dynxsty.dih4jda.interactions.components.ButtonHandler; +import xyz.dynxsty.dih4jda.util.ComponentIdBuilder; + +/** + * Handles buttons for managing custom voice channels. + */ +@AutoDetectableComponentHandler(CustomVCButtonHandler.COMPONENT_ID) +@RequiredArgsConstructor +public class CustomVCButtonHandler implements ButtonHandler { + static final String COMPONENT_ID = "custom-vc"; + + private static final String MAKE_PRIVATE_ID = "make-private"; + private static final String MAKE_PUBLIC_ID = "make-public"; + + private final CustomVCRepository repository; + + public Button createMakePrivateButton() { + return Button.primary(ComponentIdBuilder.build(COMPONENT_ID, MAKE_PRIVATE_ID), "make VC private"); + } + + public Button createMakePublicButton() { + return Button.primary(ComponentIdBuilder.build(COMPONENT_ID, MAKE_PUBLIC_ID), "make VC public"); + } + + @Override + public void handleButton(ButtonInteractionEvent event, Button button) { + if(!repository.isCustomVoiceChannel(event.getChannelIdLong()) || + repository.getOwnerId(event.getChannelIdLong()) != event.getMember().getIdLong()) { + Responses.error(event, "Only the VC owner can use this.").queue(); + return; + } + String[] id = ComponentIdBuilder.split(button.getId()); + switch (id[1]) { + case MAKE_PRIVATE_ID -> changeVisibility(event, createMakePublicButton(), "This voice channel is now private.", + PermissionOverrideAction::setDenied); + + case MAKE_PUBLIC_ID -> changeVisibility(event, createMakePrivateButton(), "This voice channel is now public.", + PermissionOverrideAction::setAllowed); + default -> Responses.error(event, "Unknown button").queue(); + } + } + + private void changeVisibility(ButtonInteractionEvent event, Button newButton, String messageContent, + BiConsumer permissionModifier) { + PermissionOverrideAction permissionOverrideAction = event + .getGuildChannel() + .asVoiceChannel() + .upsertPermissionOverride(event.getGuild().getPublicRole()); + permissionModifier.accept(permissionOverrideAction, Permission.VIEW_CHANNEL); + permissionOverrideAction + .queue(); + event + .editButton(newButton) + .flatMap(edited -> { + return event + .getHook() + .setEphemeral(true) + .sendMessage(messageContent); + }) + .queue(); + } +} diff --git a/src/main/java/net/discordjug/javabot/systems/custom_vc/CustomVCListener.java b/src/main/java/net/discordjug/javabot/systems/custom_vc/CustomVCListener.java new file mode 100644 index 000000000..8ecd66edb --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/custom_vc/CustomVCListener.java @@ -0,0 +1,82 @@ +package net.discordjug.javabot.systems.custom_vc; + +import java.util.List; + +import lombok.RequiredArgsConstructor; +import net.discordjug.javabot.data.config.BotConfig; +import net.discordjug.javabot.util.ExceptionLogger; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel; +import net.dv8tion.jda.api.entities.channel.middleman.StandardGuildChannel; +import net.dv8tion.jda.api.entities.channel.unions.AudioChannelUnion; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceUpdateEvent; +import net.dv8tion.jda.api.events.session.ReadyEvent; +import net.dv8tion.jda.api.hooks.ListenerAdapter; +import net.dv8tion.jda.api.requests.restaction.ChannelAction; + +/** + * Automatically creates a temporary voice channel when a user joins the {@link ModerationConfig#getCustomVoiceChannel() custom voice channel template} and deletes that voice channel when there are no users left. + */ +@RequiredArgsConstructor +public class CustomVCListener extends ListenerAdapter { + private final BotConfig botConfig; + private final CustomVCRepository repository; + private final CustomVCButtonHandler buttonHandler; + + @Override + public void onGuildVoiceUpdate(GuildVoiceUpdateEvent event) { + AudioChannelUnion vcJoined = event.getChannelJoined(); + long customVoiceChannelId = botConfig.get(event.getGuild()).getModerationConfig().getCustomVoiceChannelId(); + if (vcJoined != null && vcJoined.getIdLong() == customVoiceChannelId) { + createCustomVC(event, vcJoined); + } + AudioChannelUnion vcLeft = event.getChannelLeft(); + if (vcLeft != null && repository.isCustomVoiceChannel(vcLeft.getIdLong()) && vcLeft.getMembers().isEmpty()) { + vcLeft.delete().queue(); + } + } + + private void createCustomVC(GuildVoiceUpdateEvent event, AudioChannelUnion vcJoined) { + ChannelAction copy = vcJoined.createCopy(); + copy + .setName("custom-" + event.getMember().getId()) + .addMemberPermissionOverride( + event.getMember().getIdLong(), + List.of(Permission.MANAGE_CHANNEL, Permission.VIEW_CHANNEL), + List.of()) + .queue(newChannel -> { + repository.addCustomVoiceChannel(newChannel.getIdLong(), event.getMember().getIdLong()); + if (!(newChannel instanceof VoiceChannel newVC)) { + ExceptionLogger.capture(new IllegalStateException("expected VoiceChannel to be created, got " + newChannel.getClass().getCanonicalName())); + newChannel.delete().queue(); + return; + } + event.getGuild().moveVoiceMember(event.getMember(), newVC).queue(); + newVC.sendMessageEmbeds(new EmbedBuilder() + .setTitle("Your personal Voice Channel") + .setDescription(""" + This is your personal, temporary voice channel. + You can configure this channel using the button below, the `/vc-control` command or the Discord settings. + This channel will be deleted as soon as all people leave this channel. + """) + .build()) + .addContent(event.getMember().getAsMention()) + .addActionRow(buttonHandler.createMakePrivateButton()) + .queue(); + }); + } + + @Override + public void onReady(ReadyEvent event) { + for (long channelId : repository.getAllCustomVoiceChannels()) { + VoiceChannel vc = event.getJDA().getVoiceChannelById(channelId); + if (vc == null) { + repository.removeCustomVoiceChannel(channelId); + } else if (vc.getMembers().isEmpty()) { + vc.delete().queue(); + repository.removeCustomVoiceChannel(channelId); + } + } + } +} diff --git a/src/main/java/net/discordjug/javabot/systems/custom_vc/CustomVCRepository.java b/src/main/java/net/discordjug/javabot/systems/custom_vc/CustomVCRepository.java new file mode 100644 index 000000000..ba4f3d5ad --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/custom_vc/CustomVCRepository.java @@ -0,0 +1,63 @@ +package net.discordjug.javabot.systems.custom_vc; + +import java.util.List; + +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +/** + * Stores currently active custom voice channels and the owners of these channels. + */ +@Repository +@RequiredArgsConstructor +public class CustomVCRepository { + + private final JdbcTemplate jdbcTemplate; + + /** + * Stores a new custom voice channel. + * @param id the channel ID + * @param ownerId the ID of the owner of the voice channel + */ + public void addCustomVoiceChannel(long id, long ownerId) { + jdbcTemplate.update("INSERT INTO custom_vc (channel_id, owner_id) VALUES (?, ?)", + id, ownerId); + } + + /** + * Removes a custom voice channel. + * @param id the channel ID + */ + public void removeCustomVoiceChannel(long id) { + jdbcTemplate.update("DELETE FROM custom_vc WHERE channel_id = ?", id); + } + + /** + * Checks whether a channel is a custom voice channel. + * @param id the channel ID + * @return {@code true} if the channel is a custom voice channel, else {@code false} + */ + public boolean isCustomVoiceChannel(long id) { + return jdbcTemplate.queryForObject("SELECT COUNT(*) FROM custom_vc WHERE channel_id = ?", (rs, rowId) -> rs.getInt(1), id) > 0; + } + + /** + * Gets the owner of a custom voice channel. + * @param voiceChannelId the ID of the voice channel + * @return the ID of the owner of the custom voice channel + */ + public long getOwnerId(long voiceChannelId) { + return jdbcTemplate.queryForObject("SELECT owner_id FROM custom_vc WHERE channel_id = ?", + (rs, rowId) -> rs.getLong(1), + voiceChannelId); + } + + /** + * Gets all custom voice channels of all guilds. + * @return a {@link List} of all custom voice channel IDs + */ + public List getAllCustomVoiceChannels() { + return jdbcTemplate.query("SELECT channel_id FROM custom_vc", (rs, row) -> rs.getLong(1)); + } +} diff --git a/src/main/java/net/discordjug/javabot/systems/custom_vc/commands/CustomVCAddMemberSubcommand.java b/src/main/java/net/discordjug/javabot/systems/custom_vc/commands/CustomVCAddMemberSubcommand.java new file mode 100644 index 000000000..23ea8e91d --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/custom_vc/commands/CustomVCAddMemberSubcommand.java @@ -0,0 +1,31 @@ +package net.discordjug.javabot.systems.custom_vc.commands; + +import net.discordjug.javabot.data.config.BotConfig; +import net.discordjug.javabot.systems.custom_vc.CustomVCRepository; +import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; + +/** + * Adds a member to a custom voice channel. + */ +public class CustomVCAddMemberSubcommand extends CustomVCChangeMembersSubcommand { + + public CustomVCAddMemberSubcommand(CustomVCRepository dataStorage, + BotConfig botConfig) { + super(new SubcommandData("add-member", "adds a member to the voice channel"), dataStorage, botConfig); + } + + @Override + protected void apply(VoiceChannel vc, Member member, SlashCommandInteractionEvent event) { + vc.upsertPermissionOverride(member) + .setAllowed(Permission.VIEW_CHANNEL) + .queue(); + event + .reply("Successfully added " + member.getAsMention() + " to " + vc.getAsMention() + ". They can now join the voice channel.") + .setEphemeral(true) + .queue(); + } +} diff --git a/src/main/java/net/discordjug/javabot/systems/custom_vc/commands/CustomVCChangeMembersSubcommand.java b/src/main/java/net/discordjug/javabot/systems/custom_vc/commands/CustomVCChangeMembersSubcommand.java new file mode 100644 index 000000000..ef4c02a67 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/custom_vc/commands/CustomVCChangeMembersSubcommand.java @@ -0,0 +1,86 @@ +package net.discordjug.javabot.systems.custom_vc.commands; + +import net.discordjug.javabot.data.config.BotConfig; +import net.discordjug.javabot.systems.custom_vc.CustomVCRepository; +import net.discordjug.javabot.util.Responses; +import net.dv8tion.jda.api.entities.GuildVoiceState; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.channel.ChannelType; +import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel; +import net.dv8tion.jda.api.entities.channel.unions.AudioChannelUnion; +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.SubcommandData; +import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand; + +/** + * Abstract/base subcommand for changing the allowed members in custom voice channels. + */ +abstract class CustomVCChangeMembersSubcommand extends SlashCommand.Subcommand { + + protected final BotConfig botConfig; + private final CustomVCRepository repository; + + /** + * The constructor of this class, which sets the corresponding {@link net.dv8tion.jda.api.interactions.commands.build.SubcommandData}. + * @param subcommandData the configuration (name and description) of the subcommand + * @param repository The repository storing information about custom voice channels + * @param botConfig the main configuration of the bot + */ + protected CustomVCChangeMembersSubcommand(SubcommandData subcommandData, CustomVCRepository repository, BotConfig botConfig) { + this.botConfig = botConfig; + this.repository = repository; + setCommandData( + subcommandData + .addOption(OptionType.USER, "member", "The member in question") + ); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + if (botConfig.get(event.getGuild()).getModerationConfig().getCustomVoiceChannelId() == 0) { + Responses.error(event, "This feature is disabled.").queue(); + return; + } + VoiceChannel vc = getCustomVoiceChannel(event); + if (vc == null) { + Responses.error(event, "This command be used in custom voice channels only. You can create a custom voice channel by joining " + botConfig.get(event.getGuild()).getModerationConfig().getCustomVoiceChannel().getAsMention() + ".") + .queue(); + return; + } + if (repository.getOwnerId(vc.getIdLong()) != event.getMember().getIdLong()) { + Responses.error(event, "Only the owner of the custom voice channel can use this command. You can create your own custom voice channel by joining " + botConfig.get(event.getGuild()).getModerationConfig().getCustomVoiceChannel().getAsMention() + ".") + .queue(); + return; + } + Member member = event.getOption("member", null, OptionMapping::getAsMember); + if (member == null) { + Responses.replyMissingArguments(event).queue(); + return; + } + + apply(vc, member, event); + } + + protected abstract void apply(VoiceChannel vc, Member member, SlashCommandInteractionEvent event); + + private VoiceChannel getCustomVoiceChannel(SlashCommandInteractionEvent event) { + if (repository.isCustomVoiceChannel(event.getChannelIdLong()) && event.getChannel().getType() == ChannelType.VOICE) { + return event.getChannel().asVoiceChannel(); + } + GuildVoiceState voiceState = event.getMember().getVoiceState(); + if (voiceState == null) { + return null; + } + AudioChannelUnion channel = voiceState.getChannel(); + if (channel == null || channel.getType() != ChannelType.VOICE) { + return null; + } + if (repository.isCustomVoiceChannel(voiceState.getChannel().getIdLong())) { + return voiceState.getChannel().asVoiceChannel(); + } + return null; + } + +} diff --git a/src/main/java/net/discordjug/javabot/systems/custom_vc/commands/CustomVCControlCommand.java b/src/main/java/net/discordjug/javabot/systems/custom_vc/commands/CustomVCControlCommand.java new file mode 100644 index 000000000..aac9ffecf --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/custom_vc/commands/CustomVCControlCommand.java @@ -0,0 +1,21 @@ +package net.discordjug.javabot.systems.custom_vc.commands; + +import net.dv8tion.jda.api.interactions.commands.build.Commands; +import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand; + +/** + * Command for managing custom voice channels. + */ +public class CustomVCControlCommand extends SlashCommand { + /** + * The constructor of this class, which sets the corresponding {@link net.dv8tion.jda.api.interactions.commands.build.SlashCommandData}. + * @param addMemberSubcommand /vc-control add-member + * @param removeMemberSubcommand /vc-control remove-member + */ + public CustomVCControlCommand(CustomVCAddMemberSubcommand addMemberSubcommand, CustomVCRemoveMemberSubcommand removeMemberSubcommand) { + setCommandData(Commands.slash("vc-control", "Manages custom voice channels") + .setGuildOnly(true) + ); + addSubcommands(addMemberSubcommand, removeMemberSubcommand); + } +} diff --git a/src/main/java/net/discordjug/javabot/systems/custom_vc/commands/CustomVCRemoveMemberSubcommand.java b/src/main/java/net/discordjug/javabot/systems/custom_vc/commands/CustomVCRemoveMemberSubcommand.java new file mode 100644 index 000000000..4ad26d139 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/custom_vc/commands/CustomVCRemoveMemberSubcommand.java @@ -0,0 +1,53 @@ +package net.discordjug.javabot.systems.custom_vc.commands; + +import net.discordjug.javabot.data.config.BotConfig; +import net.discordjug.javabot.systems.custom_vc.CustomVCRepository; +import net.discordjug.javabot.util.Responses; +import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; + +/** + * Command for removing a member from a custom voice channel. + * Only the owner of the custom voice channel can use this command. + * This command ensures that the member in question is no longer in the custom voice channel and can no longer join that channel. + */ +public class CustomVCRemoveMemberSubcommand extends CustomVCChangeMembersSubcommand { + + public CustomVCRemoveMemberSubcommand(CustomVCRepository dataStorage, + BotConfig botConfig) { + super(new SubcommandData("remove-member", "removes a member to the voice channel"), dataStorage, botConfig); + } + + @Override + protected void apply(VoiceChannel vc, Member member, SlashCommandInteractionEvent event) { + if (member.getRoles().contains(botConfig.get(event.getGuild()).getModerationConfig().getStaffRole())) { + Responses.error(event, "Cannot remove staff members from custom voice channels.").queue(); + return; + } + + if (event.getMember().getIdLong() == member.getIdLong()) { + Responses.error(event, "You cannot perform this action on yourself.").queue(); + return; + } + + if (member.getVoiceState().getChannel() != null && member.getVoiceState().getChannel().getIdLong() == vc.getIdLong()) { + VoiceChannel afkChannel = event.getGuild().getAfkChannel(); + if (afkChannel == null) { + event.getGuild().kickVoiceMember(member).queue(); + } else { + event.getGuild().moveVoiceMember(member, afkChannel).queue(); + } + } + + vc.upsertPermissionOverride(member) + .setDenied(Permission.VIEW_CHANNEL) + .queue(); + event + .reply("Successfully removed " + member.getAsMention() + " from " + vc.getAsMention() + ". They can no longer join the voice channel.") + .setEphemeral(true) + .queue(); + } +} diff --git a/src/main/resources/database/migrations/09-15-2024_custom_vcs.sql b/src/main/resources/database/migrations/09-15-2024_custom_vcs.sql new file mode 100644 index 000000000..e08363295 --- /dev/null +++ b/src/main/resources/database/migrations/09-15-2024_custom_vcs.sql @@ -0,0 +1,4 @@ +CREATE TABLE custom_vc ( + channel_id BIGINT NOT NULL PRIMARY KEY, + owner_id BIGINT NOT NULL +) From 81dd54243244bafa3a685e46c8299dc22cc0cf5c Mon Sep 17 00:00:00 2001 From: danthe1st Date: Sun, 15 Sep 2024 14:05:25 +0200 Subject: [PATCH 063/242] remove a newline --- .../javabot/systems/custom_vc/CustomVCButtonHandler.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/systems/custom_vc/CustomVCButtonHandler.java b/src/main/java/net/discordjug/javabot/systems/custom_vc/CustomVCButtonHandler.java index 0ae2ebb09..541d9136c 100644 --- a/src/main/java/net/discordjug/javabot/systems/custom_vc/CustomVCButtonHandler.java +++ b/src/main/java/net/discordjug/javabot/systems/custom_vc/CustomVCButtonHandler.java @@ -58,8 +58,7 @@ private void changeVisibility(ButtonInteractionEvent event, Button newButton, St .asVoiceChannel() .upsertPermissionOverride(event.getGuild().getPublicRole()); permissionModifier.accept(permissionOverrideAction, Permission.VIEW_CHANNEL); - permissionOverrideAction - .queue(); + permissionOverrideAction.queue(); event .editButton(newButton) .flatMap(edited -> { From a1876a1c6d91744bc9c60da5027d8ea2f8aef8b1 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Sat, 5 Oct 2024 13:18:18 +0200 Subject: [PATCH 064/242] update JDA, use forwarding information in automod --- build.gradle.kts | 2 +- .../javabot/systems/moderation/AutoMod.java | 20 ++++++++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 33b9ced43..f3f3e002f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -31,7 +31,7 @@ dependencies { // DIH4JDA (Command Framework) & JDA implementation("com.github.DynxstyGIT:DIH4JDA:120a15ad2e") - implementation("net.dv8tion:JDA:5.0.0") { + implementation("net.dv8tion:JDA:5.1.2") { exclude(module = "opus-java") } diff --git a/src/main/java/net/discordjug/javabot/systems/moderation/AutoMod.java b/src/main/java/net/discordjug/javabot/systems/moderation/AutoMod.java index ec1948b09..4e84eeff0 100644 --- a/src/main/java/net/discordjug/javabot/systems/moderation/AutoMod.java +++ b/src/main/java/net/discordjug/javabot/systems/moderation/AutoMod.java @@ -8,7 +8,9 @@ import net.dv8tion.jda.api.Permission; import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.MessageReference; import net.dv8tion.jda.api.entities.channel.unions.MessageChannelUnion; +import net.dv8tion.jda.api.entities.messages.MessageSnapshot; import net.dv8tion.jda.api.events.message.MessageReceivedEvent; import net.dv8tion.jda.api.events.message.MessageUpdateEvent; import net.dv8tion.jda.api.hooks.ListenerAdapter; @@ -132,7 +134,7 @@ private void checkContentAutomod(@Nonnull Message message) { } private void doAutomodActions(Message message, String reason) { - notificationService.withGuild(message.getGuild()).sendToModerationLog(c -> c.sendMessageFormat("Message by %s: `%s`", message.getAuthor().getAsMention(), message.getContentRaw())); + notificationService.withGuild(message.getGuild()).sendToModerationLog(c -> c.sendMessageFormat("Message by %s: `%s`", message.getAuthor().getAsMention(), getMessageContent(message))); moderationService .warn( message.getAuthor(), @@ -188,7 +190,7 @@ private void handleSpam(@Nonnull Message msg, Member member) { * @return True if a link is found and False if not. */ public boolean hasSuspiciousLink(@NotNull Message message) { - final String messageRaw = message.getContentRaw(); + final String messageRaw = getMessageContent(message); Matcher urlMatcher = URL_PATTERN.matcher(messageRaw); if (messageRaw.contains("http://") || messageRaw.contains("https://")) { // only do it for a links, so it won't iterate for each message @@ -217,7 +219,7 @@ public boolean hasSuspiciousLink(@NotNull Message message) { */ public boolean hasAdvertisingLink(@NotNull Message message) { // Advertising - Matcher matcher = INVITE_URL.matcher(cleanString(message.getContentRaw())); + Matcher matcher = INVITE_URL.matcher(cleanString(getMessageContent(message))); int start = 0; while (matcher.find(start)) { if (botConfig.get(message.getGuild()).getModerationConfig().getAutomodInviteExcludes().stream().noneMatch(matcher.group()::contains)) { @@ -232,4 +234,16 @@ private boolean isSuggestionsChannel(@NotNull MessageChannelUnion channel) { return channel.getType().isGuild() && channel.getIdLong() == botConfig.get(channel.asGuildMessageChannel().getGuild()).getModerationConfig().getSuggestionChannel().getIdLong(); } + + private String getMessageContent(Message msg) { + //see https://github.com/discord-jda/JDA/releases/tag/v5.1.2 + MessageReference messageReference = msg.getMessageReference(); + if (messageReference != null && messageReference.getType() == MessageReference.MessageReferenceType.FORWARD) { + MessageSnapshot snapshot = msg.getMessageSnapshots().get(0); + if (snapshot != null) { + return snapshot.getContentRaw(); + } + } + return msg.getContentRaw(); + } } \ No newline at end of file From 7562bd7faaa36767d3f7cf9860f0710239432400 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Sat, 5 Oct 2024 13:34:18 +0200 Subject: [PATCH 065/242] use real forwarded content for message logs --- .../message_cache/model/CachedMessage.java | 4 +-- .../javabot/systems/moderation/AutoMod.java | 20 +++-------- .../discordjug/javabot/util/MessageUtils.java | 33 +++++++++++++++++++ 3 files changed, 39 insertions(+), 18 deletions(-) create mode 100644 src/main/java/net/discordjug/javabot/util/MessageUtils.java diff --git a/src/main/java/net/discordjug/javabot/data/h2db/message_cache/model/CachedMessage.java b/src/main/java/net/discordjug/javabot/data/h2db/message_cache/model/CachedMessage.java index d6e8d9220..c22e538ce 100644 --- a/src/main/java/net/discordjug/javabot/data/h2db/message_cache/model/CachedMessage.java +++ b/src/main/java/net/discordjug/javabot/data/h2db/message_cache/model/CachedMessage.java @@ -4,6 +4,7 @@ import java.util.List; import lombok.Data; +import net.discordjug.javabot.util.MessageUtils; import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.Message.Attachment; @@ -27,7 +28,7 @@ public static CachedMessage of(Message message) { CachedMessage cachedMessage = new CachedMessage(); cachedMessage.setMessageId(message.getIdLong()); cachedMessage.setAuthorId(message.getAuthor().getIdLong()); - cachedMessage.setMessageContent(message.getContentRaw().trim()); + cachedMessage.setMessageContent(MessageUtils.getMessageContent(message).trim()); cachedMessage.attachments = message .getAttachments() .stream() @@ -35,5 +36,4 @@ public static CachedMessage of(Message message) { .toList(); return cachedMessage; } - } diff --git a/src/main/java/net/discordjug/javabot/systems/moderation/AutoMod.java b/src/main/java/net/discordjug/javabot/systems/moderation/AutoMod.java index 4e84eeff0..6c0b85039 100644 --- a/src/main/java/net/discordjug/javabot/systems/moderation/AutoMod.java +++ b/src/main/java/net/discordjug/javabot/systems/moderation/AutoMod.java @@ -5,12 +5,11 @@ import net.discordjug.javabot.systems.moderation.warn.model.WarnSeverity; import net.discordjug.javabot.systems.notification.NotificationService; import net.discordjug.javabot.util.ExceptionLogger; +import net.discordjug.javabot.util.MessageUtils; import net.dv8tion.jda.api.Permission; import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.Message; -import net.dv8tion.jda.api.entities.MessageReference; import net.dv8tion.jda.api.entities.channel.unions.MessageChannelUnion; -import net.dv8tion.jda.api.entities.messages.MessageSnapshot; import net.dv8tion.jda.api.events.message.MessageReceivedEvent; import net.dv8tion.jda.api.events.message.MessageUpdateEvent; import net.dv8tion.jda.api.hooks.ListenerAdapter; @@ -134,7 +133,7 @@ private void checkContentAutomod(@Nonnull Message message) { } private void doAutomodActions(Message message, String reason) { - notificationService.withGuild(message.getGuild()).sendToModerationLog(c -> c.sendMessageFormat("Message by %s: `%s`", message.getAuthor().getAsMention(), getMessageContent(message))); + notificationService.withGuild(message.getGuild()).sendToModerationLog(c -> c.sendMessageFormat("Message by %s: `%s`", message.getAuthor().getAsMention(), MessageUtils.getMessageContent(message))); moderationService .warn( message.getAuthor(), @@ -190,7 +189,7 @@ private void handleSpam(@Nonnull Message msg, Member member) { * @return True if a link is found and False if not. */ public boolean hasSuspiciousLink(@NotNull Message message) { - final String messageRaw = getMessageContent(message); + final String messageRaw = MessageUtils.getMessageContent(message); Matcher urlMatcher = URL_PATTERN.matcher(messageRaw); if (messageRaw.contains("http://") || messageRaw.contains("https://")) { // only do it for a links, so it won't iterate for each message @@ -219,7 +218,7 @@ public boolean hasSuspiciousLink(@NotNull Message message) { */ public boolean hasAdvertisingLink(@NotNull Message message) { // Advertising - Matcher matcher = INVITE_URL.matcher(cleanString(getMessageContent(message))); + Matcher matcher = INVITE_URL.matcher(cleanString(MessageUtils.getMessageContent(message))); int start = 0; while (matcher.find(start)) { if (botConfig.get(message.getGuild()).getModerationConfig().getAutomodInviteExcludes().stream().noneMatch(matcher.group()::contains)) { @@ -235,15 +234,4 @@ private boolean isSuggestionsChannel(@NotNull MessageChannelUnion channel) { channel.getIdLong() == botConfig.get(channel.asGuildMessageChannel().getGuild()).getModerationConfig().getSuggestionChannel().getIdLong(); } - private String getMessageContent(Message msg) { - //see https://github.com/discord-jda/JDA/releases/tag/v5.1.2 - MessageReference messageReference = msg.getMessageReference(); - if (messageReference != null && messageReference.getType() == MessageReference.MessageReferenceType.FORWARD) { - MessageSnapshot snapshot = msg.getMessageSnapshots().get(0); - if (snapshot != null) { - return snapshot.getContentRaw(); - } - } - return msg.getContentRaw(); - } } \ No newline at end of file diff --git a/src/main/java/net/discordjug/javabot/util/MessageUtils.java b/src/main/java/net/discordjug/javabot/util/MessageUtils.java new file mode 100644 index 000000000..4a0d8d31d --- /dev/null +++ b/src/main/java/net/discordjug/javabot/util/MessageUtils.java @@ -0,0 +1,33 @@ +package net.discordjug.javabot.util; + +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.MessageReference; +import net.dv8tion.jda.api.entities.messages.MessageSnapshot; + +/** + * Utility class for JDA messages. + */ +public class MessageUtils { + + private MessageUtils() { + //prevent instantiation + } + + /** + * Gets the actual content of a message. + * In case of forwarded messages, this gets the content of the forwarded message. + * @param msg the message to check + * @return the content of the passed message + */ + public static String getMessageContent(Message msg) { + //see https://github.com/discord-jda/JDA/releases/tag/v5.1.2 + MessageReference messageReference = msg.getMessageReference(); + if (messageReference != null && messageReference.getType() == MessageReference.MessageReferenceType.FORWARD) { + MessageSnapshot snapshot = msg.getMessageSnapshots().get(0); + if (snapshot != null) { + return snapshot.getContentRaw(); + } + } + return msg.getContentRaw(); + } +} \ No newline at end of file From f9add79b8071f183c4d7c7369b6dcef9878beaf7 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Tue, 29 Oct 2024 10:48:53 +0100 Subject: [PATCH 066/242] fix QOTW sample answer race condition --- .../qotw/jobs/QOTWCloseSubmissionsJob.java | 82 +++++++++++-------- 1 file changed, 48 insertions(+), 34 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/systems/qotw/jobs/QOTWCloseSubmissionsJob.java b/src/main/java/net/discordjug/javabot/systems/qotw/jobs/QOTWCloseSubmissionsJob.java index c4c4e00a0..72f0d405e 100644 --- a/src/main/java/net/discordjug/javabot/systems/qotw/jobs/QOTWCloseSubmissionsJob.java +++ b/src/main/java/net/discordjug/javabot/systems/qotw/jobs/QOTWCloseSubmissionsJob.java @@ -12,6 +12,7 @@ import net.discordjug.javabot.systems.qotw.model.QOTWSubmission; import net.discordjug.javabot.systems.qotw.submissions.SubmissionManager; import net.discordjug.javabot.systems.qotw.submissions.SubmissionStatus; +import net.discordjug.javabot.util.ExceptionLogger; import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.Permission; @@ -45,6 +46,7 @@ import java.util.Collections; import java.util.List; import java.util.Optional; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; /** @@ -81,46 +83,58 @@ public void execute() throws SQLException { if (qotwConfig.getSubmissionChannel() == null || qotwConfig.getQuestionChannel() == null) continue; Message questionMessage = getLatestQOTWMessage(qotwConfig.getQuestionChannel(), qotwConfig, jda); questionMessage.editMessageComponents(ActionRow.of(Button.secondary("qotw-submission:closed", "Submissions closed").asDisabled())).queue(); - logChannel - .sendMessageFormat("%s%nIt's review time! There are **%s** threads to review", - qotwConfig.getQOTWReviewRole().getAsMention(), - qotwConfig.getSubmissionChannel().getThreadChannels().size()) - .flatMap(msg -> msg.createThreadChannel("QOTW review")) - .queue(thread -> { - for (ThreadChannel submission : qotwConfig.getSubmissionChannel().getThreadChannels()) { - submission.getManager().setName(SUBMISSION_PENDING + submission.getName()).queue(); - // remove the author - final QOTWSubmission s = new QOTWSubmission(submission); - s.retrieveAuthor(author -> { - submission.removeThreadMember(author).queue(); - if (author.getIdLong() == qotwConfig.getQotwSampleAnswerUserId()) { - SubmissionManager manager = new SubmissionManager(botConfig.get(guild).getQotwConfig(), pointsService, questionQueueRepository, notificationService, asyncPool); - manager.copySampleAnswerSubmission(submission, author); - } else { - thread - .sendMessage("%s by %s".formatted(submission.getAsMention(), author.getAsMention())) - .addActionRow(buildSubmissionSelectMenu(jda, submission.getIdLong())) - .queue(); - } - }); - for (Member member : guild.getMembersWithRoles(qotwConfig.getQOTWReviewRole())) { - thread.addThreadMember(member).queue(); + if (qotwConfig.getSubmissionsForumChannel() == null) { + sendReviewInformation(guild, qotwConfig, logChannel); + } else { + CompletableFuture.runAsync(() -> { + + MessageEmbed embed = questionMessage.getEmbeds().get(0); + Optional questionOptional = questionQueueRepository.findByQuestionNumber(getQuestionNumberFromEmbed(embed)); + if (questionOptional.isPresent()) { + QOTWQuestion question = questionOptional.get(); + try (MessageCreateData data = new MessageCreateBuilder() + .setEmbeds(buildQuestionEmbed(question)).build()) { + createPinnedAnswerForum(qotwConfig, question, data); } } + }, asyncPool).whenComplete((success, ex) -> { + if (ex != null) { + ExceptionLogger.capture(ex, getClass().getName()); + } + sendReviewInformation(guild, qotwConfig, logChannel); }); - if (qotwConfig.getSubmissionsForumChannel() == null) continue; - asyncPool.execute(() -> { - MessageEmbed embed = questionMessage.getEmbeds().get(0); - Optional questionOptional = questionQueueRepository.findByQuestionNumber(getQuestionNumberFromEmbed(embed)); - if (questionOptional.isPresent()) { - QOTWQuestion question = questionOptional.get(); - try (MessageCreateData data = new MessageCreateBuilder() - .setEmbeds(buildQuestionEmbed(question)).build()) { - createPinnedAnswerForum(qotwConfig, question, data); + } + } + } + + private void sendReviewInformation(Guild guild, QOTWConfig qotwConfig, TextChannel logChannel) { + logChannel + .sendMessageFormat("%s%nIt's review time! There are **%s** threads to review", + qotwConfig.getQOTWReviewRole().getAsMention(), + qotwConfig.getSubmissionChannel().getThreadChannels().size()) + .flatMap(msg -> msg.createThreadChannel("QOTW review")) + .queue(thread -> { + for (ThreadChannel submission : qotwConfig.getSubmissionChannel().getThreadChannels()) { + submission.getManager().setName(SUBMISSION_PENDING + submission.getName()).queue(); + // remove the author + final QOTWSubmission s = new QOTWSubmission(submission); + s.retrieveAuthor(author -> { + submission.removeThreadMember(author).queue(); + if (author.getIdLong() == qotwConfig.getQotwSampleAnswerUserId()) { + SubmissionManager manager = new SubmissionManager(botConfig.get(guild).getQotwConfig(), pointsService, questionQueueRepository, notificationService, asyncPool); + manager.copySampleAnswerSubmission(submission, author); + } else { + thread + .sendMessage("%s by %s".formatted(submission.getAsMention(), author.getAsMention())) + .addActionRow(buildSubmissionSelectMenu(jda, submission.getIdLong())) + .queue(); + } + }); + for (Member member : guild.getMembersWithRoles(qotwConfig.getQOTWReviewRole())) { + thread.addThreadMember(member).queue(); } } }); - } } private void createPinnedAnswerForum(QOTWConfig qotwConfig, QOTWQuestion question, MessageCreateData data) { From 4e9372b4b36a376e3719cf2ca0cc296906ed386f Mon Sep 17 00:00:00 2001 From: danthe1st Date: Sat, 23 Nov 2024 17:31:00 +0100 Subject: [PATCH 067/242] native-image compilation, disable member cache --- .github/workflows/build.yml | 12 +- Dockerfile | 20 +- build.gradle.kts | 33 +- settings.gradle.kts | 2 +- .../javabot/RuntimeHintsConfiguration.java | 73 + .../net/discordjug/javabot/SpringConfig.java | 5 +- .../discordjug/javabot/api/TomcatConfig.java | 8 +- .../qotw/QOTWLeaderboardController.java | 6 +- .../JobChannelCloseOldPostsListener.java | 3 +- .../systems/help/HelpForumUpdater.java | 5 +- .../javabot/systems/help/HelpListener.java | 9 +- .../javabot/systems/help/HelpManager.java | 25 +- .../server_lock/ServerLockManager.java | 39 +- .../systems/qotw/QOTWPointsService.java | 21 +- .../qotw/dao/QOTWChampionRepository.java | 45 + .../systems/qotw/jobs/QOTWChampionJob.java | 30 +- .../RunScheduledTaskCommand.java | 4 +- .../commands/EditCustomTagSubcommand.java | 1 - .../user_commands/ServerInfoCommand.java | 16 +- .../ExperienceLeaderboardSubcommand.java | 2 +- .../ThanksLeaderboardSubcommand.java | 2 +- .../javabot/tasks/MetricsUpdater.java | 3 + .../discordjug/javabot/util/WebhookUtil.java | 23 +- .../net.discordjug/javabot/jni-config.json | 286 + src/main/resources/application.properties | 1 + .../11-11-2024_store_qotw_champion.sql | 5 + src/main/resources/database/schema.sql | 9 +- src/main/resources/logback.xml | 15 - src/main/resources/spamLinks.txt | 4736 ----------------- 29 files changed, 601 insertions(+), 4838 deletions(-) create mode 100644 src/main/java/net/discordjug/javabot/RuntimeHintsConfiguration.java create mode 100644 src/main/java/net/discordjug/javabot/systems/qotw/dao/QOTWChampionRepository.java create mode 100644 src/main/resources/META-INF/native-image/net.discordjug/javabot/jni-config.json create mode 100644 src/main/resources/database/migrations/11-11-2024_store_qotw_champion.sql delete mode 100644 src/main/resources/spamLinks.txt diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f480b1d74..477c95034 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,13 +27,13 @@ jobs: if: ${{ (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch' }} steps: - uses: actions/checkout@v4 - - name: Set up JDK 17 - uses: actions/setup-java@v4 + - name: Set up JDK 21 + uses: graalvm/setup-graalvm@v1 with: - java-version: '17' - distribution: 'temurin' - - name: Build JAR - run: ./gradlew shadowJar + java-version: '21' + distribution: 'graalvm-community' + - name: Build native-image + run: ./gradlew nativeCompile -Pprod - name: Build Docker image run: docker build -t javabot . - name: Tag docker image diff --git a/Dockerfile b/Dockerfile index cb92850f9..e5129ae84 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,21 @@ -FROM eclipse-temurin:21-jre -RUN mkdir /work -COPY build/libs/JavaBot-1.0.0-SNAPSHOT-all.jar /work/bot.jar +FROM alpine:latest +RUN apk add --no-cache libsm libxrender libxext libxtst libxi gcompat ttf-dejavu + +COPY build/native/nativeCompile /work WORKDIR /work + RUN chown 1000:1000 /work +USER 1000 +ENV HOME=/work + +# https://github.com/openjdk/jdk/pull/20169 +# need to create fake JAVA_HOME +RUN mkdir -p /tmp/JAVA_HOME/conf/fonts +RUN mkdir /tmp/JAVA_HOME/lib + + VOLUME "/work/config" VOLUME "/work/logs" VOLUME "/work/db" VOLUME "/work/purgeArchives" -USER 1000 -ENTRYPOINT [ "java", "-jar", "bot.jar" ] \ No newline at end of file +ENTRYPOINT [ "./javabot", "-Djava.home=/tmp/JAVA_HOME" ] diff --git a/build.gradle.kts b/build.gradle.kts index 33b9ced43..b39382126 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,8 +4,9 @@ import com.github.jengelman.gradle.plugins.shadow.transformers.* plugins { java id("com.github.johnrengelman.shadow") version "7.1.2" - id("org.springframework.boot") version "3.2.0" - id("io.spring.dependency-management") version "1.0.15.RELEASE" + id("org.springframework.boot") version "3.3.5" + id("io.spring.dependency-management") version "1.1.6" + id("org.graalvm.buildtools.native") version "0.10.3" checkstyle } @@ -64,6 +65,9 @@ dependencies { // Spring implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-data-jdbc") + + //required for registering native hints + implementation("org.jetbrains.kotlin:kotlin-reflect") } configurations { @@ -101,4 +105,27 @@ tasks.withType { checkstyle { toolVersion = "9.1" configDirectory.set(File("checkstyle")) -} \ No newline at end of file +} + +tasks.withType() { + exclude("**/generated/**") +} + +tasks.checkstyleAot { + isEnabled = false +} +tasks.processTestAot { + isEnabled = false +} + +graalvmNative { + binaries { + named("main") { + if (hasProperty("prod")) { + buildArgs.add("-O3") + } else { + quickBuild.set(true) + } + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 7c2c02a73..f22613145 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,2 +1,2 @@ -rootProject.name = "JavaBot" +rootProject.name = "javabot" diff --git a/src/main/java/net/discordjug/javabot/RuntimeHintsConfiguration.java b/src/main/java/net/discordjug/javabot/RuntimeHintsConfiguration.java new file mode 100644 index 000000000..7c9b6c974 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/RuntimeHintsConfiguration.java @@ -0,0 +1,73 @@ +package net.discordjug.javabot; + +import java.nio.channels.Channel; + +import club.minnced.discord.webhook.send.WebhookEmbed; +import net.discordjug.javabot.data.config.BotConfig; +import net.discordjug.javabot.data.config.GuildConfig; +import net.discordjug.javabot.data.config.GuildConfigItem; +import net.discordjug.javabot.data.config.SystemsConfig; +import net.discordjug.javabot.data.config.guild.HelpConfig; +import net.discordjug.javabot.data.config.guild.MessageCacheConfig; +import net.discordjug.javabot.data.config.guild.MetricsConfig; +import net.discordjug.javabot.data.config.guild.ModerationConfig; +import net.discordjug.javabot.data.config.guild.QOTWConfig; +import net.discordjug.javabot.data.config.guild.ServerLockConfig; +import net.discordjug.javabot.data.config.guild.StarboardConfig; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.Role; +import net.dv8tion.jda.api.entities.ScheduledEvent; +import net.dv8tion.jda.api.entities.ThreadMember; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.entities.channel.forums.ForumTag; +import net.dv8tion.jda.api.entities.emoji.RichCustomEmoji; +import net.dv8tion.jda.api.entities.sticker.GuildSticker; +import net.dv8tion.jda.api.hooks.ListenerAdapter; +import net.dv8tion.jda.api.managers.AudioManager; +import net.dv8tion.jda.internal.entities.MemberPresenceImpl; +import net.dv8tion.jda.internal.requests.restaction.PermOverrideData; +import org.h2.server.TcpServer; +import org.springframework.aot.hint.MemberCategory; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; +import org.springframework.aot.hint.annotation.RegisterReflectionForBinding; +import org.springframework.core.io.ClassPathResource; + +/** + * Configure classes and resources to be accessible from native-image. + */ +@RegisterReflectionForBinding({ + //register config classes for reflection + BotConfig.class, GuildConfig.class, GuildConfigItem.class,SystemsConfig.class, + HelpConfig.class, MessageCacheConfig.class, MetricsConfig.class, ModerationConfig.class, QOTWConfig.class, ServerLockConfig.class, StarboardConfig.class, + + //ensure JDA can create necessary caches + User[].class, Guild[].class, Member[].class, Role[].class, Channel[].class, AudioManager[].class, ScheduledEvent[].class, ThreadMember[].class, ForumTag[].class, RichCustomEmoji[].class, GuildSticker[].class, MemberPresenceImpl[].class, + //needs to be serialized for channel managers etc + PermOverrideData.class, + //ensure that webhook embed authors can be serialized + WebhookEmbed.EmbedAuthor.class + }) +public class RuntimeHintsConfiguration implements RuntimeHintsRegistrar { + + @Override + public void registerHints(RuntimeHints hints, ClassLoader classLoader) { + + //ensure resources are available in native-image + hints.resources().registerPattern("assets/**"); + hints.resources().registerPattern("database/**"); + hints.resources().registerPattern("help_guidelines/**"); + hints.resources().registerPattern("help_overview/**"); + hints.resources().registerResource(new ClassPathResource("quartz.properties")); + + //allow H2 to create the TCP server (necessary for starting the DB) + hints.reflection().registerType(TcpServer.class, MemberCategory.INVOKE_PUBLIC_METHODS); + + // JDA needs to be able to access listener methods + hints.reflection().registerType(ListenerAdapter.class, MemberCategory.INVOKE_PUBLIC_METHODS); + + // caffeine + hints.reflection().registerTypeIfPresent(getClass().getClassLoader(), "com.github.benmanes.caffeine.cache.SSW", MemberCategory.INVOKE_DECLARED_METHODS, MemberCategory.INVOKE_DECLARED_CONSTRUCTORS); + } +} diff --git a/src/main/java/net/discordjug/javabot/SpringConfig.java b/src/main/java/net/discordjug/javabot/SpringConfig.java index 36deb1489..fe07deebf 100644 --- a/src/main/java/net/discordjug/javabot/SpringConfig.java +++ b/src/main/java/net/discordjug/javabot/SpringConfig.java @@ -10,7 +10,7 @@ import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; - +import org.springframework.context.annotation.ImportRuntimeHints; import xyz.dynxsty.dih4jda.DIH4JDA; import xyz.dynxsty.dih4jda.DIH4JDABuilder; import xyz.dynxsty.dih4jda.exceptions.DIH4JDAException; @@ -34,6 +34,7 @@ * This class holds all configuration settings and {@link Bean}s. */ @Configuration +@ImportRuntimeHints(RuntimeHintsConfiguration.class) @RequiredArgsConstructor public class SpringConfig { @Bean @@ -71,7 +72,7 @@ JDA jda(BotConfig botConfig, ApplicationContext ctx) { return JDABuilder.createDefault(botConfig.getSystems().getJdaBotToken()) .setStatus(OnlineStatus.DO_NOT_DISTURB) .setChunkingFilter(ChunkingFilter.ALL) - .setMemberCachePolicy(MemberCachePolicy.ALL) + .setMemberCachePolicy(MemberCachePolicy.NONE) .enableCache(CacheFlag.ACTIVITY) .enableIntents(GatewayIntent.GUILD_MEMBERS, GatewayIntent.GUILD_PRESENCES, GatewayIntent.MESSAGE_CONTENT) .addEventListeners(listeners.toArray()) diff --git a/src/main/java/net/discordjug/javabot/api/TomcatConfig.java b/src/main/java/net/discordjug/javabot/api/TomcatConfig.java index 7283ffac0..18ce7649b 100644 --- a/src/main/java/net/discordjug/javabot/api/TomcatConfig.java +++ b/src/main/java/net/discordjug/javabot/api/TomcatConfig.java @@ -7,6 +7,8 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import java.net.InetAddress; + import net.discordjug.javabot.data.config.SystemsConfig; @@ -18,6 +20,7 @@ public class TomcatConfig { private final int ajpPort; + private final InetAddress ajpAddress; private final boolean tomcatAjpEnabled; private final SystemsConfig systemsConfig; @@ -26,11 +29,13 @@ public class TomcatConfig { * @param ajpPort The port to run AJP under * @param tomcatAjpEnabled true if AJP is enabled, else false * @param systemsConfig an object representing the configuration of various systems + * @param ajpAddress the listen address for AJP */ - public TomcatConfig(@Value("${tomcat.ajp.port}") int ajpPort, @Value("${tomcat.ajp.enabled}") boolean tomcatAjpEnabled, SystemsConfig systemsConfig) { + public TomcatConfig(@Value("${tomcat.ajp.port}") int ajpPort, @Value("${tomcat.ajp.enabled}") boolean tomcatAjpEnabled, @Value("${tomcat.ajp.address}") InetAddress ajpAddress, SystemsConfig systemsConfig) { this.ajpPort = ajpPort; this.tomcatAjpEnabled = tomcatAjpEnabled; this.systemsConfig = systemsConfig; + this.ajpAddress = ajpAddress; } /** @@ -46,6 +51,7 @@ TomcatServletWebServerFactory servletContainer() { Connector ajpConnector = new Connector("org.apache.coyote.ajp.AjpNioProtocol"); AjpNioProtocol protocol= (AjpNioProtocol) ajpConnector.getProtocolHandler(); protocol.setSecret(systemsConfig.getApiConfig().getAjpSecret()); + protocol.setAddress(ajpAddress); ajpConnector.setPort(ajpPort); ajpConnector.setSecure(true); tomcat.addAdditionalTomcatConnectors(ajpConnector); diff --git a/src/main/java/net/discordjug/javabot/api/routes/leaderboard/qotw/QOTWLeaderboardController.java b/src/main/java/net/discordjug/javabot/api/routes/leaderboard/qotw/QOTWLeaderboardController.java index 375c19ca2..915b94ce7 100644 --- a/src/main/java/net/discordjug/javabot/api/routes/leaderboard/qotw/QOTWLeaderboardController.java +++ b/src/main/java/net/discordjug/javabot/api/routes/leaderboard/qotw/QOTWLeaderboardController.java @@ -69,9 +69,9 @@ public ResponseEntity> getQOTWLeaderboard( if (members == null || members.isEmpty()) { List topAccounts = pointsService.getTopAccounts(PAGE_AMOUNT, page); members = topAccounts.stream() - .map(account -> new Pair<>(account, jda.retrieveUserById(account.getUserId()).complete())) - .filter(pair -> guild.isMember(pair.second())) - .map(pair -> createAPIAccount(pair.first(), pair.second(), topAccounts, page)) + .map(account -> new Pair<>(account, guild.retrieveMemberById(account.getUserId()).complete())) + .filter(pair -> pair.second() != null) + .map(pair -> createAPIAccount(pair.first(), pair.second().getUser(), topAccounts, page)) .toList(); getCache().put(new Pair<>(guild.getIdLong(), page), members); } diff --git a/src/main/java/net/discordjug/javabot/listener/JobChannelCloseOldPostsListener.java b/src/main/java/net/discordjug/javabot/listener/JobChannelCloseOldPostsListener.java index 22832902b..d61751653 100644 --- a/src/main/java/net/discordjug/javabot/listener/JobChannelCloseOldPostsListener.java +++ b/src/main/java/net/discordjug/javabot/listener/JobChannelCloseOldPostsListener.java @@ -9,6 +9,7 @@ import net.discordjug.javabot.data.config.guild.ModerationConfig; import net.discordjug.javabot.util.InteractionUtils; import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.UserSnowflake; import net.dv8tion.jda.api.entities.channel.ChannelType; import net.dv8tion.jda.api.entities.channel.concrete.ThreadChannel; import net.dv8tion.jda.api.events.channel.ChannelCreateEvent; @@ -53,7 +54,7 @@ public void onChannelCreate(ChannelCreateEvent event) { .setTitle("Post closed") .setDescription("This post has been blocked because you have created other recent posts.\nPlease do not spam posts.") .build()) - .setContent(post.getOwner().getAsMention()) + .setContent(UserSnowflake.fromId(post.getOwnerIdLong()).getAsMention()) .flatMap(msg -> post.getManager().setArchived(true).setLocked(true)) .queue(); return; diff --git a/src/main/java/net/discordjug/javabot/systems/help/HelpForumUpdater.java b/src/main/java/net/discordjug/javabot/systems/help/HelpForumUpdater.java index 458e448a4..4d134a081 100644 --- a/src/main/java/net/discordjug/javabot/systems/help/HelpForumUpdater.java +++ b/src/main/java/net/discordjug/javabot/systems/help/HelpForumUpdater.java @@ -81,9 +81,8 @@ private void checkForumPost(@NotNull ThreadChannel post, HelpConfig config) { private void sendDMDormantInfoIfEnabled(ThreadChannel post, HelpConfig config) { if(Boolean.parseBoolean(preferenceService.getOrCreate(post.getOwnerIdLong(), Preference.PRIVATE_DORMANT_NOTIFICATIONS).getState())) { post - .getOwner() - .getUser() - .openPrivateChannel() + .getJDA() + .openPrivateChannelById(post.getOwnerIdLong()) .flatMap(c -> c.sendMessageEmbeds(createDMDormantInfo(post, config))) .queue(); } diff --git a/src/main/java/net/discordjug/javabot/systems/help/HelpListener.java b/src/main/java/net/discordjug/javabot/systems/help/HelpListener.java index d15763205..638e0a2cc 100644 --- a/src/main/java/net/discordjug/javabot/systems/help/HelpListener.java +++ b/src/main/java/net/discordjug/javabot/systems/help/HelpListener.java @@ -22,7 +22,6 @@ import net.dv8tion.jda.api.events.message.MessageReceivedEvent; import net.dv8tion.jda.api.hooks.ListenerAdapter; import net.dv8tion.jda.api.interactions.callbacks.IReplyCallback; -import net.dv8tion.jda.api.interactions.components.ActionComponent; import net.dv8tion.jda.api.interactions.components.ActionRow; import net.dv8tion.jda.api.interactions.components.buttons.Button; @@ -254,7 +253,7 @@ private void handleHelpThanksInteraction(@NotNull ButtonInteractionEvent event, return; } switch (id[2]) { - case "done" -> handleThanksCloseButton(event, manager, post); + case "done" -> handleThanksCloseButton(event, manager, post, ""); case "cancel" -> event.deferEdit().flatMap(h -> event.getMessage().delete()).queue(); default -> { List