Skip to content

Commit 1a17607

Browse files
committed
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
1 parent e18e616 commit 1a17607

File tree

2 files changed

+149
-101
lines changed

2 files changed

+149
-101
lines changed

application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadLifecycleListener.java

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,14 @@ void handleArchiveStatus(Instant closedAt, long id, JDA jda) {
9696
}
9797

9898
long threadId = threadChannel.getIdLong();
99-
int messageCount = threadChannel.getMessageCount();
100-
int participantsExceptAuthor = threadChannel.getMemberCount() - 1;
99+
int messageCount = threadChannel.getMessageCount(); // TODO: to be replaced with participant
100+
// message count
101+
long threadOwnerId = threadChannel.getOwnerIdLong();
102+
int participantsExceptAuthor = (int) threadChannel.getMembers()
103+
.stream()
104+
.filter(threadMember -> threadMember.getIdLong() != threadOwnerId)
105+
.filter(m -> !m.getUser().isBot())
106+
.count();
101107

102108
database.write(context -> context.update(HELP_THREADS)
103109
.set(HELP_THREADS.CLOSED_AT, closedAt)
@@ -131,7 +137,7 @@ private void handleTagsUpdate(long threadId, String updatedTag) {
131137

132138
/**
133139
* will ignore updated tag event if all new tags belong to the categories config
134-
*
140+
*
135141
* @param event updated tags event
136142
* @return boolean
137143
*/

application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadStatsCommand.java

Lines changed: 140 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,23 @@
77
import net.dv8tion.jda.api.interactions.commands.OptionMapping;
88
import net.dv8tion.jda.api.interactions.commands.OptionType;
99
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
10-
import org.jooq.DSLContext;
1110
import org.jooq.Field;
1211
import org.jooq.OrderField;
12+
import org.jooq.Record;
1313
import org.jooq.Record1;
1414

1515
import org.togetherjava.tjbot.db.Database;
1616
import org.togetherjava.tjbot.features.CommandVisibility;
1717
import org.togetherjava.tjbot.features.SlashCommandAdapter;
1818

19+
import javax.annotation.Nullable;
20+
1921
import java.awt.Color;
2022
import java.time.Duration;
2123
import java.time.Instant;
2224
import java.time.temporal.ChronoUnit;
2325
import java.util.Objects;
26+
import java.util.Optional;
2427

2528
import static org.jooq.impl.DSL.avg;
2629
import static org.jooq.impl.DSL.count;
@@ -56,11 +59,11 @@ public class HelpThreadStatsCommand extends SlashCommandAdapter {
5659
private static final String MINIMUM_THREAD_DURATION_IN_SECONDS_ALIAS = "min_sec";
5760
private static final String MAXIMUM_THREAD_DURATION_IN_SECONDS_ALIAS = "max_sec";
5861

59-
private static final String EMOJI_CHART = "\uD83D\uDCCA";
60-
private static final String EMOJI_MEMO = "\uD83D\uDCDD";
61-
private static final String EMOJI_SPEECH_BUBBLE = "\uD83D\uDCAC";
62-
private static final String EMOJI_LABEL = "\uD83C\uDFF7\uFE0F";
63-
private static final String EMOJI_LIGHTNING = "\u26A1";
62+
private static final String EMOJI_CHART = "📊";
63+
private static final String EMOJI_MEMO = "📝";
64+
private static final String EMOJI_SPEECH_BUBBLE = "💬";
65+
private static final String EMOJI_LABEL = "🏷️";
66+
private static final String EMOJI_LIGHTNING = "";
6467

6568
private static final String EMBED_BLANK_LINE = "\u200B";
6669
private static final String WHITESPACE = " ";
@@ -94,32 +97,19 @@ public HelpThreadStatsCommand(Database database) {
9497
Caffeine.newBuilder().maximumSize(500).expireAfterWrite(COOLDOWN_DURATION).build();
9598
}
9699

97-
@Override
98-
public void onSlashCommand(SlashCommandInteractionEvent event) {
99-
long channelId = event.getChannel().getIdLong();
100-
Instant now = Instant.now();
101-
100+
private long getSecondsSinceLastUsage(long channelId, Instant now) {
101+
long secondsSinceLastUsage = 0;
102102
Instant lastUsage = this.cooldownCache.getIfPresent(channelId);
103103
if (lastUsage != null) {
104104
Duration elapsed = Duration.between(lastUsage, now);
105105
// to avoid displaying -1 when elapsed just crosses cooldown
106-
long secondsLeft = Math.max(0, COOLDOWN_DURATION.minus(elapsed).toSeconds());
107-
108-
event
109-
.reply("This command is on cooldown! Please wait " + secondsLeft + " more seconds.")
110-
.setEphemeral(true)
111-
.queue();
112-
return;
106+
secondsSinceLastUsage = Math.max(0, COOLDOWN_DURATION.minus(elapsed).toSeconds());
113107
}
108+
return secondsSinceLastUsage;
109+
}
114110

115-
cooldownCache.put(channelId, now);
116-
117-
long days = event.getOption(DURATION_OPTION, 1L, OptionMapping::getAsLong);
118-
Instant startDate = Instant.now().minus(days, ChronoUnit.DAYS);
119-
120-
event.deferReply().queue();
121-
122-
database.read(context -> {
111+
private Optional<Record> getHelpThreadUsageStats(Instant statsDurationStartDate) {
112+
return database.read(context -> {
123113
var statsRecord = context
124114
.select(count().as(TOTAL_CREATED_FIELD), count()
125115
.filterWhere(
@@ -129,108 +119,157 @@ public void onSlashCommand(SlashCommandInteractionEvent event) {
129119
avg(HELP_THREADS.PARTICIPANTS).as(AVERAGE_PARTICIPANTS_ALIAS),
130120
avg(HELP_THREADS.MESSAGE_COUNT).as(AVERAGE_MESSAGE_COUNT_ALIAS),
131121
avg(durationInSeconds(HELP_THREADS.CLOSED_AT, HELP_THREADS.CREATED_AT))
122+
.filterWhere(HELP_THREADS.PARTICIPANTS.gt(0))
132123
.as(AVERAGE_THREAD_DURATION_IN_SECONDS_ALIAS),
133124
min(durationInSeconds(HELP_THREADS.CLOSED_AT, HELP_THREADS.CREATED_AT))
125+
.filterWhere(HELP_THREADS.PARTICIPANTS.gt(0))
134126
.as(MINIMUM_THREAD_DURATION_IN_SECONDS_ALIAS),
135127
max(durationInSeconds(HELP_THREADS.CLOSED_AT, HELP_THREADS.CREATED_AT))
128+
.filterWhere(HELP_THREADS.PARTICIPANTS.gt(0))
136129
.as(MAXIMUM_THREAD_DURATION_IN_SECONDS_ALIAS))
137130
.from(HELP_THREADS)
138-
.where(HELP_THREADS.CREATED_AT.ge(startDate))
131+
.where(HELP_THREADS.CREATED_AT.ge(statsDurationStartDate))
139132
.fetchOne();
140133

141134
if (statsRecord == null || statsRecord.get(TOTAL_CREATED_FIELD, Integer.class) == 0) {
142-
event.getHook()
143-
.editOriginal("No stats available for the last " + days + " days.")
144-
.queue();
145-
return null;
135+
return Optional.empty();
146136
}
137+
return Optional.of(statsRecord);
138+
});
139+
}
140+
141+
private record StatsReportData(long days, int totalCreated, int openThreads, long ghostThreads,
142+
double responseRate, String highVolumeTag, String highActivityTag,
143+
String lowActivityTag, String peakHourRange, Record rawStats
147144

148-
int totalCreated = statsRecord.get(TOTAL_CREATED_FIELD, Integer.class);
149-
int openThreads = statsRecord.get(OPEN_NOW_ALIAS, Integer.class);
150-
long ghostThreads = statsRecord.get(GHOST_NOW_ALIAS, Number.class).longValue();
145+
) {
146+
}
147+
148+
private EmbedBuilder buildStatsEmbed(StatsReportData helpThreadStatsResults,
149+
String guildIconUrl, int daysBack) {
150+
EmbedBuilder helpThreadStatsEmbed = new EmbedBuilder()
151+
.setTitle(EMOJI_CHART + " Help Thread Stats (Last " + daysBack + " Days)")
152+
.setColor(getStatusColor(helpThreadStatsResults.totalCreated(),
153+
helpThreadStatsResults.ghostThreads()))
154+
.setTimestamp(Instant.now())
155+
.setDescription(EMBED_BLANK_LINE)
156+
.setFooter("Together Java Community Stats", guildIconUrl);
157+
158+
helpThreadStatsEmbed.addField(EMOJI_MEMO + WHITESPACE + "THREAD ACTIVITY",
159+
"Created: `%d`%nCurrently Open: `%d`%nResponse Rate: %.1f%%%nPeak Hours: `%s`"
160+
.formatted(helpThreadStatsResults.totalCreated(),
161+
helpThreadStatsResults.openThreads(),
162+
helpThreadStatsResults.responseRate(),
163+
helpThreadStatsResults.peakHourRange()),
164+
false);
165+
166+
helpThreadStatsEmbed.addField(EMOJI_SPEECH_BUBBLE + WHITESPACE + "ENGAGEMENT",
167+
"Avg Messages: `%s`%nAvg Helpers: `%s`%nUnanswered (Ghost): `%d`".formatted(
168+
formatDouble(Objects.requireNonNull(helpThreadStatsResults.rawStats()
169+
.get(AVERAGE_MESSAGE_COUNT_ALIAS))),
170+
formatDouble(Objects.requireNonNull(
171+
helpThreadStatsResults.rawStats().get(AVERAGE_PARTICIPANTS_ALIAS))),
172+
helpThreadStatsResults.ghostThreads),
173+
false);
174+
175+
helpThreadStatsEmbed.addField(EMOJI_LABEL + WHITESPACE + "TAG ACTIVITY",
176+
"Most Used: `%s`%nMost Active: `%s`%nNeeds Love: `%s`".formatted(
177+
helpThreadStatsResults.highVolumeTag(),
178+
helpThreadStatsResults.highActivityTag(),
179+
helpThreadStatsResults.lowActivityTag()),
180+
false);
181+
182+
helpThreadStatsEmbed.addField(EMOJI_LIGHTNING + WHITESPACE + "RESOLUTION SPEED",
183+
"Average: `%s`%nFastest: `%s`%nSlowest: `%s`".formatted(
184+
smartFormat(helpThreadStatsResults.rawStats()
185+
.get(AVERAGE_THREAD_DURATION_IN_SECONDS_ALIAS, Double.class)),
186+
smartFormat(helpThreadStatsResults.rawStats()
187+
.get(MINIMUM_THREAD_DURATION_IN_SECONDS_ALIAS, Double.class)),
188+
smartFormat(helpThreadStatsResults.rawStats()
189+
.get(MAXIMUM_THREAD_DURATION_IN_SECONDS_ALIAS, Double.class))),
190+
false);
191+
return helpThreadStatsEmbed;
192+
}
151193

152-
double rawResRate =
194+
195+
@Override
196+
public void onSlashCommand(SlashCommandInteractionEvent event) {
197+
long channelId = event.getChannel().getIdLong();
198+
Instant now = Instant.now();
199+
long secondsSinceLastUsage = getSecondsSinceLastUsage(channelId, now);
200+
if (secondsSinceLastUsage != 0L) {
201+
event
202+
.reply("This command is on cooldown! Please wait " + secondsSinceLastUsage
203+
+ " more seconds.")
204+
.setEphemeral(true)
205+
.queue();
206+
return;
207+
}
208+
event.deferReply().queue();
209+
cooldownCache.put(channelId, now);
210+
int daysBackOption = Optional.ofNullable(event.getOption(DURATION_OPTION))
211+
.map(OptionMapping::getAsInt)
212+
.orElse(1);
213+
214+
Instant startDate = Instant.now().minus(daysBackOption, ChronoUnit.DAYS);
215+
216+
getHelpThreadUsageStats(startDate).ifPresentOrElse(stats -> {
217+
int totalCreated = stats.get(TOTAL_CREATED_FIELD, Integer.class);
218+
int openThreads = stats.get(OPEN_NOW_ALIAS, Integer.class);
219+
long ghostThreads = stats.get(GHOST_NOW_ALIAS, Number.class).longValue();
220+
221+
double helpThreadInteractionRate =
153222
totalCreated > 0 ? ((double) (totalCreated - ghostThreads) / totalCreated) * 100
154223
: 0;
155224

156-
String highVolumeTag = getTopTag(context, startDate, count().desc());
157-
String highActivityTag =
158-
getTopTag(context, startDate, avg(HELP_THREADS.MESSAGE_COUNT).desc());
159-
String lowActivityTag =
160-
getTopTag(context, startDate, avg(HELP_THREADS.MESSAGE_COUNT).asc());
161-
162-
String peakHourRange = getPeakHour(context, startDate);
163-
164-
EmbedBuilder embed = new EmbedBuilder()
165-
.setTitle(EMOJI_CHART + " Help Thread Stats (Last " + days + " Days)")
166-
.setColor(getStatusColor(totalCreated, ghostThreads))
167-
.setTimestamp(Instant.now())
168-
.setDescription(EMBED_BLANK_LINE)
169-
.setFooter("Together Java Community Stats",
170-
Objects.requireNonNull(event.getGuild()).getIconUrl());
171-
172-
embed.addField(EMOJI_MEMO + WHITESPACE + "THREAD ACTIVITY",
173-
"Created: `%d`%nCurrently Open: `%d`%nResponse Rate: %.1f%%%nPeak Hours: `%s`"
174-
.formatted(totalCreated, openThreads, rawResRate, peakHourRange),
175-
false);
176-
177-
embed.addField(EMOJI_SPEECH_BUBBLE + WHITESPACE + "ENGAGEMENT",
178-
"Avg Messages: `%s`%nAvg Helpers: `%s`%nUnanswered (Ghost): `%d`".formatted(
179-
formatDouble(Objects
180-
.requireNonNull(statsRecord.get(AVERAGE_MESSAGE_COUNT_ALIAS))),
181-
formatDouble(Objects
182-
.requireNonNull(statsRecord.get(AVERAGE_PARTICIPANTS_ALIAS))),
183-
ghostThreads),
184-
false);
185-
186-
embed.addField(EMOJI_LABEL + WHITESPACE + "TAG ACTIVITY",
187-
"Most Used: `%s`%nMost Active: `%s`%nNeeds Love: `%s`".formatted(highVolumeTag,
188-
highActivityTag, lowActivityTag),
189-
false);
190-
191-
embed.addField(EMOJI_LIGHTNING + WHITESPACE + "RESOLUTION SPEED",
192-
"Average: `%s`%nFastest: `%s`%nSlowest: `%s`".formatted(
193-
smartFormat(statsRecord.get(AVERAGE_THREAD_DURATION_IN_SECONDS_ALIAS,
194-
Double.class)),
195-
smartFormat(statsRecord.get(MINIMUM_THREAD_DURATION_IN_SECONDS_ALIAS,
196-
Double.class)),
197-
smartFormat(statsRecord.get(MAXIMUM_THREAD_DURATION_IN_SECONDS_ALIAS,
198-
Double.class))),
199-
false);
200-
201-
event.getHook().editOriginalEmbeds(embed.build()).queue();
202-
return null;
203-
});
225+
String highVolumeTag = getTopTag(startDate, count().desc());
226+
String highActivityTag = getTopTag(startDate, avg(HELP_THREADS.MESSAGE_COUNT).desc());
227+
String lowActivityTag = getTopTag(startDate, avg(HELP_THREADS.MESSAGE_COUNT).asc());
228+
229+
String peakHourRange = getPeakHour(startDate);
230+
StatsReportData fetchedStats = new StatsReportData(daysBackOption, totalCreated,
231+
openThreads, ghostThreads, helpThreadInteractionRate, highVolumeTag,
232+
highActivityTag, lowActivityTag, peakHourRange, stats);
233+
EmbedBuilder helpThreadStatsEmbed =
234+
buildStatsEmbed(fetchedStats, event.getGuild().getIconUrl(), daysBackOption);
235+
236+
event.getHook().editOriginalEmbeds(helpThreadStatsEmbed.build()).queue();
237+
238+
}, () -> event.getHook()
239+
.editOriginal("No stats available for the last " + daysBackOption + " days.")
240+
.queue());
204241
}
205242

206-
private static Color getStatusColor(int totalCreated, long ghostThreads) {
207-
double rawResRate =
208-
totalCreated > 0 ? ((double) (totalCreated - ghostThreads) / totalCreated) * 100
209-
: -1;
243+
private static Color getStatusColor(int totalHelpThreadsCreated, long ghostThreads) {
244+
double helpThreadInteractionRate = totalHelpThreadsCreated > 0
245+
? ((double) (totalHelpThreadsCreated - ghostThreads) / totalHelpThreadsCreated)
246+
* 100
247+
: -1;
210248

211-
if (rawResRate >= 70)
249+
if (helpThreadInteractionRate >= 70)
212250
return Color.GREEN;
213-
if (rawResRate >= 30)
251+
if (helpThreadInteractionRate >= 30)
214252
return Color.YELLOW;
215-
if (rawResRate >= 0)
253+
if (helpThreadInteractionRate >= 0)
216254
return Color.RED;
217255
return Color.GRAY;
218256
}
219257

220-
private String getTopTag(DSLContext context, Instant start, OrderField<?> order) {
221-
return context.select(HELP_THREADS.TAGS)
258+
private String getTopTag(Instant start, OrderField<?> order) {
259+
return database.read(context -> context.select(HELP_THREADS.TAGS)
222260
.from(HELP_THREADS)
223261
.where(HELP_THREADS.CREATED_AT.ge(start))
224262
.and(HELP_THREADS.TAGS.ne("none"))
225263
.groupBy(HELP_THREADS.TAGS)
226264
.orderBy(order)
227265
.limit(1)
228266
.fetchOptional(HELP_THREADS.TAGS)
229-
.orElse("N/A");
267+
.orElse("N/A"));
230268
}
231269

232-
private String getPeakHour(DSLContext context, Instant start) {
233-
return context.select(field("strftime('%H', {0})", String.class, HELP_THREADS.CREATED_AT))
270+
private String getPeakHour(Instant start) {
271+
return database.read(context -> context
272+
.select(field("strftime('%H', {0})", String.class, HELP_THREADS.CREATED_AT))
234273
.from(HELP_THREADS)
235274
.where(HELP_THREADS.CREATED_AT.ge(start))
236275
.groupBy(field("strftime('%H', {0})", String.class, HELP_THREADS.CREATED_AT))
@@ -241,12 +280,14 @@ private String getPeakHour(DSLContext context, Instant start) {
241280
int h = Integer.parseInt(hour);
242281
return "%02d:00 - %02d:00 UTC".formatted(h, (h + 1) % 24);
243282
})
244-
.orElse("N/A");
283+
.orElse("N/A"));
245284
}
246285

247-
private String smartFormat(Double seconds) {
248-
if (seconds < 0)
286+
private String smartFormat(@Nullable Double seconds) {
287+
if (seconds == null || seconds < 0) {
249288
return "N/A";
289+
}
290+
250291
if (seconds < 60)
251292
return "%.0f secs".formatted(seconds);
252293
if (seconds < 3600)
@@ -256,6 +297,7 @@ private String smartFormat(Double seconds) {
256297
return "%.1f days".formatted(seconds / 86400.0);
257298
}
258299

300+
259301
private String formatDouble(Object val) {
260302
return val instanceof Number num ? "%.2f".formatted(num.doubleValue()) : "0.00";
261303
}

0 commit comments

Comments
 (0)