diff --git a/CLA.md b/CLA.md index c7a4c71973..e23df7e967 100644 --- a/CLA.md +++ b/CLA.md @@ -115,8 +115,6 @@ IF THE DISCLAIMER AND DAMAGE WAIVER MENTIONED IN SECTION 5. AND SECTION 6. CANNO ### Us -Name: Daniel Tischner (aka Zabuzard, acting on behalf of Together Java) - Organization: https://github.com/Together-Java Contact: https://discord.com/invite/XXFUXzK diff --git a/application/build.gradle b/application/build.gradle index f701c01246..280d482687 100644 --- a/application/build.gradle +++ b/application/build.gradle @@ -7,7 +7,7 @@ buildscript { plugins { id 'application' id 'com.google.cloud.tools.jib' version '3.5.0' - id 'com.gradleup.shadow' version '9.2.2' + id 'com.gradleup.shadow' version '9.3.0' id 'database-settings' } @@ -57,7 +57,7 @@ dependencies { implementation 'io.mikael:urlbuilder:2.0.9' - implementation 'org.jsoup:jsoup:1.21.1' + implementation 'org.jsoup:jsoup:1.22.1' implementation 'org.scilab.forge:jlatexmath:1.0.7' implementation 'org.scilab.forge:jlatexmath-font-greek:1.0.7' @@ -69,7 +69,7 @@ dependencies { implementation "com.fasterxml.jackson.core:jackson-databind:$jacksonVersion" implementation "com.sigpwned:jackson-modules-java17-sealed-classes:2.19.0.0" - implementation 'com.github.freva:ascii-table:1.8.0' + implementation 'com.github.freva:ascii-table:1.9.0' implementation 'io.github.url-detector:url-detector:0.1.23' @@ -77,17 +77,16 @@ dependencies { implementation 'org.kohsuke:github-api:1.329' - implementation 'org.apache.commons:commons-text:1.14.0' - implementation 'com.apptasticsoftware:rssreader:3.11.0' + implementation 'org.apache.commons:commons-text:1.15.0' + implementation 'com.apptasticsoftware:rssreader:3.12.0' - testImplementation 'org.mockito:mockito-core:5.20.0' + testImplementation 'org.mockito:mockito-core:5.21.0' testImplementation "org.junit.jupiter:junit-jupiter-api:$junitVersion" testImplementation "org.junit.jupiter:junit-jupiter-params:$junitVersion" testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junitVersion" - implementation "com.theokanning.openai-gpt3-java:api:$chatGPTVersion" - implementation "com.theokanning.openai-gpt3-java:service:$chatGPTVersion" + implementation "com.openai:openai-java:$chatGPTVersion" } application { diff --git a/application/config.json.template b/application/config.json.template index 5cfe9ac38e..a950170fb4 100644 --- a/application/config.json.template +++ b/application/config.json.template @@ -194,6 +194,11 @@ "videoLinkPattern": "http(s)?://www\\.youtube.com.*", "pollIntervalInMinutes": 10 }, + "quoteBoardConfig": { + "minimumReactionsToTrigger": 5, + "channel": "quotes", + "reactionEmoji": "⭐" + }, "memberCountCategoryPattern": "Info", "topHelpers": { "rolePattern": "Top Helper.*", 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 60e6622cbc..a1ee80363d 100644 --- a/application/src/main/java/org/togetherjava/tjbot/config/Config.java +++ b/application/src/main/java/org/togetherjava/tjbot/config/Config.java @@ -48,6 +48,7 @@ public final class Config { private final RSSFeedsConfig rssFeedsConfig; private final String selectRolesChannelPattern; private final String memberCountCategoryPattern; + private final QuoteBoardConfig quoteBoardConfig; private final TopHelpersConfig topHelpers; @SuppressWarnings("ConstructorWithTooManyParameters") @@ -102,6 +103,8 @@ private Config(@JsonProperty(value = "token", required = true) String token, @JsonProperty(value = "rssConfig", required = true) RSSFeedsConfig rssFeedsConfig, @JsonProperty(value = "selectRolesChannelPattern", required = true) String selectRolesChannelPattern, + @JsonProperty(value = "quoteBoardConfig", + required = true) QuoteBoardConfig quoteBoardConfig, @JsonProperty(value = "topHelpers", required = true) TopHelpersConfig topHelpers) { this.token = Objects.requireNonNull(token); this.githubApiKey = Objects.requireNonNull(githubApiKey); @@ -137,6 +140,7 @@ private Config(@JsonProperty(value = "token", required = true) String token, this.featureBlacklistConfig = Objects.requireNonNull(featureBlacklistConfig); this.rssFeedsConfig = Objects.requireNonNull(rssFeedsConfig); this.selectRolesChannelPattern = Objects.requireNonNull(selectRolesChannelPattern); + this.quoteBoardConfig = Objects.requireNonNull(quoteBoardConfig); this.topHelpers = Objects.requireNonNull(topHelpers); } @@ -431,6 +435,18 @@ public String getSelectRolesChannelPattern() { return selectRolesChannelPattern; } + /** + * The configuration of the quote messages config. + * + *

+ * >The configuration of the quote board feature. Quotes user selected messages. + * + * @return configuration of quote messages config + */ + public QuoteBoardConfig getQuoteBoardConfig() { + return quoteBoardConfig; + } + /** * Gets the pattern matching the category that is used to display the total member count. * diff --git a/application/src/main/java/org/togetherjava/tjbot/config/QuoteBoardConfig.java b/application/src/main/java/org/togetherjava/tjbot/config/QuoteBoardConfig.java new file mode 100644 index 0000000000..faf756b4a8 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/config/QuoteBoardConfig.java @@ -0,0 +1,43 @@ +package org.togetherjava.tjbot.config; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonRootName; +import org.apache.logging.log4j.LogManager; + +import org.togetherjava.tjbot.features.basic.QuoteBoardForwarder; + +import java.util.Objects; + +/** + * Configuration for the quote board feature, see {@link QuoteBoardForwarder}. + */ +@JsonRootName("quoteBoardConfig") +public record QuoteBoardConfig( + @JsonProperty(value = "minimumReactionsToTrigger", required = true) int minimumReactions, + @JsonProperty(required = true) String channel, + @JsonProperty(value = "reactionEmoji", required = true) String reactionEmoji) { + + /** + * Creates a QuoteBoardConfig. + * + * @param minimumReactions the minimum amount of reactions + * @param channel the pattern for the board channel + * @param reactionEmoji the emoji with which users should react to + */ + public QuoteBoardConfig { + if (minimumReactions <= 0) { + throw new IllegalArgumentException("minimumReactions must be greater than zero"); + } + Objects.requireNonNull(channel); + if (channel.isBlank()) { + throw new IllegalArgumentException("channel must not be empty or blank"); + } + Objects.requireNonNull(reactionEmoji); + if (reactionEmoji.isBlank()) { + throw new IllegalArgumentException("reactionEmoji must not be empty or blank"); + } + LogManager.getLogger(QuoteBoardConfig.class) + .debug("Quote-Board configs loaded: minimumReactions={}, channel='{}', reactionEmoji='{}'", + minimumReactions, channel, reactionEmoji); + } +} 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 463c3b5248..64bf86c166 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/Features.java @@ -8,6 +8,7 @@ import org.togetherjava.tjbot.db.Database; import org.togetherjava.tjbot.features.basic.MemberCountDisplayRoutine; import org.togetherjava.tjbot.features.basic.PingCommand; +import org.togetherjava.tjbot.features.basic.QuoteBoardForwarder; import org.togetherjava.tjbot.features.basic.RoleSelectCommand; import org.togetherjava.tjbot.features.basic.SlashCommandEducator; import org.togetherjava.tjbot.features.basic.SuggestionsUpDownVoter; @@ -39,6 +40,7 @@ import org.togetherjava.tjbot.features.mathcommands.TeXCommand; import org.togetherjava.tjbot.features.mathcommands.wolframalpha.WolframAlphaCommand; import org.togetherjava.tjbot.features.mediaonly.MediaOnlyChannelListener; +import org.togetherjava.tjbot.features.messages.MessageCommand; import org.togetherjava.tjbot.features.moderation.BanCommand; import org.togetherjava.tjbot.features.moderation.KickCommand; import org.togetherjava.tjbot.features.moderation.ModerationActionsStore; @@ -160,6 +162,7 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(new CodeMessageManualDetection(codeMessageHandler)); features.add(new SlashCommandEducator()); features.add(new PinnedNotificationRemover(config)); + features.add(new QuoteBoardForwarder(config)); // Event receivers features.add(new RejoinModerationRoleListener(actionsStore, config)); @@ -203,6 +206,7 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(new BookmarksCommand(bookmarksSystem)); features.add(new ChatGptCommand(chatGptService, helpSystemHelper)); features.add(new JShellCommand(jshellEval)); + features.add(new MessageCommand()); FeatureBlacklist> blacklist = blacklistConfig.normal(); return blacklist.filterStream(features.stream(), Object::getClass).toList(); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/MessageReceiver.java b/application/src/main/java/org/togetherjava/tjbot/features/MessageReceiver.java index c5b6358434..18a1adb023 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/MessageReceiver.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/MessageReceiver.java @@ -3,6 +3,7 @@ import net.dv8tion.jda.api.events.message.MessageDeleteEvent; import net.dv8tion.jda.api.events.message.MessageReceivedEvent; import net.dv8tion.jda.api.events.message.MessageUpdateEvent; +import net.dv8tion.jda.api.events.message.react.MessageReactionAddEvent; import java.util.regex.Pattern; @@ -56,4 +57,13 @@ public interface MessageReceiver extends Feature { * message that was deleted */ void onMessageDeleted(MessageDeleteEvent event); + + /** + * Triggered by the core system whenever a new reaction was added to a message in a text channel + * of a guild the bot has been added to. + * + * @param event the event that triggered this, containing information about the corresponding + * reaction that was added + */ + void onMessageReactionAdd(MessageReactionAddEvent event); } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/MessageReceiverAdapter.java b/application/src/main/java/org/togetherjava/tjbot/features/MessageReceiverAdapter.java index 05280c97ab..6ceee951b9 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/MessageReceiverAdapter.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/MessageReceiverAdapter.java @@ -3,6 +3,7 @@ import net.dv8tion.jda.api.events.message.MessageDeleteEvent; import net.dv8tion.jda.api.events.message.MessageReceivedEvent; import net.dv8tion.jda.api.events.message.MessageUpdateEvent; +import net.dv8tion.jda.api.events.message.react.MessageReactionAddEvent; import java.util.regex.Pattern; @@ -57,4 +58,10 @@ public void onMessageUpdated(MessageUpdateEvent event) { public void onMessageDeleted(MessageDeleteEvent event) { // Adapter does not react by default, subclasses may change this behavior } + + @SuppressWarnings("NoopMethodInAbstractClass") + @Override + public void onMessageReactionAdd(MessageReactionAddEvent event) { + // Adapter does not react by default, subclasses may change this behavior + } } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/VoiceReceiver.java b/application/src/main/java/org/togetherjava/tjbot/features/VoiceReceiver.java new file mode 100644 index 0000000000..170dabd4fe --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/VoiceReceiver.java @@ -0,0 +1,69 @@ +package org.togetherjava.tjbot.features; + +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceDeafenEvent; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceMuteEvent; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceStreamEvent; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceUpdateEvent; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceVideoEvent; + +import java.util.regex.Pattern; + +/** + * Receives incoming Discord guild events from voice channels matching a given pattern. + *

+ * All voice receivers have to implement this interface. For convenience, there is a + * {@link VoiceReceiverAdapter} available that implemented most methods already. A new receiver can + * then be registered by adding it to {@link Features}. + *

+ *

+ * After registration, the system will notify a receiver whenever a new event was sent or an + * existing event was updated in any channel matching the {@link #getChannelNamePattern()} the bot + * is added to. + */ +public interface VoiceReceiver extends Feature { + /** + * Retrieves the pattern matching the names of channels of which this receiver is interested in + * receiving events from. Called by the core system once during the startup in order to register + * the receiver accordingly. + *

+ * Changes on the pattern returned by this method afterwards will not be picked up. + * + * @return the pattern matching the names of relevant channels + */ + Pattern getChannelNamePattern(); + + /** + * Triggered by the core system whenever a member joined, left or moved voice channels. + * + * @param event the event that triggered this + */ + void onVoiceUpdate(GuildVoiceUpdateEvent event); + + /** + * Triggered by the core system whenever a member toggled their camera in a voice channel. + * + * @param event the event that triggered this + */ + void onVideoToggle(GuildVoiceVideoEvent event); + + /** + * Triggered by the core system whenever a member started or stopped a stream. + * + * @param event the event that triggered this + */ + void onStreamToggle(GuildVoiceStreamEvent event); + + /** + * Triggered by the core system whenever a member toggled their mute status. + * + * @param event the event that triggered this + */ + void onMuteToggle(GuildVoiceMuteEvent event); + + /** + * Triggered by the core system whenever a member toggled their deafened status. + * + * @param event the event that triggered this + */ + void onDeafenToggle(GuildVoiceDeafenEvent event); +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/VoiceReceiverAdapter.java b/application/src/main/java/org/togetherjava/tjbot/features/VoiceReceiverAdapter.java new file mode 100644 index 0000000000..f4f86aa262 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/VoiceReceiverAdapter.java @@ -0,0 +1,59 @@ +package org.togetherjava.tjbot.features; + +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceDeafenEvent; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceMuteEvent; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceStreamEvent; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceUpdateEvent; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceVideoEvent; + +import java.util.regex.Pattern; + +/** + * Adapter implementation of a {@link VoiceReceiver}. A new receiver can then be registered by + * adding it to {@link Features}. + *

+ * {@link #onVoiceUpdate(GuildVoiceUpdateEvent)} like the other provided methods can be overridden + * if desired. The default implementation is empty, the adapter will not react to such events. + */ +public class VoiceReceiverAdapter implements VoiceReceiver { + + private final Pattern channelNamePattern; + + protected VoiceReceiverAdapter() { + this(Pattern.compile(".*")); + } + + protected VoiceReceiverAdapter(Pattern channelNamePattern) { + this.channelNamePattern = channelNamePattern; + } + + @Override + public Pattern getChannelNamePattern() { + return channelNamePattern; + } + + @Override + public void onVoiceUpdate(GuildVoiceUpdateEvent event) { + // Adapter does not react by default, subclasses may change this behavior + } + + @Override + public void onVideoToggle(GuildVoiceVideoEvent event) { + // Adapter does not react by default, subclasses may change this behavior + } + + @Override + public void onStreamToggle(GuildVoiceStreamEvent event) { + // Adapter does not react by default, subclasses may change this behavior + } + + @Override + public void onMuteToggle(GuildVoiceMuteEvent event) { + // Adapter does not react by default, subclasses may change this behavior + } + + @Override + public void onDeafenToggle(GuildVoiceDeafenEvent event) { + // Adapter does not react by default, subclasses may change this behavior + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/basic/QuoteBoardForwarder.java b/application/src/main/java/org/togetherjava/tjbot/features/basic/QuoteBoardForwarder.java new file mode 100644 index 0000000000..22ce51f43f --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/basic/QuoteBoardForwarder.java @@ -0,0 +1,151 @@ +package org.togetherjava.tjbot.features.basic; + +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.MessageReaction; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import net.dv8tion.jda.api.entities.emoji.Emoji; +import net.dv8tion.jda.api.events.message.react.MessageReactionAddEvent; +import net.dv8tion.jda.api.requests.RestAction; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.togetherjava.tjbot.config.Config; +import org.togetherjava.tjbot.config.QuoteBoardConfig; +import org.togetherjava.tjbot.features.MessageReceiverAdapter; + +import java.util.List; +import java.util.Optional; +import java.util.function.Predicate; +import java.util.regex.Pattern; + +/** + * Listens for reaction-add events and turns popular messages into "quotes". + *

+ * When someone reacts to a message with the configured emoji, the listener counts how many users + * have used that same emoji. If the total meets or exceeds the minimum threshold and the bot has + * not processed the message before, it copies (forwards) the message to the first text channel + * whose name matches the configured quote-board pattern, then reacts to the original message itself + * to mark it as handled (and to not let people spam react a message and give a way to the bot to + * know that a message has been quoted before). + *

+ * Key points: - Trigger emoji, minimum vote count and quote-board channel pattern are supplied via + * {@code QuoteBoardConfig}. + */ +public final class QuoteBoardForwarder extends MessageReceiverAdapter { + + private static final Logger logger = LoggerFactory.getLogger(QuoteBoardForwarder.class); + private final Emoji triggerReaction; + private final Predicate isQuoteBoardChannelName; + private final QuoteBoardConfig config; + + /** + * Constructs a new instance of QuoteBoardForwarder. + * + * @param config the configuration containing settings specific to the cool messages board, + * including the reaction emoji and the pattern to match board channel names + */ + public QuoteBoardForwarder(Config config) { + this.config = config.getQuoteBoardConfig(); + this.triggerReaction = Emoji.fromUnicode(this.config.reactionEmoji()); + + this.isQuoteBoardChannelName = Pattern.compile(this.config.channel()).asMatchPredicate(); + } + + @Override + public void onMessageReactionAdd(MessageReactionAddEvent event) { + logger.debug("Received MessageReactionAddEvent: messageId={}, channelId={}, userId={}", + event.getMessageId(), event.getChannel().getId(), event.getUserId()); + + final MessageReaction messageReaction = event.getReaction(); + + if (!messageReaction.getEmoji().equals(triggerReaction)) { + logger.debug("Reaction emoji '{}' does not match trigger emoji '{}'. Ignoring.", + messageReaction.getEmoji(), triggerReaction); + return; + } + + if (hasAlreadyForwardedMessage(event.getJDA(), messageReaction)) { + logger.debug("Message has already been forwarded by the bot. Skipping."); + return; + } + + long reactionCount = messageReaction.retrieveUsers().stream().count(); + if (reactionCount < config.minimumReactions()) { + logger.debug("Reaction count {} is less than minimum required {}. Skipping.", + reactionCount, config.minimumReactions()); + return; + } + + final long guildId = event.getGuild().getIdLong(); + + Optional boardChannel = findQuoteBoardChannel(event.getJDA(), guildId); + + if (boardChannel.isEmpty()) { + logger.warn( + "Could not find board channel with pattern '{}' in server with ID '{}'. Skipping reaction handling...", + this.config.channel(), guildId); + return; + } + + logger.debug("Forwarding message to quote board channel: {}", boardChannel.get().getName()); + + event.retrieveMessage() + .queue(message -> markAsProcessed(message) + .flatMap(v -> message.forwardTo(boardChannel.orElseThrow())) + .queue(_ -> logger.debug("Message forwarded to quote board channel: {}", + boardChannel.get().getName())), + + e -> logger.warn( + "Unknown error while attempting to retrieve and forward message for quote-board, message is ignored.", + e)); + + } + + private RestAction markAsProcessed(Message message) { + return message.addReaction(triggerReaction); + } + + /** + * Gets the board text channel where the quotes go to, wrapped in an optional. + * + * @param jda the JDA + * @param guildId the guild ID + * @return the board text channel + */ + private Optional findQuoteBoardChannel(JDA jda, long guildId) { + Guild guild = jda.getGuildById(guildId); + + if (guild == null) { + throw new IllegalStateException( + String.format("Guild with ID '%d' not found.", guildId)); + } + + List matchingChannels = guild.getTextChannelCache() + .stream() + .filter(channel -> isQuoteBoardChannelName.test(channel.getName())) + .toList(); + + if (matchingChannels.size() > 1) { + logger.warn( + "Multiple quote board channels found matching pattern '{}' in guild with ID '{}'. Selecting the first one anyway.", + this.config.channel(), guildId); + } + + return matchingChannels.stream().findFirst(); + } + + /** + * Checks a {@link MessageReaction} to see if the bot has reacted to it. + */ + private boolean hasAlreadyForwardedMessage(JDA jda, MessageReaction messageReaction) { + if (!triggerReaction.equals(messageReaction.getEmoji())) { + return false; + } + + return messageReaction.retrieveUsers() + .parallelStream() + .anyMatch(user -> jda.getSelfUser().getIdLong() == user.getIdLong()); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/ChatGptCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/ChatGptCommand.java index 163220d8a5..1f9b3208fb 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/ChatGptCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/ChatGptCommand.java @@ -25,6 +25,7 @@ * which it will respond with an AI generated answer. */ public final class ChatGptCommand extends SlashCommandAdapter { + private static final ChatGptModel CHAT_GPT_MODEL = ChatGptModel.HIGH_QUALITY; public static final String COMMAND_NAME = "chatgpt"; private static final String QUESTION_INPUT = "question"; private static final int MAX_MESSAGE_INPUT_LENGTH = 200; @@ -82,8 +83,8 @@ public void onModalSubmitted(ModalInteractionEvent event, List args) { String question = event.getValue(QUESTION_INPUT).getAsString(); - Optional chatgptResponse = - chatGptService.ask(question, "You may use markdown syntax for the response"); + Optional chatgptResponse = chatGptService.ask(question, + "You may use markdown syntax for the response", CHAT_GPT_MODEL); if (chatgptResponse.isPresent()) { userIdToAskedAtCache.put(event.getMember().getId(), Instant.now()); } @@ -96,7 +97,8 @@ public void onModalSubmitted(ModalInteractionEvent event, List args) { String response = chatgptResponse.orElse(errorResponse); SelfUser selfUser = event.getJDA().getSelfUser(); - MessageEmbed responseEmbed = helper.generateGptResponseEmbed(response, selfUser, question); + MessageEmbed responseEmbed = + helper.generateGptResponseEmbed(response, selfUser, question, CHAT_GPT_MODEL); event.getHook().sendMessageEmbeds(responseEmbed).queue(); } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/ChatGptModel.java b/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/ChatGptModel.java new file mode 100644 index 0000000000..e08951f4b3 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/ChatGptModel.java @@ -0,0 +1,45 @@ +package org.togetherjava.tjbot.features.chatgpt; + +import com.openai.models.ChatModel; + +/** + * Logical abstraction over OpenAI chat models. + *

+ * This enum allows the application to select models based on performance/quality intent rather than + * hard-coding specific OpenAI model versions throughout the codebase. + * + */ +public enum ChatGptModel { + /** + * Fastest response time with the lowest computational cost. + */ + FASTEST(ChatModel.GPT_3_5_TURBO), + + /** + * Balanced option between speed and quality. + */ + FAST(ChatModel.GPT_4_1_MINI), + + /** + * Highest quality responses with increased reasoning capability. + */ + HIGH_QUALITY(ChatModel.GPT_5_MINI); + + private final ChatModel chatModel; + + ChatGptModel(ChatModel chatModel) { + this.chatModel = chatModel; + } + + /** + * @return the underlying OpenAI model used by this enum. + */ + public ChatModel toChatModel() { + return chatModel; + } + + @Override + public String toString() { + return chatModel.toString(); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/ChatGptService.java b/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/ChatGptService.java index a6fdcbcb9d..02e32cde6e 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/ChatGptService.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/ChatGptService.java @@ -1,10 +1,10 @@ package org.togetherjava.tjbot.features.chatgpt; -import com.theokanning.openai.OpenAiHttpException; -import com.theokanning.openai.completion.chat.ChatCompletionRequest; -import com.theokanning.openai.completion.chat.ChatMessage; -import com.theokanning.openai.completion.chat.ChatMessageRole; -import com.theokanning.openai.service.OpenAiService; +import com.openai.client.OpenAIClient; +import com.openai.client.okhttp.OpenAIOkHttpClient; +import com.openai.models.responses.Response; +import com.openai.models.responses.ResponseCreateParams; +import com.openai.models.responses.ResponseOutputText; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -13,8 +13,8 @@ import javax.annotation.Nullable; import java.time.Duration; -import java.util.List; import java.util.Optional; +import java.util.stream.Collectors; /** * Service used to communicate to OpenAI API to generate responses. @@ -26,30 +26,8 @@ public class ChatGptService { /** The maximum number of tokens allowed for the generated answer. */ private static final int MAX_TOKENS = 3_000; - /** - * This parameter reduces the likelihood of the AI repeating itself. A higher frequency penalty - * makes the model less likely to repeat the same lines verbatim. It helps in generating more - * diverse and varied responses. - */ - private static final double FREQUENCY_PENALTY = 0.5; - - /** - * This parameter controls the randomness of the AI's responses. A higher temperature results in - * more varied, unpredictable, and creative responses. Conversely, a lower temperature makes the - * model's responses more deterministic and conservative. - */ - private static final double TEMPERATURE = 0.8; - - /** - * n: This parameter specifies the number of responses to generate for each prompt. If n is more - * than 1, the AI will generate multiple different responses to the same prompt, each one being - * a separate iteration based on the input. - */ - private static final int MAX_NUMBER_OF_RESPONSES = 1; - private static final String AI_MODEL = "gpt-3.5-turbo"; - private boolean isDisabled = false; - private OpenAiService openAiService; + private OpenAIClient openAIClient; /** * Creates instance of ChatGPTService @@ -63,23 +41,7 @@ public ChatGptService(Config config) { isDisabled = true; return; } - - openAiService = new OpenAiService(apiKey, TIMEOUT); - - ChatMessage setupMessage = new ChatMessage(ChatMessageRole.SYSTEM.value(), """ - For code supplied for review, refer to the old code supplied rather than - rewriting the code. DON'T supply a corrected version of the code.\s"""); - ChatCompletionRequest systemSetupRequest = ChatCompletionRequest.builder() - .model(AI_MODEL) - .messages(List.of(setupMessage)) - .frequencyPenalty(FREQUENCY_PENALTY) - .temperature(TEMPERATURE) - .maxTokens(50) - .n(MAX_NUMBER_OF_RESPONSES) - .build(); - - // Sending the system setup message to ChatGPT. - openAiService.createChatCompletion(systemSetupRequest); + openAIClient = OpenAIOkHttpClient.builder().apiKey(apiKey).timeout(TIMEOUT).build(); } /** @@ -88,42 +50,46 @@ public ChatGptService(Config config) { * @param question The question being asked of ChatGPT. Max is {@value MAX_TOKENS} tokens. * @param context The category of asked question, to set the context(eg. Java, Database, Other * etc). + * @param chatModel The AI model to use for this request. * @return response from ChatGPT as a String. * @see ChatGPT * Tokens. */ - public Optional ask(String question, @Nullable String context) { + public Optional ask(String question, @Nullable String context, ChatGptModel chatModel) { if (isDisabled) { return Optional.empty(); } String contextText = context == null ? "" : ", Context: %s.".formatted(context); - String fullQuestion = "(KEEP IT CONCISE, NOT MORE THAN 280 WORDS%s) - %s" - .formatted(contextText, question); - - ChatMessage chatMessage = new ChatMessage(ChatMessageRole.USER.value(), fullQuestion); - ChatCompletionRequest chatCompletionRequest = ChatCompletionRequest.builder() - .model(AI_MODEL) - .messages(List.of(chatMessage)) - .frequencyPenalty(FREQUENCY_PENALTY) - .temperature(TEMPERATURE) - .maxTokens(MAX_TOKENS) - .n(MAX_NUMBER_OF_RESPONSES) - .build(); - logger.debug("ChatGpt Request: {}", fullQuestion); + String inputPrompt = """ + For code supplied for review, refer to the old code supplied rather than + rewriting the code. DON'T supply a corrected version of the code. + + KEEP IT CONCISE, NOT MORE THAN 280 WORDS + + %s + Question: %s + """.formatted(contextText, question); + + logger.debug("ChatGpt request: {}", inputPrompt); String response = null; try { - response = openAiService.createChatCompletion(chatCompletionRequest) - .getChoices() - .getFirst() - .getMessage() - .getContent(); - } catch (OpenAiHttpException openAiHttpException) { - logger.warn( - "There was an error using the OpenAI API: {} Code: {} Type: {} Status Code: {}", - openAiHttpException.getMessage(), openAiHttpException.code, - openAiHttpException.type, openAiHttpException.statusCode); + ResponseCreateParams params = ResponseCreateParams.builder() + .model(chatModel.toChatModel()) + .input(inputPrompt) + .maxOutputTokens(MAX_TOKENS) + .build(); + + Response chatGptResponse = openAIClient.responses().create(params); + + response = chatGptResponse.output() + .stream() + .flatMap(item -> item.message().stream()) + .flatMap(message -> message.content().stream()) + .flatMap(content -> content.outputText().stream()) + .map(ResponseOutputText::text) + .collect(Collectors.joining("\n")); } catch (RuntimeException runtimeException) { logger.warn("There was an error using the OpenAI API: {}", runtimeException.getMessage()); 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 dbb6ed55e2..edf217f1ea 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 @@ -25,6 +25,7 @@ import org.togetherjava.tjbot.db.generated.tables.HelpThreads; import org.togetherjava.tjbot.db.generated.tables.records.HelpThreadsRecord; import org.togetherjava.tjbot.features.chatgpt.ChatGptCommand; +import org.togetherjava.tjbot.features.chatgpt.ChatGptModel; import org.togetherjava.tjbot.features.chatgpt.ChatGptService; import org.togetherjava.tjbot.features.componentids.ComponentIdInteractor; import org.togetherjava.tjbot.features.utils.Guilds; @@ -55,6 +56,7 @@ */ public final class HelpSystemHelper { private static final Logger logger = LoggerFactory.getLogger(HelpSystemHelper.class); + private static final ChatGptModel CHAT_GPT_MODEL = ChatGptModel.FAST; static final Color AMBIENT_COLOR = new Color(255, 255, 165); @@ -143,7 +145,7 @@ RestAction constructChatGptAttempt(ThreadChannel threadChannel, String context = "Category %s on a Java Q&A discord server. You may use markdown syntax for the response" .formatted(matchingTag.getName()); - chatGptAnswer = chatGptService.ask(question, context); + chatGptAnswer = chatGptService.ask(question, context, CHAT_GPT_MODEL); if (chatGptAnswer.isEmpty()) { return useChatGptFallbackMessage(threadChannel); @@ -168,7 +170,8 @@ RestAction constructChatGptAttempt(ThreadChannel threadChannel, answer = answer.substring(0, responseCharLimit); } - MessageEmbed responseEmbed = generateGptResponseEmbed(answer, selfUser, originalQuestion); + MessageEmbed responseEmbed = + generateGptResponseEmbed(answer, selfUser, originalQuestion, CHAT_GPT_MODEL); return post.flatMap(_ -> threadChannel.sendMessageEmbeds(responseEmbed) .addActionRow(generateDismissButton(componentIdInteractor, messageId.get()))); } @@ -178,11 +181,13 @@ RestAction constructChatGptAttempt(ThreadChannel threadChannel, * * @param answer The response text generated by AI. * @param selfUser The SelfUser representing the bot. - * @param title The title for the MessageEmbed. + * @param title The title for the MessageEmbed + * @param model The AI model that was used for the foot notes * @return A MessageEmbed that contains response generated by AI. */ - public MessageEmbed generateGptResponseEmbed(String answer, SelfUser selfUser, String title) { - String responseByGptFooter = "- AI generated response"; + public MessageEmbed generateGptResponseEmbed(String answer, SelfUser selfUser, String title, + ChatGptModel model) { + String responseByGptFooter = "- AI generated response using %s model".formatted(model); int embedTitleLimit = MessageEmbed.TITLE_MAX_LENGTH; String capitalizedTitle = Character.toUpperCase(title.charAt(0)) + title.substring(1); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/messages/MessageCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/messages/MessageCommand.java new file mode 100644 index 0000000000..ce36653b36 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/messages/MessageCommand.java @@ -0,0 +1,392 @@ +package org.togetherjava.tjbot.features.messages; + +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.channel.ChannelType; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import net.dv8tion.jda.api.entities.channel.unions.GuildChannelUnion; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.exceptions.ErrorResponseException; +import net.dv8tion.jda.api.interactions.callbacks.IDeferrableCallback; +import net.dv8tion.jda.api.interactions.callbacks.IReplyCallback; +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 net.dv8tion.jda.api.utils.FileUpload; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.togetherjava.tjbot.features.CommandVisibility; +import org.togetherjava.tjbot.features.SlashCommandAdapter; + +import java.awt.Color; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.OptionalLong; + +/** + * Implements the {@code /message} command, which offers utility dealing with messages. Available + * subcommands are: + *

+ */ +public final class MessageCommand extends SlashCommandAdapter { + private static final Logger logger = LoggerFactory.getLogger(MessageCommand.class); + static final String CONTENT_MESSAGE_ID_OPTION = "content-message-id"; + private static final String CONTENT_MESSAGE_ID_DESCRIPTION = + "the id of the message to read content from, must be in the channel this command is invoked"; + static final String SRC_CHANNEL_OPTION = "source"; + private static final String EDIT_SRC_CHANNEL_DESCRIPTION = "where to find the message to edit"; + static final String DEST_CHANNEL_OPTION = "destination"; + private static final String DEST_CHANNEL_DESCRIPTION = "where to post the message"; + static final String CONTENT_OPTION = "content"; + private static final String CONTENT_DESCRIPTION = "the content of the message"; + static final String EDIT_MESSAGE_ID_OPTION = "edit-message-id"; + private static final String EDIT_MESSAGE_ID_DESCRIPTION = "the id of the message to edit"; + + private static final Color AMBIENT_COLOR = new Color(24, 109, 221, 255); + + private static final String CONTENT_FILE_NAME = "content.md"; + + /** + * Creates a new instance. + */ + public MessageCommand() { + super("message", "Provides commands to work with messages", CommandVisibility.GUILD); + + SubcommandData raw = new SubcommandData(Subcommand.RAW.name, + "View the raw content of a message, without Discord interpreting any of its content") + .addOption(OptionType.CHANNEL, SRC_CHANNEL_OPTION, + "where to find the message to retrieve content from", true) + .addOption(OptionType.STRING, CONTENT_MESSAGE_ID_OPTION, + "the id of the message to read content from", true); + + SubcommandData post = + new SubcommandData(Subcommand.POST.name, "Let this bot post a message") + .addOption(OptionType.CHANNEL, DEST_CHANNEL_OPTION, DEST_CHANNEL_DESCRIPTION, + true) + .addOption(OptionType.STRING, CONTENT_OPTION, CONTENT_DESCRIPTION, true); + SubcommandData postWithMessage = new SubcommandData(Subcommand.POST_WITH_MESSAGE.name, + "Let this bot post a message. Content is retrieved from the given message.") + .addOption(OptionType.CHANNEL, DEST_CHANNEL_OPTION, DEST_CHANNEL_DESCRIPTION, true) + .addOption(OptionType.STRING, CONTENT_MESSAGE_ID_OPTION, CONTENT_MESSAGE_ID_DESCRIPTION, + true); + + SubcommandData edit = new SubcommandData(Subcommand.EDIT.name, + "Edits a message posted by this bot, the old content is replaced") + .addOption(OptionType.CHANNEL, SRC_CHANNEL_OPTION, EDIT_SRC_CHANNEL_DESCRIPTION, true) + .addOption(OptionType.STRING, EDIT_MESSAGE_ID_OPTION, EDIT_MESSAGE_ID_DESCRIPTION, true) + .addOption(OptionType.STRING, CONTENT_OPTION, CONTENT_DESCRIPTION, true); + SubcommandData editWithMessage = new SubcommandData(Subcommand.EDIT_WITH_MESSAGE.name, + "Edits a message posted by this bot. Content is retrieved from the given message.") + .addOption(OptionType.CHANNEL, SRC_CHANNEL_OPTION, EDIT_SRC_CHANNEL_DESCRIPTION, true) + .addOption(OptionType.STRING, EDIT_MESSAGE_ID_OPTION, EDIT_MESSAGE_ID_DESCRIPTION, true) + .addOption(OptionType.STRING, CONTENT_MESSAGE_ID_OPTION, CONTENT_MESSAGE_ID_DESCRIPTION, + true); + + getData().addSubcommands(raw, post, postWithMessage, edit, editWithMessage); + } + + /** + * Attempts to convert the given channel into a {@link TextChannel}. + *

+ * If the channel is not a text channel, an error message is send to the user. + * + * @param channel the channel to convert + * @param event the event to send messages with + * @return the channel as text channel, if successful + */ + private static Optional handleExpectMessageChannel(GuildChannelUnion channel, + IReplyCallback event) { + if (channel.getType() != ChannelType.TEXT) { + event + .reply("The given channel ('%s') is not a text-channel." + .formatted(channel.getName())) + .setEphemeral(true) + .queue(); + return Optional.empty(); + } + return Optional.of(channel.asTextChannel()); + } + + /** + * 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(String messageId, IReplyCallback event) { + try { + return OptionalLong.of(Long.parseLong(messageId)); + } catch (NumberFormatException _) { + event + .reply("The given message id '%s' is invalid, expected a number." + .formatted(messageId)) + .setEphemeral(true) + .queue(); + return OptionalLong.empty(); + } + } + + private static void handleMessageRetrieveFailed(Throwable failure, IDeferrableCallback event, + long messageId) { + handleMessageRetrieveFailed(failure, event, List.of(messageId)); + } + + private static void handleMessageRetrieveFailed(Throwable failure, IDeferrableCallback event, + List messageIds) { + if (failure instanceof ErrorResponseException ex + && ex.getErrorResponse() == ErrorResponse.UNKNOWN_MESSAGE) { + event.getHook() + .editOriginal("The messages with ids '%s' do not exist.".formatted(messageIds)) + .queue(); + return; + } + + logger.warn("Unable to retrieve the messages with ids '{}' for an unknown reason.", + messageIds, failure); + event.getHook() + .editOriginal( + "Something unexpected went wrong trying to locate the messages with ids '%s'." + .formatted(messageIds)) + .queue(); + } + + private static boolean handleIsMessageFromOtherUser(Message message, + IDeferrableCallback event) { + if (message.getAuthor().equals(message.getJDA().getSelfUser())) { + return false; + } + event.getHook() + .editOriginal( + "The message to edit must be from this bot but was posted by another user.") + .queue(); + return true; + } + + private static void sendSuccessMessage(IDeferrableCallback event, Subcommand action) { + event.getHook() + .editOriginalEmbeds(new EmbedBuilder().setTitle("Success") + .setDescription("Successfully %s message.".formatted(action.getActionVerbPast())) + .setColor(MessageCommand.AMBIENT_COLOR) + .build()) + .queue(); + } + + private static void handleActionFailed(Throwable failure, IDeferrableCallback event, + Subcommand action) { + String verb = action.getActionVerb(); + logger.warn("Unable to {} message for an unknown reason.", verb, failure); + event.getHook() + .editOriginal( + "Something unexpected went wrong trying to '%s' the message.".formatted(verb)) + .queue(); + } + + @Override + public void onSlashCommand(SlashCommandInteractionEvent event) { + switch (Subcommand.fromName(event.getSubcommandName())) { + case RAW -> rawMessage(event); + case POST -> postMessage(event); + case POST_WITH_MESSAGE -> postMessageUsingMessageContent(event); + case EDIT -> editMessage(event); + case EDIT_WITH_MESSAGE -> editMessageUsingMessageContent(event); + default -> throw new AssertionError( + "Unexpected subcommand '%s'".formatted(event.getSubcommandName())); + } + } + + private void rawMessage(SlashCommandInteractionEvent event) { + Optional srcChannelOpt = handleExpectMessageChannel( + Objects.requireNonNull(event.getOption(SRC_CHANNEL_OPTION)).getAsChannel(), event); + if (srcChannelOpt.isEmpty()) { + return; + } + TextChannel srcChannel = srcChannelOpt.orElseThrow(); + + OptionalLong contentMessageIdOpt = parseMessageIdAndHandle( + Objects.requireNonNull(event.getOption(CONTENT_MESSAGE_ID_OPTION)).getAsString(), + event); + if (contentMessageIdOpt.isEmpty()) { + return; + } + long contentMessageId = contentMessageIdOpt.orElseThrow(); + + event.deferReply().queue(); + srcChannel.retrieveMessageById(contentMessageId).queue(contentMessage -> { + String content = contentMessage.getContentRaw(); + event.getHook() + .editOriginal("") + .setFiles(FileUpload.fromData(content.getBytes(StandardCharsets.UTF_8), + CONTENT_FILE_NAME)) + .queue(); + }, failure -> handleMessageRetrieveFailed(failure, event, contentMessageId)); + } + + private void postMessage(CommandInteraction event) { + Subcommand action = Subcommand.POST; + Optional destChannelOpt = handleExpectMessageChannel( + Objects.requireNonNull(event.getOption(DEST_CHANNEL_OPTION)).getAsChannel(), event); + if (destChannelOpt.isEmpty()) { + return; + } + TextChannel destChannel = destChannelOpt.orElseThrow(); + + String content = Objects.requireNonNull(event.getOption(CONTENT_OPTION)).getAsString(); + + event.deferReply().queue(); + destChannel.sendMessage(content) + .queue(_ -> sendSuccessMessage(event, action), + failure -> handleActionFailed(failure, event, action)); + } + + private void postMessageUsingMessageContent(CommandInteraction event) { + Subcommand action = Subcommand.POST_WITH_MESSAGE; + Optional destChannelOpt = handleExpectMessageChannel( + Objects.requireNonNull(event.getOption(DEST_CHANNEL_OPTION)).getAsChannel(), event); + if (destChannelOpt.isEmpty()) { + return; + } + TextChannel destChannel = destChannelOpt.orElseThrow(); + + OptionalLong contentMessageIdOpt = parseMessageIdAndHandle( + Objects.requireNonNull(event.getOption(CONTENT_MESSAGE_ID_OPTION)).getAsString(), + event); + if (contentMessageIdOpt.isEmpty()) { + return; + } + long contentMessageId = contentMessageIdOpt.orElseThrow(); + + event.deferReply().queue(); + event.getMessageChannel().retrieveMessageById(contentMessageId).queue(contentMessage -> { + String content = contentMessage.getContentRaw(); + destChannel.sendMessage(content) + .queue(_ -> sendSuccessMessage(event, action), + failure -> handleActionFailed(failure, event, action)); + }, failure -> handleMessageRetrieveFailed(failure, event, contentMessageId)); + } + + private void editMessage(CommandInteraction event) { + Subcommand action = Subcommand.EDIT; + Optional srcChannelOpt = handleExpectMessageChannel( + Objects.requireNonNull(event.getOption(SRC_CHANNEL_OPTION)).getAsChannel(), event); + if (srcChannelOpt.isEmpty()) { + return; + } + TextChannel srcChannel = srcChannelOpt.orElseThrow(); + + OptionalLong editingMessageIdOpt = parseMessageIdAndHandle( + Objects.requireNonNull(event.getOption(EDIT_MESSAGE_ID_OPTION)).getAsString(), + event); + if (editingMessageIdOpt.isEmpty()) { + return; + } + long editingMessageId = editingMessageIdOpt.orElseThrow(); + String content = Objects.requireNonNull(event.getOption(CONTENT_OPTION)).getAsString(); + + event.deferReply().queue(); + srcChannel.retrieveMessageById(editingMessageId).queue(editingMessage -> { + if (handleIsMessageFromOtherUser(editingMessage, event)) { + return; + } + editingMessage.editMessage(content) + .queue(_ -> sendSuccessMessage(event, action), + failure -> handleActionFailed(failure, event, action)); + }, failure -> handleMessageRetrieveFailed(failure, event, editingMessageId)); + } + + private void editMessageUsingMessageContent(CommandInteraction event) { + Subcommand action = Subcommand.EDIT_WITH_MESSAGE; + Optional srcChannelOpt = handleExpectMessageChannel( + Objects.requireNonNull(event.getOption(SRC_CHANNEL_OPTION)).getAsChannel(), event); + if (srcChannelOpt.isEmpty()) { + return; + } + TextChannel srcChannel = srcChannelOpt.orElseThrow(); + + OptionalLong editingMessageIdOpt = parseMessageIdAndHandle( + Objects.requireNonNull(event.getOption(EDIT_MESSAGE_ID_OPTION)).getAsString(), + event); + if (editingMessageIdOpt.isEmpty()) { + return; + } + long editingMessageId = editingMessageIdOpt.orElseThrow(); + + OptionalLong contentMessageIdOpt = parseMessageIdAndHandle( + Objects.requireNonNull(event.getOption(CONTENT_MESSAGE_ID_OPTION)).getAsString(), + event); + if (contentMessageIdOpt.isEmpty()) { + return; + } + long contentMessageId = contentMessageIdOpt.orElseThrow(); + + event.deferReply().queue(); + record Messages(Message editingMessage, Message contentMessage) { + } + srcChannel.retrieveMessageById(editingMessageId) + .and(event.getMessageChannel().retrieveMessageById(contentMessageId), Messages::new) + .queue(messages -> { + if (handleIsMessageFromOtherUser(messages.editingMessage, event)) { + return; + } + + String content = messages.contentMessage.getContentRaw(); + messages.editingMessage.editMessage(content) + .queue(_ -> sendSuccessMessage(event, action), + failure -> handleActionFailed(failure, event, action)); + }, failure -> handleMessageRetrieveFailed(failure, event, + List.of(editingMessageId, contentMessageId))); + } + + enum Subcommand { + RAW("raw", "", ""), + POST("post", "post", "posted"), + POST_WITH_MESSAGE("post-with-message", "post", "posted"), + EDIT("edit", "edit", "edited"), + EDIT_WITH_MESSAGE("edit-with-message", "edit", "edited"); + + private final String name; + private final String actionVerb; + private final String actionVerbPast; + + Subcommand(String name, String actionVerb, String actionVerbPast) { + this.name = name; + this.actionVerb = actionVerb; + this.actionVerbPast = actionVerbPast; + } + + String getName() { + return name; + } + + String getActionVerb() { + return actionVerb; + } + + String getActionVerbPast() { + return actionVerbPast; + } + + 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/features/messages/package-info.java b/application/src/main/java/org/togetherjava/tjbot/features/messages/package-info.java new file mode 100644 index 0000000000..e816d6be6f --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/messages/package-info.java @@ -0,0 +1,11 @@ +/** + * This package offers commands dealing with messages in general. See + * {@link org.togetherjava.tjbot.features.messages.MessageCommand} as main command being offered. + */ +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package org.togetherjava.tjbot.features.messages; + +import org.togetherjava.tjbot.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; 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 index 9751397137..8fe47ce1f4 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/moderation/TransferQuestionCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/moderation/TransferQuestionCommand.java @@ -31,6 +31,7 @@ import org.togetherjava.tjbot.features.BotCommandAdapter; import org.togetherjava.tjbot.features.CommandVisibility; import org.togetherjava.tjbot.features.MessageContextCommand; +import org.togetherjava.tjbot.features.chatgpt.ChatGptModel; import org.togetherjava.tjbot.features.chatgpt.ChatGptService; import org.togetherjava.tjbot.features.utils.StringDistances; @@ -98,7 +99,8 @@ public void onMessageContext(MessageContextInteractionEvent event) { String chatGptTitleRequest = "Summarize the following question into a concise title or heading not more than 5 words, remove quotations if any: %s" .formatted(originalMessage); - Optional chatGptTitle = chatGptService.ask(chatGptTitleRequest, null); + Optional chatGptTitle = + chatGptService.ask(chatGptTitleRequest, null, ChatGptModel.FASTEST); String title = chatGptTitle.orElse(createTitle(originalMessage)); if (title.startsWith("\"") && title.endsWith("\"")) { title = title.substring(1, title.length() - 1); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/reminder/ReminderCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/reminder/ReminderCommand.java index 88bcef8607..03c9eaf9e6 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/reminder/ReminderCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/reminder/ReminderCommand.java @@ -219,6 +219,7 @@ private MessageCreateData createPendingRemindersPage( List pendingReminders, int pageToShow) { // 12 reminders, 10 per page, ceil(12 / 10) = 2 int totalPages = Math.ceilDiv(pendingReminders.size(), REMINDERS_PER_PAGE); + totalPages = Math.max(1, totalPages); pageToShow = Math.clamp(pageToShow, 1, totalPages); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/rss/FailureState.java b/application/src/main/java/org/togetherjava/tjbot/features/rss/FailureState.java new file mode 100644 index 0000000000..a5f8d41f40 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/rss/FailureState.java @@ -0,0 +1,6 @@ +package org.togetherjava.tjbot.features.rss; + +import java.time.ZonedDateTime; + +record FailureState(int count, ZonedDateTime lastFailure) { +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/rss/RSSHandlerRoutine.java b/application/src/main/java/org/togetherjava/tjbot/features/rss/RSSHandlerRoutine.java index 56aea37b74..1d89896038 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/rss/RSSHandlerRoutine.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/rss/RSSHandlerRoutine.java @@ -2,6 +2,8 @@ import com.apptasticsoftware.rssreader.Item; import com.apptasticsoftware.rssreader.RssReader; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; @@ -48,7 +50,7 @@ *

* To include a new RSS feed, simply define an {@link RSSFeed} entry in the {@code "rssFeeds"} array * within the configuration file, adhering to the format shown below: - * + * *

  * {@code
  * {
@@ -58,7 +60,7 @@
  * }
  * }
  * 
- * + *

* Where: *