diff --git a/application/config.json.template b/application/config.json.template index e2e1963c80..22c7b7988b 100644 --- a/application/config.json.template +++ b/application/config.json.template @@ -214,5 +214,19 @@ "archiveCategoryPattern": "Voice Channel Archives", "cleanChannelsAmount": 20, "minimumChannelsAmount": 40 - } + }, + "numericScoreConfig": [ + { + "forumId": 123456789123456789, + "upvoteEmoji": "upvote", + "downvoteEmoji": "downvote", + "zeroScoreEmoji": "0️⃣", + "positiveScoresEmojis": [ + "1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣", "🔟" + ], + "negativeScoresEmojis": [ + "🟡", "🟠", "🔴" + ] + } + ] } 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..d01757a3b6 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 numericScoreConfig; @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 numericScoreConfig) { 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.numericScoreConfig = numericScoreConfig; } /** @@ -486,4 +490,13 @@ public TopHelpersConfig getTopHelpers() { public DynamicVoiceChatConfig getDynamicVoiceChatConfig() { return dynamicVoiceChatConfig; } + + /** + * Gets the numeric score configuration. + * + * @return numeric score configuration + */ + public List getNumericScoreConfig() { + return numericScoreConfig; + } } 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..47674d8b76 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/config/NumericScoreConfig.java @@ -0,0 +1,25 @@ +package org.togetherjava.tjbot.config; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +/** + * Configuration of the numeric score feature. + * + * @param forumId the forum where to apply the numeric score feature + * @param upvoteEmoji the upvote emoji + * @param downvoteEmoji the downvote emoji + * @param zeroScoreEmoji the emoji for the score zero + * @param positiveScoresEmojis the emojis for positive scores starting at 1 ascending + * @param negativeScoresEmojis the emojis for negative scores starting at -1 descending + */ +public record NumericScoreConfig(@JsonProperty(value = "forumId", required = true) long forumId, + @JsonProperty(value = "upvoteEmoji", required = true) String upvoteEmoji, + @JsonProperty(value = "downvoteEmoji", required = true) String downvoteEmoji, + @JsonProperty(value = "zeroScoreEmoji", required = true) String zeroScoreEmoji, + @JsonProperty(value = "positiveScoresEmojis", + required = true) List positiveScoresEmojis, + @JsonProperty(value = "negativeScoresEmojis", + required = true) List negativeScoresEmojis) { +} 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..398689e631 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.numericscore.NumericScoreFeature; import org.togetherjava.tjbot.features.projects.ProjectsThreadCreatedListener; import org.togetherjava.tjbot.features.reminder.RemindRoutine; import org.togetherjava.tjbot.features.reminder.ReminderCommand; @@ -219,6 +220,7 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(new JShellCommand(jshellEval)); features.add(new MessageCommand()); features.add(new RewriteCommand(chatGptService)); + features.add(new NumericScoreFeature(database, config.getNumericScoreConfig())); FeatureBlacklist> blacklist = blacklistConfig.normal(); return blacklist.filterStream(features.stream(), Object::getClass).toList(); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/numericscore/NumericScoreFeature.java b/application/src/main/java/org/togetherjava/tjbot/features/numericscore/NumericScoreFeature.java new file mode 100644 index 0000000000..240f956c7f --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/numericscore/NumericScoreFeature.java @@ -0,0 +1,166 @@ +package org.togetherjava.tjbot.features.numericscore; + +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.channel.Channel; +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.EmojiUnion; +import net.dv8tion.jda.api.events.channel.ChannelCreateEvent; +import net.dv8tion.jda.api.events.message.react.*; +import net.dv8tion.jda.api.hooks.ListenerAdapter; +import org.jetbrains.annotations.NotNull; +import org.jooq.Result; + +import org.togetherjava.tjbot.config.NumericScoreConfig; +import org.togetherjava.tjbot.db.Database; +import org.togetherjava.tjbot.db.generated.tables.VoteScore; +import org.togetherjava.tjbot.db.generated.tables.records.VoteScoreRecord; +import org.togetherjava.tjbot.features.EventReceiver; + +import java.util.*; +import java.util.function.BiConsumer; +import java.util.stream.Collectors; + +public class NumericScoreFeature extends ListenerAdapter implements EventReceiver { + + private final Database database; + private final List numericScoreConfig; + /** + * Map> + */ + private final Map> reverseEmojiScoreConfig; + + public NumericScoreFeature(Database database, List numericScoreConfig) { + this.database = database; + this.numericScoreConfig = numericScoreConfig; + this.reverseEmojiScoreConfig = this.numericScoreConfig.stream() + .collect(Collectors.toMap(NumericScoreConfig::forumId, c -> { + Map map = new HashMap<>(); + map.put(c.zeroScoreEmoji(), 0); + for (int i = 0; i < c.positiveScoresEmojis().size(); i++) { + map.put(c.positiveScoresEmojis().get(i), i + 1); + } + for (int i = 0; i < c.negativeScoresEmojis().size(); i++) { + map.put(c.negativeScoresEmojis().get(i), -i - 1); + } + return map; + })); + } + + /** + * Runs a function with the config found for the given thread channel. If the given channel + * isn't a thread channel in a forum or no config is found for it, nothing will happen. + * + * @param channel the supposed thread channel + * @param configConsumer the function to run with the config found on the post message + */ + private void withConfig(Channel channel, + BiConsumer configConsumer) { + if (channel instanceof ThreadChannel threadChannel + && threadChannel.getParentChannel() instanceof ForumChannel) { + numericScoreConfig.stream() + .filter(c -> c.forumId() == threadChannel.getParentChannel().getIdLong()) + .findFirst() + .ifPresent(c -> threadChannel.retrieveStartMessage() + .queue(m -> configConsumer.accept(m, c))); + } + } + + private String findStringEmojiFromScore(NumericScoreConfig config, int score) { + if (score > 0) { + score = Math.min(score, config.positiveScoresEmojis().size()); + return config.positiveScoresEmojis().get(score - 1); + } else if (score < 0) { + score = Math.min(-score, config.negativeScoresEmojis().size()); + return config.negativeScoresEmojis().get(score - 1); + } else { + return config.zeroScoreEmoji(); + } + } + + private int calculateScore(Message message) { + Result votes = database + .read(ctx -> ctx.selectFrom(VoteScore.VOTE_SCORE) + .where(VoteScore.VOTE_SCORE.MESSAGE_ID.eq(message.getIdLong()))) + .fetch(); + int upvotes = (int) votes.stream().filter(v -> v.getVote() == 1).count(); + int downvotes = (int) votes.stream().filter(v -> v.getVote() == -1).count(); + + return 1 + upvotes - downvotes; + } + + private void updateEmojis(NumericScoreConfig config, Message message) { + Map emojiToScoreMap = reverseEmojiScoreConfig.get(config.forumId()); + int score = calculateScore(message); + + if (message.getReactions() + .stream() + .map(e -> e.getEmoji().getName()) + .anyMatch(e -> Objects.equals(emojiToScoreMap.get(e), score))) { + // If the score emoji is the same + return; + } + + Emoji scoreEmoji = findEmoji(message.getGuild(), findStringEmojiFromScore(config, score)); + Emoji upvote = findEmoji(message.getGuild(), config.upvoteEmoji()); + Emoji downvote = findEmoji(message.getGuild(), config.downvoteEmoji()); + + message.clearReactions() + .flatMap(_ -> message.addReaction(scoreEmoji)) + .flatMap(_ -> message.addReaction(upvote)) + .flatMap(_ -> message.addReaction(downvote)) + .queue(); + } + + private EmojiUnion findEmoji(Guild guild, String nameOrUnicode) { + return guild.getEmojisByName(nameOrUnicode, true) + .stream() + .findAny() + .map(e -> (EmojiUnion) e) + .orElse((EmojiUnion) Emoji.fromUnicode(nameOrUnicode)); + } + + @Override + public void onChannelCreate(@NotNull ChannelCreateEvent event) { + withConfig(event.getChannel(), (post, config) -> updateEmojis(config, post)); + } + + @Override + public void onMessageReactionAdd(@NotNull MessageReactionAddEvent event) { + withConfig(event.getChannel(), (post, config) -> { + long user = Objects.requireNonNull(event.getUser()).getIdLong(); + if (user == event.getJDA().getSelfUser().getIdLong() || user == post.getIdLong()) { + return; + } + + String emoji = event.getEmoji().getName(); + int vote = 0; + if (emoji.equals(config.upvoteEmoji())) { + vote = 1; + } + if (emoji.equals(config.downvoteEmoji())) { + vote = -1; + } + + if (vote == 0) { + database.write(ctx -> ctx.deleteFrom(VoteScore.VOTE_SCORE) + .where(VoteScore.VOTE_SCORE.MESSAGE_ID.eq(event.getMessageIdLong())) + .and(VoteScore.VOTE_SCORE.USER_ID.eq(user)) + .execute()); + } else { + int vote2 = vote; + database.write(ctx -> ctx.insertInto(VoteScore.VOTE_SCORE) + .set(VoteScore.VOTE_SCORE.MESSAGE_ID, event.getMessageIdLong()) + .set(VoteScore.VOTE_SCORE.USER_ID, user) + .set(VoteScore.VOTE_SCORE.VOTE, vote2) + .onConflict(VoteScore.VOTE_SCORE.MESSAGE_ID, VoteScore.VOTE_SCORE.USER_ID) + .doUpdate() + .set(VoteScore.VOTE_SCORE.VOTE, vote2) + .execute()); + } + updateEmojis(config, post); + }); + } +} diff --git a/application/src/main/resources/db/V19__Add_Vote_Score_System.sql b/application/src/main/resources/db/V19__Add_Vote_Score_System.sql new file mode 100644 index 0000000000..c2977a7965 --- /dev/null +++ b/application/src/main/resources/db/V19__Add_Vote_Score_System.sql @@ -0,0 +1,7 @@ +CREATE TABLE vote_score +( + message_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + vote INTEGER NOT NULL, + PRIMARY KEY (message_id, user_id) +) \ No newline at end of file