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));
diff --git a/application/src/main/java/org/togetherjava/tjbot/features/Routine.java b/application/src/main/java/org/togetherjava/tjbot/features/Routine.java
index b5d2f614af..806618649d 100644
--- a/application/src/main/java/org/togetherjava/tjbot/features/Routine.java
+++ b/application/src/main/java/org/togetherjava/tjbot/features/Routine.java
@@ -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.
@@ -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.
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * 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 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 scheduleHours, int periodHours) {
+ OffsetDateTime offsetDateTime = instant.atOffset(ZoneOffset.UTC);
+ BiFunction 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 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();
+ }
}
/**
diff --git a/application/src/main/java/org/togetherjava/tjbot/features/filesharing/FileSharingMessageListener.java b/application/src/main/java/org/togetherjava/tjbot/features/filesharing/FileSharingMessageListener.java
index b330960475..c040eaf065 100644
--- a/application/src/main/java/org/togetherjava/tjbot/features/filesharing/FileSharingMessageListener.java
+++ b/application/src/main/java/org/togetherjava/tjbot/features/filesharing/FileSharingMessageListener.java
@@ -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;
@@ -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;
@@ -99,8 +99,7 @@ public void onMessageReceived(MessageReceivedEvent event) {
public void onButtonClick(ButtonInteractionEvent event, List 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();
diff --git a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpSystemHelper.java b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpSystemHelper.java
index 222a8bfb66..dbb6ed55e2 100644
--- a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpSystemHelper.java
+++ b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpSystemHelper.java
@@ -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;
@@ -57,7 +58,7 @@ public final class HelpSystemHelper {
static final Color AMBIENT_COLOR = new Color(255, 255, 165);
- private final Predicate hasTagManageRole;
+ private final Predicate isTagManageRole;
private final Predicate isHelpForumName;
private final String helpForumPattern;
/**
@@ -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();
@@ -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) {
@@ -360,11 +361,7 @@ Optional handleRequireHelpForum(Guild guild,
Predicate isChannelName = this::isHelpForumName;
String channelPattern = getHelpForumPattern();
- Optional maybeChannel = guild.getForumChannelCache()
- .stream()
- .filter(channel -> isChannelName.test(channel.getName()))
- .findAny();
-
+ Optional maybeChannel = Guilds.findForumChannel(guild, isChannelName);
if (maybeChannel.isEmpty()) {
consumeChannelPatternIfNotFound.accept(channelPattern);
}
diff --git a/application/src/main/java/org/togetherjava/tjbot/features/moderation/ModerationUtils.java b/application/src/main/java/org/togetherjava/tjbot/features/moderation/ModerationUtils.java
index d6aba6dc68..5378b1a606 100644
--- a/application/src/main/java/org/togetherjava/tjbot/features/moderation/ModerationUtils.java
+++ b/application/src/main/java/org/togetherjava/tjbot/features/moderation/ModerationUtils.java
@@ -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;
@@ -321,7 +322,7 @@ public static Predicate getIsMutedRolePredicate(Config config) {
*/
public static Optional getMutedRole(Guild guild, Config config) {
Predicate isMutedRole = getIsMutedRolePredicate(config);
- return guild.getRoles().stream().filter(role -> isMutedRole.test(role.getName())).findAny();
+ return Guilds.findRole(guild, isMutedRole);
}
/**
@@ -343,10 +344,7 @@ public static Predicate getIsQuarantinedRolePredicate(Config config) {
*/
public static Optional getQuarantinedRole(Guild guild, Config config) {
Predicate isQuarantinedRole = getIsQuarantinedRolePredicate(config);
- return guild.getRoles()
- .stream()
- .filter(role -> isQuarantinedRole.test(role.getName()))
- .findAny();
+ return Guilds.findRole(guild, isQuarantinedRole);
}
/**
diff --git a/application/src/main/java/org/togetherjava/tjbot/features/moderation/audit/ModAuditLogRoutine.java b/application/src/main/java/org/togetherjava/tjbot/features/moderation/audit/ModAuditLogRoutine.java
index 6af6fa2248..603c1de578 100644
--- a/application/src/main/java/org/togetherjava/tjbot/features/moderation/audit/ModAuditLogRoutine.java
+++ b/application/src/main/java/org/togetherjava/tjbot/features/moderation/audit/ModAuditLogRoutine.java
@@ -28,24 +28,14 @@
import javax.annotation.Nullable;
import java.awt.Color;
-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.time.temporal.TemporalAccessor;
-import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
-import java.util.concurrent.ScheduledExecutorService;
-import java.util.concurrent.TimeUnit;
-import java.util.function.BiFunction;
-import java.util.stream.Collectors;
/**
@@ -60,7 +50,6 @@ public final class ModAuditLogRoutine implements Routine {
private static final Logger logger = LoggerFactory.getLogger(ModAuditLogRoutine.class);
private static final int CHECK_AUDIT_LOG_START_HOUR = 4;
private static final int CHECK_AUDIT_LOG_EVERY_HOURS = 8;
- private static final int HOURS_OF_DAY = 24;
private static final Color AMBIENT_COLOR = Color.decode("#4FC3F7");
private final Database database;
@@ -95,74 +84,6 @@ private static boolean isSnowflakeAfter(ISnowflake snowflake, Instant timestamp)
return TimeUtil.getTimeCreated(snowflake.getIdLong()).toInstant().isAfter(timestamp);
}
- /**
- * 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.
- *
- * 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.
- *
- * 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
- */
- private static Schedule scheduleAtFixedRateFromNextFixedTime(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 (periodHours <= 0 || periodHours >= HOURS_OF_DAY) {
- throw new IllegalArgumentException(
- "Schedule period must not be zero and must fit into a single day");
- }
- if (periodStartHour <= 0 || periodStartHour >= HOURS_OF_DAY) {
- throw new IllegalArgumentException(
- "Schedule period start hour must be a valid hour of a day (0-23)");
- }
-
- // Compute fixed schedule hours
- List 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 scheduleHours, int periodHours) {
- OffsetDateTime offsetDateTime = instant.atOffset(ZoneOffset.UTC);
- BiFunction 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 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();
- }
-
private static Optional> handleBanEntry(AuditLogEntry entry) {
// NOTE Temporary bans are realized as permanent bans with automated unban,
// hence we can not differentiate a permanent or a temporary ban here
@@ -205,7 +126,7 @@ public void runRoutine(JDA jda) {
@Override
public Schedule createSchedule() {
- Schedule schedule = scheduleAtFixedRateFromNextFixedTime(CHECK_AUDIT_LOG_START_HOUR,
+ Schedule schedule = Schedule.atFixedRateFromNextFixedTime(CHECK_AUDIT_LOG_START_HOUR,
CHECK_AUDIT_LOG_EVERY_HOURS);
logger.info("Checking audit logs is scheduled for {}.",
Instant.now().plus(schedule.initialDuration(), schedule.unit().toChronoUnit()));
diff --git a/application/src/main/java/org/togetherjava/tjbot/features/moderation/audit/ModAuditLogWriter.java b/application/src/main/java/org/togetherjava/tjbot/features/moderation/audit/ModAuditLogWriter.java
index b458017732..b0aebee61b 100644
--- a/application/src/main/java/org/togetherjava/tjbot/features/moderation/audit/ModAuditLogWriter.java
+++ b/application/src/main/java/org/togetherjava/tjbot/features/moderation/audit/ModAuditLogWriter.java
@@ -10,6 +10,7 @@
import org.slf4j.LoggerFactory;
import org.togetherjava.tjbot.config.Config;
+import org.togetherjava.tjbot.features.utils.Guilds;
import javax.annotation.Nullable;
@@ -34,7 +35,7 @@ public final class ModAuditLogWriter {
private final Config config;
- private final Predicate auditLogChannelNamePredicate;
+ private final Predicate isAuditLogChannelName;
/**
* Creates a new instance.
@@ -43,7 +44,7 @@ public final class ModAuditLogWriter {
*/
public ModAuditLogWriter(Config config) {
this.config = config;
- auditLogChannelNamePredicate =
+ isAuditLogChannelName =
Pattern.compile(config.getModAuditLogChannelPattern()).asMatchPredicate();
}
@@ -90,11 +91,8 @@ public void write(String title, String description, @Nullable User author,
* @return the channel used for moderation audit logs, if present
*/
public Optional getAndHandleModAuditLogChannel(Guild guild) {
- Optional auditLogChannel = guild.getTextChannelCache()
- .stream()
- .filter(channel -> auditLogChannelNamePredicate.test(channel.getName()))
- .findAny();
-
+ Optional auditLogChannel =
+ Guilds.findTextChannel(guild, isAuditLogChannelName);
if (auditLogChannel.isEmpty()) {
logger.warn(
"Unable to log moderation events, did not find a mod audit log channel matching the configured pattern '{}' for guild '{}'",
diff --git a/application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/ScamBlocker.java b/application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/ScamBlocker.java
index 730d7eef14..1c37fe8f3f 100644
--- a/application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/ScamBlocker.java
+++ b/application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/ScamBlocker.java
@@ -6,7 +6,6 @@
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.entities.MessageEmbed;
-import net.dv8tion.jda.api.entities.Role;
import net.dv8tion.jda.api.entities.SelfUser;
import net.dv8tion.jda.api.entities.User;
import net.dv8tion.jda.api.entities.channel.concrete.PrivateChannel;
@@ -34,6 +33,7 @@
import org.togetherjava.tjbot.features.moderation.ModerationActionsStore;
import org.togetherjava.tjbot.features.moderation.ModerationUtils;
import org.togetherjava.tjbot.features.moderation.modmail.ModMailCommand;
+import org.togetherjava.tjbot.features.utils.Guilds;
import org.togetherjava.tjbot.features.utils.MessageUtils;
import org.togetherjava.tjbot.logging.LogMarkers;
@@ -41,6 +41,7 @@
import java.util.Collection;
import java.util.EnumSet;
import java.util.List;
+import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Consumer;
@@ -65,13 +66,13 @@ public final class ScamBlocker extends MessageReceiverAdapter implements UserInt
private final ScamBlockerConfig.Mode mode;
private final String reportChannelPattern;
private final String botTrapChannelPattern;
- private final Predicate isReportChannel;
+ private final Predicate isReportChannelName;
private final Predicate isBotTrapChannel;
private final ScamDetector scamDetector;
private final Config config;
private final ModerationActionsStore actionsStore;
private final ScamHistoryStore scamHistoryStore;
- private final Predicate hasRequiredRole;
+ private final Predicate isRequiredRole;
private final ComponentIdInteractor componentIdInteractor;
@@ -91,16 +92,14 @@ public ScamBlocker(ModerationActionsStore actionsStore, ScamHistoryStore scamHis
scamDetector = new ScamDetector(config);
reportChannelPattern = config.getScamBlocker().getReportChannelPattern();
- Predicate isReportChannelName =
- Pattern.compile(reportChannelPattern).asMatchPredicate();
- isReportChannel = channel -> isReportChannelName.test(channel.getName());
+ isReportChannelName = Pattern.compile(reportChannelPattern).asMatchPredicate();
botTrapChannelPattern = config.getScamBlocker().getBotTrapChannelPattern();
Predicate isBotTrapChannelName =
Pattern.compile(botTrapChannelPattern).asMatchPredicate();
isBotTrapChannel = channel -> isBotTrapChannelName.test(channel.getName());
- hasRequiredRole = Pattern.compile(config.getSoftModerationRolePattern()).asMatchPredicate();
+ isRequiredRole = Pattern.compile(config.getSoftModerationRolePattern()).asMatchPredicate();
componentIdInteractor = new ComponentIdInteractor(getInteractionType(), getName());
}
@@ -296,7 +295,7 @@ If you think this was a mistake (for example, your account was hacked, but you g
}
private Optional getReportChannel(Guild guild) {
- return guild.getTextChannelCache().stream().filter(isReportChannel).findAny();
+ return Guilds.findTextChannel(guild, isReportChannelName);
}
private List createConfirmDialog(MessageReceivedEvent event) {
@@ -316,7 +315,8 @@ private String generateComponentId(ComponentIdArguments args) {
@Override
public void onButtonClick(ButtonInteractionEvent event, List argsRaw) {
ComponentIdArguments args = ComponentIdArguments.fromList(argsRaw);
- if (event.getMember().getRoles().stream().map(Role::getName).noneMatch(hasRequiredRole)) {
+ if (Guilds.doesMemberNotHaveRole(Objects.requireNonNull(event.getMember()),
+ isRequiredRole)) {
event.reply(
"You can not handle scam in this guild, since you do not have the required role.")
.setEphemeral(true)
diff --git a/application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/ScamDetector.java b/application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/ScamDetector.java
index df2272e795..21f3eebcef 100644
--- a/application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/ScamDetector.java
+++ b/application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/ScamDetector.java
@@ -2,10 +2,10 @@
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.Message;
-import net.dv8tion.jda.api.entities.Role;
import org.togetherjava.tjbot.config.Config;
import org.togetherjava.tjbot.config.ScamBlockerConfig;
+import org.togetherjava.tjbot.features.utils.Guilds;
import java.util.Collection;
import java.util.List;
@@ -51,8 +51,7 @@ public ScamDetector(Config config) {
*/
public boolean isScam(Message message) {
Member author = message.getMember();
- boolean isTrustedUser = author != null
- && author.getRoles().stream().map(Role::getName).anyMatch(hasTrustedRole);
+ boolean isTrustedUser = author != null && Guilds.hasMemberRole(author, hasTrustedRole);
if (isTrustedUser) {
return false;
}
diff --git a/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersAssignmentRoutine.java b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersAssignmentRoutine.java
new file mode 100644
index 0000000000..76434af7e8
--- /dev/null
+++ b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersAssignmentRoutine.java
@@ -0,0 +1,350 @@
+package org.togetherjava.tjbot.features.tophelper;
+
+import net.dv8tion.jda.api.JDA;
+import net.dv8tion.jda.api.entities.Guild;
+import net.dv8tion.jda.api.entities.Member;
+import net.dv8tion.jda.api.entities.Role;
+import net.dv8tion.jda.api.entities.UserSnowflake;
+import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
+import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel;
+import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent;
+import net.dv8tion.jda.api.events.interaction.component.StringSelectInteractionEvent;
+import net.dv8tion.jda.api.interactions.components.ComponentInteraction;
+import net.dv8tion.jda.api.interactions.components.buttons.Button;
+import net.dv8tion.jda.api.interactions.components.selections.SelectOption;
+import net.dv8tion.jda.api.interactions.components.selections.StringSelectMenu;
+import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder;
+import net.dv8tion.jda.api.utils.messages.MessageCreateData;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.togetherjava.tjbot.config.Config;
+import org.togetherjava.tjbot.config.TopHelpersConfig;
+import org.togetherjava.tjbot.features.Routine;
+import org.togetherjava.tjbot.features.UserInteractionType;
+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 javax.annotation.Nullable;
+
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.Predicate;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/**
+ * Semi-automatic routine for assigning and announcing Top Helpers of the month.
+ *
+ * The routine triggers once on the 1st of each month, but can also be started manually by calling
+ * {@link #startDialogFor(Guild)}.
+ *
+ * The routine starts a dialog in a configured channel, proposing a list of Top Helpers. A user can
+ * now choose from a list who to reward with the Top Helper role. These users are now assigned the
+ * configured Top Helper role, while users who had the role in the past have it removed from them.
+ * Afterward, one can select from the dialog whether a generic announcement that thanks the selected
+ * Top Helpers should be posted in a configured channel.
+ */
+public final class TopHelpersAssignmentRoutine implements Routine, UserInteractor {
+ private static final Logger logger = LoggerFactory.getLogger(TopHelpersAssignmentRoutine.class);
+ private static final int CHECK_AT_HOUR = 12;
+ private static final String CANCEL_BUTTON_NAME = "cancel";
+ private static final String YES_MESSAGE_BUTTON_NAME = "yes-message";
+ private static final String NO_MESSAGE_BUTTON_NAME = "no-message";
+
+ private final TopHelpersConfig config;
+ private final TopHelpersService service;
+ private final Predicate roleNamePredicate;
+ private final Predicate assignmentChannelNamePredicate;
+ private final Predicate announcementChannelNamePredicate;
+ private final ComponentIdInteractor componentIdInteractor;
+
+ /**
+ * Creates a new instance.
+ *
+ * @param config the config to use
+ * @param service the service to use to compute Top Helpers
+ */
+ public TopHelpersAssignmentRoutine(Config config, TopHelpersService service) {
+ this.config = config.getTopHelpers();
+ this.service = service;
+
+ roleNamePredicate = Pattern.compile(this.config.getRolePattern()).asMatchPredicate();
+ assignmentChannelNamePredicate =
+ Pattern.compile(this.config.getAssignmentChannelPattern()).asMatchPredicate();
+ announcementChannelNamePredicate =
+ Pattern.compile(this.config.getAnnouncementChannelPattern()).asMatchPredicate();
+
+ componentIdInteractor = new ComponentIdInteractor(getInteractionType(), getName());
+ }
+
+ @Override
+ public String getName() {
+ return "top-helper-assignment";
+ }
+
+ @Override
+ public UserInteractionType getInteractionType() {
+ return UserInteractionType.OTHER;
+ }
+
+ @Override
+ public void acceptComponentIdGenerator(ComponentIdGenerator generator) {
+ componentIdInteractor.acceptComponentIdGenerator(generator);
+ }
+
+ @Override
+ public Schedule createSchedule() {
+ // Routine schedules are hour-based. Ensuring it only triggers once per a month is done
+ // inside the routine as early-check instead.
+ return Schedule.atFixedHour(CHECK_AT_HOUR);
+ }
+
+ @Override
+ public void runRoutine(JDA jda) {
+ if (!isFirstDayOfMonth()) {
+ return;
+ }
+
+ jda.getGuilds().forEach(this::startDialogFor);
+ }
+
+ private static boolean isFirstDayOfMonth() {
+ return Instant.now().atOffset(ZoneOffset.UTC).getDayOfMonth() == 1;
+ }
+
+ /**
+ * Starts a dialog for semi-automatic Top Helper assignment and announcement. See
+ * {@link TopHelpersAssignmentRoutine} for details.
+ *
+ * @param guild to start the dialog and compute Top Helpers for
+ */
+ public void startDialogFor(Guild guild) {
+ Optional assignmentChannel =
+ Guilds.findTextChannel(guild, assignmentChannelNamePredicate);
+
+ assignmentChannel.ifPresentOrElse(this::startDialogIn, () -> logger.warn(
+ "Unable to assign Top Helpers, did not find an assignment channel matching the configured pattern '{}' for guild '{}'",
+ config.getAssignmentChannelPattern(), guild.getName()));
+ }
+
+ private void startDialogIn(TextChannel channel) {
+ Guild guild = channel.getGuild();
+
+ TopHelpersService.TimeRange timeRange = TopHelpersService.TimeRange.ofPreviousMonth();
+ List topHelpers =
+ service.computeTopHelpersDescending(guild, timeRange);
+
+ if (topHelpers.isEmpty()) {
+ channel.sendMessage(
+ "Wanted to assign Top Helpers, but there seems to be no entries for that time range (%s)."
+ .formatted(timeRange.description()))
+ .queue();
+ return;
+ }
+
+ TopHelpersService.retrieveTopHelperMembers(topHelpers, guild).onError(error -> {
+ logger.warn("Failed to compute top-helpers for automatic assignment", error);
+ channel.sendMessage("Wanted to assign Top Helpers, but something went wrong.").queue();
+ }).onSuccess(members -> sendSelectionMenu(topHelpers, members, timeRange, channel));
+ }
+
+ private void sendSelectionMenu(Collection topHelpers,
+ Collection extends Member> members, TopHelpersService.TimeRange timeRange,
+ MessageChannel channel) {
+ String content = """
+ Starting assignment of Top Helpers:
+ ```java
+ // %s
+ %s
+ ```""".formatted(timeRange.description(),
+ TopHelpersService.asAsciiTable(topHelpers, members));
+
+ StringSelectMenu.Builder menu =
+ StringSelectMenu.create(componentIdInteractor.generateComponentId())
+ .setPlaceholder("Select the Top Helpers")
+ .setMinValues(1)
+ .setMaxValues(topHelpers.size());
+
+ Map userIdToMember = TopHelpersService.mapUserIdToMember(members);
+ topHelpers.stream()
+ .map(topHelper -> topHelperToSelectOption(topHelper,
+ userIdToMember.get(topHelper.authorId())))
+ .forEach(menu::addOptions);
+
+ MessageCreateData message = new MessageCreateBuilder().setContent(content)
+ .addActionRow(menu.build())
+ .addActionRow(Button
+ .danger(componentIdInteractor.generateComponentId(CANCEL_BUTTON_NAME), "Cancel"))
+ .build();
+
+ channel.sendMessage(message).queue();
+ }
+
+ private static SelectOption topHelperToSelectOption(TopHelpersService.TopHelperStats topHelper,
+ @Nullable Member member) {
+ String id = Long.toString(topHelper.authorId());
+
+ String name = TopHelpersService.getUsernameDisplay(member);
+ long messageLengths = topHelper.messageLengths().longValue();
+ String label = messageLengths + " - " + name;
+
+ return SelectOption.of(label, id);
+ }
+
+ @Override
+ public void onStringSelectSelection(StringSelectInteractionEvent event, List args) {
+ Guild guild = Objects.requireNonNull(event.getGuild());
+ Set selectedTopHelperIds = event.getSelectedOptions()
+ .stream()
+ .map(SelectOption::getValue)
+ .map(Long::parseLong)
+ .collect(Collectors.toSet());
+
+ Optional topHelperRole = Guilds.findRole(guild, roleNamePredicate);
+ if (topHelperRole.isEmpty()) {
+ logger.warn(
+ "Unable to assign Top Helpers, did not find a role matching the configured pattern '{}' for guild '{}'",
+ config.getRolePattern(), guild.getName());
+ event.reply("Wanted to assign Top Helpers, but something went wrong.").queue();
+ return;
+ }
+
+ event.deferEdit().queue();
+ guild.findMembersWithRoles(List.of(topHelperRole.orElseThrow())).onError(error -> {
+ logger.warn("Failed to find existing top-helpers for automatic assignment", error);
+ event.getHook()
+ .editOriginal(event.getMessage()
+ + "\nβ Sorry, something went wrong trying to find existing top-helpers.")
+ .setComponents()
+ .queue();
+ })
+ .onSuccess(currentTopHelpers -> manageTopHelperRole(currentTopHelpers,
+ selectedTopHelperIds, event, topHelperRole.orElseThrow()));
+ }
+
+ private void manageTopHelperRole(Collection extends Member> currentTopHelpers,
+ Set selectedTopHelperIds, StringSelectInteractionEvent event,
+ Role topHelperRole) {
+ Guild guild = Objects.requireNonNull(event.getGuild());
+ Set currentTopHelperIds =
+ currentTopHelpers.stream().map(Member::getIdLong).collect(Collectors.toSet());
+
+ Set usersToRemoveRoleFrom = new HashSet<>(currentTopHelperIds);
+ usersToRemoveRoleFrom.removeAll(selectedTopHelperIds);
+
+ Set usersToAddRoleTo = new HashSet<>(selectedTopHelperIds);
+ usersToAddRoleTo.removeAll(currentTopHelperIds);
+
+ for (long userToRemoveRoleFrom : usersToRemoveRoleFrom) {
+ guild.removeRoleFromMember(UserSnowflake.fromId(userToRemoveRoleFrom), topHelperRole)
+ .queue();
+ }
+ for (long userToAddRoleTo : usersToAddRoleTo) {
+ guild.addRoleToMember(UserSnowflake.fromId(userToAddRoleTo), topHelperRole).queue();
+ }
+
+ reportRoleManageSuccess(event);
+ }
+
+ private void reportRoleManageSuccess(StringSelectInteractionEvent event) {
+ String topHelperList = event.getSelectedOptions()
+ .stream()
+ .map(SelectOption::getLabel)
+ .map(label -> "* " + label)
+ .collect(Collectors.joining("\n"));
+
+ Stream topHelperIds =
+ event.getSelectedOptions().stream().map(SelectOption::getValue);
+ String[] successButtonArgs = Stream.concat(Stream.of(YES_MESSAGE_BUTTON_NAME), topHelperIds)
+ .toArray(String[]::new);
+
+ String content = event.getMessage().getContentRaw() + "\nSelected Top Helpers:\n"
+ + topHelperList + "\nShould I send a generic announcement?";
+ event.getHook()
+ .editOriginal(content)
+ .setActionRow(
+ Button.success(componentIdInteractor.generateComponentId(successButtonArgs),
+ "Yes"),
+ Button.danger(componentIdInteractor.generateComponentId(NO_MESSAGE_BUTTON_NAME),
+ "No"))
+ .queue();
+ }
+
+ @Override
+ public void onButtonClick(ButtonInteractionEvent event, List args) {
+ event.deferEdit().queue();
+ String name = args.getFirst();
+ List otherArgs = args.subList(1, args.size());
+
+ switch (name) {
+ case YES_MESSAGE_BUTTON_NAME -> prepareAnnouncement(event, otherArgs);
+ case NO_MESSAGE_BUTTON_NAME -> reportFlowFinished(event, "not posting an announcement");
+ case CANCEL_BUTTON_NAME -> reportFlowFinished(event, "cancelled");
+ default -> throw new AssertionError("Unknown button name: " + name);
+ }
+ }
+
+ private void prepareAnnouncement(ButtonInteractionEvent event, List args) {
+ List topHelperIds = args.stream().map(Long::parseLong).toList();
+
+ event.getGuild().retrieveMembersByIds(topHelperIds).onError(error -> {
+ logger.warn("Failed to retrieve top-helper data for automatic assignment", error);
+ event.getHook()
+ .editOriginal(event.getMessage()
+ + "\nβ Sorry, something went wrong trying to retrieve top-helper data.")
+ .setComponents()
+ .queue();
+ }).onSuccess(topHelpers -> postAnnouncement(event, topHelpers));
+ }
+
+ private void postAnnouncement(ButtonInteractionEvent event, List extends Member> topHelpers) {
+ Guild guild = Objects.requireNonNull(event.getGuild());
+ Optional announcementChannel =
+ Guilds.findTextChannel(guild, announcementChannelNamePredicate);
+
+ if (announcementChannel.isEmpty()) {
+ logger.warn(
+ "Unable to send a Top Helper announcement, did not find an announcement channel matching the configured pattern '{}' for guild '{}'",
+ config.getAnnouncementChannelPattern(), guild.getName());
+ event.getHook()
+ .editOriginal(event.getMessage()
+ + "\nβ Sorry, something went wrong trying to post the announcement.")
+ .setComponents()
+ .queue();
+ return;
+ }
+
+ // For fairness Top Helpers in the announcement are ordered randomly
+ Collections.shuffle(topHelpers);
+
+ String topHelperList = topHelpers.stream()
+ .map(Member::getAsMention)
+ .map(mention -> "* " + mention)
+ .collect(Collectors.joining("\n"));
+ TopHelpersService.TimeRange timeRange = TopHelpersService.TimeRange.ofPreviousMonth();
+ String announcement = "Thanks to the Top Helpers of %s π%n%s"
+ .formatted(timeRange.description(), topHelperList);
+
+ announcementChannel.orElseThrow().sendMessage(announcement).queue();
+
+ reportFlowFinished(event, "posted an announcement");
+ }
+
+ private void reportFlowFinished(ComponentInteraction event, String phrase) {
+ String content = "%s%nβ
Okay, %s. See you next time π"
+ .formatted(event.getMessage().getContentRaw(), phrase);
+ event.getHook().editOriginal(content).setComponents().queue();
+ }
+}
diff --git a/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersCommand.java
index 04295920e1..6edcff5cce 100644
--- a/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersCommand.java
+++ b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersCommand.java
@@ -1,49 +1,34 @@
package org.togetherjava.tjbot.features.tophelper;
-import com.github.freva.asciitable.AsciiTable;
-import com.github.freva.asciitable.Column;
-import com.github.freva.asciitable.ColumnData;
-import com.github.freva.asciitable.HorizontalAlign;
+import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
import net.dv8tion.jda.api.interactions.callbacks.IDeferrableCallback;
import net.dv8tion.jda.api.interactions.commands.OptionMapping;
import net.dv8tion.jda.api.interactions.commands.OptionType;
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
-import org.jooq.Records;
-import org.jooq.impl.DSL;
+import net.dv8tion.jda.api.interactions.commands.build.SubcommandData;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import org.togetherjava.tjbot.db.Database;
import org.togetherjava.tjbot.features.CommandVisibility;
import org.togetherjava.tjbot.features.SlashCommandAdapter;
-import org.togetherjava.tjbot.features.utils.MessageUtils;
import javax.annotation.Nullable;
-import java.math.BigDecimal;
import java.time.Instant;
-import java.time.LocalTime;
import java.time.Month;
-import java.time.YearMonth;
import java.time.ZoneOffset;
-import java.time.ZonedDateTime;
import java.time.format.TextStyle;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
-import java.util.Map;
-import java.util.function.Function;
-import java.util.function.IntFunction;
-import java.util.stream.Collectors;
-import java.util.stream.IntStream;
-
-import static org.togetherjava.tjbot.db.generated.tables.HelpChannelMessages.HELP_CHANNEL_MESSAGES;
+import java.util.Objects;
/**
- * Command that displays the top helpers of a given time range.
+ * Command that displays the top helpers of a given time range. Can also be used to start a
+ * semi-automatic flow to assign Top Helpers.
*
* Top helpers are measured by their message length in help channels, as set by
* {@link TopHelpersMessageListener}.
@@ -51,39 +36,58 @@
public final class TopHelpersCommand extends SlashCommandAdapter {
private static final Logger logger = LoggerFactory.getLogger(TopHelpersCommand.class);
private static final String COMMAND_NAME = "top-helpers";
+ private static final String SUBCOMMAND_SHOW_NAME = "show";
+ private static final String SUBCOMMAND_ASSIGN_NAME = "assign";
private static final String MONTH_OPTION = "at-month";
- private static final int TOP_HELPER_LIMIT = 18;
-
- private static final int MAX_USER_NAME_LIMIT = 15;
- private final Database database;
+ private final TopHelpersService service;
+ private final TopHelpersAssignmentRoutine assignmentRoutine;
/**
* Creates a new instance.
*
- * @param database the database containing the message records of top helpers
+ * @param service the service that can compute top helpers
+ * @param assignmentRoutine the routine that can assign top helpers automatically
*/
- public TopHelpersCommand(Database database) {
- super(COMMAND_NAME, "Lists top helpers for the last month, or a given month",
- CommandVisibility.GUILD);
+ public TopHelpersCommand(TopHelpersService service,
+ TopHelpersAssignmentRoutine assignmentRoutine) {
+ super(COMMAND_NAME, "Manages top helpers", CommandVisibility.GUILD);
OptionData monthData = new OptionData(OptionType.STRING, MONTH_OPTION,
"the month to compute for, by default the last month", false);
Arrays.stream(Month.values())
.forEach(month -> monthData.addChoice(
month.getDisplayName(TextStyle.FULL_STANDALONE, Locale.US), month.name()));
- getData().addOptions(monthData);
- this.database = database;
+ var showCommand = new SubcommandData(SUBCOMMAND_SHOW_NAME,
+ "Lists top helpers for the last month, or a given month")
+ .addOptions(monthData);
+ var assignCommand = new SubcommandData(SUBCOMMAND_ASSIGN_NAME,
+ "Automatically assigns top helpers for the last month");
+ getData().addSubcommands(showCommand, assignCommand);
+
+ this.service = service;
+ this.assignmentRoutine = assignmentRoutine;
}
@Override
public void onSlashCommand(SlashCommandInteractionEvent event) {
+ switch (event.getSubcommandName()) {
+ case SUBCOMMAND_SHOW_NAME -> showTopHelpers(event);
+ case SUBCOMMAND_ASSIGN_NAME -> assignTopHelpers(event);
+ default -> throw new AssertionError(
+ "Unexpected subcommand '%s'".formatted(event.getSubcommandName()));
+ }
+ }
+
+ private void showTopHelpers(SlashCommandInteractionEvent event) {
OptionMapping atMonthData = event.getOption(MONTH_OPTION);
+ Guild guild = Objects.requireNonNull(event.getGuild());
- TimeRange timeRange = computeTimeRange(computeMonth(atMonthData));
- List topHelpers =
- computeTopHelpersDescending(event.getGuild().getIdLong(), timeRange);
+ TopHelpersService.TimeRange timeRange =
+ TopHelpersService.TimeRange.ofPastMonth(computeMonth(atMonthData));
+ List topHelpers =
+ service.computeTopHelpersDescending(guild, timeRange);
if (topHelpers.isEmpty()) {
event
@@ -94,13 +98,18 @@ public void onSlashCommand(SlashCommandInteractionEvent event) {
}
event.deferReply().queue();
- List topHelperIds = topHelpers.stream().map(TopHelperResult::authorId).toList();
- event.getGuild()
- .retrieveMembersByIds(topHelperIds)
+ TopHelpersService.retrieveTopHelperMembers(topHelpers, guild)
.onError(error -> handleError(error, event))
.onSuccess(members -> handleTopHelpers(topHelpers, members, timeRange, event));
}
+ private void assignTopHelpers(SlashCommandInteractionEvent event) {
+ event.reply("Automatic Top Helper assignment dialog has started")
+ .setEphemeral(true)
+ .queue();
+ assignmentRoutine.startDialogFor(Objects.requireNonNull(event.getGuild()));
+ }
+
private static Month computeMonth(@Nullable OptionMapping atMonthData) {
if (atMonthData != null) {
return Month.valueOf(atMonthData.getAsString());
@@ -110,99 +119,20 @@ private static Month computeMonth(@Nullable OptionMapping atMonthData) {
return Instant.now().atZone(ZoneOffset.UTC).minusMonths(1).getMonth();
}
- private static TimeRange computeTimeRange(Month atMonth) {
- ZonedDateTime now = Instant.now().atZone(ZoneOffset.UTC);
-
- int atYear = now.getYear();
- // E.g. using November, while it is March 2022, should use November 2021
- if (atMonth.compareTo(now.getMonth()) > 0) {
- atYear--;
- }
- YearMonth atYearMonth = YearMonth.of(atYear, atMonth);
-
- Instant start = atYearMonth.atDay(1).atTime(LocalTime.MIN).toInstant(ZoneOffset.UTC);
- Instant end = atYearMonth.atEndOfMonth().atTime(LocalTime.MAX).toInstant(ZoneOffset.UTC);
- String description = "%s %d"
- .formatted(atMonth.getDisplayName(TextStyle.FULL_STANDALONE, Locale.US), atYear);
-
- return new TimeRange(start, end, description);
- }
-
- private List computeTopHelpersDescending(long guildId, TimeRange timeRange) {
- return database.read(context -> context
- .select(HELP_CHANNEL_MESSAGES.AUTHOR_ID, DSL.sum(HELP_CHANNEL_MESSAGES.MESSAGE_LENGTH))
- .from(HELP_CHANNEL_MESSAGES)
- .where(HELP_CHANNEL_MESSAGES.GUILD_ID.eq(guildId)
- .and(HELP_CHANNEL_MESSAGES.SENT_AT.between(timeRange.start(), timeRange.end())))
- .groupBy(HELP_CHANNEL_MESSAGES.AUTHOR_ID)
- .orderBy(DSL.two().desc())
- .limit(TOP_HELPER_LIMIT)
- .fetch(Records.mapping(TopHelperResult::new)));
- }
-
private static void handleError(Throwable error, IDeferrableCallback event) {
logger.warn("Failed to compute top-helpers", error);
event.getHook().editOriginal("Sorry, something went wrong.").queue();
}
- private static void handleTopHelpers(Collection topHelpers,
- Collection extends Member> members, TimeRange timeRange, IDeferrableCallback event) {
- Map userIdToMember =
- members.stream().collect(Collectors.toMap(Member::getIdLong, Function.identity()));
-
- List> topHelpersDataTable = topHelpers.stream()
- .map(topHelper -> topHelperToDataRow(topHelper,
- userIdToMember.get(topHelper.authorId())))
- .toList();
-
+ private void handleTopHelpers(Collection topHelpers,
+ Collection extends Member> members, TopHelpersService.TimeRange timeRange,
+ IDeferrableCallback event) {
String message = """
```java
// for %s
%s
- ```""".formatted(timeRange.description(), dataTableToString(topHelpersDataTable));
-
+ ```""".formatted(timeRange.description(),
+ TopHelpersService.asAsciiTableWithIds(topHelpers, members));
event.getHook().editOriginal(message).queue();
}
-
- private static List topHelperToDataRow(TopHelperResult topHelper,
- @Nullable Member member) {
- String id = Long.toString(topHelper.authorId());
- String name = MessageUtils.abbreviate(
- member == null ? "UNKNOWN_USER" : member.getEffectiveName(), MAX_USER_NAME_LIMIT);
- String messageLengths = Long.toString(topHelper.messageLengths().longValue());
-
- return List.of(id, name, messageLengths);
- }
-
- private static String dataTableToString(Collection> dataTable) {
- return dataTableToAsciiTable(dataTable,
- List.of(new ColumnSetting("Id", HorizontalAlign.RIGHT),
- new ColumnSetting("Name", HorizontalAlign.RIGHT),
- new ColumnSetting("Message lengths", HorizontalAlign.RIGHT)));
- }
-
- private static String dataTableToAsciiTable(Collection> dataTable,
- List columnSettings) {
- IntFunction headerToAlignment = i -> columnSettings.get(i).headerName();
- IntFunction indexToAlignment = i -> columnSettings.get(i).alignment();
-
- IntFunction>> indexToColumn =
- i -> new Column().header(headerToAlignment.apply(i))
- .dataAlign(indexToAlignment.apply(i))
- .with(row -> row.get(i));
-
- List>> columns =
- IntStream.range(0, columnSettings.size()).mapToObj(indexToColumn).toList();
-
- return AsciiTable.getTable(AsciiTable.BASIC_ASCII_NO_DATA_SEPARATORS, dataTable, columns);
- }
-
- private record TimeRange(Instant start, Instant end, String description) {
- }
-
- private record TopHelperResult(long authorId, BigDecimal messageLengths) {
- }
-
- private record ColumnSetting(String headerName, HorizontalAlign alignment) {
- }
}
diff --git a/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersService.java b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersService.java
new file mode 100644
index 0000000000..73cba41b02
--- /dev/null
+++ b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersService.java
@@ -0,0 +1,261 @@
+package org.togetherjava.tjbot.features.tophelper;
+
+import com.github.freva.asciitable.AsciiTable;
+import com.github.freva.asciitable.Column;
+import com.github.freva.asciitable.ColumnData;
+import com.github.freva.asciitable.HorizontalAlign;
+import net.dv8tion.jda.api.entities.Guild;
+import net.dv8tion.jda.api.entities.Member;
+import net.dv8tion.jda.api.utils.concurrent.Task;
+import org.jooq.Records;
+import org.jooq.impl.DSL;
+
+import org.togetherjava.tjbot.db.Database;
+import org.togetherjava.tjbot.features.utils.MessageUtils;
+
+import javax.annotation.Nullable;
+
+import java.math.BigDecimal;
+import java.time.Instant;
+import java.time.LocalTime;
+import java.time.Month;
+import java.time.YearMonth;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
+import java.time.format.TextStyle;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.function.IntFunction;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+import static org.togetherjava.tjbot.db.generated.tables.HelpChannelMessages.HELP_CHANNEL_MESSAGES;
+
+/**
+ * Service used to compute Top Helpers of a given time range, see
+ * {@link #computeTopHelpersDescending(Guild, TimeRange)}.
+ *
+ * Also offers utility to process or display Top Helper results.
+ */
+public final class TopHelpersService {
+ private static final int TOP_HELPER_LIMIT = 18;
+ private static final int MAX_USER_NAME_LIMIT = 15;
+
+ private final Database database;
+
+ /**
+ * Creates a new instance.
+ *
+ * @param database the database containing the message records of top helpers
+ */
+ public TopHelpersService(Database database) {
+ this.database = database;
+ }
+
+ /**
+ * Stats for a single Top Helper, computed for a specific time range. See
+ * {@link #computeTopHelpersDescending(Guild, TimeRange)}.
+ *
+ * @param authorId ID of the Top Helper
+ * @param messageLengths lengths of messages send in the time range, the more, the better
+ */
+ public record TopHelperStats(long authorId, BigDecimal messageLengths) {
+ }
+
+
+ /**
+ * Represents a time range with a defined start and end.
+ *
+ * @param start the inclusive start of the range
+ * @param end the exclusive end of the range
+ * @param description used for visual representation e.g., 'July 2025'
+ */
+ public record TimeRange(Instant start, Instant end, String description) {
+ /**
+ * Creates a time range representing the previous month (assuming UTC). For example if the
+ * current month is April, this will return a time range for March.
+ *
+ * @return time range for the previous month
+ */
+ public static TimeRange ofPreviousMonth() {
+ Month previousMonth = Instant.now().atZone(ZoneOffset.UTC).minusMonths(1).getMonth();
+ return TimeRange.ofPastMonth(previousMonth);
+ }
+
+ /**
+ * Creates a time range representing the given month (assuming UTC). If the month lies in
+ * the future for the current year, the past year will be used instead. For example, if the
+ * current month is April 2025:
+ *
+ * {@code ofPastMonth(Month.APRIL)} returns April 2025
+ * {@code ofPastMonth(Month.MARCH)} returns March 2025
+ * {@code ofPastMonth(Month.JUNE)} returns June 2024
+ *
+ *
+ * @param atMonth the month to represent
+ * @return Time range representing the given month, either for the current or the previous
+ * year.
+ */
+ public static TimeRange ofPastMonth(Month atMonth) {
+ ZonedDateTime now = Instant.now().atZone(ZoneOffset.UTC);
+
+ int atYear = now.getYear();
+ // E.g. using November, while it is March 2022, should use November 2021
+ if (atMonth.compareTo(now.getMonth()) > 0) {
+ atYear--;
+ }
+ YearMonth atYearMonth = YearMonth.of(atYear, atMonth);
+
+ Instant start = atYearMonth.atDay(1).atTime(LocalTime.MIN).toInstant(ZoneOffset.UTC);
+ Instant end =
+ atYearMonth.atEndOfMonth().atTime(LocalTime.MAX).toInstant(ZoneOffset.UTC);
+ String description = "%s %d"
+ .formatted(atMonth.getDisplayName(TextStyle.FULL_STANDALONE, Locale.US), atYear);
+
+ return new TimeRange(start, end, description);
+ }
+ }
+
+ /**
+ * Computes the Top Helpers of the given time range.
+ *
+ * @param guild to compute Top Helpers for
+ * @param range of the time to compute results for
+ * @return list of top helpers, descending with the user who helped the most first
+ */
+ public List computeTopHelpersDescending(Guild guild, TimeRange range) {
+ return database.read(context -> context
+ .select(HELP_CHANNEL_MESSAGES.AUTHOR_ID, DSL.sum(HELP_CHANNEL_MESSAGES.MESSAGE_LENGTH))
+ .from(HELP_CHANNEL_MESSAGES)
+ .where(HELP_CHANNEL_MESSAGES.GUILD_ID.eq(guild.getIdLong())
+ .and(HELP_CHANNEL_MESSAGES.SENT_AT.between(range.start(), range.end())))
+ .groupBy(HELP_CHANNEL_MESSAGES.AUTHOR_ID)
+ .orderBy(DSL.two().desc())
+ .limit(TOP_HELPER_LIMIT)
+ .fetch(Records.mapping(TopHelperStats::new)));
+ }
+
+ /**
+ * Retrieves the member-data to a given list of top helpers.
+ *
+ * @param topHelpers the list of top helpers to retrieve member-data for
+ * @param guild the guild the top helpers are members of
+ * @return list of member-data for each top helper. If a Top helper is not member of the guild
+ * anymore, it will not be contained in the list of members. Also, the order is not
+ * given and in particular does not have to line up with the original order of Top
+ * Helpers.
+ */
+ public static Task> retrieveTopHelperMembers(List topHelpers,
+ Guild guild) {
+ List topHelperIds = topHelpers.stream().map(TopHelperStats::authorId).toList();
+ return guild.retrieveMembersByIds(topHelperIds);
+ }
+
+ /**
+ * Visual representation of the given Top Helpers as ASCII table. The table includes columns ID,
+ * Name and Message lengths.
+ *
+ * @param topHelpers the list of top helpers to represent
+ * @param members the list of available member-data, for example given by
+ * {@link #retrieveTopHelperMembers(List, Guild)}
+ * @return ASCII table representing the Top Helpers
+ */
+ public static String asAsciiTableWithIds(Collection topHelpers,
+ Collection extends Member> members) {
+ return asAsciiTable(topHelpers, members, true);
+ }
+
+ /**
+ * Visual representation of the given Top Helpers as ASCII table. The table includes columns
+ * Name and Message lengths.
+ *
+ * @param topHelpers the list of top helpers to represent
+ * @param members the list of available member-data, for example given by
+ * {@link #retrieveTopHelperMembers(List, Guild)}
+ * @return ASCII table representing the Top Helpers
+ */
+ public static String asAsciiTable(Collection topHelpers,
+ Collection extends Member> members) {
+ return asAsciiTable(topHelpers, members, false);
+ }
+
+ private static String asAsciiTable(Collection topHelpers,
+ Collection extends Member> members, boolean includeIds) {
+ Map userIdToMember = mapUserIdToMember(members);
+
+ List> topHelpersDataTable = topHelpers.stream()
+ .map(topHelper -> topHelperToDataRow(topHelper,
+ userIdToMember.get(topHelper.authorId()), includeIds))
+ .toList();
+
+ return dataTableToString(topHelpersDataTable, includeIds);
+ }
+
+ /**
+ * Given a list of members, maps them by their user ID.
+ *
+ * @param members the members to map
+ * @return a map of user ID to corresponding member
+ */
+ public static Map mapUserIdToMember(Collection extends Member> members) {
+ return members.stream().collect(Collectors.toMap(Member::getIdLong, Function.identity()));
+ }
+
+ /**
+ * Visual representation of the given members name as it should be used for display purposes.
+ *
+ * @param member to get name of, {@code null} will be represented with a placeholder
+ * @return name of the user for display purposes
+ */
+ public static String getUsernameDisplay(@Nullable Member member) {
+ return MessageUtils.abbreviate(member == null ? "UNKNOWN_USER" : member.getEffectiveName(),
+ MAX_USER_NAME_LIMIT);
+ }
+
+ private static List topHelperToDataRow(TopHelperStats topHelper,
+ @Nullable Member member, boolean includeIds) {
+ String id = Long.toString(topHelper.authorId());
+ String name = getUsernameDisplay(member);
+ String messageLengths = Long.toString(topHelper.messageLengths().longValue());
+
+ if (includeIds) {
+ return List.of(id, name, messageLengths);
+ } else {
+ return List.of(name, messageLengths);
+ }
+ }
+
+ private static String dataTableToString(Collection> dataTable,
+ boolean includeIds) {
+ List settings = new ArrayList<>();
+ if (includeIds) {
+ settings.add(new ColumnSetting("ID", HorizontalAlign.RIGHT));
+ }
+ settings.add(new ColumnSetting("Name", HorizontalAlign.RIGHT));
+ settings.add(new ColumnSetting("Message lengths", HorizontalAlign.RIGHT));
+ return dataTableToAsciiTable(dataTable, settings);
+ }
+
+ private static String dataTableToAsciiTable(Collection> dataTable,
+ List columnSettings) {
+ IntFunction headerToAlignment = i -> columnSettings.get(i).headerName();
+ IntFunction indexToAlignment = i -> columnSettings.get(i).alignment();
+
+ IntFunction>> indexToColumn =
+ i -> new Column().header(headerToAlignment.apply(i))
+ .dataAlign(indexToAlignment.apply(i))
+ .with(row -> row.get(i));
+
+ List>> columns =
+ IntStream.range(0, columnSettings.size()).mapToObj(indexToColumn).toList();
+
+ return AsciiTable.getTable(AsciiTable.BASIC_ASCII_NO_DATA_SEPARATORS, dataTable, columns);
+ }
+
+ private record ColumnSetting(String headerName, HorizontalAlign alignment) {
+ }
+}
diff --git a/application/src/main/java/org/togetherjava/tjbot/features/tophelper/package-info.java b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/package-info.java
index 3f8aa7dff5..c509f9aa23 100644
--- a/application/src/main/java/org/togetherjava/tjbot/features/tophelper/package-info.java
+++ b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/package-info.java
@@ -1,5 +1,5 @@
/**
- * This packages offers all the functionality for the top-helpers command system. The core class is
+ * This packages offers all the functionality for the top-helpers system. The core class is
* {@link org.togetherjava.tjbot.features.tophelper.TopHelpersCommand}.
*/
@MethodsReturnNonnullByDefault
diff --git a/application/src/main/java/org/togetherjava/tjbot/features/utils/Guilds.java b/application/src/main/java/org/togetherjava/tjbot/features/utils/Guilds.java
new file mode 100644
index 0000000000..9f5e86d534
--- /dev/null
+++ b/application/src/main/java/org/togetherjava/tjbot/features/utils/Guilds.java
@@ -0,0 +1,90 @@
+package org.togetherjava.tjbot.features.utils;
+
+import net.dv8tion.jda.api.entities.Guild;
+import net.dv8tion.jda.api.entities.Member;
+import net.dv8tion.jda.api.entities.Role;
+import net.dv8tion.jda.api.entities.channel.attribute.IGuildChannelContainer;
+import net.dv8tion.jda.api.entities.channel.concrete.ForumChannel;
+import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
+import net.dv8tion.jda.api.entities.channel.middleman.GuildChannel;
+
+import java.util.Optional;
+import java.util.function.Predicate;
+
+/**
+ * Utility methods for working with {@link Guild}s.
+ *
+ * This class is meant to contain all utility methods for {@link Guild}s that can be used on all
+ * other commands to avoid similar methods appearing everywhere.
+ */
+public final class Guilds {
+ private Guilds() {
+ throw new UnsupportedOperationException("Utility class, construction not supported");
+ }
+
+ /**
+ * Finds any role in the guild whose name matches the given predicate.
+ *
+ * @param guild guild to search the role in
+ * @param isRoleName a predicate matching the name of the role to search
+ * @return the matched role, if any
+ */
+ public static Optional findRole(Guild guild, Predicate super String> isRoleName) {
+ return guild.getRoles().stream().filter(role -> isRoleName.test(role.getName())).findAny();
+ }
+
+ /**
+ * Checks whether a given member has a role whose name matches a given predicate.
+ *
+ * @param member the member to check
+ * @param isRoleName a predicate matching the name of the role to search
+ * @return {@code true} if the member has a matching role, {@code false} otherwise
+ * @see #doesMemberNotHaveRole(Member, Predicate)
+ */
+ public static boolean hasMemberRole(Member member, Predicate super String> isRoleName) {
+ return member.getRoles().stream().map(Role::getName).anyMatch(isRoleName);
+ }
+
+ /**
+ * Checks whether a given member does not have a role whose name matches a given predicate.
+ *
+ * @param member the member to check
+ * @param isRoleName a predicate matching the name of the role to search
+ * @return {@code true} if the member does not have any matching role, {@code false} otherwise
+ * @see #hasMemberRole(Member, Predicate)
+ */
+ public static boolean doesMemberNotHaveRole(Member member,
+ Predicate super String> isRoleName) {
+ return member.getRoles().stream().map(Role::getName).noneMatch(isRoleName);
+ }
+
+ /**
+ * Finds any text channel in the guild whose name matches the given predicate.
+ *
+ * @param guild guild to search the channel in
+ * @param isChannelName a predicate matching the name of the text channel to search
+ * @return the matched text channel, if any
+ */
+ public static Optional findTextChannel(IGuildChannelContainer guild,
+ Predicate super String> isChannelName) {
+ return guild.getTextChannelCache()
+ .stream()
+ .filter(channel -> isChannelName.test(channel.getName()))
+ .findAny();
+ }
+
+ /**
+ * Finds any forum channel in the guild whose name matches the given predicate.
+ *
+ * @param guild guild to search the channel in
+ * @param isChannelName a predicate matching the name of the forum channel to search
+ * @return the matched forum channel, if any
+ */
+ public static Optional findForumChannel(
+ IGuildChannelContainer guild, Predicate super String> isChannelName) {
+ return guild.getForumChannelCache()
+ .stream()
+ .filter(channel -> isChannelName.test(channel.getName()))
+ .findAny();
+ }
+}
diff --git a/application/src/main/java/org/togetherjava/tjbot/features/utils/MessageUtils.java b/application/src/main/java/org/togetherjava/tjbot/features/utils/MessageUtils.java
index 615e379a3c..89cc140549 100644
--- a/application/src/main/java/org/togetherjava/tjbot/features/utils/MessageUtils.java
+++ b/application/src/main/java/org/togetherjava/tjbot/features/utils/MessageUtils.java
@@ -19,7 +19,7 @@
* This class is meant to contain all utility methods for {@link Message} that can be used on all
* other commands to avoid similar methods appearing everywhere.
*/
-public class MessageUtils {
+public final class MessageUtils {
public static final int MAXIMUM_VISIBLE_EMBEDS = 25;
public static final String ABBREVIATION = "...";
private static final String CODE_FENCE_SYMBOL = "```";
diff --git a/build.gradle b/build.gradle
index da03a76d96..272e5fffec 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,8 +1,8 @@
plugins {
id 'java'
id "com.diffplug.spotless" version "7.2.0"
- id "org.sonarqube" version "6.2.0.5505"
- id "name.remal.sonarlint" version "5.1.10"
+ id "org.sonarqube" version "6.3.1.5724"
+ id "name.remal.sonarlint" version "6.0.0"
}
repositories {
mavenCentral()
diff --git a/database/build.gradle b/database/build.gradle
index be4adf2b9d..46b20ac384 100644
--- a/database/build.gradle
+++ b/database/build.gradle
@@ -7,7 +7,7 @@ var sqliteVersion = "3.50.1.0"
dependencies {
implementation 'com.google.code.findbugs:jsr305:3.0.2'
implementation "org.xerial:sqlite-jdbc:${sqliteVersion}"
- implementation 'org.flywaydb:flyway-core:11.10.0'
+ implementation 'org.flywaydb:flyway-core:11.11.0'
implementation "org.jooq:jooq:$jooqVersion"
implementation project(':utils')
diff --git a/wiki/Access-the-VPS.md b/wiki/Access-the-VPS.md
new file mode 100644
index 0000000000..1e0d96965f
--- /dev/null
+++ b/wiki/Access-the-VPS.md
@@ -0,0 +1,28 @@
+# Overview
+
+The bot is hosted on a Virtual Private Server (VPS) by [Hetzner](https://www.hetzner.com/). The machine can be reached under the DDNS `togetherjava.duckdns.org`.
+
+Access to it is usually granted only to members of the [Moderator-Team](https://github.com/orgs/Together-Java/teams/moderators).
+
+# Guide
+
+In order to get access to the machine, the following steps have to be followed:
+
+1. Generate a private-/public-key pair for [SSH](https://en.wikipedia.org/wiki/Secure_Shell). This can be done by executing the following command:
+```batch
+ssh-keygen -t ed25519 -C "your_email@address.here" -f ~/.ssh/together-java-vps
+```
+2. Put the key pair into your `.ssh` folder (Windows `C:\Users\YourUserNameHere\.ssh`, Linux `~/.ssh`)
+3. Give the **public** key to someone who has access already
+ 3.1. The person has to add your public key to the file `~/.ssh/authorized_keys`
+4. Add the following entry to your `.ssh/config` file:
+```
+Host togetherjava
+HostName togetherjava.duckdns.org
+IdentityFile ~/.ssh/together-java-vps
+User root
+Port 22
+```
+5. Connect to the machine by using the command `ssh togetherjava`, you should get a response similar to:
+
+6. Congrats :tada:, you are now logged in. Once you are done, close the connection using `logout`.
\ No newline at end of file
diff --git a/wiki/Add-a-new-command.md b/wiki/Add-a-new-command.md
new file mode 100644
index 0000000000..890b226c28
--- /dev/null
+++ b/wiki/Add-a-new-command.md
@@ -0,0 +1,83 @@
+# Overview
+
+This tutorial shows how to add custom commands to the bot.
+
+## Prerequisites
+* [[Setup project locally]]
+ * you can run the bot locally from your IDE and connect it to a server
+
+## What you will learn
+* the basic architecture of the code
+* how the command system works
+* how to add your own custom command
+* basics of JDA, used to communicate with Discord
+* basics of jOOQ, used to interact with databases
+* basics of SLF4J, used for logging
+
+# Tutorial
+
+## Code architecture
+
+Before we get started, we have to familiarize with the general code structure.
+
+
+
+The entry point of the bot is `Application`, which will first create instances of:
+* `Config`, which provides several properties read from a configuration file
+* `Database`, a general purpose database used by the bot and its commands
+* `JDA`, the main instance of the framework used to communicate with Discord
+
+The `Config` is available to everyone from everywhere, it is a global singleton. You can just write `Config.getInstance()` and then use its properties. The `Database` is available to all commands, also for your custom command. You can read and write any data to it. From within a command, the `JDA` instance will also be available at any time. Almost all JDA objects, such as the events, provide a `getJDA()` method.
+
+Next, the application will setup the command system.
+
+## Command system
+
+The command system is based around the class `CommandSystem`, which is registered as command handler to `JDA`. It receives all command events from JDA and forwards them to the corresponding registered commands.
+
+Custom commands are added to the `Commands` class, where `CommandSystem` will fetch them by using its `createSlashCommands` method, also providing the database instance. This method could for example look like:
+```java
+public static Collection createSlashCommands(Database database) {
+ return List.of(new PingCommand(), new DatabaseCommand(database));
+}
+```
+As an example, when someone uses the `/ping` command, the event will be send to `CommandSystem` by JDA, which will then forward it to the `PingCommand` class.
+
+
+
+Commands have to implement the `SlashCommand` interface. Besides metadata (e.g. a name) and the command setup provided by `getData()`, it mostly demands implementation of the event action handlers:
+* `onSlashCommand`
+* `onButtonClick`
+* `onSelectionMenu`
+
+It is also possible to extend `SlashCommandAdapter` which already implemented all methods besides `onSlashCommand`.
+
+Therefore, a minimal example command, could look like:
+```java
+public final class PingCommand extends SlashCommandAdapter {
+ public PingCommand() {
+ super("ping", "Bot responds with 'Pong!'", SlashCommandVisibility.GUILD);
+ }
+
+ @Override
+ public void onSlashCommand(@NotNull SlashCommandEvent event) {
+ event.reply("Pong!").queue();
+ }
+}
+```
+
+## Add your own commands
+
+In the following, we will add two custom commands to the application:
+* `/days `
+ * computes the difference in days between the given dates
+ * e.g. `/days 26.09.2021 03.10.2021` will respond with `7 days`
+* `/question ask `, `/question get `
+ * asks a question and users can click a `Yes` or `No` button
+ * the choice will be saved in the database from which it can be retrieved using the `get` subcommand
+ * e.g. `/question ask "noodles" "Do you like noodles?"` and `/question get "noodles"`
+
+Please refer to
+* [[Add days command]],
+* [[Add question command]] and
+* [[Adding context commands]] respectively.
\ No newline at end of file
diff --git a/wiki/Add-days-command.md b/wiki/Add-days-command.md
new file mode 100644
index 0000000000..e2f06a5c9b
--- /dev/null
+++ b/wiki/Add-days-command.md
@@ -0,0 +1,202 @@
+# Overview
+
+This tutorial shows how to add a custom command, the `days` command:
+* `/days `
+ * computes the difference in days between the given dates
+ * e.g. `/days 26.09.2021 03.10.2021` will respond with `7 days`
+
+Please read [[Add a new command]] first.
+
+## What you will learn
+* add a custom command
+* reply to messages
+* add options (arguments) to a command
+* ephemeral messages (only visible to one user)
+* compute the difference in days between two dates
+
+# Tutorial
+
+## Create class
+
+To get started, we have to create a new class, such as `DaysCommand`. A good place for it would be in the `org.togetherjava.tjbot.commands` package. Maybe in a new subpackage or just in the existing `org.togetherjava.tjbot.commands.base` package.
+
+The class has to implement `SlashCommand`, or alternatively just extend `SlashCommandAdapter` which gets most of the work done already. For latter, we have to add a constructor that provides a `name`, a `description` and the command `visibility`. Also, we have to implement the `onSlashCommand` method, which will be called by the system when `/days` was triggered by an user. To get started, we will just respond with `Hello World`. Our first version of this class looks like:
+```java
+package org.togetherjava.tjbot.commands.basic;
+
+import net.dv8tion.jda.api.events.interaction.SlashCommandEvent;
+import org.jetbrains.annotations.NotNull;
+import org.togetherjava.tjbot.commands.SlashCommandAdapter;
+import org.togetherjava.tjbot.commands.SlashCommandVisibility;
+
+public final class DaysCommand extends SlashCommandAdapter {
+
+ public DaysCommand() {
+ super("days", "Computes the difference in days between given dates", SlashCommandVisibility.GUILD);
+ }
+
+ @Override
+ public void onSlashCommand(@NotNull SlashCommandEvent event) {
+ event.reply("Hello World!").queue();
+ }
+}
+```
+## Register command
+
+Next up, we have to register the command in the command system. Therefore, we open the `Commands` class (in package `org.togetherjava.tjbot.commands`) and simply append an instance of our new command to the `createSlashCommands` method. For example:
+```java
+public static @NotNull Collection createSlashCommands(@NotNull Database database) {
+ return List.of(new PingCommand(), new DatabaseCommand(database), new DaysCommand());
+}
+```
+## Try it out
+
+The command is now ready and can already be used.
+
+After starting up the bot, we have to use `/reload` to tell Discord that we changed the slash-commands. To be precise, you have to use `/reload` each time you change the commands signature. That is mostly whenever you add or remove commands, change their names or descriptions or anything related to their `CommandData`.
+
+Now, we can use `/days` and it will respond with `"Hello World!"`.
+
+
+
+## Add options
+
+The next step is to add the two options to our command, i.e. being able to write something like `/days 26.09.2021 03.10.2021`. The options are both supposed to be **required**.
+
+This has to be configured during the setup of the command, via the `CommandData` returned by `getData()`. We should do this in the constructor of our command. Like so:
+```java
+public DaysCommand() {
+ super("days", "Computes the difference in days between given dates",
+ SlashCommandVisibility.GUILD);
+
+ getData().addOption(OptionType.STRING, "from",
+ "the start date, in the format 'dd.MM.yyyy'", true)
+ .addOption(OptionType.STRING, "to",
+ "the end date, in the format 'dd.MM.yyyy'", true);
+}
+```
+For starters, let us try to respond back with both entered values instead of just writing `"Hello World!"`. Therefore, in `onSlashCommand`, we retrieve the entered values using `event.getOption(...)`, like so:
+```java
+@Override
+public void onSlashCommand(@NotNull SlashCommandEvent event) {
+ String from = event.getOption("from").getAsString();
+ String to = event.getOption("to").getAsString();
+
+ event.reply(from + ", " + to).queue();
+}
+```
+
+If we restart the bot, pop `/reload` again (since we added options to the command), we should now be able to enter two values and the bot will respond back with them:
+
+
+
+
+## Date validation
+
+The bot still allows us to enter any string we want. While it is not possible to restrict the input directly in the dialog box, we can easily refuse any invalid input and respond back with an error message instead. We can also use `setEphemeral(true)` on the `reply`, to make the error message only appear to the user who triggered the command.
+
+All in all, the code for the method now looks like:
+```java
+String from = event.getOption("from").getAsString();
+String to = event.getOption("to").getAsString();
+
+DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd.MM.yyyy");
+try {
+ LocalDate fromDate = LocalDate.parse(from, formatter);
+ LocalDate toDate = LocalDate.parse(to, formatter);
+
+ event.reply(from + ", " + to).queue();
+} catch (DateTimeParseException e) {
+ event.reply("The dates must be in the format 'dd.MM.yyyy', try again.")
+ .setEphemeral(true)
+ .queue();
+}
+```
+For trying it out, we do not have to use `/reload` again, since we only changed our logic but not the command structure itself.
+
+
+
+## Compute days
+
+Now that we have two valid dates, we only have to compute the difference in days and respond back with the result. Luckily, the `java.time` API got us covered, we can simply use `ChronoUnit.DAYS.between(fromDate, toDate)`:
+```java
+long days = ChronoUnit.DAYS.between(fromDate, toDate);
+event.reply(days + " days").queue();
+```
+
+
+
+## Full code
+
+After some cleanup and minor code improvements, the full code for `DaysCommand` is:
+```java
+package org.togetherjava.tjbot.commands.basic;
+
+import net.dv8tion.jda.api.events.interaction.SlashCommandEvent;
+import net.dv8tion.jda.api.interactions.commands.OptionType;
+import org.jetbrains.annotations.NotNull;
+import org.togetherjava.tjbot.commands.SlashCommandAdapter;
+import org.togetherjava.tjbot.commands.SlashCommandVisibility;
+
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.time.temporal.ChronoUnit;
+import java.util.Objects;
+
+/**
+ * This creates a command called {@code /days}, which can calculate the difference between two given
+ * dates in days.
+ *
+ * For example:
+ *
+ *
+ * {@code
+ * /days from: 26.09.2021 to: 03.10.2021
+ * // TJ-Bot: The difference between 26.09.2021 and 03.10.2021 are 7 days
+ * }
+ *
+ */
+public final class DaysCommand extends SlashCommandAdapter {
+ private static final String FROM_OPTION = "from";
+ private static final String TO_OPTION = "to";
+ private static final String FORMAT = "dd.MM.yyyy";
+ private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern(FORMAT);
+
+ /**
+ * Creates an instance of the command.
+ */
+ public DaysCommand() {
+ super("days", "Computes the difference in days between given dates",
+ SlashCommandVisibility.GUILD);
+
+ getData()
+ .addOption(OptionType.STRING, FROM_OPTION, "the start date, in the format '"
+ + FORMAT + "'", true)
+ .addOption(OptionType.STRING, TO_OPTION, "the end date, in the format '"
+ + FORMAT + "'", true);
+ }
+
+ @Override
+ public void onSlashCommand(@NotNull SlashCommandEvent event) {
+ String from = Objects.requireNonNull(event.getOption(FROM_OPTION)).getAsString();
+ String to = Objects.requireNonNull(event.getOption(TO_OPTION)).getAsString();
+
+ LocalDate fromDate;
+ LocalDate toDate;
+ try {
+ fromDate = LocalDate.parse(from, FORMATTER);
+ toDate = LocalDate.parse(to, FORMATTER);
+ } catch (DateTimeParseException e) {
+ event.reply("The dates must be in the format '" + FORMAT + "', try again.")
+ .setEphemeral(true)
+ .queue();
+ return;
+ }
+
+ long days = ChronoUnit.DAYS.between(fromDate, toDate);
+ event.reply("The difference between %s and %s are %d days".formatted(from, to, days))
+ .queue();
+ }
+}
+```
\ No newline at end of file
diff --git a/wiki/Add-question-command.md b/wiki/Add-question-command.md
new file mode 100644
index 0000000000..8f675d8c10
--- /dev/null
+++ b/wiki/Add-question-command.md
@@ -0,0 +1,458 @@
+# Overview
+
+This tutorial shows how to add a custom command, the `question` command:
+* `/question ask `, `/question get `
+ * asks a question and users can click a `Yes` or `No` button
+ * the choice will be saved in the database from which it can be retrieved using the `get` subcommand
+ * e.g. `/question ask "noodles" "Do you like noodles?"` and `/question get "noodles"`
+
+Please read [[Add a new command]] and [[Add days command]] first.
+
+## What you will learn
+* add a custom command
+* reply to messages
+* add sub-commands to a command
+* add options (arguments) to a sub-command
+* ephemeral messages (only visible to one user)
+* add buttons to a message
+* memorize data inside a button
+* react to button click
+* disable buttons from an old message
+* create a new database table and migrate it
+* read and write from/to a database (using Flyway, jOOQ and SQLite)
+* basic logging (using SLF4J)
+
+# Tutorial
+
+## Setup
+
+The next command focuses on how to use sub-commands and a database. We start with the same base setup as before, but this time we need a `Database` argument:
+```java
+public final class QuestionCommand extends SlashCommandAdapter {
+ private final Database database;
+
+ public QuestionCommand(Database database) {
+ super("question", "Asks users questions, responses are saved and can be retrieved back",
+ SlashCommandVisibility.GUILD);
+ this.database = database;
+ }
+
+ @Override
+ public void onSlashCommand(@NotNull SlashCommandEvent event) {
+ event.reply("Hello World!").queue();
+ }
+}
+```
+Also, we again have to register our new command by adding it to the list of commands in the `Commands` class, but this time providing the database instance:
+```java
+public static @NotNull Collection createSlashCommands(@NotNull Database database) {
+ return List.of(new PingCommand(), new DatabaseCommand(database), new QuestionCommand(database));
+}
+```
+
+## Add sub-commands
+
+As first step, we have to add the two sub-commands `ask` and `get` to the command.
+* `ask` expects two options, `id` and `question`,
+* while `get` only expects one option, `id`.
+
+We can configure both, the sub-commands and their options, again via the `CommandData` object returned by `getData()`, which has to be done during construction of the command:
+```java
+public QuestionCommand(Database database) {
+ super("question", "Asks users questions, responses are saved and can be retrieved back",
+ SlashCommandVisibility.GUILD);
+ this.database = database;
+
+ getData().addSubcommands(
+ new SubcommandData("ask", "Asks the users a question, responses will be saved")
+ .addOption(OptionType.STRING, "id", "Unique ID under which the question should be saved", true)
+ .addOption(OptionType.STRING, "question", "Question to ask", true),
+ new SubcommandData("get", "Gets the response to the given question")
+ .addOption(OptionType.STRING, "id", "Unique ID of the question to retrieve", true));
+}
+```
+We can retrieve back the used sub-command using `event.getSubcommandName()`, and the corresponding option values using `event.getOption(...)`. To simplify handling the command, we split them into two helper methods and `switch` on the command name:
+```java
+@Override
+public void onSlashCommand(@NotNull SlashCommandEvent event) {
+ switch (Objects.requireNonNull(event.getSubcommandName())) {
+ case "ask" -> handleAskCommand(event);
+ case "get" -> handleGetCommand(event);
+ default -> throw new AssertionError();
+ }
+}
+
+private void handleAskCommand(@NotNull SlashCommandEvent event) {
+ String id = event.getOption("id").getAsString();
+ String question = event.getOption("question").getAsString();
+
+ event.reply("Ask command: " + id + ", " + question).queue();
+}
+
+private void handleGetCommand(@NotNull SlashCommandEvent event) {
+ String id = event.getOption("id").getAsString();
+
+ event.reply("Get command: " + id).queue();
+}
+```
+
+## Try it out
+
+At this point, we should try out the code. Do not forget to use `/reload` before though. You should now be able to use `/question ask` with two required options and `/question get` with only one required option. And the bot should respond back correspondly.
+
+
+
+## `Ask` sub-command
+
+### Add buttons
+
+Instead of just writing down a question, we also want to give the user the opportunity to respond by clicking one of two buttons. This can be done by using `.addActionRow(...)` on our `reply` and then making use of `Button.of(...)`.
+
+Note that a button needs a so called **component ID**. The rules for this id are quite complex and can be read about in the documentation of `SlashCommand#onSlashCommand`. Fortunately, there is a helper that can generate component IDs easily. Since we extended `SlashCommandAdapter`, it is already directly available as `generateComponentId()` (alternatively, use the helper class `ComponentIds`).
+
+Additionally, we have to remember the question ID during the dialog, since we still need to be able to save the response under the question ID in the database. The button component ID can be used for such a situation, we can just call the generator method with arguments, like `generateComponentId(id)`, and will be able to retrieve them back later on.
+
+The full code for the `handleAskCommand` method is now:
+```java
+private void handleAskCommand(@NotNull SlashCommandEvent event) {
+String id = event.getOption("id").getAsString();
+String question = event.getOption("question").getAsString();
+
+event.reply(question)
+ .addActionRow(
+ Button.of(ButtonStyle.SUCCESS, generateComponentId(id), "Yes"),
+ Button.of(ButtonStyle.DANGER, generateComponentId(id), "No"))
+ .queue();
+}
+```
+When trying it out, we can now see the question and two buttons to respond:
+
+
+
+However, clicking the buttons still does not trigger anything yet.
+
+### React to button click
+
+In order to react to a button click, we have to give an implementation for the `onButtonClick(...)` method, which `SlashCommandAdapter` already implemented, but without any action. The method provides us with the `ButtonClickEvent` and also with a `List` of arguments, which are the optional arguments added to the **component id** earlier. In our case, we added the question id, so we can also retrieve it back now by using `args.get(0)`. Also, we can figure out which button was clicked by using `event.getButton().getStyle()`.
+
+A minimal setup could now look like:
+```java
+@Override
+public void onButtonClick(@NotNull ButtonClickEvent event, @NotNull List args) {
+ String id = args.get(0);
+ ButtonStyle buttonStyle = event.getButton().getStyle();
+
+ boolean clickedYes = switch (buttonStyle) {
+ case DANGER -> false;
+ case SUCCESS -> true;
+ default -> throw new AssertionError("Unexpected button action clicked: " + buttonStyle);
+ };
+
+ event.reply("id: " + id + ", clickedYes: " + clickedYes).queue();
+}
+```
+Clicking the buttons now works:
+
+
+
+### Disable buttons after click
+
+Right now, the buttons can be clicked as often as wanted and the bot will always be triggered again. To get rid of this, we simply have to disable the buttons after someone clicked.
+
+We can do so by using `event.getMessage().editMessageComponents(...)` and then providing a new list of components, i.e. the previous buttons but with `button.asDisabled()`. We can get hands on the previous buttons by using `event.getMessage().getButtons()`.
+
+Long story short, we can simply add:
+```java
+event.getMessage()
+ .editMessageComponents(ActionRow
+ .of(event.getMessage().getButtons().stream().map(Button::asDisabled).toList()))
+ .queue();
+```
+and the buttons will be disabled after someone clicks them:
+
+
+
+### Setup database
+
+Last but not least for the `ask` command, we have to save the response in the database. Before we can get started with this, we have to create a database table and let Flyway generate the corresponding database code.
+
+Therefore, we go to the folder `TJ-Bot\application\src\main\resources\db` and add a new database migration script, incrementing the version. For example, if the script with the highest version number is `V1`, we will add `V2` to it. Give the script a nice name, such as `V2__Add_Questions_Table.sql`. The content is simply an SQL statement to create your desired table:
+```sql
+CREATE TABLE questions
+(
+ id TEXT NOT NULL PRIMARY KEY,
+ response INTEGER NOT NULL
+)
+```
+After adding this file, if you build or run the code (or simply execute `gradle database:build`), you will be able to use the database table.
+
+## Write database
+
+Thanks to the jOOQ framework, writing to the database is now fairly simple. You can just use `database.write(...)` and make usages of the generated classes revolving around the `questions` table:
+```java
+try {
+ database.write(context -> {
+ QuestionsRecord questionsRecord = context.newRecord(Questions.QUESTIONS)
+ .setId(id)
+ .setResponse(clickedYes ? 1 : 0);
+ if (questionsRecord.update() == 0) {
+ questionsRecord.insert();
+ }
+ });
+
+ event.reply("Saved response under '" + id + "'.").queue();
+} catch (DatabaseException e) {
+ event.reply("Sorry, something went wrong.").queue();
+}
+```
+Trying it out, and we get the expected response:
+
+
+
+### Add logging
+
+At this point, we should add logging to the code to simplify debugging. Therefore, just add
+```java
+private static final Logger logger = LoggerFactory.getLogger(QuestionCommand.class);
+```
+to the top, as a new field for our class.
+
+Now, you can use the logger wherever you want, for example to log a possible error message during writing the database:
+```java
+} catch (DatabaseException e) {
+ logger.error("Failed to save response for '{}'", id, e);
+ event.reply("Sorry, something went wrong.").queue();
+}
+```
+
+The ask sub-command should now be working correctly.
+
+## `Get` sub-command
+
+This command is simpler as we do not have any dialog. We simply have to lookup the database and respond with the result, if found.
+
+### Read database
+
+Reading the database revolves around the `database.read(...)` methods and using the generated classes for the `questions` table:
+```java
+OptionalInt response = database.read(context -> {
+ return Optional.ofNullable(context.selectFrom(Questions.QUESTIONS)
+ .where(Questions.QUESTIONS.ID.eq(id)).fetchOne()
+ ).map(QuestionsRecord::getResponse)
+ .map(OptionalInt::of)
+ .orElseGet(OptionalInt::empty);
+ }
+});
+```
+
+### Reply
+
+The last part will be to reply with the saved response:
+
+```java
+if (response.isEmpty()) {
+ event.reply("There is no response saved for the id '" + id + "'.")
+ .setEphemeral(true)
+ .queue();
+ return;
+}
+
+boolean clickedYes = response.getAsInt() != 0;
+event.reply("The response for '" + id + "' is: " + (clickedYes ? "Yes" : "No")).queue();
+```
+
+The full code for the `handleGetCommand` method is now:
+```java
+String id = event.getOption("id").getAsString();
+
+try {
+ OptionalInt response = database.read(context -> {
+ return Optional.ofNullable(context.selectFrom(Questions.QUESTIONS)
+ .where(Questions.QUESTIONS.ID.eq(id)).fetchOne()
+ ).map(QuestionsRecord::getResponse)
+ .map(OptionalInt::of)
+ .orElseGet(OptionalInt::empty);
+ }
+ });
+ if (response.isEmpty()) {
+ event.reply("There is no response saved for the id '" + id + "'.")
+ .setEphemeral(true)
+ .queue();
+ return;
+ }
+
+ boolean clickedYes = response.getAsInt() != 0;
+ event.reply("The response for '" + id + "' is: " + (clickedYes ? "Yes" : "No")).queue();
+} catch (DatabaseException e) {
+ logger.error("Failed to get response for '{}'", id, e);
+ event.reply("Sorry, something went wrong.").setEphemeral(true).queue();
+}
+```
+and if we try it out, we see that the command works:
+
+
+
+## Full code
+
+After some cleanup and minor code improvements, the full code for `QuestionCommand` is:
+
+```java
+package org.togetherjava.tjbot.commands.basic;
+
+import net.dv8tion.jda.api.events.interaction.ButtonClickEvent;
+import net.dv8tion.jda.api.events.interaction.SlashCommandEvent;
+import net.dv8tion.jda.api.interactions.commands.CommandInteraction;
+import net.dv8tion.jda.api.interactions.commands.OptionType;
+import net.dv8tion.jda.api.interactions.commands.build.SubcommandData;
+import net.dv8tion.jda.api.interactions.components.ActionRow;
+import net.dv8tion.jda.api.interactions.components.Button;
+import net.dv8tion.jda.api.interactions.components.ButtonStyle;
+import org.jetbrains.annotations.NotNull;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.togetherjava.tjbot.commands.SlashCommandAdapter;
+import org.togetherjava.tjbot.commands.SlashCommandVisibility;
+import org.togetherjava.tjbot.db.Database;
+import org.togetherjava.tjbot.db.DatabaseException;
+import org.togetherjava.tjbot.db.generated.tables.Questions;
+import org.togetherjava.tjbot.db.generated.tables.records.QuestionsRecord;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.OptionalInt;
+
+/**
+ * This creates a command called {@code /question}, which can ask users questions, save their
+ * responses and retrieve back responses.
+ *
+ * For example:
+ *
+ *
+ * {@code
+ * /question ask id: noodles question: Do you like noodles?
+ * // User clicks on a 'Yes' button
+ *
+ * /question get id: noodles
+ * // TJ-Bot: The response for 'noodles' is: Yes
+ * }
+ *
+ */
+public final class QuestionCommand extends SlashCommandAdapter {
+ private static final Logger logger = LoggerFactory.getLogger(QuestionCommand.class);
+ private static final String ASK_SUBCOMMAND = "ask";
+ private static final String GET_SUBCOMMAND = "get";
+ private static final String ID_OPTION = "id";
+ private static final String QUESTION_OPTION = "question";
+ private static final String NAME = "question";
+ private final Database database;
+
+ /**
+ * Creates a new question command, using the given database.
+ *
+ * @param database the database to store the responses in
+ */
+ public QuestionCommand(Database database) {
+ super(NAME, "Asks users questions, responses are saved and can be retrieved back",
+ SlashCommandVisibility.GUILD);
+ this.database = database;
+
+ getData().addSubcommands(
+ new SubcommandData(ASK_SUBCOMMAND,
+ "Asks the users a question, responses will be saved")
+ .addOption(OptionType.STRING, ID_OPTION,
+ "Unique ID under which the question should be saved", true)
+ .addOption(OptionType.STRING, QUESTION_OPTION, "Question to ask", true),
+ new SubcommandData(GET_SUBCOMMAND, "Gets the response to the given question")
+ .addOption(OptionType.STRING, ID_OPTION,
+ "Unique ID of the question to retrieve", true));
+ }
+
+ private static int clickedYesToInt(boolean clickedYes) {
+ return clickedYes ? 1 : 0;
+ }
+
+ private static boolean isClickedYesFromInt(int clickedYes) {
+ return clickedYes != 0;
+ }
+
+ @Override
+ public void onSlashCommand(@NotNull SlashCommandEvent event) {
+ switch (Objects.requireNonNull(event.getSubcommandName())) {
+ case ASK_SUBCOMMAND -> handleAskCommand(event);
+ case GET_SUBCOMMAND -> handleGetCommand(event);
+ default -> throw new AssertionError();
+ }
+ }
+
+ private void handleAskCommand(@NotNull CommandInteraction event) {
+ String id = Objects.requireNonNull(event.getOption(ID_OPTION)).getAsString();
+ String question = Objects.requireNonNull(event.getOption(QUESTION_OPTION)).getAsString();
+
+ event.reply(question)
+ .addActionRow(Button.of(ButtonStyle.SUCCESS, generateComponentId(id), "Yes"),
+ Button.of(ButtonStyle.DANGER, generateComponentId(id), "No"))
+ .queue();
+ }
+
+ @Override
+ public void onButtonClick(@NotNull ButtonClickEvent event, @NotNull List args) {
+ String id = args.get(0);
+ ButtonStyle buttonStyle = Objects.requireNonNull(event.getButton()).getStyle();
+
+ boolean clickedYes = switch (buttonStyle) {
+ case DANGER -> false;
+ case SUCCESS -> true;
+ default -> throw new AssertionError("Unexpected button action clicked: " + buttonStyle);
+ };
+
+ event.getMessage()
+ .editMessageComponents(ActionRow
+ .of(event.getMessage().getButtons().stream().map(Button::asDisabled).toList()))
+ .queue();
+
+ try {
+ database.write(context -> {
+ QuestionsRecord questionsRecord = context.newRecord(Questions.QUESTIONS)
+ .setId(id)
+ .setResponse(clickedYesToInt(clickedYes));
+ if (questionsRecord.update() == 0) {
+ questionsRecord.insert();
+ }
+ });
+
+ event.reply("Saved response under '" + id + "'.").queue();
+ } catch (DatabaseException e) {
+ logger.error("Failed to save response for '{}'", id, e);
+ event.reply("Sorry, something went wrong.").queue();
+ }
+ }
+
+ private void handleGetCommand(@NotNull CommandInteraction event) {
+ String id = Objects.requireNonNull(event.getOption(ID_OPTION)).getAsString();
+
+ try {
+ OptionalInt response = database.read(context -> {
+ return Optional.ofNullable(context.selectFrom(Questions.QUESTIONS)
+ .where(Questions.QUESTIONS.ID.eq(id)).fetchOne()
+ ).map(QuestionsRecord::getResponse)
+ .map(OptionalInt::of)
+ .orElseGet(OptionalInt::empty);
+ }
+ });
+ if (response.isEmpty()) {
+ event.reply("There is no response saved for the id '" + id + "'.")
+ .setEphemeral(true)
+ .queue();
+ return;
+ }
+
+ boolean clickedYes = isClickedYesFromInt(response.getAsInt());
+ event.reply("The response for '" + id + "' is: " + (clickedYes ? "Yes" : "No")).queue();
+ } catch (DatabaseException e) {
+ logger.error("Failed to get response for '{}'", id, e);
+ event.reply("Sorry, something went wrong.").setEphemeral(true).queue();
+ }
+ }
+}
+```
\ No newline at end of file
diff --git a/wiki/Adding-context-commands.md b/wiki/Adding-context-commands.md
new file mode 100644
index 0000000000..fff2d0696d
--- /dev/null
+++ b/wiki/Adding-context-commands.md
@@ -0,0 +1,70 @@
+# Overview
+
+This tutorial shows how to add custom **context command** to the bot. That is, a command that can be selected when **right clicking** an user or message.
+
+Please read [[Add a new command]] first.
+
+## What you will learn
+* add a custom user context command
+* add a custom message context command
+
+# Tutorial
+
+## User-context command
+
+To create a command that can be selected when right clicking a user, we have to implement the `UserContextCommand` interface. The class `BotCommandAdapter` simplifies this process heavily.
+
+We will create a very simple command that just greets an user:
+
+
+
+
+The code is really simple:
+```java
+public final class HelloUserCommand extends BotCommandAdapter implements UserContextCommand {
+
+ public HelloUserCommand() {
+ super(Commands.user("say-hello"), CommandVisibility.GUILD);
+ }
+
+ @Override
+ public void onUserContext(UserContextInteractionEvent event) {
+ event.reply("Hello " + event.getTargetMember().getAsMention()).queue();
+ }
+}
+```
+Finally, we have to add an instance of the class to the system. We do so in the file `Features.java`:
+
+```java
+features.add(new HelloUserCommand());
+```
+
+## Message-context command
+
+To create a command that can be selected when right clicking a message, we have to implement the `MessageContextCommand` interface. `BotCommandAdapter` helps us out here again.
+
+We will create a very simple command that just repeats the given message:
+
+
+
+
+The code is very similar:
+```java
+public final class RepeatMessageCommand extends BotCommandAdapter implements MessageContextCommand {
+
+ public RepeatMessageCommand() {
+ super(Commands.message("repeat"), CommandVisibility.GUILD);
+ }
+
+ @Override
+ public void onMessageContext(MessageContextInteractionEvent event) {
+ String content = event.getTarget().getContentRaw();
+ event.reply(content).queue();
+ }
+}
+```
+And we add it to `Features.java` as well:
+
+```java
+features.add(new RepeatMessageCommand());
+```
\ No newline at end of file
diff --git a/wiki/Change-log-level.md b/wiki/Change-log-level.md
new file mode 100644
index 0000000000..e0093e7781
--- /dev/null
+++ b/wiki/Change-log-level.md
@@ -0,0 +1,9 @@
+## Overview
+
+The log level can be changed conveniently from within Discord by using the slash command `/set-log-level`:
+
+
+
+
+
+Only mods can use this command though. The change is not persisted and will reset on the next restart of the bot.
\ No newline at end of file
diff --git a/wiki/Code-Guidelines.md b/wiki/Code-Guidelines.md
new file mode 100644
index 0000000000..ca7ff2499f
--- /dev/null
+++ b/wiki/Code-Guidelines.md
@@ -0,0 +1,39 @@
+# Overview
+
+We want the project to be easy to understand and maintain, for anyone, including newcomers. Because of that, we enforce a strict code style based on popular and commonly used configurations. Additionally, we require the code to pass certain checks that analyze it with respect to maintainability, readability, security and more.
+
+All pull requests have to pass those automated checks before they can be merged to the project.
+
+Here is a quick glimpse at how our code usually looks like:
+
+
+
+## Code style (Spotless)
+
+The style and layout of the code is checked by **Spotless**. Its configuration is based on the commonly used [Google Java Style](https://google.github.io/styleguide/javaguide.html). The exact configuration being used can be found in the project at
+```
+TJ-Bot\meta\formatting\google-style-eclipse.xml
+```
+
+In order to check your code locally, you can either run **Spotless** or import the style into the formatter of the IDE of your choice. We tested the configuration with:
+* IntelliJ
+* Eclipse
+* Visual Studio Code
+
+### Run Spotless
+
+Executing Spotless manually can be done via the Gradle task `spotlessApply`, which will automatically reformat your code according to the style.
+
+Additionally, Spotless is configured to be executed automatically whenever you compile your code with Gradle, i.e. it is tied to the `compileJava` task.
+
+## Static code analysis (SonarCloud)
+
+In order to ensure that code is clean, readable and maintainable, we use static code analysis provided by **SonarCloud**.
+
+In order to check your code locally, you can either run **SonarCloud** via a Gradle task or install a plugin for your favorite IDE, e.g. the [SonarLint](https://plugins.jetbrains.com/plugin/7973-sonarlint) plugin for IntelliJ.
+
+### Run SonarCloud
+
+Executing SonarCloud manually can be done via the Gradle task `sonarqube`, which will check the whole code and explain any issues it found in detail.
+
+Additionally, SonarCloud is configured to be executed automatically whenever you build your code with Gradle, i.e. it is tied to the `build` task.
\ No newline at end of file
diff --git a/wiki/Code-in-the-cloud-(codespaces).md b/wiki/Code-in-the-cloud-(codespaces).md
new file mode 100644
index 0000000000..e29e468197
--- /dev/null
+++ b/wiki/Code-in-the-cloud-(codespaces).md
@@ -0,0 +1,80 @@
+# Overview
+
+This tutorial shows how to code and run the project in GitHubs cloud.
+
+The service is completely free and allows you to get started in just a few seconds.
+
+This approach is an alternative to setting the project up on your local machine, as explained in:
+* [[Setup project locally]]
+
+# Tutorial
+
+## Create codespace
+
+1. Visit the [landing page](https://github.com/Together-Java/TJ-Bot)
+2. Click on `Code > Create codespace on develop`
+
+
+
+GitHub now automatically sets up your codespace. The codespace is essentially a virtual machine with everything installed and configured that you need to work with this project.
+
+Mostly, it will install Java and Gradle for you, and a few other useful plugins and extensions.
+
+
+
+This process takes about 2 minutes for the first time. Once the setup is done, it opens an instance of Visual Studio Code in your browser, with the project opened. You can now get started!
+
+
+
+## Config
+
+Before you can run the bot, you have to adjust the configuration file. Therefore, open the file `application/config.json`.
+
+By default, it will be filled with some example values and most of them are totally okay for now.
+
+The most important setting you have to change is the bot token. This enables the code to connect to your private bot with which you can then interact from your private server.
+
+You can find the token at the [Discord Developer Portal](https://discord.com/developers/applications).
+
+See the following guide if you still have to create a server and a bot first:
+* [[Create Discord server and bot]]
+
+
+
+Replace `` with your bot token; you can also adjust the other settings if you want.
+
+
+
+## Run
+
+Once done, you are good to go and can run the bot. Just enter `gradle application:run` in your terminal.
+
+
+
+On the first run, this might take around 3 minutes, because it will first have to download all dependencies, generate the database and compile the code.
+
+Once the terminal reads `[main] INFO org.togetherjava.tjbot.Application - Bot is ready`, you are done!
+
+## Have fun
+
+The bot is now running and connected to your server, hurray π
+
+You can now execute commands and see the bot do its magic:
+
+
+
+# IntelliJ instead of VSC
+
+If you prefer IntelliJ, and have a license, they offer a client called [JetBrains Gateway](https://www.jetbrains.com/remote-development/gateway/).
+
+While not being all-within your browser, it is essentially an IDE that you can install, which will just remote-connect to your codespace.
+
+Once installed, you have to get the **GitHub Codespaces** plugin:
+
+
+
+You can then login to your GitHub account and select your codespace:
+
+
+
+The initial setup takes a few minutes, since it has to install IntelliJ on the codespace first.
\ No newline at end of file
diff --git a/wiki/Component-ID-Store.md b/wiki/Component-ID-Store.md
new file mode 100644
index 0000000000..915a240de7
--- /dev/null
+++ b/wiki/Component-ID-Store.md
@@ -0,0 +1,49 @@
+# Overview
+
+[[Component IDs]] are, among other things, used to carry information through an event.
+
+It does so by storing the actual payload outside of the component ID used by the buttons, in a database table (and in-memory map), associating it to a generated **UUID**, which is then used as actual component ID for JDA.
+
+# Component ID Store
+
+The `ComponentIdStore` is the central point for this mechanism, which is exposed via the interfaces:
+* `ComponentIdGenerator` - to all commands during setup for id generation
+* `ComponentIdParser` - to `CommandSystem` for the routing of events
+
+The store is basically a 2-layer `Map`:
+* first layer: `Cache` (by Caffeine), to speedup lookups, covers probably about 95% of all queries or so
+* second layer: a database table `component_ids`
+
+When an user wants to create a component ID, the store will add the payload to both layers and associate it to a generated UUID. When an user wants to parse back the payload from the UUID, the store looks up the in-memory map first, and if not found, also the database.
+
+## Eviction
+
+To prevent the database from just growing indefinitely over the years, the `ComponentIdStore` implements a LRU-mechanism on both, the in-memory map, as well as on the database table.
+
+For latter, it runs a periodic **eviction-routine**, which will locate records that have not been used for a long time and delete them.
+
+Each lookup of an UUID **heats** the record in both, the in-memory map and in the database table. That means, its `last_used` timestamp will be updated, making it not targeted for the next evictions.
+
+Users are able to listen to eviction events, by registering themselves as listener using `ComponentIdStore#addComponentIdRemovedListener`. While not used as of today, this might be interesting in the future, for example to deactivate actions (such as buttons) associated to an expired component ID.
+
+Component IDs can also be associated with a `Lifespan.PERMANENT` to prevent eviction all together. While this should not be used per default, it can be useful for actions that are not used often but should still be kept alive (for example global role-assignment reactions).
+
+Eviction details can be configured, but by default they are set to:
+- evict every **15 minutes**
+- evict entries that have not been used for longer than **20 days**
+- in-memory map has a max size of **1_000**
+
+## Database details
+
+The table is created as such:
+```sql
+CREATE TABLE component_ids
+(
+ uuid TEXT NOT NULL UNIQUE PRIMARY KEY,
+ component_id TEXT NOT NULL,
+ last_used TIMESTAMP NOT NULL,
+ lifespan TEXT NOT NULL
+)
+```
+Content looks for example like this (after popping `/reload` three times):
+
\ No newline at end of file
diff --git a/wiki/Component-IDs.md b/wiki/Component-IDs.md
new file mode 100644
index 0000000000..f5486e31d7
--- /dev/null
+++ b/wiki/Component-IDs.md
@@ -0,0 +1,114 @@
+# Overview
+
+Component IDs are, among other things, used to carry information through an event.
+
+For example the user who triggered a slash command or details to the query, which are then stored _"inside"_ a button to retrieve the associated information back when it is clicked. By that, it can be implemented for example that a button can only be clicked by the original message author.
+
+Component IDs in our system have to follow certain rules, which are explained in detail later.
+
+# Usage
+
+Our API offers two options to work with component IDs.
+
+## `SlashCommandAdapter`
+
+If your command happens to `extend SlashCommandAdapter` (our helper class), you can easily generate valid component IDs using the helper method `generateComponentId`. The method optionally accepts additional strings which can be used to carry information through the event.
+
+For example, let us suppose you want to create a button that can only be clicked by the user who also triggered the command initially. Then you would create your buttons as such:
+
+```java
+@Override
+public void onSlashCommand(@NotNull SlashCommandEvent event) {
+ event.reply("Do you want to continue?")
+ .addActionRow(
+ Button.of(ButtonStyle.SUCCESS, generateComponentId(event.getMember().getId()), "Yes"),
+ Button.of(ButtonStyle.DANGER, generateComponentId(event.getMember().getId()), "No")
+ .queue();
+)
+```
+
+and then later, when retrieving the `ButtonClickEvent`, you get back the list of arguments:
+
+```java
+@Override
+public void onButtonClick(@NotNull ButtonClickEvent event, @NotNull List args) {
+ // Ignore if another user clicked the button
+ String userId = args.get(0);
+ if (!userId.equals(Objects.requireNonNull(event.getMember()).getId())) {
+ event.reply("Sorry, but only the user who triggered the command can use these buttons.")
+ .setEphemeral(true)
+ .queue();
+ return;
+ }
+
+ event.reply("Nice!").queue();
+}
+```
+
+## `SlashCommand`
+
+Alternatively, commands can also implement the interface `SlashCommand` directly, instead of using the helper class `SlashCommandAdapter`. In that case, in order to use component IDs, one has to do some basic setup first.
+
+Component IDs can be generated by using a `ComponentIdGenerator`, which the command system will provide to the command by calling the `acceptComponentIdGenerator` method, that each command has to implement, once during setup.
+
+So as first step, you have to memorize this generator:
+
+```java
+public final class MyCommand implements SlashCommand {
+ private ComponentIdGenerator componentIdGenerator;
+
+ ...
+
+ @Override
+ public void acceptComponentIdGenerator(@NotNull ComponentIdGenerator generator) {
+ componentIdGenerator = generator;
+ }
+}
+```
+
+After that is done, component IDs can be generated easily as well, for example:
+```java
+componentIdGenerator.generate(new ComponentId(getName(), ...), Lifespan.REGULAR);
+```
+where `...` are the optional arguments you want to pass.
+
+The previous button example would now look like:
+```java
+@Override
+public void onSlashCommand(@NotNull SlashCommandEvent event) {
+ event.reply("Do you want to continue?")
+ .addActionRow(
+ Button.of(
+ ButtonStyle.SUCCESS,
+ componentIdGenerator.generate(
+ new ComponentId(getName(), Arrays.asList(event.getMember().getId()),
+ Lifespan.REGULAR),
+ "Yes"),
+ Button.of(
+ ButtonStyle.DANGER,
+ componentIdGenerator.generate(
+ new ComponentId(getName(), Arrays.asList(event.getMember().getId()),
+ Lifespan.REGULAR),
+ "No")
+ .queue();
+)
+```
+# Lifespan
+
+Component IDs can have different lifespans. If a component ID has expired, associated events can not be used anymore. For example, a button can not be clicked anymore.
+
+The default lifespan to use is `Lifespan.REGULAR`. IDs associated with this lifespan will generally be valid long enough for most use cases (multiple days). However, in some cases it might be necessary to create IDs that will not expire. `Lifespan.PERMANENT` can be used for that, but do not overuse it.
+
+Note that the lifetime of a component ID is refreshed each time it is used. Hence, IDs only expire if they have not been used by anyone for a long time.
+
+# Details
+
+Technically, for JDA, component IDs could be any text, as long as it is:
+* unique among other components in the event
+* not longer than 100 characters
+
+To overcome those limitations and ease the workflow for users, our API generates component IDs as **UUIDs** (which are unique by default).
+
+In order to attach arbitrary information (that also might be longer than just 100 characters) to the component ID, we store the actual information (the payload) externally in a database instead of stuffing it into the component ID itself. So while the actual component ID is just a UUID, we associate it to the corresponding information in a database.
+
+See [[Component ID Store]] for more details on the underlying system and the implementation.
\ No newline at end of file
diff --git a/wiki/Connect-SonarLint-extension-to-our-SonarCloud.md b/wiki/Connect-SonarLint-extension-to-our-SonarCloud.md
new file mode 100644
index 0000000000..876a88400d
--- /dev/null
+++ b/wiki/Connect-SonarLint-extension-to-our-SonarCloud.md
@@ -0,0 +1,63 @@
+# Overview
+
+This tutorial shows how to connect SonarLint extension to our SonarCloud.
+
+[SonarCloud](https://www.sonarsource.com/products/sonarcloud/) is a cloud-based static analysis tool we integrated in our CI/CD pipeline. It analyses code in each PR for bugs, vulnerabilities and code smells, and reports issues for the contributor to fix.
+
+If you want to have your code analysed locally, as you write it, you want to install [SonarLint](https://www.sonarsource.com/products/sonarlint/) extension for your IDE.
+
+Immediate feedback is important, as it increases your productivity. You can see issues immediately as you write code, and you can easily fix them. Having to push to trigger the analysis every time is cumbersome. You have to wait for the results, and then write and push the fix, and what if the fix has some issues as well?
+
+The issue is, even with SonarLint, you might encounter these workflow issues, since SonarLint is not as powerful as SonarCloud and doesn't have all of our rules enabled. So the goal of this tutorial is to mitigate that as much as possible, and connect the local SonarLint extension to the SonarCloud.
+
+## Prerequisites
+* IDE or code editor supported by SonarLint: [IntelliJ](https://www.jetbrains.com/idea/), [Eclipse](https://eclipseide.org/) or [VSCode](https://code.visualstudio.com/)
+* SonarLint extension. You can find them in marketplaces: [IntelliJ extension](https://plugins.jetbrains.com/plugin/7973-sonarlint), [Eclipse extension](https://marketplace.eclipse.org/search/site/SonarLint)
+
+## What you will learn
+* Connect SonarLint to our SonarCloud
+
+## Benefits
+
+When SonarLint works in connected mode, it can:
+
+* use the same quality profile (same rules activation, parameters, severity, ...)
+* reuse some settings defined on the server (rule exclusions, analyzer parameters, ...)
+* suppress issues that are marked as Wonβt Fix or False Positive on the server
+
+# Setting up Connected Mode
+
+## Login to SonarCloud
+
+If you don't have an account, use OAuth with your github account to [login](https://sonarcloud.io/sessions/new).
+
+## Create a User Token
+
+For connecting to SonarCloud, we will use a User Token, as it is the most secure way to connect to the SonarCloud.
+
+Go to your SonarCloud [account security settings](https://sonarcloud.io/sessions/new), and generate new token.
+
+## Quick IntelliJ guide
+
+1. Go to: _File | Settings | Tools | SonarLint_
+2. Click on _+_, or press _Alt + Insert_
+3. Enter connection name, for example 'TJ' and click *Next* (or press *return*)
+4. Enter the token you just created and click *Next*
+5. Click *'Select another organization..'* and enter `togetherjava`, click *OK* and then *Next*
+6. Click *Next* again if you are happy with notifications settings
+7. Click *Next* once again
+8. Go to: _File | Settings | Tools | SonarLint | Project Settings_
+9. Check _'Bind project to SonarQube / SonarCloud'_
+10. Select previously created connection in *'Connection:'* dropdown menu
+11. Click *'Search in list...'* button, and click *OK*; or enter project key manually: `Together-Java_TJ-Bot`
+12. Click *Apply*
+
+## IDE-specific instructions
+
+Follow these official tutorials for your IDE:
+
+* InteliJ - https://github.com/SonarSource/sonarlint-intellij/wiki/Bind-to-SonarQube-or-SonarCloud
+
+* Eclipse - https://github.com/SonarSource/sonarlint-eclipse/wiki/Connected-Mode
+
+* VSCode - https://github.com/SonarSource/sonarlint-vscode/wiki/Connected-mode
\ No newline at end of file
diff --git a/wiki/Contributing.md b/wiki/Contributing.md
new file mode 100644
index 0000000000..c11e3f6fcf
--- /dev/null
+++ b/wiki/Contributing.md
@@ -0,0 +1,95 @@
+# Welcome to the TJ-Bot project! 
+
+First off, thank you for considering contributing to TJ-Bot. :tada:
+
+TJ-Bot is an open-source project, and we love to receive contributions from our community β **you**! There are many ways to contribute, from writing tutorials, improving the documentation, submitting bug reports and feature requests or writing code which can be incorporated into TJ-Bot itself.
+
+Following these guidelines helps to communicate that you respect the time of the developers managing and developing this open-source project. In return, they should reciprocate that respect in addressing your issue, assessing changes, and helping you finalize your pull requests.
+
+## Ground Rules
+
+* Create [issues](https://github.com/Together-Java/TJ-Bot/issues) for any major changes and enhancements that you wish to make, as well as for reporting any sort of bugs. For more light-hearted talks, you can use [discussions](https://github.com/Together-Java/TJ-Bot/discussions). Discuss things transparently and get community feedback.
+* Be welcoming to newcomers and encourage diverse new contributors from all backgrounds.
+
+## Your First Contribution
+
+Unsure where to begin contributing to TJ-Bot? You can start by looking through these labels!
+* [good first issue](https://github.com/Together-Java/TJ-Bot/issues/?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) - issues which should only require a few lines of code, and a test or two.
+* [help wanted](https://github.com/Together-Java/TJ-Bot/issues/?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22) - issues which should be a bit more involved than good first issues.
+
+Let us know that you intend to work on the issue by commenting on it, and we will assign it to you.
+
+Working on your first Pull Request? You can check these resources:
+* http://makeapullrequest.com/
+* http://www.firsttimersonly.com/
+
+At this point, you're ready to make your changes! Feel free to ask for help; everyone is a beginner at first! :tada:
+
+# Getting started
+
+### Create an issue
+
+Before creating a new issue, make sure to [search](https://github.com/Together-Java/TJ-Bot/issues?q=is%3Aissue) for existing issues first.
+
+If the issue already exists, comment on it saying that you intend to work on it, and we will assign it to you!
+
+In case it doesn't, feel free to open a new issue describing what you would like to change, improve or fix. The community will then discuss the issue, and assign it to you.
+
+Now you are ready to do some work!
+
+### Create a fork
+
+Then, you fork the repository.
+
+The repository has two main branches:
+* `master`, a stable branch mostly used for releases that receives changes only occasionally
+* `develop`, the branch where the active development takes place; receives changes frequently
+
+Your work will be based off the `develop` branch.
+
+To incorporate new commits from `develop` into your feature branch, use `git pull --rebase` or equivalent GUI action. We strongly prefer having linear history, and PRs with merge commits will have to be squashed before the merge, which results in losing all valuable commit history.
+
+After your first contribution, you will be invited to the contributor team, and you will be able to work on the project directly, without a fork.
+
+In that case, create a branch like this `feature/name-of-your-feature`, and push directly to the repo!
+
+### Commit your changes
+
+After a portion of feature you are working on is done, it's time to commit your changes!
+
+Each commit should be small, self-contained, and should solve only one problem.
+
+Each commit name and message should be clear, concise, and informative: Please consider checking these resources: [writing a commit message](https://chris.beams.io/posts/git-commit/) and [writing a good commit message](https://dev.to/chrissiemhrk/git-commit-message-5e21)
+
+### Create a pull request
+
+When you are done, you will create a [pull request](https://github.com/Together-Java/TJ-Bot/pulls) to request feedback from the rest of the community. At this point, your code will be automatically tested against our [[code guidelines|Code Guidelines]] (Spotless, SonarCloud, CodeQL, and more).
+
+Each pull request should be clear, concise, and informative. Please consider checking these resources: [writing a great pull request](https://www.pullrequest.com/blog/writing-a-great-pull-request-description/) and [unwritten guide to pull requests](https://www.atlassian.com/blog/git/written-unwritten-guide-pull-requests).
+
+A pull request should only implement one feature or bugfix. If you want to add or fix more than one thing, please submit another pull request.
+
+### Automated checks and review
+
+After you created a PR, automated checks will be run. PR cannot be merged without all tests passing, so make sure to fix all the issues that are found.
+
+Your PR will be reviewed, and after being accepted by at least two members of the community, it will get merged to the `develop` branch! :tada:
+
+From there on, it will lead to an automatic re-deployment of the bot on a test environment, where you can test out your changes live.
+
+After a while, the `master` branch will be synced with `develop` again, leading to your changes finally being live on the real server!
+
+# Tutorials
+
+Make sure to head over to the [Wiki](https://github.com/Together-Java/TJ-Bot/wiki) as a general entry point to the project. It provides lots of tutorials, documentation and other information, for example
+* creating a discord bot and a private server;
+* setting up the project locally;
+* adding your own custom commands;
+* a technology overview;
+* guidance about how to maintain the bot (e.g., VPS, logs, databases, restart).
+
+# Community
+
+You can chat with the TJ-Bot users and devs in our [discord server](https://discord.com/invite/xxfuxzk)!
+
+Enjoy and have fun π
\ No newline at end of file
diff --git a/wiki/Create-Discord-server-and-bot.md b/wiki/Create-Discord-server-and-bot.md
new file mode 100644
index 0000000000..f919da5810
--- /dev/null
+++ b/wiki/Create-Discord-server-and-bot.md
@@ -0,0 +1,90 @@
+# Overview
+
+This tutorial shows how to create your own Discord server and a Discord bot, which can then be connected to a program, like the TJ-Bot.
+
+## Prerequisites
+* a [Discord](https://discord.com/) account
+
+## What you will learn
+* create your own Discord server
+* create a Discord bot
+* add the bot to your server
+
+# Tutorial
+
+## Discord Server
+
+As first step, you need to create your own Discord server. This is surprisingly easy.
+We use Discord's server template feature for this, this way you don't have to create all the channels, roles and more on your own.
+You can still modify the servers channels and roles after creation, as it's only a template.
+
+This can be done using the following [link](https://discord.new/WhtXEUZeFdTg).
+
+1. Open the URL from above
+2. Follow the dialog and enter details
+ 2.1. Upload a picture
+ 2.2. Enter a name
+ 2.3. smack the **Create** button
+3. boom! you have your own Discord server π
+
+
+
+
+
+## Discord Bot
+
+Next up, you want to create your own bot.
+
+1. visit the [Discord Developer Portal](https://discord.com/developers/applications)
+2. click on **New Application**
+ 2.1. enter the name for the bot
+3. on the **General Information** tab
+ 3.1. enter a name, description and upload a picture
+ 3.2. hit **Save Changes**
+4. on the **Bot** tab
+ 4.1. click on **Add Bot**
+ 4.2. hit the **Yes, do it!** button
+ 4.3. you can now see your bots **Token**, you will need this when connecting the bot to a program later
+ 4.4. enable the **Server Members Intent**
+ 4.5. enable the **Message Content Intent**
+5. on the **OAuth** tab
+ 5.1. select the `Bot` and `applications.commands` **Scope**s
+ 5.2. select the desired **Bot permissions**, e.g. `Send Messages`, `Read Message History`, `Add Reactions`, `Use Slash Commands`
+ 5.3. from the **Scope** section, copy the URL it generated, this is the **bots invite link**
+
+
+
+
+
+
+
+
+
+
+
+
+
+## Add bot to server
+
+Last but not least, you have to add the bot to the server you just created.
+
+1. open the bots invite link URL in a browser
+ 1.1. select your server to add the bot
+ 1.2. click **Continue**
+ 1.3. click **Authorize**
+2. thats it, your bot was now added to the server! π
+
+
+
+
+
+# What next?
+
+Now that have your own server and your own Discord bot and both are connected to each other, you can start to create or run an actual bot-program, such as TJ-Bot and give it your bots token!
+
+Once the program has your token, it will connect to the bot and you can interact with it from your server.
+
+You can learn about these steps in the following guide:
+* [[Setup project locally]]
+
+
\ No newline at end of file
diff --git a/wiki/Create-and-use-modals.md b/wiki/Create-and-use-modals.md
new file mode 100644
index 0000000000..ba0b89d892
--- /dev/null
+++ b/wiki/Create-and-use-modals.md
@@ -0,0 +1,96 @@
+# Overview
+
+This tutorial shows how to create and use **modals** in commands. That is, a popup message with a form that allows the user to input and submit data.
+
+Please read [[Add a new command]] first.
+
+## What you will learn
+* create a modal
+* react to a modal being submitted
+
+# Tutorial
+
+## Create a modal
+
+To create a modal, all we need is a way to create [[Component IDs]]. The easiest way to do so is by extending `SlashCommandAdapter` or `BotCommandAdapter`. Alternatively, there is also the helper `ComponentIdInteractor`, which can be used directly.
+
+We will create a very simple slash command that lets the user submit feedback, which is then logged in the console.
+
+
+
+
+
+
+The core of creating the model would be something like this:
+```java
+TextInput body = TextInput.create("message", "Message", TextInputStyle.PARAGRAPH)
+ .setPlaceholder("Put your feedback here")
+ .setRequiredRange(10, 200)
+ .build();
+
+// we need to use a proper component ID here
+Modal modal = Modal.create(generateComponentId(), "Feedback")
+ .addActionRow(body) // can also have multiple fields
+ .build();
+
+event.replyModal(modal).queue();
+```
+
+## React to modal submission
+
+The system automatically forwards the event based on the generated component ID. To receive it, the class that send it has to implement `UserInteractor`. The easiest way for that is by implementing `BotCommand` or `SlashCommand`, ideally by extending the helpers `BotCommandAdapter` or `SlashCommandAdapter`.
+
+This gives a method `onModalSubmitted` which will automatically be called by the system and can be used to react to the modal being submitted:
+
+```java
+@Override
+public void onModalSubmitted(ModalInteractionEvent event, List args) {
+ String message = event.getValue("message").getAsString();
+ System.out.println("User send feedback: " + message);
+
+ event.reply("Thank you for your feedback!").setEphemeral(true).queue();
+}
+
+## Add to features
+```
+Finally, we have to add an instance of the class to the system. We do so in the file `Features.java`:
+
+```java
+features.add(new SendFeedbackCommand());
+```
+
+## Full code
+
+The full code for the class is
+```java
+public final class SendFeedbackCommand extends SlashCommandAdapter {
+
+ private static final String MESSAGE_INPUT = "message";
+
+ public SendFeedbackCommand() {
+ super("feedback", "Send feedback to the server maintainers", CommandVisibility.GUILD);
+ }
+
+ @Override
+ public void onSlashCommand(SlashCommandInteractionEvent event) {
+ TextInput body = TextInput.create(MESSAGE_INPUT, "Message", TextInputStyle.PARAGRAPH)
+ .setPlaceholder("Put your feedback here")
+ .setRequiredRange(10, 200)
+ .build();
+
+ Modal modal = Modal.create(generateComponentId(), "Feedback")
+ .addActionRow(body)
+ .build();
+
+ event.replyModal(modal).queue();
+ }
+
+ @Override
+ public void onModalSubmitted(ModalInteractionEvent event, List args) {
+ String message = event.getValue(MESSAGE_INPUT).getAsString();
+ System.out.println("User send feedback: " + message);
+
+ event.reply("Thank you for your feedback!").setEphemeral(true).queue();
+ }
+}
+```
\ No newline at end of file
diff --git a/wiki/Disabling-Features.md b/wiki/Disabling-Features.md
new file mode 100644
index 0000000000..74c853c0a0
--- /dev/null
+++ b/wiki/Disabling-Features.md
@@ -0,0 +1,34 @@
+## Blacklisting a Bot Feature in the Configuration
+
+If you need to blacklist a specific feature in your bot, follow these steps to exclude it from execution:
+
+1. **Identify the Feature**:
+ - Determine the full class name (including the package) of the feature you want to blacklist.
+ - For example, let's assume the feature is named `ChatGptCommand.java`, located in the package `org.togetherjava.tjbot.features.chatgpt`.
+ - The full class name would be `org.togetherjava.tjbot.features.chatgpt.ChatGptCommand`.
+
+2. **Edit the Configuration File (`config.json`)**:
+ - Open your bot's configuration file (`config.json`).
+ - Locate the `"featureBlacklist"` section.
+
+3. **Add the Feature to the Blacklist**:
+ - Under `"normal"`, add the full class name of the feature you want to blacklist.
+ - For example:
+
+ ```json
+ "featureBlacklist": {
+ "normal": [
+ "org.togetherjava.tjbot.features.chatgpt.ChatGptCommand"
+ ],
+ "special": []
+ }
+ ```
+
+ - The `"normal"` section will prevent the specified feature from being executed when added via the `Features.java` file.
+
+4. **Save and Apply Changes**:
+ - Save the configuration file.
+ - If your bot is running, restart it to apply the changes.
+
+5. **Additional Note**:
+ - The `"special"` section can be used for features that are not added via `Features.java` for any reason.
diff --git a/wiki/Discord's-roadmap.md b/wiki/Discord's-roadmap.md
new file mode 100644
index 0000000000..a6e3c5667e
--- /dev/null
+++ b/wiki/Discord's-roadmap.md
@@ -0,0 +1,76 @@
+Discord is working on a ton, and has been working a ton.
+I'll explain some of the relavant features really short here, so you can stay up-to-date with ease
+
+## Slash-commands
+
+Command's with a `/` as their prefix.
+Examples can be the `/thread` command, allows you to create a command
+
+## Attachment option
+
+Added 8/9 febuary, so really recent. \
+Just allow you to request a file from the user in a command.
+
+## Slash-command auto-complete
+
+Easiest is to just show it tbh
+
+See a link to the YouTube video [here](https://www.youtube.com/watch?v=kTbCTxZEtZ0)
+
+Allows us to give the user "options" live, this means you won't be limited to 25 hardcoded options. \
+An example use-case would be the tag command, you can show the user possible tags live.
+
+## Context Commands
+
+Command's visible when right clicking a member/user
+
+
+
+## forms/modals
+
+
+Also added the 8th of Feburary! Another recent addition. \
+It's more or less a form, currently only accepts the text input. \
+Selection menu support will be added in the future, unsure about other things that might get added.
+
+
+
+
+Now let's talk about features that are in WIP, and unfortunately not yet released.
+
+
+## date-picker option for slash-commands
+
+Explains itself, no new info on this.
+
+## NSFW commands
+
+Don't question
+
+## Slash-commands improved UI
+
+Current UI isn't that nice, so they're working on the improvements seen below.
+
+- all required options are selected instead of 1
+- errors are rendered inside the editor
+- multi-line support
+
+
+
+This info is a few months old, I don't know anything more recent about it "it'll enable more new features"
+
+## Permission system rework
+
+This one is so important, currently you can **only** allow/deny specific roles/users. That's fine for our bot, but just shitty to manage and such.
+Discord is now allowing a lot more, you can set permissions instead, or specific roles/users.
+And even better, moderators of the server can set everything too! If they only want the command to be used in specific channels by specific people, this is possible easily, see pictures below (might has received some changes) :p
+
+
+
+
+
+Source(s):
+[GitHub Discussions](https://github.com/discord/discord-api-docs/discussions)
+[API-Plans discussion](https://github.com/discord/discord-api-docs/discussions/3581)
+[Editor upgrades article](https://auralytical.notion.site/Editor-Upgrades-dee51c93462c44b8a0a53fed3d94c4cd)
+[Autocomplete article](https://devsnek.notion.site/Application-Command-Option-Autocomplete-Interactions-dacc980320c948768cec5ae3a96a5886)
\ No newline at end of file
diff --git a/wiki/Discord-Bot-Details.md b/wiki/Discord-Bot-Details.md
new file mode 100644
index 0000000000..204ce0beca
--- /dev/null
+++ b/wiki/Discord-Bot-Details.md
@@ -0,0 +1,21 @@
+# Team
+
+The bots are managed by Discord team called [Together Java](https://discord.com/developers/teams/886331405368438795/information). All [Moderators](https://github.com/orgs/Together-Java/teams/moderators) are members of it.
+
+The team manages two bots.
+
+## TJ-Bot (`master`)
+
+
+
+This bot is the real deal. It runs the stable `master` branch and can be used at any time by all members of the [main Discord server](https://discord.com/invite/XXFUXzK).
+
+It can be managed in the [Developer Portal](https://discord.com/developers/applications/884898473676271646/information).
+
+## TJ-Bot (`develop`)
+
+
+
+For testing out new commands and features, we have this bot running the `develop` branch. This bot is accessible from a specific [test server](https://discord.com/invite/qDNZNfjbvp).
+
+It can be managed in the [Developer Portal](https://discord.com/developers/applications/886334503524638751/information).
\ No newline at end of file
diff --git a/wiki/Edit-the-Config.md b/wiki/Edit-the-Config.md
new file mode 100644
index 0000000000..013f29a1d2
--- /dev/null
+++ b/wiki/Edit-the-Config.md
@@ -0,0 +1,13 @@
+# Overview
+
+In order to edit the configuration file of the bot, one has to login to the VPS and adjust the config file manually. Only members of the [Moderator](https://github.com/orgs/Together-Java/teams/moderators)-Team can do the following steps.
+
+See [[Access the VPS]] for details of the login process.
+
+# Guide
+
+1. `ssh togetherjava` to login to the VPS
+2. Either `cd ~/docker-infra/master-bot` or `cd ~/docker-infra/develop-bot` to go to the directory of the corresponding bot
+3. Use `cd config`
+4. Edit the `config.json` file, for example `vim config.json` or `nano config.json`
+4. Save the file and [[restart the bot|Shutdown or restart the bot]].
\ No newline at end of file
diff --git a/wiki/Flyway-guide-with-solutions-to-common-issues.md b/wiki/Flyway-guide-with-solutions-to-common-issues.md
new file mode 100644
index 0000000000..74c826eb3b
--- /dev/null
+++ b/wiki/Flyway-guide-with-solutions-to-common-issues.md
@@ -0,0 +1,42 @@
+# Migration
+
+## Context
+Whenever you make changes to state of DB, you have to write a new SQL script in `/resources/db` directory.
+
+Let's say you wanna modify an existing table in DB, you write a script `V14__Alter_Help_Thread_Metadata.sql`.
+
+_Let's ignore commented SQL for now, say first iteration of your script only has first two entries_
+
+
+ when you run the application for first time. Flyway will do the migration, i.e. keep track of that change and verify it stays same.
+
+ On successful run of application, it will create a new entry in table `flyway_schema_history` of your local instance of DB.
+
+
+_screenshot of what `flyway_schema_history` table looks like_
+
+Now each time you run your application, it will verify these migrations using `checksum` from this table.
+Any changes made after migration, should be done via seperate script otherwise you run into migration errors.
+
+Now under normal circumstances, once changes are made to production environment you would have to add a new SQL script for any new changes.
+
+ But during development, requirements change frequently. Now you wanna add a new column in your newly created table. so you now add a couple in same SQL script.
+
+
+
+_Notice new entries altering state of table_
+
+Now if you try and run the application, flyway will throw migration error and you won't be able to run the application.
+
+
+
+_Error message in logs should look like this_
+
+## Solution
+1. Open local DB instance, look at table `flyway_schema_history`.
+2. Note down the version of last entry(should have name of your sql script).
+3. Run this sql command in console for local DB, `delete from flyway_schema_history where version = 'VERSION_NUMBER_HERE';`.
+4. Now drop the table/columns that are added via your new sql script using that DB console.
+5. Once you revert back to old state of DB, it's safe to rewrite new SQL script with all the statements.
+6. Run application, now it will create new entry `flyway_schema_history` and you should be able to run application.
+
\ No newline at end of file
diff --git a/wiki/Home.md b/wiki/Home.md
new file mode 100644
index 0000000000..377c4288c5
--- /dev/null
+++ b/wiki/Home.md
@@ -0,0 +1,37 @@
+# Overview 
+
+TJ-Bot is a **Discord Bot** used on the [Together Java](https://discord.com/invite/xxfuxzk) server. It is maintained by the community, anyone can contribute.
+
+If you want to join the party, please have a look at:
+* [[Contributing]]
+* [[Code Guidelines]]
+* [[Create Discord server and bot]]
+* [[Setup project locally]]
+* [[Flyway guide with solutions to common issues]]
+* Features
+ * [[Add a new command]]
+ * [[Add days command]]
+ * [[Add question command]]
+ * [[Adding context commands]]
+ * [[Create and use modals]]
+ * [[Disabling Features]]
+* [[JDA Tips and Tricks]]
+* [[Component IDs]]
+* [[Code in the cloud (codespaces)]]
+* [[Connect SonarLint extension to our SonarCloud]]
+
+We also have several guides explaining the infrastructure, architecture and flow of the project:
+* [[Tech Stack]]
+* [[Component ID Store]]
+
+As well as some tutorials regarding on how to maintain the project:
+* [[Discord Bot Details]]
+* [[Access the VPS]]
+* Logging
+ * [[View the logs]]
+ * [[Change log level]]
+ * [[Setup Log Viewer]]
+* [[Edit the Config]]
+* [[Shutdown or restart the bot]]
+* [[Release a new version]]
+* [[Reset or edit the databases]]
\ No newline at end of file
diff --git a/wiki/JDA-Tips-and-Tricks.md b/wiki/JDA-Tips-and-Tricks.md
new file mode 100644
index 0000000000..8cf0129813
--- /dev/null
+++ b/wiki/JDA-Tips-and-Tricks.md
@@ -0,0 +1,112 @@
+# Overview
+
+This guide gives lists some tips and tricks to ease the life of developers working with JDA, the Discord framework used by this project.
+
+## Tips
+
+### Use [ISnowflake#getIdLong](https://ci.dv8tion.net/job/JDA/javadoc/net/dv8tion/jda/api/entities/ISnowflake.html#getIdLong()) instead of [ISnowflake#getId](https://ci.dv8tion.net/job/JDA/javadoc/net/dv8tion/jda/api/entities/ISnowflake.html#getId())
+
+Internally JDA uses `long`s instead of `String`s, so they are faster and still work.
+
+Example:
+```java
+long userId = event.getUser().getIdLong();
+```
+However, in some cases using long is sub-optimal, for example when comparing it to a component ID. Component IDs are custom strings allowing storing data within the ID.
+Example:
+```java
+String userThatClickedId = event.getUser().getId();
+String userId = idArgs.get(0);
+
+if (userThatClickedId.equals(userId)) {
+ ...
+}
+```
+If you already have a `long`, you'll need to cast this to a String resulting in less readable and more code, when JDA can also do this for you internally.
+
+### Don't forget `.queue();`
+
+Almost all Discord requests do not run automatically and require an explicit `.queue();`.
+
+Affected requests are called [RestActions](https://ci.dv8tion.net/job/JDA/javadoc/net/dv8tion/jda/api/requests/restaction/package-summary.html). The rule of thumb is, if your message returns a result it likely has to be queued. Most IDEs can detect such a situation, as seen in the following example:
+
+
+
+### There are lot of `RestAction` types
+
+Some of the many `RestAction` types give you more flexibility and additional functionality.
+
+For example, when editing a message, you can not just add 500 options to the [TextChannel#editMessageById()](https://ci.dv8tion.net/job/JDA/javadoc/net/dv8tion/jda/api/entities/MessageChannel.html#editMessageById(long,net.dv8tion.jda.api.entities.Message)) method. Instead, it returns a [MessageAction](https://ci.dv8tion.net/job/JDA/javadoc/net/dv8tion/jda/api/requests/restaction/MessageAction.html) object, which allows you to set all the components and more.
+
+### Every JDA related object has a `getJDA` method
+
+Whenever you need an instance of `JDA`, the framework got you covered and offers a general `getJDA()` method available on pretty much any JDA related object.
+
+### Cast [JDA](https://github.com/discord/discord-api-docs/discussions/3581) to `JDAImpl` for more methods
+
+This is a dangerous tip and we advise you to not consider it unless there is really no other option. If you are unsure, please ask the other developers for help.
+
+Internally JDA uses `JDAImpl` instead of `JDA`, which has way more _(internal)_ methods. While almost, if not all, of them are probably not relevant, some might prove useful in very specific use-cases.
+
+Since this is an internal API, breaking changes can happen with any new version of JDA and it also has no documentation.
+
+### [EntityBuilder](https://github.com/DV8FromTheWorld/JDA/blob/development/src/main/java/net/dv8tion/jda/internal/entities/EntityBuilder.java) (internal)
+
+**Note, the entitybuilder isn't meant for serializing and deserializing stored users, it's not backwards compatible.**
+
+EntityBuilder is an internal class of JDA used to create Discord entities (users, guilds, and more) from their JSON value (DataObject's). Within the TJ-Bot we make usage of this to test command's their logic.
+
+By creating "fake" JSON's we can make create an event, members and such using an EntityBuilder.
+A.e the [EntityBuilder#createUser](https://github.com/DV8FromTheWorld/JDA/blob/development/src/main/java/net/dv8tion/jda/internal/entities/EntityBuilder.java#L331) method.
+
+To be more precise, you can view the createUser method [here](https://github.com/DV8FromTheWorld/JDA/blob/development/src/main/java/net/dv8tion/jda/internal/entities/EntityBuilder.java#L331).
+If we'd give the createUser method a DataObject with the following JSON.
+We'd be able to make JDA think Nelly is a real user.
+For an up-to-date example, check the [Discord docs](https://discord.com/developers/docs/resources/user#user-object)
+```json
+/*
+ Note: this json is from 13/10/2021
+ This might be changed at the moment of reading
+*/
+{
+ "id": "80351110224678912",
+ "username": "Nelly",
+ "discriminator": "1337",
+ "avatar": "8342729096ea3675442027381ff50dfe",
+ "verified": true,
+ "email": "nelly@discord.com",
+ "flags": 64,
+ "banner": "06c16474723fe537c283b8efa61a30c8",
+ "accent_color": 16711680,
+ "premium_type": 1,
+ "public_flags": 64
+}
+```
+
+## Tricks
+
+Due to the complexity of JDA, you might easily run into a situation where you solve a problem in a certain but not optimal way that is either overly complex or just very lengthy. This chapter shows some tricks to help you use JDA correct and better.
+
+### Method shortcuts
+
+JDA offers some shortcuts to methods and patterns frequently used:
+* [JDA#openPrivateChannelById](https://ci.dv8tion.net/job/JDA/javadoc/net/dv8tion/jda/api/JDA.html#openPrivateChannelById(long)), instead of manually retrieving the user and calling [User#openPrivateChannel](https://ci.dv8tion.net/job/JDA/javadoc/net/dv8tion/jda/api/entities/User.html#openPrivateChannel())
+* [JDA#getGuildChannelById](https://ci.dv8tion.net/job/JDA/javadoc/net/dv8tion/jda/api/JDA.html#getGuildChannelById(long)) also applies to _textchannels_, _storechannels_ and more. So a [Guild](https://ci.dv8tion.net/job/JDA/javadoc/net/dv8tion/jda/api/entities/Guild.html) instance is not required to get channels.
+
+### Raw events
+
+In case you need to inspect an event send by Discord or JDA closely in its raw JSON form, one can enable raw events and inspect the payloads:
+```java
+// where the JDA instance is created
+JDA jda = JDABuilder.createDefault(...)
+ .setRawEventsEnabled(true) // add this call
+ ...
+ .build();
+
+// and then add a raw event listener
+jda.addEventListener((EventListener) event -> {
+ if (event instanceof RawGatewayEvent rawEvent) {
+ System.out.println(rawEvent.getPayload());
+ }
+});
+```
\ No newline at end of file
diff --git a/wiki/Release-a-new-version.md b/wiki/Release-a-new-version.md
new file mode 100644
index 0000000000..6d0703f8ea
--- /dev/null
+++ b/wiki/Release-a-new-version.md
@@ -0,0 +1,35 @@
+# Overview
+
+Thanks to a rich pipeline, releasing a new version of the bot is fairly simple.
+
+It mainly consists of simply **pushing** `develop` over on `master`, creating an **annotated tag** for the release and possibly adjusting the **configuration** and the Discord environment, thats it.
+
+## Checklist
+
+1. Determine the next release version (for example `v1.2.3`)
+2. Create a PR to merge `develop` into `master`, call it for example `Release v1.2.3` and tag it as `release`; the PRs only purpose is visibility
+3. Ignore the PR(don't merge it via Github) and `rebase` `master` directly onto `develop`, then `force-push`(might not need to do this, try just pushing). As a result, `master` and `develop` are fully identical
+ 3.1. The PR should now automatically be marked as _merged_ by GitHub
+ 3.2. In the meantime, the pipeline automatically started deploying the new version to the server
+
+ *Note: for those who are not good with rebase, make sure to have your `develop` branch upto date. Switch to `master`, do `git rebase develop`.*
+4. Create and push an **annotated tag** like `v.1.2.3` with a short release description from the state of `master`
+ 4.1. The pipeline will now create a new release on GitHub
+ 4.2. Once the release has been created, you can adjust and beautify the description, see [releases](https://github.com/Together-Java/TJ-Bot/releases)
+ Note: There's two types of tags, annotated and normal tags. We want annotated tags, to create one via intellij follow instructions in given screenshot
+
+ CREATING AN ANNOTATED TAG IN INTELLIJ
+
+ 
+
+ PUSHING ANNOTATED TAG
+
+ `git push --follow-tags`
+
+ read more here: https://git-scm.com/docs/git-push
+
+5. In case the configuration (`config.json`) changed, make sure to update it; see [[Edit the Config]]
+6. In case the new version requires changes on Discord, such as other permissions, new channels or roles, make sure to update them as well
+7. Verify that the bot works as expected
+ 7.1. Try `/ping` and see if you get a response
+ 7.2. Maybe check the logs to see if any error pops up, see [[View the logs]]
\ No newline at end of file
diff --git a/wiki/Reset-or-edit-the-databases.md b/wiki/Reset-or-edit-the-databases.md
new file mode 100644
index 0000000000..05110ea51c
--- /dev/null
+++ b/wiki/Reset-or-edit-the-databases.md
@@ -0,0 +1,45 @@
+# Overview
+
+In order to reset of edit the databases used by the bot, one has to login to the VPS and navigate to the corresponding directory. Only members of the [Moderator](https://github.com/orgs/Together-Java/teams/moderators)-Team can do the following steps.
+
+See [[Access the VPS]] for details of the login process.
+
+# Guide
+
+1. `ssh togetherjava` to login to the VPS
+2. Consider temporarilly shutting down the bot during the database edits (see [[Shutdown or restart the bot]])
+3. Either `cd /var/lib/docker/volumes/tj-bot-master-database/_data` or `cd /var/lib/docker/volumes/tj-bot-develop-database/_data` to go to the directory of the corresponding database
+4. Edit the database manually, it is a [SQLite 3](https://www.sqlite.org/index.html) database.
+
+## Working with the database
+
+To ease inspecting and editing the database, the `sqlite3` CLI is installed on the VPS.
+
+Please make sure to either shut down the bot in the meantime or working on a copy of the database instead, to **avoid locking the actual database**:
+```bash
+cp database.db database_copy.db
+```
+
+Here are some simple example queries:
+* Connect to the database
+```bash
+sqlite3 database_copy.db
+```
+* List all available tables:
+```sql
+.tables
+```
+* Show the structure of the table
+```sql
+.schema moderation_actions
+```
+* Show all against against a user
+```sql
+SELECT * FROM moderation_actions WHERE author_id = 123456789
+```
+* Exist the database
+```sql
+.exit
+```
+
+
\ No newline at end of file
diff --git a/wiki/Setup-Log-Viewer.md b/wiki/Setup-Log-Viewer.md
new file mode 100644
index 0000000000..8f3a93e70f
--- /dev/null
+++ b/wiki/Setup-Log-Viewer.md
@@ -0,0 +1,57 @@
+# Logviewer
+
+The **logviewer** module is a _Spring + Vaadin Web-Application_ in which one can see the logs written by the applications in real-time.
+
+
+
+In the following, we explain how to set the website up and how to host it.
+
+## Setup
+
+1. First you need a _Discord application_, you can use the same also used for the main application. See [[Create Discord server and bot]] for details on how to create one.
+2. Open the [Applications Section](https://discord.com/developers/applications) from Discord
+3. Open **your Application**
+4. Open the **OAuth2 Tab**
+5. Add a **Redirect Link**, e.g. `https://localhost:443/login/oauth2/code/github`
+6. Save your changes
+7. Create a `config.json` file in the directory of the logviewer module `TJ-Bot/logviewer/config.json`. Alternatively you can place it wherever you want and provide the path to the file as a start-argument. The content of the file should be like this (fill in the Discord data):
+```json
+{
+ "clientName": "",
+ "clientId": "",
+ "clientSecret": "",
+ "rootUserName": "",
+ "rootDiscordID": "",
+ "logPath": "application/logs",
+ "databasePath": "logviewer/db/db.db",
+ "redirectPath": "https://localhost:443/login/oauth2/code/github"
+}
+```
+#### Explanation for the parameters
+
+* `clientName` is the name of your Discord Application
+* `clientId` is the clientId you can [copy in the OAuth2 Tab](https://i.imgur.com/x7mUyUW.png) from Discord
+* `clientSecret` is the secret you can [copy in the OAuth2 Tab](https://i.imgur.com/YEJzMAS.png)
+* `rootUserName` is your own Discord username
+* `rootDiscordID` is your own Discord ID, enable Developer Mode in your Discord App and [right-click](https://i.imgur.com/z0FjqPC.png) on one of your own posts
+* `logPath` is the path to the logs from the Bot, not for this application
+* `databasePath` is the path where the database for this Web-Application should be saved
+* `redirectPath` is the URL you used in the Discord OAuth2 Settings
+
+8. You are done, start the application. Open your browser on https://localhost:443 and **accept the Authorization**.
+
+
+
+
+
+
+
+## Quick overview
+
+On the **left side** you can see three views.
+* **Logs** displays the actual logfiles as they are in the configured directories.
+* **Streamed** displays the log-events as the main application sends them to the web application.
+* **User-Management** enables adding or removing users who can access this website and editing their roles (right click the panel).
+
+
+
\ No newline at end of file
diff --git a/wiki/Setup-project-locally.md b/wiki/Setup-project-locally.md
new file mode 100644
index 0000000000..8b69928eee
--- /dev/null
+++ b/wiki/Setup-project-locally.md
@@ -0,0 +1,129 @@
+# Overview
+
+This tutorial shows how to download, setup and start the **TJ-Bot** project locally on your machine.
+
+Alternatively, you can also work directly in the cloud, for free, and get started in just a few seconds. See:
+* [[Code in the cloud (codespaces)]]
+
+## Prerequisites
+* [Java 24](https://adoptium.net/temurin/releases?version=24) installed
+* your favorite Java IDE or text editor, e.g. [IntelliJ](https://www.jetbrains.com/idea/download/) or [Eclipse](https://www.eclipse.org/downloads/)
+* [`git`](https://git-scm.com/downloads) installed (or any GUI or IDE plugin)
+* [`gradle`](https://gradle.org/releases/) available (or any GUI or IDE plugin), you can either install it or use our provided wrapper
+* your own [Discord](https://discord.com/)-Bot, tied to a server (see [[Create Discord server and bot]])
+ * a token of that bot
+
+## What you will learn
+* use git to download the project
+* use gradle to download dependencies
+* use gradle to build the project
+* connect your bot to the program
+* use gradle to start the bot
+* interact with the bot from your server
+
+# Tutorial
+
+## Clone repository
+
+First of all, you have to download the project to your machine. Visit the projects [GitHub website](https://github.com/Together-Java/TJ-Bot) and copy the `.git` link, which is this
+```
+https://github.com/Together-Java/TJ-Bot.git
+```
+
+
+### IntelliJ git plugin
+
+IntelliJ comes by default with a `git` plugin. You can easily clone repositories to your disk by clicking a few buttons.
+
+1. open your IntelliJ and select `Get from VCS`.
+2. select `Git`, enter the `.git` link and select a directory for the project; smack that `Clone` button
+3. IntelliJ will now open the project
+
+
+
+
+### Manual usage of `git`
+
+To download the project, use the following command:
+```bash
+git clone https://github.com/Together-Java/TJ-Bot.git TJ-Bot
+```
+You now have the project and all its data locally.
+
+
+
+
+## Gradle
+
+Next up, you have to download all the dependencies, generate the database and build the project.
+
+### IntelliJ Gradle plugin
+
+IntelliJ comes by default with a `gradle` plugin. If not started already automatically, you can command it to do all of above by clicking a bunch of buttons.
+
+1. open the Gradle view
+2. expand the view and click on `TJ-Bot > Tasks > build > build`, or just click on the elephant icon and enter `gradle build`
+
+
+
+
+
+
+βΉοΈ If you get any gradle errors...
+Make sure that your project and gradle is setup to use the latest Java version. Sometimes IntelliJ might guess it wrong and mess up, leading to nasty issues.
+
+Therefore, review your **Project Structure** settings and the **Gradle** settings:
+
+
+
+
+### Manual usage of `gradle`
+
+You can also just execute Gradle from the command line.
+
+1. open a command line in the root directory of the project
+2. execute `gradle build`
+
+
+
+
+## Start the bot
+
+Last but not least, you want to start the bot with your bot token and let it connect to your private bot with which you can interact from one of your servers.
+
+For this step, you need to hold your bot token ready, you can find it at the [Discord Developer Portal](https://discord.com/developers/applications).
+
+See the following guide if you still have to create a server and a bot first:
+* [[Create Discord server and bot]]
+
+
+
+To run the bot, you will need a `config.json` file with specific content. You can find a template for this file, with meaningful default values, in `application/config.json.template`.
+
+Replace `` with your bot token; you can also adjust the other settings if you want.
+
+### IntelliJ
+
+1. put the configuration file to `TJ-Bot\application\config.json` or run the program with a single argument, the path to your config file
+2. in the Gradle view, click the `run` task and start it
+
+
+
+### Command line, runnable jar
+
+1. build a runnable jar of the project by executing `gradle shadowJar`
+ 1.1. the jar can now be found at `TJ-Bot\application\build\libs`
+2. unless you move the jar around, you have to adjust the database path in the config to `../../../build/database.db`
+3. put the configuration file right next to the jar or run the program with a single argument, the path to your config file
+4. run `java -jar TJ-Bot.jar`
+
+
+
+
+### Have fun
+
+The bot is now running and connected to your server, hurray π
+
+You can now execute commands and see the bot do its magic:
+
+
diff --git a/wiki/Shutdown-or-restart-the-bot.md b/wiki/Shutdown-or-restart-the-bot.md
new file mode 100644
index 0000000000..110d556cd1
--- /dev/null
+++ b/wiki/Shutdown-or-restart-the-bot.md
@@ -0,0 +1,14 @@
+# Overview
+
+In order to shutdown or restart any of the bots, one has to login to the VPS and command Docker to execute the corresponding task. Only members of the [Moderator](https://github.com/orgs/Together-Java/teams/moderators)-Team can do the following steps.
+
+See [[Access the VPS]] for details of the login process.
+
+# Guide
+
+1. `ssh togetherjava` to login to the VPS
+2. Either `cd ~/docker-infra/master-bot` or `cd ~/docker-infra/develop-bot` to go to the directory of the corresponding bot
+3. Execute the corresponding `docker-compose` command
+ a. To issue a graceful shutdown, execute `docker-compose down`
+ b. To shutdown the bot forcefully, use `docker-compose kill`
+ c. To command a restart of the bot, put `docker-compose restart`
\ No newline at end of file
diff --git a/wiki/Tech-Stack.md b/wiki/Tech-Stack.md
new file mode 100644
index 0000000000..f1296c9c36
--- /dev/null
+++ b/wiki/Tech-Stack.md
@@ -0,0 +1,47 @@
+# Overview
+
+TJ-Bot is a classic Discord bot with a slim but modern set of dependencies.
+
+
+
+## Core
+
+The project stays up to date with the latest Java version.
+
+We use [JDA](https://github.com/DV8FromTheWorld/JDA) to communicate with the Discord API and [Gradle](https://gradle.org/) to manage the project and its dependencies.
+
+## Database
+
+The bot uses a single [SQLite](https://www.sqlite.org/index.html) database, which is generated automatically by [Flyway](https://flywaydb.org/) based on the scripts found in
+```
+TJ-Bot/application/src/main/resources/db/
+```
+Interaction with the database is then done using [jOOQ](https://www.jooq.org/).
+
+## Logging
+
+We rely on [SLF4J](http://www.slf4j.org/) for logging, backed by [Log4j 2](https://logging.apache.org/log4j/2.x/).
+
+The configuration can be found at
+```
+TJ-Bot/application/src/main/resources/log4j2.xml
+```
+
+## Testing
+
+For testing the project, we use [JUnit 5](https://junit.org/junit5/docs/current/user-guide/).
+
+## Code Quality
+
+The quality of the code is ensured by [Spotless](https://github.com/diffplug/spotless), using a strict style based on the commonly used [Google Java Style](https://google.github.io/styleguide/javaguide.html). The exact style definition can be found at:
+```
+TJ-Bot/meta/formatting/google-style-eclipse.xml
+```
+
+Additionally, we use static code analyse by [SonarCloud](https://sonarcloud.io/dashboard?id=Together-Java_TJ-Bot).
+
+Further, the code is checked automatically by [CodeQL](https://codeql.github.com/docs/) and dependencies are kept up to date with the aid of [Dependabot](https://dependabot.com/)
+
+## Deployment
+
+In order for the bot to actually go live, it is deployed as [Docker](https://www.docker.com/) image, build by [jib](https://github.com/GoogleContainerTools/jib), to a VPS provided by [Hetzner](https://www.hetzner.com/) (see [[Access the VPS]] for details).
\ No newline at end of file
diff --git a/wiki/View-the-logs.md b/wiki/View-the-logs.md
new file mode 100644
index 0000000000..3fd44e0617
--- /dev/null
+++ b/wiki/View-the-logs.md
@@ -0,0 +1,31 @@
+# Overview
+
+There are two ways to read the logs of the bots:
+* by reading the forwarded messages in **Discord**
+* by manually logging in to the **VPS** and looking up the log files
+
+The log level can be changed temporarily using the command `/set-log-level`.
+
+## Discord
+
+All log messages, with a few sensitive exceptions, are forwarded to Discord via webhooks. You can read them in the two channels:
+
+* **tjbot_log_info** - contains all `INFO`, `DEBUG`, `TRACE` messages
+* **tjbot_log_error** - contains all `WARN`, `ERROR`, `FATAL` messages
+
+
+
+## Manually viewing the files
+
+In order to read the log files of the bots directly, one has to login to the VPS and command Docker to execute the corresponding task. Only members of the [Moderator](https://github.com/orgs/Together-Java/teams/moderators)-Team can do the following steps.
+
+See [[Access the VPS]] for details of the login process.
+
+# Guide
+
+1. `ssh togetherjava` to login to the VPS
+2. Either `cd ~/docker-infra/master-bot` or `cd ~/docker-infra/develop-bot` to go to the directory of the corresponding bot
+3. Execute `docker-compose logs -f`
+4. Hit Ctrl + C to stop
+
+
\ No newline at end of file
diff --git a/wiki/_Sidebar.md b/wiki/_Sidebar.md
new file mode 100644
index 0000000000..765dee1aa9
--- /dev/null
+++ b/wiki/_Sidebar.md
@@ -0,0 +1,39 @@
+#### Home
+
+* [[Overview|Home]]
+
+#### Join the party
+
+* [[Contributing]]
+* [[Code Guidelines]]
+* [[Create Discord server and bot]]
+* [[Setup project locally]]
+* [[Flyway guide with solutions to common issues]]
+* Features
+ * [[Add a new command]]
+ * [[Add days command]]
+ * [[Add question command]]
+ * [[Adding context commands]]
+ * [[Create and use modals]]
+* [[JDA Tips and Tricks]]
+* [[Component IDs]]
+* [[Code in the cloud (codespaces)]]
+* [[Connect SonarLint extension to our SonarCloud]]
+
+#### Project documentation
+
+* [[Tech Stack]]
+* [[Component ID Store]]
+
+#### Maintenance
+
+* [[Discord Bot Details]]
+* [[Access the VPS]]
+* Logging
+ * [[View the logs]]
+ * [[Change log level]]
+ * [[Setup Log Viewer]]
+* [[Edit the Config]]
+* [[Shutdown or restart the bot]]
+* [[Release a new version]]
+* [[Reset or edit the databases]]
\ No newline at end of file