Skip to content

Commit f8e27bb

Browse files
committed
Adds "this-is-scam" message context command
1 parent 227d319 commit f8e27bb

2 files changed

Lines changed: 304 additions & 0 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
import org.togetherjava.tjbot.features.moderation.QuarantineCommand;
5454
import org.togetherjava.tjbot.features.moderation.RejoinModerationRoleListener;
5555
import org.togetherjava.tjbot.features.moderation.ReportCommand;
56+
import org.togetherjava.tjbot.features.moderation.ThisIsScamCommand;
5657
import org.togetherjava.tjbot.features.moderation.TransferQuestionCommand;
5758
import org.togetherjava.tjbot.features.moderation.UnbanCommand;
5859
import org.togetherjava.tjbot.features.moderation.UnmuteCommand;
@@ -191,6 +192,7 @@ public static Collection<Feature> createFeatures(JDA jda, Database database, Con
191192

192193
// Message context commands
193194
features.add(new TransferQuestionCommand(config, chatGptService));
195+
features.add(new ThisIsScamCommand(config, actionsStore));
194196

195197
// User context commands
196198

Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
package org.togetherjava.tjbot.features.moderation;
2+
3+
import com.github.benmanes.caffeine.cache.Cache;
4+
import com.github.benmanes.caffeine.cache.Caffeine;
5+
import net.dv8tion.jda.api.EmbedBuilder;
6+
import net.dv8tion.jda.api.Permission;
7+
import net.dv8tion.jda.api.entities.Guild;
8+
import net.dv8tion.jda.api.entities.Member;
9+
import net.dv8tion.jda.api.entities.Message;
10+
import net.dv8tion.jda.api.entities.MessageEmbed;
11+
import net.dv8tion.jda.api.entities.Role;
12+
import net.dv8tion.jda.api.entities.User;
13+
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
14+
import net.dv8tion.jda.api.events.interaction.command.MessageContextInteractionEvent;
15+
import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent;
16+
import net.dv8tion.jda.api.exceptions.ErrorHandler;
17+
import net.dv8tion.jda.api.interactions.commands.build.Commands;
18+
import net.dv8tion.jda.api.interactions.components.ActionRow;
19+
import net.dv8tion.jda.api.interactions.components.buttons.Button;
20+
import net.dv8tion.jda.api.interactions.components.buttons.ButtonStyle;
21+
import net.dv8tion.jda.api.requests.ErrorResponse;
22+
import net.dv8tion.jda.api.requests.RestAction;
23+
import net.dv8tion.jda.api.requests.restaction.MessageCreateAction;
24+
import org.slf4j.Logger;
25+
import org.slf4j.LoggerFactory;
26+
27+
import org.togetherjava.tjbot.config.Config;
28+
import org.togetherjava.tjbot.features.BotCommandAdapter;
29+
import org.togetherjava.tjbot.features.CommandVisibility;
30+
import org.togetherjava.tjbot.features.MessageContextCommand;
31+
import org.togetherjava.tjbot.features.utils.Guilds;
32+
import org.togetherjava.tjbot.features.utils.MessageUtils;
33+
import org.togetherjava.tjbot.logging.LogMarkers;
34+
35+
import java.awt.Color;
36+
import java.time.Duration;
37+
import java.time.Instant;
38+
import java.util.ArrayList;
39+
import java.util.List;
40+
import java.util.Objects;
41+
import java.util.Optional;
42+
import java.util.concurrent.TimeUnit;
43+
import java.util.function.Consumer;
44+
import java.util.function.Predicate;
45+
import java.util.regex.Pattern;
46+
import java.util.stream.Collectors;
47+
48+
/**
49+
* Allows users to report a message as potential scam. Moderators can confirm the report from the
50+
* audit log, causing the author to be quarantined plus message history getting deleted.
51+
*/
52+
public final class ThisIsScamCommand extends BotCommandAdapter implements MessageContextCommand {
53+
private static final Logger logger = LoggerFactory.getLogger(ThisIsScamCommand.class);
54+
55+
private static final String COMMAND_NAME = "this-is-scam";
56+
57+
private static final String ACTION_TITLE = "Quarantine";
58+
private static final String ACTION_REASON = "Message was reported and confirmed as scam";
59+
60+
private static final String FAILED_MESSAGE =
61+
"Sorry, there was an issue forwarding your scam report to the moderators. We are investigating.";
62+
private static final Duration USER_COMMAND_COOLDOWN = Duration.ofMinutes(1);
63+
private static final Color AMBIENT_COLOR = Color.decode("#CFBFF5");
64+
65+
private final Cache<Long, Instant> reportedMessageToTimestamp =
66+
Caffeine.newBuilder().maximumSize(10_000).expireAfterWrite(Duration.ofDays(1)).build();
67+
private final Cache<Long, Instant> userToLastCommandUse = Caffeine.newBuilder()
68+
.maximumSize(10_000)
69+
.expireAfterWrite(USER_COMMAND_COOLDOWN)
70+
.build();
71+
72+
private final Config config;
73+
private final ModerationActionsStore actionsStore;
74+
private final Predicate<String> isModAuditLogChannel;
75+
76+
/**
77+
* Creates a new instance.
78+
*
79+
* @param config to resolve the moderation audit log channel and quarantined role
80+
* @param actionsStore used to store issued quarantine actions
81+
*/
82+
public ThisIsScamCommand(Config config, ModerationActionsStore actionsStore) {
83+
super(Commands.message(COMMAND_NAME), CommandVisibility.GUILD);
84+
85+
this.config = Objects.requireNonNull(config);
86+
this.actionsStore = Objects.requireNonNull(actionsStore);
87+
isModAuditLogChannel =
88+
Pattern.compile(config.getModAuditLogChannelPattern()).asMatchPredicate();
89+
}
90+
91+
@Override
92+
public void onMessageContext(MessageContextInteractionEvent event) {
93+
if (handleIsOnCooldown(event)) {
94+
return;
95+
}
96+
if (handleWasAlreadyReportedMessage(event)) {
97+
return;
98+
}
99+
100+
Optional<TextChannel> modAuditLog = getModAuditLogChannel(event);
101+
if (modAuditLog.isEmpty()) {
102+
event.reply(FAILED_MESSAGE).setEphemeral(true).queue();
103+
return;
104+
}
105+
106+
Message message = event.getTarget();
107+
reportToMods(message, modAuditLog.orElseThrow()).mapToResult().map(result -> {
108+
if (result.isFailure()) {
109+
logger.warn("Unable to forward a scam report to the mod audit log channel.",
110+
result.getFailure());
111+
return FAILED_MESSAGE;
112+
}
113+
return "Thank you for your report, a moderator will take care of it 👍";
114+
}).flatMap(response -> event.reply(response).setEphemeral(true)).queue();
115+
}
116+
117+
private boolean handleIsOnCooldown(MessageContextInteractionEvent event) {
118+
Instant lastCommandUse = userToLastCommandUse.getIfPresent(event.getUser().getIdLong());
119+
Runnable isNotOnCooldownAction =
120+
() -> userToLastCommandUse.put(event.getUser().getIdLong(), Instant.now());
121+
122+
if (lastCommandUse == null) {
123+
isNotOnCooldownAction.run();
124+
return false;
125+
}
126+
Instant momentCooldownEnds = lastCommandUse.plus(USER_COMMAND_COOLDOWN);
127+
if (Instant.now().isAfter(momentCooldownEnds)) {
128+
isNotOnCooldownAction.run();
129+
return false;
130+
}
131+
132+
event.reply("You just reported message as scam, please wait a bit.")
133+
.setEphemeral(true)
134+
.queue();
135+
return true;
136+
}
137+
138+
private boolean handleWasAlreadyReportedMessage(MessageContextInteractionEvent event) {
139+
long messageId = event.getTarget().getIdLong();
140+
if (reportedMessageToTimestamp.getIfPresent(messageId) != null) {
141+
event.reply("This message was already reported as potential scam.")
142+
.setEphemeral(true)
143+
.queue();
144+
return true;
145+
}
146+
147+
reportedMessageToTimestamp.put(messageId, Instant.now());
148+
return false;
149+
}
150+
151+
private Optional<TextChannel> getModAuditLogChannel(MessageContextInteractionEvent event) {
152+
Guild guild = Objects.requireNonNull(event.getGuild());
153+
Optional<TextChannel> modAuditLogChannel =
154+
Guilds.findTextChannel(guild, isModAuditLogChannel);
155+
if (modAuditLogChannel.isEmpty()) {
156+
logger.warn(
157+
"Cannot find the designated mod audit log channel in guild '{}' with the pattern '{}'",
158+
guild.getId(), config.getModAuditLogChannelPattern());
159+
}
160+
return modAuditLogChannel;
161+
}
162+
163+
private MessageCreateAction reportToMods(Message message, TextChannel auditChannel) {
164+
User author = message.getAuthor();
165+
String description = createDescription(message);
166+
167+
MessageEmbed reportEmbed = new EmbedBuilder().setTitle("Is this Scam?")
168+
.setDescription(
169+
MessageUtils.abbreviate(description, MessageEmbed.DESCRIPTION_MAX_LENGTH))
170+
.setAuthor(author.getName(), null, author.getEffectiveAvatarUrl())
171+
.setTimestamp(message.getTimeCreated())
172+
.setColor(AMBIENT_COLOR)
173+
.setFooter(author.getId())
174+
.build();
175+
176+
long guildId = message.getGuild().getIdLong();
177+
long authorId = author.getIdLong();
178+
String componentId = generateComponentId(String.valueOf(guildId), String.valueOf(authorId));
179+
180+
return auditChannel.sendMessageEmbeds(reportEmbed)
181+
.addActionRow(Button.success(componentId, "Yes"), Button.danger(componentId, "No"));
182+
}
183+
184+
private static String createDescription(Message target) {
185+
String content = target.getContentStripped();
186+
String description = content.isBlank() ? "(empty message)" : content;
187+
188+
List<Message.Attachment> attachments = target.getAttachments();
189+
if (!attachments.isEmpty()) {
190+
String attachmentInfo = attachments.stream()
191+
.map(Message.Attachment::getFileName)
192+
.collect(Collectors.joining("\n"));
193+
description += "\n\nAttachments:\n" + attachmentInfo;
194+
}
195+
196+
description += "\n\n[Go to message](%s)".formatted(target.getJumpUrl());
197+
return description;
198+
}
199+
200+
@Override
201+
public void onButtonClick(ButtonInteractionEvent event, List<String> args) {
202+
long guildId = Long.parseLong(args.get(0));
203+
long targetId = Long.parseLong(args.get(1));
204+
205+
ButtonStyle clickedStyle = event.getButton().getStyle();
206+
boolean isScam = clickedStyle == ButtonStyle.SUCCESS;
207+
208+
MessageEmbed resultEmbed = new EmbedBuilder()
209+
.setDescription(
210+
isScam ? "This is scam. The user was quarantined and messages were deleted."
211+
: "This is not scam, no action executed.")
212+
.setColor(isScam ? Color.GREEN : Color.RED)
213+
.build();
214+
215+
List<MessageEmbed> embeds = new ArrayList<>(event.getMessage().getEmbeds());
216+
embeds.add(resultEmbed);
217+
218+
List<Button> disabledButtons =
219+
event.getMessage().getButtons().stream().map(Button::asDisabled).toList();
220+
221+
event.editMessageEmbeds(embeds).setComponents(ActionRow.of(disabledButtons)).queue();
222+
223+
if (!isScam) {
224+
return;
225+
}
226+
227+
Guild guild = Objects.requireNonNull(event.getJDA().getGuildById(guildId));
228+
Member moderator = Objects.requireNonNull(event.getMember());
229+
230+
ErrorHandler errorHandler = new ErrorHandler()
231+
.handle(ErrorResponse.UNKNOWN_USER, failure -> logger.debug(LogMarkers.SENSITIVE,
232+
"Attempted to handle user-reported scam, but user '{}' does not exist anymore.",
233+
targetId))
234+
.handle(ErrorResponse.UNKNOWN_MEMBER, failure -> logger.debug(LogMarkers.SENSITIVE,
235+
"Attempted to handle user-reported scam, but user '{}' is not a member of guild '{}' anymore.",
236+
targetId, guildId));
237+
238+
guild.retrieveMemberById(targetId)
239+
.queue(target -> handleConfirmedScam(guild, target, moderator, event), errorHandler);
240+
}
241+
242+
private void handleConfirmedScam(Guild guild, Member target, Member moderator,
243+
ButtonInteractionEvent event) {
244+
if (!handleCanQuarantineAndBan(guild, target, event)) {
245+
return;
246+
}
247+
248+
Consumer<? super Void> onSuccess = _ -> {
249+
};
250+
Consumer<? super Throwable> onFailure = failure -> logger.warn(LogMarkers.SENSITIVE,
251+
"Failed to finish user-reported scam handling for user '{}' in guild '{}'.",
252+
target.getId(), guild.getId(), failure);
253+
254+
sendQuarantineDm(target.getUser(), guild)
255+
.flatMap(hasSentDm -> quarantineUser(guild, target, moderator))
256+
.flatMap(_ -> deleteMessagesByBanAndUnban(guild, target.getUser()))
257+
.queue(onSuccess, onFailure);
258+
}
259+
260+
private boolean handleCanQuarantineAndBan(Guild guild, Member target,
261+
ButtonInteractionEvent event) {
262+
Member bot = guild.getSelfMember();
263+
Member moderator = Objects.requireNonNull(event.getMember());
264+
Role quarantinedRole = ModerationUtils.getQuarantinedRole(guild, config).orElse(null);
265+
266+
return ModerationUtils.handleRoleChangeChecks(quarantinedRole, "quarantine", target, bot,
267+
moderator, guild, ACTION_REASON, event)
268+
&& ModerationUtils.handleHasBotPermissions("ban", Permission.BAN_MEMBERS, bot,
269+
guild, event);
270+
}
271+
272+
private RestAction<Boolean> sendQuarantineDm(User target, Guild guild) {
273+
String description =
274+
"""
275+
Hey there, sorry to tell you but unfortunately you have been put under quarantine.
276+
This means you can no longer interact with anyone in the server until you have been unquarantined again.""";
277+
278+
return ModerationUtils.sendModActionDm(ModerationUtils.getModActionEmbed(guild,
279+
ACTION_TITLE, description, ACTION_REASON, true), target);
280+
}
281+
282+
private RestAction<Void> quarantineUser(Guild guild, Member target, Member moderator) {
283+
logger.info(LogMarkers.SENSITIVE,
284+
"'{}' ({}) quarantined the user '{}' ({}) in guild '{}' for reason '{}'.",
285+
moderator.getUser().getName(), moderator.getId(), target.getUser().getName(),
286+
target.getId(), guild.getName(), ACTION_REASON);
287+
288+
actionsStore.addAction(guild.getIdLong(), moderator.getIdLong(), target.getIdLong(),
289+
ModerationAction.QUARANTINE, null, ACTION_REASON);
290+
291+
return guild
292+
.addRoleToMember(target,
293+
ModerationUtils.getQuarantinedRole(guild, config).orElseThrow())
294+
.reason(ACTION_REASON);
295+
}
296+
297+
private RestAction<Void> deleteMessagesByBanAndUnban(Guild guild, User target) {
298+
return guild.ban(target, 1, TimeUnit.DAYS)
299+
.reason(ACTION_REASON)
300+
.flatMap(_ -> guild.unban(target).reason(ACTION_REASON));
301+
}
302+
}

0 commit comments

Comments
 (0)