diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 477c95034..ec3629727 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,6 +1,3 @@ -# 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 JavaBot on: [push, pull_request, workflow_dispatch] @@ -8,32 +5,52 @@ on: [push, pull_request, workflow_dispatch] jobs: build: runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v4 - - name: Set up JDK 17 - uses: actions/setup-java@v4 + - uses: actions/checkout@v6 + - name: Set up JDK 25 + uses: actions/setup-java@v5 with: - java-version: '17' + java-version: '25' + cache: 'maven' 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 and run tests + run: mvn verify + nativetest: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + - name: Set up JDK 25 + uses: graalvm/setup-graalvm@v1 + with: + java-version: '25' + distribution: 'graalvm-community' + cache: 'maven' + - name: Run tests with native-image + run: mvn test -PnativeTest publish: runs-on: ubuntu-latest - needs: build + permissions: + contents: read + needs: + - build if: ${{ (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch' }} + concurrency: + group: publish + cancel-in-progress: true steps: - - uses: actions/checkout@v4 - - name: Set up JDK 21 + - uses: actions/checkout@v6 + - name: Set up JDK 25 uses: graalvm/setup-graalvm@v1 with: - java-version: '21' + java-version: '25' distribution: 'graalvm-community' + cache: 'maven' - name: Build native-image - run: ./gradlew nativeCompile -Pprod + run: "mvn native:compile -Pnative -Pprod '-P!dev'" - name: Build Docker image run: docker build -t javabot . - name: Tag docker image @@ -44,7 +61,7 @@ jobs: dockerhub_username: ${{ secrets.DOCKERHUB_USERNAME }} if: env.dockerhub_username != null - name: Login to DockerHub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 37a12370e..97ed05640 100644 --- a/.gitignore +++ b/.gitignore @@ -7,12 +7,13 @@ build/ /purgeArchives /logs /db - +/target/ # Eclipse settings .classpath .project .settings/ bin/ +/.factorypath # H2 Database *.mv.db diff --git a/Dockerfile b/Dockerfile index e5129ae84..416b2fa01 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,8 @@ FROM alpine:latest RUN apk add --no-cache libsm libxrender libxext libxtst libxi gcompat ttf-dejavu -COPY build/native/nativeCompile /work +COPY target/*.so /work/ +COPY target/javabot /work/ WORKDIR /work RUN chown 1000:1000 /work diff --git a/build.gradle.kts b/build.gradle.kts deleted file mode 100644 index 744e7a657..000000000 --- a/build.gradle.kts +++ /dev/null @@ -1,131 +0,0 @@ -import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar -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.3.5" - id("io.spring.dependency-management") version "1.1.6" - id("org.graalvm.buildtools.native") version "0.10.3" - checkstyle -} - -java { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 -} - -group = "net.discordjug" -version = "1.0.0-SNAPSHOT" - -repositories { - mavenCentral() - maven(url = "https://m2.dv8tion.net/releases") - maven(url = "https://jitpack.io") -} - -dependencies { - testImplementation("org.junit.jupiter:junit-jupiter-api:5.9.0") - testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.9.0") - compileOnly("com.google.code.findbugs:jsr305:3.0.2") - compileOnly("org.jetbrains:annotations:23.0.0") - - // DIH4JDA (Command Framework) & JDA - implementation("com.github.DynxstyGIT:DIH4JDA:120a15ad2e") - implementation("net.dv8tion:JDA:5.1.2") { - exclude(module = "opus-java") - } - - // Caffeine (Caching Library) - implementation("com.github.ben-manes.caffeine:caffeine:3.1.1") - - implementation("com.google.code.gson:gson:2.9.0") - implementation("org.yaml:snakeyaml:1.30") - implementation("com.google.re2j:re2j:1.6") - implementation("commons-validator:commons-validator:1.7") - - implementation("com.mashape.unirest:unirest-java:1.4.9") - - // H2 Database - implementation("com.h2database:h2:2.1.212") - implementation("com.zaxxer:HikariCP:5.0.1") - - // Webhooks - implementation("com.github.DynxstyGIT:discord-webhooks:74301a46a0") - - // Lombok Annotations - 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") - - // 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 { - all { - exclude(group = "commons-logging", module = "commons-logging") - } -} - -tasks.withType { - manifest { - attributes["Manifest-Version"] = "1.0" - attributes["Main-Class"] = "net.discordjug.javabot.Bot" - } -} - -tasks.withType{ options.encoding = "UTF-8" } -tasks.withType().configureEach { - options.forkOptions.jvmArgs = listOf("--add-opens", "jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED") -} -tasks.withType{ useJUnitPlatform() } - -tasks.withType { - isZip64 = true - // Required for Spring - mergeServiceFiles() - append("META-INF/spring.handlers") - append("META-INF/spring.schemas") - append("META-INF/spring.tooling") - transform(PropertiesFileTransformer().apply { - paths = listOf("META-INF/spring.factories") - mergeStrategy = "append" - }) -} - -checkstyle { - toolVersion = "9.1" - configDirectory.set(File("checkstyle")) -} - -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/checkstyle/suppressions.xml b/checkstyle/suppressions.xml deleted file mode 100644 index e69de29bb..000000000 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index d64cd4917..000000000 Binary files a/gradle/wrapper/gradle-wrapper.jar and /dev/null differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index 1af9e0930..000000000 --- a/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,7 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -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 deleted file mode 100755 index 1aa94a426..000000000 --- a/gradlew +++ /dev/null @@ -1,249 +0,0 @@ -#!/bin/sh - -# -# Copyright © 2015-2021 the original authors. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -############################################################################## -# -# Gradle start up script for POSIX generated by Gradle. -# -# Important for running: -# -# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is -# noncompliant, but you have some other compliant shell such as ksh or -# bash, then to run this script, type that shell name before the whole -# command line, like: -# -# ksh Gradle -# -# Busybox and similar reduced shells will NOT work, because this script -# requires all of these POSIX shell features: -# * functions; -# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», -# «${var#prefix}», «${var%suffix}», and «$( cmd )»; -# * compound commands having a testable exit status, especially «case»; -# * various built-in commands including «command», «set», and «ulimit». -# -# Important for patching: -# -# (2) This script targets any POSIX shell, so it avoids extensions provided -# by Bash, Ksh, etc; in particular arrays are avoided. -# -# The "traditional" practice of packing multiple parameters into a -# space-separated string is a well documented source of bugs and security -# problems, so this is (mostly) avoided, by progressively accumulating -# options in "$@", and eventually passing that to Java. -# -# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, -# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; -# see the in-line comments for details. -# -# There are tweaks for specific operating systems such as AIX, CygWin, -# Darwin, MinGW, and NonStop. -# -# (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/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 -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 - -# 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 - -warn () { - echo "$*" -} >&2 - -die () { - echo - echo "$*" - echo - exit 1 -} >&2 - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "$( uname )" in #( - CYGWIN* ) cygwin=true ;; #( - Darwin* ) darwin=true ;; #( - MSYS* | MINGW* ) msys=true ;; #( - NONSTOP* ) nonstop=true ;; -esac - -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar - - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD=$JAVA_HOME/jre/sh/java - else - JAVACMD=$JAVA_HOME/bin/java - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD=java - 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" && ! "$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 - -# Collect all arguments for the java command, stacking in reverse order: -# * args from the command line -# * the main class name -# * -classpath -# * -D...appname settings -# * --module-path (only if needed) -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. - -# For Cygwin or MSYS, switch paths to Windows format before running java -if "$cygwin" || "$msys" ; then - APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) - - JAVACMD=$( cygpath --unix "$JAVACMD" ) - - # Now convert the arguments - kludge to limit ourselves to /bin/sh - for arg do - if - case $arg in #( - -*) false ;; # don't mess with options #( - /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath - [ -e "$t" ] ;; #( - *) false ;; - esac - then - arg=$( cygpath --path --ignore --mixed "$arg" ) - fi - # Roll the args list around exactly as many times as the number of - # args, so each arg winds up back in the position where it started, but - # possibly modified. - # - # NB: a `for` loop captures its iteration list before it begins, so - # changing the positional parameters here affects neither the number of - # iterations, nor the values presented in `arg`. - shift # remove old arg - set -- "$@" "$arg" # push replacement arg - done -fi - - -# 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 deleted file mode 100644 index 93e3f59f1..000000000 --- a/gradlew.bat +++ /dev/null @@ -1,92 +0,0 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% 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! -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 - -:omega diff --git a/pom.xml b/pom.xml new file mode 100644 index 000000000..9a881c6c6 --- /dev/null +++ b/pom.xml @@ -0,0 +1,213 @@ + + + 4.0.0 + net.discordjug + javabot + 1.0.0-SNAPSHOT + + org.springframework.boot + spring-boot-starter-parent + 4.1.0 + + + + 25 + UTF-8 + net.discordjug.javabot.Bot + + + + + jitpack + https://jitpack.io + + + + + + com.google.code.findbugs + jsr305 + 3.0.2 + provided + + + org.jetbrains + annotations + 26.0.2 + + + com.github.jasonlessenich + DIH4JDA + 1.7.0 + + + net.dv8tion + JDA + 6.5.0 + + + opus-java + + + + + com.github.ben-manes.caffeine + caffeine + + + com.google.code.gson + gson + + + com.google.re2j + re2j + 1.8 + + + commons-validator + commons-validator + 1.10.0 + + + com.mashape.unirest + unirest-java + 1.4.9 + + + com.h2database + h2 + + + com.zaxxer + HikariCP + + + com.github.DynxstyGIT + discord-webhooks + 74301a46a0 + + + org.projectlombok + lombok + provided + + + io.sentry + sentry + 8.20.0 + + + org.springframework.boot + spring-boot-starter-webmvc + + + org.springframework.boot + spring-boot-starter-data-jdbc + + + org.jetbrains.kotlin + kotlin-reflect + + + org.slf4j + jcl-over-slf4j + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + org.apache.maven.plugins + maven-checkstyle-plugin + 3.6.0 + + checkstyle/checkstyle.xml + true + true + false + + + + run-checkstyle + verify + + check + + + + + + org.graalvm.buildtools + native-maven-plugin + + ${main.class} + + -H:+UnlockExperimentalVMOptions + -H:+ForeignAPISupport + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + + + dev + + true + + + + + org.graalvm.buildtools + native-maven-plugin + + true + + + + + + + prod + + + + org.graalvm.buildtools + native-maven-plugin + + + -O3 + + + + + + + + diff --git a/settings.gradle.kts b/settings.gradle.kts deleted file mode 100644 index f22613145..000000000 --- a/settings.gradle.kts +++ /dev/null @@ -1,2 +0,0 @@ -rootProject.name = "javabot" - diff --git a/src/main/java/net/discordjug/javabot/Bot.java b/src/main/java/net/discordjug/javabot/Bot.java index 29f478169..acc01a4fa 100644 --- a/src/main/java/net/discordjug/javabot/Bot.java +++ b/src/main/java/net/discordjug/javabot/Bot.java @@ -101,7 +101,7 @@ public void onApplicationEvent(ApplicationReadyEvent event) { */ public static void main(String[] args) throws Exception { TimeZone.setDefault(TimeZone.getTimeZone(ZoneOffset.UTC)); - MessageRequest.setDefaultMentions(EnumSet.of(Message.MentionType.ROLE, Message.MentionType.CHANNEL, Message.MentionType.USER, Message.MentionType.EMOJI)); + MessageRequest.setDefaultMentions(EnumSet.of(Message.MentionType.CHANNEL, Message.MentionType.USER, Message.MentionType.EMOJI)); SpringApplication.run(Bot.class, args); } diff --git a/src/main/java/net/discordjug/javabot/RuntimeHintsConfiguration.java b/src/main/java/net/discordjug/javabot/RuntimeHintsConfiguration.java index bf1560607..1f30b4136 100644 --- a/src/main/java/net/discordjug/javabot/RuntimeHintsConfiguration.java +++ b/src/main/java/net/discordjug/javabot/RuntimeHintsConfiguration.java @@ -1,8 +1,9 @@ package net.discordjug.javabot; -import java.nio.channels.Channel; +import java.util.concurrent.ThreadPoolExecutor; import club.minnced.discord.webhook.send.WebhookEmbed; +import com.zaxxer.hikari.HikariConfig; import net.discordjug.javabot.data.config.BotConfig; import net.discordjug.javabot.data.config.GuildConfig; import net.discordjug.javabot.data.config.GuildConfigItem; @@ -10,23 +11,15 @@ import net.discordjug.javabot.data.config.SystemsConfig.ApiConfig; import net.discordjug.javabot.data.config.guild.HelpConfig; import net.discordjug.javabot.data.config.guild.MessageCacheConfig; +import net.discordjug.javabot.data.config.guild.MessageRule; 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.entities.SoundboardSound; 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.entities.GuildVoiceStateImpl; import net.dv8tion.jda.internal.requests.restaction.PermOverrideData; import org.h2.server.TcpServer; import org.springframework.aot.hint.MemberCategory; @@ -45,14 +38,12 @@ @RegisterReflectionForBinding({ //register config classes for reflection BotConfig.class, GuildConfig.class, GuildConfigItem.class, SystemsConfig.class, ApiConfig.class, - HelpConfig.class, MessageCacheConfig.class, MetricsConfig.class, ModerationConfig.class, QOTWConfig.class, ServerLockConfig.class, StarboardConfig.class, + HelpConfig.class, MessageCacheConfig.class, MetricsConfig.class, ModerationConfig.class, QOTWConfig.class, ServerLockConfig.class, StarboardConfig.class,MessageRule.class, MessageRule.MessageAction.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 + + HikariConfig.class }) public class RuntimeHintsConfiguration implements RuntimeHintsRegistrar { @@ -72,7 +63,25 @@ public void registerHints(RuntimeHints hints, ClassLoader classLoader) { // JDA needs to be able to access listener methods hints.reflection().registerType(ListenerAdapter.class, MemberCategory.INVOKE_PUBLIC_METHODS); + hints.reflection().registerType(ThreadPoolExecutor.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); + + try { + // These classes are necessary on X11 but may not be loaded on Wayland when generating native hints + for(Class cl : getClass().getClassLoader().loadClass("sun.font.FontConfigManager").getDeclaredClasses()) { + hints.jni().registerType(cl, MemberCategory.ACCESS_DECLARED_FIELDS, MemberCategory.INVOKE_DECLARED_CONSTRUCTORS); + } + } catch (ClassNotFoundException _) { + // FontConfigManager is only supported on Linux + } + + for (Class cl : WebhookEmbed.class.getClasses()) { + hints.reflection().registerType(cl, MemberCategory.ACCESS_DECLARED_FIELDS, MemberCategory.INVOKE_PUBLIC_METHODS); + } + + hints.reflection().registerType(GuildVoiceStateImpl[].class, MemberCategory.UNSAFE_ALLOCATED); + hints.reflection().registerType(SoundboardSound[].class, MemberCategory.UNSAFE_ALLOCATED); } } diff --git a/src/main/java/net/discordjug/javabot/SpringConfig.java b/src/main/java/net/discordjug/javabot/SpringConfig.java index 1a5168a47..73bdefe2c 100644 --- a/src/main/java/net/discordjug/javabot/SpringConfig.java +++ b/src/main/java/net/discordjug/javabot/SpringConfig.java @@ -5,8 +5,6 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; -import javax.sql.DataSource; - import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -19,7 +17,6 @@ import net.discordjug.javabot.annotations.PreRegisteredListener; import net.discordjug.javabot.data.config.BotConfig; import net.discordjug.javabot.data.config.SystemsConfig; -import net.discordjug.javabot.data.h2db.DbHelper; import net.discordjug.javabot.tasks.PresenceUpdater; import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.JDABuilder; @@ -36,16 +33,8 @@ @RequiredArgsConstructor public class SpringConfig { @Bean - PresenceUpdater standardActivityPresenceUpdater() { - return PresenceUpdater.standardActivities(); - } - - @Bean - DataSource dataSource(BotConfig config) { - if (config.getSystems().getJdaBotToken().isEmpty()) { - throw new RuntimeException("JDA Token not set. Stopping Bot..."); - } - return DbHelper.initDataSource(config); + PresenceUpdater standardActivityPresenceUpdater(ScheduledExecutorService threadPool) { + return PresenceUpdater.standardActivities(threadPool); } @Bean @@ -95,6 +84,10 @@ DIH4JDA initializeDIH4JDA(JDA jda) throws DIH4JDAException { @Bean BotConfig botConfig() { - return new BotConfig(Path.of("config")); + BotConfig botConfig = new BotConfig(Path.of("config")); + if (botConfig.getSystems().getJdaBotToken().isEmpty()) { + throw new RuntimeException("JDA Token not set. Stopping Bot..."); + } + return botConfig; } } diff --git a/src/main/java/net/discordjug/javabot/api/TomcatConfig.java b/src/main/java/net/discordjug/javabot/api/TomcatConfig.java deleted file mode 100644 index 18ce7649b..000000000 --- a/src/main/java/net/discordjug/javabot/api/TomcatConfig.java +++ /dev/null @@ -1,61 +0,0 @@ -package net.discordjug.javabot.api; - -import org.apache.catalina.connector.Connector; -import org.apache.coyote.ajp.AjpNioProtocol; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -import java.net.InetAddress; - -import net.discordjug.javabot.data.config.SystemsConfig; - - -/** - * Holds all configuration for the {@link org.springframework.boot.autoconfigure.web.ServerProperties.Tomcat} - * web service. - */ -@Configuration -public class TomcatConfig { - - private final int ajpPort; - private final InetAddress ajpAddress; - private final boolean tomcatAjpEnabled; - private final SystemsConfig systemsConfig; - - /** - * Initializes this object. - * @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, @Value("${tomcat.ajp.address}") InetAddress ajpAddress, SystemsConfig systemsConfig) { - this.ajpPort = ajpPort; - this.tomcatAjpEnabled = tomcatAjpEnabled; - this.systemsConfig = systemsConfig; - this.ajpAddress = ajpAddress; - } - - /** - * Sets up the {@link TomcatServletWebServerFactory} using the {@link Value}s defined in the - * application.properties file. - * - * @return The {@link TomcatServletWebServerFactory}. - */ - @Bean - TomcatServletWebServerFactory servletContainer() { - TomcatServletWebServerFactory tomcat = new TomcatServletWebServerFactory(); - if (tomcatAjpEnabled) { - 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); - } - return tomcat; - } -} diff --git a/src/main/java/net/discordjug/javabot/data/config/BotConfig.java b/src/main/java/net/discordjug/javabot/data/config/BotConfig.java index 675dfb0ee..5b11f78d9 100644 --- a/src/main/java/net/discordjug/javabot/data/config/BotConfig.java +++ b/src/main/java/net/discordjug/javabot/data/config/BotConfig.java @@ -1,10 +1,9 @@ package net.discordjug.javabot.data.config; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; import com.google.gson.JsonSyntaxException; import lombok.extern.slf4j.Slf4j; import net.discordjug.javabot.util.ExceptionLogger; +import net.discordjug.javabot.util.GsonUtils; import net.dv8tion.jda.api.entities.Guild; import org.jetbrains.annotations.NotNull; @@ -64,11 +63,10 @@ public BotConfig(Path dir) { } } this.guilds = new ConcurrentHashMap<>(); - Gson gson = new GsonBuilder().enableComplexMapKeySerialization().create(); Path systemsFile = dir.resolve(SYSTEMS_FILE); if (Files.exists(systemsFile)) { try (BufferedReader reader = Files.newBufferedReader(systemsFile)) { - this.systemsConfig = gson.fromJson(reader, SystemsConfig.class); + this.systemsConfig = GsonUtils.fromJson(reader, SystemsConfig.class); log.info("Loaded systems config from {}", systemsFile); } catch (JsonSyntaxException e) { ExceptionLogger.capture(e, getClass().getSimpleName()); @@ -133,10 +131,9 @@ public SystemsConfig getSystems() { * Flushes all configuration to the disk. */ public void flush() { - Gson gson = new GsonBuilder().serializeNulls().setPrettyPrinting().create(); Path systemsFile = this.dir.resolve(SYSTEMS_FILE); try (BufferedWriter writer = Files.newBufferedWriter(systemsFile)) { - gson.toJson(this.systemsConfig, writer); + GsonUtils.toJson(this.systemsConfig, writer); writer.flush(); } catch (IOException e) { ExceptionLogger.capture(e, getClass().getSimpleName()); diff --git a/src/main/java/net/discordjug/javabot/data/config/GuildConfig.java b/src/main/java/net/discordjug/javabot/data/config/GuildConfig.java index 6e57bfe0e..95cdbc11a 100644 --- a/src/main/java/net/discordjug/javabot/data/config/GuildConfig.java +++ b/src/main/java/net/discordjug/javabot/data/config/GuildConfig.java @@ -1,12 +1,11 @@ package net.discordjug.javabot.data.config; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; import com.google.gson.JsonSyntaxException; import lombok.Data; import lombok.extern.slf4j.Slf4j; import net.discordjug.javabot.data.config.guild.*; import net.discordjug.javabot.util.ExceptionLogger; +import net.discordjug.javabot.util.GsonUtils; import net.discordjug.javabot.util.Pair; import net.dv8tion.jda.api.entities.Guild; @@ -18,6 +17,7 @@ import java.lang.reflect.Field; import java.nio.file.Files; import java.nio.file.Path; +import java.util.List; import java.util.Optional; /** @@ -37,6 +37,7 @@ public class GuildConfig { private StarboardConfig starboardConfig; private MessageCacheConfig messageCacheConfig; private ServerLockConfig serverLockConfig; + private List blacklistedMessageExtensions = List.of("jar", "exe", "zip"); /** * Constructor that initializes all Config classes. @@ -68,11 +69,10 @@ public GuildConfig(Guild guild, Path file) { * @throws UncheckedIOException if an IO error occurs. */ public static GuildConfig loadOrCreate(Guild guild, Path file) { - Gson gson = new GsonBuilder().create(); GuildConfig config; if (Files.exists(file)) { try (BufferedReader reader = Files.newBufferedReader(file)) { - config = gson.fromJson(reader, GuildConfig.class); + config = GsonUtils.fromJson(reader, GuildConfig.class); config.setFile(file); config.setGuild(guild); log.info("Loaded config from {}", file); @@ -113,9 +113,8 @@ private void setGuild(Guild guild) { * Saves this config to its file path. */ public synchronized void flush() { - Gson gson = new GsonBuilder().serializeNulls().setPrettyPrinting().create(); try (BufferedWriter writer = Files.newBufferedWriter(this.file)) { - gson.toJson(this, writer); + GsonUtils.toJson(this, writer); writer.flush(); } catch (IOException e) { ExceptionLogger.capture(e, getClass().getSimpleName()); @@ -158,7 +157,10 @@ public void set(String propertyName, String value) throws UnknownPropertyExcepti Optional> result = ReflectionUtils.resolveField(propertyName, this); result.ifPresent(pair -> { try { - ReflectionUtils.set(pair.first(), pair.second(), value); + Object updatedField = ReflectionUtils.set(pair.first(), pair.second(), value); + if (updatedField instanceof GuildConfigItem item) { + item.setGuildConfig(this); + } this.flush(); } catch (IllegalAccessException e) { ExceptionLogger.capture(e, getClass().getSimpleName()); diff --git a/src/main/java/net/discordjug/javabot/data/config/PatternTypeAdapter.java b/src/main/java/net/discordjug/javabot/data/config/PatternTypeAdapter.java new file mode 100644 index 000000000..dbdd4a708 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/data/config/PatternTypeAdapter.java @@ -0,0 +1,35 @@ +package net.discordjug.javabot.data.config; + +import java.io.IOException; +import java.util.regex.Pattern; + +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.google.gson.stream.JsonWriter; + +/** + * A gson {@link TypeAdapter} that allows serializing and deserializing regex {@link Pattern}s. + */ +public class PatternTypeAdapter extends TypeAdapter { + + @Override + public void write(JsonWriter writer, Pattern value) throws IOException { + if (value == null) { + writer.nullValue(); + return; + } + writer.value(value.toString()); + } + + @Override + public Pattern read(JsonReader reader) throws IOException { + if (reader.peek() == JsonToken.NULL) { + reader.nextNull(); + return null; + } + String value = reader.nextString(); + return Pattern.compile(value); + } + +} diff --git a/src/main/java/net/discordjug/javabot/data/config/ReflectionUtils.java b/src/main/java/net/discordjug/javabot/data/config/ReflectionUtils.java index 4407266f6..60961aaf6 100644 --- a/src/main/java/net/discordjug/javabot/data/config/ReflectionUtils.java +++ b/src/main/java/net/discordjug/javabot/data/config/ReflectionUtils.java @@ -2,6 +2,7 @@ import lombok.extern.slf4j.Slf4j; import net.discordjug.javabot.util.ExceptionLogger; +import net.discordjug.javabot.util.GsonUtils; import net.discordjug.javabot.util.Pair; import org.jetbrains.annotations.NotNull; @@ -10,31 +11,13 @@ import java.lang.reflect.Field; import java.lang.reflect.Modifier; import java.util.Arrays; -import java.util.HashMap; -import java.util.Map; import java.util.Optional; -import java.util.function.Function; /** * Utility class for resolving JSON files. */ @Slf4j public class ReflectionUtils { - private static final Map, Function> propertyTypeParsers = new HashMap<>(); - - static { - propertyTypeParsers.put(Integer.class, Integer::parseInt); - propertyTypeParsers.put(int.class, Integer::parseInt); - propertyTypeParsers.put(Long.class, Long::parseLong); - propertyTypeParsers.put(long.class, Long::parseLong); - propertyTypeParsers.put(Float.class, Float::parseFloat); - propertyTypeParsers.put(float.class, Float::parseFloat); - propertyTypeParsers.put(Double.class, Double::parseDouble); - propertyTypeParsers.put(double.class, Double::parseDouble); - propertyTypeParsers.put(Boolean.class, Boolean::parseBoolean); - propertyTypeParsers.put(String.class, s -> s); - } - private ReflectionUtils() { } @@ -92,47 +75,17 @@ private static Field findDeclaredField(Class cl, String name) throws NoSuchFi } /** - * Gets a mapping of properties and their type, recursively for the given - * type. - * - * @param parentPropertyName The root property name to append child field - * names to. This is null for the base case. - * @param parentClass The class to search for properties in. - * @return The map of properties and their types. - * @throws IllegalAccessException If a field cannot have its value obtained. - */ - public static @NotNull Map> getFields(@NotNull String parentPropertyName, @NotNull Class parentClass) throws IllegalAccessException { - Map> fieldsMap = new HashMap<>(); - for (Field field : parentClass.getDeclaredFields()) { - // Skip transient fields. - if (Modifier.isTransient(field.getModifiers()) || Modifier.isStatic(field.getModifiers())) continue; - field.setAccessible(true); - String fieldPropertyName = parentPropertyName == null ? field.getName() : parentPropertyName + "." + field.getName(); - // Check if the field represents a "leaf" property, one which does not have any children. - if (propertyTypeParsers.containsKey(field.getType())) { - fieldsMap.put(fieldPropertyName, field.getType()); - } else { - Map> childFieldsMap = getFields(fieldPropertyName, field.getType()); - fieldsMap.putAll(childFieldsMap); - } - } - return fieldsMap; - } - - /** - * Sets the value of a field to a certain value, using {@link ReflectionUtils#propertyTypeParsers} - * to try and parse the correct value. + * Sets the value of a field to a new value. * * @param field The field to set. * @param parent The object whose property value to set. * @param s The string representation of the value. + * @return Returns the new value. * @throws IllegalAccessException If the field cannot be set. */ - public static void set(@NotNull Field field, @NotNull Object parent, @NotNull String s) throws IllegalAccessException { - Function parser = propertyTypeParsers.get(field.getType()); - if (parser == null) { - throw new IllegalArgumentException("No supported property type parser for the type " + field.getType().getSimpleName()); - } - field.set(parent, parser.apply(s)); + public static Object set(@NotNull Field field, @NotNull Object parent, @NotNull String s) throws IllegalAccessException { + Object value = field.getType() == String.class ? s : GsonUtils.fromJson(s, field.getGenericType()); + field.set(parent, value); + return value; } } diff --git a/src/main/java/net/discordjug/javabot/data/config/SystemsConfig.java b/src/main/java/net/discordjug/javabot/data/config/SystemsConfig.java index e9501c181..3e62a6a7e 100644 --- a/src/main/java/net/discordjug/javabot/data/config/SystemsConfig.java +++ b/src/main/java/net/discordjug/javabot/data/config/SystemsConfig.java @@ -24,6 +24,11 @@ public class SystemsConfig { * The Key used for the bing-search-api. */ private String azureSubscriptionKey = ""; + + /** + * The API key of the Answer Overflow API. + */ + private String answerOverflowApiKey = ""; /** * The DSN for the Sentry API. diff --git a/src/main/java/net/discordjug/javabot/data/config/guild/MessageRule.java b/src/main/java/net/discordjug/javabot/data/config/guild/MessageRule.java new file mode 100644 index 000000000..5b6537d88 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/data/config/guild/MessageRule.java @@ -0,0 +1,60 @@ +package net.discordjug.javabot.data.config.guild; + +import java.util.HashSet; +import java.util.Set; +import java.util.regex.Pattern; + +import lombok.Data; + +/** + * If a message matches all of the given requirements of a rule, the configured action is performed on the message. + */ +@Data +public class MessageRule { + /** + * Messages must match this regex for the rule to activate. + */ + private Pattern messageRegex; + /** + * All attachments of the message must match this regex for the rule to activate. + */ + private Pattern attachmentNameRegex; + /** + * The number of attachments must be greater than or equal to that field for the rule to activate. + */ + private int minAttachments = -1; + /** + * The number of attachments must be less than or equal to that field for the rule to activate. + */ + private int maxAttachments = Integer.MAX_VALUE; + /** + * At least one attachment must match at least one of the SHA hashes for the rule to activate. + * If this set is empty, this condition is ignored. + */ + private Set attachmentSHAs = new HashSet<>(); + + /** + * There must be no messages older than that number of seconds. + * If this value is {@code 0} or negative, this condition is ignored. + */ + private long noMessagesFromAuthorBeforeSeconds = -1; + + /** + * The action to execute on the message. + */ + private MessageAction action = MessageAction.LOG; + + /** + * Enum for actions that can be performed on messages based on rules. + */ + public enum MessageAction { + /** + * The message is logged to a channel. + */ + LOG, + /** + * The message is deleted and logged to a channel. + */ + BLOCK + } +} 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 88f369205..37c78c8d5 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 @@ -8,6 +8,7 @@ import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel; +import java.util.ArrayList; import java.util.List; /** @@ -27,6 +28,18 @@ public class ModerationConfig extends GuildConfigItem { private long adminRoleId = 0; private long expertRoleId = 0; + /** + * The time window, in seconds, that the cross-channel spam automod looks back over when + * counting a user's recent messages. If this is {@code 0}, the cross-channel spam automod + * is disabled. + */ + private int crossChannelSpamWindowSeconds = 0; + + /** + * The number of distinct channels a user must post in within + * {@link #crossChannelSpamWindowSeconds} seconds before the cross-channel spam automod acts. + */ + private int crossChannelSpamMinChannels = 3; /** * ID of the share-knowledge channel. */ @@ -98,11 +111,27 @@ public class ModerationConfig extends GuildConfigItem { * 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. + */ + private String banMessageText = "Looks like you've been banned from the Discord Java Community. If you want to appeal this decision please fill out our form at ."; + + /** + * Text that is sent to users when they're unbanned. + */ + private String unbanMessageText = "You have been unbanned. You can now rejoin at but please ensure you are following the rules if you do."; + /** * Text that is sent to users when they're banned. */ - private String banMessageText = "Looks like you've been banned from the Java Discord. If you want to appeal this decision please fill out our form at ."; + private String kickMessageText = "Looks like you've been kicked from the Discord Java Community. If you want to, you should be able to rejoin at but please ensure you are following the rules if you do."; + + /** + * A list of rules that can result in a message being blocked or similar. + */ + private List messageRules = new ArrayList<>(); public TextChannel getReportChannel() { return this.getGuild().getTextChannelById(this.reportChannelId); 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 e351dc98d..b18ceb95c 100644 --- a/src/main/java/net/discordjug/javabot/data/h2db/DbHelper.java +++ b/src/main/java/net/discordjug/javabot/data/h2db/DbHelper.java @@ -2,8 +2,7 @@ import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; - -import lombok.Getter; +import jakarta.annotation.PreDestroy; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import net.discordjug.javabot.data.config.BotConfig; @@ -12,6 +11,7 @@ import org.h2.tools.Server; import org.jetbrains.annotations.NotNull; +import org.springframework.context.annotation.Bean; import org.springframework.stereotype.Service; import java.io.IOException; @@ -32,12 +32,12 @@ /** * Class that provides helper methods for dealing with the database. */ -@Getter @Slf4j @Service @RequiredArgsConstructor public class DbHelper { - private final DataSource dataSource; + private Server server; + private HikariDataSource ds; /** * Initializes the data source that'll be used throughout the bot to access @@ -48,12 +48,12 @@ public class DbHelper { * @throws IllegalStateException If an error occurs and we're unable to * start the database. */ - public static @NotNull HikariDataSource initDataSource(@NotNull BotConfig config) { + @Bean + DataSource initDataSource(@NotNull BotConfig config) { // Determine if we need to initialize the schema, before starting up the server. boolean shouldInitSchema = shouldInitSchema(config.getSystems().getHikariConfig().getJdbcUrl()); // Now that we have remembered whether we need to initialize the schema, start up the server. - Server server; try { System.setProperty("h2.bindAddress", "127.0.0.1"); server = Server.createTcpServer("-tcpPort", "9122", "-ifNotExists").start(); @@ -66,12 +66,8 @@ public class DbHelper { hikariConfig.setJdbcUrl(hikariConfigSource.getJdbcUrl()); hikariConfig.setMaximumPoolSize(hikariConfigSource.getMaximumPoolSize()); hikariConfig.setLeakDetectionThreshold(hikariConfigSource.getLeakDetectionThreshold()); - HikariDataSource ds = new HikariDataSource(hikariConfig); + ds = new HikariDataSource(hikariConfig); // Add a shutdown hook to close down the datasource and server when the JVM terminates. - Runtime.getRuntime().addShutdownHook(new Thread(() -> { - ds.close(); - server.stop(); - })); if (shouldInitSchema) { try { initializeSchema(ds); @@ -124,4 +120,17 @@ public static void initializeSchema(DataSource dataSource) throws IOException, S log.info("Successfully initialized H2 database."); } } + + /** + * Closes the database (server) when the application is shut down. + */ + @PreDestroy + public void stop() { + if (ds != null) { + ds.close(); + } + if (server != null) { + server.stop(); + } + } } diff --git a/src/main/java/net/discordjug/javabot/data/h2db/MigrationUtils.java b/src/main/java/net/discordjug/javabot/data/h2db/MigrationUtils.java index e18cdadc4..335643150 100644 --- a/src/main/java/net/discordjug/javabot/data/h2db/MigrationUtils.java +++ b/src/main/java/net/discordjug/javabot/data/h2db/MigrationUtils.java @@ -7,16 +7,32 @@ import java.nio.file.*; import java.util.HashMap; import java.util.Map; +import java.util.Objects; +import lombok.extern.slf4j.Slf4j; import net.discordjug.javabot.data.h2db.commands.MigrationsListSubcommand; /** * Utility class that handles SQL Migrations. */ +@Slf4j public class MigrationUtils { + + private static final Path migrationDirectory; private MigrationUtils() { } + + static { + Path dir; + try { + dir = createMigrationsDirectory(); + } catch (IOException | URISyntaxException e) { + log.error("Cannot create database migration directory", e); + dir = null; + } + migrationDirectory = dir; + } /** * Tries to get the Migrations Directories' Path. @@ -26,6 +42,10 @@ private MigrationUtils() { * @throws IOException If an error occurs. */ public static Path getMigrationsDirectory() throws URISyntaxException, IOException { + return Objects.requireNonNull(migrationDirectory); + } + + private static Path createMigrationsDirectory() throws IOException, URISyntaxException { URL resource = MigrationsListSubcommand.class.getResource("/database/migrations/"); if (resource == null) throw new IOException("Missing resource /migrations/"); URI uri = resource.toURI(); diff --git a/src/main/java/net/discordjug/javabot/data/h2db/commands/DbAdminCommand.java b/src/main/java/net/discordjug/javabot/data/h2db/commands/DbAdminCommand.java index 75fbdafe6..9f10f19f2 100644 --- a/src/main/java/net/discordjug/javabot/data/h2db/commands/DbAdminCommand.java +++ b/src/main/java/net/discordjug/javabot/data/h2db/commands/DbAdminCommand.java @@ -4,6 +4,7 @@ import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand; import net.discordjug.javabot.data.config.BotConfig; import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.interactions.InteractionContextType; import net.dv8tion.jda.api.interactions.commands.DefaultMemberPermissions; import net.dv8tion.jda.api.interactions.commands.build.Commands; import net.dv8tion.jda.api.interactions.commands.build.SubcommandGroupData; @@ -27,7 +28,7 @@ public DbAdminCommand(BotConfig botConfig, ExportSchemaSubcommand exportSchemaSu setRegistrationType(RegistrationType.GUILD); setCommandData(Commands.slash("db-admin", "(ADMIN ONLY) Administrative Commands for managing the bot's database.") .setDefaultPermissions(DefaultMemberPermissions.enabledFor(Permission.MANAGE_SERVER)) - .setGuildOnly(true) + .setContexts(InteractionContextType.GUILD) ); addSubcommands(exportSchemaSubcommand, exportTableSubcommand, migrationsListSubcommand, migrateSubcommand, quickMigrateSubcommand); addSubcommandGroups(SubcommandGroup.of( 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 ab111f902..567f882fe 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 @@ -52,7 +52,6 @@ public ExportTableSubcommand(ExecutorService asyncPool, SystemsConfig systemsCon .addChoice("Message Cache", "MESSAGE_CACHE") .addChoice("Question of the Week Accounts", "QOTW_POINTS") .addChoice("Question of the Week Questions", "QOTW_QUESTION") - .addChoice("Reserved Help Channels", "RESERVED_HELP_CHANNELS") .addChoice("Starboard", "STARBOARD") .addChoice("Warns", "WARN") .addChoice("User Preferences", "USER_PREFERENCES"), 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 5086883a2..4f0f46192 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 @@ -3,6 +3,7 @@ import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand; import net.discordjug.javabot.data.config.BotConfig; import net.discordjug.javabot.data.config.GuildConfig; +import net.discordjug.javabot.data.config.guild.MessageCacheConfig; import net.discordjug.javabot.data.h2db.DbActions; import net.discordjug.javabot.data.h2db.message_cache.MessageCache; import net.discordjug.javabot.util.Responses; @@ -44,13 +45,14 @@ public void execute(SlashCommandInteractionEvent event) { private MessageEmbed buildInfoEmbed(GuildConfig config, User author) { long messages = dbActions.count("SELECT count(*) FROM message_cache"); - int maxMessages = config.getMessageCacheConfig().getMaxCachedMessages(); + MessageCacheConfig messageCacheConfig = config.getMessageCacheConfig(); + int maxMessages = messageCacheConfig.getMaxCachedMessages(); return new EmbedBuilder() .setAuthor(UserUtils.getUserTag(author), null, author.getEffectiveAvatarUrl()) .setTitle("Message Cache Info") .setColor(Responses.Type.DEFAULT.getColor()) .addField("Table Size", dbActions.getLogicalSize("message_cache") + " bytes", false) - .addField("Message Count", String.valueOf(messageCache.getMessageCount()), true) + .addField("Messages since synchronization", messageCache.getMessageCount() + "/" + messageCacheConfig.getMessageSynchronizationInterval(), 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/commands/MigrateSubcommand.java b/src/main/java/net/discordjug/javabot/data/h2db/commands/MigrateSubcommand.java index 65d947cf1..623b3457f 100644 --- a/src/main/java/net/discordjug/javabot/data/h2db/commands/MigrateSubcommand.java +++ b/src/main/java/net/discordjug/javabot/data/h2db/commands/MigrateSubcommand.java @@ -64,12 +64,11 @@ public MigrateSubcommand(ExecutorService asyncPool, DataSource dataSource, Syste } /** - * Replies with all available migrations to run. + * Finds all all available migrations to run. * - * @param event The {@link CommandAutoCompleteInteractionEvent} that was fired. * @return A {@link List} with all Option Choices. */ - public static @NotNull List replyMigrations(CommandAutoCompleteInteractionEvent event) { + public static @NotNull List getAvailableMigrations() { List choices = new ArrayList<>(25); try (Stream s = Files.list(MigrationUtils.getMigrationsDirectory())) { List paths = s.filter(path -> path.getFileName().toString().endsWith(".sql")).toList(); @@ -94,7 +93,6 @@ public void execute(@NotNull SlashCommandInteractionEvent event) { return; } String sql = Files.readString(migrationFile); - migrationsDir.getFileSystem().close(); String[] statements = sql.split("\\s*;\\s*"); if (statements.length == 0) { Responses.error(event, "The migration `" + migrationName + "` does not contain any statements. Please remove or edit it before running again.").queue(); @@ -134,6 +132,6 @@ public void execute(@NotNull SlashCommandInteractionEvent event) { @Override public void handleAutoComplete(@NotNull CommandAutoCompleteInteractionEvent event, @NotNull AutoCompleteQuery target) { - event.replyChoices(AutoCompleteUtils.filterChoices(event, replyMigrations(event))).queue(); + event.replyChoices(AutoCompleteUtils.filterChoices(event, getAvailableMigrations())).queue(); } } diff --git a/src/main/java/net/discordjug/javabot/data/h2db/commands/QuickMigrateSubcommand.java b/src/main/java/net/discordjug/javabot/data/h2db/commands/QuickMigrateSubcommand.java index 851766622..c6dd89a2d 100644 --- a/src/main/java/net/discordjug/javabot/data/h2db/commands/QuickMigrateSubcommand.java +++ b/src/main/java/net/discordjug/javabot/data/h2db/commands/QuickMigrateSubcommand.java @@ -1,5 +1,6 @@ package net.discordjug.javabot.data.h2db.commands; +import net.dv8tion.jda.api.modals.Modal; import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand; import xyz.dynxsty.dih4jda.interactions.components.ModalHandler; import net.discordjug.javabot.annotations.AutoDetectableComponentHandler; @@ -7,14 +8,13 @@ import net.discordjug.javabot.util.ExceptionLogger; import net.discordjug.javabot.util.Responses; import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.components.label.Label; +import net.dv8tion.jda.api.components.textinput.TextInput; +import net.dv8tion.jda.api.components.textinput.TextInputStyle; import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; -import net.dv8tion.jda.api.interactions.components.ActionRow; -import net.dv8tion.jda.api.interactions.modals.Modal; -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.ModalMapping; import org.jetbrains.annotations.NotNull; @@ -105,7 +105,7 @@ public void handleModal(@NotNull ModalInteractionEvent event, List } private @NotNull Modal buildQuickMigrateModal() { - TextInput sqlInput = TextInput.create("sql", "SQL-Statement (H2)", TextInputStyle.PARAGRAPH) + TextInput sqlInput = TextInput.create("sql", TextInputStyle.PARAGRAPH) .setPlaceholder(""" CREATE TABLE my_table ( thread_id BIGINT PRIMARY KEY, @@ -114,12 +114,12 @@ CREATE TABLE my_table ( """) .setRequired(true) .build(); - TextInput confirmInput = TextInput.create("confirmation", "Confirmation", TextInputStyle.SHORT) + TextInput confirmInput = TextInput.create("confirmation", TextInputStyle.SHORT) .setPlaceholder("Type 'CONFIRM' to confirm this action") .setRequired(true) .build(); return Modal.create("quick-migrate", "Quick Migrate") - .addComponents(ActionRow.of(sqlInput), ActionRow.of(confirmInput)) + .addComponents(Label.of("SQL-Statement (H2)", sqlInput), Label.of("Confirmation", confirmInput)) .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 13d49d4dd..67b995e55 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 @@ -12,10 +12,12 @@ import net.discordjug.javabot.util.TimeUtils; import net.discordjug.javabot.util.UserUtils; import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.components.actionrow.ActionRow; +import net.dv8tion.jda.api.components.buttons.Button; import net.dv8tion.jda.api.entities.*; import net.dv8tion.jda.api.entities.Message.Attachment; +import net.dv8tion.jda.api.entities.channel.Channel; import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; -import net.dv8tion.jda.api.interactions.components.buttons.Button; import net.dv8tion.jda.api.requests.restaction.MessageCreateAction; import net.dv8tion.jda.api.utils.FileUpload; @@ -27,10 +29,14 @@ import java.net.http.HttpResponse.BodyHandlers; import java.nio.charset.StandardCharsets; import java.time.Instant; +import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; +import java.util.ArrayDeque; import java.util.ArrayList; +import java.util.Deque; import java.util.List; +import java.util.concurrent.ConcurrentLinkedDeque; import java.util.concurrent.ExecutorService; import java.util.stream.Collectors; @@ -46,7 +52,7 @@ public class MessageCache { /** * A memory-cache (list) of sent Messages, wrapped to a {@link CachedMessage} object. */ - public List cache = new ArrayList<>(); + public Deque cache = new ConcurrentLinkedDeque<>(); /** * Amount of messages since the last synchronization. *

@@ -71,25 +77,33 @@ public MessageCache(BotConfig botConfig, MessageCacheRepository cacheRepository, this.botConfig = botConfig; this.cacheRepository = cacheRepository; try { - cache = cacheRepository.getAll(); + cache = new ArrayDeque<>(cacheRepository.getAll()); } catch (DataAccessException e) { ExceptionLogger.capture(e, getClass().getSimpleName()); log.error("Something went wrong during retrieval of stored messages."); } - } /** - * Synchronizes Messages saved in the Database with what is currently stored in memory. + * Synchronizes Messages saved in the Database with what is currently stored in memory. This action is executed in the background. */ public void synchronize() { asyncPool.execute(()->{ - cacheRepository.delete(cache.size()); - cacheRepository.insertList(cache); - messageCount = 0; - log.info("Synchronized Database with local Cache."); + synchronizeNow(); }); } + + /** + * Synchronizes Messages saved in the Database with what is currently stored in memory and wait until the synchronization finishes. + */ + public void synchronizeNow() { + if (messageCount == 0) { + return; + } + cacheRepository.replaceWithList(new ArrayList<>(cache)); + messageCount = 0; + log.info("Synchronized Database with local Cache."); + } /** * Caches a single {@link Message} object. @@ -99,13 +113,15 @@ public void synchronize() { public void cache(Message message) { MessageCacheConfig config = botConfig.get(message.getGuild()).getMessageCacheConfig(); if (cache.size() + 1 > config.getMaxCachedMessages()) { - cache.remove(0); + cache.removeFirst(); } if (messageCount >= config.getMessageSynchronizationInterval()) { synchronize(); } messageCount++; - cache.add(CachedMessage.of(message)); + CachedMessage cachedMessage = CachedMessage.of(message); + cache.add(cachedMessage); + requestMessageAttachments(cachedMessage); } /** @@ -119,8 +135,8 @@ public void sendUpdatedMessageToLog(Message updated, CachedMessage before) { if (config.getMessageCacheLogChannel() == null) return; if (updated.getContentRaw().trim().equals(before.getMessageContent()) && updated.getAttachments().size() == before.getAttachments().size()) return; MessageCreateAction action = config.getMessageCacheLogChannel() - .sendMessageEmbeds(buildMessageEditEmbed(updated.getGuild(), updated.getAuthor(), updated.getChannel(), before, updated)) - .setActionRow(Button.link(updated.getJumpUrl(), "Jump to Message")); + .sendMessageEmbeds(buildMessageEditEmbed(updated.getAuthor(), updated.getChannel(), before, updated)) + .addComponents(ActionRow.of(Button.link(updated.getJumpUrl(), "Jump to Message"))); if (before.getMessageContent().length() > MessageEmbed.VALUE_MAX_LENGTH || updated.getContentRaw().length() > MessageEmbed.VALUE_MAX_LENGTH) { action.addFiles(FileUpload.fromData(buildEditedMessageFile(updated.getAuthor(), before, updated), before.getMessageId() + ".txt")); } @@ -134,11 +150,11 @@ public void sendUpdatedMessageToLog(Message updated, CachedMessage before) { * @param channel The message's {@link MessageChannel}. * @param message The {@link CachedMessage}. */ - public void sendDeletedMessageToLog(Guild guild, MessageChannel channel, CachedMessage message) { + public void sendDeletedMessageToLog(Guild guild, Channel channel, CachedMessage message) { MessageCacheConfig config = botConfig.get(guild).getMessageCacheConfig(); if (config.getMessageCacheLogChannel() == null) return; guild.getJDA().retrieveUserById(message.getAuthorId()).queue(author -> { - MessageCreateAction action = config.getMessageCacheLogChannel().sendMessageEmbeds(buildMessageDeleteEmbed(guild, author, channel, message)); + MessageCreateAction action = config.getMessageCacheLogChannel().sendMessageEmbeds(buildMessageDeleteEmbed(author, channel, message)); if (message.getMessageContent().length() > MessageEmbed.VALUE_MAX_LENGTH) { action.addFiles(FileUpload.fromData(buildDeletedMessageFile(author, message), message.getMessageId() + ".txt")); } @@ -146,6 +162,22 @@ public void sendDeletedMessageToLog(Guild guild, MessageChannel channel, CachedM requestMessageAttachments(message); }); } + + /** + * Retrieves all cached messages that were sent after a passed timestamp. + * @param timestamp the timestamp since when messages should be received + * @return the messages sent after the given timestamp as a {@link List} + */ + public List getMessagesAfter(OffsetDateTime timestamp) { + List cachedMessages = new ArrayList<>(); + for (CachedMessage msg : this.cache.reversed()) { + if (UserSnowflake.fromId(msg.getMessageId()).getTimeCreated().isBefore(timestamp)) { + return cachedMessages.reversed(); + } + cachedMessages.add(msg); + } + return cachedMessages.reversed(); + } /** * Requests each attachment from Discord's CDN. @@ -160,23 +192,31 @@ private void requestMessageAttachments(CachedMessage message) { } } - private EmbedBuilder buildMessageCacheEmbed(MessageChannel channel, User author, CachedMessage before) { - long epoch = IdCalculatorCommand.getTimestampFromId(before.getMessageId()) / 1000; + /** + * Creates an {@link EmbedBuilder} with information about a cached message. + * @param channel The channel the message was sent in. + * @param author The author of the message. + * @param message The message to extract the information from as a {@link CachedMessage}. + * @param contentFieldName the name of the field containing the message content in the embed. + * @return an {@link EmbedBuilder} with information about the message. + */ + public EmbedBuilder buildMessageCacheEmbed(Channel channel, User author, CachedMessage message, String contentFieldName) { + long epoch = IdCalculatorCommand.getTimestampFromId(message.getMessageId()) / 1000; return new EmbedBuilder() .setAuthor(UserUtils.getUserTag(author), null, author.getEffectiveAvatarUrl()) .addField("Author", author.getAsMention(), true) .addField("Channel", channel.getAsMention(), true) .addField("Created at", String.format("", epoch), true) - .setFooter("ID: " + before.getMessageId()); + .setFooter("ID: " + message.getMessageId()) + .addField(contentFieldName, + message.getMessageContent().substring(0, Math.min(message.getMessageContent().length(), MessageEmbed.VALUE_MAX_LENGTH)), + false); } - private MessageEmbed buildMessageEditEmbed(Guild guild, User author, MessageChannel channel, CachedMessage before, Message after) { - EmbedBuilder eb = buildMessageCacheEmbed(channel, author, before) + private MessageEmbed buildMessageEditEmbed(User author, MessageChannel channel, CachedMessage before, Message after) { + EmbedBuilder eb = buildMessageCacheEmbed(channel, author, before, "Before") .setTitle("Message Edited") .setColor(Responses.Type.WARN.getColor()) - .addField("Before", before.getMessageContent().substring(0, Math.min( - before.getMessageContent().length(), - MessageEmbed.VALUE_MAX_LENGTH)), false) .addField("After", after.getContentRaw().substring(0, Math.min( after.getContentRaw().length(), MessageEmbed.VALUE_MAX_LENGTH)), false); @@ -197,24 +237,29 @@ private MessageEmbed buildMessageEditEmbed(Guild guild, User author, MessageChan .build(); } - private MessageEmbed buildMessageDeleteEmbed(Guild guild, User author, MessageChannel channel, CachedMessage message) { - EmbedBuilder eb = buildMessageCacheEmbed(channel, author, message) + private MessageEmbed buildMessageDeleteEmbed(User author, Channel channel, CachedMessage message) { + EmbedBuilder eb = buildMessageCacheEmbed(channel, author, message, "Message Content") .setTitle("Message Deleted") - .setColor(Responses.Type.ERROR.getColor()) - .addField("Message Content", - message.getMessageContent().substring(0, Math.min( - message.getMessageContent().length(), - MessageEmbed.VALUE_MAX_LENGTH)), false); + .setColor(Responses.Type.ERROR.getColor()); if (!message.getAttachments().isEmpty()) { - eb.addField("Attachments", - message - .getAttachments() - .stream() - .collect(Collectors.joining("\n")), - false); + addAttachmentsToMessageBuilder(message, eb); } - return eb - .build(); + return eb.build(); + } + + private void addAttachmentsToMessageBuilder(CachedMessage message, EmbedBuilder eb) { + StringBuilder attachmentBuilder = new StringBuilder(); + for (String attachment : message.getAttachments()) { + if (attachmentBuilder.length() + attachment.length() >= MessageEmbed.VALUE_MAX_LENGTH - 1) { + eb.addField("Attachments", attachmentBuilder.toString(),false); + attachmentBuilder.setLength(0); + + } else { + attachmentBuilder.append('\n'); + } + attachmentBuilder.append(attachment); + } + eb.addField("Attachments", attachmentBuilder.toString(), false); } private InputStream buildDeletedMessageFile(User author, CachedMessage message) { diff --git a/src/main/java/net/discordjug/javabot/data/h2db/message_cache/MessageCacheListener.java b/src/main/java/net/discordjug/javabot/data/h2db/message_cache/MessageCacheListener.java index 237ca6398..7101b8ed4 100644 --- a/src/main/java/net/discordjug/javabot/data/h2db/message_cache/MessageCacheListener.java +++ b/src/main/java/net/discordjug/javabot/data/h2db/message_cache/MessageCacheListener.java @@ -4,6 +4,8 @@ import net.discordjug.javabot.data.config.guild.MessageCacheConfig; import net.discordjug.javabot.data.h2db.message_cache.model.CachedMessage; import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.channel.middleman.GuildChannel; +import net.dv8tion.jda.api.events.channel.ChannelDeleteEvent; import net.dv8tion.jda.api.events.message.MessageDeleteEvent; import net.dv8tion.jda.api.events.message.MessageReceivedEvent; import net.dv8tion.jda.api.events.message.MessageUpdateEvent; @@ -13,6 +15,7 @@ import lombok.RequiredArgsConstructor; +import java.util.Deque; import java.util.List; import java.util.Optional; @@ -33,16 +36,15 @@ public void onMessageReceived(@NotNull MessageReceivedEvent event) { @Override public void onMessageUpdate(@NotNull MessageUpdateEvent event) { if (this.ignoreMessageCache(event.getMessage())) return; - List cache = messageCache.cache; + Deque cache = messageCache.cache; Optional optional = cache.stream().filter(m -> m.getMessageId() == event.getMessageIdLong()).findFirst(); CachedMessage before; if (optional.isPresent()) { - before = optional.get(); - cache.set(cache.indexOf(before), CachedMessage.of(event.getMessage())); + CachedMessage inCache= optional.get(); + before = new CachedMessage(inCache.getMessageId(), inCache.getAuthorId(), inCache.getChannelId(),inCache.getMessageContent(), inCache.getAttachments()); + inCache.init(event.getMessage()); } else { - before = new CachedMessage(); - before.setMessageId(event.getMessageIdLong()); - before.setMessageContent("[unknown content]"); + before = new CachedMessage(event.getMessageIdLong(), event.getAuthor().getIdLong(), event.getChannel().getIdLong(),"[unknown content]", List.of()); messageCache.cache(event.getMessage()); } messageCache.sendUpdatedMessageToLog(event.getMessage(), before); @@ -50,14 +52,24 @@ public void onMessageUpdate(@NotNull MessageUpdateEvent event) { @Override public void onMessageDelete(@NotNull MessageDeleteEvent event) { - Optional optional = messageCache.cache.stream().filter(m -> m.getMessageId() == event.getMessageIdLong()).findFirst(); + processMessageDeletion(event.getMessageIdLong(), event.getGuildChannel()); + } + + @Override + public void onChannelDelete(ChannelDeleteEvent event) { + if (event.getChannelType().isThread()) { + processMessageDeletion(event.getChannel().getIdLong(), event.getChannel().asThreadChannel().getParentChannel()); + } + } + + private void processMessageDeletion(long messageId, GuildChannel channel) { + Optional optional = messageCache.cache.stream().filter(m -> m.getMessageId() == messageId).findFirst(); optional.ifPresent(message -> { - messageCache.sendDeletedMessageToLog(event.getGuild(), event.getChannel(), message); + messageCache.sendDeletedMessageToLog(channel.getGuild(), channel, message); messageCache.cache.remove(message); }); } - /** * Checks whether the given message should be ignored by the cache. *

diff --git a/src/main/java/net/discordjug/javabot/data/h2db/message_cache/dao/MessageCacheRepository.java b/src/main/java/net/discordjug/javabot/data/h2db/message_cache/dao/MessageCacheRepository.java index a46a45e7a..c7594a9fa 100644 --- a/src/main/java/net/discordjug/javabot/data/h2db/message_cache/dao/MessageCacheRepository.java +++ b/src/main/java/net/discordjug/javabot/data/h2db/message_cache/dao/MessageCacheRepository.java @@ -8,6 +8,7 @@ import org.springframework.jdbc.core.BatchPreparedStatementSetter; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Repository; +import org.springframework.transaction.support.TransactionTemplate; import java.sql.*; import java.util.ArrayList; @@ -23,22 +24,42 @@ @Repository public class MessageCacheRepository { private final JdbcTemplate jdbcTemplate; + private final TransactionTemplate template; + + /** + * Replaces the entire message cache with a {@link List} of {@link CachedMessage} objects. + * + * @param messages The new message cache. + */ + public void replaceWithList(@NotNull List messages) { + template.execute(_ -> { + clear(); + insertList(messages); + return null; + }); + } + + private void clear() throws DataAccessException { + jdbcTemplate.update("DELETE FROM message_cache_attachments WHERE 1 = 1"); + jdbcTemplate.update("DELETE FROM message_cache WHERE 1 = 1"); + } /** * Inserts a {@link List} of {@link CachedMessage} objects. * * @param messages The List to insert. - * @throws SQLException If an error occurs. + * @throws DataAccessException If an error occurs. */ public void insertList(@NotNull List messages) throws DataAccessException { - jdbcTemplate.batchUpdate("MERGE INTO message_cache (message_id, author_id, message_content) VALUES (?, ?, ?)", + jdbcTemplate.batchUpdate("MERGE INTO message_cache (message_id, author_id, channel_id, message_content) VALUES (?, ?, ?, ?)", new BatchPreparedStatementSetter() { @Override public void setValues(PreparedStatement stmt, int i) throws SQLException { CachedMessage msg = messages.get(i); stmt.setLong(1, msg.getMessageId()); stmt.setLong(2, msg.getAuthorId()); - stmt.setString(3, msg.getMessageContent()); + stmt.setLong(3,msg.getChannelId()); + stmt.setString(4, msg.getMessageContent()); stmt.executeUpdate(); } @@ -77,47 +98,33 @@ public int getBatchSize() { * Gets all Messages from the Database. * * @return A {@link List} of {@link CachedMessage}s. - * @throws SQLException If anything goes wrong. */ public List getAll() throws DataAccessException { List messagesWithLink = jdbcTemplate.query( - "SELECT * FROM message_cache LEFT JOIN message_cache_attachments ON message_cache.message_id = message_cache_attachments.message_id", - (rs, rowNum) -> this.read(rs)); + "SELECT * FROM message_cache LEFT JOIN message_cache_attachments ON message_cache.message_id = message_cache_attachments.message_id ORDER BY message_cache.message_id ASC", + (rs, _) -> this.read(rs)); Map messages=new LinkedHashMap<>(); for (CachedMessage msg : messagesWithLink) { - CachedMessage previous = messages.putIfAbsent(msg.getMessageId(), msg); - if(previous!=null) { - previous.getAttachments().addAll(msg.getAttachments()); - } + messages.merge(msg.getMessageId(), msg, (oldValue, value) -> { + ArrayList attachments = new ArrayList<>(oldValue.getAttachments()); + attachments.addAll(value.getAttachments()); + return new CachedMessage(oldValue.getMessageId(), oldValue.getAuthorId(), oldValue.getChannelId(),oldValue.getMessageContent(), attachments); + }); } return new ArrayList<>(messages.values()); } - /** - * Deletes the given amount of Messages. - * - * @param amount The amount to delete. - * @return If any rows we're affected. - * @throws SQLException If anything goes wrong. - */ - public boolean delete(int amount) throws DataAccessException { - int rows = jdbcTemplate.update("DELETE FROM message_cache LIMIT ?", amount); - if(rows > 0){ - jdbcTemplate.update("DELETE FROM message_cache_attachments WHERE message_id NOT IN (SELECT message_id FROM message_cache)"); - return true; - } - return false; - } - private CachedMessage read(ResultSet rs) throws SQLException { - CachedMessage cachedMessage = new CachedMessage(); - cachedMessage.setMessageId(rs.getLong("message_cache.message_id")); - cachedMessage.setAuthorId(rs.getLong("author_id")); - cachedMessage.setMessageContent(rs.getString("message_content")); + List attachments = new ArrayList<>(); String attachment = rs.getString("link"); - if(attachment!=null) { - cachedMessage.getAttachments().add(attachment); + if(attachment != null) { + attachments.add(attachment); } - return cachedMessage; + return new CachedMessage( + rs.getLong("message_cache.message_id"), + rs.getLong("author_id"), + rs.getLong("channel_id"), + rs.getString("message_content"), + attachments); } } 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 c22e538ce..cce1cd483 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 @@ -3,7 +3,9 @@ import java.util.ArrayList; import java.util.List; -import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; import net.discordjug.javabot.util.MessageUtils; import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.Message.Attachment; @@ -11,12 +13,38 @@ /** * Represents a cached Message. */ -@Data +@Getter +@EqualsAndHashCode +@ToString public class CachedMessage { - private long messageId; - private long authorId; + private final long messageId; + private final long authorId; + private final long channelId; private String messageContent; private List attachments=new ArrayList<>(); + + private CachedMessage(long messageId, long authorId,long channelId) { + this.messageId = messageId; + this.authorId = authorId; + this.channelId = channelId; + } + + /** + * Creates a {@link CachedMessage} with the given information. + * @param messageId The Discord ID of the message + * @param authorId The Discord ID of the message author + * @param channelId The Discord ID of the message channel + * @param messageContent the textual content of the message + * @param attachments The attachment URLs + */ + public CachedMessage(long messageId, long authorId, long channelId, String messageContent, List attachments) { + super(); + this.messageId = messageId; + this.authorId = authorId; + this.channelId = channelId; + this.messageContent = messageContent; + this.attachments = List.copyOf(attachments); + } /** * Converts a {@link Message} object to a {@link CachedMessage}. @@ -25,15 +53,25 @@ public class CachedMessage { * @return The built {@link CachedMessage}. */ public static CachedMessage of(Message message) { - CachedMessage cachedMessage = new CachedMessage(); - cachedMessage.setMessageId(message.getIdLong()); - cachedMessage.setAuthorId(message.getAuthor().getIdLong()); - cachedMessage.setMessageContent(MessageUtils.getMessageContent(message).trim()); - cachedMessage.attachments = message + CachedMessage cachedMessage = new CachedMessage(message.getIdLong(), message.getAuthor().getIdLong(),message.getChannelIdLong()); + cachedMessage.init(message); + return cachedMessage; + } + + /** + * Resets the current {@link CachedMessage} to have the content of the passed {@link Message}. + * @param message the {@link Message} this object is set to. + */ + public void init(Message message) { + messageContent = MessageUtils.getMessageContent(message).trim(); + this.attachments = message .getAttachments() .stream() .map(Attachment::getUrl) .toList(); - return cachedMessage; + } + + public void setMessageContent(String messageContent) { + this.messageContent = messageContent; } } diff --git a/src/main/java/net/discordjug/javabot/listener/GitHubLinkListener.java b/src/main/java/net/discordjug/javabot/listener/GitHubLinkListener.java index 13d200a16..b90c5b89a 100644 --- a/src/main/java/net/discordjug/javabot/listener/GitHubLinkListener.java +++ b/src/main/java/net/discordjug/javabot/listener/GitHubLinkListener.java @@ -4,9 +4,10 @@ import net.discordjug.javabot.util.ExceptionLogger; import net.discordjug.javabot.util.InteractionUtils; import net.discordjug.javabot.util.StringUtils; +import net.dv8tion.jda.api.components.actionrow.ActionRow; +import net.dv8tion.jda.api.components.buttons.Button; import net.dv8tion.jda.api.events.message.MessageReceivedEvent; import net.dv8tion.jda.api.hooks.ListenerAdapter; -import net.dv8tion.jda.api.interactions.components.buttons.Button; import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.NotNull; @@ -37,7 +38,7 @@ public void onMessageReceived(@NotNull MessageReceivedEvent event) { if (!content.getFirst().isBlank() && !content.getSecond().isBlank()) { event.getMessage().reply(String.format("```%s\n%s\n```", content.getSecond(), StringUtils.standardSanitizer().compute(content.getFirst()))) .setAllowedMentions(List.of()) - .setActionRow(InteractionUtils.createDeleteButton(event.getAuthor().getIdLong()), Button.link(matcher.group(), "View on GitHub")) + .addComponents(ActionRow.of(InteractionUtils.createDeleteButton(event.getAuthor().getIdLong()), Button.link(matcher.group(), "View on GitHub"))) .queue(); } } @@ -56,7 +57,7 @@ public void onMessageReceived(@NotNull MessageReceivedEvent event) { String[] segments = Arrays.copyOfRange(arr, 3, arr.length); // The file name, split by "." String[] file = segments[segments.length - 1].split("\\."); - Integer[] lines = Arrays.stream(file[1].split("L")) + Integer[] lines = Arrays.stream(file[file.length-1].split("L")) .map(line -> line.replace("-", "")) .filter(line -> line.matches("-?\\d+")) // check if the given link is a number .map(Integer::valueOf).sorted().toArray(Integer[]::new); diff --git a/src/main/java/net/discordjug/javabot/listener/HugListener.java b/src/main/java/net/discordjug/javabot/listener/HugListener.java deleted file mode 100644 index 46eac3ea9..000000000 --- a/src/main/java/net/discordjug/javabot/listener/HugListener.java +++ /dev/null @@ -1,120 +0,0 @@ -package net.discordjug.javabot.listener; - -import lombok.RequiredArgsConstructor; -import net.discordjug.javabot.data.config.BotConfig; -import net.discordjug.javabot.systems.moderation.AutoMod; -import net.discordjug.javabot.util.ExceptionLogger; -import net.discordjug.javabot.util.WebhookUtil; -import net.dv8tion.jda.api.entities.channel.ChannelType; -import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; -import net.dv8tion.jda.api.entities.channel.middleman.StandardGuildChannel; -import net.dv8tion.jda.api.events.message.MessageReceivedEvent; -import net.dv8tion.jda.api.hooks.ListenerAdapter; - -import javax.annotation.Nonnull; -import java.util.Objects; -import java.util.regex.Pattern; - -/** - * Replaces all occurrences of 'fuck' in incoming messages with 'hug'. - */ -@RequiredArgsConstructor -public class HugListener extends ListenerAdapter { - private static final Pattern FUCKER = Pattern.compile( - "(fuck)(ing|er|ed|k+)?", - Pattern.CASE_INSENSITIVE - ); - private final AutoMod autoMod; - private final BotConfig botConfig; - - private static String processHug(String originalText) { - // FucK -> HuG, FuCk -> Hug - return String.valueOf(copyCase(originalText, 0, 'h')) + copyCase(originalText, 1, 'u') + - copyCase(originalText, 3, 'g'); - } - - private static String replaceFucks(String str) { - return FUCKER.matcher(str).replaceAll(matchResult -> { - String theFuck = matchResult.group(1); - String suffix = Objects.requireNonNullElse(matchResult.group(2), ""); - String processedSuffix = switch (suffix.toLowerCase()) { - case "er", "ed", "ing" -> - copyCase(suffix, 0, 'g') + suffix; // fucking, fucker, fucked - case "" -> ""; // just fuck - default -> copyCase(suffix, "g".repeat(suffix.length())); // fuckkkkk... - }; - return processHug(theFuck) + processedSuffix; - }); - } - - private static String copyCase(String source, String toChange) { - if (source.length() != toChange.length()) { - throw new IllegalArgumentException("lengths differ"); - } - StringBuilder sb = new StringBuilder(); - for (int i = 0; i < source.length(); i++) { - sb.append(copyCase(source, i, toChange.charAt(i))); - } - return sb.toString(); - } - - private static char copyCase(String original, int index, char newChar) { - if (Character.isUpperCase(original.charAt(index))) { - return Character.toUpperCase(newChar); - } else { - return newChar; - } - } - - @Override - public void onMessageReceived(@Nonnull MessageReceivedEvent event) { - if (!event.isFromGuild()) { - return; - } - if (autoMod.hasSuspiciousLink(event.getMessage()) || - autoMod.hasAdvertisingLink(event.getMessage())) { - return; - } - if (!event.getMessage().getMentions().getUsers().isEmpty()) { - return; - } - if (event.isWebhookMessage()) { - return; - } - if (event.getChannel().getIdLong() == botConfig.get(event.getGuild()) - .getModerationConfig() - .getSuggestionChannelId()) { - return; - } - TextChannel tc = null; - if (event.isFromType(ChannelType.TEXT)) { - tc = event.getChannel().asTextChannel(); - } - if (event.isFromThread()) { - StandardGuildChannel parentChannel = event.getChannel() - .asThreadChannel() - .getParentChannel() - .asStandardGuildChannel(); - if (parentChannel instanceof TextChannel textChannel) { - tc = textChannel; - } - } - if (tc == null) { - return; - - } - String content = event.getMessage().getContentRaw(); - if (FUCKER.matcher(content).find()) { - long threadId = event.isFromThread() ? event.getChannel().getIdLong() : 0; - WebhookUtil.ensureWebhookExists( - tc, - wh -> WebhookUtil.replaceMemberMessage(wh, event.getMessage(), - replaceFucks(content), threadId - ), - e -> ExceptionLogger.capture(e, getClass().getSimpleName()) - ); - } - } - - -} diff --git a/src/main/java/net/discordjug/javabot/listener/JobChannelCloseOldPostsListener.java b/src/main/java/net/discordjug/javabot/listener/JobChannelCloseOldPostsListener.java index d61751653..aec7cadaa 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.components.actionrow.ActionRow; import net.dv8tion.jda.api.entities.UserSnowflake; import net.dv8tion.jda.api.entities.channel.ChannelType; import net.dv8tion.jda.api.entities.channel.concrete.ThreadChannel; @@ -78,7 +79,7 @@ public void onChannelCreate(ChannelCreateEvent event) { .setDescription("Since only one open post is allowed per user, older posts have been closed") .setColor(Color.YELLOW) .build()) - .addActionRow(InteractionUtils.createDeleteButton(post.getOwnerIdLong())) + .addComponents(ActionRow.of(InteractionUtils.createDeleteButton(post.getOwnerIdLong()))) .queue(); } } diff --git a/src/main/java/net/discordjug/javabot/listener/PingableNameListener.java b/src/main/java/net/discordjug/javabot/listener/PingableNameListener.java index c19bdc29a..20e1e148c 100644 --- a/src/main/java/net/discordjug/javabot/listener/PingableNameListener.java +++ b/src/main/java/net/discordjug/javabot/listener/PingableNameListener.java @@ -48,7 +48,7 @@ public PingableNameListener(NotificationService notificationService) { public void onGuildMemberJoin(GuildMemberJoinEvent event) { checkNickname(event.getMember()); } - + @Override public void onGuildMemberUpdate(GuildMemberUpdateEvent event) { checkNickname(event.getMember()); @@ -65,16 +65,18 @@ private void checkNickname(Member member) { } /** - * Changes the given {@link Member}s name to a randomly generated one. + * Changes the given {@link Member}s name to properly formatted name if fails then use random generated one. * @param member The Member whose name should be changed. */ private void changeName(Member member) { - String oldName = member.getNickname(); - String newName = generateRandomName(); + String oldName = member.getEffectiveName(); + String newName = generateNewName(member.getUser().getName()); member.modifyNickname(newName.substring(0, Math.min(31, newName.length()))).queue(); member.getUser().openPrivateChannel() - .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(); + .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(success -> {}, err -> {});//ignore closed DMs notificationService.withGuild(member.getGuild()).sendToMessageLog(c -> c.sendMessageFormat("Changed %s's nickname from `%s` to `%s`.", member.getAsMention(), oldName, newName)); } @@ -97,6 +99,14 @@ private String generateRandomName() { return StringUtils.capitalize(adjective) + StringUtils.capitalize(noun); } + private String generateNewName(String username){ + String newName = StringUtils.capitalize(username.replaceAll("(?u)(?:^[^A-Za-z]++|[^A-Za-z]++$)","")); + if(!isPingable(newName)){ + newName = generateRandomName(); + } + return newName; + } + /** * Reads strings from the given URL to a {@link List}, removes words with a dash. * @param url The URL to read. diff --git a/src/main/java/net/discordjug/javabot/listener/QOTWSubmissionListener.java b/src/main/java/net/discordjug/javabot/listener/QOTWSubmissionListener.java index 717cf4113..73c2a4791 100644 --- a/src/main/java/net/discordjug/javabot/listener/QOTWSubmissionListener.java +++ b/src/main/java/net/discordjug/javabot/listener/QOTWSubmissionListener.java @@ -4,6 +4,7 @@ import net.discordjug.javabot.data.config.BotConfig; import net.discordjug.javabot.data.config.guild.QOTWConfig; import net.discordjug.javabot.util.InteractionUtils; +import net.dv8tion.jda.api.components.actionrow.ActionRow; import net.dv8tion.jda.api.entities.channel.ChannelType; import net.dv8tion.jda.api.entities.channel.concrete.ThreadChannel; import net.dv8tion.jda.api.events.message.MessageReceivedEvent; @@ -35,7 +36,7 @@ public void onMessageReceived(@NotNull MessageReceivedEvent event) { Please keep in mind that messages **over 2000 characters** get split in half due to webhook limitations. If you want to make sure that your submission is properly formatted, split your message into smaller chunks instead.""", event.getAuthor().getAsMention()) - .setActionRow(InteractionUtils.createDeleteButton(event.getAuthor().getIdLong())) + .addComponents(ActionRow.of(InteractionUtils.createDeleteButton(event.getAuthor().getIdLong()))) .queue(); } } diff --git a/src/main/java/net/discordjug/javabot/listener/filter/BlacklistedMessageAttachmentFilter.java b/src/main/java/net/discordjug/javabot/listener/filter/BlacklistedMessageAttachmentFilter.java new file mode 100644 index 000000000..614027603 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/listener/filter/BlacklistedMessageAttachmentFilter.java @@ -0,0 +1,46 @@ +package net.discordjug.javabot.listener.filter; + +import lombok.RequiredArgsConstructor; +import net.discordjug.javabot.data.config.BotConfig; +import net.discordjug.javabot.data.config.GuildConfig; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.events.message.MessageReceivedEvent; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * This {@link MessageFilter} blocks attachments blacklisted using {@link GuildConfig}. + */ +@Component +@RequiredArgsConstructor +public class BlacklistedMessageAttachmentFilter implements MessageFilter { + + private final BotConfig botConfig; + + @Override + public MessageModificationStatus processMessage(MessageContent content) { + MessageReceivedEvent event = content.event(); + List attachments = content.attachments(); + List embeds = content.embeds(); + GuildConfig guildConfig = botConfig.get(event.getGuild()); + List blacklistedMessageExtensions = guildConfig.getBlacklistedMessageExtensions(); + boolean removed = attachments.removeIf(attachment -> blacklistedMessageExtensions.contains(attachment.getFileExtension())); + if (removed) { + MessageEmbed attachmentRemovedInfo = new EmbedBuilder() + .setDescription("Disallowed attachments have been removed from this message.") + .build(); + embeds.add(attachmentRemovedInfo); + return MessageModificationStatus.MODIFIED; + } else { + return MessageModificationStatus.NOT_MODIFIED; + } + } + + @Override + public int getOrder() { + return -10;//should run earlier than normal because it is automod-related but other automod filters are more important + } +} diff --git a/src/main/java/net/discordjug/javabot/listener/filter/HugFilter.java b/src/main/java/net/discordjug/javabot/listener/filter/HugFilter.java new file mode 100644 index 000000000..63785dc83 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/listener/filter/HugFilter.java @@ -0,0 +1,74 @@ +package net.discordjug.javabot.listener.filter; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * This {@link MessageFilter} replaces all occurrences of 'fuck' in incoming messages with 'hug'. + */ +@Component +@RequiredArgsConstructor +public class HugFilter implements MessageFilter { + + private static final Pattern FUCKER = Pattern.compile( + "(fuck)(ing|er|ed|k+)?", + Pattern.CASE_INSENSITIVE + ); + + @Override + public MessageModificationStatus processMessage(MessageContent content) { + if (!content.event().getMessage().getMentions().getUsers().isEmpty()) { + return MessageModificationStatus.NOT_MODIFIED; + } + String before = content.messageText().toString(); + String processed = replaceFucks(content.messageText().toString()); + if (!before.equals(processed)) { + content.messageText().setLength(0); + content.messageText().append(processed); + return MessageModificationStatus.MODIFIED; + } else { + return MessageModificationStatus.NOT_MODIFIED; + } + } + + private static String processHug(String originalText) { + // FucK -> HuG, FuCk -> Hug + return String.valueOf(copyCase(originalText, 0, 'h')) + copyCase(originalText, 1, 'u') + + copyCase(originalText, 3, 'g'); + } + + private static String replaceFucks(String str) { + return FUCKER.matcher(str).replaceAll(matchResult -> { + String theFuck = matchResult.group(1); + String suffix = Objects.requireNonNullElse(matchResult.group(2), ""); + String processedSuffix = switch (suffix.toLowerCase()) { + case "er", "ed", "ing" -> copyCase(suffix, 0, 'g') + suffix; // fucking, fucker, fucked + case "" -> ""; // just fuck + default -> copyCase(suffix, "g".repeat(suffix.length())); // fuckkkkk... + }; + return processHug(theFuck) + processedSuffix; + }); + } + + private static String copyCase(String source, String toChange) { + if (source.length() != toChange.length()) { + throw new IllegalArgumentException("lengths differ"); + } + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < source.length(); i++) { + sb.append(copyCase(source, i, toChange.charAt(i))); + } + return sb.toString(); + } + + private static char copyCase(String original, int index, char newChar) { + if (Character.isUpperCase(original.charAt(index))) { + return Character.toUpperCase(newChar); + } else { + return newChar; + } + } +} diff --git a/src/main/java/net/discordjug/javabot/listener/filter/MessageContent.java b/src/main/java/net/discordjug/javabot/listener/filter/MessageContent.java new file mode 100644 index 000000000..de05cd0ad --- /dev/null +++ b/src/main/java/net/discordjug/javabot/listener/filter/MessageContent.java @@ -0,0 +1,20 @@ +package net.discordjug.javabot.listener.filter; + +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.events.message.MessageReceivedEvent; + +import java.util.List; + +/** + * A class containing modifiable content of a message that has been received. + * @param event The event associated with receiving the message + * @param messageText The text associated with the message + * @param attachments The attachments associated with the message + * @param embeds The embeds associated with the message + */ +public record MessageContent(MessageReceivedEvent event, + StringBuilder messageText, + List attachments, + List embeds) { +} diff --git a/src/main/java/net/discordjug/javabot/listener/filter/MessageFilter.java b/src/main/java/net/discordjug/javabot/listener/filter/MessageFilter.java new file mode 100644 index 000000000..b48b75507 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/listener/filter/MessageFilter.java @@ -0,0 +1,36 @@ +package net.discordjug.javabot.listener.filter; + +import org.springframework.core.Ordered; + +/** + * This interface is implemented by all message filters. + * + * The {@link MessageContent} is processed by every class implementing {@link MessageFilter} + * unless one of the filters returns {@link MessageModificationStatus#STOP_PROCESSING} which stops further filters from executing. + */ +public interface MessageFilter extends Ordered { + + /** + * When a message is received, it is processed by the registered filters. + * + * @param content The content of the new message that will be reposted instead of the received message + * if at least one filter returns {@link MessageModificationStatus#MODIFIED} + * and no filter returns {@link MessageModificationStatus#STOP_PROCESSING}. + * This {@link MessageContent} is built up incrementally by the filters. + * @return the appropriate {@link MessageModificationStatus} based on the filter's processing. + * @see MessageFilterHandler + */ + MessageModificationStatus processMessage(MessageContent content); + + /** + * {@inheritDoc} + * + *

+ * By default, message filters have order 0. A higher order means they are executed afterwards while a lower order results in them being executed before. + *

+ */ + @Override + default int getOrder() { + return 0; + } +} diff --git a/src/main/java/net/discordjug/javabot/listener/filter/MessageFilterHandler.java b/src/main/java/net/discordjug/javabot/listener/filter/MessageFilterHandler.java new file mode 100644 index 000000000..528ab7fd6 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/listener/filter/MessageFilterHandler.java @@ -0,0 +1,116 @@ +package net.discordjug.javabot.listener.filter; + +import lombok.RequiredArgsConstructor; +import net.discordjug.javabot.systems.moderation.AutoMod; +import net.discordjug.javabot.util.ExceptionLogger; +import net.discordjug.javabot.util.WebhookUtil; +import net.dv8tion.jda.api.entities.channel.ChannelType; +import net.dv8tion.jda.api.entities.channel.attribute.IWebhookContainer; +import net.dv8tion.jda.api.entities.channel.middleman.StandardGuildChannel; +import net.dv8tion.jda.api.events.message.MessageReceivedEvent; +import net.dv8tion.jda.api.hooks.ListenerAdapter; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Executor; + +/** + * This class is responsible for calling {@link MessageFilter}s on incoming messages and optionally replacing the message. + * + * When a message is received, registered {@link MessageFilter filters} are executed sequentially to process the message. + * These filters are able to act on the message and modify message contents using the {@link MessageContent} record. + * Modifications to the message are performed after all filters are executed by deleting the original message and re-sending a modified message. + * + * Message filters are Spring components implementing the {@link MessageFilter} interface. + */ +@RequiredArgsConstructor +public class MessageFilterHandler extends ListenerAdapter { + + private final List filters; + private final AutoMod autoMod; + private final Executor asyncPool; + + @Override + public void onMessageReceived(@NotNull MessageReceivedEvent event) { + if (!shouldRunFilters(event)) { + return; + } + + MessageContent content = new MessageContent( + event, + new StringBuilder(event.getMessage().getContentRaw()), + new ArrayList<>(event.getMessage().getAttachments()), + new ArrayList<>(event.getMessage().getEmbeds()) + ); + + + asyncPool.execute(() -> { + boolean handled = false; + + for (MessageFilter filter : filters) { + MessageModificationStatus status = filter.processMessage(content); + if (status == MessageModificationStatus.MODIFIED) { + handled = true; + } else if (status == MessageModificationStatus.STOP_PROCESSING) { + return; + } + } + + if (handled) { + IWebhookContainer webhookContainer = null; + long threadId = 0; + if (event.isFromType(ChannelType.TEXT)) { + webhookContainer = event.getChannel().asTextChannel(); + } + if (event.isFromThread()) { + StandardGuildChannel parentChannel = event.getChannel() + .asThreadChannel() + .getParentChannel() + .asStandardGuildChannel(); + threadId = event.getChannel().getIdLong(); + webhookContainer = (IWebhookContainer) parentChannel; + } + if (webhookContainer == null) { + return; + } + replaceMessage(webhookContainer, threadId, content); + } + }); + } + + private boolean shouldRunFilters(@NotNull MessageReceivedEvent event) { + if (event.isWebhookMessage()) { + return false; + } + if (!event.isFromGuild()) { + return false; + } + if (event.getAuthor().isBot() || event.getAuthor().isSystem()) { + return false; + } + if (autoMod.hasSuspiciousLink(event.getMessage()) || + autoMod.hasAdvertisingLink(event.getMessage())) { + return false; + } + return true; + } + + private void replaceMessage(IWebhookContainer webhookContainer, long threadId, MessageContent content) { + WebhookUtil.ensureWebhookExists( + webhookContainer, + wh -> WebhookUtil.replaceMemberMessage( + wh, + content.event().getMessage(), + content.messageText().toString(), + threadId, + content.attachments(), + content.embeds() + ), + e -> ExceptionLogger.capture( + e, + this.getClass().getSimpleName() + ) + ); + } +} diff --git a/src/main/java/net/discordjug/javabot/listener/filter/MessageModificationStatus.java b/src/main/java/net/discordjug/javabot/listener/filter/MessageModificationStatus.java new file mode 100644 index 000000000..7a51a733c --- /dev/null +++ b/src/main/java/net/discordjug/javabot/listener/filter/MessageModificationStatus.java @@ -0,0 +1,24 @@ +package net.discordjug.javabot.listener.filter; + +/** + * This enum describes the result of a {@link MessageFilter} execution and what should be done with the message. + */ +public enum MessageModificationStatus { + /** + * The message representation has been modified requiring the message to be deleted and re-sent. + * + * Further filters will be executed. + */ + MODIFIED, + /** + * The message representation has not been modified and the filter does not require the message to be deleted and re-sent. + * + * If another filter returns {@link #MODIFIED}, the message is still deleted and re-sent. + * Further filters will be executed. + */ + NOT_MODIFIED, + /** + * Indicates that no further filters should be executed on the message and that the message should not be deleted and re-sent by the filter handling logic. + */ + STOP_PROCESSING +} \ No newline at end of file diff --git a/src/main/java/net/discordjug/javabot/listener/filter/MessageRuleFilter.java b/src/main/java/net/discordjug/javabot/listener/filter/MessageRuleFilter.java new file mode 100644 index 000000000..beda91832 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/listener/filter/MessageRuleFilter.java @@ -0,0 +1,155 @@ +package net.discordjug.javabot.listener.filter; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpResponse.BodyHandlers; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.time.OffsetDateTime; +import java.util.Base64; +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.MessageRule; +import net.discordjug.javabot.data.config.guild.MessageRule.MessageAction; +import net.discordjug.javabot.data.config.guild.ModerationConfig; +import net.discordjug.javabot.data.h2db.message_cache.MessageCache; +import net.discordjug.javabot.data.h2db.message_cache.model.CachedMessage; +import net.discordjug.javabot.util.Checks; +import net.discordjug.javabot.util.ExceptionLogger; +import net.discordjug.javabot.util.GsonUtils; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.Message.Attachment; +import net.dv8tion.jda.api.utils.TimeUtil; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Message; +import org.springframework.stereotype.Component; + +/** + * This {@link MessageFilter} acts on messages according to {@link MessageRule}s. + * If a message rule matches, the corresponding action is executed. + */ +@Component +@RequiredArgsConstructor +public class MessageRuleFilter implements MessageFilter { + + private final BotConfig botConfig; + private final MessageCache messageCache; + + @Override + public MessageModificationStatus processMessage(MessageContent content) { + + ModerationConfig moderationConfig = botConfig.get(content.event().getGuild()).getModerationConfig(); + List messageRules = moderationConfig.getMessageRules(); + + MessageRule ruleToExecute = null; + for (MessageRule rule : messageRules) { + if (matches(content, rule)) { + if (ruleToExecute == null || rule.getAction() == MessageAction.BLOCK) { + ruleToExecute = rule; + } + } + } + MessageModificationStatus status = MessageModificationStatus.NOT_MODIFIED; + if (ruleToExecute != null) { + if (ruleToExecute.getAction() == MessageAction.BLOCK && !Checks.hasStaffRole(botConfig, content.event().getMember())) { + content.event().getMessage().delete() + .flatMap(_ -> content.event().getChannel().sendMessage(content.event().getAuthor().getAsMention() + " Your message has been deleted for moderative reasons. If you believe this happened by mistake, please contact the server staff.")) + .delay(Duration.ofSeconds(60)) + .flatMap(Message::delete) + .queue(); + status = MessageModificationStatus.STOP_PROCESSING; + } + log(content, ruleToExecute, moderationConfig); + } + + return status; + } + + private void log(MessageContent content, MessageRule ruleToExecute, ModerationConfig moderationConfig) { + EmbedBuilder embed = messageCache.buildMessageCacheEmbed( + content.event().getMessage().getChannel(), + content.event().getMessage().getAuthor(), + CachedMessage.of(content.event().getMessage()), "Message content") + .setTitle("Message rule triggered") + .addField("Rule description", "```\n" + GsonUtils.toJson(ruleToExecute) + "\n```", false); + if (!content.attachments().isEmpty()) { + embed.addField("Attachment hashes", computeAttachmentDescription(content.attachments()), false); + } + moderationConfig.getLogChannel().sendMessageEmbeds(embed.build()).queue(); + } + + private boolean matches(MessageContent content, MessageRule rule) { + if (rule.getMessageRegex() != null && !rule.getMessageRegex().matcher(content.messageText()).matches()) { + return false; + } + if (content.attachments().size() > rule.getMaxAttachments()) { + return false; + } + if (content.attachments().size() < rule.getMinAttachments()) { + return false; + } + boolean matchesSHA = rule.getAttachmentSHAs().isEmpty(); + for (Attachment attachment : content.attachments()) { + if (rule.getAttachmentNameRegex() != null && !rule.getAttachmentNameRegex().matcher(attachment.getFileName()).matches()) { + return false; + } + if (!matchesSHA) { + if (rule.getAttachmentSHAs().contains(computeSHA(attachment))) { + matchesSHA = true; + } + } + } + if (!matchesSHA) { + return false; + } + if (rule.getNoMessagesFromAuthorBeforeSeconds() > 0) { + OffsetDateTime maxTime = content.event().getMessage().getTimeCreated() + .minusSeconds(rule.getNoMessagesFromAuthorBeforeSeconds()); + long authorId = content.event().getAuthor().getIdLong(); + return !hasMessagesOlderThan(maxTime, content.event().getGuild(), authorId); + } + return true; + } + + private boolean hasMessagesOlderThan(OffsetDateTime maxTime, Guild guild, long authorId) { + if (messageCache.cache.stream() + .filter(msg -> TimeUtil.getTimeCreated(msg.getMessageId()).isBefore(maxTime)) + .anyMatch(msg -> msg.getAuthorId() == authorId)) { + return true; + } + return guild.searchMessages() + .authors(authorId) + .maxId(TimeUtil.getDiscordTimestamp(maxTime.toInstant().toEpochMilli())) + .complete().asResults().getTotalResults() > 0; + } + + private String computeAttachmentDescription(List attachments) { + return attachments.stream() + .map(attachment -> "- " + attachment.getUrl() + ": `" + computeSHA(attachment) + "`") + .collect(Collectors.joining("\n")); + } + + private String computeSHA(Attachment attachment) { + try { + HttpResponse res = HttpClient.newHttpClient().send(HttpRequest.newBuilder(URI.create(attachment.getProxyUrl())).build(), BodyHandlers.ofByteArray()); + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(res.body()); + return Base64.getEncoder().encodeToString(hash); + } catch (IOException | InterruptedException | NoSuchAlgorithmException e) { + ExceptionLogger.capture(e); + return ""; + } + } + + @Override + public int getOrder() { + return -100;//automod should run early + } +} diff --git a/src/main/java/net/discordjug/javabot/listener/SuggestionListener.java b/src/main/java/net/discordjug/javabot/listener/filter/SuggestionFilter.java old mode 100644 new mode 100755 similarity index 67% rename from src/main/java/net/discordjug/javabot/listener/SuggestionListener.java rename to src/main/java/net/discordjug/javabot/listener/filter/SuggestionFilter.java index ee90701b4..d4d5f3b03 --- a/src/main/java/net/discordjug/javabot/listener/SuggestionListener.java +++ b/src/main/java/net/discordjug/javabot/listener/filter/SuggestionFilter.java @@ -1,54 +1,50 @@ -package net.discordjug.javabot.listener; +package net.discordjug.javabot.listener.filter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import net.discordjug.javabot.data.config.BotConfig; import net.discordjug.javabot.data.config.SystemsConfig; -import net.discordjug.javabot.systems.moderation.AutoMod; import net.discordjug.javabot.util.MessageActionUtils; import net.discordjug.javabot.util.Responses; import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.entities.*; import net.dv8tion.jda.api.entities.channel.ChannelType; import net.dv8tion.jda.api.events.message.MessageReceivedEvent; -import net.dv8tion.jda.api.hooks.ListenerAdapter; import net.dv8tion.jda.api.requests.RestAction; import org.jetbrains.annotations.NotNull; +import org.springframework.stereotype.Component; import java.time.Instant; /** - * Listens for {@link MessageReceivedEvent}s in the - * {@link net.discordjug.javabot.data.config.guild.ModerationConfig#getSuggestionChannel()} channel. + * When a message is created in the {@link net.discordjug.javabot.data.config.guild.ModerationConfig#getSuggestionChannel()} channel, it is decorated as a suggestion. */ +@Component @Slf4j @RequiredArgsConstructor -public class SuggestionListener extends ListenerAdapter { - private final AutoMod autoMod; +public class SuggestionFilter implements MessageFilter { private final BotConfig botConfig; - + @Override - public void onMessageReceived(@NotNull MessageReceivedEvent event) { - if (!canCreateSuggestion(event)) return; - if (autoMod.hasSuspiciousLink(event.getMessage()) || autoMod.hasAdvertisingLink(event.getMessage())) { - event.getMessage().delete().queue(); - return; - } - MessageEmbed embed = buildSuggestionEmbed(event.getMessage()); - MessageActionUtils.addAttachmentsAndSend(event.getMessage(), event.getChannel().sendMessageEmbeds(embed)).thenAccept(message -> { + public MessageModificationStatus processMessage(MessageContent content) { + if (!canCreateSuggestion(content.event())) return MessageModificationStatus.NOT_MODIFIED; + MessageEmbed embed = buildSuggestionEmbed(content.messageText().toString(), content.event().getMember()); + MessageActionUtils.addAttachmentsAndSend(content.attachments(), content.event().getChannel().sendMessageEmbeds(embed)) + .thenAccept(message -> { addReactions(message).queue(); - event.getMessage().delete().queue(); - message.createThreadChannel(String.format("%s — Suggestion", event.getAuthor().getName())) - .flatMap(thread -> thread.addThreadMember(event.getAuthor())) + content.event().getMessage().delete().queue(); + message.createThreadChannel(String.format("%s — Suggestion", content.event().getAuthor().getName())) + .flatMap(thread -> thread.addThreadMember(content.event().getAuthor())) .queue(); } ).exceptionally(e -> { log.error("Could not send Submission Embed", e); return null; }); + return MessageModificationStatus.STOP_PROCESSING; } - + /** * Decides whether the message author is eligible to create new suggestions. * @@ -76,10 +72,7 @@ private RestAction addReactions(Message message) { ); } - - - private MessageEmbed buildSuggestionEmbed(Message message) { - Member member = message.getMember(); + private MessageEmbed buildSuggestionEmbed(String messageContent, Member member) { // Note: member will never be null in practice. This is to satisfy code analysis tools. if (member == null) throw new IllegalStateException("Member was null when building suggestion embed."); return new EmbedBuilder() @@ -87,8 +80,13 @@ private MessageEmbed buildSuggestionEmbed(Message message) { .setAuthor(member.getEffectiveName(), null, member.getEffectiveAvatarUrl()) .setColor(Responses.Type.DEFAULT.getColor()) .setTimestamp(Instant.now()) - .setDescription(message.getContentRaw()) - .setFooter(message.getAuthor().getId()) + .setDescription(messageContent) + .setFooter(member.getId()) .build(); } + + @Override + public int getOrder() { + return LOWEST_PRECEDENCE; + } } diff --git a/src/main/java/net/discordjug/javabot/systems/configuration/ConfigCommand.java b/src/main/java/net/discordjug/javabot/systems/configuration/ConfigCommand.java index 3ee642974..03948913c 100644 --- a/src/main/java/net/discordjug/javabot/systems/configuration/ConfigCommand.java +++ b/src/main/java/net/discordjug/javabot/systems/configuration/ConfigCommand.java @@ -1,7 +1,6 @@ package net.discordjug.javabot.systems.configuration; import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand; -import net.discordjug.javabot.data.config.BotConfig; import net.discordjug.javabot.systems.moderation.CommandModerationPermissions; import net.dv8tion.jda.api.interactions.commands.build.Commands; @@ -12,12 +11,11 @@ public class ConfigCommand extends SlashCommand implements CommandModerationPermissions { /** * 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 exportConfigSubcommand /config export * @param getConfigSubcommand /config get * @param setConfigSubcommand /config set */ - public ConfigCommand(BotConfig botConfig, ExportConfigSubcommand exportConfigSubcommand, GetConfigSubcommand getConfigSubcommand, SetConfigSubcommand setConfigSubcommand) { + public ConfigCommand(ExportConfigSubcommand exportConfigSubcommand, GetConfigSubcommand getConfigSubcommand, SetConfigSubcommand setConfigSubcommand) { setModerationSlashCommandData(Commands.slash("config", "Administrative Commands for managing the bot's configuration.")); addSubcommands(exportConfigSubcommand, getConfigSubcommand, setConfigSubcommand); } diff --git a/src/main/java/net/discordjug/javabot/systems/configuration/ConfigSubcommand.java b/src/main/java/net/discordjug/javabot/systems/configuration/ConfigSubcommand.java index ce90afa32..792c7eee2 100644 --- a/src/main/java/net/discordjug/javabot/systems/configuration/ConfigSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/configuration/ConfigSubcommand.java @@ -50,7 +50,7 @@ public void execute(@NotNull SlashCommandInteractionEvent event) { try { handleConfigSubcommand(event, botConfig.get(event.getGuild())).queue(); } catch (UnknownPropertyException e) { - Responses.warning(event, "Unknown Property", "The provided property could not be found.") + Responses.warnin(event, "Unknown Property", "The provided property could not be found.") .queue(); } } diff --git a/src/main/java/net/discordjug/javabot/systems/configuration/GetConfigSubcommand.java b/src/main/java/net/discordjug/javabot/systems/configuration/GetConfigSubcommand.java index 1620b4638..fef4b777d 100644 --- a/src/main/java/net/discordjug/javabot/systems/configuration/GetConfigSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/configuration/GetConfigSubcommand.java @@ -3,6 +3,7 @@ import net.discordjug.javabot.data.config.BotConfig; import net.discordjug.javabot.data.config.GuildConfig; import net.discordjug.javabot.data.config.UnknownPropertyException; +import net.discordjug.javabot.util.GsonUtils; import net.discordjug.javabot.util.Responses; import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; @@ -38,7 +39,8 @@ public ReplyCallbackAction handleConfigSubcommand(@Nonnull SlashCommandInteracti } String property = propertyOption.getAsString().trim(); Object value = config.resolve(property); - return Responses.info(event, "Configuration Property", "The value of the property `%s` is:\n```\n%s\n```", property, value); + String json = GsonUtils.toJson(value); + return Responses.info(event, "Configuration Property", "The value of the property `%s` is:\n```\n%s\n```", property, json); } @Override diff --git a/src/main/java/net/discordjug/javabot/systems/configuration/SetConfigSubcommand.java b/src/main/java/net/discordjug/javabot/systems/configuration/SetConfigSubcommand.java index 6a903fcc5..54b49d3bd 100644 --- a/src/main/java/net/discordjug/javabot/systems/configuration/SetConfigSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/configuration/SetConfigSubcommand.java @@ -1,10 +1,15 @@ package net.discordjug.javabot.systems.configuration; +import com.google.gson.JsonSyntaxException; import net.discordjug.javabot.annotations.AutoDetectableComponentHandler; import net.discordjug.javabot.data.config.BotConfig; import net.discordjug.javabot.data.config.GuildConfig; import net.discordjug.javabot.data.config.UnknownPropertyException; +import net.discordjug.javabot.util.GsonUtils; import net.discordjug.javabot.util.Responses; +import net.dv8tion.jda.api.components.label.Label; +import net.dv8tion.jda.api.components.textinput.TextInput; +import net.dv8tion.jda.api.components.textinput.TextInputStyle; import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent; import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; @@ -12,10 +17,8 @@ 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.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 net.dv8tion.jda.api.modals.Modal; import net.dv8tion.jda.api.requests.restaction.interactions.InteractionCallbackAction; import xyz.dynxsty.dih4jda.interactions.AutoCompletable; import xyz.dynxsty.dih4jda.interactions.components.ModalHandler; @@ -56,11 +59,17 @@ public InteractionCallbackAction handleConfigSubcommand(@Nonnull SlashCommand if (resolved == null) { return Responses.error(event, "Config `%s` not found", property); } + String value; + if (resolved instanceof String s) { + value = s; + } else { + value = GsonUtils.toJson(resolved); + } return event.replyModal( Modal.create(ComponentIdBuilder.build("config-set", property), "Change configuration value") - .addActionRow(TextInput.create("value", "new value", TextInputStyle.PARAGRAPH) - .setValue(String.valueOf(resolved)) - .build()) + .addComponents(Label.of("new value", TextInput.create("value", TextInputStyle.PARAGRAPH) + .setValue(value) + .build())) .build()); } String valueString = valueOption.getAsString().trim(); @@ -77,8 +86,8 @@ public void handleModal(ModalInteractionEvent event, List values) try { guildConfig.set(property, valueString); Responses.success(event, "Configuration Updated", "The property `%s` has been set to `%s`.", property, valueString).queue(); - } catch (UnknownPropertyException e) { - Responses.error(event, "Property not found: %s", property).queue(); + } catch (UnknownPropertyException | JsonSyntaxException e) { + Responses.error(event, "Error while setting new value").queue(); } } 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 541d9136c..4bd960b54 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 @@ -6,8 +6,8 @@ import net.discordjug.javabot.annotations.AutoDetectableComponentHandler; import net.discordjug.javabot.util.Responses; import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.components.buttons.Button; 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; @@ -40,7 +40,7 @@ public void handleButton(ButtonInteractionEvent event, Button button) { Responses.error(event, "Only the VC owner can use this.").queue(); return; } - String[] id = ComponentIdBuilder.split(button.getId()); + String[] id = ComponentIdBuilder.split(button.getCustomId()); switch (id[1]) { case MAKE_PRIVATE_ID -> changeVisibility(event, createMakePublicButton(), "This voice channel is now private.", PermissionOverrideAction::setDenied); 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 index 8ecd66edb..0ae954dcf 100644 --- a/src/main/java/net/discordjug/javabot/systems/custom_vc/CustomVCListener.java +++ b/src/main/java/net/discordjug/javabot/systems/custom_vc/CustomVCListener.java @@ -7,6 +7,7 @@ import net.discordjug.javabot.util.ExceptionLogger; import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.components.actionrow.ActionRow; 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; @@ -62,7 +63,7 @@ private void createCustomVC(GuildVoiceUpdateEvent event, AudioChannelUnion vcJoi """) .build()) .addContent(event.getMember().getAsMention()) - .addActionRow(buttonHandler.createMakePrivateButton()) + .addComponents(ActionRow.of(buttonHandler.createMakePrivateButton())) .queue(); }); } 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 index aac9ffecf..c3b03b288 100644 --- 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 @@ -1,5 +1,6 @@ package net.discordjug.javabot.systems.custom_vc.commands; +import net.dv8tion.jda.api.interactions.InteractionContextType; import net.dv8tion.jda.api.interactions.commands.build.Commands; import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand; @@ -14,7 +15,7 @@ public class CustomVCControlCommand extends SlashCommand { */ public CustomVCControlCommand(CustomVCAddMemberSubcommand addMemberSubcommand, CustomVCRemoveMemberSubcommand removeMemberSubcommand) { setCommandData(Commands.slash("vc-control", "Manages custom voice channels") - .setGuildOnly(true) + .setContexts(InteractionContextType.GUILD) ); addSubcommands(addMemberSubcommand, removeMemberSubcommand); } diff --git a/src/main/java/net/discordjug/javabot/systems/help/AnswerOverflowService.java b/src/main/java/net/discordjug/javabot/systems/help/AnswerOverflowService.java new file mode 100644 index 000000000..78f5b5f48 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/help/AnswerOverflowService.java @@ -0,0 +1,58 @@ +package net.discordjug.javabot.systems.help; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpRequest.BodyPublishers; +import java.net.http.HttpResponse.BodyHandlers; + +import lombok.extern.slf4j.Slf4j; +import net.discordjug.javabot.data.config.BotConfig; +import org.springframework.stereotype.Service; + +/** + * A service class for interacting with the Answer Overflow bot. + */ +@Service +@Slf4j +public class AnswerOverflowService { + private final String apiKey; + + public AnswerOverflowService(BotConfig config) { + apiKey = config.getSystems().getAnswerOverflowApiKey(); + } + + /** + * Marks a message as the answer of a forum post with Answer Overflow. + * @param postId The Discord ID of the forum post + * @param messageId The Discord ID of the message that should be marked as the answer. + */ + public void markAnswer(long postId, long messageId) { + if (apiKey == null || apiKey.isBlank()) { + return; + } + + try (HttpClient client = HttpClient.newHttpClient()) { + client.sendAsync( + HttpRequest.newBuilder(URI.create("https://www.answeroverflow.com/api/v1/messages/" + postId)) + .header("x-api-key", apiKey) + .header("Content-Type", "application/json") + .POST(BodyPublishers.ofString(""" + { + "solutionId": "%d" + } + """.formatted(messageId))) + .build(), + BodyHandlers.ofString()) + .thenAccept(response -> { + if (response.statusCode() != 200) { + log.warn("Answer Overflow responded with unexpected status code {}, post ID: {}, message ID: {}, body: {}", response.statusCode(), postId, messageId, response.body()); + } + }) + .exceptionally(e -> { + log.error("An exception occured trying to mark a post as an answer.", e); + return null; + }); + } + } +} 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 64b483ffd..3b2ab37d6 100644 --- a/src/main/java/net/discordjug/javabot/systems/help/AutoCodeFormatter.java +++ b/src/main/java/net/discordjug/javabot/systems/help/AutoCodeFormatter.java @@ -9,6 +9,7 @@ import net.discordjug.javabot.util.InteractionUtils; import net.discordjug.javabot.util.WebhookUtil; import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.components.actionrow.ActionRow; import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.MessageEmbed; @@ -19,6 +20,7 @@ import org.springframework.stereotype.Component; import javax.annotation.Nonnull; + import java.util.Objects; /** @@ -126,9 +128,9 @@ void handleMessageEvent(@Nonnull MessageReceivedEvent event, boolean isFirstMess private void sendFormatHint(MessageReceivedEvent event) { event.getMessage() .replyEmbeds(formatHintEmbed(event.getGuild())) - .addActionRow( + .addComponents(ActionRow.of( InteractionUtils.createDeleteButton(event.getAuthor().getIdLong()) - ).queue(); + )).queue(); } 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 4d134a081..fe83c77c2 100644 --- a/src/main/java/net/discordjug/javabot/systems/help/HelpForumUpdater.java +++ b/src/main/java/net/discordjug/javabot/systems/help/HelpForumUpdater.java @@ -8,6 +8,7 @@ import net.discordjug.javabot.systems.user_preferences.model.Preference; import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.components.buttons.Button; import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.MessageEmbed; @@ -38,7 +39,7 @@ public class HelpForumUpdater { @Scheduled(cron = "0 */10 * * * *") // Run every 10 minutes public void execute() { for (Guild guild : jda.getGuilds()) { - log.info("Checking for inactive forum posts in {}", guild.getName()); + log.debug("Checking for inactive forum posts in {}", guild.getName()); HelpConfig config = botConfig.get(guild).getHelpConfig(); ForumChannel forum = config.getHelpForumChannel(); if (forum != null) { @@ -104,7 +105,7 @@ private MessageEmbed createDMDormantInfo(ThreadChannel post, HelpConfig config) } private boolean isThanksMessage(@NotNull Message m) { - return m.getAuthor().isBot() && !m.getButtons().isEmpty() && - m.getButtons().stream().allMatch(b -> b.getId() != null && b.getId().contains(HelpManager.HELP_THANKS_IDENTIFIER)); + return m.getAuthor().isBot() && !m.getComponents().isEmpty() && + m.getComponents().stream().allMatch(c -> c instanceof Button b && b.getCustomId() != null && b.getCustomId().contains(HelpManager.HELP_THANKS_IDENTIFIER)); } } 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 638e0a2cc..efcd8b87e 100644 --- a/src/main/java/net/discordjug/javabot/systems/help/HelpListener.java +++ b/src/main/java/net/discordjug/javabot/systems/help/HelpListener.java @@ -11,6 +11,8 @@ import net.discordjug.javabot.util.InteractionUtils; import net.discordjug.javabot.util.Responses; import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.components.actionrow.ActionRow; +import net.dv8tion.jda.api.components.buttons.Button; import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.UserSnowflake; import net.dv8tion.jda.api.entities.channel.Channel; @@ -22,14 +24,14 @@ 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.ActionRow; -import net.dv8tion.jda.api.interactions.components.buttons.Button; import org.jetbrains.annotations.NotNull; import xyz.dynxsty.dih4jda.interactions.components.ButtonHandler; import xyz.dynxsty.dih4jda.util.ComponentIdBuilder; import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; /** * Listens for all events related to the forum help channel system. @@ -78,6 +80,7 @@ protected boolean removeEldestEntry(Map.Entry eldest) { return System.currentTimeMillis() > eldest.getValue() || size() >= 32; } }; + private final AnswerOverflowService answerOverflowService; @Override public void onMessageReceived(@NotNull MessageReceivedEvent event) { @@ -120,12 +123,8 @@ public void onMessageReceived(@NotNull MessageReceivedEvent event) { return; } // cache messages - List messages = new ArrayList<>(); - messages.add(event.getMessage()); - if (HELP_POST_MESSAGES.containsKey(post.getIdLong())) { - messages.addAll(HELP_POST_MESSAGES.get(post.getIdLong())); - } - HELP_POST_MESSAGES.put(post.getIdLong(), messages); + HELP_POST_MESSAGES.computeIfAbsent(post.getIdLong(), _ -> new ArrayList<>()) + .add(event.getMessage()); // suggest to close post on "problem solved"-messages replyCloseSuggestionIfPatternMatches(event.getMessage()); } @@ -161,10 +160,10 @@ private void replyCloseSuggestionIfPatternMatches(Message msg) { If you are not, please ignore this message. Note that you will not be able to send further messages here after this post have been closed but you will be able to create new posts. """) - .addActionRow( + .addComponents(ActionRow.of( createCloseSuggestionButton(msg.getChannel().asThreadChannel()), InteractionUtils.createDeleteButton(msg.getAuthor().getIdLong()) - ).queue(); + )).queue(); recentlyCloseSuggestedPosts.put( postId, System.currentTimeMillis() + SUGGEST_CLOSE_TIMEOUT @@ -256,16 +255,16 @@ private void handleHelpThanksInteraction(@NotNull ButtonInteractionEvent event, case "done" -> handleThanksCloseButton(event, manager, post, ""); case "cancel" -> event.deferEdit().flatMap(h -> event.getMessage().delete()).queue(); default -> { - List