3
0
Fork 0
mirror of https://github.com/ZeppelinBot/Zeppelin.git synced 2025-03-15 05:41:51 +00:00

Merge pull request #91 from DarkView/k30_spam

[K30] Migrated Spam
This commit is contained in:
Miikka 2020-07-28 21:35:23 +03:00 committed by GitHub
commit 176346f3ff
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 622 additions and 0 deletions

View file

@ -0,0 +1,74 @@
import { zeppelinPlugin } from "../ZeppelinPluginBlueprint";
import { PluginOptions } from "knub";
import { ConfigSchema, SpamPluginType } from "./types";
import { GuildLogs } from "src/data/GuildLogs";
import { GuildArchives } from "src/data/GuildArchives";
import { GuildSavedMessages } from "src/data/GuildSavedMessages";
import { GuildMutes } from "src/data/GuildMutes";
import { onMessageCreate } from "./util/onMessageCreate";
import { clearOldRecentActions } from "./util/clearOldRecentActions";
import { SpamVoiceJoinEvt, SpamVoiceSwitchEvt } from "./events/SpamVoiceEvt";
const defaultOptions: PluginOptions<SpamPluginType> = {
config: {
max_censor: null,
max_messages: null,
max_mentions: null,
max_links: null,
max_attachments: null,
max_emojis: null,
max_newlines: null,
max_duplicates: null,
max_characters: null,
max_voice_moves: null,
},
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,
max_characters: null,
max_voice_moves: null,
},
},
],
};
export const SpamPlugin = zeppelinPlugin<SpamPluginType>()("spam", {
configSchema: ConfigSchema,
defaultOptions,
// prettier-ignore
events: [
SpamVoiceJoinEvt,
SpamVoiceSwitchEvt,
],
onLoad(pluginData) {
const { state, guild } = pluginData;
state.logs = new GuildLogs(guild.id);
state.archives = GuildArchives.getGuildInstance(guild.id);
state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id);
state.mutes = GuildMutes.getGuildInstance(guild.id);
state.recentActions = [];
state.expiryInterval = setInterval(() => clearOldRecentActions(pluginData), 1000 * 60);
state.lastHandledMsgIds = new Map();
state.spamDetectionQueue = Promise.resolve();
state.onMessageCreateFn = msg => onMessageCreate(pluginData, msg);
state.savedMessages.events.on("create", state.onMessageCreateFn);
},
onUnload(pluginData) {
pluginData.state.savedMessages.events.off("create", pluginData.state.onMessageCreateFn);
},
});

View file

@ -0,0 +1,52 @@
import { spamEvent, RecentActionType } from "../types";
import { logAndDetectOtherSpam } from "../util/logAndDetectOtherSpam";
export const SpamVoiceJoinEvt = spamEvent({
event: "voiceChannelJoin",
async listener(meta) {
const member = meta.args.member;
const channel = meta.args.newChannel;
const config = meta.pluginData.config.getMatchingConfig({ member, channelId: channel.id });
const maxVoiceMoves = config.max_voice_moves;
if (maxVoiceMoves) {
logAndDetectOtherSpam(
meta.pluginData,
RecentActionType.VoiceChannelMove,
maxVoiceMoves,
member.id,
1,
"0",
Date.now(),
null,
"too many voice channel moves",
);
}
},
});
export const SpamVoiceSwitchEvt = spamEvent({
event: "voiceChannelSwitch",
async listener(meta) {
const member = meta.args.member;
const channel = meta.args.newChannel;
const config = meta.pluginData.config.getMatchingConfig({ member, channelId: channel.id });
const maxVoiceMoves = config.max_voice_moves;
if (maxVoiceMoves) {
logAndDetectOtherSpam(
meta.pluginData,
RecentActionType.VoiceChannelMove,
maxVoiceMoves,
member.id,
1,
"0",
Date.now(),
null,
"too many voice channel moves",
);
}
},
});

View file

@ -0,0 +1,78 @@
import * as t from "io-ts";
import { BasePluginType, eventListener } from "knub";
import { tNullable } from "src/utils";
import { GuildLogs } from "src/data/GuildLogs";
import { GuildArchives } from "src/data/GuildArchives";
import { GuildSavedMessages } from "src/data/GuildSavedMessages";
import { GuildMutes } from "src/data/GuildMutes";
const BaseSingleSpamConfig = t.type({
interval: t.number,
count: t.number,
mute: tNullable(t.boolean),
mute_time: tNullable(t.number),
clean: tNullable(t.boolean),
});
export type TBaseSingleSpamConfig = t.TypeOf<typeof BaseSingleSpamConfig>;
export const ConfigSchema = t.type({
max_censor: tNullable(BaseSingleSpamConfig),
max_messages: tNullable(BaseSingleSpamConfig),
max_mentions: tNullable(BaseSingleSpamConfig),
max_links: tNullable(BaseSingleSpamConfig),
max_attachments: tNullable(BaseSingleSpamConfig),
max_emojis: tNullable(BaseSingleSpamConfig),
max_newlines: tNullable(BaseSingleSpamConfig),
max_duplicates: tNullable(BaseSingleSpamConfig),
max_characters: tNullable(BaseSingleSpamConfig),
max_voice_moves: tNullable(BaseSingleSpamConfig),
});
export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
export enum RecentActionType {
Message = 1,
Mention,
Link,
Attachment,
Emoji,
Newline,
Censor,
Character,
VoiceChannelMove,
}
interface IRecentAction<T> {
type: RecentActionType;
userId: string;
actionGroupId: string;
extraData: T;
timestamp: number;
count: number;
}
export interface SpamPluginType extends BasePluginType {
config: TConfigSchema;
state: {
logs: GuildLogs;
archives: GuildArchives;
savedMessages: GuildSavedMessages;
mutes: GuildMutes;
onMessageCreateFn;
// Handle spam detection with a queue so we don't have overlapping detections on the same user
spamDetectionQueue: Promise<void>;
// List of recent potentially-spammy actions
recentActions: Array<IRecentAction<any>>;
// 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
lastHandledMsgIds: Map<string, Map<string, string>>;
expiryInterval;
};
}
export const spamEvent = eventListener<SpamPluginType>();

View file

@ -0,0 +1,14 @@
import { PluginData } from "knub";
import { SpamPluginType, RecentActionType } from "../types";
export function addRecentAction(
pluginData: PluginData<SpamPluginType>,
type: RecentActionType,
userId: string,
actionGroupId: string,
extraData: any,
timestamp: number,
count = 1,
) {
pluginData.state.recentActions.push({ type, userId, actionGroupId, extraData, timestamp, count });
}

View file

@ -0,0 +1,7 @@
const MAX_INTERVAL = 300;
export function clearOldRecentActions(pluginData) {
// TODO: Figure out expiry time from longest interval in the config?
const expiryTimestamp = Date.now() - 1000 * MAX_INTERVAL;
pluginData.state.recentActions = pluginData.state.recentActions.filter(action => action.timestamp >= expiryTimestamp);
}

View file

@ -0,0 +1,7 @@
import { RecentActionType } from "../types";
export function clearRecentUserActions(pluginData, type: RecentActionType, userId: string, actionGroupId: string) {
pluginData.state.recentActions = pluginData.state.recentActions.filter(action => {
return action.type !== type || action.userId !== userId || action.actionGroupId !== actionGroupId;
});
}

View file

@ -0,0 +1,17 @@
import { RecentActionType } from "../types";
export function getRecentActionCount(
pluginData,
type: RecentActionType,
userId: string,
actionGroupId: string,
since: number,
) {
return pluginData.state.recentActions.reduce((count, action) => {
if (action.timestamp < since) return count;
if (action.type !== type) return count;
if (action.actionGroupId !== actionGroupId) return count;
if (action.userId !== userId) return false;
return count + action.count;
}, 0);
}

View file

@ -0,0 +1,17 @@
import { RecentActionType } from "../types";
export function getRecentActions(
pluginData,
type: RecentActionType,
userId: string,
actionGroupId: string,
since: number,
) {
return pluginData.state.recentActions.filter(action => {
if (action.timestamp < since) return false;
if (action.type !== type) return false;
if (action.actionGroupId !== actionGroupId) return false;
if (action.userId !== userId) return false;
return true;
});
}

View file

@ -0,0 +1,167 @@
import { SavedMessage } from "src/data/entities/SavedMessage";
import { RecentActionType, TBaseSingleSpamConfig, SpamPluginType } from "../types";
import moment from "moment-timezone";
import { MuteResult } from "src/plugins/Mutes/types";
import { convertDelayStringToMS, trimLines, stripObjectToScalars, resolveMember, noop } from "src/utils";
import { LogType } from "src/data/LogType";
import { CaseTypes } from "src/data/CaseTypes";
import { logger } from "src/logger";
import { PluginData } from "knub";
import { MutesPlugin } from "src/plugins/Mutes/MutesPlugin";
import { CasesPlugin } from "src/plugins/Cases/CasesPlugin";
import { addRecentAction } from "./addRecentAction";
import { getRecentActionCount } from "./getRecentActionCount";
import { getRecentActions } from "./getRecentActions";
import { clearRecentUserActions } from "./clearRecentUserActions";
import { saveSpamArchives } from "./saveSpamArchives";
export async function logAndDetectMessageSpam(
pluginData: PluginData<SpamPluginType>,
savedMessage: SavedMessage,
type: RecentActionType,
spamConfig: TBaseSingleSpamConfig,
actionCount: number,
description: string,
) {
if (actionCount === 0) return;
// Make sure we're not handling some messages twice
if (pluginData.state.lastHandledMsgIds.has(savedMessage.user_id)) {
const channelMap = pluginData.state.lastHandledMsgIds.get(savedMessage.user_id);
if (channelMap.has(savedMessage.channel_id)) {
const lastHandledMsgId = channelMap.get(savedMessage.channel_id);
if (lastHandledMsgId >= savedMessage.id) return;
}
}
pluginData.state.spamDetectionQueue = pluginData.state.spamDetectionQueue.then(
async () => {
const timestamp = moment(savedMessage.posted_at).valueOf();
const member = await resolveMember(pluginData.client, pluginData.guild, savedMessage.user_id);
// Log this action...
addRecentAction(
pluginData,
type,
savedMessage.user_id,
savedMessage.channel_id,
savedMessage,
timestamp,
actionCount,
);
// ...and then check if it trips the spam filters
const since = timestamp - 1000 * spamConfig.interval;
const recentActionsCount = getRecentActionCount(
pluginData,
type,
savedMessage.user_id,
savedMessage.channel_id,
since,
);
// If the user tripped the spam filter...
if (recentActionsCount > spamConfig.count) {
const recentActions = getRecentActions(pluginData, type, savedMessage.user_id, savedMessage.channel_id, since);
// Start by muting them, if enabled
let muteResult: MuteResult;
if (spamConfig.mute && member) {
const mutesPlugin = pluginData.getPlugin(MutesPlugin);
const muteTime = spamConfig.mute_time ? convertDelayStringToMS(spamConfig.mute_time.toString()) : 120 * 1000;
muteResult = await mutesPlugin.muteUser(member.id, muteTime, "Automatic spam detection", {
caseArgs: {
modId: pluginData.client.user.id,
postInCaseLogOverride: false,
},
});
}
// 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 savedMessages = recentActions.map(a => a.extraData as SavedMessage);
const msgIds = savedMessages.map(m => m.id);
const lastDetectedMsgId = msgIds[msgIds.length - 1];
const additionalMessages = await pluginData.state.savedMessages.getUserMessagesByChannelAfterId(
savedMessage.user_id,
savedMessage.channel_id,
lastDetectedMsgId,
);
additionalMessages.forEach(m => msgIds.push(m.id));
// Then, if enabled, remove the spam messages
if (spamConfig.clean !== false) {
msgIds.forEach(id => pluginData.state.logs.ignoreLog(LogType.MESSAGE_DELETE, id));
pluginData.client.deleteMessages(savedMessage.channel_id, msgIds).catch(noop);
}
// Store the ID of the last handled message
const uniqueMessages = Array.from(new Set([...savedMessages, ...additionalMessages]));
uniqueMessages.sort((a, b) => (a.id > b.id ? 1 : -1));
const lastHandledMsgId = uniqueMessages.reduce((last: string, m: SavedMessage): string => {
return !last || m.id > last ? m.id : last;
}, null);
if (!pluginData.state.lastHandledMsgIds.has(savedMessage.user_id)) {
pluginData.state.lastHandledMsgIds.set(savedMessage.user_id, new Map());
}
const channelMap = pluginData.state.lastHandledMsgIds.get(savedMessage.user_id);
channelMap.set(savedMessage.channel_id, lastHandledMsgId);
// Clear the handled actions from recentActions
clearRecentUserActions(pluginData, type, savedMessage.user_id, savedMessage.channel_id);
// Generate a log from the detected messages
const channel = pluginData.guild.channels.get(savedMessage.channel_id);
const archiveUrl = await saveSpamArchives(pluginData, uniqueMessages);
// Create a case
const casesPlugin = pluginData.getPlugin(CasesPlugin);
if (muteResult) {
// If the user was muted, the mute already generated a case - in that case, just update the case with extra details
// This will also post the case in the case log channel, which we didn't do with the mute initially to avoid
// posting the case on the channel twice: once with the initial reason, and then again with the note from here
const updateText = trimLines(`
Details: ${description} (over ${spamConfig.count} in ${spamConfig.interval}s)
${archiveUrl}
`);
casesPlugin.createCaseNote({
caseId: muteResult.case.id,
modId: muteResult.case.mod_id,
body: updateText,
automatic: true,
});
} else {
// If the user was not muted, create a note case of the detected spam instead
const caseText = trimLines(`
Automatic spam detection: ${description} (over ${spamConfig.count} in ${spamConfig.interval}s)
${archiveUrl}
`);
casesPlugin.createCase({
userId: savedMessage.user_id,
modId: pluginData.client.user.id,
type: CaseTypes.Note,
reason: caseText,
automatic: true,
});
}
// Create a log entry
pluginData.state.logs.log(LogType.MESSAGE_SPAM_DETECTED, {
member: stripObjectToScalars(member, ["user", "roles"]),
channel: stripObjectToScalars(channel),
description,
limit: spamConfig.count,
interval: spamConfig.interval,
archiveUrl,
});
}
},
err => {
logger.error(`Error while detecting spam:\n${err}`);
},
);
}

View file

@ -0,0 +1,66 @@
import { PluginData } from "knub";
import { SpamPluginType, RecentActionType } from "../types";
import { addRecentAction } from "./addRecentAction";
import { getRecentActionCount } from "./getRecentActionCount";
import { resolveMember, convertDelayStringToMS, stripObjectToScalars } from "src/utils";
import { MutesPlugin } from "src/plugins/Mutes/MutesPlugin";
import { CasesPlugin } from "src/plugins/Cases/CasesPlugin";
import { CaseTypes } from "src/data/CaseTypes";
import { clearRecentUserActions } from "./clearRecentUserActions";
import { LogType } from "src/data/LogType";
export async function logAndDetectOtherSpam(
pluginData: PluginData<SpamPluginType>,
type: RecentActionType,
spamConfig: any,
userId: string,
actionCount: number,
actionGroupId: string,
timestamp: number,
extraData = null,
description: string,
) {
pluginData.state.spamDetectionQueue = pluginData.state.spamDetectionQueue.then(async () => {
// Log this action...
addRecentAction(pluginData, type, userId, actionGroupId, extraData, timestamp, actionCount);
// ...and then check if it trips the spam filters
const since = timestamp - 1000 * spamConfig.interval;
const recentActionsCount = getRecentActionCount(pluginData, type, userId, actionGroupId, since);
if (recentActionsCount > spamConfig.count) {
const member = await resolveMember(pluginData.client, pluginData.guild, userId);
const details = `${description} (over ${spamConfig.count} in ${spamConfig.interval}s)`;
if (spamConfig.mute && member) {
const mutesPlugin = pluginData.getPlugin(MutesPlugin);
const muteTime = spamConfig.mute_time ? convertDelayStringToMS(spamConfig.mute_time.toString()) : 120 * 1000;
await mutesPlugin.muteUser(member.id, muteTime, "Automatic spam detection", {
caseArgs: {
modId: pluginData.client.user.id,
extraNotes: [`Details: ${details}`],
},
});
} else {
// If we're not muting the user, just add a note on them
const casesPlugin = pluginData.getPlugin(CasesPlugin);
await casesPlugin.createCase({
userId,
modId: pluginData.client.user.id,
type: CaseTypes.Note,
reason: `Automatic spam detection: ${details}`,
});
}
// Clear recent cases
clearRecentUserActions(pluginData, RecentActionType.VoiceChannelMove, userId, actionGroupId);
pluginData.state.logs.log(LogType.OTHER_SPAM_DETECTED, {
member: stripObjectToScalars(member, ["user", "roles"]),
description,
limit: spamConfig.count,
interval: spamConfig.interval,
});
}
});
}

View file

@ -0,0 +1,23 @@
import { PluginData } from "knub";
import { SpamPluginType, RecentActionType } from "../types";
import { SavedMessage } from "src/data/entities/SavedMessage";
import { logAndDetectMessageSpam } from "./logAndDetectMessageSpam";
export async function logCensor(pluginData: PluginData<SpamPluginType>, savedMessage: SavedMessage) {
const config = pluginData.config.getMatchingConfig({
userId: savedMessage.user_id,
channelId: savedMessage.channel_id,
});
const spamConfig = config.max_censor;
if (spamConfig) {
logAndDetectMessageSpam(
pluginData,
savedMessage,
RecentActionType.Censor,
spamConfig,
1,
"too many censored messages",
);
}
}

View file

@ -0,0 +1,86 @@
import { PluginData } from "knub";
import { SpamPluginType, RecentActionType } from "../types";
import { SavedMessage } from "src/data/entities/SavedMessage";
import { getUserMentions, getRoleMentions, getUrlsInString, getEmojiInString } from "src/utils";
import { logAndDetectMessageSpam } from "./logAndDetectMessageSpam";
export async function onMessageCreate(pluginData: PluginData<SpamPluginType>, savedMessage: SavedMessage) {
if (savedMessage.is_bot) return;
const config = pluginData.config.getMatchingConfig({
userId: savedMessage.user_id,
channelId: savedMessage.channel_id,
});
const maxMessages = config.max_messages;
if (maxMessages) {
logAndDetectMessageSpam(pluginData, savedMessage, RecentActionType.Message, maxMessages, 1, "too many messages");
}
const maxMentions = config.max_mentions;
const mentions = savedMessage.data.content
? [...getUserMentions(savedMessage.data.content), ...getRoleMentions(savedMessage.data.content)]
: [];
if (maxMentions && mentions.length) {
logAndDetectMessageSpam(
pluginData,
savedMessage,
RecentActionType.Mention,
maxMentions,
mentions.length,
"too many mentions",
);
}
const maxLinks = config.max_links;
if (maxLinks && savedMessage.data.content && typeof savedMessage.data.content === "string") {
const links = getUrlsInString(savedMessage.data.content);
logAndDetectMessageSpam(pluginData, savedMessage, RecentActionType.Link, maxLinks, links.length, "too many links");
}
const maxAttachments = config.max_attachments;
if (maxAttachments && savedMessage.data.attachments) {
logAndDetectMessageSpam(
pluginData,
savedMessage,
RecentActionType.Attachment,
maxAttachments,
savedMessage.data.attachments.length,
"too many attachments",
);
}
const maxEmojis = config.max_emojis;
if (maxEmojis && savedMessage.data.content) {
const emojiCount = getEmojiInString(savedMessage.data.content).length;
logAndDetectMessageSpam(pluginData, savedMessage, RecentActionType.Emoji, maxEmojis, emojiCount, "too many emoji");
}
const maxNewlines = config.max_newlines;
if (maxNewlines && savedMessage.data.content) {
const newlineCount = (savedMessage.data.content.match(/\n/g) || []).length;
logAndDetectMessageSpam(
pluginData,
savedMessage,
RecentActionType.Newline,
maxNewlines,
newlineCount,
"too many newlines",
);
}
const maxCharacters = config.max_characters;
if (maxCharacters && savedMessage.data.content) {
const characterCount = [...savedMessage.data.content.trim()].length;
logAndDetectMessageSpam(
pluginData,
savedMessage,
RecentActionType.Character,
maxCharacters,
characterCount,
"too many characters",
);
}
// TODO: Max duplicates check
}

View file

@ -0,0 +1,12 @@
import { SavedMessage } from "src/data/entities/SavedMessage";
import moment from "moment-timezone";
import { getBaseUrl } from "src/pluginUtils";
const SPAM_ARCHIVE_EXPIRY_DAYS = 90;
export async function saveSpamArchives(pluginData, savedMessages: SavedMessage[]) {
const expiresAt = moment().add(SPAM_ARCHIVE_EXPIRY_DAYS, "days");
const archiveId = await pluginData.state.archives.createFromSavedMessages(savedMessages, pluginData.guild, expiresAt);
return pluginData.state.archives.getUrl(getBaseUrl, archiveId);
}

View file

@ -22,6 +22,7 @@ import { RolesPlugin } from "./Roles/RolesPlugin";
import { SlowmodePlugin } from "./Slowmode/SlowmodePlugin";
import { StarboardPlugin } from "./Starboard/StarboardPlugin";
import { ChannelArchiverPlugin } from "./ChannelArchiver/ChannelArchiverPlugin";
import { SpamPlugin } from "./Spam/SpamPlugin";
import { ReactionRolesPlugin } from "./ReactionRoles/ReactionRolesPlugin";
import { AutomodPlugin } from "./Automod/AutomodPlugin";
@ -43,6 +44,7 @@ export const guildPlugins: Array<ZeppelinPluginBlueprint<any>> = [
RemindersPlugin,
RolesPlugin,
SlowmodePlugin,
SpamPlugin,
StarboardPlugin,
TagsPlugin,
UsernameSaverPlugin,