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:
+ *
+ * - {@code raw}
+ * - {@code post}
+ * - {@code post-with-message}
+ * - {@code edit}
+ * - {@code edit-with-message}
+ *
+ */
+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:
*
* - {@code url} represents the URL of the RSS feed.
@@ -84,6 +86,14 @@ public final class RSSHandlerRoutine implements Routine {
private final int interval;
private final Database database;
+ private final Cache circuitBreaker =
+ Caffeine.newBuilder().expireAfterWrite(7, TimeUnit.DAYS).maximumSize(500).build();
+
+ private static final int DEAD_RSS_FEED_FAILURE_THRESHOLD = 15;
+ private static final double BACKOFF_BASE = 2.0;
+ private static final double BACKOFF_EXPONENT_OFFSET = 1.0;
+ private static final double MAX_BACKOFF_HOURS = 24.0;
+
/**
* Constructs an RSSHandlerRoutine with the provided configuration and database.
*
@@ -117,7 +127,14 @@ public Schedule createSchedule() {
@Override
public void runRoutine(@Nonnull JDA jda) {
- this.config.feeds().forEach(feed -> sendRSS(jda, feed));
+ this.config.feeds().forEach(feed -> {
+ if (isBackingOff(feed.url())) {
+ logger.debug("Skipping RSS feed (Backing off): {}", feed.url());
+ return;
+ }
+
+ sendRSS(jda, feed);
+ });
}
/**
@@ -257,7 +274,6 @@ private void postItem(List textChannels, Item rssItem, RSSFeed feed
* @param rssFeedRecord the record representing the RSS feed, can be null if not found in the
* database
* @param lastPostedDate the last posted date to be updated
- *
* @throws DateTimeParseException if the date cannot be parsed
*/
private void updateLastDateToDatabase(RSSFeed feedConfig, @Nullable RssFeedRecord rssFeedRecord,
@@ -400,9 +416,26 @@ private MessageCreateData constructMessage(Item item, RSSFeed feedConfig) {
*/
private List- fetchRSSItemsFromURL(String rssUrl) {
try {
- return rssReader.read(rssUrl).toList();
+ List
- items = rssReader.read(rssUrl).toList();
+ circuitBreaker.invalidate(rssUrl);
+ return items;
} catch (IOException e) {
- logger.error("Could not fetch RSS from URL ({})", rssUrl, e);
+ FailureState oldState = circuitBreaker.getIfPresent(rssUrl);
+ int newCount = (oldState == null) ? 1 : oldState.count() + 1;
+
+ if (newCount >= DEAD_RSS_FEED_FAILURE_THRESHOLD) {
+ logger.error(
+ "Possibly dead RSS feed URL: {} - Failed {} times. Please remove it from config.",
+ rssUrl, newCount);
+ }
+ circuitBreaker.put(rssUrl, new FailureState(newCount, ZonedDateTime.now()));
+
+ long blacklistedHours = calculateWaitHours(newCount);
+
+ logger.warn(
+ "RSS fetch failed for {} (Attempt #{}). Backing off for {} hours. Reason: {}",
+ rssUrl, newCount, blacklistedHours, e.getMessage(), e);
+
return List.of();
}
}
@@ -424,4 +457,21 @@ private static ZonedDateTime getZonedDateTime(@Nullable String date, String form
return ZonedDateTime.parse(date, DateTimeFormatter.ofPattern(format));
}
+
+ private long calculateWaitHours(int failureCount) {
+ return (long) Math.min(Math.pow(BACKOFF_BASE, failureCount - BACKOFF_EXPONENT_OFFSET),
+ MAX_BACKOFF_HOURS);
+ }
+
+ private boolean isBackingOff(String url) {
+ FailureState state = circuitBreaker.getIfPresent(url);
+ if (state == null) {
+ return false;
+ }
+
+ long waitHours = calculateWaitHours(state.count());
+ ZonedDateTime retryAt = state.lastFailure().plusHours(waitHours);
+
+ return ZonedDateTime.now().isBefore(retryAt);
+ }
}
diff --git a/application/src/main/java/org/togetherjava/tjbot/features/system/BotCore.java b/application/src/main/java/org/togetherjava/tjbot/features/system/BotCore.java
index 869e978a17..e9d99bc4d1 100644
--- a/application/src/main/java/org/togetherjava/tjbot/features/system/BotCore.java
+++ b/application/src/main/java/org/togetherjava/tjbot/features/system/BotCore.java
@@ -2,6 +2,12 @@
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.entities.channel.Channel;
+import net.dv8tion.jda.api.entities.channel.unions.AudioChannelUnion;
+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 net.dv8tion.jda.api.events.interaction.ModalInteractionEvent;
import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent;
import net.dv8tion.jda.api.events.interaction.command.MessageContextInteractionEvent;
@@ -13,9 +19,12 @@
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 net.dv8tion.jda.api.hooks.ListenerAdapter;
import net.dv8tion.jda.api.interactions.callbacks.IReplyCallback;
import net.dv8tion.jda.api.interactions.components.ComponentInteraction;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -32,6 +41,7 @@
import org.togetherjava.tjbot.features.UserContextCommand;
import org.togetherjava.tjbot.features.UserInteractionType;
import org.togetherjava.tjbot.features.UserInteractor;
+import org.togetherjava.tjbot.features.VoiceReceiver;
import org.togetherjava.tjbot.features.componentids.ComponentId;
import org.togetherjava.tjbot.features.componentids.ComponentIdParser;
import org.togetherjava.tjbot.features.componentids.ComponentIdStore;
@@ -75,6 +85,7 @@ public final class BotCore extends ListenerAdapter implements CommandProvider {
private final ComponentIdParser componentIdParser;
private final ComponentIdStore componentIdStore;
private final Map channelNameToMessageReceiver = new HashMap<>();
+ private final Map channelNameToVoiceReceiver = new HashMap<>();
/**
* Creates a new command system which uses the given database to allow commands to persist data.
@@ -96,6 +107,13 @@ public BotCore(JDA jda, Database database, Config config) {
.forEach(messageReceiver -> channelNameToMessageReceiver
.put(messageReceiver.getChannelNamePattern(), messageReceiver));
+ // Voice receivers
+ features.stream()
+ .filter(VoiceReceiver.class::isInstance)
+ .map(VoiceReceiver.class::cast)
+ .forEach(voiceReceiver -> channelNameToVoiceReceiver
+ .put(voiceReceiver.getChannelNamePattern(), voiceReceiver));
+
// Event receivers
features.stream()
.filter(EventReceiver.class::isInstance)
@@ -238,6 +256,104 @@ public void onMessageDelete(final MessageDeleteEvent event) {
}
}
+ @Override
+ public void onMessageReactionAdd(final MessageReactionAddEvent event) {
+ if (event.isFromGuild()) {
+ getMessageReceiversSubscribedTo(event.getChannel())
+ .forEach(messageReceiver -> messageReceiver.onMessageReactionAdd(event));
+ }
+ }
+
+ /**
+ * Calculates the correct voice channel to act upon.
+ *
+ *
+ * If there is a channelJoined and a channelLeft, then the
+ * channelJoined is prioritized and returned. Otherwise, it returns
+ * channelLeft.
+ *
+ *
+ * This is an essential method due to the need of updating both channel categories that a member
+ * utilizes. For example, take the scenario of a user browsing through voice channels:
+ *
+ *
+ * - User joins General -> channelJoined = General | channelLeft = null
+ * - User switches to Gaming -> channelJoined = Gaming | channelLeft = General
+ * - User leaves Discord -> channelJoined = null | channelLeft = Gaming
+ *
+ *
+ *
+ * This way, we make sure that all relevant voice channels are updated.
+ *
+ * @param channelJoined the channel that the member has connected to, if any
+ * @param channelLeft the channel that the member left from, if any
+ * @return the join channel if not null, otherwise the leave channel, otherwise an empty
+ * optional
+ */
+ private Optional selectPreferredAudioChannel(@Nullable AudioChannelUnion channelJoined,
+ @Nullable AudioChannelUnion channelLeft) {
+ if (channelJoined != null) {
+ return Optional.of(channelJoined);
+ }
+
+ return Optional.ofNullable(channelLeft);
+ }
+
+ @Override
+ public void onGuildVoiceUpdate(@NotNull GuildVoiceUpdateEvent event) {
+ selectPreferredAudioChannel(event.getChannelJoined(), event.getChannelLeft())
+ .ifPresent(channel -> getVoiceReceiversSubscribedTo(channel)
+ .forEach(voiceReceiver -> voiceReceiver.onVoiceUpdate(event)));
+ }
+
+ @Override
+ public void onGuildVoiceVideo(@NotNull GuildVoiceVideoEvent event) {
+ AudioChannelUnion channel = event.getVoiceState().getChannel();
+
+ if (channel == null) {
+ return;
+ }
+
+ getVoiceReceiversSubscribedTo(channel)
+ .forEach(voiceReceiver -> voiceReceiver.onVideoToggle(event));
+ }
+
+ @Override
+ public void onGuildVoiceStream(@NotNull GuildVoiceStreamEvent event) {
+ AudioChannelUnion channel = event.getVoiceState().getChannel();
+
+ if (channel == null) {
+ return;
+ }
+
+ getVoiceReceiversSubscribedTo(channel)
+ .forEach(voiceReceiver -> voiceReceiver.onStreamToggle(event));
+ }
+
+ @Override
+ public void onGuildVoiceMute(@NotNull GuildVoiceMuteEvent event) {
+ AudioChannelUnion channel = event.getVoiceState().getChannel();
+
+ if (channel == null) {
+ return;
+ }
+
+ getVoiceReceiversSubscribedTo(channel)
+ .forEach(voiceReceiver -> voiceReceiver.onMuteToggle(event));
+ }
+
+ @Override
+ public void onGuildVoiceDeafen(@NotNull GuildVoiceDeafenEvent event) {
+ AudioChannelUnion channel = event.getVoiceState().getChannel();
+
+ if (channel == null) {
+ return;
+ }
+
+ getVoiceReceiversSubscribedTo(channel)
+ .forEach(voiceReceiver -> voiceReceiver.onDeafenToggle(event));
+ }
+
private Stream getMessageReceiversSubscribedTo(Channel channel) {
String channelName = channel.getName();
return channelNameToMessageReceiver.entrySet()
@@ -248,6 +364,16 @@ private Stream getMessageReceiversSubscribedTo(Channel channel)
.map(Map.Entry::getValue);
}
+ private Stream getVoiceReceiversSubscribedTo(Channel channel) {
+ String channelName = channel.getName();
+ return channelNameToVoiceReceiver.entrySet()
+ .stream()
+ .filter(patternAndReceiver -> patternAndReceiver.getKey()
+ .matcher(channelName)
+ .matches())
+ .map(Map.Entry::getValue);
+ }
+
@Override
public void onSlashCommandInteraction(SlashCommandInteractionEvent event) {
String name = event.getName();
diff --git a/build.gradle b/build.gradle
index f3562829ed..de2caad6c0 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,8 +1,8 @@
plugins {
id 'java'
- id "com.diffplug.spotless" version "8.0.0"
- id "org.sonarqube" version "7.0.1.6134"
- id "name.remal.sonarlint" version "6.0.0"
+ id "com.diffplug.spotless" version "8.2.0"
+ id "org.sonarqube" version "7.2.0.6526"
+ id "name.remal.sonarlint" version "7.0.0"
}
repositories {
mavenCentral()
@@ -14,7 +14,7 @@ version '1.0-SNAPSHOT'
ext {
jooqVersion = '3.20.5'
jacksonVersion = '2.19.1'
- chatGPTVersion = '0.18.2'
+ chatGPTVersion = '4.16.0'
junitVersion = '6.0.0'
}
diff --git a/database/build.gradle b/database/build.gradle
index 8b9f5fd380..20f1804a49 100644
--- a/database/build.gradle
+++ b/database/build.gradle
@@ -7,7 +7,7 @@ var sqliteVersion = "3.51.0.0"
dependencies {
implementation 'com.google.code.findbugs:jsr305:3.0.2'
implementation "org.xerial:sqlite-jdbc:${sqliteVersion}"
- implementation 'org.flywaydb:flyway-core:11.17.0'
+ implementation 'org.flywaydb:flyway-core:11.20.0'
implementation "org.jooq:jooq:$jooqVersion"
implementation project(':utils')
diff --git a/scripts/pre-commit b/scripts/pre-commit
index 87a8fdc42c..b1da09e748 100644
--- a/scripts/pre-commit
+++ b/scripts/pre-commit
@@ -44,6 +44,14 @@ if [ "$spotlessExitCode" -ne 0 ]; then
exit "$spotlessExitCode"
fi
+echo "**Running Sonarlint checks**"
+./gradlew sonarlintMain
+sonarlintExitCode=$?
+if [ "$sonarlintExitCode" -ne 0 ]; then
+ pop_stash
+ exit "$sonarlintMain"
+fi
+
# Spotless possibly found changes, apply them, excluding untracked files
git add -u