diff --git a/application/config.json.template b/application/config.json.template index a1aec8f470..c53ceccf00 100644 --- a/application/config.json.template +++ b/application/config.json.template @@ -103,6 +103,10 @@ "special": [ ] }, + "starboard": { + "emojiNames" : ["⭐"], + "channelPattern": "starboard" + }, "selectRolesChannelPattern": "select-your-roles", "rssConfig": { "feeds": [ 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 e819f8e7d1..3317251adf 100644 --- a/application/src/main/java/org/togetherjava/tjbot/config/Config.java +++ b/application/src/main/java/org/togetherjava/tjbot/config/Config.java @@ -42,11 +42,13 @@ public final class Config { private final String openaiApiKey; private final String sourceCodeBaseUrl; private final JShellConfig jshell; + private final StarboardConfig starboard; private final FeatureBlacklistConfig featureBlacklistConfig; private final RSSFeedsConfig rssFeedsConfig; private final String selectRolesChannelPattern; private final String memberCountCategoryPattern; + @SuppressWarnings("ConstructorWithTooManyParameters") @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) private Config(@JsonProperty(value = "token", required = true) String token, @@ -94,7 +96,8 @@ private Config(@JsonProperty(value = "token", required = true) String token, required = true) FeatureBlacklistConfig featureBlacklistConfig, @JsonProperty(value = "rssConfig", required = true) RSSFeedsConfig rssFeedsConfig, @JsonProperty(value = "selectRolesChannelPattern", - required = true) String selectRolesChannelPattern) { + required = true) String selectRolesChannelPattern, + @JsonProperty(value = "starboard", required = true) StarboardConfig starboard) { this.token = Objects.requireNonNull(token); this.githubApiKey = Objects.requireNonNull(githubApiKey); this.databasePath = Objects.requireNonNull(databasePath); @@ -127,6 +130,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.starboard = Objects.requireNonNull(starboard); } /** @@ -391,6 +395,17 @@ public FeatureBlacklistConfig getFeatureBlacklistConfig() { return featureBlacklistConfig; } + /** + * Gets the config for the Starboard. The starboard displays certain messages in a special + * emojis{@link StarboardConfig#emojiNames()} channel {@link StarboardConfig#channelPattern()} + * if a user reacts with one of the recognized + * + * @return the config of the Starboard + */ + public StarboardConfig getStarboard() { + return starboard; + } + /** * Gets the REGEX pattern used to identify the channel in which users can select their helper * roles. diff --git a/application/src/main/java/org/togetherjava/tjbot/config/StarboardConfig.java b/application/src/main/java/org/togetherjava/tjbot/config/StarboardConfig.java new file mode 100644 index 0000000000..e55f00ed4e --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/config/StarboardConfig.java @@ -0,0 +1,53 @@ +package org.togetherjava.tjbot.config; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonRootName; + +import java.util.List; +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * Starboard Config + * + * @param emojiNames the List of emojis which are recognized by the starboard + * @param channelPattern the pattern of the channel with the starboard + */ +@JsonRootName("starboard") +public record StarboardConfig(List emojiNames, Pattern channelPattern) { + /** + * Creates a Starboard config. + * + * @param emojiNames the List of emojis which are recognized by the starboard + * @param channelPattern the pattern of the channel with the starboard + */ + @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) + public StarboardConfig { + Objects.requireNonNull(emojiNames); + Objects.requireNonNull(channelPattern); + } + + /** + * Gets the list of emotes that are recognized by the starboard feature. A message that is + * reacted on with an emote in this list will be reposted in a special channel + * {@link #channelPattern()}. + *

+ * Empty to deactivate the feature. + * + * @return The List of emojis recognized by the starboard + */ + @Override + public List emojiNames() { + return emojiNames; + } + + /** + * Gets the pattern of the channel with the starboard. Deactivate by using a non-existent + * channel name. + * + * @return the pattern of the channel with the starboard + */ + public Pattern channelPattern() { + return channelPattern; + } +} 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 9b837799ef..6fdf45a63a 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/Features.java @@ -10,6 +10,7 @@ import org.togetherjava.tjbot.features.basic.PingCommand; import org.togetherjava.tjbot.features.basic.RoleSelectCommand; import org.togetherjava.tjbot.features.basic.SlashCommandEducator; +import org.togetherjava.tjbot.features.basic.Starboard; import org.togetherjava.tjbot.features.basic.SuggestionsUpDownVoter; import org.togetherjava.tjbot.features.bookmarks.BookmarksCommand; import org.togetherjava.tjbot.features.bookmarks.BookmarksSystem; @@ -131,6 +132,7 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(new GuildLeaveCloseThreadListener(config)); features.add(new LeftoverBookmarksListener(bookmarksSystem)); features.add(new HelpThreadCreatedListener(helpSystemHelper)); + features.add(new Starboard(config, database)); // Message context commands features.add(new TransferQuestionCommand(config)); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/basic/Starboard.java b/application/src/main/java/org/togetherjava/tjbot/features/basic/Starboard.java new file mode 100644 index 0000000000..5689021eee --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/basic/Starboard.java @@ -0,0 +1,111 @@ +package org.togetherjava.tjbot.features.basic; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.entities.*; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import net.dv8tion.jda.api.entities.channel.middleman.GuildChannel; +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.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.togetherjava.tjbot.config.Config; +import org.togetherjava.tjbot.config.StarboardConfig; +import org.togetherjava.tjbot.db.Database; +import org.togetherjava.tjbot.features.EventReceiver; + +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +import static org.togetherjava.tjbot.db.generated.tables.StarboardMessages.STARBOARD_MESSAGES; + +public class Starboard extends ListenerAdapter implements EventReceiver { + + private static final Logger logger = LoggerFactory.getLogger(Starboard.class); + private final StarboardConfig config; + private final Database database; + + private final Cache messageCache; + + public Starboard(Config config, Database database) { + this.config = config.getStarboard(); + this.database = database; + this.messageCache = Caffeine.newBuilder() + .maximumSize(100) + .expireAfterAccess(24, TimeUnit.HOURS) // TODO make these constants + .build(); + } + + @Override + public void onMessageReactionAdd(@NotNull MessageReactionAddEvent event) { + String emojiName = event.getEmoji().getName(); + Guild guild = event.getGuild(); + long messageId = event.getMessageIdLong(); + if (shouldIgnoreMessage(emojiName, guild, event.getGuildChannel(), messageId, true)) { + return; + } + Optional starboardChannel = getStarboardChannel(guild); + if (starboardChannel.isEmpty()) { + logger.warn("There is no channel for the starboard in the guild with the name {}", + config.channelPattern()); + return; + } + database.write(context -> context.newRecord(STARBOARD_MESSAGES).setMessageId(messageId)); + messageCache.put(messageId, new Object()); + event.retrieveMessage() + .flatMap( + message -> starboardChannel.orElseThrow().sendMessageEmbeds(formEmbed(message))) + .queue(); + } + + @Override + public void onMessageReactionRemove(@NotNull MessageReactionRemoveEvent event) { + String emojiName = event.getEmoji().getName(); + Guild guild = event.getGuild(); + long messageId = event.getMessageIdLong(); + if (shouldIgnoreMessage(emojiName, guild, event.getGuildChannel(), messageId, false)) { + return; + } + event.retrieveMessage() + .map(m -> m.getReactions() + .stream() + .map(reaction -> reaction.getEmoji().getName()) + .noneMatch(config.emojiNames()::contains)) + .onSuccess(noGoodReactions -> { + if (noGoodReactions) { + database.write(context -> context.data().remove(messageId)); + messageCache.invalidate(messageId); + } + }) + .queue(); + } + + private boolean shouldIgnoreMessage(String emojiName, Guild guild, GuildChannel channel, + long messageId, boolean addingMessage) { + return !config.emojiNames().contains(emojiName) + || !guild.getPublicRole().hasPermission(channel, Permission.VIEW_CHANNEL) + || (addingMessage == (messageCache.getIfPresent(messageId) != null || database + .read(context -> context.fetchExists(context.selectFrom(STARBOARD_MESSAGES) + .where(STARBOARD_MESSAGES.MESSAGE_ID.eq(messageId)))))); + } + + private Optional getStarboardChannel(Guild guild) { + return guild.getTextChannels() + .stream() + .filter(channel -> config.channelPattern().matcher(channel.getName()).find()) + .findFirst(); + } + + private static MessageEmbed formEmbed(Message message) { + User author = message.getAuthor(); + return new EmbedBuilder().setAuthor(author.getName(), null, author.getAvatarUrl()) + .setDescription(message.getContentDisplay()) + .appendDescription("%n [Link](%s)".formatted(message.getJumpUrl())) + .build(); + } +} diff --git a/application/src/main/resources/db/V15__Add_Starboard.sql b/application/src/main/resources/db/V15__Add_Starboard.sql new file mode 100644 index 0000000000..d683cab67b --- /dev/null +++ b/application/src/main/resources/db/V15__Add_Starboard.sql @@ -0,0 +1,4 @@ +CREATE TABLE starboard_messages +( + message_id BIGINT NOT NULL PRIMARY KEY +)