From f41d280fab3355fbcd21b240bbc2122a0128fe15 Mon Sep 17 00:00:00 2001
From: Dragory <2606411+Dragory@users.noreply.github.com>
Date: Fri, 11 Oct 2019 01:59:56 +0300
Subject: [PATCH] Automod work. Add config examples to automod.
---
dashboard/src/components/Expandable.vue | 4 +
.../src/components/docs/ArgumentTypes.vue | 59 +-
dashboard/src/components/docs/Plugin.vue | 15 +-
dashboard/src/style/content.pcss | 3 +-
dashboard/src/style/dashboard.scss | 78 ---
src/data/DefaultLogMessages.json | 3 +-
src/data/GuildSavedMessages.ts | 8 +
src/data/LogType.ts | 1 +
src/plugins/Automod.ts | 587 ++++++++++++++----
src/plugins/Logs.ts | 19 +-
src/utils.ts | 20 +
11 files changed, 539 insertions(+), 258 deletions(-)
delete mode 100644 dashboard/src/style/dashboard.scss
diff --git a/dashboard/src/components/Expandable.vue b/dashboard/src/components/Expandable.vue
index 98b2b8b0..c9f6b38b 100644
--- a/dashboard/src/components/Expandable.vue
+++ b/dashboard/src/components/Expandable.vue
@@ -68,6 +68,10 @@
code:not([class]) {
@apply bg-gray-900;
}
+
+ .codeblock {
+ box-shadow: none;
+ }
diff --git a/dashboard/src/components/docs/Plugin.vue b/dashboard/src/components/docs/Plugin.vue
index 8ce75909..93b6856f 100644
--- a/dashboard/src/components/docs/Plugin.vue
+++ b/dashboard/src/components/docs/Plugin.vue
@@ -116,17 +116,12 @@
Config schema
-
-
-
+
+ Click to expand
+
{{ data.configSchema }}
-
-
+
+
diff --git a/dashboard/src/style/content.pcss b/dashboard/src/style/content.pcss
index ed10a11e..d359c20c 100644
--- a/dashboard/src/style/content.pcss
+++ b/dashboard/src/style/content.pcss
@@ -14,6 +14,7 @@
}
& h3 {
+ @apply text-xl;
@apply font-semibold;
@apply mb-1;
}
@@ -39,7 +40,7 @@
@apply inline-code;
}
- & .expandable {
+ & .expandable:not(.wide) {
max-width: 600px;
}
}
diff --git a/dashboard/src/style/dashboard.scss b/dashboard/src/style/dashboard.scss
deleted file mode 100644
index 0e7e6186..00000000
--- a/dashboard/src/style/dashboard.scss
+++ /dev/null
@@ -1,78 +0,0 @@
-@import "~buefy/dist/buefy.css";
-@import "~highlight.js/styles/ocean.css";
-
-$bulmaswatch-import-font: false;
-$family-primary: 'Open Sans', sans-serif;
-$list-background-color: transparent;
-
-$size-1: 2.5rem;
-$size-2: 2rem;
-$size-3: 1.5rem;
-$size-4: 1.25rem;
-
-@import "~bulmaswatch/superhero/_variables";
-
-$tabs-link-color: $grey-light;
-$tabs-link-active-color: $grey-lighter;
-$tabs-link-active-border-bottom-color: $grey-lighter;
-
-@import "~bulma/bulma";
-@import "~bulmaswatch/superhero/_overrides";
-
-
-.init-cloak {
- visibility: visible !important;
-}
-
-.z-title {
- line-height: 1.125;
-
- &.is-1 { font-size: $size-1; }
- &.is-2 { font-size: $size-2; }
- &.is-3 { font-size: $size-3; }
- &.is-4 { font-size: $size-4; }
- &.is-5 { font-size: $size-5; }
- &.is-6 { font-size: $size-6; }
-}
-
-.z-list {
- margin-left: 1.5rem;
-}
-
-.z-ul {
- list-style: disc;
-}
-
-.mt-1 { margin-top: 1rem; }
-.mt-2 { margin-top: 1.5rem; }
-.mt-3 { margin-top: 2rem; }
-
-.mb-1 { margin-bottom: 1rem; }
-.mb-2 { margin-bottom: 1.5rem; }
-.mb-3 { margin-bottom: 2rem; }
-
-.codeblock,
-.content .codeblock {
- border-radius: 3px;
- padding: 16px;
- max-width: 970px; /* FIXME: temp fix for overflowing code blocks, look into properly later */
-}
-
-.codeblock .hljs {
- background: transparent;
- padding: 0;
-}
-
-.menu-label {
- &:not(:first-child) {
- margin-top: 1.4em;
- }
-
- &:not(:last-child) {
- margin-bottom: 0.4em;
- }
-}
-
-.menu-list .router-link-active {
- text-decoration: underline;
-}
diff --git a/src/data/DefaultLogMessages.json b/src/data/DefaultLogMessages.json
index 9aeb8081..56300459 100644
--- a/src/data/DefaultLogMessages.json
+++ b/src/data/DefaultLogMessages.json
@@ -58,5 +58,6 @@
"POSTED_SCHEDULED_MESSAGE": "\uD83D\uDCE8 Posted scheduled message (`{messageId}`) to {channelMention(channel)} as scheduled by {userMention(author)}",
"BOT_ALERT": "⚠ {tmplEval(body)}",
- "AUTOMOD_ALERT": "{text}"
+ "AUTOMOD_ALERT": "{text}",
+ "AUTOMOD_ACTION": "\uD83E\uDD16 Automod rule **{rule}** triggered by {userMention(user)}, actions taken: {actionsTaken}\n{matchSummary}"
}
diff --git a/src/data/GuildSavedMessages.ts b/src/data/GuildSavedMessages.ts
index c1af28bf..c7a498fb 100644
--- a/src/data/GuildSavedMessages.ts
+++ b/src/data/GuildSavedMessages.ts
@@ -127,6 +127,14 @@ export class GuildSavedMessages extends BaseGuildRepository {
return query.getMany();
}
+ getMultiple(messageIds: string[]): Promise {
+ return this.messages
+ .createQueryBuilder()
+ .where("guild_id = :guild_id", { guild_id: this.guildId })
+ .andWhere("id IN (:messageIds)", { messageIds })
+ .getMany();
+ }
+
async create(data) {
const isPermanent = this.toBePermanent.has(data.id);
if (isPermanent) {
diff --git a/src/data/LogType.ts b/src/data/LogType.ts
index 69a62bda..64df22d8 100644
--- a/src/data/LogType.ts
+++ b/src/data/LogType.ts
@@ -59,4 +59,5 @@ export enum LogType {
BOT_ALERT,
AUTOMOD_ALERT,
+ AUTOMOD_ACTION,
}
diff --git a/src/plugins/Automod.ts b/src/plugins/Automod.ts
index 0ed2dcc2..4a768cf4 100644
--- a/src/plugins/Automod.ts
+++ b/src/plugins/Automod.ts
@@ -1,4 +1,4 @@
-import { ZeppelinPlugin } from "./ZeppelinPlugin";
+import { PluginInfo, trimPluginDescription, ZeppelinPlugin } from "./ZeppelinPlugin";
import * as t from "io-ts";
import {
convertDelayStringToMS,
@@ -7,8 +7,11 @@ import {
getRoleMentions,
getUrlsInString,
getUserMentions,
+ messageSummary,
MINUTES,
noop,
+ SECONDS,
+ stripObjectToScalars,
tNullable,
} from "../utils";
import { decorators as d } from "knub";
@@ -22,12 +25,19 @@ import { ModActionsPlugin } from "./ModActions";
import { MutesPlugin } from "./Mutes";
import { LogsPlugin } from "./Logs";
import { LogType } from "../data/LogType";
+import { TSafeRegex } from "../validatorUtils";
+import { GuildSavedMessages } from "../data/GuildSavedMessages";
+import { GuildArchives } from "../data/GuildArchives";
+import { GuildLogs } from "../data/GuildLogs";
+import { SavedMessage } from "../data/entities/SavedMessage";
+import moment from "moment-timezone";
type MessageInfo = { channelId: string; messageId: string };
type TextTriggerWithMultipleMatchTypes = {
match_messages: boolean;
match_embeds: boolean;
+ match_visible_names: boolean;
match_usernames: boolean;
match_nicknames: boolean;
};
@@ -44,7 +54,7 @@ interface MessageTextTriggerMatchResult extends TriggerMatchResult {
}
interface OtherTextTriggerMatchResult extends TriggerMatchResult {
- type: "username" | "nickname";
+ type: "username" | "nickname" | "visiblename";
str: string;
userId: string;
}
@@ -89,6 +99,7 @@ const MatchWordsTrigger = t.type({
only_full_words: t.boolean,
match_messages: t.boolean,
match_embeds: t.boolean,
+ match_visible_names: t.boolean,
match_usernames: t.boolean,
match_nicknames: t.boolean,
});
@@ -99,15 +110,17 @@ const defaultMatchWordsTrigger: TMatchWordsTrigger = {
only_full_words: true,
match_messages: true,
match_embeds: true,
+ match_visible_names: false,
match_usernames: false,
match_nicknames: false,
};
const MatchRegexTrigger = t.type({
- patterns: t.array(t.string),
+ patterns: t.array(TSafeRegex),
case_sensitive: t.boolean,
match_messages: t.boolean,
match_embeds: t.boolean,
+ match_visible_names: t.boolean,
match_usernames: t.boolean,
match_nicknames: t.boolean,
});
@@ -116,6 +129,7 @@ const defaultMatchRegexTrigger: Partial = {
case_sensitive: false,
match_messages: true,
match_embeds: true,
+ match_visible_names: false,
match_usernames: false,
match_nicknames: false,
};
@@ -128,6 +142,7 @@ const MatchInvitesTrigger = t.type({
allow_group_dm_invites: t.boolean,
match_messages: t.boolean,
match_embeds: t.boolean,
+ match_visible_names: t.boolean,
match_usernames: t.boolean,
match_nicknames: t.boolean,
});
@@ -136,6 +151,7 @@ const defaultMatchInvitesTrigger: Partial = {
allow_group_dm_invites: false,
match_messages: true,
match_embeds: true,
+ match_visible_names: false,
match_usernames: false,
match_nicknames: false,
};
@@ -146,6 +162,7 @@ const MatchLinksTrigger = t.type({
include_subdomains: t.boolean,
match_messages: t.boolean,
match_embeds: t.boolean,
+ match_visible_names: t.boolean,
match_usernames: t.boolean,
match_nicknames: t.boolean,
});
@@ -154,6 +171,7 @@ const defaultMatchLinksTrigger: Partial = {
include_subdomains: true,
match_messages: true,
match_embeds: true,
+ match_visible_names: false,
match_usernames: false,
match_nicknames: false,
};
@@ -173,22 +191,22 @@ const defaultTextSpamTrigger: Partial> = {
per_channel: true,
};
-const MaxMessagesTrigger = BaseTextSpamTrigger;
-type TMaxMessagesTrigger = t.TypeOf;
-const MaxMentionsTrigger = BaseTextSpamTrigger;
-type TMaxMentionsTrigger = t.TypeOf;
-const MaxLinksTrigger = BaseTextSpamTrigger;
-type TMaxLinksTrigger = t.TypeOf;
-const MaxAttachmentsTrigger = BaseTextSpamTrigger;
-type TMaxAttachmentsTrigger = t.TypeOf;
-const MaxEmojisTrigger = BaseTextSpamTrigger;
-type TMaxEmojisTrigger = t.TypeOf;
-const MaxLinesTrigger = BaseTextSpamTrigger;
-type TMaxLinesTrigger = t.TypeOf;
-const MaxCharactersTrigger = BaseTextSpamTrigger;
-type TMaxCharactersTrigger = t.TypeOf;
-const MaxVoiceMovesTrigger = BaseSpamTrigger;
-type TMaxVoiceMovesTrigger = t.TypeOf;
+const MessageSpamTrigger = BaseTextSpamTrigger;
+type TMessageSpamTrigger = t.TypeOf;
+const MentionSpamTrigger = BaseTextSpamTrigger;
+type TMentionSpamTrigger = t.TypeOf;
+const LinkSpamTrigger = BaseTextSpamTrigger;
+type TLinkSpamTrigger = t.TypeOf;
+const AttachmentSpamTrigger = BaseTextSpamTrigger;
+type TAttachmentSpamTrigger = t.TypeOf;
+const EmojiSpamTrigger = BaseTextSpamTrigger;
+type TEmojiSpamTrigger = t.TypeOf;
+const LineSpamTrigger = BaseTextSpamTrigger;
+type TLineSpamTrigger = t.TypeOf;
+const CharacterSpamTrigger = BaseTextSpamTrigger;
+type TCharacterSpamTrigger = t.TypeOf;
+const VoiceMoveSpamTrigger = BaseSpamTrigger;
+type TVoiceMoveSpamTrigger = t.TypeOf;
/**
* ACTIONS
@@ -217,6 +235,10 @@ const AlertAction = t.type({
text: t.string,
});
+const ChangeNicknameAction = t.type({
+ name: t.string,
+});
+
/**
* FULL CONFIG SCHEMA
*/
@@ -231,14 +253,14 @@ const Rule = t.type({
match_regex: tNullable(MatchRegexTrigger),
match_invites: tNullable(MatchInvitesTrigger),
match_links: tNullable(MatchLinksTrigger),
- max_messages: tNullable(MaxMessagesTrigger),
- max_mentions: tNullable(MaxMentionsTrigger),
- max_links: tNullable(MaxLinksTrigger),
- max_attachments: tNullable(MaxAttachmentsTrigger),
- max_emojis: tNullable(MaxEmojisTrigger),
- max_lines: tNullable(MaxLinesTrigger),
- max_characters: tNullable(MaxCharactersTrigger),
- max_voice_moves: tNullable(MaxVoiceMovesTrigger),
+ message_spam: tNullable(MessageSpamTrigger),
+ mention_spam: tNullable(MentionSpamTrigger),
+ link_spam: tNullable(LinkSpamTrigger),
+ attachment_spam: tNullable(AttachmentSpamTrigger),
+ emoji_spam: tNullable(EmojiSpamTrigger),
+ line_spam: tNullable(LineSpamTrigger),
+ character_spam: tNullable(CharacterSpamTrigger),
+ // voice_move_spam: tNullable(VoiceMoveSpamTrigger), // TODO
// TODO: Duplicates trigger
}),
),
@@ -249,6 +271,7 @@ const Rule = t.type({
kick: tNullable(KickAction),
ban: tNullable(BanAction),
alert: tNullable(AlertAction),
+ change_nickname: tNullable(ChangeNicknameAction),
}),
});
type TRule = t.TypeOf;
@@ -264,6 +287,16 @@ type TConfigSchema = t.TypeOf;
const defaultTriggers = {
match_words: defaultMatchWordsTrigger,
+ match_regex: defaultMatchRegexTrigger,
+ match_invites: defaultMatchInvitesTrigger,
+ match_links: defaultMatchLinksTrigger,
+ message_spam: defaultTextSpamTrigger,
+ mention_spam: defaultTextSpamTrigger,
+ link_spam: defaultTextSpamTrigger,
+ attachment_spam: defaultTextSpamTrigger,
+ emoji_spam: defaultTextSpamTrigger,
+ line_spam: defaultTextSpamTrigger,
+ character_spam: defaultTextSpamTrigger,
};
/**
@@ -303,9 +336,12 @@ type OtherRecentAction = BaseRecentAction & {
type: RecentActionType.VoiceChannelMove;
};
-type RecentAction = TextRecentAction | OtherRecentAction;
+type RecentAction = (TextRecentAction | OtherRecentAction) & { expiresAt: number };
-const MAX_SPAM_CHECK_TIMESPAN = 5 * MINUTES;
+const SPAM_GRACE_PERIOD_LENGTH = 10 * SECONDS;
+const RECENT_ACTION_EXPIRY_TIME = 2 * MINUTES;
+const MAX_RECENTLY_DELETED_MESSAGES = 10;
+const RECENT_NICKNAME_CHANGE_EXPIRY_TIME = 5 * MINUTES;
const inviteCache = new SimpleCache(10 * MINUTES);
@@ -314,12 +350,95 @@ export class AutomodPlugin extends ZeppelinPlugin {
public static configSchema = ConfigSchema;
public static dependencies = ["mod_actions", "mutes", "logs"];
+ public static pluginInfo: PluginInfo = {
+ prettyName: "Automod",
+ description: trimPluginDescription(`
+ This plugin allows you to specify automated actions in response to triggers. Example use cases include word filtering and spam prevention.
+ `),
+ configurationGuide: trimPluginDescription(`
+ The automod plugin is very customizable. For a full list of available triggers, actions, and their options, see Config schema at the bottom of this page.
+
+ ### Simple word filter
+ Removes any messages that contain the word 'banana' and sends a warning to the user.
+ Moderators (level >= 50) are ignored by the filter based on the override.
+
+ ~~~yml
+ automod:
+ config:
+ rules:
+ my_filter:
+ triggers:
+ - match_words:
+ words: ['banana']
+ case_sensitive: false
+ only_full_words: true
+ actions:
+ clean: true
+ warn:
+ reason: 'Do not talk about bananas!'
+ overrides:
+ - level: '>=50'
+ config:
+ rules:
+ my_filter:
+ enabled: false
+ ~~~
+
+ ### Spam detection
+ This example includes 2 filters:
+
+ - The first one is triggered if a user sends 5 messages within 10 seconds OR 3 attachments within 60 seconds.
+ The messages are deleted and the user is muted for 5 minutes.
+ - The second filter is triggered if a user sends more than 2 emoji within 5 seconds.
+ The messages are deleted but the user is not muted.
+
+ Moderators are ignored by both filters based on the override.
+
+ ~~~yml
+ automod:
+ config:
+ rules:
+ my_spam_filter:
+ triggers:
+ - message_spam:
+ amount: 5
+ within: 10s
+ - attachment_spam:
+ amount: 3
+ within: 60s
+ actions:
+ clean: true
+ mute:
+ duration: 5m
+ reason: 'Auto-muted for spam'
+ my_second_filter:
+ triggers:
+ - message_spam:
+ amount: 5
+ within: 10s
+ actions:
+ clean: true
+ mute:
+ duration: 5m
+ reason: 'Auto-muted for spam'
+ overrides:
+ - level: '>=50'
+ config:
+ rules:
+ my_spam_filter:
+ enabled: false
+ my_second_filter:
+ enabled: false
+ ~~~
+ `),
+ };
+
protected unloaded = false;
// Handle automod checks/actions in a queue so we don't get overlap on the same user
protected automodQueue: Queue;
- // Recent actions are used to detect "max_*" type of triggers, i.e. spam triggers
+ // Recent actions are used to detect spam triggers
protected recentActions: RecentAction[];
protected recentActionClearInterval: Timeout;
@@ -327,13 +446,24 @@ export class AutomodPlugin extends ZeppelinPlugin {
// During this grace period, if the user repeats the same type of recent action that tripped the rule, that message will
// be deleted and no further action will be carried out. This is mainly to account for the delay between the spam message
// being posted and the bot reacting to it, during which the user could keep posting more spam.
- protected spamGracePeriods: Array<{ key: string; type: RecentActionType; expiresAt: number }>;
+ protected spamGracePeriods: Map; // Key = identifier-actionType
protected spamGracePriodClearInterval: Timeout;
+ protected recentlyDeletedMessages: string[];
+
+ protected recentNicknameChanges: Map;
+ protected recentNicknameChangesClearInterval: Timeout;
+
+ protected onMessageCreateFn;
+
protected modActions: ModActionsPlugin;
protected mutes: MutesPlugin;
protected logs: LogsPlugin;
+ protected savedMessages: GuildSavedMessages;
+ protected archives: GuildArchives;
+ protected guildLogs: GuildLogs;
+
protected static preprocessStaticConfig(config) {
if (config.rules && typeof config.rules === "object") {
// Loop through each rule
@@ -351,7 +481,7 @@ export class AutomodPlugin extends ZeppelinPlugin {
if (rule["triggers"] != null && Array.isArray(rule["triggers"])) {
for (const trigger of rule["triggers"]) {
if (trigger == null || typeof trigger !== "object") continue;
- // Apply default triggers to the triggers used in this rule
+ // Apply default config to the triggers used in this rule
for (const [defaultTriggerName, defaultTrigger] of Object.entries(defaultTriggers)) {
if (trigger[defaultTriggerName] && typeof trigger[defaultTriggerName] === "object") {
trigger[defaultTriggerName] = mergeConfig({}, defaultTrigger, trigger[defaultTriggerName]);
@@ -373,31 +503,60 @@ export class AutomodPlugin extends ZeppelinPlugin {
protected onLoad() {
this.automodQueue = new Queue();
- this.modActions = this.getPlugin("mod_actions");
- this.logs = this.getPlugin("logs");
+
+ this.recentActions = [];
+ this.spamGracePeriods = new Map();
+ this.spamGracePriodClearInterval = setInterval(() => this.clearExpiredGracePeriods(), 1 * SECONDS);
+
+ this.recentlyDeletedMessages = [];
+
+ this.recentNicknameChanges = new Map();
+ this.recentNicknameChangesClearInterval = setInterval(() => this.clearExpiredRecentNicknameChanges(), 30 * SECONDS);
+
+ this.savedMessages = GuildSavedMessages.getGuildInstance(this.guildId);
+ this.archives = GuildArchives.getGuildInstance(this.guildId);
+ this.guildLogs = new GuildLogs(this.guildId);
+
+ this.onMessageCreateFn = msg => this.onMessageCreate(msg);
+ this.savedMessages.events.on("create", this.onMessageCreateFn);
+ }
+
+ protected getModActions(): ModActionsPlugin {
+ return this.getPlugin("mod_actions");
+ }
+
+ protected getLogs(): LogsPlugin {
+ return this.getPlugin("logs");
}
protected onUnload() {
this.unloaded = true;
+ this.savedMessages.events.off("create", this.onMessageCreateFn);
clearInterval(this.recentActionClearInterval);
clearInterval(this.spamGracePriodClearInterval);
}
protected evaluateMatchWordsTrigger(trigger: TMatchWordsTrigger, str: string): boolean {
for (const word of trigger.words) {
- const pattern = trigger.only_full_words ? `\b${escapeStringRegexp(word)}\b` : escapeStringRegexp(word);
+ const pattern = trigger.only_full_words ? `\\b${escapeStringRegexp(word)}\\b` : escapeStringRegexp(word);
const regex = new RegExp(pattern, trigger.case_sensitive ? "" : "i");
- return regex.test(str);
+ const test = regex.test(str);
+ if (test) return true;
}
+
+ return false;
}
protected evaluateMatchRegexTrigger(trigger: TMatchRegexTrigger, str: string): boolean {
// TODO: Time limit regexes
for (const pattern of trigger.patterns) {
const regex = new RegExp(pattern, trigger.case_sensitive ? "" : "i");
- return regex.test(str);
+ const test = regex.test(str);
+ if (test) return true;
}
+
+ return false;
}
protected async evaluateMatchInvitesTrigger(trigger: TMatchInvitesTrigger, str: string): Promise {
@@ -478,19 +637,23 @@ export class AutomodPlugin extends ZeppelinPlugin {
protected matchTextSpamTrigger(
recentActionType: RecentActionType,
trigger: TBaseTextSpamTrigger,
- msg: Message,
+ msg: SavedMessage,
): TextSpamTriggerMatchResult {
- const since = msg.timestamp - convertDelayStringToMS(trigger.within);
+ const since = moment.utc(msg.posted_at).valueOf() - convertDelayStringToMS(trigger.within);
const recentActions = trigger.per_channel
- ? this.getMatchingRecentActions(recentActionType, `${msg.channel.id}-${msg.author.id}`, since)
- : this.getMatchingRecentActions(recentActionType, msg.author.id, since);
- if (recentActions.length > trigger.amount) {
+ ? this.getMatchingRecentActions(recentActionType, `${msg.channel_id}-${msg.user_id}`, since)
+ : this.getMatchingRecentActions(recentActionType, msg.user_id, since);
+ const totalCount = recentActions.reduce((total, action) => {
+ return total + action.count;
+ }, 0);
+
+ if (totalCount >= trigger.amount) {
return {
type: "textspam",
actionType: recentActionType,
- channelId: trigger.per_channel ? msg.channel.id : null,
+ channelId: trigger.per_channel ? msg.channel_id : null,
messageInfos: recentActions.map(action => (action as TextRecentAction).messageInfo),
- userId: msg.author.id,
+ userId: msg.user_id,
};
}
@@ -499,33 +662,40 @@ export class AutomodPlugin extends ZeppelinPlugin {
protected async matchMultipleTextTypesOnMessage(
trigger: TextTriggerWithMultipleMatchTypes,
- msg: Message,
+ msg: SavedMessage,
cb,
): Promise {
- const messageInfo: MessageInfo = { channelId: msg.channel.id, messageId: msg.id };
+ const messageInfo: MessageInfo = { channelId: msg.channel_id, messageId: msg.id };
+ const member = this.guild.members.get(msg.user_id);
if (trigger.match_messages) {
- const str = msg.content;
+ const str = msg.data.content;
const match = await cb(str);
- if (match) return { type: "message", str, userId: msg.author.id, messageInfo };
+ if (match) return { type: "message", str, userId: msg.user_id, messageInfo };
}
- if (trigger.match_embeds && msg.embeds.length) {
- const str = JSON.stringify(msg.embeds[0]);
+ if (trigger.match_embeds && msg.data.embeds && msg.data.embeds.length) {
+ const str = JSON.stringify(msg.data.embeds[0]);
const match = await cb(str);
- if (match) return { type: "embed", str, userId: msg.author.id, messageInfo };
+ if (match) return { type: "embed", str, userId: msg.user_id, messageInfo };
+ }
+
+ if (trigger.match_visible_names) {
+ const str = member.nick || msg.data.author.username;
+ const match = await cb(str);
+ if (match) return { type: "visiblename", str, userId: msg.user_id };
}
if (trigger.match_usernames) {
- const str = `${msg.author.username}#${msg.author.discriminator}`;
+ const str = `${msg.data.author.username}#${msg.data.author.discriminator}`;
const match = await cb(str);
- if (match) return { type: "username", str, userId: msg.author.id };
+ if (match) return { type: "username", str, userId: msg.user_id };
}
- if (trigger.match_nicknames && msg.member.nick) {
- const str = msg.member.nick;
+ if (trigger.match_nicknames && member.nick) {
+ const str = member.nick;
const match = await cb(str);
- if (match) return { type: "nickname", str, userId: msg.author.id };
+ if (match) return { type: "nickname", str, userId: msg.user_id };
}
return null;
@@ -556,8 +726,10 @@ export class AutomodPlugin extends ZeppelinPlugin {
*/
protected async matchRuleToMessage(
rule: TRule,
- msg: Message,
+ msg: SavedMessage,
): Promise {
+ if (!rule.enabled) return;
+
for (const trigger of rule.triggers) {
if (trigger.match_words) {
const match = await this.matchMultipleTextTypesOnMessage(trigger.match_words, msg, str => {
@@ -587,38 +759,38 @@ export class AutomodPlugin extends ZeppelinPlugin {
if (match) return match;
}
- if (trigger.max_messages) {
- const match = this.matchTextSpamTrigger(RecentActionType.Message, trigger.max_messages, msg);
+ if (trigger.message_spam) {
+ const match = this.matchTextSpamTrigger(RecentActionType.Message, trigger.message_spam, msg);
if (match) return match;
}
- if (trigger.max_mentions) {
- const match = this.matchTextSpamTrigger(RecentActionType.Mention, trigger.max_mentions, msg);
+ if (trigger.mention_spam) {
+ const match = this.matchTextSpamTrigger(RecentActionType.Mention, trigger.mention_spam, msg);
if (match) return match;
}
- if (trigger.max_links) {
- const match = this.matchTextSpamTrigger(RecentActionType.Link, trigger.max_links, msg);
+ if (trigger.link_spam) {
+ const match = this.matchTextSpamTrigger(RecentActionType.Link, trigger.link_spam, msg);
if (match) return match;
}
- if (trigger.max_attachments) {
- const match = this.matchTextSpamTrigger(RecentActionType.Attachment, trigger.max_attachments, msg);
+ if (trigger.attachment_spam) {
+ const match = this.matchTextSpamTrigger(RecentActionType.Attachment, trigger.attachment_spam, msg);
if (match) return match;
}
- if (trigger.max_emojis) {
- const match = this.matchTextSpamTrigger(RecentActionType.Emoji, trigger.max_emojis, msg);
+ if (trigger.emoji_spam) {
+ const match = this.matchTextSpamTrigger(RecentActionType.Emoji, trigger.emoji_spam, msg);
if (match) return match;
}
- if (trigger.max_lines) {
- const match = this.matchTextSpamTrigger(RecentActionType.Line, trigger.max_lines, msg);
+ if (trigger.line_spam) {
+ const match = this.matchTextSpamTrigger(RecentActionType.Line, trigger.line_spam, msg);
if (match) return match;
}
- if (trigger.max_characters) {
- const match = this.matchTextSpamTrigger(RecentActionType.Character, trigger.max_characters, msg);
+ if (trigger.character_spam) {
+ const match = this.matchTextSpamTrigger(RecentActionType.Character, trigger.character_spam, msg);
if (match) return match;
}
}
@@ -626,23 +798,45 @@ export class AutomodPlugin extends ZeppelinPlugin {
return null;
}
+ protected async addRecentMessageAction(action: TextRecentAction) {
+ const gracePeriodKey = `${action.identifier}-${action.type}`;
+ if (this.spamGracePeriods.has(gracePeriodKey)) {
+ // If we're on spam detection grace period, just delete the message
+ if (!this.recentlyDeletedMessages.includes(action.messageInfo.messageId)) {
+ this.bot.deleteMessage(action.messageInfo.channelId, action.messageInfo.messageId);
+
+ this.recentlyDeletedMessages.push(action.messageInfo.messageId);
+ if (this.recentlyDeletedMessages.length > MAX_RECENTLY_DELETED_MESSAGES) {
+ this.recentlyDeletedMessages.splice(0, this.recentlyDeletedMessages.length - MAX_RECENTLY_DELETED_MESSAGES);
+ }
+ }
+
+ return;
+ }
+
+ this.recentActions.push({
+ ...action,
+ expiresAt: Date.now() + RECENT_ACTION_EXPIRY_TIME,
+ });
+ }
+
/**
* Logs recent actions for spam detection purposes
*/
- protected async logRecentActionsForMessage(msg: Message) {
- const timestamp = msg.timestamp;
- const globalIdentifier = msg.author.id;
- const perChannelIdentifier = `${msg.channel.id}-${msg.author.id}`;
- const messageInfo: MessageInfo = { channelId: msg.channel.id, messageId: msg.id };
+ protected async logRecentActionsForMessage(msg: SavedMessage) {
+ const timestamp = moment.utc(msg.posted_at).valueOf();
+ const globalIdentifier = msg.user_id;
+ const perChannelIdentifier = `${msg.channel_id}-${msg.user_id}`;
+ const messageInfo: MessageInfo = { channelId: msg.channel_id, messageId: msg.id };
- this.recentActions.push({
+ this.addRecentMessageAction({
type: RecentActionType.Message,
identifier: globalIdentifier,
timestamp,
count: 1,
messageInfo,
});
- this.recentActions.push({
+ this.addRecentMessageAction({
type: RecentActionType.Message,
identifier: perChannelIdentifier,
timestamp,
@@ -650,16 +844,17 @@ export class AutomodPlugin extends ZeppelinPlugin {
messageInfo,
});
- const mentionCount = getUserMentions(msg.content || "").length + getRoleMentions(msg.content || "").length;
+ const mentionCount =
+ getUserMentions(msg.data.content || "").length + getRoleMentions(msg.data.content || "").length;
if (mentionCount) {
- this.recentActions.push({
+ this.addRecentMessageAction({
type: RecentActionType.Mention,
identifier: globalIdentifier,
timestamp,
count: mentionCount,
messageInfo,
});
- this.recentActions.push({
+ this.addRecentMessageAction({
type: RecentActionType.Mention,
identifier: perChannelIdentifier,
timestamp,
@@ -668,16 +863,16 @@ export class AutomodPlugin extends ZeppelinPlugin {
});
}
- const linkCount = getUrlsInString(msg.content || "").length;
+ const linkCount = getUrlsInString(msg.data.content || "").length;
if (linkCount) {
- this.recentActions.push({
+ this.addRecentMessageAction({
type: RecentActionType.Link,
identifier: globalIdentifier,
timestamp,
count: linkCount,
messageInfo,
});
- this.recentActions.push({
+ this.addRecentMessageAction({
type: RecentActionType.Link,
identifier: perChannelIdentifier,
timestamp,
@@ -686,16 +881,16 @@ export class AutomodPlugin extends ZeppelinPlugin {
});
}
- const attachmentCount = msg.attachments.length;
+ const attachmentCount = msg.data.attachments && msg.data.attachments.length;
if (attachmentCount) {
- this.recentActions.push({
+ this.addRecentMessageAction({
type: RecentActionType.Attachment,
identifier: globalIdentifier,
timestamp,
count: attachmentCount,
messageInfo,
});
- this.recentActions.push({
+ this.addRecentMessageAction({
type: RecentActionType.Attachment,
identifier: perChannelIdentifier,
timestamp,
@@ -704,16 +899,16 @@ export class AutomodPlugin extends ZeppelinPlugin {
});
}
- const emojiCount = getEmojiInString(msg.content || "").length;
+ const emojiCount = getEmojiInString(msg.data.content || "").length;
if (emojiCount) {
- this.recentActions.push({
+ this.addRecentMessageAction({
type: RecentActionType.Emoji,
identifier: globalIdentifier,
timestamp,
count: emojiCount,
messageInfo,
});
- this.recentActions.push({
+ this.addRecentMessageAction({
type: RecentActionType.Emoji,
identifier: perChannelIdentifier,
timestamp,
@@ -723,16 +918,16 @@ export class AutomodPlugin extends ZeppelinPlugin {
}
// + 1 is for the first line of the message (which doesn't have a line break)
- const lineCount = msg.content ? msg.content.match(/\n/g).length + 1 : 0;
+ const lineCount = msg.data.content ? (msg.data.content.match(/\n/g) || []).length + 1 : 0;
if (lineCount) {
- this.recentActions.push({
+ this.addRecentMessageAction({
type: RecentActionType.Line,
identifier: globalIdentifier,
timestamp,
count: lineCount,
messageInfo,
});
- this.recentActions.push({
+ this.addRecentMessageAction({
type: RecentActionType.Line,
identifier: perChannelIdentifier,
timestamp,
@@ -741,16 +936,16 @@ export class AutomodPlugin extends ZeppelinPlugin {
});
}
- const characterCount = [...(msg.content || "")].length;
+ const characterCount = [...(msg.data.content || "")].length;
if (characterCount) {
- this.recentActions.push({
+ this.addRecentMessageAction({
type: RecentActionType.Character,
identifier: globalIdentifier,
timestamp,
count: characterCount,
messageInfo,
});
- this.recentActions.push({
+ this.addRecentMessageAction({
type: RecentActionType.Character,
identifier: perChannelIdentifier,
timestamp,
@@ -766,37 +961,132 @@ export class AutomodPlugin extends ZeppelinPlugin {
});
}
- protected async applyActionsOnMatch(rule: TRule, matchResult: AnyTriggerMatchResult) {
- if (rule.actions.clean) {
- if (matchResult.type === "message" || matchResult.type === "embed") {
- await this.bot.deleteMessage(matchResult.messageInfo.channelId, matchResult.messageInfo.messageId).catch(noop);
- } else if (matchResult.type === "textspam" || matchResult.type === "raidspam") {
- for (const { channelId, messageId } of matchResult.messageInfos) {
- await this.bot.deleteMessage(channelId, messageId).catch(noop);
- }
+ protected async activateGracePeriod(matchResult: TextSpamTriggerMatchResult) {
+ const expiresAt = Date.now() + SPAM_GRACE_PERIOD_LENGTH;
+
+ // Global identifier
+ this.spamGracePeriods.set(`${matchResult.userId}-${matchResult.actionType}`, { expiresAt, deletedMessages: [] });
+ // Per-channel identifier
+ this.spamGracePeriods.set(`${matchResult.channelId}-${matchResult.userId}-${matchResult.actionType}`, {
+ expiresAt,
+ deletedMessages: [],
+ });
+ }
+
+ protected async clearExpiredGracePeriods() {
+ for (const [key, info] of this.spamGracePeriods.entries()) {
+ if (info.expiresAt <= Date.now()) {
+ this.spamGracePeriods.delete(key);
}
}
+ }
+
+ protected async clearOldRecentActions() {
+ this.recentActions = this.recentActions.filter(info => {
+ return info.expiresAt <= Date.now();
+ });
+ }
+
+ protected async clearExpiredRecentNicknameChanges() {
+ for (const [key, info] of this.recentNicknameChanges.entries()) {
+ if (info.expiresAt <= Date.now()) {
+ this.recentNicknameChanges.delete(key);
+ }
+ }
+ }
+
+ protected async clearSpecificRecentActions(type: RecentActionType, identifier: string) {
+ this.recentActions = this.recentActions.filter(info => {
+ return !(info.type === type && info.identifier === identifier);
+ });
+ }
+
+ protected async applyActionsOnMatch(rule: TRule, matchResult: AnyTriggerMatchResult) {
+ const actionsTaken = [];
+
+ let matchSummary = null;
+ let caseExtraNote = null;
+
+ if (matchResult.type === "textspam") {
+ this.activateGracePeriod(matchResult);
+ this.clearSpecificRecentActions(
+ matchResult.actionType,
+ matchResult.channelId ? `${matchResult.channelId}-${matchResult.userId}` : matchResult.userId,
+ );
+ }
+
+ // Match summary
+ let matchedMessageIds = [];
+ if (matchResult.type === "message" || matchResult.type === "embed") {
+ matchedMessageIds = [matchResult.messageInfo.messageId];
+ } else if (matchResult.type === "textspam" || matchResult.type === "raidspam") {
+ matchedMessageIds = matchResult.messageInfos.map(m => m.messageId);
+ }
+
+ if (matchedMessageIds.length > 1) {
+ const savedMessages = await this.savedMessages.getMultiple(matchedMessageIds);
+ const archiveId = await this.archives.createFromSavedMessages(savedMessages, this.guild);
+ const baseUrl = this.knub.getGlobalConfig().url;
+ const archiveUrl = this.archives.getUrl(baseUrl, archiveId);
+ matchSummary = `Deleted messages: <${archiveUrl}>`;
+ } else if (matchedMessageIds.length === 1) {
+ const message = await this.savedMessages.find(matchedMessageIds[0]);
+ matchSummary = `Deleted message:\n${messageSummary(message)}`;
+ }
+
+ if (matchResult.type === "username") {
+ matchSummary = `Matched username: ${matchResult.str}`;
+ } else if (matchResult.type === "nickname") {
+ matchSummary = `Matched nickname: ${matchResult.str}`;
+ } else if (matchResult.type === "visiblename") {
+ matchSummary = `Matched visible name: ${matchResult.str}`;
+ }
+
+ caseExtraNote = `Matched automod rule "${rule.name}"`;
+ if (matchSummary) {
+ caseExtraNote += `\n${matchSummary}`;
+ }
+
+ // Actions
+ if (rule.actions.clean) {
+ const messagesToDelete: Array<{ channelId: string; messageId: string }> = [];
+
+ if (matchResult.type === "message" || matchResult.type === "embed") {
+ messagesToDelete.push(matchResult.messageInfo);
+ } else if (matchResult.type === "textspam" || matchResult.type === "raidspam") {
+ messagesToDelete.push(...matchResult.messageInfos);
+ }
+
+ for (const { channelId, messageId } of messagesToDelete) {
+ await this.bot.deleteMessage(channelId, messageId).catch(noop);
+ }
+
+ actionsTaken.push("clean");
+ }
if (rule.actions.warn) {
- const reason = rule.actions.mute.reason || "Warned automatically";
+ const reason = rule.actions.warn.reason || "Warned automatically";
+
const caseArgs = {
modId: this.bot.user.id,
- extraNotes: [`Matched automod rule "${rule.name}"`],
+ extraNotes: [caseExtraNote],
};
if (matchResult.type === "message" || matchResult.type === "embed" || matchResult.type === "textspam") {
const member = await this.getMember(matchResult.userId);
if (member) {
- await this.modActions.warnMember(member, reason, caseArgs);
+ await this.getModActions().warnMember(member, reason, caseArgs);
}
} else if (matchResult.type === "raidspam") {
for (const userId of matchResult.userIds) {
const member = await this.getMember(userId);
if (member) {
- await this.modActions.warnMember(member, reason, caseArgs);
+ await this.getModActions().warnMember(member, reason, caseArgs);
}
}
}
+
+ actionsTaken.push("warn");
}
if (rule.actions.mute) {
@@ -804,7 +1094,7 @@ export class AutomodPlugin extends ZeppelinPlugin {
const reason = rule.actions.mute.reason || "Muted automatically";
const caseArgs = {
modId: this.bot.user.id,
- extraNotes: [`Matched automod rule "${rule.name}"`],
+ extraNotes: [caseExtraNote],
};
if (matchResult.type === "message" || matchResult.type === "embed" || matchResult.type === "textspam") {
@@ -814,60 +1104,115 @@ export class AutomodPlugin extends ZeppelinPlugin {
await this.mutes.muteUser(userId, duration, reason, caseArgs);
}
}
+
+ actionsTaken.push("mute");
}
if (rule.actions.kick) {
const reason = rule.actions.kick.reason || "Kicked automatically";
const caseArgs = {
modId: this.bot.user.id,
- extraNotes: [`Matched automod rule "${rule.name}"`],
+ extraNotes: [caseExtraNote],
};
if (matchResult.type === "message" || matchResult.type === "embed" || matchResult.type === "textspam") {
const member = await this.getMember(matchResult.userId);
if (member) {
- await this.modActions.kickMember(member, reason, caseArgs);
+ await this.getModActions().kickMember(member, reason, caseArgs);
}
} else if (matchResult.type === "raidspam") {
for (const userId of matchResult.userIds) {
const member = await this.getMember(userId);
if (member) {
- await this.modActions.kickMember(member, reason, caseArgs);
+ await this.getModActions().kickMember(member, reason, caseArgs);
}
}
}
+
+ actionsTaken.push("kick");
}
if (rule.actions.ban) {
const reason = rule.actions.ban.reason || "Banned automatically";
const caseArgs = {
modId: this.bot.user.id,
- extraNotes: [`Matched automod rule "${rule.name}"`],
+ extraNotes: [caseExtraNote],
};
if (matchResult.type === "message" || matchResult.type === "embed" || matchResult.type === "textspam") {
- await this.modActions.banUserId(matchResult.userId, reason, caseArgs);
+ await this.getModActions().banUserId(matchResult.userId, reason, caseArgs);
} else if (matchResult.type === "raidspam") {
for (const userId of matchResult.userIds) {
- await this.modActions.banUserId(userId, reason, caseArgs);
+ await this.getModActions().banUserId(userId, reason, caseArgs);
}
}
+
+ actionsTaken.push("ban");
}
- if (rule.actions.alert) {
- const text = rule.actions.alert.text;
- this.logs.log(LogType.AUTOMOD_ALERT, { text });
+ if (rule.actions.change_nickname) {
+ const userIdsToChange =
+ matchResult.type === "raidspam" || matchResult.type === "otherspam"
+ ? matchResult.userIds
+ : [matchResult.userId];
+
+ for (const userId of userIdsToChange) {
+ if (this.recentNicknameChanges.has(userId)) continue;
+ this.guild
+ .editMember(userId, {
+ nick: rule.actions.change_nickname.name,
+ })
+ .catch(() => {
+ this.getLogs().log(LogType.BOT_ALERT, {
+ body: `Failed to change the nickname of \`${userId}\``,
+ });
+ });
+ this.recentNicknameChanges.set(userId, { expiresAt: RECENT_NICKNAME_CHANGE_EXPIRY_TIME });
+ }
+
+ actionsTaken.push("nickname");
+ }
+
+ if (rule.actions.alert || matchResult.type !== "raidspam") {
+ const user = await this.resolveUser((matchResult as any).userId || "0");
+
+ if (rule.actions.alert) {
+ const text = rule.actions.alert.text;
+ this.getLogs().log(LogType.AUTOMOD_ALERT, {
+ rule: rule.name,
+ user: stripObjectToScalars(user),
+ text,
+ matchSummary,
+ });
+
+ actionsTaken.push("alert");
+ }
+
+ if (matchResult.type !== "raidspam") {
+ this.getLogs().log(LogType.AUTOMOD_ACTION, {
+ rule: rule.name,
+ user: stripObjectToScalars(user),
+ actionsTaken: actionsTaken.length ? actionsTaken.join(", ") : "",
+ matchSummary,
+ });
+ }
}
}
- @d.event("messageCreate")
- protected onMessageCreate(msg: Message) {
+ protected onMessageCreate(msg: SavedMessage) {
+ if (msg.is_bot) return;
+
this.automodQueue.add(async () => {
if (this.unloaded) return;
await this.logRecentActionsForMessage(msg);
- const config = this.getMatchingConfig({ message: msg });
+ const member = this.guild.members.get(msg.user_id);
+ const config = this.getMatchingConfig({
+ member,
+ userId: msg.user_id,
+ channelId: msg.channel_id,
+ });
for (const [name, rule] of Object.entries(config.rules)) {
const matchResult = await this.matchRuleToMessage(rule, msg);
if (matchResult) {
diff --git a/src/plugins/Logs.ts b/src/plugins/Logs.ts
index d2ab4380..4aa1d2dc 100644
--- a/src/plugins/Logs.ts
+++ b/src/plugins/Logs.ts
@@ -8,6 +8,7 @@ import {
disableCodeBlocks,
disableLinkPreviews,
findRelevantAuditLogEntry,
+ messageSummary,
noop,
stripObjectToScalars,
UnknownUser,
@@ -205,22 +206,8 @@ export class LogsPlugin extends ZeppelinPlugin {
return `<#${channel.id}> (**#${channel.name}**, \`${channel.id}\`)`;
},
messageSummary: (msg: SavedMessage) => {
- // Regular text content
- let result = "```" + (msg.data.content ? disableCodeBlocks(msg.data.content) : "") + "```";
-
- // Rich embed
- const richEmbed = (msg.data.embeds || []).find(e => (e as Embed).type === "rich");
- if (richEmbed) result += "Embed:```" + disableCodeBlocks(JSON.stringify(richEmbed)) + "```";
-
- // Attachments
- if (msg.data.attachments) {
- result +=
- "Attachments:\n" +
- msg.data.attachments.map((a: Attachment) => disableLinkPreviews(a.url)).join("\n") +
- "\n";
- }
-
- return result;
+ if (!msg) return "";
+ return messageSummary(msg);
},
};
diff --git a/src/utils.ts b/src/utils.ts
index c306c774..7dae65a7 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -1,5 +1,7 @@
import {
+ Attachment,
Client,
+ Embed,
EmbedOptions,
Emoji,
Guild,
@@ -23,6 +25,7 @@ const fsp = fs.promises;
import https from "https";
import tmp from "tmp";
import { logger, waitForReaction } from "knub";
+import { SavedMessage } from "./data/entities/SavedMessage";
const delayStringMultipliers = {
w: 1000 * 60 * 60 * 24 * 7,
@@ -772,3 +775,20 @@ export async function confirm(bot: Client, channel: TextableChannel, userId: str
msg.delete().catch(noop);
return reply && reply.name === "✅";
}
+
+export function messageSummary(msg: SavedMessage) {
+ // Regular text content
+ let result = "```" + (msg.data.content ? disableCodeBlocks(msg.data.content) : "") + "```";
+
+ // Rich embed
+ const richEmbed = (msg.data.embeds || []).find(e => (e as Embed).type === "rich");
+ if (richEmbed) result += "Embed:```" + disableCodeBlocks(JSON.stringify(richEmbed)) + "```";
+
+ // Attachments
+ if (msg.data.attachments) {
+ result +=
+ "Attachments:\n" + msg.data.attachments.map((a: Attachment) => disableLinkPreviews(a.url)).join("\n") + "\n";
+ }
+
+ return result;
+}