diff --git a/.github/workflows/basic-checks.yml b/.github/workflows/basic-checks.yml index 698c4dc36f..cdf98a6c8e 100644 --- a/.github/workflows/basic-checks.yml +++ b/.github/workflows/basic-checks.yml @@ -3,7 +3,7 @@ name: Basic checks on: [pull_request] env: - JAVA_VERSION: 19 + JAVA_VERSION: 20 jobs: spotless: diff --git a/.github/workflows/code-analysis.yml b/.github/workflows/code-analysis.yml index 94cbba8bf6..b8b74e8534 100644 --- a/.github/workflows/code-analysis.yml +++ b/.github/workflows/code-analysis.yml @@ -8,7 +8,7 @@ on: - cron: '0 20 * * 4' env: - JAVA_VERSION: 19 + JAVA_VERSION: 20 jobs: sonar: @@ -72,7 +72,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v1 + uses: github/codeql-action/init@v2 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -83,7 +83,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) #- name: Autobuild - # uses: github/codeql-action/autobuild@v1 + # uses: github/codeql-action/autobuild@v2 # â„šī¸ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -95,4 +95,4 @@ jobs: ./gradlew build - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 + uses: github/codeql-action/analyze@v2 diff --git a/.github/workflows/docker-publish.yaml b/.github/workflows/docker-publish.yaml index bc787405d2..b26aee1440 100644 --- a/.github/workflows/docker-publish.yaml +++ b/.github/workflows/docker-publish.yaml @@ -7,7 +7,7 @@ on: - 'master' env: - JAVA_VERSION: 19 + JAVA_VERSION: 20 jobs: docker: diff --git a/.github/workflows/docker-verify.yaml b/.github/workflows/docker-verify.yaml index 1c96145eb4..71a3427241 100644 --- a/.github/workflows/docker-verify.yaml +++ b/.github/workflows/docker-verify.yaml @@ -3,7 +3,7 @@ name: Docker Verify on: [pull_request] env: - JAVA_VERSION: 19 + JAVA_VERSION: 20 jobs: docker: diff --git a/.github/workflows/releases.yaml b/.github/workflows/releases.yaml index dcda2557f7..a2c5427348 100644 --- a/.github/workflows/releases.yaml +++ b/.github/workflows/releases.yaml @@ -10,7 +10,7 @@ defaults: shell: bash env: - JAVA_VERSION: 19 + JAVA_VERSION: 20 jobs: diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml deleted file mode 100644 index 86ed01e66e..0000000000 --- a/.github/workflows/stale.yml +++ /dev/null @@ -1,34 +0,0 @@ -# This workflow warns and then closes issues and PRs that have had no activity for a specified amount of time. -# -# You can adjust the behavior by modifying this file. -# For more information, see: -# https://github.com/actions/stale -name: Mark stale issues and pull requests - -on: - schedule: - - cron: '0 7 * * *' - -jobs: - stale: - - runs-on: ubuntu-latest - permissions: - issues: write - pull-requests: write - - steps: - - uses: actions/stale@v4 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - stale-issue-message: 'This issue is stale because it has been open 30 days with no activity. Remove stale label, comment or add the valid label or this will be closed in 5 days.' - stale-pr-message: 'This pull request is stale because it has been open 30 days with no activity. Remove stale label, comment or add the valid label or this will be closed in 5 days.' - stale-issue-label: 'stale' - stale-pr-label: 'stale' - close-issue-label: 'inactivity-closed' - close-pr-label: 'inactivity-closed' - labels-to-add-when-unstale: 'valid' - exempt-issue-labels: 'valid,good first issue' - exempt-pr-labels: 'valid,good first issue' - days-before-stale: 30 - days-before-close: 5 diff --git a/README.md b/README.md index 93c2f14826..467bc3f036 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # TJ-Bot [![codefactor](https://img.shields.io/codefactor/grade/github/together-java/tj-bot)](https://www.codefactor.io/repository/github/together-java/tj-bot) -![Java](https://img.shields.io/badge/Java-19-ff696c) +![Java](https://img.shields.io/badge/Java-20-ff696c) [![license](https://img.shields.io/github/license/Together-Java/TJ-Bot)](https://github.com/Together-Java/TJ-Bot/blob/master/LICENSE) ![GitHub release (latest by date)](https://img.shields.io/github/v/release/Together-Java/TJ-Bot?label=release) diff --git a/application/build.gradle b/application/build.gradle index dda761af2e..069f425477 100644 --- a/application/build.gradle +++ b/application/build.gradle @@ -6,7 +6,7 @@ buildscript { plugins { id 'application' - id 'com.google.cloud.tools.jib' version '3.3.0' + id 'com.google.cloud.tools.jib' version '3.4.0' id 'com.github.johnrengelman.shadow' version '8.1.0' id 'database-settings' } @@ -18,7 +18,7 @@ repositories { var outputImage = 'togetherjava.org:5001/togetherjava/tjbot:' + System.getenv('BRANCH_NAME') ?: 'latest' jib { - from.image = 'eclipse-temurin:19' + from.image = 'eclipse-temurin:20' to { image = outputImage auth { @@ -48,7 +48,7 @@ dependencies { implementation 'net.dv8tion:JDA:5.0.0-alpha.20' - implementation 'org.apache.logging.log4j:log4j-core:2.20.0' + implementation 'org.apache.logging.log4j:log4j-core:2.21.0' runtimeOnly 'org.apache.logging.log4j:log4j-slf4j18-impl:2.18.0' implementation 'club.minnced:discord-webhooks:0.8.2' @@ -74,7 +74,7 @@ dependencies { implementation 'com.github.ben-manes.caffeine:caffeine:3.1.1' - implementation 'org.kohsuke:github-api:1.315' + implementation 'org.kohsuke:github-api:1.317' testImplementation 'org.mockito:mockito-core:5.3.1' testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.0' diff --git a/application/config.json.template b/application/config.json.template index 5391037110..c625b3d212 100644 --- a/application/config.json.template +++ b/application/config.json.template @@ -101,5 +101,11 @@ "pruneMemberAmount": 7, "inactivateAfterDays": 90, "recentlyJoinedDays": 4 + }, + "featureBlacklist": { + "normal": [ + ], + "special": [ + ] } } diff --git a/application/src/main/java/org/togetherjava/tjbot/config/Config.java b/application/src/main/java/org/togetherjava/tjbot/config/Config.java index 4697573b06..161bfe75fd 100644 --- a/application/src/main/java/org/togetherjava/tjbot/config/Config.java +++ b/application/src/main/java/org/togetherjava/tjbot/config/Config.java @@ -40,6 +40,7 @@ public final class Config { private final String sourceCodeBaseUrl; private final JShellConfig jshell; private final HelperPruneConfig helperPruneConfig; + private final FeatureBlacklistConfig featureBlacklistConfig; @SuppressWarnings("ConstructorWithTooManyParameters") @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) @@ -79,7 +80,9 @@ private Config(@JsonProperty(value = "token", required = true) String token, @JsonProperty(value = "sourceCodeBaseUrl", required = true) String sourceCodeBaseUrl, @JsonProperty(value = "jshell", required = true) JShellConfig jshell, @JsonProperty(value = "helperPruneConfig", - required = true) HelperPruneConfig helperPruneConfig) { + required = true) HelperPruneConfig helperPruneConfig, + @JsonProperty(value = "featureBlacklist", + required = true) FeatureBlacklistConfig featureBlacklistConfig) { this.token = Objects.requireNonNull(token); this.gistApiKey = Objects.requireNonNull(gistApiKey); this.databasePath = Objects.requireNonNull(databasePath); @@ -106,6 +109,7 @@ private Config(@JsonProperty(value = "token", required = true) String token, this.sourceCodeBaseUrl = Objects.requireNonNull(sourceCodeBaseUrl); this.jshell = Objects.requireNonNull(jshell); this.helperPruneConfig = Objects.requireNonNull(helperPruneConfig); + this.featureBlacklistConfig = Objects.requireNonNull(featureBlacklistConfig); } /** @@ -355,4 +359,13 @@ public JShellConfig getJshell() { public HelperPruneConfig getHelperPruneConfig() { return helperPruneConfig; } + + /** + * The configuration of blacklisted features. + * + * @return configuration of blacklisted features + */ + public FeatureBlacklistConfig getFeatureBlacklistConfig() { + return featureBlacklistConfig; + } } diff --git a/application/src/main/java/org/togetherjava/tjbot/config/FeatureBlacklist.java b/application/src/main/java/org/togetherjava/tjbot/config/FeatureBlacklist.java new file mode 100644 index 0000000000..be69163841 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/config/FeatureBlacklist.java @@ -0,0 +1,35 @@ +package org.togetherjava.tjbot.config; + +import com.fasterxml.jackson.annotation.JsonCreator; + +import java.util.Set; + +/** + * Blacklist of features, use {@link FeatureBlacklist#isEnabled(T)} to test if a feature is enabled. + * If a feature is blacklisted, it won't be enabled by the bot, and so will be ignored. + * + * @param the type of the feature identifier + */ +public class FeatureBlacklist { + private final Set featureIdentifierBlacklist; + + /** + * Creates a feature blacklist + * + * @param featureIdentifierBlacklist a set of identifiers which are blacklisted + */ + @JsonCreator + public FeatureBlacklist(Set featureIdentifierBlacklist) { + this.featureIdentifierBlacklist = Set.copyOf(featureIdentifierBlacklist); + } + + /** + * Returns if a feature is enabled or not. + * + * @param featureId the identifier of the feature + * @return true if a feature is enabled, false otherwise + */ + public boolean isEnabled(T featureId) { + return !featureIdentifierBlacklist.contains(featureId); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/config/FeatureBlacklistConfig.java b/application/src/main/java/org/togetherjava/tjbot/config/FeatureBlacklistConfig.java new file mode 100644 index 0000000000..231cbdb4f8 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/config/FeatureBlacklistConfig.java @@ -0,0 +1,28 @@ +package org.togetherjava.tjbot.config; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.Objects; + +/** + * Configuration of the feature blacklist, any feature present here will be disabled. + * + * @param normal the normal features, which are present in + * {@link org.togetherjava.tjbot.features.Features} + * @param special the special features, which require special code + */ +public record FeatureBlacklistConfig( + @JsonProperty(value = "normal", required = true) FeatureBlacklist> normal, + @JsonProperty(value = "special", required = true) FeatureBlacklist special) { + + /** + * Creates a FeatureBlacklistConfig. + * + * @param normal the list of normal features, must be not null + * @param special the list of special features, must be not null + */ + public FeatureBlacklistConfig { + Objects.requireNonNull(normal); + Objects.requireNonNull(special); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/Features.java b/application/src/main/java/org/togetherjava/tjbot/features/Features.java index e5186ad087..6bce808543 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/Features.java @@ -3,6 +3,8 @@ import net.dv8tion.jda.api.JDA; import org.togetherjava.tjbot.config.Config; +import org.togetherjava.tjbot.config.FeatureBlacklist; +import org.togetherjava.tjbot.config.FeatureBlacklistConfig; import org.togetherjava.tjbot.db.Database; import org.togetherjava.tjbot.features.basic.PingCommand; import org.togetherjava.tjbot.features.basic.RoleSelectCommand; @@ -19,6 +21,7 @@ import org.togetherjava.tjbot.features.code.CodeMessageManualDetection; import org.togetherjava.tjbot.features.filesharing.FileSharingMessageListener; import org.togetherjava.tjbot.features.help.*; +import org.togetherjava.tjbot.features.jshell.JShellCommand; import org.togetherjava.tjbot.features.jshell.JShellEval; import org.togetherjava.tjbot.features.mathcommands.TeXCommand; import org.togetherjava.tjbot.features.mathcommands.wolframalpha.WolframAlphaCommand; @@ -73,6 +76,7 @@ private Features() { * @return a collection of all features */ public static Collection createFeatures(JDA jda, Database database, Config config) { + FeatureBlacklistConfig blacklistConfig = config.getFeatureBlacklistConfig(); JShellEval jshellEval = new JShellEval(config.getJshell()); TagSystem tagSystem = new TagSystem(database); @@ -80,7 +84,8 @@ public static Collection createFeatures(JDA jda, Database database, Con ModerationActionsStore actionsStore = new ModerationActionsStore(database); ModAuditLogWriter modAuditLogWriter = new ModAuditLogWriter(config); ScamHistoryStore scamHistoryStore = new ScamHistoryStore(database); - CodeMessageHandler codeMessageHandler = new CodeMessageHandler(jshellEval); + CodeMessageHandler codeMessageHandler = + new CodeMessageHandler(blacklistConfig.special(), jshellEval); ChatGptService chatGptService = new ChatGptService(config); HelpSystemHelper helpSystemHelper = new HelpSystemHelper(config, database, chatGptService); @@ -121,6 +126,7 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(new HelpThreadCreatedListener(helpSystemHelper)); // Message context commands + features.add(new TransferQuestionCommand(config)); // User context commands @@ -151,7 +157,9 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(new ReportCommand(config)); features.add(new BookmarksCommand(bookmarksSystem)); features.add(new ChatGptCommand(chatGptService)); - // features.add(new JShellCommand(jshellEval)); - return features; + features.add(new JShellCommand(jshellEval)); + + FeatureBlacklist> blacklist = blacklistConfig.normal(); + return features.stream().filter(f -> blacklist.isEnabled(f.getClass())).toList(); } } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/code/CodeMessageHandler.java b/application/src/main/java/org/togetherjava/tjbot/features/code/CodeMessageHandler.java index f4d27e85d5..916cef8bb6 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/code/CodeMessageHandler.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/code/CodeMessageHandler.java @@ -15,6 +15,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.togetherjava.tjbot.config.FeatureBlacklist; import org.togetherjava.tjbot.features.MessageReceiverAdapter; import org.togetherjava.tjbot.features.UserInteractionType; import org.togetherjava.tjbot.features.UserInteractor; @@ -30,6 +31,7 @@ import java.util.*; import java.util.function.Function; import java.util.stream.Collectors; +import java.util.stream.Stream; /** * Handles code in registered messages and offers code actions to the user, such as formatting their @@ -63,14 +65,18 @@ public final class CodeMessageHandler extends MessageReceiverAdapter implements /** * Creates a new instance. - * + * + * @param blacklist the feature blacklist, used to test if certain code actions should be + * disabled * @param jshellEval used to execute java code and build visual result */ - public CodeMessageHandler(JShellEval jshellEval) { + public CodeMessageHandler(FeatureBlacklist blacklist, JShellEval jshellEval) { componentIdInteractor = new ComponentIdInteractor(getInteractionType(), getName()); List codeActions = - List.of(new FormatCodeCommand()/* , new EvalCodeCommand(jshellEval) */); + Stream.of(new FormatCodeCommand(), new EvalCodeCommand(jshellEval)) + .filter(a -> blacklist.isEnabled(a.getClass().getSimpleName())) + .toList(); labelToCodeAction = codeActions.stream() .collect(Collectors.toMap(CodeAction::getLabel, Function.identity(), (x, y) -> y, diff --git a/application/src/main/java/org/togetherjava/tjbot/features/help/AutoPruneHelperRoutine.java b/application/src/main/java/org/togetherjava/tjbot/features/help/AutoPruneHelperRoutine.java index 9fcd05e4ef..63563ccf1e 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/help/AutoPruneHelperRoutine.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/help/AutoPruneHelperRoutine.java @@ -163,7 +163,9 @@ private void pruneMemberFromRole(Member member, Role role, ForumChannel helpForu guild.removeRoleFromMember(member, role) .flatMap(any -> member.getUser().openPrivateChannel()) .flatMap(channel -> channel.sendMessage(dmMessage)) - .queue(); + .queue(null, failure -> logger.debug( + "Failed sending a DM to user ({}) while pruning them from a helper role.", + member.getId())); } private void warnModsAbout(String message, Guild guild) { diff --git a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpSystemHelper.java b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpSystemHelper.java index 8604ac631b..9d987954ac 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpSystemHelper.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpSystemHelper.java @@ -1,10 +1,7 @@ package org.togetherjava.tjbot.features.help; import net.dv8tion.jda.api.EmbedBuilder; -import net.dv8tion.jda.api.entities.Guild; -import net.dv8tion.jda.api.entities.Message; -import net.dv8tion.jda.api.entities.MessageEmbed; -import net.dv8tion.jda.api.entities.Role; +import net.dv8tion.jda.api.entities.*; import net.dv8tion.jda.api.entities.channel.attribute.IThreadContainer; import net.dv8tion.jda.api.entities.channel.concrete.ForumChannel; import net.dv8tion.jda.api.entities.channel.concrete.ThreadChannel; @@ -12,6 +9,7 @@ import net.dv8tion.jda.api.entities.channel.forums.ForumTagSnowflake; import net.dv8tion.jda.api.entities.channel.middleman.GuildChannel; import net.dv8tion.jda.api.entities.channel.middleman.GuildMessageChannel; +import net.dv8tion.jda.api.interactions.components.buttons.Button; import net.dv8tion.jda.api.requests.RestAction; import net.dv8tion.jda.api.requests.restaction.MessageCreateAction; import net.dv8tion.jda.api.utils.FileUpload; @@ -26,6 +24,7 @@ import org.togetherjava.tjbot.db.generated.tables.records.HelpThreadsRecord; import org.togetherjava.tjbot.features.chatgpt.ChatGptCommand; import org.togetherjava.tjbot.features.chatgpt.ChatGptService; +import org.togetherjava.tjbot.features.componentids.ComponentIdInteractor; import javax.annotation.Nullable; @@ -40,6 +39,7 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Predicate; @@ -59,6 +59,7 @@ public final class HelpSystemHelper { private static final String CODE_SYNTAX_EXAMPLE_PATH = "codeSyntaxExample.png"; + private final Predicate hasTagManageRole; private final Predicate isHelpForumName; private final String helpForumPattern; /** @@ -88,6 +89,7 @@ public HelpSystemHelper(Config config, Database database, ChatGptService chatGpt this.database = database; this.chatGptService = chatGptService; + hasTagManageRole = Pattern.compile(config.getTagManageRolePattern()).asMatchPredicate(); helpForumPattern = helpConfig.getHelpForumPattern(); isHelpForumName = Pattern.compile(helpForumPattern).asMatchPredicate(); @@ -161,7 +163,7 @@ private RestAction sendExplanationMessage(GuildMessageChannel threadCha * why the message wasn't used. */ RestAction constructChatGptAttempt(ThreadChannel threadChannel, - String originalQuestion) { + String originalQuestion, ComponentIdInteractor componentIdInteractor) { Optional questionOptional = prepareChatGptQuestion(threadChannel, originalQuestion); Optional chatGPTAnswer; @@ -176,6 +178,7 @@ RestAction constructChatGptAttempt(ThreadChannel threadChannel, return useChatGptFallbackMessage(threadChannel); } + List ids = new CopyOnWriteArrayList<>(); RestAction message = mentionGuildSlashCommand(threadChannel.getGuild(), ChatGptCommand.COMMAND_NAME) .map(""" @@ -183,15 +186,31 @@ RestAction constructChatGptAttempt(ThreadChannel threadChannel, In any case, a human is on the way 👍. To continue talking to the AI, you can use \ %s. """::formatted) - .flatMap(threadChannel::sendMessage); + .flatMap(threadChannel::sendMessage) + .onSuccess(m -> ids.add(m.getId())); + String[] answers = chatGPTAnswer.orElseThrow(); + + for (int i = 0; i < answers.length; i++) { + MessageCreateAction answer = threadChannel.sendMessage(answers[i]); + + if (i == answers.length - 1) { + message = message.flatMap(any -> answer + .addActionRow(generateDismissButton(componentIdInteractor, ids))); + continue; + } - for (String aiResponse : chatGPTAnswer.get()) { - message = message.map(aiResponse::formatted).flatMap(threadChannel::sendMessage); + message = message.flatMap(ignored -> answer.onSuccess(m -> ids.add(m.getId()))); } return message; } + private Button generateDismissButton(ComponentIdInteractor componentIdInteractor, + List ids) { + String buttonId = componentIdInteractor.generateComponentId(ids.toArray(String[]::new)); + return Button.danger(buttonId, "Dismiss"); + } + private Optional prepareChatGptQuestion(ThreadChannel threadChannel, String originalQuestion) { String questionTitle = threadChannel.getName(); @@ -344,6 +363,10 @@ private static ForumTag requireTag(String tagName, ForumChannel forumChannel) { return matchingTags.get(0); } + boolean hasTagManageRole(Member member) { + return member.getRoles().stream().map(Role::getName).anyMatch(hasTagManageRole); + } + boolean isHelpForumName(String channelName) { return isHelpForumName.test(channelName); } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadCreatedListener.java b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadCreatedListener.java index 6bbf80eff7..19516dc347 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadCreatedListener.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadCreatedListener.java @@ -2,23 +2,31 @@ import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; +import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.MessageEmbed; import net.dv8tion.jda.api.entities.Role; import net.dv8tion.jda.api.entities.channel.concrete.ThreadChannel; import net.dv8tion.jda.api.entities.channel.forums.ForumTag; import net.dv8tion.jda.api.events.channel.ChannelCreateEvent; +import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent; +import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; +import net.dv8tion.jda.api.events.interaction.component.SelectMenuInteractionEvent; import net.dv8tion.jda.api.hooks.ListenerAdapter; import net.dv8tion.jda.api.requests.RestAction; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.togetherjava.tjbot.features.EventReceiver; +import org.togetherjava.tjbot.features.UserInteractionType; +import org.togetherjava.tjbot.features.UserInteractor; +import org.togetherjava.tjbot.features.componentids.ComponentIdGenerator; +import org.togetherjava.tjbot.features.componentids.ComponentIdInteractor; import java.time.Instant; import java.time.temporal.ChronoUnit; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; +import java.util.List; +import java.util.Objects; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; /** * Listens for new help threads being created. That is, a user posted a question in the help forum. @@ -26,15 +34,16 @@ * Will for example record thread metadata in the database and send an explanation message to the * user. */ -public final class HelpThreadCreatedListener extends ListenerAdapter implements EventReceiver { - private static final Logger logger = LoggerFactory.getLogger(HelpThreadCreatedListener.class); - private static final ScheduledExecutorService SERVICE = Executors.newScheduledThreadPool(2); +public final class HelpThreadCreatedListener extends ListenerAdapter + implements EventReceiver, UserInteractor { private final HelpSystemHelper helper; private final Cache threadIdToCreatedAtCache = Caffeine.newBuilder() .maximumSize(1_000) .expireAfterAccess(2, TimeUnit.of(ChronoUnit.MINUTES)) .build(); + private final ComponentIdInteractor componentIdInteractor = + new ComponentIdInteractor(getInteractionType(), getName()); /** * Creates a new instance. @@ -74,31 +83,26 @@ private boolean wasThreadAlreadyHandled(long threadChannelId) { private void handleHelpThreadCreated(ThreadChannel threadChannel) { helper.writeHelpThreadToDatabase(threadChannel.getOwnerIdLong(), threadChannel); - Runnable createMessages = () -> { - try { - createMessages(threadChannel).queue(); - } catch (Exception e) { - logger.error( - "Unknown error while creating messages after help-thread ({}) creation", - threadChannel.getId(), e); - } - }; - // The creation is delayed, because otherwise it could be too fast and be executed // after Discord created the thread, but before Discord send OPs initial message. // Sending messages at that moment is not allowed. - SERVICE.schedule(createMessages, 5, TimeUnit.SECONDS); + createMessages(threadChannel).and(pinOriginalQuestion(threadChannel)) + .queueAfter(5, TimeUnit.SECONDS); } private RestAction createAIResponse(ThreadChannel threadChannel) { RestAction originalQuestion = threadChannel.retrieveMessageById(threadChannel.getIdLong()); - return originalQuestion.flatMap( - message -> helper.constructChatGptAttempt(threadChannel, message.getContentRaw())); + return originalQuestion.flatMap(message -> helper.constructChatGptAttempt(threadChannel, + getMessageContent(message), componentIdInteractor)); + } + + private RestAction pinOriginalQuestion(ThreadChannel threadChannel) { + return threadChannel.retrieveMessageById(threadChannel.getIdLong()).flatMap(Message::pin); } private RestAction createMessages(ThreadChannel threadChannel) { - return sendHelperHeadsUp(threadChannel).flatMap(Message::pin) + return sendHelperHeadsUp(threadChannel) .flatMap(any -> helper.sendExplanationMessage(threadChannel)) .flatMap(any -> createAIResponse(threadChannel)); } @@ -123,4 +127,59 @@ private RestAction sendHelperHeadsUp(ThreadChannel threadChannel) { return threadChannel.sendMessage(headsUpWithoutRole) .flatMap(message -> message.editMessage(headsUpWithRole)); } + + private String getMessageContent(Message message) { + if (message.getEmbeds().isEmpty()) { + return message.getContentRaw(); + } + + return message.getEmbeds() + .stream() + .map(MessageEmbed::getDescription) + .collect(Collectors.joining("\n")); + } + + @Override + public String getName() { + return "chatpgt-answer"; + } + + @Override + public UserInteractionType getInteractionType() { + return UserInteractionType.OTHER; + } + + @Override + public void acceptComponentIdGenerator(ComponentIdGenerator generator) { + componentIdInteractor.acceptComponentIdGenerator(generator); + } + + @Override + public void onButtonClick(ButtonInteractionEvent event, List args) { + // This method handles chatgpt's automatic response "dismiss" button + ThreadChannel channel = event.getChannel().asThreadChannel(); + Member interactionUser = Objects.requireNonNull(event.getMember()); + if (channel.getOwnerIdLong() != interactionUser.getIdLong() + && !helper.hasTagManageRole(interactionUser)) { + event.reply("You do not have permission for this action.").setEphemeral(true).queue(); + return; + } + + RestAction deleteMessages = event.getMessage().delete(); + for (String id : args) { + deleteMessages = deleteMessages.and(channel.deleteMessageById(id)); + } + deleteMessages.queue(); + } + + @Override + public void onSelectMenuSelection(SelectMenuInteractionEvent event, List args) { + throw new UnsupportedOperationException("Not used"); + } + + @Override + public void onModalSubmitted(ModalInteractionEvent event, List args) { + throw new UnsupportedOperationException("Not used"); + } + } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/mathcommands/wolframalpha/WolframAlphaHandler.java b/application/src/main/java/org/togetherjava/tjbot/features/mathcommands/wolframalpha/WolframAlphaHandler.java index ca17d32346..4b0719e98e 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/mathcommands/wolframalpha/WolframAlphaHandler.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/mathcommands/wolframalpha/WolframAlphaHandler.java @@ -11,13 +11,14 @@ import org.togetherjava.tjbot.features.mathcommands.wolframalpha.api.*; import org.togetherjava.tjbot.features.mathcommands.wolframalpha.api.Error; -import java.awt.Color; +import java.awt.*; import java.awt.image.BufferedImage; import java.io.IOException; -import java.io.UncheckedIOException; import java.net.HttpURLConnection; +import java.net.URISyntaxException; import java.net.http.HttpResponse; import java.util.*; +import java.util.List; import java.util.function.Function; import java.util.stream.Collectors; @@ -169,7 +170,7 @@ private HandlerResponse handleSuccessfulResponse(QueryResult queryResult) { for (SubPod subPod : pod.getSubPods()) { try { images.add(WolframAlphaImages.renderSubPod(subPod)); - } catch (UncheckedIOException e) { + } catch (IOException | URISyntaxException e) { LOGGER.error( "Failed to render sub pod (title: '{}') from pod (title: '{}') from the WolframAlpha response (for query: '{}')", subPod.getTitle(), pod.getTitle(), query, e); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/mathcommands/wolframalpha/WolframAlphaImages.java b/application/src/main/java/org/togetherjava/tjbot/features/mathcommands/wolframalpha/WolframAlphaImages.java index 1da467e528..3bad6ee03e 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/mathcommands/wolframalpha/WolframAlphaImages.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/mathcommands/wolframalpha/WolframAlphaImages.java @@ -5,9 +5,7 @@ import javax.imageio.ImageIO; -import java.awt.Color; -import java.awt.Font; -import java.awt.Graphics; +import java.awt.*; import java.awt.font.FontRenderContext; import java.awt.geom.AffineTransform; import java.awt.geom.Rectangle2D; @@ -16,7 +14,8 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.UncheckedIOException; -import java.net.URL; +import java.net.URI; +import java.net.URISyntaxException; import java.util.ArrayList; import java.util.Collection; import java.util.List; @@ -55,7 +54,7 @@ static BufferedImage renderTitle(String title) { return image; } - static BufferedImage renderSubPod(SubPod subPod) { + static BufferedImage renderSubPod(SubPod subPod) throws IOException, URISyntaxException { WolframAlphaImage sourceImage = subPod.getImage(); int widthPx = sourceImage.getWidth() + 2 * IMAGE_MARGIN_PX; @@ -65,12 +64,9 @@ static BufferedImage renderSubPod(SubPod subPod) { new BufferedImage(widthPx, heightPx, BufferedImage.TYPE_4BYTE_ABGR); Graphics graphics = destinationImage.getGraphics(); - try { - graphics.drawImage(ImageIO.read(new URL(sourceImage.getSource())), IMAGE_MARGIN_PX, - IMAGE_MARGIN_PX, null); - } catch (IOException e) { - throw new UncheckedIOException(e); - } + graphics.drawImage(ImageIO.read(new URI(sourceImage.getSource()).toURL()), IMAGE_MARGIN_PX, + IMAGE_MARGIN_PX, null); + return destinationImage; } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/moderation/TransferQuestionCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/moderation/TransferQuestionCommand.java new file mode 100644 index 0000000000..7b9ef1dfc4 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/moderation/TransferQuestionCommand.java @@ -0,0 +1,264 @@ +package org.togetherjava.tjbot.features.moderation; + +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.entities.Guild; +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.entities.channel.concrete.ForumChannel; +import net.dv8tion.jda.api.entities.channel.forums.ForumTag; +import net.dv8tion.jda.api.entities.channel.forums.ForumTagSnowflake; +import net.dv8tion.jda.api.entities.channel.unions.MessageChannelUnion; +import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent; +import net.dv8tion.jda.api.events.interaction.command.MessageContextInteractionEvent; +import net.dv8tion.jda.api.interactions.commands.build.Commands; +import net.dv8tion.jda.api.interactions.components.Modal; +import net.dv8tion.jda.api.interactions.components.text.TextInput; +import net.dv8tion.jda.api.interactions.components.text.TextInput.Builder; +import net.dv8tion.jda.api.interactions.components.text.TextInputStyle; +import net.dv8tion.jda.api.requests.RestAction; +import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder; +import net.dv8tion.jda.api.utils.messages.MessageCreateData; + +import org.togetherjava.tjbot.config.Config; +import org.togetherjava.tjbot.features.BotCommandAdapter; +import org.togetherjava.tjbot.features.CommandVisibility; +import org.togetherjava.tjbot.features.MessageContextCommand; +import org.togetherjava.tjbot.features.utils.StringDistances; + +import java.awt.Color; +import java.util.List; +import java.util.Optional; +import java.util.function.Predicate; +import java.util.function.Supplier; +import java.util.regex.Pattern; + +/** + * This command can be used to transfer questions asked in any channel to the helper forum. The user + * is given the chance to edit details of the question and upon submitting, the original message + * will be deleted and recreated in the helper forum. The original author is notified and redirected + * to the new post. + */ +public final class TransferQuestionCommand extends BotCommandAdapter + implements MessageContextCommand { + private static final String COMMAND_NAME = "transfer-question"; + private static final String MODAL_TITLE_ID = "transferID"; + private static final String MODAL_INPUT_ID = "transferQuestion"; + private static final String MODAL_TAG = "tags"; + private static final int TITLE_MAX_LENGTH = 50; + private static final Pattern TITLE_GUESS_COMPACT_REMOVAL_PATTERN = Pattern.compile("\\W"); + private static final int TITLE_MIN_LENGTH = 3; + private static final Color EMBED_COLOR = new Color(50, 164, 168); + private static final int INPUT_MAX_LENGTH = Message.MAX_CONTENT_LENGTH; + private static final int INPUT_MIN_LENGTH = 3; + private final Predicate isHelpForumName; + private final List tags; + + + /** + * Creates a new instance. + * + * @param config to get the helper forum and tags + */ + public TransferQuestionCommand(Config config) { + super(Commands.message(COMMAND_NAME), CommandVisibility.GUILD); + + isHelpForumName = + Pattern.compile(config.getHelpSystem().getHelpForumPattern()).asMatchPredicate(); + + tags = config.getHelpSystem().getCategories(); + } + + @Override + public void onMessageContext(MessageContextInteractionEvent event) { + + if (isInvalidForTransfer(event)) { + return; + } + + String originalMessage = event.getTarget().getContentRaw(); + String originalMessageId = event.getTarget().getId(); + String originalChannelId = event.getChannel().getId(); + String authorId = event.getTarget().getAuthor().getId(); + String mostCommonTag = tags.get(0); + + TextInput modalTitle = TextInput.create(MODAL_TITLE_ID, "Title", TextInputStyle.SHORT) + .setMaxLength(TITLE_MAX_LENGTH) + .setMinLength(TITLE_MIN_LENGTH) + .setPlaceholder("Describe the question in short") + .setValue(createTitle(originalMessage)) + .build(); + + Builder modalInputBuilder = + TextInput.create(MODAL_INPUT_ID, "Question", TextInputStyle.PARAGRAPH) + .setRequiredRange(INPUT_MIN_LENGTH, INPUT_MAX_LENGTH) + .setPlaceholder("Contents of the question"); + + if (!isQuestionTooShort(originalMessage)) { + String trimmedMessage = getMessageUptoMaxLimit(originalMessage); + modalInputBuilder.setValue(trimmedMessage); + } + + TextInput modalTag = TextInput.create(MODAL_TAG, "Most fitting tag", TextInputStyle.SHORT) + .setValue(mostCommonTag) + .setPlaceholder("Suitable tag for the question") + .build(); + + String modalComponentId = + generateComponentId(authorId, originalMessageId, originalChannelId); + Modal transferModal = Modal.create(modalComponentId, "Transfer this question") + .addActionRow(modalTitle) + .addActionRow(modalInputBuilder.build()) + .addActionRow(modalTag) + .build(); + + event.replyModal(transferModal).queue(); + } + + @Override + public void onModalSubmitted(ModalInteractionEvent event, List args) { + event.deferEdit().queue(); + + String authorId = args.get(0); + String messageId = args.get(1); + String channelId = args.get(2); + + event.getJDA() + .retrieveUserById(authorId) + .flatMap(fetchedUser -> createForumPost(event, fetchedUser)) + .flatMap(createdforumPost -> dmUser(event.getChannel(), createdforumPost, + event.getGuild())) + .flatMap(dmSent -> deleteOriginalMessage(event.getJDA(), channelId, messageId)) + .queue(); + } + + private static String createTitle(String message) { + if (message.length() >= TITLE_MAX_LENGTH) { + int lastWordEnd = message.lastIndexOf(' ', TITLE_MAX_LENGTH); + + if (lastWordEnd == -1) { + lastWordEnd = TITLE_MAX_LENGTH; + } + + message = message.substring(0, lastWordEnd).replace('\n', ' '); + } + + return isTitleValid(message) ? message : "Untitled"; + } + + private static boolean isTitleValid(CharSequence title) { + String titleCompact = TITLE_GUESS_COMPACT_REMOVAL_PATTERN.matcher(title).replaceAll(""); + + return titleCompact.length() >= TITLE_MIN_LENGTH + && titleCompact.length() <= TITLE_MAX_LENGTH; + } + + private RestAction createForumPost(ModalInteractionEvent event, User originalUser) { + + String originalMessage = event.getValue(MODAL_INPUT_ID).getAsString(); + + MessageEmbed embedForPost = makeEmbedForPost(originalUser, originalMessage); + + MessageCreateData forumMessage = new MessageCreateBuilder() + .addContent("%s has a question:".formatted(originalUser.getAsMention())) + .setEmbeds(embedForPost) + .build(); + + String forumTitle = event.getValue(MODAL_TITLE_ID).getAsString(); + String transferQuestionTag = event.getValue(MODAL_TAG).getAsString(); + + ForumChannel questionsForum = getHelperForum(event.getJDA()); + String mostCommonTag = tags.get(0); + + String queryTag = + StringDistances.closestMatch(transferQuestionTag, tags).orElse(mostCommonTag); + + ForumTag tag = getTagOrDefault(questionsForum.getAvailableTagsByName(queryTag, true), + () -> questionsForum.getAvailableTagsByName(mostCommonTag, true).get(0)); + + return questionsForum.createForumPost(forumTitle, forumMessage) + .setTags(ForumTagSnowflake.fromId(tag.getId())) + .map(createdPost -> new ForumPost(originalUser, createdPost.getMessage())); + } + + private RestAction dmUser(MessageChannelUnion sourceChannel, ForumPost forumPost, + Guild guild) { + + String messageTemplate = + """ + Hello%s 👋 You have asked a question in the wrong channel%s. Not a big deal, but none of the experts who could help you are reading your question there 🙁 + + Your question has been automatically transferred to %s, please continue there, thank you 👍 + """; + + String messageForDm = messageTemplate.formatted("", " on" + " " + guild.getName(), + forumPost.message.getJumpUrl()); + + String messageOnDmFailure = messageTemplate.formatted(" " + forumPost.author.getAsMention(), + "", forumPost.message.getJumpUrl()); + + return forumPost.author.openPrivateChannel() + .flatMap(channel -> channel.sendMessage(messageForDm)) + .onErrorFlatMap(error -> sourceChannel.sendMessage(messageOnDmFailure)); + } + + private RestAction deleteOriginalMessage(JDA jda, String channelId, String messageId) { + return jda.getTextChannelById(channelId).deleteMessageById(messageId); + } + + private ForumChannel getHelperForum(JDA jda) { + Optional forumChannelOptional = jda.getForumChannels() + .stream() + .filter(forumChannel -> isHelpForumName.test(forumChannel.getName())) + .findFirst(); + + return forumChannelOptional.orElseThrow(() -> new IllegalStateException( + "Did not find the helper-forum while trying to transfer a question. Make sure the config is setup properly.")); + } + + private static ForumTag getTagOrDefault(List tagsFoundOnForum, + Supplier defaultTag) { + return tagsFoundOnForum.isEmpty() ? defaultTag.get() : tagsFoundOnForum.get(0); + } + + private MessageEmbed makeEmbedForPost(User originalUser, String originalMessage) { + return new EmbedBuilder() + .setAuthor(originalUser.getName(), originalUser.getAvatarUrl(), + originalUser.getAvatar().getUrl()) + .setDescription(originalMessage) + .setColor(EMBED_COLOR) + .build(); + } + + private record ForumPost(User author, Message message) { + } + + private boolean isBotMessageTransfer(User author) { + return author.isBot(); + } + + private void handleBotMessageTransfer(MessageContextInteractionEvent event) { + event.reply("Cannot transfer messages from a bot.").setEphemeral(true).queue(); + } + + private boolean isQuestionTooShort(String question) { + return question.length() < INPUT_MIN_LENGTH; + } + + private boolean isInvalidForTransfer(MessageContextInteractionEvent event) { + User author = event.getTarget().getAuthor(); + + if (isBotMessageTransfer(author)) { + handleBotMessageTransfer(event); + return true; + } + return false; + } + + private String getMessageUptoMaxLimit(String originalMessage) { + return originalMessage.length() > INPUT_MAX_LENGTH + ? originalMessage.substring(0, INPUT_MAX_LENGTH) + : originalMessage; + } +} diff --git a/application/src/main/resources/log4j2.xml b/application/src/main/resources/log4j2.xml index 3de21e9d65..417a7eaa6c 100644 --- a/application/src/main/resources/log4j2.xml +++ b/application/src/main/resources/log4j2.xml @@ -1,5 +1,5 @@ - + diff --git a/build.gradle b/build.gradle index 1e648c367b..90dafecc17 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,7 @@ plugins { id 'java' - id "com.diffplug.spotless" version "6.21.0" - id "org.sonarqube" version "4.3.0.3225" + id "com.diffplug.spotless" version "6.22.0" + id "org.sonarqube" version "4.4.0.3356" id "name.remal.sonarlint" version "3.3.0" } @@ -65,8 +65,8 @@ subprojects { options.encoding = 'UTF-8' // Nails the Java-Version of every Subproject - sourceCompatibility = JavaVersion.VERSION_19 - targetCompatibility = JavaVersion.VERSION_19 + sourceCompatibility = JavaVersion.VERSION_20 + targetCompatibility = JavaVersion.VERSION_20 } compileJava(compileTasks) diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle index fd7f55577c..b10f4f8ea5 100644 --- a/buildSrc/build.gradle +++ b/buildSrc/build.gradle @@ -7,6 +7,6 @@ repositories { } dependencies { - implementation "gradle.plugin.org.flywaydb:gradle-plugin-publishing:9.22.0" + implementation "gradle.plugin.org.flywaydb:gradle-plugin-publishing:10.0.0" implementation 'nu.studer:gradle-jooq-plugin:8.2' } diff --git a/database/build.gradle b/database/build.gradle index 55c940a648..a373316dcb 100644 --- a/database/build.gradle +++ b/database/build.gradle @@ -7,7 +7,7 @@ var sqliteVersion = "3.43.0.0" dependencies { implementation 'com.google.code.findbugs:jsr305:3.0.2' implementation "org.xerial:sqlite-jdbc:${sqliteVersion}" - implementation 'org.flywaydb:flyway-core:9.22.0' + implementation 'org.flywaydb:flyway-core:10.0.0' implementation "org.jooq:jooq:$jooqVersion" implementation project(':utils') diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index ccebba7710..7f93135c49 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index fc10b601f7..ac72c34e8a 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-bin.zip networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 79a61d421c..0adc8e1a53 100755 --- a/gradlew +++ b/gradlew @@ -83,10 +83,8 @@ done # This is normally unused # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -133,10 +131,13 @@ location of your Java installation." fi else JAVACMD=java - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. @@ -197,6 +198,10 @@ if "$cygwin" || "$msys" ; then done fi + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + # Collect all arguments for the java command; # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of # shell script including quotes and variable substitutions, so put them in