Skip to content

Semi-Automatic Top Helper Assignment #1303

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 14 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions application/config.json.template
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
"mutedRolePattern": "Muted",
"heavyModerationRolePattern": "Moderator",
"softModerationRolePattern": "Moderator|Community Ambassador",
"tagManageRolePattern": "Moderator|Community Ambassador|Top Helpers .+",
"excludeCodeAutoDetectionRolePattern": "Top Helpers .+|Moderator|Community Ambassador|Expert",
"tagManageRolePattern": "Moderator|Community Ambassador|Top Helper.*",
"excludeCodeAutoDetectionRolePattern": "Top Helper.*|Moderator|Community Ambassador|Expert",
"suggestions": {
"channelPattern": "tj-suggestions",
"upVoteEmoteName": "peepo_yes",
Expand All @@ -22,7 +22,7 @@
"mode": "AUTO_DELETE_BUT_APPROVE_QUARANTINE",
"reportChannelPattern": "commands",
"botTrapChannelPattern": "bot-trap",
"trustedUserRolePattern": "Top Helpers .+|Moderator|Community Ambassador|Expert",
"trustedUserRolePattern": "Top Helper.*|Moderator|Community Ambassador|Expert",
"suspiciousKeywords": [
"nitro",
"boob",
Expand Down Expand Up @@ -189,5 +189,10 @@
"fallbackChannelPattern": "java-news-and-changes",
"pollIntervalInMinutes": 10
},
"memberCountCategoryPattern": "Info"
"memberCountCategoryPattern": "Info",
"topHelpers": {
"rolePattern": "Top Helper.*",
"assignmentChannelPattern": "community-commands",
"announcementChannelPattern": "hall-of-fame"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ public final class Config {
private final RSSFeedsConfig rssFeedsConfig;
private final String selectRolesChannelPattern;
private final String memberCountCategoryPattern;
private final TopHelpersConfig topHelpers;

@SuppressWarnings("ConstructorWithTooManyParameters")
@JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
Expand Down Expand Up @@ -100,7 +101,8 @@ private Config(@JsonProperty(value = "token", required = true) String token,
required = true) FeatureBlacklistConfig featureBlacklistConfig,
@JsonProperty(value = "rssConfig", required = true) RSSFeedsConfig rssFeedsConfig,
@JsonProperty(value = "selectRolesChannelPattern",
required = true) String selectRolesChannelPattern) {
required = true) String selectRolesChannelPattern,
@JsonProperty(value = "topHelpers", required = true) TopHelpersConfig topHelpers) {
this.token = Objects.requireNonNull(token);
this.githubApiKey = Objects.requireNonNull(githubApiKey);
this.databasePath = Objects.requireNonNull(databasePath);
Expand Down Expand Up @@ -135,6 +137,7 @@ 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.topHelpers = Objects.requireNonNull(topHelpers);
}

/**
Expand Down Expand Up @@ -445,4 +448,13 @@ public String getMemberCountCategoryPattern() {
public RSSFeedsConfig getRSSFeedsConfig() {
return rssFeedsConfig;
}

/**
* Gets the config for the Top Helpers system.
*
* @return the configuration
*/
public TopHelpersConfig getTopHelpers() {
return topHelpers;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package org.togetherjava.tjbot.config;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonRootName;

import java.util.Objects;

/**
* Configuration for the top helper system, see
* {@link org.togetherjava.tjbot.features.tophelper.TopHelpersCommand}.
*/
@JsonRootName("topHelpers")
public final class TopHelpersConfig {
private final String rolePattern;
private final String assignmentChannelPattern;
private final String announcementChannelPattern;

@JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
private TopHelpersConfig(
@JsonProperty(value = "rolePattern", required = true) String rolePattern,
@JsonProperty(value = "assignmentChannelPattern",
required = true) String assignmentChannelPattern,
@JsonProperty(value = "announcementChannelPattern",
required = true) String announcementChannelPattern) {
this.rolePattern = Objects.requireNonNull(rolePattern);
this.assignmentChannelPattern = Objects.requireNonNull(assignmentChannelPattern);
this.announcementChannelPattern = Objects.requireNonNull(announcementChannelPattern);
}

/**
* Gets the REGEX pattern matching the role used to represent Top Helpers.
*
* @return the role name pattern
*/
public String getRolePattern() {
return rolePattern;
}

/**
* Gets the REGEX pattern used to identify the channel where Top Helper assignments are
* automatically executed.
*
* @return the channel name pattern
*/
public String getAssignmentChannelPattern() {
return assignmentChannelPattern;
}

/**
* Gets the REGEX pattern used to identify the channel where Top Helper announcements are send.
*
* @return the channel name pattern
*/
public String getAnnouncementChannelPattern() {
return announcementChannelPattern;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,11 @@
import org.togetherjava.tjbot.features.tags.TagManageCommand;
import org.togetherjava.tjbot.features.tags.TagSystem;
import org.togetherjava.tjbot.features.tags.TagsCommand;
import org.togetherjava.tjbot.features.tophelper.TopHelpersAssignmentRoutine;
import org.togetherjava.tjbot.features.tophelper.TopHelpersCommand;
import org.togetherjava.tjbot.features.tophelper.TopHelpersMessageListener;
import org.togetherjava.tjbot.features.tophelper.TopHelpersPurgeMessagesRoutine;
import org.togetherjava.tjbot.features.tophelper.TopHelpersService;

import java.util.ArrayList;
import java.util.Collection;
Expand Down Expand Up @@ -119,6 +121,9 @@ public static Collection<Feature> createFeatures(JDA jda, Database database, Con
HelpSystemHelper helpSystemHelper = new HelpSystemHelper(config, database, chatGptService);
HelpThreadLifecycleListener helpThreadLifecycleListener =
new HelpThreadLifecycleListener(helpSystemHelper, database);
TopHelpersService topHelpersService = new TopHelpersService(database);
TopHelpersAssignmentRoutine topHelpersAssignmentRoutine =
new TopHelpersAssignmentRoutine(config, topHelpersService);

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

// Message receivers
features.add(new TopHelpersMessageListener(database, config));
Expand Down Expand Up @@ -182,7 +188,7 @@ public static Collection<Feature> createFeatures(JDA jda, Database database, Con
features.add(new AuditCommand(actionsStore));
features.add(new MuteCommand(actionsStore, config));
features.add(new UnmuteCommand(actionsStore, config));
features.add(new TopHelpersCommand(database));
features.add(new TopHelpersCommand(topHelpersService, topHelpersAssignmentRoutine));
features.add(new RoleSelectCommand());
features.add(new NoteCommand(actionsStore));
features.add(new ReminderCommand(database));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,19 @@

import net.dv8tion.jda.api.JDA;

import java.time.Duration;
import java.time.Instant;
import java.time.LocalTime;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.function.BiFunction;
import java.util.stream.Collectors;

/**
* Routines are executed on a reoccurring schedule by the core system.
Expand Down Expand Up @@ -45,6 +57,97 @@ public interface Routine extends Feature {
* seconds
*/
record Schedule(ScheduleMode mode, long initialDuration, long duration, TimeUnit unit) {

private static final int HOURS_OF_DAY = 24;

/**
* Creates a schedule for execution at a fixed hour of the day. The initial first execution
* will be delayed to the next fixed time that matches the given hour of the day,
* effectively making execution stable at that fixed hour - regardless of when this method
* was originally triggered.
* <p>
* For example, if the given hour is 12 o'clock, this leads to the fixed execution times of
* only 12:00 each day. The first execution is then delayed to the closest time in that
* schedule. For example, if triggered at 7:00, execution will happen at 12:00 and then
* follow the schedule.
* <p>
* Execution will also correctly roll over to the next day, for example if the method is
* triggered at 21:30, the next execution will be at 12:00 the following day.
*
* @param hourOfDay the hour of the day that marks the start of this period
* @return the according schedule representing the planned execution
*/
public static Schedule atFixedHour(int hourOfDay) {
return atFixedRateFromNextFixedTime(hourOfDay, HOURS_OF_DAY);
}

/**
* Creates a schedule for execution at a fixed rate (see
* {@link ScheduledExecutorService#scheduleAtFixedRate(Runnable, long, long, TimeUnit)}).
* The initial first execution will be delayed to the next fixed time that matches the given
* period, effectively making execution stable at fixed times of a day - regardless of when
* this method was originally triggered.
* <p>
* For example, if the given period is 8 hours with a start hour of 4 o'clock, this leads to
* the fixed execution times of 4:00, 12:00 and 20:00 each day. The first execution is then
* delayed to the closest time in that schedule. For example, if triggered at 7:00,
* execution will happen at 12:00 and then follow the schedule.
* <p>
* Execution will also correctly roll over to the next day, for example if the method is
* triggered at 21:30, the next execution will be at 4:00 the following day.
*
* @param periodStartHour the hour of the day that marks the start of this period
* @param periodHours the scheduling period in hours
* @return the according schedule representing the planned execution
*/
public static Schedule atFixedRateFromNextFixedTime(int periodStartHour, int periodHours) {
// NOTE This scheduler could be improved, for example supporting arbitrary periods (not
// just hour-based). Also, it probably does not correctly handle all date/time-quirks,
// for example if a schedule would hit a time that does not exist for a specific date
// due to DST or similar issues. Those are minor though and can be ignored for now.
if (periodStartHour < 0 || periodStartHour >= HOURS_OF_DAY) {
throw new IllegalArgumentException(
"Schedule period start hour must be a valid hour of a day (0-23)");
}
if (periodHours <= 0 || periodHours > HOURS_OF_DAY) {
throw new IllegalArgumentException(
"Schedule period must not be zero and must fit into a single day (0-24)");
}

// Compute fixed schedule hours
List<Integer> fixedScheduleHours = new ArrayList<>();

for (int hour = periodStartHour; hour < HOURS_OF_DAY; hour += periodHours) {
fixedScheduleHours.add(hour);
}

Instant now = Instant.now();
Instant nextFixedTime =
computeClosestNextScheduleDate(now, fixedScheduleHours, periodHours);
return new Schedule(ScheduleMode.FIXED_RATE,
ChronoUnit.SECONDS.between(now, nextFixedTime),
TimeUnit.HOURS.toSeconds(periodHours), TimeUnit.SECONDS);
}

private static Instant computeClosestNextScheduleDate(Instant instant,
List<Integer> scheduleHours, int periodHours) {
OffsetDateTime offsetDateTime = instant.atOffset(ZoneOffset.UTC);
BiFunction<OffsetDateTime, Integer, Instant> dateAtTime =
(date, hour) -> date.with(LocalTime.of(hour, 0)).toInstant();

// The instant is either before the given hours, in between, or after.
// For latter, we roll the schedule over once to the next day
List<Instant> scheduleDates = scheduleHours.stream()
.map(hour -> dateAtTime.apply(offsetDateTime, hour))
.collect(Collectors.toCollection(ArrayList::new));
int rolloverHour = (scheduleHours.getLast() + periodHours) % HOURS_OF_DAY;
scheduleDates.add(dateAtTime.apply(offsetDateTime.plusDays(1), rolloverHour));

return scheduleDates.stream()
.filter(instant::isBefore)
.min(Comparator.comparing(scheduleDate -> Duration.between(instant, scheduleDate)))
.orElseThrow();
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.entities.Role;
import net.dv8tion.jda.api.entities.User;
import net.dv8tion.jda.api.entities.channel.ChannelType;
import net.dv8tion.jda.api.entities.channel.concrete.ThreadChannel;
Expand All @@ -21,6 +20,7 @@
import org.togetherjava.tjbot.features.UserInteractor;
import org.togetherjava.tjbot.features.componentids.ComponentIdGenerator;
import org.togetherjava.tjbot.features.componentids.ComponentIdInteractor;
import org.togetherjava.tjbot.features.utils.Guilds;

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

if (!gistAuthorId.equals(interactionUser.getId()) && !hasSoftModPermissions) {
event.reply("You do not have permission for this action.").setEphemeral(true).queue();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import org.togetherjava.tjbot.features.chatgpt.ChatGptCommand;
import org.togetherjava.tjbot.features.chatgpt.ChatGptService;
import org.togetherjava.tjbot.features.componentids.ComponentIdInteractor;
import org.togetherjava.tjbot.features.utils.Guilds;

import java.awt.Color;
import java.time.Instant;
Expand Down Expand Up @@ -57,7 +58,7 @@ public final class HelpSystemHelper {

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

private final Predicate<String> hasTagManageRole;
private final Predicate<String> isTagManageRole;
private final Predicate<String> isHelpForumName;
private final String helpForumPattern;
/**
Expand Down Expand Up @@ -88,7 +89,7 @@ public HelpSystemHelper(Config config, Database database, ChatGptService chatGpt
this.database = database;
this.chatGptService = chatGptService;

hasTagManageRole = Pattern.compile(config.getTagManageRolePattern()).asMatchPredicate();
isTagManageRole = Pattern.compile(config.getTagManageRolePattern()).asMatchPredicate();
helpForumPattern = helpConfig.getHelpForumPattern();
isHelpForumName = Pattern.compile(helpForumPattern).asMatchPredicate();

Expand Down Expand Up @@ -344,7 +345,7 @@ private static ForumTag requireTag(String tagName, ForumChannel forumChannel) {
}

boolean hasTagManageRole(Member member) {
return member.getRoles().stream().map(Role::getName).anyMatch(hasTagManageRole);
return Guilds.hasMemberRole(member, isTagManageRole);
}

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

Optional<ForumChannel> maybeChannel = guild.getForumChannelCache()
.stream()
.filter(channel -> isChannelName.test(channel.getName()))
.findAny();

Optional<ForumChannel> maybeChannel = Guilds.findForumChannel(guild, isChannelName);
if (maybeChannel.isEmpty()) {
consumeChannelPatternIfNotFound.accept(channelPattern);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

import org.togetherjava.tjbot.config.Config;
import org.togetherjava.tjbot.features.moderation.modmail.ModMailCommand;
import org.togetherjava.tjbot.features.utils.Guilds;
import org.togetherjava.tjbot.features.utils.MessageUtils;

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

/**
Expand All @@ -343,10 +344,7 @@ public static Predicate<String> getIsQuarantinedRolePredicate(Config config) {
*/
public static Optional<Role> getQuarantinedRole(Guild guild, Config config) {
Predicate<String> isQuarantinedRole = getIsQuarantinedRolePredicate(config);
return guild.getRoles()
.stream()
.filter(role -> isQuarantinedRole.test(role.getName()))
.findAny();
return Guilds.findRole(guild, isQuarantinedRole);
}

/**
Expand Down
Loading