Skip to content

Commit a2771b1

Browse files
authored
Semi-Automatic Top Helper Assignment (#1303)
* config, command adjustments, routine and base setup * select menu * role management and more flow * announcement, failure logic * (fixed some comment that spotless wrapped ugly) * javadoc and some readability changes * fixed some wrong javadoc * (minor description tweak) * CR: better default channel * CR message format improvement * CR added mapUserIdToMember utility to service * Added Guilds, a helper with role and channel find methods * CR readability with split args * Added channel find methods to Guilds helper (findTextChannel etc)
1 parent 9f59235 commit a2771b1

18 files changed

+970
-243
lines changed

application/config.json.template

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@
1010
"mutedRolePattern": "Muted",
1111
"heavyModerationRolePattern": "Moderator",
1212
"softModerationRolePattern": "Moderator|Community Ambassador",
13-
"tagManageRolePattern": "Moderator|Community Ambassador|Top Helpers .+",
14-
"excludeCodeAutoDetectionRolePattern": "Top Helpers .+|Moderator|Community Ambassador|Expert",
13+
"tagManageRolePattern": "Moderator|Community Ambassador|Top Helper.*",
14+
"excludeCodeAutoDetectionRolePattern": "Top Helper.*|Moderator|Community Ambassador|Expert",
1515
"suggestions": {
1616
"channelPattern": "tj-suggestions",
1717
"upVoteEmoteName": "peepo_yes",
@@ -22,7 +22,7 @@
2222
"mode": "AUTO_DELETE_BUT_APPROVE_QUARANTINE",
2323
"reportChannelPattern": "commands",
2424
"botTrapChannelPattern": "bot-trap",
25-
"trustedUserRolePattern": "Top Helpers .+|Moderator|Community Ambassador|Expert",
25+
"trustedUserRolePattern": "Top Helper.*|Moderator|Community Ambassador|Expert",
2626
"suspiciousKeywords": [
2727
"nitro",
2828
"boob",
@@ -189,5 +189,10 @@
189189
"fallbackChannelPattern": "java-news-and-changes",
190190
"pollIntervalInMinutes": 10
191191
},
192-
"memberCountCategoryPattern": "Info"
192+
"memberCountCategoryPattern": "Info",
193+
"topHelpers": {
194+
"rolePattern": "Top Helper.*",
195+
"assignmentChannelPattern": "community-commands",
196+
"announcementChannelPattern": "hall-of-fame"
197+
}
193198
}

application/src/main/java/org/togetherjava/tjbot/config/Config.java

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ public final class Config {
4848
private final RSSFeedsConfig rssFeedsConfig;
4949
private final String selectRolesChannelPattern;
5050
private final String memberCountCategoryPattern;
51+
private final TopHelpersConfig topHelpers;
5152

5253
@SuppressWarnings("ConstructorWithTooManyParameters")
5354
@JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
@@ -100,7 +101,8 @@ private Config(@JsonProperty(value = "token", required = true) String token,
100101
required = true) FeatureBlacklistConfig featureBlacklistConfig,
101102
@JsonProperty(value = "rssConfig", required = true) RSSFeedsConfig rssFeedsConfig,
102103
@JsonProperty(value = "selectRolesChannelPattern",
103-
required = true) String selectRolesChannelPattern) {
104+
required = true) String selectRolesChannelPattern,
105+
@JsonProperty(value = "topHelpers", required = true) TopHelpersConfig topHelpers) {
104106
this.token = Objects.requireNonNull(token);
105107
this.githubApiKey = Objects.requireNonNull(githubApiKey);
106108
this.databasePath = Objects.requireNonNull(databasePath);
@@ -135,6 +137,7 @@ private Config(@JsonProperty(value = "token", required = true) String token,
135137
this.featureBlacklistConfig = Objects.requireNonNull(featureBlacklistConfig);
136138
this.rssFeedsConfig = Objects.requireNonNull(rssFeedsConfig);
137139
this.selectRolesChannelPattern = Objects.requireNonNull(selectRolesChannelPattern);
140+
this.topHelpers = Objects.requireNonNull(topHelpers);
138141
}
139142

140143
/**
@@ -445,4 +448,13 @@ public String getMemberCountCategoryPattern() {
445448
public RSSFeedsConfig getRSSFeedsConfig() {
446449
return rssFeedsConfig;
447450
}
451+
452+
/**
453+
* Gets the config for the Top Helpers system.
454+
*
455+
* @return the configuration
456+
*/
457+
public TopHelpersConfig getTopHelpers() {
458+
return topHelpers;
459+
}
448460
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package org.togetherjava.tjbot.config;
2+
3+
import com.fasterxml.jackson.annotation.JsonCreator;
4+
import com.fasterxml.jackson.annotation.JsonProperty;
5+
import com.fasterxml.jackson.annotation.JsonRootName;
6+
7+
import java.util.Objects;
8+
9+
/**
10+
* Configuration for the top helper system, see
11+
* {@link org.togetherjava.tjbot.features.tophelper.TopHelpersCommand}.
12+
*/
13+
@JsonRootName("topHelpers")
14+
public final class TopHelpersConfig {
15+
private final String rolePattern;
16+
private final String assignmentChannelPattern;
17+
private final String announcementChannelPattern;
18+
19+
@JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
20+
private TopHelpersConfig(
21+
@JsonProperty(value = "rolePattern", required = true) String rolePattern,
22+
@JsonProperty(value = "assignmentChannelPattern",
23+
required = true) String assignmentChannelPattern,
24+
@JsonProperty(value = "announcementChannelPattern",
25+
required = true) String announcementChannelPattern) {
26+
this.rolePattern = Objects.requireNonNull(rolePattern);
27+
this.assignmentChannelPattern = Objects.requireNonNull(assignmentChannelPattern);
28+
this.announcementChannelPattern = Objects.requireNonNull(announcementChannelPattern);
29+
}
30+
31+
/**
32+
* Gets the REGEX pattern matching the role used to represent Top Helpers.
33+
*
34+
* @return the role name pattern
35+
*/
36+
public String getRolePattern() {
37+
return rolePattern;
38+
}
39+
40+
/**
41+
* Gets the REGEX pattern used to identify the channel where Top Helper assignments are
42+
* automatically executed.
43+
*
44+
* @return the channel name pattern
45+
*/
46+
public String getAssignmentChannelPattern() {
47+
return assignmentChannelPattern;
48+
}
49+
50+
/**
51+
* Gets the REGEX pattern used to identify the channel where Top Helper announcements are send.
52+
*
53+
* @return the channel name pattern
54+
*/
55+
public String getAnnouncementChannelPattern() {
56+
return announcementChannelPattern;
57+
}
58+
}

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,11 @@
7272
import org.togetherjava.tjbot.features.tags.TagManageCommand;
7373
import org.togetherjava.tjbot.features.tags.TagSystem;
7474
import org.togetherjava.tjbot.features.tags.TagsCommand;
75+
import org.togetherjava.tjbot.features.tophelper.TopHelpersAssignmentRoutine;
7576
import org.togetherjava.tjbot.features.tophelper.TopHelpersCommand;
7677
import org.togetherjava.tjbot.features.tophelper.TopHelpersMessageListener;
7778
import org.togetherjava.tjbot.features.tophelper.TopHelpersPurgeMessagesRoutine;
79+
import org.togetherjava.tjbot.features.tophelper.TopHelpersService;
7880

7981
import java.util.ArrayList;
8082
import java.util.Collection;
@@ -119,6 +121,9 @@ public static Collection<Feature> createFeatures(JDA jda, Database database, Con
119121
HelpSystemHelper helpSystemHelper = new HelpSystemHelper(config, database, chatGptService);
120122
HelpThreadLifecycleListener helpThreadLifecycleListener =
121123
new HelpThreadLifecycleListener(helpSystemHelper, database);
124+
TopHelpersService topHelpersService = new TopHelpersService(database);
125+
TopHelpersAssignmentRoutine topHelpersAssignmentRoutine =
126+
new TopHelpersAssignmentRoutine(config, topHelpersService);
122127

123128
// NOTE The system can add special system relevant commands also by itself,
124129
// hence this list may not necessarily represent the full list of all commands actually
@@ -140,6 +145,7 @@ public static Collection<Feature> createFeatures(JDA jda, Database database, Con
140145
features.add(new MarkHelpThreadCloseInDBRoutine(database, helpThreadLifecycleListener));
141146
features.add(new MemberCountDisplayRoutine(config));
142147
features.add(new RSSHandlerRoutine(config, database));
148+
features.add(topHelpersAssignmentRoutine);
143149

144150
// Message receivers
145151
features.add(new TopHelpersMessageListener(database, config));
@@ -182,7 +188,7 @@ public static Collection<Feature> createFeatures(JDA jda, Database database, Con
182188
features.add(new AuditCommand(actionsStore));
183189
features.add(new MuteCommand(actionsStore, config));
184190
features.add(new UnmuteCommand(actionsStore, config));
185-
features.add(new TopHelpersCommand(database));
191+
features.add(new TopHelpersCommand(topHelpersService, topHelpersAssignmentRoutine));
186192
features.add(new RoleSelectCommand());
187193
features.add(new NoteCommand(actionsStore));
188194
features.add(new ReminderCommand(database));

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

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,19 @@
22

33
import net.dv8tion.jda.api.JDA;
44

5+
import java.time.Duration;
6+
import java.time.Instant;
7+
import java.time.LocalTime;
8+
import java.time.OffsetDateTime;
9+
import java.time.ZoneOffset;
10+
import java.time.temporal.ChronoUnit;
11+
import java.util.ArrayList;
12+
import java.util.Comparator;
13+
import java.util.List;
14+
import java.util.concurrent.ScheduledExecutorService;
515
import java.util.concurrent.TimeUnit;
16+
import java.util.function.BiFunction;
17+
import java.util.stream.Collectors;
618

719
/**
820
* Routines are executed on a reoccurring schedule by the core system.
@@ -45,6 +57,97 @@ public interface Routine extends Feature {
4557
* seconds
4658
*/
4759
record Schedule(ScheduleMode mode, long initialDuration, long duration, TimeUnit unit) {
60+
61+
private static final int HOURS_OF_DAY = 24;
62+
63+
/**
64+
* Creates a schedule for execution at a fixed hour of the day. The initial first execution
65+
* will be delayed to the next fixed time that matches the given hour of the day,
66+
* effectively making execution stable at that fixed hour - regardless of when this method
67+
* was originally triggered.
68+
* <p>
69+
* For example, if the given hour is 12 o'clock, this leads to the fixed execution times of
70+
* only 12:00 each day. The first execution is then delayed to the closest time in that
71+
* schedule. For example, if triggered at 7:00, execution will happen at 12:00 and then
72+
* follow the schedule.
73+
* <p>
74+
* Execution will also correctly roll over to the next day, for example if the method is
75+
* triggered at 21:30, the next execution will be at 12:00 the following day.
76+
*
77+
* @param hourOfDay the hour of the day that marks the start of this period
78+
* @return the according schedule representing the planned execution
79+
*/
80+
public static Schedule atFixedHour(int hourOfDay) {
81+
return atFixedRateFromNextFixedTime(hourOfDay, HOURS_OF_DAY);
82+
}
83+
84+
/**
85+
* Creates a schedule for execution at a fixed rate (see
86+
* {@link ScheduledExecutorService#scheduleAtFixedRate(Runnable, long, long, TimeUnit)}).
87+
* The initial first execution will be delayed to the next fixed time that matches the given
88+
* period, effectively making execution stable at fixed times of a day - regardless of when
89+
* this method was originally triggered.
90+
* <p>
91+
* For example, if the given period is 8 hours with a start hour of 4 o'clock, this leads to
92+
* the fixed execution times of 4:00, 12:00 and 20:00 each day. The first execution is then
93+
* delayed to the closest time in that schedule. For example, if triggered at 7:00,
94+
* execution will happen at 12:00 and then follow the schedule.
95+
* <p>
96+
* Execution will also correctly roll over to the next day, for example if the method is
97+
* triggered at 21:30, the next execution will be at 4:00 the following day.
98+
*
99+
* @param periodStartHour the hour of the day that marks the start of this period
100+
* @param periodHours the scheduling period in hours
101+
* @return the according schedule representing the planned execution
102+
*/
103+
public static Schedule atFixedRateFromNextFixedTime(int periodStartHour, int periodHours) {
104+
// NOTE This scheduler could be improved, for example supporting arbitrary periods (not
105+
// just hour-based). Also, it probably does not correctly handle all date/time-quirks,
106+
// for example if a schedule would hit a time that does not exist for a specific date
107+
// due to DST or similar issues. Those are minor though and can be ignored for now.
108+
if (periodStartHour < 0 || periodStartHour >= HOURS_OF_DAY) {
109+
throw new IllegalArgumentException(
110+
"Schedule period start hour must be a valid hour of a day (0-23)");
111+
}
112+
if (periodHours <= 0 || periodHours > HOURS_OF_DAY) {
113+
throw new IllegalArgumentException(
114+
"Schedule period must not be zero and must fit into a single day (0-24)");
115+
}
116+
117+
// Compute fixed schedule hours
118+
List<Integer> fixedScheduleHours = new ArrayList<>();
119+
120+
for (int hour = periodStartHour; hour < HOURS_OF_DAY; hour += periodHours) {
121+
fixedScheduleHours.add(hour);
122+
}
123+
124+
Instant now = Instant.now();
125+
Instant nextFixedTime =
126+
computeClosestNextScheduleDate(now, fixedScheduleHours, periodHours);
127+
return new Schedule(ScheduleMode.FIXED_RATE,
128+
ChronoUnit.SECONDS.between(now, nextFixedTime),
129+
TimeUnit.HOURS.toSeconds(periodHours), TimeUnit.SECONDS);
130+
}
131+
132+
private static Instant computeClosestNextScheduleDate(Instant instant,
133+
List<Integer> scheduleHours, int periodHours) {
134+
OffsetDateTime offsetDateTime = instant.atOffset(ZoneOffset.UTC);
135+
BiFunction<OffsetDateTime, Integer, Instant> dateAtTime =
136+
(date, hour) -> date.with(LocalTime.of(hour, 0)).toInstant();
137+
138+
// The instant is either before the given hours, in between, or after.
139+
// For latter, we roll the schedule over once to the next day
140+
List<Instant> scheduleDates = scheduleHours.stream()
141+
.map(hour -> dateAtTime.apply(offsetDateTime, hour))
142+
.collect(Collectors.toCollection(ArrayList::new));
143+
int rolloverHour = (scheduleHours.getLast() + periodHours) % HOURS_OF_DAY;
144+
scheduleDates.add(dateAtTime.apply(offsetDateTime.plusDays(1), rolloverHour));
145+
146+
return scheduleDates.stream()
147+
.filter(instant::isBefore)
148+
.min(Comparator.comparing(scheduleDate -> Duration.between(instant, scheduleDate)))
149+
.orElseThrow();
150+
}
48151
}
49152

50153
/**

application/src/main/java/org/togetherjava/tjbot/features/filesharing/FileSharingMessageListener.java

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import net.dv8tion.jda.api.entities.Member;
44
import net.dv8tion.jda.api.entities.Message;
5-
import net.dv8tion.jda.api.entities.Role;
65
import net.dv8tion.jda.api.entities.User;
76
import net.dv8tion.jda.api.entities.channel.ChannelType;
87
import net.dv8tion.jda.api.entities.channel.concrete.ThreadChannel;
@@ -21,6 +20,7 @@
2120
import org.togetherjava.tjbot.features.UserInteractor;
2221
import org.togetherjava.tjbot.features.componentids.ComponentIdGenerator;
2322
import org.togetherjava.tjbot.features.componentids.ComponentIdInteractor;
23+
import org.togetherjava.tjbot.features.utils.Guilds;
2424

2525
import java.io.IOException;
2626
import java.io.InputStream;
@@ -99,8 +99,7 @@ public void onMessageReceived(MessageReceivedEvent event) {
9999
public void onButtonClick(ButtonInteractionEvent event, List<String> args) {
100100
Member interactionUser = event.getMember();
101101
String gistAuthorId = args.getFirst();
102-
boolean hasSoftModPermissions =
103-
interactionUser.getRoles().stream().map(Role::getName).anyMatch(isSoftModRole);
102+
boolean hasSoftModPermissions = Guilds.hasMemberRole(interactionUser, isSoftModRole);
104103

105104
if (!gistAuthorId.equals(interactionUser.getId()) && !hasSoftModPermissions) {
106105
event.reply("You do not have permission for this action.").setEphemeral(true).queue();

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

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import org.togetherjava.tjbot.features.chatgpt.ChatGptCommand;
2828
import org.togetherjava.tjbot.features.chatgpt.ChatGptService;
2929
import org.togetherjava.tjbot.features.componentids.ComponentIdInteractor;
30+
import org.togetherjava.tjbot.features.utils.Guilds;
3031

3132
import java.awt.Color;
3233
import java.time.Instant;
@@ -57,7 +58,7 @@ public final class HelpSystemHelper {
5758

5859
static final Color AMBIENT_COLOR = new Color(255, 255, 165);
5960

60-
private final Predicate<String> hasTagManageRole;
61+
private final Predicate<String> isTagManageRole;
6162
private final Predicate<String> isHelpForumName;
6263
private final String helpForumPattern;
6364
/**
@@ -88,7 +89,7 @@ public HelpSystemHelper(Config config, Database database, ChatGptService chatGpt
8889
this.database = database;
8990
this.chatGptService = chatGptService;
9091

91-
hasTagManageRole = Pattern.compile(config.getTagManageRolePattern()).asMatchPredicate();
92+
isTagManageRole = Pattern.compile(config.getTagManageRolePattern()).asMatchPredicate();
9293
helpForumPattern = helpConfig.getHelpForumPattern();
9394
isHelpForumName = Pattern.compile(helpForumPattern).asMatchPredicate();
9495

@@ -344,7 +345,7 @@ private static ForumTag requireTag(String tagName, ForumChannel forumChannel) {
344345
}
345346

346347
boolean hasTagManageRole(Member member) {
347-
return member.getRoles().stream().map(Role::getName).anyMatch(hasTagManageRole);
348+
return Guilds.hasMemberRole(member, isTagManageRole);
348349
}
349350

350351
boolean isHelpForumName(String channelName) {
@@ -360,11 +361,7 @@ Optional<ForumChannel> handleRequireHelpForum(Guild guild,
360361
Predicate<String> isChannelName = this::isHelpForumName;
361362
String channelPattern = getHelpForumPattern();
362363

363-
Optional<ForumChannel> maybeChannel = guild.getForumChannelCache()
364-
.stream()
365-
.filter(channel -> isChannelName.test(channel.getName()))
366-
.findAny();
367-
364+
Optional<ForumChannel> maybeChannel = Guilds.findForumChannel(guild, isChannelName);
368365
if (maybeChannel.isEmpty()) {
369366
consumeChannelPatternIfNotFound.accept(channelPattern);
370367
}

application/src/main/java/org/togetherjava/tjbot/features/moderation/ModerationUtils.java

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import org.togetherjava.tjbot.config.Config;
2020
import org.togetherjava.tjbot.features.moderation.modmail.ModMailCommand;
21+
import org.togetherjava.tjbot.features.utils.Guilds;
2122
import org.togetherjava.tjbot.features.utils.MessageUtils;
2223

2324
import javax.annotation.Nullable;
@@ -321,7 +322,7 @@ public static Predicate<String> getIsMutedRolePredicate(Config config) {
321322
*/
322323
public static Optional<Role> getMutedRole(Guild guild, Config config) {
323324
Predicate<String> isMutedRole = getIsMutedRolePredicate(config);
324-
return guild.getRoles().stream().filter(role -> isMutedRole.test(role.getName())).findAny();
325+
return Guilds.findRole(guild, isMutedRole);
325326
}
326327

327328
/**
@@ -343,10 +344,7 @@ public static Predicate<String> getIsQuarantinedRolePredicate(Config config) {
343344
*/
344345
public static Optional<Role> getQuarantinedRole(Guild guild, Config config) {
345346
Predicate<String> isQuarantinedRole = getIsQuarantinedRolePredicate(config);
346-
return guild.getRoles()
347-
.stream()
348-
.filter(role -> isQuarantinedRole.test(role.getName()))
349-
.findAny();
347+
return Guilds.findRole(guild, isQuarantinedRole);
350348
}
351349

352350
/**

0 commit comments

Comments
 (0)