2018-07-31 02:42:45 +03:00
|
|
|
import { decorators as d, Plugin } from "knub";
|
2018-08-18 19:51:28 +03:00
|
|
|
import { Channel, Message, TextChannel, User } from "eris";
|
2018-07-31 02:42:45 +03:00
|
|
|
import {
|
2018-08-18 19:51:28 +03:00
|
|
|
formatTemplateString,
|
2018-07-31 02:42:45 +03:00
|
|
|
getEmojiInString,
|
2018-08-01 19:13:32 +03:00
|
|
|
getRoleMentions,
|
2018-07-31 02:42:45 +03:00
|
|
|
getUrlsInString,
|
2018-08-01 19:13:32 +03:00
|
|
|
getUserMentions,
|
2018-08-18 19:21:39 +03:00
|
|
|
sleep,
|
2018-08-01 20:21:55 +03:00
|
|
|
stripObjectToScalars,
|
|
|
|
trimLines
|
2018-07-31 02:42:45 +03:00
|
|
|
} from "../utils";
|
|
|
|
import { LogType } from "../data/LogType";
|
|
|
|
import { GuildLogs } from "../data/GuildLogs";
|
|
|
|
import { ModActionsPlugin } from "./ModActions";
|
|
|
|
import { CaseType } from "../data/CaseType";
|
2018-08-18 19:51:28 +03:00
|
|
|
import { GuildArchives } from "../data/GuildArchives";
|
|
|
|
import moment from "moment-timezone";
|
2018-07-31 02:42:45 +03:00
|
|
|
|
|
|
|
enum RecentActionType {
|
|
|
|
Message = 1,
|
|
|
|
Mention,
|
|
|
|
Link,
|
|
|
|
Attachment,
|
|
|
|
Emoji,
|
|
|
|
Newline
|
|
|
|
}
|
|
|
|
|
|
|
|
interface IRecentAction {
|
|
|
|
type: RecentActionType;
|
|
|
|
userId: string;
|
|
|
|
channelId: string;
|
2018-08-01 19:13:32 +03:00
|
|
|
msg: Message;
|
2018-07-31 02:42:45 +03:00
|
|
|
timestamp: number;
|
|
|
|
count: number;
|
|
|
|
}
|
|
|
|
|
2018-08-01 19:13:32 +03:00
|
|
|
const MAX_INTERVAL = 300;
|
|
|
|
|
2018-08-18 19:51:28 +03:00
|
|
|
const ARCHIVE_EXPIRY_DAYS = 90;
|
|
|
|
const ARCHIVE_HEADER_FORMAT = trimLines(`
|
|
|
|
Server: {guild.name} ({guild.id})
|
|
|
|
Channel: #{channel.name} ({channel.id})
|
|
|
|
User: {user.username}#{user.discriminator} ({user.id})
|
|
|
|
`);
|
|
|
|
const ARCHIVE_MESSAGE_FORMAT = "[MSG ID {message.id}] [{timestamp}] {user.username}: {message.content}{attachments}";
|
|
|
|
const ARCHIVE_FOOTER_FORMAT = trimLines(`
|
|
|
|
Log file generated on {timestamp}
|
|
|
|
Expires at {expires}
|
|
|
|
`);
|
|
|
|
|
2018-07-31 02:42:45 +03:00
|
|
|
export class SpamPlugin extends Plugin {
|
|
|
|
protected logs: GuildLogs;
|
2018-08-18 19:51:28 +03:00
|
|
|
protected archives: GuildArchives;
|
2018-07-31 02:42:45 +03:00
|
|
|
|
2018-08-18 19:21:39 +03:00
|
|
|
// Handle spam detection with a queue so we don't have overlapping detections on the same user
|
|
|
|
protected spamDetectionQueue: Promise<void>;
|
|
|
|
|
|
|
|
// List of recent potentially-spammy actions
|
2018-07-31 02:42:45 +03:00
|
|
|
protected recentActions: IRecentAction[];
|
|
|
|
|
2018-08-18 19:21:39 +03:00
|
|
|
// A map of userId => channelId => msgId
|
|
|
|
// Keeps track of the last handled (= spam detected and acted on) message ID per user, per channel
|
|
|
|
// TODO: Prevent this from growing infinitely somehow
|
|
|
|
protected lastHandledMsgIds: Map<string, Map<string, string>>;
|
|
|
|
|
2018-07-31 02:42:45 +03:00
|
|
|
private expiryInterval;
|
|
|
|
|
|
|
|
getDefaultOptions() {
|
|
|
|
return {
|
|
|
|
config: {
|
|
|
|
max_messages: null,
|
|
|
|
max_mentions: null,
|
|
|
|
max_links: null,
|
|
|
|
max_attachments: null,
|
|
|
|
max_emojis: null,
|
|
|
|
max_newlines: null,
|
|
|
|
max_duplicates: null
|
2018-08-02 22:22:51 +03:00
|
|
|
},
|
|
|
|
overrides: [
|
|
|
|
{
|
|
|
|
level: ">=50",
|
|
|
|
config: {
|
|
|
|
max_messages: null,
|
|
|
|
max_mentions: null,
|
|
|
|
max_links: null,
|
|
|
|
max_attachments: null,
|
|
|
|
max_emojis: null,
|
|
|
|
max_newlines: null,
|
|
|
|
max_duplicates: null
|
|
|
|
}
|
|
|
|
}
|
|
|
|
]
|
2018-07-31 02:42:45 +03:00
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
onLoad() {
|
|
|
|
this.logs = new GuildLogs(this.guildId);
|
2018-08-18 19:51:28 +03:00
|
|
|
this.archives = new GuildArchives(this.guildId);
|
2018-08-18 19:21:39 +03:00
|
|
|
|
2018-07-31 02:42:45 +03:00
|
|
|
this.recentActions = [];
|
2018-08-18 19:21:39 +03:00
|
|
|
this.expiryInterval = setInterval(() => this.clearOldRecentActions(), 1000 * 60);
|
|
|
|
this.lastHandledMsgIds = new Map();
|
|
|
|
|
|
|
|
this.spamDetectionQueue = Promise.resolve();
|
2018-07-31 02:42:45 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
onUnload() {
|
|
|
|
clearInterval(this.expiryInterval);
|
|
|
|
}
|
|
|
|
|
|
|
|
addRecentAction(
|
|
|
|
type: RecentActionType,
|
|
|
|
userId: string,
|
|
|
|
channelId: string,
|
2018-08-01 19:13:32 +03:00
|
|
|
msg: Message,
|
2018-07-31 02:42:45 +03:00
|
|
|
timestamp: number,
|
|
|
|
count = 1
|
|
|
|
) {
|
2018-08-18 19:21:39 +03:00
|
|
|
this.recentActions.push({ type, userId, channelId, msg, timestamp, count });
|
2018-07-31 02:42:45 +03:00
|
|
|
}
|
|
|
|
|
2018-08-01 19:13:32 +03:00
|
|
|
getRecentActions(type: RecentActionType, userId: string, channelId: string, since: number) {
|
|
|
|
return this.recentActions.filter(action => {
|
|
|
|
if (action.timestamp < since) return false;
|
|
|
|
if (action.type !== type) return false;
|
|
|
|
if (action.channelId !== channelId) return false;
|
2018-08-03 11:23:08 +03:00
|
|
|
if (action.userId !== userId) return false;
|
2018-08-01 19:13:32 +03:00
|
|
|
return true;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2018-07-31 02:42:45 +03:00
|
|
|
getRecentActionCount(type: RecentActionType, userId: string, channelId: string, since: number) {
|
|
|
|
return this.recentActions.reduce((count, action) => {
|
|
|
|
if (action.timestamp < since) return count;
|
|
|
|
if (action.type !== type) return count;
|
|
|
|
if (action.channelId !== channelId) return count;
|
2018-08-03 11:23:08 +03:00
|
|
|
if (action.userId !== userId) return false;
|
2018-07-31 02:42:45 +03:00
|
|
|
return count + action.count;
|
|
|
|
}, 0);
|
|
|
|
}
|
|
|
|
|
|
|
|
clearRecentUserActions(type: RecentActionType, userId: string, channelId: string) {
|
|
|
|
this.recentActions = this.recentActions.filter(action => {
|
|
|
|
return action.type !== type || action.userId !== userId || action.channelId !== channelId;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
clearOldRecentActions() {
|
|
|
|
// TODO: Figure out expiry time from longest interval in the config?
|
2018-08-01 19:13:32 +03:00
|
|
|
const expiryTimestamp = Date.now() - 1000 * MAX_INTERVAL;
|
2018-07-31 02:42:45 +03:00
|
|
|
this.recentActions = this.recentActions.filter(action => action.timestamp >= expiryTimestamp);
|
|
|
|
}
|
|
|
|
|
2018-08-18 19:51:28 +03:00
|
|
|
async saveSpamArchives(messages: Message[], channel: Channel, user: User) {
|
|
|
|
const expiresAt = moment().add(ARCHIVE_EXPIRY_DAYS, "days");
|
|
|
|
|
|
|
|
const headerStr = formatTemplateString(ARCHIVE_HEADER_FORMAT, {
|
|
|
|
guild: this.guild,
|
|
|
|
channel,
|
|
|
|
user
|
|
|
|
});
|
|
|
|
const msgLines = messages.map(msg => {
|
|
|
|
return formatTemplateString(ARCHIVE_MESSAGE_FORMAT, {
|
|
|
|
message: msg,
|
|
|
|
timestamp: moment(msg.timestamp, "x").format("HH:mm:ss"),
|
|
|
|
user
|
|
|
|
});
|
|
|
|
});
|
|
|
|
const messagesStr = msgLines.join("\n");
|
|
|
|
const footerStr = formatTemplateString(ARCHIVE_FOOTER_FORMAT, {
|
|
|
|
timestamp: moment().format("YYYY-MM-DD [at] HH:mm:ss (Z)"),
|
|
|
|
expires: expiresAt.format("YYYY-MM-DD [at] HH:mm:ss (Z)")
|
|
|
|
});
|
|
|
|
|
|
|
|
const logId = await this.archives.create([headerStr, messagesStr, footerStr].join("\n\n"), expiresAt);
|
2018-08-01 20:09:51 +03:00
|
|
|
|
|
|
|
const url = this.knub.getGlobalConfig().url;
|
2018-08-18 19:51:28 +03:00
|
|
|
return url ? `${url}/archives/${logId}` : `Archive ID: ${logId}`;
|
2018-08-01 20:09:51 +03:00
|
|
|
}
|
|
|
|
|
2018-08-18 19:21:39 +03:00
|
|
|
async logAndDetectSpam(
|
2018-07-31 02:42:45 +03:00
|
|
|
msg: Message,
|
|
|
|
type: RecentActionType,
|
|
|
|
spamConfig: any,
|
|
|
|
actionCount: number,
|
|
|
|
description: string
|
|
|
|
) {
|
|
|
|
if (actionCount === 0) return;
|
|
|
|
|
2018-08-18 19:21:39 +03:00
|
|
|
// Make sure we're not handling some messages twice
|
|
|
|
if (this.lastHandledMsgIds.has(msg.author.id)) {
|
|
|
|
const channelMap = this.lastHandledMsgIds.get(msg.author.id);
|
|
|
|
if (channelMap.has(msg.channel.id)) {
|
|
|
|
const lastHandledMsgId = channelMap.get(msg.channel.id);
|
|
|
|
if (lastHandledMsgId >= msg.id) return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
this.spamDetectionQueue = this.spamDetectionQueue.then(
|
|
|
|
async () => {
|
|
|
|
// Log this action...
|
|
|
|
this.addRecentAction(type, msg.author.id, msg.channel.id, msg, msg.timestamp, actionCount);
|
2018-08-01 19:13:32 +03:00
|
|
|
|
2018-08-18 19:21:39 +03:00
|
|
|
// ...and then check if it trips the spam filters
|
|
|
|
const since = msg.timestamp - 1000 * spamConfig.interval;
|
|
|
|
const recentActionsCount = this.getRecentActionCount(type, msg.author.id, msg.channel.id, since);
|
2018-07-31 02:42:45 +03:00
|
|
|
|
2018-08-18 19:21:39 +03:00
|
|
|
// If the user tripped the spam filter...
|
|
|
|
if (recentActionsCount > spamConfig.count) {
|
|
|
|
const recentActions = this.getRecentActions(type, msg.author.id, msg.channel.id, since);
|
|
|
|
let modActionsPlugin;
|
2018-07-31 02:42:45 +03:00
|
|
|
|
2018-08-18 19:21:39 +03:00
|
|
|
// Start by muting them, if enabled
|
|
|
|
if (spamConfig.mute) {
|
|
|
|
// We use the ModActions plugin for muting the user
|
|
|
|
// This means that spam mute functionality requires the ModActions plugin to be loaded
|
|
|
|
const guildData = this.knub.getGuildData(this.guildId);
|
|
|
|
modActionsPlugin = guildData.loadedPlugins.get("mod_actions") as ModActionsPlugin;
|
|
|
|
if (!modActionsPlugin) return;
|
2018-07-31 02:42:45 +03:00
|
|
|
|
2018-08-18 19:21:39 +03:00
|
|
|
const muteTime = spamConfig.mute_time ? spamConfig.mute_time * 60 * 1000 : 120 * 1000;
|
|
|
|
|
|
|
|
this.logs.ignoreLog(LogType.MEMBER_ROLE_ADD, msg.member.id);
|
|
|
|
modActionsPlugin.muteMember(msg.member, muteTime, "Automatic spam detection");
|
|
|
|
}
|
|
|
|
|
|
|
|
// Get the offending message IDs
|
|
|
|
// We also get the IDs of any messages after the last offending message, to account for lag before detection
|
|
|
|
const messages = recentActions.map(a => a.msg);
|
|
|
|
const msgIds = messages.map(m => m.id);
|
|
|
|
const lastDetectedMsgId = msgIds[msgIds.length - 1];
|
|
|
|
const additionalMessages = await this.bot.getMessages(msg.channel.id, 100, null, lastDetectedMsgId);
|
|
|
|
additionalMessages.forEach(m => msgIds.push(m.id));
|
|
|
|
|
|
|
|
// Then, if enabled, remove the spam messages
|
|
|
|
if (spamConfig.clean !== false) {
|
|
|
|
msgIds.forEach(id => this.logs.ignoreLog(LogType.MESSAGE_DELETE, id));
|
|
|
|
this.bot.deleteMessages(msg.channel.id, msgIds);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Store the ID of the last handled message
|
|
|
|
const uniqueMessages = Array.from(new Set([...messages, ...additionalMessages]));
|
|
|
|
uniqueMessages.sort((a, b) => (a.id > b.id ? 1 : -1));
|
|
|
|
const lastHandledMsgId = uniqueMessages.reduce((last: string, m: Message): string => {
|
|
|
|
return !last || m.id > last ? m.id : last;
|
|
|
|
}, null);
|
|
|
|
|
|
|
|
if (!this.lastHandledMsgIds.has(msg.author.id)) {
|
|
|
|
this.lastHandledMsgIds.set(msg.author.id, new Map());
|
|
|
|
}
|
|
|
|
|
|
|
|
const channelMap = this.lastHandledMsgIds.get(msg.author.id);
|
|
|
|
channelMap.set(msg.channel.id, lastHandledMsgId);
|
|
|
|
|
|
|
|
// Clear the handled actions from recentActions
|
|
|
|
this.clearRecentUserActions(type, msg.author.id, msg.channel.id);
|
|
|
|
|
|
|
|
// Generate a log from the detected messages
|
2018-08-18 19:51:28 +03:00
|
|
|
const logUrl = await this.saveSpamArchives(uniqueMessages, msg.channel, msg.author);
|
2018-08-18 19:21:39 +03:00
|
|
|
|
|
|
|
// Create a case and log the actions taken above
|
|
|
|
const caseType = spamConfig.mute ? CaseType.Mute : CaseType.Note;
|
|
|
|
const caseText = trimLines(`
|
|
|
|
Automatic spam detection: ${description} (over ${spamConfig.count} in ${spamConfig.interval}s)
|
|
|
|
${logUrl}
|
|
|
|
`);
|
|
|
|
|
|
|
|
this.logs.log(LogType.SPAM_DETECTED, {
|
|
|
|
member: stripObjectToScalars(msg.member, ["user"]),
|
|
|
|
channel: stripObjectToScalars(msg.channel),
|
|
|
|
description,
|
|
|
|
limit: spamConfig.count,
|
|
|
|
interval: spamConfig.interval,
|
|
|
|
logUrl
|
|
|
|
});
|
|
|
|
|
|
|
|
const caseId = await modActionsPlugin.createCase(
|
|
|
|
msg.member.id,
|
|
|
|
this.bot.user.id,
|
|
|
|
caseType,
|
|
|
|
null,
|
|
|
|
caseText,
|
|
|
|
true
|
|
|
|
);
|
|
|
|
|
|
|
|
// For mutes, also set the mute's case id (for !mutes)
|
|
|
|
if (spamConfig.mute) {
|
|
|
|
await modActionsPlugin.mutes.setCaseId(msg.member.id, caseId);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
},
|
|
|
|
err => {
|
|
|
|
console.error("Error while detecting spam:");
|
|
|
|
console.error(err);
|
|
|
|
}
|
|
|
|
);
|
2018-07-31 02:42:45 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
@d.event("messageCreate")
|
2018-08-18 19:21:39 +03:00
|
|
|
async onMessageCreate(msg: Message) {
|
2018-07-31 02:42:45 +03:00
|
|
|
if (msg.author.bot) return;
|
|
|
|
|
|
|
|
const maxMessages = this.configValueForMsg(msg, "max_messages");
|
|
|
|
if (maxMessages) {
|
2018-08-18 19:21:39 +03:00
|
|
|
this.logAndDetectSpam(msg, RecentActionType.Message, maxMessages, 1, "too many messages");
|
2018-07-31 02:42:45 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
const maxMentions = this.configValueForMsg(msg, "max_mentions");
|
2018-08-18 19:21:39 +03:00
|
|
|
const mentions = msg.content ? [...getUserMentions(msg.content), ...getRoleMentions(msg.content)] : [];
|
2018-08-01 19:13:32 +03:00
|
|
|
if (maxMentions && mentions.length) {
|
2018-08-18 19:21:39 +03:00
|
|
|
this.logAndDetectSpam(msg, RecentActionType.Mention, maxMentions, mentions.length, "too many mentions");
|
2018-07-31 02:42:45 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
const maxLinks = this.configValueForMsg(msg, "max_links");
|
|
|
|
if (maxLinks && msg.content) {
|
|
|
|
const links = getUrlsInString(msg.content);
|
2018-08-18 19:21:39 +03:00
|
|
|
this.logAndDetectSpam(msg, RecentActionType.Link, maxLinks, links.length, "too many links");
|
2018-07-31 02:42:45 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
const maxAttachments = this.configValueForMsg(msg, "max_attachments");
|
|
|
|
if (maxAttachments && msg.attachments.length) {
|
2018-08-18 19:21:39 +03:00
|
|
|
this.logAndDetectSpam(
|
2018-07-31 02:42:45 +03:00
|
|
|
msg,
|
|
|
|
RecentActionType.Attachment,
|
|
|
|
maxAttachments,
|
|
|
|
msg.attachments.length,
|
|
|
|
"too many attachments"
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
const maxEmoji = this.configValueForMsg(msg, "max_emoji");
|
|
|
|
if (maxEmoji && msg.content) {
|
|
|
|
const emojiCount = getEmojiInString(msg.content).length;
|
2018-08-18 19:21:39 +03:00
|
|
|
this.logAndDetectSpam(msg, RecentActionType.Emoji, maxEmoji, emojiCount, "too many emoji");
|
2018-07-31 02:42:45 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
const maxNewlines = this.configValueForMsg(msg, "max_newlines");
|
|
|
|
if (maxNewlines && msg.content) {
|
|
|
|
const newlineCount = (msg.content.match(/\n/g) || []).length;
|
2018-08-18 19:21:39 +03:00
|
|
|
this.logAndDetectSpam(msg, RecentActionType.Newline, maxNewlines, newlineCount, "too many newlines");
|
2018-07-31 02:42:45 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
// TODO: Max duplicates
|
|
|
|
}
|
|
|
|
}
|