diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/Commands.java b/application/src/main/java/org/togetherjava/tjbot/commands/Commands.java index c099717579..4f58449d44 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/Commands.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/Commands.java @@ -7,6 +7,7 @@ import org.togetherjava.tjbot.commands.free.FreeCommand; import org.togetherjava.tjbot.commands.mathcommands.TeXCommand; import org.togetherjava.tjbot.commands.moderation.*; +import org.togetherjava.tjbot.commands.modmail.ModmailCommand; import org.togetherjava.tjbot.commands.tags.TagCommand; import org.togetherjava.tjbot.commands.tags.TagManageCommand; import org.togetherjava.tjbot.commands.tags.TagSystem; @@ -70,6 +71,10 @@ public enum Commands { commands.add(new MuteCommand(actionsStore)); commands.add(new UnmuteCommand(actionsStore)); + ModmailCommand modmailCommand = new ModmailCommand(); + commands.add(modmailCommand); + commands.add(modmailCommand.new ReloadModmailCommand()); + return commands; } } diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/modmail/ModmailCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/modmail/ModmailCommand.java new file mode 100644 index 0000000000..463b9ca482 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/modmail/ModmailCommand.java @@ -0,0 +1,169 @@ +package org.togetherjava.tjbot.commands.modmail; + +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.events.ReadyEvent; +import net.dv8tion.jda.api.events.interaction.SelectionMenuEvent; +import net.dv8tion.jda.api.events.interaction.SlashCommandEvent; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.components.ActionRow; +import net.dv8tion.jda.api.interactions.components.selections.SelectOption; +import net.dv8tion.jda.api.interactions.components.selections.SelectionMenu; +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.config.Config; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Command that either sends a direct message to a moderator or sends the message to the dedicated + * mod audit channel by the moderators. + */ +public class ModmailCommand extends SlashCommandAdapter { + private static final Logger logger = LoggerFactory.getLogger(ModmailCommand.class); + + private static final String COMMAND_NAME = "modmail"; + private static final String TARGET_OPTION = "message"; + private static final Config config = Config.getInstance(); + + final List mods = new ArrayList<>(); + + /** + * Creates an instance of the ModMail command. + */ + public ModmailCommand() { + super(COMMAND_NAME, + "sends a message to either a single moderator or on the mod_audit_log channel", + SlashCommandVisibility.GLOBAL); + + getData().addOption(OptionType.STRING, TARGET_OPTION, "The message to send", true); + + mods.add(SelectOption.of("All Moderators", "all")); + } + + @Override + public void onSlashCommand(@NotNull SlashCommandEvent event) { + String memberId = event.getUser().getId(); + + event.reply(""" + Select the moderator to send message to, or select "All Moderators" to send to + the guild's mod audit channel. + """) + .addActionRow(SelectionMenu + .create(generateComponentId(memberId, event.getOption(TARGET_OPTION).getAsString())) + .addOptions(mods) + .build()) + .setEphemeral(false) + .queue(); + } + + @Override + public void onSelectionMenu(@NotNull SelectionMenuEvent event, @NotNull List args) { + String message = args.get(1); + // Ignore if another user clicked the button. + String userId = args.get(0); + if (!userId.equals(event.getUser().getId())) { + event.reply( + "Sorry, but only the user who triggered the command can interact with the menu.") + .setEphemeral(true) + .queue(); + return; + } + + SelectionMenu disabledMenu = event.getSelectionMenu().asDisabled(); + + // did user select to send message to all mods + String modId = event.getValues().get(0); + if (modId.equals("all")) { + // currently blocked by #296 + event.reply("Message now sent to all mods").setEphemeral(true).queue(); + return; + } + + // disable selection menu + event.getMessage().editMessageComponents(ActionRow.of(disabledMenu)).queue(); + + boolean wasSent = sendToMod(modId, message, event); + if (!wasSent) { + event.reply("The moderator you chose was not found on the guild.") + .setEphemeral(true) + .queue(); + + String modSelectedByUser = event.getSelectedOptions().get(0).getLabel(); + logger.warn(""" + Moderator '{}' chosen by user is not on the guild. Use the /reloadmod command + to update the list of moderators. + """, modSelectedByUser); + + return; + } + + event.reply("Message now sent to selected moderator").setEphemeral(true).queue(); + } + + /** + * Populates the list of moderators and stores it into a list to avoid querying an expensive + * call to discord everytime the command is used. + * + * @param event the event that triggered this method + */ + @Override + public void onReady(@NotNull ReadyEvent event) { + Guild guild = event.getJDA().getGuildById(config.getGuildId()); + ModmailUtil.listOfMod(guild, mods); + } + + private boolean sendToMod(@NotNull String modId, @NotNull String message, + @NotNull SelectionMenuEvent event) { + // the else is when the user invoked the command not on the context of a guild. + Guild guild = Objects.requireNonNullElse(event.getGuild(), + event.getJDA().getGuildById(config.getGuildId())); + + return !guild.retrieveMemberById(modId) + .submit() + .thenCompose(user -> user.getUser().openPrivateChannel().submit()) + .thenAccept(channel -> channel + .sendMessageEmbeds(ModmailUtil.messageEmbed(event.getUser().getName(), message)) + .queue()) + .whenComplete((v, err) -> { + if (err != null) + err.printStackTrace(); + }) + .isCompletedExceptionally(); + } + + /** + * Reloads the list of moderators to choose from from the {@link ModmailCommand}. + *

+ * Only members who have the Moderator role can use this command. + */ + public class ReloadModmailCommand extends SlashCommandAdapter { + + private static final String COMMAND_NAME = "reloadmod"; + + public ReloadModmailCommand() { + super(COMMAND_NAME, "reloads the moderators in the modmail command", + SlashCommandVisibility.GUILD); + } + + @Override + public void onSlashCommand(@NotNull SlashCommandEvent event) { + mods.clear(); + ModmailUtil.listOfMod(event.getGuild(), mods); + + if (ModmailUtil.doesUserHaveModRole(event.getMember(), event.getGuild())) { + event.reply("List of moderators has now been updated.").setEphemeral(true).queue(); + return; + } + + event.reply("Only moderators can use this command.").setEphemeral(true).queue(); + } + + } + + +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/modmail/ModmailUtil.java b/application/src/main/java/org/togetherjava/tjbot/commands/modmail/ModmailUtil.java new file mode 100644 index 0000000000..dc0c90da40 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/modmail/ModmailUtil.java @@ -0,0 +1,89 @@ +package org.togetherjava.tjbot.commands.modmail; + +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.entities.Role; +import net.dv8tion.jda.api.interactions.components.selections.SelectOption; +import org.jetbrains.annotations.NotNull; +import org.togetherjava.tjbot.config.Config; + +import java.awt.*; +import java.util.List; +import java.util.Optional; +import java.util.function.Predicate; +import java.util.regex.Pattern; + +/** + * Utility methods that interact either directly or indirectly with the {@link ModmailCommand}. + */ +public class ModmailUtil { + + public static final Predicate isModRole = + Pattern.compile(Config.getInstance().getHeavyModerationRolePattern()) + .asMatchPredicate(); + + private ModmailUtil() { + throw new UnsupportedOperationException(); + } + + /** + * Finds the moderators on the given guild and stores it for later use. + *

+ * This call is expensive, thus, it should only be used preferably once by storing the result + * from the given list or seldomly when moderators want to reload the list of moderators to + * choose from from the {@link ModmailCommand}. + *

+ * Since the elements in the given list will not be overwritten, the caller is responsible in + * doing such actions. + */ + public static void listOfMod(@NotNull Guild guild, List modsOptions) { + Role modRole = getModRole(guild) + .orElseThrow(() -> new IllegalStateException("No moderator role found")); + + guild.findMembersWithRoles(modRole) + .onSuccess(mods -> mods.forEach( + mod -> modsOptions.add(SelectOption.of(mod.getEffectiveName(), mod.getId())))); + } + + /** + * Gets the moderator role. + * + * @param guild the guild to get the moderator role from + * @return the moderator role, if found + */ + public static @NotNull Optional getModRole(@NotNull Guild guild) { + return guild.getRoles().stream().filter(role -> isModRole.test(role.getName())).findAny(); + } + + /** + * Checks whether the given member is a moderator on the given guild. + *

+ * See {@link Config#getHeavyModerationRolePattern()}. + * + * @param member the member to check for moderator role. + * @param guild the guild to get the moderator role from. + * @return true if the member has the role Moderator + */ + public static boolean doesUserHaveModRole(@NotNull Member member, @NotNull Guild guild) { + return member.canInteract(getModRole(guild) + .orElseThrow(() -> new IllegalStateException("No moderator role found"))); + } + + /** + * Creates a color black {@link MessageEmbed} with a non-inline field of the supplied message. + * + * @param user the user who invoked the command. + * @param message the message the user wants to send to to a moderator or the moderators. + * @return returns a {@link MessageEmbed} to send to the moderator. + */ + public static MessageEmbed messageEmbed(@NotNull String user, @NotNull String message) { + return new EmbedBuilder().setAuthor("Modmail Command invoked") + .setColor(Color.BLACK) + .setTitle("Message from user '%s' who used /modmail command".formatted(user)) + .addField("Message", message, false) + .build(); + } + +} diff --git a/application/src/main/java/org/togetherjava/tjbot/config/Config.java b/application/src/main/java/org/togetherjava/tjbot/config/Config.java index 35c077dc13..d62c42b768 100644 --- a/application/src/main/java/org/togetherjava/tjbot/config/Config.java +++ b/application/src/main/java/org/togetherjava/tjbot/config/Config.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.ObjectMapper; import org.jetbrains.annotations.NotNull; +import org.togetherjava.tjbot.commands.modmail.ModmailCommand; import java.io.IOException; import java.nio.file.Path; @@ -32,6 +33,7 @@ public final class Config { private final String heavyModerationRolePattern; private final String softModerationRolePattern; private final String tagManageRolePattern; + private final String guildId; private final List freeCommand; @@ -46,6 +48,7 @@ private Config(@JsonProperty("token") String token, @JsonProperty("heavyModerationRolePattern") String heavyModerationRolePattern, @JsonProperty("softModerationRolePattern") String softModerationRolePattern, @JsonProperty("tagManageRolePattern") String tagManageRolePattern, + @JsonProperty("guildId") String guildId, @JsonProperty("freeCommand") List freeCommand) { this.token = token; this.databasePath = databasePath; @@ -56,6 +59,7 @@ private Config(@JsonProperty("token") String token, this.heavyModerationRolePattern = heavyModerationRolePattern; this.softModerationRolePattern = softModerationRolePattern; this.tagManageRolePattern = tagManageRolePattern; + this.guildId = guildId; this.freeCommand = Collections.unmodifiableList(freeCommand); } @@ -178,4 +182,13 @@ public String getTagManageRolePattern() { public @NotNull Collection getFreeCommandConfig() { return freeCommand; // already unmodifiable } + + /** + * Gets the id of the guild. See {@link ModmailCommand} for such uses + * + * @return the guildId. + */ + public String getGuildId() { + return guildId; + } }