From 8e1ae7d24352ce331c9d46ec09497e540c4050dd Mon Sep 17 00:00:00 2001 From: vishv843 <201901453@daiict.ac.in> Date: Thu, 29 Aug 2024 12:14:46 -0400 Subject: [PATCH 01/14] added help stats command --- .../togetherjava/tjbot/features/Features.java | 12 +- .../features/help/HelpThreadStatsCommand.java | 156 ++++++++++++++++++ 2 files changed, 158 insertions(+), 10 deletions(-) create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java 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..657dd1f5b7 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/Features.java @@ -23,16 +23,7 @@ import org.togetherjava.tjbot.features.filesharing.FileSharingMessageListener; import org.togetherjava.tjbot.features.github.GitHubCommand; import org.togetherjava.tjbot.features.github.GitHubReference; -import org.togetherjava.tjbot.features.help.GuildLeaveCloseThreadListener; -import org.togetherjava.tjbot.features.help.HelpSystemHelper; -import org.togetherjava.tjbot.features.help.HelpThreadActivityUpdater; -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.help.*; import org.togetherjava.tjbot.features.javamail.RSSHandlerRoutine; import org.togetherjava.tjbot.features.jshell.JShellCommand; import org.togetherjava.tjbot.features.jshell.JShellEval; @@ -192,6 +183,7 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(new BookmarksCommand(bookmarksSystem)); features.add(new ChatGptCommand(chatGptService, helpSystemHelper)); features.add(new JShellCommand(jshellEval)); + features.add(new HelpThreadStatsCommand()); FeatureBlacklist> blacklist = blacklistConfig.normal(); return blacklist.filterStream(features.stream(), Object::getClass).toList(); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java new file mode 100644 index 0000000000..c61ea74f8a --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java @@ -0,0 +1,156 @@ +package org.togetherjava.tjbot.features.help; + +import net.dv8tion.jda.api.entities.channel.concrete.ForumChannel; +import net.dv8tion.jda.api.entities.channel.concrete.ThreadChannel; +import net.dv8tion.jda.api.entities.channel.forums.ForumTag; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandGroupData; +import net.dv8tion.jda.api.requests.restaction.pagination.ThreadChannelPaginationAction; + +import org.togetherjava.tjbot.features.CommandVisibility; +import org.togetherjava.tjbot.features.SlashCommandAdapter; + +import java.time.OffsetDateTime; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static java.util.stream.Collectors.averagingDouble; +import static java.util.stream.Collectors.toMap; + +public class HelpThreadStatsCommand extends SlashCommandAdapter { + + public static final String COMMAND_NAME = "help-thread-stats"; + public static final String DURATION_OPTION = "duration-option"; + public static final String DURATION_SUBCOMMAND = "duration"; + public static final String OPTIONAL_SUBCOMMAND_GROUP = "optional"; + private final Map nameToSubcommand; + + public HelpThreadStatsCommand() { + super(COMMAND_NAME, "Display Help Thread Statistics", CommandVisibility.GUILD); + OptionData durationOption = + new OptionData(OptionType.STRING, DURATION_OPTION, "optional duration", false) + .setMinLength(1); + SubcommandData duration = Subcommand.DURATION.toSubcommandData().addOptions(durationOption); + SubcommandGroupData optionalCommands = + new SubcommandGroupData(OPTIONAL_SUBCOMMAND_GROUP, "optional commands") + .addSubcommands(duration); + getData().addSubcommandGroups(optionalCommands); + nameToSubcommand = streamSubcommands() + .collect(Collectors.toMap(Subcommand::getCommandName, Function.identity())); + } + + @Override + public void onSlashCommand(SlashCommandInteractionEvent event) { + List forumChannels = + Objects.requireNonNull(event.getGuild()).getForumChannels(); + Subcommand invokedSubcommand = nameToSubcommand.get(event.getSubcommandName()); + OffsetDateTime startDate = OffsetDateTime.MIN; + if (Objects.nonNull(invokedSubcommand) && invokedSubcommand.equals(Subcommand.DURATION) + && Objects.nonNull(event.getOption(DURATION_OPTION))) { + startDate = + OffsetDateTime.now().minusDays(event.getOption(DURATION_OPTION).getAsLong()); + } + ForumTag mostPopularTag = getMostPopularForumTag(forumChannels, startDate); + Double averageNumberOfParticipants = + getAverageNumberOfParticipantsPerThread(forumChannels, startDate); + Integer totalNumberOfThreads = + getThreadChannelsStream(forumChannels, startDate).toList().size(); + Long emptyThreads = getThreadsWithNoParticipants(forumChannels, startDate); + Integer totalMessages = getTotalNumberOfMessages(forumChannels, startDate); + Double averageNumberOfMessages = Double.valueOf(totalMessages) / totalNumberOfThreads; + Double averageThreadLifecycle = getAverageThreadLifecycle(forumChannels, startDate); + String statistics = + "Most Popular Tag: %s%nAverage Number Of Participants: %.2f%nEmpty Threads: %s%nAverage Number Of Messages: %.2f%nAverage Thread Lifecycle: %.2f" + .formatted(mostPopularTag.getName(), averageNumberOfParticipants, emptyThreads, + averageNumberOfMessages, averageThreadLifecycle); + event.reply(statistics).delay(2, TimeUnit.SECONDS).queue(); + } + + private ForumTag getMostPopularForumTag(List forumChannels, + OffsetDateTime startDate) { + Map tagCount = getThreadChannelsStream(forumChannels, startDate) + .flatMap((threadChannel -> threadChannel.getAppliedTags().stream())) + .collect(toMap(Function.identity(), tag -> 1, Integer::sum)); + return Collections.max(tagCount.entrySet(), Map.Entry.comparingByValue()).getKey(); + } + + private Double getAverageNumberOfParticipantsPerThread(List forumChannels, + OffsetDateTime startDate) { + return getThreadChannelsStream(forumChannels, startDate) + .collect(averagingDouble((ThreadChannel::getMemberCount))); + } + + private Long getThreadsWithNoParticipants(List forumChannels, + OffsetDateTime startDate) { + return getThreadChannelsStream(forumChannels, startDate) + .filter(threadChannel -> threadChannel.getMemberCount() > 1) + .count(); + } + + private Integer getTotalNumberOfMessages(List forumChannels, + OffsetDateTime startDate) { + return getThreadChannelsStream(forumChannels, startDate) + .mapToInt(ThreadChannel::getMessageCount) + .sum(); + } + + private Double getAverageThreadLifecycle(List forumChannels, + OffsetDateTime startDate) { + return getThreadChannelsStream(forumChannels, startDate).filter(ThreadChannel::isArchived) + .mapToDouble(threadChannel -> calculateDurationInDays( + threadChannel.getTimeArchiveInfoLastModified(), threadChannel.getTimeCreated())) + .average() + .orElse(0); + } + + private Double calculateDurationInDays(OffsetDateTime t1, OffsetDateTime t2) { + long time1 = t1.toEpochSecond(); + long time2 = t2.toEpochSecond(); + return (time1 - time2) / 86400.0; + } + + private Stream getThreadChannelsStream(List forumChannels, + OffsetDateTime startDate) { + return forumChannels.stream() + .flatMap(forumChannel -> getAllThreadChannels(forumChannel).stream()) + .filter(threadChannel -> threadChannel.getTimeCreated().isAfter(startDate)); + } + + private Set getAllThreadChannels(ForumChannel forumChannel) { + Set threadChannels = new HashSet<>(forumChannel.getThreadChannels()); + Optional publicThreadChannels = + Optional.of(forumChannel.retrieveArchivedPublicThreadChannels()); + publicThreadChannels.ifPresent(threads -> threads.forEach(threadChannels::add)); + return threadChannels; + } + + private static Stream streamSubcommands() { + return Arrays.stream(Subcommand.values()); + } + + enum Subcommand { + DURATION(DURATION_SUBCOMMAND, "Set the duration"); + + private final String commandName; + private final String description; + + Subcommand(String commandName, String description) { + this.commandName = commandName; + this.description = description; + } + + String getCommandName() { + return commandName; + } + + SubcommandData toSubcommandData() { + return new SubcommandData(commandName, description); + } + } +} From a1e9ab5acfb901d55ce781d0584612b079b55e31 Mon Sep 17 00:00:00 2001 From: Ankit Yadav Date: Sun, 21 Dec 2025 16:20:08 +0530 Subject: [PATCH 02/14] refactor: help-stats slash command replaces fetching metrics directly from database instead of discord, uses embed for showcases stats and making duration as optional choice in terms of days --- .../togetherjava/tjbot/features/Features.java | 2 +- .../features/help/HelpThreadStatsCommand.java | 270 ++++++++++-------- 2 files changed, 144 insertions(+), 128 deletions(-) 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 aa95930c07..b9d339be95 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/Features.java @@ -204,7 +204,7 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(new BookmarksCommand(bookmarksSystem)); features.add(new ChatGptCommand(chatGptService, helpSystemHelper)); features.add(new JShellCommand(jshellEval)); - features.add(new HelpThreadStatsCommand()); + features.add(new HelpThreadStatsCommand(database)); FeatureBlacklist> blacklist = blacklistConfig.normal(); return blacklist.filterStream(features.stream(), Object::getClass).toList(); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java index c61ea74f8a..efab34a5c8 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java @@ -1,156 +1,172 @@ package org.togetherjava.tjbot.features.help; -import net.dv8tion.jda.api.entities.channel.concrete.ForumChannel; -import net.dv8tion.jda.api.entities.channel.concrete.ThreadChannel; -import net.dv8tion.jda.api.entities.channel.forums.ForumTag; +import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; import net.dv8tion.jda.api.interactions.commands.OptionType; import net.dv8tion.jda.api.interactions.commands.build.OptionData; -import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; -import net.dv8tion.jda.api.interactions.commands.build.SubcommandGroupData; -import net.dv8tion.jda.api.requests.restaction.pagination.ThreadChannelPaginationAction; - +import org.jooq.DSLContext; +import org.jooq.OrderField; +import org.jooq.Record1; +import org.togetherjava.tjbot.db.Database; import org.togetherjava.tjbot.features.CommandVisibility; import org.togetherjava.tjbot.features.SlashCommandAdapter; -import java.time.OffsetDateTime; -import java.util.*; -import java.util.concurrent.TimeUnit; -import java.util.function.Function; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -import static java.util.stream.Collectors.averagingDouble; -import static java.util.stream.Collectors.toMap; - +import java.awt.Color; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Objects; + +import static org.jooq.impl.DSL.*; +import static org.togetherjava.tjbot.db.generated.Tables.HELP_THREADS; + +/** + * Implements the '/help-thread-stats' command which provides analytical insights into the + * help forum's activity over a specific duration. + *

+ * Example usage: + *

+ * {@code
+ * /help-thread-stats duration-option: 7 Days
+ * }
+ * 
+ *

+ * The command aggregates data such as response rates, engagement metrics (messages/helpers), + * tag popularity, and resolution speeds. + */ public class HelpThreadStatsCommand extends SlashCommandAdapter { - public static final String COMMAND_NAME = "help-thread-stats"; public static final String DURATION_OPTION = "duration-option"; - public static final String DURATION_SUBCOMMAND = "duration"; - public static final String OPTIONAL_SUBCOMMAND_GROUP = "optional"; - private final Map nameToSubcommand; - - public HelpThreadStatsCommand() { - super(COMMAND_NAME, "Display Help Thread Statistics", CommandVisibility.GUILD); - OptionData durationOption = - new OptionData(OptionType.STRING, DURATION_OPTION, "optional duration", false) - .setMinLength(1); - SubcommandData duration = Subcommand.DURATION.toSubcommandData().addOptions(durationOption); - SubcommandGroupData optionalCommands = - new SubcommandGroupData(OPTIONAL_SUBCOMMAND_GROUP, "optional commands") - .addSubcommands(duration); - getData().addSubcommandGroups(optionalCommands); - nameToSubcommand = streamSubcommands() - .collect(Collectors.toMap(Subcommand::getCommandName, Function.identity())); - } - @Override - public void onSlashCommand(SlashCommandInteractionEvent event) { - List forumChannels = - Objects.requireNonNull(event.getGuild()).getForumChannels(); - Subcommand invokedSubcommand = nameToSubcommand.get(event.getSubcommandName()); - OffsetDateTime startDate = OffsetDateTime.MIN; - if (Objects.nonNull(invokedSubcommand) && invokedSubcommand.equals(Subcommand.DURATION) - && Objects.nonNull(event.getOption(DURATION_OPTION))) { - startDate = - OffsetDateTime.now().minusDays(event.getOption(DURATION_OPTION).getAsLong()); - } - ForumTag mostPopularTag = getMostPopularForumTag(forumChannels, startDate); - Double averageNumberOfParticipants = - getAverageNumberOfParticipantsPerThread(forumChannels, startDate); - Integer totalNumberOfThreads = - getThreadChannelsStream(forumChannels, startDate).toList().size(); - Long emptyThreads = getThreadsWithNoParticipants(forumChannels, startDate); - Integer totalMessages = getTotalNumberOfMessages(forumChannels, startDate); - Double averageNumberOfMessages = Double.valueOf(totalMessages) / totalNumberOfThreads; - Double averageThreadLifecycle = getAverageThreadLifecycle(forumChannels, startDate); - String statistics = - "Most Popular Tag: %s%nAverage Number Of Participants: %.2f%nEmpty Threads: %s%nAverage Number Of Messages: %.2f%nAverage Thread Lifecycle: %.2f" - .formatted(mostPopularTag.getName(), averageNumberOfParticipants, emptyThreads, - averageNumberOfMessages, averageThreadLifecycle); - event.reply(statistics).delay(2, TimeUnit.SECONDS).queue(); - } + private final Database database; - private ForumTag getMostPopularForumTag(List forumChannels, - OffsetDateTime startDate) { - Map tagCount = getThreadChannelsStream(forumChannels, startDate) - .flatMap((threadChannel -> threadChannel.getAppliedTags().stream())) - .collect(toMap(Function.identity(), tag -> 1, Integer::sum)); - return Collections.max(tagCount.entrySet(), Map.Entry.comparingByValue()).getKey(); - } + /** + * Creates an instance of the command. + * + * @param database the database to fetch help thread metrics from + */ + public HelpThreadStatsCommand(Database database) { + super(COMMAND_NAME, "Display Help Thread Statistics", CommandVisibility.GUILD); - private Double getAverageNumberOfParticipantsPerThread(List forumChannels, - OffsetDateTime startDate) { - return getThreadChannelsStream(forumChannels, startDate) - .collect(averagingDouble((ThreadChannel::getMemberCount))); - } + OptionData durationOption = new OptionData(OptionType.INTEGER, DURATION_OPTION, "The time range for statistics", false) + .addChoice("1 Day", 1) + .addChoice("7 Days", 7) + .addChoice("30 Days", 30) + .addChoice("90 Days", 90) + .addChoice("180 Days", 180); - private Long getThreadsWithNoParticipants(List forumChannels, - OffsetDateTime startDate) { - return getThreadChannelsStream(forumChannels, startDate) - .filter(threadChannel -> threadChannel.getMemberCount() > 1) - .count(); + getData().addOptions(durationOption); + this.database = database; } - private Integer getTotalNumberOfMessages(List forumChannels, - OffsetDateTime startDate) { - return getThreadChannelsStream(forumChannels, startDate) - .mapToInt(ThreadChannel::getMessageCount) - .sum(); + @Override + public void onSlashCommand(SlashCommandInteractionEvent event) { + long days = event.getOption(DURATION_OPTION) != null + ? Objects.requireNonNull(event.getOption(DURATION_OPTION)).getAsLong() + : 1; + Instant startDate = Instant.now().minus(days, ChronoUnit.DAYS); + + event.deferReply().queue(); + + database.read(context -> { + var statsRecord = context.select( + count().as("total_created"), + count().filterWhere(HELP_THREADS.TICKET_STATUS.eq(HelpSystemHelper.TicketStatus.ACTIVE.val)).as("open_now"), + count().filterWhere(HELP_THREADS.PARTICIPANTS.eq(1)).as("ghost_count"), + avg(HELP_THREADS.PARTICIPANTS).as("avg_parts"), + avg(HELP_THREADS.MESSAGE_COUNT).as("avg_msgs"), + avg(field("unixepoch({0}) - unixepoch({1})", Double.class, HELP_THREADS.CLOSED_AT, HELP_THREADS.CREATED_AT)).as("avg_sec"), + min(field("unixepoch({0}) - unixepoch({1})", Double.class, HELP_THREADS.CLOSED_AT, HELP_THREADS.CREATED_AT)).as("min_sec"), + max(field("unixepoch({0}) - unixepoch({1})", Double.class, HELP_THREADS.CLOSED_AT, HELP_THREADS.CREATED_AT)).as("max_sec") + ) + .from(HELP_THREADS) + .where(HELP_THREADS.CREATED_AT.ge(startDate)) + .fetchOne(); + + if (statsRecord == null || statsRecord.get("total_created", Integer.class) == 0) { + event.getHook().editOriginal("No stats available for the last " + days + " days.").queue(); + return null; + } + + int totalCreated = statsRecord.get("total_created", Integer.class); + int openThreads = statsRecord.get("open_now", Integer.class); + long ghostThreads = statsRecord.get("ghost_count", Number.class).longValue(); + + double rawResRate = totalCreated > 0 ? ((double) (totalCreated - ghostThreads) / totalCreated) * 100 : 0; + + String highVolumeTag = getTopTag(context, startDate, count().desc()); + String highActivityTag = getTopTag(context, startDate, avg(HELP_THREADS.MESSAGE_COUNT).desc()); + String lowActivityTag = getTopTag(context, startDate, avg(HELP_THREADS.MESSAGE_COUNT).asc()); + + String peakHourRange = getPeakHour(context, startDate); + + EmbedBuilder embed = new EmbedBuilder() + .setTitle("πŸ“Š Help Thread Stats (Last " + days + " Days)") + .setColor(getStatusColor(totalCreated, ghostThreads)) + .setTimestamp(Instant.now()) + .setDescription("\u200B") + .setFooter("Together Java Community Stats", Objects.requireNonNull(event.getGuild()).getIconUrl()); + + embed.addField("πŸ“ THREAD ACTIVITY", + "Created: `%d`\nCurrently Open: `%d`\nResponse Rate: %.1f%%\nPeak Hours: `%s`" + .formatted(totalCreated, openThreads, rawResRate, peakHourRange), false); + + embed.addField("πŸ’¬ ENGAGEMENT", + "Avg Messages: `%s`\nAvg Helpers: `%s`\nUnanswered (Ghost): `%d`".formatted( + formatDouble(Objects.requireNonNull(statsRecord.get("avg_msgs"))), + formatDouble(Objects.requireNonNull(statsRecord.get("avg_parts"))), + ghostThreads), false); + + embed.addField("🏷️ TAG ACTIVITY", + "Most Used: `%s`\nMost Active: `%s`\nNeeds Love: `%s`".formatted( + highVolumeTag, highActivityTag, lowActivityTag), false); + + embed.addField("⚑ RESOLUTION SPEED", + "Average: `%s`\nFastest: `%s`\nSlowest: `%s`".formatted( + smartFormat(statsRecord.get("avg_sec", Double.class)), + smartFormat(statsRecord.get("min_sec", Double.class)), + smartFormat(statsRecord.get("max_sec", Double.class))), false); + + event.getHook().editOriginalEmbeds(embed.build()).queue(); + return null; + }); } - private Double getAverageThreadLifecycle(List forumChannels, - OffsetDateTime startDate) { - return getThreadChannelsStream(forumChannels, startDate).filter(ThreadChannel::isArchived) - .mapToDouble(threadChannel -> calculateDurationInDays( - threadChannel.getTimeArchiveInfoLastModified(), threadChannel.getTimeCreated())) - .average() - .orElse(0); - } + private static Color getStatusColor(int totalCreated, long ghostThreads) { + double rawResRate = totalCreated > 0 ? ((double) (totalCreated - ghostThreads) / totalCreated) * 100 : -1; - private Double calculateDurationInDays(OffsetDateTime t1, OffsetDateTime t2) { - long time1 = t1.toEpochSecond(); - long time2 = t2.toEpochSecond(); - return (time1 - time2) / 86400.0; + if (rawResRate >= 70) return Color.GREEN; + if (rawResRate >= 30) return Color.YELLOW; + if (rawResRate >= 0) return Color.RED; + return Color.GRAY; } - private Stream getThreadChannelsStream(List forumChannels, - OffsetDateTime startDate) { - return forumChannels.stream() - .flatMap(forumChannel -> getAllThreadChannels(forumChannel).stream()) - .filter(threadChannel -> threadChannel.getTimeCreated().isAfter(startDate)); + private String getTopTag(DSLContext context, Instant start, OrderField order) { + return context.select(HELP_THREADS.TAGS).from(HELP_THREADS) + .where(HELP_THREADS.CREATED_AT.ge(start)).and(HELP_THREADS.TAGS.ne("none")) + .groupBy(HELP_THREADS.TAGS).orderBy(order).limit(1) + .fetchOptional(HELP_THREADS.TAGS).orElse("N/A"); } - private Set getAllThreadChannels(ForumChannel forumChannel) { - Set threadChannels = new HashSet<>(forumChannel.getThreadChannels()); - Optional publicThreadChannels = - Optional.of(forumChannel.retrieveArchivedPublicThreadChannels()); - publicThreadChannels.ifPresent(threads -> threads.forEach(threadChannels::add)); - return threadChannels; + private String getPeakHour(DSLContext context, Instant start) { + return context.select(field("strftime('%H', {0})", String.class, HELP_THREADS.CREATED_AT)) + .from(HELP_THREADS).where(HELP_THREADS.CREATED_AT.ge(start)) + .groupBy(field("strftime('%H', {0})", String.class, HELP_THREADS.CREATED_AT)) + .orderBy(count().desc()).limit(1).fetchOptional(Record1::value1) + .map(hour -> { + int h = Integer.parseInt(hour); + return "%02d:00 - %02d:00 UTC".formatted(h, (h + 1) % 24); + }).orElse("N/A"); } - private static Stream streamSubcommands() { - return Arrays.stream(Subcommand.values()); + private String smartFormat(Double seconds) { + if (seconds < 0) return "N/A"; + if (seconds < 60) return "%.0f secs".formatted(seconds); + if (seconds < 3600) return "%.1f mins".formatted(seconds / 60.0); + if (seconds < 86400) return "%.1f hrs".formatted(seconds / 3600.0); + return "%.1f days".formatted(seconds / 86400.0); } - enum Subcommand { - DURATION(DURATION_SUBCOMMAND, "Set the duration"); - - private final String commandName; - private final String description; - - Subcommand(String commandName, String description) { - this.commandName = commandName; - this.description = description; - } - - String getCommandName() { - return commandName; - } - - SubcommandData toSubcommandData() { - return new SubcommandData(commandName, description); - } + private String formatDouble(Object val) { + return val instanceof Number num ? "%.2f".formatted(num.doubleValue()) : "0.00"; } -} +} \ No newline at end of file From 70fafefa6b0446d8eaaafa222e1827c6fcbdb79f Mon Sep 17 00:00:00 2001 From: Ankit Yadav Date: Sun, 21 Dec 2025 16:38:20 +0530 Subject: [PATCH 03/14] chore: spotless fix --- .../features/help/HelpThreadStatsCommand.java | 157 +++++++++++------- 1 file changed, 98 insertions(+), 59 deletions(-) diff --git a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java index efab34a5c8..9b3bd08788 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java @@ -7,6 +7,7 @@ import org.jooq.DSLContext; import org.jooq.OrderField; import org.jooq.Record1; + import org.togetherjava.tjbot.db.Database; import org.togetherjava.tjbot.features.CommandVisibility; import org.togetherjava.tjbot.features.SlashCommandAdapter; @@ -20,18 +21,19 @@ import static org.togetherjava.tjbot.db.generated.Tables.HELP_THREADS; /** - * Implements the '/help-thread-stats' command which provides analytical insights into the - * help forum's activity over a specific duration. + * Implements the '/help-thread-stats' command which provides analytical insights into the help + * forum's activity over a specific duration. *

* Example usage: + * *

  * {@code
  * /help-thread-stats duration-option: 7 Days
  * }
  * 
*

- * The command aggregates data such as response rates, engagement metrics (messages/helpers), - * tag popularity, and resolution speeds. + * The command aggregates data such as response rates, engagement metrics (messages/helpers), tag + * popularity, and resolution speeds. */ public class HelpThreadStatsCommand extends SlashCommandAdapter { public static final String COMMAND_NAME = "help-thread-stats"; @@ -47,12 +49,13 @@ public class HelpThreadStatsCommand extends SlashCommandAdapter { public HelpThreadStatsCommand(Database database) { super(COMMAND_NAME, "Display Help Thread Statistics", CommandVisibility.GUILD); - OptionData durationOption = new OptionData(OptionType.INTEGER, DURATION_OPTION, "The time range for statistics", false) - .addChoice("1 Day", 1) - .addChoice("7 Days", 7) - .addChoice("30 Days", 30) - .addChoice("90 Days", 90) - .addChoice("180 Days", 180); + OptionData durationOption = new OptionData(OptionType.INTEGER, DURATION_OPTION, + "The time range for statistics", false) + .addChoice("1 Day", 1) + .addChoice("7 Days", 7) + .addChoice("30 Days", 30) + .addChoice("90 Days", 90) + .addChoice("180 Days", 180); getData().addOptions(durationOption); this.database = database; @@ -68,22 +71,31 @@ public void onSlashCommand(SlashCommandInteractionEvent event) { event.deferReply().queue(); database.read(context -> { - var statsRecord = context.select( - count().as("total_created"), - count().filterWhere(HELP_THREADS.TICKET_STATUS.eq(HelpSystemHelper.TicketStatus.ACTIVE.val)).as("open_now"), - count().filterWhere(HELP_THREADS.PARTICIPANTS.eq(1)).as("ghost_count"), - avg(HELP_THREADS.PARTICIPANTS).as("avg_parts"), - avg(HELP_THREADS.MESSAGE_COUNT).as("avg_msgs"), - avg(field("unixepoch({0}) - unixepoch({1})", Double.class, HELP_THREADS.CLOSED_AT, HELP_THREADS.CREATED_AT)).as("avg_sec"), - min(field("unixepoch({0}) - unixepoch({1})", Double.class, HELP_THREADS.CLOSED_AT, HELP_THREADS.CREATED_AT)).as("min_sec"), - max(field("unixepoch({0}) - unixepoch({1})", Double.class, HELP_THREADS.CLOSED_AT, HELP_THREADS.CREATED_AT)).as("max_sec") - ) - .from(HELP_THREADS) - .where(HELP_THREADS.CREATED_AT.ge(startDate)) - .fetchOne(); + var statsRecord = context + .select(count().as("total_created"), count() + .filterWhere( + HELP_THREADS.TICKET_STATUS.eq(HelpSystemHelper.TicketStatus.ACTIVE.val)) + .as("open_now"), + count().filterWhere(HELP_THREADS.PARTICIPANTS.eq(1)).as("ghost_count"), + avg(HELP_THREADS.PARTICIPANTS).as("avg_parts"), + avg(HELP_THREADS.MESSAGE_COUNT).as("avg_msgs"), + avg(field("unixepoch({0}) - unixepoch({1})", Double.class, + HELP_THREADS.CLOSED_AT, HELP_THREADS.CREATED_AT)) + .as("avg_sec"), + min(field("unixepoch({0}) - unixepoch({1})", Double.class, + HELP_THREADS.CLOSED_AT, HELP_THREADS.CREATED_AT)) + .as("min_sec"), + max(field("unixepoch({0}) - unixepoch({1})", Double.class, + HELP_THREADS.CLOSED_AT, HELP_THREADS.CREATED_AT)) + .as("max_sec")) + .from(HELP_THREADS) + .where(HELP_THREADS.CREATED_AT.ge(startDate)) + .fetchOne(); if (statsRecord == null || statsRecord.get("total_created", Integer.class) == 0) { - event.getHook().editOriginal("No stats available for the last " + days + " days.").queue(); + event.getHook() + .editOriginal("No stats available for the last " + days + " days.") + .queue(); return null; } @@ -91,40 +103,49 @@ public void onSlashCommand(SlashCommandInteractionEvent event) { int openThreads = statsRecord.get("open_now", Integer.class); long ghostThreads = statsRecord.get("ghost_count", Number.class).longValue(); - double rawResRate = totalCreated > 0 ? ((double) (totalCreated - ghostThreads) / totalCreated) * 100 : 0; + double rawResRate = + totalCreated > 0 ? ((double) (totalCreated - ghostThreads) / totalCreated) * 100 + : 0; String highVolumeTag = getTopTag(context, startDate, count().desc()); - String highActivityTag = getTopTag(context, startDate, avg(HELP_THREADS.MESSAGE_COUNT).desc()); - String lowActivityTag = getTopTag(context, startDate, avg(HELP_THREADS.MESSAGE_COUNT).asc()); + String highActivityTag = + getTopTag(context, startDate, avg(HELP_THREADS.MESSAGE_COUNT).desc()); + String lowActivityTag = + getTopTag(context, startDate, avg(HELP_THREADS.MESSAGE_COUNT).asc()); String peakHourRange = getPeakHour(context, startDate); - EmbedBuilder embed = new EmbedBuilder() - .setTitle("πŸ“Š Help Thread Stats (Last " + days + " Days)") - .setColor(getStatusColor(totalCreated, ghostThreads)) - .setTimestamp(Instant.now()) - .setDescription("\u200B") - .setFooter("Together Java Community Stats", Objects.requireNonNull(event.getGuild()).getIconUrl()); + EmbedBuilder embed = + new EmbedBuilder().setTitle("πŸ“Š Help Thread Stats (Last " + days + " Days)") + .setColor(getStatusColor(totalCreated, ghostThreads)) + .setTimestamp(Instant.now()) + .setDescription("\u200B") + .setFooter("Together Java Community Stats", + Objects.requireNonNull(event.getGuild()).getIconUrl()); embed.addField("πŸ“ THREAD ACTIVITY", "Created: `%d`\nCurrently Open: `%d`\nResponse Rate: %.1f%%\nPeak Hours: `%s`" - .formatted(totalCreated, openThreads, rawResRate, peakHourRange), false); + .formatted(totalCreated, openThreads, rawResRate, peakHourRange), + false); embed.addField("πŸ’¬ ENGAGEMENT", "Avg Messages: `%s`\nAvg Helpers: `%s`\nUnanswered (Ghost): `%d`".formatted( formatDouble(Objects.requireNonNull(statsRecord.get("avg_msgs"))), formatDouble(Objects.requireNonNull(statsRecord.get("avg_parts"))), - ghostThreads), false); + ghostThreads), + false); embed.addField("🏷️ TAG ACTIVITY", - "Most Used: `%s`\nMost Active: `%s`\nNeeds Love: `%s`".formatted( - highVolumeTag, highActivityTag, lowActivityTag), false); + "Most Used: `%s`\nMost Active: `%s`\nNeeds Love: `%s`".formatted(highVolumeTag, + highActivityTag, lowActivityTag), + false); embed.addField("⚑ RESOLUTION SPEED", "Average: `%s`\nFastest: `%s`\nSlowest: `%s`".formatted( smartFormat(statsRecord.get("avg_sec", Double.class)), smartFormat(statsRecord.get("min_sec", Double.class)), - smartFormat(statsRecord.get("max_sec", Double.class))), false); + smartFormat(statsRecord.get("max_sec", Double.class))), + false); event.getHook().editOriginalEmbeds(embed.build()).queue(); return null; @@ -132,41 +153,59 @@ public void onSlashCommand(SlashCommandInteractionEvent event) { } private static Color getStatusColor(int totalCreated, long ghostThreads) { - double rawResRate = totalCreated > 0 ? ((double) (totalCreated - ghostThreads) / totalCreated) * 100 : -1; - - if (rawResRate >= 70) return Color.GREEN; - if (rawResRate >= 30) return Color.YELLOW; - if (rawResRate >= 0) return Color.RED; + double rawResRate = + totalCreated > 0 ? ((double) (totalCreated - ghostThreads) / totalCreated) * 100 + : -1; + + if (rawResRate >= 70) + return Color.GREEN; + if (rawResRate >= 30) + return Color.YELLOW; + if (rawResRate >= 0) + return Color.RED; return Color.GRAY; } private String getTopTag(DSLContext context, Instant start, OrderField order) { - return context.select(HELP_THREADS.TAGS).from(HELP_THREADS) - .where(HELP_THREADS.CREATED_AT.ge(start)).and(HELP_THREADS.TAGS.ne("none")) - .groupBy(HELP_THREADS.TAGS).orderBy(order).limit(1) - .fetchOptional(HELP_THREADS.TAGS).orElse("N/A"); + return context.select(HELP_THREADS.TAGS) + .from(HELP_THREADS) + .where(HELP_THREADS.CREATED_AT.ge(start)) + .and(HELP_THREADS.TAGS.ne("none")) + .groupBy(HELP_THREADS.TAGS) + .orderBy(order) + .limit(1) + .fetchOptional(HELP_THREADS.TAGS) + .orElse("N/A"); } private String getPeakHour(DSLContext context, Instant start) { return context.select(field("strftime('%H', {0})", String.class, HELP_THREADS.CREATED_AT)) - .from(HELP_THREADS).where(HELP_THREADS.CREATED_AT.ge(start)) - .groupBy(field("strftime('%H', {0})", String.class, HELP_THREADS.CREATED_AT)) - .orderBy(count().desc()).limit(1).fetchOptional(Record1::value1) - .map(hour -> { - int h = Integer.parseInt(hour); - return "%02d:00 - %02d:00 UTC".formatted(h, (h + 1) % 24); - }).orElse("N/A"); + .from(HELP_THREADS) + .where(HELP_THREADS.CREATED_AT.ge(start)) + .groupBy(field("strftime('%H', {0})", String.class, HELP_THREADS.CREATED_AT)) + .orderBy(count().desc()) + .limit(1) + .fetchOptional(Record1::value1) + .map(hour -> { + int h = Integer.parseInt(hour); + return "%02d:00 - %02d:00 UTC".formatted(h, (h + 1) % 24); + }) + .orElse("N/A"); } private String smartFormat(Double seconds) { - if (seconds < 0) return "N/A"; - if (seconds < 60) return "%.0f secs".formatted(seconds); - if (seconds < 3600) return "%.1f mins".formatted(seconds / 60.0); - if (seconds < 86400) return "%.1f hrs".formatted(seconds / 3600.0); + if (seconds < 0) + return "N/A"; + if (seconds < 60) + return "%.0f secs".formatted(seconds); + if (seconds < 3600) + return "%.1f mins".formatted(seconds / 60.0); + if (seconds < 86400) + return "%.1f hrs".formatted(seconds / 3600.0); return "%.1f days".formatted(seconds / 86400.0); } private String formatDouble(Object val) { return val instanceof Number num ? "%.2f".formatted(num.doubleValue()) : "0.00"; } -} \ No newline at end of file +} From 31f419066dfee5b0a6c1ced3af43ec1068cfbd57 Mon Sep 17 00:00:00 2001 From: Ankit Yadav Date: Sun, 21 Dec 2025 16:50:57 +0530 Subject: [PATCH 04/14] refactor: helper for duration in seconds calculation spotless fixes, helper for calculating duration in seconds between datetime fields to be more compliant with code quality tests --- .../features/help/HelpThreadStatsCommand.java | 73 ++++++++++++------- 1 file changed, 46 insertions(+), 27 deletions(-) diff --git a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java index 9b3bd08788..ebea078445 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java @@ -5,6 +5,7 @@ import net.dv8tion.jda.api.interactions.commands.OptionType; import net.dv8tion.jda.api.interactions.commands.build.OptionData; import org.jooq.DSLContext; +import org.jooq.Field; import org.jooq.OrderField; import org.jooq.Record1; @@ -38,6 +39,14 @@ public class HelpThreadStatsCommand extends SlashCommandAdapter { public static final String COMMAND_NAME = "help-thread-stats"; public static final String DURATION_OPTION = "duration-option"; + private static final String TOTAL_CREATED_FIELD = "total_created"; + private static final String OPEN_NOW_ALIAS = "open_now"; + private static final String GHOST_NOW_ALIAS = "ghost_count"; + private static final String AVERAGE_PARTICIPANTS_ALIAS = "avg_parts"; + private static final String AVERAGE_MESSAGE_COUNT_ALIAS = "avg_msgs"; + private static final String AVERAGE_THREAD_DURATION_IN_SECONDS_ALIAS = "avg_sec"; + private static final String MINIMUM_THREAD_DURATION_IN_SECONDS_ALIAS = "min_sec"; + private static final String MAXIMUM_THREAD_DURATION_IN_SECONDS_ALIAS = "max_sec"; private final Database database; @@ -72,36 +81,33 @@ public void onSlashCommand(SlashCommandInteractionEvent event) { database.read(context -> { var statsRecord = context - .select(count().as("total_created"), count() + .select(count().as(TOTAL_CREATED_FIELD), count() .filterWhere( HELP_THREADS.TICKET_STATUS.eq(HelpSystemHelper.TicketStatus.ACTIVE.val)) - .as("open_now"), - count().filterWhere(HELP_THREADS.PARTICIPANTS.eq(1)).as("ghost_count"), - avg(HELP_THREADS.PARTICIPANTS).as("avg_parts"), - avg(HELP_THREADS.MESSAGE_COUNT).as("avg_msgs"), - avg(field("unixepoch({0}) - unixepoch({1})", Double.class, - HELP_THREADS.CLOSED_AT, HELP_THREADS.CREATED_AT)) - .as("avg_sec"), - min(field("unixepoch({0}) - unixepoch({1})", Double.class, - HELP_THREADS.CLOSED_AT, HELP_THREADS.CREATED_AT)) - .as("min_sec"), - max(field("unixepoch({0}) - unixepoch({1})", Double.class, - HELP_THREADS.CLOSED_AT, HELP_THREADS.CREATED_AT)) - .as("max_sec")) + .as(OPEN_NOW_ALIAS), + count().filterWhere(HELP_THREADS.PARTICIPANTS.eq(1)).as(GHOST_NOW_ALIAS), + avg(HELP_THREADS.PARTICIPANTS).as(AVERAGE_PARTICIPANTS_ALIAS), + avg(HELP_THREADS.MESSAGE_COUNT).as(AVERAGE_MESSAGE_COUNT_ALIAS), + avg(durationInSeconds(HELP_THREADS.CLOSED_AT, HELP_THREADS.CREATED_AT)) + .as(AVERAGE_THREAD_DURATION_IN_SECONDS_ALIAS), + min(durationInSeconds(HELP_THREADS.CLOSED_AT, HELP_THREADS.CREATED_AT)) + .as(MINIMUM_THREAD_DURATION_IN_SECONDS_ALIAS), + max(durationInSeconds(HELP_THREADS.CLOSED_AT, HELP_THREADS.CREATED_AT)) + .as(MAXIMUM_THREAD_DURATION_IN_SECONDS_ALIAS)) .from(HELP_THREADS) .where(HELP_THREADS.CREATED_AT.ge(startDate)) .fetchOne(); - if (statsRecord == null || statsRecord.get("total_created", Integer.class) == 0) { + if (statsRecord == null || statsRecord.get(TOTAL_CREATED_FIELD, Integer.class) == 0) { event.getHook() .editOriginal("No stats available for the last " + days + " days.") .queue(); return null; } - int totalCreated = statsRecord.get("total_created", Integer.class); - int openThreads = statsRecord.get("open_now", Integer.class); - long ghostThreads = statsRecord.get("ghost_count", Number.class).longValue(); + int totalCreated = statsRecord.get(TOTAL_CREATED_FIELD, Integer.class); + int openThreads = statsRecord.get(OPEN_NOW_ALIAS, Integer.class); + long ghostThreads = statsRecord.get(GHOST_NOW_ALIAS, Number.class).longValue(); double rawResRate = totalCreated > 0 ? ((double) (totalCreated - ghostThreads) / totalCreated) * 100 @@ -124,27 +130,32 @@ public void onSlashCommand(SlashCommandInteractionEvent event) { Objects.requireNonNull(event.getGuild()).getIconUrl()); embed.addField("πŸ“ THREAD ACTIVITY", - "Created: `%d`\nCurrently Open: `%d`\nResponse Rate: %.1f%%\nPeak Hours: `%s`" + "Created: `%d`%nCurrently Open: `%d`%nResponse Rate: %.1f%%%nPeak Hours: `%s`" .formatted(totalCreated, openThreads, rawResRate, peakHourRange), false); embed.addField("πŸ’¬ ENGAGEMENT", - "Avg Messages: `%s`\nAvg Helpers: `%s`\nUnanswered (Ghost): `%d`".formatted( - formatDouble(Objects.requireNonNull(statsRecord.get("avg_msgs"))), - formatDouble(Objects.requireNonNull(statsRecord.get("avg_parts"))), + "Avg Messages: `%s`%nAvg Helpers: `%s`%nUnanswered (Ghost): `%d`".formatted( + formatDouble(Objects + .requireNonNull(statsRecord.get(AVERAGE_MESSAGE_COUNT_ALIAS))), + formatDouble(Objects + .requireNonNull(statsRecord.get(AVERAGE_PARTICIPANTS_ALIAS))), ghostThreads), false); embed.addField("🏷️ TAG ACTIVITY", - "Most Used: `%s`\nMost Active: `%s`\nNeeds Love: `%s`".formatted(highVolumeTag, + "Most Used: `%s`%nMost Active: `%s`%nNeeds Love: `%s`".formatted(highVolumeTag, highActivityTag, lowActivityTag), false); embed.addField("⚑ RESOLUTION SPEED", - "Average: `%s`\nFastest: `%s`\nSlowest: `%s`".formatted( - smartFormat(statsRecord.get("avg_sec", Double.class)), - smartFormat(statsRecord.get("min_sec", Double.class)), - smartFormat(statsRecord.get("max_sec", Double.class))), + "Average: `%s`%nFastest: `%s`%nSlowest: `%s`".formatted( + smartFormat(statsRecord.get(AVERAGE_THREAD_DURATION_IN_SECONDS_ALIAS, + Double.class)), + smartFormat(statsRecord.get(MINIMUM_THREAD_DURATION_IN_SECONDS_ALIAS, + Double.class)), + smartFormat(statsRecord.get(MAXIMUM_THREAD_DURATION_IN_SECONDS_ALIAS, + Double.class))), false); event.getHook().editOriginalEmbeds(embed.build()).queue(); @@ -208,4 +219,12 @@ private String smartFormat(Double seconds) { private String formatDouble(Object val) { return val instanceof Number num ? "%.2f".formatted(num.doubleValue()) : "0.00"; } + + /** + * Calculates the duration in seconds between two timestamp fields. Uses SQLite unixepoch for + * conversion. + */ + private Field durationInSeconds(Field end, Field start) { + return field("unixepoch({0}) - unixepoch({1})", Double.class, end, start); + } } From d9aec8f4b4cbbe0c9d8809c6b81437b024068dd1 Mon Sep 17 00:00:00 2001 From: Ankit Yadav Date: Sun, 21 Dec 2025 17:14:57 +0530 Subject: [PATCH 05/14] refactor: emojis and whitespace constants change emojis to unicode character constants with descriptive names, single/blank line of space to be more intentional and clear --- .../features/help/HelpThreadStatsCommand.java | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java index ebea078445..35760386c8 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java @@ -26,7 +26,7 @@ * forum's activity over a specific duration. *

* Example usage: - * + * *

  * {@code
  * /help-thread-stats duration-option: 7 Days
@@ -48,6 +48,15 @@ public class HelpThreadStatsCommand extends SlashCommandAdapter {
     private static final String MINIMUM_THREAD_DURATION_IN_SECONDS_ALIAS = "min_sec";
     private static final String MAXIMUM_THREAD_DURATION_IN_SECONDS_ALIAS = "max_sec";
 
+    private static final String EMOJI_CHART = "\uD83D\uDCCA";
+    private static final String EMOJI_MEMO = "\uD83D\uDCDD";
+    private static final String EMOJI_SPEECH_BUBBLE = "\uD83D\uDCAC";
+    private static final String EMOJI_LABEL = "\uD83C\uDFF7\uFE0F";
+    private static final String EMOJI_LIGHTNING = "\u26A1";
+
+    private static final String EMBED_BLANK_LINE = "\u200B";
+    private static final String WHITESPACE = " ";
+
     private final Database database;
 
     /**
@@ -121,20 +130,20 @@ public void onSlashCommand(SlashCommandInteractionEvent event) {
 
             String peakHourRange = getPeakHour(context, startDate);
 
-            EmbedBuilder embed =
-                    new EmbedBuilder().setTitle("πŸ“Š Help Thread Stats (Last " + days + " Days)")
-                        .setColor(getStatusColor(totalCreated, ghostThreads))
-                        .setTimestamp(Instant.now())
-                        .setDescription("\u200B")
-                        .setFooter("Together Java Community Stats",
-                                Objects.requireNonNull(event.getGuild()).getIconUrl());
+            EmbedBuilder embed = new EmbedBuilder()
+                .setTitle(EMOJI_CHART + " Help Thread Stats (Last " + days + " Days)")
+                .setColor(getStatusColor(totalCreated, ghostThreads))
+                .setTimestamp(Instant.now())
+                .setDescription(EMBED_BLANK_LINE)
+                .setFooter("Together Java Community Stats",
+                        Objects.requireNonNull(event.getGuild()).getIconUrl());
 
-            embed.addField("πŸ“ THREAD ACTIVITY",
+            embed.addField(EMOJI_MEMO + WHITESPACE + "THREAD ACTIVITY",
                     "Created: `%d`%nCurrently Open: `%d`%nResponse Rate: %.1f%%%nPeak Hours: `%s`"
                         .formatted(totalCreated, openThreads, rawResRate, peakHourRange),
                     false);
 
-            embed.addField("πŸ’¬ ENGAGEMENT",
+            embed.addField(EMOJI_SPEECH_BUBBLE + WHITESPACE + "ENGAGEMENT",
                     "Avg Messages: `%s`%nAvg Helpers: `%s`%nUnanswered (Ghost): `%d`".formatted(
                             formatDouble(Objects
                                 .requireNonNull(statsRecord.get(AVERAGE_MESSAGE_COUNT_ALIAS))),
@@ -143,12 +152,12 @@ public void onSlashCommand(SlashCommandInteractionEvent event) {
                             ghostThreads),
                     false);
 
-            embed.addField("🏷️ TAG ACTIVITY",
+            embed.addField(EMOJI_LABEL + WHITESPACE + "TAG ACTIVITY",
                     "Most Used: `%s`%nMost Active: `%s`%nNeeds Love: `%s`".formatted(highVolumeTag,
                             highActivityTag, lowActivityTag),
                     false);
 
-            embed.addField("⚑ RESOLUTION SPEED",
+            embed.addField(EMOJI_LIGHTNING + WHITESPACE + "RESOLUTION SPEED",
                     "Average: `%s`%nFastest: `%s`%nSlowest: `%s`".formatted(
                             smartFormat(statsRecord.get(AVERAGE_THREAD_DURATION_IN_SECONDS_ALIAS,
                                     Double.class)),

From ea88b0a780ae843b03995f923a9cb7cafba1cfaa Mon Sep 17 00:00:00 2001
From: Ankit Yadav 
Date: Wed, 31 Dec 2025 17:41:36 +0530
Subject: [PATCH 06/14] refactor: remove star imports

---
 .../main/java/org/togetherjava/tjbot/features/Features.java | 2 +-
 .../tjbot/features/help/HelpThreadStatsCommand.java         | 6 +++++-
 2 files changed, 6 insertions(+), 2 deletions(-)

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 b9d339be95..f253cd4c5a 100644
--- a/application/src/main/java/org/togetherjava/tjbot/features/Features.java
+++ b/application/src/main/java/org/togetherjava/tjbot/features/Features.java
@@ -23,7 +23,6 @@
 import org.togetherjava.tjbot.features.filesharing.FileSharingMessageListener;
 import org.togetherjava.tjbot.features.github.GitHubCommand;
 import org.togetherjava.tjbot.features.github.GitHubReference;
-import org.togetherjava.tjbot.features.help.*;
 import org.togetherjava.tjbot.features.help.AutoPruneHelperRoutine;
 import org.togetherjava.tjbot.features.help.GuildLeaveCloseThreadListener;
 import org.togetherjava.tjbot.features.help.HelpSystemHelper;
@@ -33,6 +32,7 @@
 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.HelpThreadStatsCommand;
 import org.togetherjava.tjbot.features.help.MarkHelpThreadCloseInDBRoutine;
 import org.togetherjava.tjbot.features.help.PinnedNotificationRemover;
 import org.togetherjava.tjbot.features.jshell.JShellCommand;
diff --git a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java
index 35760386c8..3d24163947 100644
--- a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java
+++ b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java
@@ -18,7 +18,11 @@
 import java.time.temporal.ChronoUnit;
 import java.util.Objects;
 
-import static org.jooq.impl.DSL.*;
+import static org.jooq.impl.DSL.avg;
+import static org.jooq.impl.DSL.count;
+import static org.jooq.impl.DSL.field;
+import static org.jooq.impl.DSL.max;
+import static org.jooq.impl.DSL.min;
 import static org.togetherjava.tjbot.db.generated.Tables.HELP_THREADS;
 
 /**

From 5cc8a3db7a0a8a84d99e98eb030ce9b9bd61bdbd Mon Sep 17 00:00:00 2001
From: alphaBEE 
Date: Mon, 5 Jan 2026 22:02:03 +0530
Subject: [PATCH 07/14] Update
 application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

use a more simpler way to get days and a fallback if user entered option is missing

Co-authored-by: Connor SchweighΓΆfer 
---
 .../tjbot/features/help/HelpThreadStatsCommand.java           | 4 +---
 1 file changed, 1 insertion(+), 3 deletions(-)

diff --git a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java
index 3d24163947..87e257114d 100644
--- a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java
+++ b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java
@@ -85,9 +85,7 @@ public HelpThreadStatsCommand(Database database) {
 
     @Override
     public void onSlashCommand(SlashCommandInteractionEvent event) {
-        long days = event.getOption(DURATION_OPTION) != null
-                ? Objects.requireNonNull(event.getOption(DURATION_OPTION)).getAsLong()
-                : 1;
+        long days = event.getOption(DURATION_OPTION, 1L, OptionMapping::getAsLong)
         Instant startDate = Instant.now().minus(days, ChronoUnit.DAYS);
 
         event.deferReply().queue();

From 06510840bc99ed03734759917cc3dfada3317872 Mon Sep 17 00:00:00 2001
From: Ankit Yadav 
Date: Mon, 5 Jan 2026 22:05:16 +0530
Subject: [PATCH 08/14] chore: fix missing import

---
 .../tjbot/features/help/HelpThreadStatsCommand.java            | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java
index 87e257114d..6a1cb0ffe3 100644
--- a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java
+++ b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java
@@ -2,6 +2,7 @@
 
 import net.dv8tion.jda.api.EmbedBuilder;
 import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
+import net.dv8tion.jda.api.interactions.commands.OptionMapping;
 import net.dv8tion.jda.api.interactions.commands.OptionType;
 import net.dv8tion.jda.api.interactions.commands.build.OptionData;
 import org.jooq.DSLContext;
@@ -85,7 +86,7 @@ public HelpThreadStatsCommand(Database database) {
 
     @Override
     public void onSlashCommand(SlashCommandInteractionEvent event) {
-        long days = event.getOption(DURATION_OPTION, 1L, OptionMapping::getAsLong)
+        long days = event.getOption(DURATION_OPTION, 1L, OptionMapping::getAsLong);
         Instant startDate = Instant.now().minus(days, ChronoUnit.DAYS);
 
         event.deferReply().queue();

From c3a01d7f5f48f005032d3cb24df5046e4287dc35 Mon Sep 17 00:00:00 2001
From: Ankit Yadav 
Date: Mon, 5 Jan 2026 22:31:19 +0530
Subject: [PATCH 09/14] fix: ghost thread count query

since when saving thread meta data, participant count
excludes author which means thread with 0 participants should
be counted as ghost threads instead of single participant
---
 .../tjbot/features/help/HelpThreadStatsCommand.java             | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java
index 6a1cb0ffe3..c6a10da0dd 100644
--- a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java
+++ b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java
@@ -97,7 +97,7 @@ public void onSlashCommand(SlashCommandInteractionEvent event) {
                     .filterWhere(
                             HELP_THREADS.TICKET_STATUS.eq(HelpSystemHelper.TicketStatus.ACTIVE.val))
                     .as(OPEN_NOW_ALIAS),
-                        count().filterWhere(HELP_THREADS.PARTICIPANTS.eq(1)).as(GHOST_NOW_ALIAS),
+                        count().filterWhere(HELP_THREADS.PARTICIPANTS.eq(0)).as(GHOST_NOW_ALIAS),
                         avg(HELP_THREADS.PARTICIPANTS).as(AVERAGE_PARTICIPANTS_ALIAS),
                         avg(HELP_THREADS.MESSAGE_COUNT).as(AVERAGE_MESSAGE_COUNT_ALIAS),
                         avg(durationInSeconds(HELP_THREADS.CLOSED_AT, HELP_THREADS.CREATED_AT))

From 7bf55b29827b814d80391f2a480a63eef2198d93 Mon Sep 17 00:00:00 2001
From: Ankit Yadav 
Date: Mon, 5 Jan 2026 22:57:48 +0530
Subject: [PATCH 10/14] feat: add cooldown per channel

to avoid users from spamming, this adds
a cooldown period of a min per channel
---
 .../features/help/HelpThreadStatsCommand.java | 30 +++++++++++++++++++
 1 file changed, 30 insertions(+)

diff --git a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java
index c6a10da0dd..84589bbc2d 100644
--- a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java
+++ b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java
@@ -1,5 +1,7 @@
 package org.togetherjava.tjbot.features.help;
 
+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.events.interaction.command.SlashCommandInteractionEvent;
 import net.dv8tion.jda.api.interactions.commands.OptionMapping;
@@ -18,6 +20,7 @@
 import java.time.Instant;
 import java.time.temporal.ChronoUnit;
 import java.util.Objects;
+import java.util.concurrent.TimeUnit;
 
 import static org.jooq.impl.DSL.avg;
 import static org.jooq.impl.DSL.count;
@@ -64,6 +67,12 @@ public class HelpThreadStatsCommand extends SlashCommandAdapter {
 
     private final Database database;
 
+    private static final int COOLDOWN_VALUE = 1;
+    private static final ChronoUnit COOLDOWN_UNIT = ChronoUnit.MINUTES;
+
+    private final Cache cooldownCache;
+
+
     /**
      * Creates an instance of the command.
      *
@@ -82,10 +91,31 @@ public HelpThreadStatsCommand(Database database) {
 
         getData().addOptions(durationOption);
         this.database = database;
+        this.cooldownCache = Caffeine.newBuilder()
+            .maximumSize(500)
+            .expireAfterWrite(COOLDOWN_VALUE, TimeUnit.of(COOLDOWN_UNIT))
+            .build();
     }
 
     @Override
     public void onSlashCommand(SlashCommandInteractionEvent event) {
+        long channelId = event.getChannel().getIdLong();
+        Instant now = Instant.now();
+
+        Instant lastUsage = this.cooldownCache.getIfPresent(channelId);
+        if (lastUsage != null) {
+            long secondsLeft = COOLDOWN_UNIT.getDuration().getSeconds()
+                    - ChronoUnit.SECONDS.between(lastUsage, now);
+
+            event
+                .reply("This command is on cooldown! Please wait " + secondsLeft + " more seconds.")
+                .setEphemeral(true)
+                .queue();
+            return;
+        }
+
+        cooldownCache.put(channelId, now);
+
         long days = event.getOption(DURATION_OPTION, 1L, OptionMapping::getAsLong);
         Instant startDate = Instant.now().minus(days, ChronoUnit.DAYS);
 

From e18e616fdcc602ec64e0f6ba0217a24333e44f8a Mon Sep 17 00:00:00 2001
From: Ankit Yadav 
Date: Tue, 6 Jan 2026 01:24:54 +0530
Subject: [PATCH 11/14] refactor: use Duration on cache write

uses Duration instance for expiration of cache items instead
of seperate values for ease of maintenance and clarity
---
 .../features/help/HelpThreadStatsCommand.java    | 16 +++++++---------
 1 file changed, 7 insertions(+), 9 deletions(-)

diff --git a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java
index 84589bbc2d..1a57604cf0 100644
--- a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java
+++ b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java
@@ -17,10 +17,10 @@
 import org.togetherjava.tjbot.features.SlashCommandAdapter;
 
 import java.awt.Color;
+import java.time.Duration;
 import java.time.Instant;
 import java.time.temporal.ChronoUnit;
 import java.util.Objects;
-import java.util.concurrent.TimeUnit;
 
 import static org.jooq.impl.DSL.avg;
 import static org.jooq.impl.DSL.count;
@@ -67,8 +67,7 @@ public class HelpThreadStatsCommand extends SlashCommandAdapter {
 
     private final Database database;
 
-    private static final int COOLDOWN_VALUE = 1;
-    private static final ChronoUnit COOLDOWN_UNIT = ChronoUnit.MINUTES;
+    private static final Duration COOLDOWN_DURATION = Duration.ofMinutes(1);
 
     private final Cache cooldownCache;
 
@@ -91,10 +90,8 @@ public HelpThreadStatsCommand(Database database) {
 
         getData().addOptions(durationOption);
         this.database = database;
-        this.cooldownCache = Caffeine.newBuilder()
-            .maximumSize(500)
-            .expireAfterWrite(COOLDOWN_VALUE, TimeUnit.of(COOLDOWN_UNIT))
-            .build();
+        this.cooldownCache =
+                Caffeine.newBuilder().maximumSize(500).expireAfterWrite(COOLDOWN_DURATION).build();
     }
 
     @Override
@@ -104,8 +101,9 @@ public void onSlashCommand(SlashCommandInteractionEvent event) {
 
         Instant lastUsage = this.cooldownCache.getIfPresent(channelId);
         if (lastUsage != null) {
-            long secondsLeft = COOLDOWN_UNIT.getDuration().getSeconds()
-                    - ChronoUnit.SECONDS.between(lastUsage, now);
+            Duration elapsed = Duration.between(lastUsage, now);
+            // to avoid displaying -1 when elapsed just crosses cooldown
+            long secondsLeft = Math.max(0, COOLDOWN_DURATION.minus(elapsed).toSeconds());
 
             event
                 .reply("This command is on cooldown! Please wait " + secondsLeft + " more seconds.")

From 1a17607d4a2d302ce48b1a07116238284e97837b Mon Sep 17 00:00:00 2001
From: Ankit Yadav 
Date: Sun, 8 Feb 2026 07:12:14 +0530
Subject: [PATCH 12/14] refactor: helpthreadstats and helpthreadlistener

utf code with actual emojis for clarity,
break onSlash handler into more helpers
update helpthreadlistener to only store
true participant count in database
---
 .../help/HelpThreadLifecycleListener.java     |  12 +-
 .../features/help/HelpThreadStatsCommand.java | 238 ++++++++++--------
 2 files changed, 149 insertions(+), 101 deletions(-)

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
index 3669ed2517..2a67c9c065 100644
--- a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadLifecycleListener.java
+++ b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadLifecycleListener.java
@@ -96,8 +96,14 @@ void handleArchiveStatus(Instant closedAt, long id, JDA jda) {
         }
 
         long threadId = threadChannel.getIdLong();
-        int messageCount = threadChannel.getMessageCount();
-        int participantsExceptAuthor = threadChannel.getMemberCount() - 1;
+        int messageCount = threadChannel.getMessageCount(); // TODO: to be replaced with participant
+                                                            // message count
+        long threadOwnerId = threadChannel.getOwnerIdLong();
+        int participantsExceptAuthor = (int) threadChannel.getMembers()
+            .stream()
+            .filter(threadMember -> threadMember.getIdLong() != threadOwnerId)
+            .filter(m -> !m.getUser().isBot())
+            .count();
 
         database.write(context -> context.update(HELP_THREADS)
             .set(HELP_THREADS.CLOSED_AT, closedAt)
@@ -131,7 +137,7 @@ private void handleTagsUpdate(long threadId, String updatedTag) {
 
     /**
      * will ignore updated tag event if all new tags belong to the categories config
-     * 
+     *
      * @param event updated tags event
      * @return boolean
      */
diff --git a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java
index 1a57604cf0..59ca5436da 100644
--- a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java
+++ b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java
@@ -7,20 +7,23 @@
 import net.dv8tion.jda.api.interactions.commands.OptionMapping;
 import net.dv8tion.jda.api.interactions.commands.OptionType;
 import net.dv8tion.jda.api.interactions.commands.build.OptionData;
-import org.jooq.DSLContext;
 import org.jooq.Field;
 import org.jooq.OrderField;
+import org.jooq.Record;
 import org.jooq.Record1;
 
 import org.togetherjava.tjbot.db.Database;
 import org.togetherjava.tjbot.features.CommandVisibility;
 import org.togetherjava.tjbot.features.SlashCommandAdapter;
 
+import javax.annotation.Nullable;
+
 import java.awt.Color;
 import java.time.Duration;
 import java.time.Instant;
 import java.time.temporal.ChronoUnit;
 import java.util.Objects;
+import java.util.Optional;
 
 import static org.jooq.impl.DSL.avg;
 import static org.jooq.impl.DSL.count;
@@ -56,11 +59,11 @@ public class HelpThreadStatsCommand extends SlashCommandAdapter {
     private static final String MINIMUM_THREAD_DURATION_IN_SECONDS_ALIAS = "min_sec";
     private static final String MAXIMUM_THREAD_DURATION_IN_SECONDS_ALIAS = "max_sec";
 
-    private static final String EMOJI_CHART = "\uD83D\uDCCA";
-    private static final String EMOJI_MEMO = "\uD83D\uDCDD";
-    private static final String EMOJI_SPEECH_BUBBLE = "\uD83D\uDCAC";
-    private static final String EMOJI_LABEL = "\uD83C\uDFF7\uFE0F";
-    private static final String EMOJI_LIGHTNING = "\u26A1";
+    private static final String EMOJI_CHART = "πŸ“Š";
+    private static final String EMOJI_MEMO = "πŸ“";
+    private static final String EMOJI_SPEECH_BUBBLE = "πŸ’¬";
+    private static final String EMOJI_LABEL = "🏷️";
+    private static final String EMOJI_LIGHTNING = "⚑";
 
     private static final String EMBED_BLANK_LINE = "\u200B";
     private static final String WHITESPACE = " ";
@@ -94,32 +97,19 @@ public HelpThreadStatsCommand(Database database) {
                 Caffeine.newBuilder().maximumSize(500).expireAfterWrite(COOLDOWN_DURATION).build();
     }
 
-    @Override
-    public void onSlashCommand(SlashCommandInteractionEvent event) {
-        long channelId = event.getChannel().getIdLong();
-        Instant now = Instant.now();
-
+    private long getSecondsSinceLastUsage(long channelId, Instant now) {
+        long secondsSinceLastUsage = 0;
         Instant lastUsage = this.cooldownCache.getIfPresent(channelId);
         if (lastUsage != null) {
             Duration elapsed = Duration.between(lastUsage, now);
             // to avoid displaying -1 when elapsed just crosses cooldown
-            long secondsLeft = Math.max(0, COOLDOWN_DURATION.minus(elapsed).toSeconds());
-
-            event
-                .reply("This command is on cooldown! Please wait " + secondsLeft + " more seconds.")
-                .setEphemeral(true)
-                .queue();
-            return;
+            secondsSinceLastUsage = Math.max(0, COOLDOWN_DURATION.minus(elapsed).toSeconds());
         }
+        return secondsSinceLastUsage;
+    }
 
-        cooldownCache.put(channelId, now);
-
-        long days = event.getOption(DURATION_OPTION, 1L, OptionMapping::getAsLong);
-        Instant startDate = Instant.now().minus(days, ChronoUnit.DAYS);
-
-        event.deferReply().queue();
-
-        database.read(context -> {
+    private Optional getHelpThreadUsageStats(Instant statsDurationStartDate) {
+        return database.read(context -> {
             var statsRecord = context
                 .select(count().as(TOTAL_CREATED_FIELD), count()
                     .filterWhere(
@@ -129,96 +119,144 @@ public void onSlashCommand(SlashCommandInteractionEvent event) {
                         avg(HELP_THREADS.PARTICIPANTS).as(AVERAGE_PARTICIPANTS_ALIAS),
                         avg(HELP_THREADS.MESSAGE_COUNT).as(AVERAGE_MESSAGE_COUNT_ALIAS),
                         avg(durationInSeconds(HELP_THREADS.CLOSED_AT, HELP_THREADS.CREATED_AT))
+                            .filterWhere(HELP_THREADS.PARTICIPANTS.gt(0))
                             .as(AVERAGE_THREAD_DURATION_IN_SECONDS_ALIAS),
                         min(durationInSeconds(HELP_THREADS.CLOSED_AT, HELP_THREADS.CREATED_AT))
+                            .filterWhere(HELP_THREADS.PARTICIPANTS.gt(0))
                             .as(MINIMUM_THREAD_DURATION_IN_SECONDS_ALIAS),
                         max(durationInSeconds(HELP_THREADS.CLOSED_AT, HELP_THREADS.CREATED_AT))
+                            .filterWhere(HELP_THREADS.PARTICIPANTS.gt(0))
                             .as(MAXIMUM_THREAD_DURATION_IN_SECONDS_ALIAS))
                 .from(HELP_THREADS)
-                .where(HELP_THREADS.CREATED_AT.ge(startDate))
+                .where(HELP_THREADS.CREATED_AT.ge(statsDurationStartDate))
                 .fetchOne();
 
             if (statsRecord == null || statsRecord.get(TOTAL_CREATED_FIELD, Integer.class) == 0) {
-                event.getHook()
-                    .editOriginal("No stats available for the last " + days + " days.")
-                    .queue();
-                return null;
+                return Optional.empty();
             }
+            return Optional.of(statsRecord);
+        });
+    }
+
+    private record StatsReportData(long days, int totalCreated, int openThreads, long ghostThreads,
+            double responseRate, String highVolumeTag, String highActivityTag,
+            String lowActivityTag, String peakHourRange, Record rawStats
 
-            int totalCreated = statsRecord.get(TOTAL_CREATED_FIELD, Integer.class);
-            int openThreads = statsRecord.get(OPEN_NOW_ALIAS, Integer.class);
-            long ghostThreads = statsRecord.get(GHOST_NOW_ALIAS, Number.class).longValue();
+    ) {
+    }
+
+    private EmbedBuilder buildStatsEmbed(StatsReportData helpThreadStatsResults,
+            String guildIconUrl, int daysBack) {
+        EmbedBuilder helpThreadStatsEmbed = new EmbedBuilder()
+            .setTitle(EMOJI_CHART + " Help Thread Stats (Last " + daysBack + " Days)")
+            .setColor(getStatusColor(helpThreadStatsResults.totalCreated(),
+                    helpThreadStatsResults.ghostThreads()))
+            .setTimestamp(Instant.now())
+            .setDescription(EMBED_BLANK_LINE)
+            .setFooter("Together Java Community Stats", guildIconUrl);
+
+        helpThreadStatsEmbed.addField(EMOJI_MEMO + WHITESPACE + "THREAD ACTIVITY",
+                "Created: `%d`%nCurrently Open: `%d`%nResponse Rate: %.1f%%%nPeak Hours: `%s`"
+                    .formatted(helpThreadStatsResults.totalCreated(),
+                            helpThreadStatsResults.openThreads(),
+                            helpThreadStatsResults.responseRate(),
+                            helpThreadStatsResults.peakHourRange()),
+                false);
+
+        helpThreadStatsEmbed.addField(EMOJI_SPEECH_BUBBLE + WHITESPACE + "ENGAGEMENT",
+                "Avg Messages: `%s`%nAvg Helpers: `%s`%nUnanswered (Ghost): `%d`".formatted(
+                        formatDouble(Objects.requireNonNull(helpThreadStatsResults.rawStats()
+                            .get(AVERAGE_MESSAGE_COUNT_ALIAS))),
+                        formatDouble(Objects.requireNonNull(
+                                helpThreadStatsResults.rawStats().get(AVERAGE_PARTICIPANTS_ALIAS))),
+                        helpThreadStatsResults.ghostThreads),
+                false);
+
+        helpThreadStatsEmbed.addField(EMOJI_LABEL + WHITESPACE + "TAG ACTIVITY",
+                "Most Used: `%s`%nMost Active: `%s`%nNeeds Love: `%s`".formatted(
+                        helpThreadStatsResults.highVolumeTag(),
+                        helpThreadStatsResults.highActivityTag(),
+                        helpThreadStatsResults.lowActivityTag()),
+                false);
+
+        helpThreadStatsEmbed.addField(EMOJI_LIGHTNING + WHITESPACE + "RESOLUTION SPEED",
+                "Average: `%s`%nFastest: `%s`%nSlowest: `%s`".formatted(
+                        smartFormat(helpThreadStatsResults.rawStats()
+                            .get(AVERAGE_THREAD_DURATION_IN_SECONDS_ALIAS, Double.class)),
+                        smartFormat(helpThreadStatsResults.rawStats()
+                            .get(MINIMUM_THREAD_DURATION_IN_SECONDS_ALIAS, Double.class)),
+                        smartFormat(helpThreadStatsResults.rawStats()
+                            .get(MAXIMUM_THREAD_DURATION_IN_SECONDS_ALIAS, Double.class))),
+                false);
+        return helpThreadStatsEmbed;
+    }
 
-            double rawResRate =
+
+    @Override
+    public void onSlashCommand(SlashCommandInteractionEvent event) {
+        long channelId = event.getChannel().getIdLong();
+        Instant now = Instant.now();
+        long secondsSinceLastUsage = getSecondsSinceLastUsage(channelId, now);
+        if (secondsSinceLastUsage != 0L) {
+            event
+                .reply("This command is on cooldown! Please wait " + secondsSinceLastUsage
+                        + " more seconds.")
+                .setEphemeral(true)
+                .queue();
+            return;
+        }
+        event.deferReply().queue();
+        cooldownCache.put(channelId, now);
+        int daysBackOption = Optional.ofNullable(event.getOption(DURATION_OPTION))
+            .map(OptionMapping::getAsInt)
+            .orElse(1);
+
+        Instant startDate = Instant.now().minus(daysBackOption, ChronoUnit.DAYS);
+
+        getHelpThreadUsageStats(startDate).ifPresentOrElse(stats -> {
+            int totalCreated = stats.get(TOTAL_CREATED_FIELD, Integer.class);
+            int openThreads = stats.get(OPEN_NOW_ALIAS, Integer.class);
+            long ghostThreads = stats.get(GHOST_NOW_ALIAS, Number.class).longValue();
+
+            double helpThreadInteractionRate =
                     totalCreated > 0 ? ((double) (totalCreated - ghostThreads) / totalCreated) * 100
                             : 0;
 
-            String highVolumeTag = getTopTag(context, startDate, count().desc());
-            String highActivityTag =
-                    getTopTag(context, startDate, avg(HELP_THREADS.MESSAGE_COUNT).desc());
-            String lowActivityTag =
-                    getTopTag(context, startDate, avg(HELP_THREADS.MESSAGE_COUNT).asc());
-
-            String peakHourRange = getPeakHour(context, startDate);
-
-            EmbedBuilder embed = new EmbedBuilder()
-                .setTitle(EMOJI_CHART + " Help Thread Stats (Last " + days + " Days)")
-                .setColor(getStatusColor(totalCreated, ghostThreads))
-                .setTimestamp(Instant.now())
-                .setDescription(EMBED_BLANK_LINE)
-                .setFooter("Together Java Community Stats",
-                        Objects.requireNonNull(event.getGuild()).getIconUrl());
-
-            embed.addField(EMOJI_MEMO + WHITESPACE + "THREAD ACTIVITY",
-                    "Created: `%d`%nCurrently Open: `%d`%nResponse Rate: %.1f%%%nPeak Hours: `%s`"
-                        .formatted(totalCreated, openThreads, rawResRate, peakHourRange),
-                    false);
-
-            embed.addField(EMOJI_SPEECH_BUBBLE + WHITESPACE + "ENGAGEMENT",
-                    "Avg Messages: `%s`%nAvg Helpers: `%s`%nUnanswered (Ghost): `%d`".formatted(
-                            formatDouble(Objects
-                                .requireNonNull(statsRecord.get(AVERAGE_MESSAGE_COUNT_ALIAS))),
-                            formatDouble(Objects
-                                .requireNonNull(statsRecord.get(AVERAGE_PARTICIPANTS_ALIAS))),
-                            ghostThreads),
-                    false);
-
-            embed.addField(EMOJI_LABEL + WHITESPACE + "TAG ACTIVITY",
-                    "Most Used: `%s`%nMost Active: `%s`%nNeeds Love: `%s`".formatted(highVolumeTag,
-                            highActivityTag, lowActivityTag),
-                    false);
-
-            embed.addField(EMOJI_LIGHTNING + WHITESPACE + "RESOLUTION SPEED",
-                    "Average: `%s`%nFastest: `%s`%nSlowest: `%s`".formatted(
-                            smartFormat(statsRecord.get(AVERAGE_THREAD_DURATION_IN_SECONDS_ALIAS,
-                                    Double.class)),
-                            smartFormat(statsRecord.get(MINIMUM_THREAD_DURATION_IN_SECONDS_ALIAS,
-                                    Double.class)),
-                            smartFormat(statsRecord.get(MAXIMUM_THREAD_DURATION_IN_SECONDS_ALIAS,
-                                    Double.class))),
-                    false);
-
-            event.getHook().editOriginalEmbeds(embed.build()).queue();
-            return null;
-        });
+            String highVolumeTag = getTopTag(startDate, count().desc());
+            String highActivityTag = getTopTag(startDate, avg(HELP_THREADS.MESSAGE_COUNT).desc());
+            String lowActivityTag = getTopTag(startDate, avg(HELP_THREADS.MESSAGE_COUNT).asc());
+
+            String peakHourRange = getPeakHour(startDate);
+            StatsReportData fetchedStats = new StatsReportData(daysBackOption, totalCreated,
+                    openThreads, ghostThreads, helpThreadInteractionRate, highVolumeTag,
+                    highActivityTag, lowActivityTag, peakHourRange, stats);
+            EmbedBuilder helpThreadStatsEmbed =
+                    buildStatsEmbed(fetchedStats, event.getGuild().getIconUrl(), daysBackOption);
+
+            event.getHook().editOriginalEmbeds(helpThreadStatsEmbed.build()).queue();
+
+        }, () -> event.getHook()
+            .editOriginal("No stats available for the last " + daysBackOption + " days.")
+            .queue());
     }
 
-    private static Color getStatusColor(int totalCreated, long ghostThreads) {
-        double rawResRate =
-                totalCreated > 0 ? ((double) (totalCreated - ghostThreads) / totalCreated) * 100
-                        : -1;
+    private static Color getStatusColor(int totalHelpThreadsCreated, long ghostThreads) {
+        double helpThreadInteractionRate = totalHelpThreadsCreated > 0
+                ? ((double) (totalHelpThreadsCreated - ghostThreads) / totalHelpThreadsCreated)
+                        * 100
+                : -1;
 
-        if (rawResRate >= 70)
+        if (helpThreadInteractionRate >= 70)
             return Color.GREEN;
-        if (rawResRate >= 30)
+        if (helpThreadInteractionRate >= 30)
             return Color.YELLOW;
-        if (rawResRate >= 0)
+        if (helpThreadInteractionRate >= 0)
             return Color.RED;
         return Color.GRAY;
     }
 
-    private String getTopTag(DSLContext context, Instant start, OrderField order) {
-        return context.select(HELP_THREADS.TAGS)
+    private String getTopTag(Instant start, OrderField order) {
+        return database.read(context -> context.select(HELP_THREADS.TAGS)
             .from(HELP_THREADS)
             .where(HELP_THREADS.CREATED_AT.ge(start))
             .and(HELP_THREADS.TAGS.ne("none"))
@@ -226,11 +264,12 @@ private String getTopTag(DSLContext context, Instant start, OrderField order)
             .orderBy(order)
             .limit(1)
             .fetchOptional(HELP_THREADS.TAGS)
-            .orElse("N/A");
+            .orElse("N/A"));
     }
 
-    private String getPeakHour(DSLContext context, Instant start) {
-        return context.select(field("strftime('%H', {0})", String.class, HELP_THREADS.CREATED_AT))
+    private String getPeakHour(Instant start) {
+        return database.read(context -> context
+            .select(field("strftime('%H', {0})", String.class, HELP_THREADS.CREATED_AT))
             .from(HELP_THREADS)
             .where(HELP_THREADS.CREATED_AT.ge(start))
             .groupBy(field("strftime('%H', {0})", String.class, HELP_THREADS.CREATED_AT))
@@ -241,12 +280,14 @@ private String getPeakHour(DSLContext context, Instant start) {
                 int h = Integer.parseInt(hour);
                 return "%02d:00 - %02d:00 UTC".formatted(h, (h + 1) % 24);
             })
-            .orElse("N/A");
+            .orElse("N/A"));
     }
 
-    private String smartFormat(Double seconds) {
-        if (seconds < 0)
+    private String smartFormat(@Nullable Double seconds) {
+        if (seconds == null || seconds < 0) {
             return "N/A";
+        }
+
         if (seconds < 60)
             return "%.0f secs".formatted(seconds);
         if (seconds < 3600)
@@ -256,6 +297,7 @@ private String smartFormat(Double seconds) {
         return "%.1f days".formatted(seconds / 86400.0);
     }
 
+
     private String formatDouble(Object val) {
         return val instanceof Number num ? "%.2f".formatted(num.doubleValue()) : "0.00";
     }

From 4c75144d598b24f41d26073bb3e5d639f79e0551 Mon Sep 17 00:00:00 2001
From: Ankit Yadav 
Date: Sun, 8 Feb 2026 07:26:19 +0530
Subject: [PATCH 13/14] refactor: add helper for getting nonbotuser

adds helper function in HelpThreadLifecycleListener
to get nonBot participant for database updates
---
 .../features/help/HelpThreadLifecycleListener.java     | 10 ++++++++--
 1 file changed, 8 insertions(+), 2 deletions(-)

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
index 2a67c9c065..7cadf66f3d 100644
--- a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadLifecycleListener.java
+++ b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadLifecycleListener.java
@@ -1,6 +1,8 @@
 package org.togetherjava.tjbot.features.help;
 
 import net.dv8tion.jda.api.JDA;
+import net.dv8tion.jda.api.entities.Member;
+import net.dv8tion.jda.api.entities.User;
 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;
@@ -83,6 +85,11 @@ private void handleThreadStatus(ThreadChannel threadChannel) {
         updateThreadStatusToActive(threadId);
     }
 
+    private boolean isNonBotParticipant(long threadOwnerId, Member threadMember) {
+        User targetUser = threadMember.getUser();
+        return threadOwnerId != targetUser.getIdLong() && !targetUser.isBot();
+    }
+
     void handleArchiveStatus(Instant closedAt, long id, JDA jda) {
         ThreadChannel threadChannel = jda.getThreadChannelById(id);
         if (threadChannel == null) {
@@ -101,8 +108,7 @@ void handleArchiveStatus(Instant closedAt, long id, JDA jda) {
         long threadOwnerId = threadChannel.getOwnerIdLong();
         int participantsExceptAuthor = (int) threadChannel.getMembers()
             .stream()
-            .filter(threadMember -> threadMember.getIdLong() != threadOwnerId)
-            .filter(m -> !m.getUser().isBot())
+            .filter(threadMember -> isNonBotParticipant(threadOwnerId, threadMember))
             .count();
 
         database.write(context -> context.update(HELP_THREADS)

From b089ae355438a1756696964e7e572ebfbe45c107 Mon Sep 17 00:00:00 2001
From: Ankit Yadav 
Date: Sun, 8 Feb 2026 07:34:22 +0530
Subject: [PATCH 14/14] Merge develop into feat/help-thread-stats

---
 .github/workflows/docker-publish.yaml         |  34 --
 .woodpecker.yml                               |  30 ++
 CLA.md                                        |   2 -
 application/build.gradle                      |   9 +-
 application/config.json.template              |  15 +
 .../org/togetherjava/tjbot/config/Config.java |  31 +-
 .../tjbot/config/DynamicVoiceChatConfig.java  |  34 ++
 .../tjbot/config/QuoteBoardConfig.java        |  43 ++
 .../togetherjava/tjbot/features/Features.java |  10 +
 .../tjbot/features/MessageReceiver.java       |  10 +
 .../features/MessageReceiverAdapter.java      |   7 +
 .../tjbot/features/VoiceReceiver.java         |  69 +++
 .../tjbot/features/VoiceReceiverAdapter.java  |  59 +++
 .../features/basic/QuoteBoardForwarder.java   | 157 +++++++
 .../features/chatgpt/ChatGptCommand.java      |   8 +-
 .../tjbot/features/chatgpt/ChatGptModel.java  |  45 ++
 .../features/chatgpt/ChatGptService.java      | 164 ++++----
 .../tjbot/features/help/HelpSystemHelper.java |  15 +-
 .../help/HelpThreadCreatedListener.java       |  29 +-
 .../features/messages/MessageCommand.java     | 392 ++++++++++++++++++
 .../features/messages/RewriteCommand.java     | 186 +++++++++
 .../tjbot/features/messages/package-info.java |  11 +
 .../moderation/TransferQuestionCommand.java   |   4 +-
 .../tjbot/features/rss/FailureState.java      |   6 +
 .../tjbot/features/rss/RSSHandlerRoutine.java |  62 ++-
 .../tjbot/features/system/BotCore.java        | 126 ++++++
 .../features/voicechat/DynamicVoiceChat.java  | 196 +++++++++
 .../voicechat/OldestVoiceChatCleanup.java     |  40 ++
 .../voicechat/VoiceChatCleanupStrategy.java   |  21 +
 .../features/voicechat/package-info.java      |  10 +
 build.gradle                                  |   4 +-
 database/build.gradle                         |   2 +-
 scripts/pre-commit                            |   8 +
 wiki/Access-the-VPS.md                        |   2 +-
 34 files changed, 1697 insertions(+), 144 deletions(-)
 delete mode 100644 .github/workflows/docker-publish.yaml
 create mode 100644 .woodpecker.yml
 create mode 100644 application/src/main/java/org/togetherjava/tjbot/config/DynamicVoiceChatConfig.java
 create mode 100644 application/src/main/java/org/togetherjava/tjbot/config/QuoteBoardConfig.java
 create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/VoiceReceiver.java
 create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/VoiceReceiverAdapter.java
 create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/basic/QuoteBoardForwarder.java
 create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/chatgpt/ChatGptModel.java
 create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/messages/MessageCommand.java
 create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/messages/RewriteCommand.java
 create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/messages/package-info.java
 create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/rss/FailureState.java
 create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/voicechat/DynamicVoiceChat.java
 create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/voicechat/OldestVoiceChatCleanup.java
 create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/voicechat/VoiceChatCleanupStrategy.java
 create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/voicechat/package-info.java

diff --git a/.github/workflows/docker-publish.yaml b/.github/workflows/docker-publish.yaml
deleted file mode 100644
index b78064ff15..0000000000
--- a/.github/workflows/docker-publish.yaml
+++ /dev/null
@@ -1,34 +0,0 @@
-name: Docker Publish
-
-on:
-  push:
-    branches:
-      - 'develop'
-      - 'master'
-
-env:
-  JAVA_VERSION: 25
-
-jobs:
-  docker:
-    runs-on: ubuntu-latest
-    steps:
-      - name: Set up JDK
-        uses: actions/setup-java@v1
-        with:
-          java-version: ${{ env.JAVA_VERSION }}
-      - name: Extract branch name
-        shell: bash
-        run: echo "##[set-output name=branch;]$(echo ${GITHUB_REF#refs/heads/})"
-        id: extract_branch
-      - uses: actions/checkout@v4
-        with:
-          fetch-depth: 0
-      - name: Build and Publish Docker Image
-        env:
-          REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
-          REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
-          BRANCH_NAME: ${{ steps.extract_branch.outputs.branch }}
-        run: ./gradlew jib
-      - name: Re-Deploy
-        run: "curl -H 'Authorization: Bearer ${{ secrets.WATCHTOWER_TOKEN }}' https://togetherjava.org:5003/v1/update"
diff --git a/.woodpecker.yml b/.woodpecker.yml
new file mode 100644
index 0000000000..32884c68ca
--- /dev/null
+++ b/.woodpecker.yml
@@ -0,0 +1,30 @@
+when:
+  branch:
+    - develop
+    - master
+  event: push
+
+steps:
+  publish:
+    image: eclipse-temurin:25-jdk
+    environment:
+      REGISTRY_USER:
+        from_secret: REGISTRY_USER
+      REGISTRY_PASSWORD:
+        from_secret: REGISTRY_PASSWORD
+      BRANCH_NAME: ${CI_COMMIT_BRANCH}
+    commands:
+      - echo ${CI_COMMIT_BRANCH}
+      - ./gradlew jib
+
+  deploy:
+    image: alpine
+    depends_on:
+      - publish
+    environment:
+      WATCHTOWER_TOKEN:
+        from_secret: WATCHTOWER_TOKEN
+    commands:
+      - apk add --no-cache curl
+      - | 
+        curl -H "Authorization: Bearer $WATCHTOWER_TOKEN" https://togetherjava.org:5003/v1/update
diff --git a/CLA.md b/CLA.md
index c7a4c71973..e23df7e967 100644
--- a/CLA.md
+++ b/CLA.md
@@ -115,8 +115,6 @@ IF THE DISCLAIMER AND DAMAGE WAIVER MENTIONED IN SECTION 5. AND SECTION 6. CANNO
 
 ### Us
 
-Name: Daniel Tischner (aka Zabuzard, acting on behalf of Together Java)
-
 Organization: https://github.com/Together-Java
 
 Contact: https://discord.com/invite/XXFUXzK
diff --git a/application/build.gradle b/application/build.gradle
index aaa48828c7..280d482687 100644
--- a/application/build.gradle
+++ b/application/build.gradle
@@ -57,7 +57,7 @@ dependencies {
 
     implementation 'io.mikael:urlbuilder:2.0.9'
 
-    implementation 'org.jsoup:jsoup:1.21.1'
+    implementation 'org.jsoup:jsoup:1.22.1'
 
     implementation 'org.scilab.forge:jlatexmath:1.0.7'
     implementation 'org.scilab.forge:jlatexmath-font-greek:1.0.7'
@@ -69,7 +69,7 @@ dependencies {
     implementation "com.fasterxml.jackson.core:jackson-databind:$jacksonVersion"
     implementation "com.sigpwned:jackson-modules-java17-sealed-classes:2.19.0.0"
 
-	implementation 'com.github.freva:ascii-table:1.8.0'
+	implementation 'com.github.freva:ascii-table:1.9.0'
 
     implementation 'io.github.url-detector:url-detector:0.1.23'
 
@@ -80,14 +80,13 @@ dependencies {
     implementation 'org.apache.commons:commons-text:1.15.0'
     implementation 'com.apptasticsoftware:rssreader:3.12.0'
 
-    testImplementation 'org.mockito:mockito-core:5.20.0'
+    testImplementation 'org.mockito:mockito-core:5.21.0'
     testImplementation "org.junit.jupiter:junit-jupiter-api:$junitVersion"
     testImplementation "org.junit.jupiter:junit-jupiter-params:$junitVersion"
     testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
     testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junitVersion"
 
-    implementation "com.theokanning.openai-gpt3-java:api:$chatGPTVersion"
-    implementation "com.theokanning.openai-gpt3-java:service:$chatGPTVersion"
+    implementation "com.openai:openai-java:$chatGPTVersion"
 }
 
 application {
diff --git a/application/config.json.template b/application/config.json.template
index 5cfe9ac38e..e2e1963c80 100644
--- a/application/config.json.template
+++ b/application/config.json.template
@@ -194,10 +194,25 @@
         "videoLinkPattern": "http(s)?://www\\.youtube.com.*",
         "pollIntervalInMinutes": 10
     },
+    "quoteBoardConfig": {
+        "minimumReactionsToTrigger": 5,
+        "channel": "quotes",
+        "reactionEmoji": "⭐"
+    },
     "memberCountCategoryPattern": "Info",
     "topHelpers": {
         "rolePattern": "Top Helper.*",
         "assignmentChannelPattern": "community-commands",
         "announcementChannelPattern": "hall-of-fame"
+    },
+    "dynamicVoiceChatConfig": {
+        "dynamicChannelPatterns": [
+            "Gaming",
+            "Support/Studying Room",
+            "Chit Chat"
+        ],
+        "archiveCategoryPattern": "Voice Channel Archives",
+        "cleanChannelsAmount": 20,
+        "minimumChannelsAmount": 40
     }
 }
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 60e6622cbc..33362afcb0 100644
--- a/application/src/main/java/org/togetherjava/tjbot/config/Config.java
+++ b/application/src/main/java/org/togetherjava/tjbot/config/Config.java
@@ -48,7 +48,9 @@ public final class Config {
     private final RSSFeedsConfig rssFeedsConfig;
     private final String selectRolesChannelPattern;
     private final String memberCountCategoryPattern;
+    private final QuoteBoardConfig quoteBoardConfig;
     private final TopHelpersConfig topHelpers;
+    private final DynamicVoiceChatConfig dynamicVoiceChatConfig;
 
     @SuppressWarnings("ConstructorWithTooManyParameters")
     @JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
@@ -102,7 +104,11 @@ private Config(@JsonProperty(value = "token", required = true) String token,
             @JsonProperty(value = "rssConfig", required = true) RSSFeedsConfig rssFeedsConfig,
             @JsonProperty(value = "selectRolesChannelPattern",
                     required = true) String selectRolesChannelPattern,
-            @JsonProperty(value = "topHelpers", required = true) TopHelpersConfig topHelpers) {
+            @JsonProperty(value = "quoteBoardConfig",
+                    required = true) QuoteBoardConfig quoteBoardConfig,
+            @JsonProperty(value = "topHelpers", required = true) TopHelpersConfig topHelpers,
+            @JsonProperty(value = "dynamicVoiceChatConfig",
+                    required = true) DynamicVoiceChatConfig dynamicVoiceChatConfig) {
         this.token = Objects.requireNonNull(token);
         this.githubApiKey = Objects.requireNonNull(githubApiKey);
         this.databasePath = Objects.requireNonNull(databasePath);
@@ -137,7 +143,9 @@ 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.quoteBoardConfig = Objects.requireNonNull(quoteBoardConfig);
         this.topHelpers = Objects.requireNonNull(topHelpers);
+        this.dynamicVoiceChatConfig = Objects.requireNonNull(dynamicVoiceChatConfig);
     }
 
     /**
@@ -431,6 +439,18 @@ public String getSelectRolesChannelPattern() {
         return selectRolesChannelPattern;
     }
 
+    /**
+     * The configuration of the quote messages config.
+     *
+     * 

+ * >The configuration of the quote board feature. Quotes user selected messages. + * + * @return configuration of quote messages config + */ + public QuoteBoardConfig getQuoteBoardConfig() { + return quoteBoardConfig; + } + /** * Gets the pattern matching the category that is used to display the total member count. * @@ -457,4 +477,13 @@ public RSSFeedsConfig getRSSFeedsConfig() { public TopHelpersConfig getTopHelpers() { return topHelpers; } + + /** + * Gets the dynamic voice chat configuration + * + * @return the dynamic voice chat configuration + */ + public DynamicVoiceChatConfig getDynamicVoiceChatConfig() { + return dynamicVoiceChatConfig; + } } diff --git a/application/src/main/java/org/togetherjava/tjbot/config/DynamicVoiceChatConfig.java b/application/src/main/java/org/togetherjava/tjbot/config/DynamicVoiceChatConfig.java new file mode 100644 index 0000000000..6215da5636 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/config/DynamicVoiceChatConfig.java @@ -0,0 +1,34 @@ +package org.togetherjava.tjbot.config; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * Configuration for the dynamic voice chat feature. + * + * @param archiveCategoryPattern the name of the Discord Guild category in which the archived + * channels will go + * @param cleanChannelsAmount the amount of channels to clean once a cleanup is triggered + * @param minimumChannelsAmount the amount of voice channels for the archive category to have before + * a cleanup triggers + */ +public record DynamicVoiceChatConfig( + @JsonProperty(value = "dynamicChannelPatterns", + required = true) List dynamicChannelPatterns, + @JsonProperty(value = "archiveCategoryPattern", + required = true) String archiveCategoryPattern, + @JsonProperty(value = "cleanChannelsAmount") int cleanChannelsAmount, + @JsonProperty(value = "minimumChannelsAmount", required = true) int minimumChannelsAmount) { + + /** + * Constructs an instance of {@code DynamicVoiceChatConfig} and throws if + * {@code dynamicChannelPatterns} or @{code archiveCategoryPattern} is null. + */ + public DynamicVoiceChatConfig { + Objects.requireNonNull(dynamicChannelPatterns); + Objects.requireNonNull(archiveCategoryPattern); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/config/QuoteBoardConfig.java b/application/src/main/java/org/togetherjava/tjbot/config/QuoteBoardConfig.java new file mode 100644 index 0000000000..faf756b4a8 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/config/QuoteBoardConfig.java @@ -0,0 +1,43 @@ +package org.togetherjava.tjbot.config; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonRootName; +import org.apache.logging.log4j.LogManager; + +import org.togetherjava.tjbot.features.basic.QuoteBoardForwarder; + +import java.util.Objects; + +/** + * Configuration for the quote board feature, see {@link QuoteBoardForwarder}. + */ +@JsonRootName("quoteBoardConfig") +public record QuoteBoardConfig( + @JsonProperty(value = "minimumReactionsToTrigger", required = true) int minimumReactions, + @JsonProperty(required = true) String channel, + @JsonProperty(value = "reactionEmoji", required = true) String reactionEmoji) { + + /** + * Creates a QuoteBoardConfig. + * + * @param minimumReactions the minimum amount of reactions + * @param channel the pattern for the board channel + * @param reactionEmoji the emoji with which users should react to + */ + public QuoteBoardConfig { + if (minimumReactions <= 0) { + throw new IllegalArgumentException("minimumReactions must be greater than zero"); + } + Objects.requireNonNull(channel); + if (channel.isBlank()) { + throw new IllegalArgumentException("channel must not be empty or blank"); + } + Objects.requireNonNull(reactionEmoji); + if (reactionEmoji.isBlank()) { + throw new IllegalArgumentException("reactionEmoji must not be empty or blank"); + } + LogManager.getLogger(QuoteBoardConfig.class) + .debug("Quote-Board configs loaded: minimumReactions={}, channel='{}', reactionEmoji='{}'", + minimumReactions, channel, reactionEmoji); + } +} 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 f253cd4c5a..c35e22f04f 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/Features.java @@ -8,6 +8,7 @@ import org.togetherjava.tjbot.db.Database; import org.togetherjava.tjbot.features.basic.MemberCountDisplayRoutine; import org.togetherjava.tjbot.features.basic.PingCommand; +import org.togetherjava.tjbot.features.basic.QuoteBoardForwarder; import org.togetherjava.tjbot.features.basic.RoleSelectCommand; import org.togetherjava.tjbot.features.basic.SlashCommandEducator; import org.togetherjava.tjbot.features.basic.SuggestionsUpDownVoter; @@ -40,6 +41,8 @@ import org.togetherjava.tjbot.features.mathcommands.TeXCommand; import org.togetherjava.tjbot.features.mathcommands.wolframalpha.WolframAlphaCommand; import org.togetherjava.tjbot.features.mediaonly.MediaOnlyChannelListener; +import org.togetherjava.tjbot.features.messages.MessageCommand; +import org.togetherjava.tjbot.features.messages.RewriteCommand; import org.togetherjava.tjbot.features.moderation.BanCommand; import org.togetherjava.tjbot.features.moderation.KickCommand; import org.togetherjava.tjbot.features.moderation.ModerationActionsStore; @@ -78,6 +81,7 @@ import org.togetherjava.tjbot.features.tophelper.TopHelpersMessageListener; import org.togetherjava.tjbot.features.tophelper.TopHelpersPurgeMessagesRoutine; import org.togetherjava.tjbot.features.tophelper.TopHelpersService; +import org.togetherjava.tjbot.features.voicechat.DynamicVoiceChat; import java.util.ArrayList; import java.util.Collection; @@ -161,6 +165,10 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(new CodeMessageManualDetection(codeMessageHandler)); features.add(new SlashCommandEducator()); features.add(new PinnedNotificationRemover(config)); + features.add(new QuoteBoardForwarder(config)); + + // Voice receivers + features.add(new DynamicVoiceChat(config)); // Event receivers features.add(new RejoinModerationRoleListener(actionsStore, config)); @@ -204,6 +212,8 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(new BookmarksCommand(bookmarksSystem)); features.add(new ChatGptCommand(chatGptService, helpSystemHelper)); features.add(new JShellCommand(jshellEval)); + features.add(new MessageCommand()); + features.add(new RewriteCommand(chatGptService)); features.add(new HelpThreadStatsCommand(database)); FeatureBlacklist> blacklist = blacklistConfig.normal(); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/MessageReceiver.java b/application/src/main/java/org/togetherjava/tjbot/features/MessageReceiver.java index c5b6358434..18a1adb023 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/MessageReceiver.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/MessageReceiver.java @@ -3,6 +3,7 @@ import net.dv8tion.jda.api.events.message.MessageDeleteEvent; import net.dv8tion.jda.api.events.message.MessageReceivedEvent; import net.dv8tion.jda.api.events.message.MessageUpdateEvent; +import net.dv8tion.jda.api.events.message.react.MessageReactionAddEvent; import java.util.regex.Pattern; @@ -56,4 +57,13 @@ public interface MessageReceiver extends Feature { * message that was deleted */ void onMessageDeleted(MessageDeleteEvent event); + + /** + * Triggered by the core system whenever a new reaction was added to a message in a text channel + * of a guild the bot has been added to. + * + * @param event the event that triggered this, containing information about the corresponding + * reaction that was added + */ + void onMessageReactionAdd(MessageReactionAddEvent event); } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/MessageReceiverAdapter.java b/application/src/main/java/org/togetherjava/tjbot/features/MessageReceiverAdapter.java index 05280c97ab..6ceee951b9 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/MessageReceiverAdapter.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/MessageReceiverAdapter.java @@ -3,6 +3,7 @@ import net.dv8tion.jda.api.events.message.MessageDeleteEvent; import net.dv8tion.jda.api.events.message.MessageReceivedEvent; import net.dv8tion.jda.api.events.message.MessageUpdateEvent; +import net.dv8tion.jda.api.events.message.react.MessageReactionAddEvent; import java.util.regex.Pattern; @@ -57,4 +58,10 @@ public void onMessageUpdated(MessageUpdateEvent event) { public void onMessageDeleted(MessageDeleteEvent event) { // Adapter does not react by default, subclasses may change this behavior } + + @SuppressWarnings("NoopMethodInAbstractClass") + @Override + public void onMessageReactionAdd(MessageReactionAddEvent event) { + // Adapter does not react by default, subclasses may change this behavior + } } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/VoiceReceiver.java b/application/src/main/java/org/togetherjava/tjbot/features/VoiceReceiver.java new file mode 100644 index 0000000000..170dabd4fe --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/VoiceReceiver.java @@ -0,0 +1,69 @@ +package org.togetherjava.tjbot.features; + +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceDeafenEvent; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceMuteEvent; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceStreamEvent; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceUpdateEvent; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceVideoEvent; + +import java.util.regex.Pattern; + +/** + * Receives incoming Discord guild events from voice channels matching a given pattern. + *

+ * All voice receivers have to implement this interface. For convenience, there is a + * {@link VoiceReceiverAdapter} available that implemented most methods already. A new receiver can + * then be registered by adding it to {@link Features}. + *

+ *

+ * After registration, the system will notify a receiver whenever a new event was sent or an + * existing event was updated in any channel matching the {@link #getChannelNamePattern()} the bot + * is added to. + */ +public interface VoiceReceiver extends Feature { + /** + * Retrieves the pattern matching the names of channels of which this receiver is interested in + * receiving events from. Called by the core system once during the startup in order to register + * the receiver accordingly. + *

+ * Changes on the pattern returned by this method afterwards will not be picked up. + * + * @return the pattern matching the names of relevant channels + */ + Pattern getChannelNamePattern(); + + /** + * Triggered by the core system whenever a member joined, left or moved voice channels. + * + * @param event the event that triggered this + */ + void onVoiceUpdate(GuildVoiceUpdateEvent event); + + /** + * Triggered by the core system whenever a member toggled their camera in a voice channel. + * + * @param event the event that triggered this + */ + void onVideoToggle(GuildVoiceVideoEvent event); + + /** + * Triggered by the core system whenever a member started or stopped a stream. + * + * @param event the event that triggered this + */ + void onStreamToggle(GuildVoiceStreamEvent event); + + /** + * Triggered by the core system whenever a member toggled their mute status. + * + * @param event the event that triggered this + */ + void onMuteToggle(GuildVoiceMuteEvent event); + + /** + * Triggered by the core system whenever a member toggled their deafened status. + * + * @param event the event that triggered this + */ + void onDeafenToggle(GuildVoiceDeafenEvent event); +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/VoiceReceiverAdapter.java b/application/src/main/java/org/togetherjava/tjbot/features/VoiceReceiverAdapter.java new file mode 100644 index 0000000000..f4f86aa262 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/VoiceReceiverAdapter.java @@ -0,0 +1,59 @@ +package org.togetherjava.tjbot.features; + +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceDeafenEvent; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceMuteEvent; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceStreamEvent; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceUpdateEvent; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceVideoEvent; + +import java.util.regex.Pattern; + +/** + * Adapter implementation of a {@link VoiceReceiver}. A new receiver can then be registered by + * adding it to {@link Features}. + *

+ * {@link #onVoiceUpdate(GuildVoiceUpdateEvent)} like the other provided methods can be overridden + * if desired. The default implementation is empty, the adapter will not react to such events. + */ +public class VoiceReceiverAdapter implements VoiceReceiver { + + private final Pattern channelNamePattern; + + protected VoiceReceiverAdapter() { + this(Pattern.compile(".*")); + } + + protected VoiceReceiverAdapter(Pattern channelNamePattern) { + this.channelNamePattern = channelNamePattern; + } + + @Override + public Pattern getChannelNamePattern() { + return channelNamePattern; + } + + @Override + public void onVoiceUpdate(GuildVoiceUpdateEvent event) { + // Adapter does not react by default, subclasses may change this behavior + } + + @Override + public void onVideoToggle(GuildVoiceVideoEvent event) { + // Adapter does not react by default, subclasses may change this behavior + } + + @Override + public void onStreamToggle(GuildVoiceStreamEvent event) { + // Adapter does not react by default, subclasses may change this behavior + } + + @Override + public void onMuteToggle(GuildVoiceMuteEvent event) { + // Adapter does not react by default, subclasses may change this behavior + } + + @Override + public void onDeafenToggle(GuildVoiceDeafenEvent event) { + // Adapter does not react by default, subclasses may change this behavior + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/basic/QuoteBoardForwarder.java b/application/src/main/java/org/togetherjava/tjbot/features/basic/QuoteBoardForwarder.java new file mode 100644 index 0000000000..a6d067e16e --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/basic/QuoteBoardForwarder.java @@ -0,0 +1,157 @@ +package org.togetherjava.tjbot.features.basic; + +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.TextChannel; +import net.dv8tion.jda.api.entities.emoji.Emoji; +import net.dv8tion.jda.api.events.message.react.MessageReactionAddEvent; +import net.dv8tion.jda.api.requests.RestAction; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.togetherjava.tjbot.config.Config; +import org.togetherjava.tjbot.config.QuoteBoardConfig; +import org.togetherjava.tjbot.features.MessageReceiverAdapter; + +import java.util.List; +import java.util.Optional; +import java.util.function.Predicate; +import java.util.regex.Pattern; + +/** + * Listens for reaction-add events and turns popular messages into "quotes". + *

+ * When someone reacts to a message with the configured emoji, the listener counts how many users + * have used that same emoji. If the total meets or exceeds the minimum threshold and the bot has + * not processed the message before, it copies (forwards) the message to the first text channel + * whose name matches the configured quote-board pattern, then reacts to the original message itself + * to mark it as handled (and to not let people spam react a message and give a way to the bot to + * know that a message has been quoted before). + *

+ * Key points: - Trigger emoji, minimum vote count and quote-board channel pattern are supplied via + * {@code QuoteBoardConfig}. + */ +public final class QuoteBoardForwarder extends MessageReceiverAdapter { + + private static final Logger logger = LoggerFactory.getLogger(QuoteBoardForwarder.class); + private final Emoji triggerReaction; + private final Predicate isQuoteBoardChannelName; + private final QuoteBoardConfig config; + + /** + * Constructs a new instance of QuoteBoardForwarder. + * + * @param config the configuration containing settings specific to the cool messages board, + * including the reaction emoji and the pattern to match board channel names + */ + public QuoteBoardForwarder(Config config) { + this.config = config.getQuoteBoardConfig(); + this.triggerReaction = Emoji.fromUnicode(this.config.reactionEmoji()); + + this.isQuoteBoardChannelName = Pattern.compile(this.config.channel()).asMatchPredicate(); + } + + @Override + public void onMessageReactionAdd(MessageReactionAddEvent event) { + logger.debug("Received MessageReactionAddEvent: messageId={}, channelId={}, userId={}", + event.getMessageId(), event.getChannel().getId(), event.getUserId()); + + final MessageReaction messageReaction = event.getReaction(); + + if (!messageReaction.getEmoji().equals(triggerReaction)) { + logger.debug("Reaction emoji '{}' does not match trigger emoji '{}'. Ignoring.", + messageReaction.getEmoji(), triggerReaction); + return; + } + + if (hasAlreadyForwardedMessage(event.getJDA(), messageReaction)) { + logger.debug("Message has already been forwarded by the bot. Skipping."); + return; + } + + long reactionCount = messageReaction.retrieveUsers().stream().count(); + if (reactionCount < config.minimumReactions()) { + logger.debug("Reaction count {} is less than minimum required {}. Skipping.", + reactionCount, config.minimumReactions()); + return; + } + + final long guildId = event.getGuild().getIdLong(); + + Optional boardChannelOptional = findQuoteBoardChannel(event.getJDA(), guildId); + + if (boardChannelOptional.isEmpty()) { + logger.warn( + "Could not find board channel with pattern '{}' in server with ID '{}'. Skipping reaction handling...", + this.config.channel(), guildId); + return; + } + + TextChannel boardChannel = boardChannelOptional.orElseThrow(); + + if (boardChannel.getId().equals(event.getChannel().getId())) { + logger.debug("Someone tried to react with the react emoji to the quotes channel."); + return; + } + + logger.debug("Forwarding message to quote board channel: {}", boardChannel.getName()); + + event.retrieveMessage() + .queue(message -> markAsProcessed(message).flatMap(v -> message.forwardTo(boardChannel)) + .queue(_ -> logger.debug("Message forwarded to quote board channel: {}", + boardChannel.getName())), + + e -> logger.warn( + "Unknown error while attempting to retrieve and forward message for quote-board, message is ignored.", + e)); + + } + + private RestAction markAsProcessed(Message message) { + return message.addReaction(triggerReaction); + } + + /** + * Gets the board text channel where the quotes go to, wrapped in an optional. + * + * @param jda the JDA + * @param guildId the guild ID + * @return the board text channel + */ + private Optional findQuoteBoardChannel(JDA jda, long guildId) { + Guild guild = jda.getGuildById(guildId); + + if (guild == null) { + throw new IllegalStateException( + String.format("Guild with ID '%d' not found.", guildId)); + } + + List matchingChannels = guild.getTextChannelCache() + .stream() + .filter(channel -> isQuoteBoardChannelName.test(channel.getName())) + .toList(); + + if (matchingChannels.size() > 1) { + logger.warn( + "Multiple quote board channels found matching pattern '{}' in guild with ID '{}'. Selecting the first one anyway.", + this.config.channel(), guildId); + } + + return matchingChannels.stream().findFirst(); + } + + /** + * Checks a {@link MessageReaction} to see if the bot has reacted to it. + */ + private boolean hasAlreadyForwardedMessage(JDA jda, MessageReaction messageReaction) { + if (!triggerReaction.equals(messageReaction.getEmoji())) { + return false; + } + + return messageReaction.retrieveUsers() + .parallelStream() + .anyMatch(user -> jda.getSelfUser().getIdLong() == user.getIdLong()); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/ChatGptCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/ChatGptCommand.java index 163220d8a5..1f9b3208fb 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/ChatGptCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/ChatGptCommand.java @@ -25,6 +25,7 @@ * which it will respond with an AI generated answer. */ public final class ChatGptCommand extends SlashCommandAdapter { + private static final ChatGptModel CHAT_GPT_MODEL = ChatGptModel.HIGH_QUALITY; public static final String COMMAND_NAME = "chatgpt"; private static final String QUESTION_INPUT = "question"; private static final int MAX_MESSAGE_INPUT_LENGTH = 200; @@ -82,8 +83,8 @@ public void onModalSubmitted(ModalInteractionEvent event, List args) { String question = event.getValue(QUESTION_INPUT).getAsString(); - Optional chatgptResponse = - chatGptService.ask(question, "You may use markdown syntax for the response"); + Optional chatgptResponse = chatGptService.ask(question, + "You may use markdown syntax for the response", CHAT_GPT_MODEL); if (chatgptResponse.isPresent()) { userIdToAskedAtCache.put(event.getMember().getId(), Instant.now()); } @@ -96,7 +97,8 @@ public void onModalSubmitted(ModalInteractionEvent event, List args) { String response = chatgptResponse.orElse(errorResponse); SelfUser selfUser = event.getJDA().getSelfUser(); - MessageEmbed responseEmbed = helper.generateGptResponseEmbed(response, selfUser, question); + MessageEmbed responseEmbed = + helper.generateGptResponseEmbed(response, selfUser, question, CHAT_GPT_MODEL); event.getHook().sendMessageEmbeds(responseEmbed).queue(); } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/ChatGptModel.java b/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/ChatGptModel.java new file mode 100644 index 0000000000..e08951f4b3 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/ChatGptModel.java @@ -0,0 +1,45 @@ +package org.togetherjava.tjbot.features.chatgpt; + +import com.openai.models.ChatModel; + +/** + * Logical abstraction over OpenAI chat models. + *

+ * This enum allows the application to select models based on performance/quality intent rather than + * hard-coding specific OpenAI model versions throughout the codebase. + * + */ +public enum ChatGptModel { + /** + * Fastest response time with the lowest computational cost. + */ + FASTEST(ChatModel.GPT_3_5_TURBO), + + /** + * Balanced option between speed and quality. + */ + FAST(ChatModel.GPT_4_1_MINI), + + /** + * Highest quality responses with increased reasoning capability. + */ + HIGH_QUALITY(ChatModel.GPT_5_MINI); + + private final ChatModel chatModel; + + ChatGptModel(ChatModel chatModel) { + this.chatModel = chatModel; + } + + /** + * @return the underlying OpenAI model used by this enum. + */ + public ChatModel toChatModel() { + return chatModel; + } + + @Override + public String toString() { + return chatModel.toString(); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/ChatGptService.java b/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/ChatGptService.java index a6fdcbcb9d..08ddbee729 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/ChatGptService.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/ChatGptService.java @@ -1,10 +1,10 @@ package org.togetherjava.tjbot.features.chatgpt; -import com.theokanning.openai.OpenAiHttpException; -import com.theokanning.openai.completion.chat.ChatCompletionRequest; -import com.theokanning.openai.completion.chat.ChatMessage; -import com.theokanning.openai.completion.chat.ChatMessageRole; -import com.theokanning.openai.service.OpenAiService; +import com.openai.client.OpenAIClient; +import com.openai.client.okhttp.OpenAIOkHttpClient; +import com.openai.models.responses.Response; +import com.openai.models.responses.ResponseCreateParams; +import com.openai.models.responses.ResponseOutputText; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -13,8 +13,8 @@ import javax.annotation.Nullable; import java.time.Duration; -import java.util.List; import java.util.Optional; +import java.util.stream.Collectors; /** * Service used to communicate to OpenAI API to generate responses. @@ -24,32 +24,10 @@ public class ChatGptService { private static final Duration TIMEOUT = Duration.ofSeconds(90); /** The maximum number of tokens allowed for the generated answer. */ - private static final int MAX_TOKENS = 3_000; - - /** - * This parameter reduces the likelihood of the AI repeating itself. A higher frequency penalty - * makes the model less likely to repeat the same lines verbatim. It helps in generating more - * diverse and varied responses. - */ - private static final double FREQUENCY_PENALTY = 0.5; - - /** - * This parameter controls the randomness of the AI's responses. A higher temperature results in - * more varied, unpredictable, and creative responses. Conversely, a lower temperature makes the - * model's responses more deterministic and conservative. - */ - private static final double TEMPERATURE = 0.8; - - /** - * n: This parameter specifies the number of responses to generate for each prompt. If n is more - * than 1, the AI will generate multiple different responses to the same prompt, each one being - * a separate iteration based on the input. - */ - private static final int MAX_NUMBER_OF_RESPONSES = 1; - private static final String AI_MODEL = "gpt-3.5-turbo"; + private static final int MAX_TOKENS = 1000; private boolean isDisabled = false; - private OpenAiService openAiService; + private OpenAIClient openAIClient; /** * Creates instance of ChatGPTService @@ -61,25 +39,11 @@ public ChatGptService(Config config) { boolean keyIsDefaultDescription = apiKey.startsWith("<") && apiKey.endsWith(">"); if (apiKey.isBlank() || keyIsDefaultDescription) { isDisabled = true; + logger.warn("ChatGPT service is disabled: API key is not configured"); return; } - - openAiService = new OpenAiService(apiKey, TIMEOUT); - - ChatMessage setupMessage = new ChatMessage(ChatMessageRole.SYSTEM.value(), """ - For code supplied for review, refer to the old code supplied rather than - rewriting the code. DON'T supply a corrected version of the code.\s"""); - ChatCompletionRequest systemSetupRequest = ChatCompletionRequest.builder() - .model(AI_MODEL) - .messages(List.of(setupMessage)) - .frequencyPenalty(FREQUENCY_PENALTY) - .temperature(TEMPERATURE) - .maxTokens(50) - .n(MAX_NUMBER_OF_RESPONSES) - .build(); - - // Sending the system setup message to ChatGPT. - openAiService.createChatCompletion(systemSetupRequest); + openAIClient = OpenAIOkHttpClient.builder().apiKey(apiKey).timeout(TIMEOUT).build(); + logger.info("ChatGPT service initialized successfully"); } /** @@ -88,52 +52,88 @@ public ChatGptService(Config config) { * @param question The question being asked of ChatGPT. Max is {@value MAX_TOKENS} tokens. * @param context The category of asked question, to set the context(eg. Java, Database, Other * etc). + * @param chatModel The AI model to use for this request. + * @return response from ChatGPT as a String. + * @see ChatGPT + * Tokens. + */ + public Optional ask(String question, @Nullable String context, ChatGptModel chatModel) { + String contextText = context == null ? "" : ", Context: %s.".formatted(context); + String inputPrompt = """ + For code supplied for review, refer to the old code supplied rather than + rewriting the code. DON'T supply a corrected version of the code. + + KEEP IT CONCISE, NOT MORE THAN 280 WORDS + + %s + Question: %s + """.formatted(contextText, question); + + return sendPrompt(inputPrompt, chatModel); + } + + /** + * Prompt ChatGPT with a raw prompt and receive a response without any prefix wrapping. + *

+ * Use this method when you need full control over the prompt structure without the service's + * opinionated formatting (e.g., for iterative refinement or specialized use cases). + * + * @param inputPrompt The raw prompt to send to ChatGPT. Max is {@value MAX_TOKENS} tokens. + * @param chatModel The AI model to use for this request. * @return response from ChatGPT as a String. * @see ChatGPT * Tokens. */ - public Optional ask(String question, @Nullable String context) { + public Optional askRaw(String inputPrompt, ChatGptModel chatModel) { + return sendPrompt(inputPrompt, chatModel); + } + + /** + * Sends a prompt to the ChatGPT API and returns the response. + * + * @param prompt The prompt to send to ChatGPT. + * @param chatModel The AI model to use for this request. + * @return response from ChatGPT as a String. + */ + private Optional sendPrompt(String prompt, ChatGptModel chatModel) { if (isDisabled) { + logger.warn("ChatGPT request attempted but service is disabled"); return Optional.empty(); } - String contextText = context == null ? "" : ", Context: %s.".formatted(context); - String fullQuestion = "(KEEP IT CONCISE, NOT MORE THAN 280 WORDS%s) - %s" - .formatted(contextText, question); - - ChatMessage chatMessage = new ChatMessage(ChatMessageRole.USER.value(), fullQuestion); - ChatCompletionRequest chatCompletionRequest = ChatCompletionRequest.builder() - .model(AI_MODEL) - .messages(List.of(chatMessage)) - .frequencyPenalty(FREQUENCY_PENALTY) - .temperature(TEMPERATURE) - .maxTokens(MAX_TOKENS) - .n(MAX_NUMBER_OF_RESPONSES) - .build(); - logger.debug("ChatGpt Request: {}", fullQuestion); - - String response = null; + logger.debug("ChatGpt request: {}", prompt); + try { - response = openAiService.createChatCompletion(chatCompletionRequest) - .getChoices() - .getFirst() - .getMessage() - .getContent(); - } catch (OpenAiHttpException openAiHttpException) { - logger.warn( - "There was an error using the OpenAI API: {} Code: {} Type: {} Status Code: {}", - openAiHttpException.getMessage(), openAiHttpException.code, - openAiHttpException.type, openAiHttpException.statusCode); + ResponseCreateParams params = ResponseCreateParams.builder() + .model(chatModel.toChatModel()) + .input(prompt) + .maxOutputTokens(MAX_TOKENS) + .build(); + + Response chatGptResponse = openAIClient.responses().create(params); + + String response = chatGptResponse.output() + .stream() + .flatMap(item -> item.message().stream()) + .flatMap(message -> message.content().stream()) + .flatMap(content -> content.outputText().stream()) + .map(ResponseOutputText::text) + .collect(Collectors.joining("\n")); + + logger.debug("ChatGpt Response: {}", response); + + if (response.isBlank()) { + logger.warn("ChatGPT returned an empty response"); + return Optional.empty(); + } + + logger.debug("ChatGpt response received successfully, length: {} characters", + response.length()); + return Optional.of(response); } catch (RuntimeException runtimeException) { - logger.warn("There was an error using the OpenAI API: {}", - runtimeException.getMessage()); - } - - logger.debug("ChatGpt Response: {}", response); - if (response == null) { + logger.error("Error communicating with OpenAI API: {}", runtimeException.getMessage(), + runtimeException); return Optional.empty(); } - - return Optional.of(response); } } 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 dbb6ed55e2..edf217f1ea 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 @@ -25,6 +25,7 @@ import org.togetherjava.tjbot.db.generated.tables.HelpThreads; import org.togetherjava.tjbot.db.generated.tables.records.HelpThreadsRecord; import org.togetherjava.tjbot.features.chatgpt.ChatGptCommand; +import org.togetherjava.tjbot.features.chatgpt.ChatGptModel; import org.togetherjava.tjbot.features.chatgpt.ChatGptService; import org.togetherjava.tjbot.features.componentids.ComponentIdInteractor; import org.togetherjava.tjbot.features.utils.Guilds; @@ -55,6 +56,7 @@ */ public final class HelpSystemHelper { private static final Logger logger = LoggerFactory.getLogger(HelpSystemHelper.class); + private static final ChatGptModel CHAT_GPT_MODEL = ChatGptModel.FAST; static final Color AMBIENT_COLOR = new Color(255, 255, 165); @@ -143,7 +145,7 @@ RestAction constructChatGptAttempt(ThreadChannel threadChannel, String context = "Category %s on a Java Q&A discord server. You may use markdown syntax for the response" .formatted(matchingTag.getName()); - chatGptAnswer = chatGptService.ask(question, context); + chatGptAnswer = chatGptService.ask(question, context, CHAT_GPT_MODEL); if (chatGptAnswer.isEmpty()) { return useChatGptFallbackMessage(threadChannel); @@ -168,7 +170,8 @@ RestAction constructChatGptAttempt(ThreadChannel threadChannel, answer = answer.substring(0, responseCharLimit); } - MessageEmbed responseEmbed = generateGptResponseEmbed(answer, selfUser, originalQuestion); + MessageEmbed responseEmbed = + generateGptResponseEmbed(answer, selfUser, originalQuestion, CHAT_GPT_MODEL); return post.flatMap(_ -> threadChannel.sendMessageEmbeds(responseEmbed) .addActionRow(generateDismissButton(componentIdInteractor, messageId.get()))); } @@ -178,11 +181,13 @@ RestAction constructChatGptAttempt(ThreadChannel threadChannel, * * @param answer The response text generated by AI. * @param selfUser The SelfUser representing the bot. - * @param title The title for the MessageEmbed. + * @param title The title for the MessageEmbed + * @param model The AI model that was used for the foot notes * @return A MessageEmbed that contains response generated by AI. */ - public MessageEmbed generateGptResponseEmbed(String answer, SelfUser selfUser, String title) { - String responseByGptFooter = "- AI generated response"; + public MessageEmbed generateGptResponseEmbed(String answer, SelfUser selfUser, String title, + ChatGptModel model) { + String responseByGptFooter = "- AI generated response using %s model".formatted(model); int embedTitleLimit = MessageEmbed.TITLE_MAX_LENGTH; String capitalizedTitle = Character.toUpperCase(title.charAt(0)) + title.substring(1); 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 cd453eab63..bbf8490a2c 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 @@ -12,8 +12,12 @@ import net.dv8tion.jda.api.entities.channel.forums.ForumTag; import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; import net.dv8tion.jda.api.events.message.MessageReceivedEvent; +import net.dv8tion.jda.api.exceptions.ErrorResponseException; import net.dv8tion.jda.api.hooks.ListenerAdapter; +import net.dv8tion.jda.api.requests.ErrorResponse; import net.dv8tion.jda.api.requests.RestAction; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.togetherjava.tjbot.features.EventReceiver; import org.togetherjava.tjbot.features.UserInteractionType; @@ -28,8 +32,10 @@ import java.util.List; import java.util.Objects; import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; import java.util.stream.Collectors; + /** * Listens for new help threads being created. That is, a user posted a question in the help forum. *

@@ -38,6 +44,7 @@ */ public final class HelpThreadCreatedListener extends ListenerAdapter implements EventReceiver, UserInteractor { + private static final Logger log = LoggerFactory.getLogger(HelpThreadCreatedListener.class); private final HelpSystemHelper helper; private final Cache threadIdToCreatedAtCache = Caffeine.newBuilder() @@ -159,6 +166,25 @@ public void acceptComponentIdGenerator(ComponentIdGenerator generator) { componentIdInteractor.acceptComponentIdGenerator(generator); } + private Consumer handleParentMessageDeleted(Member user, ThreadChannel channel, + ButtonInteractionEvent event, List args) { + int noOfMessages = 1; // we only care about first message from channel history + return error -> { + if (error instanceof ErrorResponseException ere + && ere.getErrorResponse() == ErrorResponse.UNKNOWN_MESSAGE) { + channel.getIterableHistory().reverse().limit(noOfMessages).queue(messages -> { + if (!messages.isEmpty()) { + handleDismiss(user, channel, messages.getFirst(), event, args); + } + }); + } else { + log.error( + "Trying to dismiss AI help message for thread: {}, unable to find original message.", + channel.getId(), error); + } + }; + } + @Override public void onButtonClick(ButtonInteractionEvent event, List args) { // This method handles chatgpt's automatic response "dismiss" button @@ -169,7 +195,8 @@ public void onButtonClick(ButtonInteractionEvent event, List args) { channel.retrieveStartMessage() .queue(forumPostMessage -> handleDismiss(interactionUser, channel, forumPostMessage, - event, args)); + event, args), + handleParentMessageDeleted(interactionUser, channel, event, args)); } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/messages/MessageCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/messages/MessageCommand.java new file mode 100644 index 0000000000..ce36653b36 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/messages/MessageCommand.java @@ -0,0 +1,392 @@ +package org.togetherjava.tjbot.features.messages; + +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.channel.ChannelType; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import net.dv8tion.jda.api.entities.channel.unions.GuildChannelUnion; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.exceptions.ErrorResponseException; +import net.dv8tion.jda.api.interactions.callbacks.IDeferrableCallback; +import net.dv8tion.jda.api.interactions.callbacks.IReplyCallback; +import net.dv8tion.jda.api.interactions.commands.CommandInteraction; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import net.dv8tion.jda.api.requests.ErrorResponse; +import net.dv8tion.jda.api.utils.FileUpload; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.togetherjava.tjbot.features.CommandVisibility; +import org.togetherjava.tjbot.features.SlashCommandAdapter; + +import java.awt.Color; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.OptionalLong; + +/** + * Implements the {@code /message} command, which offers utility dealing with messages. Available + * subcommands are: + *

    + *
  • {@code raw}
  • + *
  • {@code post}
  • + *
  • {@code post-with-message}
  • + *
  • {@code edit}
  • + *
  • {@code edit-with-message}
  • + *
+ */ +public final class MessageCommand extends SlashCommandAdapter { + private static final Logger logger = LoggerFactory.getLogger(MessageCommand.class); + static final String CONTENT_MESSAGE_ID_OPTION = "content-message-id"; + private static final String CONTENT_MESSAGE_ID_DESCRIPTION = + "the id of the message to read content from, must be in the channel this command is invoked"; + static final String SRC_CHANNEL_OPTION = "source"; + private static final String EDIT_SRC_CHANNEL_DESCRIPTION = "where to find the message to edit"; + static final String DEST_CHANNEL_OPTION = "destination"; + private static final String DEST_CHANNEL_DESCRIPTION = "where to post the message"; + static final String CONTENT_OPTION = "content"; + private static final String CONTENT_DESCRIPTION = "the content of the message"; + static final String EDIT_MESSAGE_ID_OPTION = "edit-message-id"; + private static final String EDIT_MESSAGE_ID_DESCRIPTION = "the id of the message to edit"; + + private static final Color AMBIENT_COLOR = new Color(24, 109, 221, 255); + + private static final String CONTENT_FILE_NAME = "content.md"; + + /** + * Creates a new instance. + */ + public MessageCommand() { + super("message", "Provides commands to work with messages", CommandVisibility.GUILD); + + SubcommandData raw = new SubcommandData(Subcommand.RAW.name, + "View the raw content of a message, without Discord interpreting any of its content") + .addOption(OptionType.CHANNEL, SRC_CHANNEL_OPTION, + "where to find the message to retrieve content from", true) + .addOption(OptionType.STRING, CONTENT_MESSAGE_ID_OPTION, + "the id of the message to read content from", true); + + SubcommandData post = + new SubcommandData(Subcommand.POST.name, "Let this bot post a message") + .addOption(OptionType.CHANNEL, DEST_CHANNEL_OPTION, DEST_CHANNEL_DESCRIPTION, + true) + .addOption(OptionType.STRING, CONTENT_OPTION, CONTENT_DESCRIPTION, true); + SubcommandData postWithMessage = new SubcommandData(Subcommand.POST_WITH_MESSAGE.name, + "Let this bot post a message. Content is retrieved from the given message.") + .addOption(OptionType.CHANNEL, DEST_CHANNEL_OPTION, DEST_CHANNEL_DESCRIPTION, true) + .addOption(OptionType.STRING, CONTENT_MESSAGE_ID_OPTION, CONTENT_MESSAGE_ID_DESCRIPTION, + true); + + SubcommandData edit = new SubcommandData(Subcommand.EDIT.name, + "Edits a message posted by this bot, the old content is replaced") + .addOption(OptionType.CHANNEL, SRC_CHANNEL_OPTION, EDIT_SRC_CHANNEL_DESCRIPTION, true) + .addOption(OptionType.STRING, EDIT_MESSAGE_ID_OPTION, EDIT_MESSAGE_ID_DESCRIPTION, true) + .addOption(OptionType.STRING, CONTENT_OPTION, CONTENT_DESCRIPTION, true); + SubcommandData editWithMessage = new SubcommandData(Subcommand.EDIT_WITH_MESSAGE.name, + "Edits a message posted by this bot. Content is retrieved from the given message.") + .addOption(OptionType.CHANNEL, SRC_CHANNEL_OPTION, EDIT_SRC_CHANNEL_DESCRIPTION, true) + .addOption(OptionType.STRING, EDIT_MESSAGE_ID_OPTION, EDIT_MESSAGE_ID_DESCRIPTION, true) + .addOption(OptionType.STRING, CONTENT_MESSAGE_ID_OPTION, CONTENT_MESSAGE_ID_DESCRIPTION, + true); + + getData().addSubcommands(raw, post, postWithMessage, edit, editWithMessage); + } + + /** + * Attempts to convert the given channel into a {@link TextChannel}. + *

+ * If the channel is not a text channel, an error message is send to the user. + * + * @param channel the channel to convert + * @param event the event to send messages with + * @return the channel as text channel, if successful + */ + private static Optional handleExpectMessageChannel(GuildChannelUnion channel, + IReplyCallback event) { + if (channel.getType() != ChannelType.TEXT) { + event + .reply("The given channel ('%s') is not a text-channel." + .formatted(channel.getName())) + .setEphemeral(true) + .queue(); + return Optional.empty(); + } + return Optional.of(channel.asTextChannel()); + } + + /** + * Attempts to parse the given message id. + *

+ * If the message id could not be parsed, because it is invalid, an error message is send to the + * user. + * + * @param messageId the message id to parse + * @param event the event to send messages with + * @return the parsed message id, if successful + */ + private static OptionalLong parseMessageIdAndHandle(String messageId, IReplyCallback event) { + try { + return OptionalLong.of(Long.parseLong(messageId)); + } catch (NumberFormatException _) { + event + .reply("The given message id '%s' is invalid, expected a number." + .formatted(messageId)) + .setEphemeral(true) + .queue(); + return OptionalLong.empty(); + } + } + + private static void handleMessageRetrieveFailed(Throwable failure, IDeferrableCallback event, + long messageId) { + handleMessageRetrieveFailed(failure, event, List.of(messageId)); + } + + private static void handleMessageRetrieveFailed(Throwable failure, IDeferrableCallback event, + List messageIds) { + if (failure instanceof ErrorResponseException ex + && ex.getErrorResponse() == ErrorResponse.UNKNOWN_MESSAGE) { + event.getHook() + .editOriginal("The messages with ids '%s' do not exist.".formatted(messageIds)) + .queue(); + return; + } + + logger.warn("Unable to retrieve the messages with ids '{}' for an unknown reason.", + messageIds, failure); + event.getHook() + .editOriginal( + "Something unexpected went wrong trying to locate the messages with ids '%s'." + .formatted(messageIds)) + .queue(); + } + + private static boolean handleIsMessageFromOtherUser(Message message, + IDeferrableCallback event) { + if (message.getAuthor().equals(message.getJDA().getSelfUser())) { + return false; + } + event.getHook() + .editOriginal( + "The message to edit must be from this bot but was posted by another user.") + .queue(); + return true; + } + + private static void sendSuccessMessage(IDeferrableCallback event, Subcommand action) { + event.getHook() + .editOriginalEmbeds(new EmbedBuilder().setTitle("Success") + .setDescription("Successfully %s message.".formatted(action.getActionVerbPast())) + .setColor(MessageCommand.AMBIENT_COLOR) + .build()) + .queue(); + } + + private static void handleActionFailed(Throwable failure, IDeferrableCallback event, + Subcommand action) { + String verb = action.getActionVerb(); + logger.warn("Unable to {} message for an unknown reason.", verb, failure); + event.getHook() + .editOriginal( + "Something unexpected went wrong trying to '%s' the message.".formatted(verb)) + .queue(); + } + + @Override + public void onSlashCommand(SlashCommandInteractionEvent event) { + switch (Subcommand.fromName(event.getSubcommandName())) { + case RAW -> rawMessage(event); + case POST -> postMessage(event); + case POST_WITH_MESSAGE -> postMessageUsingMessageContent(event); + case EDIT -> editMessage(event); + case EDIT_WITH_MESSAGE -> editMessageUsingMessageContent(event); + default -> throw new AssertionError( + "Unexpected subcommand '%s'".formatted(event.getSubcommandName())); + } + } + + private void rawMessage(SlashCommandInteractionEvent event) { + Optional srcChannelOpt = handleExpectMessageChannel( + Objects.requireNonNull(event.getOption(SRC_CHANNEL_OPTION)).getAsChannel(), event); + if (srcChannelOpt.isEmpty()) { + return; + } + TextChannel srcChannel = srcChannelOpt.orElseThrow(); + + OptionalLong contentMessageIdOpt = parseMessageIdAndHandle( + Objects.requireNonNull(event.getOption(CONTENT_MESSAGE_ID_OPTION)).getAsString(), + event); + if (contentMessageIdOpt.isEmpty()) { + return; + } + long contentMessageId = contentMessageIdOpt.orElseThrow(); + + event.deferReply().queue(); + srcChannel.retrieveMessageById(contentMessageId).queue(contentMessage -> { + String content = contentMessage.getContentRaw(); + event.getHook() + .editOriginal("") + .setFiles(FileUpload.fromData(content.getBytes(StandardCharsets.UTF_8), + CONTENT_FILE_NAME)) + .queue(); + }, failure -> handleMessageRetrieveFailed(failure, event, contentMessageId)); + } + + private void postMessage(CommandInteraction event) { + Subcommand action = Subcommand.POST; + Optional destChannelOpt = handleExpectMessageChannel( + Objects.requireNonNull(event.getOption(DEST_CHANNEL_OPTION)).getAsChannel(), event); + if (destChannelOpt.isEmpty()) { + return; + } + TextChannel destChannel = destChannelOpt.orElseThrow(); + + String content = Objects.requireNonNull(event.getOption(CONTENT_OPTION)).getAsString(); + + event.deferReply().queue(); + destChannel.sendMessage(content) + .queue(_ -> sendSuccessMessage(event, action), + failure -> handleActionFailed(failure, event, action)); + } + + private void postMessageUsingMessageContent(CommandInteraction event) { + Subcommand action = Subcommand.POST_WITH_MESSAGE; + Optional destChannelOpt = handleExpectMessageChannel( + Objects.requireNonNull(event.getOption(DEST_CHANNEL_OPTION)).getAsChannel(), event); + if (destChannelOpt.isEmpty()) { + return; + } + TextChannel destChannel = destChannelOpt.orElseThrow(); + + OptionalLong contentMessageIdOpt = parseMessageIdAndHandle( + Objects.requireNonNull(event.getOption(CONTENT_MESSAGE_ID_OPTION)).getAsString(), + event); + if (contentMessageIdOpt.isEmpty()) { + return; + } + long contentMessageId = contentMessageIdOpt.orElseThrow(); + + event.deferReply().queue(); + event.getMessageChannel().retrieveMessageById(contentMessageId).queue(contentMessage -> { + String content = contentMessage.getContentRaw(); + destChannel.sendMessage(content) + .queue(_ -> sendSuccessMessage(event, action), + failure -> handleActionFailed(failure, event, action)); + }, failure -> handleMessageRetrieveFailed(failure, event, contentMessageId)); + } + + private void editMessage(CommandInteraction event) { + Subcommand action = Subcommand.EDIT; + Optional srcChannelOpt = handleExpectMessageChannel( + Objects.requireNonNull(event.getOption(SRC_CHANNEL_OPTION)).getAsChannel(), event); + if (srcChannelOpt.isEmpty()) { + return; + } + TextChannel srcChannel = srcChannelOpt.orElseThrow(); + + OptionalLong editingMessageIdOpt = parseMessageIdAndHandle( + Objects.requireNonNull(event.getOption(EDIT_MESSAGE_ID_OPTION)).getAsString(), + event); + if (editingMessageIdOpt.isEmpty()) { + return; + } + long editingMessageId = editingMessageIdOpt.orElseThrow(); + String content = Objects.requireNonNull(event.getOption(CONTENT_OPTION)).getAsString(); + + event.deferReply().queue(); + srcChannel.retrieveMessageById(editingMessageId).queue(editingMessage -> { + if (handleIsMessageFromOtherUser(editingMessage, event)) { + return; + } + editingMessage.editMessage(content) + .queue(_ -> sendSuccessMessage(event, action), + failure -> handleActionFailed(failure, event, action)); + }, failure -> handleMessageRetrieveFailed(failure, event, editingMessageId)); + } + + private void editMessageUsingMessageContent(CommandInteraction event) { + Subcommand action = Subcommand.EDIT_WITH_MESSAGE; + Optional srcChannelOpt = handleExpectMessageChannel( + Objects.requireNonNull(event.getOption(SRC_CHANNEL_OPTION)).getAsChannel(), event); + if (srcChannelOpt.isEmpty()) { + return; + } + TextChannel srcChannel = srcChannelOpt.orElseThrow(); + + OptionalLong editingMessageIdOpt = parseMessageIdAndHandle( + Objects.requireNonNull(event.getOption(EDIT_MESSAGE_ID_OPTION)).getAsString(), + event); + if (editingMessageIdOpt.isEmpty()) { + return; + } + long editingMessageId = editingMessageIdOpt.orElseThrow(); + + OptionalLong contentMessageIdOpt = parseMessageIdAndHandle( + Objects.requireNonNull(event.getOption(CONTENT_MESSAGE_ID_OPTION)).getAsString(), + event); + if (contentMessageIdOpt.isEmpty()) { + return; + } + long contentMessageId = contentMessageIdOpt.orElseThrow(); + + event.deferReply().queue(); + record Messages(Message editingMessage, Message contentMessage) { + } + srcChannel.retrieveMessageById(editingMessageId) + .and(event.getMessageChannel().retrieveMessageById(contentMessageId), Messages::new) + .queue(messages -> { + if (handleIsMessageFromOtherUser(messages.editingMessage, event)) { + return; + } + + String content = messages.contentMessage.getContentRaw(); + messages.editingMessage.editMessage(content) + .queue(_ -> sendSuccessMessage(event, action), + failure -> handleActionFailed(failure, event, action)); + }, failure -> handleMessageRetrieveFailed(failure, event, + List.of(editingMessageId, contentMessageId))); + } + + enum Subcommand { + RAW("raw", "", ""), + POST("post", "post", "posted"), + POST_WITH_MESSAGE("post-with-message", "post", "posted"), + EDIT("edit", "edit", "edited"), + EDIT_WITH_MESSAGE("edit-with-message", "edit", "edited"); + + private final String name; + private final String actionVerb; + private final String actionVerbPast; + + Subcommand(String name, String actionVerb, String actionVerbPast) { + this.name = name; + this.actionVerb = actionVerb; + this.actionVerbPast = actionVerbPast; + } + + String getName() { + return name; + } + + String getActionVerb() { + return actionVerb; + } + + String getActionVerbPast() { + return actionVerbPast; + } + + static Subcommand fromName(String name) { + for (Subcommand subcommand : Subcommand.values()) { + if (subcommand.name.equals(name)) { + return subcommand; + } + } + throw new IllegalArgumentException( + "Subcommand with name '%s' is unknown".formatted(name)); + } + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/messages/RewriteCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/messages/RewriteCommand.java new file mode 100644 index 0000000000..44b143558b --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/messages/RewriteCommand.java @@ -0,0 +1,186 @@ +package org.togetherjava.tjbot.features.messages; + +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.togetherjava.tjbot.features.CommandVisibility; +import org.togetherjava.tjbot.features.SlashCommandAdapter; +import org.togetherjava.tjbot.features.chatgpt.ChatGptModel; +import org.togetherjava.tjbot.features.chatgpt.ChatGptService; + +import java.util.Arrays; + +/** + * The implemented command is {@code /rewrite}, which allows users to have their message rewritten + * in a clearer, more professional, or better structured form using AI. + *

+ * The rewritten message is shown as an ephemeral message visible only to the user who triggered the + * command. + *

+ * Users can optionally specify a tone/style for the rewrite. + */ +public final class RewriteCommand extends SlashCommandAdapter { + private static final Logger logger = LoggerFactory.getLogger(RewriteCommand.class); + private static final String COMMAND_NAME = "rewrite"; + private static final String MESSAGE_OPTION = "message"; + private static final String TONE_OPTION = "tone"; + + private static final int MAX_MESSAGE_LENGTH = Message.MAX_CONTENT_LENGTH; + private static final int MIN_MESSAGE_LENGTH = 3; + + private static final String AI_REWRITE_PROMPT_TEMPLATE = """ + You are rewriting a Discord text chat message for clarity and professionalism. + Keep it conversational and casual, not email or formal document format. + + Tone: %s + + Rewrite the message to: + - Improve clarity and structure + - Maintain the original meaning + - Avoid em-dashes (β€”) + - Stay under %d characters (strict limit) + + If the message is already well-written, make only minor improvements. + + Reply with ONLY the rewritten message, nothing else (greetings, preamble, etc). + + Message to rewrite: + %s + """.stripIndent(); + + private final ChatGptService chatGptService; + + /** + * Creates the slash command definition and configures available options for rewriting messages. + * + * @param chatGptService service for interacting with ChatGPT + */ + public RewriteCommand(ChatGptService chatGptService) { + super(COMMAND_NAME, "Let AI rephrase and improve your message", CommandVisibility.GUILD); + + this.chatGptService = chatGptService; + + OptionData messageOption = + new OptionData(OptionType.STRING, MESSAGE_OPTION, "The message you want to rewrite", + true) + .setMinLength(MIN_MESSAGE_LENGTH) + .setMaxLength(MAX_MESSAGE_LENGTH); + + OptionData toneOption = new OptionData(OptionType.STRING, TONE_OPTION, + "The tone/style for the rewritten message (default: " + + MessageTone.CLEAR.displayName + ")", + false); + + Arrays.stream(MessageTone.values()) + .forEach(tone -> toneOption.addChoice(tone.displayName, tone.name())); + + getData().addOptions(messageOption, toneOption); + } + + @Override + public void onSlashCommand(SlashCommandInteractionEvent event) { + + OptionMapping messageOption = event.getOption(MESSAGE_OPTION); + + if (messageOption == null) { + throw new IllegalArgumentException( + "Required option '" + MESSAGE_OPTION + "' is missing"); + } + + String userMessage = messageOption.getAsString(); + + MessageTone tone = parseTone(event.getOption(TONE_OPTION)); + + event.deferReply(true).queue(); + + String rewrittenMessage = rewrite(userMessage, tone); + + if (rewrittenMessage.isEmpty()) { + logger.debug("Failed to obtain a response for /{}, original message: '{}'", + COMMAND_NAME, userMessage); + + event.getHook() + .editOriginal( + "An error occurred while processing your request. Please try again later.") + .queue(); + + return; + } + + logger.debug("Rewrite successful; rewritten message length: {}", rewrittenMessage.length()); + + event.getHook().sendMessage(rewrittenMessage).setEphemeral(true).queue(); + } + + private MessageTone parseTone(@Nullable OptionMapping toneOption) + throws IllegalArgumentException { + + if (toneOption == null) { + logger.debug("Tone option not provided, using default '{}'", MessageTone.CLEAR.name()); + return MessageTone.CLEAR; + } + + return MessageTone.valueOf(toneOption.getAsString()); + } + + private String rewrite(String userMessage, MessageTone tone) { + + String rewritePrompt = createAiPrompt(userMessage, tone); + + ChatGptModel aiModel = tone.model; + + String attempt = askAi(rewritePrompt, aiModel); + + if (attempt.length() <= MAX_MESSAGE_LENGTH) { + return attempt; + } + + logger.debug("Rewritten message exceeded {} characters; retrying with stricter constraint", + MAX_MESSAGE_LENGTH); + + String shortenPrompt = + """ + %s + + Constraint reminder: Your previous rewrite exceeded %d characters. + Provide a revised rewrite strictly under %d characters while preserving meaning and tone. + """ + .formatted(rewritePrompt, MAX_MESSAGE_LENGTH, MAX_MESSAGE_LENGTH); + + return askAi(shortenPrompt, aiModel); + } + + private String askAi(String shortenPrompt, ChatGptModel aiModel) { + return chatGptService.askRaw(shortenPrompt, aiModel).orElse(""); + } + + private static String createAiPrompt(String userMessage, MessageTone tone) { + return AI_REWRITE_PROMPT_TEMPLATE.formatted(tone.description, MAX_MESSAGE_LENGTH, + userMessage); + } + + private enum MessageTone { + CLEAR("Clear", "Make it clear and easy to understand.", ChatGptModel.FASTEST), + PROFESSIONAL("Professional", "Use a professional and polished tone.", ChatGptModel.FASTEST), + DETAILED("Detailed", "Expand with more detail and explanation.", ChatGptModel.HIGH_QUALITY), + TECHNICAL("Technical", "Use technical and specialized language where appropriate.", + ChatGptModel.HIGH_QUALITY); + + private final String displayName; + private final String description; + private final ChatGptModel model; + + MessageTone(String displayName, String description, ChatGptModel model) { + this.displayName = displayName; + this.description = description; + this.model = model; + } + } + +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/messages/package-info.java b/application/src/main/java/org/togetherjava/tjbot/features/messages/package-info.java new file mode 100644 index 0000000000..e816d6be6f --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/messages/package-info.java @@ -0,0 +1,11 @@ +/** + * This package offers commands dealing with messages in general. See + * {@link org.togetherjava.tjbot.features.messages.MessageCommand} as main command being offered. + */ +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package org.togetherjava.tjbot.features.messages; + +import org.togetherjava.tjbot.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/application/src/main/java/org/togetherjava/tjbot/features/moderation/TransferQuestionCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/moderation/TransferQuestionCommand.java index 9751397137..8fe47ce1f4 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/moderation/TransferQuestionCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/moderation/TransferQuestionCommand.java @@ -31,6 +31,7 @@ import org.togetherjava.tjbot.features.BotCommandAdapter; import org.togetherjava.tjbot.features.CommandVisibility; import org.togetherjava.tjbot.features.MessageContextCommand; +import org.togetherjava.tjbot.features.chatgpt.ChatGptModel; import org.togetherjava.tjbot.features.chatgpt.ChatGptService; import org.togetherjava.tjbot.features.utils.StringDistances; @@ -98,7 +99,8 @@ public void onMessageContext(MessageContextInteractionEvent event) { String chatGptTitleRequest = "Summarize the following question into a concise title or heading not more than 5 words, remove quotations if any: %s" .formatted(originalMessage); - Optional chatGptTitle = chatGptService.ask(chatGptTitleRequest, null); + Optional chatGptTitle = + chatGptService.ask(chatGptTitleRequest, null, ChatGptModel.FASTEST); String title = chatGptTitle.orElse(createTitle(originalMessage)); if (title.startsWith("\"") && title.endsWith("\"")) { title = title.substring(1, title.length() - 1); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/rss/FailureState.java b/application/src/main/java/org/togetherjava/tjbot/features/rss/FailureState.java new file mode 100644 index 0000000000..a5f8d41f40 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/rss/FailureState.java @@ -0,0 +1,6 @@ +package org.togetherjava.tjbot.features.rss; + +import java.time.ZonedDateTime; + +record FailureState(int count, ZonedDateTime lastFailure) { +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/rss/RSSHandlerRoutine.java b/application/src/main/java/org/togetherjava/tjbot/features/rss/RSSHandlerRoutine.java index 56aea37b74..1d89896038 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/rss/RSSHandlerRoutine.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/rss/RSSHandlerRoutine.java @@ -2,6 +2,8 @@ import com.apptasticsoftware.rssreader.Item; import com.apptasticsoftware.rssreader.RssReader; +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.JDA; import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; @@ -48,7 +50,7 @@ *

* To include a new RSS feed, simply define an {@link RSSFeed} entry in the {@code "rssFeeds"} array * within the configuration file, adhering to the format shown below: - * + * *

  * {@code
  * {
@@ -58,7 +60,7 @@
  * }
  * }
  * 
- * + *

* Where: *

    *
  • {@code url} represents the URL of the RSS feed.
  • @@ -84,6 +86,14 @@ public final class RSSHandlerRoutine implements Routine { private final int interval; private final Database database; + private final Cache circuitBreaker = + Caffeine.newBuilder().expireAfterWrite(7, TimeUnit.DAYS).maximumSize(500).build(); + + private static final int DEAD_RSS_FEED_FAILURE_THRESHOLD = 15; + private static final double BACKOFF_BASE = 2.0; + private static final double BACKOFF_EXPONENT_OFFSET = 1.0; + private static final double MAX_BACKOFF_HOURS = 24.0; + /** * Constructs an RSSHandlerRoutine with the provided configuration and database. * @@ -117,7 +127,14 @@ public Schedule createSchedule() { @Override public void runRoutine(@Nonnull JDA jda) { - this.config.feeds().forEach(feed -> sendRSS(jda, feed)); + this.config.feeds().forEach(feed -> { + if (isBackingOff(feed.url())) { + logger.debug("Skipping RSS feed (Backing off): {}", feed.url()); + return; + } + + sendRSS(jda, feed); + }); } /** @@ -257,7 +274,6 @@ private void postItem(List textChannels, Item rssItem, RSSFeed feed * @param rssFeedRecord the record representing the RSS feed, can be null if not found in the * database * @param lastPostedDate the last posted date to be updated - * * @throws DateTimeParseException if the date cannot be parsed */ private void updateLastDateToDatabase(RSSFeed feedConfig, @Nullable RssFeedRecord rssFeedRecord, @@ -400,9 +416,26 @@ private MessageCreateData constructMessage(Item item, RSSFeed feedConfig) { */ private List fetchRSSItemsFromURL(String rssUrl) { try { - return rssReader.read(rssUrl).toList(); + List items = rssReader.read(rssUrl).toList(); + circuitBreaker.invalidate(rssUrl); + return items; } catch (IOException e) { - logger.error("Could not fetch RSS from URL ({})", rssUrl, e); + FailureState oldState = circuitBreaker.getIfPresent(rssUrl); + int newCount = (oldState == null) ? 1 : oldState.count() + 1; + + if (newCount >= DEAD_RSS_FEED_FAILURE_THRESHOLD) { + logger.error( + "Possibly dead RSS feed URL: {} - Failed {} times. Please remove it from config.", + rssUrl, newCount); + } + circuitBreaker.put(rssUrl, new FailureState(newCount, ZonedDateTime.now())); + + long blacklistedHours = calculateWaitHours(newCount); + + logger.warn( + "RSS fetch failed for {} (Attempt #{}). Backing off for {} hours. Reason: {}", + rssUrl, newCount, blacklistedHours, e.getMessage(), e); + return List.of(); } } @@ -424,4 +457,21 @@ private static ZonedDateTime getZonedDateTime(@Nullable String date, String form return ZonedDateTime.parse(date, DateTimeFormatter.ofPattern(format)); } + + private long calculateWaitHours(int failureCount) { + return (long) Math.min(Math.pow(BACKOFF_BASE, failureCount - BACKOFF_EXPONENT_OFFSET), + MAX_BACKOFF_HOURS); + } + + private boolean isBackingOff(String url) { + FailureState state = circuitBreaker.getIfPresent(url); + if (state == null) { + return false; + } + + long waitHours = calculateWaitHours(state.count()); + ZonedDateTime retryAt = state.lastFailure().plusHours(waitHours); + + return ZonedDateTime.now().isBefore(retryAt); + } } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/system/BotCore.java b/application/src/main/java/org/togetherjava/tjbot/features/system/BotCore.java index 869e978a17..e9d99bc4d1 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/system/BotCore.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/system/BotCore.java @@ -2,6 +2,12 @@ import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.entities.channel.Channel; +import net.dv8tion.jda.api.entities.channel.unions.AudioChannelUnion; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceDeafenEvent; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceMuteEvent; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceStreamEvent; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceUpdateEvent; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceVideoEvent; import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent; import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; import net.dv8tion.jda.api.events.interaction.command.MessageContextInteractionEvent; @@ -13,9 +19,12 @@ import net.dv8tion.jda.api.events.message.MessageDeleteEvent; import net.dv8tion.jda.api.events.message.MessageReceivedEvent; import net.dv8tion.jda.api.events.message.MessageUpdateEvent; +import net.dv8tion.jda.api.events.message.react.MessageReactionAddEvent; import net.dv8tion.jda.api.hooks.ListenerAdapter; import net.dv8tion.jda.api.interactions.callbacks.IReplyCallback; import net.dv8tion.jda.api.interactions.components.ComponentInteraction; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Unmodifiable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -32,6 +41,7 @@ import org.togetherjava.tjbot.features.UserContextCommand; import org.togetherjava.tjbot.features.UserInteractionType; import org.togetherjava.tjbot.features.UserInteractor; +import org.togetherjava.tjbot.features.VoiceReceiver; import org.togetherjava.tjbot.features.componentids.ComponentId; import org.togetherjava.tjbot.features.componentids.ComponentIdParser; import org.togetherjava.tjbot.features.componentids.ComponentIdStore; @@ -75,6 +85,7 @@ public final class BotCore extends ListenerAdapter implements CommandProvider { private final ComponentIdParser componentIdParser; private final ComponentIdStore componentIdStore; private final Map channelNameToMessageReceiver = new HashMap<>(); + private final Map channelNameToVoiceReceiver = new HashMap<>(); /** * Creates a new command system which uses the given database to allow commands to persist data. @@ -96,6 +107,13 @@ public BotCore(JDA jda, Database database, Config config) { .forEach(messageReceiver -> channelNameToMessageReceiver .put(messageReceiver.getChannelNamePattern(), messageReceiver)); + // Voice receivers + features.stream() + .filter(VoiceReceiver.class::isInstance) + .map(VoiceReceiver.class::cast) + .forEach(voiceReceiver -> channelNameToVoiceReceiver + .put(voiceReceiver.getChannelNamePattern(), voiceReceiver)); + // Event receivers features.stream() .filter(EventReceiver.class::isInstance) @@ -238,6 +256,104 @@ public void onMessageDelete(final MessageDeleteEvent event) { } } + @Override + public void onMessageReactionAdd(final MessageReactionAddEvent event) { + if (event.isFromGuild()) { + getMessageReceiversSubscribedTo(event.getChannel()) + .forEach(messageReceiver -> messageReceiver.onMessageReactionAdd(event)); + } + } + + /** + * Calculates the correct voice channel to act upon. + * + *

    + * If there is a channelJoined and a channelLeft, then the + * channelJoined is prioritized and returned. Otherwise, it returns + * channelLeft. + * + *

    + * This is an essential method due to the need of updating both channel categories that a member + * utilizes. For example, take the scenario of a user browsing through voice channels: + * + *

    +     *     - User joins General -> channelJoined = General | channelLeft = null
    +     *     - User switches to Gaming -> channelJoined = Gaming | channelLeft = General
    +     *     - User leaves Discord -> channelJoined = null | channelLeft = Gaming
    +     * 
    + * + *

    + * This way, we make sure that all relevant voice channels are updated. + * + * @param channelJoined the channel that the member has connected to, if any + * @param channelLeft the channel that the member left from, if any + * @return the join channel if not null, otherwise the leave channel, otherwise an empty + * optional + */ + private Optional selectPreferredAudioChannel(@Nullable AudioChannelUnion channelJoined, + @Nullable AudioChannelUnion channelLeft) { + if (channelJoined != null) { + return Optional.of(channelJoined); + } + + return Optional.ofNullable(channelLeft); + } + + @Override + public void onGuildVoiceUpdate(@NotNull GuildVoiceUpdateEvent event) { + selectPreferredAudioChannel(event.getChannelJoined(), event.getChannelLeft()) + .ifPresent(channel -> getVoiceReceiversSubscribedTo(channel) + .forEach(voiceReceiver -> voiceReceiver.onVoiceUpdate(event))); + } + + @Override + public void onGuildVoiceVideo(@NotNull GuildVoiceVideoEvent event) { + AudioChannelUnion channel = event.getVoiceState().getChannel(); + + if (channel == null) { + return; + } + + getVoiceReceiversSubscribedTo(channel) + .forEach(voiceReceiver -> voiceReceiver.onVideoToggle(event)); + } + + @Override + public void onGuildVoiceStream(@NotNull GuildVoiceStreamEvent event) { + AudioChannelUnion channel = event.getVoiceState().getChannel(); + + if (channel == null) { + return; + } + + getVoiceReceiversSubscribedTo(channel) + .forEach(voiceReceiver -> voiceReceiver.onStreamToggle(event)); + } + + @Override + public void onGuildVoiceMute(@NotNull GuildVoiceMuteEvent event) { + AudioChannelUnion channel = event.getVoiceState().getChannel(); + + if (channel == null) { + return; + } + + getVoiceReceiversSubscribedTo(channel) + .forEach(voiceReceiver -> voiceReceiver.onMuteToggle(event)); + } + + @Override + public void onGuildVoiceDeafen(@NotNull GuildVoiceDeafenEvent event) { + AudioChannelUnion channel = event.getVoiceState().getChannel(); + + if (channel == null) { + return; + } + + getVoiceReceiversSubscribedTo(channel) + .forEach(voiceReceiver -> voiceReceiver.onDeafenToggle(event)); + } + private Stream getMessageReceiversSubscribedTo(Channel channel) { String channelName = channel.getName(); return channelNameToMessageReceiver.entrySet() @@ -248,6 +364,16 @@ private Stream getMessageReceiversSubscribedTo(Channel channel) .map(Map.Entry::getValue); } + private Stream getVoiceReceiversSubscribedTo(Channel channel) { + String channelName = channel.getName(); + return channelNameToVoiceReceiver.entrySet() + .stream() + .filter(patternAndReceiver -> patternAndReceiver.getKey() + .matcher(channelName) + .matches()) + .map(Map.Entry::getValue); + } + @Override public void onSlashCommandInteraction(SlashCommandInteractionEvent event) { String name = event.getName(); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/voicechat/DynamicVoiceChat.java b/application/src/main/java/org/togetherjava/tjbot/features/voicechat/DynamicVoiceChat.java new file mode 100644 index 0000000000..9294d6dcd0 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/voicechat/DynamicVoiceChat.java @@ -0,0 +1,196 @@ +package org.togetherjava.tjbot.features.voicechat; + +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.entities.Guild; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.entities.channel.ChannelType; +import net.dv8tion.jda.api.entities.channel.concrete.Category; +import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel; +import net.dv8tion.jda.api.entities.channel.middleman.AudioChannel; +import net.dv8tion.jda.api.entities.channel.unions.AudioChannelUnion; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceUpdateEvent; +import net.dv8tion.jda.api.managers.channel.middleman.AudioChannelManager; +import net.dv8tion.jda.api.requests.RestAction; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.togetherjava.tjbot.config.Config; +import org.togetherjava.tjbot.config.DynamicVoiceChatConfig; +import org.togetherjava.tjbot.features.VoiceReceiverAdapter; + +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +/** + * Handles dynamic voice channel creation and deletion based on user activity. + *

    + * When a member joins a configured root channel, a temporary copy is created and the member is + * moved into it. Once the channel becomes empty, it is archived and further deleted using a + * {@link VoiceChatCleanupStrategy}. + */ +public final class DynamicVoiceChat extends VoiceReceiverAdapter { + private static final Logger logger = LoggerFactory.getLogger(DynamicVoiceChat.class); + + private final VoiceChatCleanupStrategy voiceChatCleanupStrategy; + private final DynamicVoiceChatConfig dynamicVoiceChannelConfig; + + private final Cache deletedChannels = + Caffeine.newBuilder().expireAfterWrite(5, TimeUnit.MINUTES).build(); + + /** + * Creates a new instance of {@code DynamicVoiceChat} + * + * @param config the configurations needed for this feature. See: + * {@link org.togetherjava.tjbot.config.DynamicVoiceChatConfig} + */ + public DynamicVoiceChat(Config config) { + this.dynamicVoiceChannelConfig = config.getDynamicVoiceChatConfig(); + + this.voiceChatCleanupStrategy = + new OldestVoiceChatCleanup(dynamicVoiceChannelConfig.cleanChannelsAmount(), + dynamicVoiceChannelConfig.minimumChannelsAmount()); + } + + @Override + public void onVoiceUpdate(GuildVoiceUpdateEvent event) { + Member member = event.getMember(); + User user = member.getUser(); + + AudioChannelUnion channelJoined = event.getChannelJoined(); + AudioChannelUnion channelLeft = event.getChannelLeft(); + + if (channelJoined != null && isVoiceChannel(channelJoined) && !user.isBot()) { + handleVoiceChannelJoin(event, channelJoined); + } + + if (channelLeft != null && isVoiceChannel(channelLeft)) { + handleVoiceChannelLeave(channelLeft); + } + } + + private void handleVoiceChannelJoin(GuildVoiceUpdateEvent event, + AudioChannelUnion channelJoined) { + if (eventHappenOnDynamicRootChannel(channelJoined)) { + logger.debug("Event happened on joined channel {}", channelJoined); + createDynamicVoiceChannel(event, channelJoined.asVoiceChannel()); + } + } + + private synchronized void handleVoiceChannelLeave(AudioChannelUnion channelLeft) { + long channelId = channelLeft.getIdLong(); + + if (Boolean.TRUE.equals(deletedChannels.getIfPresent(channelId))) { + return; + } + + if (!eventHappenOnDynamicRootChannel(channelLeft)) { + logger.debug("Event happened on left channel {}", channelLeft); + + if (hasMembers(channelLeft)) { + logger.debug("Voice channel {} not empty, so not doing anything.", + channelLeft.getName()); + return; + } + + channelLeft.asVoiceChannel().getHistory().retrievePast(2).queue(messages -> { + if (messages.size() > 1) { + archiveDynamicVoiceChannel(channelLeft); + } else { + deletedChannels.put(channelId, true); + try { + channelLeft.delete().queue(); + } catch (Exception _) { + // Ignore + } + } + }); + } + } + + private boolean eventHappenOnDynamicRootChannel(AudioChannelUnion channel) { + return dynamicVoiceChannelConfig.dynamicChannelPatterns() + .stream() + .anyMatch(pattern -> pattern.matcher(channel.getName()).matches()); + } + + private void createDynamicVoiceChannel(GuildVoiceUpdateEvent event, VoiceChannel channel) { + Guild guild = event.getGuild(); + Member member = event.getMember(); + String newChannelName = "%s's %s".formatted(member.getEffectiveName(), channel.getName()); + + channel.createCopy() + .setName(newChannelName) + .setPosition(channel.getPositionRaw()) + .onSuccess(newChannel -> { + moveMember(guild, member, newChannel); + sendWarningEmbed(newChannel); + }) + .queue(newChannel -> logger.trace("Successfully created {} voice channel.", + newChannel.getName()), + error -> logger.error("Failed to create dynamic voice channel", error)); + } + + private void moveMember(Guild guild, Member member, AudioChannel channel) { + guild.moveVoiceMember(member, channel) + .queue(_ -> logger.trace( + "Successfully moved {} to newly created dynamic voice channel {}", + member.getEffectiveName(), channel.getName()), + error -> logger.error( + "Failed to move user into dynamically created voice channel {}, {}", + member.getNickname(), channel.getName(), error)); + } + + private boolean hasMembers(AudioChannelUnion channel) { + return !channel.getMembers().isEmpty(); + } + + private void archiveDynamicVoiceChannel(AudioChannelUnion channel) { + String channelName = channel.getName(); + + Optional archiveCategoryOptional = channel.getGuild() + .getCategoryCache() + .stream() + .filter(c -> c.getName() + .equalsIgnoreCase(dynamicVoiceChannelConfig.archiveCategoryPattern())) + .findFirst(); + + AudioChannelManager channelManager = channel.getManager(); + RestAction restActionChain = + channelManager.setName(String.format("%s (Archived)", channelName)) + .and(channel.getPermissionContainer().getManager().clearOverridesAdded()); + + if (archiveCategoryOptional.isEmpty()) { + logger.error("Could not find category matching {}", + dynamicVoiceChannelConfig.archiveCategoryPattern()); + return; + } + + archiveCategoryOptional.ifPresent(archiveCategory -> restActionChain + .and(channelManager.setParent(archiveCategory)) + .and(channelManager.sync(archiveCategory)) + .queue(_ -> voiceChatCleanupStrategy.cleanup(archiveCategory.getVoiceChannels()), + err -> logger.error("Could not archive dynamic voice chat", err))); + } + + private static void sendWarningEmbed(VoiceChannel channel) { + channel + .sendMessageEmbeds( + new EmbedBuilder() + .addField("πŸ‘‹ Heads up!", + """ + This is a **temporary** voice chat channel. Messages sent here will be *cleared* once \ + the channel is deleted when everyone leaves. If you need to keep something important, \ + make sure to save it elsewhere. πŸ’¬ + """, + false) + .build()) + .queue(); + } + + private static boolean isVoiceChannel(AudioChannelUnion channel) { + return channel.getType() == ChannelType.VOICE; + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/voicechat/OldestVoiceChatCleanup.java b/application/src/main/java/org/togetherjava/tjbot/features/voicechat/OldestVoiceChatCleanup.java new file mode 100644 index 0000000000..6648589922 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/voicechat/OldestVoiceChatCleanup.java @@ -0,0 +1,40 @@ +package org.togetherjava.tjbot.features.voicechat; + +import net.dv8tion.jda.api.entities.ISnowflake; +import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel; + +import java.util.Comparator; +import java.util.List; + +/** + * Cleans up voice chats from an archive prioritizing the oldest {@link VoiceChannel}. + *

    + * Considering a list of voice channels is provided with all of them obviously having a different + * addition time, the first few {@link VoiceChannel} elements provided, amounting to the value of + * cleanChannelsAmount will be removed from the guild. + *

    + * The cleanup strategy will not be executed if the amount of voice channels does not exceed + * the value of minimumChannelsAmountToTrigger. + */ +final class OldestVoiceChatCleanup implements VoiceChatCleanupStrategy { + + private final int cleanChannelsAmount; + private final int minimumChannelsAmountToTrigger; + + OldestVoiceChatCleanup(int cleanChannelsAmount, int minimumChannelsAmountToTrigger) { + this.cleanChannelsAmount = cleanChannelsAmount; + this.minimumChannelsAmountToTrigger = minimumChannelsAmountToTrigger; + } + + @Override + public void cleanup(List voiceChannels) { + if (voiceChannels.size() < minimumChannelsAmountToTrigger) { + return; + } + + voiceChannels.stream() + .sorted(Comparator.comparing(ISnowflake::getTimeCreated)) + .limit(cleanChannelsAmount) + .forEach(voiceChannel -> voiceChannel.delete().queue()); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/voicechat/VoiceChatCleanupStrategy.java b/application/src/main/java/org/togetherjava/tjbot/features/voicechat/VoiceChatCleanupStrategy.java new file mode 100644 index 0000000000..fe067b703a --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/voicechat/VoiceChatCleanupStrategy.java @@ -0,0 +1,21 @@ +package org.togetherjava.tjbot.features.voicechat; + +import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel; + +import java.util.List; + +/** + * Voice chat cleanup strategy interface for handling voice chat archive removal. + *

    + * See provided implementation {@link OldestVoiceChatCleanup} for a more concrete usage example. + */ +public interface VoiceChatCleanupStrategy { + + /** + * Attempts to delete the {@link VoiceChannel} channels from the Discord guild found in the + * inputted list. + * + * @param voiceChannels a list of voice channels to be considered for removal + */ + void cleanup(List voiceChannels); +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/voicechat/package-info.java b/application/src/main/java/org/togetherjava/tjbot/features/voicechat/package-info.java new file mode 100644 index 0000000000..57ab07e37c --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/voicechat/package-info.java @@ -0,0 +1,10 @@ +/** + * This package contains classes for the dynamic voice chat functionality. + */ +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package org.togetherjava.tjbot.features.voicechat; + +import org.togetherjava.tjbot.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/build.gradle b/build.gradle index 548b509dd6..19cdc35324 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ plugins { id 'java' - id "com.diffplug.spotless" version "8.1.0" + id "com.diffplug.spotless" version "8.2.0" id "org.sonarqube" version "7.2.0.6526" id "name.remal.sonarlint" version "7.0.0" } @@ -14,7 +14,7 @@ version '1.0-SNAPSHOT' ext { jooqVersion = '3.20.5' jacksonVersion = '2.19.1' - chatGPTVersion = '0.18.2' + chatGPTVersion = '4.18.0' junitVersion = '6.0.0' } diff --git a/database/build.gradle b/database/build.gradle index 7f62b722ef..8ef3ef97cd 100644 --- a/database/build.gradle +++ b/database/build.gradle @@ -7,7 +7,7 @@ var sqliteVersion = "3.51.0.0" dependencies { implementation 'com.google.code.findbugs:jsr305:3.0.2' implementation "org.xerial:sqlite-jdbc:${sqliteVersion}" - implementation 'org.flywaydb:flyway-core:11.18.0' + implementation 'org.flywaydb:flyway-core:12.0.0' implementation "org.jooq:jooq:$jooqVersion" implementation project(':utils') diff --git a/scripts/pre-commit b/scripts/pre-commit index 87a8fdc42c..b1da09e748 100644 --- a/scripts/pre-commit +++ b/scripts/pre-commit @@ -44,6 +44,14 @@ if [ "$spotlessExitCode" -ne 0 ]; then exit "$spotlessExitCode" fi +echo "**Running Sonarlint checks**" +./gradlew sonarlintMain +sonarlintExitCode=$? +if [ "$sonarlintExitCode" -ne 0 ]; then + pop_stash + exit "$sonarlintMain" +fi + # Spotless possibly found changes, apply them, excluding untracked files git add -u diff --git a/wiki/Access-the-VPS.md b/wiki/Access-the-VPS.md index 1e0d96965f..ed00470826 100644 --- a/wiki/Access-the-VPS.md +++ b/wiki/Access-the-VPS.md @@ -18,7 +18,7 @@ ssh-keygen -t ed25519 -C "your_email@address.here" -f ~/.ssh/together-java-vps 4. Add the following entry to your `.ssh/config` file: ``` Host togetherjava -HostName togetherjava.duckdns.org +HostName togetherjava.org IdentityFile ~/.ssh/together-java-vps User root Port 22