diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 455aef7019..58be1a6a47 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @Together-Java/moderators @Together-Java/staff-helpers +* @Together-Java/moderators @Together-Java/staff-assistants diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b00c3bd2aa..ac04ab32d3 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,4 +3,8 @@ updates: - package-ecosystem: "gradle" directory: "/" schedule: - interval: "daily" + interval: "monthly" + ignore: + - dependency-name: "*" + update-types: ["version-update:semver-patch", "version-update:semver-minor"] + open-pull-requests-limit: 10 \ No newline at end of file diff --git a/.github/workflows/code-analysis.yml b/.github/workflows/code-analysis.yml index e0c4e10c71..a56a4e5d29 100644 --- a/.github/workflows/code-analysis.yml +++ b/.github/workflows/code-analysis.yml @@ -14,8 +14,6 @@ jobs: sonar: name: SonarCloud runs-on: ubuntu-latest - # Skip this on PRs from forks as it will fail anyways as it needs secrets - if: ${{ !github.event.pull_request.head.repo.fork }} steps: - uses: actions/checkout@v2 with: @@ -43,6 +41,8 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Sonar login token with 'Execute Analysis' privileges SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + # this this on forks or dependabot PRs + if: env.SONAR_TOKEN != null run: ./gradlew build sonarqube --info codeql: diff --git a/.gitignore b/.gitignore index d301e7549e..f24a747936 100644 --- a/.gitignore +++ b/.gitignore @@ -145,3 +145,17 @@ gradle-app.setting # End of https://www.toptal.com/developers/gitignore/api/netbeans,intellij,java,gradle,eclipse application/db/ config.json +*.db +*.db-shm +*.db-wal +logviewer/frontend/generated +logviewer/node_modules +logviewer/.npmrc +logviewer/package.json +logviewer/pnpmfile.js +logviewer/tsconfig.json +logviewer/types.d.ts +logviewer/pnpm-lock.yaml +logviewer/webpack.config.js +logviewer/webpack.generated.js +.DS_Store diff --git a/CLA.md b/CLA.md new file mode 100644 index 0000000000..c7a4c71973 --- /dev/null +++ b/CLA.md @@ -0,0 +1,126 @@ +## Contributor Agreement + +## Individual Contributor Exclusive License Agreement + +## (including the Traditional Patent License OPTION) + +Thank you for your interest in contributing to Together Java's TJ-Bot ("We" or "Us"). + +The purpose of this contributor agreement ("Agreement") is to clarify and document the rights granted by contributors to Us. To make this document effective, please follow the instructions given by the CLA-assistant bot in your Pull-Request. + +### How to use this Contributor Agreement + +If You are an employee and have created the Contribution as part of your employment, You need to have Your employer approve this Agreement or sign the Entity version of this document. If You do not own the Copyright in the entire work of authorship, any other author of the Contribution should also sign this – in any event, please contact Us at together.java.tjbot@gmail.com + +### 1\. Definitions + +**"You"** means the individual Copyright owner who Submits a Contribution to Us. + +**"Legal Entity"** means an entity that is not a natural person. + +**"Affiliate"** means any other Legal Entity that controls, is controlled by, or under common control with that Legal Entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such Legal Entity, whether by contract or otherwise, (ii) ownership of fifty percent (50%) or more of the outstanding shares or securities that vote to elect the management or other persons who direct such Legal Entity or (iii) beneficial ownership of such entity. + +**"Contribution"** means any original work of authorship, including any original modifications or additions to an existing work of authorship, Submitted by You to Us, in which You own the Copyright. + +**"Copyright"** means all rights protecting works of authorship, including copyright, moral and neighboring rights, as appropriate, for the full term of their existence. + +**"Material"** means the software or documentation made available by Us to third parties. When this Agreement covers more than one software project, the Material means the software or documentation to which the Contribution was Submitted. After You Submit the Contribution, it may be included in the Material. + +**"Submit"** means any act by which a Contribution is transferred to Us by You by means of tangible or intangible media, including but not limited to electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, Us, but excluding any transfer that is conspicuously marked or otherwise designated in writing by You as "Not a Contribution." + +**"Documentation"** means any non-software portion of a Contribution. + +### 2\. License grant + +#### 2.1 Copyright license to Us + +Subject to the terms and conditions of this Agreement, You hereby grant to Us a worldwide, royalty-free, Exclusive, perpetual and irrevocable (except as stated in Section 8.2) license, with the right to transfer an unlimited number of non-exclusive licenses or to grant sublicenses to third parties, under the Copyright covering the Contribution to use the Contribution by all means, including, but not limited to: + +* publish the Contribution, +* modify the Contribution, +* prepare derivative works based upon or containing the Contribution and/or to combine the Contribution with other Materials, +* reproduce the Contribution in original or modified form, +* distribute, to make the Contribution available to the public, display and publicly perform the Contribution in original or modified form. + +#### 2.2 Moral rights + +Moral Rights remain unaffected to the extent they are recognized and not waivable by applicable law. Notwithstanding, You may add your name to the attribution mechanism customary used in the Materials you Contribute to, such as the header of the source code files of Your Contribution, and We will respect this attribution when using Your Contribution. + +#### 2.3 Copyright license back to You + +Upon such grant of rights to Us, We immediately grant to You a worldwide, royalty-free, non-exclusive, perpetual and irrevocable license, with the right to transfer an unlimited number of non-exclusive licenses or to grant sublicenses to third parties, under the Copyright covering the Contribution to use the Contribution by all means, including, but not limited to: + +* publish the Contribution, +* modify the Contribution, +* prepare derivative works based upon or containing the Contribution and/or to combine the Contribution with other Materials, +* reproduce the Contribution in original or modified form, +* distribute, to make the Contribution available to the public, display and publicly perform the Contribution in original or modified form. + +This license back is limited to the Contribution and does not provide any rights to the Material. + +### 3\. Patents + +#### 3.1 Patent license + +Subject to the terms and conditions of this Agreement You hereby grant to Us and to recipients of Materials distributed by Us a worldwide, royalty-free, non-exclusive, perpetual and irrevocable (except as stated in Section 3.2) patent license, with the right to transfer an unlimited number of non-exclusive licenses or to grant sublicenses to third parties, to make, have made, use, sell, offer for sale, import and otherwise transfer the Contribution and the Contribution in combination with any Material (and portions of such combination). This license applies to all patents owned or controlled by You, whether already acquired or hereafter acquired, that would be infringed by making, having made, using, selling, offering for sale, importing or otherwise transferring of Your Contribution(s) alone or by combination of Your Contribution(s) with any Material. + +#### 3.2 Revocation of patent license + +You reserve the right to revoke the patent license stated in section 3.1 if We make any infringement claim that is targeted at your Contribution and not asserted for a Defensive Purpose. An assertion of claims of the Patents shall be considered for a "Defensive Purpose" if the claims are asserted against an entity that has filed, maintained, threatened, or voluntarily participated in a patent infringement lawsuit against Us or any of Our licensees. + +### 4\. License obligations by Us + +We agree to license the Contribution only under the terms of the license or licenses that We are using on the Submission Date for the Material (including any rights to adopt any future version of a license). + +In addition, We may use the following licenses for Documentation in the Contribution: CC-BY-SA-4.0 (including any right to adopt any future version of a license). + +We agree to license patents owned or controlled by You only to the extent necessary to (sub)license Your Contribution(s) and the combination of Your Contribution(s) with the Material under the terms of the license or licenses that We are using on the Submission Date. + +### 5. Disclaimer + +THE CONTRIBUTION IS PROVIDED "AS IS". MORE PARTICULARLY, ALL EXPRESS OR IMPLIED WARRANTIES INCLUDING, WITHOUT LIMITATION, ANY IMPLIED WARRANTY OF SATISFACTORY QUALITY, FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT ARE EXPRESSLY DISCLAIMED BY YOU TO US AND BY US TO YOU. TO THE EXTENT THAT ANY SUCH WARRANTIES CANNOT BE DISCLAIMED, SUCH WARRANTY IS LIMITED IN DURATION AND EXTENT TO THE MINIMUM PERIOD AND EXTENT PERMITTED BY LAW. + +### 6. Consequential damage waiver + +TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT WILL YOU OR WE BE LIABLE FOR ANY LOSS OF PROFITS, LOSS OF ANTICIPATED SAVINGS, LOSS OF DATA, INDIRECT, SPECIAL, INCIDENTAL, CONSEQUENTIAL AND EXEMPLARY DAMAGES ARISING OUT OF THIS AGREEMENT REGARDLESS OF THE LEGAL OR EQUITABLE THEORY (CONTRACT, TORT OR OTHERWISE) UPON WHICH THE CLAIM IS BASED. + +### 7. Approximation of disclaimer and damage waiver + +IF THE DISCLAIMER AND DAMAGE WAIVER MENTIONED IN SECTION 5. AND SECTION 6. CANNOT BE GIVEN LEGAL EFFECT UNDER APPLICABLE LOCAL LAW, REVIEWING COURTS SHALL APPLY LOCAL LAW THAT MOST CLOSELY APPROXIMATES AN ABSOLUTE WAIVER OF ALL CIVIL OR CONTRACTUAL LIABILITY IN CONNECTION WITH THE CONTRIBUTION. + +### 8. Term + +8.1 This Agreement shall come into effect upon Your acceptance of the terms and conditions. + +8.2 This Agreement shall apply for the term of the copyright and patents licensed here. However, You shall have the right to terminate the Agreement if We do not fulfill the obligations as set forth in Section 4. Such termination must be made in writing. + +8.3 In the event of a termination of this Agreement Sections 5, 6, 7, 8 and 9 shall survive such termination and shall remain in full force thereafter. For the avoidance of doubt, Free and Open Source Software (sub)licenses that have already been granted for Contributions at the date of the termination shall remain in full force after the termination of this Agreement. + +### 9 Miscellaneous + +9.1 This Agreement and all disputes, claims, actions, suits or other proceedings arising out of this agreement or relating in any way to it shall be governed by the laws of Germany excluding its private international law provisions. + +9.2 This Agreement sets out the entire agreement between You and Us for Your Contributions to Us and overrides all other agreements or understandings. + +9.3 In case of Your death, this agreement shall continue with Your heirs. In case of more than one heir, all heirs must exercise their rights through a commonly authorized person. + +9.4 If any provision of this Agreement is found void and unenforceable, such provision will be replaced to the extent possible with a provision that comes closest to the meaning of the original provision and that is enforceable. The terms and conditions set forth in this Agreement shall apply notwithstanding any failure of essential purpose of this Agreement or any limited remedy to the maximum extent possible under law. + +9.5 You agree to notify Us of any facts or circumstances of which you become aware that would make this Agreement inaccurate in any respect. + + +### You + +(sign this document digitally with your GitHub account) + +### Us + +Name: Daniel Tischner (aka Zabuzard, acting on behalf of Together Java) + +Organization: https://github.com/Together-Java + +Contact: https://discord.com/invite/XXFUXzK + +#### Note + +This document can be recreated or adjusted [here](https://contributoragreements.org/ca-cla-chooser/?beneficiary-name=Together+Java&project-name=TJ-Bot&project-website=https%3A%2F%2Fgithub.com%2FTogether-Java%2FTJ-Bot&project-email=together.java.tjbot%40gmail.com&process-url=&project-jurisdiction=Germany&agreement-exclusivity=exclusive&fsfe-compliance=&fsfe-fla=&outbound-option=same&outboundlist=GPL-3.0&outboundlist-custom=&medialist=CC-BY-SA-4.0&patent-option=Traditional&your-date=&your-name=&your-title=&your-address=&your-patents=&pos=apply&action=). diff --git a/README.md b/README.md index fbeb8f4e73..f0982139ea 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ TJ-Bot is a Discord Bot used on the [Together Java](https://discord.com/invite/XXFUXzK) server. It is maintained by the community, anyone can contribute. +![bot says hello](https://i.imgur.com/FE1MJTV.png) + # Getting started Please read [Contributing](https://github.com/Together-Java/TJ-Bot/wiki/Contributing) if you want to propose ideas and changes or even implement some yourself. diff --git a/application/build.gradle b/application/build.gradle index cb3aaaad35..e47629d0d9 100644 --- a/application/build.gradle +++ b/application/build.gradle @@ -8,6 +8,7 @@ plugins { id 'application' id 'com.google.cloud.tools.jib' version '3.1.4' id 'com.github.johnrengelman.shadow' version '7.1.0' + id 'database-settings' } repositories { @@ -29,6 +30,10 @@ jib { password = System.getenv('REGISTRY_PASSWORD') ?: '' } } + container { + mainClass = 'org.togetherjava.tjbot.BootstrapLauncher' + setCreationTime(java.time.Instant.now().toString()) + } } shadowJar { @@ -40,7 +45,7 @@ shadowJar { dependencies { implementation project(':database') - implementation 'net.dv8tion:JDA:4.3.0_331' + implementation 'net.dv8tion:JDA:4.3.0_339' implementation 'org.apache.logging.log4j:log4j-api:2.14.1' implementation 'org.apache.logging.log4j:log4j-core:2.14.1' @@ -48,15 +53,23 @@ dependencies { implementation 'org.jooq:jooq:3.15.3' - implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-csv:2.12.5' + implementation 'org.scilab.forge:jlatexmath:1.0.7' + implementation 'org.scilab.forge:jlatexmath-font-greek:1.0.7' + implementation 'org.scilab.forge:jlatexmath-font-cyrillic:1.0.7' + + implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-csv:2.13.0' implementation 'com.fasterxml.jackson.core:jackson-databind:2.13.0' + implementation 'com.github.ben-manes.caffeine:caffeine:3.0.4' + + testImplementation 'org.mockito:mockito-core:4.0.0' + testRuntimeOnly 'org.mockito:mockito-core:4.0.0' testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.1' testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.1' } application { - mainClass = 'org.togetherjava.tjbot.Application' + mainClass = 'org.togetherjava.tjbot.BootstrapLauncher' } test { diff --git a/application/src/main/java/org/togetherjava/tjbot/Application.java b/application/src/main/java/org/togetherjava/tjbot/Application.java index c0bda7e90c..e6326df1d5 100644 --- a/application/src/main/java/org/togetherjava/tjbot/Application.java +++ b/application/src/main/java/org/togetherjava/tjbot/Application.java @@ -8,9 +8,11 @@ import org.togetherjava.tjbot.commands.system.CommandSystem; import org.togetherjava.tjbot.config.Config; import org.togetherjava.tjbot.db.Database; +import org.togetherjava.tjbot.routines.ModAuditLogRoutine; import javax.security.auth.login.LoginException; import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.sql.SQLException; @@ -41,8 +43,6 @@ public static void main(final String[] args) { + DEFAULT_CONFIG_PATH + "' will be assumed."); } - setSystemProperties(); - Path configPath = Path.of(args.length == 1 ? args[0] : DEFAULT_CONFIG_PATH); try { Config.load(configPath); @@ -66,9 +66,14 @@ public static void main(final String[] args) { * @param token the Discord Bot token to connect with * @param databasePath the path to the database to use */ + @SuppressWarnings("WeakerAccess") public static void runBot(String token, Path databasePath) { logger.info("Starting bot..."); try { + Path parentDatabasePath = databasePath.toAbsolutePath().getParent(); + if (parentDatabasePath != null) { + Files.createDirectories(parentDatabasePath); + } Database database = new Database("jdbc:sqlite:" + databasePath.toAbsolutePath()); JDA jda = JDABuilder.createDefault(token) @@ -77,6 +82,10 @@ public static void runBot(String token, Path databasePath) { jda.awaitReady(); logger.info("Bot is ready"); + // TODO This should be moved into some proper command system instead (see GH issue #235 + // which adds support for routines) + new ModAuditLogRoutine(jda, database).start(); + Runtime.getRuntime().addShutdownHook(new Thread(Application::onShutdown)); } catch (LoginException e) { logger.error("Failed to login", e); @@ -85,6 +94,9 @@ public static void runBot(String token, Path databasePath) { Thread.currentThread().interrupt(); } catch (SQLException e) { logger.error("Failed to create database", e); + } catch (IOException e) { + logger.error("Failed to create path to the database at: {}", + databasePath.toAbsolutePath(), e); } } @@ -96,22 +108,4 @@ private static void onShutdown() { logger.info("Bot has been stopped"); } - /** - * Sets any system-properties before anything else is touched. - */ - private static void setSystemProperties() { - final int cores = Runtime.getRuntime().availableProcessors(); - if (cores <= 1) { - // If we are in a docker container, we officially might just have 1 core - // and Java would then set the parallelism of the common ForkJoinPool to 0. - // And 0 means no workers, so JDA cannot function, no Callback's on REST-Requests - // are executed - // NOTE This will likely be fixed with Java 18 or newer, remove afterwards (see - // https://bugs.openjdk.java.net/browse/JDK-8274349 and - // https://github.com/openjdk/jdk/pull/5784) - logger.debug("Available Cores \"{}\", setting Parallelism Flag", cores); - // noinspection AccessOfSystemProperties - System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism", "1"); - } - } } diff --git a/application/src/main/java/org/togetherjava/tjbot/BootstrapLauncher.java b/application/src/main/java/org/togetherjava/tjbot/BootstrapLauncher.java new file mode 100644 index 0000000000..0be2bb353a --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/BootstrapLauncher.java @@ -0,0 +1,42 @@ +package org.togetherjava.tjbot; + +/** + * A bootstrap launcher with minimal dependencies that sets up needed parts and workarounds for the + * main logic to take over. + */ +public enum BootstrapLauncher { + ; + + /** + * Starts the main application. + * + * @param args arguments are forwarded, see {@link Application#main(String[])} + */ + public static void main(String[] args) { + setSystemProperties(); + + Application.main(args); + } + + + /** + * Sets any system-properties before anything else is touched. + */ + @SuppressWarnings("squid:S106") // we can not afford any dependencies, even on a logger + private static void setSystemProperties() { + final int cores = Runtime.getRuntime().availableProcessors(); + if (cores <= 1) { + // If we are in a docker container, we officially might just have 1 core + // and Java would then set the parallelism of the common ForkJoinPool to 0. + // And 0 means no workers, so JDA cannot function, no Callback's on REST-Requests + // are executed + // NOTE This will likely be fixed with Java 18 or newer, remove afterwards (see + // https://bugs.openjdk.java.net/browse/JDK-8274349 and + // https://github.com/openjdk/jdk/pull/5784) + // noinspection UseOfSystemOutOrSystemErr + System.out.println("Available Cores \"" + cores + "\", setting Parallelism Flag"); + // noinspection AccessOfSystemProperties + System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism", "1"); + } + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/Commands.java b/application/src/main/java/org/togetherjava/tjbot/commands/Commands.java index 88d6f4dd6e..de704a1f7a 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/Commands.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/Commands.java @@ -3,10 +3,19 @@ import org.jetbrains.annotations.NotNull; import org.togetherjava.tjbot.commands.basic.DatabaseCommand; import org.togetherjava.tjbot.commands.basic.PingCommand; +import org.togetherjava.tjbot.commands.basic.VcActivityCommand; +import org.togetherjava.tjbot.commands.mathcommands.TeXCommand; +import org.togetherjava.tjbot.commands.moderation.BanCommand; +import org.togetherjava.tjbot.commands.moderation.KickCommand; +import org.togetherjava.tjbot.commands.moderation.UnbanCommand; +import org.togetherjava.tjbot.commands.tags.TagCommand; +import org.togetherjava.tjbot.commands.tags.TagManageCommand; +import org.togetherjava.tjbot.commands.tags.TagSystem; +import org.togetherjava.tjbot.commands.tags.TagsCommand; import org.togetherjava.tjbot.db.Database; +import java.util.ArrayList; import java.util.Collection; -import java.util.List; /** * Utility class that offers all commands that should be registered by the system. New commands have @@ -30,9 +39,23 @@ public enum Commands { */ public static @NotNull Collection createSlashCommands( @NotNull Database database) { + TagSystem tagSystem = new TagSystem(database); // NOTE The command system can add special system relevant commands also by itself, // hence this list may not necessarily represent the full list of all commands actually // available. - return List.of(new PingCommand(), new DatabaseCommand(database)); + Collection commands = new ArrayList<>(); + + commands.add(new PingCommand()); + commands.add(new DatabaseCommand(database)); + commands.add(new TeXCommand()); + commands.add(new TagCommand(tagSystem)); + commands.add(new TagManageCommand(tagSystem)); + commands.add(new TagsCommand(tagSystem)); + commands.add(new VcActivityCommand()); + commands.add(new KickCommand()); + commands.add(new BanCommand()); + commands.add(new UnbanCommand()); + + return commands; } } diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/SlashCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/SlashCommand.java index 6aca0e5bc3..c8644c1147 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/SlashCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/SlashCommand.java @@ -1,6 +1,7 @@ package org.togetherjava.tjbot.commands; import net.dv8tion.jda.api.entities.Emoji; +import net.dv8tion.jda.api.events.ReadyEvent; import net.dv8tion.jda.api.events.interaction.ButtonClickEvent; import net.dv8tion.jda.api.events.interaction.SelectionMenuEvent; import net.dv8tion.jda.api.events.interaction.SlashCommandEvent; @@ -8,6 +9,9 @@ import net.dv8tion.jda.api.interactions.components.ButtonStyle; import net.dv8tion.jda.api.interactions.components.ComponentInteraction; import org.jetbrains.annotations.NotNull; +import org.togetherjava.tjbot.commands.componentids.ComponentId; +import org.togetherjava.tjbot.commands.componentids.ComponentIdGenerator; +import org.togetherjava.tjbot.commands.componentids.Lifespan; import java.util.List; @@ -21,8 +25,8 @@ *

*

* Slash commands can either be visible globally in Discord or just to specific guilds. They can - * have subcommands and, options, menus and more. This can be configured via {@link CommandData}, - * which is then to be returned by {@link #getData()} where the system will then pick it up from. + * have subcommands, options, menus and more. This can be configured via {@link CommandData}, which + * is then to be returned by {@link #getData()} where the system will then pick it up from. *

* After registration, the system will notify a command whenever one of its corresponding slash * commands ({@link #onSlashCommand(SlashCommandEvent)}), buttons @@ -89,39 +93,58 @@ public interface SlashCommand { CommandData getData(); /** - * Triggered by the command system when a slash command corresponding to this implementation - * (based on {@link #getData()} has been triggered. + * Triggered by the command system after system startup is complete. This can be used for + * initialisation actions that cannot occur during construction. + *

+ * This method may be called multi-threaded. There is no guarantee as to the order that commands + * will get called and there is no guarantee which thread they will be called on or even that + * they will be called by the same thread. + *

+ * There is also no guarantee that slashCommands will be registered on guilds before this is + * called. Do not use this method to interact with slashCommands. *

* Details are available in the given event and the event also enables implementations to * respond to it. *

+ * This method will be called in a multi-threaded context and the event may not be hold valid + * forever. + * + * @param event the event that triggered this + */ + void onReady(@NotNull ReadyEvent event); + + /** + * Triggered by the command system when a slash command corresponding to this implementation + * (based on {@link #getData()}) has been triggered. *

- * Buttons or menus have to be created with component ID (see + * This method may be called multi-threaded. In particular, there are no guarantees that it will + * be executed on the same thread repeatedly or on the same thread that other event methods have + * been called on. + *

+ * Details are available in the given event and the event also enables implementations to + * respond to it. + *

+ * Buttons or menus have to be created with a component ID (see * {@link ComponentInteraction#getComponentId()}, * {@link net.dv8tion.jda.api.interactions.components.Button#of(ButtonStyle, String, Emoji)}) in * a very specific format, otherwise the command system will fail to identify the command that * corresponded to the button or menu click event and is unable to route it back. *

- * The component ID has to be a string with less than 100 characters, in a valid CSV format - * (separated by comma {@code ,}), where: - *

- * An example would be {@code 13,ping,"hello,world"}. - *

- * Any provided extra arguments can then be picked up during the corresponding event (see + * The component ID has to be a UUID-string (see {@link java.util.UUID}), which is associated to + * a specific database entry, containing meta information about the command being executed. Such + * a database entry can be created and a UUID be obtained by using + * {@link ComponentIdGenerator#generate(ComponentId, Lifespan)}, as provided by the instance + * given to {@link #acceptComponentIdGenerator(ComponentIdGenerator)} during system setup. The + * required {@link ComponentId} instance accepts optional extra arguments, which, if provided, + * can be picked up during the corresponding event (see * {@link #onButtonClick(ButtonClickEvent, List)}, * {@link #onSelectionMenu(SelectionMenuEvent, List)}). *

- * The helper {@link org.togetherjava.tjbot.commands.system.ComponentIds} can be used to - * generate valid IDs. Alternatively, if {@link SlashCommandAdapter} has been extended, it also - * offers a handy {@link SlashCommandAdapter#generateComponentId(String...)} method to ease the - * flow. + * Alternatively, if {@link SlashCommandAdapter} has been extended, it also offers a handy + * {@link SlashCommandAdapter#generateComponentId(String...)} method to ease the flow. *

+ * See Component-IDs on + * our Wiki for more details and examples of how to use component IDs. *

* This method will be called in a multi-threaded context and the event may not be hold valid * forever. @@ -132,7 +155,11 @@ public interface SlashCommand { /** * Triggered by the command system when a button corresponding to this implementation (based on - * {@link #getData()} has been clicked. + * {@link #getData()}) has been clicked. + *

+ * This method may be called multi-threaded. In particular, there are no guarantees that it will + * be executed on the same thread repeatedly or on the same thread that other event methods have + * been called on. *

* Details are available in the given event and the event also enables implementations to * respond to it. @@ -148,7 +175,11 @@ public interface SlashCommand { /** * Triggered by the command system when a selection menu corresponding to this implementation - * (based on {@link #getData()} has been clicked. + * (based on {@link #getData()}) has been clicked. + *

+ * This method may be called multi-threaded. In particular, there are no guarantees that it will + * be executed on the same thread repeatedly or on the same thread that other event methods have + * been called on. *

* Details are available in the given event and the event also enables implementations to * respond to it. @@ -161,4 +192,14 @@ public interface SlashCommand { * {@link #onSlashCommand(SlashCommandEvent)} for details on how these are created */ void onSelectionMenu(@NotNull SelectionMenuEvent event, @NotNull List args); + + /** + * Triggered by the command system during its setup phase. It will provide the command a + * component id generator through this method, which can be used to generate component ids, as + * used for button or selection menus. See {@link #onSlashCommand(SlashCommandEvent)} for + * details on how to use this. + * + * @param generator the provided component id generator + */ + void acceptComponentIdGenerator(@NotNull ComponentIdGenerator generator); } diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/SlashCommandAdapter.java b/application/src/main/java/org/togetherjava/tjbot/commands/SlashCommandAdapter.java index 88a2763b74..9b461b50fe 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/SlashCommandAdapter.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/SlashCommandAdapter.java @@ -1,15 +1,18 @@ package org.togetherjava.tjbot.commands; -import com.fasterxml.jackson.core.JsonProcessingException; +import net.dv8tion.jda.api.events.ReadyEvent; import net.dv8tion.jda.api.events.interaction.ButtonClickEvent; import net.dv8tion.jda.api.events.interaction.SelectionMenuEvent; import net.dv8tion.jda.api.events.interaction.SlashCommandEvent; import net.dv8tion.jda.api.interactions.commands.build.CommandData; import org.jetbrains.annotations.NotNull; -import org.togetherjava.tjbot.commands.system.ComponentIds; +import org.togetherjava.tjbot.commands.componentids.ComponentId; +import org.togetherjava.tjbot.commands.componentids.ComponentIdGenerator; +import org.togetherjava.tjbot.commands.componentids.Lifespan; import java.util.Arrays; import java.util.List; +import java.util.Objects; /** * Adapter implementation of a {@link SlashCommand}. The minimal setup only requires implementation @@ -22,7 +25,7 @@ *

*

* The adapter manages all command related data itself, which can be provided during construction - * (see {@link #SlashCommandAdapter(String, String, SlashCommandVisibility)}. In order to add + * (see {@link #SlashCommandAdapter(String, String, SlashCommandVisibility)}). In order to add * options, subcommands or similar command configurations, use {@link #getData()} and mutate the * returned data object (see {@link CommandData} for details on how to work with this class). *

@@ -34,13 +37,13 @@ *

* Some example commands are available in {@link org.togetherjava.tjbot.commands.basic}. A minimal * setup would consist of a class like - * + * *

  * {
  *     @code
- *     class PingCommand extends SlashCommandAdapter {
- *         PingCommand() {
- *             super("ping", "responds with !pong", SlashCommandVisibility.GUILD);
+ *     public class PingCommand extends SlashCommandAdapter {
+ *         public PingCommand() {
+ *             super("ping", "Responds with 'Pong!'", SlashCommandVisibility.GUILD);
  *         }
  *
  *         @Override
@@ -50,7 +53,7 @@
  *     }
  * }
  * 
- * + *

* and registration of an instance of that class in {@link Commands}. */ public abstract class SlashCommandAdapter implements SlashCommand { @@ -58,6 +61,7 @@ public abstract class SlashCommandAdapter implements SlashCommand { private final String description; private final SlashCommandVisibility visibility; private final CommandData data; + private ComponentIdGenerator componentIdGenerator; /** * Creates a new adapter with the given data. @@ -97,6 +101,17 @@ protected SlashCommandAdapter(@NotNull String name, @NotNull String description, return data; } + @Override + public final void acceptComponentIdGenerator(@NotNull ComponentIdGenerator generator) { + componentIdGenerator = generator; + } + + @SuppressWarnings("NoopMethodInAbstractClass") + @Override + public void onReady(@NotNull ReadyEvent event) { + // Adapter does not react by default, subclasses may change this behavior + } + @SuppressWarnings("NoopMethodInAbstractClass") @Override public void onButtonClick(@NotNull ButtonClickEvent event, @NotNull List args) { @@ -116,18 +131,34 @@ public void onSelectionMenu(@NotNull SelectionMenuEvent event, @NotNull List + * IDs generated by this method have a regular lifespan, meaning that they might get evicted and + * expire after not being used for a long time. Use + * {@link #generateComponentId(Lifespan, String...)} to set other lifespans, if desired. + * + * @param args the extra arguments that should be part of the ID + * @return the generated component ID + */ + @SuppressWarnings("OverloadedVarargsMethod") + protected final @NotNull String generateComponentId(@NotNull String... args) { + return generateComponentId(Lifespan.REGULAR, args); + } + + /** + * Helper method to generate component IDs that are considered valid per + * {@link SlashCommand#onSlashCommand(SlashCommandEvent)}. *

- * The arguments must not be too long. The system will fail if the generated component ID exceed - * the character limit specified in {@link SlashCommand#onSlashCommand(SlashCommandEvent)}. + * They can be used to create buttons or selection menus and transport additional data + * throughout the event (e.g. the user id who created the button dialog). * + * @param lifespan the lifespan of the component id, controls when an id that was not used for a + * long time might be evicted and expire * @param args the extra arguments that should be part of the ID * @return the generated component ID */ - public final @NotNull String generateComponentId(@NotNull String... args) { - try { - return ComponentIds.generate(getName(), Arrays.asList(args)); - } catch (JsonProcessingException e) { - throw new IllegalArgumentException(e); - } + @SuppressWarnings({"OverloadedVarargsMethod", "WeakerAccess"}) + protected final @NotNull String generateComponentId(@NotNull Lifespan lifespan, + @NotNull String... args) { + return Objects.requireNonNull(componentIdGenerator) + .generate(new ComponentId(getName(), Arrays.asList(args)), lifespan); } } diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/basic/DatabaseCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/basic/DatabaseCommand.java index 08474c1adc..cc8d158ae5 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/basic/DatabaseCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/basic/DatabaseCommand.java @@ -85,12 +85,12 @@ private void handleGetCommand(@NotNull CommandInteraction event) { // /db get hello String key = Objects.requireNonNull(event.getOption(KEY_OPTION)).getAsString(); try { - Optional value = database.read(context -> { - try (var select = context.selectFrom(Storage.STORAGE)) { - return Optional.ofNullable(select.where(Storage.STORAGE.KEY.eq(key)).fetchOne()) - .map(StorageRecord::getValue); - } - }); + Optional value = database.read( + context -> Optional + .ofNullable(context.selectFrom(Storage.STORAGE) + .where(Storage.STORAGE.KEY.eq(key)) + .fetchOne()) + .map(StorageRecord::getValue)); if (value.isEmpty()) { event.reply("Nothing found for the key '" + key + "'").setEphemeral(true).queue(); return; diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/basic/VcActivityCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/basic/VcActivityCommand.java new file mode 100644 index 0000000000..7d94a12eb1 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/basic/VcActivityCommand.java @@ -0,0 +1,255 @@ +package org.togetherjava.tjbot.commands.basic; + +import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.entities.GuildVoiceState; +import net.dv8tion.jda.api.entities.Invite; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.VoiceChannel; +import net.dv8tion.jda.api.events.interaction.SlashCommandEvent; +import net.dv8tion.jda.api.interactions.commands.Command; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import net.dv8tion.jda.api.requests.restaction.interactions.ReplyAction; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.togetherjava.tjbot.commands.SlashCommandAdapter; +import org.togetherjava.tjbot.commands.SlashCommandVisibility; + +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Implements the {@code vc-activity} command. Creates VC activities. + * + *

+ * An VC activity is a so called "Embedded application". To explain it extremely simple, interactive + * screensharing.
+ * To give you a better idea of what it actually is, think about games like Poker, Chess, or + * watching YouTube Together using one of these.
+ */ +public final class VcActivityCommand extends SlashCommandAdapter { + private static final Logger logger = LoggerFactory.getLogger(VcActivityCommand.class); + + private static final String APPLICATION_SUBCOMMAND = "application"; + + private static final String APPLICATION_OPTION = "application"; + private static final String ID_OPTION = "id"; + + private static final String MAX_USES_OPTION = "max-uses"; + private static final String MAX_AGE_OPTION = "max-age"; + + private static final String YOUTUBE_TOGETHER_NAME = " YouTube Together"; + public static final String POKER_NAME = "Poker"; + public static final String BETRAYAL_IO_NAME = "Betrayal.io"; + public static final String FISHINGTON_IO_NAME = "Fishington.io"; + public static final String CHESS_CG_2_DEV = "Chess / CG 2 Dev"; + public static final String AWKWORD_NAME = "Awkword"; + public static final String SPELLCAST_NAME = "Spellcast"; + public static final String DOODLECREW_NAME = "Doodlecrew"; + public static final String WORDSNACK_NAME = "Wordsnack"; + public static final String LETTERTILE_NAME = "Lettertile"; + + + private static final List VC_APPLICATIONS = + List.of(new Command.Choice(YOUTUBE_TOGETHER_NAME, YOUTUBE_TOGETHER_NAME), + new Command.Choice(POKER_NAME, POKER_NAME), + new Command.Choice(BETRAYAL_IO_NAME, BETRAYAL_IO_NAME), + new Command.Choice(FISHINGTON_IO_NAME, FISHINGTON_IO_NAME), + new Command.Choice(CHESS_CG_2_DEV, CHESS_CG_2_DEV), + new Command.Choice(AWKWORD_NAME, AWKWORD_NAME), + new Command.Choice(SPELLCAST_NAME, SPELLCAST_NAME), + new Command.Choice(DOODLECREW_NAME, DOODLECREW_NAME), + new Command.Choice(WORDSNACK_NAME, WORDSNACK_NAME), + new Command.Choice(LETTERTILE_NAME, LETTERTILE_NAME)); + + + /** + * List comes from the "Implement + * invite targets" PR on JDA. There is no official list from Discord themselves, so this is + * our best bet. + */ + private static final Map VC_APPLICATION_TO_ID = Map.of(YOUTUBE_TOGETHER_NAME, + "755600276941176913", POKER_NAME, "755827207812677713", BETRAYAL_IO_NAME, + "773336526917861400", FISHINGTON_IO_NAME, "814288819477020702", CHESS_CG_2_DEV, + "832012586023256104", AWKWORD_NAME, "879863881349087252", SPELLCAST_NAME, + "852509694341283871", DOODLECREW_NAME, "878067389634314250", WORDSNACK_NAME, + "879863976006127627", LETTERTILE_NAME, "879863686565621790"); + + private static final List inviteOptions = List.of( + new OptionData(OptionType.STRING, MAX_USES_OPTION, + "The amount of times the invite can be used, default is infinity", false), + new OptionData(OptionType.INTEGER, MAX_AGE_OPTION, + "Max age in seconds. Set this to 0 to never expire, default is 1 day", false)); + + + /** + * Constructs an instance + * + * @see VcActivityCommand + */ + public VcActivityCommand() { + super("vc-activity", + "Starts a VC activity (you need to be in an voice channel to run this command)", + SlashCommandVisibility.GUILD); + + + SubcommandData applicationSubCommand = + new SubcommandData(APPLICATION_SUBCOMMAND, "Choose an application from our list") + .addOptions(new OptionData(OptionType.STRING, APPLICATION_OPTION, + "the application", true).addChoices(VC_APPLICATIONS)) + .addOptions(inviteOptions); + + + SubcommandData idSubCommand = + new SubcommandData("id", "specify the ID for the application manually") + .addOption(OptionType.STRING, ID_OPTION, "the ID of the application", true) + .addOptions(inviteOptions); + + + getData().addSubcommands(applicationSubCommand, idSubCommand); + } + + + @Override + public void onSlashCommand(@NotNull SlashCommandEvent event) { + Member member = Objects.requireNonNull(event.getMember(), "member is null"); + GuildVoiceState voiceState = Objects.requireNonNull(member.getVoiceState(), + "Voicestates aren't being cached, check the JDABuilder"); + + if (!voiceState.inVoiceChannel()) { + event.reply("You need to be in a voicechannel to run this command!") + .setEphemeral(true) + .queue(); + + return; + } + + VoiceChannel voiceChannel = Objects.requireNonNull(voiceState.getChannel()); + + Member selfMember = Objects.requireNonNull(event.getGuild()).getSelfMember(); + if (!selfMember.hasPermission(Permission.CREATE_INSTANT_INVITE)) { + event.reply("The bot needs the create instant invite permission!") + .setEphemeral(true) + .queue(); + logger.warn("Bot doesn't have the create instant permission"); + return; + } + + OptionMapping applicationOption = event.getOption(APPLICATION_OPTION); + OptionMapping idOption = event.getOption(ID_OPTION); + + OptionMapping maxUsesOption = event.getOption(MAX_USES_OPTION); + OptionMapping maxAgeOption = event.getOption(MAX_AGE_OPTION); + + Integer maxUses; + + // the user already received the error in the handleIntegerTypeOption method + // it still throws to tell us to return this method and stop the proceeding code + try { + maxUses = handleIntegerTypeOption(event, maxUsesOption); + } catch (IllegalArgumentException ignore) { + return; + } + + Integer maxAge; + + // the user already received the error in the handleIntegerTypeOption method + // it still throws to tell us to return this method and stop the proceeding code + try { + maxAge = handleIntegerTypeOption(event, maxAgeOption); + } catch (IllegalArgumentException ignore) { + return; + } + + + String applicationId; + + if (applicationOption != null) { + applicationId = VC_APPLICATION_TO_ID.get(applicationOption.getAsString()); + } else { + applicationId = idOption.getAsString(); + } + + handleSubcommand(event, voiceChannel, applicationId, maxUses, maxAge); + } + + private static void handleSubcommand(@NotNull SlashCommandEvent event, + @NotNull VoiceChannel voiceChannel, @NotNull String applicationId, + @Nullable Integer maxUses, @Nullable Integer maxAge) { + + voiceChannel.createInvite() + .setTargetApplication(applicationId) + .setMaxUses(maxUses) + .setMaxAge(maxAge) + .flatMap(invite -> replyInvite(event, invite)) + .queue(null, throwable -> handleErrors(event, throwable)); + } + + private static @NotNull ReplyAction replyInvite(@NotNull SlashCommandEvent event, + @NotNull Invite invite) { + return event.reply(""" + I wish you a lot of fun, here's the invite: %s + If it says the activity ended, click on the URL instead. + """.formatted(invite.getUrl())); + } + + private static void handleErrors(@NotNull SlashCommandEvent event, + @Nullable Throwable throwable) { + event.reply("Something went wrong :/").queue(); + logger.warn("Something went wrong in the VcActivityCommand", throwable); + } + + + /** + * This grabs the OptionMapping, after this it
+ * - validates whenever it's within an {@link Integer Integer's} range
+ * - validates whenever it's positive
+ * + *

+ *

+ * + * @param event the {@link SlashCommandEvent} + * @param optionMapping the {@link OptionMapping} + * @return nullable {@link Integer} + * @throws java.lang.IllegalArgumentException if the option's value is - outside of + * {@link Integer#MAX_VALUE} - negative + */ + @Contract("_, null -> null") + private static @Nullable Integer handleIntegerTypeOption(@NotNull SlashCommandEvent event, + @Nullable OptionMapping optionMapping) { + + int optionValue; + + if (optionMapping == null) { + return null; + } + + try { + optionValue = Math.toIntExact(optionMapping.getAsLong()); + } catch (ArithmeticException e) { + event + .reply("The " + optionMapping.getName() + " is above `" + Integer.MAX_VALUE + + "`, which is too high") + .setEphemeral(true) + .queue(); + throw new IllegalArgumentException( + optionMapping.getName() + " can't be above " + Integer.MAX_VALUE); + } + + if (optionValue < 0) { + event.reply("The " + optionMapping.getName() + " is negative, which isn't supported") + .setEphemeral(true) + .queue(); + throw new IllegalArgumentException(optionMapping.getName() + " can't be negative"); + } + + + return optionValue; + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/componentids/ComponentId.java b/application/src/main/java/org/togetherjava/tjbot/commands/componentids/ComponentId.java new file mode 100644 index 0000000000..eaeb9b17b8 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/componentids/ComponentId.java @@ -0,0 +1,18 @@ +package org.togetherjava.tjbot.commands.componentids; + +import net.dv8tion.jda.api.events.interaction.SlashCommandEvent; +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +/** + * Payload carried by component IDs. See + * {@link org.togetherjava.tjbot.commands.SlashCommand#onSlashCommand(SlashCommandEvent)} for its + * usages. + * + * @param commandName the name of the command that handles the event associated to this component + * ID, when triggered + * @param elements the additional elements to carry along this component ID, empty if not desired + */ +public record ComponentId(@NotNull String commandName, @NotNull List elements) { +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/componentids/ComponentIdGenerator.java b/application/src/main/java/org/togetherjava/tjbot/commands/componentids/ComponentIdGenerator.java new file mode 100644 index 0000000000..52bffc901a --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/componentids/ComponentIdGenerator.java @@ -0,0 +1,39 @@ +package org.togetherjava.tjbot.commands.componentids; + +import net.dv8tion.jda.api.entities.Emoji; +import net.dv8tion.jda.api.events.interaction.SlashCommandEvent; +import net.dv8tion.jda.api.interactions.components.ButtonStyle; +import net.dv8tion.jda.api.interactions.components.ComponentInteraction; +import org.jetbrains.annotations.NotNull; + +/** + * Provides component ID generation. + *

+ * Component IDs are used during button or selection menu events. They can carry arbitrary data and + * are persisted by the system. + *

+ * See {@link org.togetherjava.tjbot.commands.SlashCommand#onSlashCommand(SlashCommandEvent)} for + * more context on how to use this. + *

+ * The interface {@link ComponentIdParser} is the counterpart to this, offering parsing back the + * payload from the ID. + */ +@FunctionalInterface +public interface ComponentIdGenerator { + /** + * Generates and persists a valid component ID for the given payload, which can then be used in + * interactions, such as button or selection menus. + *

+ * See {@link ComponentInteraction#getComponentId()} and + * {@link net.dv8tion.jda.api.interactions.components.Button#of(ButtonStyle, String, Emoji)} for + * details on where the generated ID can be used. + * + * @param componentId the component ID payload to persist and generate a valid ID for + * @param lifespan the lifespan of the generated and persisted component ID + * @return a UUID for the given payload, which can be used as component ID + * @throws InvalidComponentIdFormatException if the given component ID was in an unexpected + * format and could not be serialized + */ + @NotNull + String generate(@NotNull ComponentId componentId, @NotNull Lifespan lifespan); +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/componentids/ComponentIdParser.java b/application/src/main/java/org/togetherjava/tjbot/commands/componentids/ComponentIdParser.java new file mode 100644 index 0000000000..a6188da8b9 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/componentids/ComponentIdParser.java @@ -0,0 +1,41 @@ +package org.togetherjava.tjbot.commands.componentids; + +import net.dv8tion.jda.api.entities.Emoji; +import net.dv8tion.jda.api.events.interaction.SlashCommandEvent; +import net.dv8tion.jda.api.interactions.components.ButtonStyle; +import net.dv8tion.jda.api.interactions.components.ComponentInteraction; +import org.jetbrains.annotations.NotNull; + +import java.util.Optional; + +/** + * Provides component ID parsing. + *

+ * Component IDs are used during button or selection menu events. They can carry arbitrary data and + * are persisted by the system. + *

+ * See {@link org.togetherjava.tjbot.commands.SlashCommand#onSlashCommand(SlashCommandEvent)} for + * more context on how to use this. + *

+ * The interface {@link ComponentIdGenerator} is the counterpart to this, offering generation of IDs + * from payload. + */ +@FunctionalInterface +public interface ComponentIdParser { + /** + * Parses a previously generated and persisted component ID payload, as used during + * interactions, such as button or selection menus. + *

+ * See {@link ComponentInteraction#getComponentId()} and + * {@link net.dv8tion.jda.api.interactions.components.Button#of(ButtonStyle, String, Emoji)} for + * details on where the ID was originally transported with. + * + * @param uuid the UUID to parse which represents the component ID + * @return the payload associated to the given UUID, if empty the component ID either never + * existed to begin with or expired due to its lifetime setting + * @throws InvalidComponentIdFormatException if the component ID associated to the given UUID + * was in an unexpected format and could not be deserialized + */ + @NotNull + Optional parse(@NotNull String uuid); +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/componentids/ComponentIdStore.java b/application/src/main/java/org/togetherjava/tjbot/commands/componentids/ComponentIdStore.java new file mode 100644 index 0000000000..8aca4f38c3 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/componentids/ComponentIdStore.java @@ -0,0 +1,334 @@ +package org.togetherjava.tjbot.commands.componentids; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.dataformat.csv.CsvMapper; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import net.dv8tion.jda.api.events.interaction.SlashCommandEvent; +import org.jetbrains.annotations.NotNull; +import org.jooq.Result; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.togetherjava.tjbot.db.Database; +import org.togetherjava.tjbot.db.generated.tables.ComponentIds; +import org.togetherjava.tjbot.db.generated.tables.records.ComponentIdsRecord; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalUnit; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +/** + * Thread-safe storage for component IDs. Can put, persist and get back component IDs based on + * UUIDs. Component IDs are used for button and selection menu commands, see + * {@link org.togetherjava.tjbot.commands.SlashCommand#onSlashCommand(SlashCommandEvent)} for + * details. + *

+ * Use {@link #putOrThrow(UUID, ComponentId, Lifespan)} to put and persist a component ID; and + * {@link #get(UUID)} to get it back. Component IDs are persisted during application runs and can + * hence be retrieved back even after long times. + *

+ *

+ * Component IDs which have not been used for a long time, depending on their {@link Lifespan} + * setting, might get evicted from the store after some time. The store implements a + * LRU-cache and each call of {@link #get(UUID)} will update the usage-timestamp + * for the component ID. + *

+ * Users can react to eviction by adding a listener to + * {@link #addComponentIdRemovedListener(Consumer)}. + *

+ * The store is fully thread-safe, component IDs can be generated and parsed multithreaded. + */ +@SuppressWarnings("ClassWithTooManyFields") +public final class ComponentIdStore implements AutoCloseable { + private static final Logger logger = LoggerFactory.getLogger(ComponentIdStore.class); + private static final CsvMapper CSV = new CsvMapper(); + + private static final long EVICT_DATABASE_EVERY_INITIAL_DELAY = 1; + private static final long EVICT_DATABASE_EVERY_DELAY = 15; + private static final ChronoUnit EVICT_DATABASE_EVERY_UNIT = ChronoUnit.MINUTES; + private static final long EVICT_DATABASE_OLDER_THAN = 20; + private static final ChronoUnit EVICT_DATABASE_OLDER_THAN_UNIT = ChronoUnit.DAYS; + + private static final int CACHE_SIZE = 1_000; + private static final int EVICT_CACHE_OLDER_THAN = 2; + private static final ChronoUnit EVICT_CACHE_OLDER_THAN_UNIT = ChronoUnit.HOURS; + + private final Object storeLock = new Object(); + private final Database database; + /** + * In-memory cache which is used as first stage before the database, to speedup look-ups. Should + * cover the majority of all queries, as most queries (e.g. button clicks) come from messages + * that have been created in the past hours and not days. + */ + private final Cache storeCache; + private final Collection> componentIdRemovedListeners = + Collections.synchronizedCollection(new ArrayList<>()); + private final ExecutorService heatService = Executors.newCachedThreadPool(); + private final ExecutorService componentIdRemovedListenerService = + Executors.newCachedThreadPool(); + private final ScheduledExecutorService evictionService = + Executors.newSingleThreadScheduledExecutor(); + private final ScheduledFuture evictionTask; + private final long evictDatabaseOlderThan; + private final TemporalUnit evictDatabaseOlderThanUnit; + + /** + * Creates a new instance with default eviction settings. + * + * @param database the database to use to persist component IDs in + */ + public ComponentIdStore(@NotNull Database database) { + this(database, EVICT_DATABASE_EVERY_INITIAL_DELAY, EVICT_DATABASE_EVERY_DELAY, + EVICT_DATABASE_EVERY_UNIT, EVICT_DATABASE_OLDER_THAN, + EVICT_DATABASE_OLDER_THAN_UNIT); + } + + /** + * Creates a new instance with given eviction settings. + * + * @param database the database to use to persist component IDs in + * @param evictEveryInitialDelay delay before the first eviction is triggered + * @param evictEveryDelay delay after which the next eviction is triggered, measured after an + * eviction is done + * @param evictEveryUnit the unit of the 'evictEvery' values + * @param evictOlderThan component IDs that have not been used for longer than this will be + * deleted during eviction + * @param evictOlderThanUnit the unit of the 'evictOlderThan' value + */ + @SuppressWarnings({"WeakerAccess", "ConstructorWithTooManyParameters"}) + public ComponentIdStore(@NotNull Database database, long evictEveryInitialDelay, + long evictEveryDelay, ChronoUnit evictEveryUnit, long evictOlderThan, + @SuppressWarnings("TypeMayBeWeakened") ChronoUnit evictOlderThanUnit) { + this.database = database; + evictDatabaseOlderThan = evictOlderThan; + evictDatabaseOlderThanUnit = evictOlderThanUnit; + + storeCache = Caffeine.newBuilder() + .maximumSize(CACHE_SIZE) + .expireAfterAccess(EVICT_CACHE_OLDER_THAN, TimeUnit.of(EVICT_CACHE_OLDER_THAN_UNIT)) + .build(); + evictionTask = evictionService.scheduleWithFixedDelay(this::evictDatabase, + evictEveryInitialDelay, evictEveryDelay, TimeUnit.of(evictEveryUnit)); + + logDebugSizeStatistics(); + } + + /** + * Adds a listener for component ID removal. The listener is triggered during eviction, once for + * each component ID that has been removed from the store. + *

+ * The listener might be triggered multithreaded, there are no guarantees made regarding the + * executing thread. In particular, it might be a different thread each time it is triggered. + * + * @param listener the listener to add + */ + public void addComponentIdRemovedListener(@NotNull Consumer listener) { + componentIdRemovedListeners.add(listener); + } + + /** + * Gets the component ID associated to the given UUID. + *

+ * If a component ID is not present, this can either mean it was never inserted before or it has + * been evicted already. Use {@link #addComponentIdRemovedListener(Consumer)} to react to this + * event. + *

+ * Use {@link #putOrThrow(UUID, ComponentId, Lifespan)} to add component IDs. + * + * @param uuid the UUID to lookup + * @return the associated component ID, if present + * @throws InvalidComponentIdFormatException if the given component ID was in an unexpected + * format and could not be serialized + */ + @SuppressWarnings("WeakerAccess") + public @NotNull Optional get(@NotNull UUID uuid) { + synchronized (storeLock) { + // Get it from the cache or, if not found, the database + return Optional.ofNullable(storeCache.getIfPresent(uuid)).or(() -> { + Optional databaseComponentId = getFromDatabase(uuid); + databaseComponentId.ifPresent(id -> { + // Put it back into the cache + storeCache.put(uuid, id); + + heatService.execute(() -> heatRecord(uuid)); + }); + return databaseComponentId; + }); + } + } + + /** + * Adds the given component ID to the store, associated with the given UUID as key. + *

+ * The method throws if the UUID is already associated to a component ID. After a component ID + * has been evicted (see {@link #addComponentIdRemovedListener(Consumer)}), it is safe to call + * this method again for the evicted UUID. + * + * @param uuid the UUID to associate the component ID with + * @param componentId the component ID to add to the store + * @param lifespan the lifespan of the component ID, controls when it will be targeted for + * eviction + * @throws IllegalArgumentException if the given UUID is already associated to a component ID + * @throws InvalidComponentIdFormatException if the component ID associated to the given UUID + * was in an unexpected format and could not be deserialized + */ + @SuppressWarnings("WeakerAccess") + public void putOrThrow(@NotNull UUID uuid, @NotNull ComponentId componentId, + @NotNull Lifespan lifespan) { + Supplier alreadyExistsMessageSupplier = + () -> "The UUID '%s' already exists and is associated to a component id." + .formatted(uuid); + + synchronized (storeLock) { + if (storeCache.getIfPresent(uuid) != null) { + throw new IllegalArgumentException(alreadyExistsMessageSupplier.get()); + } + storeCache.put(uuid, componentId); + + database.writeTransaction(context -> { + String uuidText = uuid.toString(); + if (context.fetchExists(ComponentIds.COMPONENT_IDS, + ComponentIds.COMPONENT_IDS.UUID.eq(uuidText))) { + throw new IllegalArgumentException(alreadyExistsMessageSupplier.get()); + } + + ComponentIdsRecord componentIdsRecord = + context.newRecord(ComponentIds.COMPONENT_IDS) + .setUuid(uuid.toString()) + .setComponentId(serializeComponentId(componentId)) + .setLastUsed(Instant.now()) + .setLifespan(lifespan.name()); + componentIdsRecord.insert(); + }); + } + } + + private @NotNull Optional getFromDatabase(@NotNull UUID uuid) { + return database.read(context -> Optional + .ofNullable(context.selectFrom(ComponentIds.COMPONENT_IDS) + .where(ComponentIds.COMPONENT_IDS.UUID.eq(uuid.toString())) + .fetchOne()) + .map(ComponentIdsRecord::getComponentId) + .map(ComponentIdStore::deserializeComponentId)); + } + + /** + * Updates the last_used timestamp for the given UUID in the database to the current + * time. This effectively heats the record, so that it will not be targeted for the next + * evictions. + * + * @param uuid the uuid to heat + * @throws IllegalArgumentException if there is no, or multiple, records associated to that UUID + */ + private void heatRecord(@NotNull UUID uuid) { + int updatedRecords; + synchronized (storeLock) { + updatedRecords = + database.writeAndProvide(context -> context.update(ComponentIds.COMPONENT_IDS) + .set(ComponentIds.COMPONENT_IDS.LAST_USED, Instant.now()) + .where(ComponentIds.COMPONENT_IDS.UUID.eq(uuid.toString())) + .execute()); + } + + // NOTE Case 0, where no records are updated, is ignored on purpose. + // This happens when the entry has been evicted before the heating was executed. + if (updatedRecords > 1) { + throw new AssertionError( + "Multiple records had the UUID '%s' even though it is unique.".formatted(uuid)); + } + } + + private void evictDatabase() { + logger.debug("Evicting old non-permanent component ids from the database..."); + AtomicInteger evictedCounter = new AtomicInteger(0); + synchronized (storeLock) { + database.write(context -> { + Result oldRecords = context + .selectFrom(ComponentIds.COMPONENT_IDS) + .where(ComponentIds.COMPONENT_IDS.LIFESPAN.notEqual(Lifespan.PERMANENT.name()) + .and(ComponentIds.COMPONENT_IDS.LAST_USED.lessOrEqual(Instant.now() + .minus(evictDatabaseOlderThan, evictDatabaseOlderThanUnit)))) + .fetch(); + + oldRecords.forEach(recordToDelete -> { + UUID uuid = UUID + .fromString(recordToDelete.getValue(ComponentIds.COMPONENT_IDS.UUID)); + ComponentId componentId = deserializeComponentId( + recordToDelete.getValue(ComponentIds.COMPONENT_IDS.COMPONENT_ID)); + Instant lastUsed = recordToDelete.getLastUsed(); + + recordToDelete.delete(); + evictedCounter.getAndIncrement(); + logger.debug( + "Evicted component id with uuid '{}' from command '{}', last used '{}'", + uuid, componentId.commandName(), lastUsed); + + // Remove them from the cache if still in there + storeCache.invalidate(uuid); + // Notify all listeners, but non-blocking to not delay eviction + componentIdRemovedListeners + .forEach(listener -> componentIdRemovedListenerService + .execute(() -> listener.accept(componentId))); + }); + }); + } + + if (evictedCounter.get() != 0) { + logger.info("Evicted {} old non-permanent component ids from the database", + evictedCounter.get()); + } + } + + private static @NotNull String serializeComponentId(@NotNull ComponentId componentId) { + try { + return CSV.writerFor(ComponentId.class) + .with(CSV.schemaFor(ComponentId.class)) + .writeValueAsString(componentId); + } catch (JsonProcessingException e) { + throw new InvalidComponentIdFormatException(e); + } + } + + private static @NotNull ComponentId deserializeComponentId(@NotNull String componentId) { + try { + return CSV.readerFor(ComponentId.class) + .with(CSV.schemaFor(ComponentId.class)) + .readValue(componentId); + } catch (JsonProcessingException e) { + throw new InvalidComponentIdFormatException(e); + } + } + + private void logDebugSizeStatistics() { + if (!logger.isDebugEnabled()) { + return; + } + + Map lifespanToCount = Arrays.stream(Lifespan.values()) + .collect(Collectors.toMap(Function.identity(), + lifespan -> database + .read(context -> context.fetchCount(ComponentIds.COMPONENT_IDS, + ComponentIds.COMPONENT_IDS.LIFESPAN.eq(lifespan.name()))))); + int recordsCount = lifespanToCount.values().stream().mapToInt(Integer::intValue).sum(); + + logger.debug("The component id store consists of {} records ({})", recordsCount, + lifespanToCount); + } + + @Override + public void close() { + heatService.shutdown(); + if (evictionTask != null) { + evictionTask.cancel(false); + } + evictionService.shutdown(); + componentIdRemovedListenerService.shutdown(); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/componentids/InvalidComponentIdFormatException.java b/application/src/main/java/org/togetherjava/tjbot/commands/componentids/InvalidComponentIdFormatException.java new file mode 100644 index 0000000000..99b2f9a825 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/componentids/InvalidComponentIdFormatException.java @@ -0,0 +1,27 @@ +package org.togetherjava.tjbot.commands.componentids; + +import java.io.Serial; + +/** + * Exception that is thrown whenever a component ID is in an unexpected format and can not be + * serialized or deserialized. See {@link ComponentIdGenerator} and {@link ComponentIdParser} for + * details. + */ +public final class InvalidComponentIdFormatException extends RuntimeException { + @Serial + private static final long serialVersionUID = 2180184251986422000L; + + /** + * Creates a new instance. + */ + public InvalidComponentIdFormatException() {} + + /** + * Creates a new instance with a given cause. + * + * @param cause the cause of this exception + */ + public InvalidComponentIdFormatException(Throwable cause) { + super(cause); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/componentids/Lifespan.java b/application/src/main/java/org/togetherjava/tjbot/commands/componentids/Lifespan.java new file mode 100644 index 0000000000..b9d8d05d63 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/componentids/Lifespan.java @@ -0,0 +1,18 @@ +package org.togetherjava.tjbot.commands.componentids; + +/** + * The lifespan of a component ID. Controls when it will be targeted for eviction and expire. + */ +public enum Lifespan { + /** + * Component IDs with permanent lifespan are never deleted, hence will never expire. Use this + * with care and only with events that actually have to be usable forever. + */ + PERMANENT, + /** + * Component IDs with a regular lifespan are deleted after some time when needed. Once this + * happens, their associated event expires and can not be used anymore. This should be the + * default setting for most component IDs. + */ + REGULAR +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/componentids/package-info.java b/application/src/main/java/org/togetherjava/tjbot/commands/componentids/package-info.java new file mode 100644 index 0000000000..1353844d85 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/componentids/package-info.java @@ -0,0 +1,13 @@ +/** + * This package provides utilities to generate, persist and parse component IDs. + *

+ * See + * {@link org.togetherjava.tjbot.commands.SlashCommand#onSlashCommand(net.dv8tion.jda.api.events.interaction.SlashCommandEvent)} + * for details on component IDs. + *

+ * The class {@link org.togetherjava.tjbot.commands.componentids.ComponentIdStore} is the central + * point of this package and is generally exposed as + * {@link org.togetherjava.tjbot.commands.componentids.ComponentIdGenerator} and + * {@link org.togetherjava.tjbot.commands.componentids.ComponentIdParser}. + */ +package org.togetherjava.tjbot.commands.componentids; diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/mathcommands/TeXCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/mathcommands/TeXCommand.java new file mode 100644 index 0000000000..468fe7fda2 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/mathcommands/TeXCommand.java @@ -0,0 +1,106 @@ +package org.togetherjava.tjbot.commands.mathcommands; + +import net.dv8tion.jda.api.events.interaction.ButtonClickEvent; +import net.dv8tion.jda.api.events.interaction.SlashCommandEvent; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.components.Button; +import net.dv8tion.jda.api.interactions.components.ButtonStyle; +import org.jetbrains.annotations.NotNull; +import org.scilab.forge.jlatexmath.ParseException; +import org.scilab.forge.jlatexmath.TeXConstants; +import org.scilab.forge.jlatexmath.TeXFormula; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.togetherjava.tjbot.commands.SlashCommandAdapter; +import org.togetherjava.tjbot.commands.SlashCommandVisibility; + +import javax.imageio.ImageIO; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.List; +import java.util.Objects; + +/** + * Implementation of a tex command which takes a string and renders an image corresponding to the + * mathematical expression in that string. + *

+ * The implemented command is {@code /tex}. This has a single option called {@code latex} which is a + * string. If it is invalid latex or there is an error in rendering the image, it displays an error + * message. + */ + +public class TeXCommand extends SlashCommandAdapter { + + private static final String LATEX_OPTION = "latex"; + private static final String RENDERING_ERROR = "There was an error generating the image"; + private static final float DEFAULT_IMAGE_SIZE = 40F; + private static final Color BACKGROUND_COLOR = Color.decode("#36393F"); + private static final Color FOREGROUND_COLOR = Color.decode("#FFFFFF"); + private static final Logger logger = LoggerFactory.getLogger(TeXCommand.class); + + /** + * Creates a new Instance. + */ + public TeXCommand() { + super("tex", + "This command accepts a latex expression and generates an image corresponding to it.", + SlashCommandVisibility.GUILD); + getData().addOption(OptionType.STRING, LATEX_OPTION, + "The latex which is rendered as an image", true); + } + + @Override + public void onSlashCommand(@NotNull final SlashCommandEvent event) { + String latex = Objects.requireNonNull(event.getOption(LATEX_OPTION)).getAsString(); + String userID = (Objects.requireNonNull(event.getMember()).getId()); + TeXFormula formula; + try { + formula = new TeXFormula(latex); + } catch (ParseException e) { + event.reply("That is an invalid latex: " + e.getMessage()).setEphemeral(true).queue(); + return; + } + event.deferReply().queue(); + Image image = formula.createBufferedImage(TeXConstants.STYLE_DISPLAY, DEFAULT_IMAGE_SIZE, + FOREGROUND_COLOR, BACKGROUND_COLOR); + if (image.getWidth(null) == -1 || image.getHeight(null) == -1) { + event.getHook().setEphemeral(true).editOriginal(RENDERING_ERROR).queue(); + logger.warn( + "Unable to render latex, image does not have an accessible width or height. Formula was {}", + latex); + return; + } + BufferedImage renderedTextImage = new BufferedImage(image.getWidth(null), + image.getHeight(null), BufferedImage.TYPE_4BYTE_ABGR); + renderedTextImage.getGraphics().drawImage(image, 0, 0, null); + ByteArrayOutputStream renderedTextImageStream = new ByteArrayOutputStream(); + + try { + ImageIO.write(renderedTextImage, "png", renderedTextImageStream); + } catch (IOException e) { + event.getHook().setEphemeral(true).editOriginal(RENDERING_ERROR).queue(); + logger.warn( + "Unable to render latex, could not convert the image into an attachable form. Formula was {}", + latex, e); + return; + } + event.getHook() + .editOriginal(renderedTextImageStream.toByteArray(), "tex.png") + .setActionRow(Button.of(ButtonStyle.DANGER, generateComponentId(userID), "Delete")) + .queue(); + } + + @Override + public void onButtonClick(@NotNull final ButtonClickEvent event, + @NotNull final List args) { + if (!args.get(0).equals(Objects.requireNonNull(event.getMember()).getId())) { + event.reply("You are not the person who executed the command, you cannot do that") + .setEphemeral(true) + .queue(); + return; + } + event.getMessage().delete().queue(); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/BanCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/BanCommand.java new file mode 100644 index 0000000000..427eb63ee4 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/BanCommand.java @@ -0,0 +1,196 @@ +package org.togetherjava.tjbot.commands.moderation; + +import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.entities.*; +import net.dv8tion.jda.api.events.GenericEvent; +import net.dv8tion.jda.api.events.interaction.SlashCommandEvent; +import net.dv8tion.jda.api.exceptions.ErrorResponseException; +import net.dv8tion.jda.api.interactions.Interaction; +import net.dv8tion.jda.api.interactions.InteractionHook; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import net.dv8tion.jda.api.requests.ErrorResponse; +import net.dv8tion.jda.api.requests.RestAction; +import net.dv8tion.jda.api.requests.restaction.AuditableRestAction; +import net.dv8tion.jda.api.utils.Result; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.togetherjava.tjbot.commands.SlashCommandAdapter; +import org.togetherjava.tjbot.commands.SlashCommandVisibility; +import org.togetherjava.tjbot.config.Config; + +import java.util.Objects; +import java.util.Optional; +import java.util.function.Predicate; +import java.util.regex.Pattern; + +/** + * This command can ban users and optionally remove their messages from the past days. Banning can + * also be paired with a ban reason. The command will also try to DM the user to inform them about + * the action and the reason. + *

+ * The command fails if the user triggering it is lacking permissions to either ban other users or + * to ban the specific given user (for example a moderator attempting to ban an admin). + */ +public final class BanCommand extends SlashCommandAdapter { + private static final Logger logger = LoggerFactory.getLogger(BanCommand.class); + private static final String TARGET_OPTION = "user"; + private static final String DELETE_HISTORY_OPTION = "delete-history"; + private static final String REASON_OPTION = "reason"; + private static final String COMMAND_NAME = "ban"; + private static final String ACTION_VERB = "ban"; + private final Predicate hasRequiredRole; + + /** + * Constructs an instance. + */ + public BanCommand() { + super(COMMAND_NAME, "Bans the given user from the server", SlashCommandVisibility.GUILD); + + getData().addOption(OptionType.USER, TARGET_OPTION, "The user who you want to ban", true) + .addOption(OptionType.STRING, REASON_OPTION, "Why the user should be banned", true) + .addOptions(new OptionData(OptionType.INTEGER, DELETE_HISTORY_OPTION, + "the amount of days of the message history to delete, none means no messages are deleted.", + true).addChoice("none", 0).addChoice("recent", 1).addChoice("all", 7)); + + hasRequiredRole = Pattern.compile(Config.getInstance().getHeavyModerationRolePattern()) + .asMatchPredicate(); + } + + private static RestAction handleAlreadyBanned(@NotNull Guild.Ban ban, + @NotNull Interaction event) { + String reason = ban.getReason(); + String reasonText = + reason == null || reason.isBlank() ? "" : " (reason: %s)".formatted(reason); + + String message = "The user '%s' is already banned%s.".formatted(ban.getUser().getAsTag(), + reasonText); + return event.reply(message).setEphemeral(true); + } + + @SuppressWarnings("MethodWithTooManyParameters") + private static RestAction banUserFlow(@NotNull User target, + @NotNull Member author, @NotNull String reason, int deleteHistoryDays, + @NotNull Guild guild, @NotNull SlashCommandEvent event) { + return sendDm(target, reason, guild, event) + .flatMap(hasSentDm -> banUser(target, author, reason, deleteHistoryDays, guild) + .map(banResult -> hasSentDm)) + .map(hasSentDm -> sendFeedback(hasSentDm, target, author, reason)) + .flatMap(event::replyEmbeds); + } + + private static RestAction sendDm(@NotNull ISnowflake target, @NotNull String reason, + @NotNull Guild guild, @NotNull GenericEvent event) { + return event.getJDA() + .openPrivateChannelById(target.getId()) + .flatMap(channel -> channel.sendMessage( + """ + Hey there, sorry to tell you but unfortunately you have been banned from the server %s. + If you think this was a mistake, please contact a moderator or admin of the server. + The reason for the ban is: %s + """ + .formatted(guild.getName(), reason))) + .mapToResult() + .map(Result::isSuccess); + } + + private static AuditableRestAction banUser(@NotNull User target, @NotNull Member author, + @NotNull String reason, int deleteHistoryDays, @NotNull Guild guild) { + logger.info( + "'{}' ({}) banned the user '{}' ({}) from guild '{}' and deleted their message history of the last {} days, for reason '{}'.", + author.getUser().getAsTag(), author.getId(), target.getAsTag(), target.getId(), + guild.getName(), deleteHistoryDays, reason); + + return guild.ban(target, deleteHistoryDays, reason); + } + + private static @NotNull MessageEmbed sendFeedback(boolean hasSentDm, @NotNull User target, + @NotNull Member author, @NotNull String reason) { + String dmNoticeText = ""; + if (!hasSentDm) { + dmNoticeText = "(Unable to send them a DM.)"; + } + return ModerationUtils.createActionResponse(author.getUser(), ModerationUtils.Action.BAN, + target, dmNoticeText, reason); + } + + private static Optional> handleNotAlreadyBannedResponse( + @NotNull Throwable alreadyBannedFailure, @NotNull Interaction event, + @NotNull Guild guild, @NotNull User target) { + if (alreadyBannedFailure instanceof ErrorResponseException errorResponseException) { + if (errorResponseException.getErrorResponse() == ErrorResponse.UNKNOWN_BAN) { + return Optional.empty(); + } + + if (errorResponseException.getErrorResponse() == ErrorResponse.MISSING_PERMISSIONS) { + logger.error("The bot does not have the '{}' permission on the guild '{}'.", + Permission.BAN_MEMBERS, guild.getName()); + return Optional.of(event.reply( + "I can not ban users in this guild since I do not have the %s permission." + .formatted(Permission.BAN_MEMBERS)) + .setEphemeral(true)); + } + } + logger.warn("Something unexpected went wrong while trying to ban the user '{}'.", + target.getAsTag(), alreadyBannedFailure); + return Optional.of(event.reply("Failed to ban the user due to an unexpected problem.") + .setEphemeral(true)); + } + + @SuppressWarnings({"BooleanMethodNameMustStartWithQuestion", "MethodWithTooManyParameters"}) + private boolean handleChecks(@NotNull Member bot, @NotNull Member author, + @Nullable Member target, @NotNull CharSequence reason, @NotNull Guild guild, + @NotNull Interaction event) { + // Member doesn't exist if attempting to ban a user who is not part of the guild. + if (target != null && !ModerationUtils.handleCanInteractWithTarget(ACTION_VERB, bot, author, + target, event)) { + return false; + } + if (!ModerationUtils.handleHasAuthorRole(ACTION_VERB, hasRequiredRole, author, event)) { + return false; + } + if (!ModerationUtils.handleHasBotPermissions(ACTION_VERB, Permission.BAN_MEMBERS, bot, + guild, event)) { + return false; + } + if (!ModerationUtils.handleHasAuthorPermissions(ACTION_VERB, Permission.BAN_MEMBERS, author, + guild, event)) { + return false; + } + return ModerationUtils.handleReason(reason, event); + } + + @Override + public void onSlashCommand(@NotNull SlashCommandEvent event) { + OptionMapping targetOption = + Objects.requireNonNull(event.getOption(TARGET_OPTION), "The target is null"); + User target = targetOption.getAsUser(); + Member author = Objects.requireNonNull(event.getMember(), "The author is null"); + String reason = Objects.requireNonNull(event.getOption(REASON_OPTION), "The reason is null") + .getAsString(); + + Guild guild = Objects.requireNonNull(event.getGuild()); + Member bot = guild.getSelfMember(); + + if (!handleChecks(bot, author, targetOption.getAsMember(), reason, guild, event)) { + return; + } + + int deleteHistoryDays = Math + .toIntExact(Objects.requireNonNull(event.getOption(DELETE_HISTORY_OPTION)).getAsLong()); + + // Ban the user, but only if not already banned + guild.retrieveBan(target).mapToResult().flatMap(alreadyBanned -> { + if (alreadyBanned.isSuccess()) { + return handleAlreadyBanned(alreadyBanned.get(), event); + } + + return handleNotAlreadyBannedResponse(Objects + .requireNonNull(alreadyBanned.getFailure()), event, guild, target).orElseGet( + () -> banUserFlow(target, author, reason, deleteHistoryDays, guild, event)); + }).queue(); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/KickCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/KickCommand.java new file mode 100644 index 0000000000..27f81c3501 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/KickCommand.java @@ -0,0 +1,145 @@ +package org.togetherjava.tjbot.commands.moderation; + +import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.entities.*; +import net.dv8tion.jda.api.events.GenericEvent; +import net.dv8tion.jda.api.events.interaction.SlashCommandEvent; +import net.dv8tion.jda.api.interactions.Interaction; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.requests.RestAction; +import net.dv8tion.jda.api.requests.restaction.AuditableRestAction; +import net.dv8tion.jda.api.utils.Result; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.togetherjava.tjbot.commands.SlashCommandAdapter; +import org.togetherjava.tjbot.commands.SlashCommandVisibility; +import org.togetherjava.tjbot.config.Config; + +import java.util.Objects; +import java.util.function.Predicate; +import java.util.regex.Pattern; + + +/** + * This command can kicks users. Kicking can also be paired with a kick reason. The command will + * also try to DM the user to inform them about the action and the reason. + *

+ * The command fails if the user triggering it is lacking permissions to either kick other users or + * to kick the specific given user (for example a moderator attempting to kick an admin). + */ +public final class KickCommand extends SlashCommandAdapter { + private static final Logger logger = LoggerFactory.getLogger(KickCommand.class); + private static final String TARGET_OPTION = "user"; + private static final String REASON_OPTION = "reason"; + private static final String COMMAND_NAME = "kick"; + private static final String ACTION_VERB = "kick"; + private final Predicate hasRequiredRole; + + /** + * Constructs an instance. + */ + public KickCommand() { + super(COMMAND_NAME, "Kicks the given user from the server", SlashCommandVisibility.GUILD); + + getData().addOption(OptionType.USER, TARGET_OPTION, "The user who you want to kick", true) + .addOption(OptionType.STRING, REASON_OPTION, "Why the user should be kicked", true); + + hasRequiredRole = Pattern.compile(Config.getInstance().getSoftModerationRolePattern()) + .asMatchPredicate(); + } + + private static void handleAbsentTarget(@NotNull Interaction event) { + event.reply("I can not kick the given user since they are not part of the guild anymore.") + .setEphemeral(true) + .queue(); + } + + private static void kickUserFlow(@NotNull Member target, @NotNull Member author, + @NotNull String reason, @NotNull Guild guild, @NotNull SlashCommandEvent event) { + sendDm(target, reason, guild, event) + .flatMap(hasSentDm -> kickUser(target, author, reason, guild) + .map(kickResult -> hasSentDm)) + .map(hasSentDm -> sendFeedback(hasSentDm, target, author, reason)) + .flatMap(event::replyEmbeds) + .queue(); + } + + private static RestAction sendDm(@NotNull ISnowflake target, @NotNull String reason, + @NotNull Guild guild, @NotNull GenericEvent event) { + return event.getJDA() + .openPrivateChannelById(target.getId()) + .flatMap(channel -> channel.sendMessage( + """ + Hey there, sorry to tell you but unfortunately you have been kicked from the server %s. + If you think this was a mistake, please contact a moderator or admin of the server. + The reason for the kick is: %s + """ + .formatted(guild.getName(), reason))) + .mapToResult() + .map(Result::isSuccess); + } + + private static AuditableRestAction kickUser(@NotNull Member target, + @NotNull Member author, @NotNull String reason, @NotNull Guild guild) { + logger.info("'{}' ({}) kicked the user '{}' ({}) from guild '{}' for reason '{}'.", + author.getUser().getAsTag(), author.getId(), target.getUser().getAsTag(), + target.getId(), guild.getName(), reason); + + return guild.kick(target, reason).reason(reason); + } + + private static @NotNull MessageEmbed sendFeedback(boolean hasSentDm, @NotNull Member target, + @NotNull Member author, @NotNull String reason) { + String dmNoticeText = ""; + if (!hasSentDm) { + dmNoticeText = "(Unable to send them a DM.)"; + } + return ModerationUtils.createActionResponse(author.getUser(), ModerationUtils.Action.KICK, + target.getUser(), dmNoticeText, reason); + } + + @SuppressWarnings({"BooleanMethodNameMustStartWithQuestion", "MethodWithTooManyParameters"}) + private boolean handleChecks(@NotNull Member bot, @NotNull Member author, + @Nullable Member target, @NotNull CharSequence reason, @NotNull Guild guild, + @NotNull Interaction event) { + // Member doesn't exist if attempting to kick a user who is not part of the guild anymore. + if (target == null) { + handleAbsentTarget(event); + return false; + } + if (!ModerationUtils.handleCanInteractWithTarget(ACTION_VERB, bot, author, target, event)) { + return false; + } + if (!ModerationUtils.handleHasAuthorRole(ACTION_VERB, hasRequiredRole, author, event)) { + return false; + } + if (!ModerationUtils.handleHasBotPermissions(ACTION_VERB, Permission.KICK_MEMBERS, bot, + guild, event)) { + return false; + } + if (!ModerationUtils.handleHasAuthorPermissions(ACTION_VERB, Permission.KICK_MEMBERS, + author, guild, event)) { + return false; + } + return ModerationUtils.handleReason(reason, event); + } + + @Override + public void onSlashCommand(@NotNull SlashCommandEvent event) { + Member target = Objects.requireNonNull(event.getOption(TARGET_OPTION), "The target is null") + .getAsMember(); + Member author = Objects.requireNonNull(event.getMember(), "The author is null"); + String reason = Objects.requireNonNull(event.getOption(REASON_OPTION), "The reason is null") + .getAsString(); + + Guild guild = Objects.requireNonNull(event.getGuild()); + Member bot = guild.getSelfMember(); + + if (!handleChecks(bot, author, target, reason, guild, event)) { + return; + } + kickUserFlow(Objects.requireNonNull(target), author, reason, guild, event); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/ModerationUtils.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/ModerationUtils.java new file mode 100644 index 0000000000..4070c3dfa5 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/ModerationUtils.java @@ -0,0 +1,244 @@ +package org.togetherjava.tjbot.commands.moderation; + +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.entities.*; +import net.dv8tion.jda.api.interactions.Interaction; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.awt.*; +import java.time.Instant; +import java.util.function.Predicate; + +/** + * Utility class offering helpers revolving around user moderation, such as banning or kicking. + */ +enum ModerationUtils { + ; + + private static final Logger logger = LoggerFactory.getLogger(ModerationUtils.class); + /** + * The maximal character limit for the reason of an auditable action, see for example + * {@link Guild#ban(User, int, String)}. + */ + private static final int REASON_MAX_LENGTH = 512; + private static final Color AMBIENT_COLOR = Color.decode("#895FE8"); + + /** + * Checks whether the given reason is valid. If not, it will handle the situation and respond to + * the user. + * + * @param reason the reason to check + * @param event the event used to respond to the user + * @return whether the reason is valid + */ + @SuppressWarnings("BooleanMethodNameMustStartWithQuestion") + static boolean handleReason(@NotNull CharSequence reason, @NotNull Interaction event) { + if (reason.length() <= REASON_MAX_LENGTH) { + return true; + } + + event + .reply("The reason can not be longer than %d characters (current length is %d)." + .formatted(REASON_MAX_LENGTH, reason.length())) + .setEphemeral(true) + .queue(); + return false; + } + + /** + * Checks whether the given author and bot can interact with the target user. For example + * whether they have enough permissions to ban the user. + *

+ * If not, it will handle the situation and respond to the user. + * + * @param actionVerb the interaction as verb, for example {@code "ban"} or {@code "kick"} + * @param bot the bot attempting to interact with the user + * @param author the author triggering the command + * @param target the target user of the interaction + * @param event the event used to respond to the user + * @return Whether the author and bot can interact with the target user + */ + @SuppressWarnings("BooleanMethodNameMustStartWithQuestion") + static boolean handleCanInteractWithTarget(@NotNull String actionVerb, @NotNull Member bot, + @NotNull Member author, @NotNull Member target, @NotNull Interaction event) { + String targetTag = target.getUser().getAsTag(); + if (!author.canInteract(target)) { + event + .reply("The user %s is too powerful for you to %s.".formatted(targetTag, + actionVerb)) + .setEphemeral(true) + .queue(); + return false; + } + + if (!bot.canInteract(target)) { + event + .reply("The user %s is too powerful for me to %s.".formatted(targetTag, actionVerb)) + .setEphemeral(true) + .queue(); + return false; + } + return true; + } + + /** + * Checks whether the given bot has enough permission to execute the given action. For example + * whether it has enough permissions to ban users. + *

+ * If not, it will handle the situation and respond to the user. + * + * @param actionVerb the interaction as verb, for example {@code "ban"} or {@code "kick"} + * @param permission the required permission to check + * @param bot the bot attempting to interact with the user + * @param event the event used to respond to the user + * @return Whether the bot has the required permission + */ + @SuppressWarnings("BooleanMethodNameMustStartWithQuestion") + static boolean handleHasBotPermissions(@NotNull String actionVerb, + @NotNull Permission permission, @NotNull IPermissionHolder bot, @NotNull Guild guild, + @NotNull Interaction event) { + if (!bot.hasPermission(permission)) { + event + .reply("I can not %s users in this guild since I do not have the %s permission." + .formatted(actionVerb, permission)) + .setEphemeral(true) + .queue(); + + logger.error("The bot does not have the '{}' permission on the guild '{}'.", permission, + guild.getName()); + return false; + } + return true; + } + + /** + * Checks whether the given author has enough permission to execute the given action. For + * example whether they have enough permissions to ban users. + *

+ * If not, it will handle the situation and respond to the user. + * + * @param actionVerb the interaction as verb, for example {@code "ban"} or {@code "kick"} + * @param permission the required permission to check + * @param author the author attempting to interact with the target user + * @param event the event used to respond to the user + * @return Whether the author has the required permission + */ + @SuppressWarnings("BooleanMethodNameMustStartWithQuestion") + static boolean handleHasAuthorPermissions(@NotNull String actionVerb, + @NotNull Permission permission, @NotNull IPermissionHolder author, @NotNull Guild guild, + @NotNull Interaction event) { + if (!author.hasPermission(permission)) { + event + .reply("You can not %s users in this guild since you do not have the %s permission." + .formatted(actionVerb, permission)) + .setEphemeral(true) + .queue(); + return false; + } + return true; + } + + /** + * Checks whether the given bot has enough permission to execute the given action. For example + * whether it has enough permissions to ban users. + *

+ * If not, it will handle the situation and respond to the user. + * + * @param actionVerb the interaction as verb, for example {@code "ban"} or {@code "kick"} + * @param hasRequiredRole a predicate used to identify required roles by their name + * @param author the author attempting to interact with the target + * @param event the event used to respond to the user + * @return Whether the bot has the required permission + */ + @SuppressWarnings("BooleanMethodNameMustStartWithQuestion") + static boolean handleHasAuthorRole(@NotNull String actionVerb, + @NotNull Predicate hasRequiredRole, @NotNull Member author, + @NotNull Interaction event) { + if (author.getRoles().stream().map(Role::getName).anyMatch(hasRequiredRole)) { + return true; + } + event + .reply("You can not %s users in this guild since you do not have the required role." + .formatted(actionVerb)) + .setEphemeral(true) + .queue(); + return false; + } + + /** + * Creates a message to be displayed as response to a moderation action. + * + * Essentially, it informs others about the action, such as "John banned Bob for playing with + * the fire.". + * + * @param author the author executing the action + * @param action the action that is executed + * @param target the target of the action + * @param extraMessage an optional extra message to be displayed in the response, {@code null} + * if not desired + * @param reason an optional reason for why the action is executed, {@code null} if not desired + * @return the created response + */ + static @NotNull MessageEmbed createActionResponse(@NotNull User author, @NotNull Action action, + @NotNull User target, @Nullable String extraMessage, @Nullable String reason) { + String description = "%s **%s** (id: %s).".formatted(action.getVerb(), target.getAsTag(), + target.getId()); + if (extraMessage != null && !extraMessage.isBlank()) { + description += "\n" + extraMessage; + } + if (reason != null && !reason.isBlank()) { + description += "\n\nReason: " + reason; + } + return new EmbedBuilder().setAuthor(author.getAsTag(), null, author.getAvatarUrl()) + .setDescription(description) + .setTimestamp(Instant.now()) + .setColor(AMBIENT_COLOR) + .build(); + } + + /** + * All available moderation actions. + */ + enum Action { + /** + * When a user bans another user. + */ + BAN("banned"), + /** + * When a user unbans another user. + */ + UNBAN("unbanned"), + /** + * When a user kicks another user. + */ + KICK("kicked"); + + private final String verb; + + /** + * Creates an instance with the given verb + * + * @param verb the verb of the action, as it would be used in a sentence, such as "banned" + * or "kicked" + */ + Action(@NotNull String verb) { + this.verb = verb; + } + + /** + * Gets the verb of the action, as it would be used in a sentence. + *

+ * Such as "banned" or "kicked" + * + * @return the verb of this action + */ + @NotNull + String getVerb() { + return verb; + } + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/UnbanCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/UnbanCommand.java new file mode 100644 index 0000000000..a8908e20fe --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/UnbanCommand.java @@ -0,0 +1,118 @@ +package org.togetherjava.tjbot.commands.moderation; + +import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.entities.*; +import net.dv8tion.jda.api.events.interaction.SlashCommandEvent; +import net.dv8tion.jda.api.exceptions.ErrorResponseException; +import net.dv8tion.jda.api.interactions.Interaction; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.requests.ErrorResponse; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.togetherjava.tjbot.commands.SlashCommandAdapter; +import org.togetherjava.tjbot.commands.SlashCommandVisibility; +import org.togetherjava.tjbot.config.Config; + +import java.util.Objects; +import java.util.function.Predicate; +import java.util.regex.Pattern; + +/** + * Unbans a given user. Unbanning can also be paired with a reason. The command fails if the user is + * currently not banned. + */ +public final class UnbanCommand extends SlashCommandAdapter { + private static final Logger logger = LoggerFactory.getLogger(UnbanCommand.class); + private static final String TARGET_OPTION = "user"; + private static final String REASON_OPTION = "reason"; + private static final String COMMAND_NAME = "unban"; + private static final String ACTION_VERB = "unban"; + private final Predicate hasRequiredRole; + + /** + * Constructs an instance. + */ + public UnbanCommand() { + super(COMMAND_NAME, "Unbans the given user from the server", SlashCommandVisibility.GUILD); + + getData() + .addOption(OptionType.USER, TARGET_OPTION, "The banned user who you want to unban", + true) + .addOption(OptionType.STRING, REASON_OPTION, "Why the user should be unbanned", true); + + hasRequiredRole = Pattern.compile(Config.getInstance().getHeavyModerationRolePattern()) + .asMatchPredicate(); + } + + private static void unban(@NotNull User target, @NotNull Member author, @NotNull String reason, + @NotNull Guild guild, @NotNull Interaction event) { + guild.unban(target).reason(reason).queue(result -> { + MessageEmbed message = ModerationUtils.createActionResponse(author.getUser(), + ModerationUtils.Action.UNBAN, target, null, reason); + event.replyEmbeds(message).queue(); + + logger.info("'{}' ({}) unbanned the user '{}' ({}) from guild '{}' for reason '{}'.", + author.getUser().getAsTag(), author.getId(), target.getAsTag(), target.getId(), + guild.getName(), reason); + }, unbanFailure -> handleFailure(unbanFailure, target, event)); + } + + private static void handleFailure(@NotNull Throwable unbanFailure, @NotNull User target, + @NotNull Interaction event) { + String targetTag = target.getAsTag(); + if (unbanFailure instanceof ErrorResponseException errorResponseException) { + if (errorResponseException.getErrorResponse() == ErrorResponse.UNKNOWN_USER) { + event.reply("The specified user does not exist.").setEphemeral(true).queue(); + logger.debug("Unable to unban the user '{}' because they do not exist.", targetTag); + return; + } + + if (errorResponseException.getErrorResponse() == ErrorResponse.UNKNOWN_BAN) { + event.reply("The specified user is not banned.").setEphemeral(true).queue(); + logger.debug("Unable to unban the user '{}' because they are not banned.", + targetTag); + return; + } + } + + event.reply("Sorry, but something went wrong.").setEphemeral(true).queue(); + logger.warn("Something unexpected went wrong while trying to unban the user '{}'.", + targetTag, unbanFailure); + } + + @SuppressWarnings({"BooleanMethodNameMustStartWithQuestion"}) + private boolean handleChecks(@NotNull IPermissionHolder bot, @NotNull Member author, + @NotNull CharSequence reason, @NotNull Guild guild, @NotNull Interaction event) { + if (!ModerationUtils.handleHasAuthorRole(ACTION_VERB, hasRequiredRole, author, event)) { + return false; + } + if (!ModerationUtils.handleHasBotPermissions(ACTION_VERB, Permission.BAN_MEMBERS, bot, + guild, event)) { + return false; + } + if (!ModerationUtils.handleHasAuthorPermissions(ACTION_VERB, Permission.BAN_MEMBERS, author, + guild, event)) { + return false; + } + + return ModerationUtils.handleReason(reason, event); + } + + @Override + public void onSlashCommand(@NotNull SlashCommandEvent event) { + User target = Objects.requireNonNull(event.getOption(TARGET_OPTION), "The target is null") + .getAsUser(); + Member author = Objects.requireNonNull(event.getMember(), "The author is null"); + String reason = Objects.requireNonNull(event.getOption(REASON_OPTION), "The reason is null") + .getAsString(); + + Guild guild = Objects.requireNonNull(event.getGuild()); + Member bot = guild.getSelfMember(); + + if (!handleChecks(bot, author, reason, guild, event)) { + return; + } + unban(target, author, reason, guild, event); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/package-info.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/package-info.java new file mode 100644 index 0000000000..399fc1fa1f --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/package-info.java @@ -0,0 +1,5 @@ +/** + * This package offers all the moderation commands from the application such as banning and kicking + * users. + */ +package org.togetherjava.tjbot.commands.moderation; diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/system/CommandSystem.java b/application/src/main/java/org/togetherjava/tjbot/commands/system/CommandSystem.java index 4a5a0db0d7..e829790546 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/system/CommandSystem.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/system/CommandSystem.java @@ -1,22 +1,32 @@ package org.togetherjava.tjbot.commands.system; -import com.fasterxml.jackson.core.JsonProcessingException; +import net.dv8tion.jda.api.Permission; import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.TextChannel; import net.dv8tion.jda.api.events.ReadyEvent; import net.dv8tion.jda.api.events.interaction.ButtonClickEvent; import net.dv8tion.jda.api.events.interaction.SelectionMenuEvent; import net.dv8tion.jda.api.events.interaction.SlashCommandEvent; +import net.dv8tion.jda.api.exceptions.ErrorHandler; import net.dv8tion.jda.api.hooks.ListenerAdapter; import net.dv8tion.jda.api.interactions.commands.Command; import net.dv8tion.jda.api.interactions.components.ComponentInteraction; +import net.dv8tion.jda.api.requests.ErrorResponse; import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.togetherjava.tjbot.commands.Commands; import org.togetherjava.tjbot.commands.SlashCommand; +import org.togetherjava.tjbot.commands.componentids.ComponentId; +import org.togetherjava.tjbot.commands.componentids.ComponentIdParser; +import org.togetherjava.tjbot.commands.componentids.ComponentIdStore; +import org.togetherjava.tjbot.commands.componentids.InvalidComponentIdFormatException; +import org.togetherjava.tjbot.config.Config; import org.togetherjava.tjbot.db.Database; import java.util.*; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.function.Function; import java.util.stream.Collectors; @@ -35,7 +45,10 @@ public final class CommandSystem extends ListenerAdapter implements SlashCommandProvider { private static final Logger logger = LoggerFactory.getLogger(CommandSystem.class); private static final String RELOAD_COMMAND = "reload"; + private static final ExecutorService COMMAND_SERVICE = Executors.newCachedThreadPool(); private final Map nameToSlashCommands; + private final ComponentIdParser componentIdParser; + private final ComponentIdStore componentIdStore; /** * Creates a new command system which uses the given database to allow commands to persist data. @@ -56,8 +69,19 @@ public CommandSystem(@NotNull Database database) { } nameToSlashCommands.put(RELOAD_COMMAND, new ReloadCommand(this)); + componentIdStore = new ComponentIdStore(database); + componentIdStore.addComponentIdRemovedListener(CommandSystem::onComponentIdRemoved); + componentIdParser = uuid -> componentIdStore.get(UUID.fromString(uuid)); + nameToSlashCommands.values() + .forEach(slashCommand -> slashCommand + .acceptComponentIdGenerator(((componentId, lifespan) -> { + UUID uuid = UUID.randomUUID(); + componentIdStore.putOrThrow(uuid, componentId, lifespan); + return uuid.toString(); + }))); + if (logger.isInfoEnabled()) { - logger.info("Available commands: {}", nameToSlashCommands.values()); + logger.info("Available commands: {}", nameToSlashCommands.keySet()); } } @@ -75,31 +99,39 @@ public CommandSystem(@NotNull Database database) { public void onReady(@NotNull ReadyEvent event) { // Register reload on all guilds logger.debug("JDA is ready, registering reload command"); - event.getJDA().getGuildCache().forEach(this::registerReloadCommand); + event.getJDA() + .getGuildCache() + .forEach(guild -> COMMAND_SERVICE.execute(() -> registerReloadCommand(guild))); // NOTE We do not have to wait for reload to complete for the command system to be ready // itself logger.debug("Command system is now ready"); + + // Propagate the onReady event to all commands + // NOTE 'registerReloadCommands' will not be finished running, this does not wait for it + nameToSlashCommands.values() + .forEach(command -> COMMAND_SERVICE.execute(() -> command.onReady(event))); } @Override public void onSlashCommand(@NotNull SlashCommandEvent event) { logger.debug("Received slash command '{}' (#{}) on guild '{}'", event.getName(), event.getId(), event.getGuild()); - requireSlashCommand(event.getName()).onSlashCommand(event); + COMMAND_SERVICE.execute(() -> requireSlashCommand(event.getName()).onSlashCommand(event)); } @Override public void onButtonClick(@NotNull ButtonClickEvent event) { logger.debug("Received button click '{}' (#{}) on guild '{}'", event.getComponentId(), event.getId(), event.getGuild()); - forwardComponentCommand(event, SlashCommand::onButtonClick); + COMMAND_SERVICE.execute(() -> forwardComponentCommand(event, SlashCommand::onButtonClick)); } @Override public void onSelectionMenu(@NotNull SelectionMenuEvent event) { logger.debug("Received selection menu event '{}' (#{}) on guild '{}'", event.getComponentId(), event.getId(), event.getGuild()); - forwardComponentCommand(event, SlashCommand::onSelectionMenu); + COMMAND_SERVICE + .execute(() -> forwardComponentCommand(event, SlashCommand::onSelectionMenu)); } private void registerReloadCommand(@NotNull Guild guild) { @@ -116,7 +148,7 @@ private void registerReloadCommand(@NotNull Guild guild) { guild.upsertCommand(reloadCommand.getData()) .queue(command -> logger.debug("Registered '{}' for guild '{}'", RELOAD_COMMAND, guild.getName())); - }); + }, ex -> handleRegisterErrors(ex, guild)); } /** @@ -138,10 +170,10 @@ private void registerReloadCommand(@NotNull Guild guild) { */ private void forwardComponentCommand(@NotNull T event, @NotNull TriConsumer> commandArgumentConsumer) { - ComponentId componentId; + Optional componentIdOpt; try { - componentId = ComponentIds.parse(event.getComponentId()); - } catch (JsonProcessingException e) { + componentIdOpt = componentIdParser.parse(event.getComponentId()); + } catch (InvalidComponentIdFormatException e) { logger .error("Unable to route event (#{}) back to its corresponding slash command. The component ID was in an unexpected format." + " All button and menu events have to use a component ID created in a specific format" @@ -150,11 +182,20 @@ private void forwardComponentCommand(@NotNull T // Unable to forward, simply fade out the event return; } - SlashCommand command = requireSlashCommand(componentId.getCommandName()); + if (componentIdOpt.isEmpty()) { + logger.warn("The event (#{}) has an expired component ID, which was: {}.", + event.getId(), event.getComponentId()); + event.reply("Sorry, but this event has expired. You can not use it anymore.") + .setEphemeral(true) + .queue(); + return; + } + ComponentId componentId = componentIdOpt.orElseThrow(); + SlashCommand command = requireSlashCommand(componentId.commandName()); logger.trace("Routing a component event with id '{}' back to command '{}'", event.getComponentId(), command.getName()); - commandArgumentConsumer.accept(command, event, componentId.getElements()); + commandArgumentConsumer.accept(command, event, componentId.elements()); } /** @@ -168,6 +209,41 @@ private void forwardComponentCommand(@NotNull T return Objects.requireNonNull(nameToSlashCommands.get(name)); } + private static void handleRegisterErrors(Throwable ex, Guild guild) { + new ErrorHandler().handle(ErrorResponse.MISSING_ACCESS, errorResponse -> { + // Find a channel that we have permissions to write to + // NOTE Unfortunately, there is no better accurate way to find a proper channel + // where we can report the setup problems other than simply iterating all of them. + Optional channelToReportTo = guild.getTextChannelCache() + .stream() + .filter(channel -> guild.getPublicRole() + .hasPermission(channel, Permission.MESSAGE_WRITE)) + .findAny(); + + // Report the problem to the guild + Config config = Config.getInstance(); + channelToReportTo.ifPresent(textChannel -> textChannel + .sendMessage("I need the commands scope, please invite me correctly." + + " You can join '%s' or visit '%s' for more info, I will leave your guild now." + .formatted(config.getDiscordGuildInvite(), config.getProjectWebsite())) + .queue()); + + guild.leave().queue(); + + String unableToReportText = channelToReportTo.isPresent() ? "" + : " Did not find any public text channel to report the issue to, unable to inform the guild."; + logger.warn( + "Guild '{}' does not have the required command scope, unable to register, leaving it.{}", + guild.getName(), unableToReportText, ex); + }).accept(ex); + } + + @SuppressWarnings("EmptyMethod") + private static void onComponentIdRemoved(ComponentId componentId) { + // NOTE As of now, we do not act on this event, but we could use it + // in the future to, for example, disable buttons or delete the associated message + } + /** * Extension of {@link java.util.function.BiConsumer} but for 3 elements. *

diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/system/ComponentId.java b/application/src/main/java/org/togetherjava/tjbot/commands/system/ComponentId.java deleted file mode 100644 index a3a905d9d1..0000000000 --- a/application/src/main/java/org/togetherjava/tjbot/commands/system/ComponentId.java +++ /dev/null @@ -1,71 +0,0 @@ -package org.togetherjava.tjbot.commands.system; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; -import net.dv8tion.jda.api.events.interaction.SlashCommandEvent; -import org.jetbrains.annotations.NotNull; - -import java.util.Collections; -import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; - -/** - * Wrapper for component IDs, see - * {@link org.togetherjava.tjbot.commands.SlashCommand#onSlashCommand(SlashCommandEvent)} for its - * usages. - *

- * {@link ComponentIds} can be used to generate and parse instances of this class. - */ -public final class ComponentId { - private static final AtomicInteger NEXT_ID = new AtomicInteger(0); - private final int id; - private final String commandName; - private final List elements; - - /** - * Creates a new component ID with the given data. - * - * @param commandName the name of the command that corresponds to this component ID - * @param elements the extra elements contained in this component ID, may be empty - */ - public ComponentId(@NotNull String commandName, @NotNull List elements) { - this(NEXT_ID.getAndIncrement(), commandName, elements); - } - - @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) - private ComponentId(@JsonProperty("id") int id, - @JsonProperty("commandName") @NotNull String commandName, - @JsonProperty("elements") @NotNull List elements) { - this.id = id; - this.commandName = commandName; - this.elements = elements; - } - - /** - * Gets the name of the command that corresponds to this component ID. - * - * @return the name of the command - */ - public @NotNull String getCommandName() { - return commandName; - } - - /** - * Gets the ID of this component ID, which is unique within the context of the message the - * component belongs to. - * - * @return the ID - */ - public int getId() { - return id; - } - - /** - * Gets the extra elements contained in this component ID - * - * @return the extra elements - */ - public @NotNull List getElements() { - return Collections.unmodifiableList(elements); - } -} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/system/ComponentIds.java b/application/src/main/java/org/togetherjava/tjbot/commands/system/ComponentIds.java deleted file mode 100644 index 70062e723d..0000000000 --- a/application/src/main/java/org/togetherjava/tjbot/commands/system/ComponentIds.java +++ /dev/null @@ -1,56 +0,0 @@ -package org.togetherjava.tjbot.commands.system; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.dataformat.csv.CsvMapper; -import net.dv8tion.jda.api.events.interaction.SlashCommandEvent; -import org.jetbrains.annotations.NotNull; - -import java.util.List; - -/** - * Utility class to generate component IDs valid per - * {@link org.togetherjava.tjbot.commands.SlashCommand#onSlashCommand(SlashCommandEvent)}. - *

- *

- * There is also the option to extend {@link org.togetherjava.tjbot.commands.SlashCommandAdapter} - * and use its - * {@link org.togetherjava.tjbot.commands.SlashCommandAdapter#generateComponentId(String...)} - * method. - */ -public enum ComponentIds { - ; - - private static final CsvMapper CSV = new CsvMapper(); - - /** - * Generates a component ID valid per - * {@link org.togetherjava.tjbot.commands.SlashCommand#onSlashCommand(SlashCommandEvent)}. - * - * @param commandName the name of the command that corresponds to this component ID - * @param elements the additional elements to transport with the component, can be empty - * @return the generated component ID - * @throws JsonProcessingException if generation failed - */ - public static @NotNull String generate(@NotNull String commandName, - @NotNull List elements) throws JsonProcessingException { - return CSV.writerFor(ComponentId.class) - .with(CSV.schemaFor(ComponentId.class)) - .writeValueAsString(new ComponentId(commandName, elements)); - } - - /** - * Parses component IDs from their text form. - * - * @param componentId the component ID to parse, must be valid per - * {@link org.togetherjava.tjbot.commands.SlashCommand#onSlashCommand(SlashCommandEvent)} - * @return the parsed component ID object - * @throws JsonProcessingException if parsing failed, for example because the component ID was - * not in a valid format - */ - public static @NotNull ComponentId parse(@NotNull String componentId) - throws JsonProcessingException { - return CSV.readerFor(ComponentId.class) - .with(CSV.schemaFor(ComponentId.class)) - .readValue(componentId); - } -} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/system/ReloadCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/system/ReloadCommand.java index ecf0b891e5..33613c797a 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/system/ReloadCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/system/ReloadCommand.java @@ -16,6 +16,7 @@ import org.togetherjava.tjbot.commands.SlashCommand; import org.togetherjava.tjbot.commands.SlashCommandAdapter; import org.togetherjava.tjbot.commands.SlashCommandVisibility; +import org.togetherjava.tjbot.commands.utils.MessageUtils; import java.util.ArrayList; import java.util.Collections; @@ -79,15 +80,15 @@ public void onButtonClick(@NotNull ButtonClickEvent event, @NotNull List // Ignore if another user clicked the button String userId = args.get(0); if (!userId.equals(Objects.requireNonNull(event.getMember()).getId())) { + event.reply("Sorry, but only the user who triggered the command can use these buttons.") + .setEphemeral(true) + .queue(); return; } ButtonStyle buttonStyle = Objects.requireNonNull(event.getButton()).getStyle(); switch (buttonStyle) { - case DANGER -> { - event.reply("Okay, will not reload.").queue(); - event.getMessage().editMessageComponents().queue(); - } + case DANGER -> event.reply("Okay, will not reload.").queue(); case SUCCESS -> { logger.info("Reloading commands, triggered by user '{}' in guild '{}'", userId, event.getGuild()); @@ -101,7 +102,8 @@ public void onButtonClick(@NotNull ButtonClickEvent event, @NotNull List getGlobalUpdateAction(event.getJDA()))); // Reload guild commands (potentially many guilds) - // NOTE Storing the guild actions in a list is potentially dangerous since the bot + // NOTE Storing the guild actions in a list is potentially dangerous since the + // bot // might theoretically be part of so many guilds that it exceeds the max size of // list. However, correctly reducing RestActions in a stream is not trivial. getGuildUpdateActions(event.getJDA()) @@ -120,6 +122,8 @@ public void onButtonClick(@NotNull ButtonClickEvent event, @NotNull List } default -> throw new AssertionError("Unexpected button action clicked: " + buttonStyle); } + + MessageUtils.disableButtons(event.getMessage()); } /** diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagCommand.java new file mode 100644 index 0000000000..9d48fba27a --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagCommand.java @@ -0,0 +1,51 @@ +package org.togetherjava.tjbot.commands.tags; + +import net.dv8tion.jda.api.events.interaction.SlashCommandEvent; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import org.jetbrains.annotations.NotNull; +import org.togetherjava.tjbot.commands.SlashCommandAdapter; +import org.togetherjava.tjbot.commands.SlashCommandVisibility; +import org.togetherjava.tjbot.commands.utils.MessageUtils; + +import java.util.Objects; + +/** + * Implements the {@code /tag} command which lets the bot respond content of a tag that has been + * added previously. + *

+ * Tags can be added by using {@link TagManageCommand} and a list of all tags is available using + * {@link TagsCommand}. + */ +public final class TagCommand extends SlashCommandAdapter { + private final TagSystem tagSystem; + + private static final String ID_OPTION = "id"; + + /** + * Creates a new instance, using the given tag system as base. + * + * @param tagSystem the system providing the actual tag data + */ + public TagCommand(TagSystem tagSystem) { + super("tag", "Display a tags content", SlashCommandVisibility.GUILD); + + this.tagSystem = tagSystem; + + // TODO Thing about adding an ephemeral selection menu with pagination support + // if the user calls this without id or similar + getData().addOption(OptionType.STRING, ID_OPTION, "the id of the tag to display", true); + } + + @Override + public void onSlashCommand(@NotNull SlashCommandEvent event) { + String id = Objects.requireNonNull(event.getOption(ID_OPTION)).getAsString(); + if (tagSystem.handleIsUnknownTag(id, event)) { + return; + } + + event + .replyEmbeds(MessageUtils.generateEmbed(null, tagSystem.getTag(id).orElseThrow(), + event.getUser(), TagSystem.AMBIENT_COLOR)) + .queue(); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagContentStyle.java b/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagContentStyle.java new file mode 100644 index 0000000000..898576a394 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagContentStyle.java @@ -0,0 +1,18 @@ +package org.togetherjava.tjbot.commands.tags; + +/** + * The style of a tag content. + */ +public enum TagContentStyle { + /** + * Content that will be interpreted by Discord, for example a message containing {@code **foo**} + * will be displayed in bold. + */ + INTERPRETED, + /** + * Content that will be displayed raw, not interpreted by Discord. For example a message + * containing {@code **foo**} will be displayed as {@code **foo**} literally, by escaping the + * special characters. + */ + RAW +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagManageCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagManageCommand.java new file mode 100644 index 0000000000..0e34190be5 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagManageCommand.java @@ -0,0 +1,317 @@ +package org.togetherjava.tjbot.commands.tags; + +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.Role; +import net.dv8tion.jda.api.events.interaction.SlashCommandEvent; +import net.dv8tion.jda.api.exceptions.ErrorResponseException; +import net.dv8tion.jda.api.interactions.Interaction; +import net.dv8tion.jda.api.interactions.commands.CommandInteraction; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import net.dv8tion.jda.api.requests.ErrorResponse; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.togetherjava.tjbot.commands.SlashCommandAdapter; +import org.togetherjava.tjbot.commands.SlashCommandVisibility; +import org.togetherjava.tjbot.commands.utils.MessageUtils; +import org.togetherjava.tjbot.config.Config; + +import java.util.Objects; +import java.util.OptionalLong; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Predicate; +import java.util.regex.Pattern; + +/** + * Implements the {@code /tag-manage} command which allows management of tags, such as creating, + * editing or deleting them. Available subcommands are: + *

+ *

+ * Tags can be added by using {@link TagManageCommand} and a list of all tags is available using + * {@link TagsCommand}. + */ +public final class TagManageCommand extends SlashCommandAdapter { + private static final Logger logger = LoggerFactory.getLogger(TagManageCommand.class); + private static final String ID_OPTION = "id"; + private static final String ID_DESCRIPTION = "the id of the tag"; + private static final String CONTENT_OPTION = "content"; + private static final String CONTENT_DESCRIPTION = "the content of the tag"; + private static final String MESSAGE_ID_OPTION = "message-id"; + private static final String MESSAGE_ID_DESCRIPTION = "the id of the message to refer to"; + private final TagSystem tagSystem; + private final Predicate hasRequiredRole; + + /** + * Creates a new instance, using the given tag system as base. + * + * @param tagSystem the system providing the actual tag data + */ + public TagManageCommand(TagSystem tagSystem) { + super("tag-manage", "Provides commands to manage all tags", SlashCommandVisibility.GUILD); + + this.tagSystem = tagSystem; + hasRequiredRole = + Pattern.compile(Config.getInstance().getTagManageRolePattern()).asMatchPredicate(); + + // TODO Think about adding a "Are you sure"-dialog to 'edit', 'edit-with-message' and + // 'delete' + getData().addSubcommands(new SubcommandData(Subcommand.RAW.name, + "View the raw content of a tag, without Discord interpreting any of its content") + .addOption(OptionType.STRING, ID_OPTION, ID_DESCRIPTION, true), + new SubcommandData(Subcommand.CREATE.name, "Creates a new tag") + .addOption(OptionType.STRING, ID_OPTION, ID_DESCRIPTION, true) + .addOption(OptionType.STRING, CONTENT_OPTION, CONTENT_DESCRIPTION, true), + new SubcommandData(Subcommand.CREATE_WITH_MESSAGE.name, + "Creates a new tag. Content is retrieved from the given message.") + .addOption(OptionType.STRING, ID_OPTION, ID_DESCRIPTION, true) + .addOption(OptionType.STRING, MESSAGE_ID_OPTION, MESSAGE_ID_DESCRIPTION, + true), + new SubcommandData(Subcommand.EDIT.name, "Edits a tag, the old content is replaced") + .addOption(OptionType.STRING, ID_OPTION, ID_DESCRIPTION, true) + .addOption(OptionType.STRING, CONTENT_OPTION, CONTENT_DESCRIPTION, true), + new SubcommandData(Subcommand.EDIT_WITH_MESSAGE.name, + "Edits a tag, the old content is replaced. Content is retrieved from the given message.") + .addOption(OptionType.STRING, ID_OPTION, ID_DESCRIPTION, true) + .addOption(OptionType.STRING, MESSAGE_ID_OPTION, MESSAGE_ID_DESCRIPTION, + true), + new SubcommandData(Subcommand.DELETE.name, "Deletes a tag") + .addOption(OptionType.STRING, ID_OPTION, ID_DESCRIPTION, true)); + } + + private static void sendSuccessMessage(@NotNull Interaction event, @NotNull String id, + @NotNull String actionVerb) { + logger.info("User '{}' {} the tag with id '{}'.", event.getUser().getId(), actionVerb, id); + event.replyEmbeds(MessageUtils.generateEmbed("Success", + "Successfully %s tag '%s'.".formatted(actionVerb, id), event.getUser(), + TagSystem.AMBIENT_COLOR)) + .queue(); + } + + /** + * Attempts to parse the given message id. + *

+ * If the message id could not be parsed, because it is invalid, an error message is send to the + * user. + * + * @param messageId the message id to parse + * @param event the event to send messages with + * @return the parsed message id, if successful + */ + private static OptionalLong parseMessageIdAndHandle(@NotNull String messageId, + @NotNull Interaction event) { + try { + return OptionalLong.of(Long.parseLong(messageId)); + } catch (NumberFormatException e) { + event + .reply("The given message id '%s' is invalid, expected a number." + .formatted(messageId)) + .setEphemeral(true) + .queue(); + return OptionalLong.empty(); + } + } + + @Override + public void onSlashCommand(@NotNull SlashCommandEvent event) { + if (!hasTagManageRole(Objects.requireNonNull(event.getMember()))) { + event.reply("Tags can only be managed by users with a corresponding role.") + .setEphemeral(true) + .queue(); + return; + } + + switch (Subcommand.fromName(event.getSubcommandName())) { + case RAW -> rawTag(event); + case CREATE -> createTag(event); + case CREATE_WITH_MESSAGE -> createTagWithMessage(event); + case EDIT -> editTag(event); + case EDIT_WITH_MESSAGE -> editTagWithMessage(event); + case DELETE -> deleteTag(event); + default -> throw new AssertionError( + "Unexpected subcommand '%s'".formatted(event.getSubcommandName())); + } + } + + private void rawTag(@NotNull SlashCommandEvent event) { + String id = Objects.requireNonNull(event.getOption(ID_OPTION)).getAsString(); + if (tagSystem.handleIsUnknownTag(id, event)) { + return; + } + + event.replyEmbeds(MessageUtils.generateEmbed(null, + MessageUtils.escapeMarkdown(tagSystem.getTag(id).orElseThrow()), event.getUser(), + TagSystem.AMBIENT_COLOR)) + .queue(); + } + + private void createTag(@NotNull CommandInteraction event) { + String content = Objects.requireNonNull(event.getOption(CONTENT_OPTION)).getAsString(); + + handleAction(TagStatus.NOT_EXISTS, id -> tagSystem.putTag(id, content), "created", event); + } + + private void createTagWithMessage(@NotNull CommandInteraction event) { + handleActionWithMessage(TagStatus.NOT_EXISTS, tagSystem::putTag, "created", event); + } + + private void editTag(@NotNull CommandInteraction event) { + String content = Objects.requireNonNull(event.getOption(CONTENT_OPTION)).getAsString(); + + handleAction(TagStatus.EXISTS, id -> tagSystem.putTag(id, content), "edited", event); + } + + private void editTagWithMessage(@NotNull CommandInteraction event) { + handleActionWithMessage(TagStatus.EXISTS, tagSystem::putTag, "edited", event); + } + + private void deleteTag(@NotNull CommandInteraction event) { + handleAction(TagStatus.EXISTS, tagSystem::deleteTag, "deleted", event); + } + + /** + * Executes the given action on the tag id and sends a success message to the user. + *

+ * If the tag status does not line up with the required status, an error message is send to the + * user. + * + * @param requiredTagStatus the required status of the tag + * @param idAction the action to perform on the id + * @param actionVerb the verb describing the executed action, i.e. edited or + * created, will be displayed in the message send to the user + * @param event the event to send messages with, it must have an {@code id} option set + */ + private void handleAction(@NotNull TagStatus requiredTagStatus, + @NotNull Consumer idAction, @NotNull String actionVerb, + @NotNull CommandInteraction event) { + String id = Objects.requireNonNull(event.getOption(ID_OPTION)).getAsString(); + if (isWrongTagStatusAndHandle(requiredTagStatus, id, event)) { + return; + } + + idAction.accept(id); + sendSuccessMessage(event, id, actionVerb); + } + + /** + * Executes the given action on the tag id and the content and sends a success message to the + * user. + *

+ * The content is retrieved by looking up the message with the id stored in the event. + *

+ * If the tag status does not line up with the required status or a message with the given id + * does not exist, an error message is send to the user. + * + * @param requiredTagStatus the required status of the tag + * @param idAndContentAction the action to perform on the id and content + * @param actionVerb the verb describing the executed action, i.e. edited or + * created, will be displayed in the message send to the user + * @param event the event to send messages with, it must have an {@code id} and + * {@code message-id} option set + */ + private void handleActionWithMessage(@NotNull TagStatus requiredTagStatus, + @NotNull BiConsumer idAndContentAction, + @NotNull String actionVerb, @NotNull CommandInteraction event) { + String tagId = Objects.requireNonNull(event.getOption(ID_OPTION)).getAsString(); + OptionalLong messageIdOpt = parseMessageIdAndHandle( + Objects.requireNonNull(event.getOption(MESSAGE_ID_OPTION)).getAsString(), event); + if (messageIdOpt.isEmpty()) { + return; + } + long messageId = messageIdOpt.orElseThrow(); + if (isWrongTagStatusAndHandle(requiredTagStatus, tagId, event)) { + return; + } + + event.getMessageChannel().retrieveMessageById(messageId).queue(message -> { + idAndContentAction.accept(tagId, message.getContentRaw()); + sendSuccessMessage(event, tagId, actionVerb); + }, failure -> { + if (failure instanceof ErrorResponseException ex + && ex.getErrorResponse() == ErrorResponse.UNKNOWN_MESSAGE) { + event.reply("The message with id '%d' does not exist.".formatted(messageId)) + .setEphemeral(true) + .queue(); + return; + } + + logger.warn("Unable to retrieve the message with id '{}' for an unknown reason.", + messageId, failure); + event + .reply("Something unexpected went wrong trying to locate the message with id '%d'." + .formatted(messageId)) + .setEphemeral(true) + .queue(); + }); + } + + /** + * Returns whether the status of the given tag is not equal to the required status. + *

+ * If not, it sends an error message to the user. + * + * @param requiredTagStatus the required status of the tag + * @param id the id of the tag to check + * @param event the event to send messages with + * @return whether the status of the given tag is not equal to the required status + */ + private boolean isWrongTagStatusAndHandle(@NotNull TagStatus requiredTagStatus, + @NotNull String id, @NotNull Interaction event) { + if (requiredTagStatus == TagStatus.EXISTS) { + return tagSystem.handleIsUnknownTag(id, event); + } else if (requiredTagStatus == TagStatus.NOT_EXISTS) { + if (tagSystem.hasTag(id)) { + event.reply("The tag with id '%s' already exists.".formatted(id)) + .setEphemeral(true) + .queue(); + return true; + } + } else { + throw new AssertionError("Unknown tag status '%s'".formatted(requiredTagStatus)); + } + return false; + } + + private boolean hasTagManageRole(@NotNull Member member) { + return member.getRoles().stream().map(Role::getName).anyMatch(hasRequiredRole); + } + + private enum TagStatus { + EXISTS, + NOT_EXISTS + } + + + private enum Subcommand { + RAW("raw"), + CREATE("create"), + CREATE_WITH_MESSAGE("create-with-message"), + EDIT("edit"), + EDIT_WITH_MESSAGE("edit-with-message"), + DELETE("delete"); + + private final String name; + + Subcommand(String name) { + this.name = name; + } + + static Subcommand fromName(String name) { + for (Subcommand subcommand : Subcommand.values()) { + if (subcommand.name.equals(name)) { + return subcommand; + } + } + throw new IllegalArgumentException( + "Subcommand with name '%s' is unknown".formatted(name)); + } + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagSystem.java b/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagSystem.java new file mode 100644 index 0000000000..2acbb525bb --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagSystem.java @@ -0,0 +1,146 @@ +package org.togetherjava.tjbot.commands.tags; + +import net.dv8tion.jda.api.entities.Emoji; +import net.dv8tion.jda.api.interactions.Interaction; +import net.dv8tion.jda.api.interactions.components.Button; +import net.dv8tion.jda.api.interactions.components.ButtonStyle; +import org.jetbrains.annotations.NotNull; +import org.togetherjava.tjbot.commands.utils.StringDistances; +import org.togetherjava.tjbot.db.Database; +import org.togetherjava.tjbot.db.generated.tables.Tags; +import org.togetherjava.tjbot.db.generated.tables.records.TagsRecord; + +import java.awt.*; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * The core of the tag system. Provides methods to read and create tags, directly tied to the + * underlying database. + */ +public final class TagSystem { + /** + * The ambient color to use for tag system related messages. + */ + static final Color AMBIENT_COLOR = Color.decode("#FA8072"); + + private final Database database; + + /** + * Creates an instance. + * + * @param database the database to store and retrieve tags from + */ + public TagSystem(Database database) { + this.database = database; + } + + /** + * Creates a delete button with the given component id that can be used in message dialogs. For + * example to delete a message. + * + * @param componentId the component id to use for the button + * @return the created delete button + */ + @SuppressWarnings("StaticMethodOnlyUsedInOneClass") + static Button createDeleteButton(String componentId) { + return Button.of(ButtonStyle.DANGER, componentId, "Delete", + Emoji.fromUnicode("\uD83D\uDDD1")); // trash bin + } + + /** + * Returns whether the given tag is unknown to the system. + *

+ * If it is unknown, it sends an error message to the user. + * + * @param id the id of the tag to check + * @param event the event to send messages with + * @return whether the given tag is unknown to the system + */ + @SuppressWarnings("BooleanMethodNameMustStartWithQuestion") + boolean handleIsUnknownTag(@NotNull String id, @NotNull Interaction event) { + if (hasTag(id)) { + return false; + } + String suggestionText = StringDistances.closestMatch(id, getAllIds()) + .map(", did you perhaps mean '%s'?"::formatted) + .orElse("."); + + event.reply("Could not find any tag with id '%s'%s".formatted(id, suggestionText)) + .setEphemeral(true) + .queue(); + return true; + } + + /** + * Checks if the given tag is known to the tag system. + * + * @param id the id of the tag to check + * @return whether the tag is known to the tag system + */ + boolean hasTag(String id) { + return database.readTransaction(context -> context.selectFrom(Tags.TAGS) + .where(Tags.TAGS.ID.eq(id)) + .fetchOne() != null); + } + + /** + * Deletes a tag from the tag system. + * + * @param id the id of the tag to delete + * @throws IllegalArgumentException if the tag is unknown to the system, see + * {@link #hasTag(String)} + */ + void deleteTag(String id) { + int deletedRecords = database.writeAndProvide( + context -> context.deleteFrom(Tags.TAGS).where(Tags.TAGS.ID.eq(id)).execute()); + if (deletedRecords == 0) { + throw new IllegalArgumentException( + "Unable to delete the tag '%s', it is unknown to the system".formatted(id)); + } + } + + /** + * Inserts or replaces the tag with the given data into the system. + * + * @param id the id of the tag to put + * @param content the content of the tag to put + */ + // Execute closes resources; without curly braces on the lambda, the call would be ambiguous + void putTag(String id, String content) { + database.writeTransaction( + context -> context.insertInto(Tags.TAGS, Tags.TAGS.ID, Tags.TAGS.CONTENT) + .values(id, content) + .onDuplicateKeyUpdate() + .set(Tags.TAGS.CONTENT, content) + .execute()); + } + + /** + * Retrieves the content of the given tag, if it is known to the system (see + * {@link #hasTag(String)}). + * + * @param id the id of the tag to get + * @return the content of the tag, if the tag is known to the system + */ + Optional getTag(String id) { + return database.readTransaction(context -> Optional + .ofNullable(context.selectFrom(Tags.TAGS).where(Tags.TAGS.ID.eq(id)).fetchOne()) + .map(TagsRecord::getContent)); + } + + /** + * Gets the ids of all tags known to the system. + * + * @return a set of all ids known to the system, not backed + */ + Set getAllIds() { + return database.readTransaction(context -> context.select(Tags.TAGS.ID) + .from(Tags.TAGS) + .fetch() + .stream() + .map(dbRecord -> dbRecord.getValue(Tags.TAGS.ID)) + .collect(Collectors.toSet())); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagsCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagsCommand.java new file mode 100644 index 0000000000..82baa36a94 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/tags/TagsCommand.java @@ -0,0 +1,67 @@ +package org.togetherjava.tjbot.commands.tags; + +import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.events.interaction.ButtonClickEvent; +import net.dv8tion.jda.api.events.interaction.SlashCommandEvent; +import org.jetbrains.annotations.NotNull; +import org.togetherjava.tjbot.commands.SlashCommandAdapter; +import org.togetherjava.tjbot.commands.SlashCommandVisibility; +import org.togetherjava.tjbot.commands.utils.MessageUtils; + +import java.util.List; +import java.util.Objects; + +/** + * Implements the {@code /tags} command which lets the bot respond with all available tags. + *

+ * Tags can be added by using {@link TagManageCommand} and viewed by {@link TagCommand}. + *

+ * For example, suppose there is a tag with id {@code foo} and content {@code bar}, then: + * + *

+ * {@code
+ * /tag foo
+ * // TJ-Bot: bar
+ * }
+ * 
+ */ +public final class TagsCommand extends SlashCommandAdapter { + private final TagSystem tagSystem; + + /** + * Creates a new instance, using the given tag system as base. + * + * @param tagSystem the system providing the actual tag data + */ + public TagsCommand(TagSystem tagSystem) { + super("tags", "Displays all available tags", SlashCommandVisibility.GUILD); + + this.tagSystem = tagSystem; + } + + @Override + public void onSlashCommand(@NotNull SlashCommandEvent event) { + // TODO A list might be better than comma separated, which is hard to read + event.replyEmbeds(MessageUtils.generateEmbed("All available tags", + String.join(", ", tagSystem.getAllIds()), event.getUser(), TagSystem.AMBIENT_COLOR)) + .addActionRow( + TagSystem.createDeleteButton(generateComponentId(event.getUser().getId()))) + .queue(); + } + + @Override + public void onButtonClick(@NotNull ButtonClickEvent event, @NotNull List args) { + String userId = args.get(0); + + if (!event.getUser().getId().equals(userId) && !Objects.requireNonNull(event.getMember()) + .hasPermission(Permission.MESSAGE_MANAGE)) { + event.reply( + "The message can only be deleted by its author or an user with 'MESSAGE_MANAGE' permissions.") + .setEphemeral(true) + .queue(); + return; + } + + event.getMessage().delete().queue(); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/tags/package-info.java b/application/src/main/java/org/togetherjava/tjbot/commands/tags/package-info.java new file mode 100644 index 0000000000..e250c06ae9 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/tags/package-info.java @@ -0,0 +1,7 @@ +/** + * This package offers the tag system and its commands. See + * {@link org.togetherjava.tjbot.commands.tags.TagSystem} for the core of the system and commands + * like {@link org.togetherjava.tjbot.commands.tags.TagCommand} as entry point to the package's + * offered functionality. + */ +package org.togetherjava.tjbot.commands.tags; diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/utils/MessageUtils.java b/application/src/main/java/org/togetherjava/tjbot/commands/utils/MessageUtils.java new file mode 100644 index 0000000000..ce35d8789d --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/utils/MessageUtils.java @@ -0,0 +1,80 @@ +package org.togetherjava.tjbot.commands.utils; + +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.interactions.components.ActionRow; +import net.dv8tion.jda.api.interactions.components.Button; +import net.dv8tion.jda.api.utils.MarkdownSanitizer; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.awt.*; +import java.time.Instant; +import java.util.List; + +/** + * Utility methods for {@link Message}. + *

+ * This class is meant to contain all utility methods for {@link Message} that can be used on all + * other commands to avoid similar methods appearing everywhere. + */ +public enum MessageUtils { + ; + + /** + * Disables all the buttons that a message has. Disabling buttons deems it as not clickable to + * the user who sees it. + *

+ * This method already queues the changes for you and does not block in any way. + * + * @param message the message that contains at least one button + * @throws IllegalArgumentException when the given message does not contain any button + */ + public static void disableButtons(@NotNull Message message) { + List