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 ad852119ac..e255088301 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/Features.java @@ -177,7 +177,7 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(new CodeMessageAutoDetection(config, codeMessageHandler)); features.add(new CodeMessageManualDetection(codeMessageHandler)); features.add(new PinnedNotificationRemover(config)); - features.add(new QuoteBoardForwarder(config)); + features.add(new QuoteBoardForwarder(config, jda)); // Voice receivers features.add(new DynamicVoiceChat(config, metrics)); 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 index a6cee17024..eb449fbde6 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/basic/QuoteBoardForwarder.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/basic/QuoteBoardForwarder.java @@ -3,21 +3,27 @@ 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.entities.emoji.EmojiUnion; import net.dv8tion.jda.api.events.message.react.MessageReactionAddEvent; import net.dv8tion.jda.api.requests.RestAction; +import net.dv8tion.jda.api.utils.TimeUtil; 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 org.togetherjava.tjbot.features.Routine; +import java.time.OffsetDateTime; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.Predicate; import java.util.regex.Pattern; @@ -34,9 +40,14 @@ * Key points: - Trigger emoji, minimum vote count and quote-board channel pattern are supplied via * {@code QuoteBoardConfig}. */ -public final class QuoteBoardForwarder extends MessageReceiverAdapter { +public final class QuoteBoardForwarder extends MessageReceiverAdapter implements Routine { private static final Logger logger = LoggerFactory.getLogger(QuoteBoardForwarder.class); + private static final int CLEANUP_INTERVAL_HOURS = 1; + private static final int MAX_MESSAGE_AGE_DAYS = 7; + + private final Map reactions = new ConcurrentHashMap<>(); + private final JDA jda; private final Emoji botEmoji; private final Predicate isQuoteBoardChannelName; private final QuoteBoardConfig config; @@ -47,7 +58,8 @@ public final class QuoteBoardForwarder extends MessageReceiverAdapter { * @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) { + public QuoteBoardForwarder(Config config, JDA jda) { + this.jda = jda; this.config = config.getQuoteBoardConfig(); this.botEmoji = Emoji.fromUnicode(this.config.botEmoji()); @@ -59,6 +71,13 @@ public void onMessageReactionAdd(MessageReactionAddEvent event) { logger.debug("Received MessageReactionAddEvent: messageId={}, channelId={}, userId={}", event.getMessageId(), event.getChannel().getId(), event.getUserId()); + var messageId = event.getMessageIdLong(); + var messageTime = TimeUtil.getTimeCreated(messageId); + if (messageTime.isBefore(OffsetDateTime.now().minusDays(MAX_MESSAGE_AGE_DAYS))) { + logger.debug("Ignoring reaction as message is older than {} days", MAX_MESSAGE_AGE_DAYS); + return; + } + if (!config.allowChannels().contains(event.getChannel().getName())) { logger.debug("Skipping as reaction occurred in non-whitelisted channel"); return; @@ -82,30 +101,44 @@ public void onMessageReactionAdd(MessageReactionAddEvent event) { return; } - event.retrieveMessage().queue(message -> { - if (hasAlreadyForwardedMessage(message)) { - logger.debug("Message has already been forwarded by the bot. Skipping."); - return; - } - - float emojiScore = calculateMessageScore(message.getReactions()); + var userId = event.getUserIdLong(); + var emoji = event.getReaction().getEmoji().getAsReactionCode(); + reactions.computeIfAbsent(messageId, _ -> new EmojiScore()).addReaction(emoji, userId); - if (emojiScore < config.minimumScoreToTrigger()) { - return; - } + // check if we've already moved this message... + if (hasBotReactedToMessage(messageId)) { + logger.debug("Message has already been forwarded by the bot. Skipping."); + return; + } + // calculate the overall score of the message reactions + var reactionScore = calcReactionScore(messageId); + if (reactionScore < config.minimumScoreToTrigger()) { + return; + } logger.debug("Attempting to forward message to quote board channel: {}", boardChannel.getName()); - markAsProcessed(message).flatMap(_ -> message.forwardTo(boardChannel)) + event.retrieveMessage() + .queue(message -> markAsProcessed(message).flatMap(_ -> message.forwardTo(boardChannel)) .queue(_ -> logger.debug("Message forwarded to quote board channel: {}", boardChannel.getName()), - e -> logger.warn( - "Unknown error while attempting to retrieve and forward message for quote-board, message is ignored.", - e)); - }); + e -> logger.warn( + "Unknown error while attempting to retrieve and forward message for quote-board, message is ignored.", e))); + } + + @Override + public Schedule createSchedule() { + return new Schedule(ScheduleMode.FIXED_RATE, 0, CLEANUP_INTERVAL_HOURS, TimeUnit.HOURS); } + @Override + public void runRoutine(JDA jda) { + OffsetDateTime cutoff = OffsetDateTime.now().minusDays(MAX_MESSAGE_AGE_DAYS); + reactions.keySet().removeIf(messageId -> TimeUtil.getTimeCreated(messageId).isBefore(cutoff)); + } + + private RestAction markAsProcessed(Message message) { return message.addReaction(botEmoji); } @@ -125,7 +158,7 @@ private Optional findQuoteBoardChannel(JDA jda, long guildId) { String.format("Guild with ID '%d' not found.", guildId)); } - List matchingChannels = guild.getTextChannelCache() + List matchingChannels = guild.getTextChannels() .stream() .filter(channel -> isQuoteBoardChannelName.test(channel.getName())) .toList(); @@ -139,26 +172,31 @@ private Optional findQuoteBoardChannel(JDA jda, long guildId) { return matchingChannels.stream().findFirst(); } - /** - * Checks whether the bot has already reacted to the given message with its marker emoji. - */ - private boolean hasAlreadyForwardedMessage(Message message) { - return message.getReactions() - .stream() - .filter(reaction -> botEmoji.equals(reaction.getEmoji())) - .anyMatch(MessageReaction::isSelf); + private boolean hasBotReactedToMessage(Long messageId) { + Map> emojiAndReactions = reactions.get(messageId).reactions(); + return emojiAndReactions.values().stream() + .anyMatch(users -> users.contains(jda.getSelfUser().getApplicationIdLong())); } - private float calculateMessageScore(List reactions) { - return (float) reactions.stream() - .mapToDouble(reaction -> reaction.getCount() * getEmojiScore(reaction.getEmoji())) - .sum(); + private float calcReactionScore(Long messageId) { + var reacts = reactions.get(messageId).reactions(); + var scores = new AtomicReference<>(0.0F); + reacts.keySet().forEach(emojiCode -> scores.updateAndGet(v -> v + getEmojiScore(emojiCode))); + return scores.get(); } - private float getEmojiScore(EmojiUnion emoji) { - float defaultScore = config.defaultEmojiScore(); - String reactionCode = emoji.getAsReactionCode(); + private float getEmojiScore(String emojiCode) { + return config.emojiScores().getOrDefault(emojiCode, config.defaultEmojiScore()); + } - return config.emojiScores().getOrDefault(reactionCode, defaultScore); + private record EmojiScore(Map> reactions) { + public EmojiScore() { + this(new ConcurrentHashMap<>()); + } + + public void addReaction(String emojiCode, Long userId) { + reactions.computeIfAbsent(emojiCode, _ -> ConcurrentHashMap.newKeySet()) + .add(userId); + } } }