Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ public static Collection<Feature> 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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<Long, EmojiScore> reactions = new ConcurrentHashMap<>();
private final JDA jda;
private final Emoji botEmoji;
private final Predicate<String> isQuoteBoardChannelName;
private final QuoteBoardConfig config;
Expand All @@ -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());

Expand All @@ -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;
Expand All @@ -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<Void> markAsProcessed(Message message) {
return message.addReaction(botEmoji);
}
Expand All @@ -125,7 +158,7 @@ private Optional<TextChannel> findQuoteBoardChannel(JDA jda, long guildId) {
String.format("Guild with ID '%d' not found.", guildId));
}

List<TextChannel> matchingChannels = guild.getTextChannelCache()
List<TextChannel> matchingChannels = guild.getTextChannels()
.stream()
.filter(channel -> isQuoteBoardChannelName.test(channel.getName()))
.toList();
Expand All @@ -139,26 +172,31 @@ private Optional<TextChannel> 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<String, Set<Long>> emojiAndReactions = reactions.get(messageId).reactions();
return emojiAndReactions.values().stream()
.anyMatch(users -> users.contains(jda.getSelfUser().getApplicationIdLong()));
}

private float calculateMessageScore(List<MessageReaction> 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<String, Set<Long>> reactions) {
public EmojiScore() {
this(new ConcurrentHashMap<>());
}

public void addReaction(String emojiCode, Long userId) {
reactions.computeIfAbsent(emojiCode, _ -> ConcurrentHashMap.newKeySet())
.add(userId);
}
}
}