diff --git a/application/config.json.template b/application/config.json.template index e2e1963c80..3ee7fcd859 100644 --- a/application/config.json.template +++ b/application/config.json.template @@ -214,5 +214,15 @@ "archiveCategoryPattern": "Voice Channel Archives", "cleanChannelsAmount": 20, "minimumChannelsAmount": 40 - } + }, + "numericScoreConfig": [ + { + "forumId": "", + "upVoteEmoteName": "peepo_yes", + "downVoteEmoteName": "peepo_no", + "zeroScore": "0️⃣", + "positiveScores": ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣", "🔟"], + "negativeScores": ["🟡", "🟠", "🔴"] + } + ] } diff --git a/application/src/main/java/org/togetherjava/tjbot/config/Config.java b/application/src/main/java/org/togetherjava/tjbot/config/Config.java index 33362afcb0..47d0fd1f07 100644 --- a/application/src/main/java/org/togetherjava/tjbot/config/Config.java +++ b/application/src/main/java/org/togetherjava/tjbot/config/Config.java @@ -51,6 +51,7 @@ public final class Config { private final QuoteBoardConfig quoteBoardConfig; private final TopHelpersConfig topHelpers; private final DynamicVoiceChatConfig dynamicVoiceChatConfig; + private final List numericScoreConfigs; @SuppressWarnings("ConstructorWithTooManyParameters") @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) @@ -108,7 +109,9 @@ private Config(@JsonProperty(value = "token", required = true) String token, required = true) QuoteBoardConfig quoteBoardConfig, @JsonProperty(value = "topHelpers", required = true) TopHelpersConfig topHelpers, @JsonProperty(value = "dynamicVoiceChatConfig", - required = true) DynamicVoiceChatConfig dynamicVoiceChatConfig) { + required = true) DynamicVoiceChatConfig dynamicVoiceChatConfig, + @JsonProperty(value = "numericScoreConfig", + required = true) List numericScoreConfigs) { this.token = Objects.requireNonNull(token); this.githubApiKey = Objects.requireNonNull(githubApiKey); this.databasePath = Objects.requireNonNull(databasePath); @@ -146,6 +149,7 @@ private Config(@JsonProperty(value = "token", required = true) String token, this.quoteBoardConfig = Objects.requireNonNull(quoteBoardConfig); this.topHelpers = Objects.requireNonNull(topHelpers); this.dynamicVoiceChatConfig = Objects.requireNonNull(dynamicVoiceChatConfig); + this.numericScoreConfigs = Objects.requireNonNull(numericScoreConfigs); } /** @@ -486,4 +490,13 @@ public TopHelpersConfig getTopHelpers() { public DynamicVoiceChatConfig getDynamicVoiceChatConfig() { return dynamicVoiceChatConfig; } + + /** + * Gets the list of numeric score configurations for project forum channels. + * + * @return the numeric score configurations + */ + public List getNumericScoreConfigs() { + return Collections.unmodifiableList(numericScoreConfigs); + } } diff --git a/application/src/main/java/org/togetherjava/tjbot/config/NumericScoreConfig.java b/application/src/main/java/org/togetherjava/tjbot/config/NumericScoreConfig.java new file mode 100644 index 0000000000..f977cbcda9 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/config/NumericScoreConfig.java @@ -0,0 +1,44 @@ +package org.togetherjava.tjbot.config; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonRootName; + +import org.togetherjava.tjbot.features.projects.ProjectsNumericScoreListener; + +import java.util.List; +import java.util.Objects; + +/** + * Configuration for the numeric score feature on forum posts, see + * {@link ProjectsNumericScoreListener}. + * + * @param forumId the ID of the Discord forum channel to apply the score system to + * @param upVoteEmoteName the name of the emoji used for upvoting (custom emoji name or raw unicode) + * @param downVoteEmoteName the name of the emoji used for downvoting (custom emoji name or raw + * unicode) + * @param zeroScore the emoji to display when the score is zero + * @param positiveScores the emojis to display for positive scores, ordered from +1 upwards + * @param negativeScores the emojis to display for negative scores, ordered from -1 downwards + */ +@JsonRootName("numericScoreConfig") +public record NumericScoreConfig(@JsonProperty(value = "forumId", required = true) long forumId, + @JsonProperty(value = "upVoteEmoteName", required = true) String upVoteEmoteName, + @JsonProperty(value = "downVoteEmoteName", required = true) String downVoteEmoteName, + @JsonProperty(value = "zeroScore", required = true) String zeroScore, + @JsonProperty(value = "positiveScores", required = true) List positiveScores, + @JsonProperty(value = "negativeScores", required = true) List negativeScores) { + + /** + * Creates a NumericScoreConfig and validates its fields. + */ + public NumericScoreConfig { + Objects.requireNonNull(upVoteEmoteName); + Objects.requireNonNull(downVoteEmoteName); + Objects.requireNonNull(zeroScore); + positiveScores = List.copyOf(Objects.requireNonNull(positiveScores)); + negativeScores = List.copyOf(Objects.requireNonNull(negativeScores)); + if (positiveScores.isEmpty()) { + throw new IllegalArgumentException("positiveScores must not be empty"); + } + } +} 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 c360bacdd1..46d7583e93 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/Features.java @@ -66,6 +66,7 @@ import org.togetherjava.tjbot.features.moderation.scam.ScamHistoryPurgeRoutine; import org.togetherjava.tjbot.features.moderation.scam.ScamHistoryStore; import org.togetherjava.tjbot.features.moderation.temp.TemporaryModerationRoutine; +import org.togetherjava.tjbot.features.projects.ProjectsNumericScoreListener; import org.togetherjava.tjbot.features.projects.ProjectsThreadCreatedListener; import org.togetherjava.tjbot.features.reminder.RemindRoutine; import org.togetherjava.tjbot.features.reminder.ReminderCommand; @@ -182,6 +183,7 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(helpThreadCreatedListener); features.add(new HelpThreadLifecycleListener(helpSystemHelper, database)); features.add(new ProjectsThreadCreatedListener(config)); + features.add(new ProjectsNumericScoreListener(config)); // Message context commands features.add(new TransferQuestionCommand(config, chatGptService)); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/projects/ProjectsNumericScoreListener.java b/application/src/main/java/org/togetherjava/tjbot/features/projects/ProjectsNumericScoreListener.java new file mode 100644 index 0000000000..be99cfa3fb --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/projects/ProjectsNumericScoreListener.java @@ -0,0 +1,299 @@ +package org.togetherjava.tjbot.features.projects; + +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.ForumChannel; +import net.dv8tion.jda.api.entities.channel.concrete.ThreadChannel; +import net.dv8tion.jda.api.entities.emoji.Emoji; +import net.dv8tion.jda.api.entities.emoji.RichCustomEmoji; +import net.dv8tion.jda.api.events.message.MessageReceivedEvent; +import net.dv8tion.jda.api.events.message.react.MessageReactionAddEvent; +import net.dv8tion.jda.api.events.message.react.MessageReactionRemoveEvent; +import net.dv8tion.jda.api.hooks.ListenerAdapter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.togetherjava.tjbot.config.Config; +import org.togetherjava.tjbot.config.NumericScoreConfig; +import org.togetherjava.tjbot.features.EventReceiver; +import org.togetherjava.tjbot.features.Routine; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Manages a numeric score display on forum posts in configured project forums. + * + *

+ * When a new thread is created in a configured forum: + *

    + *
  • The bot reacts with upvote and downvote emojis so users know how to vote
  • + *
  • The bot reacts with the initial score emoji (score = 1, counting the OP)
  • + *
+ * + *

+ * When users add or remove upvote/downvote reactions on the post, the score emoji is updated. Score + * emojis cannot be added by users — any such reaction is removed immediately. + * + *

+ * Score formula: {@code 1 (base for OP) + upvotes - downvotes}, where the OP's own votes are + * excluded. The displayed emoji is determined by the configured score-to-emoji mapping. + * + *

+ * On startup, a routine scan corrects any missing or stale score emojis on all active posts, + * recovering from bot downtime. + */ +public final class ProjectsNumericScoreListener extends ListenerAdapter + implements EventReceiver, Routine { + + private static final Logger logger = + LoggerFactory.getLogger(ProjectsNumericScoreListener.class); + private static final int BASE_SCORE = 1; + + private final Map forumIdToConfig; + private final Map> forumIdToScoreEmojis; + + /** + * Creates a new instance. + * + * @param config the application configuration + */ + public ProjectsNumericScoreListener(Config config) { + forumIdToConfig = config.getNumericScoreConfigs() + .stream() + .collect(Collectors.toMap(NumericScoreConfig::forumId, Function.identity())); + + forumIdToScoreEmojis = config.getNumericScoreConfigs() + .stream() + .collect(Collectors.toMap(NumericScoreConfig::forumId, + ProjectsNumericScoreListener::buildScoreEmojiSet)); + } + + @Override + public Schedule createSchedule() { + // Run immediately on startup, then daily as a maintenance check + return new Schedule(ScheduleMode.FIXED_RATE, 0, 24, TimeUnit.HOURS); + } + + @Override + public void runRoutine(JDA jda) { + forumIdToConfig.forEach((forumId, cfg) -> syncForum(forumId, cfg, jda)); + } + + private void syncForum(long forumId, NumericScoreConfig cfg, JDA jda) { + ForumChannel forum = jda.getChannelById(ForumChannel.class, forumId); + if (forum == null) { + logger.warn("Numeric score: forum channel {} not found or not cached, skipping sync", + forumId); + return; + } + + List activeThreads = forum.getThreadChannels() + .stream() + .filter(t -> !t.isArchived()) + .toList(); + + logger.debug("Syncing score emojis for {} active threads in forum {}", activeThreads.size(), + forum.getName()); + + Guild guild = forum.getGuild(); + activeThreads.forEach(thread -> thread.retrieveMessageById(thread.getIdLong()) + .queue(post -> refreshScore(post, thread, guild, jda), + e -> logger.warn("Failed to retrieve post for thread {} during sync", + thread.getId(), e))); + } + + @Override + public void onMessageReceived(MessageReceivedEvent event) { + if (!event.isFromThread()) { + return; + } + + ThreadChannel thread = event.getChannel().asThreadChannel(); + NumericScoreConfig cfg = forumIdToConfig.get(thread.getParentChannel().getIdLong()); + if (cfg == null) { + return; + } + + // Only act on the initial post (first message — its ID equals the thread ID) + if (!event.getMessageId().equals(thread.getId())) { + return; + } + + Message post = event.getMessage(); + Guild guild = event.getGuild(); + + addVoteEmoji(cfg.upVoteEmoteName(), guild, post); + addVoteEmoji(cfg.downVoteEmoteName(), guild, post); + + Emoji initialEmoji = Emoji.fromUnicode(scoreToEmojiStr(BASE_SCORE, cfg)); + post.addReaction(initialEmoji).queue(_ -> {}, e -> logger + .warn("Failed to add initial score emoji to post {}", post.getId(), e)); + } + + @Override + public void onMessageReactionAdd(MessageReactionAddEvent event) { + if (!event.isFromThread()) { + return; + } + + ThreadChannel thread = event.getChannel().asThreadChannel(); + long forumId = thread.getParentChannel().getIdLong(); + NumericScoreConfig cfg = forumIdToConfig.get(forumId); + if (cfg == null) { + return; + } + + if (event.getMessageIdLong() != thread.getIdLong()) { + return; + } + + long selfId = event.getJDA().getSelfUser().getIdLong(); + if (event.getUserIdLong() == selfId) { + return; + } + + Emoji reacted = event.getReaction().getEmoji(); + if (isScoreEmoji(reacted, forumId)) { + event.retrieveUser() + .flatMap(user -> event.getReaction().removeReaction(user)) + .queue(_ -> {}, e -> logger.warn( + "Failed to remove score emoji added by user {} on post {}", + event.getUserId(), event.getMessageId(), e)); + return; + } + + event.retrieveMessage() + .queue(post -> refreshScore(post, thread, event.getGuild(), event.getJDA())); + } + + @Override + public void onMessageReactionRemove(MessageReactionRemoveEvent event) { + if (!event.isFromThread()) { + return; + } + + ThreadChannel thread = event.getChannel().asThreadChannel(); + long forumId = thread.getParentChannel().getIdLong(); + NumericScoreConfig cfg = forumIdToConfig.get(forumId); + if (cfg == null) { + return; + } + + if (event.getMessageIdLong() != thread.getIdLong()) { + return; + } + + // Score emoji removals are caused by the bot updating the score — do not react + if (isScoreEmoji(event.getReaction().getEmoji(), forumId)) { + return; + } + + event.retrieveMessage() + .queue(post -> refreshScore(post, thread, event.getGuild(), event.getJDA())); + } + + private void refreshScore(Message post, ThreadChannel thread, Guild guild, JDA jda) { + long forumId = thread.getParentChannel().getIdLong(); + NumericScoreConfig cfg = forumIdToConfig.get(forumId); + if (cfg == null) { + return; + } + + long opId = thread.getOwnerIdLong(); + long botId = jda.getSelfUser().getIdLong(); + + int upvotes = countVotes(post, cfg.upVoteEmoteName(), guild, opId, botId); + int downvotes = countVotes(post, cfg.downVoteEmoteName(), guild, opId, botId); + int score = BASE_SCORE + upvotes - downvotes; + + Emoji newScoreEmoji = Emoji.fromUnicode(scoreToEmojiStr(score, cfg)); + + Optional currentBotScoreReaction = post.getReactions() + .stream() + .filter(r -> isScoreEmoji(r.getEmoji(), forumId) && r.isSelf()) + .findFirst(); + + if (currentBotScoreReaction.isPresent()) { + Emoji current = currentBotScoreReaction.get().getEmoji(); + if (current.equals(newScoreEmoji)) { + return; + } + post.removeReaction(current) + .flatMap(_ -> post.addReaction(newScoreEmoji)) + .queue(_ -> logger.debug("Updated score to {} on post {}", score, post.getId()), + e -> logger.warn("Failed to update score emoji on post {}", post.getId(), + e)); + } else { + post.addReaction(newScoreEmoji) + .queue(_ -> logger.debug("Added score {} to post {}", score, post.getId()), + e -> logger.warn("Failed to add score emoji to post {}", post.getId(), e)); + } + } + + private static int countVotes(Message post, String emoteName, Guild guild, long opId, + long botId) { + Optional emojiOpt = resolveEmoji(emoteName, guild); + if (emojiOpt.isEmpty()) { + return 0; + } + Emoji voteEmoji = emojiOpt.get(); + + return post.getReactions() + .stream() + .filter(r -> r.getEmoji().equals(voteEmoji)) + .findFirst() + .map(r -> (int) r.retrieveUsers() + .stream() + .filter(u -> u.getIdLong() != opId && u.getIdLong() != botId) + .count()) + .orElse(0); + } + + private static void addVoteEmoji(String emoteName, Guild guild, Message post) { + resolveEmoji(emoteName, guild).ifPresent(emoji -> post.addReaction(emoji).queue(_ -> { + }, e -> logger.warn("Failed to add vote emoji '{}' to post {}", emoteName, post.getId(), + e))); + } + + private static Optional resolveEmoji(String emoteName, Guild guild) { + List custom = guild.getEmojisByName(emoteName, false); + if (!custom.isEmpty()) { + return Optional.of(custom.get(0)); + } + return Optional.of(Emoji.fromUnicode(emoteName)); + } + + private boolean isScoreEmoji(Emoji emoji, long forumId) { + Set scoreEmojis = forumIdToScoreEmojis.get(forumId); + return scoreEmojis != null && scoreEmojis.contains(emoji); + } + + private static Set buildScoreEmojiSet(NumericScoreConfig cfg) { + return Stream + .concat(Stream.concat(Stream.of(cfg.zeroScore()), cfg.positiveScores().stream()), + cfg.negativeScores().stream()) + .map(Emoji::fromUnicode) + .collect(Collectors.toUnmodifiableSet()); + } + + private static String scoreToEmojiStr(int score, NumericScoreConfig cfg) { + if (score == 0) { + return cfg.zeroScore(); + } + if (score > 0) { + List positive = cfg.positiveScores(); + return positive.get(Math.min(score - 1, positive.size() - 1)); + } + List negative = cfg.negativeScores(); + return negative.get(Math.min(-score - 1, negative.size() - 1)); + } +}