Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@
import net.dv8tion.jda.api.events.interaction.command.MessageContextInteractionEvent;
import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent;
import net.dv8tion.jda.api.exceptions.ErrorHandler;
import net.dv8tion.jda.api.interactions.InteractionHook;
import net.dv8tion.jda.api.interactions.commands.build.Commands;
import net.dv8tion.jda.api.interactions.components.buttons.Button;
import net.dv8tion.jda.api.interactions.components.buttons.ButtonStyle;
import net.dv8tion.jda.api.requests.ErrorResponse;
import net.dv8tion.jda.api.requests.RestAction;
import net.dv8tion.jda.api.requests.restaction.MessageCreateAction;
Expand Down Expand Up @@ -49,19 +49,29 @@
import java.util.stream.Collectors;

/**
* Allows users to report a message as potential scam. Moderators can confirm the report from the
* audit log, causing the author to be quarantined plus message history getting deleted.
* Allows users to alert moderators about a message that violates the rules. Moderators then decide
* how to handle it:
* <ul>
* <li><b>Scam</b>: the author is quarantined, then banned and unbanned to delete their recent
* messages.</li>
* <li><b>Job-Ad</b>: the author is warned, then banned and unbanned to delete their recent
* messages.</li>
* <li><b>Dismiss</b>: nothing happens, the alert is closed.</li>
* </ul>
* The ban and unban only serves to delete the messages, the user is free to join back afterward.
*/
public final class ThisIsScamCommand extends BotCommandAdapter implements MessageContextCommand {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(rename the file, ThisIsAgainstRulesCommand)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will do it at the end

private static final Logger logger = LoggerFactory.getLogger(ThisIsScamCommand.class);

private static final String COMMAND_NAME = "this-is-scam";
private static final String COMMAND_NAME = "this-is-against-rules";

private static final String ACTION_TITLE = "Quarantine";
private static final String ACTION_REASON = "Message was reported and confirmed as scam";
private static final String SCAM_REASON = "Message was reported and confirmed as scam";
private static final String JOB_AD_REASON =
"Message was reported and confirmed as a job advertisement";

private static final String FAILED_MESSAGE =
"Sorry, there was an issue forwarding your scam report to the moderators. We are investigating.";
"Sorry, there was an issue forwarding your report to the moderators. We are investigating.";

private static final Duration USER_COMMAND_COOLDOWN = Duration.ofMinutes(1);

private final Config config;
Expand All @@ -78,8 +88,8 @@ public final class ThisIsScamCommand extends BotCommandAdapter implements Messag
/**
* Creates a new instance.
*
* @param config to resolve the moderation audit log channel and quarantined role
* @param actionsStore used to store issued quarantine actions
* @param config resolve the mod channel
* @param actionsStore used to store issued moderative actions
*/
public ThisIsScamCommand(Config config, ModerationActionsStore actionsStore) {
super(Commands.message(COMMAND_NAME), CommandVisibility.GUILD);
Expand All @@ -98,16 +108,18 @@ public void onMessageContext(MessageContextInteractionEvent event) {
return;
}

Optional<TextChannel> modAuditLog = findModMailChannel(event);
if (modAuditLog.isEmpty()) {
Message message = event.getTarget();
Optional<TextChannel> modChannel = findModChannel(event);
if (modChannel.isEmpty()) {
reportedMessageToTimestamp.invalidate(message.getIdLong());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why that logic change?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's an edge-case: I noticed an issue while doing the tests. I know the handleWasAlreadyReportedMessage() puts the message Id in reportedMessageToTimestamp before the alert is sent to mods, to block duplicate reports. But If the send then fails, the message stay marked as already reported for the cooldown duration. which is 1 day and I keep getting that msg again an again. But with this change, invalidating that entry on failure so the report can be retried once a problem is fixed. It only runs on the failure cases -> the successful alerts are unaffected

event.reply(FAILED_MESSAGE).setEphemeral(true).queue();
return;
}

Message message = event.getTarget();
reportToMods(message, modAuditLog.orElseThrow()).mapToResult().map(result -> {
reportToMods(message, modChannel.orElseThrow()).mapToResult().map(result -> {
if (result.isFailure()) {
logger.warn("Unable to forward a scam report to the mod audit log channel.",
reportedMessageToTimestamp.invalidate(message.getIdLong());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why that logic change?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logger.warn("Unable to forward a report to the mod mail channel.",
result.getFailure());
return FAILED_MESSAGE;
}
Expand All @@ -130,43 +142,39 @@ private boolean handleIsOnCooldown(MessageContextInteractionEvent event) {
return false;
}

event.reply("You just reported a message as scam, please wait a bit.")
.setEphemeral(true)
.queue();
event.reply("You just reported a message, please wait a bit.").setEphemeral(true).queue();
return true;
}

private boolean handleWasAlreadyReportedMessage(MessageContextInteractionEvent event) {
long messageId = event.getTarget().getIdLong();
if (reportedMessageToTimestamp.getIfPresent(messageId) != null) {
event.reply("This message was already reported as potential scam.")
.setEphemeral(true)
.queue();
event.reply("This message was already reported.").setEphemeral(true).queue();
return true;
}

reportedMessageToTimestamp.put(messageId, Instant.now());
return false;
}

private Optional<TextChannel> findModMailChannel(MessageContextInteractionEvent event) {
private Optional<TextChannel> findModChannel(MessageContextInteractionEvent event) {
Guild guild = Objects.requireNonNull(event.getGuild());
Optional<TextChannel> modMailChannel = Guilds.findTextChannel(guild, isModMailChannel);
if (modMailChannel.isEmpty()) {
logger.warn(
"Cannot find the designated mod audit log channel in guild '{}' with the pattern '{}'",
guild.getId(), config.getModAuditLogChannelPattern());
"Cannot find the designated mod mail channel in guild '{}' with the pattern '{}'",
guild.getId(), config.getModMailChannelPattern());
}
return modMailChannel;
}

private MessageCreateAction reportToMods(Message message, TextChannel auditChannel) {
private MessageCreateAction reportToMods(Message message, TextChannel modMailChannel) {
User author = message.getAuthor();
String description = createDescription(message);
long accountAgeDays = ChronoUnit.DAYS.between(author.getTimeCreated(),
OffsetDateTime.now(ZoneOffset.UTC));

MessageEmbed reportEmbed = new EmbedBuilder().setTitle("Is this Scam?")
MessageEmbed reportEmbed = new EmbedBuilder().setTitle("Rule Violation")
.setDescription(
MessageUtils.abbreviate(description, MessageEmbed.DESCRIPTION_MAX_LENGTH))
.setAuthor(author.getName(), null, author.getEffectiveAvatarUrl())
Expand All @@ -175,13 +183,19 @@ private MessageCreateAction reportToMods(Message message, TextChannel auditChann
.setFooter("%s - account age %d days".formatted(author.getId(), accountAgeDays))
.build();

long guildId = message.getGuild().getIdLong();
long authorId = author.getIdLong();
String[] args = {String.valueOf(guildId), String.valueOf(authorId)};

return auditChannel.sendMessageEmbeds(reportEmbed)
.addActionRow(Button.success(generateComponentId(args), "Yes"),
Button.danger(generateComponentId(args), "No"));
String guildId = String.valueOf(message.getGuild().getIdLong());
String authorId = String.valueOf(author.getIdLong());

return modMailChannel.sendMessageEmbeds(reportEmbed)
.addActionRow(
Button.success(generateComponentId(guildId, authorId, ReportAction.SCAM.name()),
"Scam"),
Button.success(
generateComponentId(guildId, authorId, ReportAction.JOB_AD.name()),
"Job-Ad"),
Button.danger(
generateComponentId(guildId, authorId, ReportAction.DISMISS.name()),
"Dismiss"));
}

private static String createDescription(Message target) {
Expand Down Expand Up @@ -209,29 +223,20 @@ private static String createDescription(Message target) {
public void onButtonClick(ButtonInteractionEvent event, List<String> args) {
long guildId = Long.parseLong(args.get(0));
long targetId = Long.parseLong(args.get(1));
ReportAction action = ReportAction.valueOf(args.get(2));

ButtonStyle clickedStyle = event.getButton().getStyle();
boolean isScam = clickedStyle == ButtonStyle.SUCCESS;

MessageEmbed resultEmbed = new EmbedBuilder()
.setDescription(
isScam ? "This is scam. The user was quarantined and messages were deleted."
: "This is not scam, no action executed.")
.setColor(isScam ? Color.GREEN : Color.RED)
.build();

List<MessageEmbed> embeds = new ArrayList<>(event.getMessage().getEmbeds());
embeds.add(resultEmbed);

event.editMessageEmbeds(embeds).setComponents().queue();
Member moderator = Objects.requireNonNull(event.getMember());
List<MessageEmbed> existingEmbeds = new ArrayList<>(event.getMessage().getEmbeds());

if (!isScam) {
if (action == ReportAction.DISMISS) {
event
.editMessageEmbeds(createResponseEmbeds(existingEmbeds,
"Dismissed, no action executed.", Color.RED))
.setComponents()
.queue();
return;
}

Guild guild = Objects.requireNonNull(event.getJDA().getGuildById(guildId));
Member moderator = Objects.requireNonNull(event.getMember());

ErrorHandler errorHandler = new ErrorHandler()
.handle(ErrorResponse.UNKNOWN_USER, failure -> logger.debug(LogMarkers.SENSITIVE,
"Attempted to handle user-reported scam, but user '{}' does not exist anymore.",
Expand All @@ -240,41 +245,76 @@ public void onButtonClick(ButtonInteractionEvent event, List<String> args) {
"Attempted to handle user-reported scam, but user '{}' is not a member of guild '{}' anymore.",
targetId, guildId));

Guild guild = Objects.requireNonNull(event.getJDA().getGuildById(guildId));

guild.retrieveMemberById(targetId)
.queue(target -> handleConfirmedScam(guild, target, moderator, event), errorHandler);
.queue(target -> handleConfirmedViolation(event, action, guild, moderator, target,
existingEmbeds), errorHandler);
}

private void handleConfirmedScam(Guild guild, Member target, Member moderator,
ButtonInteractionEvent event) {
if (!handleCanQuarantineAndBan(guild, target, event)) {
private void handleConfirmedViolation(ButtonInteractionEvent event, ReportAction action,
Guild guild, Member moderator, Member target, List<MessageEmbed> alertEmbeds) {

Member bot = guild.getSelfMember();

boolean canExecute = switch (action) {
case SCAM -> {
Role quarantinedRole =
ModerationUtils.getQuarantinedRole(guild, config).orElse(null);
yield ModerationUtils.handleRoleChangeChecks(quarantinedRole, "quarantine", target,
bot, moderator, guild, SCAM_REASON, event);
}
case JOB_AD ->
ModerationUtils.handleCanInteractWithTarget("ban", bot, moderator, target, event);
default -> false;
};

canExecute &= ModerationUtils.handleHasBotPermissions("ban", Permission.BAN_MEMBERS, bot,
guild, event);

if (!canExecute) {
logger.warn(LogMarkers.SENSITIVE,
"Moderator '{}' tried to execute report for user '{}' in guild '{}', but checks failed.",
moderator.getId(), target.getId(), guild.getId());
return;
}

Consumer<? super Void> onSuccess = _ -> {
String successText;
RestAction<Void> handling = switch (action) {
case SCAM -> {
successText = "This is scam. The user was quarantined and messages were deleted.";
yield quarantineAndRemove(guild, target, moderator);
}
case JOB_AD -> {
successText = "This is a job-ad. The user was warned and messages were deleted.";
yield warnAndRemove(guild, target, moderator);
}
default -> throw new IllegalStateException("Unknown action provided: " + action);
};
Consumer<? super Throwable> onFailure = failure -> logger.warn(LogMarkers.SENSITIVE,
"Failed to finish user-reported scam handling for user '{}' in guild '{}'.",
target.getId(), guild.getId(), failure);

sendQuarantineDm(target.getUser(), guild)
.flatMap(hasSentDm -> quarantineUser(guild, target, moderator))
.flatMap(_ -> deleteMessagesByBanAndUnban(guild, target.getUser()))
.queue(onSuccess, onFailure);
}

private boolean handleCanQuarantineAndBan(Guild guild, Member target,
ButtonInteractionEvent event) {
Member bot = guild.getSelfMember();
Member moderator = Objects.requireNonNull(event.getMember());
Role quarantinedRole = ModerationUtils.getQuarantinedRole(guild, config).orElse(null);

return ModerationUtils.handleRoleChangeChecks(quarantinedRole, "quarantine", target, bot,
moderator, guild, ACTION_REASON, event)
&& ModerationUtils.handleHasBotPermissions("ban", Permission.BAN_MEMBERS, bot,
guild, event);
String pendingText = "%s is handling this alert...".formatted(moderator.getAsMention());

event.editMessageEmbeds(createResponseEmbeds(alertEmbeds, pendingText, Color.ORANGE))
.setComponents()
.queue(hook -> {
Consumer<Void> onSuccess = ignored -> showResult(hook, alertEmbeds,
"%s (handled by %s)".formatted(successText, moderator.getAsMention()),
Color.GREEN);

Consumer<Throwable> onFailure = failure -> {
logger.warn(LogMarkers.SENSITIVE,
"Failed to handle alert for user '{}' in guild '{}'.", target.getId(),
guild.getId(), failure);
showResult(hook, alertEmbeds,
"Handling this alert failed, please check the logs and handle the user manually.",
Color.RED);
};

handling.queue(onSuccess, onFailure);
});
}

private RestAction<Boolean> sendQuarantineDm(User target, Guild guild) {
private RestAction<Void> quarantineAndRemove(Guild guild, Member target, Member moderator) {
String description =
"""
Hey there, sorry to tell you but unfortunately you have been put under quarantine after sending scam.
Expand All @@ -284,28 +324,71 @@ private RestAction<Boolean> sendQuarantineDm(User target, Guild guild) {
After you regained control of your account make sure you secure it properly, for example by using 2FA.
You can then join back our server and contact the mods to get unquarantined.""";

return ModerationUtils.sendModActionDm(ModerationUtils.getModActionEmbed(guild,
ACTION_TITLE, description, ACTION_REASON, true), target);
return sendDm(guild, target.getUser(), "Quarantine", description, SCAM_REASON)
.flatMap(_ -> {
recordAction(guild, target, moderator, ModerationAction.QUARANTINE, SCAM_REASON);
return guild
.addRoleToMember(target,
ModerationUtils.getQuarantinedRole(guild, config).orElseThrow())
.reason(SCAM_REASON);
})
.flatMap(_ -> deleteMessagesByBanThenUnban(guild, target.getUser(), SCAM_REASON));
}

private RestAction<Void> quarantineUser(Guild guild, Member target, Member moderator) {
logger.info(LogMarkers.SENSITIVE,
"'{}' ({}) quarantined the user '{}' ({}) in guild '{}' for reason '{}'.",
moderator.getUser().getName(), moderator.getId(), target.getUser().getName(),
target.getId(), guild.getName(), ACTION_REASON);
private RestAction<Void> warnAndRemove(Guild guild, Member target, Member moderator) {
String description =
"""
Hey there, sorry to tell you but unfortunately you have been warned in the server for posting a job advertisement.
To delete your messages you were briefly removed from the server, you are welcome to join back.""";
Comment on lines +341 to +342

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

im not super happy with the wording of this message. but we can talk about that at the very end when other stuff is fixed


return sendDm(guild, target.getUser(), "Warning", description, JOB_AD_REASON).flatMap(_ -> {
recordAction(guild, target, moderator, ModerationAction.WARN, JOB_AD_REASON);
return deleteMessagesByBanThenUnban(guild, target.getUser(), JOB_AD_REASON);
}).flatMap(_ -> deleteMessagesByBanThenUnban(guild, target.getUser(), JOB_AD_REASON));
}

actionsStore.addAction(guild.getIdLong(), moderator.getIdLong(), target.getIdLong(),
ModerationAction.QUARANTINE, null, ACTION_REASON);
private static RestAction<Boolean> sendDm(Guild guild, User target, String title,
String description, String reason) {
return ModerationUtils.sendModActionDm(
ModerationUtils.getModActionEmbed(guild, title, description, reason, true), target);
}

private void recordAction(Guild guild, Member target, Member moderator, ModerationAction action,
String reason) {
logger.info(LogMarkers.SENSITIVE,
"'{}' ({}) executed '{}' against the user '{}' ({}) in guild '{}' for reason '{}'.",
moderator.getUser().getName(), moderator.getId(), action,
target.getUser().getName(), target.getId(), guild.getName(), reason);

return guild
.addRoleToMember(target,
ModerationUtils.getQuarantinedRole(guild, config).orElseThrow())
.reason(ACTION_REASON);
actionsStore.addAction(guild.getIdLong(), moderator.getIdLong(), target.getIdLong(), action,
null, reason);
}

private RestAction<Void> deleteMessagesByBanAndUnban(Guild guild, User target) {
private static RestAction<Void> deleteMessagesByBanThenUnban(Guild guild, User target,
String reason) {
return guild.ban(target, 1, TimeUnit.DAYS)
.reason(ACTION_REASON)
.flatMap(_ -> guild.unban(target).reason(ACTION_REASON));
.reason(reason)
.flatMap(_ -> guild.unban(target).reason(reason));
}

private static List<MessageEmbed> createResponseEmbeds(List<MessageEmbed> stagedEmbeds,
String text, Color color) {
MessageEmbed resultEmbed = new EmbedBuilder().setDescription(text).setColor(color).build();

List<MessageEmbed> embeds = new ArrayList<>(stagedEmbeds);
embeds.add(resultEmbed);

return embeds;
}

private static void showResult(InteractionHook hook, List<MessageEmbed> alertEmbeds,
String text, Color color) {
hook.editOriginalEmbeds(createResponseEmbeds(alertEmbeds, text, color)).queue();
}

private enum ReportAction {
SCAM,
JOB_AD,
DISMISS
}
}
Loading