/*
* Copyright 2016 John Grosh (jagrosh).
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.jagrosh.vortex.automod;
import com.jagrosh.vortex.Constants;
import com.jagrosh.vortex.Vortex;
import com.jagrosh.vortex.database.managers.AutomodManager;
import com.jagrosh.vortex.database.managers.AutomodManager.AutomodSettings;
import com.jagrosh.vortex.logging.MessageCache.CachedMessage;
import com.jagrosh.vortex.automod.URLResolver.*;
import com.jagrosh.vortex.utils.FixedCache;
import com.jagrosh.vortex.utils.OtherUtil;
import com.jagrosh.vortex.utils.Usage;
import com.typesafe.config.Config;
import java.time.OffsetDateTime;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.function.Predicate;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import net.dv8tion.jda.api.Permission;
import net.dv8tion.jda.api.entities.channel.concrete.*;
import net.dv8tion.jda.api.entities.*;
import net.dv8tion.jda.api.entities.Guild.VerificationLevel;
import net.dv8tion.jda.api.events.guild.member.GuildMemberJoinEvent;
import net.dv8tion.jda.api.exceptions.PermissionException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
*
* @author John Grosh (jagrosh)
*/
public class AutoMod
{
private static final Pattern INVITES = Pattern.compile("discord\\s?(?:(?:\\.|dot|\\(\\.\\)|\\(dot\\))\\s?gg|(?:app)?\\s?\\.\\s?com\\s?\\/\\s?invite)\\s?\\/\\s?([A-Z0-9-]{2,18})",
Pattern.CASE_INSENSITIVE);
private static final Pattern REF = Pattern.compile("https?:\\/\\/\\S+(?:\\/ref\\/|[?&#]ref(?:errer|erral)?=)\\S+", Pattern.CASE_INSENSITIVE);
private static final Pattern LINK = Pattern.compile("https?:\\/\\/\\S+", Pattern.CASE_INSENSITIVE);
private static final String INVITE_LINK = "https?:\\/\\/discord(?:app\\.com\\/invite|\\.com\\/invite|\\.gg)\\/(\\S+)";
private static final String CONDENSER = "(.+?)\\s*(\\1\\s*)+";
private static final Logger LOG = LoggerFactory.getLogger("AutoMod");
public static final String RESTORE_MUTE_ROLE_AUDIT = "Restoring Muted Role";
private final Vortex vortex;
private String[] refLinkList;
private final URLResolver urlResolver;
private final InviteResolver inviteResolver = new InviteResolver();
private final CopypastaResolver copypastaResolver = new CopypastaResolver();
private final FixedCache<String,DupeStatus> spams = new FixedCache<>(3000);
private final HashMap<Long,OffsetDateTime> latestGuildJoin = new HashMap<>();
private final Usage usage = new Usage();
public AutoMod(Vortex vortex, Config config)
{
this.vortex = vortex;
this.urlResolver = config.getBoolean("url-resolver.active") ? new ActiveURLResolver(config) : new DummyURLResolver();
loadCopypastas();
loadReferralDomains();
}
public final void loadCopypastas()
{
this.copypastaResolver.load();
}
public final void loadSafeDomains()
{
this.urlResolver.loadSafeDomains();
}
public final void loadReferralDomains()
{
this.refLinkList = OtherUtil.readLines("referral_domains");
}
public Usage getUsage()
{
return usage;
}
public void enableRaidMode(Guild guild, Member moderator, OffsetDateTime now, String reason)
{
vortex.getDatabase().settings.enableRaidMode(guild);
if(guild.getVerificationLevel().getKey()<VerificationLevel.HIGH.getKey())
{
try
{
guild.getManager().setVerificationLevel(VerificationLevel.HIGH).reason("Enabling Anti-Raid Mode").queue();
} catch(PermissionException ignore) {}
}
vortex.getModLogger().postRaidmodeCase(moderator, now, true, reason);
}
public void disableRaidMode(Guild guild, Member moderator, OffsetDateTime now, String reason)
{
VerificationLevel last = vortex.getDatabase().settings.disableRaidMode(guild);
if(guild.getVerificationLevel()!=last)
{
try
{
guild.getManager().setVerificationLevel(last).reason("Disabling Anti-Raid Mode").queue();
} catch(PermissionException ignore) {}
}
vortex.getModLogger().postRaidmodeCase(moderator, now, false, reason);
}
public void memberJoin(GuildMemberJoinEvent event)
{
// completely ignore bots for raidmode
if(event.getMember().getUser().isBot())
return;
boolean inRaidMode = vortex.getDatabase().settings.getSettings(event.getGuild()).isInRaidMode();
AutomodSettings ams = vortex.getDatabase().automod.getSettings(event.getGuild());
OffsetDateTime now = OffsetDateTime.now();
boolean kicking = false;
// if we're in raid mode...
if(inRaidMode)
{
// ...and this server uses auto raid mode, check if we should be turning it off automatically
// this means that we should turn it off if the latest attempted join was more than 2 minutes ago
if(ams.useAutoRaidMode()
&& latestGuildJoin.containsKey(event.getGuild().getIdLong())
&& latestGuildJoin.get(event.getGuild().getIdLong()).until(now, ChronoUnit.SECONDS)>120)
{
disableRaidMode(event.getGuild(), event.getGuild().getSelfMember(), now, "No recent join attempts");
}
// otherwise, boot 'em
else if(event.getGuild().getSelfMember().hasPermission(Permission.KICK_MEMBERS))
{
kicking = true;
}
}
// now, if we're not in raid mode, and auto mode is enabled
else if(ams.useAutoRaidMode())
{
// find the time that we should be looking after, and count the number of people that joined after that
OffsetDateTime min = event.getMember().getTimeJoined().minusSeconds(ams.raidmodeTime);
long recent = event.getGuild().getMemberCache().stream().filter(m -> !m.getUser().isBot() && m.getTimeJoined().isAfter(min)).count();
if(recent>=ams.raidmodeNumber)
{
enableRaidMode(event.getGuild(), event.getGuild().getSelfMember(), now, "Maximum join rate exceeded ("+ams.raidmodeNumber+"/"+ams.raidmodeTime+"s)");
kicking = true;
}
}
if(kicking)
{
OtherUtil.safeDM(event.getUser(), "Sorry, **"+event.getGuild().getName()+"** is currently under lockdown. "
+ "Please try joining again later. Sorry for the inconvenience.", true, () ->
{
try
{
event.getGuild().kick(event.getMember()).reason("Anti-Raid Mode").queue();
}catch(Exception ignore){}
});
}
else
{
if(vortex.getDatabase().tempmutes.isMuted(event.getMember()))
{
try
{
event.getGuild()
.addRoleToMember(event.getMember(), vortex.getDatabase().settings.getSettings(event.getGuild()).getMutedRole(event.getGuild()))
.reason(RESTORE_MUTE_ROLE_AUDIT).queue();
} catch(Exception ignore){}
}
dehoist(event.getMember());
}
latestGuildJoin.put(event.getGuild().getIdLong(), now);
}
private boolean shouldPerformAutomod(Member member, TextChannel channel)
{
// ignore users not in the guild
if(member==null)
return false;
// ignore broken guilds
if(member.getGuild().getOwner()==null)
return false;
// ignore bots
if(member.getUser().isBot())
return false;
// ignore users vortex cant interact with
if(!member.getGuild().getSelfMember().canInteract(member))
return false;
// ignore users that can kick
if(member.hasPermission(Permission.KICK_MEMBERS))
return false;
// ignore users that can ban
if(member.hasPermission(Permission.BAN_MEMBERS))
return false;
// ignore users that can manage server
if(member.hasPermission(Permission.MANAGE_SERVER))
return false;
// if a channel is specified, ignore users that can manage messages in that channel
if(channel!=null && (member.hasPermission(channel, Permission.MESSAGE_MANAGE) || vortex.getDatabase().ignores.isIgnored(channel)))
return false;
if(vortex.getDatabase().ignores.isIgnored(member))
return false;
return true;
}
public void dehoist(Member member)
{
if(!member.getGuild().getSelfMember().hasPermission(Permission.NICKNAME_MANAGE))
return;
if(!shouldPerformAutomod(member, null))
return;
AutomodSettings settings = vortex.getDatabase().automod.getSettings(member.getGuild());
if(settings==null || settings.dehoistChar==(char)0 || member.getEffectiveName().charAt(0)>settings.dehoistChar)
return;
try
{
usage.increment(member.getGuild().getIdLong());
OtherUtil.dehoist(member, settings.dehoistChar);
}
catch(Exception ignore) {}
}
public void performAutomod(Message message)
{
//ignore users with Manage Messages, Kick Members, Ban Members, Manage Server, or anyone the bot can't interact with
if(!shouldPerformAutomod(message.getMember(), message.getChannel().asTextChannel()))
return;
//get the settings
AutomodSettings settings = vortex.getDatabase().automod.getSettings(message.getGuild());
if(settings==null)
return;
// check the channel for channel-specific settings
String topic = message.getChannel().asTextChannel().getTopic();
boolean preventSpam = topic==null || !topic.toLowerCase().contains("{spam}");
boolean preventInvites = topic==null || !topic.toLowerCase().contains("{invites}");
// get additional settings and data from the database
List<Long> inviteWhitelist = !preventInvites ? Collections.emptyList()
: vortex.getDatabase().inviteWhitelist.readWhitelist(message.getGuild());
List<Filter> filters = vortex.getDatabase().filters.getFilters(message.getGuild());
usage.increment(message.getGuild().getIdLong());
// detect status of different automod features
AutomodStatus currentStatus = new AutomodStatus();
if(preventSpam)
{
runAntiDuplicate(settings, currentStatus, message);
runMaxLines(settings, currentStatus, message);
runAntiCopypasta(settings, currentStatus, message);
runAntiEveryone(settings, currentStatus, message);
}
if(preventInvites)
{
runAntiInvite(settings, currentStatus, message, inviteWhitelist);
}
runAntiMention(settings, currentStatus, message);
runAntiMentionRoles(settings, currentStatus, message);
runAntiReferral(settings, currentStatus, message);
runFilters(settings, currentStatus, message, filters);
// delete the message if applicable
if(currentStatus.deleteMessage)
{
try
{
message.delete().reason("Automod").queue(v->{}, f->{});
}catch(PermissionException ignore){}
}
// send a short 'warning' message that self-deletes
if(currentStatus.channelWarning != null && message.getGuild().getSelfMember().hasPermission(message.getChannel().asTextChannel(), Permission.MESSAGE_SEND))
{
message.getChannel().sendMessage(message.getAuthor().getAsMention() + Constants.WARNING + " " + currentStatus.channelWarning)
.queue(m -> m.delete().queueAfter(2500, TimeUnit.MILLISECONDS, s->{}, f->{}), f->{});
}
// assign strikes if necessary
if(currentStatus.strikeTotal > 0)
{
vortex.getStrikeHandler().applyStrikes(message.getGuild().getSelfMember(),
latestTime(message), message.getAuthor(), currentStatus.strikeTotal, currentStatus.reason.toString().substring(2));
}
// now, lets resolve links, but async
if(!currentStatus.deleteMessage && settings.resolveUrls && (preventInvites || settings.refStrikes>0))
{
List<String> links = new LinkedList<>();
Matcher m = LINK.matcher(message.getContentRaw());
while(m.find())
links.add(m.group().endsWith(">") ? m.group().substring(0,m.group().length()-1) : m.group());
if(!links.isEmpty())
vortex.getThreadpool().execute(() ->
{
boolean containsInvite = false;
boolean containsRef = false;
String llink = null;
List<String> redirects = null;
for(String link: links)
{
llink = link;
redirects = urlResolver.findRedirects(link);
for(String resolved: redirects)
{
if(preventInvites && resolved.matches(INVITE_LINK))
{
String code = resolved.replaceAll(INVITE_LINK, "$1");
LOG.info("Delayed resolving invite in " + message.getGuild().getId() + ": " + code);
long invite = inviteResolver.resolve(code, message.getJDA());
if(invite != message.getGuild().getIdLong() && !inviteWhitelist.contains(invite))
containsInvite = true;
containsInvite = false;
}
if(settings.refStrikes>0)
{
if(resolved.matches(REF.pattern()) || Arrays.stream(refLinkList).anyMatch(reflink -> resolved.contains(reflink)))
containsRef = true;
}
}
if((containsInvite || !preventInvites) && (containsRef || settings.refStrikes<1))
break;
}
int rstrikeTotal = (containsInvite ? settings.inviteStrikes : 0) + (containsRef ? settings.refStrikes : 0);
if(rstrikeTotal > 0)
{
vortex.getBasicLogger().logRedirectPath(message, llink, redirects);
String rreason = ((containsInvite ? ", Advertising (Resolved Link)" : "") + (containsRef ? ", Referral Link (Resolved Link)" : "")).substring(2);
try
{
message.delete().reason("Automod").queue(v->{}, f->{});
}catch(PermissionException ignore){}
vortex.getStrikeHandler().applyStrikes(message.getGuild().getSelfMember(),
latestTime(message), message.getAuthor(), rstrikeTotal, rreason);
}
});
}
}
private void runAntiDuplicate(AutomodSettings settings, AutomodStatus currentStatus, Message message)
{
if(!settings.useAntiDuplicate())
return;
// This is a temporary measure to reduce some potentially-high CPU traffic that may
// be unnecessary. These are the default values from when anti-duplicate was automatically
// set up when automod was set up.
if(settings.dupeStrikes == 1 && settings.dupeDeleteThresh == 2 && settings.dupeStrikeThresh == 4)
return;
String key = message.getAuthor().getId()+"|"+message.getGuild().getId();
String content = condensedContent(message);
DupeStatus status = spams.get(key);
if(status==null)
{
spams.put(key, new DupeStatus(content, latestTime(message)));
}
else
{
OffsetDateTime now = latestTime(message);
int offenses = status.update(content, now);
if(offenses==settings.dupeDeleteThresh)
{
currentStatus.channelWarning = "Please stop spamming.";
purgeMessages(message.getGuild(), m -> m.getAuthorId()==message.getAuthor().getIdLong() && m.getTimeCreated().plusMinutes(2).isAfter(now));
}
else if(offenses>settings.dupeDeleteThresh)
currentStatus.deleteMessage();
if(offenses >= settings.dupeStrikeThresh)
{
currentStatus.addStrikes(settings.dupeStrikes);
currentStatus.appendReason("Duplicate messages");
}
}
}
private void runAntiMention(AutomodSettings settings, AutomodStatus currentStatus, Message message)
{
if(settings.maxMentions < AutomodManager.MENTION_MINIMUM)
return;
long mentions = message.getMentions().getUsers().stream().filter(u -> !u.isBot() && !u.equals(message.getAuthor())).distinct().count();
if(mentions > settings.maxMentions)
{
currentStatus.addStrikes((int)(mentions - settings.maxMentions));
currentStatus.appendReason("Mentioning " + mentions + " users");
currentStatus.deleteMessage();
}
}
private void runAntiMentionRoles(AutomodSettings settings, AutomodStatus currentStatus, Message message)
{
if(settings.maxRoleMentions < AutomodManager.ROLE_MENTION_MINIMUM)
return;
long mentions = message.getMentions().getRoles().stream().distinct().count();
if(mentions > settings.maxRoleMentions)
{
currentStatus.addStrikes((int)(mentions - settings.maxRoleMentions));
currentStatus.appendReason("Mentioning " + mentions + " roles");
currentStatus.deleteMessage();
}
}
private void runMaxLines(AutomodSettings settings, AutomodStatus currentStatus, Message message)
{
if(settings.maxLines>0)
{
int count = message.getContentRaw().split("\n").length;
if(count > settings.maxLines)
{
currentStatus.addStrikes((int) Math.ceil((double)(count-settings.maxLines)/settings.maxLines));
currentStatus.appendReason("Message contained " + count + " newlines");
currentStatus.deleteMessage();
}
}
}
private void runAntiCopypasta(AutomodSettings settings, AutomodStatus currentStatus, Message message)
{
if(settings.copypastaStrikes <= 0)
return;
String copypastaName = copypastaResolver.getCopypasta(message.getContentRaw());
if(copypastaName!=null)
{
currentStatus.addStrikes(settings.copypastaStrikes);
currentStatus.appendReason(copypastaName + " copypasta");
currentStatus.deleteMessage();
}
}
private void runAntiEveryone(AutomodSettings settings, AutomodStatus currentStatus, Message message)
{
if(settings.everyoneStrikes <= 0 || message.getMember().hasPermission(message.getChannel().asTextChannel(), Permission.MESSAGE_MENTION_EVERYONE))
return;
String filtered = message.getContentRaw().replace("`@everyone`", "").replace("`@here`", "");
if(filtered.contains("@everyone") || filtered.contains("@here")
|| message.getMentions().getRoles().stream().anyMatch(role -> role.getName().equalsIgnoreCase("everyone") || role.getName().equalsIgnoreCase("here")))
{
currentStatus.addStrikes(settings.everyoneStrikes);
currentStatus.appendReason("Attempted @\u0435veryone/here"); // cyrillic e
currentStatus.deleteMessage();
}
}
private void runAntiInvite(AutomodSettings settings, AutomodStatus currentStatus, Message message, List<Long> inviteWhitelist)
{
if(settings.inviteStrikes <= 0)
return;
List<String> invites = new ArrayList<>();
Matcher m = INVITES.matcher(message.getContentRaw());
while(m.find())
invites.add(m.group(1));
LOG.trace("Found "+invites.size()+" invites.");
for(String inviteCode : invites)
{
long gid = inviteResolver.resolve(inviteCode, message.getJDA());
LOG.info("Resolving invite in " + message.getGuild().getId() + ": " + inviteCode + " -> " + gid);
if(gid != message.getGuild().getIdLong() && !inviteWhitelist.contains(gid))
{
currentStatus.addStrikes(settings.inviteStrikes);
currentStatus.appendReason("Advertising");
currentStatus.deleteMessage();
break;
}
}
}
private void runAntiReferral(AutomodSettings settings, AutomodStatus currentStatus, Message message)
{
if(settings.refStrikes <= 0)
return;
Matcher m = REF.matcher(message.getContentRaw());
boolean found = false;
if(m.find())
found = true;
else
{
String lower = message.getContentRaw().toLowerCase();
for(String reflink: refLinkList)
if(lower.contains(reflink))
{
found = true;
break;
}
}
if(found)
{
currentStatus.addStrikes(settings.refStrikes);
currentStatus.appendReason("Referral link");
currentStatus.deleteMessage();
}
}
private void runFilters(AutomodSettings settings, AutomodStatus currentStatus, Message message, List<Filter> filters)
{
if(filters.isEmpty())
return;
Filter mostStrikesFilter = null;
for(Filter filter: filters)
if((mostStrikesFilter == null || filter.strikes > mostStrikesFilter.strikes) && filter.test(message.getContentRaw()))
mostStrikesFilter = filter;
if(mostStrikesFilter != null)
{
currentStatus.addStrikes(mostStrikesFilter.strikes);
currentStatus.appendReason("'" + mostStrikesFilter.name + "' Filter");
currentStatus.deleteMessage();
}
}
private void purgeMessages(Guild guild, Predicate<CachedMessage> predicate)
{
vortex.getMessageCache().getMessages(guild, predicate).stream()
.collect(Collectors.groupingBy(CachedMessage::getTextChannelId)).entrySet().forEach(entry ->
{
try
{
TextChannel mtc = guild.getTextChannelById(entry.getKey());
if(mtc != null)
mtc.purgeMessagesById(entry.getValue().stream().map(CachedMessage::getId).collect(Collectors.toList()));
}
catch(PermissionException ignore) {}
catch(Exception ex) { LOG.error("Error in purging messages: ", ex); }
});
}
private final static List<String> ZEROWIDTH = Arrays.asList("\u00AD", "\u034F", "\u17B4", "\u17B5", "\u180B", "\u180C", "\u180D",
"\u180E", "\u200B", "\u200C", "\u200D", "\u200E", "\u202A", "\u202C", "\u202D", "\u2060", "\u2061", "\u2062",
"\u2063", "\u2064", "\u2065", "\u2066", "\u2067", "\u2068", "\u2069", "\u206A", "\u206B", "\u206C", "\u206D",
"\u206E", "\u206F", "\uFE00", "\uFE01", "\uFE02", "\uFE03", "\uFE04", "\uFE05", "\uFE06", "\uFE07", "\uFE08",
"\uFE09", "\uFE0A", "\uFE0B", "\uFE0C", "\uFE0D", "\uFE0E", "\uFE0F", "\uFEFF", "\uFFF0", "\uFFF1", "\uFFF2",
"\uFFF3", "\uFFF4", "\uFFF5", "\uFFF6", "\uFFF7", "\uFFF8");
private static String condensedContent(Message m)
{
StringBuilder sb = new StringBuilder(m.getContentRaw());
m.getAttachments().forEach(at -> sb.append("\n").append(at.getFileName()));
try
{
StringBuilder sb2 = new StringBuilder();
sb.chars().filter(c -> !ZEROWIDTH.contains(Character.toString((char)c))).forEach(c -> sb2.append((char)c));
return sb2.toString().trim().replaceAll(CONDENSER, "$1");
}
catch(Exception ex)
{
return sb.toString().trim();
}
}
private static OffsetDateTime latestTime(Message m)
{
return m.isEdited() ? m.getTimeEdited(): m.getTimeCreated();
}
private class AutomodStatus
{
private int strikeTotal = 0;
private boolean deleteMessage = false;
private String channelWarning = null;
private StringBuilder reason = new StringBuilder();
private void deleteMessage()
{
this.deleteMessage = true;
}
private void appendReason(String input)
{
reason.append(", ").append(input);
}
private void addStrikes(int value)
{
this.strikeTotal += value;
}
}
private class DupeStatus
{
private String content;
private OffsetDateTime time;
private int count;
private DupeStatus(String content, OffsetDateTime time)
{
this.content = content;
this.time = time;
count = 0;
}
private int update(String nextcontent, OffsetDateTime nexttime)
{
if(nextcontent.equals(content) && this.time.plusSeconds(30).isAfter(nexttime))
{
count++;
this.time = nexttime;
return count;
}
else
{
this.content = nextcontent;
this.time = nexttime;
count = 0;
return count;
}
}
}
}