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..3d075d0635 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;
@@ -160,6 +161,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));
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/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/system/BotCore.java b/application/src/main/java/org/togetherjava/tjbot/features/system/BotCore.java
index 869e978a17..bbf71c54f6 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
@@ -13,6 +13,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 net.dv8tion.jda.api.hooks.ListenerAdapter;
import net.dv8tion.jda.api.interactions.callbacks.IReplyCallback;
import net.dv8tion.jda.api.interactions.components.ComponentInteraction;
@@ -238,6 +239,14 @@ public void onMessageDelete(final MessageDeleteEvent event) {
}
}
+ @Override
+ public void onMessageReactionAdd(final MessageReactionAddEvent event) {
+ if (event.isFromGuild()) {
+ getMessageReceiversSubscribedTo(event.getChannel())
+ .forEach(messageReceiver -> messageReceiver.onMessageReactionAdd(event));
+ }
+ }
+
private Stream getMessageReceiversSubscribedTo(Channel channel) {
String channelName = channel.getName();
return channelNameToMessageReceiver.entrySet()