Skip to content
5 changes: 5 additions & 0 deletions application/config.json.template
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,11 @@
"videoLinkPattern": "http(s)?://www\\.youtube.com.*",
"pollIntervalInMinutes": 10
},
"quoteBoardConfig": {
"minimumReactionsToTrigger": 5,
"channel": "quotes",
"reactionEmoji": "⭐"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • I'd give this property a more readable name like triggerReaction, or simply reaction.
  • I'm not certain about putting emojis in code like this, instead, why not using the discord code of it :star:? I think it's cleaner

},
"memberCountCategoryPattern": "Info",
"topHelpers": {
"rolePattern": "Top Helper.*",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -431,6 +435,18 @@ public String getSelectRolesChannelPattern() {
return selectRolesChannelPattern;
}

/**
* The configuration of the quote messages config.
*
* <p>
* >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.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • I'd suggest to do more controls like checking that the minimum reaction count is not zero or negative, the strings aren't empty, etc.
  • It'd be helpful to add a log

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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -160,6 +161,7 @@ public static Collection<Feature> 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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package org.togetherjava.tjbot.features.basic;

import net.dv8tion.jda.api.JDA;
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".
* <p>
* 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).
* <p>
* 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<String> isQuoteBoardChannelName;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As said below, i don't see a reason for this field 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<TextChannel> 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<Void> 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<TextChannel> findQuoteBoardChannel(JDA jda, long guildId) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Id rename this method to findQuoteBoardChannelByName() and add another param channelName;
  • I'd mark this method as static;
  • I'd move it above instance methods in this class.

var guild = jda.getGuildById(guildId);

if (guild == null) {
throw new IllegalStateException(
String.format("Guild with ID '%d' not found.", guildId));
}

List<TextChannel> 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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<MessageReceiver> getMessageReceiversSubscribedTo(Channel channel) {
String channelName = channel.getName();
return channelNameToMessageReceiver.entrySet()
Expand Down
Loading