Skip to content

Commit e43f7c3

Browse files
christolissuryatejessfirasrg
authored
Implement Quotes Board (Together-Java#1029)
* feat(cool-messages): add configuration files * feat(cool-messages): add primary logic * feat(cool-message): forward messages instead of using embed For this feature, the version of JDA had to be bumped to 5.1.2 * refactor: code review addressed by Zabuzard * minimumReactions-5, star symbol instead of encoding * requests changes by zabuzard except for moving getBoardChannel down and markMessageAsProcessed * Following JavaDocs guidelines of making the first letter capital * code refactoring * refactor: use correct method for reactionsCount It turns out that for each event fired, every *single* damn time, messageReaction.hasCount() would always return false. No matter what. Terrible documentation from JDA's side. As a result, because of the ternary operator: messageReaction.hasCount() ? messageReaction.getCount() + 1 : 1 the result of `reactionsCount` would always end up holding the value of one. In the following changes, we use `messageReaction.retrieveUsers()` to get a list of the people reacted, get a `Stream<User>` from that and get its count. Much more reliable this way and it also happens to be more readable. Signed-off-by: Chris Sdogkos <[email protected]> Co-authored-by: Chris Sdogkos <[email protected]> Co-authored-by: Surya Tejess <[email protected]> * doc(QuoteBoardForwarder.java): improve JavaDoc Since 1ade409 (refactor: code review addressed by Zabuzard, 2025-06-28) primarily contains a generaly vague JavaDoc describing what the `QuoteBoardForwarder.java` class is doing, a more descriptive one replaces it. Signed-off-by: Chris Sdogkos <[email protected]> * refactor: code review addressed by zabuzard * rename coolMessagesConfig to quoteMessagesConfig * removed backticks for QuoteBoardForwarder and added a qualifier statement for QuoteBoardForwarder * param check for reactionEmoji * straight quotes instead of smart quotes * rename isCoolEmoji to isTriggerEmoji * early return when reactionsCount < config.minimumReactions() * early return for isCoolEmoji * fix: more renaming to quoteMessageConfig Due to some hastiness in resolving the recent merge conflicts, some parts with "coolMessagesConfig" were not renamed to "quoteMessagesConfig". Take care of that. Signed-off-by: Chris Sdogkos <[email protected]> * fixes a single compilation error: "getCoolMessageConfig" is an old name * applies FirasRG first review Comments: 1. improved settings 2. using meaningful names 3. validations 4. some debug logs * [smallFix] FirasRG first review: replace var with explicit type --------- Signed-off-by: Chris Sdogkos <[email protected]> Co-authored-by: Surya Tejess <[email protected]> Co-authored-by: Surya Tejess <[email protected]> Co-authored-by: Firas Regaieg <[email protected]>
1 parent ef6fa5a commit e43f7c3

File tree

8 files changed

+243
-0
lines changed

8 files changed

+243
-0
lines changed

application/config.json.template

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,11 @@
194194
"videoLinkPattern": "http(s)?://www\\.youtube.com.*",
195195
"pollIntervalInMinutes": 10
196196
},
197+
"quoteBoardConfig": {
198+
"minimumReactionsToTrigger": 5,
199+
"channel": "quotes",
200+
"reactionEmoji": "⭐"
201+
},
197202
"memberCountCategoryPattern": "Info",
198203
"topHelpers": {
199204
"rolePattern": "Top Helper.*",

application/src/main/java/org/togetherjava/tjbot/config/Config.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ public final class Config {
4848
private final RSSFeedsConfig rssFeedsConfig;
4949
private final String selectRolesChannelPattern;
5050
private final String memberCountCategoryPattern;
51+
private final QuoteBoardConfig quoteBoardConfig;
5152
private final TopHelpersConfig topHelpers;
5253

5354
@SuppressWarnings("ConstructorWithTooManyParameters")
@@ -102,6 +103,8 @@ private Config(@JsonProperty(value = "token", required = true) String token,
102103
@JsonProperty(value = "rssConfig", required = true) RSSFeedsConfig rssFeedsConfig,
103104
@JsonProperty(value = "selectRolesChannelPattern",
104105
required = true) String selectRolesChannelPattern,
106+
@JsonProperty(value = "quoteBoardConfig",
107+
required = true) QuoteBoardConfig quoteBoardConfig,
105108
@JsonProperty(value = "topHelpers", required = true) TopHelpersConfig topHelpers) {
106109
this.token = Objects.requireNonNull(token);
107110
this.githubApiKey = Objects.requireNonNull(githubApiKey);
@@ -137,6 +140,7 @@ private Config(@JsonProperty(value = "token", required = true) String token,
137140
this.featureBlacklistConfig = Objects.requireNonNull(featureBlacklistConfig);
138141
this.rssFeedsConfig = Objects.requireNonNull(rssFeedsConfig);
139142
this.selectRolesChannelPattern = Objects.requireNonNull(selectRolesChannelPattern);
143+
this.quoteBoardConfig = Objects.requireNonNull(quoteBoardConfig);
140144
this.topHelpers = Objects.requireNonNull(topHelpers);
141145
}
142146

@@ -431,6 +435,18 @@ public String getSelectRolesChannelPattern() {
431435
return selectRolesChannelPattern;
432436
}
433437

438+
/**
439+
* The configuration of the quote messages config.
440+
*
441+
* <p>
442+
* >The configuration of the quote board feature. Quotes user selected messages.
443+
*
444+
* @return configuration of quote messages config
445+
*/
446+
public QuoteBoardConfig getQuoteBoardConfig() {
447+
return quoteBoardConfig;
448+
}
449+
434450
/**
435451
* Gets the pattern matching the category that is used to display the total member count.
436452
*
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package org.togetherjava.tjbot.config;
2+
3+
import com.fasterxml.jackson.annotation.JsonProperty;
4+
import com.fasterxml.jackson.annotation.JsonRootName;
5+
import org.apache.logging.log4j.LogManager;
6+
7+
import org.togetherjava.tjbot.features.basic.QuoteBoardForwarder;
8+
9+
import java.util.Objects;
10+
11+
/**
12+
* Configuration for the quote board feature, see {@link QuoteBoardForwarder}.
13+
*/
14+
@JsonRootName("quoteBoardConfig")
15+
public record QuoteBoardConfig(
16+
@JsonProperty(value = "minimumReactionsToTrigger", required = true) int minimumReactions,
17+
@JsonProperty(required = true) String channel,
18+
@JsonProperty(value = "reactionEmoji", required = true) String reactionEmoji) {
19+
20+
/**
21+
* Creates a QuoteBoardConfig.
22+
*
23+
* @param minimumReactions the minimum amount of reactions
24+
* @param channel the pattern for the board channel
25+
* @param reactionEmoji the emoji with which users should react to
26+
*/
27+
public QuoteBoardConfig {
28+
if (minimumReactions <= 0) {
29+
throw new IllegalArgumentException("minimumReactions must be greater than zero");
30+
}
31+
Objects.requireNonNull(channel);
32+
if (channel.isBlank()) {
33+
throw new IllegalArgumentException("channel must not be empty or blank");
34+
}
35+
Objects.requireNonNull(reactionEmoji);
36+
if (reactionEmoji.isBlank()) {
37+
throw new IllegalArgumentException("reactionEmoji must not be empty or blank");
38+
}
39+
LogManager.getLogger(QuoteBoardConfig.class)
40+
.debug("Quote-Board configs loaded: minimumReactions={}, channel='{}', reactionEmoji='{}'",
41+
minimumReactions, channel, reactionEmoji);
42+
}
43+
}

application/src/main/java/org/togetherjava/tjbot/features/Features.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import org.togetherjava.tjbot.db.Database;
99
import org.togetherjava.tjbot.features.basic.MemberCountDisplayRoutine;
1010
import org.togetherjava.tjbot.features.basic.PingCommand;
11+
import org.togetherjava.tjbot.features.basic.QuoteBoardForwarder;
1112
import org.togetherjava.tjbot.features.basic.RoleSelectCommand;
1213
import org.togetherjava.tjbot.features.basic.SlashCommandEducator;
1314
import org.togetherjava.tjbot.features.basic.SuggestionsUpDownVoter;
@@ -161,6 +162,7 @@ public static Collection<Feature> createFeatures(JDA jda, Database database, Con
161162
features.add(new CodeMessageManualDetection(codeMessageHandler));
162163
features.add(new SlashCommandEducator());
163164
features.add(new PinnedNotificationRemover(config));
165+
features.add(new QuoteBoardForwarder(config));
164166

165167
// Event receivers
166168
features.add(new RejoinModerationRoleListener(actionsStore, config));

application/src/main/java/org/togetherjava/tjbot/features/MessageReceiver.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import net.dv8tion.jda.api.events.message.MessageDeleteEvent;
44
import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
55
import net.dv8tion.jda.api.events.message.MessageUpdateEvent;
6+
import net.dv8tion.jda.api.events.message.react.MessageReactionAddEvent;
67

78
import java.util.regex.Pattern;
89

@@ -56,4 +57,13 @@ public interface MessageReceiver extends Feature {
5657
* message that was deleted
5758
*/
5859
void onMessageDeleted(MessageDeleteEvent event);
60+
61+
/**
62+
* Triggered by the core system whenever a new reaction was added to a message in a text channel
63+
* of a guild the bot has been added to.
64+
*
65+
* @param event the event that triggered this, containing information about the corresponding
66+
* reaction that was added
67+
*/
68+
void onMessageReactionAdd(MessageReactionAddEvent event);
5969
}

application/src/main/java/org/togetherjava/tjbot/features/MessageReceiverAdapter.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import net.dv8tion.jda.api.events.message.MessageDeleteEvent;
44
import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
55
import net.dv8tion.jda.api.events.message.MessageUpdateEvent;
6+
import net.dv8tion.jda.api.events.message.react.MessageReactionAddEvent;
67

78
import java.util.regex.Pattern;
89

@@ -57,4 +58,10 @@ public void onMessageUpdated(MessageUpdateEvent event) {
5758
public void onMessageDeleted(MessageDeleteEvent event) {
5859
// Adapter does not react by default, subclasses may change this behavior
5960
}
61+
62+
@SuppressWarnings("NoopMethodInAbstractClass")
63+
@Override
64+
public void onMessageReactionAdd(MessageReactionAddEvent event) {
65+
// Adapter does not react by default, subclasses may change this behavior
66+
}
6067
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
package org.togetherjava.tjbot.features.basic;
2+
3+
import net.dv8tion.jda.api.JDA;
4+
import net.dv8tion.jda.api.entities.Guild;
5+
import net.dv8tion.jda.api.entities.Message;
6+
import net.dv8tion.jda.api.entities.MessageReaction;
7+
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
8+
import net.dv8tion.jda.api.entities.emoji.Emoji;
9+
import net.dv8tion.jda.api.events.message.react.MessageReactionAddEvent;
10+
import net.dv8tion.jda.api.requests.RestAction;
11+
import org.slf4j.Logger;
12+
import org.slf4j.LoggerFactory;
13+
14+
import org.togetherjava.tjbot.config.Config;
15+
import org.togetherjava.tjbot.config.QuoteBoardConfig;
16+
import org.togetherjava.tjbot.features.MessageReceiverAdapter;
17+
18+
import java.util.List;
19+
import java.util.Optional;
20+
import java.util.function.Predicate;
21+
import java.util.regex.Pattern;
22+
23+
/**
24+
* Listens for reaction-add events and turns popular messages into "quotes".
25+
* <p>
26+
* When someone reacts to a message with the configured emoji, the listener counts how many users
27+
* have used that same emoji. If the total meets or exceeds the minimum threshold and the bot has
28+
* not processed the message before, it copies (forwards) the message to the first text channel
29+
* whose name matches the configured quote-board pattern, then reacts to the original message itself
30+
* to mark it as handled (and to not let people spam react a message and give a way to the bot to
31+
* know that a message has been quoted before).
32+
* <p>
33+
* Key points: - Trigger emoji, minimum vote count and quote-board channel pattern are supplied via
34+
* {@code QuoteBoardConfig}.
35+
*/
36+
public final class QuoteBoardForwarder extends MessageReceiverAdapter {
37+
38+
private static final Logger logger = LoggerFactory.getLogger(QuoteBoardForwarder.class);
39+
private final Emoji triggerReaction;
40+
private final Predicate<String> isQuoteBoardChannelName;
41+
private final QuoteBoardConfig config;
42+
43+
/**
44+
* Constructs a new instance of QuoteBoardForwarder.
45+
*
46+
* @param config the configuration containing settings specific to the cool messages board,
47+
* including the reaction emoji and the pattern to match board channel names
48+
*/
49+
public QuoteBoardForwarder(Config config) {
50+
this.config = config.getQuoteBoardConfig();
51+
this.triggerReaction = Emoji.fromUnicode(this.config.reactionEmoji());
52+
53+
this.isQuoteBoardChannelName = Pattern.compile(this.config.channel()).asMatchPredicate();
54+
}
55+
56+
@Override
57+
public void onMessageReactionAdd(MessageReactionAddEvent event) {
58+
logger.debug("Received MessageReactionAddEvent: messageId={}, channelId={}, userId={}",
59+
event.getMessageId(), event.getChannel().getId(), event.getUserId());
60+
61+
final MessageReaction messageReaction = event.getReaction();
62+
63+
if (!messageReaction.getEmoji().equals(triggerReaction)) {
64+
logger.debug("Reaction emoji '{}' does not match trigger emoji '{}'. Ignoring.",
65+
messageReaction.getEmoji(), triggerReaction);
66+
return;
67+
}
68+
69+
if (hasAlreadyForwardedMessage(event.getJDA(), messageReaction)) {
70+
logger.debug("Message has already been forwarded by the bot. Skipping.");
71+
return;
72+
}
73+
74+
long reactionCount = messageReaction.retrieveUsers().stream().count();
75+
if (reactionCount < config.minimumReactions()) {
76+
logger.debug("Reaction count {} is less than minimum required {}. Skipping.",
77+
reactionCount, config.minimumReactions());
78+
return;
79+
}
80+
81+
final long guildId = event.getGuild().getIdLong();
82+
83+
Optional<TextChannel> boardChannel = findQuoteBoardChannel(event.getJDA(), guildId);
84+
85+
if (boardChannel.isEmpty()) {
86+
logger.warn(
87+
"Could not find board channel with pattern '{}' in server with ID '{}'. Skipping reaction handling...",
88+
this.config.channel(), guildId);
89+
return;
90+
}
91+
92+
logger.debug("Forwarding message to quote board channel: {}", boardChannel.get().getName());
93+
94+
event.retrieveMessage()
95+
.queue(message -> markAsProcessed(message)
96+
.flatMap(v -> message.forwardTo(boardChannel.orElseThrow()))
97+
.queue(_ -> logger.debug("Message forwarded to quote board channel: {}",
98+
boardChannel.get().getName())),
99+
100+
e -> logger.warn(
101+
"Unknown error while attempting to retrieve and forward message for quote-board, message is ignored.",
102+
e));
103+
104+
}
105+
106+
private RestAction<Void> markAsProcessed(Message message) {
107+
return message.addReaction(triggerReaction);
108+
}
109+
110+
/**
111+
* Gets the board text channel where the quotes go to, wrapped in an optional.
112+
*
113+
* @param jda the JDA
114+
* @param guildId the guild ID
115+
* @return the board text channel
116+
*/
117+
private Optional<TextChannel> findQuoteBoardChannel(JDA jda, long guildId) {
118+
Guild guild = jda.getGuildById(guildId);
119+
120+
if (guild == null) {
121+
throw new IllegalStateException(
122+
String.format("Guild with ID '%d' not found.", guildId));
123+
}
124+
125+
List<TextChannel> matchingChannels = guild.getTextChannelCache()
126+
.stream()
127+
.filter(channel -> isQuoteBoardChannelName.test(channel.getName()))
128+
.toList();
129+
130+
if (matchingChannels.size() > 1) {
131+
logger.warn(
132+
"Multiple quote board channels found matching pattern '{}' in guild with ID '{}'. Selecting the first one anyway.",
133+
this.config.channel(), guildId);
134+
}
135+
136+
return matchingChannels.stream().findFirst();
137+
}
138+
139+
/**
140+
* Checks a {@link MessageReaction} to see if the bot has reacted to it.
141+
*/
142+
private boolean hasAlreadyForwardedMessage(JDA jda, MessageReaction messageReaction) {
143+
if (!triggerReaction.equals(messageReaction.getEmoji())) {
144+
return false;
145+
}
146+
147+
return messageReaction.retrieveUsers()
148+
.parallelStream()
149+
.anyMatch(user -> jda.getSelfUser().getIdLong() == user.getIdLong());
150+
}
151+
}

application/src/main/java/org/togetherjava/tjbot/features/system/BotCore.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import net.dv8tion.jda.api.events.message.MessageDeleteEvent;
2020
import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
2121
import net.dv8tion.jda.api.events.message.MessageUpdateEvent;
22+
import net.dv8tion.jda.api.events.message.react.MessageReactionAddEvent;
2223
import net.dv8tion.jda.api.hooks.ListenerAdapter;
2324
import net.dv8tion.jda.api.interactions.callbacks.IReplyCallback;
2425
import net.dv8tion.jda.api.interactions.components.ComponentInteraction;
@@ -255,6 +256,14 @@ public void onMessageDelete(final MessageDeleteEvent event) {
255256
}
256257
}
257258

259+
@Override
260+
public void onMessageReactionAdd(final MessageReactionAddEvent event) {
261+
if (event.isFromGuild()) {
262+
getMessageReceiversSubscribedTo(event.getChannel())
263+
.forEach(messageReceiver -> messageReceiver.onMessageReactionAdd(event));
264+
}
265+
}
266+
258267
/**
259268
* Calculates the correct voice channel to act upon.
260269
*

0 commit comments

Comments
 (0)