Add anti-raid levels to automod. Large refactor of spam detection. Add member_join and member_join_spam triggers.
Anti-raid levels don't by themselves do anything, but they can be used in overrides to activate specific automod items. Spam detection should now be more reliable and also combine further spam messages after the initial detection into the archive. Messages deleted by automod no longer create the normal deletion log entry. Instead, the AUTOMOD_ACTION log entry contains the deleted message or an archive if there are multiple (i.e. spam).
This commit is contained in:
parent
dc27821a63
commit
84135b201b
14 changed files with 1179 additions and 550 deletions
|
@ -61,5 +61,8 @@
|
|||
"POSTED_SCHEDULED_MESSAGE": "\uD83D\uDCE8 Posted scheduled message (`{messageId}`) to {channelMention(channel)} as scheduled by {userMention(author)}",
|
||||
|
||||
"BOT_ALERT": "⚠ {tmplEval(body)}",
|
||||
"AUTOMOD_ACTION": "\uD83E\uDD16 Automod rule **{rule}** triggered by {userMention(user)}\n{matchSummary}\nActions taken: **{actionsTaken}**"
|
||||
"AUTOMOD_ACTION": "\uD83E\uDD16 Automod rule **{rule}** triggered by {userMention(users)}\n{matchSummary}\nActions taken: **{actionsTaken}**",
|
||||
|
||||
"SET_ANTIRAID_USER": "⚔ {userMention(user)} set anti-raid to **{level}**",
|
||||
"SET_ANTIRAID_AUTO": "⚔ Anti-raid automatically set to **{level}**"
|
||||
}
|
||||
|
|
42
backend/src/data/GuildAntiraidLevels.ts
Normal file
42
backend/src/data/GuildAntiraidLevels.ts
Normal file
|
@ -0,0 +1,42 @@
|
|||
import { BaseGuildRepository } from "./BaseGuildRepository";
|
||||
import { getRepository, Repository } from "typeorm";
|
||||
import { AntiraidLevel } from "./entities/AntiraidLevel";
|
||||
|
||||
export class GuildAntiraidLevels extends BaseGuildRepository {
|
||||
protected antiraidLevels: Repository<AntiraidLevel>;
|
||||
|
||||
constructor(guildId: string) {
|
||||
super(guildId);
|
||||
this.antiraidLevels = getRepository(AntiraidLevel);
|
||||
}
|
||||
|
||||
async get() {
|
||||
const row = await this.antiraidLevels.findOne({
|
||||
where: {
|
||||
guild_id: this.guildId,
|
||||
},
|
||||
});
|
||||
|
||||
return row?.level ?? null;
|
||||
}
|
||||
|
||||
async set(level: string | null) {
|
||||
if (level === null) {
|
||||
await this.antiraidLevels.delete({
|
||||
guild_id: this.guildId,
|
||||
});
|
||||
} else {
|
||||
// Upsert: https://stackoverflow.com/a/47064558/316944
|
||||
await this.antiraidLevels
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.values({
|
||||
guild_id: this.guildId,
|
||||
level,
|
||||
})
|
||||
.onConflict('("guild_id") DO UPDATE SET "guild_id" = :guildId')
|
||||
.setParameter("guildId", this.guildId)
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -71,10 +71,7 @@ export class GuildArchives extends BaseGuildRepository {
|
|||
return result.identifiers[0].id;
|
||||
}
|
||||
|
||||
async createFromSavedMessages(savedMessages: SavedMessage[], guild: Guild, expiresAt = null) {
|
||||
if (expiresAt == null) expiresAt = moment().add(DEFAULT_EXPIRY_DAYS, "days");
|
||||
|
||||
const headerStr = await renderTemplate(MESSAGE_ARCHIVE_HEADER_FORMAT, { guild });
|
||||
protected async renderLinesFromSavedMessages(savedMessages: SavedMessage[], guild: Guild) {
|
||||
const msgLines = [];
|
||||
for (const msg of savedMessages) {
|
||||
const channel = guild.channels.get(msg.channel_id);
|
||||
|
@ -89,11 +86,29 @@ export class GuildArchives extends BaseGuildRepository {
|
|||
});
|
||||
msgLines.push(line);
|
||||
}
|
||||
return msgLines;
|
||||
}
|
||||
|
||||
async createFromSavedMessages(savedMessages: SavedMessage[], guild: Guild, expiresAt = null) {
|
||||
if (expiresAt == null) expiresAt = moment().add(DEFAULT_EXPIRY_DAYS, "days");
|
||||
|
||||
const headerStr = await renderTemplate(MESSAGE_ARCHIVE_HEADER_FORMAT, { guild });
|
||||
const msgLines = await this.renderLinesFromSavedMessages(savedMessages, guild);
|
||||
const messagesStr = msgLines.join("\n");
|
||||
|
||||
return this.create([headerStr, messagesStr].join("\n\n"), expiresAt);
|
||||
}
|
||||
|
||||
async addSavedMessagesToArchive(archiveId: string, savedMessages: SavedMessage[], guild: Guild) {
|
||||
const msgLines = await this.renderLinesFromSavedMessages(savedMessages, guild);
|
||||
const messagesStr = msgLines.join("\n");
|
||||
|
||||
const archive = await this.find(archiveId);
|
||||
archive.body += "\n" + messagesStr;
|
||||
|
||||
await this.archives.update({ id: archiveId }, { body: archive.body });
|
||||
}
|
||||
|
||||
getUrl(baseUrl, archiveId) {
|
||||
return baseUrl ? `${baseUrl}/archives/${archiveId}` : `Archive ID: ${archiveId}`;
|
||||
}
|
||||
|
|
|
@ -64,4 +64,9 @@ export enum LogType {
|
|||
REPEATED_MESSAGE,
|
||||
|
||||
MESSAGE_DELETE_AUTO,
|
||||
|
||||
SET_ANTIRAID_USER,
|
||||
SET_ANTIRAID_AUTO,
|
||||
|
||||
AUTOMOD_SPAM_NEW,
|
||||
}
|
||||
|
|
11
backend/src/data/entities/AntiraidLevel.ts
Normal file
11
backend/src/data/entities/AntiraidLevel.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
import { Entity, Column, PrimaryColumn } from "typeorm";
|
||||
|
||||
@Entity("antiraid_levels")
|
||||
export class AntiraidLevel {
|
||||
@Column()
|
||||
@PrimaryColumn()
|
||||
guild_id: string;
|
||||
|
||||
@Column()
|
||||
level: string;
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
import { MigrationInterface, QueryRunner, Table } from "typeorm";
|
||||
|
||||
export class CreateAntiraidLevelsTable1580038836906 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: "antiraid_levels",
|
||||
columns: [
|
||||
{
|
||||
name: "guild_id",
|
||||
type: "bigint",
|
||||
unsigned: true,
|
||||
isPrimary: true,
|
||||
},
|
||||
{
|
||||
name: "level",
|
||||
type: "varchar",
|
||||
length: "64",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.dropTable("antiraid_levels");
|
||||
}
|
||||
}
|
159
backend/src/plugins/AntiRaid.ts
Normal file
159
backend/src/plugins/AntiRaid.ts
Normal file
|
@ -0,0 +1,159 @@
|
|||
import { IPluginOptions, logger } from "knub";
|
||||
import * as t from "io-ts";
|
||||
import { ZeppelinPlugin } from "./ZeppelinPlugin";
|
||||
import { GuildSavedMessages } from "../data/GuildSavedMessages";
|
||||
import { SavedMessage } from "../data/entities/SavedMessage";
|
||||
import { convertDelayStringToMS, MINUTES, sorter, stripObjectToScalars, tDelayString } from "../utils";
|
||||
import { GuildLogs } from "../data/GuildLogs";
|
||||
import { LogType } from "../data/LogType";
|
||||
import moment from "moment-timezone";
|
||||
|
||||
const AntiRaidLevel = t.type({
|
||||
on_join: t.type({
|
||||
kick: t.boolean,
|
||||
ban: t.boolean,
|
||||
}),
|
||||
});
|
||||
|
||||
const ConfigSchema = t.type({
|
||||
enabled: t.boolean,
|
||||
delay: tDelayString,
|
||||
});
|
||||
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
||||
|
||||
interface IDeletionQueueItem {
|
||||
deleteAt: number;
|
||||
message: SavedMessage;
|
||||
}
|
||||
|
||||
const MAX_DELAY = 5 * MINUTES;
|
||||
|
||||
export class AntiRaid extends ZeppelinPlugin<TConfigSchema> {
|
||||
public static pluginName = "auto_delete";
|
||||
public static showInDocs = true;
|
||||
|
||||
public static configSchema = ConfigSchema;
|
||||
|
||||
public static pluginInfo = {
|
||||
prettyName: "Auto-delete",
|
||||
description: "Allows Zeppelin to auto-delete messages from a channel after a delay",
|
||||
configurationGuide: "Maximum deletion delay is currently 5 minutes",
|
||||
};
|
||||
|
||||
protected guildSavedMessages: GuildSavedMessages;
|
||||
protected guildLogs: GuildLogs;
|
||||
|
||||
protected onMessageCreateFn;
|
||||
protected onMessageDeleteFn;
|
||||
protected onMessageDeleteBulkFn;
|
||||
|
||||
protected deletionQueue: IDeletionQueueItem[];
|
||||
protected nextDeletion: number;
|
||||
protected nextDeletionTimeout;
|
||||
|
||||
protected maxDelayWarningSent = false;
|
||||
|
||||
public static getStaticDefaultOptions(): IPluginOptions<TConfigSchema> {
|
||||
return {
|
||||
config: {
|
||||
enabled: false,
|
||||
delay: "5s",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
protected onLoad() {
|
||||
this.guildSavedMessages = GuildSavedMessages.getGuildInstance(this.guildId);
|
||||
this.guildLogs = new GuildLogs(this.guildId);
|
||||
|
||||
this.deletionQueue = [];
|
||||
|
||||
this.onMessageCreateFn = this.onMessageCreate.bind(this);
|
||||
this.onMessageDeleteFn = this.onMessageDelete.bind(this);
|
||||
this.onMessageDeleteBulkFn = this.onMessageDeleteBulk.bind(this);
|
||||
|
||||
this.guildSavedMessages.events.on("create", this.onMessageCreateFn);
|
||||
this.guildSavedMessages.events.on("delete", this.onMessageDeleteFn);
|
||||
this.guildSavedMessages.events.on("deleteBulk", this.onMessageDeleteBulkFn);
|
||||
}
|
||||
|
||||
protected onUnload() {
|
||||
this.guildSavedMessages.events.off("create", this.onMessageCreateFn);
|
||||
this.guildSavedMessages.events.off("delete", this.onMessageDeleteFn);
|
||||
this.guildSavedMessages.events.off("deleteBulk", this.onMessageDeleteFn);
|
||||
clearTimeout(this.nextDeletionTimeout);
|
||||
}
|
||||
|
||||
protected addMessageToDeletionQueue(msg: SavedMessage, delay: number) {
|
||||
const deleteAt = Date.now() + delay;
|
||||
this.deletionQueue.push({ deleteAt, message: msg });
|
||||
this.deletionQueue.sort(sorter("deleteAt"));
|
||||
|
||||
this.scheduleNextDeletion();
|
||||
}
|
||||
|
||||
protected scheduleNextDeletion() {
|
||||
if (this.deletionQueue.length === 0) {
|
||||
clearTimeout(this.nextDeletionTimeout);
|
||||
return;
|
||||
}
|
||||
|
||||
const firstDeleteAt = this.deletionQueue[0].deleteAt;
|
||||
clearTimeout(this.nextDeletionTimeout);
|
||||
this.nextDeletionTimeout = setTimeout(() => this.deleteNextItem(), firstDeleteAt - Date.now());
|
||||
}
|
||||
|
||||
protected async deleteNextItem() {
|
||||
const [itemToDelete] = this.deletionQueue.splice(0, 1);
|
||||
if (!itemToDelete) return;
|
||||
|
||||
this.guildLogs.ignoreLog(LogType.MESSAGE_DELETE, itemToDelete.message.id);
|
||||
this.bot.deleteMessage(itemToDelete.message.channel_id, itemToDelete.message.id).catch(logger.warn);
|
||||
|
||||
this.scheduleNextDeletion();
|
||||
|
||||
const user = await this.resolveUser(itemToDelete.message.user_id);
|
||||
const channel = this.guild.channels.get(itemToDelete.message.channel_id);
|
||||
const messageDate = moment(itemToDelete.message.data.timestamp, "x").format("YYYY-MM-DD HH:mm:ss");
|
||||
|
||||
this.guildLogs.log(LogType.MESSAGE_DELETE_AUTO, {
|
||||
message: itemToDelete.message,
|
||||
user: stripObjectToScalars(user),
|
||||
channel: stripObjectToScalars(channel),
|
||||
messageDate,
|
||||
});
|
||||
}
|
||||
|
||||
protected onMessageCreate(msg: SavedMessage) {
|
||||
const config = this.getConfigForMemberIdAndChannelId(msg.user_id, msg.channel_id);
|
||||
if (config.enabled) {
|
||||
let delay = convertDelayStringToMS(config.delay);
|
||||
|
||||
if (delay > MAX_DELAY) {
|
||||
delay = MAX_DELAY;
|
||||
if (!this.maxDelayWarningSent) {
|
||||
this.guildLogs.log(LogType.BOT_ALERT, {
|
||||
body: `Clamped auto-deletion delay in <#${msg.channel_id}> to 5 minutes`,
|
||||
});
|
||||
this.maxDelayWarningSent = true;
|
||||
}
|
||||
}
|
||||
|
||||
this.addMessageToDeletionQueue(msg, delay);
|
||||
}
|
||||
}
|
||||
|
||||
protected onMessageDelete(msg: SavedMessage) {
|
||||
const indexToDelete = this.deletionQueue.findIndex(item => item.message.id === msg.id);
|
||||
if (indexToDelete > -1) {
|
||||
this.deletionQueue.splice(indexToDelete, 1);
|
||||
this.scheduleNextDeletion();
|
||||
}
|
||||
}
|
||||
|
||||
protected onMessageDeleteBulk(messages: SavedMessage[]) {
|
||||
for (const msg of messages) {
|
||||
this.onMessageDelete(msg);
|
||||
}
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load diff
101
backend/src/plugins/Automod/info.ts
Normal file
101
backend/src/plugins/Automod/info.ts
Normal file
|
@ -0,0 +1,101 @@
|
|||
import { PluginInfo, trimPluginDescription } from "../ZeppelinPlugin";
|
||||
|
||||
export const pluginInfo: PluginInfo = {
|
||||
prettyName: "Automod",
|
||||
description: trimPluginDescription(`
|
||||
Allows specifying 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
|
||||
overrides:
|
||||
- level: '>=50'
|
||||
config:
|
||||
rules:
|
||||
my_spam_filter:
|
||||
enabled: false
|
||||
my_second_filter:
|
||||
enabled: false
|
||||
~~~
|
||||
|
||||
### Custom status alerts
|
||||
This example sends an alert any time a user with a matching custom status sends a message.
|
||||
|
||||
~~~yml
|
||||
automod:
|
||||
config:
|
||||
rules:
|
||||
bad_custom_statuses:
|
||||
triggers:
|
||||
- match_words:
|
||||
words: ['banana']
|
||||
match_custom_status: true
|
||||
actions:
|
||||
alert:
|
||||
channel: "473087035574321152"
|
||||
text: |-
|
||||
Bad custom status on user <@!{user.id}>:
|
||||
{matchSummary}
|
||||
~~~
|
||||
`),
|
||||
};
|
303
backend/src/plugins/Automod/types.ts
Normal file
303
backend/src/plugins/Automod/types.ts
Normal file
|
@ -0,0 +1,303 @@
|
|||
import * as t from "io-ts";
|
||||
import { TSafeRegex } from "../../validatorUtils";
|
||||
import { tDelayString, tNullable } from "../../utils";
|
||||
|
||||
export enum RecentActionType {
|
||||
Message = 1,
|
||||
Mention,
|
||||
Link,
|
||||
Attachment,
|
||||
Emoji,
|
||||
Line,
|
||||
Character,
|
||||
VoiceChannelMove,
|
||||
MemberJoin,
|
||||
}
|
||||
|
||||
export interface BaseRecentAction {
|
||||
identifier: string;
|
||||
timestamp: number;
|
||||
count: number;
|
||||
actioned?: boolean;
|
||||
}
|
||||
|
||||
export type TextRecentAction = BaseRecentAction & {
|
||||
type:
|
||||
| RecentActionType.Message
|
||||
| RecentActionType.Mention
|
||||
| RecentActionType.Link
|
||||
| RecentActionType.Attachment
|
||||
| RecentActionType.Emoji
|
||||
| RecentActionType.Line
|
||||
| RecentActionType.Character;
|
||||
messageInfo: MessageInfo;
|
||||
};
|
||||
|
||||
export type OtherRecentAction = BaseRecentAction & {
|
||||
type: RecentActionType.VoiceChannelMove | RecentActionType.MemberJoin;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type RecentAction = (TextRecentAction | OtherRecentAction) & { expiresAt: number };
|
||||
|
||||
export interface RecentSpam {
|
||||
archiveId: string;
|
||||
actionedUsers: Set<string>;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export type MessageInfo = { channelId: string; messageId: string; userId: string };
|
||||
|
||||
export type TextTriggerWithMultipleMatchTypes = {
|
||||
match_messages: boolean;
|
||||
match_embeds: boolean;
|
||||
match_visible_names: boolean;
|
||||
match_usernames: boolean;
|
||||
match_nicknames: boolean;
|
||||
match_custom_status: boolean;
|
||||
};
|
||||
|
||||
export interface TriggerMatchResult {
|
||||
trigger: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface MessageTextTriggerMatchResult<T = any> extends TriggerMatchResult {
|
||||
type: "message" | "embed";
|
||||
str: string;
|
||||
userId: string;
|
||||
messageInfo: MessageInfo;
|
||||
matchedValue: T;
|
||||
}
|
||||
|
||||
export interface OtherTextTriggerMatchResult<T = any> extends TriggerMatchResult {
|
||||
type: "username" | "nickname" | "visiblename" | "customstatus";
|
||||
str: string;
|
||||
userId: string;
|
||||
matchedValue: T;
|
||||
}
|
||||
|
||||
export type TextTriggerMatchResult<T = any> = MessageTextTriggerMatchResult<T> | OtherTextTriggerMatchResult<T>;
|
||||
|
||||
export interface TextSpamTriggerMatchResult extends TriggerMatchResult {
|
||||
type: "textspam";
|
||||
actionType: RecentActionType;
|
||||
recentActions: TextRecentAction[];
|
||||
|
||||
// Rule that specified the criteria used for matching the spam
|
||||
rule: TRule;
|
||||
|
||||
// The identifier used to match the recentActions above.
|
||||
// If not null, this should match the identifier of each of the recentActions above.
|
||||
identifier: string;
|
||||
}
|
||||
|
||||
export interface OtherSpamTriggerMatchResult extends TriggerMatchResult {
|
||||
type: "otherspam";
|
||||
actionType: RecentActionType;
|
||||
recentActions: OtherRecentAction[];
|
||||
|
||||
// Rule that specified the criteria used for matching the spam
|
||||
rule: TRule;
|
||||
|
||||
// The identifier used to match the recentActions above.
|
||||
// If not null, this should match the identifier of each of the recentActions above.
|
||||
identifier: string;
|
||||
}
|
||||
|
||||
export interface OtherTriggerMatchResult extends TriggerMatchResult {
|
||||
type: "other";
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export type AnyTriggerMatchResult =
|
||||
| TextTriggerMatchResult
|
||||
| OtherTriggerMatchResult
|
||||
| TextSpamTriggerMatchResult
|
||||
| OtherSpamTriggerMatchResult;
|
||||
|
||||
export type AnySpamTriggerMatchResult = TextSpamTriggerMatchResult | OtherSpamTriggerMatchResult;
|
||||
|
||||
/**
|
||||
* TRIGGERS
|
||||
*/
|
||||
|
||||
export const MatchWordsTrigger = 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,
|
||||
});
|
||||
export type TMatchWordsTrigger = t.TypeOf<typeof MatchWordsTrigger>;
|
||||
|
||||
export const MatchRegexTrigger = t.type({
|
||||
patterns: t.array(TSafeRegex),
|
||||
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,
|
||||
});
|
||||
export type TMatchRegexTrigger = t.TypeOf<typeof MatchRegexTrigger>;
|
||||
|
||||
export const MatchInvitesTrigger = t.type({
|
||||
include_guilds: tNullable(t.array(t.string)),
|
||||
exclude_guilds: tNullable(t.array(t.string)),
|
||||
include_invite_codes: tNullable(t.array(t.string)),
|
||||
exclude_invite_codes: tNullable(t.array(t.string)),
|
||||
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,
|
||||
match_custom_status: t.boolean,
|
||||
});
|
||||
export type TMatchInvitesTrigger = t.TypeOf<typeof MatchInvitesTrigger>;
|
||||
|
||||
export const MatchLinksTrigger = t.type({
|
||||
include_domains: tNullable(t.array(t.string)),
|
||||
exclude_domains: tNullable(t.array(t.string)),
|
||||
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,
|
||||
match_custom_status: t.boolean,
|
||||
});
|
||||
export type TMatchLinksTrigger = t.TypeOf<typeof MatchLinksTrigger>;
|
||||
|
||||
export const BaseSpamTrigger = t.type({
|
||||
amount: t.number,
|
||||
within: t.string,
|
||||
});
|
||||
export type TBaseSpamTrigger = t.TypeOf<typeof BaseSpamTrigger>;
|
||||
|
||||
export const BaseTextSpamTrigger = t.intersection([
|
||||
BaseSpamTrigger,
|
||||
t.type({
|
||||
per_channel: t.boolean,
|
||||
}),
|
||||
]);
|
||||
export type TBaseTextSpamTrigger = t.TypeOf<typeof BaseTextSpamTrigger>;
|
||||
|
||||
export const MessageSpamTrigger = BaseTextSpamTrigger;
|
||||
export type TMessageSpamTrigger = t.TypeOf<typeof MessageSpamTrigger>;
|
||||
export const MentionSpamTrigger = BaseTextSpamTrigger;
|
||||
export type TMentionSpamTrigger = t.TypeOf<typeof MentionSpamTrigger>;
|
||||
export const LinkSpamTrigger = BaseTextSpamTrigger;
|
||||
export type TLinkSpamTrigger = t.TypeOf<typeof LinkSpamTrigger>;
|
||||
export const AttachmentSpamTrigger = BaseTextSpamTrigger;
|
||||
export type TAttachmentSpamTrigger = t.TypeOf<typeof AttachmentSpamTrigger>;
|
||||
export const EmojiSpamTrigger = BaseTextSpamTrigger;
|
||||
export type TEmojiSpamTrigger = t.TypeOf<typeof EmojiSpamTrigger>;
|
||||
export const LineSpamTrigger = BaseTextSpamTrigger;
|
||||
export type TLineSpamTrigger = t.TypeOf<typeof LineSpamTrigger>;
|
||||
export const CharacterSpamTrigger = BaseTextSpamTrigger;
|
||||
export type TCharacterSpamTrigger = t.TypeOf<typeof CharacterSpamTrigger>;
|
||||
export const VoiceMoveSpamTrigger = BaseSpamTrigger;
|
||||
export type TVoiceMoveSpamTrigger = t.TypeOf<typeof VoiceMoveSpamTrigger>;
|
||||
|
||||
export const MemberJoinTrigger = t.type({
|
||||
only_new: t.boolean,
|
||||
new_threshold: tDelayString,
|
||||
});
|
||||
export type TMemberJoinTrigger = t.TypeOf<typeof MemberJoinTrigger>;
|
||||
|
||||
export const MemberJoinSpamTrigger = BaseSpamTrigger;
|
||||
export type TMemberJoinSpamTrigger = t.TypeOf<typeof MemberJoinTrigger>;
|
||||
|
||||
/**
|
||||
* ACTIONS
|
||||
*/
|
||||
|
||||
export const CleanAction = t.boolean;
|
||||
|
||||
export const WarnAction = t.type({
|
||||
reason: t.string,
|
||||
});
|
||||
|
||||
export const MuteAction = t.type({
|
||||
duration: t.string,
|
||||
reason: tNullable(t.string),
|
||||
});
|
||||
|
||||
export const KickAction = t.type({
|
||||
reason: tNullable(t.string),
|
||||
});
|
||||
|
||||
export const BanAction = t.type({
|
||||
reason: tNullable(t.string),
|
||||
});
|
||||
|
||||
export const AlertAction = t.type({
|
||||
channel: t.string,
|
||||
text: t.string,
|
||||
});
|
||||
|
||||
export const ChangeNicknameAction = t.type({
|
||||
name: t.string,
|
||||
});
|
||||
|
||||
export const LogAction = t.boolean;
|
||||
|
||||
export const AddRolesAction = t.array(t.string);
|
||||
export const RemoveRolesAction = t.array(t.string);
|
||||
|
||||
export const SetAntiraidLevelAction = t.string;
|
||||
|
||||
/**
|
||||
* RULES
|
||||
*/
|
||||
|
||||
export const Rule = t.type({
|
||||
enabled: t.boolean,
|
||||
name: t.string,
|
||||
presets: tNullable(t.array(t.string)),
|
||||
triggers: t.array(
|
||||
t.type({
|
||||
match_words: tNullable(MatchWordsTrigger),
|
||||
match_regex: tNullable(MatchRegexTrigger),
|
||||
match_invites: tNullable(MatchInvitesTrigger),
|
||||
match_links: tNullable(MatchLinksTrigger),
|
||||
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),
|
||||
member_join: tNullable(MemberJoinTrigger),
|
||||
member_join_spam: tNullable(MemberJoinSpamTrigger),
|
||||
// voice_move_spam: tNullable(VoiceMoveSpamTrigger), // TODO
|
||||
// TODO: Duplicates trigger
|
||||
}),
|
||||
),
|
||||
actions: t.type({
|
||||
clean: tNullable(CleanAction),
|
||||
warn: tNullable(WarnAction),
|
||||
mute: tNullable(MuteAction),
|
||||
kick: tNullable(KickAction),
|
||||
ban: tNullable(BanAction),
|
||||
alert: tNullable(AlertAction),
|
||||
change_nickname: tNullable(ChangeNicknameAction),
|
||||
log: tNullable(LogAction),
|
||||
add_roles: tNullable(AddRolesAction),
|
||||
remove_roles: tNullable(RemoveRolesAction),
|
||||
set_antiraid_level: tNullable(SetAntiraidLevelAction),
|
||||
}),
|
||||
cooldown: tNullable(t.string),
|
||||
});
|
||||
export type TRule = t.TypeOf<typeof Rule>;
|
|
@ -198,23 +198,30 @@ export class LogsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
try {
|
||||
const values = {
|
||||
...data,
|
||||
userMention: async userOrMember => {
|
||||
if (!userOrMember) return "";
|
||||
userMention: async inputUserOrMember => {
|
||||
if (!inputUserOrMember) return "";
|
||||
|
||||
let user;
|
||||
let member;
|
||||
const usersOrMembers = Array.isArray(inputUserOrMember) ? inputUserOrMember : [inputUserOrMember];
|
||||
|
||||
if (userOrMember.user) {
|
||||
member = userOrMember;
|
||||
user = member.user;
|
||||
} else {
|
||||
user = userOrMember;
|
||||
member = this.guild.members.get(user.id) || { id: user.id, user };
|
||||
const mentions = [];
|
||||
for (const userOrMember of usersOrMembers) {
|
||||
let user;
|
||||
let member;
|
||||
|
||||
if (userOrMember.user) {
|
||||
member = userOrMember;
|
||||
user = member.user;
|
||||
} else {
|
||||
user = userOrMember;
|
||||
member = this.guild.members.get(user.id) || { id: user.id, user };
|
||||
}
|
||||
|
||||
const memberConfig = this.getMatchingConfig({ member, userId: user.id }) || ({} as any);
|
||||
|
||||
mentions.push(memberConfig.ping_user ? verboseUserMention(user) : verboseUserName(user));
|
||||
}
|
||||
|
||||
const memberConfig = this.getMatchingConfig({ member, userId: user.id }) || ({} as any);
|
||||
|
||||
return memberConfig.ping_user ? verboseUserMention(user) : verboseUserName(user);
|
||||
return mentions.join(", ");
|
||||
},
|
||||
channelMention: channel => {
|
||||
if (!channel) return "";
|
||||
|
|
|
@ -9,6 +9,8 @@ import escapeStringRegexp from "escape-string-regexp";
|
|||
import { SavedMessage } from "../data/entities/SavedMessage";
|
||||
import { GuildSavedMessages } from "../data/GuildSavedMessages";
|
||||
|
||||
//region TYPES
|
||||
|
||||
const tBaseSource = t.type({
|
||||
name: tAlphanumeric,
|
||||
track: t.boolean,
|
||||
|
@ -50,8 +52,14 @@ const tConfigSchema = t.type({
|
|||
type TConfigSchema = t.TypeOf<typeof tConfigSchema>;
|
||||
const tPartialConfigSchema = tDeepPartial(tConfigSchema);
|
||||
|
||||
//endregion
|
||||
//region CONSTANTS
|
||||
|
||||
const DEFAULT_RETENTION_PERIOD = "4w";
|
||||
|
||||
//endregion
|
||||
//region PLUGIN
|
||||
|
||||
export class StatsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
||||
public static pluginName = "stats";
|
||||
public static configSchema = tConfigSchema;
|
||||
|
@ -71,20 +79,21 @@ export class StatsPlugin extends ZeppelinPlugin<TConfigSchema> {
|
|||
};
|
||||
}
|
||||
|
||||
protected static preprocessStaticConfig(config: t.TypeOf<typeof tPartialConfigSchema>) {
|
||||
// TODO: Limit min period, min period start date
|
||||
protected static applyDefaultsToSource(source: Partial<TSource>) {
|
||||
if (source.track == null) {
|
||||
source.track = true;
|
||||
}
|
||||
|
||||
if (source.retention_period == null) {
|
||||
source.retention_period = DEFAULT_RETENTION_PERIOD;
|
||||
}
|
||||
}
|
||||
|
||||
protected static preprocessStaticConfig(config: t.TypeOf<typeof tPartialConfigSchema>) {
|
||||
if (config.sources) {
|
||||
for (const [key, source] of Object.entries(config.sources)) {
|
||||
source.name = key;
|
||||
|
||||
if (source.track == null) {
|
||||
source.track = true;
|
||||
}
|
||||
|
||||
if (source.retention_period == null) {
|
||||
source.retention_period = DEFAULT_RETENTION_PERIOD;
|
||||
}
|
||||
this.applyDefaultsToSource(source);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -62,7 +62,10 @@ export function trimPluginDescription(str) {
|
|||
|
||||
const inviteCache = new SimpleCache<Promise<Invite>>(10 * MINUTES, 200);
|
||||
|
||||
export class ZeppelinPlugin<TConfig extends {} = IBasePluginConfig> extends Plugin<TConfig> {
|
||||
export class ZeppelinPlugin<
|
||||
TConfig extends {} = IBasePluginConfig,
|
||||
TCustomOverrideCriteria extends {} = {}
|
||||
> extends Plugin<TConfig, TCustomOverrideCriteria> {
|
||||
public static pluginInfo: PluginInfo;
|
||||
public static showInDocs: boolean = true;
|
||||
|
||||
|
@ -98,8 +101,11 @@ export class ZeppelinPlugin<TConfig extends {} = IBasePluginConfig> extends Plug
|
|||
/**
|
||||
* Wrapper to fetch the real default options from getStaticDefaultOptions()
|
||||
*/
|
||||
protected getDefaultOptions(): IPluginOptions<TConfig> {
|
||||
return (this.constructor as typeof ZeppelinPlugin).getStaticDefaultOptions() as IPluginOptions<TConfig>;
|
||||
protected getDefaultOptions(): IPluginOptions<TConfig, TCustomOverrideCriteria> {
|
||||
return (this.constructor as typeof ZeppelinPlugin).getStaticDefaultOptions() as IPluginOptions<
|
||||
TConfig,
|
||||
TCustomOverrideCriteria
|
||||
>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -165,14 +171,14 @@ export class ZeppelinPlugin<TConfig extends {} = IBasePluginConfig> extends Plug
|
|||
/**
|
||||
* Wrapper that calls mergeAndValidateStaticOptions()
|
||||
*/
|
||||
protected getMergedOptions(): IPluginOptions<TConfig> {
|
||||
protected getMergedOptions(): IPluginOptions<TConfig, TCustomOverrideCriteria> {
|
||||
if (!this.mergedPluginOptions) {
|
||||
this.mergedPluginOptions = ((this.constructor as unknown) as typeof ZeppelinPlugin).mergeAndDecodeStaticOptions(
|
||||
this.pluginOptions,
|
||||
);
|
||||
}
|
||||
|
||||
return this.mergedPluginOptions as IPluginOptions<TConfig>;
|
||||
return this.mergedPluginOptions as IPluginOptions<TConfig, TCustomOverrideCriteria>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -26,7 +26,7 @@ import { CompanionChannelPlugin } from "./CompanionChannels";
|
|||
import { LocatePlugin } from "./LocateUser";
|
||||
import { GuildConfigReloader } from "./GuildConfigReloader";
|
||||
import { ChannelArchiverPlugin } from "./ChannelArchiver";
|
||||
import { AutomodPlugin } from "./Automod";
|
||||
import { AutomodPlugin } from "./Automod/Automod";
|
||||
import { RolesPlugin } from "./Roles";
|
||||
import { AutoDeletePlugin } from "./AutoDelete";
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue