From e27bccc83686f64bdbefaa36e1b3d7c3fd09c055 Mon Sep 17 00:00:00 2001 From: ByteDream Date: Sat, 14 Nov 2020 00:14:31 +0100 Subject: [PATCH] Initial commit --- Dockerfile | 32 + README.md | 159 +++++ dockerfiles/database.sql | 31 + dockerfiles/run.sh | 14 + src/org/bytedream/untisbot/Crypt.java | 87 +++ src/org/bytedream/untisbot/Main.java | 201 ++++++ src/org/bytedream/untisbot/Utils.java | 79 +++ src/org/bytedream/untisbot/data/Data.java | 163 +++++ .../untisbot/data/DataConnector.java | 571 +++++++++++++++ .../bytedream/untisbot/data/StoreType.java | 12 + .../bytedream/untisbot/discord/Discord.java | 43 ++ .../discord/DiscordCommandListener.java | 665 ++++++++++++++++++ src/org/bytedream/untisbot/language.json | 40 ++ .../bytedream/untisbot/resources/logback.xml | 21 + .../untisbot/untis/CheckCallback.java | 89 +++ .../untisbot/untis/TimetableChecker.java | 171 +++++ 16 files changed, 2378 insertions(+) create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 dockerfiles/database.sql create mode 100755 dockerfiles/run.sh create mode 100644 src/org/bytedream/untisbot/Crypt.java create mode 100644 src/org/bytedream/untisbot/Main.java create mode 100644 src/org/bytedream/untisbot/Utils.java create mode 100644 src/org/bytedream/untisbot/data/Data.java create mode 100644 src/org/bytedream/untisbot/data/DataConnector.java create mode 100644 src/org/bytedream/untisbot/data/StoreType.java create mode 100644 src/org/bytedream/untisbot/discord/Discord.java create mode 100644 src/org/bytedream/untisbot/discord/DiscordCommandListener.java create mode 100644 src/org/bytedream/untisbot/language.json create mode 100644 src/org/bytedream/untisbot/resources/logback.xml create mode 100644 src/org/bytedream/untisbot/untis/CheckCallback.java create mode 100644 src/org/bytedream/untisbot/untis/TimetableChecker.java diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..79fd7f6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +FROM yobasystems/alpine-mariadb:latest + +ENV token "" +ENV password "toor" +ENV encrypt "" + +ENV MYSQL_DATABASE "Untis" +ENV MYSQL_ROOT_PASSWORD $password + +RUN mkdir /untisbot-discord/ && \ + mkdir /untisbot-discord/lib && \ + mkdir /untisbot-discord/out && \ + mkdir /untisbot-discord/src + +RUN apk add --no-cache openjdk8 curl && \ + rm -f /var/cache/apk/* + +RUN wget -O /untisbot-discord/lib/logback-core.jar https://repo1.maven.org/maven2/ch/qos/logback/logback-core/1.2.3/logback-core-1.2.3.jar && \ + wget -O /untisbot-discord/lib/logback-classic.jar https://repo1.maven.org/maven2/ch/qos/logback/logback-classic/1.2.3/logback-classic-1.2.3.jar && \ + wget -O /untisbot-discord/lib/mariadb-java-client.jar https://downloads.mariadb.com/Connectors/java/connector-java-2.7.0/mariadb-java-client-2.7.0.jar && \ + wget -O /untisbot-discord/lib/untis4j.jar $(curl -s https://api.github.com/repos/ByteDream/untis4j/releases/latest | grep "browser_download_url" | grep "withDependencies.jar" | cut -d '"' -f 4) && \ + wget -O /untisbot-discord/lib/JDA.jar $(curl -s https://api.github.com/repos/DV8FromTheWorld/JDA/releases/latest | grep "browser_download_url" | grep "withDependencies-min.jar" | cut -d '"' -f 4) + +ADD dockerfiles/run.sh /untisbot-discord/ +ADD dockerfiles/database.sql /untisbot-discord/ +ADD src/ /untisbot-discord/src + +EXPOSE 3306 + +VOLUME ["/var/lib/mysql"] + +ENTRYPOINT ["/untisbot-discord/run.sh"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ecf5dcd --- /dev/null +++ b/README.md @@ -0,0 +1,159 @@ +### UntisBot + +**UntisBot** is a java programmed discord bot, which uses the [WebUntis](https://webuntis.com/) timetable software / api to automatically sends messages when the timetable from a given account or class changes. +You can invite the bot right [here](https://github.com/ByteDream/untisbot-discord/releases/tag/v1.0/UntisBot-1.0.jar) or [host it yourself](#Self-hosting). + +## Commands + +The default prefix for the bot is `!untis `, so you have to call every command with `!untis `. + +To see all available commands and get infos about it, simply type `help` + +`channel` - In the channel where this command is entered, the bot shows the timetable changes | eg. `channel`. + +`clear` - Clears the given untis data, given from the `data` command | eg. `clear`. + +`data [class name]` - Sets the data with which the bot logs in to untis and checks for timetable changes. The data is stored encrypted on the server. +`username` and `password` are the normal untis login data with which one also logs in to the untis website / app. To gain the login page url you have to go to webuntis.com, type in your school and choose it. +Then you will be redirected to the untis login page, The url of this page is the login page url, for example `https://example.webuntis.com/WebUntis/?school=myschool#/basic/main`. +`class name` is just the name of the class you want to check (eg. `12AB`). If `class name` is not specified, the bot tries to get the default class which is assigned to the given account. + +eg. `data myname secure https://example.webuntis.com/WebUntis/?school=example#/basic/main 12AB`. + +`help ` - Displays help to a given command | eg. `help data`. + +`language ` - Changes the language in which the timetable information are displayed. Currently only 'de' (german) and 'en' (english) are supported | eg. `language de` | default: `en`. + +`prefix ` - Changes the prefix with which commands are called | eg. `prefix $` | default: `!untis `. + +`stats` - Displays a message with some stats (total cancelled lessons, etc.) | eg. `stats`. + +`<>` = required; `[]` = optional + +Note: All commands except for `help ` and `` can only be executed by a member with admin rights. + +## Self-hosting + +If you want to host **UntisBot** on your own server / pc you have the choice between two types of hosting: + - Run the bot in a [docker container](#Docker) + - Run it [natively](#Natively) on your machine + +## Docker + +Download this repository with `git clone https://github.com/ByteDream/untisbot-discord.git` and go into the cloned directory. +Then run `docker build -t untisbot-discord .` to build the docker image and if this is done, type `docker run -d --name untisbot-discord -e token= untisbot-discord` to run it. + +Note: You can declare more [environment variables](#Run-options-for-docker-container) besides `token`. + +## Natively + +When you run the bot natively you can choose from 2 types of data storage: + - [In-memory](#In-memory-storage) (simpler) + - [Database storage](#MariaDB) (MariaDB) + +### In-memory storage + +In memory data storage is pretty simple: Just download the [jar]() and run it with `java -jar UntisBot-1.0.jar token=`. +The simple things have unfortunately also often disadvantages: The user data is only stored as long as the bot is running. If you shut it down, all data will be lost. +If you want to keep the data even after a shutdown, you should use [database storage](#MariaDB). + +### MariaDB + +**_Note_: This description is only for linux, but the most things should also work on windows** + +With MariaDB you can store the data safely in a sql database, and they won't be lost after a shutdown. + +If you haven't installed MariaDB, you can follow the instructions from [here](https://linuxize.com/post/how-to-install-mariadb-on-ubuntu-18-04/) (this tutorial is for ubuntu, but it should work with every debian distro). + +To set up the database, you have two options to choose from. + +##### The short one: +```bash +mysql --user= --password= -e "CREATE DATABASE Untis;" && https://raw.githubusercontent.com/ByteDream/untisbot-discord/master/src/org/bytedream/untisbot/dockerfiles/database.sql | mysql --user= --password= Untis +``` +Just copy this and replace `` with the sql user which should manage the database and `` with the user's password. + +--- + +##### And the long one: + +First you have to connect you with MariaDB. When you are connected, enter the following commands (without the '>'): +```sql +> CREATE DATABASE Untis; +> USE Untis; +> CREATE TABLE Guilds (GUILDID BIGINT NOT NULL, LANGUAGE TINYTEXT, USERNAME TINYTEXT, PASSWORD TEXT, SERVER TINYTEXT, SCHOOL TINYTEXT, KLASSEID SMALLINT, CHANNELID BIGINT, PREFIX VARCHAR(7) DEFAULT '!untis ' NOT NULL, SLEEPTIME BIGINT DEFAULT 3600000 NOT NULL, ISCHECKACTIVE BOOLEAN DEFAULT FALSE NOT NULL, LASTCHECKED DATE); +> CREATE TABLE Stats (GUILDID BIGINT NOT NULL, TOTALREQUESTS INT DEFAULT 0 NOT NULL, TOTALDAYS SMALLINT DEFAULT 0 NOT NULL, TOTALLESSONS INT DEFAULT 0 NOT NULL, TOTALCANCELLEDLESSONS SMALLINT DEFAULT 0 NOT NULL, TOTALMOVEDLESSONS SMALLINT DEFAULT 0 NOT NULL, AVERAGECANCELLEDLESSONS FLOAT DEFAULT 0 NOT NULL, AVERAGEMOVEDLESSONS FLOAT DEFAULT 0 NOT NULL); +> CREATE TABLE AbsentTeachers (GUILDID BIGINT NOT NULL, TEACHERNAME TINYTEXT NOT NULL, ABSENTLESSONS SMALLINT NOT NULL); +``` + +--- + +Now you have set up the database and are ready to go. Download the [jar]() and run it with `java -jar UntisBot-1.0.jar mariadb`. + +## Run options + +The syntax of the following arguments / run option is very simple: `key=value`. + +### Run options for docker container + +When you start the container you can declare several environment variables: + - `token` (required!) - The discord bot token + - `password` (optional) - Password for the given user | default: `toor` + - `encrypt` (optional) - A password to encrypt the user's untis username and password | default: `password` + +(always remember when declaring a new environment variable `-e` must be prefixed) + +--- + +Example: + - `docker run -d -e token=BLySFrzvz3tAHtquQevY1FF5W8CT0UMyMNmCSUCbJAPdNAmnnqYVBzaPTkz -e password=very_secure untisbot-discord` + +### Run options for native hosting + +There are several arguments to start the bot with: + - `token` (required!) - The discord bot token + - `encrypt` (optional) - A password to encrypt the user's untis username and password | default: `password` + - `lng` (optional) - Path to a language file | default: `` (uses the [internal](src/org/bytedream/untisbot/language.json) language file) + +The following arguments are only for MariaDB user: + - `user` (optional) - The user who should connect to the mariadb database | default: `root` + - `password` (optional) - Password for the given mariadb user | default: `` + - `port` (optional) - Port of mariadb | default: `3306` + - `ip` (optional) - IP address of mariadb | default: `127.0.0.1` + +If you want to use MariaDB as store type you have to add the argument `mariadb` (without any value). + +--- + +Alternatively, you can write the arguments in a `json` file and load this via `java -jar UntisBot-1.0.jar file=` + +Example: +```json +{ + "token": "BLySFrzvz3tAHtquQevY1FF5W8CT0UMyMNmCSUCbJAPdNAmnnqYVBzaPTkz", + "lng": "lng.json" +} +``` + +If you use this, you can still declare arguments on the command line, but if they are also in the json file, they will be overwritten. +This might be useful when you run the bot on a server and won't that your token or other args are shown when, for example, [htop](https://github.com/htop-dev/htop/) is running where you can see the cli arguments. + +--- + +In-memory examples: + - `UntisBot-1.0.jar token=BLySFrzvz3tAHtquQevY1FF5W8CT0UMyMNmCSUCbJAPdNAmnnqYVBzaPTkz` + - `UntisBot-1.0.jar token=BLySFrzvz3tAHtquQevY1FF5W8CT0UMyMNmCSUCbJAPdNAmnnqYVBzaPTkz encrypt=super_secure_password lng=/home/user/more_languages.json` + +MariaDB examples: + - `UntisBot-1.0.jar mariadb token=BLySFrzvz3tAHtquQevY1FF5W8CT0UMyMNmCSUCbJAPdNAmnnqYVBzaPTkz encrypt=super_ultra_secure_password` + - `UntisBot-1.0.jar mariadb token=BLySFrzvz3tAHtquQevY1FF5W8CT0UMyMNmCSUCbJAPdNAmnnqYVBzaPTkz encrypt=super_ultra_secure_password user=untis password=toor` + +## Dependencies + +- Java 8 or higher +- [Discord library](https://github.com/DV8FromTheWorld/JDA) (JDA) +- [Untis library](https://github.com/ByteDream/untis4j) (untis4j) +- [MariaDB client](https://github.com/mariadb-corporation/mariadb-connector-j) (mariadb java client) +- [Logger](https://github.com/qos-ch/logback) (logback-core and logback-classic) + +**_Note_: The [UntisBot jar file](https://github.com/ByteDream/untisbot-discord/releases/tag/v1.0/UntisBot-1.0.jar) and the [Dockerfile](Dockerfile) are containing all dependencies.** \ No newline at end of file diff --git a/dockerfiles/database.sql b/dockerfiles/database.sql new file mode 100644 index 0000000..25612de --- /dev/null +++ b/dockerfiles/database.sql @@ -0,0 +1,31 @@ +CREATE TABLE IF NOT EXISTS `AbsentTeachers` ( + `GUILDID` bigint(20) NOT NULL, + `TEACHERNAME` tinytext NOT NULL, + `ABSENTLESSONS` smallint(6) NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `Guilds` ( + `GUILDID` bigint(20) NOT NULL, + `LANGUAGE` tinytext DEFAULT NULL, + `USERNAME` tinytext DEFAULT NULL, + `PASSWORD` text DEFAULT NULL, + `SERVER` tinytext DEFAULT NULL, + `SCHOOL` tinytext DEFAULT NULL, + `KLASSEID` smallint(6), + `CHANNELID` bigint(20) DEFAULT NULL, + `PREFIX` varchar(7) NOT NULL DEFAULT '!untis ', + `SLEEPTIME` bigint(20) NOT NULL DEFAULT 3600000, + `ISCHECKACTIVE` tinyint(1) NOT NULL DEFAULT 0, + `LASTCHECKED` date DEFAULT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `Stats` ( + `GUILDID` bigint(20) NOT NULL, + `TOTALREQUESTS` int(11) NOT NULL DEFAULT 0, + `TOTALDAYS` smallint(6) NOT NULL DEFAULT 0, + `TOTALLESSONS` int(11) NOT NULL DEFAULT 0, + `TOTALCANCELLEDLESSONS` smallint(6) NOT NULL DEFAULT 0, + `TOTALMOVEDLESSONS` smallint(6) NOT NULL DEFAULT 0, + `AVERAGECANCELLEDLESSONS` float NOT NULL DEFAULT 0, + `AVERAGEMOVEDLESSONS` float NOT NULL DEFAULT 0 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/dockerfiles/run.sh b/dockerfiles/run.sh new file mode 100755 index 0000000..a55e75c --- /dev/null +++ b/dockerfiles/run.sh @@ -0,0 +1,14 @@ +#!/bin/sh + +/scripts/run.sh & + +sleep 10 + +mariadb --user=root --password="$MYSQL_ROOT_PASSWORD" -h 127.0.0.1 Untis < "/untisbot-discord/database.sql" + +/usr/lib/jvm/java-1.8-openjdk/bin/javac -cp "/untisbot-discord/lib/*" $(find /untisbot-discord/src/ -name '*.java') + +cp -r /untisbot-discord/src/* /untisbot-discord/out/ +rm -r $(find /untisbot-discord/out/ -name '*.java') + +java -Dfile.encoding=UTF-8 -cp "/untisbot-discord/out:/untisbot-discord/lib/*" org.bytedream.untisbot.Main mariadb token=$token user=root password=$MYSQL_ROOT_PASSWORD encrypt=$encrypt \ No newline at end of file diff --git a/src/org/bytedream/untisbot/Crypt.java b/src/org/bytedream/untisbot/Crypt.java new file mode 100644 index 0000000..4184477 --- /dev/null +++ b/src/org/bytedream/untisbot/Crypt.java @@ -0,0 +1,87 @@ +package org.bytedream.untisbot; + +import javax.crypto.*; +import javax.crypto.spec.PBEKeySpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.Key; +import java.security.NoSuchAlgorithmException; +import java.security.spec.InvalidKeySpecException; +import java.util.Base64; + +/** + * Class to en- / decrypt strings {@see https://github.com/ByteDream/cryptoGX} + * + * @version 1.0 + * @since 1.0 + */ +public class Crypt { + + private final String key; + + private final String secretKeyFactoryAlgorithm = "PBKDF2WithHmacSHA512"; + private final int keySize = 256; + private final int iterations = 65536; + + public Crypt(String key) { + this.key = key; + } + + /** + * Generates a new secret key for en- / decryption + * + * @return the secret key + * @throws NoSuchAlgorithmException + * @throws InvalidKeySpecException + * @since 1.0 + */ + private byte[] createSecretKey() throws NoSuchAlgorithmException, InvalidKeySpecException { + SecretKeyFactory factory = SecretKeyFactory.getInstance(secretKeyFactoryAlgorithm); + PBEKeySpec keySpec = new PBEKeySpec(key.toCharArray(), new byte[16], iterations, keySize); + + return factory.generateSecret(keySpec).getEncoded(); + } + + /** + * Encrypts a given string + * + * @param string string to encrypt + * @return the encrypted string + * @throws BadPaddingException + * @throws NoSuchAlgorithmException + * @throws IllegalBlockSizeException + * @throws NoSuchPaddingException + * @throws InvalidKeyException + * @throws InvalidKeySpecException + * @since 1.0 + */ + public String encrypt(String string) throws BadPaddingException, NoSuchAlgorithmException, IllegalBlockSizeException, NoSuchPaddingException, InvalidKeyException, InvalidKeySpecException { + Key secretKey = new SecretKeySpec(createSecretKey(), "AES"); + + Cipher encryptCipher = Cipher.getInstance("AES"); + encryptCipher.init(Cipher.ENCRYPT_MODE, secretKey); + return Base64.getEncoder().encodeToString(encryptCipher.doFinal(string.getBytes(StandardCharsets.UTF_8))); + } + + /** + * Decrypts a given string + * + * @param string string to decrypt + * @return the decypted string + * @throws BadPaddingException + * @throws IllegalBlockSizeException + * @throws NoSuchPaddingException + * @throws NoSuchAlgorithmException + * @throws InvalidKeySpecException + * @throws InvalidKeyException + */ + public String decrypt(String string) throws BadPaddingException, IllegalBlockSizeException, NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeySpecException, InvalidKeyException { + Key secretKey = new SecretKeySpec(createSecretKey(), "AES"); + + Cipher decryptCipher = Cipher.getInstance("AES"); + decryptCipher.init(Cipher.DECRYPT_MODE, secretKey); + return new String(decryptCipher.doFinal(Base64.getDecoder().decode(string)), StandardCharsets.UTF_8); + } + +} diff --git a/src/org/bytedream/untisbot/Main.java b/src/org/bytedream/untisbot/Main.java new file mode 100644 index 0000000..bb95612 --- /dev/null +++ b/src/org/bytedream/untisbot/Main.java @@ -0,0 +1,201 @@ +package org.bytedream.untisbot; + +import ch.qos.logback.classic.Logger; +import org.bytedream.untisbot.data.StoreType; +import org.bytedream.untisbot.discord.Discord; +import org.json.JSONObject; +import org.json.JSONTokener; +import org.slf4j.LoggerFactory; + +import javax.security.auth.login.LoginException; +import java.io.*; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; + +/** + * Main class + */ +public class Main { + + private static Logger logger; + private static Connection connection; + + public static void main(String[] args) throws ClassNotFoundException, SQLException, LoginException { + String os = System.getProperty("os.name").toLowerCase(); + File logFile; + if (os.contains("linux") || os.contains("unix")) { + logFile = new File("/var/log/untis.log"); + } else { + logFile = new File("untis.log"); + } + if (!logFile.exists()) { + try { + logFile.createNewFile(); + } catch (IOException e) { + e.printStackTrace(); + } + } + + System.setProperty("LOG_FILE", logFile.getAbsolutePath()); + logger = (Logger) LoggerFactory.getLogger("Untis"); + Discord discord; + String token = null; + StoreType storeType = StoreType.MEMORY; + String dataEncryptPassword = "password"; + String user = "root"; + String password = ""; + String databaseIP = "127.0.0.1"; + String languageFile = ""; + int databasePort = 3306; + + String argsFile = Arrays.stream(args).filter(s -> s.trim().toLowerCase().startsWith("file=")).findAny().orElse(null); + + if (argsFile != null) { + FileInputStream configReader; + try { + configReader = new FileInputStream(argsFile.substring(5)); + } catch (FileNotFoundException e) { + e.printStackTrace(); + return; + } + + HashSet argsAsSet = new HashSet<>(Arrays.asList(args)); + JSONTokener jsonTokener = new JSONTokener(configReader); + JSONObject jsonObject = new JSONObject(jsonTokener); + + for (String s : jsonObject.keySet()) { + argsAsSet.add(s + "=" + jsonObject.getString(s)); + } + + args = argsAsSet.toArray(new String[0]); + } + + for (String arg : args) { + try { + String[] realArgs = arg.trim().split("="); + String realValue = realArgs[1].trim(); + + switch (realArgs[0].trim().toLowerCase()) { + case "token": + token = realValue; + break; + case "user": + user = realValue; + logger.info("Set custom database user"); + break; + case "password": + password = realValue; + logger.info("Set custom database password"); + break; + case "ip": + if (!Utils.isIPValid(realValue)) { + System.err.println("IP is not valid"); + return; + } else { + databaseIP = realValue; + logger.info("Set custom database ip"); + } + break; + case "port": + try { + databasePort = Integer.parseInt(realValue); + logger.info("Set custom database port"); + } catch (NumberFormatException e) { + System.err.println(realValue + " is not a number"); + return; + } + case "encrypt": + dataEncryptPassword = realValue; + logger.info("Set custom database encrypt password"); + break; + case "lng": + File file = new File(realValue); + if (!file.exists()) { + System.err.println("The file '" + realValue + "' doesn't exists"); + return; + } + if (!file.isFile()) { + System.err.println("'" + realValue + "' must be a file"); + return; + } + languageFile = realValue; + logger.info("Set custom language file"); + } + } catch (ArrayIndexOutOfBoundsException ignore) { + if (arg.trim().toLowerCase().equals("mariadb")) { + storeType = StoreType.MARIADB; + logger.info("Using mariadb for data storage"); + } + } + } + + if (token == null) { + System.err.println("Token is missing. Run me again and use your discord bot token as argument (e.g. token=BLySFrzvz3tAHtquQevY1FF5W8CT0UMyMNmCSUCbJAPdNAmnnqYVBzaPTkz)"); + return; + } + + if (storeType == StoreType.MARIADB) { + Class.forName("org.mariadb.jdbc.Driver"); + String finalDatabaseIP = databaseIP; + int finalDatabasePort = databasePort; + String finalUser = user; + String finalPassword = password; + connection = DriverManager.getConnection(Utils.advancedFormat("jdbc:mariadb://{databaseIP}:{databasePort}/Untis?user={user}&password={password}", new HashMap() {{ + put("databaseIP", finalDatabaseIP); + put("databasePort", finalDatabasePort); + put("user", finalUser); + put("password", finalPassword); + }})); + logger.info("Connected to mariadb"); + } + + InputStream languageFileReader; + if (languageFile.isEmpty()) { + languageFileReader = Main.class.getResourceAsStream("language.json"); + if (languageFileReader == null) { + System.err.println("Cannot load internal language file"); + return; + } + } else { + try { + languageFileReader = new FileInputStream(languageFile); + } catch (FileNotFoundException e) { + e.printStackTrace(); + return; + } + } + + JSONTokener jsonTokener = new JSONTokener(languageFileReader); + + discord = new Discord(token, storeType, dataEncryptPassword, new JSONObject(jsonTokener)); + discord.start(); + logger.info("Started bot"); + + //https://discord.com/api/oauth2/authorize?client_id=768841979433451520&permissions=268437504&scope=bot + } + + /** + * Returns the logger + * + * @return the logger + * @since 1.0 + */ + public static Logger getLogger() { + return logger; + } + + /** + * Returns the database connection + * + * @return the database connection + * @since 1.0 + */ + public static Connection getConnection() { + return connection; + } + +} diff --git a/src/org/bytedream/untisbot/Utils.java b/src/org/bytedream/untisbot/Utils.java new file mode 100644 index 0000000..8403475 --- /dev/null +++ b/src/org/bytedream/untisbot/Utils.java @@ -0,0 +1,79 @@ +package org.bytedream.untisbot; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; + +public class Utils { + + /** + * An alternative way to format a string + * + * @param stringToFormat string that should be formatted + * @param args args to format the string + * @return the formatted string + * @since 1.0 + */ + public static String advancedFormat(String stringToFormat, Map args) { + for (Map.Entry entry : args.entrySet()) { + stringToFormat = stringToFormat.replace("{" + entry.getKey() + "}", entry.getValue().toString()); + } + return stringToFormat; + } + + /** + * Creates a new logger + * + * @return the logger + * @since 1.0 + */ + public static Logger createLogger() { + return LoggerFactory.getLogger("root"); + } + + /** + * Checks a given ip for its validity + * + * @param ip ip to check + * @return if the ip is valid + * @since 1.0 + */ + public static boolean isIPValid(String ip) { + if (ip == null || ip.isEmpty()) { + return false; + } + + String[] parts = ip.split("\\."); + if (parts.length != 4 || ip.startsWith(".") || ip.endsWith(".")) { + return false; + } + + for (String s : parts) { + try { + int i = Integer.parseInt(s); + if (i < 0 || i > 255) { + return false; + } + } catch (NumberFormatException e) { + return false; + } + } + + return true; + } + + /** + * Rounds numbers to a given decimal place + * + * @param value number to round + * @param decimalPoints decimal places to round + * @return the rounded number + * @since 1.0 + */ + public static double round(double value, int decimalPoints) { + double d = Math.pow(10, decimalPoints); + return Math.rint(value * d) / d; + } + +} diff --git a/src/org/bytedream/untisbot/data/Data.java b/src/org/bytedream/untisbot/data/Data.java new file mode 100644 index 0000000..e7119bd --- /dev/null +++ b/src/org/bytedream/untisbot/data/Data.java @@ -0,0 +1,163 @@ +package org.bytedream.untisbot.data; + +import org.bytedream.untisbot.Crypt; + +import javax.crypto.BadPaddingException; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.spec.InvalidKeySpecException; +import java.time.LocalDate; +import java.util.HashMap; + +/** + * Class to store given guild / user data + * + * @version 1.0 + * @since 1.0 + */ +public class Data { + + /** + * Class to store guild data + * + * @version 1.0 + * @since 1.0 + */ + public static class Guild { + + private final Crypt crypt; + private Object[] data; + + public Guild(Object[] data, Crypt crypt) { + this.data = data; + this.crypt = crypt; + } + + public Object[] getData() { + return data; + } + + public long getGuildId() { + return (long) data[0]; + } + + public String getLanguage() { + return (String) (data[1]); + } + + public String getUsername() { + try { + return crypt.decrypt((String) (data[2])); + } catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException | IllegalBlockSizeException | BadPaddingException | InvalidKeySpecException ignore) { + return null; + } + } + + public String getPassword() { + try { + return crypt.decrypt((String) (data[3])); + } catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException | IllegalBlockSizeException | BadPaddingException | InvalidKeySpecException ignore) { + return null; + } + } + + public String getServer() { + return (String) data[4]; + } + + public String getSchool() { + return (String) data[5]; + } + + public Short getKlasseId() { + return (short) data[6]; + } + + public Long getChannelId() { + return (Long) data[7]; + } + + public String getPrefix() { + return (String) data[8]; + } + + public long getSleepTime() { + return (long) data[9]; + } + + public boolean isCheckActive() { + return (boolean) data[10]; + } + + public LocalDate getLastChecked() { + return (LocalDate) data[11]; + } + + protected void update(Object[] data) { + this.data = data; + } + } + + /** + * Class to store guild stats + * + * @version 1.0 + * @since 1.0 + */ + public static class Stats { + + private Object[] data; + + public Stats(Object[] data) { + this.data = data; + } + + public Object[] getData() { + return data; + } + + public long getGuildId() { + return (long) data[0]; + } + + public int getTotalRequests() { + return (int) data[1]; + } + + public short getTotalDays() { + return (short) data[5]; + } + + public int getTotalLessons() { + return (int) data[3]; + } + + public short getTotalCancelledLessons() { + return (short) data[5]; + } + + public short getTotalMovedLessons() { + return (short) data[5]; + } + + public float getAverageCancelledLessonsPerWeek() { + return (float) data[6]; + } + + public float getAverageMovedLessonsPerWeek() { + return (float) data[7]; + } + + public HashMap getAbsentTeachers() { + return (HashMap) data[8]; + } + + protected void update(Object[] data) { + this.data = data; + } + + } + +} diff --git a/src/org/bytedream/untisbot/data/DataConnector.java b/src/org/bytedream/untisbot/data/DataConnector.java new file mode 100644 index 0000000..3a52487 --- /dev/null +++ b/src/org/bytedream/untisbot/data/DataConnector.java @@ -0,0 +1,571 @@ +package org.bytedream.untisbot.data; + +import org.bytedream.untisbot.Crypt; +import org.bytedream.untisbot.Main; + +import java.security.GeneralSecurityException; +import java.sql.*; +import java.sql.Date; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.*; + +/** + * Base class to manage all data + * + * @version 1.0 + * @since 1.0 + */ +public class DataConnector { + + private final StoreType storeType; + private final Crypt crypt; + + public DataConnector(StoreType storeType, Crypt crypt) { + this.storeType = storeType; + this.crypt = crypt; + } + + public Guild guildConnector() { + return new Guild(storeType, crypt); + } + + public Stats statsConnector() { + return new Stats(storeType); + } + + /** + * Class to manage all the guild data + * + * @version 1.0 + * @since 1.0 + */ + public static class Guild { + private final StoreType storeType; + private final Crypt crypt; + private final Map memoryData = new HashMap<>(); + private Connection connection; + + /** + * Initializes the guild data connector and connects to the database if {@code storeType} is database + * + * @param storeType type how to store the given untis data {@link StoreType} + * @param crypt {@link Crypt} class to en- / decrypt the untis account passwords + * @since 1.0 + */ + private Guild(StoreType storeType, Crypt crypt) { + this.storeType = storeType; + this.crypt = crypt; + if (storeType == StoreType.MARIADB) { + connection = Main.getConnection(); + } + } + + /** + * Creates a new guild data entry + * + * @param guildId guild id of the new entry + * @since 1.0 + */ + public void add(long guildId) { + if (storeType == StoreType.MARIADB) { + try { + connection.createStatement().executeUpdate("INSERT INTO Guilds (GUILDID) VALUES (" + guildId + ")"); + } catch (SQLException e) { + e.printStackTrace(); + } + } else { + Object[] data = new Object[12]; + data[0] = guildId; + data[1] = null; + data[2] = null; + data[3] = null; + data[4] = null; + data[5] = null; + data[6] = null; + data[7] = null; + data[8] = "!untis "; + data[9] = 3600000L; + data[10] = false; + data[11] = null; + memoryData.put(guildId, new Data.Guild(data, crypt)); + } + } + + /** + * Returns the guild data from a guild id + * + * @param guildId to get the data from + * @return the guild data + * @since 1.0 + */ + public Data.Guild get(long guildId) { + Object[] data = new Object[12]; + + if (storeType == StoreType.MARIADB) { + try { + ResultSet resultSet = connection.createStatement().executeQuery("SELECT * FROM Guilds WHERE GUILDID=" + guildId); + + while (resultSet.next()) { + ResultSetMetaData metaData = resultSet.getMetaData(); + + for (int i = 1; i <= metaData.getColumnCount(); i++) { + switch (metaData.getColumnType(i)) { + case 5: //small int + data[i - 1] = resultSet.getShort(i); + break; + case 91: //date + Date date = resultSet.getDate(i); + if (date != null) { + data[i - 1] = date.toLocalDate(); + } else { + data[i - 1] = null; + } + break; + default: + data[i - 1] = resultSet.getObject(i); + } + } + } + } catch (SQLException e) { + e.printStackTrace(); + return null; + } + } else { + data = memoryData.get(guildId).getData(); + } + return new Data.Guild(data, crypt); + } + + /** + * Returns all stored guild data + * + * @return all stored guild data + * @since 1.0 + */ + public HashSet getAll() { + HashSet allData = new HashSet<>(); + if (storeType == StoreType.MARIADB) { + try { + ResultSet resultSet = connection.createStatement().executeQuery("SELECT * FROM Guilds"); + + while (resultSet.next()) { + Object[] data = new Object[12]; + int maxColumns = resultSet.getMetaData().getColumnCount(); + + for (int i = 1; i <= maxColumns; i++) { + Object object = resultSet.getObject(i); + data[i - 1] = object; + } + + allData.add(new Data.Guild(data, crypt)); + } + } catch (SQLException e) { + e.printStackTrace(); + } + } else { + allData.addAll(memoryData.values()); + } + return allData; + } + + /** + * Updates the guild data for a specific guild id + * + * @param guildId guild id from which the data should be updated + * @param language new language in which the timetable changes should be displayed + * @param username new untis username + * @param password new untis password + * @param server new untis server + * @param school new untis school + * @param channelId new channel id in which the timetable changes are sent + * @param prefix new command prefix + * @param sleepTime new sleep time between every timetable check + * @param isCheckActive new boolean to say if the timetable should be checked + * @param lastChecked new date on which the timetable was last checked + * @since 1.0 + */ + public void update(long guildId, String language, String username, String password, String server, String school, Short klasseId, Long channelId, String prefix, Long sleepTime, Boolean isCheckActive, LocalDate lastChecked) { + LinkedHashMap args = new LinkedHashMap<>(); + + args.put("GUILDID", guildId); + args.put("LANGUAGE", language); + if (username != null) { + if (username.isEmpty()) { + args.put("USERNAME", "NULL"); + } else { + try { + args.put("USERNAME", crypt.encrypt(username)); + } catch (GeneralSecurityException ignore) { + args.put("USERNAME", null); + } + } + } else { + args.put("USERNAME", null); + } + if (password != null) { + if (password.isEmpty()) { + args.put("PASSWORD", "NULL"); + } else { + try { + args.put("PASSWORD", crypt.encrypt(password)); + } catch (GeneralSecurityException ignore) { + args.put("PASSWORD", null); + } + } + } else { + args.put("PASSWORD", null); + } + if (server != null) { + if (server.isEmpty()) { + args.put("SERVER", "NULL"); + } else { + args.put("SERVER", server); + } + } else { + args.put("SERVER", null); + } + if (school != null) { + if (school.isEmpty()) { + args.put("SCHOOL", "NULL"); + } else { + args.put("SCHOOL", school); + } + } else { + args.put("SCHOOL", null); + } + args.put("KLASSEID", klasseId); + args.put("CHANNELID", channelId); + args.put("PREFIX", prefix); + args.put("SLEEPTIME", sleepTime); + args.put("ISCHECKACTIVE", isCheckActive); + args.put("LASTCHECKED", lastChecked); + + if (storeType == StoreType.MARIADB) { + StringBuilder stringBuilder = new StringBuilder("UPDATE Guilds SET "); + for (Map.Entry entry : args.entrySet()) { + Object value = entry.getValue(); + if (value != null) { + if (String.class.isAssignableFrom(value.getClass())) { + stringBuilder.append(entry.getKey()).append("='").append((String) value).append("',"); + } else if (LocalDate.class.isAssignableFrom(value.getClass())) { + stringBuilder.append(entry.getKey()).append("='").append(((LocalDate) value).format(DateTimeFormatter.ISO_LOCAL_DATE)).append("',"); + } else { + stringBuilder.append(entry.getKey()).append("=").append(value).append(","); + } + } + } + + String preFinalQuery = stringBuilder.toString(); + preFinalQuery = preFinalQuery.substring(0, preFinalQuery.length() - 1); + + try { + connection.createStatement().executeUpdate(preFinalQuery + " WHERE GUILDID=" + guildId); + } catch (SQLException e) { + e.printStackTrace(); + } + } else { + Object[] data = memoryData.get(guildId).getData(); + Iterator iterator = args.values().iterator(); + + int index = 0; + while (iterator.hasNext()) { + Object o = iterator.next(); + if (o != null) { + data[index] = o; + } + index++; + } + memoryData.replace(guildId, new Data.Guild(data, crypt)); + } + } + + /** + * Checks if the given guild id exist in the guild data + * + * @param guildId to check + * @return if the guild id exists + * @since 1.0 + */ + public boolean has(long guildId) { + if (storeType == StoreType.MARIADB) { + try { + return connection.createStatement().executeQuery("SELECT GUILDID FROM Guilds WHERE GUILDID=" + guildId).first(); + } catch (SQLException e) { + e.printStackTrace(); + return true; + } + } else { + return memoryData.containsKey(guildId); + } + } + + /** + * Removes a guild data entry + * + * @param guildId guild id of the entry to be removed + * @since 1.0 + */ + public void remove(long guildId) { + if (storeType == StoreType.MARIADB) { + try { + connection.createStatement().executeUpdate("DELETE FROM Guilds WHERE GUILDID=" + guildId); + } catch (SQLException e) { + e.printStackTrace(); + } + } else { + memoryData.remove(guildId); + } + } + } + + /** + * Class to manage all the guild stats + * + * @version 1.0 + * @since 1.0 + */ + public static class Stats { + private final StoreType storeType; + private final Map memoryData = new HashMap<>(); + private Connection connection; + + /** + * Initializes the stats data connector and connects to the database if {@code storeType} is database + * + * @param storeType type how to store the given untis data {@link StoreType} + * @since 1.0 + */ + private Stats(StoreType storeType) { + this.storeType = storeType; + if (storeType == StoreType.MARIADB) { + connection = Main.getConnection(); + } + } + + /** + * Creates a new stats data entry + * + * @param guildId guild id of the new entry + * @since 1.0 + */ + public void add(long guildId) { + if (storeType == StoreType.MARIADB) { + try { + connection.createStatement().executeUpdate("INSERT INTO Stats (GUILDID) VALUES (" + guildId + ");"); + } catch (SQLException e) { + e.printStackTrace(); + } + } else { + Object[] data = new Object[10]; + data[0] = guildId; + data[1] = 0; + data[2] = 0; + data[3] = 0; + data[4] = (short) 0; + data[5] = (short) 0; + data[6] = 0f; + data[7] = 0f; + data[8] = new HashMap(); + memoryData.put(guildId, new Data.Stats(data)); + } + } + + /** + * Returns the stats data from a guild id + * + * @param guildId to get the data from + * @return the stats data + * @since 1.0 + */ + public Data.Stats get(long guildId) { + if (storeType == StoreType.MARIADB) { + Object[] data = new Object[9]; + try { + ResultSet resultSet = connection.createStatement().executeQuery("SELECT * FROM Stats WHERE GUILDID=" + guildId); + + while (resultSet.next()) { + ResultSetMetaData metaData = resultSet.getMetaData(); + + for (int i = 1; i <= metaData.getColumnCount(); i++) { + switch (metaData.getColumnType(i)) { + case 5: //small int + data[i - 1] = resultSet.getShort(i); + break; + case 6: //float + data[i - 1] = resultSet.getFloat(i); + break; + default: + data[i - 1] = resultSet.getObject(i); + } + } + } + + resultSet = connection.createStatement().executeQuery("SELECT * FROM AbsentTeachers WHERE GUILDID=" + guildId); + HashMap absentTeachers = new HashMap<>(); + while (resultSet.next()) { + absentTeachers.put(resultSet.getString("TEACHERNAME"), resultSet.getShort("ABSENTLESSONS")); + } + data[0] = guildId; + data[8] = absentTeachers; + return new Data.Stats(data); + } catch (SQLException e) { + e.printStackTrace(); + return null; + } + } else { + return memoryData.get(guildId); + } + } + + /** + * Updates the stats data for a specific guild id + * + * @param guildId guild id from which the data should be updated + * @param totalRequests new total timetable requests + * @param totalDays new total days that have been checked + * @param totalLessons new total lessons that have been checked + * @param totalCancelledLessons new total cancelled lessons that have been checked + * @param totalMovedLessons new total moved lessons that have been checked + * @param averageCancelledLessonsPerWeek new average cancelled lessons per week + * @param averageMovedLessonsPerWeek new average moved lessons per week + * @since 1.0 + */ + public void update(long guildId, Integer totalRequests, Short totalDays, Integer totalLessons, Short totalCancelledLessons, Short totalMovedLessons, Float averageCancelledLessonsPerWeek, Float averageMovedLessonsPerWeek) { + LinkedHashMap args = new LinkedHashMap<>(); + args.put("GUILDID", guildId); + args.put("TOTALREQUESTS", totalRequests); + args.put("TOTALDAYS", totalDays); + args.put("TOTALLESSONS", totalLessons); + args.put("TOTALCANCELLEDLESSONS", totalCancelledLessons); + args.put("TOTALMOVEDLESSONS", totalMovedLessons); + args.put("AVERAGECANCELLEDLESSONS", averageCancelledLessonsPerWeek); + args.put("AVERAGEMOVEDLESSONS", averageMovedLessonsPerWeek); + if (storeType == StoreType.MARIADB) { + String[] argsClasses = new String[]{"Long", "Integer", "Short", "Integer", "Short", "Short", "Float", "Float"}; + + StringBuilder stringBuilder = new StringBuilder("UPDATE Stats SET "); + int index = 0; + for (Map.Entry entry : args.entrySet()) { + Object value = entry.getValue(); + if (value != null) { + switch (argsClasses[index]) { + case "Float": + if (Float.isNaN((Float) value)) { + value = 0f; + } + case "Integer": + case "Short": + stringBuilder.append(entry.getKey()).append("=").append(value).append(","); + break; + } + } + index++; + } + + String preFinalQuery = stringBuilder.toString(); + preFinalQuery = preFinalQuery.substring(0, preFinalQuery.length() - 1); + + try { + connection.createStatement().executeUpdate(preFinalQuery + " WHERE GUILDID=" + guildId); + } catch (SQLException e) { + e.printStackTrace(); + } + } else { + Data.Stats stats = memoryData.get(guildId); + Object[] data = stats.getData(); + + Iterator iterator = args.values().iterator(); + + int index = 0; + while (iterator.hasNext()) { + Object o = iterator.next(); + if (o != null) { + data[index] = o; + } + index++; + } + + data[9] = stats.getAbsentTeachers(); + memoryData.replace(guildId, new Data.Stats(data)); + } + } + + /** + * Updates the absent teachers data for a specific guild id + * + * @param guildId guild id from which the data should be updated + * @param teacherName teacher name that should be updated + * @param absentLessons new number of lessons where the teacher were absent + * @since 1.0 + */ + public void updateAbsentTeachers(long guildId, String teacherName, short absentLessons) { + if (storeType == StoreType.MARIADB) { + try { + if (!connection.createStatement().executeQuery("SELECT GUILDID FROM AbsentTeachers WHERE GUILDID=" + guildId + " AND TEACHERNAME='" + teacherName + "'").first()) { + PreparedStatement preparedStatement = connection.prepareStatement("INSERT INTO AbsentTeachers (GUILDID, TEACHERNAME, ABSENTLESSONS) VALUES (?, ?, ?)"); + preparedStatement.setLong(1, guildId); + preparedStatement.setString(2, teacherName); + preparedStatement.setShort(3, absentLessons); + preparedStatement.executeUpdate(); + } else { + PreparedStatement preparedStatement = connection.prepareStatement("UPDATE AbsentTeachers SET ABSENTLESSONS=? WHERE GUILDID=? AND TEACHERNAME=?"); + preparedStatement.setShort(1, absentLessons); + preparedStatement.setLong(2, guildId); + preparedStatement.setString(3, teacherName); + preparedStatement.executeUpdate(); + } + } catch (SQLException throwables) { + throwables.printStackTrace(); + } + } else { + memoryData.get(guildId).getAbsentTeachers().computeIfPresent(teacherName, (s, aShort) -> memoryData.get(guildId).getAbsentTeachers().put(s, aShort)); + memoryData.get(guildId).getAbsentTeachers().putIfAbsent(teacherName, absentLessons); + } + } + + /** + * Checks if the given guild id exist in the stats data + * + * @param guildId to check + * @return if the guild id exists + * @since 1.0 + */ + public boolean has(long guildId) { + if (storeType == StoreType.MARIADB) { + try { + return connection.createStatement().executeQuery("SELECT GUILDID FROM Stats WHERE GUILDID=" + guildId).first(); + } catch (SQLException e) { + e.printStackTrace(); + return false; + } + } else { + return memoryData.containsKey(guildId); + } + } + + /** + * Removes a guild data entry + * + * @param guildId guild id of the entry to be removed + * @since 1.0 + */ + public void remove(long guildId) { + if (storeType == StoreType.MARIADB) { + try { + connection.createStatement().executeUpdate("DELETE FROM Stats WHERE GUILDID=" + guildId); + } catch (SQLException e) { + e.printStackTrace(); + } + try { + connection.createStatement().executeUpdate("DELETE FROM AbsentTeachers WHERE GUILDID=" + guildId); + } catch (SQLException e) { + e.printStackTrace(); + } + } else { + memoryData.remove(guildId); + } + } + } + +} diff --git a/src/org/bytedream/untisbot/data/StoreType.java b/src/org/bytedream/untisbot/data/StoreType.java new file mode 100644 index 0000000..afe1a9d --- /dev/null +++ b/src/org/bytedream/untisbot/data/StoreType.java @@ -0,0 +1,12 @@ +package org.bytedream.untisbot.data; + +/** + * Simple enum to differ between store types + * + * @version 1.0 + * @since 1.0 + */ +public enum StoreType { + MARIADB, + MEMORY +} diff --git a/src/org/bytedream/untisbot/discord/Discord.java b/src/org/bytedream/untisbot/discord/Discord.java new file mode 100644 index 0000000..263c4cf --- /dev/null +++ b/src/org/bytedream/untisbot/discord/Discord.java @@ -0,0 +1,43 @@ +package org.bytedream.untisbot.discord; + +import net.dv8tion.jda.api.JDABuilder; +import org.bytedream.untisbot.Crypt; +import org.bytedream.untisbot.data.StoreType; +import org.json.JSONObject; + +import javax.security.auth.login.LoginException; + +/** + * Base class to start the bot + * + * @version 1.0 + * @since 1.0 + */ +public class Discord { + + private final JDABuilder jdaBuilder; + + /** + * Configures the bot to make it ready to launch + * + * @param token bot token + * @param storeType type how to store the given untis data {@link StoreType} + * @param encryptPassword password to encrypt all passwords from the untis accounts + * @since 1.0 + */ + public Discord(String token, StoreType storeType, String encryptPassword, JSONObject languages) { + jdaBuilder = JDABuilder.createDefault(token); + jdaBuilder.addEventListeners(new DiscordCommandListener(storeType, new Crypt(encryptPassword), languages)); + } + + /** + * Starts the bot + * + * @throws LoginException if the given login credentials are invalid + * @since 1.0 + */ + public void start() throws LoginException { + jdaBuilder.build(); + } + +} diff --git a/src/org/bytedream/untisbot/discord/DiscordCommandListener.java b/src/org/bytedream/untisbot/discord/DiscordCommandListener.java new file mode 100644 index 0000000..b9e8cd6 --- /dev/null +++ b/src/org/bytedream/untisbot/discord/DiscordCommandListener.java @@ -0,0 +1,665 @@ +package org.bytedream.untisbot.discord; + +import ch.qos.logback.classic.Logger; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.MessageChannel; +import net.dv8tion.jda.api.entities.TextChannel; +import net.dv8tion.jda.api.events.ReadyEvent; +import net.dv8tion.jda.api.events.guild.GuildJoinEvent; +import net.dv8tion.jda.api.events.guild.GuildLeaveEvent; +import net.dv8tion.jda.api.events.message.MessageReceivedEvent; +import net.dv8tion.jda.api.events.message.guild.GuildMessageReceivedEvent; +import net.dv8tion.jda.api.hooks.ListenerAdapter; +import org.bytedream.untis4j.LoginException; +import org.bytedream.untis4j.Session; +import org.bytedream.untis4j.responseObjects.Teachers; +import org.bytedream.untis4j.responseObjects.TimeUnits; +import org.bytedream.untis4j.responseObjects.Timetable; +import org.bytedream.untisbot.Crypt; +import org.bytedream.untisbot.Main; +import org.bytedream.untisbot.Utils; +import org.bytedream.untisbot.data.Data; +import org.bytedream.untisbot.data.DataConnector; +import org.bytedream.untisbot.data.StoreType; +import org.bytedream.untisbot.untis.CheckCallback; +import org.bytedream.untisbot.untis.TimetableChecker; +import org.jetbrains.annotations.NotNull; +import org.json.JSONObject; + +import java.awt.*; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.util.*; + +/** + * Adapter to handle all events + * + * @version 1.0 + * @since 1.0 + */ +public class DiscordCommandListener extends ListenerAdapter { + + private final DataConnector.Guild guildDataConnector; + private final DataConnector.Stats statsDataConnector; + private final JSONObject languages; + + private final HashMap allTimetableChecker = new HashMap<>(); + private final Logger logger = Main.getLogger(); + + /** + * Sets up the adapter + * + * @param storeType type how to store the given untis data {@link StoreType} + * @param crypt {@link Crypt} object to encrypt all passwords from the untis accounts + * @param languages {@link JSONObject} containing different languages to print out when the timetable is checked + * @since 1.0 + */ + public DiscordCommandListener(StoreType storeType, Crypt crypt, JSONObject languages) { + DataConnector dataConnector = new DataConnector(storeType, crypt); + + guildDataConnector = dataConnector.guildConnector(); + statsDataConnector = dataConnector.statsConnector(); + this.languages = languages; + + EmbedBuilder embedBuilder = new EmbedBuilder(); + embedBuilder.setColor(Color.GREEN); + } + + /** + * Checks the timetable from the given guild and sends an embed if the timetable has changes + * + * @param guild guild to send the timetable + * @since 1.0 + */ + public void runTimetableChecker(Guild guild) { + long guildId = guild.getIdLong(); + Timer timer = new Timer(); + Data.Guild data = guildDataConnector.get(guildId); + TimetableChecker timetableChecker; + TextChannel textChannel = guild.getTextChannelById(data.getChannelId()); + if (textChannel == null) { + textChannel = guild.getDefaultChannel(); + if (textChannel != null) { + guildDataConnector.update(guildId, null, null, null, null, null, null, textChannel.getIdLong(), null, null, null, null); + textChannel.sendMessage("It seems like, that the channel where I should send the timetable messages in doesn't exists anymore." + + "I'll send the changes now in this channel." + + "If you want that I send these messages into another channel, type `" + data.getPrefix() + "channel` in the channel where I should send the messages in").queue(); + } + } + try { + timetableChecker = new TimetableChecker(data.getUsername(), data.getPassword(), data.getServer(), data.getSchool(), data.getKlasseId()); + } catch (LoginException e) { + e.printStackTrace(); + logger.warn(guild.getName() + " failed to login", e); + textChannel.sendMessage("Failed to login. Please try to re-set your data").queue(); + return; + } catch (IOException e) { + e.printStackTrace(); + logger.warn(guild.getName() + " ran into an exception while trying to setup the timetable checker", e); + textChannel.sendMessage("An error occurred while trying to setup the timetable checking process." + + "You should try to re-set your data or trying to contact my author <@650417934073593886> (:3) if the problem won't go away").queue(); + return; + } + timer.scheduleAtFixedRate(new TimerTask() { + private int latestImportTime = 0; + + private void main() { + Data.Guild data = guildDataConnector.get(guildId); + TextChannel textChannel = guild.getTextChannelById(data.getChannelId()); + if (textChannel == null) { + textChannel = guild.getDefaultChannel(); + if (textChannel == null) { + return; + } else { + guildDataConnector.update(guildId, null, null, null, null, null, null, textChannel.getIdLong(), null, null, null, null); + textChannel.sendMessage("It seems like, that the channel where I should send the timetable messages in doesn't exists anymore. " + + "I'll send the changes now in this channel." + + "If you want that I send these messages into another channel, type `" + data.getPrefix() + "set-channel` in the channel where I should send the messages in").queue(); + } + } + + boolean error = false; + Data.Stats stats = statsDataConnector.get(guildId); + String setLanguage = data.getLanguage(); + if (setLanguage == null) { + setLanguage = "en"; + } + JSONObject language = languages.getJSONObject(setLanguage); + LocalDate now = LocalDate.now(); + + int i = 0; + int daysToCheck = 6; + + try { + CheckCallback checkCallback = timetableChecker.check(now); + Timetable allLessons = checkCallback.getAllLessons(); + + if (Timetable.sortByStartTime(allLessons).get(allLessons.size() - 1).getEndTime().isBefore(LocalTime.now())) { + // checks if all lessons are over, and if so, it stops checking the timetable for today + i++; + daysToCheck++; + } + } catch (IOException e) { + e.printStackTrace(); + } + + for (; i <= daysToCheck; i++) { + LocalDate localDate = now.plusDays(i); + try { + CheckCallback checkCallback = timetableChecker.check(localDate); + + EmbedBuilder embedBuilder = new EmbedBuilder(); + embedBuilder.setColor(Color.CYAN); + embedBuilder.setTitle(Utils.advancedFormat(language.getString("title"), new HashMap() {{ + put("weekday", language.getString(localDate.getDayOfWeek().name().toLowerCase())); + put("date", localDate.format(DateTimeFormatter.ofPattern("dd.MM.yyyy"))); + }})); + + ArrayList cancelledLessons = checkCallback.getCancelled(); + ArrayList movedLessons = checkCallback.getMoved(); + ArrayList notCancelledLessons = checkCallback.getNotCancelled(); + ArrayList notMovedLessons = checkCallback.getNotMoved(); + + for (Timetable.Lesson lesson : cancelledLessons) { + TimeUnits.TimeUnitObject timeUnitObject = lesson.getTimeUnitObject(); + HashMap formatMap = new HashMap() {{ + put("lesson-name", timeUnitObject.getName()); + put("date", lesson.getDate()); + put("start-time", timeUnitObject.getStartTime().format(DateTimeFormatter.ofPattern("HH:mm"))); + put("end-time", timeUnitObject.getEndTime().format(DateTimeFormatter.ofPattern("HH:mm"))); + put("teachers", String.join(", ", lesson.getTeachers().getFullNames())); + put("subjects", String.join(", ", lesson.getSubjects().getLongNames())); + put("rooms", String.join(", ", lesson.getRooms().getLongNames())); + }}; + embedBuilder.addField(Utils.advancedFormat(language.getString("cancelled-title"), formatMap), + Utils.advancedFormat(language.getString("cancelled-body"), formatMap), false); + } + + for (Timetable.Lesson[] lesson : movedLessons) { + TimeUnits.TimeUnitObject timeUnitObject = lesson[0].getTimeUnitObject(); + Timetable.Lesson to = lesson[0]; + Timetable.Lesson from = lesson[1]; + HashMap formatMap = new HashMap() {{ + put("from-lesson-name", from.getTimeUnitObject().getName()); + put("to-lesson-name", to.getTimeUnitObject().getName()); + put("date", from.getDate()); + put("start-time", timeUnitObject.getStartTime().format(DateTimeFormatter.ofPattern("HH:mm"))); + put("end-time", timeUnitObject.getEndTime().format(DateTimeFormatter.ofPattern("HH:mm"))); + put("teachers", String.join(", ", from.getTeachers().getFullNames())); + put("subjects", String.join(", ", from.getSubjects().getLongNames())); + put("rooms", String.join(", ", from.getRooms().getLongNames())); + }}; + embedBuilder.addField(Utils.advancedFormat(language.getString("moved-title"), formatMap), + Utils.advancedFormat(language.getString("moved-body"), formatMap), false); + } + + for (Timetable.Lesson lesson : notCancelledLessons) { + TimeUnits.TimeUnitObject timeUnitObject = lesson.getTimeUnitObject(); + HashMap formatMap = new HashMap() {{ + put("lesson-name", timeUnitObject.getName()); + put("date", lesson.getDate()); + put("start-time", timeUnitObject.getStartTime().format(DateTimeFormatter.ofPattern("HH:mm"))); + put("end-time", timeUnitObject.getEndTime().format(DateTimeFormatter.ofPattern("HH:mm"))); + put("teachers", String.join(", ", lesson.getTeachers().getFullNames())); + put("subjects", String.join(", ", lesson.getSubjects().getLongNames())); + put("rooms", String.join(", ", lesson.getRooms().getLongNames())); + }}; + embedBuilder.addField(Utils.advancedFormat(language.getString("not-cancelled-title"), formatMap), + Utils.advancedFormat(languages.getString("not-cancelled-body"), formatMap), false); + } + + for (Timetable.Lesson[] lesson : notMovedLessons) { + TimeUnits.TimeUnitObject timeUnitObject = lesson[0].getTimeUnitObject(); + Timetable.Lesson from = lesson[0]; + Timetable.Lesson to = lesson[1]; + HashMap formatMap = new HashMap() {{ + put("from-lesson-name", from.getTimeUnitObject().getName()); + put("to-lesson-name", to.getTimeUnitObject().getName()); + put("date", from.getDate()); + put("start-time", timeUnitObject.getStartTime().format(DateTimeFormatter.ofPattern("HH:mm"))); + put("end-time", timeUnitObject.getEndTime().format(DateTimeFormatter.ofPattern("HH:mm"))); + put("teachers", String.join(", ", from.getTeachers().getFullNames())); + put("subjects", String.join(", ", from.getSubjects().getLongNames())); + put("rooms", String.join(", ", from.getRooms().getLongNames())); + }}; + embedBuilder.addField(Utils.advancedFormat(language.getString("not-moved-title"), formatMap), + Utils.advancedFormat(language.getString("not-moved-body"), formatMap), false); + } + if (!embedBuilder.getFields().isEmpty()) { + textChannel.sendMessage(embedBuilder.build()).queue(); + } + + LocalDate lastChecked = guildDataConnector.get(guildId).getLastChecked(); + Short totalDays = stats.getTotalDays(); + int totalLessons = stats.getTotalLessons(); + + if (lastChecked == null || lastChecked.isBefore(now.plusDays(i))) { + totalDays++; + totalLessons += checkCallback.getAllLessons().size(); + guildDataConnector.update(guildId, null, null, null, null, null, null, null, null, null, null, now.plusDays(i)); + } + short totalCancelledLessons = (short) (stats.getTotalCancelledLessons() + cancelledLessons.size() - notCancelledLessons.size()); + short totalMovedLessons = (short) (stats.getTotalMovedLessons() + movedLessons.size() - notMovedLessons.size()); + + statsDataConnector.update(guildId, stats.getTotalRequests() + 1, totalDays, totalLessons, totalCancelledLessons, totalMovedLessons, + (float) Utils.round((float) totalCancelledLessons / totalLessons, 3) * 5, + (float) Utils.round((float) totalMovedLessons / totalLessons, 3) * 5); + + for (Timetable.Lesson lesson : checkCallback.getCancelled()) { + HashMap teachers = stats.getAbsentTeachers(); + for (Teachers.TeacherObject teacher : lesson.getTeachers()) { + String name = teacher.getFullName(); + statsDataConnector.updateAbsentTeachers(guildId, name, (short) (teachers.getOrDefault(name, (short) 0) + 1)); + } + } + for (Timetable.Lesson lesson : checkCallback.getNotCancelled()) { + HashMap teachers = stats.getAbsentTeachers(); + for (Teachers.TeacherObject teacher : lesson.getTeachers()) { + String name = teacher.getFullName(); + statsDataConnector.updateAbsentTeachers(guildId, name, (short) (teachers.getOrDefault(name, (short) 0) - 1)); + } + } + stats = statsDataConnector.get(guildId); + + if (error) { + error = false; + } + } catch (Exception e) { + logger.warn(guild.getName() + " ran into an exception while trying to check the timetable for the " + localDate.format(DateTimeFormatter.ofPattern("dd.MM.yyyy")), e); + if (!error) { + textChannel.sendMessage("An error occurred while trying to check the timetable." + + "You can try to re-set your data or trying to contact my author <@650417934073593886> (:3) if the problem won't go away").queue(); + error = true; + } + try { + Thread.sleep((i + 1) * 5000); + } catch (InterruptedException ignore) { + } + } + } + } + + @Override + public void run() { + try { + Session session = timetableChecker.getSession(); + session.reconnect(); + if (latestImportTime < session.getLatestImportTime().getLatestImportTime()) { + latestImportTime = session.getLatestImportTime().getLatestImportTime(); + main(); + } else { + main(); + } + } catch (IOException e) { + main(); + } + } + }, 0, data.getSleepTime()); + allTimetableChecker.put(guildId, timer); + logger.info(guild.getName() + " started timetable listening"); + } + + @Override + public void onReady(ReadyEvent event) { + ArrayList allGuilds = new ArrayList<>(); + for (Guild guild : event.getJDA().getGuilds()) { + long guildId = guild.getIdLong(); + if (!guildDataConnector.has(guildId)) { + guildDataConnector.add(guildId); + } + if (!statsDataConnector.has(guildId)) { + statsDataConnector.add(guildId); + } + + if (guildDataConnector.get(guildId).isCheckActive()) { + runTimetableChecker(guild); + } + + allGuilds.add(guildId); + } + for (Data.Guild data : guildDataConnector.getAll()) { + if (!allGuilds.contains(data.getGuildId())) { + guildDataConnector.remove(data.getGuildId()); + statsDataConnector.remove(data.getGuildId()); + } + } + logger.info("Bot is ready | Total guilds: " + guildDataConnector.getAll().size()); + } + + @Override + public void onGuildMessageReceived(GuildMessageReceivedEvent event) { + new Thread(() -> { + long guildId = event.getGuild().getIdLong(); + Data.Guild data = guildDataConnector.get(guildId); + try { + if (event.getAuthor().isBot() || event.getAuthor().isFake() || !event.getMessage().getContentDisplay().startsWith(data.getPrefix())) { + return; + } + } catch (StringIndexOutOfBoundsException e) { + // if (for example) a picture is sent, the bot checks for the first letter from the message an a because a picture has no letters, this error gets thrown + return; + } + String guildName = event.getGuild().getName(); + + Guild guild = event.getGuild(); + String userInput = event.getMessage().getContentDisplay().substring(data.getPrefix().length()).trim().replaceAll(" +", " "); + String userInputLow = userInput.toLowerCase(); + + String[] splitCommand = userInputLow.split(" "); + String command = splitCommand[0]; + String[] args = Arrays.copyOfRange(splitCommand, 1, splitCommand.length); + + TextChannel channel = event.getChannel(); + + try { + if (command.equals("stats")) { + if (args.length == 0) { + Data.Stats stats = statsDataConnector.get(guildId); + + EmbedBuilder embedBuilder = new EmbedBuilder(); + if (guildName.trim().endsWith("s")) { + embedBuilder.setTitle(guild.getName() + " untis status"); + } else { + embedBuilder.setTitle(guild.getName() + "'s untis status"); + } + + ArrayList mostMissedTeachers = new ArrayList<>(); + short missedLessons = 0; + for (Map.Entry entry : stats.getAbsentTeachers().entrySet()) { + if (entry.getValue() > missedLessons) { + mostMissedTeachers.clear(); + mostMissedTeachers.add(entry.getKey()); + missedLessons = entry.getValue(); + } else if (entry.getValue() == missedLessons) { + mostMissedTeachers.add(entry.getKey()); + } + } + String mostMissedTeachersText; + if (missedLessons == 0) { + mostMissedTeachersText = "n/a"; + } else { + mostMissedTeachersText = String.join(", ", mostMissedTeachers) + " - " + missedLessons + " missed lessons"; + } + + String timetableChecking; + if (data.isCheckActive()) { + timetableChecking = "\uD83D\uDFE2 Active"; + embedBuilder.setColor(Color.GREEN); + } else { + timetableChecking = "\uD83D\uDD34 Inactive"; + embedBuilder.setFooter("To start timetable checking, type `" + data.getPrefix() + "set-data ` - type `" + data.getPrefix() + "help` for more details"); + embedBuilder.setColor(Color.RED); + } + embedBuilder.addField("Timetable checking", timetableChecking, true); + //embedBuilder.addField("Checking interval", data.getSleepTime() / 60000 + " minutes", true); + embedBuilder.addField("Total timetable requests", String.valueOf(stats.getTotalRequests()), true); + embedBuilder.addField("Total lessons checked", String.valueOf(stats.getTotalLessons()), true); + embedBuilder.addField("Total weeks checked", String.valueOf((int) (Math.floor((float) stats.getTotalDays() / 7))), true); + embedBuilder.addField("Total cancelled lessons", String.valueOf(stats.getTotalCancelledLessons()), true); + embedBuilder.addField("Total moved lessons", String.valueOf(stats.getTotalMovedLessons()), true); + embedBuilder.addField("Average cancelled lessons per week", String.valueOf(stats.getAverageCancelledLessonsPerWeek()), true); + embedBuilder.addField("Average moved lessons per week", String.valueOf(stats.getAverageMovedLessonsPerWeek()), true); + embedBuilder.addField("Most missed teacher", mostMissedTeachersText, false); + + channel.sendMessage(embedBuilder.build()).queue(); + } else { + channel.sendMessage("Wrong number of arguments were given, type `" + data.getPrefix() + "help` for help").queue(); + } + } else if (event.getMember().getPermissions().contains(Permission.ADMINISTRATOR)) { + switch (command) { + case "channel": // `channel` command + if (args.length == 0) { + guildDataConnector.update(guild.getIdLong(), null, null, null, null, null, null, channel.getIdLong(), null, null, null, null); + logger.info(guildName + " set a new channel to send the timetable changes to"); + channel.sendMessage("This channel is now set as the channel where I send the timetable changes in").queue(); + } else { + channel.sendMessage("Wrong number of arguments were given (expected 0, got " + args.length + "), type `" + data.getPrefix() + "help channel` for help").queue(); + } + break; + case "clear": // `clear` command + if (args.length == 0) { + guildDataConnector.update(guild.getIdLong(), null, "", "", "", "", (short) 0, null, null, null, false, null); + logger.info(guildName + " cleared their data"); + channel.sendMessage("Cleared untis data and stopped timetable listening").queue(); + } else { + channel.sendMessage("Wrong number of arguments were given (expected 0, got " + args.length + "), type `" + data.getPrefix() + "help clear` for help").queue(); + } + break; + case "data": // `data ` command + if (args.length >= 3 && args.length <= 4) { + String schoolName; + try { + schoolName = new URL(args[2]).getQuery().split("=")[1]; + } catch (MalformedURLException | ArrayIndexOutOfBoundsException e) { + channel.sendMessage("The given login data is invalid").queue(); + return; + } + String server = args[2].replace("https://", "").replace("http://", ""); + server = "https://" + server.substring(0, server.indexOf("/")); + short klasseId; + try { + channel.sendMessage("Verifying data...").queue(); + Session session = Session.login(args[0], args[1], server, schoolName); + if (args.length == 3) { + klasseId = (short) session.getInfos().getKlasseId(); + } else { + try { + klasseId = (short) session.getKlassen().findByName(args[3]).getId(); + } catch (NullPointerException e) { + channel.sendMessage("❌ Cannot find the given class").queue(); + return; + } + } + session.logout(); + } catch (IOException e) { + channel.sendMessage("❌ The given login data is invalid").queue(); + return; + } + + boolean isCheckActive = data.isCheckActive(); + + if (data.getChannelId() == null) { + guildDataConnector.update(guildId, null, args[0], args[1], server, schoolName, klasseId, channel.getIdLong(), null, null, true, null); + } else { + guildDataConnector.update(guildId, null, args[0], args[1], server, schoolName, klasseId, null, null, null, true, null); + } + + if (isCheckActive) { + Timer timer = allTimetableChecker.get(guildId); + timer.cancel(); + timer.purge(); + allTimetableChecker.remove(guildId); + runTimetableChecker(guild); + channel.sendMessage("✅ Updated data and restarted timetable listening").queue(); + } else { + runTimetableChecker(guild); + channel.sendMessage("✅ Timetable listening has been started").queue(); + } + logger.info(guildName + " set new data"); + } else { + channel.sendMessage("Wrong number of arguments were given (expected 3 or 4, got " + args.length + "), type `" + data.getPrefix() + "help data` for help").queue(); + } + break; + case "language": // `language ` command + if (args.length == 1) { + String language = args[0]; + + if (!languages.has(language)) { + channel.sendMessage("The language `" + language + "` is not supported. Type `" + data.getPrefix() + "help` to see all available languages").queue(); + } else { + guildDataConnector.update(guildId, language, null, null, null, null, null, null, null, null, null, null); + logger.info(guildName + " set their language to " + language); + channel.sendMessage("Updated language to `" + language + "`").queue(); + } + } else { + channel.sendMessage("Wrong number of arguments were given (expected 1, got " + args.length + "), type `" + data.getPrefix() + "help language` for help").queue(); + } + break; + case "prefix": // `prefix ` command + if (args.length == 1) { + String prefix = args[0]; + + if (prefix.length() == 0 || prefix.length() > 6) { + channel.sendMessage("The prefix must be between 1 and 6 characters long").queue(); + } else { + String note = ""; + if (prefix.contains("'") || prefix.contains("\"")) { + channel.sendMessage("Cannot use `'` or `\"` in prefix").queue(); + return; + } + if (prefix.length() == 1) { + if ("!?$¥§%&@€#|/\\=.:-_+,;*+~<>^°".indexOf(prefix.charAt(0)) == -1) { + note += "\n_Note_: Because the prefix is not in `!?$¥§%&@€#|/\\=.:-_+,;*+~<>^°` you have to call commands with a blank space between it and the prefix"; + } + } else { + prefix += " "; + note += "\n_Note_: Because the prefix is longer than 1 character you have to call commands with a blank space between it and the prefix"; + } + guildDataConnector.update(guildId, null, null, null, null, null, null, null, prefix, null, null, null); + logger.info(guildName + " set their prefix to " + prefix); + channel.sendMessage("Updated prefix to `" + prefix + "`" + note).queue(); + } + } else { + channel.sendMessage("Wrong number of arguments were given (expected 3, got " + args.length + "), type `" + data.getPrefix() + "help prefix` for help").queue(); + } + break; + default: + + } + } + } catch (NullPointerException ignore) { + } + }).start(); + } + + @Override + public void onMessageReceived(@NotNull MessageReceivedEvent event) { // only for `help` command + new Thread(() -> { + if (event.getAuthor().isBot()) { + return; + } + + String message = event.getMessage().getContentDisplay().trim().toLowerCase(); + MessageChannel channel = event.getChannel(); + EmbedBuilder embedBuilder = new EmbedBuilder(); + + String prefix; + if (message.contains("help")) { // `help` command + if (event.isFromGuild()) { + prefix = guildDataConnector.get(event.getGuild().getIdLong()).getPrefix(); + embedBuilder.setFooter("Note: Every command must be called with the set prefix ('" + prefix + "')"); + if (!event.getMessage().getContentDisplay().startsWith(prefix + "help")) { + return; + } + } else if (message.equals("help") || message.startsWith("help ")) { + prefix = ""; + } else { + return; + } + } else { + return; + } + + String[] splitMessage = message.substring(prefix.length()).split(" "); + String[] args = Arrays.copyOfRange(splitMessage, 1, splitMessage.length); + String help = "Use `" + prefix + "help ` to get help / information about a command.\n\n" + + "All available commands are:\n" + + "`channel` `clear` `data` `help` `language` `prefix` `stats`"; + if (args.length > 1) { + channel.sendMessage("Wrong number of arguments are given (expected 0 or 1, got " + splitMessage.length + "). " + help).queue(); + } else if (args.length == 0) { + channel.sendMessage(help).queue(); + } else { + String title; + String description; + String example; + String _default = null; + switch (args[0]) { + case "channel": + title = "`channel` command"; + description = "In the channel where this command is entered, the bot shows the timetable changes"; + example = "`channel`"; + break; + case "clear": + title = "`clear` command"; + description = "Clears the given untis data, given from the `data` command"; + example = "`clear`"; + break; + case "data": + title = "`data ` command"; + description = "Sets the data with which the bot logs in to untis and checks for timetable changes. The data is stored encrypted on the server.\n" + + "`username` and `password` are the normal untis login data with which one also logs in to the untis website / app. To gain the login page url you have to go to webuntis.com, type in your school and choose it.\n" + + "Then you will be redirected to the untis login page, The url of this page is the login page url, for example `https://example.webuntis.com/WebUntis/?school=myschool#/basic/main`.\n" + + "`class name` is just the name of the class you want to check (eg. `12AB`). If `class name` is not specified, the bot tries to get the default class which is assigned to the given account."; + example = "`data myname secure https://example.webuntis.com/WebUntis/?school=example#/basic/main 12AB`"; + _default = "`en`"; + break; + case "help": + title = "`help ` command"; + description = "Displays help to a given command"; + example = "`help data`"; + break; + case "language": + title = "`language ` command"; + description = "Changes the language in which the timetable information are displayed. Currently only 'de' (german) and 'en' (english) are supported"; + example = "`language de`"; + _default = "`en`"; + break; + case "prefix": + title = "`prefix ` command"; + description = "Changes the prefix with which commands are called"; + example = "`prefix $`"; + _default = "`!untis `"; + break; + case "stats": + title = "`stats` command"; + description = "Displays a message with some stats (total cancelled lessons, etc.)"; + example = "`stats`"; + break; + default: + channel.sendMessage("Unknown command was given. " + help).queue(); + return; + } + embedBuilder.setColor(Color.CYAN); + embedBuilder.setTitle(title); + embedBuilder.addField("Description", description, false); + embedBuilder.addField("Example", example, false); + if (_default != null) { + embedBuilder.addField("Default", _default, false); + } + embedBuilder.setFooter("`<>` = required; `[]` = optional"); + channel.sendMessage(embedBuilder.build()).queue(); + } + }).start(); + } + + @Override + public void onGuildJoin(GuildJoinEvent event) { + Guild guild = event.getGuild(); + long guildId = guild.getIdLong(); + + if (!guildDataConnector.has(guildId)) { + guildDataConnector.add(guildId); + } + if (!statsDataConnector.has(guildId)) { + statsDataConnector.add(guildId); + } + logger.info("Joined new guild - Name: " + event.getGuild().getName() + " | Total guilds: " + guildDataConnector.getAll().size()); + } + + @Override + public void onGuildLeave(@NotNull GuildLeaveEvent event) { + long guildId = event.getGuild().getIdLong(); + guildDataConnector.remove(guildId); + statsDataConnector.remove(guildId); + + logger.info("Left guild - Name: " + event.getGuild().getName() + " | Total guilds: " + guildDataConnector.getAll().size()); + } +} diff --git a/src/org/bytedream/untisbot/language.json b/src/org/bytedream/untisbot/language.json new file mode 100644 index 0000000..e859b24 --- /dev/null +++ b/src/org/bytedream/untisbot/language.json @@ -0,0 +1,40 @@ +{ + "de": { + "language": "German", + "title": "Stunden Ausfall Information für {weekday}, den {date}", + "cancelled-title": "Ausfall {lesson-name}. Stunde ({start-time} Uhr - {end-time} Uhr)", + "cancelled-body": "Ausfall bei {teachers}, in {subjects}, in der {lesson-name}. Stunde", + "moved-title": "{from-lesson-name}. Stunde wird zur {to-lesson-name}. Stunde umverlegt", + "moved-body": "Die {from-lesson-name}. Stunde bei {teachers} in {subjects} wird zur {to-lesson-name}. Stunde umverlegt", + "not-cancelled-title": "KEIN Ausfall {lesson-name}. Stunde ({start-time} Uhr - {end-time} Uhr)", + "not-cancelled-body": "KEIN Ausfall bei {teachers}, in {subjects}, in der {lesson-name}. Stunde", + "not-moved-title": "{from-lesson-name}. Stunde wird NICHT zur {to-lesson-name}. Stunde umverlegt", + "not-moved-body": "Die {from-lesson-name}. Stunde bei {teachers} wird NICHT zur {to-lesson-name}. Stunde umverlegt", + "monday": "Montag", + "tuesday": "Dienstag", + "wednesday": "Mittwoch", + "thursday": "Donnerstag", + "friday": "Freitag", + "saturday": "Samstag", + "sunday": "Sonntag" + }, + "en": { + "language": "English", + "title": "Irregular lesson information for {weekday}, {date}", + "cancelled-title": "Cancelled {lesson-name}. lesson ({start-time} - {end-time})", + "cancelled-body": "The {lesson-name}. lesson with {teachers} in {subjects} is cancelled", + "moved-title": "The {from-lesson-name}. lesson is moved to {to-lesson-name}. lesson", + "moved-body": "The {from-lesson-name}. lesson with {teachers} in {subjects} is moved to the {to-lesson-name}. lesson", + "not-cancelled-title": "{lesson-name}. lesson ({start-time} - {end-time}) is NO cancelled", + "not-cancelled-body": "The {lesson-name}. lesson with {teachers} in {subjects} is NOT cancelled", + "not-moved-title": "The {from-lesson-name}. lesson is NOT moved to {to-lesson-name}.", + "not-moved-body": "The {from-lesson-name}. lesson with {teachers} in {subjects} is NOT moved to the {to-lesson-name}. lesson", + "monday": "monday", + "tuesday": "tuesday", + "wednesday": "wednesday", + "thursday": "thursday", + "friday": "friday", + "saturday": "saturday", + "sunday": "sunday" + } +} \ No newline at end of file diff --git a/src/org/bytedream/untisbot/resources/logback.xml b/src/org/bytedream/untisbot/resources/logback.xml new file mode 100644 index 0000000..f636472 --- /dev/null +++ b/src/org/bytedream/untisbot/resources/logback.xml @@ -0,0 +1,21 @@ + + + + + UTF-8 + %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{35} - %msg%n + + + + ${LOG_FILE} + + %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{35} - %msg%n + + + + + + + + + \ No newline at end of file diff --git a/src/org/bytedream/untisbot/untis/CheckCallback.java b/src/org/bytedream/untisbot/untis/CheckCallback.java new file mode 100644 index 0000000..43324cf --- /dev/null +++ b/src/org/bytedream/untisbot/untis/CheckCallback.java @@ -0,0 +1,89 @@ +package org.bytedream.untisbot.untis; + +import org.bytedream.untis4j.responseObjects.Timetable; + +import java.sql.Time; +import java.time.LocalDate; +import java.util.ArrayList; + +/** + * Callback of {@link TimetableChecker#check(LocalDate)} + * + * @version 1.0 + * @since 1.0 + */ +public class CheckCallback { + + private final Timetable allLessons; + private final ArrayList cancelled; + private final ArrayList moved; + private final ArrayList notCancelled; + private final ArrayList notMoved; + + /** + * Initialize the {@link CheckCallback} class + * + * @param cancelled all cancelled messages + * @param moved all moved messages + * @param notCancelled all not cancelled messages + * @param notMoved all not moved messages + * @since 1.0 + */ + public CheckCallback(Timetable allLessons, ArrayList cancelled, ArrayList moved, ArrayList notCancelled, ArrayList notMoved) { + this.allLessons = allLessons; + this.cancelled = cancelled; + this.moved = moved; + this.notCancelled = notCancelled; + this.notMoved = notMoved; + } + + /** + * Returns all that were checked + * + * @return all that were checked + * @since 1.0 + */ + public Timetable getAllLessons() { + return allLessons; + } + + /** + * Returns all cancelled lessons + * + * @return all cancelled lessons + * @since 1.0 + */ + public ArrayList getCancelled() { + return cancelled; + } + + /** + * Returns all moved lessons + * + * @return all moved lessons + * @since 1.0 + */ + public ArrayList getMoved() { + return moved; + } + + /** + * Returns all not cancelled lessons + * + * @return all not cancelled lessons + * @since 1.0 + */ + public ArrayList getNotCancelled() { + return notCancelled; + } + + /** + * Returns all not moved lessons + * + * @return all not moved lessons + * @since 1.0 + */ + public ArrayList getNotMoved() { + return notMoved; + } +} diff --git a/src/org/bytedream/untisbot/untis/TimetableChecker.java b/src/org/bytedream/untisbot/untis/TimetableChecker.java new file mode 100644 index 0000000..d2ce439 --- /dev/null +++ b/src/org/bytedream/untisbot/untis/TimetableChecker.java @@ -0,0 +1,171 @@ +package org.bytedream.untisbot.untis; + +import org.bytedream.untis4j.LoginException; +import org.bytedream.untis4j.RequestManager; +import org.bytedream.untis4j.Session; +import org.bytedream.untis4j.UntisUtils; +import org.bytedream.untis4j.responseObjects.Timetable; +import org.bytedream.untisbot.Main; + +import java.io.IOException; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; + +/** + * Class to check the untis timetable + * + * @version 1.0 + * @since 1.0 + */ +public class TimetableChecker { + + private final Session session; + private final int klasseId; + private final LocalDate[] cancelledLessonsDay = new LocalDate[7]; + private final LocalDate[] ignoredLessonsDay = new LocalDate[7]; + private final LocalDate[] movedLessonsDay = new LocalDate[7]; + private final Timetable[] cancelledLessons = new Timetable[]{new Timetable(), new Timetable(), new Timetable(), new Timetable(), new Timetable(), new Timetable(), new Timetable()}; + private final Timetable[] ignoredLessons = new Timetable[]{new Timetable(), new Timetable(), new Timetable(), new Timetable(), new Timetable(), new Timetable(), new Timetable()}; + private final ArrayList> movedLessons = new ArrayList<>(); + + /** + * Sets all necessary configurations and connects to the untis account with the given untis credentials + * + * @param username username of the untis account + * @param password user password of the untis account + * @param server the server from the school as URL + * @param schoolName name of the school + * @throws IOException if any {@link IOException} while the login occurs + * @since 1.0 + */ + public TimetableChecker(String username, String password, String server, String schoolName, int klasseId) throws IOException { + session = Session.login(username, password, server, schoolName); + this.klasseId = klasseId; + + for (LocalDate[] localDates : new HashSet() {{ + add(cancelledLessonsDay); + add(ignoredLessonsDay); + add(movedLessonsDay); + }}) { + for (int i = 0; i < 7; i++) { + localDates[i] = LocalDate.now().plusDays(i + 1); + } + } + + for (int i = 0; i < 7; i++) { + movedLessons.add(new HashMap<>()); + } + } + + /** + * Checks the timetable on a specific date. Automatically deletes cached lessons from the past, so you should not call the method in descending date order + * + * @param dateToCheck date which should be checked + * @return {@link CheckCallback} with information about the timetable (if anything has changed) + * @throws IOException if any {@link IOException} occurs + * @since 1.0 + */ + public CheckCallback check(LocalDate dateToCheck) throws IOException { + Timetable timetable = session.getTimetableFromKlasseId(dateToCheck, dateToCheck, klasseId); + timetable.sortByStartTime(); + + int dayOfWeekInArray = dateToCheck.getDayOfWeek().getValue() - 1; + + Timetable allCancelledLessons = cancelledLessons[dayOfWeekInArray]; + Timetable allIgnoredLessons = ignoredLessons[dayOfWeekInArray]; + HashMap allMovedLessons = movedLessons.get(dayOfWeekInArray); + + Timetable totalLessons = new Timetable(); + ArrayList cancelledLesson = new ArrayList<>(); + ArrayList movedLesson = new ArrayList<>(); + ArrayList notCancelledLessons = new ArrayList<>(); + ArrayList notMovedLessons = new ArrayList<>(); + + for (Timetable.Lesson lesson : timetable) { + totalLessons.add(lesson); + if (lesson.getCode() == UntisUtils.LessonCode.CANCELLED && !allCancelledLessons.contains(lesson) && !allIgnoredLessons.contains(lesson)) { + Timetable specificLessons = timetable.searchByStartTime(lesson.getStartTime()); + specificLessons.remove(lesson); + + switch (specificLessons.size()) { + case 0: // lesson is cancelled + allCancelledLessons.add(lesson); + cancelledLesson.add(lesson); + break; + case 1: // lesson is maybe moved + if (specificLessons.get(0).getCode() == UntisUtils.LessonCode.IRREGULAR) { // lesson is moved + Timetable.Lesson irregularLesson = specificLessons.get(0); + + for (Timetable.Lesson lesson1 : timetable.searchByTeachers(irregularLesson.getTeachers())) { + if (lesson1.getCode() == UntisUtils.LessonCode.CANCELLED && !allIgnoredLessons.contains(lesson1)) { + allIgnoredLessons.add(lesson1); + allCancelledLessons.remove(lesson1); + + allMovedLessons.put(lesson, lesson1); + movedLesson.add(new Timetable.Lesson[]{lesson, lesson1}); + break; + } + } + } else { // lesson is not moved but cancelled + allCancelledLessons.add(lesson); + cancelledLesson.add(lesson); + } + break; + } + } else if (lesson.getCode() == UntisUtils.LessonCode.IRREGULAR && timetable.searchByStartTime(lesson.getStartTime()).size() == 1 && !allIgnoredLessons.contains(lesson)) { + // lesson is maybe moved + for (Timetable.Lesson lesson1 : timetable) { + // checks if another lesson exist with the same 'stats' and if it's cancelled + if (lesson1.getCode() == UntisUtils.LessonCode.CANCELLED && !allIgnoredLessons.contains(lesson1) && lesson.getSubjects().containsAll(lesson1.getSubjects())) { + allIgnoredLessons.add(lesson1); + + allMovedLessons.put(lesson, lesson1); + movedLesson.add(new Timetable.Lesson[]{lesson, lesson1}); + break; + } + } + } else if (allMovedLessons.containsKey(lesson) && lesson.getCode() == UntisUtils.LessonCode.REGULAR) { // checks if a moved lesson takes place again + Timetable.Lesson value = allMovedLessons.get(lesson); + allIgnoredLessons.remove(value); + + allMovedLessons.remove(lesson); + notMovedLessons.add(new Timetable.Lesson[]{lesson, value}); + break; + } else if (allCancelledLessons.contains(lesson) && lesson.getCode() == UntisUtils.LessonCode.REGULAR) { // checks if a cancelled lesson takes place again + allCancelledLessons.remove(lesson); + notCancelledLessons.add(lesson); + break; + } + } + + if (cancelledLessonsDay[dayOfWeekInArray].compareTo(dateToCheck) > 0) { + cancelledLessonsDay[dayOfWeekInArray] = dateToCheck; + } + if (ignoredLessonsDay[dayOfWeekInArray].compareTo(dateToCheck) > 0) { + ignoredLessonsDay[dayOfWeekInArray] = dateToCheck; + } + if (movedLessonsDay[dayOfWeekInArray].compareTo(dateToCheck) > 0) { + movedLessonsDay[dayOfWeekInArray] = dateToCheck; + } + + cancelledLessons[dayOfWeekInArray] = allCancelledLessons; + ignoredLessons[dayOfWeekInArray] = allIgnoredLessons; + movedLessons.remove(dayOfWeekInArray); + movedLessons.add(dayOfWeekInArray, allMovedLessons); + + return new CheckCallback(totalLessons, cancelledLesson, movedLesson, notCancelledLessons, notMovedLessons); + } + + /** + * Returns the session + * + * @return the session + * @since 1.0 + */ + public Session getSession() { + return session; + } + +}