diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6ed308e --- /dev/null +++ b/.gitignore @@ -0,0 +1,49 @@ +.gradle +build/ + +### IntelliJ IDEA ### +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store!/.idea/ + +!/.gradle/ + +!/logs/ + +!/build/ + +!/database.db + +!/config.json diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..5dfb135 --- /dev/null +++ b/build.gradle @@ -0,0 +1,61 @@ +plugins { + id 'java' + id 'application' + id 'com.github.johnrengelman.shadow' version '7.1.2' +} + +group 'dev.ubujira' +version '1.0-SNAPSHOT' + +repositories { + mavenCentral() + maven { + name 'm2-dv8tion' + url 'https://m2.dv8tion.net/releases' + } + maven { + name 'ubujira' + url 'https://maven.ubujira.dev/' + } + maven { + name 'jitpack' + url 'https://jitpack.io' + } +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +application { + mainClassName = 'dev.ubujira.galaxusbot.GalaxusBot' +} + +configurations.all { + // Check for updates every build + resolutionStrategy.cacheChangingModulesFor 0, 'seconds' +} + +tasks.withType(JavaCompile).each { + it.options.compilerArgs.add('--enable-preview'); +} + +dependencies { + implementation "com.github.freya022:JDA:e3a42e2dcd7076adc935fff6f9c290401d6b6561" + implementation 'com.github.freya022:BotCommands:9b301d1ed44635bada36abf95b923351097e5c51' + implementation 'dev.ubujira:Galaxus-API:0.5.1' + implementation 'com.google.guava:guava:31.1-jre' + implementation 'org.apache.logging.log4j:log4j-api:2.17.2' + implementation 'org.apache.logging.log4j:log4j-core:2.17.2' + implementation 'org.postgresql:postgresql:42.3.4' + implementation 'com.zaxxer:HikariCP:5.0.1' + + compileOnly 'org.projectlombok:lombok:1.18.24' + annotationProcessor 'org.projectlombok:lombok:1.18.24' + testCompileOnly 'org.projectlombok:lombok:1.18.24' + testAnnotationProcessor 'org.projectlombok:lombok:1.18.24' + + implementation 'org.apache.logging.log4j:log4j-slf4j-impl:2.17.2' +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..41d9927 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..41dfb87 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..1b6c787 --- /dev/null +++ b/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..c12693a --- /dev/null +++ b/settings.gradle @@ -0,0 +1,2 @@ +rootProject.name = 'Galaxus-Bot' + diff --git a/src/main/java/dev/ubujira/galaxusbot/Config.java b/src/main/java/dev/ubujira/galaxusbot/Config.java new file mode 100644 index 0000000..51ce0ee --- /dev/null +++ b/src/main/java/dev/ubujira/galaxusbot/Config.java @@ -0,0 +1,73 @@ +package dev.ubujira.galaxusbot; + +import com.google.gson.*; +import lombok.Getter; +import lombok.extern.log4j.Log4j2; + +import java.io.*; + +@Log4j2 +public class Config { + + private final File configFile = new File("config.json"); + + @Getter + private String token; + + @Getter + private String dbPassword; + + public Config() { + if (!configFile.exists()) { + try { + configFile.createNewFile(); + JsonObject jsonObject = new JsonObject(); + jsonObject.addProperty("token", "token"); + jsonObject.addProperty("dbPassword", "password"); + save(); + log.info("Created config file!"); + System.exit(0); + } catch (IOException e) { + log.error("Failed to create config file!", e); + System.exit(1); + } + } else { + try { + JsonElement jsonElement = JsonParser.parseReader(new FileReader(configFile)); + if (jsonElement.isJsonObject()) { + JsonObject jsonObject = jsonElement.getAsJsonObject(); + if (jsonObject.has("token")) { + token = jsonObject.get("token").getAsString(); + } else { + log.error("Config file doesn't contain token! Delete it/create the value and restart"); + System.exit(1); + } + if (jsonObject.has("dbPassword")) { + dbPassword = jsonObject.get("dbPassword").getAsString(); + } else { + log.error("Config file doesn't contain dbPassword! Delete it/create the value and restart"); + System.exit(1); + } + } else { + log.error("Config file is not a json object!"); + System.exit(1); + } + } catch (FileNotFoundException e) { + log.error("Failed to read config file!", e); + } + } + } + + public void save() { + JsonObject jsonObject = new JsonObject(); + jsonObject.addProperty("token", ""); + jsonObject.addProperty("dbPassword", ""); + try (Writer writer = new FileWriter(configFile)) { + Gson gson = new GsonBuilder().setPrettyPrinting().create(); + gson.toJson(jsonObject, writer); + } catch (IOException e) { + log.error("Failed to save config file!", e); + } + } + +} diff --git a/src/main/java/dev/ubujira/galaxusbot/GalaxusBot.java b/src/main/java/dev/ubujira/galaxusbot/GalaxusBot.java new file mode 100644 index 0000000..1d1ecd1 --- /dev/null +++ b/src/main/java/dev/ubujira/galaxusbot/GalaxusBot.java @@ -0,0 +1,90 @@ +package dev.ubujira.galaxusbot; + +import com.freya02.botcommands.api.CommandsBuilder; +import com.freya02.botcommands.api.components.DefaultComponentManager; +import dev.ubujira.galaxusapi.GalaxusAPI; +import dev.ubujira.galaxusapi.Site; +import dev.ubujira.galaxusbot.database.DatabaseManager; +import dev.ubujira.galaxusbot.digitecgalaxus.ProductManager; +import dev.ubujira.galaxusbot.messager.MessagerManager; +import lombok.Getter; +import lombok.extern.log4j.Log4j2; +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.JDABuilder; + +import javax.security.auth.login.LoginException; +import java.io.IOException; +import java.sql.Connection; +import java.sql.SQLException; +import java.util.function.Supplier; + +@Log4j2 +public class GalaxusBot { + + @Getter + private static final GalaxusAPI galaxusAPI = new GalaxusAPI(Site.GALAXUS); + @Getter + private static final GalaxusAPI digitecAPI = new GalaxusAPI(Site.DIGITEC); + @Getter + private static DatabaseManager databaseManager; + @Getter + private static GalaxusBot instance; + @Getter + private static JDA jda; + @Getter + private static MessagerManager messagerManager; + @Getter + private static ProductManager productManager; + @Getter + private final Config config; + + public GalaxusBot() { + instance = this; + log.info("GalaxusBot version {} is starting...", GalaxusBot.class.getPackage().getImplementationVersion()); + + config = new Config(); + databaseManager = new DatabaseManager(); + + try { + jda = JDABuilder.createDefault(config.getToken()).build(); + try { + jda.awaitReady(); + + final CommandsBuilder commandsBuilder = CommandsBuilder.newBuilder(745697970124750948L); + Supplier connectionSupplier = () -> { + try { + return databaseManager.getConnection(); + } catch (SQLException e) { + log.error("Error while initializing commands builder", e); + } + return null; + }; + DefaultComponentManager componentManager = new DefaultComponentManager(connectionSupplier); + commandsBuilder.setComponentManager(componentManager); + try { + commandsBuilder.build(jda, "dev.ubujira.galaxusbot.commands"); + } catch (IOException e) { + log.error("Failed to load commands", e); + System.exit(1); + } + + messagerManager = new MessagerManager(); + productManager = new ProductManager(); + } catch (InterruptedException e) { + log.error("JDA await ready thread interrupted!", e); + System.exit(1); + } + } catch (LoginException e) { + log.error("Failed to login to Discord", e); + System.exit(1); + } + } + + public static void main(String[] args) { + /*GalaxusAPI galaxusAPI = new GalaxusAPI(); + PriceHistory priceHistory = galaxusAPI.getPriceHistory(2352460); + log.info(priceHistory.getCurrentPrice());*/ + + new GalaxusBot(); + } +} diff --git a/src/main/java/dev/ubujira/galaxusbot/commands/ProductCommand.java b/src/main/java/dev/ubujira/galaxusbot/commands/ProductCommand.java new file mode 100644 index 0000000..952dbe8 --- /dev/null +++ b/src/main/java/dev/ubujira/galaxusbot/commands/ProductCommand.java @@ -0,0 +1,66 @@ +package dev.ubujira.galaxusbot.commands; + +import com.freya02.botcommands.api.application.ApplicationCommand; +import com.freya02.botcommands.api.application.CommandPath; +import com.freya02.botcommands.api.application.annotations.AppOption; +import com.freya02.botcommands.api.application.slash.GuildSlashEvent; +import com.freya02.botcommands.api.application.slash.annotations.JDASlashCommand; +import dev.ubujira.galaxusapi.entities.ProductDetails; +import dev.ubujira.galaxusapi.entities.SearchResult; +import dev.ubujira.galaxusbot.GalaxusBot; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.interactions.commands.Command; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.List; + +public class ProductCommand extends ApplicationCommand { + + @Override + @NotNull + public List getOptionChoices(@Nullable Guild guild, @NotNull CommandPath commandPath, int optionIndex) { + if (optionIndex == 1) { // Site select option + return List.of( + new Command.Choice("Digitec", "Digitec"), + new Command.Choice("Galaxus", "Galaxus") + ); + } + + return List.of(); + } + + @JDASlashCommand( + name = "product", + description = "Get product information" + ) + public void onSlash(GuildSlashEvent event, @AppOption(name = "product") String product, @AppOption(name = "site") String site) { + SearchResult result; + + if (site.equalsIgnoreCase("Galaxus")) { + result = GalaxusBot.getGalaxusAPI().getSearchResults(product); + } else { + result = GalaxusBot.getDigitecAPI().getSearchResults(product); + } + + if (result.getResults().size() == 0) { + event.reply(":x: No results found").queue(); + } else { + SearchResult.Result result1 = result.getResults().get(0); + + ProductDetails details = site.toLowerCase().contains("galaxus") ? GalaxusBot.getGalaxusAPI().getProductDetailed(result1.getProductId()) : GalaxusBot.getDigitecAPI().getProductDetailed(result1.getProductId()); + + EmbedBuilder builder = new EmbedBuilder(); + builder.setTitle(details.getProduct().getBrandName() + " - " + details.getProduct().getName()); + builder.setDescription("Rating: " + details.getProduct().getAverageRating() + " (" + details.getProduct().getTotalRatings() + ")"); + + if (details.getProduct().getImages().size() != 0) { + builder.setThumbnail(details.getProduct().getImages().get(0).getUrl()); + } + + event.replyEmbeds(builder.build()).queue(); + } + } + +} diff --git a/src/main/java/dev/ubujira/galaxusbot/commands/SearchCommand.java b/src/main/java/dev/ubujira/galaxusbot/commands/SearchCommand.java new file mode 100644 index 0000000..6133b72 --- /dev/null +++ b/src/main/java/dev/ubujira/galaxusbot/commands/SearchCommand.java @@ -0,0 +1,77 @@ +package dev.ubujira.galaxusbot.commands; + +import com.freya02.botcommands.api.application.ApplicationCommand; +import com.freya02.botcommands.api.application.CommandPath; +import com.freya02.botcommands.api.application.CommandScope; +import com.freya02.botcommands.api.application.annotations.AppOption; +import com.freya02.botcommands.api.application.slash.GuildSlashEvent; +import com.freya02.botcommands.api.application.slash.annotations.JDASlashCommand; +import com.freya02.botcommands.api.components.InteractionConstraints; +import com.freya02.botcommands.api.pagination.paginator.Paginator; +import com.freya02.botcommands.api.pagination.paginator.PaginatorBuilder; +import dev.ubujira.galaxusapi.entities.SearchResult; +import dev.ubujira.galaxusbot.GalaxusBot; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.interactions.commands.Command; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; + +public class SearchCommand extends ApplicationCommand { + + @Override + @NotNull + public List getOptionChoices(@Nullable Guild guild, @NotNull CommandPath commandPath, int optionIndex) { + if (optionIndex == 1) { // Site select option + return List.of( + new Command.Choice("Digitec", "Digitec"), + new Command.Choice("Galaxus", "Galaxus") + ); + } + + return List.of(); + } + + @JDASlashCommand( + name = "search", + description = "Search for a product", + scope = CommandScope.GUILD + ) + public void onSlash(GuildSlashEvent event, @AppOption(name = "query") String query, @AppOption(name = "site") String site) { + List embedBuilders = new ArrayList<>(); + + SearchResult searchResult; + + if (site.equalsIgnoreCase("digitec")) { + searchResult = GalaxusBot.getDigitecAPI().getSearchResults(query); + } else { + searchResult = GalaxusBot.getGalaxusAPI().getSearchResults(query); + } + + if (searchResult.getResults().isEmpty() || searchResult.getResultsCount() == 0) { + event.reply("No results found").queue(); + } else { + for (int i = 0; i < searchResult.getResults().size(); i++) { + SearchResult.Result result = searchResult.getResults().get(i); + EmbedBuilder embedBuilder = new EmbedBuilder(); + embedBuilder.setTitle(result.getBrandName() + " - " + result.getProductName()); + embedBuilder.setDescription("Price: " + result.getPrice() + " CHF"); + embedBuilder.setImage(result.getImageUrl()); + embedBuilder.setFooter("Page " + (i + 1) + " of " + searchResult.getResultsCount()); + embedBuilders.add(embedBuilder); + } + + final Paginator paginator = new PaginatorBuilder() + .setConstraints(InteractionConstraints.ofUsers(event.getUser())) + .useDeleteButton(true) + .setMaxPages(searchResult.getResults().size()) + .setPaginatorSupplier((instance, messageBuilder, components, page) -> embedBuilders.get(page).build()) + .build(); + + event.reply(paginator.get()).queue(); + } + } +} diff --git a/src/main/java/dev/ubujira/galaxusbot/commands/SubscribeCommand.java b/src/main/java/dev/ubujira/galaxusbot/commands/SubscribeCommand.java new file mode 100644 index 0000000..3739e02 --- /dev/null +++ b/src/main/java/dev/ubujira/galaxusbot/commands/SubscribeCommand.java @@ -0,0 +1,56 @@ +package dev.ubujira.galaxusbot.commands; + +import com.freya02.botcommands.api.application.ApplicationCommand; +import com.freya02.botcommands.api.application.CommandScope; +import com.freya02.botcommands.api.application.annotations.AppOption; +import com.freya02.botcommands.api.application.slash.GuildSlashEvent; +import com.freya02.botcommands.api.application.slash.annotations.JDASlashCommand; +import dev.ubujira.galaxusbot.GalaxusBot; +import dev.ubujira.galaxusbot.digitecgalaxus.ProductManager; +import net.dv8tion.jda.api.EmbedBuilder; + +public class SubscribeCommand extends ApplicationCommand { + + @JDASlashCommand( + name = "subscribe", + description = "Subscribes you to product price updates", + scope = CommandScope.GUILD + ) + public void onSlash(GuildSlashEvent event, + @AppOption(name = "producturl", description = "Link to product") String productUrl) { + event.deferReply(true).queue(deferred -> { + String shopSite = productUrl.substring("https://".length()); + if (shopSite.startsWith("galaxus.ch") || shopSite.startsWith("www.galaxus.ch") || shopSite.startsWith("digitec.ch") || shopSite.startsWith("www.digitec.ch")) { + String shop = shopSite.substring(0, shopSite.indexOf("/")); + int productId = 0; + String[] productIdSplit = shopSite.split("-"); + String productIdStr = productIdSplit[productIdSplit.length - 1]; + if (productIdStr.contains("?")) { + productIdStr = productIdStr.split("\\?")[0]; + } + productId = Integer.parseInt(productIdStr); + + if (!GalaxusBot.getDatabaseManager().isSubscribed(event.getUser().getIdLong(), productId)) { + GalaxusBot.getDatabaseManager().subscribe(event.getUser().getIdLong(), productId, shop); + + ProductManager.Product product = GalaxusBot.getProductManager().addProduct(productId, shop); + + EmbedBuilder builder = new EmbedBuilder(); + builder.setTitle("Subscribed to **" + product.getBrand() + " - " + product.getName() + "**"); + builder.setDescription("You will be notified when the price of this product changes."); + builder.setImage(product.getUrl()); + builder.setFooter("Product ID: " + productId); + deferred.editOriginalEmbeds(builder.build()).queue(); + + //Get actual product and return name + //event.reply("Subscribed to product " + productDetails.getProduct().getBrandName() + "").setEphemeral(true).queue(); + } else { + deferred.editOriginal("You are already subscribed to product " + productId).queue(); + } + } else { + deferred.editOriginal("Sorry, I can't subscribe you to this product.").queue(); + } + }); + } + +} diff --git a/src/main/java/dev/ubujira/galaxusbot/commands/UnsubscribeCommand.java b/src/main/java/dev/ubujira/galaxusbot/commands/UnsubscribeCommand.java new file mode 100644 index 0000000..dd0df91 --- /dev/null +++ b/src/main/java/dev/ubujira/galaxusbot/commands/UnsubscribeCommand.java @@ -0,0 +1,49 @@ +package dev.ubujira.galaxusbot.commands; + +import com.freya02.botcommands.api.application.ApplicationCommand; +import com.freya02.botcommands.api.application.CommandScope; +import com.freya02.botcommands.api.application.annotations.AppOption; +import com.freya02.botcommands.api.application.slash.GuildSlashEvent; +import com.freya02.botcommands.api.application.slash.annotations.JDASlashCommand; +import dev.ubujira.galaxusbot.GalaxusBot; +import dev.ubujira.galaxusbot.digitecgalaxus.ProductManager; + +public class UnsubscribeCommand extends ApplicationCommand { + + @JDASlashCommand( + name = "unsubscribe", + description = "Unsubscribe from a product price update", + scope = CommandScope.GUILD + ) + public void onSlash(GuildSlashEvent event, + @AppOption(name = "producturl", description = "Link to product") String productUrl) { + String shopSite = productUrl.substring("https://".length()); + if (shopSite.startsWith("galaxus.ch") || shopSite.startsWith("www.galaxus.ch") || shopSite.startsWith("digitec.ch") || shopSite.startsWith("www.digitec.ch")) { + int productId = 0; + String[] productIdSplit = shopSite.split("-"); + String productIdStr = productIdSplit[productIdSplit.length - 1]; + if (productIdStr.contains("?")) { + productIdStr = productIdStr.split("\\?")[0]; + } + productId = Integer.parseInt(productIdStr); + + ProductManager.Product product = GalaxusBot.getProductManager().getProductHashMap().get(productId); + + if (product != null) { + if (GalaxusBot.getDatabaseManager().isSubscribed(event.getUser().getIdLong(), productId)) { + GalaxusBot.getDatabaseManager().unsubscribe(event.getUser().getIdLong(), productId); + //Get actual product and return name + event.reply("Unsubscribed from product **" + product.getBrand() + " " + product.getName() + "**.").setEphemeral(true).queue(); + } else { + event.reply("You are not subscribed to product **" + product.getBrand() + " " + product.getName() + "**.").setEphemeral(true).queue(); + } + } + + + } else { + event.reply("Sorry, I can't unsubscribe you from this product.").setEphemeral(true).queue(); + } + + } + +} diff --git a/src/main/java/dev/ubujira/galaxusbot/commands/UpdateCommand.java b/src/main/java/dev/ubujira/galaxusbot/commands/UpdateCommand.java new file mode 100644 index 0000000..bb7fde8 --- /dev/null +++ b/src/main/java/dev/ubujira/galaxusbot/commands/UpdateCommand.java @@ -0,0 +1,28 @@ +package dev.ubujira.galaxusbot.commands; + +import com.freya02.botcommands.api.annotations.UserPermissions; +import com.freya02.botcommands.api.application.ApplicationCommand; +import com.freya02.botcommands.api.application.annotations.AppOption; +import com.freya02.botcommands.api.application.slash.GuildSlashEvent; +import com.freya02.botcommands.api.application.slash.annotations.JDASlashCommand; +import dev.ubujira.galaxusbot.GalaxusBot; +import dev.ubujira.galaxusbot.digitecgalaxus.ProductManager; +import net.dv8tion.jda.api.Permission; + +@UserPermissions(Permission.ADMINISTRATOR) +public class UpdateCommand extends ApplicationCommand { + + @JDASlashCommand( + name = "update", + description = "Update a product" + ) + public void onSlashUpdate(GuildSlashEvent event, @AppOption(name = "price") double price, @AppOption(name = "productid") int productId) { + ProductManager.Product product = GalaxusBot.getProductManager().getProductHashMap().get(productId); + if (product != null) { + product.setPrice((float) price); + GalaxusBot.getDatabaseManager().updateProduct(productId, product); + event.reply("Product updated").queue(); + } + } + +} diff --git a/src/main/java/dev/ubujira/galaxusbot/database/DatabaseManager.java b/src/main/java/dev/ubujira/galaxusbot/database/DatabaseManager.java new file mode 100644 index 0000000..8e8a349 --- /dev/null +++ b/src/main/java/dev/ubujira/galaxusbot/database/DatabaseManager.java @@ -0,0 +1,189 @@ +package dev.ubujira.galaxusbot.database; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import dev.ubujira.galaxusbot.GalaxusBot; +import dev.ubujira.galaxusbot.digitecgalaxus.ProductManager; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.log4j.Log4j2; +import org.postgresql.ds.PGSimpleDataSource; + +import java.sql.*; + +@Log4j2 +public class DatabaseManager { + + private static final HikariConfig config = new HikariConfig(); + private static final HikariDataSource dataSource; + + static { + config.setDataSource(new PGSimpleDataSource()); + //config.setJdbcUrl("jdbc:postgresql://localhost/galaxusbot"); + config.setUsername("galaxusbot"); + config.setPassword(GalaxusBot.getInstance().getConfig().getDbPassword()); + config.addDataSourceProperty("cachePrepStmts", "true"); + config.addDataSourceProperty("prepStmtCacheSize", "250"); + config.addDataSourceProperty("prepStmtCacheSqlLimit", "2048"); + dataSource = new HikariDataSource(config); + } + + public DatabaseManager() { + log.info("Loading database..."); + + //Create table subscribed_products if not exists + log.debug("Creating table subscribed_products if not exists"); + try { + getConnection().createStatement().execute("CREATE TABLE IF NOT EXISTS subscribed_products (user_id bigint, product_id bigint, store TEXT)"); + } catch (SQLException e) { + log.error("Failed to create table subscribed_products", e); + System.exit(1); + } + + //Create table products if not exists + log.debug("Creating table products if not exists"); + try { + getConnection().createStatement().execute("CREATE TABLE IF NOT EXISTS products (product_id bigint, name TEXT, brand TEXT, pictureUrl TEXT, price FLOAT, store TEXT)"); + } catch (SQLException e) { + log.error("Failed to create table products", e); + System.exit(1); + } + + } + + public static Connection getConnectionNoThrow() { + try { + return dataSource.getConnection(); + } catch (SQLException e) { + log.error("Failed to get connection", e); + } + return null; + } + + public boolean subscribe(long userId, int productId, String store) { + try { + try (PreparedStatement statement = getConnection().prepareStatement("INSERT INTO subscribed_products (user_id, product_id, store) VALUES (?, ?, ?)")) { + statement.setLong(1, userId); + statement.setInt(2, productId); + statement.setString(3, store); + statement.executeUpdate(); + } + } catch (SQLException e) { + log.error("Failed to subscribe user {} to product {}", userId, productId, e); + return false; + } + GalaxusBot.getProductManager().getSubscribedProductsMap().put(userId, productId); + return true; + } + + public boolean unsubscribe(long userId, int productId) { + try { + try (PreparedStatement statement = getConnection().prepareStatement("DELETE FROM subscribed_products WHERE user_id = ? AND product_id = ?")) { + statement.setLong(1, userId); + statement.setInt(2, productId); + statement.executeUpdate(); + } + } catch (SQLException e) { + log.error("Failed to unsubscribe user {} to product {}", userId, productId, e); + return false; + } + GalaxusBot.getProductManager().getSubscribedProductsMap().remove(userId, productId); + + //Loop over subscribed products and check if no one is subscribed to this product + if (!GalaxusBot.getProductManager().getSubscribedProductsMap().containsValue(productId)) { + //If no one is subscribed to this product, remove it from the product map + GalaxusBot.getProductManager().removeProduct(productId); + } + + return true; + } + + public boolean isSubscribed(long userId, int productId) { + return GalaxusBot.getProductManager().getSubscribedProductsMap().containsKey(userId) && + GalaxusBot.getProductManager().getSubscribedProductsMap().get(userId).contains(productId); + } + + public void updateProduct(int productId, ProductManager.Product product) { + if (GalaxusBot.getProductManager().getProductHashMap().containsKey(productId)) { + log.debug("Product {} already exists, updating it", productId); + try { + try (PreparedStatement statement = getConnection().prepareStatement("UPDATE products SET name = ?, brand = ?, pictureURL = ?, price = ? WHERE product_id = ?")) { + statement.setString(1, product.getName()); + statement.setString(2, product.getBrand()); + statement.setString(3, product.getUrl()); + statement.setFloat(4, product.getPrice()); + statement.setInt(5, productId); + statement.executeUpdate(); + } + log.debug("Updated product {}", productId); + } catch (SQLException e) { + log.error("Failed to update product {}", productId, e); + } + } else { + log.debug("Product {} does not exist, adding it", productId); + try { + try (PreparedStatement statement = getConnection().prepareStatement("INSERT INTO products (product_id, name, brand, pictureURL, price, store) VALUES (?, ?, ?, ?, ?, ?)")) { + statement.setInt(1, productId); + statement.setString(2, product.getName()); + statement.setString(3, product.getBrand()); + statement.setString(4, product.getUrl()); + statement.setFloat(5, product.getPrice()); + statement.setString(6, product.getStore()); + statement.executeUpdate(); + } + log.debug("Added new product {}", productId); + } catch (SQLException e) { + log.error("Failed to insert new product {}", productId, e); + } + } + } + + public void deleteProduct(int productId) { + try { + try (PreparedStatement statement = getConnection().prepareStatement("DELETE FROM products WHERE product_id = ?")) { + statement.setInt(1, productId); + statement.executeUpdate(); + } + log.debug("Deleted product {}", productId); + } catch (SQLException e) { + log.error("Failed to delete product {}", productId, e); + } + } + + public ResultSet fetchProducts() { + try { + Statement statement = getConnection().createStatement(); + return statement.executeQuery("SELECT * FROM products"); + } catch (SQLException e) { + log.error("Failed to fetch products", e); + return null; + } + } + + public ResultSet fetchSubscribedProducts() { + try { + Statement statement = getConnection().createStatement(); + return statement.executeQuery("SELECT * FROM subscribed_products"); + } catch (SQLException e) { + log.error("Failed to fetch subscribed products", e); + return null; + } + } + + public Connection getConnection() throws SQLException { + return dataSource.getConnection(); + } + + @Getter + @Setter + @AllArgsConstructor + public static class Product { + private final int productId; + private final String name; + private final String brand; + private final String url; + private final float price; + } + +} diff --git a/src/main/java/dev/ubujira/galaxusbot/digitecgalaxus/ProductManager.java b/src/main/java/dev/ubujira/galaxusbot/digitecgalaxus/ProductManager.java new file mode 100644 index 0000000..300268e --- /dev/null +++ b/src/main/java/dev/ubujira/galaxusbot/digitecgalaxus/ProductManager.java @@ -0,0 +1,166 @@ +package dev.ubujira.galaxusbot.digitecgalaxus; + +import com.google.common.collect.HashMultimap; +import dev.ubujira.galaxusapi.entities.PriceHistory; +import dev.ubujira.galaxusapi.entities.ProductDetails; +import dev.ubujira.galaxusbot.GalaxusBot; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.log4j.Log4j2; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +@Log4j2 +public class ProductManager { + + @Getter + private final HashMap productHashMap = new HashMap<>(); + + // User Id (Discord), Product Id(Galaxus/Digitec) + @Getter + private final HashMultimap subscribedProductsMap = HashMultimap.create(); + + public ProductManager() { + //Fetch all relevant tables and add them to the hashmaps + try (ResultSet products = GalaxusBot.getDatabaseManager().fetchProducts()) { + while (!products.isClosed() && products.next()) { + var productId = products.getInt("product_id"); + log.debug("Fetched product: " + productId); + ProductManager.Product product = new ProductManager.Product( + productId, + products.getString("name"), + products.getString("brand"), + products.getString("pictureurl"), + products.getFloat("price"), + products.getString("store") + ); + log.debug("Product: " + product.getBrand() + " - " + product.getName() + " - " + product.getPrice() + " - " + product.getUrl() + " - " + product.getProductId()); + productHashMap.put(productId, product); + } + log.info("Loaded {} total products", productHashMap.size()); + } catch (SQLException e) { + log.error("Error fetching products from database", e); + } + + try (ResultSet subscribedProducts = GalaxusBot.getDatabaseManager().fetchSubscribedProducts()) { + while (!subscribedProducts.isClosed() && subscribedProducts.next()) { + int productId = subscribedProducts.getInt("product_id"); + long userId = subscribedProducts.getLong("user_id"); + + subscribedProductsMap.put(userId, productId); + } + log.info("Loaded {} total subscribed products", subscribedProductsMap.size()); + log.info("Loaded {} subscribing users and {} subscribed products", subscribedProductsMap.keySet().size(), subscribedProductsMap.values().size()); + } catch (SQLException e) { + log.error("Error fetching subscribed products from database", e); + } + + ScheduledExecutorService executorService = Executors.newSingleThreadScheduledExecutor(); + executorService.scheduleAtFixedRate(() -> { + log.info("Updating products."); + updateProducts(); + }, 0, 1, TimeUnit.HOURS); + } + + public Product addProduct(int productId, String store) { + + ProductDetails productDetails; + + if (store.toLowerCase().contains("galaxus")) { + productDetails = GalaxusBot.getGalaxusAPI().getProductDetailed(productId); + } else { + productDetails = GalaxusBot.getDigitecAPI().getProductDetailed(productId); + } + + if (productDetails != null) { + PriceHistory priceHistory = GalaxusBot.getGalaxusAPI().getPriceHistory(productId); + + ProductDetails.Product product = productDetails.getProduct(); + Product productInternal = new Product(product.getProductId(), + product.getName(), + product.getBrandName(), + product.getImages().get(0).getUrl(), + priceHistory.getCurrentPrice(), + store); + + GalaxusBot.getDatabaseManager().updateProduct(productId, productInternal); + + productHashMap.put(productId, productInternal); + return productInternal; + } else { + log.error("Product not found: " + productId); + } + return null; + } + + public void updateProducts() { + List updatedProducts = new ArrayList<>(); + + log.info("Updating " + productHashMap.size() + " products."); + + for (Product product : productHashMap.values()) { + PriceHistory priceHistory; + + if (product.getStore().toLowerCase().contains("galaxus")) { + priceHistory = GalaxusBot.getGalaxusAPI().getPriceHistory(product.getProductId()); + } else { + priceHistory = GalaxusBot.getDigitecAPI().getPriceHistory(product.getProductId()); + } + + float oldPrice = product.getPrice(); + + if (priceHistory != null && product.getPrice() != priceHistory.getCurrentPrice()) { + log.debug("Price changed for " + product.getName() + " - " + product.getPrice() + " -> " + priceHistory.getCurrentPrice()); + product.setPrice(priceHistory.getCurrentPrice()); + + GalaxusBot.getDatabaseManager().updateProduct(product.getProductId(), product); + log.debug("Product updated in database"); + + UpdatedProduct updatedProduct = new UpdatedProduct(product.getProductId(), product, product.getPrice(), oldPrice); + updatedProducts.add(updatedProduct); + log.debug("Product added to updated list"); + } + } + + if (!updatedProducts.isEmpty()) { + log.info("Sending {} updated products to subscribers", updatedProducts.size()); + GalaxusBot.getMessagerManager().processUpdatedPrices(updatedProducts); + } + } + + //Should be called when a product has no subscribers + public void removeProduct(int productId) { + GalaxusBot.getDatabaseManager().deleteProduct(productId); + productHashMap.remove(productId); + } + + @Getter + @AllArgsConstructor + public static class UpdatedProduct { + private final int productId; + private final Product product; + private final float price; + private final float oldPrice; + } + + @Getter + @Setter + @AllArgsConstructor + public static class Product { + private final int productId; + private final String name; + private final String brand; + private final String url; + private float price; + private String store; + } + +} diff --git a/src/main/java/dev/ubujira/galaxusbot/messager/MessagerManager.java b/src/main/java/dev/ubujira/galaxusbot/messager/MessagerManager.java new file mode 100644 index 0000000..89a5b76 --- /dev/null +++ b/src/main/java/dev/ubujira/galaxusbot/messager/MessagerManager.java @@ -0,0 +1,70 @@ +package dev.ubujira.galaxusbot.messager; + +import com.google.common.collect.HashMultimap; +import dev.ubujira.galaxusbot.GalaxusBot; +import dev.ubujira.galaxusbot.digitecgalaxus.ProductManager; +import lombok.extern.log4j.Log4j2; +import net.dv8tion.jda.api.EmbedBuilder; + +import java.awt.*; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.List; + +@Log4j2 +public class MessagerManager { + + public MessagerManager() { + + } + + public void processUpdatedPrices(List productUpdate) { + //First get all users and map them to a product + //Then get the price of the product and send it to the user + log.info("Processing updated prices"); + + HashMultimap users = HashMultimap.create(); + HashMultimap multimap = GalaxusBot.getProductManager().getSubscribedProductsMap(); + + for (ProductManager.UpdatedProduct update : productUpdate) { + log.debug("Processing product update for product {}", update.getProductId()); + multimap.forEach((userId, productId) -> { + log.debug("Checking if user {} is subscribed to product {}", userId, productId); + if (productId == update.getProductId()) { + log.debug("Found user {} subscribed to product {}", userId, productId); + users.put(userId, update); + } + }); + } + + log.info("Found " + users.size() + " users"); + + users.forEach((userId, update) -> { + log.debug("Sending message to user {}", userId); + EmbedBuilder builder = new EmbedBuilder(); + builder.setTitle("Product price update!"); + builder.setDescription("**" + update.getProduct().getBrand() + " - " + update.getProduct().getName() + "** is now **" + roundPrice(update.getPrice()) + " CHF**!\nIt was **" + roundPrice(update.getOldPrice()) + " CHF** before."); + builder.setColor(Color.BLUE); + builder.setImage(update.getProduct().getUrl()); + if (userId != null) { + GalaxusBot.getJda().openPrivateChannelById(userId).queue(channel -> { + channel.sendMessage("Product **" + update.getProduct().getBrand() + " - " + update.getProduct().getName() + "** price changed!").setEmbeds(builder.build()).queue(null, error -> { + log.error("Error sending message to user {}", userId); + }); + }, error -> { + log.error("Error sending message to user {}", userId); + }); + } else { + log.error("User id {} is null", userId); + } + }); + + log.info("Finished processing updated prices"); + } + + private double roundPrice(double price) { + BigDecimal bigDecimal = new BigDecimal(price).setScale(2, RoundingMode.HALF_UP); + return bigDecimal.doubleValue(); + } + +} diff --git a/src/main/resources/log4j2.xml b/src/main/resources/log4j2.xml new file mode 100644 index 0000000..dd165ed --- /dev/null +++ b/src/main/resources/log4j2.xml @@ -0,0 +1,27 @@ + + + + + + %d %p %c{1.} [%t] %m%n + + + + + + + + + + %d %p %c{1.} [%t] %m%n + + + + + + + + + + \ No newline at end of file