mirror of
https://github.com/ZeppelinBot/Zeppelin.git
synced 2025-03-16 14:11:50 +00:00
Migrate Spam to new Plugin structure
This commit is contained in:
parent
140ba84544
commit
4f831f6bf6
14 changed files with 622 additions and 0 deletions
74
backend/src/plugins/Spam/SpamPlugin.ts
Normal file
74
backend/src/plugins/Spam/SpamPlugin.ts
Normal 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);
|
||||||
|
},
|
||||||
|
});
|
52
backend/src/plugins/Spam/events/SpamVoiceEvt.ts
Normal file
52
backend/src/plugins/Spam/events/SpamVoiceEvt.ts
Normal 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",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
78
backend/src/plugins/Spam/types.ts
Normal file
78
backend/src/plugins/Spam/types.ts
Normal 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>();
|
14
backend/src/plugins/Spam/util/addRecentAction.ts
Normal file
14
backend/src/plugins/Spam/util/addRecentAction.ts
Normal 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 });
|
||||||
|
}
|
7
backend/src/plugins/Spam/util/clearOldRecentActions.ts
Normal file
7
backend/src/plugins/Spam/util/clearOldRecentActions.ts
Normal 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);
|
||||||
|
}
|
7
backend/src/plugins/Spam/util/clearRecentUserActions.ts
Normal file
7
backend/src/plugins/Spam/util/clearRecentUserActions.ts
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
17
backend/src/plugins/Spam/util/getRecentActionCount.ts
Normal file
17
backend/src/plugins/Spam/util/getRecentActionCount.ts
Normal 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);
|
||||||
|
}
|
17
backend/src/plugins/Spam/util/getRecentActions.ts
Normal file
17
backend/src/plugins/Spam/util/getRecentActions.ts
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
167
backend/src/plugins/Spam/util/logAndDetectMessageSpam.ts
Normal file
167
backend/src/plugins/Spam/util/logAndDetectMessageSpam.ts
Normal 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}`);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
66
backend/src/plugins/Spam/util/logAndDetectOtherSpam.ts
Normal file
66
backend/src/plugins/Spam/util/logAndDetectOtherSpam.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
23
backend/src/plugins/Spam/util/logCensor.ts
Normal file
23
backend/src/plugins/Spam/util/logCensor.ts
Normal 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",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
86
backend/src/plugins/Spam/util/onMessageCreate.ts
Normal file
86
backend/src/plugins/Spam/util/onMessageCreate.ts
Normal 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
|
||||||
|
}
|
12
backend/src/plugins/Spam/util/saveSpamArchives.ts
Normal file
12
backend/src/plugins/Spam/util/saveSpamArchives.ts
Normal 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);
|
||||||
|
}
|
|
@ -22,6 +22,7 @@ import { RolesPlugin } from "./Roles/RolesPlugin";
|
||||||
import { SlowmodePlugin } from "./Slowmode/SlowmodePlugin";
|
import { SlowmodePlugin } from "./Slowmode/SlowmodePlugin";
|
||||||
import { StarboardPlugin } from "./Starboard/StarboardPlugin";
|
import { StarboardPlugin } from "./Starboard/StarboardPlugin";
|
||||||
import { ChannelArchiverPlugin } from "./ChannelArchiver/ChannelArchiverPlugin";
|
import { ChannelArchiverPlugin } from "./ChannelArchiver/ChannelArchiverPlugin";
|
||||||
|
import { SpamPlugin } from "./Spam/SpamPlugin";
|
||||||
|
|
||||||
// prettier-ignore
|
// prettier-ignore
|
||||||
export const guildPlugins: Array<ZeppelinPluginBlueprint<any>> = [
|
export const guildPlugins: Array<ZeppelinPluginBlueprint<any>> = [
|
||||||
|
@ -40,6 +41,7 @@ export const guildPlugins: Array<ZeppelinPluginBlueprint<any>> = [
|
||||||
RemindersPlugin,
|
RemindersPlugin,
|
||||||
RolesPlugin,
|
RolesPlugin,
|
||||||
SlowmodePlugin,
|
SlowmodePlugin,
|
||||||
|
SpamPlugin,
|
||||||
StarboardPlugin,
|
StarboardPlugin,
|
||||||
TagsPlugin,
|
TagsPlugin,
|
||||||
UsernameSaverPlugin,
|
UsernameSaverPlugin,
|
||||||
|
|
Loading…
Add table
Reference in a new issue