11package 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 ;
64import net .dv8tion .jda .api .events .interaction .command .SlashCommandInteractionEvent ;
75import net .dv8tion .jda .api .interactions .commands .OptionType ;
86import 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 ;
1311import org .togetherjava .tjbot .features .CommandVisibility ;
1412import 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+ */
2636public 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`\n Currently Open: `%d`\n Response Rate: %.1f%%\n Peak Hours: `%s`"
111+ .formatted (totalCreated , openThreads , rawResRate , peakHourRange ), false );
112+
113+ embed .addField ("💬 ENGAGEMENT" ,
114+ "Avg Messages: `%s`\n Avg Helpers: `%s`\n Unanswered (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`\n Most Active: `%s`\n Needs Love: `%s`" .formatted (
121+ highVolumeTag , highActivityTag , lowActivityTag ), false );
122+
123+ embed .addField ("⚡ RESOLUTION SPEED" ,
124+ "Average: `%s`\n Fastest: `%s`\n Slowest: `%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