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

Merge branch 'knub30' into k30_reactionRoles

This commit is contained in:
Miikka 2020-07-28 21:34:43 +03:00 committed by GitHub
commit 9a95b8e12e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
55 changed files with 2048 additions and 6 deletions

View file

@ -41,4 +41,8 @@ export class Queue {
setTimeout(resolve, this.timeout);
}).then(() => this.next());
}
public clear() {
this.queue.splice(0, this.queue.length);
}
}

View file

@ -3,7 +3,7 @@
*/
import { Member } from "eris";
import { configUtils, helpers, PluginData, PluginOptions } from "knub";
import { configUtils, helpers, PluginBlueprint, PluginData, PluginOptions } from "knub";
import { decodeAndValidateStrict, StrictValidationError } from "./validatorUtils";
import { deepKeyIntersect, errorMessage, successMessage } from "./utils";
import { ZeppelinPluginBlueprint } from "./plugins/ZeppelinPluginBlueprint";
@ -27,8 +27,15 @@ export function hasPermission(pluginData: PluginData<any>, permission: string, m
return helpers.hasPermission(config, permission);
}
export function getPluginConfigPreprocessor(blueprint: ZeppelinPluginBlueprint) {
return (options: PluginOptions<any>) => {
export function getPluginConfigPreprocessor(
blueprint: ZeppelinPluginBlueprint,
customPreprocessor?: PluginBlueprint<any>["configPreprocessor"],
) {
return async (options: PluginOptions<any>) => {
if (customPreprocessor) {
options = await customPreprocessor(options);
}
const decodedConfig = blueprint.configSchema
? decodeAndValidateStrict(blueprint.configSchema, options.config)
: options.config;

View file

@ -0,0 +1,159 @@
import { zeppelinPlugin } from "../ZeppelinPluginBlueprint";
import { AutomodPluginType, ConfigSchema } from "./types";
import { RunAutomodOnJoinEvt } from "./events/RunAutomodOnJoinEvt";
import { GuildLogs } from "../../data/GuildLogs";
import { GuildSavedMessages } from "../../data/GuildSavedMessages";
import { runAutomodOnMessage } from "./events/runAutomodOnMessage";
import { Queue } from "../../Queue";
import { configUtils } from "knub";
import { availableTriggers } from "./triggers/availableTriggers";
import { StrictValidationError } from "../../validatorUtils";
import { ConfigPreprocessorFn } from "knub/dist/config/configTypes";
import { availableActions } from "./actions/availableActions";
import { clearOldRecentActions } from "./functions/clearOldRecentActions";
import { MINUTES, SECONDS } from "../../utils";
import { clearOldRecentSpam } from "./functions/clearOldRecentSpam";
import { GuildAntiraidLevels } from "../../data/GuildAntiraidLevels";
import { GuildArchives } from "../../data/GuildArchives";
import { clearOldRecentNicknameChanges } from "./functions/clearOldNicknameChanges";
const defaultOptions = {
config: {
rules: {},
antiraid_levels: ["low", "medium", "high"],
can_set_antiraid: false,
can_view_antiraid: false,
},
overrides: [
{
level: ">=50",
config: {
can_view_antiraid: true,
},
},
{
level: ">=100",
config: {
can_set_antiraid: true,
},
},
],
};
/**
* Config preprocessor to set default values for triggers
*/
const configPreprocessor: ConfigPreprocessorFn<AutomodPluginType> = options => {
if (options.config?.rules) {
// Loop through each rule
for (const [name, rule] of Object.entries(options.config.rules)) {
rule["name"] = name;
// If the rule doesn't have an explicitly set "enabled" property, set it to true
if (rule["enabled"] == null) {
rule["enabled"] = true;
}
if (rule["affects_bots"] == null) {
rule["affects_bots"] = false;
}
// Loop through the rule's triggers
if (rule["triggers"]) {
for (const triggerObj of rule["triggers"]) {
for (const triggerName in triggerObj) {
if (!availableTriggers[triggerName]) {
throw new StrictValidationError([`Unknown trigger '${triggerName}' in rule '${rule.name}'`]);
}
const triggerBlueprint = availableTriggers[triggerName];
triggerObj[triggerName] = configUtils.mergeConfig(triggerBlueprint.defaultConfig, triggerObj[triggerName]);
if (triggerObj[triggerName].match_attachment_type) {
const white = triggerObj[triggerName].match_attachment_type.whitelist_enabled;
const black = triggerObj[triggerName].match_attachment_type.blacklist_enabled;
if (white && black) {
throw new StrictValidationError([
`Cannot have both blacklist and whitelist enabled at rule <${rule.name}/match_attachment_type>`,
]);
} else if (!white && !black) {
throw new StrictValidationError([
`Must have either blacklist or whitelist enabled at rule <${rule.name}/match_attachment_type>`,
]);
}
}
}
}
}
// Enable logging of automod actions by default
if (rule["actions"]) {
for (const actionName in rule.actions) {
if (!availableActions[actionName]) {
throw new StrictValidationError([`Unknown action '${actionName}' in rule '${rule.name}'`]);
}
}
if (rule["actions"]["log"] == null) {
rule["actions"]["log"] = true;
}
}
}
}
return options;
};
export const AutomodPlugin = zeppelinPlugin<AutomodPluginType>()("automod", {
configSchema: ConfigSchema,
defaultOptions,
configPreprocessor,
events: [
RunAutomodOnJoinEvt,
// Messages use message events from SavedMessages, see onLoad below
],
onLoad(pluginData) {
pluginData.state.queue = new Queue();
pluginData.state.recentActions = [];
pluginData.state.clearRecentActionsInterval = setInterval(() => clearOldRecentActions(pluginData), 1 * MINUTES);
pluginData.state.recentSpam = [];
pluginData.state.clearRecentSpamInterval = setInterval(() => clearOldRecentSpam(pluginData), 1 * SECONDS);
pluginData.state.recentNicknameChanges = new Map();
pluginData.state.clearRecentNicknameChangesInterval = setInterval(
() => clearOldRecentNicknameChanges(pluginData),
30 * SECONDS,
);
pluginData.state.cachedAntiraidLevel = null; // TODO
pluginData.state.logs = new GuildLogs(pluginData.guild.id);
pluginData.state.savedMessages = GuildSavedMessages.getGuildInstance(pluginData.guild.id);
pluginData.state.antiraidLevels = GuildAntiraidLevels.getGuildInstance(pluginData.guild.id);
pluginData.state.archives = GuildArchives.getGuildInstance(pluginData.guild.id);
pluginData.state.onMessageCreateFn = message => runAutomodOnMessage(pluginData, message, false);
pluginData.state.savedMessages.events.on("create", pluginData.state.onMessageCreateFn);
pluginData.state.onMessageUpdateFn = message => runAutomodOnMessage(pluginData, message, true);
pluginData.state.savedMessages.events.on("update", pluginData.state.onMessageUpdateFn);
},
onUnload(pluginData) {
pluginData.state.queue.clear();
clearInterval(pluginData.state.clearRecentActionsInterval);
clearInterval(pluginData.state.clearRecentSpamInterval);
clearInterval(pluginData.state.clearRecentNicknameChangesInterval);
pluginData.state.savedMessages.events.off("create", pluginData.state.onMessageCreateFn);
pluginData.state.savedMessages.events.off("update", pluginData.state.onMessageUpdateFn);
},
});

View file

@ -0,0 +1,35 @@
import * as t from "io-ts";
import { automodAction } from "../helpers";
import { LogType } from "../../../data/LogType";
import { asyncMap, resolveMember, tNullable } from "../../../utils";
import { resolveActionContactMethods } from "../functions/resolveActionContactMethods";
import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin";
export const AddRolesAction = automodAction({
configType: t.array(t.string),
async apply({ pluginData, contexts, actionConfig }) {
const members = contexts.map(c => c.member).filter(Boolean);
const uniqueMembers = new Set(members);
await Promise.all(
Array.from(uniqueMembers.values()).map(async member => {
const memberRoles = new Set(member.roles);
for (const roleId of actionConfig) {
memberRoles.add(roleId);
}
if (memberRoles.size === member.roles.length) {
// No role changes
return;
}
const rolesArr = Array.from(memberRoles.values());
await member.edit({
roles: rolesArr,
});
member.roles = rolesArr; // Make sure we know of the new roles internally as well
}),
);
},
});

View file

@ -0,0 +1,48 @@
import * as t from "io-ts";
import { automodAction } from "../helpers";
import { LogType } from "../../../data/LogType";
import { asyncMap, messageLink, resolveMember, stripObjectToScalars, tNullable } from "../../../utils";
import { resolveActionContactMethods } from "../functions/resolveActionContactMethods";
import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin";
import { TextChannel } from "eris";
import { renderTemplate } from "../../../templateFormatter";
export const AlertAction = automodAction({
configType: t.type({
channel: t.string,
text: t.string,
}),
async apply({ pluginData, contexts, actionConfig, ruleName, matchResult }) {
const channel = pluginData.guild.channels.get(actionConfig.channel);
if (channel && channel instanceof TextChannel) {
const text = actionConfig.text;
const theMessageLink =
contexts[0].message && messageLink(pluginData.guild.id, contexts[0].message.channel_id, contexts[0].message.id);
const safeUsers = contexts.map(c => c.user && stripObjectToScalars(c.user)).filter(Boolean);
const safeUser = safeUsers[0];
const takenActions = Object.keys(pluginData.config.get().rules[ruleName].actions);
// TODO: Generate logMessage
const logMessage = "";
const rendered = await renderTemplate(actionConfig.text, {
rule: ruleName,
user: safeUser,
users: safeUsers,
text,
matchSummary: matchResult.summary,
messageLink: theMessageLink,
logMessage,
});
channel.createMessage(rendered);
} else {
// TODO: Post BOT_ALERT log
/*this.getLogs().log(LogType.BOT_ALERT, {
body: `Invalid channel id \`${actionConfig.channel}\` for alert action in automod rule **${rule.name}**`,
});*/
}
},
});

View file

@ -0,0 +1,11 @@
import * as t from "io-ts";
import { CleanAction } from "./clean";
import { AutomodActionBlueprint } from "../helpers";
export const availableActions: Record<string, AutomodActionBlueprint<any>> = {
clean: CleanAction,
};
export const AvailableActions = t.type({
clean: CleanAction.configType,
});

View file

@ -0,0 +1,35 @@
import * as t from "io-ts";
import { automodAction } from "../helpers";
import { LogType } from "../../../data/LogType";
import { asyncMap, resolveMember, tNullable } from "../../../utils";
import { resolveActionContactMethods } from "../functions/resolveActionContactMethods";
import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin";
export const BanAction = automodAction({
configType: t.type({
reason: tNullable(t.string),
notify: tNullable(t.string),
notifyChannel: tNullable(t.string),
deleteMessageDays: tNullable(t.number),
}),
async apply({ pluginData, contexts, actionConfig }) {
const reason = actionConfig.reason || "Kicked automatically";
const contactMethods = resolveActionContactMethods(pluginData, actionConfig);
const deleteMessageDays = actionConfig.deleteMessageDays;
const caseArgs = {
modId: pluginData.client.user.id,
extraNotes: [
/* TODO */
],
};
const userIdsToBan = contexts.map(c => c.user?.id).filter(Boolean);
const modActions = pluginData.getPlugin(ModActionsPlugin);
for (const userId of userIdsToBan) {
await modActions.banUserId(userId, reason, { contactMethods, caseArgs, deleteMessageDays });
}
},
});

View file

@ -0,0 +1,27 @@
import * as t from "io-ts";
import { automodAction } from "../helpers";
import { LogType } from "../../../data/LogType";
import { asyncMap, resolveMember, tNullable } from "../../../utils";
import { resolveActionContactMethods } from "../functions/resolveActionContactMethods";
import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin";
export const ChangeNicknameAction = automodAction({
configType: t.type({
name: t.string,
}),
async apply({ pluginData, contexts, actionConfig }) {
const members = contexts.map(c => c.member).filter(Boolean);
const uniqueMembers = new Set(members);
for (const member of uniqueMembers) {
if (pluginData.state.recentNicknameChanges.has(member.id)) continue;
member.edit({ nick: actionConfig.name }).catch(err => {
/* TODO: Log this error */
});
pluginData.state.recentNicknameChanges.set(member.id, { timestamp: Date.now() });
}
},
});

View file

@ -0,0 +1,28 @@
import * as t from "io-ts";
import { automodAction } from "../helpers";
import { LogType } from "../../../data/LogType";
export const CleanAction = automodAction({
configType: t.boolean,
async apply({ pluginData, contexts }) {
const messageIdsToDeleteByChannelId: Map<string, string[]> = new Map();
for (const context of contexts) {
if (context.message) {
if (!messageIdsToDeleteByChannelId.has(context.message.channel_id)) {
messageIdsToDeleteByChannelId.set(context.message.channel_id, []);
}
messageIdsToDeleteByChannelId.get(context.message.channel_id).push(context.message.id);
}
}
for (const [channelId, messageIds] of messageIdsToDeleteByChannelId.entries()) {
for (const id of messageIds) {
pluginData.state.logs.ignoreLog(LogType.MESSAGE_DELETE, id);
}
await pluginData.client.deleteMessages(channelId, messageIds);
}
},
});

View file

@ -0,0 +1,12 @@
import * as t from "io-ts";
import { automodAction } from "../helpers";
export const ExampleAction = automodAction({
configType: t.type({
someValue: t.string,
}),
async apply({ pluginData, contexts, actionConfig }) {
// TODO: Everything
},
});

View file

@ -0,0 +1,34 @@
import * as t from "io-ts";
import { automodAction } from "../helpers";
import { LogType } from "../../../data/LogType";
import { asyncMap, resolveMember, tNullable } from "../../../utils";
import { resolveActionContactMethods } from "../functions/resolveActionContactMethods";
import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin";
export const KickAction = automodAction({
configType: t.type({
reason: tNullable(t.string),
notify: tNullable(t.string),
notifyChannel: tNullable(t.string),
}),
async apply({ pluginData, contexts, actionConfig }) {
const reason = actionConfig.reason || "Kicked automatically";
const contactMethods = resolveActionContactMethods(pluginData, actionConfig);
const caseArgs = {
modId: pluginData.client.user.id,
extraNotes: [
/* TODO */
],
};
const userIdsToKick = contexts.map(c => c.user?.id).filter(Boolean);
const membersToKick = await asyncMap(userIdsToKick, id => resolveMember(pluginData.client, pluginData.guild, id));
const modActions = pluginData.getPlugin(ModActionsPlugin);
for (const member of membersToKick) {
await modActions.kickMember(member, reason, { contactMethods, caseArgs });
}
},
});

View file

@ -0,0 +1,10 @@
import * as t from "io-ts";
import { automodAction } from "../helpers";
export const LogAction = automodAction({
configType: t.boolean,
async apply({ pluginData, contexts, actionConfig }) {
// TODO: Everything
},
});

View file

@ -0,0 +1,36 @@
import * as t from "io-ts";
import { automodAction } from "../helpers";
import { LogType } from "../../../data/LogType";
import { asyncMap, convertDelayStringToMS, resolveMember, tDelayString, tNullable } from "../../../utils";
import { resolveActionContactMethods } from "../functions/resolveActionContactMethods";
import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin";
import { MutesPlugin } from "../../Mutes/MutesPlugin";
export const MuteAction = automodAction({
configType: t.type({
reason: tNullable(t.string),
duration: tNullable(tDelayString),
notify: tNullable(t.string),
notifyChannel: tNullable(t.string),
}),
async apply({ pluginData, contexts, actionConfig }) {
const duration = actionConfig.duration ? convertDelayStringToMS(actionConfig.duration) : null;
const reason = actionConfig.reason || "Muted automatically";
const contactMethods = resolveActionContactMethods(pluginData, actionConfig);
const caseArgs = {
modId: pluginData.client.user.id,
extraNotes: [
/* TODO */
],
};
const userIdsToMute = contexts.map(c => c.user?.id).filter(Boolean);
const mutes = pluginData.getPlugin(MutesPlugin);
for (const userId of userIdsToMute) {
await mutes.muteUser(userId, duration, reason, { contactMethods, caseArgs });
}
},
});

View file

@ -0,0 +1,35 @@
import * as t from "io-ts";
import { automodAction } from "../helpers";
import { LogType } from "../../../data/LogType";
import { asyncMap, resolveMember, tNullable } from "../../../utils";
import { resolveActionContactMethods } from "../functions/resolveActionContactMethods";
import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin";
export const RemoveRolesAction = automodAction({
configType: t.array(t.string),
async apply({ pluginData, contexts, actionConfig }) {
const members = contexts.map(c => c.member).filter(Boolean);
const uniqueMembers = new Set(members);
await Promise.all(
Array.from(uniqueMembers.values()).map(async member => {
const memberRoles = new Set(member.roles);
for (const roleId of actionConfig) {
memberRoles.delete(roleId);
}
if (memberRoles.size === member.roles.length) {
// No role changes
return;
}
const rolesArr = Array.from(memberRoles.values());
await member.edit({
roles: rolesArr,
});
member.roles = rolesArr; // Make sure we know of the new roles internally as well
}),
);
},
});

View file

@ -0,0 +1,63 @@
import * as t from "io-ts";
import { automodAction } from "../helpers";
import {
convertDelayStringToMS,
noop,
renderRecursively,
stripObjectToScalars,
tDelayString,
tMessageContent,
tNullable,
} from "../../../utils";
import { TextChannel } from "eris";
import { AutomodContext } from "../types";
import { renderTemplate } from "../../../templateFormatter";
export const ReplyAction = automodAction({
configType: t.union([
t.string,
t.type({
text: tMessageContent,
auto_delete: tNullable(t.union([tDelayString, t.number])),
}),
]),
async apply({ pluginData, contexts, actionConfig }) {
const contextsWithTextChannels = contexts
.filter(c => c.message?.channel_id)
.filter(c => pluginData.guild.channels.get(c.message.channel_id) instanceof TextChannel);
const contextsByChannelId = contextsWithTextChannels.reduce((map: Map<string, AutomodContext[]>, context) => {
if (!map.has(context.message.channel_id)) {
map.set(context.message.channel_id, []);
}
map.get(context.message.channel_id).push(context);
return map;
}, new Map());
for (const [channelId, _contexts] of contextsByChannelId.entries()) {
const users = Array.from(new Set(_contexts.map(c => c.user).filter(Boolean)));
const user = users[0];
const renderReplyText = async str =>
renderTemplate(str, {
user: stripObjectToScalars(user),
});
const formatted =
typeof actionConfig === "string"
? await renderReplyText(actionConfig)
: await renderRecursively(actionConfig.text, renderReplyText);
if (formatted) {
const channel = pluginData.guild.channels.get(channelId) as TextChannel;
const replyMsg = await channel.createMessage(formatted);
if (typeof actionConfig === "object" && actionConfig.auto_delete) {
const delay = convertDelayStringToMS(String(actionConfig.auto_delete));
setTimeout(() => replyMsg.delete().catch(noop), delay);
}
}
}
},
});

View file

@ -0,0 +1,11 @@
import * as t from "io-ts";
import { automodAction } from "../helpers";
import { setAntiraidLevel } from "../functions/setAntiraidLevel";
export const SetAntiraidLevelAction = automodAction({
configType: t.string,
async apply({ pluginData, contexts, actionConfig }) {
setAntiraidLevel(pluginData, actionConfig);
},
});

View file

@ -0,0 +1,34 @@
import * as t from "io-ts";
import { automodAction } from "../helpers";
import { LogType } from "../../../data/LogType";
import { asyncMap, resolveMember, tNullable } from "../../../utils";
import { resolveActionContactMethods } from "../functions/resolveActionContactMethods";
import { ModActionsPlugin } from "../../ModActions/ModActionsPlugin";
export const WarnAction = automodAction({
configType: t.type({
reason: tNullable(t.string),
notify: tNullable(t.string),
notifyChannel: tNullable(t.string),
}),
async apply({ pluginData, contexts, actionConfig }) {
const reason = actionConfig.reason || "Warned automatically";
const contactMethods = resolveActionContactMethods(pluginData, actionConfig);
const caseArgs = {
modId: pluginData.client.user.id,
extraNotes: [
/* TODO */
],
};
const userIdsToWarn = contexts.map(c => c.user?.id).filter(Boolean);
const membersToWarn = await asyncMap(userIdsToWarn, id => resolveMember(pluginData.client, pluginData.guild, id));
const modActions = pluginData.getPlugin(ModActionsPlugin);
for (const member of membersToWarn) {
await modActions.warnMember(member, reason, { contactMethods, caseArgs });
}
},
});

View file

@ -0,0 +1,17 @@
import { MINUTES, SECONDS } from "../../utils";
export const RECENT_SPAM_EXPIRY_TIME = 10 * SECONDS;
export const RECENT_ACTION_EXPIRY_TIME = 5 * MINUTES;
export const RECENT_NICKNAME_CHANGE_EXPIRY_TIME = 5 * MINUTES;
export enum RecentActionType {
Message = 1,
Mention,
Link,
Attachment,
Emoji,
Line,
Character,
VoiceChannelMove,
MemberJoin,
}

View file

@ -0,0 +1,27 @@
import { eventListener } from "knub";
import { AutomodContext, AutomodPluginType } from "../types";
import { runAutomod } from "../functions/runAutomod";
import { RecentActionType } from "../constants";
export const RunAutomodOnJoinEvt = eventListener<AutomodPluginType>()(
"guildMemberAdd",
({ pluginData, args: { member } }) => {
const context: AutomodContext = {
timestamp: Date.now(),
user: member.user,
member,
joined: true,
};
pluginData.state.queue.add(() => {
pluginData.state.recentActions.push({
type: RecentActionType.MemberJoin,
context,
count: 1,
identifier: null,
});
runAutomod(pluginData, context);
});
},
);

View file

@ -0,0 +1,23 @@
import { SavedMessage } from "../../../data/entities/SavedMessage";
import { PluginData } from "knub";
import { AutomodContext, AutomodPluginType } from "../types";
import { runAutomod } from "../functions/runAutomod";
import { addRecentActionsFromMessage } from "../functions/addRecentActionsFromMessage";
import moment from "moment-timezone";
export function runAutomodOnMessage(pluginData: PluginData<AutomodPluginType>, message: SavedMessage, isEdit: boolean) {
const user = pluginData.client.users.get(message.user_id);
const member = pluginData.guild.members.get(message.user_id);
const context: AutomodContext = {
timestamp: moment.utc(message.posted_at).valueOf(),
message,
user,
member,
};
pluginData.state.queue.add(async () => {
addRecentActionsFromMessage(pluginData, context);
await runAutomod(pluginData, context);
});
}

View file

@ -0,0 +1,129 @@
import { AutomodContext, AutomodPluginType } from "../types";
import { PluginData } from "knub";
import { RECENT_ACTION_EXPIRY_TIME, RecentActionType } from "../constants";
import { getEmojiInString, getRoleMentions, getUrlsInString, getUserMentions } from "../../../utils";
export function addRecentActionsFromMessage(pluginData: PluginData<AutomodPluginType>, context: AutomodContext) {
const globalIdentifier = context.message.user_id;
const perChannelIdentifier = `${context.message.channel_id}-${context.message.user_id}`;
const expiresAt = Date.now() + RECENT_ACTION_EXPIRY_TIME;
pluginData.state.recentActions.push({
context,
type: RecentActionType.Message,
identifier: globalIdentifier,
count: 1,
});
pluginData.state.recentActions.push({
context,
type: RecentActionType.Message,
identifier: perChannelIdentifier,
count: 1,
});
const mentionCount =
getUserMentions(context.message.data.content || "").length +
getRoleMentions(context.message.data.content || "").length;
if (mentionCount) {
pluginData.state.recentActions.push({
context,
type: RecentActionType.Mention,
identifier: globalIdentifier,
count: mentionCount,
});
pluginData.state.recentActions.push({
context,
type: RecentActionType.Mention,
identifier: perChannelIdentifier,
count: mentionCount,
});
}
const linkCount = getUrlsInString(context.message.data.content || "").length;
if (linkCount) {
pluginData.state.recentActions.push({
context,
type: RecentActionType.Link,
identifier: globalIdentifier,
count: linkCount,
});
pluginData.state.recentActions.push({
context,
type: RecentActionType.Link,
identifier: perChannelIdentifier,
count: linkCount,
});
}
const attachmentCount = context.message.data.attachments && context.message.data.attachments.length;
if (attachmentCount) {
pluginData.state.recentActions.push({
context,
type: RecentActionType.Attachment,
identifier: globalIdentifier,
count: attachmentCount,
});
pluginData.state.recentActions.push({
context,
type: RecentActionType.Attachment,
identifier: perChannelIdentifier,
count: attachmentCount,
});
}
const emojiCount = getEmojiInString(context.message.data.content || "").length;
if (emojiCount) {
pluginData.state.recentActions.push({
context,
type: RecentActionType.Emoji,
identifier: globalIdentifier,
count: emojiCount,
});
pluginData.state.recentActions.push({
context,
type: RecentActionType.Emoji,
identifier: perChannelIdentifier,
count: emojiCount,
});
}
// + 1 is for the first line of the message (which doesn't have a line break)
const lineCount = context.message.data.content ? (context.message.data.content.match(/\n/g) || []).length + 1 : 0;
if (lineCount) {
pluginData.state.recentActions.push({
context,
type: RecentActionType.Line,
identifier: globalIdentifier,
count: lineCount,
});
pluginData.state.recentActions.push({
context,
type: RecentActionType.Line,
identifier: perChannelIdentifier,
count: lineCount,
});
}
const characterCount = [...(context.message.data.content || "")].length;
if (characterCount) {
pluginData.state.recentActions.push({
context,
type: RecentActionType.Character,
identifier: globalIdentifier,
count: characterCount,
});
pluginData.state.recentActions.push({
context,
type: RecentActionType.Character,
identifier: perChannelIdentifier,
count: characterCount,
});
}
}

View file

@ -0,0 +1,12 @@
import { PluginData } from "knub";
import { AutomodPluginType } from "../types";
import { RECENT_NICKNAME_CHANGE_EXPIRY_TIME, RECENT_SPAM_EXPIRY_TIME } from "../constants";
export function clearOldRecentNicknameChanges(pluginData: PluginData<AutomodPluginType>) {
const now = Date.now();
for (const [userId, { timestamp }] of pluginData.state.recentNicknameChanges) {
if (timestamp + RECENT_NICKNAME_CHANGE_EXPIRY_TIME <= now) {
pluginData.state.recentNicknameChanges.delete(userId);
}
}
}

View file

@ -0,0 +1,10 @@
import { PluginData } from "knub";
import { AutomodPluginType } from "../types";
import { RECENT_ACTION_EXPIRY_TIME } from "../constants";
export function clearOldRecentActions(pluginData: PluginData<AutomodPluginType>) {
const now = Date.now();
pluginData.state.recentActions = pluginData.state.recentActions.filter(info => {
return info.context.timestamp + RECENT_ACTION_EXPIRY_TIME > now;
});
}

View file

@ -0,0 +1,10 @@
import { PluginData } from "knub";
import { AutomodPluginType } from "../types";
import { RECENT_SPAM_EXPIRY_TIME } from "../constants";
export function clearOldRecentSpam(pluginData: PluginData<AutomodPluginType>) {
const now = Date.now();
pluginData.state.recentSpam = pluginData.state.recentSpam.filter(spam => {
return spam.timestamp + RECENT_SPAM_EXPIRY_TIME > now;
});
}

View file

@ -0,0 +1,81 @@
import { RecentActionType } from "../constants";
import { automodTrigger } from "../helpers";
import { getBaseUrl } from "../../../pluginUtils";
import { convertDelayStringToMS, tDelayString, tNullable } from "../../../utils";
import { humanizeDurationShort } from "../../../humanizeDurationShort";
import { findRecentSpam } from "./findRecentSpam";
import { getMatchingMessageRecentActions } from "./getMatchingMessageRecentActions";
import * as t from "io-ts";
const MessageSpamTriggerConfig = t.type({
amount: t.number,
within: tDelayString,
per_channel: tNullable(t.boolean),
});
type TMessageSpamTriggerConfig = t.TypeOf<typeof MessageSpamTriggerConfig>;
interface TMessageSpamMatchResultType {
archiveId: string;
}
export function createMessageSpamTrigger(spamType: RecentActionType, prettyName: string) {
return automodTrigger<TMessageSpamMatchResultType>()({
configType: MessageSpamTriggerConfig,
defaultConfig: {},
async match({ pluginData, context, triggerConfig }) {
if (!context.message) {
return;
}
const recentSpam = findRecentSpam(pluginData, spamType, context.message.user_id);
if (recentSpam) {
// TODO: Combine with old archive
return {
silentClean: true,
};
}
const within = convertDelayStringToMS(triggerConfig.within);
const matchedSpam = getMatchingMessageRecentActions(
pluginData,
context.message,
spamType,
triggerConfig.amount,
within,
triggerConfig.per_channel,
);
if (matchedSpam) {
// TODO: Generate archive link
const archiveId = "TODO";
pluginData.state.recentSpam.push({
type: spamType,
userIds: [context.message.user_id],
archiveId,
timestamp: Date.now(),
});
return {
extraContexts: matchedSpam.recentActions
.map(action => action.context)
.filter(_context => _context !== context),
extra: {
archiveId,
},
};
}
},
renderMatchInformation({ pluginData, matchResult, triggerConfig }) {
const baseUrl = getBaseUrl(pluginData);
const archiveUrl = pluginData.state.archives.getUrl(baseUrl, matchResult.extra.archiveId);
const withinMs = convertDelayStringToMS(triggerConfig.within);
const withinStr = humanizeDurationShort(withinMs);
return `Matched ${prettyName} spam (${triggerConfig.amount} in ${withinStr}): ${archiveUrl}`;
},
});
}

View file

@ -0,0 +1,9 @@
import { PluginData } from "knub";
import { AutomodPluginType } from "../types";
import { RecentActionType } from "../constants";
export function findRecentSpam(pluginData: PluginData<AutomodPluginType>, type: RecentActionType, userId?: string) {
return pluginData.state.recentSpam.find(spam => {
return spam.type === type && (!userId || spam.userIds.includes(userId));
});
}

View file

@ -0,0 +1,28 @@
import { PluginData } from "knub";
import { AutomodPluginType } from "../types";
import { SavedMessage } from "../../../data/entities/SavedMessage";
import moment from "moment-timezone";
import { getMatchingRecentActions } from "./getMatchingRecentActions";
import { RecentActionType } from "../constants";
export function getMatchingMessageRecentActions(
pluginData: PluginData<AutomodPluginType>,
message: SavedMessage,
type: RecentActionType,
count: number,
within: number,
perChannel: boolean,
) {
const since = moment.utc(message.posted_at).valueOf() - within;
const to = moment.utc(message.posted_at).valueOf();
const identifier = perChannel ? `${message.channel_id}-${message.user_id}` : message.user_id;
const recentActions = getMatchingRecentActions(pluginData, type, identifier, since, to);
const totalCount = recentActions.reduce((total, action) => total + action.count, 0);
if (totalCount >= count) {
return {
identifier,
recentActions,
};
}
}

View file

@ -0,0 +1,22 @@
import { PluginData } from "knub";
import { AutomodPluginType } from "../types";
import { RecentActionType } from "../constants";
export function getMatchingRecentActions(
pluginData: PluginData<AutomodPluginType>,
type: RecentActionType,
identifier: string | null,
since: number,
to?: number,
) {
to = to || Date.now();
return pluginData.state.recentActions.filter(action => {
return (
action.type === type &&
(!identifier || action.identifier === identifier) &&
action.context.timestamp >= since &&
action.context.timestamp <= to
);
});
}

View file

@ -0,0 +1,58 @@
import { SavedMessage } from "../../../data/entities/SavedMessage";
import { resolveMember } from "../../../utils";
import { PluginData } from "knub";
import { AutomodPluginType } from "../types";
type TextTriggerWithMultipleMatchTypes = {
match_messages: boolean;
match_embeds: boolean;
match_visible_names: boolean;
match_usernames: boolean;
match_nicknames: boolean;
match_custom_status: boolean;
};
export type MatchableTextType = "message" | "embed" | "visiblename" | "username" | "nickname" | "customstatus";
type YieldedContent = [MatchableTextType, string];
/**
* Generator function that allows iterating through matchable pieces of text of a SavedMessage
*/
export async function* matchMultipleTextTypesOnMessage(
pluginData: PluginData<AutomodPluginType>,
trigger: TextTriggerWithMultipleMatchTypes,
msg: SavedMessage,
): AsyncIterableIterator<YieldedContent> {
const member = await resolveMember(pluginData.client, pluginData.guild, msg.user_id);
if (!member) return;
if (trigger.match_messages && msg.data.content) {
yield ["message", msg.data.content];
}
if (trigger.match_embeds && msg.data.embeds && msg.data.embeds.length) {
const copiedEmbed = JSON.parse(JSON.stringify(msg.data.embeds[0]));
if (copiedEmbed.type === "video") {
copiedEmbed.description = ""; // The description is not rendered, hence it doesn't need to be matched
}
yield ["embed", JSON.stringify(copiedEmbed)];
}
if (trigger.match_visible_names) {
yield ["visiblename", member.nick || msg.data.author.username];
}
if (trigger.match_usernames) {
yield ["username", `${msg.data.author.username}#${msg.data.author.discriminator}`];
}
if (trigger.match_nicknames && member.nick) {
yield ["nickname", member.nick];
}
// type 4 = custom status
if (trigger.match_custom_status && member.game?.type === 4 && member.game?.state) {
yield ["customstatus", member.game.state];
}
}

View file

@ -0,0 +1,32 @@
import { disableUserNotificationStrings, UserNotificationMethod } from "../../../utils";
import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError";
import { TextChannel } from "eris";
import { PluginData } from "knub";
import { AutomodPluginType } from "../types";
export function resolveActionContactMethods(
pluginData: PluginData<AutomodPluginType>,
actionConfig: {
notify?: string;
notifyChannel?: string;
},
): UserNotificationMethod[] | null {
if (actionConfig.notify === "dm") {
return [{ type: "dm" }];
} else if (actionConfig.notify === "channel") {
if (!actionConfig.notifyChannel) {
throw new RecoverablePluginError(ERRORS.NO_USER_NOTIFICATION_CHANNEL);
}
const channel = pluginData.guild.channels.get(actionConfig.notifyChannel);
if (!(channel instanceof TextChannel)) {
throw new RecoverablePluginError(ERRORS.INVALID_USER_NOTIFICATION_CHANNEL);
}
return [{ type: "channel", channel }];
} else if (actionConfig.notify && disableUserNotificationStrings.includes(actionConfig.notify)) {
return [];
}
return null;
}

View file

@ -0,0 +1,86 @@
import { PluginData } from "knub";
import { AutomodContext, AutomodPluginType } from "../types";
import { availableTriggers } from "../triggers/availableTriggers";
import { availableActions } from "../actions/availableActions";
import { AutomodTriggerMatchResult } from "../helpers";
import { CleanAction } from "../actions/clean";
export async function runAutomod(pluginData: PluginData<AutomodPluginType>, context: AutomodContext) {
const userId = context.user?.id || context.message?.user_id;
const 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);
const categoryId = channel?.parentID;
const config = pluginData.config.getMatchingConfig({
channelId,
categoryId,
userId,
member,
});
for (const [ruleName, rule] of Object.entries(config.rules)) {
if (rule.enabled === false) continue;
if (!rule.affects_bots && user.bot) continue;
let matchResult: AutomodTriggerMatchResult<any>;
let contexts: AutomodContext[];
triggerLoop: for (const triggerItem of rule.triggers) {
for (const [triggerName, triggerConfig] of Object.entries(triggerItem)) {
const trigger = availableTriggers[triggerName];
matchResult = await trigger.match({
ruleName,
pluginData,
context,
triggerConfig,
});
if (matchResult) {
contexts = [context, ...(matchResult.extraContexts || [])];
for (const _context of contexts) {
_context.actioned = true;
}
matchResult.summary = await trigger.renderMatchInformation({
ruleName,
pluginData,
contexts,
matchResult,
triggerConfig,
});
if (matchResult.silentClean) {
await CleanAction.apply({
ruleName,
pluginData,
contexts,
actionConfig: true,
matchResult,
});
return;
}
break triggerLoop;
}
}
}
if (matchResult) {
for (const [actionName, actionConfig] of Object.entries(rule.actions)) {
const action = availableActions[actionName];
action.apply({
ruleName,
pluginData,
contexts,
actionConfig,
matchResult,
});
}
break;
}
}
}

View file

@ -0,0 +1,18 @@
import { User } from "eris";
import { PluginData } from "knub";
import { AutomodPluginType } from "../types";
export async function setAntiraidLevel(
pluginData: PluginData<AutomodPluginType>,
newLevel: string | null,
user?: User,
) {
pluginData.state.cachedAntiraidLevel = newLevel;
await pluginData.state.antiraidLevels.set(newLevel);
if (user) {
// TODO: Log user action
} else {
// TODO: Log automatic action
}
}

View file

@ -0,0 +1,5 @@
import { RecentAction } from "../types";
export function sumRecentActionCounts(actions: RecentAction[]) {
return actions.reduce((total, action) => total + action.count, 0);
}

View file

@ -0,0 +1,71 @@
import { PluginData } from "knub";
import { Awaitable } from "knub/dist/utils";
import * as t from "io-ts";
import { AutomodContext, AutomodPluginType } from "./types";
export interface AutomodTriggerMatchResult<TExtra extends any = unknown> {
extraContexts?: AutomodContext[];
extra?: TExtra;
silentClean?: boolean; // TODO: Maybe generalize to a "silent" value in general, which mutes alert/log
summary?: string;
}
type AutomodTriggerMatchFn<TConfigType, TMatchResultExtra> = (meta: {
ruleName: string;
pluginData: PluginData<AutomodPluginType>;
context: AutomodContext;
triggerConfig: TConfigType;
}) => Awaitable<null | AutomodTriggerMatchResult<TMatchResultExtra>>;
type AutomodTriggerRenderMatchInformationFn<TConfigType, TMatchResultExtra> = (meta: {
ruleName: string;
pluginData: PluginData<AutomodPluginType>;
contexts: AutomodContext[];
triggerConfig: TConfigType;
matchResult: AutomodTriggerMatchResult<TMatchResultExtra>;
}) => Awaitable<string>;
export interface AutomodTriggerBlueprint<TConfigType extends t.Any, TMatchResultExtra> {
configType: TConfigType;
defaultConfig: Partial<t.TypeOf<TConfigType>>;
match: AutomodTriggerMatchFn<t.TypeOf<TConfigType>, TMatchResultExtra>;
renderMatchInformation: AutomodTriggerRenderMatchInformationFn<t.TypeOf<TConfigType>, TMatchResultExtra>;
}
export function automodTrigger<TMatchResultExtra>(): <TConfigType extends t.Any>(
blueprint: AutomodTriggerBlueprint<TConfigType, TMatchResultExtra>,
) => 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: {
ruleName: string;
pluginData: PluginData<AutomodPluginType>;
contexts: AutomodContext[];
actionConfig: TConfigType;
matchResult: AutomodTriggerMatchResult;
}) => Awaitable<void>;
export interface AutomodActionBlueprint<TConfigType extends t.Any> {
configType: TConfigType;
apply: AutomodActionApplyFn<t.TypeOf<TConfigType>>;
}
export function automodAction<TConfigType extends t.Any>(
blueprint: AutomodActionBlueprint<TConfigType>,
): AutomodActionBlueprint<TConfigType> {
return blueprint;
}

View file

@ -0,0 +1,4 @@
import { RecentActionType } from "../constants";
import { createMessageSpamTrigger } from "../functions/createMessageSpamTrigger";
export const AttachmentSpamTrigger = createMessageSpamTrigger(RecentActionType.Attachment, "attachment");

View file

@ -0,0 +1,52 @@
import * as t from "io-ts";
import { MatchWordsTrigger } from "./matchWords";
import { AutomodTriggerBlueprint } from "../helpers";
import { MessageSpamTrigger } from "./messageSpam";
import { MentionSpamTrigger } from "./mentionSpam";
import { LinkSpamTrigger } from "./linkSpam";
import { AttachmentSpamTrigger } from "./attachmentSpam";
import { EmojiSpamTrigger } from "./emojiSpam";
import { LineSpamTrigger } from "./lineSpam";
import { CharacterSpamTrigger } from "./characterSpam";
import { MatchRegexTrigger } from "./matchRegex";
import { MatchInvitesTrigger } from "./matchInvites";
import { MatchLinksTrigger } from "./matchLinks";
import { MatchAttachmentTypeTrigger } from "./matchAttachmentType";
import { MemberJoinSpamTrigger } from "./memberJoinSpam";
import { MemberJoinTrigger } from "./memberJoin";
export const availableTriggers: Record<string, AutomodTriggerBlueprint<any, any>> = {
match_words: MatchWordsTrigger,
match_regex: MatchRegexTrigger,
match_invites: MatchInvitesTrigger,
match_links: MatchLinksTrigger,
match_attachment_type: MatchAttachmentTypeTrigger,
member_join: MemberJoinTrigger,
message_spam: MessageSpamTrigger,
mention_spam: MentionSpamTrigger,
link_spam: LinkSpamTrigger,
attachment_spam: AttachmentSpamTrigger,
emoji_spam: EmojiSpamTrigger,
line_spam: LineSpamTrigger,
character_spam: CharacterSpamTrigger,
member_join_spam: MemberJoinSpamTrigger,
};
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,
member_join: MemberJoinTrigger.configType,
message_spam: MessageSpamTrigger.configType,
mention_spam: MentionSpamTrigger.configType,
link_spam: LinkSpamTrigger.configType,
attachment_spam: AttachmentSpamTrigger.configType,
emoji_spam: EmojiSpamTrigger.configType,
line_spam: LineSpamTrigger.configType,
character_spam: CharacterSpamTrigger.configType,
member_join_spam: MemberJoinSpamTrigger.configType,
});

View file

@ -0,0 +1,4 @@
import { RecentActionType } from "../constants";
import { createMessageSpamTrigger } from "../functions/createMessageSpamTrigger";
export const CharacterSpamTrigger = createMessageSpamTrigger(RecentActionType.Character, "character");

View file

@ -0,0 +1,4 @@
import { RecentActionType } from "../constants";
import { createMessageSpamTrigger } from "../functions/createMessageSpamTrigger";
export const EmojiSpamTrigger = createMessageSpamTrigger(RecentActionType.Emoji, "emoji");

View file

@ -0,0 +1,31 @@
import * as t from "io-ts";
import { automodTrigger } from "../helpers";
interface ExampleMatchResultType {
isBanana: boolean;
}
export const ExampleTrigger = automodTrigger<ExampleMatchResultType>()({
configType: t.type({
allowedFruits: t.array(t.string),
}),
defaultConfig: {
allowedFruits: ["peach", "banana"],
},
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,4 @@
import { RecentActionType } from "../constants";
import { createMessageSpamTrigger } from "../functions/createMessageSpamTrigger";
export const LineSpamTrigger = createMessageSpamTrigger(RecentActionType.Line, "line");

View file

@ -0,0 +1,4 @@
import { RecentActionType } from "../constants";
import { createMessageSpamTrigger } from "../functions/createMessageSpamTrigger";
export const LinkSpamTrigger = createMessageSpamTrigger(RecentActionType.Link, "link");

View file

@ -0,0 +1,73 @@
import * as t from "io-ts";
import { automodTrigger } from "../helpers";
import { asSingleLine, disableCodeBlocks, disableInlineCode, verboseChannelMention } from "../../../utils";
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,101 @@
import * as t from "io-ts";
import { GuildInvite } from "eris";
import { automodTrigger } from "../helpers";
import {
disableCodeBlocks,
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,147 @@
import * as t from "io-ts";
import escapeStringRegexp from "escape-string-regexp";
import { automodTrigger } from "../helpers";
import {
asSingleLine,
disableCodeBlocks,
disableInlineCode,
getUrlsInString,
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

@ -0,0 +1,72 @@
import * as t from "io-ts";
import { transliterate } from "transliteration";
import { automodTrigger } from "../helpers";
import { disableInlineCode, verboseChannelMention } from "../../../utils";
import { MatchableTextType, matchMultipleTextTypesOnMessage } from "../functions/matchMultipleTextTypesOnMessage";
import { TSafeRegex } from "../../../validatorUtils";
interface MatchResultType {
pattern: string;
type: MatchableTextType;
}
export const MatchRegexTrigger = automodTrigger<MatchResultType>()({
configType: 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,
}),
defaultConfig: {
case_sensitive: false,
normalize: false,
match_messages: true,
match_embeds: true,
match_visible_names: false,
match_usernames: false,
match_nicknames: false,
match_custom_status: false,
},
async match({ pluginData, context, triggerConfig: trigger }) {
if (!context.message) {
return;
}
for await (let [type, str] of matchMultipleTextTypesOnMessage(pluginData, trigger, context.message)) {
if (trigger.normalize) {
str = transliterate(str);
}
for (const sourceRegex of trigger.patterns) {
const regex = new RegExp(sourceRegex.source, trigger.case_sensitive ? "" : "i");
const test = regex.test(str);
if (test) {
return {
extra: {
pattern: sourceRegex.source,
type,
},
};
}
}
}
return null;
},
renderMatchInformation({ pluginData, contexts, matchResult }) {
const channel = pluginData.guild.channels.get(contexts[0].message.channel_id);
const prettyChannel = verboseChannelMention(channel);
return `Matched regex \`${disableInlineCode(matchResult.extra.pattern)}\` in message (\`${
contexts[0].message.id
}\`) in ${prettyChannel}:`;
},
});

View file

@ -0,0 +1,90 @@
import * as t from "io-ts";
import { transliterate } from "transliteration";
import escapeStringRegexp from "escape-string-regexp";
import { automodTrigger } from "../helpers";
import { disableInlineCode, verboseChannelMention } from "../../../utils";
import { MatchableTextType, matchMultipleTextTypesOnMessage } from "../functions/matchMultipleTextTypesOnMessage";
interface MatchResultType {
word: string;
type: MatchableTextType;
}
export const MatchWordsTrigger = automodTrigger<MatchResultType>()({
configType: t.type({
words: t.array(t.string),
case_sensitive: t.boolean,
only_full_words: t.boolean,
normalize: t.boolean,
loose_matching: t.boolean,
loose_matching_threshold: t.number,
match_messages: t.boolean,
match_embeds: t.boolean,
match_visible_names: t.boolean,
match_usernames: t.boolean,
match_nicknames: t.boolean,
match_custom_status: t.boolean,
}),
defaultConfig: {
case_sensitive: false,
only_full_words: true,
normalize: false,
loose_matching: false,
loose_matching_threshold: 4,
match_messages: true,
match_embeds: true,
match_visible_names: false,
match_usernames: false,
match_nicknames: false,
match_custom_status: false,
},
async match({ pluginData, context, triggerConfig: trigger }) {
if (!context.message) {
return;
}
for await (let [type, str] of matchMultipleTextTypesOnMessage(pluginData, trigger, context.message)) {
if (trigger.normalize) {
str = transliterate(str);
}
const looseMatchingThreshold = Math.min(Math.max(trigger.loose_matching_threshold, 1), 64);
for (const word of trigger.words) {
// When performing loose matching, allow any amount of whitespace or up to looseMatchingThreshold number of other
// characters between the matched characters. E.g. if we're matching banana, a loose match could also match b a n a n a
let pattern = trigger.loose_matching
? [...word].map(c => escapeStringRegexp(c)).join(`(?:\\s*|.{0,${looseMatchingThreshold})`)
: escapeStringRegexp(word);
if (trigger.only_full_words) {
pattern = `\\b${pattern}\\b`;
}
const regex = new RegExp(pattern, trigger.case_sensitive ? "" : "i");
const test = regex.test(str);
if (test) {
return {
extra: {
word,
type,
},
};
}
}
}
return null;
},
renderMatchInformation({ pluginData, contexts, matchResult }) {
const channel = pluginData.guild.channels.get(contexts[0].message.channel_id);
const prettyChannel = verboseChannelMention(channel);
return `Matched word \`${disableInlineCode(matchResult.extra.word)}\` in message (\`${
contexts[0].message.id
}\`) in ${prettyChannel}:`;
},
});

View file

@ -0,0 +1,34 @@
import * as t from "io-ts";
import { automodTrigger } from "../helpers";
import { convertDelayStringToMS, tDelayString } from "../../../utils";
export const MemberJoinTrigger = automodTrigger<unknown>()({
configType: t.type({
only_new: t.boolean,
new_threshold: tDelayString,
}),
defaultConfig: {
only_new: false,
new_threshold: "1h",
},
async match({ pluginData, context, triggerConfig }) {
if (!context.joined || !context.member) {
return;
}
if (triggerConfig.only_new) {
const threshold = Date.now() - convertDelayStringToMS(triggerConfig.new_threshold);
if (context.member.createdAt >= threshold) {
return {};
}
}
return {};
},
renderMatchInformation({ pluginData, contexts, triggerConfig }) {
return null;
},
});

View file

@ -0,0 +1,55 @@
import * as t from "io-ts";
import { automodTrigger } from "../helpers";
import { convertDelayStringToMS, tDelayString } from "../../../utils";
import { getMatchingRecentActions } from "../functions/getMatchingRecentActions";
import { RecentActionType } from "../constants";
import { sumRecentActionCounts } from "../functions/sumRecentActionCounts";
import { findRecentSpam } from "../functions/findRecentSpam";
export const MemberJoinSpamTrigger = automodTrigger<unknown>()({
configType: t.type({
amount: t.number,
within: tDelayString,
}),
defaultConfig: {},
async match({ pluginData, context, triggerConfig }) {
if (!context.joined || !context.member) {
return;
}
const recentSpam = findRecentSpam(pluginData, RecentActionType.MemberJoin);
if (recentSpam) {
context.actioned = true;
return {};
}
const since = Date.now() - convertDelayStringToMS(triggerConfig.within);
const matchingActions = getMatchingRecentActions(pluginData, RecentActionType.MemberJoin, null, since);
const totalCount = sumRecentActionCounts(matchingActions);
if (totalCount >= triggerConfig.amount) {
const contexts = [context, ...matchingActions.map(a => a.context).filter(c => c !== context)];
for (const _context of contexts) {
_context.actioned = true;
}
pluginData.state.recentSpam.push({
type: RecentActionType.MemberJoin,
timestamp: Date.now(),
archiveId: null,
userIds: [],
});
return {
extraContexts: contexts,
};
}
},
renderMatchInformation({ pluginData, contexts, triggerConfig }) {
return null;
},
});

View file

@ -0,0 +1,4 @@
import { RecentActionType } from "../constants";
import { createMessageSpamTrigger } from "../functions/createMessageSpamTrigger";
export const MentionSpamTrigger = createMessageSpamTrigger(RecentActionType.Mention, "mention");

View file

@ -0,0 +1,4 @@
import { RecentActionType } from "../constants";
import { createMessageSpamTrigger } from "../functions/createMessageSpamTrigger";
export const MessageSpamTrigger = createMessageSpamTrigger(RecentActionType.Message, "message");

View file

@ -0,0 +1,95 @@
import * as t from "io-ts";
import { tNullable, UnknownUser } from "../../utils";
import { BasePluginType } from "knub";
import { GuildSavedMessages } from "../../data/GuildSavedMessages";
import { GuildLogs } from "../../data/GuildLogs";
import { SavedMessage } from "../../data/entities/SavedMessage";
import { Member, User } from "eris";
import { AvailableTriggers } from "./triggers/availableTriggers";
import { AvailableActions } from "./actions/availableActions";
import { Queue } from "../../Queue";
import { GuildAntiraidLevels } from "../../data/GuildAntiraidLevels";
import { GuildArchives } from "../../data/GuildArchives";
import { RecentActionType } from "./constants";
import Timeout = NodeJS.Timeout;
export const Rule = t.type({
enabled: t.boolean,
name: t.string,
presets: tNullable(t.array(t.string)),
affects_bots: t.boolean,
triggers: t.array(t.partial(AvailableTriggers.props)),
actions: t.partial(AvailableActions.props),
cooldown: tNullable(t.string),
});
export type TRule = t.TypeOf<typeof Rule>;
export const ConfigSchema = t.type({
rules: t.record(t.string, Rule),
antiraid_levels: t.array(t.string),
can_set_antiraid: t.boolean,
can_view_antiraid: t.boolean,
});
export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
export interface AutomodPluginType extends BasePluginType {
config: TConfigSchema;
state: {
/**
* Automod checks/actions are handled in a queue so we don't get overlap on the same user
*/
queue: Queue;
/**
* Recent actions are used for spam triggers
*/
recentActions: RecentAction[];
clearRecentActionsInterval: Timeout;
/**
* After a spam trigger is tripped and the rule's action carried out, a unique identifier is placed here so further
* spam (either messages that were sent before the bot managed to mute the user or, with global spam, other users
* continuing to spam) is "included" in the same match and doesn't generate duplicate cases or logs.
* Key: rule_name-match_identifier
*/
recentSpam: RecentSpam[];
clearRecentSpamInterval: Timeout;
recentNicknameChanges: Map<string, { timestamp: number }>;
clearRecentNicknameChangesInterval: Timeout;
cachedAntiraidLevel: string | null;
savedMessages: GuildSavedMessages;
logs: GuildLogs;
antiraidLevels: GuildAntiraidLevels;
archives: GuildArchives;
onMessageCreateFn: any;
onMessageUpdateFn: any;
};
}
export interface AutomodContext {
timestamp: number;
actioned?: boolean;
user?: User | UnknownUser;
message?: SavedMessage;
member?: Member;
joined?: boolean;
}
export interface RecentAction {
type: RecentActionType;
identifier: string;
count: number;
context: AutomodContext;
}
export interface RecentSpam {
archiveId: string;
type: RecentActionType;
userIds: string[];
timestamp: number;
}

View file

@ -1,7 +1,7 @@
import { zeppelinPlugin } from "../ZeppelinPluginBlueprint";
import { CasesPlugin } from "../Cases/CasesPlugin";
import { MutesPlugin } from "../Mutes/MutesPlugin";
import { ConfigSchema, ModActionsPluginType } from "./types";
import { BanOptions, ConfigSchema, KickOptions, ModActionsPluginType, WarnOptions } from "./types";
import { CreateBanCaseOnManualBanEvt } from "./events/CreateBanCaseOnManualBanEvt";
import { CreateUnbanCaseOnManualUnbanEvt } from "./events/CreateUnbanCaseOnManualUnbanEvt";
import { CreateKickCaseOnManualKickEvt } from "./events/CreateKickCaseOnManualKickEvt";
@ -28,6 +28,10 @@ import { GuildMutes } from "src/data/GuildMutes";
import { GuildCases } from "src/data/GuildCases";
import { GuildLogs } from "src/data/GuildLogs";
import { ForceUnmuteCmd } from "./commands/ForceunmuteCmd";
import { warnMember } from "./functions/warnMember";
import { Member } from "eris";
import { kickMember } from "./functions/kickMember";
import { banUserId } from "./functions/banUserId";
const defaultOptions = {
config: {
@ -119,6 +123,26 @@ export const ModActionsPlugin = zeppelinPlugin<ModActionsPluginType>()("mod_acti
UnhideCaseCmd,
],
public: {
warnMember(pluginData) {
return (member: Member, reason: string, warnOptions?: WarnOptions) => {
warnMember(pluginData, member, reason, warnOptions);
};
},
kickMember(pluginData) {
return (member: Member, reason: string, kickOptions?: KickOptions) => {
kickMember(pluginData, member, reason, kickOptions);
};
},
banUserId(pluginData) {
return (userId: string, reason?: string, banOptions?: BanOptions) => {
banUserId(pluginData, userId, reason, banOptions);
};
},
},
onLoad(pluginData) {
const { state, guild } = pluginData;

View file

@ -26,12 +26,12 @@ export function zeppelinPlugin<TPluginType extends BasePluginType>(): <
>(
name: string,
blueprint: TPartialBlueprint,
) => TPartialBlueprint & { name: string };
) => TPartialBlueprint & { name: string; configPreprocessor: PluginBlueprint<TPluginType>["configPreprocessor"] };
export function zeppelinPlugin(...args) {
if (args.length) {
const blueprint: ZeppelinPluginBlueprint = plugin(...(args as Parameters<typeof plugin>));
blueprint.configPreprocessor = getPluginConfigPreprocessor(blueprint);
blueprint.configPreprocessor = getPluginConfigPreprocessor(blueprint, blueprint.configPreprocessor);
return blueprint;
} else {
return zeppelinPlugin;

View file

@ -23,6 +23,7 @@ import { SlowmodePlugin } from "./Slowmode/SlowmodePlugin";
import { StarboardPlugin } from "./Starboard/StarboardPlugin";
import { ChannelArchiverPlugin } from "./ChannelArchiver/ChannelArchiverPlugin";
import { ReactionRolesPlugin } from "./ReactionRoles/ReactionRolesPlugin";
import { AutomodPlugin } from "./Automod/AutomodPlugin";
// prettier-ignore
export const guildPlugins: Array<ZeppelinPluginBlueprint<any>> = [
@ -49,6 +50,7 @@ export const guildPlugins: Array<ZeppelinPluginBlueprint<any>> = [
WelcomeMessagePlugin,
CasesPlugin,
MutesPlugin,
AutomodPlugin,
];
// prettier-ignore

View file

@ -1,4 +1,5 @@
import {
AnyInvite,
Attachment,
ChannelInvite,
Client,
@ -9,6 +10,7 @@ import {
GuildAuditLog,
GuildAuditLogEntry,
GuildChannel,
GuildInvite,
Member,
Message,
MessageContent,
@ -32,6 +34,7 @@ import { either } from "fp-ts/lib/Either";
import moment from "moment-timezone";
import { SimpleCache } from "./SimpleCache";
import { logger } from "./logger";
import { Awaitable } from "knub/dist/utils";
const fsp = fs.promises;
@ -1216,3 +1219,11 @@ 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;
}
export function asyncMap<T, R>(arr: T[], fn: (item: T) => Promise<R>): Promise<R[]> {
return Promise.all(arr.map((item, index) => fn(item)));
}