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()