Skip to content

Commit 5abb9a1

Browse files
committed
wip
1 parent fd35376 commit 5abb9a1

File tree

5 files changed

+328
-2
lines changed

5 files changed

+328
-2
lines changed

application/config.json.template

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,5 +214,15 @@
214214
"archiveCategoryPattern": "Voice Channel Archives",
215215
"cleanChannelsAmount": 20,
216216
"minimumChannelsAmount": 40
217-
}
217+
},
218+
"numericScoreConfig": [
219+
{
220+
"forumId": "<the_forum_channel_id>",
221+
"upVoteEmoteName": "peepo_yes",
222+
"downVoteEmoteName": "peepo_no",
223+
"zeroScore": "0️⃣",
224+
"positiveScores": ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣", "🔟"],
225+
"negativeScores": ["🟡", "🟠", "🔴"]
226+
}
227+
]
218228
}

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ public final class Config {
5151
private final QuoteBoardConfig quoteBoardConfig;
5252
private final TopHelpersConfig topHelpers;
5353
private final DynamicVoiceChatConfig dynamicVoiceChatConfig;
54+
private final List<NumericScoreConfig> numericScoreConfigs;
5455

5556
@SuppressWarnings("ConstructorWithTooManyParameters")
5657
@JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
@@ -108,7 +109,9 @@ private Config(@JsonProperty(value = "token", required = true) String token,
108109
required = true) QuoteBoardConfig quoteBoardConfig,
109110
@JsonProperty(value = "topHelpers", required = true) TopHelpersConfig topHelpers,
110111
@JsonProperty(value = "dynamicVoiceChatConfig",
111-
required = true) DynamicVoiceChatConfig dynamicVoiceChatConfig) {
112+
required = true) DynamicVoiceChatConfig dynamicVoiceChatConfig,
113+
@JsonProperty(value = "numericScoreConfig",
114+
required = true) List<NumericScoreConfig> numericScoreConfigs) {
112115
this.token = Objects.requireNonNull(token);
113116
this.githubApiKey = Objects.requireNonNull(githubApiKey);
114117
this.databasePath = Objects.requireNonNull(databasePath);
@@ -146,6 +149,7 @@ private Config(@JsonProperty(value = "token", required = true) String token,
146149
this.quoteBoardConfig = Objects.requireNonNull(quoteBoardConfig);
147150
this.topHelpers = Objects.requireNonNull(topHelpers);
148151
this.dynamicVoiceChatConfig = Objects.requireNonNull(dynamicVoiceChatConfig);
152+
this.numericScoreConfigs = Objects.requireNonNull(numericScoreConfigs);
149153
}
150154

151155
/**
@@ -486,4 +490,13 @@ public TopHelpersConfig getTopHelpers() {
486490
public DynamicVoiceChatConfig getDynamicVoiceChatConfig() {
487491
return dynamicVoiceChatConfig;
488492
}
493+
494+
/**
495+
* Gets the list of numeric score configurations for project forum channels.
496+
*
497+
* @return the numeric score configurations
498+
*/
499+
public List<NumericScoreConfig> getNumericScoreConfigs() {
500+
return Collections.unmodifiableList(numericScoreConfigs);
501+
}
489502
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package org.togetherjava.tjbot.config;
2+
3+
import com.fasterxml.jackson.annotation.JsonProperty;
4+
import com.fasterxml.jackson.annotation.JsonRootName;
5+
6+
import org.togetherjava.tjbot.features.projects.ProjectsNumericScoreListener;
7+
8+
import java.util.List;
9+
import java.util.Objects;
10+
11+
/**
12+
* Configuration for the numeric score feature on forum posts, see
13+
* {@link ProjectsNumericScoreListener}.
14+
*
15+
* @param forumId the ID of the Discord forum channel to apply the score system to
16+
* @param upVoteEmoteName the name of the emoji used for upvoting (custom emoji name or raw unicode)
17+
* @param downVoteEmoteName the name of the emoji used for downvoting (custom emoji name or raw
18+
* unicode)
19+
* @param zeroScore the emoji to display when the score is zero
20+
* @param positiveScores the emojis to display for positive scores, ordered from +1 upwards
21+
* @param negativeScores the emojis to display for negative scores, ordered from -1 downwards
22+
*/
23+
@JsonRootName("numericScoreConfig")
24+
public record NumericScoreConfig(@JsonProperty(value = "forumId", required = true) long forumId,
25+
@JsonProperty(value = "upVoteEmoteName", required = true) String upVoteEmoteName,
26+
@JsonProperty(value = "downVoteEmoteName", required = true) String downVoteEmoteName,
27+
@JsonProperty(value = "zeroScore", required = true) String zeroScore,
28+
@JsonProperty(value = "positiveScores", required = true) List<String> positiveScores,
29+
@JsonProperty(value = "negativeScores", required = true) List<String> negativeScores) {
30+
31+
/**
32+
* Creates a NumericScoreConfig and validates its fields.
33+
*/
34+
public NumericScoreConfig {
35+
Objects.requireNonNull(upVoteEmoteName);
36+
Objects.requireNonNull(downVoteEmoteName);
37+
Objects.requireNonNull(zeroScore);
38+
positiveScores = List.copyOf(Objects.requireNonNull(positiveScores));
39+
negativeScores = List.copyOf(Objects.requireNonNull(negativeScores));
40+
if (positiveScores.isEmpty()) {
41+
throw new IllegalArgumentException("positiveScores must not be empty");
42+
}
43+
}
44+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
import org.togetherjava.tjbot.features.moderation.scam.ScamHistoryPurgeRoutine;
6767
import org.togetherjava.tjbot.features.moderation.scam.ScamHistoryStore;
6868
import org.togetherjava.tjbot.features.moderation.temp.TemporaryModerationRoutine;
69+
import org.togetherjava.tjbot.features.projects.ProjectsNumericScoreListener;
6970
import org.togetherjava.tjbot.features.projects.ProjectsThreadCreatedListener;
7071
import org.togetherjava.tjbot.features.reminder.RemindRoutine;
7172
import org.togetherjava.tjbot.features.reminder.ReminderCommand;
@@ -182,6 +183,7 @@ public static Collection<Feature> createFeatures(JDA jda, Database database, Con
182183
features.add(helpThreadCreatedListener);
183184
features.add(new HelpThreadLifecycleListener(helpSystemHelper, database));
184185
features.add(new ProjectsThreadCreatedListener(config));
186+
features.add(new ProjectsNumericScoreListener(config));
185187

186188
// Message context commands
187189
features.add(new TransferQuestionCommand(config, chatGptService));
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
package org.togetherjava.tjbot.features.projects;
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.ThreadChannel;
8+
import net.dv8tion.jda.api.entities.emoji.Emoji;
9+
import net.dv8tion.jda.api.entities.emoji.RichCustomEmoji;
10+
import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
11+
import net.dv8tion.jda.api.events.message.react.MessageReactionAddEvent;
12+
import net.dv8tion.jda.api.events.message.react.MessageReactionRemoveEvent;
13+
import net.dv8tion.jda.api.hooks.ListenerAdapter;
14+
import org.slf4j.Logger;
15+
import org.slf4j.LoggerFactory;
16+
17+
import org.togetherjava.tjbot.config.Config;
18+
import org.togetherjava.tjbot.config.NumericScoreConfig;
19+
import org.togetherjava.tjbot.features.EventReceiver;
20+
21+
import java.util.List;
22+
import java.util.Map;
23+
import java.util.Optional;
24+
import java.util.Set;
25+
import java.util.function.Function;
26+
import java.util.stream.Collectors;
27+
import java.util.stream.Stream;
28+
29+
/**
30+
* Manages a numeric score display on forum posts in configured project forums.
31+
*
32+
* <p>
33+
* When a new thread is created in a configured forum:
34+
* <ul>
35+
* <li>The bot reacts with upvote and downvote emojis so users know how to vote</li>
36+
* <li>The bot reacts with the initial score emoji (score = 1, counting the OP)</li>
37+
* </ul>
38+
*
39+
* <p>
40+
* When users add or remove upvote/downvote reactions on the post, the score emoji is updated. Score
41+
* emojis cannot be added by users — any such reaction is removed immediately.
42+
*
43+
* <p>
44+
* Score formula: {@code 1 (base for OP) + upvotes - downvotes}, where the OP's own votes are
45+
* excluded. The displayed emoji is determined by the configured score-to-emoji mapping.
46+
*/
47+
public final class ProjectsNumericScoreListener extends ListenerAdapter implements EventReceiver {
48+
49+
private static final Logger logger =
50+
LoggerFactory.getLogger(ProjectsNumericScoreListener.class);
51+
private static final int BASE_SCORE = 1;
52+
53+
private final Map<Long, NumericScoreConfig> forumIdToConfig;
54+
private final Map<Long, Set<Emoji>> forumIdToScoreEmojis;
55+
56+
/**
57+
* Creates a new instance.
58+
*
59+
* @param config the application configuration
60+
*/
61+
public ProjectsNumericScoreListener(Config config) {
62+
forumIdToConfig = config.getNumericScoreConfigs()
63+
.stream()
64+
.collect(Collectors.toMap(NumericScoreConfig::forumId, Function.identity()));
65+
66+
forumIdToScoreEmojis = config.getNumericScoreConfigs()
67+
.stream()
68+
.collect(Collectors.toMap(NumericScoreConfig::forumId,
69+
ProjectsNumericScoreListener::buildScoreEmojiSet));
70+
}
71+
72+
@Override
73+
public void onMessageReceived(MessageReceivedEvent event) {
74+
if (!event.isFromThread()) {
75+
return;
76+
}
77+
78+
ThreadChannel thread = event.getChannel().asThreadChannel();
79+
NumericScoreConfig cfg = forumIdToConfig.get(thread.getParentChannel().getIdLong());
80+
if (cfg == null) {
81+
return;
82+
}
83+
84+
// Only act on the initial post (first message — its ID equals the thread ID)
85+
if (!event.getMessageId().equals(thread.getId())) {
86+
return;
87+
}
88+
89+
Message post = event.getMessage();
90+
Guild guild = event.getGuild();
91+
92+
addVoteEmoji(cfg.upVoteEmoteName(), guild, post);
93+
addVoteEmoji(cfg.downVoteEmoteName(), guild, post);
94+
95+
Emoji initialEmoji = Emoji.fromUnicode(scoreToEmojiStr(BASE_SCORE, cfg));
96+
post.addReaction(initialEmoji).queue(_ -> {
97+
}, e -> logger.warn("Failed to add initial score emoji to post {}", post.getId(), e));
98+
}
99+
100+
@Override
101+
public void onMessageReactionAdd(MessageReactionAddEvent event) {
102+
if (!event.isFromThread()) {
103+
return;
104+
}
105+
106+
ThreadChannel thread = event.getChannel().asThreadChannel();
107+
long forumId = thread.getParentChannel().getIdLong();
108+
NumericScoreConfig cfg = forumIdToConfig.get(forumId);
109+
if (cfg == null) {
110+
return;
111+
}
112+
113+
if (event.getMessageIdLong() != thread.getIdLong()) {
114+
return;
115+
}
116+
117+
long selfId = event.getJDA().getSelfUser().getIdLong();
118+
if (event.getUserIdLong() == selfId) {
119+
return;
120+
}
121+
122+
Emoji reacted = event.getReaction().getEmoji();
123+
if (isScoreEmoji(reacted, forumId)) {
124+
event.retrieveUser()
125+
.flatMap(user -> event.getReaction().removeReaction(user))
126+
.queue(_ -> {
127+
}, e -> logger.warn("Failed to remove score emoji added by user {} on post {}",
128+
event.getUserId(), event.getMessageId(), e));
129+
return;
130+
}
131+
132+
event.retrieveMessage()
133+
.queue(post -> refreshScore(post, thread, event.getGuild(), event.getJDA()));
134+
}
135+
136+
@Override
137+
public void onMessageReactionRemove(MessageReactionRemoveEvent event) {
138+
if (!event.isFromThread()) {
139+
return;
140+
}
141+
142+
ThreadChannel thread = event.getChannel().asThreadChannel();
143+
long forumId = thread.getParentChannel().getIdLong();
144+
NumericScoreConfig cfg = forumIdToConfig.get(forumId);
145+
if (cfg == null) {
146+
return;
147+
}
148+
149+
if (event.getMessageIdLong() != thread.getIdLong()) {
150+
return;
151+
}
152+
153+
// Score emoji removals are caused by the bot updating the score — do not react
154+
if (isScoreEmoji(event.getReaction().getEmoji(), forumId)) {
155+
return;
156+
}
157+
158+
event.retrieveMessage()
159+
.queue(post -> refreshScore(post, thread, event.getGuild(), event.getJDA()));
160+
}
161+
162+
private void refreshScore(Message post, ThreadChannel thread, Guild guild, JDA jda) {
163+
long forumId = thread.getParentChannel().getIdLong();
164+
NumericScoreConfig cfg = forumIdToConfig.get(forumId);
165+
if (cfg == null) {
166+
return;
167+
}
168+
169+
long opId = thread.getOwnerIdLong();
170+
long botId = jda.getSelfUser().getIdLong();
171+
172+
int upvotes = countVotes(post, cfg.upVoteEmoteName(), guild, opId, botId);
173+
int downvotes = countVotes(post, cfg.downVoteEmoteName(), guild, opId, botId);
174+
int score = BASE_SCORE + upvotes - downvotes;
175+
176+
Emoji newScoreEmoji = Emoji.fromUnicode(scoreToEmojiStr(score, cfg));
177+
178+
Optional<MessageReaction> currentBotScoreReaction = post.getReactions()
179+
.stream()
180+
.filter(r -> isScoreEmoji(r.getEmoji(), forumId) && r.isSelf())
181+
.findFirst();
182+
183+
if (currentBotScoreReaction.isPresent()) {
184+
Emoji current = currentBotScoreReaction.get().getEmoji();
185+
if (current.equals(newScoreEmoji)) {
186+
return;
187+
}
188+
post.removeReaction(current)
189+
.flatMap(_ -> post.addReaction(newScoreEmoji))
190+
.queue(_ -> logger.debug("Updated score to {} on post {}", score, post.getId()),
191+
e -> logger.warn("Failed to update score emoji on post {}", post.getId(),
192+
e));
193+
} else {
194+
post.addReaction(newScoreEmoji)
195+
.queue(_ -> logger.debug("Added score {} to post {}", score, post.getId()),
196+
e -> logger.warn("Failed to add score emoji to post {}", post.getId(), e));
197+
}
198+
}
199+
200+
private static int countVotes(Message post, String emoteName, Guild guild, long opId,
201+
long botId) {
202+
Optional<Emoji> emojiOpt = resolveEmoji(emoteName, guild);
203+
if (emojiOpt.isEmpty()) {
204+
return 0;
205+
}
206+
Emoji voteEmoji = emojiOpt.get();
207+
208+
return post.getReactions()
209+
.stream()
210+
.filter(r -> r.getEmoji().equals(voteEmoji))
211+
.findFirst()
212+
.map(r -> (int) r.retrieveUsers()
213+
.stream()
214+
.filter(u -> u.getIdLong() != opId && u.getIdLong() != botId)
215+
.count())
216+
.orElse(0);
217+
}
218+
219+
private static void addVoteEmoji(String emoteName, Guild guild, Message post) {
220+
resolveEmoji(emoteName, guild).ifPresent(emoji -> post.addReaction(emoji).queue(_ -> {
221+
}, e -> logger.warn("Failed to add vote emoji '{}' to post {}", emoteName, post.getId(),
222+
e)));
223+
}
224+
225+
private static Optional<Emoji> resolveEmoji(String emoteName, Guild guild) {
226+
List<RichCustomEmoji> custom = guild.getEmojisByName(emoteName, false);
227+
if (!custom.isEmpty()) {
228+
return Optional.of(custom.get(0));
229+
}
230+
return Optional.of(Emoji.fromUnicode(emoteName));
231+
}
232+
233+
private boolean isScoreEmoji(Emoji emoji, long forumId) {
234+
Set<Emoji> scoreEmojis = forumIdToScoreEmojis.get(forumId);
235+
return scoreEmojis != null && scoreEmojis.contains(emoji);
236+
}
237+
238+
private static Set<Emoji> buildScoreEmojiSet(NumericScoreConfig cfg) {
239+
return Stream
240+
.concat(Stream.concat(Stream.of(cfg.zeroScore()), cfg.positiveScores().stream()),
241+
cfg.negativeScores().stream())
242+
.map(Emoji::fromUnicode)
243+
.collect(Collectors.toUnmodifiableSet());
244+
}
245+
246+
private static String scoreToEmojiStr(int score, NumericScoreConfig cfg) {
247+
if (score == 0) {
248+
return cfg.zeroScore();
249+
}
250+
if (score > 0) {
251+
List<String> positive = cfg.positiveScores();
252+
return positive.get(Math.min(score - 1, positive.size() - 1));
253+
}
254+
List<String> negative = cfg.negativeScores();
255+
return negative.get(Math.min(-score - 1, negative.size() - 1));
256+
}
257+
}

0 commit comments

Comments
 (0)