diff --git a/application/config.json.template b/application/config.json.template index a1aec8f470..ec8737824d 100644 --- a/application/config.json.template +++ b/application/config.json.template @@ -6,6 +6,7 @@ "discordGuildInvite": "https://discord.com/invite/XXFUXzK", "modAuditLogChannelPattern": "mod-audit-log", "modMailChannelPattern": "modmail", + "projectsChannelPattern": "projects" "mutedRolePattern": "Muted", "heavyModerationRolePattern": "Moderator", "softModerationRolePattern": "Moderator|Community Ambassador", 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..5d46cae584 100644 --- a/application/src/main/java/org/togetherjava/tjbot/config/Config.java +++ b/application/src/main/java/org/togetherjava/tjbot/config/Config.java @@ -23,6 +23,7 @@ public final class Config { private final String discordGuildInvite; private final String modAuditLogChannelPattern; private final String modMailChannelPattern; + private final String projectsChannelPattern; private final String mutedRolePattern; private final String heavyModerationRolePattern; private final String softModerationRolePattern; @@ -58,6 +59,8 @@ private Config(@JsonProperty(value = "token", required = true) String token, required = true) String modAuditLogChannelPattern, @JsonProperty(value = "modMailChannelPattern", required = true) String modMailChannelPattern, + @JsonProperty(value = "projectsChannelPattern", + required = true) String projectsChannelPattern, @JsonProperty(value = "mutedRolePattern", required = true) String mutedRolePattern, @JsonProperty(value = "heavyModerationRolePattern", required = true) String heavyModerationRolePattern, @@ -103,6 +106,7 @@ private Config(@JsonProperty(value = "token", required = true) String token, this.discordGuildInvite = Objects.requireNonNull(discordGuildInvite); this.modAuditLogChannelPattern = Objects.requireNonNull(modAuditLogChannelPattern); this.modMailChannelPattern = Objects.requireNonNull(modMailChannelPattern); + this.projectsChannelPattern = Objects.requireNonNull(projectsChannelPattern); this.mutedRolePattern = Objects.requireNonNull(mutedRolePattern); this.heavyModerationRolePattern = Objects.requireNonNull(heavyModerationRolePattern); this.softModerationRolePattern = Objects.requireNonNull(softModerationRolePattern); @@ -170,6 +174,16 @@ public String getModMailChannelPattern() { return modMailChannelPattern; } + /** + * Gets the REGEX pattern used to identify the channel that is supposed to contain information + * about user projects + * + * @return the channel name pattern + */ + public String getProjectsChannelPattern() { + return projectsChannelPattern; + } + /** * Gets the token of the Discord bot to connect this application to. * 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 893adbc00f..82b1e327e1 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/Features.java @@ -62,6 +62,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.ProjectsThreadCreatedListener; import org.togetherjava.tjbot.features.reminder.RemindRoutine; import org.togetherjava.tjbot.features.reminder.ReminderCommand; import org.togetherjava.tjbot.features.system.BotCore; @@ -157,6 +158,7 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(new LeftoverBookmarksListener(bookmarksSystem)); features.add(new HelpThreadCreatedListener(helpSystemHelper)); features.add(new HelpThreadLifecycleListener(helpSystemHelper, database)); + features.add(new ProjectsThreadCreatedListener(config)); // Message context commands features.add(new TransferQuestionCommand(config, chatGptService)); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/projects/ProjectsThreadCreatedListener.java b/application/src/main/java/org/togetherjava/tjbot/features/projects/ProjectsThreadCreatedListener.java new file mode 100644 index 0000000000..74c961594f --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/projects/ProjectsThreadCreatedListener.java @@ -0,0 +1,62 @@ +package org.togetherjava.tjbot.features.projects; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import net.dv8tion.jda.api.entities.channel.Channel; +import net.dv8tion.jda.api.entities.channel.concrete.ThreadChannel; +import net.dv8tion.jda.api.events.message.MessageReceivedEvent; +import net.dv8tion.jda.api.hooks.ListenerAdapter; + +import org.togetherjava.tjbot.config.Config; +import org.togetherjava.tjbot.features.EventReceiver; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.concurrent.TimeUnit; + +/** + * Listens for new threads being created in the "projects" forum and pins the first message. * + * {@link Config#getProjectsChannelPattern()}. + */ +public final class ProjectsThreadCreatedListener extends ListenerAdapter implements EventReceiver { + private final String configProjectsChannelPattern; + private final Cache threadIdToCreatedAtCache = Caffeine.newBuilder() + .maximumSize(1_000) + .expireAfterAccess(2, TimeUnit.of(ChronoUnit.MINUTES)) + .build(); + + public ProjectsThreadCreatedListener(Config config) { + configProjectsChannelPattern = config.getProjectsChannelPattern(); + } + + @Override + public void onMessageReceived(MessageReceivedEvent event) { + if (event.isFromThread()) { + ThreadChannel threadChannel = event.getChannel().asThreadChannel(); + Channel parentChannel = threadChannel.getParentChannel(); + boolean isPost = isPostMessage(threadChannel, event); + + if (parentChannel.getName().equals(configProjectsChannelPattern) && isPost) { + pinParentMessage(event); + } + } + } + + private boolean wasThreadAlreadyHandled(long threadChannelId) { + Instant now = Instant.now(); + Instant createdAt = threadIdToCreatedAtCache.get(threadChannelId, any -> now); + return createdAt != now; + } + + private boolean isPostMessage(ThreadChannel threadChannel, MessageReceivedEvent event) { + int messageCount = threadChannel.getMessageCount(); + if (messageCount <= 1 && !wasThreadAlreadyHandled(threadChannel.getIdLong())) { + return event.getMessageId().equals(threadChannel.getId()); + } + return false; + } + + private void pinParentMessage(MessageReceivedEvent event) { + event.getMessage().pin().queue(); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/projects/package-info.java b/application/src/main/java/org/togetherjava/tjbot/features/projects/package-info.java new file mode 100644 index 0000000000..614cc24ed9 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/projects/package-info.java @@ -0,0 +1,11 @@ +/** + * This packages offers all the functionality for the projects channel. The core class is + * {@link org.togetherjava.tjbot.features.projects.ProjectsThreadCreatedListener}. + */ +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package org.togetherjava.tjbot.features.projects; + +import org.togetherjava.tjbot.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault;