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

Automod work. Add config examples to automod.

This commit is contained in:
Dragory 2019-10-11 01:59:56 +03:00
parent 5a9ad943a2
commit f41d280fab
11 changed files with 539 additions and 258 deletions

View file

@ -68,6 +68,10 @@
code:not([class]) { code:not([class]) {
@apply bg-gray-900; @apply bg-gray-900;
} }
.codeblock {
box-shadow: none;
}
</style> </style>
<script type="ts"> <script type="ts">

View file

@ -77,40 +77,37 @@
Note that the delay should always be written as 1 word, without spaces! Note that the delay should always be written as 1 word, without spaces!
</p> </p>
<div :open="false" class="card mb-1"> <!-- b-collapse --> <Expandable>
<div slot="trigger" slot-scope="props" class="card-header" role="button"> <template v-slot:title>Additional information</template>
<p class="card-header-title">Additional Information</p> <template v-slot:content>
<a class="card-header-icon"> Durations:
<b-icon :icon="props.open ? 'menu-down' : 'menu-up'"></b-icon> <ul>
</a> <li>
</div> <code>d</code> Day
<div class="card-content"> </li>
<div class="content"> <li>
Durations: <code>h</code> Hour
<ul> </li>
<li> <li>
<code>d</code> Day <code>m</code> Minute
</li> </li>
<li> <li>
<code>h</code> Hour <code>s</code> Seconds
</li> </li>
<li> </ul>
<code>m</code> Minute </template>
</li> </Expandable>
<li>
<code>s</code> Seconds
</li>
</ul>
</div>
</div>
</div>
</div> </div>
</template> </template>
<script> <script>
import CodeBlock from "./CodeBlock"; import CodeBlock from "./CodeBlock";
import Expandable from "../Expandable";
export default { export default {
components: { CodeBlock }, components: {
}; CodeBlock,
Expandable,
},
};
</script> </script>

View file

@ -116,17 +116,12 @@
<!-- Config schema --> <!-- Config schema -->
<h2 id="config-schema">Config schema</h2> <h2 id="config-schema">Config schema</h2>
<div :open="false" class="card mt-1 mb-1"> <!-- b-collapse --> <Expandable class="wide">
<div slot="trigger" slot-scope="props" class="card-header" role="button"> <template v-slot:title>Click to expand</template>
<p class="card-header-title">Click to expand</p> <template v-slot:content>
<a class="card-header-icon">
<b-icon :icon="props.open ? 'menu-up' : 'menu-down'"></b-icon>
</a>
</div>
<div class="card-content">
<CodeBlock lang="plain">{{ data.configSchema }}</CodeBlock> <CodeBlock lang="plain">{{ data.configSchema }}</CodeBlock>
</div> </template>
</div> </Expandable>
</div> </div>
</div> </div>
</template> </template>

View file

@ -14,6 +14,7 @@
} }
& h3 { & h3 {
@apply text-xl;
@apply font-semibold; @apply font-semibold;
@apply mb-1; @apply mb-1;
} }
@ -39,7 +40,7 @@
@apply inline-code; @apply inline-code;
} }
& .expandable { & .expandable:not(.wide) {
max-width: 600px; max-width: 600px;
} }
} }

View file

@ -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;
}

View file

@ -58,5 +58,6 @@
"POSTED_SCHEDULED_MESSAGE": "\uD83D\uDCE8 Posted scheduled message (`{messageId}`) to {channelMention(channel)} as scheduled by {userMention(author)}", "POSTED_SCHEDULED_MESSAGE": "\uD83D\uDCE8 Posted scheduled message (`{messageId}`) to {channelMention(channel)} as scheduled by {userMention(author)}",
"BOT_ALERT": "⚠ {tmplEval(body)}", "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}"
} }

View file

@ -127,6 +127,14 @@ export class GuildSavedMessages extends BaseGuildRepository {
return query.getMany(); return query.getMany();
} }
getMultiple(messageIds: string[]): Promise<SavedMessage[]> {
return this.messages
.createQueryBuilder()
.where("guild_id = :guild_id", { guild_id: this.guildId })
.andWhere("id IN (:messageIds)", { messageIds })
.getMany();
}
async create(data) { async create(data) {
const isPermanent = this.toBePermanent.has(data.id); const isPermanent = this.toBePermanent.has(data.id);
if (isPermanent) { if (isPermanent) {

View file

@ -59,4 +59,5 @@ export enum LogType {
BOT_ALERT, BOT_ALERT,
AUTOMOD_ALERT, AUTOMOD_ALERT,
AUTOMOD_ACTION,
} }

View file

@ -1,4 +1,4 @@
import { ZeppelinPlugin } from "./ZeppelinPlugin"; import { PluginInfo, trimPluginDescription, ZeppelinPlugin } from "./ZeppelinPlugin";
import * as t from "io-ts"; import * as t from "io-ts";
import { import {
convertDelayStringToMS, convertDelayStringToMS,
@ -7,8 +7,11 @@ import {
getRoleMentions, getRoleMentions,
getUrlsInString, getUrlsInString,
getUserMentions, getUserMentions,
messageSummary,
MINUTES, MINUTES,
noop, noop,
SECONDS,
stripObjectToScalars,
tNullable, tNullable,
} from "../utils"; } from "../utils";
import { decorators as d } from "knub"; import { decorators as d } from "knub";
@ -22,12 +25,19 @@ import { ModActionsPlugin } from "./ModActions";
import { MutesPlugin } from "./Mutes"; import { MutesPlugin } from "./Mutes";
import { LogsPlugin } from "./Logs"; import { LogsPlugin } from "./Logs";
import { LogType } from "../data/LogType"; 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 MessageInfo = { channelId: string; messageId: string };
type TextTriggerWithMultipleMatchTypes = { type TextTriggerWithMultipleMatchTypes = {
match_messages: boolean; match_messages: boolean;
match_embeds: boolean; match_embeds: boolean;
match_visible_names: boolean;
match_usernames: boolean; match_usernames: boolean;
match_nicknames: boolean; match_nicknames: boolean;
}; };
@ -44,7 +54,7 @@ interface MessageTextTriggerMatchResult extends TriggerMatchResult {
} }
interface OtherTextTriggerMatchResult extends TriggerMatchResult { interface OtherTextTriggerMatchResult extends TriggerMatchResult {
type: "username" | "nickname"; type: "username" | "nickname" | "visiblename";
str: string; str: string;
userId: string; userId: string;
} }
@ -89,6 +99,7 @@ const MatchWordsTrigger = t.type({
only_full_words: t.boolean, only_full_words: t.boolean,
match_messages: t.boolean, match_messages: t.boolean,
match_embeds: t.boolean, match_embeds: t.boolean,
match_visible_names: t.boolean,
match_usernames: t.boolean, match_usernames: t.boolean,
match_nicknames: t.boolean, match_nicknames: t.boolean,
}); });
@ -99,15 +110,17 @@ const defaultMatchWordsTrigger: TMatchWordsTrigger = {
only_full_words: true, only_full_words: true,
match_messages: true, match_messages: true,
match_embeds: true, match_embeds: true,
match_visible_names: false,
match_usernames: false, match_usernames: false,
match_nicknames: false, match_nicknames: false,
}; };
const MatchRegexTrigger = t.type({ const MatchRegexTrigger = t.type({
patterns: t.array(t.string), patterns: t.array(TSafeRegex),
case_sensitive: t.boolean, case_sensitive: t.boolean,
match_messages: t.boolean, match_messages: t.boolean,
match_embeds: t.boolean, match_embeds: t.boolean,
match_visible_names: t.boolean,
match_usernames: t.boolean, match_usernames: t.boolean,
match_nicknames: t.boolean, match_nicknames: t.boolean,
}); });
@ -116,6 +129,7 @@ const defaultMatchRegexTrigger: Partial<TMatchRegexTrigger> = {
case_sensitive: false, case_sensitive: false,
match_messages: true, match_messages: true,
match_embeds: true, match_embeds: true,
match_visible_names: false,
match_usernames: false, match_usernames: false,
match_nicknames: false, match_nicknames: false,
}; };
@ -128,6 +142,7 @@ const MatchInvitesTrigger = t.type({
allow_group_dm_invites: t.boolean, allow_group_dm_invites: t.boolean,
match_messages: t.boolean, match_messages: t.boolean,
match_embeds: t.boolean, match_embeds: t.boolean,
match_visible_names: t.boolean,
match_usernames: t.boolean, match_usernames: t.boolean,
match_nicknames: t.boolean, match_nicknames: t.boolean,
}); });
@ -136,6 +151,7 @@ const defaultMatchInvitesTrigger: Partial<TMatchInvitesTrigger> = {
allow_group_dm_invites: false, allow_group_dm_invites: false,
match_messages: true, match_messages: true,
match_embeds: true, match_embeds: true,
match_visible_names: false,
match_usernames: false, match_usernames: false,
match_nicknames: false, match_nicknames: false,
}; };
@ -146,6 +162,7 @@ const MatchLinksTrigger = t.type({
include_subdomains: t.boolean, include_subdomains: t.boolean,
match_messages: t.boolean, match_messages: t.boolean,
match_embeds: t.boolean, match_embeds: t.boolean,
match_visible_names: t.boolean,
match_usernames: t.boolean, match_usernames: t.boolean,
match_nicknames: t.boolean, match_nicknames: t.boolean,
}); });
@ -154,6 +171,7 @@ const defaultMatchLinksTrigger: Partial<TMatchLinksTrigger> = {
include_subdomains: true, include_subdomains: true,
match_messages: true, match_messages: true,
match_embeds: true, match_embeds: true,
match_visible_names: false,
match_usernames: false, match_usernames: false,
match_nicknames: false, match_nicknames: false,
}; };
@ -173,22 +191,22 @@ const defaultTextSpamTrigger: Partial<t.TypeOf<typeof BaseTextSpamTrigger>> = {
per_channel: true, per_channel: true,
}; };
const MaxMessagesTrigger = BaseTextSpamTrigger; const MessageSpamTrigger = BaseTextSpamTrigger;
type TMaxMessagesTrigger = t.TypeOf<typeof MaxMessagesTrigger>; type TMessageSpamTrigger = t.TypeOf<typeof MessageSpamTrigger>;
const MaxMentionsTrigger = BaseTextSpamTrigger; const MentionSpamTrigger = BaseTextSpamTrigger;
type TMaxMentionsTrigger = t.TypeOf<typeof MaxMentionsTrigger>; type TMentionSpamTrigger = t.TypeOf<typeof MentionSpamTrigger>;
const MaxLinksTrigger = BaseTextSpamTrigger; const LinkSpamTrigger = BaseTextSpamTrigger;
type TMaxLinksTrigger = t.TypeOf<typeof MaxLinksTrigger>; type TLinkSpamTrigger = t.TypeOf<typeof LinkSpamTrigger>;
const MaxAttachmentsTrigger = BaseTextSpamTrigger; const AttachmentSpamTrigger = BaseTextSpamTrigger;
type TMaxAttachmentsTrigger = t.TypeOf<typeof MaxAttachmentsTrigger>; type TAttachmentSpamTrigger = t.TypeOf<typeof AttachmentSpamTrigger>;
const MaxEmojisTrigger = BaseTextSpamTrigger; const EmojiSpamTrigger = BaseTextSpamTrigger;
type TMaxEmojisTrigger = t.TypeOf<typeof MaxEmojisTrigger>; type TEmojiSpamTrigger = t.TypeOf<typeof EmojiSpamTrigger>;
const MaxLinesTrigger = BaseTextSpamTrigger; const LineSpamTrigger = BaseTextSpamTrigger;
type TMaxLinesTrigger = t.TypeOf<typeof MaxLinesTrigger>; type TLineSpamTrigger = t.TypeOf<typeof LineSpamTrigger>;
const MaxCharactersTrigger = BaseTextSpamTrigger; const CharacterSpamTrigger = BaseTextSpamTrigger;
type TMaxCharactersTrigger = t.TypeOf<typeof MaxCharactersTrigger>; type TCharacterSpamTrigger = t.TypeOf<typeof CharacterSpamTrigger>;
const MaxVoiceMovesTrigger = BaseSpamTrigger; const VoiceMoveSpamTrigger = BaseSpamTrigger;
type TMaxVoiceMovesTrigger = t.TypeOf<typeof MaxVoiceMovesTrigger>; type TVoiceMoveSpamTrigger = t.TypeOf<typeof VoiceMoveSpamTrigger>;
/** /**
* ACTIONS * ACTIONS
@ -217,6 +235,10 @@ const AlertAction = t.type({
text: t.string, text: t.string,
}); });
const ChangeNicknameAction = t.type({
name: t.string,
});
/** /**
* FULL CONFIG SCHEMA * FULL CONFIG SCHEMA
*/ */
@ -231,14 +253,14 @@ const Rule = t.type({
match_regex: tNullable(MatchRegexTrigger), match_regex: tNullable(MatchRegexTrigger),
match_invites: tNullable(MatchInvitesTrigger), match_invites: tNullable(MatchInvitesTrigger),
match_links: tNullable(MatchLinksTrigger), match_links: tNullable(MatchLinksTrigger),
max_messages: tNullable(MaxMessagesTrigger), message_spam: tNullable(MessageSpamTrigger),
max_mentions: tNullable(MaxMentionsTrigger), mention_spam: tNullable(MentionSpamTrigger),
max_links: tNullable(MaxLinksTrigger), link_spam: tNullable(LinkSpamTrigger),
max_attachments: tNullable(MaxAttachmentsTrigger), attachment_spam: tNullable(AttachmentSpamTrigger),
max_emojis: tNullable(MaxEmojisTrigger), emoji_spam: tNullable(EmojiSpamTrigger),
max_lines: tNullable(MaxLinesTrigger), line_spam: tNullable(LineSpamTrigger),
max_characters: tNullable(MaxCharactersTrigger), character_spam: tNullable(CharacterSpamTrigger),
max_voice_moves: tNullable(MaxVoiceMovesTrigger), // voice_move_spam: tNullable(VoiceMoveSpamTrigger), // TODO
// TODO: Duplicates trigger // TODO: Duplicates trigger
}), }),
), ),
@ -249,6 +271,7 @@ const Rule = t.type({
kick: tNullable(KickAction), kick: tNullable(KickAction),
ban: tNullable(BanAction), ban: tNullable(BanAction),
alert: tNullable(AlertAction), alert: tNullable(AlertAction),
change_nickname: tNullable(ChangeNicknameAction),
}), }),
}); });
type TRule = t.TypeOf<typeof Rule>; type TRule = t.TypeOf<typeof Rule>;
@ -264,6 +287,16 @@ type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
const defaultTriggers = { const defaultTriggers = {
match_words: defaultMatchWordsTrigger, 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: 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); const inviteCache = new SimpleCache(10 * MINUTES);
@ -314,12 +350,95 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
public static configSchema = ConfigSchema; public static configSchema = ConfigSchema;
public static dependencies = ["mod_actions", "mutes", "logs"]; 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; protected unloaded = false;
// Handle automod checks/actions in a queue so we don't get overlap on the same user // Handle automod checks/actions in a queue so we don't get overlap on the same user
protected automodQueue: Queue; 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 recentActions: RecentAction[];
protected recentActionClearInterval: Timeout; protected recentActionClearInterval: Timeout;
@ -327,13 +446,24 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
// During this grace period, if the user repeats the same type of recent action that tripped the rule, that message will // 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 // 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. // 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<string, { expiresAt: number; deletedMessages: string[] }>; // Key = identifier-actionType
protected spamGracePriodClearInterval: Timeout; protected spamGracePriodClearInterval: Timeout;
protected recentlyDeletedMessages: string[];
protected recentNicknameChanges: Map<string, { expiresAt: number }>;
protected recentNicknameChangesClearInterval: Timeout;
protected onMessageCreateFn;
protected modActions: ModActionsPlugin; protected modActions: ModActionsPlugin;
protected mutes: MutesPlugin; protected mutes: MutesPlugin;
protected logs: LogsPlugin; protected logs: LogsPlugin;
protected savedMessages: GuildSavedMessages;
protected archives: GuildArchives;
protected guildLogs: GuildLogs;
protected static preprocessStaticConfig(config) { protected static preprocessStaticConfig(config) {
if (config.rules && typeof config.rules === "object") { if (config.rules && typeof config.rules === "object") {
// Loop through each rule // Loop through each rule
@ -351,7 +481,7 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
if (rule["triggers"] != null && Array.isArray(rule["triggers"])) { if (rule["triggers"] != null && Array.isArray(rule["triggers"])) {
for (const trigger of rule["triggers"]) { for (const trigger of rule["triggers"]) {
if (trigger == null || typeof trigger !== "object") continue; 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)) { for (const [defaultTriggerName, defaultTrigger] of Object.entries(defaultTriggers)) {
if (trigger[defaultTriggerName] && typeof trigger[defaultTriggerName] === "object") { if (trigger[defaultTriggerName] && typeof trigger[defaultTriggerName] === "object") {
trigger[defaultTriggerName] = mergeConfig({}, defaultTrigger, trigger[defaultTriggerName]); trigger[defaultTriggerName] = mergeConfig({}, defaultTrigger, trigger[defaultTriggerName]);
@ -373,31 +503,60 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
protected onLoad() { protected onLoad() {
this.automodQueue = new Queue(); 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() { protected onUnload() {
this.unloaded = true; this.unloaded = true;
this.savedMessages.events.off("create", this.onMessageCreateFn);
clearInterval(this.recentActionClearInterval); clearInterval(this.recentActionClearInterval);
clearInterval(this.spamGracePriodClearInterval); clearInterval(this.spamGracePriodClearInterval);
} }
protected evaluateMatchWordsTrigger(trigger: TMatchWordsTrigger, str: string): boolean { protected evaluateMatchWordsTrigger(trigger: TMatchWordsTrigger, str: string): boolean {
for (const word of trigger.words) { 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"); 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 { protected evaluateMatchRegexTrigger(trigger: TMatchRegexTrigger, str: string): boolean {
// TODO: Time limit regexes // TODO: Time limit regexes
for (const pattern of trigger.patterns) { for (const pattern of trigger.patterns) {
const regex = new RegExp(pattern, trigger.case_sensitive ? "" : "i"); 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<boolean> { protected async evaluateMatchInvitesTrigger(trigger: TMatchInvitesTrigger, str: string): Promise<boolean> {
@ -478,19 +637,23 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
protected matchTextSpamTrigger( protected matchTextSpamTrigger(
recentActionType: RecentActionType, recentActionType: RecentActionType,
trigger: TBaseTextSpamTrigger, trigger: TBaseTextSpamTrigger,
msg: Message, msg: SavedMessage,
): TextSpamTriggerMatchResult { ): TextSpamTriggerMatchResult {
const since = msg.timestamp - convertDelayStringToMS(trigger.within); const since = moment.utc(msg.posted_at).valueOf() - convertDelayStringToMS(trigger.within);
const recentActions = trigger.per_channel const recentActions = trigger.per_channel
? this.getMatchingRecentActions(recentActionType, `${msg.channel.id}-${msg.author.id}`, since) ? this.getMatchingRecentActions(recentActionType, `${msg.channel_id}-${msg.user_id}`, since)
: this.getMatchingRecentActions(recentActionType, msg.author.id, since); : this.getMatchingRecentActions(recentActionType, msg.user_id, since);
if (recentActions.length > trigger.amount) { const totalCount = recentActions.reduce((total, action) => {
return total + action.count;
}, 0);
if (totalCount >= trigger.amount) {
return { return {
type: "textspam", type: "textspam",
actionType: recentActionType, 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), 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<TConfigSchema> {
protected async matchMultipleTextTypesOnMessage( protected async matchMultipleTextTypesOnMessage(
trigger: TextTriggerWithMultipleMatchTypes, trigger: TextTriggerWithMultipleMatchTypes,
msg: Message, msg: SavedMessage,
cb, cb,
): Promise<TextTriggerMatchResult> { ): Promise<TextTriggerMatchResult> {
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) { if (trigger.match_messages) {
const str = msg.content; const str = msg.data.content;
const match = await cb(str); 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) { if (trigger.match_embeds && msg.data.embeds && msg.data.embeds.length) {
const str = JSON.stringify(msg.embeds[0]); const str = JSON.stringify(msg.data.embeds[0]);
const match = await cb(str); 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) { 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); 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) { if (trigger.match_nicknames && member.nick) {
const str = msg.member.nick; const str = member.nick;
const match = await cb(str); 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; return null;
@ -556,8 +726,10 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
*/ */
protected async matchRuleToMessage( protected async matchRuleToMessage(
rule: TRule, rule: TRule,
msg: Message, msg: SavedMessage,
): Promise<TextTriggerMatchResult | TextSpamTriggerMatchResult> { ): Promise<TextTriggerMatchResult | TextSpamTriggerMatchResult> {
if (!rule.enabled) return;
for (const trigger of rule.triggers) { for (const trigger of rule.triggers) {
if (trigger.match_words) { if (trigger.match_words) {
const match = await this.matchMultipleTextTypesOnMessage(trigger.match_words, msg, str => { const match = await this.matchMultipleTextTypesOnMessage(trigger.match_words, msg, str => {
@ -587,38 +759,38 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
if (match) return match; if (match) return match;
} }
if (trigger.max_messages) { if (trigger.message_spam) {
const match = this.matchTextSpamTrigger(RecentActionType.Message, trigger.max_messages, msg); const match = this.matchTextSpamTrigger(RecentActionType.Message, trigger.message_spam, msg);
if (match) return match; if (match) return match;
} }
if (trigger.max_mentions) { if (trigger.mention_spam) {
const match = this.matchTextSpamTrigger(RecentActionType.Mention, trigger.max_mentions, msg); const match = this.matchTextSpamTrigger(RecentActionType.Mention, trigger.mention_spam, msg);
if (match) return match; if (match) return match;
} }
if (trigger.max_links) { if (trigger.link_spam) {
const match = this.matchTextSpamTrigger(RecentActionType.Link, trigger.max_links, msg); const match = this.matchTextSpamTrigger(RecentActionType.Link, trigger.link_spam, msg);
if (match) return match; if (match) return match;
} }
if (trigger.max_attachments) { if (trigger.attachment_spam) {
const match = this.matchTextSpamTrigger(RecentActionType.Attachment, trigger.max_attachments, msg); const match = this.matchTextSpamTrigger(RecentActionType.Attachment, trigger.attachment_spam, msg);
if (match) return match; if (match) return match;
} }
if (trigger.max_emojis) { if (trigger.emoji_spam) {
const match = this.matchTextSpamTrigger(RecentActionType.Emoji, trigger.max_emojis, msg); const match = this.matchTextSpamTrigger(RecentActionType.Emoji, trigger.emoji_spam, msg);
if (match) return match; if (match) return match;
} }
if (trigger.max_lines) { if (trigger.line_spam) {
const match = this.matchTextSpamTrigger(RecentActionType.Line, trigger.max_lines, msg); const match = this.matchTextSpamTrigger(RecentActionType.Line, trigger.line_spam, msg);
if (match) return match; if (match) return match;
} }
if (trigger.max_characters) { if (trigger.character_spam) {
const match = this.matchTextSpamTrigger(RecentActionType.Character, trigger.max_characters, msg); const match = this.matchTextSpamTrigger(RecentActionType.Character, trigger.character_spam, msg);
if (match) return match; if (match) return match;
} }
} }
@ -626,23 +798,45 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
return null; 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 * Logs recent actions for spam detection purposes
*/ */
protected async logRecentActionsForMessage(msg: Message) { protected async logRecentActionsForMessage(msg: SavedMessage) {
const timestamp = msg.timestamp; const timestamp = moment.utc(msg.posted_at).valueOf();
const globalIdentifier = msg.author.id; const globalIdentifier = msg.user_id;
const perChannelIdentifier = `${msg.channel.id}-${msg.author.id}`; const perChannelIdentifier = `${msg.channel_id}-${msg.user_id}`;
const messageInfo: MessageInfo = { channelId: msg.channel.id, messageId: msg.id }; const messageInfo: MessageInfo = { channelId: msg.channel_id, messageId: msg.id };
this.recentActions.push({ this.addRecentMessageAction({
type: RecentActionType.Message, type: RecentActionType.Message,
identifier: globalIdentifier, identifier: globalIdentifier,
timestamp, timestamp,
count: 1, count: 1,
messageInfo, messageInfo,
}); });
this.recentActions.push({ this.addRecentMessageAction({
type: RecentActionType.Message, type: RecentActionType.Message,
identifier: perChannelIdentifier, identifier: perChannelIdentifier,
timestamp, timestamp,
@ -650,16 +844,17 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
messageInfo, 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) { if (mentionCount) {
this.recentActions.push({ this.addRecentMessageAction({
type: RecentActionType.Mention, type: RecentActionType.Mention,
identifier: globalIdentifier, identifier: globalIdentifier,
timestamp, timestamp,
count: mentionCount, count: mentionCount,
messageInfo, messageInfo,
}); });
this.recentActions.push({ this.addRecentMessageAction({
type: RecentActionType.Mention, type: RecentActionType.Mention,
identifier: perChannelIdentifier, identifier: perChannelIdentifier,
timestamp, timestamp,
@ -668,16 +863,16 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
}); });
} }
const linkCount = getUrlsInString(msg.content || "").length; const linkCount = getUrlsInString(msg.data.content || "").length;
if (linkCount) { if (linkCount) {
this.recentActions.push({ this.addRecentMessageAction({
type: RecentActionType.Link, type: RecentActionType.Link,
identifier: globalIdentifier, identifier: globalIdentifier,
timestamp, timestamp,
count: linkCount, count: linkCount,
messageInfo, messageInfo,
}); });
this.recentActions.push({ this.addRecentMessageAction({
type: RecentActionType.Link, type: RecentActionType.Link,
identifier: perChannelIdentifier, identifier: perChannelIdentifier,
timestamp, timestamp,
@ -686,16 +881,16 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
}); });
} }
const attachmentCount = msg.attachments.length; const attachmentCount = msg.data.attachments && msg.data.attachments.length;
if (attachmentCount) { if (attachmentCount) {
this.recentActions.push({ this.addRecentMessageAction({
type: RecentActionType.Attachment, type: RecentActionType.Attachment,
identifier: globalIdentifier, identifier: globalIdentifier,
timestamp, timestamp,
count: attachmentCount, count: attachmentCount,
messageInfo, messageInfo,
}); });
this.recentActions.push({ this.addRecentMessageAction({
type: RecentActionType.Attachment, type: RecentActionType.Attachment,
identifier: perChannelIdentifier, identifier: perChannelIdentifier,
timestamp, timestamp,
@ -704,16 +899,16 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
}); });
} }
const emojiCount = getEmojiInString(msg.content || "").length; const emojiCount = getEmojiInString(msg.data.content || "").length;
if (emojiCount) { if (emojiCount) {
this.recentActions.push({ this.addRecentMessageAction({
type: RecentActionType.Emoji, type: RecentActionType.Emoji,
identifier: globalIdentifier, identifier: globalIdentifier,
timestamp, timestamp,
count: emojiCount, count: emojiCount,
messageInfo, messageInfo,
}); });
this.recentActions.push({ this.addRecentMessageAction({
type: RecentActionType.Emoji, type: RecentActionType.Emoji,
identifier: perChannelIdentifier, identifier: perChannelIdentifier,
timestamp, timestamp,
@ -723,16 +918,16 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
} }
// + 1 is for the first line of the message (which doesn't have a line break) // + 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) { if (lineCount) {
this.recentActions.push({ this.addRecentMessageAction({
type: RecentActionType.Line, type: RecentActionType.Line,
identifier: globalIdentifier, identifier: globalIdentifier,
timestamp, timestamp,
count: lineCount, count: lineCount,
messageInfo, messageInfo,
}); });
this.recentActions.push({ this.addRecentMessageAction({
type: RecentActionType.Line, type: RecentActionType.Line,
identifier: perChannelIdentifier, identifier: perChannelIdentifier,
timestamp, timestamp,
@ -741,16 +936,16 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
}); });
} }
const characterCount = [...(msg.content || "")].length; const characterCount = [...(msg.data.content || "")].length;
if (characterCount) { if (characterCount) {
this.recentActions.push({ this.addRecentMessageAction({
type: RecentActionType.Character, type: RecentActionType.Character,
identifier: globalIdentifier, identifier: globalIdentifier,
timestamp, timestamp,
count: characterCount, count: characterCount,
messageInfo, messageInfo,
}); });
this.recentActions.push({ this.addRecentMessageAction({
type: RecentActionType.Character, type: RecentActionType.Character,
identifier: perChannelIdentifier, identifier: perChannelIdentifier,
timestamp, timestamp,
@ -766,37 +961,132 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
}); });
} }
protected async applyActionsOnMatch(rule: TRule, matchResult: AnyTriggerMatchResult) { protected async activateGracePeriod(matchResult: TextSpamTriggerMatchResult) {
if (rule.actions.clean) { const expiresAt = Date.now() + SPAM_GRACE_PERIOD_LENGTH;
if (matchResult.type === "message" || matchResult.type === "embed") {
await this.bot.deleteMessage(matchResult.messageInfo.channelId, matchResult.messageInfo.messageId).catch(noop); // Global identifier
} else if (matchResult.type === "textspam" || matchResult.type === "raidspam") { this.spamGracePeriods.set(`${matchResult.userId}-${matchResult.actionType}`, { expiresAt, deletedMessages: [] });
for (const { channelId, messageId } of matchResult.messageInfos) { // Per-channel identifier
await this.bot.deleteMessage(channelId, messageId).catch(noop); 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) { if (rule.actions.warn) {
const reason = rule.actions.mute.reason || "Warned automatically"; const reason = rule.actions.warn.reason || "Warned automatically";
const caseArgs = { const caseArgs = {
modId: this.bot.user.id, modId: this.bot.user.id,
extraNotes: [`Matched automod rule "${rule.name}"`], extraNotes: [caseExtraNote],
}; };
if (matchResult.type === "message" || matchResult.type === "embed" || matchResult.type === "textspam") { if (matchResult.type === "message" || matchResult.type === "embed" || matchResult.type === "textspam") {
const member = await this.getMember(matchResult.userId); const member = await this.getMember(matchResult.userId);
if (member) { if (member) {
await this.modActions.warnMember(member, reason, caseArgs); await this.getModActions().warnMember(member, reason, caseArgs);
} }
} else if (matchResult.type === "raidspam") { } else if (matchResult.type === "raidspam") {
for (const userId of matchResult.userIds) { for (const userId of matchResult.userIds) {
const member = await this.getMember(userId); const member = await this.getMember(userId);
if (member) { if (member) {
await this.modActions.warnMember(member, reason, caseArgs); await this.getModActions().warnMember(member, reason, caseArgs);
} }
} }
} }
actionsTaken.push("warn");
} }
if (rule.actions.mute) { if (rule.actions.mute) {
@ -804,7 +1094,7 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
const reason = rule.actions.mute.reason || "Muted automatically"; const reason = rule.actions.mute.reason || "Muted automatically";
const caseArgs = { const caseArgs = {
modId: this.bot.user.id, modId: this.bot.user.id,
extraNotes: [`Matched automod rule "${rule.name}"`], extraNotes: [caseExtraNote],
}; };
if (matchResult.type === "message" || matchResult.type === "embed" || matchResult.type === "textspam") { if (matchResult.type === "message" || matchResult.type === "embed" || matchResult.type === "textspam") {
@ -814,60 +1104,115 @@ export class AutomodPlugin extends ZeppelinPlugin<TConfigSchema> {
await this.mutes.muteUser(userId, duration, reason, caseArgs); await this.mutes.muteUser(userId, duration, reason, caseArgs);
} }
} }
actionsTaken.push("mute");
} }
if (rule.actions.kick) { if (rule.actions.kick) {
const reason = rule.actions.kick.reason || "Kicked automatically"; const reason = rule.actions.kick.reason || "Kicked automatically";
const caseArgs = { const caseArgs = {
modId: this.bot.user.id, modId: this.bot.user.id,
extraNotes: [`Matched automod rule "${rule.name}"`], extraNotes: [caseExtraNote],
}; };
if (matchResult.type === "message" || matchResult.type === "embed" || matchResult.type === "textspam") { if (matchResult.type === "message" || matchResult.type === "embed" || matchResult.type === "textspam") {
const member = await this.getMember(matchResult.userId); const member = await this.getMember(matchResult.userId);
if (member) { if (member) {
await this.modActions.kickMember(member, reason, caseArgs); await this.getModActions().kickMember(member, reason, caseArgs);
} }
} else if (matchResult.type === "raidspam") { } else if (matchResult.type === "raidspam") {
for (const userId of matchResult.userIds) { for (const userId of matchResult.userIds) {
const member = await this.getMember(userId); const member = await this.getMember(userId);
if (member) { if (member) {
await this.modActions.kickMember(member, reason, caseArgs); await this.getModActions().kickMember(member, reason, caseArgs);
} }
} }
} }
actionsTaken.push("kick");
} }
if (rule.actions.ban) { if (rule.actions.ban) {
const reason = rule.actions.ban.reason || "Banned automatically"; const reason = rule.actions.ban.reason || "Banned automatically";
const caseArgs = { const caseArgs = {
modId: this.bot.user.id, modId: this.bot.user.id,
extraNotes: [`Matched automod rule "${rule.name}"`], extraNotes: [caseExtraNote],
}; };
if (matchResult.type === "message" || matchResult.type === "embed" || matchResult.type === "textspam") { 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") { } else if (matchResult.type === "raidspam") {
for (const userId of matchResult.userIds) { 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) { if (rule.actions.change_nickname) {
const text = rule.actions.alert.text; const userIdsToChange =
this.logs.log(LogType.AUTOMOD_ALERT, { text }); 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(", ") : "<none>",
matchSummary,
});
}
} }
} }
@d.event("messageCreate") protected onMessageCreate(msg: SavedMessage) {
protected onMessageCreate(msg: Message) { if (msg.is_bot) return;
this.automodQueue.add(async () => { this.automodQueue.add(async () => {
if (this.unloaded) return; if (this.unloaded) return;
await this.logRecentActionsForMessage(msg); 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)) { for (const [name, rule] of Object.entries(config.rules)) {
const matchResult = await this.matchRuleToMessage(rule, msg); const matchResult = await this.matchRuleToMessage(rule, msg);
if (matchResult) { if (matchResult) {

View file

@ -8,6 +8,7 @@ import {
disableCodeBlocks, disableCodeBlocks,
disableLinkPreviews, disableLinkPreviews,
findRelevantAuditLogEntry, findRelevantAuditLogEntry,
messageSummary,
noop, noop,
stripObjectToScalars, stripObjectToScalars,
UnknownUser, UnknownUser,
@ -205,22 +206,8 @@ export class LogsPlugin extends ZeppelinPlugin<TConfigSchema> {
return `<#${channel.id}> (**#${channel.name}**, \`${channel.id}\`)`; return `<#${channel.id}> (**#${channel.name}**, \`${channel.id}\`)`;
}, },
messageSummary: (msg: SavedMessage) => { messageSummary: (msg: SavedMessage) => {
// Regular text content if (!msg) return "";
let result = "```" + (msg.data.content ? disableCodeBlocks(msg.data.content) : "<no text content>") + "```"; return messageSummary(msg);
// 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;
}, },
}; };

View file

@ -1,5 +1,7 @@
import { import {
Attachment,
Client, Client,
Embed,
EmbedOptions, EmbedOptions,
Emoji, Emoji,
Guild, Guild,
@ -23,6 +25,7 @@ const fsp = fs.promises;
import https from "https"; import https from "https";
import tmp from "tmp"; import tmp from "tmp";
import { logger, waitForReaction } from "knub"; import { logger, waitForReaction } from "knub";
import { SavedMessage } from "./data/entities/SavedMessage";
const delayStringMultipliers = { const delayStringMultipliers = {
w: 1000 * 60 * 60 * 24 * 7, w: 1000 * 60 * 60 * 24 * 7,
@ -772,3 +775,20 @@ export async function confirm(bot: Client, channel: TextableChannel, userId: str
msg.delete().catch(noop); msg.delete().catch(noop);
return reply && reply.name === "✅"; return reply && reply.name === "✅";
} }
export function messageSummary(msg: SavedMessage) {
// Regular text content
let result = "```" + (msg.data.content ? disableCodeBlocks(msg.data.content) : "<no text 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;
}