Automod work
This commit is contained in:
parent
140ba84544
commit
f657b169df
32 changed files with 1099 additions and 5 deletions
|
@ -41,4 +41,8 @@ export class Queue {
|
||||||
setTimeout(resolve, this.timeout);
|
setTimeout(resolve, this.timeout);
|
||||||
}).then(() => this.next());
|
}).then(() => this.next());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public clear() {
|
||||||
|
this.queue.splice(0, this.queue.length);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Member } from "eris";
|
import { Member } from "eris";
|
||||||
import { configUtils, helpers, PluginData, PluginOptions } from "knub";
|
import { configUtils, helpers, PluginBlueprint, PluginData, PluginOptions } from "knub";
|
||||||
import { decodeAndValidateStrict, StrictValidationError } from "./validatorUtils";
|
import { decodeAndValidateStrict, StrictValidationError } from "./validatorUtils";
|
||||||
import { deepKeyIntersect, errorMessage, successMessage } from "./utils";
|
import { deepKeyIntersect, errorMessage, successMessage } from "./utils";
|
||||||
import { ZeppelinPluginBlueprint } from "./plugins/ZeppelinPluginBlueprint";
|
import { ZeppelinPluginBlueprint } from "./plugins/ZeppelinPluginBlueprint";
|
||||||
|
@ -27,8 +27,15 @@ export function hasPermission(pluginData: PluginData<any>, permission: string, m
|
||||||
return helpers.hasPermission(config, permission);
|
return helpers.hasPermission(config, permission);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPluginConfigPreprocessor(blueprint: ZeppelinPluginBlueprint) {
|
export function getPluginConfigPreprocessor(
|
||||||
return (options: PluginOptions<any>) => {
|
blueprint: ZeppelinPluginBlueprint,
|
||||||
|
customPreprocessor?: PluginBlueprint<any>["configPreprocessor"],
|
||||||
|
) {
|
||||||
|
return async (options: PluginOptions<any>) => {
|
||||||
|
if (customPreprocessor) {
|
||||||
|
options = await customPreprocessor(options);
|
||||||
|
}
|
||||||
|
|
||||||
const decodedConfig = blueprint.configSchema
|
const decodedConfig = blueprint.configSchema
|
||||||
? decodeAndValidateStrict(blueprint.configSchema, options.config)
|
? decodeAndValidateStrict(blueprint.configSchema, options.config)
|
||||||
: options.config;
|
: options.config;
|
||||||
|
|
148
backend/src/plugins/Automod/AutomodPlugin.ts
Normal file
148
backend/src/plugins/Automod/AutomodPlugin.ts
Normal file
|
@ -0,0 +1,148 @@
|
||||||
|
import { zeppelinPlugin } from "../ZeppelinPluginBlueprint";
|
||||||
|
import { AutomodPluginType, ConfigSchema } from "./types";
|
||||||
|
import { RunAutomodOnJoinEvt } from "./events/RunAutomodOnJoinEvt";
|
||||||
|
import { GuildLogs } from "../../data/GuildLogs";
|
||||||
|
import { GuildSavedMessages } from "../../data/GuildSavedMessages";
|
||||||
|
import { runAutomodOnMessage } from "./events/runAutomodOnMessage";
|
||||||
|
import { Queue } from "../../Queue";
|
||||||
|
import { configUtils } from "knub";
|
||||||
|
import { availableTriggers } from "./triggers/availableTriggers";
|
||||||
|
import { StrictValidationError } from "../../validatorUtils";
|
||||||
|
import { ConfigPreprocessorFn } from "knub/dist/config/configTypes";
|
||||||
|
import { availableActions } from "./actions/availableActions";
|
||||||
|
import { clearOldRecentActions } from "./functions/clearOldRecentActions";
|
||||||
|
import { MINUTES, SECONDS } from "../../utils";
|
||||||
|
import { clearOldRecentSpam } from "./functions/clearOldRecentSpam";
|
||||||
|
import { GuildAntiraidLevels } from "../../data/GuildAntiraidLevels";
|
||||||
|
import { GuildArchives } from "../../data/GuildArchives";
|
||||||
|
|
||||||
|
const defaultOptions = {
|
||||||
|
config: {
|
||||||
|
rules: {},
|
||||||
|
antiraid_levels: ["low", "medium", "high"],
|
||||||
|
can_set_antiraid: false,
|
||||||
|
can_view_antiraid: false,
|
||||||
|
},
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
level: ">=50",
|
||||||
|
config: {
|
||||||
|
can_view_antiraid: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
level: ">=100",
|
||||||
|
config: {
|
||||||
|
can_set_antiraid: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Config preprocessor to set default values for triggers
|
||||||
|
*/
|
||||||
|
const configPreprocessor: ConfigPreprocessorFn<AutomodPluginType> = options => {
|
||||||
|
if (options.config?.rules) {
|
||||||
|
// Loop through each rule
|
||||||
|
for (const [name, rule] of Object.entries(options.config.rules)) {
|
||||||
|
rule["name"] = name;
|
||||||
|
|
||||||
|
// If the rule doesn't have an explicitly set "enabled" property, set it to true
|
||||||
|
if (rule["enabled"] == null) {
|
||||||
|
rule["enabled"] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule["affects_bots"] == null) {
|
||||||
|
rule["affects_bots"] = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loop through the rule's triggers
|
||||||
|
if (rule["triggers"]) {
|
||||||
|
for (const triggerObj of rule["triggers"]) {
|
||||||
|
for (const triggerName in triggerObj) {
|
||||||
|
if (!availableTriggers[triggerName]) {
|
||||||
|
throw new StrictValidationError([`Unknown trigger '${triggerName}' in rule '${rule.name}'`]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const triggerBlueprint = availableTriggers[triggerName];
|
||||||
|
triggerObj[triggerName] = configUtils.mergeConfig(triggerBlueprint.defaultConfig, triggerObj[triggerName]);
|
||||||
|
|
||||||
|
if (triggerObj[triggerName].match_attachment_type) {
|
||||||
|
const white = triggerObj[triggerName].match_attachment_type.whitelist_enabled;
|
||||||
|
const black = triggerObj[triggerName].match_attachment_type.blacklist_enabled;
|
||||||
|
|
||||||
|
if (white && black) {
|
||||||
|
throw new StrictValidationError([
|
||||||
|
`Cannot have both blacklist and whitelist enabled at rule <${rule.name}/match_attachment_type>`,
|
||||||
|
]);
|
||||||
|
} else if (!white && !black) {
|
||||||
|
throw new StrictValidationError([
|
||||||
|
`Must have either blacklist or whitelist enabled at rule <${rule.name}/match_attachment_type>`,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable logging of automod actions by default
|
||||||
|
if (rule["actions"]) {
|
||||||
|
for (const actionName in rule.actions) {
|
||||||
|
if (!availableActions[actionName]) {
|
||||||
|
throw new StrictValidationError([`Unknown action '${actionName}' in rule '${rule.name}'`]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// if (rule["actions"]["log"] == null) {
|
||||||
|
// rule["actions"]["log"] = true;
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AutomodPlugin = zeppelinPlugin<AutomodPluginType>()("automod", {
|
||||||
|
configSchema: ConfigSchema,
|
||||||
|
defaultOptions,
|
||||||
|
configPreprocessor,
|
||||||
|
|
||||||
|
events: [
|
||||||
|
RunAutomodOnJoinEvt,
|
||||||
|
// Messages use message events from SavedMessages, see onLoad below
|
||||||
|
],
|
||||||
|
|
||||||
|
onLoad(pluginData) {
|
||||||
|
pluginData.state.queue = new Queue();
|
||||||
|
|
||||||
|
pluginData.state.recentActions = [];
|
||||||
|
pluginData.state.clearRecentActionsInterval = setInterval(() => clearOldRecentActions(pluginData), 1 * MINUTES);
|
||||||
|
|
||||||
|
pluginData.state.recentSpam = [];
|
||||||
|
pluginData.state.clearRecentSpamInterval = setInterval(() => clearOldRecentSpam(pluginData), 1 * SECONDS);
|
||||||
|
|
||||||
|
pluginData.state.logs = new GuildLogs(pluginData.guild.id);
|
||||||
|
pluginData.state.savedMessages = GuildSavedMessages.getGuildInstance(pluginData.guild.id);
|
||||||
|
pluginData.state.antiraidLevels = GuildAntiraidLevels.getGuildInstance(pluginData.guild.id);
|
||||||
|
pluginData.state.archives = GuildArchives.getGuildInstance(pluginData.guild.id);
|
||||||
|
|
||||||
|
pluginData.state.onMessageCreateFn = message => runAutomodOnMessage(pluginData, message, false);
|
||||||
|
pluginData.state.savedMessages.events.on("create", pluginData.state.onMessageCreateFn);
|
||||||
|
|
||||||
|
pluginData.state.onMessageUpdateFn = message => runAutomodOnMessage(pluginData, message, true);
|
||||||
|
pluginData.state.savedMessages.events.on("update", pluginData.state.onMessageUpdateFn);
|
||||||
|
},
|
||||||
|
|
||||||
|
onUnload(pluginData) {
|
||||||
|
pluginData.state.queue.clear();
|
||||||
|
|
||||||
|
clearInterval(pluginData.state.clearRecentActionsInterval);
|
||||||
|
|
||||||
|
clearInterval(pluginData.state.clearRecentSpamInterval);
|
||||||
|
|
||||||
|
pluginData.state.savedMessages.events.off("create", pluginData.state.onMessageCreateFn);
|
||||||
|
pluginData.state.savedMessages.events.off("update", pluginData.state.onMessageUpdateFn);
|
||||||
|
},
|
||||||
|
});
|
11
backend/src/plugins/Automod/actions/availableActions.ts
Normal file
11
backend/src/plugins/Automod/actions/availableActions.ts
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import * as t from "io-ts";
|
||||||
|
import { CleanAction } from "./clean";
|
||||||
|
import { AutomodActionBlueprint } from "../helpers";
|
||||||
|
|
||||||
|
export const availableActions: Record<string, AutomodActionBlueprint<any>> = {
|
||||||
|
clean: CleanAction,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AvailableActions = t.type({
|
||||||
|
clean: CleanAction.configType,
|
||||||
|
});
|
28
backend/src/plugins/Automod/actions/clean.ts
Normal file
28
backend/src/plugins/Automod/actions/clean.ts
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import * as t from "io-ts";
|
||||||
|
import { automodAction } from "../helpers";
|
||||||
|
import { LogType } from "../../../data/LogType";
|
||||||
|
|
||||||
|
export const CleanAction = automodAction({
|
||||||
|
configType: t.boolean,
|
||||||
|
|
||||||
|
async apply({ pluginData, contexts }) {
|
||||||
|
const messageIdsToDeleteByChannelId: Map<string, string[]> = new Map();
|
||||||
|
for (const context of contexts) {
|
||||||
|
if (context.message) {
|
||||||
|
if (!messageIdsToDeleteByChannelId.has(context.message.channel_id)) {
|
||||||
|
messageIdsToDeleteByChannelId.set(context.message.channel_id, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
messageIdsToDeleteByChannelId.get(context.message.channel_id).push(context.message.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [channelId, messageIds] of messageIdsToDeleteByChannelId.entries()) {
|
||||||
|
for (const id of messageIds) {
|
||||||
|
pluginData.state.logs.ignoreLog(LogType.MESSAGE_DELETE, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
await pluginData.client.deleteMessages(channelId, messageIds);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
16
backend/src/plugins/Automod/constants.ts
Normal file
16
backend/src/plugins/Automod/constants.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { MINUTES, SECONDS } from "../../utils";
|
||||||
|
|
||||||
|
export const RECENT_SPAM_EXPIRY_TIME = 10 * SECONDS;
|
||||||
|
export const RECENT_ACTION_EXPIRY_TIME = 5 * MINUTES;
|
||||||
|
|
||||||
|
export enum RecentActionType {
|
||||||
|
Message = 1,
|
||||||
|
Mention,
|
||||||
|
Link,
|
||||||
|
Attachment,
|
||||||
|
Emoji,
|
||||||
|
Line,
|
||||||
|
Character,
|
||||||
|
VoiceChannelMove,
|
||||||
|
MemberJoin,
|
||||||
|
}
|
16
backend/src/plugins/Automod/events/RunAutomodOnJoinEvt.ts
Normal file
16
backend/src/plugins/Automod/events/RunAutomodOnJoinEvt.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { SavedMessage } from "../../../data/entities/SavedMessage";
|
||||||
|
import { eventListener, PluginData } from "knub";
|
||||||
|
import { AutomodContext, AutomodPluginType } from "../types";
|
||||||
|
import { runAutomod } from "../functions/runAutomod";
|
||||||
|
|
||||||
|
export const RunAutomodOnJoinEvt = eventListener<AutomodPluginType>()(
|
||||||
|
"guildMemberAdd",
|
||||||
|
({ pluginData, args: { member } }) => {
|
||||||
|
const context: AutomodContext = {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
user: member.user,
|
||||||
|
};
|
||||||
|
|
||||||
|
pluginData.state.queue.add(() => runAutomod(pluginData, context));
|
||||||
|
},
|
||||||
|
);
|
18
backend/src/plugins/Automod/events/runAutomodOnMessage.ts
Normal file
18
backend/src/plugins/Automod/events/runAutomodOnMessage.ts
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
import { SavedMessage } from "../../../data/entities/SavedMessage";
|
||||||
|
import { PluginData } from "knub";
|
||||||
|
import { AutomodContext, AutomodPluginType } from "../types";
|
||||||
|
import { runAutomod } from "../functions/runAutomod";
|
||||||
|
import { addRecentActionsFromMessage } from "../functions/addRecentActionsFromMessage";
|
||||||
|
import moment from "moment-timezone";
|
||||||
|
|
||||||
|
export function runAutomodOnMessage(pluginData: PluginData<AutomodPluginType>, message: SavedMessage, isEdit: boolean) {
|
||||||
|
const context: AutomodContext = {
|
||||||
|
timestamp: moment.utc(message.posted_at).valueOf(),
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
|
||||||
|
pluginData.state.queue.add(async () => {
|
||||||
|
addRecentActionsFromMessage(pluginData, context);
|
||||||
|
await runAutomod(pluginData, context);
|
||||||
|
});
|
||||||
|
}
|
|
@ -0,0 +1,130 @@
|
||||||
|
import moment from "moment-timezone";
|
||||||
|
import { AutomodContext, AutomodPluginType } from "../types";
|
||||||
|
import { PluginData } from "knub";
|
||||||
|
import { RECENT_ACTION_EXPIRY_TIME, RecentActionType } from "../constants";
|
||||||
|
import { getEmojiInString, getRoleMentions, getUrlsInString, getUserMentions } from "../../../utils";
|
||||||
|
|
||||||
|
export function addRecentActionsFromMessage(pluginData: PluginData<AutomodPluginType>, context: AutomodContext) {
|
||||||
|
const globalIdentifier = context.message.user_id;
|
||||||
|
const perChannelIdentifier = `${context.message.channel_id}-${context.message.user_id}`;
|
||||||
|
const expiresAt = Date.now() + RECENT_ACTION_EXPIRY_TIME;
|
||||||
|
|
||||||
|
pluginData.state.recentActions.push({
|
||||||
|
context,
|
||||||
|
type: RecentActionType.Message,
|
||||||
|
identifier: globalIdentifier,
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
pluginData.state.recentActions.push({
|
||||||
|
context,
|
||||||
|
type: RecentActionType.Message,
|
||||||
|
identifier: perChannelIdentifier,
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mentionCount =
|
||||||
|
getUserMentions(context.message.data.content || "").length +
|
||||||
|
getRoleMentions(context.message.data.content || "").length;
|
||||||
|
if (mentionCount) {
|
||||||
|
pluginData.state.recentActions.push({
|
||||||
|
context,
|
||||||
|
type: RecentActionType.Mention,
|
||||||
|
identifier: globalIdentifier,
|
||||||
|
count: mentionCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
pluginData.state.recentActions.push({
|
||||||
|
context,
|
||||||
|
type: RecentActionType.Mention,
|
||||||
|
identifier: perChannelIdentifier,
|
||||||
|
count: mentionCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkCount = getUrlsInString(context.message.data.content || "").length;
|
||||||
|
if (linkCount) {
|
||||||
|
pluginData.state.recentActions.push({
|
||||||
|
context,
|
||||||
|
type: RecentActionType.Link,
|
||||||
|
identifier: globalIdentifier,
|
||||||
|
count: linkCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
pluginData.state.recentActions.push({
|
||||||
|
context,
|
||||||
|
type: RecentActionType.Link,
|
||||||
|
identifier: perChannelIdentifier,
|
||||||
|
count: linkCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachmentCount = context.message.data.attachments && context.message.data.attachments.length;
|
||||||
|
if (attachmentCount) {
|
||||||
|
pluginData.state.recentActions.push({
|
||||||
|
context,
|
||||||
|
type: RecentActionType.Attachment,
|
||||||
|
identifier: globalIdentifier,
|
||||||
|
count: attachmentCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
pluginData.state.recentActions.push({
|
||||||
|
context,
|
||||||
|
type: RecentActionType.Attachment,
|
||||||
|
identifier: perChannelIdentifier,
|
||||||
|
count: attachmentCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const emojiCount = getEmojiInString(context.message.data.content || "").length;
|
||||||
|
if (emojiCount) {
|
||||||
|
pluginData.state.recentActions.push({
|
||||||
|
context,
|
||||||
|
type: RecentActionType.Emoji,
|
||||||
|
identifier: globalIdentifier,
|
||||||
|
count: emojiCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
pluginData.state.recentActions.push({
|
||||||
|
context,
|
||||||
|
type: RecentActionType.Emoji,
|
||||||
|
identifier: perChannelIdentifier,
|
||||||
|
count: emojiCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// + 1 is for the first line of the message (which doesn't have a line break)
|
||||||
|
const lineCount = context.message.data.content ? (context.message.data.content.match(/\n/g) || []).length + 1 : 0;
|
||||||
|
if (lineCount) {
|
||||||
|
pluginData.state.recentActions.push({
|
||||||
|
context,
|
||||||
|
type: RecentActionType.Line,
|
||||||
|
identifier: globalIdentifier,
|
||||||
|
count: lineCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
pluginData.state.recentActions.push({
|
||||||
|
context,
|
||||||
|
type: RecentActionType.Line,
|
||||||
|
identifier: perChannelIdentifier,
|
||||||
|
count: lineCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const characterCount = [...(context.message.data.content || "")].length;
|
||||||
|
if (characterCount) {
|
||||||
|
pluginData.state.recentActions.push({
|
||||||
|
context,
|
||||||
|
type: RecentActionType.Character,
|
||||||
|
identifier: globalIdentifier,
|
||||||
|
count: characterCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
pluginData.state.recentActions.push({
|
||||||
|
context,
|
||||||
|
type: RecentActionType.Character,
|
||||||
|
identifier: perChannelIdentifier,
|
||||||
|
count: characterCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { PluginData } from "knub";
|
||||||
|
import { AutomodPluginType } from "../types";
|
||||||
|
import { RECENT_ACTION_EXPIRY_TIME } from "../constants";
|
||||||
|
|
||||||
|
export function clearOldRecentActions(pluginData: PluginData<AutomodPluginType>) {
|
||||||
|
const now = Date.now();
|
||||||
|
pluginData.state.recentActions = pluginData.state.recentActions.filter(info => {
|
||||||
|
return info.context.timestamp + RECENT_ACTION_EXPIRY_TIME > now;
|
||||||
|
});
|
||||||
|
}
|
10
backend/src/plugins/Automod/functions/clearOldRecentSpam.ts
Normal file
10
backend/src/plugins/Automod/functions/clearOldRecentSpam.ts
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import { PluginData } from "knub";
|
||||||
|
import { AutomodPluginType } from "../types";
|
||||||
|
import { RECENT_SPAM_EXPIRY_TIME } from "../constants";
|
||||||
|
|
||||||
|
export function clearOldRecentSpam(pluginData: PluginData<AutomodPluginType>) {
|
||||||
|
const now = Date.now();
|
||||||
|
pluginData.state.recentSpam = pluginData.state.recentSpam.filter(spam => {
|
||||||
|
return spam.timestamp + RECENT_SPAM_EXPIRY_TIME > now;
|
||||||
|
});
|
||||||
|
}
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { RecentActionType } from "../constants";
|
||||||
|
import { automodTrigger } from "../helpers";
|
||||||
|
import { getBaseUrl } from "../../../pluginUtils";
|
||||||
|
import { convertDelayStringToMS, tDelayString, tNullable } from "../../../utils";
|
||||||
|
import { humanizeDurationShort } from "../../../humanizeDurationShort";
|
||||||
|
import { findRecentSpam } from "./findRecentSpam";
|
||||||
|
import { getMatchingMessageRecentActions } from "./getMatchingMessageRecentActions";
|
||||||
|
import * as t from "io-ts";
|
||||||
|
|
||||||
|
const MessageSpamTriggerConfig = t.type({
|
||||||
|
amount: t.number,
|
||||||
|
within: tDelayString,
|
||||||
|
per_channel: tNullable(t.boolean),
|
||||||
|
});
|
||||||
|
type TMessageSpamTriggerConfig = t.TypeOf<typeof MessageSpamTriggerConfig>;
|
||||||
|
|
||||||
|
const MessageSpamMatchResultType = t.type({
|
||||||
|
archiveId: t.string,
|
||||||
|
});
|
||||||
|
type TMessageSpamMatchResultType = t.TypeOf<typeof MessageSpamMatchResultType>;
|
||||||
|
|
||||||
|
export function createMessageSpamTrigger(spamType: RecentActionType, prettyName: string) {
|
||||||
|
return automodTrigger({
|
||||||
|
configType: MessageSpamTriggerConfig,
|
||||||
|
defaultConfig: {},
|
||||||
|
|
||||||
|
matchResultType: MessageSpamMatchResultType,
|
||||||
|
|
||||||
|
async match({ pluginData, context, triggerConfig }) {
|
||||||
|
if (!context.message) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recentSpam = findRecentSpam(pluginData, spamType, context.message.user_id);
|
||||||
|
if (recentSpam) {
|
||||||
|
// TODO: Combine with old archive
|
||||||
|
return {
|
||||||
|
silentClean: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const within = convertDelayStringToMS(triggerConfig.within);
|
||||||
|
const matchedSpam = getMatchingMessageRecentActions(
|
||||||
|
pluginData,
|
||||||
|
context.message,
|
||||||
|
spamType,
|
||||||
|
triggerConfig.amount,
|
||||||
|
within,
|
||||||
|
triggerConfig.per_channel,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matchedSpam) {
|
||||||
|
// TODO: Generate archive link
|
||||||
|
const archiveId = "TODO";
|
||||||
|
|
||||||
|
pluginData.state.recentSpam.push({
|
||||||
|
type: spamType,
|
||||||
|
userId: context.message.user_id,
|
||||||
|
archiveId,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
extraContexts: matchedSpam.recentActions
|
||||||
|
.map(action => action.context)
|
||||||
|
.filter(_context => _context !== context),
|
||||||
|
|
||||||
|
extra: {
|
||||||
|
archiveId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
renderMatchInformation({ pluginData, matchResult, triggerConfig }) {
|
||||||
|
const baseUrl = getBaseUrl(pluginData);
|
||||||
|
const archiveUrl = pluginData.state.archives.getUrl(baseUrl, matchResult.extra.archiveId);
|
||||||
|
const withinMs = convertDelayStringToMS(triggerConfig.within);
|
||||||
|
const withinStr = humanizeDurationShort(withinMs);
|
||||||
|
|
||||||
|
return `Matched ${prettyName} spam (${triggerConfig.amount} in ${withinStr}): ${archiveUrl}`;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
9
backend/src/plugins/Automod/functions/findRecentSpam.ts
Normal file
9
backend/src/plugins/Automod/functions/findRecentSpam.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
import { PluginData } from "knub";
|
||||||
|
import { AutomodPluginType } from "../types";
|
||||||
|
import { RecentActionType } from "../constants";
|
||||||
|
|
||||||
|
export function findRecentSpam(pluginData: PluginData<AutomodPluginType>, type: RecentActionType, userId: string) {
|
||||||
|
return pluginData.state.recentSpam.find(spam => {
|
||||||
|
return spam.type === type && spam.userId === userId;
|
||||||
|
});
|
||||||
|
}
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { PluginData } from "knub";
|
||||||
|
import { AutomodPluginType } from "../types";
|
||||||
|
import { SavedMessage } from "../../../data/entities/SavedMessage";
|
||||||
|
import moment from "moment-timezone";
|
||||||
|
import { getMatchingRecentActions } from "./getMatchingRecentActions";
|
||||||
|
import { RecentActionType } from "../constants";
|
||||||
|
|
||||||
|
export function getMatchingMessageRecentActions(
|
||||||
|
pluginData: PluginData<AutomodPluginType>,
|
||||||
|
message: SavedMessage,
|
||||||
|
type: RecentActionType,
|
||||||
|
count: number,
|
||||||
|
within: number,
|
||||||
|
perChannel: boolean,
|
||||||
|
) {
|
||||||
|
const since = moment.utc(message.posted_at).valueOf() - within;
|
||||||
|
const to = moment.utc(message.posted_at).valueOf();
|
||||||
|
const identifier = perChannel ? `${message.channel_id}-${message.user_id}` : message.user_id;
|
||||||
|
const recentActions = getMatchingRecentActions(pluginData, type, identifier, since, to);
|
||||||
|
const totalCount = recentActions.reduce((total, action) => total + action.count, 0);
|
||||||
|
|
||||||
|
if (totalCount >= count) {
|
||||||
|
return {
|
||||||
|
identifier,
|
||||||
|
recentActions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { PluginData } from "knub";
|
||||||
|
import { AutomodPluginType } from "../types";
|
||||||
|
import { RecentActionType } from "../constants";
|
||||||
|
|
||||||
|
export function getMatchingRecentActions(
|
||||||
|
pluginData: PluginData<AutomodPluginType>,
|
||||||
|
type: RecentActionType,
|
||||||
|
identifier: string | null,
|
||||||
|
since: number,
|
||||||
|
to: number,
|
||||||
|
) {
|
||||||
|
return pluginData.state.recentActions.filter(action => {
|
||||||
|
return (
|
||||||
|
action.type === type &&
|
||||||
|
(!identifier || action.identifier === identifier) &&
|
||||||
|
action.context.timestamp >= since &&
|
||||||
|
action.context.timestamp <= to
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
|
@ -0,0 +1,68 @@
|
||||||
|
import * as t from "io-ts";
|
||||||
|
import { SavedMessage } from "../../../data/entities/SavedMessage";
|
||||||
|
import { resolveMember } from "../../../utils";
|
||||||
|
import { PluginData } from "knub";
|
||||||
|
import { AutomodPluginType } from "../types";
|
||||||
|
|
||||||
|
type TextTriggerWithMultipleMatchTypes = {
|
||||||
|
match_messages: boolean;
|
||||||
|
match_embeds: boolean;
|
||||||
|
match_visible_names: boolean;
|
||||||
|
match_usernames: boolean;
|
||||||
|
match_nicknames: boolean;
|
||||||
|
match_custom_status: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MatchableTextType = t.union([
|
||||||
|
t.literal("message"),
|
||||||
|
t.literal("embed"),
|
||||||
|
t.literal("visiblename"),
|
||||||
|
t.literal("username"),
|
||||||
|
t.literal("nickname"),
|
||||||
|
t.literal("customstatus"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type TMatchableTextType = t.TypeOf<typeof MatchableTextType>;
|
||||||
|
|
||||||
|
type YieldedContent = [TMatchableTextType, string];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generator function that allows iterating through matchable pieces of text of a SavedMessage
|
||||||
|
*/
|
||||||
|
export async function* matchMultipleTextTypesOnMessage(
|
||||||
|
pluginData: PluginData<AutomodPluginType>,
|
||||||
|
trigger: TextTriggerWithMultipleMatchTypes,
|
||||||
|
msg: SavedMessage,
|
||||||
|
): AsyncIterableIterator<YieldedContent> {
|
||||||
|
const member = await resolveMember(pluginData.client, pluginData.guild, msg.user_id);
|
||||||
|
if (!member) return;
|
||||||
|
|
||||||
|
if (trigger.match_messages && msg.data.content) {
|
||||||
|
yield ["message", msg.data.content];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trigger.match_embeds && msg.data.embeds && msg.data.embeds.length) {
|
||||||
|
const copiedEmbed = JSON.parse(JSON.stringify(msg.data.embeds[0]));
|
||||||
|
if (copiedEmbed.type === "video") {
|
||||||
|
copiedEmbed.description = ""; // The description is not rendered, hence it doesn't need to be matched
|
||||||
|
}
|
||||||
|
yield ["embed", JSON.stringify(copiedEmbed)];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trigger.match_visible_names) {
|
||||||
|
yield ["visiblename", member.nick || msg.data.author.username];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trigger.match_usernames) {
|
||||||
|
yield ["username", `${msg.data.author.username}#${msg.data.author.discriminator}`];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trigger.match_nicknames && member.nick) {
|
||||||
|
yield ["nickname", member.nick];
|
||||||
|
}
|
||||||
|
|
||||||
|
// type 4 = custom status
|
||||||
|
if (trigger.match_custom_status && member.game?.type === 4 && member.game?.state) {
|
||||||
|
yield ["customstatus", member.game.state];
|
||||||
|
}
|
||||||
|
}
|
83
backend/src/plugins/Automod/functions/runAutomod.ts
Normal file
83
backend/src/plugins/Automod/functions/runAutomod.ts
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
import { PluginData } from "knub";
|
||||||
|
import { AutomodContext, AutomodPluginType, TRule } from "../types";
|
||||||
|
import { availableTriggers } from "../triggers/availableTriggers";
|
||||||
|
import { availableActions } from "../actions/availableActions";
|
||||||
|
import { AutomodTriggerMatchResult } from "../helpers";
|
||||||
|
import { CleanAction } from "../actions/clean";
|
||||||
|
|
||||||
|
export async function runAutomod(pluginData: PluginData<AutomodPluginType>, context: AutomodContext) {
|
||||||
|
const userId = context.user?.id || context.message?.user_id;
|
||||||
|
const member = userId && pluginData.guild.members.get(userId);
|
||||||
|
const channelId = context.message?.channel_id;
|
||||||
|
const channel = channelId && pluginData.guild.channels.get(channelId);
|
||||||
|
const categoryId = channel?.parentID;
|
||||||
|
|
||||||
|
const config = pluginData.config.getMatchingConfig({
|
||||||
|
channelId,
|
||||||
|
categoryId,
|
||||||
|
userId,
|
||||||
|
member,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [ruleName, rule] of Object.entries(config.rules)) {
|
||||||
|
if (rule.enabled === false) continue;
|
||||||
|
|
||||||
|
let matchResult: AutomodTriggerMatchResult<any>;
|
||||||
|
let matchSummary: string;
|
||||||
|
let contexts: AutomodContext[];
|
||||||
|
|
||||||
|
triggerLoop: for (const triggerItem of rule.triggers) {
|
||||||
|
for (const [triggerName, triggerConfig] of Object.entries(triggerItem)) {
|
||||||
|
const trigger = availableTriggers[triggerName];
|
||||||
|
matchResult = await trigger.match({
|
||||||
|
ruleName,
|
||||||
|
pluginData,
|
||||||
|
context,
|
||||||
|
triggerConfig,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matchResult) {
|
||||||
|
contexts = [context, ...(matchResult.extraContexts || [])];
|
||||||
|
|
||||||
|
for (const _context of contexts) {
|
||||||
|
_context.actioned = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchResult.silentClean) {
|
||||||
|
await CleanAction.apply({
|
||||||
|
ruleName,
|
||||||
|
pluginData,
|
||||||
|
contexts,
|
||||||
|
actionConfig: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
matchSummary = await trigger.renderMatchInformation({
|
||||||
|
ruleName,
|
||||||
|
pluginData,
|
||||||
|
contexts,
|
||||||
|
matchResult,
|
||||||
|
triggerConfig,
|
||||||
|
});
|
||||||
|
|
||||||
|
break triggerLoop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchResult) {
|
||||||
|
for (const [actionName, actionConfig] of Object.entries(rule.actions)) {
|
||||||
|
const action = availableActions[actionName];
|
||||||
|
action.apply({
|
||||||
|
ruleName,
|
||||||
|
pluginData,
|
||||||
|
contexts,
|
||||||
|
actionConfig,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
60
backend/src/plugins/Automod/helpers.ts
Normal file
60
backend/src/plugins/Automod/helpers.ts
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
import { PluginData } from "knub";
|
||||||
|
import { Awaitable } from "knub/dist/utils";
|
||||||
|
import * as t from "io-ts";
|
||||||
|
import { AutomodContext, AutomodPluginType } from "./types";
|
||||||
|
|
||||||
|
export interface AutomodTriggerMatchResult<TExtra extends any = unknown> {
|
||||||
|
extraContexts?: AutomodContext[];
|
||||||
|
extra?: TExtra;
|
||||||
|
|
||||||
|
silentClean?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AutomodTriggerMatchFn<TConfigType, TMatchResultExtra> = (meta: {
|
||||||
|
ruleName: string;
|
||||||
|
pluginData: PluginData<AutomodPluginType>;
|
||||||
|
context: AutomodContext;
|
||||||
|
triggerConfig: TConfigType;
|
||||||
|
}) => Awaitable<null | AutomodTriggerMatchResult<TMatchResultExtra>>;
|
||||||
|
|
||||||
|
type AutomodTriggerRenderMatchInformationFn<TConfigType, TMatchResultExtra> = (meta: {
|
||||||
|
ruleName: string;
|
||||||
|
pluginData: PluginData<AutomodPluginType>;
|
||||||
|
contexts: AutomodContext[];
|
||||||
|
triggerConfig: TConfigType;
|
||||||
|
matchResult: AutomodTriggerMatchResult<TMatchResultExtra>;
|
||||||
|
}) => Awaitable<string>;
|
||||||
|
|
||||||
|
export interface AutomodTriggerBlueprint<TConfigType extends t.Any, TMatchResultExtra extends t.Any> {
|
||||||
|
configType: TConfigType;
|
||||||
|
defaultConfig: Partial<t.TypeOf<TConfigType>>;
|
||||||
|
|
||||||
|
matchResultType: TMatchResultExtra;
|
||||||
|
|
||||||
|
match: AutomodTriggerMatchFn<t.TypeOf<TConfigType>, t.TypeOf<TMatchResultExtra>>;
|
||||||
|
renderMatchInformation: AutomodTriggerRenderMatchInformationFn<t.TypeOf<TConfigType>, t.TypeOf<TMatchResultExtra>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function automodTrigger<TConfigType extends t.Any, TMatchResultExtra extends t.Any>(
|
||||||
|
blueprint: AutomodTriggerBlueprint<TConfigType, TMatchResultExtra>,
|
||||||
|
): AutomodTriggerBlueprint<TConfigType, TMatchResultExtra> {
|
||||||
|
return blueprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AutomodActionApplyFn<TConfigType> = (meta: {
|
||||||
|
ruleName: string;
|
||||||
|
pluginData: PluginData<AutomodPluginType>;
|
||||||
|
contexts: AutomodContext[];
|
||||||
|
actionConfig: TConfigType;
|
||||||
|
}) => Awaitable<void>;
|
||||||
|
|
||||||
|
export interface AutomodActionBlueprint<TConfigType extends t.Any> {
|
||||||
|
configType: TConfigType;
|
||||||
|
apply: AutomodActionApplyFn<t.TypeOf<TConfigType>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function automodAction<TConfigType extends t.Any>(
|
||||||
|
blueprint: AutomodActionBlueprint<TConfigType>,
|
||||||
|
): AutomodActionBlueprint<TConfigType> {
|
||||||
|
return blueprint;
|
||||||
|
}
|
4
backend/src/plugins/Automod/triggers/attachmentSpam.ts
Normal file
4
backend/src/plugins/Automod/triggers/attachmentSpam.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
import { RecentActionType } from "../constants";
|
||||||
|
import { createMessageSpamTrigger } from "../functions/createMessageSpamTrigger";
|
||||||
|
|
||||||
|
export const AttachmentSpamTrigger = createMessageSpamTrigger(RecentActionType.Attachment, "attachment");
|
37
backend/src/plugins/Automod/triggers/availableTriggers.ts
Normal file
37
backend/src/plugins/Automod/triggers/availableTriggers.ts
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
import * as t from "io-ts";
|
||||||
|
import { MatchWordsTrigger } from "./matchWords";
|
||||||
|
import { AutomodTriggerBlueprint } from "../helpers";
|
||||||
|
import { MessageSpamTrigger } from "./messageSpam";
|
||||||
|
import { MentionSpamTrigger } from "./mentionSpam";
|
||||||
|
import { LinkSpamTrigger } from "./linkSpam";
|
||||||
|
import { AttachmentSpamTrigger } from "./attachmentSpam";
|
||||||
|
import { EmojiSpamTrigger } from "./emojiSpam";
|
||||||
|
import { LineSpamTrigger } from "./lineSpam";
|
||||||
|
import { CharacterSpamTrigger } from "./characterSpam";
|
||||||
|
import { MatchRegexTrigger } from "./matchRegex";
|
||||||
|
|
||||||
|
export const availableTriggers: Record<string, AutomodTriggerBlueprint<any, any>> = {
|
||||||
|
match_words: MatchWordsTrigger,
|
||||||
|
match_regex: MatchRegexTrigger,
|
||||||
|
|
||||||
|
message_spam: MessageSpamTrigger,
|
||||||
|
mention_spam: MentionSpamTrigger,
|
||||||
|
link_spam: LinkSpamTrigger,
|
||||||
|
attachment_spam: AttachmentSpamTrigger,
|
||||||
|
emoji_spam: EmojiSpamTrigger,
|
||||||
|
line_spam: LineSpamTrigger,
|
||||||
|
character_spam: CharacterSpamTrigger,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AvailableTriggers = t.type({
|
||||||
|
match_words: MatchWordsTrigger.configType,
|
||||||
|
match_regex: MatchRegexTrigger.configType,
|
||||||
|
|
||||||
|
message_spam: MessageSpamTrigger.configType,
|
||||||
|
mention_spam: MentionSpamTrigger.configType,
|
||||||
|
link_spam: LinkSpamTrigger.configType,
|
||||||
|
attachment_spam: AttachmentSpamTrigger.configType,
|
||||||
|
emoji_spam: EmojiSpamTrigger.configType,
|
||||||
|
line_spam: LineSpamTrigger.configType,
|
||||||
|
character_spam: CharacterSpamTrigger.configType,
|
||||||
|
});
|
4
backend/src/plugins/Automod/triggers/characterSpam.ts
Normal file
4
backend/src/plugins/Automod/triggers/characterSpam.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
import { RecentActionType } from "../constants";
|
||||||
|
import { createMessageSpamTrigger } from "../functions/createMessageSpamTrigger";
|
||||||
|
|
||||||
|
export const CharacterSpamTrigger = createMessageSpamTrigger(RecentActionType.Character, "character");
|
4
backend/src/plugins/Automod/triggers/emojiSpam.ts
Normal file
4
backend/src/plugins/Automod/triggers/emojiSpam.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
import { RecentActionType } from "../constants";
|
||||||
|
import { createMessageSpamTrigger } from "../functions/createMessageSpamTrigger";
|
||||||
|
|
||||||
|
export const EmojiSpamTrigger = createMessageSpamTrigger(RecentActionType.Emoji, "emoji");
|
27
backend/src/plugins/Automod/triggers/exampleTrigger.ts
Normal file
27
backend/src/plugins/Automod/triggers/exampleTrigger.ts
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import * as t from "io-ts";
|
||||||
|
import { automodTrigger } from "../helpers";
|
||||||
|
|
||||||
|
export const ExampleTrigger = automodTrigger({
|
||||||
|
configType: t.type({
|
||||||
|
some: t.number,
|
||||||
|
value: t.string,
|
||||||
|
}),
|
||||||
|
|
||||||
|
defaultConfig: {},
|
||||||
|
|
||||||
|
matchResultType: t.type({
|
||||||
|
thing: t.string,
|
||||||
|
}),
|
||||||
|
|
||||||
|
async match() {
|
||||||
|
return {
|
||||||
|
extra: {
|
||||||
|
thing: "hi",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
renderMatchInformation() {
|
||||||
|
return "";
|
||||||
|
},
|
||||||
|
});
|
4
backend/src/plugins/Automod/triggers/lineSpam.ts
Normal file
4
backend/src/plugins/Automod/triggers/lineSpam.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
import { RecentActionType } from "../constants";
|
||||||
|
import { createMessageSpamTrigger } from "../functions/createMessageSpamTrigger";
|
||||||
|
|
||||||
|
export const LineSpamTrigger = createMessageSpamTrigger(RecentActionType.Line, "line");
|
4
backend/src/plugins/Automod/triggers/linkSpam.ts
Normal file
4
backend/src/plugins/Automod/triggers/linkSpam.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
import { RecentActionType } from "../constants";
|
||||||
|
import { createMessageSpamTrigger } from "../functions/createMessageSpamTrigger";
|
||||||
|
|
||||||
|
export const LinkSpamTrigger = createMessageSpamTrigger(RecentActionType.Link, "link");
|
72
backend/src/plugins/Automod/triggers/matchRegex.ts
Normal file
72
backend/src/plugins/Automod/triggers/matchRegex.ts
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
import * as t from "io-ts";
|
||||||
|
import { transliterate } from "transliteration";
|
||||||
|
import escapeStringRegexp from "escape-string-regexp";
|
||||||
|
import { automodTrigger } from "../helpers";
|
||||||
|
import { disableInlineCode, verboseChannelMention } from "../../../utils";
|
||||||
|
import { MatchableTextType, matchMultipleTextTypesOnMessage } from "../functions/matchMultipleTextTypesOnMessage";
|
||||||
|
|
||||||
|
export const MatchRegexTrigger = automodTrigger({
|
||||||
|
configType: t.type({
|
||||||
|
patterns: t.array(t.string),
|
||||||
|
case_sensitive: t.boolean,
|
||||||
|
normalize: t.boolean,
|
||||||
|
match_messages: t.boolean,
|
||||||
|
match_embeds: t.boolean,
|
||||||
|
match_visible_names: t.boolean,
|
||||||
|
match_usernames: t.boolean,
|
||||||
|
match_nicknames: t.boolean,
|
||||||
|
match_custom_status: t.boolean,
|
||||||
|
}),
|
||||||
|
|
||||||
|
defaultConfig: {
|
||||||
|
case_sensitive: false,
|
||||||
|
normalize: false,
|
||||||
|
match_messages: true,
|
||||||
|
match_embeds: true,
|
||||||
|
match_visible_names: false,
|
||||||
|
match_usernames: false,
|
||||||
|
match_nicknames: false,
|
||||||
|
match_custom_status: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
matchResultType: t.type({
|
||||||
|
pattern: t.string,
|
||||||
|
type: MatchableTextType,
|
||||||
|
}),
|
||||||
|
|
||||||
|
async match({ pluginData, context, triggerConfig: trigger }) {
|
||||||
|
if (!context.message) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for await (let [type, str] of matchMultipleTextTypesOnMessage(pluginData, trigger, context.message)) {
|
||||||
|
if (trigger.normalize) {
|
||||||
|
str = transliterate(str);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const pattern of trigger.patterns) {
|
||||||
|
const regex = new RegExp(pattern, trigger.case_sensitive ? "" : "i");
|
||||||
|
const test = regex.test(str);
|
||||||
|
if (test) {
|
||||||
|
return {
|
||||||
|
extra: {
|
||||||
|
pattern,
|
||||||
|
type,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
renderMatchInformation({ pluginData, contexts, matchResult }) {
|
||||||
|
const channel = pluginData.guild.channels.get(contexts[0].message.channel_id);
|
||||||
|
const prettyChannel = verboseChannelMention(channel);
|
||||||
|
|
||||||
|
return `Matched regex \`${disableInlineCode(matchResult.extra.pattern)}\` in message (\`${
|
||||||
|
contexts[0].message.id
|
||||||
|
}\`) in ${prettyChannel}:`;
|
||||||
|
},
|
||||||
|
});
|
90
backend/src/plugins/Automod/triggers/matchWords.ts
Normal file
90
backend/src/plugins/Automod/triggers/matchWords.ts
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
import * as t from "io-ts";
|
||||||
|
import { transliterate } from "transliteration";
|
||||||
|
import escapeStringRegexp from "escape-string-regexp";
|
||||||
|
import { automodTrigger } from "../helpers";
|
||||||
|
import { disableInlineCode, verboseChannelMention } from "../../../utils";
|
||||||
|
import { MatchableTextType, matchMultipleTextTypesOnMessage } from "../functions/matchMultipleTextTypesOnMessage";
|
||||||
|
|
||||||
|
export const MatchWordsTrigger = automodTrigger({
|
||||||
|
configType: t.type({
|
||||||
|
words: t.array(t.string),
|
||||||
|
case_sensitive: t.boolean,
|
||||||
|
only_full_words: t.boolean,
|
||||||
|
normalize: t.boolean,
|
||||||
|
loose_matching: t.boolean,
|
||||||
|
loose_matching_threshold: t.number,
|
||||||
|
match_messages: t.boolean,
|
||||||
|
match_embeds: t.boolean,
|
||||||
|
match_visible_names: t.boolean,
|
||||||
|
match_usernames: t.boolean,
|
||||||
|
match_nicknames: t.boolean,
|
||||||
|
match_custom_status: t.boolean,
|
||||||
|
}),
|
||||||
|
|
||||||
|
defaultConfig: {
|
||||||
|
case_sensitive: false,
|
||||||
|
only_full_words: true,
|
||||||
|
normalize: false,
|
||||||
|
loose_matching: false,
|
||||||
|
loose_matching_threshold: 4,
|
||||||
|
match_messages: true,
|
||||||
|
match_embeds: true,
|
||||||
|
match_visible_names: false,
|
||||||
|
match_usernames: false,
|
||||||
|
match_nicknames: false,
|
||||||
|
match_custom_status: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
matchResultType: t.type({
|
||||||
|
word: t.string,
|
||||||
|
type: MatchableTextType,
|
||||||
|
}),
|
||||||
|
|
||||||
|
async match({ pluginData, context, triggerConfig: trigger }) {
|
||||||
|
if (!context.message) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for await (let [type, str] of matchMultipleTextTypesOnMessage(pluginData, trigger, context.message)) {
|
||||||
|
if (trigger.normalize) {
|
||||||
|
str = transliterate(str);
|
||||||
|
}
|
||||||
|
|
||||||
|
const looseMatchingThreshold = Math.min(Math.max(trigger.loose_matching_threshold, 1), 64);
|
||||||
|
|
||||||
|
for (const word of trigger.words) {
|
||||||
|
// When performing loose matching, allow any amount of whitespace or up to looseMatchingThreshold number of other
|
||||||
|
// characters between the matched characters. E.g. if we're matching banana, a loose match could also match b a n a n a
|
||||||
|
let pattern = trigger.loose_matching
|
||||||
|
? [...word].map(c => escapeStringRegexp(c)).join(`(?:\\s*|.{0,${looseMatchingThreshold})`)
|
||||||
|
: escapeStringRegexp(word);
|
||||||
|
|
||||||
|
if (trigger.only_full_words) {
|
||||||
|
pattern = `\\b${pattern}\\b`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const regex = new RegExp(pattern, trigger.case_sensitive ? "" : "i");
|
||||||
|
const test = regex.test(str);
|
||||||
|
if (test) {
|
||||||
|
return {
|
||||||
|
extra: {
|
||||||
|
word,
|
||||||
|
type,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
renderMatchInformation({ pluginData, contexts, matchResult }) {
|
||||||
|
const channel = pluginData.guild.channels.get(contexts[0].message.channel_id);
|
||||||
|
const prettyChannel = verboseChannelMention(channel);
|
||||||
|
|
||||||
|
return `Matched word \`${disableInlineCode(matchResult.extra.word)}\` in message (\`${
|
||||||
|
contexts[0].message.id
|
||||||
|
}\`) in ${prettyChannel}:`;
|
||||||
|
},
|
||||||
|
});
|
4
backend/src/plugins/Automod/triggers/mentionSpam.ts
Normal file
4
backend/src/plugins/Automod/triggers/mentionSpam.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
import { RecentActionType } from "../constants";
|
||||||
|
import { createMessageSpamTrigger } from "../functions/createMessageSpamTrigger";
|
||||||
|
|
||||||
|
export const MentionSpamTrigger = createMessageSpamTrigger(RecentActionType.Mention, "mention");
|
4
backend/src/plugins/Automod/triggers/messageSpam.ts
Normal file
4
backend/src/plugins/Automod/triggers/messageSpam.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
import { RecentActionType } from "../constants";
|
||||||
|
import { createMessageSpamTrigger } from "../functions/createMessageSpamTrigger";
|
||||||
|
|
||||||
|
export const MessageSpamTrigger = createMessageSpamTrigger(RecentActionType.Message, "message");
|
88
backend/src/plugins/Automod/types.ts
Normal file
88
backend/src/plugins/Automod/types.ts
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
import * as t from "io-ts";
|
||||||
|
import { tNullable, UnknownUser } from "../../utils";
|
||||||
|
import { BasePluginType } from "knub";
|
||||||
|
import { GuildSavedMessages } from "../../data/GuildSavedMessages";
|
||||||
|
import { GuildLogs } from "../../data/GuildLogs";
|
||||||
|
import { SavedMessage } from "../../data/entities/SavedMessage";
|
||||||
|
import { User } from "eris";
|
||||||
|
import { AvailableTriggers } from "./triggers/availableTriggers";
|
||||||
|
import { AvailableActions } from "./actions/availableActions";
|
||||||
|
import { Queue } from "../../Queue";
|
||||||
|
import { GuildAntiraidLevels } from "../../data/GuildAntiraidLevels";
|
||||||
|
import { GuildArchives } from "../../data/GuildArchives";
|
||||||
|
import { RecentActionType } from "./constants";
|
||||||
|
import Timeout = NodeJS.Timeout;
|
||||||
|
|
||||||
|
export const Rule = t.type({
|
||||||
|
enabled: t.boolean,
|
||||||
|
name: t.string,
|
||||||
|
presets: tNullable(t.array(t.string)),
|
||||||
|
affects_bots: t.boolean,
|
||||||
|
triggers: t.array(t.partial(AvailableTriggers.props)),
|
||||||
|
actions: t.partial(AvailableActions.props),
|
||||||
|
cooldown: tNullable(t.string),
|
||||||
|
});
|
||||||
|
export type TRule = t.TypeOf<typeof Rule>;
|
||||||
|
|
||||||
|
export const ConfigSchema = t.type({
|
||||||
|
rules: t.record(t.string, Rule),
|
||||||
|
antiraid_levels: t.array(t.string),
|
||||||
|
can_set_antiraid: t.boolean,
|
||||||
|
can_view_antiraid: t.boolean,
|
||||||
|
});
|
||||||
|
export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
||||||
|
|
||||||
|
export interface AutomodPluginType extends BasePluginType {
|
||||||
|
config: TConfigSchema;
|
||||||
|
state: {
|
||||||
|
/**
|
||||||
|
* Automod checks/actions are handled in a queue so we don't get overlap on the same user
|
||||||
|
*/
|
||||||
|
queue: Queue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recent actions are used for spam triggers
|
||||||
|
*/
|
||||||
|
recentActions: RecentAction[];
|
||||||
|
clearRecentActionsInterval: Timeout;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After a spam trigger is tripped and the rule's action carried out, a unique identifier is placed here so further
|
||||||
|
* spam (either messages that were sent before the bot managed to mute the user or, with global spam, other users
|
||||||
|
* continuing to spam) is "included" in the same match and doesn't generate duplicate cases or logs.
|
||||||
|
* Key: rule_name-match_identifier
|
||||||
|
*/
|
||||||
|
recentSpam: RecentSpam[];
|
||||||
|
clearRecentSpamInterval: Timeout;
|
||||||
|
|
||||||
|
savedMessages: GuildSavedMessages;
|
||||||
|
logs: GuildLogs;
|
||||||
|
antiraidLevels: GuildAntiraidLevels;
|
||||||
|
archives: GuildArchives;
|
||||||
|
|
||||||
|
onMessageCreateFn: any;
|
||||||
|
onMessageUpdateFn: any;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutomodContext {
|
||||||
|
timestamp: number;
|
||||||
|
actioned?: boolean;
|
||||||
|
|
||||||
|
user?: User | UnknownUser;
|
||||||
|
message?: SavedMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecentAction {
|
||||||
|
type: RecentActionType;
|
||||||
|
identifier: string;
|
||||||
|
count: number;
|
||||||
|
context: AutomodContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecentSpam {
|
||||||
|
archiveId: string;
|
||||||
|
type: RecentActionType;
|
||||||
|
userId: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
|
@ -26,12 +26,12 @@ export function zeppelinPlugin<TPluginType extends BasePluginType>(): <
|
||||||
>(
|
>(
|
||||||
name: string,
|
name: string,
|
||||||
blueprint: TPartialBlueprint,
|
blueprint: TPartialBlueprint,
|
||||||
) => TPartialBlueprint & { name: string };
|
) => TPartialBlueprint & { name: string; configPreprocessor: PluginBlueprint<TPluginType>["configPreprocessor"] };
|
||||||
|
|
||||||
export function zeppelinPlugin(...args) {
|
export function zeppelinPlugin(...args) {
|
||||||
if (args.length) {
|
if (args.length) {
|
||||||
const blueprint: ZeppelinPluginBlueprint = plugin(...(args as Parameters<typeof plugin>));
|
const blueprint: ZeppelinPluginBlueprint = plugin(...(args as Parameters<typeof plugin>));
|
||||||
blueprint.configPreprocessor = getPluginConfigPreprocessor(blueprint);
|
blueprint.configPreprocessor = getPluginConfigPreprocessor(blueprint, blueprint.configPreprocessor);
|
||||||
return blueprint;
|
return blueprint;
|
||||||
} else {
|
} else {
|
||||||
return zeppelinPlugin;
|
return zeppelinPlugin;
|
||||||
|
|
|
@ -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 { AutomodPlugin } from "./Automod/AutomodPlugin";
|
||||||
|
|
||||||
// prettier-ignore
|
// prettier-ignore
|
||||||
export const guildPlugins: Array<ZeppelinPluginBlueprint<any>> = [
|
export const guildPlugins: Array<ZeppelinPluginBlueprint<any>> = [
|
||||||
|
@ -47,6 +48,7 @@ export const guildPlugins: Array<ZeppelinPluginBlueprint<any>> = [
|
||||||
WelcomeMessagePlugin,
|
WelcomeMessagePlugin,
|
||||||
CasesPlugin,
|
CasesPlugin,
|
||||||
MutesPlugin,
|
MutesPlugin,
|
||||||
|
AutomodPlugin,
|
||||||
];
|
];
|
||||||
|
|
||||||
// prettier-ignore
|
// prettier-ignore
|
||||||
|
|
Loading…
Add table
Reference in a new issue