diff --git a/src/main/java/net/discordjug/javabot/data/config/SystemsConfig.java b/src/main/java/net/discordjug/javabot/data/config/SystemsConfig.java index e9501c181..3e62a6a7e 100644 --- a/src/main/java/net/discordjug/javabot/data/config/SystemsConfig.java +++ b/src/main/java/net/discordjug/javabot/data/config/SystemsConfig.java @@ -24,6 +24,11 @@ public class SystemsConfig { * The Key used for the bing-search-api. */ private String azureSubscriptionKey = ""; + + /** + * The API key of the Answer Overflow API. + */ + private String answerOverflowApiKey = ""; /** * The DSN for the Sentry API. diff --git a/src/main/java/net/discordjug/javabot/systems/help/AnswerOverflowService.java b/src/main/java/net/discordjug/javabot/systems/help/AnswerOverflowService.java new file mode 100644 index 000000000..78f5b5f48 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/help/AnswerOverflowService.java @@ -0,0 +1,58 @@ +package net.discordjug.javabot.systems.help; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpRequest.BodyPublishers; +import java.net.http.HttpResponse.BodyHandlers; + +import lombok.extern.slf4j.Slf4j; +import net.discordjug.javabot.data.config.BotConfig; +import org.springframework.stereotype.Service; + +/** + * A service class for interacting with the Answer Overflow bot. + */ +@Service +@Slf4j +public class AnswerOverflowService { + private final String apiKey; + + public AnswerOverflowService(BotConfig config) { + apiKey = config.getSystems().getAnswerOverflowApiKey(); + } + + /** + * Marks a message as the answer of a forum post with Answer Overflow. + * @param postId The Discord ID of the forum post + * @param messageId The Discord ID of the message that should be marked as the answer. + */ + public void markAnswer(long postId, long messageId) { + if (apiKey == null || apiKey.isBlank()) { + return; + } + + try (HttpClient client = HttpClient.newHttpClient()) { + client.sendAsync( + HttpRequest.newBuilder(URI.create("https://www.answeroverflow.com/api/v1/messages/" + postId)) + .header("x-api-key", apiKey) + .header("Content-Type", "application/json") + .POST(BodyPublishers.ofString(""" + { + "solutionId": "%d" + } + """.formatted(messageId))) + .build(), + BodyHandlers.ofString()) + .thenAccept(response -> { + if (response.statusCode() != 200) { + log.warn("Answer Overflow responded with unexpected status code {}, post ID: {}, message ID: {}, body: {}", response.statusCode(), postId, messageId, response.body()); + } + }) + .exceptionally(e -> { + log.error("An exception occured trying to mark a post as an answer.", e); + return null; + }); + } + } +} diff --git a/src/main/java/net/discordjug/javabot/systems/help/HelpListener.java b/src/main/java/net/discordjug/javabot/systems/help/HelpListener.java index 889d820a4..efcd8b87e 100644 --- a/src/main/java/net/discordjug/javabot/systems/help/HelpListener.java +++ b/src/main/java/net/discordjug/javabot/systems/help/HelpListener.java @@ -30,6 +30,7 @@ import xyz.dynxsty.dih4jda.util.ComponentIdBuilder; import java.util.*; +import java.util.stream.Collectors; import java.util.stream.Stream; /** @@ -79,6 +80,7 @@ protected boolean removeEldestEntry(Map.Entry eldest) { return System.currentTimeMillis() > eldest.getValue() || size() >= 32; } }; + private final AnswerOverflowService answerOverflowService; @Override public void onMessageReceived(@NotNull MessageReceivedEvent event) { @@ -121,12 +123,8 @@ public void onMessageReceived(@NotNull MessageReceivedEvent event) { return; } // cache messages - List messages = new ArrayList<>(); - messages.add(event.getMessage()); - if (HELP_POST_MESSAGES.containsKey(post.getIdLong())) { - messages.addAll(HELP_POST_MESSAGES.get(post.getIdLong())); - } - HELP_POST_MESSAGES.put(post.getIdLong(), messages); + HELP_POST_MESSAGES.computeIfAbsent(post.getIdLong(), _ -> new ArrayList<>()) + .add(event.getMessage()); // suggest to close post on "problem solved"-messages replyCloseSuggestionIfPatternMatches(event.getMessage()); } @@ -291,17 +289,34 @@ private void handleThanksCloseButton(@NotNull ButtonInteractionEvent event, Help event.getMessage().delete().queue(s -> { experienceService.addMessageBasedHelpXP(post, true); // thank all helpers - getButtonStream(event.getMessage()) - .filter(b -> b.getCustomId() != null) - .filter(b -> b.isDisabled() || (b.getCustomId().equals(additionalButtonId))) - .forEach(b -> manager.thankHelper( - event.getGuild(), - post, - Long.parseLong(ComponentIdBuilder.split(b.getCustomId())[2]) - )); + + Set helpersToThank = getButtonStream(event.getMessage()) + .filter(b -> b.getCustomId() != null) + .filter(b -> b.isDisabled() || (b.getCustomId().equals(additionalButtonId))) + .map(b -> Long.parseLong(ComponentIdBuilder.split(b.getCustomId())[2])) + .collect(Collectors.toSet()); + + for (Long helperId : helpersToThank) { + manager.thankHelper(event.getGuild(), post, helperId); + } + markAnswer(post, helpersToThank); + HELP_POST_MESSAGES.remove(post.getIdLong()); }); } + private void markAnswer(ThreadChannel post, Set helpers) { + List messages = HELP_POST_MESSAGES.get(post.getIdLong()); + if (messages == null) { + return; + } + for (Message msg : messages.reversed()) { + if (helpers.contains(msg.getAuthor().getIdLong())) { + answerOverflowService.markAnswer(post.getIdLong(), msg.getIdLong()); + return; + } + } + } + private void handleReplyGuidelines(@NotNull IReplyCallback callback, @NotNull ForumChannel channel) { callback.replyEmbeds(new EmbedBuilder().setTitle("Help Guidelines") .setDescription(channel.getTopic()) diff --git a/src/main/java/net/discordjug/javabot/systems/qotw/jobs/QOTWCloseSubmissionsJob.java b/src/main/java/net/discordjug/javabot/systems/qotw/jobs/QOTWCloseSubmissionsJob.java index c9597562a..e00b76e88 100644 --- a/src/main/java/net/discordjug/javabot/systems/qotw/jobs/QOTWCloseSubmissionsJob.java +++ b/src/main/java/net/discordjug/javabot/systems/qotw/jobs/QOTWCloseSubmissionsJob.java @@ -5,6 +5,7 @@ import net.discordjug.javabot.data.config.GuildConfig; import net.discordjug.javabot.data.config.SystemsConfig; import net.discordjug.javabot.data.config.guild.QOTWConfig; +import net.discordjug.javabot.systems.help.AnswerOverflowService; import net.discordjug.javabot.systems.notification.NotificationService; import net.discordjug.javabot.systems.qotw.QOTWPointsService; import net.discordjug.javabot.systems.qotw.dao.QuestionQueueRepository; @@ -63,6 +64,7 @@ public class QOTWCloseSubmissionsJob { private final ExecutorService asyncPool; private final QOTWPointsService pointsService; private final NotificationService notificationService; + private final AnswerOverflowService answerOverflowService; private final BotConfig botConfig; /** @@ -131,7 +133,7 @@ private void sendReviewInformation(Guild guild, QOTWConfig qotwConfig, TextChann s.retrieveAuthor(author -> { submission.removeThreadMember(author).queue(); if (author.getIdLong() == qotwConfig.getQotwSampleAnswerUserId()) { - SubmissionManager manager = new SubmissionManager(botConfig.get(guild).getQotwConfig(), pointsService, questionQueueRepository, notificationService, asyncPool); + SubmissionManager manager = new SubmissionManager(botConfig.get(guild).getQotwConfig(), pointsService, questionQueueRepository, notificationService, asyncPool, answerOverflowService); manager.copySampleAnswerSubmission(submission, author); } else { thread diff --git a/src/main/java/net/discordjug/javabot/systems/qotw/jobs/QOTWUserReminderJob.java b/src/main/java/net/discordjug/javabot/systems/qotw/jobs/QOTWUserReminderJob.java index 61bf0227a..d5158f5d1 100644 --- a/src/main/java/net/discordjug/javabot/systems/qotw/jobs/QOTWUserReminderJob.java +++ b/src/main/java/net/discordjug/javabot/systems/qotw/jobs/QOTWUserReminderJob.java @@ -2,6 +2,7 @@ import net.discordjug.javabot.data.config.BotConfig; import net.discordjug.javabot.data.config.guild.QOTWConfig; +import net.discordjug.javabot.systems.help.AnswerOverflowService; import net.discordjug.javabot.systems.notification.NotificationService; import net.discordjug.javabot.systems.qotw.QOTWPointsService; import net.discordjug.javabot.systems.qotw.dao.QuestionQueueRepository; @@ -33,6 +34,7 @@ public class QOTWUserReminderJob { private final QOTWPointsService pointsService; private final NotificationService notificationService; private final QuestionQueueRepository questionQueueRepository; + private final AnswerOverflowService answerOverflowService; private final ExecutorService asyncPool; /** @@ -42,7 +44,7 @@ public class QOTWUserReminderJob { public void execute() { for (Guild guild : jda.getGuilds()) { QOTWConfig config = botConfig.get(guild).getQotwConfig(); - List submissions = new SubmissionManager(config, pointsService, questionQueueRepository, notificationService, asyncPool).getActiveSubmissions(); + List submissions = new SubmissionManager(config, pointsService, questionQueueRepository, notificationService, asyncPool, answerOverflowService).getActiveSubmissions(); for (QOTWSubmission submission : submissions) { submission.retrieveAuthor(author -> { UserPreference preference = userPreferenceService.getOrCreate(author.getIdLong(), Preference.QOTW_REMINDER); diff --git a/src/main/java/net/discordjug/javabot/systems/qotw/submissions/SubmissionInteractionManager.java b/src/main/java/net/discordjug/javabot/systems/qotw/submissions/SubmissionInteractionManager.java index 661102bda..cb633b162 100644 --- a/src/main/java/net/discordjug/javabot/systems/qotw/submissions/SubmissionInteractionManager.java +++ b/src/main/java/net/discordjug/javabot/systems/qotw/submissions/SubmissionInteractionManager.java @@ -8,6 +8,7 @@ import lombok.RequiredArgsConstructor; import net.discordjug.javabot.annotations.AutoDetectableComponentHandler; import net.discordjug.javabot.data.config.BotConfig; +import net.discordjug.javabot.systems.help.AnswerOverflowService; import net.discordjug.javabot.systems.notification.NotificationService; import net.discordjug.javabot.systems.qotw.QOTWPointsService; import net.discordjug.javabot.systems.qotw.dao.QuestionQueueRepository; @@ -29,11 +30,12 @@ public class SubmissionInteractionManager implements ButtonHandler, StringSelect private final NotificationService notificationService; private final BotConfig botConfig; private final QuestionQueueRepository questionQueueRepository; + private final AnswerOverflowService answerOverflowService; private final ExecutorService asyncPool; @Override public void handleButton(@NotNull ButtonInteractionEvent event, Button button) { - SubmissionManager manager = new SubmissionManager(botConfig.get(event.getGuild()).getQotwConfig(), pointsService, questionQueueRepository, notificationService, asyncPool); + SubmissionManager manager = new SubmissionManager(botConfig.get(event.getGuild()).getQotwConfig(), pointsService, questionQueueRepository, notificationService, asyncPool, answerOverflowService); String[] id = ComponentIdBuilder.split(event.getComponentId()); switch (id[1]) { case "submit" -> manager.handleSubmission(event, Integer.parseInt(id[2])).queue(); @@ -43,7 +45,7 @@ public void handleButton(@NotNull ButtonInteractionEvent event, Button button) { @Override public void handleStringSelectMenu(@NotNull StringSelectInteractionEvent event, @NotNull List values) { - SubmissionManager manager = new SubmissionManager(botConfig.get(event.getGuild()).getQotwConfig(), pointsService, questionQueueRepository, notificationService, asyncPool); + SubmissionManager manager = new SubmissionManager(botConfig.get(event.getGuild()).getQotwConfig(), pointsService, questionQueueRepository, notificationService, asyncPool, answerOverflowService); String[] id = ComponentIdBuilder.split(event.getComponentId()); switch (id[1]) { case "review" -> manager.handleSelectReview(event, id[2]); diff --git a/src/main/java/net/discordjug/javabot/systems/qotw/submissions/SubmissionManager.java b/src/main/java/net/discordjug/javabot/systems/qotw/submissions/SubmissionManager.java index 7c48fb749..1d29dd97f 100644 --- a/src/main/java/net/discordjug/javabot/systems/qotw/submissions/SubmissionManager.java +++ b/src/main/java/net/discordjug/javabot/systems/qotw/submissions/SubmissionManager.java @@ -4,6 +4,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import net.discordjug.javabot.data.config.guild.QOTWConfig; +import net.discordjug.javabot.systems.help.AnswerOverflowService; import net.discordjug.javabot.systems.notification.NotificationService; import net.discordjug.javabot.systems.qotw.QOTWPointsService; import net.discordjug.javabot.systems.qotw.dao.QuestionQueueRepository; @@ -39,6 +40,8 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; +import club.minnced.discord.webhook.receive.ReadonlyMessage; + /** * Handles & manages QOTW Submissions by using Discords {@link ThreadChannel}s. */ @@ -57,6 +60,7 @@ public class SubmissionManager { private final QuestionQueueRepository questionQueueRepository; private final NotificationService notificationService; private final ExecutorService asyncPool; + private final AnswerOverflowService answerOverflowService; /** * Handles the "Submit your Answer" Button interaction. @@ -221,14 +225,19 @@ private void sendToQOTWAnswerArchive(ThreadChannel thread, User author, Accepted ThreadChannel newestPost = newestPostOptional.get(); WebhookUtil.ensureWebhookExists(newestPost.getParentChannel().asForumChannel(), wh -> getMessagesByUser(thread, author).thenAccept(messages -> { - for (Message message : messages) { - boolean lastMessage = messages.indexOf(message) + 1 == messages.size(); + for (int i = 0; i < messages.size(); i++) { + Message message = messages.get(i); + boolean lastMessage = i + 1 == messages.size(); if (message.getAuthor().isBot() || message.getType() != MessageType.DEFAULT) continue; + ReadonlyMessage firstPostedMessage; if (message.getContentRaw().length() > 2000) { - WebhookUtil.mirrorMessageToWebhook(wh, message, message.getContentRaw().substring(0, 2000), newestPost.getIdLong(), null, null).join(); + firstPostedMessage = WebhookUtil.mirrorMessageToWebhook(wh, message, message.getContentRaw().substring(0, 2000), newestPost.getIdLong(), null, null).join(); WebhookUtil.mirrorMessageToWebhook(wh, message, message.getContentRaw().substring(2000), newestPost.getIdLong(), null, lastMessage ? List.of(buildAuthorEmbed(author, type)) : null).join(); } else { - WebhookUtil.mirrorMessageToWebhook(wh, message, message.getContentRaw(), newestPost.getIdLong(), null, lastMessage ? List.of(buildAuthorEmbed(author, type)) : null).join(); + firstPostedMessage = WebhookUtil.mirrorMessageToWebhook(wh, message, message.getContentRaw(), newestPost.getIdLong(), null, lastMessage ? List.of(buildAuthorEmbed(author, type)) : null).join(); + } + if (i == 0 && (type == AcceptedAnswerType.BEST_ANSWER || type == AcceptedAnswerType.SAMPLE_ANSWER)) { + answerOverflowService.markAnswer(firstPostedMessage.getChannelId(), firstPostedMessage.getId()); } } }).exceptionally(err->{ diff --git a/src/main/java/net/discordjug/javabot/systems/qotw/submissions/subcommands/QOTWReviewSubcommand.java b/src/main/java/net/discordjug/javabot/systems/qotw/submissions/subcommands/QOTWReviewSubcommand.java index 6d356c76a..37a286024 100644 --- a/src/main/java/net/discordjug/javabot/systems/qotw/submissions/subcommands/QOTWReviewSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/qotw/submissions/subcommands/QOTWReviewSubcommand.java @@ -2,6 +2,7 @@ import net.discordjug.javabot.data.config.BotConfig; import net.discordjug.javabot.data.config.guild.QOTWConfig; +import net.discordjug.javabot.systems.help.AnswerOverflowService; import net.discordjug.javabot.systems.notification.NotificationService; import net.discordjug.javabot.systems.qotw.QOTWPointsService; import net.discordjug.javabot.systems.qotw.dao.QuestionQueueRepository; @@ -34,7 +35,7 @@ public class QOTWReviewSubcommand extends SlashCommand.Subcommand { private final QuestionQueueRepository questionQueueRepository; private final BotConfig botConfig; private final ExecutorService asyncPool; - + private final AnswerOverflowService answerOverflowService; /** * The constructor of this class, which sets the corresponding {@link SubcommandData}. @@ -43,13 +44,15 @@ public class QOTWReviewSubcommand extends SlashCommand.Subcommand { * @param questionQueueRepository The {@link QuestionQueueRepository}. * @param botConfig The main configuration of the bot * @param asyncPool The main thread pool for asynchronous operations + * @param answerOverflowService The {@link AnswerOverflowService} for interacting with the Answer Overflow bot. */ - public QOTWReviewSubcommand(QOTWPointsService pointsService, NotificationService notificationService, QuestionQueueRepository questionQueueRepository, BotConfig botConfig, ExecutorService asyncPool) { + public QOTWReviewSubcommand(QOTWPointsService pointsService, NotificationService notificationService, QuestionQueueRepository questionQueueRepository, BotConfig botConfig, ExecutorService asyncPool, AnswerOverflowService answerOverflowService) { this.pointsService = pointsService; this.notificationService = notificationService; this.questionQueueRepository = questionQueueRepository; this.botConfig = botConfig; this.asyncPool = asyncPool; + this.answerOverflowService = answerOverflowService; setCommandData(new SubcommandData("review", "Administrative command for reviewing QOTW-submissions") .addOptions( new OptionData(OptionType.CHANNEL, "submission", "A users' submission", true) @@ -80,7 +83,7 @@ public void execute(@NotNull SlashCommandInteractionEvent event) { event.deferReply().queue(); QOTWSubmission submission = new QOTWSubmission(submissionThread); submission.retrieveAuthor(author -> { - SubmissionManager manager = new SubmissionManager(qotwConfig, pointsService, questionQueueRepository, notificationService, asyncPool); + SubmissionManager manager = new SubmissionManager(qotwConfig, pointsService, questionQueueRepository, notificationService, asyncPool, answerOverflowService); if (state.contains("ACCEPT")) { manager.acceptSubmission(submissionThread, author, event.getMember(), state.equals("ACCEPT_BEST")); Responses.success(event.getHook(), "Submission Accepted", "Successfully accepted submission by " + author.getAsMention()).queue();