diff --git a/PP.md b/PP.md index ae5cce3537..31544ac609 100644 --- a/PP.md +++ b/PP.md @@ -48,6 +48,13 @@ The databases may store * `guild_id` of guilds the **bot** is member of (the unique id of a Discord guild), * `channel_id` of channels belonging to guilds the **bot** is member of (the unique id of a Discord channel), * `message_id` of messages send by users in guilds the **bot** is member of (the unique id of a Discord message), +* `participant_count` of no of people who participated in help thread discussions, +* `tags` aka categories to which these help threads belong to, +* `timestamp`s for both when thread was created and closed, +* `message_count` the no of messages that were sent in lifecycle of any help thread + +_Note: Help threads are just threads that are created via forum channels, used for anyone to ask questions and get help +in certain problems._ and any combination of those. @@ -55,7 +62,7 @@ For example, **TJ-Bot** may associate your `user_id` with a `message_id` and a ` **TJ-Bot** may further store data that you explicitly provided for **TJ-Bot** to offer its services. For example the reason of a moderative action when using its moderation commands. -Furthermore, upon utilization of our help service, `user_id`s and `channel_id`s are stored to track when/how many questions a user asks. The data may be stored for up to **30** days. +Furthermore, upon utilization of our help service, `user_id`s and `channel_id`s are stored to track when/how many questions a user asks. The data may be stored for up to **180** days. The stored data is not linked to any information that is personally identifiable. 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 569db4a881..893adbc00f 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/Features.java @@ -29,7 +29,9 @@ import org.togetherjava.tjbot.features.help.HelpThreadAutoArchiver; import org.togetherjava.tjbot.features.help.HelpThreadCommand; import org.togetherjava.tjbot.features.help.HelpThreadCreatedListener; +import org.togetherjava.tjbot.features.help.HelpThreadLifecycleListener; import org.togetherjava.tjbot.features.help.HelpThreadMetadataPurger; +import org.togetherjava.tjbot.features.help.MarkHelpThreadCloseInDBRoutine; import org.togetherjava.tjbot.features.help.PinnedNotificationRemover; import org.togetherjava.tjbot.features.javamail.RSSHandlerRoutine; import org.togetherjava.tjbot.features.jshell.JShellCommand; @@ -113,6 +115,8 @@ public static Collection createFeatures(JDA jda, Database database, Con new CodeMessageHandler(blacklistConfig.special(), jshellEval); ChatGptService chatGptService = new ChatGptService(config); HelpSystemHelper helpSystemHelper = new HelpSystemHelper(config, database, chatGptService); + HelpThreadLifecycleListener helpThreadLifecycleListener = + new HelpThreadLifecycleListener(helpSystemHelper, database); // NOTE The system can add special system relevant commands also by itself, // hence this list may not necessarily represent the full list of all commands actually @@ -129,6 +133,7 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(new HelpThreadActivityUpdater(helpSystemHelper)); features.add(new HelpThreadAutoArchiver(helpSystemHelper)); features.add(new LeftoverBookmarksCleanupRoutine(bookmarksSystem)); + features.add(new MarkHelpThreadCloseInDBRoutine(database, helpThreadLifecycleListener)); features.add(new MemberCountDisplayRoutine(config)); features.add(new RSSHandlerRoutine(config, database)); @@ -151,6 +156,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 HelpThreadLifecycleListener(helpSystemHelper, database)); // Message context commands features.add(new TransferQuestionCommand(config, chatGptService)); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpSystemHelper.java b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpSystemHelper.java index a2aff66bfb..27022fcf5f 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpSystemHelper.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpSystemHelper.java @@ -29,11 +29,11 @@ import org.togetherjava.tjbot.features.componentids.ComponentIdInteractor; import java.awt.Color; +import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Comparator; -import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; @@ -66,6 +66,7 @@ public final class HelpSystemHelper { private final Set categories; private final Set threadActivityTagNames; private final String categoryRoleSuffix; + private final Database database; private final ChatGptService chatGptService; private static final int MAX_QUESTION_LENGTH = 200; @@ -90,7 +91,10 @@ public HelpSystemHelper(Config config, Database database, ChatGptService chatGpt isHelpForumName = Pattern.compile(helpForumPattern).asMatchPredicate(); List categoriesList = helpConfig.getCategories(); - categories = new HashSet<>(categoriesList); + categories = categoriesList.stream() + .map(String::strip) + .map(String::toLowerCase) + .collect(Collectors.toSet()); categoryRoleSuffix = helpConfig.getCategoryRoleSuffix(); Map categoryToCommonDesc = IntStream.range(0, categoriesList.size()) @@ -104,6 +108,8 @@ public HelpSystemHelper(Config config, Database database, ChatGptService chatGpt threadActivityTagNames = Arrays.stream(ThreadActivity.values()) .map(ThreadActivity::getTagName) .collect(Collectors.toSet()); + + } /** @@ -221,11 +227,22 @@ private RestAction useChatGptFallbackMessage(ThreadChannel threadChanne } void writeHelpThreadToDatabase(long authorId, ThreadChannel threadChannel) { + + Instant createdAt = threadChannel.getTimeCreated().toInstant(); + + String appliedTags = threadChannel.getAppliedTags() + .stream() + .filter(this::shouldIgnoreTag) + .map(ForumTag::getName) + .collect(Collectors.joining(",")); + database.write(content -> { HelpThreadsRecord helpThreadsRecord = content.newRecord(HelpThreads.HELP_THREADS) .setAuthorId(authorId) .setChannelId(threadChannel.getIdLong()) - .setCreatedAt(threadChannel.getTimeCreated().toInstant()); + .setCreatedAt(createdAt) + .setTags(appliedTags) + .setTicketStatus(TicketStatus.ACTIVE.val); if (helpThreadsRecord.update() == 0) { helpThreadsRecord.insert(); } @@ -265,7 +282,7 @@ private Optional getFirstMatchingTagOfChannel(Set tagNamesToMa ThreadChannel channel) { return channel.getAppliedTags() .stream() - .filter(tag -> tagNamesToMatch.contains(tag.getName())) + .filter(tag -> tagNamesToMatch.contains(tag.getName().toLowerCase())) .min(byCategoryCommonnessAsc); } @@ -375,6 +392,17 @@ public String getTagName() { } } + enum TicketStatus { + ARCHIVED(0), + ACTIVE(1); + + final int val; + + TicketStatus(int val) { + this.val = val; + } + } + Optional getAuthorByHelpThreadId(final long channelId) { logger.debug("Looking for thread-record using channel ID: {}", channelId); @@ -384,4 +412,15 @@ Optional getAuthorByHelpThreadId(final long channelId) { .where(HelpThreads.HELP_THREADS.CHANNEL_ID.eq(channelId)) .fetchOptional(HelpThreads.HELP_THREADS.AUTHOR_ID)); } + + + /** + * will be used to filter a tag based on categories config + * + * @param tag applied tag + * @return boolean result whether to ignore this tag or not + */ + boolean shouldIgnoreTag(ForumTag tag) { + return this.categories.contains(tag.getName().toLowerCase()); + } } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadCreatedListener.java b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadCreatedListener.java index 02a959e5e4..306aaed2a2 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadCreatedListener.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadCreatedListener.java @@ -14,7 +14,6 @@ import net.dv8tion.jda.api.events.message.MessageReceivedEvent; import net.dv8tion.jda.api.hooks.ListenerAdapter; import net.dv8tion.jda.api.requests.RestAction; -import org.jetbrains.annotations.NotNull; import org.togetherjava.tjbot.features.EventReceiver; import org.togetherjava.tjbot.features.UserInteractionType; @@ -58,7 +57,7 @@ public HelpThreadCreatedListener(HelpSystemHelper helper) { } @Override - public void onMessageReceived(@NotNull MessageReceivedEvent event) { + public void onMessageReceived(MessageReceivedEvent event) { if (event.isFromThread()) { ThreadChannel threadChannel = event.getChannel().asThreadChannel(); Channel parentChannel = threadChannel.getParentChannel(); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadLifecycleListener.java b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadLifecycleListener.java new file mode 100644 index 0000000000..3c2e778ba1 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadLifecycleListener.java @@ -0,0 +1,130 @@ +package org.togetherjava.tjbot.features.help; + +import net.dv8tion.jda.api.entities.channel.concrete.ThreadChannel; +import net.dv8tion.jda.api.entities.channel.forums.ForumTag; +import net.dv8tion.jda.api.events.channel.update.ChannelUpdateAppliedTagsEvent; +import net.dv8tion.jda.api.events.channel.update.ChannelUpdateArchivedEvent; +import net.dv8tion.jda.api.hooks.ListenerAdapter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.togetherjava.tjbot.db.Database; +import org.togetherjava.tjbot.features.EventReceiver; + +import java.time.Instant; +import java.util.List; +import java.util.stream.Collectors; + +import static org.togetherjava.tjbot.db.generated.tables.HelpThreads.HELP_THREADS; + +/** + * Listens for help thread events after creation of thread. Updates metadata based on those events + * in database. + */ +public final class HelpThreadLifecycleListener extends ListenerAdapter implements EventReceiver { + private static final Logger logger = LoggerFactory.getLogger(HelpThreadLifecycleListener.class); + private final HelpSystemHelper helper; + private final Database database; + + /** + * Creates a new instance. + * + * @param helper to work with the help threads + * @param database the database to store help thread metadata in + */ + public HelpThreadLifecycleListener(HelpSystemHelper helper, Database database) { + this.helper = helper; + this.database = database; + } + + @Override + public void onChannelUpdateArchived(ChannelUpdateArchivedEvent event) { + ThreadChannel threadChannel = event.getChannel().asThreadChannel(); + + if (!helper.isHelpForumName(threadChannel.getParentChannel().getName())) { + return; + } + handleThreadStatus(threadChannel); + } + + @Override + public void onChannelUpdateAppliedTags(ChannelUpdateAppliedTagsEvent event) { + ThreadChannel threadChannel = event.getChannel().asThreadChannel(); + + if (!helper.isHelpForumName(threadChannel.getParentChannel().getName()) + || shouldIgnoreUpdatedTagEvent(event)) { + return; + } + + + String newlyAppliedTagsOnly = event.getNewTags() + .stream() + .filter(helper::shouldIgnoreTag) + .map(ForumTag::getName) + .collect(Collectors.joining(",")); + + + long threadId = threadChannel.getIdLong(); + + handleTagsUpdate(threadId, newlyAppliedTagsOnly); + } + + private void handleThreadStatus(ThreadChannel threadChannel) { + Instant closedAt = threadChannel.getTimeArchiveInfoLastModified().toInstant(); + long threadId = threadChannel.getIdLong(); + boolean isArchived = threadChannel.isArchived(); + + if (isArchived) { + handleArchiveStatus(closedAt, threadChannel); + return; + } + + updateThreadStatusToActive(threadId); + } + + void handleArchiveStatus(Instant closedAt, ThreadChannel threadChannel) { + long threadId = threadChannel.getIdLong(); + int messageCount = threadChannel.getMessageCount(); + int participantsExceptAuthor = threadChannel.getMemberCount() - 1; + + database.write(context -> context.update(HELP_THREADS) + .set(HELP_THREADS.CLOSED_AT, closedAt) + .set(HELP_THREADS.TICKET_STATUS, HelpSystemHelper.TicketStatus.ARCHIVED.val) + .set(HELP_THREADS.MESSAGE_COUNT, messageCount) + .set(HELP_THREADS.PARTICIPANTS, participantsExceptAuthor) + .where(HELP_THREADS.CHANNEL_ID.eq(threadId)) + .execute()); + + logger.info("Thread with id: {}, updated to archived status in database", threadId); + } + + private void updateThreadStatusToActive(long threadId) { + database.write(context -> context.update(HELP_THREADS) + .set(HELP_THREADS.TICKET_STATUS, HelpSystemHelper.TicketStatus.ACTIVE.val) + .where(HELP_THREADS.CHANNEL_ID.eq(threadId)) + .execute()); + + logger.info("Thread with id: {}, updated to active status in database", threadId); + } + + private void handleTagsUpdate(long threadId, String updatedTag) { + database.write(context -> context.update(HELP_THREADS) + .set(HELP_THREADS.TAGS, updatedTag) + .where(HELP_THREADS.CHANNEL_ID.eq(threadId)) + .execute()); + + logger.info("Updated tag for thread with id: {} in database", threadId); + } + + /** + * will ignore updated tag event if all new tags belong to the categories config + * + * @param event updated tags event + * @return boolean + */ + private boolean shouldIgnoreUpdatedTagEvent(ChannelUpdateAppliedTagsEvent event) { + List newTags = + event.getNewTags().stream().filter(helper::shouldIgnoreTag).toList(); + return newTags.isEmpty(); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadMetadataPurger.java b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadMetadataPurger.java index 457a222a4a..794a8391c0 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadMetadataPurger.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadMetadataPurger.java @@ -18,7 +18,7 @@ public class HelpThreadMetadataPurger implements Routine { private final Database database; private static final Logger logger = LoggerFactory.getLogger(HelpThreadMetadataPurger.class); - private static final Period DELETE_MESSAGE_RECORDS_AFTER = Period.ofDays(30); + private static final Period DELETE_MESSAGE_RECORDS_AFTER = Period.ofDays(180); /** * Creates a new instance. @@ -31,7 +31,7 @@ public HelpThreadMetadataPurger(Database database) { @Override public Schedule createSchedule() { - return new Schedule(ScheduleMode.FIXED_RATE, 0, 4, TimeUnit.HOURS); + return new Schedule(ScheduleMode.FIXED_RATE, 0, 1, TimeUnit.DAYS); } @Override diff --git a/application/src/main/java/org/togetherjava/tjbot/features/help/MarkHelpThreadCloseInDBRoutine.java b/application/src/main/java/org/togetherjava/tjbot/features/help/MarkHelpThreadCloseInDBRoutine.java new file mode 100644 index 0000000000..ec96d77c72 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/help/MarkHelpThreadCloseInDBRoutine.java @@ -0,0 +1,72 @@ +package org.togetherjava.tjbot.features.help; + +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.entities.channel.concrete.ThreadChannel; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.togetherjava.tjbot.db.Database; +import org.togetherjava.tjbot.db.generated.tables.records.HelpThreadsRecord; +import org.togetherjava.tjbot.features.Routine; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static org.togetherjava.tjbot.db.generated.tables.HelpThreads.HELP_THREADS; + +/** + * Updates the status of help threads in database that were created a few days ago and couldn't be + * closed. + */ +public final class MarkHelpThreadCloseInDBRoutine implements Routine { + private static final Logger logger = + LoggerFactory.getLogger(MarkHelpThreadCloseInDBRoutine.class); + private final Database database; + private final HelpThreadLifecycleListener helpThreadLifecycleListener; + + /** + * Creates a new instance. + * + * @param database the database to store help thread metadata in + * @param helpThreadLifecycleListener class which offers method to update thread status in + * database + */ + public MarkHelpThreadCloseInDBRoutine(Database database, + HelpThreadLifecycleListener helpThreadLifecycleListener) { + this.database = database; + this.helpThreadLifecycleListener = helpThreadLifecycleListener; + } + + @Override + public Schedule createSchedule() { + return new Schedule(ScheduleMode.FIXED_RATE, 0, 24, TimeUnit.HOURS); + } + + @Override + public void runRoutine(JDA jda) { + updateTicketStatus(jda); + } + + private void updateTicketStatus(JDA jda) { + Instant now = Instant.now(); + Instant threeDaysAgo = now.minus(3, ChronoUnit.DAYS); + List threadIdsToClose = database.read(context -> context.selectFrom(HELP_THREADS) + .where(HELP_THREADS.TICKET_STATUS.eq(HelpSystemHelper.TicketStatus.ACTIVE.val)) + .and(HELP_THREADS.CREATED_AT.lessThan(threeDaysAgo)) + .stream() + .map(HelpThreadsRecord::getChannelId) + .toList()); + + + threadIdsToClose.forEach(id -> { + try { + ThreadChannel threadChannel = jda.getThreadChannelById(id); + helpThreadLifecycleListener.handleArchiveStatus(now, threadChannel); + } catch (Exception exception) { + logger.warn("unable to mark thread as close with id :{}", id, exception); + } + }); + } +} diff --git a/application/src/main/resources/db/V15__Alter_Help_Thread_Metadata.sql b/application/src/main/resources/db/V15__Alter_Help_Thread_Metadata.sql new file mode 100644 index 0000000000..1e7235d3b6 --- /dev/null +++ b/application/src/main/resources/db/V15__Alter_Help_Thread_Metadata.sql @@ -0,0 +1,5 @@ +ALTER TABLE help_threads ADD ticket_status INTEGER DEFAULT 0; +ALTER TABLE help_threads ADD tags TEXT DEFAULT 'none'; +ALTER TABLE help_threads ADD closed_at TIMESTAMP NULL; +ALTER TABLE help_threads ADD participants INTEGER DEFAULT 1; +ALTER TABLE help_threads ADD message_count INTEGER DEFAULT 0; \ No newline at end of file