Automod work vol 2

This commit is contained in:
Dragory 2020-07-27 21:51:03 +03:00
parent f657b169df
commit 0e9f65e0d5
No known key found for this signature in database
GPG key ID: 5F387BA66DF8AAC1
12 changed files with 420 additions and 59 deletions

View file

@ -14,18 +14,15 @@ const MessageSpamTriggerConfig = t.type({
});
type TMessageSpamTriggerConfig = t.TypeOf<typeof MessageSpamTriggerConfig>;
const MessageSpamMatchResultType = t.type({
archiveId: t.string,
});
type TMessageSpamMatchResultType = t.TypeOf<typeof MessageSpamMatchResultType>;
interface TMessageSpamMatchResultType {
archiveId: string;
}
export function createMessageSpamTrigger(spamType: RecentActionType, prettyName: string) {
return automodTrigger({
return automodTrigger<TMessageSpamMatchResultType>()({
configType: MessageSpamTriggerConfig,
defaultConfig: {},
matchResultType: MessageSpamMatchResultType,
async match({ pluginData, context, triggerConfig }) {
if (!context.message) {
return;

View file

@ -13,18 +13,9 @@ type TextTriggerWithMultipleMatchTypes = {
match_custom_status: boolean;
};
export const MatchableTextType = t.union([
t.literal("message"),
t.literal("embed"),
t.literal("visiblename"),
t.literal("username"),
t.literal("nickname"),
t.literal("customstatus"),
]);
export type MatchableTextType = "message" | "embed" | "visiblename" | "username" | "nickname" | "customstatus";
export type TMatchableTextType = t.TypeOf<typeof MatchableTextType>;
type YieldedContent = [TMatchableTextType, string];
type YieldedContent = [MatchableTextType, string];
/**
* Generator function that allows iterating through matchable pieces of text of a SavedMessage

View file

@ -7,6 +7,7 @@ import { CleanAction } from "../actions/clean";
export async function runAutomod(pluginData: PluginData<AutomodPluginType>, context: AutomodContext) {
const userId = context.user?.id || context.message?.user_id;
const user = userId && pluginData.client.users.get(userId);
const member = userId && pluginData.guild.members.get(userId);
const channelId = context.message?.channel_id;
const channel = channelId && pluginData.guild.channels.get(channelId);
@ -21,6 +22,7 @@ export async function runAutomod(pluginData: PluginData<AutomodPluginType>, cont
for (const [ruleName, rule] of Object.entries(config.rules)) {
if (rule.enabled === false) continue;
if (!rule.affects_bots && user.bot) continue;
let matchResult: AutomodTriggerMatchResult<any>;
let matchSummary: string;

View file

@ -25,20 +25,28 @@ type AutomodTriggerRenderMatchInformationFn<TConfigType, TMatchResultExtra> = (m
matchResult: AutomodTriggerMatchResult<TMatchResultExtra>;
}) => Awaitable<string>;
export interface AutomodTriggerBlueprint<TConfigType extends t.Any, TMatchResultExtra extends t.Any> {
export interface AutomodTriggerBlueprint<TConfigType extends t.Any, TMatchResultExtra> {
configType: TConfigType;
defaultConfig: Partial<t.TypeOf<TConfigType>>;
matchResultType: TMatchResultExtra;
match: AutomodTriggerMatchFn<t.TypeOf<TConfigType>, t.TypeOf<TMatchResultExtra>>;
renderMatchInformation: AutomodTriggerRenderMatchInformationFn<t.TypeOf<TConfigType>, t.TypeOf<TMatchResultExtra>>;
match: AutomodTriggerMatchFn<t.TypeOf<TConfigType>, TMatchResultExtra>;
renderMatchInformation: AutomodTriggerRenderMatchInformationFn<t.TypeOf<TConfigType>, TMatchResultExtra>;
}
export function automodTrigger<TConfigType extends t.Any, TMatchResultExtra extends t.Any>(
export function automodTrigger<TMatchResultExtra>(): <TConfigType extends t.Any>(
blueprint: AutomodTriggerBlueprint<TConfigType, TMatchResultExtra>,
): AutomodTriggerBlueprint<TConfigType, TMatchResultExtra> {
return blueprint;
) => AutomodTriggerBlueprint<TConfigType, TMatchResultExtra>;
export function automodTrigger<TConfigType extends t.Any>(
blueprint: AutomodTriggerBlueprint<TConfigType, unknown>,
): AutomodTriggerBlueprint<TConfigType, unknown>;
export function automodTrigger(...args) {
if (args.length) {
return args[0];
} else {
return automodTrigger;
}
}
type AutomodActionApplyFn<TConfigType> = (meta: {

View file

@ -9,10 +9,16 @@ import { EmojiSpamTrigger } from "./emojiSpam";
import { LineSpamTrigger } from "./lineSpam";
import { CharacterSpamTrigger } from "./characterSpam";
import { MatchRegexTrigger } from "./matchRegex";
import { MatchInvitesTrigger } from "./matchInvites";
import { MatchLinksTrigger } from "./matchLinks";
import { MatchAttachmentTypeTrigger } from "./matchAttachmentType";
export const availableTriggers: Record<string, AutomodTriggerBlueprint<any, any>> = {
match_words: MatchWordsTrigger,
match_regex: MatchRegexTrigger,
match_invites: MatchInvitesTrigger,
match_links: MatchLinksTrigger,
match_attachment_type: MatchAttachmentTypeTrigger,
message_spam: MessageSpamTrigger,
mention_spam: MentionSpamTrigger,
@ -26,6 +32,9 @@ export const availableTriggers: Record<string, AutomodTriggerBlueprint<any, any>
export const AvailableTriggers = t.type({
match_words: MatchWordsTrigger.configType,
match_regex: MatchRegexTrigger.configType,
match_invites: MatchInvitesTrigger.configType,
match_links: MatchLinksTrigger.configType,
match_attachment_type: MatchAttachmentTypeTrigger.configType,
message_spam: MessageSpamTrigger.configType,
mention_spam: MentionSpamTrigger.configType,

View file

@ -1,27 +1,31 @@
import * as t from "io-ts";
import { automodTrigger } from "../helpers";
export const ExampleTrigger = automodTrigger({
interface ExampleMatchResultType {
isBanana: boolean;
}
export const ExampleTrigger = automodTrigger<ExampleMatchResultType>()({
configType: t.type({
some: t.number,
value: t.string,
allowedFruits: t.array(t.string),
}),
defaultConfig: {},
matchResultType: t.type({
thing: t.string,
}),
async match() {
return {
extra: {
thing: "hi",
},
};
defaultConfig: {
allowedFruits: ["peach", "banana"],
},
renderMatchInformation() {
return "";
async match({ triggerConfig, context }) {
const foundFruit = triggerConfig.allowedFruits.find(fruit => context.message?.data.content === fruit);
if (foundFruit) {
return {
extra: {
isBanana: foundFruit === "banana",
},
};
}
},
renderMatchInformation({ matchResult }) {
return `Matched fruit, isBanana: ${matchResult.extra.isBanana ? "yes" : "no"}`;
},
});

View file

@ -0,0 +1,86 @@
import * as t from "io-ts";
import { transliterate } from "transliteration";
import escapeStringRegexp from "escape-string-regexp";
import { AnyInvite, Attachment, GuildInvite } from "eris";
import { automodTrigger } from "../helpers";
import {
asSingleLine,
disableCodeBlocks,
disableInlineCode,
getInviteCodesInString,
isGuildInvite,
resolveInvite,
tNullable,
verboseChannelMention,
} from "../../../utils";
import { MatchableTextType, matchMultipleTextTypesOnMessage } from "../functions/matchMultipleTextTypesOnMessage";
interface MatchResultType {
matchedType: string;
mode: "blacklist" | "whitelist";
}
export const MatchAttachmentTypeTrigger = automodTrigger<MatchResultType>()({
configType: t.type({
filetype_blacklist: t.array(t.string),
blacklist_enabled: t.boolean,
filetype_whitelist: t.array(t.string),
whitelist_enabled: t.boolean,
}),
defaultConfig: {
filetype_blacklist: [],
blacklist_enabled: false,
filetype_whitelist: [],
whitelist_enabled: false,
},
async match({ pluginData, context, triggerConfig: trigger }) {
if (!context.message) {
return;
}
if (!context.message.data.attachments) return null;
const attachments: any[] = context.message.data.attachments;
for (const attachment of attachments) {
const attachmentType = attachment.filename.split(`.`).pop();
if (trigger.blacklist_enabled && trigger.filetype_blacklist.includes(attachmentType)) {
return {
extra: {
matchedType: attachmentType,
mode: "blacklist",
},
};
}
if (trigger.whitelist_enabled && !trigger.filetype_whitelist.includes(attachmentType)) {
return {
extra: {
matchedType: attachmentType,
mode: "whitelist",
},
};
}
}
return null;
},
renderMatchInformation({ pluginData, contexts, matchResult }) {
const channel = pluginData.guild.channels.get(contexts[0].message.channel_id);
const prettyChannel = verboseChannelMention(channel);
return (
asSingleLine(`
Matched attachment type \`${disableInlineCode(matchResult.extra.matchedType)}\`
(${matchResult.extra.mode === "blacklist" ? "(blacklisted)" : "(not in whitelist)"})
in message (\`${contexts[0].message.id}\`) in ${prettyChannel}:
`) +
"\n```" +
disableCodeBlocks(contexts[0].message.data.content) +
"```"
);
},
});

View file

@ -0,0 +1,105 @@
import * as t from "io-ts";
import { transliterate } from "transliteration";
import escapeStringRegexp from "escape-string-regexp";
import { AnyInvite, GuildInvite } from "eris";
import { automodTrigger } from "../helpers";
import {
asSingleLine,
disableCodeBlocks,
disableInlineCode,
getInviteCodesInString,
isGuildInvite,
resolveInvite,
tNullable,
verboseChannelMention,
} from "../../../utils";
import { MatchableTextType, matchMultipleTextTypesOnMessage } from "../functions/matchMultipleTextTypesOnMessage";
interface MatchResultType {
type: MatchableTextType;
code: string;
invite?: GuildInvite;
}
export const MatchInvitesTrigger = automodTrigger<MatchResultType>()({
configType: 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,
}),
defaultConfig: {
allow_group_dm_invites: false,
match_messages: true,
match_embeds: true,
match_visible_names: false,
match_usernames: false,
match_nicknames: false,
match_custom_status: false,
},
async match({ pluginData, context, triggerConfig: trigger }) {
if (!context.message) {
return;
}
for await (const [type, str] of matchMultipleTextTypesOnMessage(pluginData, trigger, context.message)) {
const inviteCodes = getInviteCodesInString(str);
if (inviteCodes.length === 0) return null;
const uniqueInviteCodes = Array.from(new Set(inviteCodes));
for (const code of uniqueInviteCodes) {
if (trigger.include_invite_codes && trigger.include_invite_codes.includes(code)) {
return { extra: { type, code } };
}
if (trigger.exclude_invite_codes && !trigger.exclude_invite_codes.includes(code)) {
return { extra: { type, code } };
}
}
for (const code of uniqueInviteCodes) {
const invite = await resolveInvite(pluginData.client, code);
if (!invite || !isGuildInvite(invite)) return { code };
if (trigger.include_guilds && trigger.include_guilds.includes(invite.guild.id)) {
return { extra: { type, code, invite } };
}
if (trigger.exclude_guilds && !trigger.exclude_guilds.includes(invite.guild.id)) {
return { extra: { type, code, invite } };
}
}
}
return null;
},
renderMatchInformation({ pluginData, contexts, matchResult }) {
const channel = pluginData.guild.channels.get(contexts[0].message.channel_id);
const prettyChannel = verboseChannelMention(channel);
let matchedText;
if (matchResult.extra.invite) {
const invite = matchResult.extra.invite as GuildInvite;
matchedText = `invite code \`${matchResult.extra.code}\` (**${invite.guild.name}**, \`${invite.guild.id}\`)`;
} else {
matchedText = `invite code \`${matchResult.extra.code}\``;
}
return (
`${matchedText} in message (\`${contexts[0].message.id}\`) in ${prettyChannel}:\n` +
"```" +
disableCodeBlocks(contexts[0].message.data.content) +
"```"
);
},
});

View file

@ -0,0 +1,152 @@
import * as t from "io-ts";
import { transliterate } from "transliteration";
import escapeStringRegexp from "escape-string-regexp";
import { AnyInvite, GuildInvite } from "eris";
import { automodTrigger } from "../helpers";
import {
asSingleLine,
disableCodeBlocks,
disableInlineCode,
getInviteCodesInString,
getUrlsInString,
isGuildInvite,
resolveInvite,
tNullable,
verboseChannelMention,
} from "../../../utils";
import { MatchableTextType, matchMultipleTextTypesOnMessage } from "../functions/matchMultipleTextTypesOnMessage";
import { TSafeRegex } from "../../../validatorUtils";
interface MatchResultType {
type: MatchableTextType;
link: string;
}
export const MatchLinksTrigger = automodTrigger<MatchResultType>()({
configType: t.type({
include_domains: tNullable(t.array(t.string)),
exclude_domains: tNullable(t.array(t.string)),
include_subdomains: t.boolean,
include_words: tNullable(t.array(t.string)),
exclude_words: tNullable(t.array(t.string)),
include_regex: tNullable(t.array(TSafeRegex)),
exclude_regex: tNullable(t.array(TSafeRegex)),
only_real_links: t.boolean,
match_messages: t.boolean,
match_embeds: t.boolean,
match_visible_names: t.boolean,
match_usernames: t.boolean,
match_nicknames: t.boolean,
match_custom_status: t.boolean,
}),
defaultConfig: {
include_subdomains: true,
match_messages: true,
match_embeds: true,
match_visible_names: false,
match_usernames: false,
match_nicknames: false,
match_custom_status: false,
only_real_links: true,
},
async match({ pluginData, context, triggerConfig: trigger }) {
if (!context.message) {
return;
}
typeLoop: for await (const [type, str] of matchMultipleTextTypesOnMessage(pluginData, trigger, context.message)) {
const links = getUrlsInString(str, true);
for (const link of links) {
// "real link" = a link that Discord highlights
if (trigger.only_real_links && !link.input.match(/^https?:\/\//i)) {
continue;
}
const normalizedHostname = link.hostname.toLowerCase();
// Exclude > Include
// In order of specificity, regex > word > domain
if (trigger.exclude_regex) {
for (const pattern of trigger.exclude_regex) {
if (pattern.test(link.input)) {
continue typeLoop;
}
}
}
if (trigger.include_regex) {
for (const pattern of trigger.include_regex) {
if (pattern.test(link.input)) {
return { extra: { type, link: link.input } };
}
}
}
if (trigger.exclude_words) {
for (const word of trigger.exclude_words) {
const regex = new RegExp(escapeStringRegexp(word), "i");
if (regex.test(link.input)) {
continue typeLoop;
}
}
}
if (trigger.include_words) {
for (const word of trigger.include_words) {
const regex = new RegExp(escapeStringRegexp(word), "i");
if (regex.test(link.input)) {
return { extra: { type, link: link.input } };
}
}
}
if (trigger.exclude_domains) {
for (const domain of trigger.exclude_domains) {
const normalizedDomain = domain.toLowerCase();
if (normalizedDomain === normalizedHostname) {
continue typeLoop;
}
if (trigger.include_subdomains && normalizedHostname.endsWith(`.${domain}`)) {
continue typeLoop;
}
}
return { extra: { type, link: link.toString() } };
}
if (trigger.include_domains) {
for (const domain of trigger.include_domains) {
const normalizedDomain = domain.toLowerCase();
if (normalizedDomain === normalizedHostname) {
return { extra: { type, link: domain } };
}
if (trigger.include_subdomains && normalizedHostname.endsWith(`.${domain}`)) {
return { extra: { type, link: domain } };
}
}
}
}
}
return null;
},
renderMatchInformation({ pluginData, contexts, matchResult }) {
const channel = pluginData.guild.channels.get(contexts[0].message.channel_id);
const prettyChannel = verboseChannelMention(channel);
return (
asSingleLine(`
Matched link \`${disableInlineCode(matchResult.extra.link)}\`
in message (\`${contexts[0].message.id}\`) in ${prettyChannel}:
`) +
"\n```" +
disableCodeBlocks(contexts[0].message.data.content) +
"```"
);
},
});

View file

@ -4,10 +4,16 @@ import escapeStringRegexp from "escape-string-regexp";
import { automodTrigger } from "../helpers";
import { disableInlineCode, verboseChannelMention } from "../../../utils";
import { MatchableTextType, matchMultipleTextTypesOnMessage } from "../functions/matchMultipleTextTypesOnMessage";
import { TSafeRegex } from "../../../validatorUtils";
export const MatchRegexTrigger = automodTrigger({
interface MatchResultType {
pattern: string;
type: MatchableTextType;
}
export const MatchRegexTrigger = automodTrigger<MatchResultType>()({
configType: t.type({
patterns: t.array(t.string),
patterns: t.array(TSafeRegex),
case_sensitive: t.boolean,
normalize: t.boolean,
match_messages: t.boolean,
@ -29,11 +35,6 @@ export const MatchRegexTrigger = automodTrigger({
match_custom_status: false,
},
matchResultType: t.type({
pattern: t.string,
type: MatchableTextType,
}),
async match({ pluginData, context, triggerConfig: trigger }) {
if (!context.message) {
return;
@ -44,13 +45,13 @@ export const MatchRegexTrigger = automodTrigger({
str = transliterate(str);
}
for (const pattern of trigger.patterns) {
const regex = new RegExp(pattern, trigger.case_sensitive ? "" : "i");
for (const sourceRegex of trigger.patterns) {
const regex = new RegExp(sourceRegex.source, trigger.case_sensitive ? "" : "i");
const test = regex.test(str);
if (test) {
return {
extra: {
pattern,
pattern: sourceRegex.source,
type,
},
};

View file

@ -5,7 +5,12 @@ import { automodTrigger } from "../helpers";
import { disableInlineCode, verboseChannelMention } from "../../../utils";
import { MatchableTextType, matchMultipleTextTypesOnMessage } from "../functions/matchMultipleTextTypesOnMessage";
export const MatchWordsTrigger = automodTrigger({
interface MatchResultType {
word: string;
type: MatchableTextType;
}
export const MatchWordsTrigger = automodTrigger<MatchResultType>()({
configType: t.type({
words: t.array(t.string),
case_sensitive: t.boolean,
@ -35,11 +40,6 @@ export const MatchWordsTrigger = automodTrigger({
match_custom_status: false,
},
matchResultType: t.type({
word: t.string,
type: MatchableTextType,
}),
async match({ pluginData, context, triggerConfig: trigger }) {
if (!context.message) {
return;

View file

@ -1,4 +1,5 @@
import {
AnyInvite,
Attachment,
ChannelInvite,
Client,
@ -9,6 +10,7 @@ import {
GuildAuditLog,
GuildAuditLogEntry,
GuildChannel,
GuildInvite,
Member,
Message,
MessageContent,
@ -1216,3 +1218,7 @@ export function trimPluginDescription(str) {
export function isFullMessage(msg: PossiblyUncachedMessage): msg is Message {
return (msg as Message).createdAt != null;
}
export function isGuildInvite(invite: AnyInvite): invite is GuildInvite {
return (invite as GuildInvite).guild != null;
}