Skip to content

Commit ba39ae1

Browse files
authored
Adds "this-is-scam" message context command (#1505)
* Adds "this-is-scam" message context command * bugfix duplicate component id * make sonar happy * remove buttons instead of disabling them, cleaner * (typo) * CR * Added account age to footer * improved DM with extra info * (typo) * CR * using new ambient color class
1 parent 69fcda0 commit ba39ae1

4 files changed

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

application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/ScamBlocker.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,7 @@ private void reportScamMessage(MessageReceivedEvent event, String reportTitle,
267267
.setTitle(reportTitle)
268268
.setAuthor(author.getName(), null, avatarOrDefaultUrl)
269269
.setTimestamp(event.getMessage().getTimeCreated())
270-
.setColor(AmbientColors.SCAM_BLOCKER)
270+
.setColor(AmbientColors.MODERATION_SCAM)
271271
.setFooter(author.getId())
272272
.build();
273273

application/src/main/java/org/togetherjava/tjbot/features/utils/AmbientColors.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,12 @@ private AmbientColors() {
2323
public static final Color MODERATION = Color.decode("#895FE8");
2424
public static final Color MODERATION_AUDIT_ACTION = Color.decode("#4FC3F7");
2525
public static final Color MODERATION_AUDIT_LOG = Color.decode("#3788AC");
26+
public static final Color MODERATION_SCAM = Color.decode("#CFBFF5");
2627
public static final Color MODMAIL = Color.BLACK;
2728
public static final Color PURGE_CONFIRMATION = Color.RED;
2829
public static final Color QUESTION_TRANSFER = Color.decode("#32A4A8");
2930
public static final Color REMINDER = Color.decode("#F7F492");
3031
public static final Color ROLE_MANAGEMENT = Color.decode("#18DD88");
31-
public static final Color SCAM_BLOCKER = Color.decode("#CFBFF5");
3232
public static final Color TAGS = Color.decode("#FA8072");
3333
public static final Color WOLFRAM_ALPHA = Color.decode("#4290F5");
3434

0 commit comments

Comments
 (0)