diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4dade8c93..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] @@ -11,33 +8,49 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up JDK 25 - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: 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 permissions: contents: read - needs: build + 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 + - uses: actions/checkout@v6 - name: Set up JDK 25 uses: graalvm/setup-graalvm@v1 with: 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 @@ -48,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 dec9e4341..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 "8.1.1" - id("org.springframework.boot") version "4.0.1" - id("io.spring.dependency-management") version "1.1.7" - id("org.graalvm.buildtools.native") version "0.11.3" - checkstyle -} - -java { - toolchain { - languageVersion = JavaLanguageVersion.of(25) - } -} - -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") - testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") - compileOnly("com.google.code.findbugs:jsr305:3.0.2") - compileOnly("org.jetbrains:annotations:26.0.2") - - // DIH4JDA (Command Framework) & JDA - implementation("com.github.jasonlessenich:DIH4JDA:1.7.0") - //implementation("xyz.dynxsty:dih4jda:1.7.0") - implementation("net.dv8tion:JDA:6.2.0") { - exclude(module = "opus-java") - } - - // Caffeine (Caching Library) - implementation("com.github.ben-manes.caffeine:caffeine:3.2.2") - - implementation("com.google.code.gson:gson:2.13.1") - implementation("org.yaml:snakeyaml:2.4") - implementation("com.google.re2j:re2j:1.8") - implementation("commons-validator:commons-validator:1.10.0") - - implementation("com.mashape.unirest:unirest-java:1.4.9") - - // H2 Database - implementation("com.h2database:h2:2.3.232") - implementation("com.zaxxer:HikariCP") - - // Webhooks - implementation("com.github.DynxstyGIT:discord-webhooks:74301a46a0") - - // Lombok Annotations - compileOnly("org.projectlombok:lombok:1.18.38") - annotationProcessor("org.projectlombok:lombok:1.18.38") - testCompileOnly("org.projectlombok:lombok:1.18.38") - testAnnotationProcessor("org.projectlombok:lombok:1.18.38") - - // Sentry - implementation("io.sentry:sentry:8.20.0") - - // Spring - implementation("org.springframework.boot:spring-boot-starter-webmvc") - implementation("org.springframework.boot:spring-boot-starter-data-jdbc") - - //required for registering native hints - implementation("org.jetbrains.kotlin:kotlin-reflect") - - implementation("org.slf4j:jcl-over-slf4j") -} - -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") { - buildArgs.add("-H:+ForeignAPISupport")//needed for AWT/plotting, see https://bugs.openjdk.org/browse/JDK-8337237 - 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 1b33c55ba..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 23449a2b5..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-9.2.1-bin.zip -networkTimeout=10000 -validateDistributionUrl=true -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew deleted file mode 100755 index 23d15a936..000000000 --- a/gradlew +++ /dev/null @@ -1,251 +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. -# -# SPDX-License-Identifier: Apache-2.0 -# - -############################################################################## -# -# 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/platforms/jvm/plugins-application/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 -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || 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="\\\"\\\"" - - -# 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, 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" \ - -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ - "$@" - -# 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 db3a6ac20..000000000 --- a/gradlew.bat +++ /dev/null @@ -1,94 +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 -@rem SPDX-License-Identifier: Apache-2.0 -@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. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH= - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* - -: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/RuntimeHintsConfiguration.java b/src/main/java/net/discordjug/javabot/RuntimeHintsConfiguration.java index 9f78facda..1f30b4136 100644 --- a/src/main/java/net/discordjug/javabot/RuntimeHintsConfiguration.java +++ b/src/main/java/net/discordjug/javabot/RuntimeHintsConfiguration.java @@ -1,5 +1,7 @@ package net.discordjug.javabot; +import java.util.concurrent.ThreadPoolExecutor; + import club.minnced.discord.webhook.send.WebhookEmbed; import com.zaxxer.hikari.HikariConfig; import net.discordjug.javabot.data.config.BotConfig; @@ -15,6 +17,7 @@ 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.SoundboardSound; import net.dv8tion.jda.api.hooks.ListenerAdapter; import net.dv8tion.jda.internal.entities.GuildVoiceStateImpl; import net.dv8tion.jda.internal.requests.restaction.PermOverrideData; @@ -60,6 +63,8 @@ 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); @@ -68,8 +73,8 @@ public void registerHints(RuntimeHints hints, ClassLoader classLoader) { for(Class cl : getClass().getClassLoader().loadClass("sun.font.FontConfigManager").getDeclaredClasses()) { hints.jni().registerType(cl, MemberCategory.ACCESS_DECLARED_FIELDS, MemberCategory.INVOKE_DECLARED_CONSTRUCTORS); } - } catch (ClassNotFoundException e) { - throw new RuntimeException(e); + } catch (ClassNotFoundException _) { + // FontConfigManager is only supported on Linux } for (Class cl : WebhookEmbed.class.getClasses()) { @@ -77,5 +82,6 @@ public void registerHints(RuntimeHints hints, ClassLoader classLoader) { } 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/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 a98f5dbe5..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; @@ -20,7 +19,6 @@ import java.nio.file.Path; import java.util.List; import java.util.Optional; -import java.util.regex.Pattern; /** * A collection of guild-specific configuration items, each of which represents @@ -71,13 +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() - .registerTypeAdapter(Pattern.class, new PatternTypeAdapter()) - .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); @@ -118,13 +113,8 @@ private void setGuild(Guild guild) { * Saves this config to its file path. */ public synchronized void flush() { - Gson gson = new GsonBuilder() - .serializeNulls() - .setPrettyPrinting() - .registerTypeAdapter(Pattern.class, new PatternTypeAdapter()) - .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()); @@ -167,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/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 index 68f742d21..5b6537d88 100644 --- a/src/main/java/net/discordjug/javabot/data/config/guild/MessageRule.java +++ b/src/main/java/net/discordjug/javabot/data/config/guild/MessageRule.java @@ -33,6 +33,12 @@ public class MessageRule { */ 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. */ 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 565178783..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 @@ -28,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. */ @@ -103,7 +115,18 @@ public class ModerationConfig extends GuildConfigItem { /** * 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 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 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. 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/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 3a4a28632..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 @@ -93,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(); 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 e729b7a1e..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 @@ -16,6 +16,7 @@ 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.requests.restaction.MessageCreateAction; import net.dv8tion.jda.api.utils.FileUpload; @@ -35,6 +36,7 @@ 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; @@ -50,7 +52,7 @@ public class MessageCache { /** * A memory-cache (list) of sent Messages, wrapped to a {@link CachedMessage} object. */ - public Deque cache = new ArrayDeque<>(); + public Deque cache = new ConcurrentLinkedDeque<>(); /** * Amount of messages since the last synchronization. *

@@ -95,8 +97,10 @@ public void synchronize() { * Synchronizes Messages saved in the Database with what is currently stored in memory and wait until the synchronization finishes. */ public void synchronizeNow() { - cacheRepository.delete(cache.size()); - cacheRepository.insertList(new ArrayList<>(cache)); + if (messageCount == 0) { + return; + } + cacheRepository.replaceWithList(new ArrayList<>(cache)); messageCount = 0; log.info("Synchronized Database with local Cache."); } @@ -146,7 +150,7 @@ 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 -> { @@ -196,7 +200,7 @@ private void requestMessageAttachments(CachedMessage message) { * @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(MessageChannel channel, User author, CachedMessage message, String contentFieldName) { + 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()) @@ -233,7 +237,7 @@ private MessageEmbed buildMessageEditEmbed(User author, MessageChannel channel, .build(); } - private MessageEmbed buildMessageDeleteEmbed(User author, MessageChannel channel, CachedMessage 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()); 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 061f1c5be..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; @@ -39,10 +41,10 @@ public void onMessageUpdate(@NotNull MessageUpdateEvent event) { CachedMessage before; if (optional.isPresent()) { CachedMessage inCache= optional.get(); - before = new CachedMessage(inCache.getMessageId(), inCache.getAuthorId(), inCache.getMessageContent(), inCache.getAttachments()); + before = new CachedMessage(inCache.getMessageId(), inCache.getAuthorId(), inCache.getChannelId(),inCache.getMessageContent(), inCache.getAttachments()); inCache.init(event.getMessage()); } else { - before = new CachedMessage(event.getMessageIdLong(), event.getAuthor().getIdLong(), "[unknown content]", List.of()); + 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 e86f1408e..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,39 +98,22 @@ 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", + "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) { 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.getMessageContent(), attachments); + 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 { List attachments = new ArrayList<>(); String attachment = rs.getString("link"); @@ -119,6 +123,7 @@ private CachedMessage read(ResultSet rs) throws SQLException { 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 2d7976a28..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 @@ -19,25 +19,29 @@ public class CachedMessage { 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) { + 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, String messageContent, List attachments) { + 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); } @@ -49,7 +53,7 @@ public CachedMessage(long messageId, long authorId, String messageContent, List< * @return The built {@link CachedMessage}. */ public static CachedMessage of(Message message) { - CachedMessage cachedMessage = new CachedMessage(message.getIdLong(), message.getAuthor().getIdLong()); + CachedMessage cachedMessage = new CachedMessage(message.getIdLong(), message.getAuthor().getIdLong(),message.getChannelIdLong()); cachedMessage.init(message); return cachedMessage; } diff --git a/src/main/java/net/discordjug/javabot/listener/PingableNameListener.java b/src/main/java/net/discordjug/javabot/listener/PingableNameListener.java index a4e4671fd..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,12 +65,12 @@ 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.getEffectiveName(); - String newName = generateRandomName(); + String newName = generateNewName(member.getUser().getName()); member.modifyNickname(newName.substring(0, Math.min(31, newName.length()))).queue(); member.getUser().openPrivateChannel() .flatMap(channel -> @@ -99,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/filter/BlacklistedMessageAttachmentFilter.java b/src/main/java/net/discordjug/javabot/listener/filter/BlacklistedMessageAttachmentFilter.java index e9db4e99b..614027603 100644 --- a/src/main/java/net/discordjug/javabot/listener/filter/BlacklistedMessageAttachmentFilter.java +++ b/src/main/java/net/discordjug/javabot/listener/filter/BlacklistedMessageAttachmentFilter.java @@ -38,4 +38,9 @@ public MessageModificationStatus processMessage(MessageContent content) { 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/MessageFilter.java b/src/main/java/net/discordjug/javabot/listener/filter/MessageFilter.java index 2a7141b21..b48b75507 100644 --- a/src/main/java/net/discordjug/javabot/listener/filter/MessageFilter.java +++ b/src/main/java/net/discordjug/javabot/listener/filter/MessageFilter.java @@ -1,12 +1,14 @@ 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 { +public interface MessageFilter extends Ordered { /** * When a message is received, it is processed by the registered filters. @@ -20,4 +22,15 @@ public interface MessageFilter { */ 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 index 0700b21d4..528ab7fd6 100644 --- a/src/main/java/net/discordjug/javabot/listener/filter/MessageFilterHandler.java +++ b/src/main/java/net/discordjug/javabot/listener/filter/MessageFilterHandler.java @@ -1,7 +1,6 @@ package net.discordjug.javabot.listener.filter; 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; @@ -14,6 +13,7 @@ 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. @@ -29,7 +29,7 @@ public class MessageFilterHandler extends ListenerAdapter { private final List filters; private final AutoMod autoMod; - private final BotConfig botConfig; + private final Executor asyncPool; @Override public void onMessageReceived(@NotNull MessageReceivedEvent event) { @@ -44,36 +44,39 @@ public void onMessageReceived(@NotNull MessageReceivedEvent event) { new ArrayList<>(event.getMessage().getEmbeds()) ); - 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; - } - } + asyncPool.execute(() -> { + boolean handled = false; - 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; + for (MessageFilter filter : filters) { + MessageModificationStatus status = filter.processMessage(content); + if (status == MessageModificationStatus.MODIFIED) { + handled = true; + } else if (status == MessageModificationStatus.STOP_PROCESSING) { + return; + } } - if (webhookContainer == null) { - 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); } - replaceMessage(webhookContainer, threadId, content); - } + }); } private boolean shouldRunFilters(@NotNull MessageReceivedEvent event) { @@ -90,11 +93,6 @@ private boolean shouldRunFilters(@NotNull MessageReceivedEvent event) { autoMod.hasAdvertisingLink(event.getMessage())) { return false; } - if (event.getChannel().getIdLong() == botConfig.get(event.getGuild()) - .getModerationConfig() - .getSuggestionChannelId()) { - return false; - } return true; } diff --git a/src/main/java/net/discordjug/javabot/listener/filter/MessageRuleFilter.java b/src/main/java/net/discordjug/javabot/listener/filter/MessageRuleFilter.java index f7c70b9bf..beda91832 100644 --- a/src/main/java/net/discordjug/javabot/listener/filter/MessageRuleFilter.java +++ b/src/main/java/net/discordjug/javabot/listener/filter/MessageRuleFilter.java @@ -9,16 +9,13 @@ 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.regex.Pattern; import java.util.stream.Collectors; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; import lombok.RequiredArgsConstructor; import net.discordjug.javabot.data.config.BotConfig; -import net.discordjug.javabot.data.config.PatternTypeAdapter; 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; @@ -26,8 +23,11 @@ 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; @@ -73,17 +73,12 @@ public MessageModificationStatus processMessage(MessageContent content) { } private void log(MessageContent content, MessageRule ruleToExecute, ModerationConfig moderationConfig) { - Gson gson = new GsonBuilder() - .serializeNulls() - .setPrettyPrinting() - .registerTypeAdapter(Pattern.class, new PatternTypeAdapter()) - .create(); 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" + gson.toJson(ruleToExecute) + "\n```", false); + .addField("Rule description", "```\n" + GsonUtils.toJson(ruleToExecute) + "\n```", false); if (!content.attachments().isEmpty()) { embed.addField("Attachment hashes", computeAttachmentDescription(content.attachments()), false); } @@ -111,7 +106,28 @@ private boolean matches(MessageContent content, MessageRule rule) { } } } - return matchesSHA; + 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) { @@ -131,4 +147,9 @@ private String computeSHA(Attachment attachment) { 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/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 8cdab6554..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,9 +1,11 @@ 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; @@ -57,10 +59,16 @@ 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") .addComponents(Label.of("new value", TextInput.create("value", TextInputStyle.PARAGRAPH) - .setValue(String.valueOf(resolved)) + .setValue(value) .build())) .build()); } @@ -78,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/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/HelpListener.java b/src/main/java/net/discordjug/javabot/systems/help/HelpListener.java index 889d820a4..efcd8b87e 100644 --- a/src/main/java/net/discordjug/javabot/systems/help/HelpListener.java +++ b/src/main/java/net/discordjug/javabot/systems/help/HelpListener.java @@ -30,6 +30,7 @@ import xyz.dynxsty.dih4jda.util.ComponentIdBuilder; import java.util.*; +import java.util.stream.Collectors; import java.util.stream.Stream; /** @@ -79,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) { @@ -121,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()); } @@ -291,17 +289,34 @@ private void handleThanksCloseButton(@NotNull ButtonInteractionEvent event, Help event.getMessage().delete().queue(s -> { experienceService.addMessageBasedHelpXP(post, true); // thank all helpers - getButtonStream(event.getMessage()) - .filter(b -> b.getCustomId() != null) - .filter(b -> b.isDisabled() || (b.getCustomId().equals(additionalButtonId))) - .forEach(b -> manager.thankHelper( - event.getGuild(), - post, - Long.parseLong(ComponentIdBuilder.split(b.getCustomId())[2]) - )); + + Set helpersToThank = getButtonStream(event.getMessage()) + .filter(b -> b.getCustomId() != null) + .filter(b -> b.isDisabled() || (b.getCustomId().equals(additionalButtonId))) + .map(b -> Long.parseLong(ComponentIdBuilder.split(b.getCustomId())[2])) + .collect(Collectors.toSet()); + + for (Long helperId : helpersToThank) { + manager.thankHelper(event.getGuild(), post, helperId); + } + markAnswer(post, helpersToThank); + HELP_POST_MESSAGES.remove(post.getIdLong()); }); } + private void markAnswer(ThreadChannel post, Set helpers) { + List messages = HELP_POST_MESSAGES.get(post.getIdLong()); + if (messages == null) { + return; + } + for (Message msg : messages.reversed()) { + if (helpers.contains(msg.getAuthor().getIdLong())) { + answerOverflowService.markAnswer(post.getIdLong(), msg.getIdLong()); + return; + } + } + } + private void handleReplyGuidelines(@NotNull IReplyCallback callback, @NotNull ForumChannel channel) { callback.replyEmbeds(new EmbedBuilder().setTitle("Help Guidelines") .setDescription(channel.getTopic()) diff --git a/src/main/java/net/discordjug/javabot/systems/moderation/AutoMod.java b/src/main/java/net/discordjug/javabot/systems/moderation/AutoMod.java index f04ab922d..7dc3232fe 100644 --- a/src/main/java/net/discordjug/javabot/systems/moderation/AutoMod.java +++ b/src/main/java/net/discordjug/javabot/systems/moderation/AutoMod.java @@ -3,6 +3,7 @@ import lombok.extern.slf4j.Slf4j; import net.discordjug.javabot.data.config.BotConfig; import net.discordjug.javabot.data.h2db.message_cache.MessageCache; +import net.discordjug.javabot.data.h2db.message_cache.model.CachedMessage; import net.discordjug.javabot.systems.moderation.warn.model.WarnSeverity; import net.discordjug.javabot.systems.notification.NotificationService; import net.discordjug.javabot.util.ExceptionLogger; @@ -24,9 +25,7 @@ import java.net.URL; import java.time.Duration; import java.time.temporal.ChronoUnit; -import java.util.Collections; -import java.util.List; -import java.util.Scanner; +import java.util.*; import java.util.concurrent.TimeUnit; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -50,18 +49,19 @@ public class AutoMod extends ListenerAdapter { private final MessageCache messageCache; /** - * Constructor of the class, that creates a list of strings with potential spam/scam urls. + * Constructor of the class, that creates a list of strings with potential spam/scam URLs. + * * @param notificationService The {@link QOTWPointsService} - * @param botConfig The main configuration of the bot - * @param moderationService Service object for moderating members - * @param messageCache service for retrieving cached messages + * @param botConfig The main configuration of the bot + * @param moderationService Service object for moderating members + * @param messageCache service for retrieving cached messages */ public AutoMod(NotificationService notificationService, BotConfig botConfig, ModerationService moderationService, MessageCache messageCache) { this.notificationService = notificationService; this.botConfig = botConfig; this.moderationService = moderationService; this.messageCache = messageCache; - try(Scanner scan = new Scanner(new URL("https://raw.githubusercontent.com/DevSpen/scam-links/master/src/links.txt").openStream()).useDelimiter("\\A")) { + try (Scanner scan = new Scanner(new URL("https://raw.githubusercontent.com/DevSpen/scam-links/master/src/links.txt").openStream()).useDelimiter("\\A")) { String response = scan.next(); spamUrls = List.of(response.split("\n")); } catch (IOException e) { @@ -105,21 +105,46 @@ private boolean canBypassAutomod(Member member) { private void checkNewMessageAutomod(@Nonnull Message message) { // spam long spamCount = messageCache.getMessagesAfter(message.getTimeCreated().minusSeconds(6)) - .stream() - .filter(cached -> cached.getMessageId() != message.getIdLong()) // exclude new/current message - .filter(cached -> cached.getAuthorId() == message.getAuthor().getIdLong()) - .filter(cached -> - // only java files -> not spam - cached.getAttachments().isEmpty() || - cached.getAttachments().stream() - .anyMatch(attachment -> !attachment.contains(".java?"))) - .count() + 1; // include new message - + .stream() + .filter(cached -> cached.getMessageId() != message.getIdLong()) // exclude new/current message + .filter(cached -> cached.getAuthorId() == message.getAuthor().getIdLong()) + .filter(cached -> + // only java files -> not spam + cached.getAttachments().isEmpty() || + cached.getAttachments().stream() + .anyMatch(attachment -> !attachment.contains(".java?"))) + .count() + 1; // include new message + if (spamCount >= 5) { - handleSpam(message, message.getMember()); + handleSpam(message); } - checkContentAutomod(message); + checkCrossChannelSpam(message); + } + + private void checkCrossChannelSpam(@Nonnull Message message) { + int spamWindowSeconds = (botConfig.get(message.getGuild()).getModerationConfig()).getCrossChannelSpamWindowSeconds(); + Set channelIds = new HashSet<>(); + List spamMessages = new ArrayList<>(); + + if (spamWindowSeconds <= 0) { + return; + } + + for (CachedMessage cachedMessage : messageCache.getMessagesAfter(message.getTimeCreated().minusSeconds(spamWindowSeconds))) { + if (cachedMessage.getMessageId() == message.getIdLong()) { + continue; + } + if (cachedMessage.getAuthorId() != message.getAuthor().getIdLong()) { + continue; + } + channelIds.add(cachedMessage.getChannelId()); + spamMessages.add(cachedMessage); + } + + if (channelIds.size() >= (botConfig.get(message.getGuild()).getModerationConfig()).getCrossChannelSpamMinChannels()) { + handleSpam(spamMessages, message); + } } /** @@ -130,7 +155,7 @@ private void checkNewMessageAutomod(@Nonnull Message message) { private void checkContentAutomod(@Nonnull Message message) { //Check for Advertising Links if (hasAdvertisingLink(message)) { - doAutomodActions(message,"Advertising"); + doAutomodActions(message, "Advertising"); } //Check for suspicious Links @@ -158,20 +183,29 @@ private void doAutomodActions(Message message, String reason) { /** * Handles detected spam messages. * - * @param msg the (last) spam message - * @param member the member to be potentially warned + * @param msg the (last) spam message */ - private void handleSpam(@Nonnull Message msg, Member member) { + private void handleSpam(@Nonnull Message msg) { + timeoutForSpam(msg); + msg.delete().queue(); + } + + private void handleSpam(@Nonnull List cachedMessages, Message message) { + timeoutForSpam(message); + cachedMessages.forEach(cachedMessage -> message.getGuild().getTextChannelById(cachedMessage.getChannelId()) + .deleteMessageById(cachedMessage.getMessageId()).queue()); + } + + private void timeoutForSpam(@Nonnull Message message) { moderationService .timeout( - member.getUser(), + message.getAuthor(), "Automod: Spam", - msg.getGuild().getSelfMember(), + message.getGuild().getSelfMember(), Duration.of(6, ChronoUnit.HOURS), - msg.getChannel(), + message.getChannel(), false ); - msg.delete().queue(); } /** @@ -237,5 +271,5 @@ private boolean isSuggestionsChannel(@NotNull MessageChannelUnion channel) { return channel.getType().isGuild() && channel.getIdLong() == botConfig.get(channel.asGuildMessageChannel().getGuild()).getModerationConfig().getSuggestionChannel().getIdLong(); } - + } \ No newline at end of file diff --git a/src/main/java/net/discordjug/javabot/systems/moderation/ModerationService.java b/src/main/java/net/discordjug/javabot/systems/moderation/ModerationService.java index f2cf8f5e4..4ff99b0a0 100644 --- a/src/main/java/net/discordjug/javabot/systems/moderation/ModerationService.java +++ b/src/main/java/net/discordjug/javabot/systems/moderation/ModerationService.java @@ -10,6 +10,7 @@ import net.discordjug.javabot.util.Responses; import net.discordjug.javabot.util.UserUtils; import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.entities.*; import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; import net.dv8tion.jda.api.utils.MarkdownUtil; @@ -20,16 +21,20 @@ import lombok.RequiredArgsConstructor; +import javax.annotation.CheckReturnValue; import javax.annotation.Nonnull; import java.time.Duration; import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneOffset; +import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Optional; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; /** * This service provides methods for performing moderation actions, like banning @@ -281,23 +286,44 @@ private void banAndSendGuildNotifications(User user, String reason, Member banne * @param quiet If true, don't send a message in the channel. * @return Whether the member is banned or not. */ - public boolean unban(long userId, String reason, Member bannedBy, MessageChannel channel, boolean quiet) { + @CheckReturnValue + public CompletableFuture unban(long userId, String reason, Member bannedBy, MessageChannel channel, boolean quiet) { MessageEmbed unbanEmbed = this.buildUnbanEmbed(userId, reason, bannedBy); - boolean isBanned = isBanned(bannedBy.getGuild(), userId); ModerationConfig moderationConfig = getModerationConfig(bannedBy); - if (isBanned) { - bannedBy.getGuild().unban(User.fromId(userId)).queue(s -> { - moderationConfig.getLogChannel().sendMessageEmbeds(unbanEmbed).queue(); - if (!quiet) channel.sendMessageEmbeds(unbanEmbed).queue(); - }, ExceptionLogger::capture); - } - return isBanned; + return bannedBy.getGuild().unban(User.fromId(userId)).reason(reason).map(_ -> { + moderationConfig.getLogChannel().sendMessageEmbeds(unbanEmbed).queue(unbanLogMessage -> notifyUserAboutUnban(userId, unbanEmbed, moderationConfig, unbanLogMessage)); + if (!quiet) { + channel.sendMessageEmbeds(unbanEmbed).queue(); + } + return null; + }).submit(); } - private boolean isBanned(@NotNull Guild guild, long userId) { - return guild.retrieveBanList().complete() - .stream().map(Guild.Ban::getUser) - .map(User::getIdLong).toList().contains(userId); + private void notifyUserAboutUnban(long userId, MessageEmbed unbanEmbed, ModerationConfig moderationConfig, Message unbanLogMessage) { + JDA jda = moderationConfig.getGuild().getJDA(); + jda.retrieveUserById(userId) + .flatMap(User::openPrivateChannel) + .flatMap(c -> c.getHistory().retrievePast(1)) + .queue(history -> { + if (history.isEmpty()) { + return; + } + Message banMessage = history.getFirst(); + if (banMessage.getAuthor().getIdLong() != jda.getSelfUser().getIdLong()) { + return; + } + ArrayList embeds = new ArrayList<>(banMessage.getEmbeds()); + embeds.add(new EmbedBuilder(unbanEmbed).setColor(Responses.Type.SUCCESS.getColor()).build()); + banMessage.editMessageEmbeds(embeds).setContent(moderationConfig.getUnbanMessageText()).queue(success -> { + List unbanLogEmbeds = unbanLogMessage.getEmbeds(); + if (unbanLogEmbeds.isEmpty()) { + return; + } + unbanLogMessage.editMessageEmbeds(new EmbedBuilder(unbanLogEmbeds.getLast()) + .addField("User informed", "The ban info in the user's DMs has been updated.", true).build()) + .queue(); + }); + }); } /** @@ -311,11 +337,14 @@ private boolean isBanned(@NotNull Guild guild, long userId) { */ public void kick(User user, String reason, Member kickedBy, MessageChannel channel, boolean quiet) { MessageEmbed kickEmbed = buildKickEmbed(user, kickedBy, reason); - kickedBy.getGuild().kick(user).queue(s -> { - notificationService.withUser(user).sendDirectMessage(c -> c.sendMessageEmbeds(kickEmbed)); - notificationService.withGuild(kickedBy.getGuild()).sendToModerationLog(c -> c.sendMessageEmbeds(kickEmbed)); - if (!quiet) channel.sendMessageEmbeds(kickEmbed).queue(); - }, ExceptionLogger::capture); + user.openPrivateChannel() + .flatMap(c -> c.sendMessageEmbeds(kickEmbed).setContent(getModerationConfig(kickedBy).getKickMessageText())) + .mapToResult() // errors in sending DMs should still result in the kicking logic being executed + .queue(_ -> { + kickedBy.getGuild().kick(user).queue(); + notificationService.withGuild(kickedBy.getGuild()).sendToModerationLog(c -> c.sendMessageEmbeds(kickEmbed)); + if (!quiet) channel.sendMessageEmbeds(kickEmbed).queue(); + }); } public void sendKickGuildNotification(User user, String reason, Member moderator) { @@ -326,8 +355,19 @@ public void sendBanGuildNotification(User user, String reason, Member moderator) sendGuildNotification(moderator.getGuild(), buildBanEmbed(user, moderator, reason)); } + /** + * Sends an unban notification to the guild log. + * + * This will also try to update the ban notification of the user to say they are unbanned. + * @param user The unbanned user + * @param reason The reason they were unbanned + * @param moderator The moderator unbanning them (That {@link Member}'s {@link Guild} is used to determine the guild log to send the notification to. + */ public void sendUnbanGuildNotification(User user, String reason, Member moderator) { - sendGuildNotification(moderator.getGuild(), buildUnbanEmbed(user.getIdLong(), reason, moderator)); + MessageEmbed unbanEmbed = buildUnbanEmbed(user.getIdLong(), reason, moderator); + sendGuildNotification(moderator.getGuild(), unbanEmbed, msg -> { + notifyUserAboutUnban(user.getIdLong(), unbanEmbed, botConfig.get(moderator.getGuild()).getModerationConfig(), msg); + }); } public void sendTimeoutGuildNotification(User user, String reason, Member moderator, Duration duration) { @@ -339,12 +379,19 @@ public void sendRemoveTimeoutGuildNotification(User user, String reason, Member } private void sendGuildNotification(Guild guild, MessageEmbed embed) { + sendGuildNotification(guild, embed, _ -> {}); + } + + private void sendGuildNotification(Guild guild, MessageEmbed embed, Consumer onComplete) { MessageEmbed newEmbed = new EmbedBuilder(embed) .addField("Source", "This action was executed manually without a bot command.", false) .build(); notificationService .withGuild(guild) - .sendToModerationLog(c -> c.sendMessageEmbeds(newEmbed)); + .sendToModerationLog(c -> c.sendMessageEmbeds(newEmbed).map(success -> { + onComplete.accept(success); + return success; + })); } private @NotNull EmbedBuilder buildModerationEmbed(@NotNull User user, @NotNull Member moderator, String reason) { diff --git a/src/main/java/net/discordjug/javabot/systems/moderation/UnbanCommand.java b/src/main/java/net/discordjug/javabot/systems/moderation/UnbanCommand.java index 4d71b4211..a8cc725c5 100644 --- a/src/main/java/net/discordjug/javabot/systems/moderation/UnbanCommand.java +++ b/src/main/java/net/discordjug/javabot/systems/moderation/UnbanCommand.java @@ -43,10 +43,13 @@ protected ReplyCallbackAction handleModerationCommand(@NotNull SlashCommandInter } long id = idOption.getAsLong(); boolean quiet = ModerateUserCommand.isQuiet(botConfig, event); - if (moderationService.unban(id, reasonOption.getAsString(), event.getMember(), event.getChannel(), quiet)) { - return Responses.success(event, "User Unbanned", "User with id `%s` has been unbanned.", id); - } else { - return Responses.warning(event, "Could not find banned User with id `%s`", id); - } + moderationService.unban(id, reasonOption.getAsString(), event.getMember(), event.getChannel(), quiet) + .thenAccept(success -> { + Responses.success(event.getHook(), "User Unbanned", "User with id `%s` has been unbanned.", id).queue(); + }).exceptionally(failed -> { + Responses.warning(event.getHook(), "Could not find banned User with id `%s` or a different error occured: `%s`", id, failed.getMessage()).queue(); + return null; + }); + return event.deferReply(); } } \ No newline at end of file diff --git a/src/main/java/net/discordjug/javabot/systems/moderation/report/ReportManager.java b/src/main/java/net/discordjug/javabot/systems/moderation/report/ReportManager.java index 7542c6974..2d984e36d 100644 --- a/src/main/java/net/discordjug/javabot/systems/moderation/report/ReportManager.java +++ b/src/main/java/net/discordjug/javabot/systems/moderation/report/ReportManager.java @@ -214,7 +214,7 @@ WebhookMessageCreateAction handleUserReport(InteractionHook hook, @NotN } private void sendReportResponse(InteractionHook hook, User targetUser, EmbedBuilder reportEmbed, ThreadChannel reportThread) { - reportEmbed.setDescription("Successfully reported " + "`" + UserUtils.getUserTag(targetUser) + "`!\nYour report has been send to our Moderators.\nIn case you want to supply additional details, please use the \"Create thread\" button below."); + reportEmbed.setDescription("Successfully reported " + "`" + UserUtils.getUserTag(targetUser) + "`!\nYour report has been sent to our Moderators.\nIn case you want to supply additional details, please use the \"Create thread\" button below."); hook.sendMessageEmbeds(reportEmbed.build()) .addComponents(ActionRow.of( Button.secondary( diff --git a/src/main/java/net/discordjug/javabot/systems/notification/GuildNotificationService.java b/src/main/java/net/discordjug/javabot/systems/notification/GuildNotificationService.java index 65eb1ce17..b626036ee 100644 --- a/src/main/java/net/discordjug/javabot/systems/notification/GuildNotificationService.java +++ b/src/main/java/net/discordjug/javabot/systems/notification/GuildNotificationService.java @@ -4,7 +4,9 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import net.discordjug.javabot.data.config.GuildConfig; +import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; +import net.dv8tion.jda.api.requests.RestAction; import net.dv8tion.jda.api.requests.restaction.MessageCreateAction; import org.jetbrains.annotations.NotNull; @@ -22,9 +24,9 @@ public final class GuildNotificationService extends NotificationService.MessageC /** * Sends a notification to the log channel. * - * @param function The {@link Function} to use which MUST return a {@link MessageCreateAction}. + * @param function A {@link Function} sending the message. */ - public void sendToModerationLog(@NotNull Function function) { + public void sendToModerationLog(@NotNull Function> function) { MessageChannel channel = guildConfig.getModerationConfig().getLogChannel(); if (channel == null) { log.error("Could not send message to LogChannel in guild " + guildConfig.getGuild().getId()); diff --git a/src/main/java/net/discordjug/javabot/systems/notification/NotificationService.java b/src/main/java/net/discordjug/javabot/systems/notification/NotificationService.java index 892dec9b5..408c2c788 100644 --- a/src/main/java/net/discordjug/javabot/systems/notification/NotificationService.java +++ b/src/main/java/net/discordjug/javabot/systems/notification/NotificationService.java @@ -5,9 +5,10 @@ import net.discordjug.javabot.data.config.BotConfig; import net.discordjug.javabot.systems.qotw.QOTWPointsService; import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.User; import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; -import net.dv8tion.jda.api.requests.restaction.MessageCreateAction; +import net.dv8tion.jda.api.requests.RestAction; import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.NotNull; import org.springframework.stereotype.Service; @@ -58,7 +59,7 @@ abstract static class MessageChannelNotification { * @param channel The target {@link MessageChannel}. * @param function The {@link Function} which is used in order to send the message. */ - protected void send(MessageChannel channel, @NotNull Function function) { + protected void send(MessageChannel channel, @NotNull Function> function) { function.apply(channel).queue(s -> {}, err -> log.error("Could not send message to channel \" " + channel.getName() + "\": ", err) ); diff --git a/src/main/java/net/discordjug/javabot/systems/qotw/dao/QOTWChampionRepository.java b/src/main/java/net/discordjug/javabot/systems/qotw/dao/QOTWChampionRepository.java index 4a9361787..7a9555274 100644 --- a/src/main/java/net/discordjug/javabot/systems/qotw/dao/QOTWChampionRepository.java +++ b/src/main/java/net/discordjug/javabot/systems/qotw/dao/QOTWChampionRepository.java @@ -35,7 +35,7 @@ public List getCurrentQOTWChampions(long guildId) { * @param users the QOTW champions */ public void setCurrentQOTWChampions(long guild, long[] users) { - jdbcTemplate.update("DELETE FROM qotw_champion WHERE guild_id = ?"); + jdbcTemplate.update("DELETE FROM qotw_champion WHERE guild_id = ?", guild); List params = new ArrayList<>(); for (long userId : users) { params.add(new Object[] {guild, userId}); diff --git a/src/main/java/net/discordjug/javabot/systems/qotw/jobs/QOTWChampionJob.java b/src/main/java/net/discordjug/javabot/systems/qotw/jobs/QOTWChampionJob.java index 0bd60757a..0d599a1e6 100644 --- a/src/main/java/net/discordjug/javabot/systems/qotw/jobs/QOTWChampionJob.java +++ b/src/main/java/net/discordjug/javabot/systems/qotw/jobs/QOTWChampionJob.java @@ -56,6 +56,7 @@ public void execute() { for (Member member : membersToAdd) { guild.addRoleToMember(member, qotwChampionRole).queue(); } + qotwChampionRepository.setCurrentQOTWChampions(guild.getIdLong(), membersToAdd.stream().mapToLong(Member::getIdLong).toArray()); }); }); }); diff --git a/src/main/java/net/discordjug/javabot/systems/qotw/jobs/QOTWCloseSubmissionsJob.java b/src/main/java/net/discordjug/javabot/systems/qotw/jobs/QOTWCloseSubmissionsJob.java index 0f3834ba8..e00b76e88 100644 --- a/src/main/java/net/discordjug/javabot/systems/qotw/jobs/QOTWCloseSubmissionsJob.java +++ b/src/main/java/net/discordjug/javabot/systems/qotw/jobs/QOTWCloseSubmissionsJob.java @@ -5,6 +5,7 @@ import net.discordjug.javabot.data.config.GuildConfig; import net.discordjug.javabot.data.config.SystemsConfig; import net.discordjug.javabot.data.config.guild.QOTWConfig; +import net.discordjug.javabot.systems.help.AnswerOverflowService; import net.discordjug.javabot.systems.notification.NotificationService; import net.discordjug.javabot.systems.qotw.QOTWPointsService; import net.discordjug.javabot.systems.qotw.dao.QuestionQueueRepository; @@ -63,6 +64,7 @@ public class QOTWCloseSubmissionsJob { private final ExecutorService asyncPool; private final QOTWPointsService pointsService; private final NotificationService notificationService; + private final AnswerOverflowService answerOverflowService; private final BotConfig botConfig; /** @@ -82,6 +84,10 @@ public void execute() throws SQLException { Permission.CREATE_PRIVATE_THREADS, Permission.CREATE_PUBLIC_THREADS, Permission.MESSAGE_ADD_REACTION)) + .putPermissionOverride( + qotwConfig.getQOTWReviewRole(), + Set.of(Permission.MESSAGE_SEND_IN_THREADS, Permission.MANAGE_THREADS, Permission.USE_APPLICATION_COMMANDS), + Set.of()) .queue(); TextChannel logChannel = config.getModerationConfig().getLogChannel(); if (logChannel == null) continue; @@ -127,7 +133,7 @@ private void sendReviewInformation(Guild guild, QOTWConfig qotwConfig, TextChann s.retrieveAuthor(author -> { submission.removeThreadMember(author).queue(); if (author.getIdLong() == qotwConfig.getQotwSampleAnswerUserId()) { - SubmissionManager manager = new SubmissionManager(botConfig.get(guild).getQotwConfig(), pointsService, questionQueueRepository, notificationService, asyncPool); + SubmissionManager manager = new SubmissionManager(botConfig.get(guild).getQotwConfig(), pointsService, questionQueueRepository, notificationService, asyncPool, answerOverflowService); manager.copySampleAnswerSubmission(submission, author); } else { thread diff --git a/src/main/java/net/discordjug/javabot/systems/qotw/jobs/QOTWJob.java b/src/main/java/net/discordjug/javabot/systems/qotw/jobs/QOTWJob.java index 58782a92f..9cac6be47 100644 --- a/src/main/java/net/discordjug/javabot/systems/qotw/jobs/QOTWJob.java +++ b/src/main/java/net/discordjug/javabot/systems/qotw/jobs/QOTWJob.java @@ -63,6 +63,7 @@ public void execute() throws SQLException { Permission.CREATE_PRIVATE_THREADS, Permission.CREATE_PUBLIC_THREADS, Permission.MESSAGE_ADD_REACTION)) + .removePermissionOverride(qotw.getQOTWReviewRole()) .queue(); if (question.getQuestionNumber() == null) { question.setQuestionNumber(questionQueueRepository.getNextQuestionNumber()); diff --git a/src/main/java/net/discordjug/javabot/systems/qotw/jobs/QOTWUserReminderJob.java b/src/main/java/net/discordjug/javabot/systems/qotw/jobs/QOTWUserReminderJob.java index 61bf0227a..d5158f5d1 100644 --- a/src/main/java/net/discordjug/javabot/systems/qotw/jobs/QOTWUserReminderJob.java +++ b/src/main/java/net/discordjug/javabot/systems/qotw/jobs/QOTWUserReminderJob.java @@ -2,6 +2,7 @@ import net.discordjug.javabot.data.config.BotConfig; import net.discordjug.javabot.data.config.guild.QOTWConfig; +import net.discordjug.javabot.systems.help.AnswerOverflowService; import net.discordjug.javabot.systems.notification.NotificationService; import net.discordjug.javabot.systems.qotw.QOTWPointsService; import net.discordjug.javabot.systems.qotw.dao.QuestionQueueRepository; @@ -33,6 +34,7 @@ public class QOTWUserReminderJob { private final QOTWPointsService pointsService; private final NotificationService notificationService; private final QuestionQueueRepository questionQueueRepository; + private final AnswerOverflowService answerOverflowService; private final ExecutorService asyncPool; /** @@ -42,7 +44,7 @@ public class QOTWUserReminderJob { public void execute() { for (Guild guild : jda.getGuilds()) { QOTWConfig config = botConfig.get(guild).getQotwConfig(); - List submissions = new SubmissionManager(config, pointsService, questionQueueRepository, notificationService, asyncPool).getActiveSubmissions(); + List submissions = new SubmissionManager(config, pointsService, questionQueueRepository, notificationService, asyncPool, answerOverflowService).getActiveSubmissions(); for (QOTWSubmission submission : submissions) { submission.retrieveAuthor(author -> { UserPreference preference = userPreferenceService.getOrCreate(author.getIdLong(), Preference.QOTW_REMINDER); diff --git a/src/main/java/net/discordjug/javabot/systems/qotw/submissions/SubmissionInteractionManager.java b/src/main/java/net/discordjug/javabot/systems/qotw/submissions/SubmissionInteractionManager.java index 661102bda..cb633b162 100644 --- a/src/main/java/net/discordjug/javabot/systems/qotw/submissions/SubmissionInteractionManager.java +++ b/src/main/java/net/discordjug/javabot/systems/qotw/submissions/SubmissionInteractionManager.java @@ -8,6 +8,7 @@ import lombok.RequiredArgsConstructor; import net.discordjug.javabot.annotations.AutoDetectableComponentHandler; import net.discordjug.javabot.data.config.BotConfig; +import net.discordjug.javabot.systems.help.AnswerOverflowService; import net.discordjug.javabot.systems.notification.NotificationService; import net.discordjug.javabot.systems.qotw.QOTWPointsService; import net.discordjug.javabot.systems.qotw.dao.QuestionQueueRepository; @@ -29,11 +30,12 @@ public class SubmissionInteractionManager implements ButtonHandler, StringSelect private final NotificationService notificationService; private final BotConfig botConfig; private final QuestionQueueRepository questionQueueRepository; + private final AnswerOverflowService answerOverflowService; private final ExecutorService asyncPool; @Override public void handleButton(@NotNull ButtonInteractionEvent event, Button button) { - SubmissionManager manager = new SubmissionManager(botConfig.get(event.getGuild()).getQotwConfig(), pointsService, questionQueueRepository, notificationService, asyncPool); + SubmissionManager manager = new SubmissionManager(botConfig.get(event.getGuild()).getQotwConfig(), pointsService, questionQueueRepository, notificationService, asyncPool, answerOverflowService); String[] id = ComponentIdBuilder.split(event.getComponentId()); switch (id[1]) { case "submit" -> manager.handleSubmission(event, Integer.parseInt(id[2])).queue(); @@ -43,7 +45,7 @@ public void handleButton(@NotNull ButtonInteractionEvent event, Button button) { @Override public void handleStringSelectMenu(@NotNull StringSelectInteractionEvent event, @NotNull List values) { - SubmissionManager manager = new SubmissionManager(botConfig.get(event.getGuild()).getQotwConfig(), pointsService, questionQueueRepository, notificationService, asyncPool); + SubmissionManager manager = new SubmissionManager(botConfig.get(event.getGuild()).getQotwConfig(), pointsService, questionQueueRepository, notificationService, asyncPool, answerOverflowService); String[] id = ComponentIdBuilder.split(event.getComponentId()); switch (id[1]) { case "review" -> manager.handleSelectReview(event, id[2]); diff --git a/src/main/java/net/discordjug/javabot/systems/qotw/submissions/SubmissionManager.java b/src/main/java/net/discordjug/javabot/systems/qotw/submissions/SubmissionManager.java index 7c48fb749..1d29dd97f 100644 --- a/src/main/java/net/discordjug/javabot/systems/qotw/submissions/SubmissionManager.java +++ b/src/main/java/net/discordjug/javabot/systems/qotw/submissions/SubmissionManager.java @@ -4,6 +4,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import net.discordjug.javabot.data.config.guild.QOTWConfig; +import net.discordjug.javabot.systems.help.AnswerOverflowService; import net.discordjug.javabot.systems.notification.NotificationService; import net.discordjug.javabot.systems.qotw.QOTWPointsService; import net.discordjug.javabot.systems.qotw.dao.QuestionQueueRepository; @@ -39,6 +40,8 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; +import club.minnced.discord.webhook.receive.ReadonlyMessage; + /** * Handles & manages QOTW Submissions by using Discords {@link ThreadChannel}s. */ @@ -57,6 +60,7 @@ public class SubmissionManager { private final QuestionQueueRepository questionQueueRepository; private final NotificationService notificationService; private final ExecutorService asyncPool; + private final AnswerOverflowService answerOverflowService; /** * Handles the "Submit your Answer" Button interaction. @@ -221,14 +225,19 @@ private void sendToQOTWAnswerArchive(ThreadChannel thread, User author, Accepted ThreadChannel newestPost = newestPostOptional.get(); WebhookUtil.ensureWebhookExists(newestPost.getParentChannel().asForumChannel(), wh -> getMessagesByUser(thread, author).thenAccept(messages -> { - for (Message message : messages) { - boolean lastMessage = messages.indexOf(message) + 1 == messages.size(); + for (int i = 0; i < messages.size(); i++) { + Message message = messages.get(i); + boolean lastMessage = i + 1 == messages.size(); if (message.getAuthor().isBot() || message.getType() != MessageType.DEFAULT) continue; + ReadonlyMessage firstPostedMessage; if (message.getContentRaw().length() > 2000) { - WebhookUtil.mirrorMessageToWebhook(wh, message, message.getContentRaw().substring(0, 2000), newestPost.getIdLong(), null, null).join(); + firstPostedMessage = WebhookUtil.mirrorMessageToWebhook(wh, message, message.getContentRaw().substring(0, 2000), newestPost.getIdLong(), null, null).join(); WebhookUtil.mirrorMessageToWebhook(wh, message, message.getContentRaw().substring(2000), newestPost.getIdLong(), null, lastMessage ? List.of(buildAuthorEmbed(author, type)) : null).join(); } else { - WebhookUtil.mirrorMessageToWebhook(wh, message, message.getContentRaw(), newestPost.getIdLong(), null, lastMessage ? List.of(buildAuthorEmbed(author, type)) : null).join(); + firstPostedMessage = WebhookUtil.mirrorMessageToWebhook(wh, message, message.getContentRaw(), newestPost.getIdLong(), null, lastMessage ? List.of(buildAuthorEmbed(author, type)) : null).join(); + } + if (i == 0 && (type == AcceptedAnswerType.BEST_ANSWER || type == AcceptedAnswerType.SAMPLE_ANSWER)) { + answerOverflowService.markAnswer(firstPostedMessage.getChannelId(), firstPostedMessage.getId()); } } }).exceptionally(err->{ diff --git a/src/main/java/net/discordjug/javabot/systems/qotw/submissions/subcommands/QOTWReviewSubcommand.java b/src/main/java/net/discordjug/javabot/systems/qotw/submissions/subcommands/QOTWReviewSubcommand.java index 6d356c76a..37a286024 100644 --- a/src/main/java/net/discordjug/javabot/systems/qotw/submissions/subcommands/QOTWReviewSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/qotw/submissions/subcommands/QOTWReviewSubcommand.java @@ -2,6 +2,7 @@ import net.discordjug.javabot.data.config.BotConfig; import net.discordjug.javabot.data.config.guild.QOTWConfig; +import net.discordjug.javabot.systems.help.AnswerOverflowService; import net.discordjug.javabot.systems.notification.NotificationService; import net.discordjug.javabot.systems.qotw.QOTWPointsService; import net.discordjug.javabot.systems.qotw.dao.QuestionQueueRepository; @@ -34,7 +35,7 @@ public class QOTWReviewSubcommand extends SlashCommand.Subcommand { private final QuestionQueueRepository questionQueueRepository; private final BotConfig botConfig; private final ExecutorService asyncPool; - + private final AnswerOverflowService answerOverflowService; /** * The constructor of this class, which sets the corresponding {@link SubcommandData}. @@ -43,13 +44,15 @@ public class QOTWReviewSubcommand extends SlashCommand.Subcommand { * @param questionQueueRepository The {@link QuestionQueueRepository}. * @param botConfig The main configuration of the bot * @param asyncPool The main thread pool for asynchronous operations + * @param answerOverflowService The {@link AnswerOverflowService} for interacting with the Answer Overflow bot. */ - public QOTWReviewSubcommand(QOTWPointsService pointsService, NotificationService notificationService, QuestionQueueRepository questionQueueRepository, BotConfig botConfig, ExecutorService asyncPool) { + public QOTWReviewSubcommand(QOTWPointsService pointsService, NotificationService notificationService, QuestionQueueRepository questionQueueRepository, BotConfig botConfig, ExecutorService asyncPool, AnswerOverflowService answerOverflowService) { this.pointsService = pointsService; this.notificationService = notificationService; this.questionQueueRepository = questionQueueRepository; this.botConfig = botConfig; this.asyncPool = asyncPool; + this.answerOverflowService = answerOverflowService; setCommandData(new SubcommandData("review", "Administrative command for reviewing QOTW-submissions") .addOptions( new OptionData(OptionType.CHANNEL, "submission", "A users' submission", true) @@ -80,7 +83,7 @@ public void execute(@NotNull SlashCommandInteractionEvent event) { event.deferReply().queue(); QOTWSubmission submission = new QOTWSubmission(submissionThread); submission.retrieveAuthor(author -> { - SubmissionManager manager = new SubmissionManager(qotwConfig, pointsService, questionQueueRepository, notificationService, asyncPool); + SubmissionManager manager = new SubmissionManager(qotwConfig, pointsService, questionQueueRepository, notificationService, asyncPool, answerOverflowService); if (state.contains("ACCEPT")) { manager.acceptSubmission(submissionThread, author, event.getMember(), state.equals("ACCEPT_BEST")); Responses.success(event.getHook(), "Submission Accepted", "Successfully accepted submission by " + author.getAsMention()).queue(); diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java new file mode 100644 index 000000000..b50e08c8e --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java @@ -0,0 +1,291 @@ +package net.discordjug.javabot.systems.staff_commands.forms; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Function; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.discordjug.javabot.annotations.AutoDetectableComponentHandler; +import net.discordjug.javabot.systems.staff_commands.forms.dao.FormsRepository; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormData; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormField; +import net.discordjug.javabot.util.ExceptionLogger; +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.actionrow.ActionRowChildComponent; +import net.dv8tion.jda.api.components.buttons.Button; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; +import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.modals.ModalMapping; +import net.dv8tion.jda.api.modals.Modal; +import xyz.dynxsty.dih4jda.interactions.components.ButtonHandler; +import xyz.dynxsty.dih4jda.interactions.components.ModalHandler; +import xyz.dynxsty.dih4jda.util.ComponentIdBuilder; + +/** + * Handle forms interactions, including buttons and submissions modals. + */ +@AutoDetectableComponentHandler(FormInteractionManager.FORM_COMPONENT_ID) +@RequiredArgsConstructor +@Slf4j +public class FormInteractionManager implements ButtonHandler, ModalHandler { + + /** + * String representation of the date and time format used in forms. + */ + public static final String DATE_FORMAT_STRING = "dd.MM.yyyy HH:mm"; + + /** + * Date and time formatter used in forms. + */ + public static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern(DATE_FORMAT_STRING); + + /** + * Component ID used for form buttons and modals. + */ + public static final String FORM_COMPONENT_ID = "modal-form"; + + private static final String SUBMISSION_ERROR_LOG = "A user tried to submit a form \"%s\", but an error occured."; + + private static final String SUBMISSION_ERROR_MSG = "We couldn't receive your submission due to an error. Please contact server staff."; + + private static final String FORM_NOT_FOUND_MSG = "This form was not found in the database. Please report this to the server staff."; + + private final FormsRepository formsRepo; + + /** + * Closes the form, preventing further submissions and disabling associated + * buttons from a message this form is attached to, if any. + * + * @param guild guild this form is located in. + * @param form form to close. + */ + public void closeForm(Guild guild, FormData form) { + formsRepo.closeForm(form); + + form.getAttachmentInfo().ifPresent(info -> { + long messageChannelId = info.messageChannelId(); + long messageId = info.messageId(); + MessageChannel formChannel = guild.getJDA().getChannelById(MessageChannel.class, messageChannelId); + formChannel.retrieveMessageById(messageId).queue(msg -> { + editFormMessageButtons(msg, btn -> { + String cptId = btn.getCustomId(); + String[] split = ComponentIdBuilder.split(cptId); + if (split[0].equals(FormInteractionManager.FORM_COMPONENT_ID) + && split[1].equals(Long.toString(form.id()))) { + return btn.asDisabled(); + } + return btn; + }); + }, ExceptionLogger::capture); + }); + } + + @Override + public void handleButton(ButtonInteractionEvent event, Button button) { + long formId = Long.parseLong(ComponentIdBuilder.split(button.getCustomId())[1]); + Optional formOpt = formsRepo.getForm(formId); + if (!formOpt.isPresent()) { + Responses.error(event, FORM_NOT_FOUND_MSG).queue(); + return; + } + FormData form = formOpt.get(); + if (!isOpen(form)) { + event.reply("This form is not accepting new submissions.").setEphemeral(true).queue(); + if (!form.closed()) { + closeForm(event.getGuild(), form); + } + return; + } + + if (form.onetime() && formsRepo.hasSubmitted(event.getUser(), form)) { + event.reply("You have already submitted this form").setEphemeral(true).queue(); + return; + } + + Modal modal = createSubmissionModal(form); + + event.replyModal(modal).queue(); + } + + @Override + public void handleModal(ModalInteractionEvent event, List values) { + event.deferReply().setEphemeral(true).queue(); + long formId = Long.parseLong(ComponentIdBuilder.split(event.getModalId())[1]); + Optional formOpt = formsRepo.getForm(formId); + if (!formOpt.isPresent()) { + event.reply(FORM_NOT_FOUND_MSG).setEphemeral(true).queue(); + return; + } + + FormData form = formOpt.get(); + + if (!isOpen(form)) { + event.getHook().sendMessage("This form is not accepting new submissions.").queue(); + return; + } + + if (form.onetime() && formsRepo.hasSubmitted(event.getUser(), form)) { + event.getHook().sendMessage("You have already submitted this form").queue(); + return; + } + + TextChannel channel = event.getGuild().getTextChannelById(form.submitChannel()); + if (channel == null) { + log.warn("A user tried to submit a form \"%s\" because the submission channel does not exist." + .formatted(form.title())); + event.getHook().sendMessage(SUBMISSION_ERROR_MSG).queue(); + return; + } + + try { + channel.sendMessageEmbeds(createSubmissionEmbed(form, values, event.getMember())).queue(msg -> { + formsRepo.addSubmission(event.getUser(), form, msg); + event.getHook().sendMessage(form.getOptionalSubmitMessage().orElse("Your submission was received!")) + .queue(); + }, e -> { + event.getHook().sendMessage(SUBMISSION_ERROR_MSG).queue(); + log.error(SUBMISSION_ERROR_LOG.formatted(form.title())); + ExceptionLogger.capture(e); + }); + } catch (IllegalArgumentException e) { + event.getHook().sendMessage(SUBMISSION_ERROR_MSG).queue(); + log.error(SUBMISSION_ERROR_LOG.formatted(form.title())); + ExceptionLogger.capture(e); + } + } + + /** + * Modifies buttons in a message using given function for mapping. + * + * @param msg message to modify buttons in. + * @param editFunction function to edit the buttons. + */ + public void editFormMessageButtons(Message msg, Function editFunction) { + List components = msg.getComponents().stream().map(messageComponent -> { + ActionRow row = messageComponent.asActionRow(); + List cpts = row.getComponents().stream().map(cpt -> { + if (cpt instanceof Button btn) { + return editFunction.apply(btn); + } + return cpt; + }).toList(); + if (cpts.isEmpty()) { + return null; + } + return ActionRow.of(cpts); + }).filter(Objects::nonNull).toList(); + msg.editMessageComponents(components).queue(); + } + + /** + * Re-opens the form, re-enabling associated buttons in the message it's + * attached to, if any. + * + * @param guild guild this form is contained in. + * @param form form to re-open. + */ + public void reopenForm(Guild guild, FormData form) { + formsRepo.reopenForm(form); + + form.getAttachmentInfo().ifPresent(info -> { + long messageChannelId = info.messageChannelId(); + long messageId = info.messageId(); + TextChannel formChannel = guild.getTextChannelById(messageChannelId); + formChannel.retrieveMessageById(messageId).queue(msg -> { + editFormMessageButtons(msg, btn -> { + String cptId = btn.getCustomId(); + String[] split = ComponentIdBuilder.split(cptId); + if (split[0].equals(FormInteractionManager.FORM_COMPONENT_ID) + && split[1].equals(Long.toString(form.id()))) { + return btn.asEnabled(); + } + return btn; + }); + }, ExceptionLogger::capture); + }); + } + + /** + * Creates a submission modal for the given form. + * + * @param form form to open submission modal for. + * @return submission modal to be presented to the user. + */ + public static Modal createSubmissionModal(FormData form) { + Modal modal = Modal.create(ComponentIdBuilder.build(FORM_COMPONENT_ID, form.id()), form.title()) + .addComponents(form.createComponents()).build(); + return modal; + } + + /** + * Gets expiration time from the slash comamnd event. + * + * @param event slash event to get expiration from. + * @return an optional containing expiration time, or an empty optional if it's + * not present. + * @throws IllegalArgumentException if the date doesn't follow the format. + */ + public static Optional parseExpiration(SlashCommandInteractionEvent event) + throws IllegalArgumentException { + String expirationStr = event.getOption("expiration", null, OptionMapping::getAsString); + Optional expiration; + if (expirationStr == null) { + expiration = Optional.empty(); + } else { + try { + expiration = Optional.of(LocalDateTime.parse(expirationStr, DATE_FORMATTER).toInstant(ZoneOffset.UTC)); + } catch (DateTimeParseException e) { + throw new IllegalArgumentException("Invalid date. You should follow the format `" + + FormInteractionManager.DATE_FORMAT_STRING + "`."); + } + } + + if (expiration.isPresent() && expiration.get().isBefore(Instant.now())) { + throw new IllegalArgumentException("The expiration date shouldn't be in the past"); + } + return expiration; + } + + private static boolean isOpen(FormData data) { + if (data.closed() || data.hasExpired()) { + return false; + } + + return true; + } + + private static MessageEmbed createSubmissionEmbed(FormData form, List values, Member author) { + EmbedBuilder builder = new EmbedBuilder().setTitle("New form submission received") + .setAuthor(author.getEffectiveName(), null, author.getEffectiveAvatarUrl()).setTimestamp(Instant.now()); + builder.addField("Sender", String.format("%s (`%s`)", author.getAsMention(), author.getId()), true) + .addField("Title", form.title(), true); + + int len = Math.min(values.size(), form.fields().size()); + for (int i = 0; i < len; i++) { + ModalMapping mapping = values.get(i); + FormField field = form.fields().get(i); + String value = mapping.getAsString(); + if (value != null) value = value.replace("```", "` ` `"); + builder.addField(field.label(), value == null ? "*Empty*" : "```\n" + value + "\n```", false); + } + + return builder.build(); + } +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AddFieldFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AddFieldFormSubcommand.java new file mode 100644 index 000000000..5376ab471 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AddFieldFormSubcommand.java @@ -0,0 +1,109 @@ +package net.discordjug.javabot.systems.staff_commands.forms.commands; + +import java.util.Arrays; +import java.util.Optional; + +import net.discordjug.javabot.data.config.BotConfig; +import net.discordjug.javabot.systems.staff_commands.forms.dao.FormsRepository; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormData; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormField; +import net.discordjug.javabot.util.Responses; +import net.dv8tion.jda.api.components.textinput.TextInputStyle; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.AutoCompleteQuery; +import net.dv8tion.jda.api.interactions.commands.Command.Choice; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import net.dv8tion.jda.api.modals.Modal; +import xyz.dynxsty.dih4jda.interactions.AutoCompletable; + +/** + * The `/form add-field` command. This command allows for modification of + * {@link FormData} by adding new fields to it. See + * {@link RemoveFieldFormSubcommand} for the command used to remove fields from + * a form.
+ * Currently, due to Discord limitations, only 5 fields are allowed per form. + * Trying to add more fields will have no effect. + * + * @see FormData + */ +public class AddFieldFormSubcommand extends FormSubcommand implements AutoCompletable { + + private static final String FORM_VALUE_FIELD = "value"; + private static final String FORM_STYLE_FIELD = "style"; + private static final String FORM_REQUIRED_FIELD = "required"; + private static final String FORM_PLACEHOLDER_FIELD = "placeholder"; + private static final String FORM_MAX_FIELD = "max"; + private static final String FORM_MIN_FIELD = "min"; + private static final String FORM_LABEL_FIELD = "label"; + private final FormsRepository formsRepo; + + /** + * The main constructor of this subcommand. + * + * @param formsRepo the forms repository + * @param botConfig bot configuration + */ + public AddFieldFormSubcommand(FormsRepository formsRepo, BotConfig botConfig) { + super(botConfig, formsRepo); + this.formsRepo = formsRepo; + setCommandData(new SubcommandData("add-field", "Adds a field to an existing form") + .addOption(OptionType.INTEGER, FORM_ID_FIELD, "Form ID to add the field to", true, true) + .addOption(OptionType.STRING, FORM_LABEL_FIELD, "Field label", true) + .addOption(OptionType.INTEGER, FORM_MIN_FIELD, "Minimum number of characters") + .addOption(OptionType.INTEGER, FORM_MAX_FIELD, "Maximum number of characters") + .addOption(OptionType.STRING, FORM_PLACEHOLDER_FIELD, "Field placeholder") + .addOption(OptionType.BOOLEAN, FORM_REQUIRED_FIELD, + "Whether or not the user has to input data in this field. Default: false") + .addOptions(new OptionData(OptionType.STRING, FORM_STYLE_FIELD, "Input style. Default: SHORT", false) + .addChoices(Arrays.stream(TextInputStyle.values()).filter(t -> t != TextInputStyle.UNKNOWN) + .map(style -> new Choice(style.name(), style.name())).toList())) + .addOption(OptionType.STRING, FORM_VALUE_FIELD, "Initial field value")); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + if (!checkForStaffRole(event)) return; + Optional formOpt = formsRepo.getForm(event.getOption(FORM_ID_FIELD, OptionMapping::getAsLong)); + if (formOpt.isEmpty()) { + Responses.error(event, "A form with this ID was not found.").queue(); + return; + } + FormData form = formOpt.get(); + + if (form.fields().size() >= Modal.MAX_COMPONENTS) { + Responses.error(event, "Can't add more than %s components to a form", Message.MAX_COMPONENT_COUNT).queue(); + return; + } + + formsRepo.addField(form, createFormFieldFromEvent(event)); + event.reply("Added a new field to the form.").setEphemeral(true).queue(); + } + + @Override + public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { + handleFormIDAutocomplete(event, target); + } + + private static FormField createFormFieldFromEvent(SlashCommandInteractionEvent e) { + String label = e.getOption(FORM_LABEL_FIELD, OptionMapping::getAsString); + int min = e.getOption(FORM_MIN_FIELD, 0, OptionMapping::getAsInt); + int max = e.getOption(FORM_MAX_FIELD, 64, OptionMapping::getAsInt); + String placeholder = e.getOption(FORM_PLACEHOLDER_FIELD, OptionMapping::getAsString); + boolean required = e.getOption(FORM_REQUIRED_FIELD, false, OptionMapping::getAsBoolean); + TextInputStyle style = e.getOption(FORM_STYLE_FIELD, TextInputStyle.SHORT, t -> { + try { + return TextInputStyle.valueOf(t.getAsString().toUpperCase()); + } catch (IllegalArgumentException e2) { + return TextInputStyle.SHORT; + } + }); + String value = e.getOption(FORM_VALUE_FIELD, OptionMapping::getAsString); + + return new FormField(label, max, min, placeholder, required, style, value, 0); + } +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AttachFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AttachFormSubcommand.java new file mode 100644 index 000000000..2cc6c557a --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AttachFormSubcommand.java @@ -0,0 +1,160 @@ +package net.discordjug.javabot.systems.staff_commands.forms.commands; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import net.discordjug.javabot.data.config.BotConfig; +import net.discordjug.javabot.systems.staff_commands.forms.FormInteractionManager; +import net.discordjug.javabot.systems.staff_commands.forms.dao.FormsRepository; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormData; +import net.discordjug.javabot.util.Responses; +import net.dv8tion.jda.api.components.Component.Type; +import net.dv8tion.jda.api.components.MessageTopLevelComponentUnion; +import net.dv8tion.jda.api.components.actionrow.ActionRow; +import net.dv8tion.jda.api.components.actionrow.ActionRowChildComponent; +import net.dv8tion.jda.api.components.buttons.Button; +import net.dv8tion.jda.api.components.buttons.ButtonStyle; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.SelfUser; +import net.dv8tion.jda.api.entities.channel.middleman.GuildChannel; +import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; +import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.AutoCompleteQuery; +import net.dv8tion.jda.api.interactions.commands.Command.Choice; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import xyz.dynxsty.dih4jda.interactions.AutoCompletable; +import xyz.dynxsty.dih4jda.util.ComponentIdBuilder; + +/** + * The `/form attach` command. This command can be used to attach a form to an + * existing message. "Attaching" a form to message in this case means that the + * bot will modify the target message with a button, that when interacted with, + * will bring up a modal where the user can input their data. See + * {@link DetachFormSubcommand} for a command used to detach the form from a + * message. + * + * @see FormData + */ +public class AttachFormSubcommand extends FormSubcommand implements AutoCompletable { + + private static final String FORM_BUTTON_STYLE_FIELD = "button-style"; + private static final String FORM_BUTTON_LABEL_FIELD = "button-label"; + private final FormsRepository formsRepo; + + /** + * The main constructor of this subcommand. + * + * @param formsRepo the forms repository + * @param botConfig bot configuration + */ + public AttachFormSubcommand(FormsRepository formsRepo, BotConfig botConfig) { + super(botConfig, formsRepo); + this.formsRepo = formsRepo; + setCommandData(new SubcommandData("attach", "Add a button for bringing up the form to a message").addOptions( + new OptionData(OptionType.INTEGER, FORM_ID_FIELD, "ID of the form to attach", true, true), + new OptionData(OptionType.STRING, FORM_MESSAGE_ID_FIELD, "ID of the message to attach the form to", + true), + new OptionData(OptionType.CHANNEL, FORM_CHANNEL_FIELD, + "Channel of the message. Required if the message is in a different channel"), + new OptionData(OptionType.STRING, FORM_BUTTON_LABEL_FIELD, + "Label of the submit button. Default is \"Submit\""), + new OptionData(OptionType.STRING, FORM_BUTTON_STYLE_FIELD, "Submit button style. Defaults to primary", + false) + .addChoices(Set + .of(ButtonStyle.DANGER, ButtonStyle.PRIMARY, ButtonStyle.SECONDARY, ButtonStyle.SUCCESS) + .stream().map(style -> new Choice(style.name(), style.name())).toList()))); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + if (!checkForStaffRole(event)) return; + + Optional formOpt = formsRepo.getForm(event.getOption(FORM_ID_FIELD, OptionMapping::getAsLong)); + if (formOpt.isEmpty()) { + Responses.error(event, "A form with this ID was not found.").queue(); + return; + } + FormData form = formOpt.get(); + + if (form.getAttachmentInfo().isPresent()) { + Responses.error(event, "The form seems to already be attached to a message. Detach it before continuing.") + .queue(); + return; + } + + if (form.fields().isEmpty()) { + Responses.error(event, "You can't attach a form with no fields.").queue(); + return; + } + + String messageId = event.getOption(FORM_MESSAGE_ID_FIELD, OptionMapping::getAsString); + GuildChannel channel = event.getOption(FORM_CHANNEL_FIELD, event.getChannel().asGuildMessageChannel(), + OptionMapping::getAsChannel); + + if (channel == null) { + Responses.error(event, "A channel with this ID was not found.").queue(); + return; + } + + if (!(channel instanceof MessageChannel msgChannel)) { + Responses.error(event, "You must specify a message channel").queue(); + return; + } + + String buttonLabel = event.getOption(FORM_BUTTON_LABEL_FIELD, "Submit", OptionMapping::getAsString); + ButtonStyle style = event.getOption(FORM_BUTTON_STYLE_FIELD, ButtonStyle.PRIMARY, t -> { + try { + return ButtonStyle.valueOf(t.getAsString().toUpperCase()); + } catch (IllegalArgumentException e) { + return ButtonStyle.PRIMARY; + } + }); + + msgChannel.retrieveMessageById(messageId).queue(message -> { + SelfUser self = message.getJDA().getSelfUser(); + if (message.getAuthor().getIdLong() != self.getIdLong()) { + Responses.error(event, "You can only attach forms to messages sent by " + self.getAsMention()).queue(); + return; + } + attachFormToMessage(message, buttonLabel, style, form); + formsRepo.attachForm(form, msgChannel, message); + event.reply("Successfully attached the form to the [message](" + message.getJumpUrl() + ")!") + .setEphemeral(true).queue(); + }, _ -> Responses.error(event, "A message with this ID was not found").queue()); + } + + @Override + public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { + handleFormIDAutocomplete(event, target); + } + + private static void attachFormToMessage(Message message, String buttonLabel, ButtonStyle style, FormData form) { + List rows = new ArrayList<>( + message.getComponents().stream().map(MessageTopLevelComponentUnion::asActionRow).toList()); + + Button button = Button.of(style, ComponentIdBuilder.build(FormInteractionManager.FORM_COMPONENT_ID, form.id()), + buttonLabel); + + if (form.closed() || form.hasExpired()) { + button = button.asDisabled(); + } + + if (rows.isEmpty() + || rows.get(rows.size() - 1).getActionComponents().size() >= ActionRow.getMaxAllowed(Type.BUTTON)) { + rows.add(ActionRow.of(button)); + } else { + ActionRow lastRow = rows.get(rows.size() - 1); + List components = new ArrayList<>(lastRow.getComponents()); + components.add(button); + rows.set(rows.size() - 1, ActionRow.of(components)); + } + + message.editMessageComponents(rows.toArray(new ActionRow[0])).queue(); + } + +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/CloseFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/CloseFormSubcommand.java new file mode 100644 index 000000000..86243eb37 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/CloseFormSubcommand.java @@ -0,0 +1,74 @@ +package net.discordjug.javabot.systems.staff_commands.forms.commands; + +import java.util.Optional; + +import net.discordjug.javabot.data.config.BotConfig; +import net.discordjug.javabot.systems.staff_commands.forms.FormInteractionManager; +import net.discordjug.javabot.systems.staff_commands.forms.dao.FormsRepository; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormData; +import net.discordjug.javabot.util.Responses; +import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.AutoCompleteQuery; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import xyz.dynxsty.dih4jda.interactions.AutoCompletable; + +/** + * The `/form close` command. This command closes a form. A closed form doesn't + * accept any new submissions. See {@link ReopenFormSubcommand} for a command + * that can be used to re-open a closed form. + * + * @see FormData + */ +public class CloseFormSubcommand extends FormSubcommand implements AutoCompletable { + + private final FormsRepository formsRepo; + private final FormInteractionManager interactionManager; + + /** + * The main constructor of this subcommand. + * + * @param formsRepo the forms repository + * @param interactionManager form interaction manager + * @param botConfig main bot configuration + */ + public CloseFormSubcommand(FormsRepository formsRepo, FormInteractionManager interactionManager, + BotConfig botConfig) { + super(botConfig, formsRepo); + this.formsRepo = formsRepo; + this.interactionManager = interactionManager; + setCommandData(new SubcommandData("close", "Close an existing form, preventing further submissions.") + .addOptions(new OptionData(OptionType.INTEGER, FORM_ID_FIELD, "The ID of a form to close", true, true))); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + if (!checkForStaffRole(event)) return; + long id = event.getOption(FORM_ID_FIELD, OptionMapping::getAsLong); + Optional formOpt = formsRepo.getForm(id); + if (formOpt.isEmpty()) { + Responses.error(event, "A form with this ID was not found.").queue(); + return; + } + FormData form = formOpt.get(); + + if (form.closed()) { + Responses.error(event, "This form is already closed").queue(); + return; + } + + event.deferReply(true).queue(); + + interactionManager.closeForm(event.getGuild(), form); + + event.getHook().sendMessage("Form closed!").queue(); + } + + @Override + public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { + handleFormIDAutocomplete(event, target); + } +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/CreateFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/CreateFormSubcommand.java new file mode 100644 index 000000000..4e9c5317e --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/CreateFormSubcommand.java @@ -0,0 +1,76 @@ +package net.discordjug.javabot.systems.staff_commands.forms.commands; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +import net.discordjug.javabot.data.config.BotConfig; +import net.discordjug.javabot.systems.staff_commands.forms.FormInteractionManager; +import net.discordjug.javabot.systems.staff_commands.forms.dao.FormsRepository; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormData; +import net.discordjug.javabot.util.Responses; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; + +/** + * The `/form create` command. This command creates a new, empty form. Newly + * created forms have no fields, and thus can't be attached (See + * {@link AttachFormSubcommand}) to messages. Use {@link AddFieldFormSubcommand} + * to add new fields to the form. + * + * @see FormData + */ +public class CreateFormSubcommand extends FormSubcommand { + + private final FormsRepository formsRepo; + + /** + * The main constructor of this subcommand. + * + * @param formsRepo the forms repository + * @param botConfig bot configuration + */ + public CreateFormSubcommand(FormsRepository formsRepo, BotConfig botConfig) { + super(botConfig, formsRepo); + this.formsRepo = formsRepo; + setCommandData(new SubcommandData("create", "Create a new form").addOptions( + new OptionData(OptionType.STRING, FORM_TITLE_FIELD, "Form title (shown in modal)", true), + new OptionData(OptionType.CHANNEL, FORM_SUBMIT_CHANNEL_FIELD, "Channel to log form submissions in", + true), + new OptionData(OptionType.STRING, FORM_SUBMIT_MESSAGE_FIELD, + "Message displayed to the user once they submit the form"), + new OptionData(OptionType.STRING, FORM_EXPIRATION_FIELD, + "UTC time after which the form will not accept further submissions. " + + FormInteractionManager.DATE_FORMAT_STRING), + new OptionData(OptionType.BOOLEAN, FORM_ONETIME_FIELD, + "If the form should only accept one submission per user. Defaults to false."))); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + if (!checkForStaffRole(event)) return; + Optional expirationOpt; + try { + expirationOpt = FormInteractionManager.parseExpiration(event); + } catch (IllegalArgumentException e) { + Responses.error(event, e.getMessage()).queue(); + return; + } + + event.deferReply().setEphemeral(true).queue(); + Instant expiration = expirationOpt.orElse(null); + + FormData form = new FormData(0, List.of(), event.getOption(FORM_TITLE_FIELD, OptionMapping::getAsString), + event.getOption(FORM_SUBMIT_CHANNEL_FIELD, OptionMapping::getAsChannel).getIdLong(), + event.getOption(FORM_SUBMIT_MESSAGE_FIELD, null, OptionMapping::getAsString), null, null, expiration, + false, event.getOption(FORM_ONETIME_FIELD, false, OptionMapping::getAsBoolean)); + + formsRepo.insertForm(form); + event.getHook() + .sendMessage("The form was created! Remember to add fields to it before attaching it to a message.") + .queue(); + } +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DeleteFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DeleteFormSubcommand.java new file mode 100644 index 000000000..269493237 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DeleteFormSubcommand.java @@ -0,0 +1,69 @@ +package net.discordjug.javabot.systems.staff_commands.forms.commands; + +import java.util.Optional; +import net.discordjug.javabot.data.config.BotConfig; +import net.discordjug.javabot.systems.staff_commands.forms.dao.FormsRepository; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormData; +import net.discordjug.javabot.util.Responses; +import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.AutoCompleteQuery; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import xyz.dynxsty.dih4jda.interactions.AutoCompletable; + +/** + * The `/form delete` command. Deletes an existing form. This command also does + * delete submission records from the database. This command won't work if the + * form is attached to a message, see {@link DetachFormSubcommand} + * + * @see FormData + */ +public class DeleteFormSubcommand extends FormSubcommand implements AutoCompletable { + + private final FormsRepository formsRepo; + + /** + * The main constructor of this subcommand. + * + * @param formsRepo the forms repository + * @param botConfig bot configuration + */ + public DeleteFormSubcommand(FormsRepository formsRepo, BotConfig botConfig) { + super(botConfig, formsRepo); + this.formsRepo = formsRepo; + setCommandData(new SubcommandData("delete", "Delete an existing form").addOptions( + new OptionData(OptionType.INTEGER, FORM_ID_FIELD, "The ID of a form to delete", true, true))); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + if (!checkForStaffRole(event)) return; + long id = event.getOption(FORM_ID_FIELD, OptionMapping::getAsLong); + Optional formOpt = formsRepo.getForm(id); + if (formOpt.isEmpty()) { + Responses.error(event, "A form with this ID was not found.").queue(); + return; + } + + FormData form = formOpt.get(); + + if (form.getAttachmentInfo().isPresent()) { + Responses.error(event, + "This form is attached to a message. Use `details` subcommand to check the message this form is attached to, or `detach` subcommand to detach the message before deleting.") + .queue(); + return; + } + + event.deferReply(true).queue(); + formsRepo.deleteForm(form); + event.getHook().sendMessage("Form deleted!").queue(); + } + + @Override + public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { + handleFormIDAutocomplete(event, target); + } +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetachFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetachFormSubcommand.java new file mode 100644 index 000000000..8a18fd314 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetachFormSubcommand.java @@ -0,0 +1,119 @@ +package net.discordjug.javabot.systems.staff_commands.forms.commands; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import net.discordjug.javabot.data.config.BotConfig; +import net.discordjug.javabot.systems.staff_commands.forms.FormInteractionManager; +import net.discordjug.javabot.systems.staff_commands.forms.dao.FormsRepository; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormData; +import net.discordjug.javabot.util.ExceptionLogger; +import net.discordjug.javabot.util.Responses; +import net.dv8tion.jda.api.components.actionrow.ActionRow; +import net.dv8tion.jda.api.components.actionrow.ActionRowChildComponentUnion; +import net.dv8tion.jda.api.components.buttons.Button; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; +import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.AutoCompleteQuery; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import xyz.dynxsty.dih4jda.interactions.AutoCompletable; +import xyz.dynxsty.dih4jda.util.ComponentIdBuilder; + +/** + * The `/form detach` command. This command detaches this form from the message + * it's attached to. Detaching a form means that any buttons that could be used + * to bring its input dialog will be removed. See {@link AttachFormSubcommand} + * for a command for attaching the form to a message. + * + * @see FormData + */ +public class DetachFormSubcommand extends FormSubcommand implements AutoCompletable { + + private final FormsRepository formsRepo; + + /** + * The main constructor of this subcommand. + * + * @param formsRepo the forms repository + * @param botConfig bot configuration + */ + public DetachFormSubcommand(FormsRepository formsRepo, BotConfig botConfig) { + super(botConfig, formsRepo); + this.formsRepo = formsRepo; + setCommandData(new SubcommandData("detach", + "Remove any buttons that could be used to bring the form's input modal") + .addOptions(new OptionData(OptionType.INTEGER, FORM_ID_FIELD, "ID of the form to attach", true, true))); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + if (!checkForStaffRole(event)) return; + + Optional formOpt = formsRepo.getForm(event.getOption(FORM_ID_FIELD, OptionMapping::getAsLong)); + if (formOpt.isEmpty()) { + Responses.error(event, "A form with this ID was not found.").queue(); + return; + } + FormData form = formOpt.get(); + + if (form.getAttachmentInfo().isEmpty()) { + Responses.error(event, "This form doesn't seem to be attached to a message").queue(); + return; + } + + event.deferReply().setEphemeral(true).queue(); + detachFromMessage(form, event.getGuild()); + formsRepo.detachForm(form); + + event.getHook().sendMessage("Form detached!").queue(); + } + + @Override + public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { + handleFormIDAutocomplete(event, target); + } + + /** + * Detaches the form from a message it's attached to, deleting any associated + * buttons. Fails silently if the message was not found. + * + * @param form form to detach + * @param guild guild this form is contained in + */ + public static void detachFromMessage(FormData form, Guild guild) { + form.getAttachmentInfo().ifPresent(info -> { + long messageChannelId = info.messageChannelId(); + long messageId = info.messageId(); + MessageChannel formChannel = guild.getJDA().getChannelById(MessageChannel.class, messageChannelId); + if (formChannel != null) { + formChannel.retrieveMessageById(messageId).queue(msg -> { + List components = msg.getComponents().stream().map(msgComponent -> { + ActionRow row = msgComponent.asActionRow(); + List cpts = row.getComponents().stream().filter(cpt -> { + if (cpt instanceof Button btn) { + String cptId = btn.getCustomId(); + String[] split = ComponentIdBuilder.split(cptId); + if (split[0].equals(FormInteractionManager.FORM_COMPONENT_ID)) { + return !split[1].equals(Long.toString(form.id())); + } + } + return true; + }).toList(); + if (cpts.isEmpty()) { + return null; + } + return ActionRow.of(cpts); + }).filter(Objects::nonNull).toList(); + msg.editMessageComponents(components).queue(); + }, ExceptionLogger::capture); + } + }); + } + +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetailsFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetailsFormSubcommand.java new file mode 100644 index 000000000..10d5d7a1f --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetailsFormSubcommand.java @@ -0,0 +1,127 @@ +package net.discordjug.javabot.systems.staff_commands.forms.commands; + +import java.util.Optional; + +import net.discordjug.javabot.data.config.BotConfig; +import net.discordjug.javabot.systems.staff_commands.forms.dao.FormsRepository; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormAttachmentInfo; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormData; +import net.discordjug.javabot.util.Responses; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; +import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.AutoCompleteQuery; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import net.dv8tion.jda.api.utils.MarkdownUtil; +import net.dv8tion.jda.api.utils.TimeFormat; +import xyz.dynxsty.dih4jda.interactions.AutoCompletable; + +/** + * The `/form details` command. Displays information about the specified form. + * The information is sent as a non-ephemeral embed in the same channel this + * command is executed in. + * + * @see FormData + */ +public class DetailsFormSubcommand extends FormSubcommand implements AutoCompletable { + + private final FormsRepository formsRepo; + + /** + * The main constructor of this subcommand. + * + * @param formsRepo the forms repository + * @param botConfig bot configuration + */ + public DetailsFormSubcommand(FormsRepository formsRepo, BotConfig botConfig) { + super(botConfig, formsRepo); + this.formsRepo = formsRepo; + setCommandData(new SubcommandData("details", "Get details about a form").addOptions( + new OptionData(OptionType.INTEGER, FORM_ID_FIELD, "The ID of a form to get details for", true, true))); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + if (!checkForStaffRole(event)) return; + Optional formOpt = formsRepo.getForm(event.getOption(FORM_ID_FIELD, OptionMapping::getAsLong)); + if (formOpt.isEmpty()) { + Responses.error(event, "Couldn't find a form with this id").queue(); + return; + } + + event.deferReply().setEphemeral(false).queue(); + FormData form = formOpt.get(); + EmbedBuilder embedBuilder = createFormDetailsEmbed(form, event.getGuild()); + embedBuilder.setAuthor(event.getMember().getEffectiveName(), null, event.getMember().getEffectiveAvatarUrl()); + + event.getHook().sendMessageEmbeds(embedBuilder.build()).queue(); + } + + @Override + public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { + handleFormIDAutocomplete(event, target); + } + + private EmbedBuilder createFormDetailsEmbed(FormData form, Guild guild) { + EmbedBuilder builder = new EmbedBuilder().setTitle("Form details"); + + long id = form.id(); + + addCodeblockField(builder, "ID", id, true); + + builder.addField("Expires at", + form.hasExpirationTime() ? TimeFormat.DATE_TIME_LONG.format(form.expiration().toEpochMilli()) + : "`Never`", + true); + + addCodeblockField(builder, "State", form.closed() ? "Closed" : form.hasExpired() ? "Expired" : "Open", false); + + String channelMention; + String messageLink; + Optional attachmentInfoOptonal = form.getAttachmentInfo(); + + channelMention = attachmentInfoOptonal.map(info -> { + long channelId = info.messageChannelId(); + MessageChannel channel = guild.getJDA().getChannelById(MessageChannel.class, channelId); + return channel != null ? channel.getAsMention() : "`" + channelId + "`"; + }).orElse("*Not attached*"); + + messageLink = attachmentInfoOptonal.map(attachmentInfo -> { + long messageId = attachmentInfo.messageId(); + long channelId = attachmentInfo.messageChannelId(); + return MarkdownUtil.maskedLink("Link", + String.format(Message.JUMP_URL, guild.getId(), channelId, messageId)); + }).orElse("*Not attached*"); + + String submissionsChannelMention; + MessageChannel submissionsChannel = guild.getJDA().getChannelById(MessageChannel.class, form.submitChannel()); + if (submissionsChannel != null) { + submissionsChannelMention = submissionsChannel.getAsMention(); + } else { + submissionsChannelMention = "`" + form.submitChannel() + "`"; + } + + builder.addField("Attached in", channelMention, true); + builder.addField("Attached to", messageLink, true); + + builder.addField("Submissions channel", submissionsChannelMention, true); + builder.addField("Is one-time", form.onetime() ? ":white_check_mark:" : ":x:", true); + addCodeblockField(builder, "Submission message", form.getOptionalSubmitMessage().orElse("Default"), true); + + addCodeblockField(builder, "Number of fields", form.fields().size(), true); + addCodeblockField(builder, "Number of submissions", formsRepo.getTotalSubmissionsCount(form), true); + + return builder; + } + + private static void addCodeblockField(EmbedBuilder builder, String name, Object content, boolean inline) { + builder.addField(name, String.format("```\n%s\n```", content), inline); + } + +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/FormCommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/FormCommand.java new file mode 100644 index 000000000..f09eb438c --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/FormCommand.java @@ -0,0 +1,44 @@ +package net.discordjug.javabot.systems.staff_commands.forms.commands; + +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 xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand; + +/** + * The {@code /form} command. This is the base command. It holds subcommands + * used to manage forms and their submissions. + */ +public class FormCommand extends SlashCommand { + + /** + * The main constructor of this subcommand. + * + * @param createSub form create subcommand + * @param deleteSub form delete subcommand + * @param closeSub form close subcommand + * @param reopenSub form reopen subcommand + * @param detailsSub form details subcommand + * @param modifySub form modify subcommand + * @param addFieldSub form add-field subcommand + * @param removeFieldSub form remove-field subcommand + * @param showSub form show subcommands + * @param attachSub form attach subcommand + * @param detachSub form detach subcommand + * @param submissionsGetSub form submissions-get subcommand + * @param submissionsDeleteSub form submissions-delete subcommand + * + */ + public FormCommand(CreateFormSubcommand createSub, DeleteFormSubcommand deleteSub, CloseFormSubcommand closeSub, + ReopenFormSubcommand reopenSub, DetailsFormSubcommand detailsSub, ModifyFormSubcommand modifySub, + AddFieldFormSubcommand addFieldSub, RemoveFieldFormSubcommand removeFieldSub, ShowFormSubcommand showSub, + AttachFormSubcommand attachSub, DetachFormSubcommand detachSub, + SubmissionsExportFormSubcommand submissionsGetSub, SubmissionsDeleteFormSubcommand submissionsDeleteSub) { + setCommandData( + Commands.slash("form", "Commands for managing modal forms").setContexts(InteractionContextType.GUILD) + .setDefaultPermissions(DefaultMemberPermissions.enabledFor(Permission.MANAGE_SERVER))); + addSubcommands(createSub, deleteSub, closeSub, reopenSub, detailsSub, modifySub, addFieldSub, removeFieldSub, + showSub, attachSub, detachSub, submissionsGetSub, submissionsDeleteSub); + } +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/FormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/FormSubcommand.java new file mode 100644 index 000000000..7bfaca3da --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/FormSubcommand.java @@ -0,0 +1,104 @@ +package net.discordjug.javabot.systems.staff_commands.forms.commands; + +import net.discordjug.javabot.data.config.BotConfig; +import net.discordjug.javabot.systems.staff_commands.forms.dao.FormsRepository; +import net.discordjug.javabot.util.Checks; +import net.discordjug.javabot.util.Responses; +import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; +import net.dv8tion.jda.api.interactions.AutoCompleteQuery; +import net.dv8tion.jda.api.interactions.callbacks.IReplyCallback; +import net.dv8tion.jda.api.interactions.commands.Command.Choice; +import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand.Subcommand; +import xyz.dynxsty.dih4jda.util.AutoCompleteUtils; + +/** + * Base abstract class containing common methods used in form subcommands. + */ +public abstract class FormSubcommand extends Subcommand { + + /** + * Form ID field identificator used in form subcommands. + */ + protected static final String FORM_ID_FIELD = "form-id"; + + /** + * Channel field identifier. + */ + protected static final String FORM_CHANNEL_FIELD = "channel"; + + /** + * Message id field identifier. + */ + protected static final String FORM_MESSAGE_ID_FIELD = "message-id"; + + /** + * Expiration field identifier. + */ + protected static final String FORM_EXPIRATION_FIELD = "expiration"; + + /** + * "onetime" field identifier. + */ + protected static final String FORM_ONETIME_FIELD = "onetime"; + + /** + * Submit message field identifier. + */ + protected static final String FORM_SUBMIT_MESSAGE_FIELD = "submit-message"; + + /** + * Submit channel field identifier. + */ + protected static final String FORM_SUBMIT_CHANNEL_FIELD = "submit-channel"; + + /** + * Form title field identifier. + */ + protected static final String FORM_TITLE_FIELD = "title"; + private final BotConfig botConfig; + private final FormsRepository formsRepository; + + /** + * The main constructor. + * + * @param botConfig main bot configuration + * @param formsRepository the forms repository + */ + public FormSubcommand(BotConfig botConfig, FormsRepository formsRepository) { + this.botConfig = botConfig; + this.formsRepository = formsRepository; + } + + /** + * Check if the author of this event has the configured staff role. If not, + * reply to the event with a message and return `false` + * + * @param event event to reply to + * @return true if the user has the staff role + */ + protected boolean checkForStaffRole(IReplyCallback event) { + if (!Checks.hasStaffRole(botConfig, event.getMember())) { + Responses.replyStaffOnly(event, botConfig.get(event.getGuild())).queue(); + return false; + } + return true; + } + + /** + * Tries to handle the auto completion event initiated by a user. If current + * focused field's id is equal to {@link #FORM_ID_FIELD}, the method will handle + * the event by replying with a list of all available form IDs, + * + * @param event the event to handle + * @param target auto completion target + * @return true if the event was handled by this method + */ + protected boolean handleFormIDAutocomplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { + if (FORM_ID_FIELD.equals(target.getName())) { + event.replyChoices(AutoCompleteUtils.filterChoices(event, formsRepository.getAllForms().stream() + .map(form -> new Choice(form.toString(), form.id())).toList())).queue(); + return true; + } + return false; + } +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ModifyFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ModifyFormSubcommand.java new file mode 100644 index 000000000..ebe49f7cf --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ModifyFormSubcommand.java @@ -0,0 +1,118 @@ +package net.discordjug.javabot.systems.staff_commands.forms.commands; + +import java.time.Instant; +import java.util.Optional; + +import net.discordjug.javabot.data.config.BotConfig; +import net.discordjug.javabot.systems.staff_commands.forms.FormInteractionManager; +import net.discordjug.javabot.systems.staff_commands.forms.dao.FormsRepository; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormAttachmentInfo; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormData; +import net.discordjug.javabot.util.Responses; +import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.AutoCompleteQuery; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import xyz.dynxsty.dih4jda.interactions.AutoCompletable; + +/** + * The `/form modify` command. Modifies attributes of an existing form. For + * modifying form fields see {@link AddFieldFormSubcommand} and + * {@link RemoveFieldFormSubcommand} + * + * @see FormData + */ +public class ModifyFormSubcommand extends FormSubcommand implements AutoCompletable { + + private final FormsRepository formsRepo; + + /** + * The main constructor of this subcommand. + * + * @param formsRepo the forms repository + * @param botConfig bot configuration + */ + public ModifyFormSubcommand(FormsRepository formsRepo, BotConfig botConfig) { + super(botConfig, formsRepo); + this.formsRepo = formsRepo; + setCommandData(new SubcommandData("modify", + "Modify an existing form's data. Use *-field commands to manage form fields") + .addOptions(new OptionData(OptionType.INTEGER, FORM_ID_FIELD, "ID of the form to modify", true, true), + new OptionData(OptionType.STRING, FORM_TITLE_FIELD, "Form title (shown in modal)"), + new OptionData(OptionType.CHANNEL, FORM_SUBMIT_CHANNEL_FIELD, + "Channel to log form submissions in"), + new OptionData(OptionType.STRING, FORM_SUBMIT_MESSAGE_FIELD, + "Message displayed to the user once they submit the form"), + new OptionData(OptionType.STRING, FORM_EXPIRATION_FIELD, + "UTC time after which the form stops accepting submissions. - for no expiration. " + + FormInteractionManager.DATE_FORMAT_STRING), + new OptionData(OptionType.BOOLEAN, FORM_ONETIME_FIELD, + "If the form should only accept one submission per user. Defaults to false."))); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + if (!checkForStaffRole(event)) return; + Optional formOpt = formsRepo.getForm(event.getOption(FORM_ID_FIELD, OptionMapping::getAsLong)); + if (formOpt.isEmpty()) { + Responses.error(event, "Couldn't find a form with this ID").queue(); + return; + } + event.deferReply(true).queue(); + FormData oldForm = formOpt.get(); + + String title = event.getOption(FORM_TITLE_FIELD, oldForm.title(), OptionMapping::getAsString); + long submitChannel = event.getOption(FORM_SUBMIT_CHANNEL_FIELD, oldForm.submitChannel(), + OptionMapping::getAsLong); + String submitMessage = event.getOption(FORM_SUBMIT_MESSAGE_FIELD, oldForm.submitMessage(), + OptionMapping::getAsString); + Instant expiration; + if (event.getOption(FORM_EXPIRATION_FIELD) == null) { + expiration = oldForm.expiration(); + } else { + if ("-".equals(event.getOption(FORM_EXPIRATION_FIELD, OptionMapping::getAsString))) { + expiration = null; + } else { + Optional expirationOpt; + try { + expirationOpt = FormInteractionManager.parseExpiration(event); + } catch (IllegalArgumentException e) { + event.getHook().sendMessage(e.getMessage()).queue(); + return; + } + expiration = expirationOpt.orElse(oldForm.expiration()); + } + } + + boolean onetime = event.getOption(FORM_ONETIME_FIELD, oldForm.onetime(), OptionMapping::getAsBoolean); + + Long messageId; + Long messageChannel; + + Optional infoOptional = oldForm.getAttachmentInfo(); + if (infoOptional.isPresent()) { + FormAttachmentInfo info = infoOptional.get(); + messageId = info.messageId(); + messageChannel = info.messageChannelId(); + } else { + messageChannel = null; + messageId = null; + } + + FormData newForm = new FormData(oldForm.id(), oldForm.fields(), title, submitChannel, submitMessage, messageId, + messageChannel, expiration, oldForm.closed(), onetime); + + formsRepo.updateForm(newForm); + + event.getHook().sendMessage("Form updated!").queue(); + } + + @Override + public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { + handleFormIDAutocomplete(event, target); + } + +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/RemoveFieldFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/RemoveFieldFormSubcommand.java new file mode 100644 index 000000000..d7bfcbd3d --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/RemoveFieldFormSubcommand.java @@ -0,0 +1,92 @@ +package net.discordjug.javabot.systems.staff_commands.forms.commands; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import net.discordjug.javabot.data.config.BotConfig; +import net.discordjug.javabot.systems.staff_commands.forms.dao.FormsRepository; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormData; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormField; +import net.discordjug.javabot.util.Responses; +import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.AutoCompleteQuery; +import net.dv8tion.jda.api.interactions.commands.Command.Choice; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import xyz.dynxsty.dih4jda.interactions.AutoCompletable; +import xyz.dynxsty.dih4jda.util.AutoCompleteUtils; + +/** + * The `/form remove-field` command. This command removes a field from the form. + * + * @see AddFieldFormSubcommand + * @see FormData + */ +public class RemoveFieldFormSubcommand extends FormSubcommand implements AutoCompletable { + + private static final String FORM_FIELD_INDEX_FIELD = "field"; + private final FormsRepository formsRepo; + + /** + * The main constructor of this subcommand. + * + * @param formsRepo the forms repository + * @param botConfig bot configuration + */ + public RemoveFieldFormSubcommand(FormsRepository formsRepo, BotConfig botConfig) { + super(botConfig, formsRepo); + this.formsRepo = formsRepo; + setCommandData(new SubcommandData("remove-field", "Remove a field from an existing form") + .addOption(OptionType.INTEGER, FORM_ID_FIELD, "Form ID to add the field to", true, true) + .addOption(OptionType.INTEGER, FORM_FIELD_INDEX_FIELD, "0-indexed # of the field to remove", true, true)); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + if (!checkForStaffRole(event)) return; + Optional formOpt = formsRepo.getForm(event.getOption(FORM_ID_FIELD, OptionMapping::getAsLong)); + int index = event.getOption(FORM_FIELD_INDEX_FIELD, OptionMapping::getAsInt); + if (formOpt.isEmpty()) { + Responses.error(event, "A form with this ID was not found.").queue(); + return; + } + FormData form = formOpt.get(); + + if (form.getAttachmentInfo().isPresent() && form.fields().size() <= 1) { + Responses.error(event, + "Can't remove the last field from an attached form. Detach the form before removing the field") + .queue(); + return; + } + + if (!formsRepo.removeField(form, index)) { + Responses.error(event, "A field on this index was not found.").queue(); + return; + } + + event.reply("Removed field `" + form.fields().get(index).label() + "` from the form.") + .setEphemeral(true).queue(); + } + + @Override + public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { + if (!handleFormIDAutocomplete(event, target) && FORM_FIELD_INDEX_FIELD.equals(target.getName())) { + Long formId = event.getOption(FORM_ID_FIELD, OptionMapping::getAsLong); + if (formId != null) { + Optional form = formsRepo.getForm(formId); + if (form.isPresent()) { + List choices = new ArrayList<>(); + List fields = form.get().fields(); + for (int i = 0; i < fields.size(); i++) { + choices.add(new Choice(fields.get(i).label(), i)); + } + event.replyChoices(AutoCompleteUtils.filterChoices(event, choices)).queue(); + return; + } + } + } + } +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ReopenFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ReopenFormSubcommand.java new file mode 100644 index 000000000..df6af4cf9 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ReopenFormSubcommand.java @@ -0,0 +1,78 @@ +package net.discordjug.javabot.systems.staff_commands.forms.commands; + +import java.util.Optional; +import net.discordjug.javabot.data.config.BotConfig; +import net.discordjug.javabot.systems.staff_commands.forms.FormInteractionManager; +import net.discordjug.javabot.systems.staff_commands.forms.dao.FormsRepository; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormData; +import net.discordjug.javabot.util.Responses; +import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.AutoCompleteQuery; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import xyz.dynxsty.dih4jda.interactions.AutoCompletable; + +/** + * The `/form reopen` command. Reopens a closed form, allowing new submissions. + * + * @see CloseFormSubcommandr + * @see FormData + */ +public class ReopenFormSubcommand extends FormSubcommand implements AutoCompletable { + + private final FormsRepository formsRepo; + private final FormInteractionManager interactionManager; + + /** + * The main constructor of this subcommand. + * + * @param formsRepo the forms repository + * @param interactionManager form interaction manager + * @param botConfig main bot configuration + */ + public ReopenFormSubcommand(FormsRepository formsRepo, FormInteractionManager interactionManager, + BotConfig botConfig) { + super(botConfig, formsRepo); + this.formsRepo = formsRepo; + this.interactionManager = interactionManager; + setCommandData(new SubcommandData("reopen", "Reopen a closed form. This will allow new submissions.") + .addOptions(new OptionData(OptionType.INTEGER, FORM_ID_FIELD, "The ID of a closed form to reopen", true, + true))); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + if (!checkForStaffRole(event)) return; + long id = event.getOption(FORM_ID_FIELD, OptionMapping::getAsLong); + Optional formOpt = formsRepo.getForm(id); + if (formOpt.isEmpty()) { + Responses.error(event, "A form with this ID was not found.").queue(); + return; + } + FormData form = formOpt.get(); + + if (!form.closed()) { + Responses.error(event, "This form is already opened").queue(); + return; + } + + if (form.hasExpired()) { + Responses.error(event, "You can't reopen an expired form").queue(); + return; + } + + event.deferReply(true).queue(); + + interactionManager.reopenForm(event.getGuild(), form); + + event.getHook().sendMessage("Form reopened!").queue(); + } + + @Override + public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { + handleFormIDAutocomplete(event, target); + } +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ShowFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ShowFormSubcommand.java new file mode 100644 index 000000000..9dbf03144 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ShowFormSubcommand.java @@ -0,0 +1,63 @@ +package net.discordjug.javabot.systems.staff_commands.forms.commands; + +import java.util.Optional; + +import net.discordjug.javabot.data.config.BotConfig; +import net.discordjug.javabot.systems.staff_commands.forms.FormInteractionManager; +import net.discordjug.javabot.systems.staff_commands.forms.dao.FormsRepository; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormData; +import net.discordjug.javabot.util.Responses; +import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.AutoCompleteQuery; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import xyz.dynxsty.dih4jda.interactions.AutoCompletable; + +/** + * The `/form show` command. Brings up an input modal for the given form. This + * command works even if the form is currently not accepting new submissions + * (due to being closed or expired), or is not attached to a message. + * + * @see FormData + */ +public class ShowFormSubcommand extends FormSubcommand implements AutoCompletable { + + private final FormsRepository formsRepo; + + /** + * The main constructor of this subcommand. + * + * @param formsRepo the forms repository + * @param botConfig bot configuration + */ + public ShowFormSubcommand(FormsRepository formsRepo, BotConfig botConfig) { + super(botConfig, formsRepo); + this.formsRepo = formsRepo; + setCommandData(new SubcommandData("show", + "Forcefully opens a form dialog, even if it's closed, or not attached to a message") + .addOption(OptionType.INTEGER, FORM_ID_FIELD, "Form ID to add the field to", true, true)); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + if (!checkForStaffRole(event)) return; + Optional formOpt = formsRepo.getForm(event.getOption(FORM_ID_FIELD, OptionMapping::getAsLong)); + if (formOpt.isEmpty()) { + Responses.error(event, "A form with this ID was not found.").queue(); + return; + } + FormData form = formOpt.get(); + if (form.fields().isEmpty()) { + Responses.error(event, "You can't open a form with no fields").queue(); + return; + } + event.replyModal(FormInteractionManager.createSubmissionModal(form)).queue(); + } + + @Override + public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { + handleFormIDAutocomplete(event, target); + } +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsDeleteFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsDeleteFormSubcommand.java new file mode 100644 index 000000000..4b253397a --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsDeleteFormSubcommand.java @@ -0,0 +1,66 @@ +package net.discordjug.javabot.systems.staff_commands.forms.commands; + +import java.util.Optional; + +import net.discordjug.javabot.data.config.BotConfig; +import net.discordjug.javabot.systems.staff_commands.forms.dao.FormsRepository; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormData; +import net.discordjug.javabot.util.Responses; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.AutoCompleteQuery; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import xyz.dynxsty.dih4jda.interactions.AutoCompletable; + +/** + * The `/form submissions-delete` command. Deletes all submission records from a + * given user from the database. For one-time forms this will allow a user who + * already submitted the form to submit it again. + * + * @see FormData + */ +public class SubmissionsDeleteFormSubcommand extends FormSubcommand implements AutoCompletable { + + private static final String FORM_USER_FIELD = "user"; + private final FormsRepository formsRepo; + + /** + * The main constructor of this subcommand. + * + * @param formsRepo the forms repository + * @param botConfig bot configuration + */ + public SubmissionsDeleteFormSubcommand(FormsRepository formsRepo, BotConfig botConfig) { + super(botConfig, formsRepo); + this.formsRepo = formsRepo; + setCommandData(new SubcommandData("submissions-delete", "Deletes submissions of a user in the form").addOptions( + new OptionData(OptionType.INTEGER, FORM_ID_FIELD, "The ID of a form to delete submissions from", true, true), + new OptionData(OptionType.USER, FORM_USER_FIELD, "User to delete submissions of", true))); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + if (!checkForStaffRole(event)) return; + Optional formOpt = formsRepo.getForm(event.getOption(FORM_ID_FIELD, OptionMapping::getAsLong)); + if (formOpt.isEmpty()) { + Responses.error(event, "Couldn't find a form with this id").queue(); + return; + } + + event.deferReply().setEphemeral(true).queue(); + User user = event.getOption(FORM_USER_FIELD, OptionMapping::getAsUser); + FormData form = formOpt.get(); + + int count = formsRepo.deleteSubmissions(form, user); + event.getHook().sendMessage("Deleted " + count + " of this user's submissions!").queue(); + } + + @Override + public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { + handleFormIDAutocomplete(event, target); + } +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsExportFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsExportFormSubcommand.java new file mode 100644 index 000000000..a1337bffc --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsExportFormSubcommand.java @@ -0,0 +1,84 @@ +package net.discordjug.javabot.systems.staff_commands.forms.commands; + +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.Optional; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import net.discordjug.javabot.data.config.BotConfig; +import net.discordjug.javabot.systems.staff_commands.forms.dao.FormsRepository; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormData; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormUser; +import net.discordjug.javabot.util.Responses; +import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.AutoCompleteQuery; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import net.dv8tion.jda.api.utils.FileUpload; +import xyz.dynxsty.dih4jda.interactions.AutoCompletable; + +/** + * The `/form submissions-export` command. Export a list of users who have + * submitted the specified form from the database in JSON format. + * + * @see FormData + */ +public class SubmissionsExportFormSubcommand extends FormSubcommand implements AutoCompletable { + + private final FormsRepository formsRepo; + private final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + + /** + * The main constructor of this subcommand. + * + * @param formsRepo the forms repository + * @param botConfig bot configuration + */ + public SubmissionsExportFormSubcommand(FormsRepository formsRepo, BotConfig botConfig) { + super(botConfig, formsRepo); + this.formsRepo = formsRepo; + setCommandData(new SubcommandData("submissions-export", "Export all of the form's submissions") + .addOptions(new OptionData(OptionType.INTEGER, FORM_ID_FIELD, "The ID of a form to get submissions for", + true, true))); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + if (!checkForStaffRole(event)) return; + Optional formOpt = formsRepo.getForm(event.getOption(FORM_ID_FIELD, OptionMapping::getAsLong)); + if (formOpt.isEmpty()) { + Responses.error(event, "Couldn't find a form with this id").queue(); + return; + } + + event.deferReply().setEphemeral(false).queue(); + FormData form = formOpt.get(); + Map submissions = formsRepo.getSubmissionsCountPerUser(form); + JsonObject root = new JsonObject(); + JsonObject details = new JsonObject(); + JsonArray users = new JsonArray(); + submissions.forEach((formUser, value) -> { + JsonObject uobj = new JsonObject(); + uobj.addProperty("username", formUser.username()); + uobj.addProperty("submissions", value); + details.add(Long.toString(formUser.id()), uobj); + users.add(formUser.username()); + }); + root.add("users", users); + root.add("details", details); + event.getHook().sendFiles(FileUpload.fromData(gson.toJson(root).getBytes(StandardCharsets.UTF_8), + "submissions_" + form.id() + ".json")).queue(); + } + + @Override + public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { + handleFormIDAutocomplete(event, target); + } +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java new file mode 100644 index 000000000..66dcc0815 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java @@ -0,0 +1,309 @@ +package net.discordjug.javabot.systems.staff_commands.forms.dao; + +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormAttachmentInfo; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormData; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormField; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormUser; +import net.dv8tion.jda.api.components.textinput.TextInputStyle; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; + +/** + * Dao class that represents the FORMS table. + */ +@RequiredArgsConstructor +@Repository +public class FormsRepository { + private final JdbcTemplate jdbcTemplate; + + /** + * Add a field to a form. + * + * @param form form to add field to + * @param field field to add + */ + public void addField(FormData form, FormField field) { + jdbcTemplate.update( + "INSERT INTO form_fields (form_id, label, min, max, placeholder, \"required\", \"style\", initial) " + + "VALUES(?, ?, ?, ?, ?, ?, ?, ?)", + form.id(), field.label(), field.min(), field.max(), field.placeholder(), field.required(), + field.style().name(), field.value()); + } + + /** + * Attaches a form to a message. + * + * @param form form to attach + * @param message message to attach the form to + * @param channel channel of the message + */ + public void attachForm(FormData form, MessageChannel channel, Message message) { + Objects.requireNonNull(form); + Objects.requireNonNull(channel); + Objects.requireNonNull(message); + jdbcTemplate.update("update `forms` set `message_id` = ?, `message_channel` = ? where `form_id` = ?", + message.getId(), channel.getId(), form.id()); + } + + /** + * Set this form's closed state to true. + * + * @param form form to close + */ + public void closeForm(FormData form) { + jdbcTemplate.update("update `forms` set `closed` = true where `form_id` = ?", form.id()); + } + + /** + * Deletes a form from the database. + * + * @param form form to delete + */ + public void deleteForm(FormData form) { + jdbcTemplate.update("delete from `forms` where `form_id` = ?", form.id()); + } + + /** + * Deletes user's submissions from this form. + * + * @param form form to delete submissions for + * @param user user to delete submissions for + * @return number of deleted submissions + */ + public int deleteSubmissions(FormData form, User user) { + Objects.requireNonNull(form); + Objects.requireNonNull(user); + return jdbcTemplate.update("delete from `form_submissions` where `form_id` = ? and `user_id` = ?", form.id(), + user.getIdLong()); + } + + /** + * Detaches a form from a message. + * + * @param form form to detach + */ + public void detachForm(FormData form) { + Objects.requireNonNull(form); + jdbcTemplate.update("update `forms` set `message_id` = NULL, `message_channel` = NULL where `form_id` = ?", + form.id()); + } + + /** + * Get all forms from the database. + * + * @return A list of forms + */ + public List getAllForms() { + return jdbcTemplate.query("select * from `forms`", (rs, rowNum) -> read(rs, readFormFields(rowNum))); + } + + /** + * Get all forms matching given closed state. + * + * @param closed the closed state + * @return A list of forms matching the closed state + */ + public List getAllForms(boolean closed) { + return jdbcTemplate.query(con -> { + PreparedStatement statement = con.prepareStatement("select * from `forms` where `closed` = ?"); + statement.setBoolean(1, closed); + return statement; + }, (rs, rowNum) -> read(rs, readFormFields(rowNum))); + } + + /** + * Get all submissions of this form in a user -> count map. + * + * @param form a form to get submissions for + * @return a map of users and the number of their submissions + */ + public Map getSubmissionsCountPerUser(FormData form) { + Objects.requireNonNull(form); + List users = jdbcTemplate.query("select * from `form_submissions` where `form_id` = ?", + (rs, _) -> new FormUser(rs.getLong("user_id"), rs.getString("user_name")), form.id()); + Map map = new HashMap<>(); + for (FormUser user : users) { + map.merge(user, 1, Integer::sum); + } + return Collections.unmodifiableMap(map); + } + + /** + * Get a form for given ID. + * + * @param formId form ID to query + * @return the obtained form or {@link Optional#empty()} + */ + public Optional getForm(long formId) { + try { + return Optional.of(jdbcTemplate.queryForObject("select * from `forms` where `form_id` = ?", + (RowMapper) (rs, _) -> read(rs, readFormFields(formId)), formId)); + } catch (EmptyResultDataAccessException e) { + return Optional.empty(); + } + } + + /** + * Get a count of logged submissions for the given form. + * + * @param form form to get submission for + * @return A total number of logged submission + */ + public int getTotalSubmissionsCount(FormData form) { + Objects.requireNonNull(form); + return jdbcTemplate.queryForObject("select count(*) from `form_submissions` where `form_id` = ?", + (rs, _) -> rs.getInt(1), form.id()); + } + + /** + * Checks if a user already submitted the form. + * + * @param user user to check + * @param form form to check on + * @return true if the user has submitted at leas one submission, false + * otherwise + */ + public boolean hasSubmitted(User user, FormData form) { + try { + return jdbcTemplate.queryForObject( + "select * from `form_submissions` where `user_id` = ? and `form_id` = ? limit 1", (_, _) -> true, + user.getIdLong(), form.id()); + } catch (EmptyResultDataAccessException e) { + return false; + } + } + + /** + * Create a new form entry in the database. + * + * @param data form data to insert. + */ + public void insertForm(@NonNull FormData data) { + Objects.requireNonNull(data); + jdbcTemplate.update(con -> { + + Optional attachmentInfoOptional = data.getAttachmentInfo(); + + PreparedStatement statement = con.prepareStatement( + "insert into `forms` (title, submit_message, submit_channel, message_id, message_channel, expiration, onetime) values (?, ?, ?, ?, ?, ?, ?)"); + statement.setString(1, data.title()); + statement.setString(2, data.submitMessage()); + statement.setLong(3, data.submitChannel()); + statement.setObject(4, attachmentInfoOptional.map(FormAttachmentInfo::messageId).orElse(null)); + statement.setObject(5, attachmentInfoOptional.map(FormAttachmentInfo::messageChannelId).orElse(null)); + statement.setTimestamp(6, + data.hasExpirationTime() ? new Timestamp(data.expiration().toEpochMilli()) : null); + statement.setBoolean(7, data.onetime()); + return statement; + }); + } + + /** + * Add a user form submission to the database. + * + * @param user the user who submitted the form + * @param form form to log on + * @param message message containing details about this user's submission + */ + public void addSubmission(User user, FormData form, Message message) { + Objects.requireNonNull(user); + Objects.requireNonNull(form); + jdbcTemplate.update(con -> { + PreparedStatement statement = con.prepareStatement( + "insert into `form_submissions` (`message_id`, `user_id`, `form_id`, `user_name`) values (?, ?, ?, ?)"); + statement.setLong(1, message.getIdLong()); + statement.setLong(2, user.getIdLong()); + statement.setLong(3, form.id()); + statement.setString(4, user.getName()); + return statement; + }); + } + + /** + * Remove a field from a form. Fails and return false if the index is out of + * bounds. + * + * @param form form to remove the field from + * @param index index of the field to remove + * @return true if changes were successfully made to the database. + */ + public boolean removeField(FormData form, int index) { + List fields = form.fields(); + if (index < 0 || index >= fields.size()) return false; + return jdbcTemplate.update("delete from `form_fields` where `id` = ? and `form_id` = ?", fields.get(index).id(), + form.id()) > 0; + } + + /** + * Set this form's closed state to false. + * + * @param form form to re-open + */ + public void reopenForm(FormData form) { + jdbcTemplate.update("update `forms` set `closed` = false where `form_id` = ?", form.id()); + } + + /** + * Synchronizes form object's values with fields in database. + * + * @param newData new form data. A form with matching ID will be updated in the + * database. + */ + public void updateForm(FormData newData) { + Objects.requireNonNull(newData); + jdbcTemplate.update(con -> { + PreparedStatement statement = con.prepareStatement( + "update `forms` set `title` = ?, `submit_channel` = ?, `submit_message` = ?, `expiration` = ?, `onetime` = ? where `form_id` = ?"); + statement.setString(1, newData.title()); + statement.setLong(2, newData.submitChannel()); + statement.setString(3, newData.submitMessage()); + statement.setTimestamp(4, + newData.hasExpirationTime() ? new Timestamp(newData.expiration().toEpochMilli()) : null); + statement.setBoolean(5, newData.onetime()); + statement.setLong(6, newData.id()); + return statement; + }); + } + + private List readFormFields(long formId) { + return jdbcTemplate.query("select * from `form_fields` where `form_id` = ?", (rs, _) -> readField(rs), formId); + } + + private static FormData read(ResultSet rs, List fields) throws SQLException { + Long messageId = rs.getLong("message_id"); + if (rs.wasNull()) messageId = null; + Long messageChannel = rs.getLong("message_channel"); + if (rs.wasNull()) messageChannel = null; + Timestamp timestamp = rs.getTimestamp("expiration"); + Instant expiration = timestamp == null ? null : timestamp.toInstant(); + return new FormData(rs.getLong("form_id"), fields, rs.getString("title"), rs.getLong("submit_channel"), + rs.getString("submit_message"), messageId, messageChannel, expiration, rs.getBoolean("closed"), + rs.getBoolean("onetime")); + } + + private static FormField readField(ResultSet rs) throws SQLException { + return new FormField(rs.getString("label"), rs.getInt("max"), rs.getInt("min"), rs.getString("placeholder"), + rs.getBoolean("required"), TextInputStyle.valueOf(rs.getString("style").toUpperCase()), + rs.getString("initial"), rs.getInt("id")); + } +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormAttachmentInfo.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormAttachmentInfo.java new file mode 100644 index 000000000..2bb3e75f5 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormAttachmentInfo.java @@ -0,0 +1,14 @@ +package net.discordjug.javabot.systems.staff_commands.forms.model; + +/** + * Contains information about form's attachment state. In other words, if the + * form is attached to a message, this records contains the message's and its + * channel IDs. + * + * @param messageId id of the message the form is attached to. + * @param messageChannelId id of the message's channel + * + * @see FormData + */ +public record FormAttachmentInfo(long messageId, long messageChannelId) { +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormData.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormData.java new file mode 100644 index 000000000..fad77a730 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormData.java @@ -0,0 +1,129 @@ +package net.discordjug.javabot.systems.staff_commands.forms.model; + +import java.time.Instant; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import net.discordjug.javabot.systems.staff_commands.forms.FormInteractionManager; +import net.dv8tion.jda.api.components.label.Label; +import net.dv8tion.jda.api.modals.Modal; + +/** + * Class containing information about a form. + * + * @param id the form ID. + * @param fields a list of text input fields associated with this form. + * A form can only hold a maximum of 5 fields at a time. + * @param title form title used in the modal displayed to the user. + * @param submitChannel ID of the channel the form submissions are sent to. + * @param submitMessage message displayed to the user once they submit the + * form. + * @param messageId ID of the message this form is attached to. null if the + * form is not attached to any message. + * @param messageChannel channel of the message this form is attached to. null + * if the form is not attached to any message. + * @param expiration time after which this user won't accept any further + * submissions. null to indicate that the form has no + * expiration date. + * @param closed closed state of this form. If the form is closed, it + * doesn't accept further submissions, even if it's + * expired. + * @param onetime onetime state of this form. If it's true, the form only + * accepts one submission per user. + */ +public record FormData(long id, List fields, String title, long submitChannel, String submitMessage, + Long messageId, Long messageChannel, Instant expiration, boolean closed, boolean onetime) { + + /** + * The main constructor. + */ + public FormData { + Objects.requireNonNull(title); + fields = List.copyOf(fields); + if (fields.size() > Modal.MAX_COMPONENTS) { + throw new IllegalArgumentException("fields.size() > " + Modal.MAX_COMPONENTS); + } + } + + /** + * Get this form's submit message as an {@link Optional}. If the message is null + * or blank, the returned optional will be empty. + * + * @return optional submit message + */ + public Optional getOptionalSubmitMessage() { + if (submitMessage == null || submitMessage.isBlank()) { + return Optional.empty(); + } + return Optional.of(submitMessage); + } + + /** + * Get information about the form's attachment state. If the form is attached to + * a message, this method will return a non-empty optional containing + * information about the message this form is attached to. + * + * @return optional attachment info + */ + public Optional getAttachmentInfo() { + if (messageChannel != null && messageId != null) { + return Optional.of(new FormAttachmentInfo(messageId, messageChannel)); + } + return Optional.empty(); + } + + /** + * Creates text components for use in the submission modal. + * + * @return An unmodifiable list of layout components for use in the submission + * modal. + */ + public List