Skip to content

Commit a1e9ab5

Browse files
committed
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
1 parent 3962878 commit a1e9ab5

File tree

2 files changed

+144
-128
lines changed

2 files changed

+144
-128
lines changed

application/src/main/java/org/togetherjava/tjbot/features/Features.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ public static Collection<Feature> createFeatures(JDA jda, Database database, Con
204204
features.add(new BookmarksCommand(bookmarksSystem));
205205
features.add(new ChatGptCommand(chatGptService, helpSystemHelper));
206206
features.add(new JShellCommand(jshellEval));
207-
features.add(new HelpThreadStatsCommand());
207+
features.add(new HelpThreadStatsCommand(database));
208208

209209
FeatureBlacklist<Class<?>> blacklist = blacklistConfig.normal();
210210
return blacklist.filterStream(features.stream(), Object::getClass).toList();
Lines changed: 143 additions & 127 deletions
Original file line numberDiff line numberDiff line change
@@ -1,156 +1,172 @@
11
package org.togetherjava.tjbot.features.help;
22

3-
import net.dv8tion.jda.api.entities.channel.concrete.ForumChannel;
4-
import net.dv8tion.jda.api.entities.channel.concrete.ThreadChannel;
5-
import net.dv8tion.jda.api.entities.channel.forums.ForumTag;
3+
import net.dv8tion.jda.api.EmbedBuilder;
64
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
75
import net.dv8tion.jda.api.interactions.commands.OptionType;
86
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
9-
import net.dv8tion.jda.api.interactions.commands.build.SubcommandData;
10-
import net.dv8tion.jda.api.interactions.commands.build.SubcommandGroupData;
11-
import net.dv8tion.jda.api.requests.restaction.pagination.ThreadChannelPaginationAction;
12-
7+
import org.jooq.DSLContext;
8+
import org.jooq.OrderField;
9+
import org.jooq.Record1;
10+
import org.togetherjava.tjbot.db.Database;
1311
import org.togetherjava.tjbot.features.CommandVisibility;
1412
import org.togetherjava.tjbot.features.SlashCommandAdapter;
1513

16-
import java.time.OffsetDateTime;
17-
import java.util.*;
18-
import java.util.concurrent.TimeUnit;
19-
import java.util.function.Function;
20-
import java.util.stream.Collectors;
21-
import java.util.stream.Stream;
22-
23-
import static java.util.stream.Collectors.averagingDouble;
24-
import static java.util.stream.Collectors.toMap;
25-
14+
import java.awt.Color;
15+
import java.time.Instant;
16+
import java.time.temporal.ChronoUnit;
17+
import java.util.Objects;
18+
19+
import static org.jooq.impl.DSL.*;
20+
import static org.togetherjava.tjbot.db.generated.Tables.HELP_THREADS;
21+
22+
/**
23+
* Implements the '/help-thread-stats' command which provides analytical insights into the
24+
* help forum's activity over a specific duration.
25+
* <p>
26+
* Example usage:
27+
* <pre>
28+
* {@code
29+
* /help-thread-stats duration-option: 7 Days
30+
* }
31+
* </pre>
32+
* <p>
33+
* The command aggregates data such as response rates, engagement metrics (messages/helpers),
34+
* tag popularity, and resolution speeds.
35+
*/
2636
public class HelpThreadStatsCommand extends SlashCommandAdapter {
27-
2837
public static final String COMMAND_NAME = "help-thread-stats";
2938
public static final String DURATION_OPTION = "duration-option";
30-
public static final String DURATION_SUBCOMMAND = "duration";
31-
public static final String OPTIONAL_SUBCOMMAND_GROUP = "optional";
32-
private final Map<String, Subcommand> nameToSubcommand;
33-
34-
public HelpThreadStatsCommand() {
35-
super(COMMAND_NAME, "Display Help Thread Statistics", CommandVisibility.GUILD);
36-
OptionData durationOption =
37-
new OptionData(OptionType.STRING, DURATION_OPTION, "optional duration", false)
38-
.setMinLength(1);
39-
SubcommandData duration = Subcommand.DURATION.toSubcommandData().addOptions(durationOption);
40-
SubcommandGroupData optionalCommands =
41-
new SubcommandGroupData(OPTIONAL_SUBCOMMAND_GROUP, "optional commands")
42-
.addSubcommands(duration);
43-
getData().addSubcommandGroups(optionalCommands);
44-
nameToSubcommand = streamSubcommands()
45-
.collect(Collectors.toMap(Subcommand::getCommandName, Function.identity()));
46-
}
4739

48-
@Override
49-
public void onSlashCommand(SlashCommandInteractionEvent event) {
50-
List<ForumChannel> forumChannels =
51-
Objects.requireNonNull(event.getGuild()).getForumChannels();
52-
Subcommand invokedSubcommand = nameToSubcommand.get(event.getSubcommandName());
53-
OffsetDateTime startDate = OffsetDateTime.MIN;
54-
if (Objects.nonNull(invokedSubcommand) && invokedSubcommand.equals(Subcommand.DURATION)
55-
&& Objects.nonNull(event.getOption(DURATION_OPTION))) {
56-
startDate =
57-
OffsetDateTime.now().minusDays(event.getOption(DURATION_OPTION).getAsLong());
58-
}
59-
ForumTag mostPopularTag = getMostPopularForumTag(forumChannels, startDate);
60-
Double averageNumberOfParticipants =
61-
getAverageNumberOfParticipantsPerThread(forumChannels, startDate);
62-
Integer totalNumberOfThreads =
63-
getThreadChannelsStream(forumChannels, startDate).toList().size();
64-
Long emptyThreads = getThreadsWithNoParticipants(forumChannels, startDate);
65-
Integer totalMessages = getTotalNumberOfMessages(forumChannels, startDate);
66-
Double averageNumberOfMessages = Double.valueOf(totalMessages) / totalNumberOfThreads;
67-
Double averageThreadLifecycle = getAverageThreadLifecycle(forumChannels, startDate);
68-
String statistics =
69-
"Most Popular Tag: %s%nAverage Number Of Participants: %.2f%nEmpty Threads: %s%nAverage Number Of Messages: %.2f%nAverage Thread Lifecycle: %.2f"
70-
.formatted(mostPopularTag.getName(), averageNumberOfParticipants, emptyThreads,
71-
averageNumberOfMessages, averageThreadLifecycle);
72-
event.reply(statistics).delay(2, TimeUnit.SECONDS).queue();
73-
}
40+
private final Database database;
7441

75-
private ForumTag getMostPopularForumTag(List<ForumChannel> forumChannels,
76-
OffsetDateTime startDate) {
77-
Map<ForumTag, Integer> tagCount = getThreadChannelsStream(forumChannels, startDate)
78-
.flatMap((threadChannel -> threadChannel.getAppliedTags().stream()))
79-
.collect(toMap(Function.identity(), tag -> 1, Integer::sum));
80-
return Collections.max(tagCount.entrySet(), Map.Entry.comparingByValue()).getKey();
81-
}
42+
/**
43+
* Creates an instance of the command.
44+
*
45+
* @param database the database to fetch help thread metrics from
46+
*/
47+
public HelpThreadStatsCommand(Database database) {
48+
super(COMMAND_NAME, "Display Help Thread Statistics", CommandVisibility.GUILD);
8249

83-
private Double getAverageNumberOfParticipantsPerThread(List<ForumChannel> forumChannels,
84-
OffsetDateTime startDate) {
85-
return getThreadChannelsStream(forumChannels, startDate)
86-
.collect(averagingDouble((ThreadChannel::getMemberCount)));
87-
}
50+
OptionData durationOption = new OptionData(OptionType.INTEGER, DURATION_OPTION, "The time range for statistics", false)
51+
.addChoice("1 Day", 1)
52+
.addChoice("7 Days", 7)
53+
.addChoice("30 Days", 30)
54+
.addChoice("90 Days", 90)
55+
.addChoice("180 Days", 180);
8856

89-
private Long getThreadsWithNoParticipants(List<ForumChannel> forumChannels,
90-
OffsetDateTime startDate) {
91-
return getThreadChannelsStream(forumChannels, startDate)
92-
.filter(threadChannel -> threadChannel.getMemberCount() > 1)
93-
.count();
57+
getData().addOptions(durationOption);
58+
this.database = database;
9459
}
9560

96-
private Integer getTotalNumberOfMessages(List<ForumChannel> forumChannels,
97-
OffsetDateTime startDate) {
98-
return getThreadChannelsStream(forumChannels, startDate)
99-
.mapToInt(ThreadChannel::getMessageCount)
100-
.sum();
61+
@Override
62+
public void onSlashCommand(SlashCommandInteractionEvent event) {
63+
long days = event.getOption(DURATION_OPTION) != null
64+
? Objects.requireNonNull(event.getOption(DURATION_OPTION)).getAsLong()
65+
: 1;
66+
Instant startDate = Instant.now().minus(days, ChronoUnit.DAYS);
67+
68+
event.deferReply().queue();
69+
70+
database.read(context -> {
71+
var statsRecord = context.select(
72+
count().as("total_created"),
73+
count().filterWhere(HELP_THREADS.TICKET_STATUS.eq(HelpSystemHelper.TicketStatus.ACTIVE.val)).as("open_now"),
74+
count().filterWhere(HELP_THREADS.PARTICIPANTS.eq(1)).as("ghost_count"),
75+
avg(HELP_THREADS.PARTICIPANTS).as("avg_parts"),
76+
avg(HELP_THREADS.MESSAGE_COUNT).as("avg_msgs"),
77+
avg(field("unixepoch({0}) - unixepoch({1})", Double.class, HELP_THREADS.CLOSED_AT, HELP_THREADS.CREATED_AT)).as("avg_sec"),
78+
min(field("unixepoch({0}) - unixepoch({1})", Double.class, HELP_THREADS.CLOSED_AT, HELP_THREADS.CREATED_AT)).as("min_sec"),
79+
max(field("unixepoch({0}) - unixepoch({1})", Double.class, HELP_THREADS.CLOSED_AT, HELP_THREADS.CREATED_AT)).as("max_sec")
80+
)
81+
.from(HELP_THREADS)
82+
.where(HELP_THREADS.CREATED_AT.ge(startDate))
83+
.fetchOne();
84+
85+
if (statsRecord == null || statsRecord.get("total_created", Integer.class) == 0) {
86+
event.getHook().editOriginal("No stats available for the last " + days + " days.").queue();
87+
return null;
88+
}
89+
90+
int totalCreated = statsRecord.get("total_created", Integer.class);
91+
int openThreads = statsRecord.get("open_now", Integer.class);
92+
long ghostThreads = statsRecord.get("ghost_count", Number.class).longValue();
93+
94+
double rawResRate = totalCreated > 0 ? ((double) (totalCreated - ghostThreads) / totalCreated) * 100 : 0;
95+
96+
String highVolumeTag = getTopTag(context, startDate, count().desc());
97+
String highActivityTag = getTopTag(context, startDate, avg(HELP_THREADS.MESSAGE_COUNT).desc());
98+
String lowActivityTag = getTopTag(context, startDate, avg(HELP_THREADS.MESSAGE_COUNT).asc());
99+
100+
String peakHourRange = getPeakHour(context, startDate);
101+
102+
EmbedBuilder embed = new EmbedBuilder()
103+
.setTitle("📊 Help Thread Stats (Last " + days + " Days)")
104+
.setColor(getStatusColor(totalCreated, ghostThreads))
105+
.setTimestamp(Instant.now())
106+
.setDescription("\u200B")
107+
.setFooter("Together Java Community Stats", Objects.requireNonNull(event.getGuild()).getIconUrl());
108+
109+
embed.addField("📝 THREAD ACTIVITY",
110+
"Created: `%d`\nCurrently Open: `%d`\nResponse Rate: %.1f%%\nPeak Hours: `%s`"
111+
.formatted(totalCreated, openThreads, rawResRate, peakHourRange), false);
112+
113+
embed.addField("💬 ENGAGEMENT",
114+
"Avg Messages: `%s`\nAvg Helpers: `%s`\nUnanswered (Ghost): `%d`".formatted(
115+
formatDouble(Objects.requireNonNull(statsRecord.get("avg_msgs"))),
116+
formatDouble(Objects.requireNonNull(statsRecord.get("avg_parts"))),
117+
ghostThreads), false);
118+
119+
embed.addField("🏷️ TAG ACTIVITY",
120+
"Most Used: `%s`\nMost Active: `%s`\nNeeds Love: `%s`".formatted(
121+
highVolumeTag, highActivityTag, lowActivityTag), false);
122+
123+
embed.addField("⚡ RESOLUTION SPEED",
124+
"Average: `%s`\nFastest: `%s`\nSlowest: `%s`".formatted(
125+
smartFormat(statsRecord.get("avg_sec", Double.class)),
126+
smartFormat(statsRecord.get("min_sec", Double.class)),
127+
smartFormat(statsRecord.get("max_sec", Double.class))), false);
128+
129+
event.getHook().editOriginalEmbeds(embed.build()).queue();
130+
return null;
131+
});
101132
}
102133

103-
private Double getAverageThreadLifecycle(List<ForumChannel> forumChannels,
104-
OffsetDateTime startDate) {
105-
return getThreadChannelsStream(forumChannels, startDate).filter(ThreadChannel::isArchived)
106-
.mapToDouble(threadChannel -> calculateDurationInDays(
107-
threadChannel.getTimeArchiveInfoLastModified(), threadChannel.getTimeCreated()))
108-
.average()
109-
.orElse(0);
110-
}
134+
private static Color getStatusColor(int totalCreated, long ghostThreads) {
135+
double rawResRate = totalCreated > 0 ? ((double) (totalCreated - ghostThreads) / totalCreated) * 100 : -1;
111136

112-
private Double calculateDurationInDays(OffsetDateTime t1, OffsetDateTime t2) {
113-
long time1 = t1.toEpochSecond();
114-
long time2 = t2.toEpochSecond();
115-
return (time1 - time2) / 86400.0;
137+
if (rawResRate >= 70) return Color.GREEN;
138+
if (rawResRate >= 30) return Color.YELLOW;
139+
if (rawResRate >= 0) return Color.RED;
140+
return Color.GRAY;
116141
}
117142

118-
private Stream<ThreadChannel> getThreadChannelsStream(List<ForumChannel> forumChannels,
119-
OffsetDateTime startDate) {
120-
return forumChannels.stream()
121-
.flatMap(forumChannel -> getAllThreadChannels(forumChannel).stream())
122-
.filter(threadChannel -> threadChannel.getTimeCreated().isAfter(startDate));
143+
private String getTopTag(DSLContext context, Instant start, OrderField<?> order) {
144+
return context.select(HELP_THREADS.TAGS).from(HELP_THREADS)
145+
.where(HELP_THREADS.CREATED_AT.ge(start)).and(HELP_THREADS.TAGS.ne("none"))
146+
.groupBy(HELP_THREADS.TAGS).orderBy(order).limit(1)
147+
.fetchOptional(HELP_THREADS.TAGS).orElse("N/A");
123148
}
124149

125-
private Set<ThreadChannel> getAllThreadChannels(ForumChannel forumChannel) {
126-
Set<ThreadChannel> threadChannels = new HashSet<>(forumChannel.getThreadChannels());
127-
Optional<ThreadChannelPaginationAction> publicThreadChannels =
128-
Optional.of(forumChannel.retrieveArchivedPublicThreadChannels());
129-
publicThreadChannels.ifPresent(threads -> threads.forEach(threadChannels::add));
130-
return threadChannels;
150+
private String getPeakHour(DSLContext context, Instant start) {
151+
return context.select(field("strftime('%H', {0})", String.class, HELP_THREADS.CREATED_AT))
152+
.from(HELP_THREADS).where(HELP_THREADS.CREATED_AT.ge(start))
153+
.groupBy(field("strftime('%H', {0})", String.class, HELP_THREADS.CREATED_AT))
154+
.orderBy(count().desc()).limit(1).fetchOptional(Record1::value1)
155+
.map(hour -> {
156+
int h = Integer.parseInt(hour);
157+
return "%02d:00 - %02d:00 UTC".formatted(h, (h + 1) % 24);
158+
}).orElse("N/A");
131159
}
132160

133-
private static Stream<Subcommand> streamSubcommands() {
134-
return Arrays.stream(Subcommand.values());
161+
private String smartFormat(Double seconds) {
162+
if (seconds < 0) return "N/A";
163+
if (seconds < 60) return "%.0f secs".formatted(seconds);
164+
if (seconds < 3600) return "%.1f mins".formatted(seconds / 60.0);
165+
if (seconds < 86400) return "%.1f hrs".formatted(seconds / 3600.0);
166+
return "%.1f days".formatted(seconds / 86400.0);
135167
}
136168

137-
enum Subcommand {
138-
DURATION(DURATION_SUBCOMMAND, "Set the duration");
139-
140-
private final String commandName;
141-
private final String description;
142-
143-
Subcommand(String commandName, String description) {
144-
this.commandName = commandName;
145-
this.description = description;
146-
}
147-
148-
String getCommandName() {
149-
return commandName;
150-
}
151-
152-
SubcommandData toSubcommandData() {
153-
return new SubcommandData(commandName, description);
154-
}
169+
private String formatDouble(Object val) {
170+
return val instanceof Number num ? "%.2f".formatted(num.doubleValue()) : "0.00";
155171
}
156-
}
172+
}

0 commit comments

Comments
 (0)