mirror of
https://github.com/ZeppelinBot/Zeppelin.git
synced 2025-03-16 22:21:51 +00:00
Merge branch 'knub30' into k30_logs
This commit is contained in:
commit
930ca184b4
88 changed files with 3600 additions and 6 deletions
|
@ -41,4 +41,8 @@ export class Queue {
|
||||||
setTimeout(resolve, this.timeout);
|
setTimeout(resolve, this.timeout);
|
||||||
}).then(() => this.next());
|
}).then(() => this.next());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public clear() {
|
||||||
|
this.queue.splice(0, this.queue.length);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Member } from "eris";
|
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 { decodeAndValidateStrict, StrictValidationError } from "./validatorUtils";
|
||||||
import { deepKeyIntersect, errorMessage, successMessage } from "./utils";
|
import { deepKeyIntersect, errorMessage, successMessage } from "./utils";
|
||||||
import { ZeppelinPluginBlueprint } from "./plugins/ZeppelinPluginBlueprint";
|
import { ZeppelinPluginBlueprint } from "./plugins/ZeppelinPluginBlueprint";
|
||||||
|
@ -27,8 +27,15 @@ export function hasPermission(pluginData: PluginData<any>, permission: string, m
|
||||||
return helpers.hasPermission(config, permission);
|
return helpers.hasPermission(config, permission);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPluginConfigPreprocessor(blueprint: ZeppelinPluginBlueprint) {
|
export function getPluginConfigPreprocessor(
|
||||||
return (options: PluginOptions<any>) => {
|
blueprint: ZeppelinPluginBlueprint,
|
||||||
|
customPreprocessor?: PluginBlueprint<any>["configPreprocessor"],
|
||||||
|
) {
|
||||||
|
return async (options: PluginOptions<any>) => {
|
||||||
|
if (customPreprocessor) {
|
||||||
|
options = await customPreprocessor(options);
|
||||||
|
}
|
||||||
|
|
||||||
const decodedConfig = blueprint.configSchema
|
const decodedConfig = blueprint.configSchema
|
||||||
? decodeAndValidateStrict(blueprint.configSchema, options.config)
|
? decodeAndValidateStrict(blueprint.configSchema, options.config)
|
||||||
: options.config;
|
: options.config;
|
||||||
|
|
159
backend/src/plugins/Automod/AutomodPlugin.ts
Normal file
159
backend/src/plugins/Automod/AutomodPlugin.ts
Normal 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);
|
||||||
|
},
|
||||||
|
});
|
35
backend/src/plugins/Automod/actions/addRoles.ts
Normal file
35
backend/src/plugins/Automod/actions/addRoles.ts
Normal 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
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
48
backend/src/plugins/Automod/actions/alert.ts
Normal file
48
backend/src/plugins/Automod/actions/alert.ts
Normal 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}**`,
|
||||||
|
});*/
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
11
backend/src/plugins/Automod/actions/availableActions.ts
Normal file
11
backend/src/plugins/Automod/actions/availableActions.ts
Normal 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,
|
||||||
|
});
|
35
backend/src/plugins/Automod/actions/ban.ts
Normal file
35
backend/src/plugins/Automod/actions/ban.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
27
backend/src/plugins/Automod/actions/changeNickname.ts
Normal file
27
backend/src/plugins/Automod/actions/changeNickname.ts
Normal 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() });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
28
backend/src/plugins/Automod/actions/clean.ts
Normal file
28
backend/src/plugins/Automod/actions/clean.ts
Normal 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
12
backend/src/plugins/Automod/actions/exampleAction.ts
Normal file
12
backend/src/plugins/Automod/actions/exampleAction.ts
Normal 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
|
||||||
|
},
|
||||||
|
});
|
34
backend/src/plugins/Automod/actions/kick.ts
Normal file
34
backend/src/plugins/Automod/actions/kick.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
10
backend/src/plugins/Automod/actions/log.ts
Normal file
10
backend/src/plugins/Automod/actions/log.ts
Normal 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
|
||||||
|
},
|
||||||
|
});
|
36
backend/src/plugins/Automod/actions/mute.ts
Normal file
36
backend/src/plugins/Automod/actions/mute.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
35
backend/src/plugins/Automod/actions/removeRoles.ts
Normal file
35
backend/src/plugins/Automod/actions/removeRoles.ts
Normal 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
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
63
backend/src/plugins/Automod/actions/reply.ts
Normal file
63
backend/src/plugins/Automod/actions/reply.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
11
backend/src/plugins/Automod/actions/setAntiraidLevel.ts
Normal file
11
backend/src/plugins/Automod/actions/setAntiraidLevel.ts
Normal 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);
|
||||||
|
},
|
||||||
|
});
|
34
backend/src/plugins/Automod/actions/warn.ts
Normal file
34
backend/src/plugins/Automod/actions/warn.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
17
backend/src/plugins/Automod/constants.ts
Normal file
17
backend/src/plugins/Automod/constants.ts
Normal 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,
|
||||||
|
}
|
27
backend/src/plugins/Automod/events/RunAutomodOnJoinEvt.ts
Normal file
27
backend/src/plugins/Automod/events/RunAutomodOnJoinEvt.ts
Normal 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);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
23
backend/src/plugins/Automod/events/runAutomodOnMessage.ts
Normal file
23
backend/src/plugins/Automod/events/runAutomodOnMessage.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
});
|
||||||
|
}
|
10
backend/src/plugins/Automod/functions/clearOldRecentSpam.ts
Normal file
10
backend/src/plugins/Automod/functions/clearOldRecentSpam.ts
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
|
@ -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}`;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
9
backend/src/plugins/Automod/functions/findRecentSpam.ts
Normal file
9
backend/src/plugins/Automod/functions/findRecentSpam.ts
Normal 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));
|
||||||
|
});
|
||||||
|
}
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
|
@ -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];
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
86
backend/src/plugins/Automod/functions/runAutomod.ts
Normal file
86
backend/src/plugins/Automod/functions/runAutomod.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
18
backend/src/plugins/Automod/functions/setAntiraidLevel.ts
Normal file
18
backend/src/plugins/Automod/functions/setAntiraidLevel.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { RecentAction } from "../types";
|
||||||
|
|
||||||
|
export function sumRecentActionCounts(actions: RecentAction[]) {
|
||||||
|
return actions.reduce((total, action) => total + action.count, 0);
|
||||||
|
}
|
71
backend/src/plugins/Automod/helpers.ts
Normal file
71
backend/src/plugins/Automod/helpers.ts
Normal 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;
|
||||||
|
}
|
4
backend/src/plugins/Automod/triggers/attachmentSpam.ts
Normal file
4
backend/src/plugins/Automod/triggers/attachmentSpam.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
import { RecentActionType } from "../constants";
|
||||||
|
import { createMessageSpamTrigger } from "../functions/createMessageSpamTrigger";
|
||||||
|
|
||||||
|
export const AttachmentSpamTrigger = createMessageSpamTrigger(RecentActionType.Attachment, "attachment");
|
52
backend/src/plugins/Automod/triggers/availableTriggers.ts
Normal file
52
backend/src/plugins/Automod/triggers/availableTriggers.ts
Normal 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,
|
||||||
|
});
|
4
backend/src/plugins/Automod/triggers/characterSpam.ts
Normal file
4
backend/src/plugins/Automod/triggers/characterSpam.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
import { RecentActionType } from "../constants";
|
||||||
|
import { createMessageSpamTrigger } from "../functions/createMessageSpamTrigger";
|
||||||
|
|
||||||
|
export const CharacterSpamTrigger = createMessageSpamTrigger(RecentActionType.Character, "character");
|
4
backend/src/plugins/Automod/triggers/emojiSpam.ts
Normal file
4
backend/src/plugins/Automod/triggers/emojiSpam.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
import { RecentActionType } from "../constants";
|
||||||
|
import { createMessageSpamTrigger } from "../functions/createMessageSpamTrigger";
|
||||||
|
|
||||||
|
export const EmojiSpamTrigger = createMessageSpamTrigger(RecentActionType.Emoji, "emoji");
|
31
backend/src/plugins/Automod/triggers/exampleTrigger.ts
Normal file
31
backend/src/plugins/Automod/triggers/exampleTrigger.ts
Normal 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"}`;
|
||||||
|
},
|
||||||
|
});
|
4
backend/src/plugins/Automod/triggers/lineSpam.ts
Normal file
4
backend/src/plugins/Automod/triggers/lineSpam.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
import { RecentActionType } from "../constants";
|
||||||
|
import { createMessageSpamTrigger } from "../functions/createMessageSpamTrigger";
|
||||||
|
|
||||||
|
export const LineSpamTrigger = createMessageSpamTrigger(RecentActionType.Line, "line");
|
4
backend/src/plugins/Automod/triggers/linkSpam.ts
Normal file
4
backend/src/plugins/Automod/triggers/linkSpam.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
import { RecentActionType } from "../constants";
|
||||||
|
import { createMessageSpamTrigger } from "../functions/createMessageSpamTrigger";
|
||||||
|
|
||||||
|
export const LinkSpamTrigger = createMessageSpamTrigger(RecentActionType.Link, "link");
|
73
backend/src/plugins/Automod/triggers/matchAttachmentType.ts
Normal file
73
backend/src/plugins/Automod/triggers/matchAttachmentType.ts
Normal 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) +
|
||||||
|
"```"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
101
backend/src/plugins/Automod/triggers/matchInvites.ts
Normal file
101
backend/src/plugins/Automod/triggers/matchInvites.ts
Normal 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) +
|
||||||
|
"```"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
147
backend/src/plugins/Automod/triggers/matchLinks.ts
Normal file
147
backend/src/plugins/Automod/triggers/matchLinks.ts
Normal 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) +
|
||||||
|
"```"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
72
backend/src/plugins/Automod/triggers/matchRegex.ts
Normal file
72
backend/src/plugins/Automod/triggers/matchRegex.ts
Normal 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}:`;
|
||||||
|
},
|
||||||
|
});
|
90
backend/src/plugins/Automod/triggers/matchWords.ts
Normal file
90
backend/src/plugins/Automod/triggers/matchWords.ts
Normal 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}:`;
|
||||||
|
},
|
||||||
|
});
|
34
backend/src/plugins/Automod/triggers/memberJoin.ts
Normal file
34
backend/src/plugins/Automod/triggers/memberJoin.ts
Normal 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;
|
||||||
|
},
|
||||||
|
});
|
55
backend/src/plugins/Automod/triggers/memberJoinSpam.ts
Normal file
55
backend/src/plugins/Automod/triggers/memberJoinSpam.ts
Normal 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;
|
||||||
|
},
|
||||||
|
});
|
4
backend/src/plugins/Automod/triggers/mentionSpam.ts
Normal file
4
backend/src/plugins/Automod/triggers/mentionSpam.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
import { RecentActionType } from "../constants";
|
||||||
|
import { createMessageSpamTrigger } from "../functions/createMessageSpamTrigger";
|
||||||
|
|
||||||
|
export const MentionSpamTrigger = createMessageSpamTrigger(RecentActionType.Mention, "mention");
|
4
backend/src/plugins/Automod/triggers/messageSpam.ts
Normal file
4
backend/src/plugins/Automod/triggers/messageSpam.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
import { RecentActionType } from "../constants";
|
||||||
|
import { createMessageSpamTrigger } from "../functions/createMessageSpamTrigger";
|
||||||
|
|
||||||
|
export const MessageSpamTrigger = createMessageSpamTrigger(RecentActionType.Message, "message");
|
95
backend/src/plugins/Automod/types.ts
Normal file
95
backend/src/plugins/Automod/types.ts
Normal 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;
|
||||||
|
}
|
|
@ -1,7 +1,7 @@
|
||||||
import { zeppelinPlugin } from "../ZeppelinPluginBlueprint";
|
import { zeppelinPlugin } from "../ZeppelinPluginBlueprint";
|
||||||
import { CasesPlugin } from "../Cases/CasesPlugin";
|
import { CasesPlugin } from "../Cases/CasesPlugin";
|
||||||
import { MutesPlugin } from "../Mutes/MutesPlugin";
|
import { MutesPlugin } from "../Mutes/MutesPlugin";
|
||||||
import { ConfigSchema, ModActionsPluginType } from "./types";
|
import { BanOptions, ConfigSchema, KickOptions, ModActionsPluginType, WarnOptions } from "./types";
|
||||||
import { CreateBanCaseOnManualBanEvt } from "./events/CreateBanCaseOnManualBanEvt";
|
import { CreateBanCaseOnManualBanEvt } from "./events/CreateBanCaseOnManualBanEvt";
|
||||||
import { CreateUnbanCaseOnManualUnbanEvt } from "./events/CreateUnbanCaseOnManualUnbanEvt";
|
import { CreateUnbanCaseOnManualUnbanEvt } from "./events/CreateUnbanCaseOnManualUnbanEvt";
|
||||||
import { CreateKickCaseOnManualKickEvt } from "./events/CreateKickCaseOnManualKickEvt";
|
import { CreateKickCaseOnManualKickEvt } from "./events/CreateKickCaseOnManualKickEvt";
|
||||||
|
@ -28,6 +28,10 @@ import { GuildMutes } from "src/data/GuildMutes";
|
||||||
import { GuildCases } from "src/data/GuildCases";
|
import { GuildCases } from "src/data/GuildCases";
|
||||||
import { GuildLogs } from "src/data/GuildLogs";
|
import { GuildLogs } from "src/data/GuildLogs";
|
||||||
import { ForceUnmuteCmd } from "./commands/ForceunmuteCmd";
|
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 = {
|
const defaultOptions = {
|
||||||
config: {
|
config: {
|
||||||
|
@ -119,6 +123,26 @@ export const ModActionsPlugin = zeppelinPlugin<ModActionsPluginType>()("mod_acti
|
||||||
UnhideCaseCmd,
|
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) {
|
onLoad(pluginData) {
|
||||||
const { state, guild } = pluginData;
|
const { state, guild } = pluginData;
|
||||||
|
|
||||||
|
|
70
backend/src/plugins/ReactionRoles/ReactionRolesPlugin.ts
Normal file
70
backend/src/plugins/ReactionRoles/ReactionRolesPlugin.ts
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
import { zeppelinPlugin } from "../ZeppelinPluginBlueprint";
|
||||||
|
import { PluginOptions } from "knub";
|
||||||
|
import { ConfigSchema, ReactionRolesPluginType } from "./types";
|
||||||
|
import { GuildReactionRoles } from "src/data/GuildReactionRoles";
|
||||||
|
import { GuildSavedMessages } from "src/data/GuildSavedMessages";
|
||||||
|
import { Queue } from "src/Queue";
|
||||||
|
import { autoRefreshLoop } from "./util/autoRefreshLoop";
|
||||||
|
import { InitReactionRolesCmd } from "./commands/InitReactionRolesCmd";
|
||||||
|
import { RefreshReactionRolesCmd } from "./commands/RefreshReactionRolesCmd";
|
||||||
|
import { ClearReactionRolesCmd } from "./commands/ClearReactionRolesCmd";
|
||||||
|
import { AddReactionRoleEvt } from "./events/AddReactionRoleEvt";
|
||||||
|
|
||||||
|
const MIN_AUTO_REFRESH = 1000 * 60 * 15; // 15min minimum, let's not abuse the API
|
||||||
|
|
||||||
|
const defaultOptions: PluginOptions<ReactionRolesPluginType> = {
|
||||||
|
config: {
|
||||||
|
auto_refresh_interval: MIN_AUTO_REFRESH,
|
||||||
|
|
||||||
|
can_manage: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
level: ">=100",
|
||||||
|
config: {
|
||||||
|
can_manage: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ReactionRolesPlugin = zeppelinPlugin<ReactionRolesPluginType>()("reaction_roles", {
|
||||||
|
configSchema: ConfigSchema,
|
||||||
|
defaultOptions,
|
||||||
|
|
||||||
|
// prettier-ignore
|
||||||
|
commands: [
|
||||||
|
RefreshReactionRolesCmd,
|
||||||
|
ClearReactionRolesCmd,
|
||||||
|
InitReactionRolesCmd,
|
||||||
|
],
|
||||||
|
|
||||||
|
// prettier-ignore
|
||||||
|
events: [
|
||||||
|
AddReactionRoleEvt,
|
||||||
|
],
|
||||||
|
|
||||||
|
onLoad(pluginData) {
|
||||||
|
const { state, guild } = pluginData;
|
||||||
|
|
||||||
|
state.reactionRoles = GuildReactionRoles.getGuildInstance(guild.id);
|
||||||
|
state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id);
|
||||||
|
state.reactionRemoveQueue = new Queue();
|
||||||
|
state.roleChangeQueue = new Queue();
|
||||||
|
state.pendingRoleChanges = new Map();
|
||||||
|
state.pendingRefreshes = new Set();
|
||||||
|
|
||||||
|
let autoRefreshInterval = pluginData.config.get().auto_refresh_interval;
|
||||||
|
if (autoRefreshInterval != null) {
|
||||||
|
autoRefreshInterval = Math.max(MIN_AUTO_REFRESH, autoRefreshInterval);
|
||||||
|
autoRefreshLoop(pluginData, autoRefreshInterval);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onUnload(pluginData) {
|
||||||
|
if (pluginData.state.autoRefreshTimeout) {
|
||||||
|
clearTimeout(pluginData.state.autoRefreshTimeout);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { reactionRolesCmd } from "../types";
|
||||||
|
import { commandTypeHelpers as ct } from "../../../commandTypes";
|
||||||
|
import { sendErrorMessage, sendSuccessMessage } from "src/pluginUtils";
|
||||||
|
import { TextChannel } from "eris";
|
||||||
|
|
||||||
|
export const ClearReactionRolesCmd = reactionRolesCmd({
|
||||||
|
trigger: "reaction_roles clear",
|
||||||
|
permission: "can_manage",
|
||||||
|
|
||||||
|
signature: {
|
||||||
|
messageId: ct.string(),
|
||||||
|
},
|
||||||
|
|
||||||
|
async run({ message: msg, args, pluginData }) {
|
||||||
|
const savedMessage = await pluginData.state.savedMessages.find(args.messageId);
|
||||||
|
if (!savedMessage) {
|
||||||
|
sendErrorMessage(pluginData, msg.channel, "Unknown message");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingReactionRoles = pluginData.state.reactionRoles.getForMessage(args.messageId);
|
||||||
|
if (!existingReactionRoles) {
|
||||||
|
sendErrorMessage(pluginData, msg.channel, "Message doesn't have reaction roles on it");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pluginData.state.reactionRoles.removeFromMessage(args.messageId);
|
||||||
|
|
||||||
|
const channel = pluginData.guild.channels.get(savedMessage.channel_id) as TextChannel;
|
||||||
|
const targetMessage = await channel.getMessage(savedMessage.id);
|
||||||
|
await targetMessage.removeReactions();
|
||||||
|
|
||||||
|
sendSuccessMessage(pluginData, msg.channel, "Reaction roles cleared");
|
||||||
|
},
|
||||||
|
});
|
|
@ -0,0 +1,107 @@
|
||||||
|
import { reactionRolesCmd, TReactionRolePair } from "../types";
|
||||||
|
import { commandTypeHelpers as ct } from "../../../commandTypes";
|
||||||
|
import { sendErrorMessage, sendSuccessMessage } from "src/pluginUtils";
|
||||||
|
import { TextChannel } from "eris";
|
||||||
|
import { RecoverablePluginError, ERRORS } from "src/RecoverablePluginError";
|
||||||
|
import { canUseEmoji } from "src/utils";
|
||||||
|
import { applyReactionRoleReactionsToMessage } from "../util/applyReactionRoleReactionsToMessage";
|
||||||
|
|
||||||
|
const CLEAR_ROLES_EMOJI = "❌";
|
||||||
|
|
||||||
|
export const InitReactionRolesCmd = reactionRolesCmd({
|
||||||
|
trigger: "reaction_roles",
|
||||||
|
permission: "can_manage",
|
||||||
|
|
||||||
|
signature: {
|
||||||
|
messageId: ct.string(),
|
||||||
|
reactionRolePairs: ct.string({ catchAll: true }),
|
||||||
|
|
||||||
|
exclusive: ct.bool({ option: true, isSwitch: true, shortcut: "e" }),
|
||||||
|
},
|
||||||
|
|
||||||
|
async run({ message: msg, args, pluginData }) {
|
||||||
|
const savedMessage = await pluginData.state.savedMessages.find(args.messageId);
|
||||||
|
if (!savedMessage) {
|
||||||
|
sendErrorMessage(pluginData, msg.channel, "Unknown message");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = (await pluginData.guild.channels.get(savedMessage.channel_id)) as TextChannel;
|
||||||
|
if (!channel || !(channel instanceof TextChannel)) {
|
||||||
|
sendErrorMessage(pluginData, msg.channel, "Channel no longer exists");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetMessage = await channel.getMessage(args.messageId);
|
||||||
|
if (!targetMessage) {
|
||||||
|
sendErrorMessage(pluginData, msg.channel, "Unknown message (2)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear old reaction roles for the message from the DB
|
||||||
|
await pluginData.state.reactionRoles.removeFromMessage(targetMessage.id);
|
||||||
|
|
||||||
|
// Turn "emoji = role" pairs into an array of tuples of the form [emoji, roleId]
|
||||||
|
// Emoji is either a unicode emoji or the snowflake of a custom emoji
|
||||||
|
const emojiRolePairs: TReactionRolePair[] = args.reactionRolePairs
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.map(v => v.split("=").map(v => v.trim())) // tslint:disable-line
|
||||||
|
.map(
|
||||||
|
(pair): TReactionRolePair => {
|
||||||
|
const customEmojiMatch = pair[0].match(/^<a?:(.*?):(\d+)>$/);
|
||||||
|
if (customEmojiMatch) {
|
||||||
|
return [customEmojiMatch[2], pair[1], customEmojiMatch[1]];
|
||||||
|
} else {
|
||||||
|
return pair as TReactionRolePair;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify the specified emojis and roles are valid and usable
|
||||||
|
for (const pair of emojiRolePairs) {
|
||||||
|
if (pair[0] === CLEAR_ROLES_EMOJI) {
|
||||||
|
sendErrorMessage(
|
||||||
|
pluginData,
|
||||||
|
msg.channel,
|
||||||
|
`The emoji for clearing roles (${CLEAR_ROLES_EMOJI}) is reserved and cannot be used`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!canUseEmoji(pluginData.client, pair[0])) {
|
||||||
|
sendErrorMessage(
|
||||||
|
pluginData,
|
||||||
|
msg.channel,
|
||||||
|
"I can only use regular emojis and custom emojis from servers I'm on",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof RecoverablePluginError && e.code === ERRORS.INVALID_EMOJI) {
|
||||||
|
sendErrorMessage(pluginData, msg.channel, `Invalid emoji: ${pair[0]}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pluginData.guild.roles.has(pair[1])) {
|
||||||
|
sendErrorMessage(pluginData, msg.channel, `Unknown role ${pair[1]}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the new reaction roles to the database
|
||||||
|
for (const pair of emojiRolePairs) {
|
||||||
|
await pluginData.state.reactionRoles.add(channel.id, targetMessage.id, pair[0], pair[1], args.exclusive);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply the reactions themselves
|
||||||
|
const reactionRoles = await pluginData.state.reactionRoles.getForMessage(targetMessage.id);
|
||||||
|
await applyReactionRoleReactionsToMessage(pluginData, targetMessage.channel.id, targetMessage.id, reactionRoles);
|
||||||
|
|
||||||
|
sendSuccessMessage(pluginData, msg.channel, "Reaction roles added");
|
||||||
|
},
|
||||||
|
});
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { reactionRolesCmd } from "../types";
|
||||||
|
import { commandTypeHelpers as ct } from "../../../commandTypes";
|
||||||
|
import { sendErrorMessage, sendSuccessMessage } from "src/pluginUtils";
|
||||||
|
import { refreshReactionRoles } from "../util/refreshReactionRoles";
|
||||||
|
|
||||||
|
export const RefreshReactionRolesCmd = reactionRolesCmd({
|
||||||
|
trigger: "reaction_roles refresh",
|
||||||
|
permission: "can_manage",
|
||||||
|
|
||||||
|
signature: {
|
||||||
|
messageId: ct.string(),
|
||||||
|
},
|
||||||
|
|
||||||
|
async run({ message: msg, args, pluginData }) {
|
||||||
|
const savedMessage = await pluginData.state.savedMessages.find(args.messageId);
|
||||||
|
if (!savedMessage) {
|
||||||
|
console.log("ah");
|
||||||
|
sendErrorMessage(pluginData, msg.channel, "Unknown message");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pluginData.state.pendingRefreshes.has(`${savedMessage.channel_id}-${savedMessage.id}`)) {
|
||||||
|
sendErrorMessage(pluginData, msg.channel, "Another refresh in progress");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshReactionRoles(pluginData, savedMessage.channel_id, savedMessage.id);
|
||||||
|
|
||||||
|
sendSuccessMessage(pluginData, msg.channel, "Reaction roles refreshed");
|
||||||
|
},
|
||||||
|
});
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { reactionRolesEvent } from "../types";
|
||||||
|
import { resolveMember, noop, sleep } from "src/utils";
|
||||||
|
import { addMemberPendingRoleChange } from "../util/addMemberPendingRoleChange";
|
||||||
|
import { Message } from "eris";
|
||||||
|
|
||||||
|
const CLEAR_ROLES_EMOJI = "❌";
|
||||||
|
|
||||||
|
export const AddReactionRoleEvt = reactionRolesEvent({
|
||||||
|
event: "messageReactionAdd",
|
||||||
|
|
||||||
|
async listener(meta) {
|
||||||
|
const pluginData = meta.pluginData;
|
||||||
|
const msg = meta.args.message as Message;
|
||||||
|
const emoji = meta.args.emoji;
|
||||||
|
const userId = meta.args.userID;
|
||||||
|
|
||||||
|
// Make sure this message has reaction roles on it
|
||||||
|
const reactionRoles = await pluginData.state.reactionRoles.getForMessage(msg.id);
|
||||||
|
if (reactionRoles.length === 0) return;
|
||||||
|
|
||||||
|
const member = await resolveMember(pluginData.client, pluginData.guild, userId);
|
||||||
|
if (!member) return;
|
||||||
|
|
||||||
|
if (emoji.name === CLEAR_ROLES_EMOJI) {
|
||||||
|
// User reacted with "clear roles" emoji -> clear their roles
|
||||||
|
const reactionRoleRoleIds = reactionRoles.map(rr => rr.role_id);
|
||||||
|
for (const roleId of reactionRoleRoleIds) {
|
||||||
|
addMemberPendingRoleChange(pluginData, userId, "-", roleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
pluginData.state.reactionRemoveQueue.add(async () => {
|
||||||
|
await msg.channel.removeMessageReaction(msg.id, CLEAR_ROLES_EMOJI, userId);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// User reacted with a reaction role emoji -> add the role
|
||||||
|
const matchingReactionRole = await pluginData.state.reactionRoles.getByMessageAndEmoji(
|
||||||
|
msg.id,
|
||||||
|
emoji.id || emoji.name,
|
||||||
|
);
|
||||||
|
if (!matchingReactionRole) return;
|
||||||
|
|
||||||
|
// If the reaction role is exclusive, remove any other roles in the message first
|
||||||
|
if (matchingReactionRole.is_exclusive) {
|
||||||
|
const messageReactionRoles = await pluginData.state.reactionRoles.getForMessage(msg.id);
|
||||||
|
for (const reactionRole of messageReactionRoles) {
|
||||||
|
addMemberPendingRoleChange(pluginData, userId, "-", reactionRole.role_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addMemberPendingRoleChange(pluginData, userId, "+", matchingReactionRole.role_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the reaction after a small delay
|
||||||
|
setTimeout(() => {
|
||||||
|
pluginData.state.reactionRemoveQueue.add(async () => {
|
||||||
|
const reaction = emoji.id ? `${emoji.name}:${emoji.id}` : emoji.name;
|
||||||
|
const wait = sleep(1500);
|
||||||
|
await msg.channel.removeMessageReaction(msg.id, reaction, userId).catch(noop);
|
||||||
|
await wait;
|
||||||
|
});
|
||||||
|
}, 1500);
|
||||||
|
},
|
||||||
|
});
|
44
backend/src/plugins/ReactionRoles/types.ts
Normal file
44
backend/src/plugins/ReactionRoles/types.ts
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
import * as t from "io-ts";
|
||||||
|
import { BasePluginType, eventListener, command, PluginData } from "knub";
|
||||||
|
import { GuildSavedMessages } from "src/data/GuildSavedMessages";
|
||||||
|
import { GuildReactionRoles } from "src/data/GuildReactionRoles";
|
||||||
|
import { Queue } from "src/Queue";
|
||||||
|
|
||||||
|
export const ConfigSchema = t.type({
|
||||||
|
auto_refresh_interval: t.number,
|
||||||
|
can_manage: t.boolean,
|
||||||
|
});
|
||||||
|
export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
||||||
|
|
||||||
|
export type RoleChangeMode = "+" | "-";
|
||||||
|
|
||||||
|
export type PendingMemberRoleChanges = {
|
||||||
|
timeout: NodeJS.Timeout;
|
||||||
|
applyFn: () => void;
|
||||||
|
changes: Array<{
|
||||||
|
mode: RoleChangeMode;
|
||||||
|
roleId: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ReactionRolePair = t.union([t.tuple([t.string, t.string, t.string]), t.tuple([t.string, t.string])]);
|
||||||
|
export type TReactionRolePair = t.TypeOf<typeof ReactionRolePair>;
|
||||||
|
type ReactionRolePair = [string, string, string?];
|
||||||
|
|
||||||
|
export interface ReactionRolesPluginType extends BasePluginType {
|
||||||
|
config: TConfigSchema;
|
||||||
|
state: {
|
||||||
|
reactionRoles: GuildReactionRoles;
|
||||||
|
savedMessages: GuildSavedMessages;
|
||||||
|
|
||||||
|
reactionRemoveQueue: Queue;
|
||||||
|
roleChangeQueue: Queue;
|
||||||
|
pendingRoleChanges: Map<string, PendingMemberRoleChanges>;
|
||||||
|
pendingRefreshes: Set<string>;
|
||||||
|
|
||||||
|
autoRefreshTimeout: NodeJS.Timeout;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const reactionRolesCmd = command<ReactionRolesPluginType>();
|
||||||
|
export const reactionRolesEvent = eventListener<ReactionRolesPluginType>();
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { PluginData } from "knub";
|
||||||
|
import { ReactionRolesPluginType, RoleChangeMode, PendingMemberRoleChanges } from "../types";
|
||||||
|
import { resolveMember } from "src/utils";
|
||||||
|
import { logger } from "src/logger";
|
||||||
|
|
||||||
|
const ROLE_CHANGE_BATCH_DEBOUNCE_TIME = 1500;
|
||||||
|
|
||||||
|
export async function addMemberPendingRoleChange(
|
||||||
|
pluginData: PluginData<ReactionRolesPluginType>,
|
||||||
|
memberId: string,
|
||||||
|
mode: RoleChangeMode,
|
||||||
|
roleId: string,
|
||||||
|
) {
|
||||||
|
if (!pluginData.state.pendingRoleChanges.has(memberId)) {
|
||||||
|
const newPendingRoleChangeObj: PendingMemberRoleChanges = {
|
||||||
|
timeout: null,
|
||||||
|
changes: [],
|
||||||
|
applyFn: async () => {
|
||||||
|
pluginData.state.pendingRoleChanges.delete(memberId);
|
||||||
|
|
||||||
|
const lock = await pluginData.locks.acquire(`member-roles-${memberId}`);
|
||||||
|
|
||||||
|
const member = await resolveMember(pluginData.client, pluginData.guild, memberId);
|
||||||
|
if (member) {
|
||||||
|
const newRoleIds = new Set(member.roles);
|
||||||
|
for (const change of newPendingRoleChangeObj.changes) {
|
||||||
|
if (change.mode === "+") newRoleIds.add(change.roleId);
|
||||||
|
else newRoleIds.delete(change.roleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await member.edit(
|
||||||
|
{
|
||||||
|
roles: Array.from(newRoleIds.values()),
|
||||||
|
},
|
||||||
|
"Reaction roles",
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn(
|
||||||
|
`Failed to apply role changes to ${member.username}#${member.discriminator} (${member.id}): ${e.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lock.unlock();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
pluginData.state.pendingRoleChanges.set(memberId, newPendingRoleChangeObj);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingRoleChangeObj = pluginData.state.pendingRoleChanges.get(memberId);
|
||||||
|
pendingRoleChangeObj.changes.push({ mode, roleId });
|
||||||
|
|
||||||
|
if (pendingRoleChangeObj.timeout) clearTimeout(pendingRoleChangeObj.timeout);
|
||||||
|
pendingRoleChangeObj.timeout = setTimeout(
|
||||||
|
() => pluginData.state.roleChangeQueue.add(pendingRoleChangeObj.applyFn),
|
||||||
|
ROLE_CHANGE_BATCH_DEBOUNCE_TIME,
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { PluginData } from "knub";
|
||||||
|
import { ReactionRolesPluginType } from "../types";
|
||||||
|
import { ReactionRole } from "src/data/entities/ReactionRole";
|
||||||
|
import { TextChannel } from "eris";
|
||||||
|
import { isDiscordRESTError, sleep, isSnowflake } from "src/utils";
|
||||||
|
import { logger } from "src/logger";
|
||||||
|
|
||||||
|
const CLEAR_ROLES_EMOJI = "❌";
|
||||||
|
|
||||||
|
export async function applyReactionRoleReactionsToMessage(
|
||||||
|
pluginData: PluginData<ReactionRolesPluginType>,
|
||||||
|
channelId: string,
|
||||||
|
messageId: string,
|
||||||
|
reactionRoles: ReactionRole[],
|
||||||
|
) {
|
||||||
|
const channel = pluginData.guild.channels.get(channelId) as TextChannel;
|
||||||
|
if (!channel) return;
|
||||||
|
|
||||||
|
let targetMessage;
|
||||||
|
try {
|
||||||
|
targetMessage = await channel.getMessage(messageId);
|
||||||
|
} catch (e) {
|
||||||
|
if (isDiscordRESTError(e)) {
|
||||||
|
if (e.code === 10008) {
|
||||||
|
// Unknown message, remove reaction roles from the message
|
||||||
|
logger.warn(
|
||||||
|
`Removed reaction roles from unknown message ${channelId}/${messageId} in guild ${pluginData.guild.name} (${pluginData.guild.id})`,
|
||||||
|
);
|
||||||
|
await pluginData.state.reactionRoles.removeFromMessage(messageId);
|
||||||
|
} else {
|
||||||
|
logger.warn(
|
||||||
|
`Error when applying reaction roles to message ${channelId}/${messageId} in guild ${pluginData.guild.name} (${pluginData.guild.id}), error code ${e.code}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove old reactions, if any
|
||||||
|
const removeSleep = sleep(1250);
|
||||||
|
await targetMessage.removeReactions();
|
||||||
|
await removeSleep;
|
||||||
|
|
||||||
|
// Add reaction role reactions
|
||||||
|
for (const rr of reactionRoles) {
|
||||||
|
const emoji = isSnowflake(rr.emoji) ? `foo:${rr.emoji}` : rr.emoji;
|
||||||
|
|
||||||
|
const sleepTime = sleep(1250); // Make sure we only add 1 reaction per ~second so as not to hit rate limits
|
||||||
|
await targetMessage.addReaction(emoji);
|
||||||
|
await sleepTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the "clear reactions" button
|
||||||
|
await targetMessage.addReaction(CLEAR_ROLES_EMOJI);
|
||||||
|
}
|
10
backend/src/plugins/ReactionRoles/util/autoRefreshLoop.ts
Normal file
10
backend/src/plugins/ReactionRoles/util/autoRefreshLoop.ts
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import { PluginData } from "knub";
|
||||||
|
import { ReactionRolesPluginType } from "../types";
|
||||||
|
import { runAutoRefresh } from "./runAutoRefresh";
|
||||||
|
|
||||||
|
export async function autoRefreshLoop(pluginData: PluginData<ReactionRolesPluginType>, interval: number) {
|
||||||
|
pluginData.state.autoRefreshTimeout = setTimeout(async () => {
|
||||||
|
await runAutoRefresh(pluginData);
|
||||||
|
autoRefreshLoop(pluginData, interval);
|
||||||
|
}, interval);
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { ReactionRolesPluginType } from "../types";
|
||||||
|
import { PluginData } from "knub";
|
||||||
|
import { applyReactionRoleReactionsToMessage } from "./applyReactionRoleReactionsToMessage";
|
||||||
|
|
||||||
|
export async function refreshReactionRoles(
|
||||||
|
pluginData: PluginData<ReactionRolesPluginType>,
|
||||||
|
channelId: string,
|
||||||
|
messageId: string,
|
||||||
|
) {
|
||||||
|
const pendingKey = `${channelId}-${messageId}`;
|
||||||
|
if (pluginData.state.pendingRefreshes.has(pendingKey)) return;
|
||||||
|
pluginData.state.pendingRefreshes.add(pendingKey);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const reactionRoles = await pluginData.state.reactionRoles.getForMessage(messageId);
|
||||||
|
await applyReactionRoleReactionsToMessage(pluginData, channelId, messageId, reactionRoles);
|
||||||
|
} finally {
|
||||||
|
pluginData.state.pendingRefreshes.delete(pendingKey);
|
||||||
|
}
|
||||||
|
}
|
13
backend/src/plugins/ReactionRoles/util/runAutoRefresh.ts
Normal file
13
backend/src/plugins/ReactionRoles/util/runAutoRefresh.ts
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import { PluginData } from "knub";
|
||||||
|
import { ReactionRolesPluginType } from "../types";
|
||||||
|
import { refreshReactionRoles } from "./refreshReactionRoles";
|
||||||
|
|
||||||
|
export async function runAutoRefresh(pluginData: PluginData<ReactionRolesPluginType>) {
|
||||||
|
// Refresh reaction roles on all reaction role messages
|
||||||
|
const reactionRoles = await pluginData.state.reactionRoles.all();
|
||||||
|
const idPairs = new Set(reactionRoles.map(r => `${r.channel_id}-${r.message_id}`));
|
||||||
|
for (const pair of idPairs) {
|
||||||
|
const [channelId, messageId] = pair.split("-");
|
||||||
|
await refreshReactionRoles(pluginData, channelId, messageId);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { PluginOptions } from "knub";
|
||||||
|
import { SelfGrantableRolesPluginType, ConfigSchema, defaultSelfGrantableRoleEntry } from "./types";
|
||||||
|
import { zeppelinPlugin } from "../ZeppelinPluginBlueprint";
|
||||||
|
import { trimPluginDescription } from "src/utils";
|
||||||
|
import { RoleAddCmd } from "./commands/RoleAddCmd";
|
||||||
|
import { RoleRemoveCmd } from "./commands/RoleRemoveCmd";
|
||||||
|
import { RoleHelpCmd } from "./commands/RoleHelpCmd";
|
||||||
|
|
||||||
|
const defaultOptions: PluginOptions<SelfGrantableRolesPluginType> = {
|
||||||
|
config: {
|
||||||
|
entries: {},
|
||||||
|
mention_roles: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SelfGrantableRolesPlugin = zeppelinPlugin<SelfGrantableRolesPluginType>()("self_grantable_roles", {
|
||||||
|
configSchema: ConfigSchema,
|
||||||
|
defaultOptions,
|
||||||
|
|
||||||
|
info: {
|
||||||
|
prettyName: "Self-grantable roles",
|
||||||
|
description: trimPluginDescription(`
|
||||||
|
Allows users to grant themselves roles via a command
|
||||||
|
`),
|
||||||
|
configurationGuide: trimPluginDescription(`
|
||||||
|
### Basic configuration
|
||||||
|
In this example, users can add themselves platform roles on the channel 473087035574321152 by using the
|
||||||
|
\`!role\` command. For example, \`!role pc ps4\` to add both the "pc" and "ps4" roles as specified below.
|
||||||
|
|
||||||
|
~~~yml
|
||||||
|
self_grantable_roles:
|
||||||
|
config:
|
||||||
|
entries:
|
||||||
|
basic:
|
||||||
|
roles:
|
||||||
|
"543184300250759188": ["pc", "computer"]
|
||||||
|
"534710505915547658": ["ps4", "ps", "playstation"]
|
||||||
|
"473085927053590538": ["xbox", "xb1", "xb"]
|
||||||
|
overrides:
|
||||||
|
- channel: "473087035574321152"
|
||||||
|
config:
|
||||||
|
entries:
|
||||||
|
basic:
|
||||||
|
roles:
|
||||||
|
can_use: true
|
||||||
|
~~~
|
||||||
|
|
||||||
|
### Maximum number of roles
|
||||||
|
This is identical to the basic example above, but users can only choose 1 role.
|
||||||
|
|
||||||
|
~~~yml
|
||||||
|
self_grantable_roles:
|
||||||
|
config:
|
||||||
|
entries:
|
||||||
|
basic:
|
||||||
|
roles:
|
||||||
|
"543184300250759188": ["pc", "computer"]
|
||||||
|
"534710505915547658": ["ps4", "ps", "playstation"]
|
||||||
|
"473085927053590538": ["xbox", "xb1", "xb"]
|
||||||
|
max_roles: 1
|
||||||
|
overrides:
|
||||||
|
- channel: "473087035574321152"
|
||||||
|
config:
|
||||||
|
entries:
|
||||||
|
basic:
|
||||||
|
roles:
|
||||||
|
can_use: true
|
||||||
|
~~~
|
||||||
|
`),
|
||||||
|
},
|
||||||
|
|
||||||
|
configPreprocessor: options => {
|
||||||
|
const config = options.config;
|
||||||
|
for (const [key, entry] of Object.entries(config.entries)) {
|
||||||
|
// Apply default entry config
|
||||||
|
config.entries[key] = { ...defaultSelfGrantableRoleEntry, ...entry };
|
||||||
|
|
||||||
|
// Normalize alias names
|
||||||
|
if (entry.roles) {
|
||||||
|
for (const [roleId, aliases] of Object.entries(entry.roles)) {
|
||||||
|
entry.roles[roleId] = aliases.map(a => a.toLowerCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...options, config };
|
||||||
|
},
|
||||||
|
|
||||||
|
// prettier-ignore
|
||||||
|
commands: [
|
||||||
|
RoleHelpCmd,
|
||||||
|
RoleRemoveCmd,
|
||||||
|
RoleAddCmd,
|
||||||
|
]
|
||||||
|
});
|
124
backend/src/plugins/SelfGrantableRoles/commands/RoleAddCmd.ts
Normal file
124
backend/src/plugins/SelfGrantableRoles/commands/RoleAddCmd.ts
Normal file
|
@ -0,0 +1,124 @@
|
||||||
|
import { selfGrantableRolesCmd } from "../types";
|
||||||
|
import { commandTypeHelpers as ct } from "../../../commandTypes";
|
||||||
|
import { getApplyingEntries } from "../util/getApplyingEntries";
|
||||||
|
import { sendErrorMessage, sendSuccessMessage } from "src/pluginUtils";
|
||||||
|
import { splitRoleNames } from "../util/splitRoleNames";
|
||||||
|
import { normalizeRoleNames } from "../util/normalizeRoleNames";
|
||||||
|
import { findMatchingRoles } from "../util/findMatchingRoles";
|
||||||
|
import { Role } from "eris";
|
||||||
|
|
||||||
|
export const RoleAddCmd = selfGrantableRolesCmd({
|
||||||
|
trigger: ["role", "role add"],
|
||||||
|
permission: null,
|
||||||
|
|
||||||
|
signature: {
|
||||||
|
roleNames: ct.string({ rest: true }),
|
||||||
|
},
|
||||||
|
|
||||||
|
async run({ message: msg, args, pluginData }) {
|
||||||
|
const lock = await pluginData.locks.acquire(`grantableRoles:${msg.author.id}`);
|
||||||
|
|
||||||
|
const applyingEntries = getApplyingEntries(pluginData, msg);
|
||||||
|
if (applyingEntries.length === 0) {
|
||||||
|
lock.unlock();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleNames = normalizeRoleNames(splitRoleNames(args.roleNames));
|
||||||
|
const matchedRoleIds = findMatchingRoles(roleNames, applyingEntries);
|
||||||
|
|
||||||
|
const hasUnknownRoles = matchedRoleIds.length !== roleNames.length;
|
||||||
|
|
||||||
|
const rolesToAdd: Map<string, Role> = Array.from(matchedRoleIds.values())
|
||||||
|
.map(id => pluginData.guild.roles.get(id))
|
||||||
|
.filter(Boolean)
|
||||||
|
.reduce((map, role) => {
|
||||||
|
map.set(role.id, role);
|
||||||
|
return map;
|
||||||
|
}, new Map());
|
||||||
|
|
||||||
|
if (!rolesToAdd.size) {
|
||||||
|
sendErrorMessage(
|
||||||
|
pluginData,
|
||||||
|
msg.channel,
|
||||||
|
`<@!${msg.author.id}> Unknown ${args.roleNames.length === 1 ? "role" : "roles"}`,
|
||||||
|
);
|
||||||
|
lock.unlock();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grant the roles
|
||||||
|
const newRoleIds = new Set([...rolesToAdd.keys(), ...msg.member.roles]);
|
||||||
|
|
||||||
|
// Remove extra roles (max_roles) for each entry
|
||||||
|
const skipped: Set<Role> = new Set();
|
||||||
|
const removed: Set<Role> = new Set();
|
||||||
|
|
||||||
|
for (const entry of applyingEntries) {
|
||||||
|
if (entry.max_roles === 0) continue;
|
||||||
|
|
||||||
|
let foundRoles = 0;
|
||||||
|
|
||||||
|
for (const roleId of newRoleIds) {
|
||||||
|
if (entry.roles[roleId]) {
|
||||||
|
if (foundRoles < entry.max_roles) {
|
||||||
|
foundRoles++;
|
||||||
|
} else {
|
||||||
|
newRoleIds.delete(roleId);
|
||||||
|
rolesToAdd.delete(roleId);
|
||||||
|
|
||||||
|
if (msg.member.roles.includes(roleId)) {
|
||||||
|
removed.add(pluginData.guild.roles.get(roleId));
|
||||||
|
} else {
|
||||||
|
skipped.add(pluginData.guild.roles.get(roleId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await msg.member.edit({
|
||||||
|
roles: Array.from(newRoleIds),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
sendErrorMessage(
|
||||||
|
pluginData,
|
||||||
|
msg.channel,
|
||||||
|
`<@!${msg.author.id}> Got an error while trying to grant you the roles`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mentionRoles = pluginData.config.get().mention_roles;
|
||||||
|
const addedRolesStr = Array.from(rolesToAdd.values()).map(r => (mentionRoles ? `<@&${r.id}>` : `**${r.name}**`));
|
||||||
|
const addedRolesWord = rolesToAdd.size === 1 ? "role" : "roles";
|
||||||
|
|
||||||
|
const messageParts = [];
|
||||||
|
messageParts.push(`Granted you the ${addedRolesStr.join(", ")} ${addedRolesWord}`);
|
||||||
|
|
||||||
|
if (skipped.size || removed.size) {
|
||||||
|
const skippedRolesStr = skipped.size
|
||||||
|
? "skipped " +
|
||||||
|
Array.from(skipped.values())
|
||||||
|
.map(r => (mentionRoles ? `<@&${r.id}>` : `**${r.name}**`))
|
||||||
|
.join(",")
|
||||||
|
: null;
|
||||||
|
const removedRolesStr = removed.size
|
||||||
|
? "removed " + Array.from(removed.values()).map(r => (mentionRoles ? `<@&${r.id}>` : `**${r.name}**`))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const skippedRemovedStr = [skippedRolesStr, removedRolesStr].filter(Boolean).join(" and ");
|
||||||
|
|
||||||
|
messageParts.push(`${skippedRemovedStr} due to role limits`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasUnknownRoles) {
|
||||||
|
messageParts.push("couldn't recognize some of the roles");
|
||||||
|
}
|
||||||
|
|
||||||
|
sendSuccessMessage(pluginData, msg.channel, `<@!${msg.author.id}> ${messageParts.join("; ")}`);
|
||||||
|
|
||||||
|
lock.unlock();
|
||||||
|
},
|
||||||
|
});
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { selfGrantableRolesCmd } from "../types";
|
||||||
|
import { asSingleLine, trimLines } from "src/utils";
|
||||||
|
import { getApplyingEntries } from "../util/getApplyingEntries";
|
||||||
|
|
||||||
|
export const RoleHelpCmd = selfGrantableRolesCmd({
|
||||||
|
trigger: ["role help", "role"],
|
||||||
|
permission: null,
|
||||||
|
|
||||||
|
async run({ message: msg, pluginData }) {
|
||||||
|
const applyingEntries = getApplyingEntries(pluginData, msg);
|
||||||
|
if (applyingEntries.length === 0) return;
|
||||||
|
|
||||||
|
const allPrimaryAliases = [];
|
||||||
|
for (const entry of applyingEntries) {
|
||||||
|
for (const aliases of Object.values(entry.roles)) {
|
||||||
|
if (aliases[0]) {
|
||||||
|
allPrimaryAliases.push(aliases[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = pluginData.guildConfig.prefix;
|
||||||
|
const [firstRole, secondRole] = allPrimaryAliases;
|
||||||
|
|
||||||
|
const help1 = asSingleLine(`
|
||||||
|
To give yourself a role, type e.g. \`${prefix}role ${firstRole}\` where **${firstRole}** is the role you want.
|
||||||
|
${secondRole ? `You can also add multiple roles at once, e.g. \`${prefix}role ${firstRole} ${secondRole}\`` : ""}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const help2 = asSingleLine(`
|
||||||
|
To remove a role, type \`${prefix}role remove ${firstRole}\`,
|
||||||
|
again replacing **${firstRole}** with the role you want to remove.
|
||||||
|
`);
|
||||||
|
|
||||||
|
const helpMessage = trimLines(`
|
||||||
|
${help1}
|
||||||
|
|
||||||
|
${help2}
|
||||||
|
|
||||||
|
**Roles available to you:**
|
||||||
|
${allPrimaryAliases.join(", ")}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const helpEmbed = {
|
||||||
|
title: "How to get roles",
|
||||||
|
description: helpMessage,
|
||||||
|
color: parseInt("42bff4", 16),
|
||||||
|
};
|
||||||
|
|
||||||
|
msg.channel.createMessage({ embed: helpEmbed });
|
||||||
|
},
|
||||||
|
});
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { selfGrantableRolesCmd } from "../types";
|
||||||
|
import { commandTypeHelpers as ct } from "../../../commandTypes";
|
||||||
|
import { getApplyingEntries } from "../util/getApplyingEntries";
|
||||||
|
import { sendErrorMessage, sendSuccessMessage } from "src/pluginUtils";
|
||||||
|
import { splitRoleNames } from "../util/splitRoleNames";
|
||||||
|
import { normalizeRoleNames } from "../util/normalizeRoleNames";
|
||||||
|
import { findMatchingRoles } from "../util/findMatchingRoles";
|
||||||
|
|
||||||
|
export const RoleRemoveCmd = selfGrantableRolesCmd({
|
||||||
|
trigger: "role remove",
|
||||||
|
permission: null,
|
||||||
|
|
||||||
|
signature: {
|
||||||
|
roleNames: ct.string({ rest: true }),
|
||||||
|
},
|
||||||
|
|
||||||
|
async run({ message: msg, args, pluginData }) {
|
||||||
|
const lock = await pluginData.locks.acquire(`grantableRoles:${msg.author.id}`);
|
||||||
|
|
||||||
|
const applyingEntries = getApplyingEntries(pluginData, msg);
|
||||||
|
if (applyingEntries.length === 0) {
|
||||||
|
lock.unlock();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleNames = normalizeRoleNames(splitRoleNames(args.roleNames));
|
||||||
|
const matchedRoleIds = findMatchingRoles(roleNames, applyingEntries);
|
||||||
|
|
||||||
|
const rolesToRemove = Array.from(matchedRoleIds.values()).map(id => pluginData.guild.roles.get(id));
|
||||||
|
const roleIdsToRemove = rolesToRemove.map(r => r.id);
|
||||||
|
|
||||||
|
// Remove the roles
|
||||||
|
if (rolesToRemove.length) {
|
||||||
|
const newRoleIds = msg.member.roles.filter(roleId => !roleIdsToRemove.includes(roleId));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await msg.member.edit({
|
||||||
|
roles: newRoleIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
const removedRolesStr = rolesToRemove.map(r => `**${r.name}**`);
|
||||||
|
const removedRolesWord = rolesToRemove.length === 1 ? "role" : "roles";
|
||||||
|
|
||||||
|
if (rolesToRemove.length !== roleNames.length) {
|
||||||
|
sendSuccessMessage(
|
||||||
|
pluginData,
|
||||||
|
msg.channel,
|
||||||
|
`<@!${msg.author.id}> Removed ${removedRolesStr.join(", ")} ${removedRolesWord};` +
|
||||||
|
` couldn't recognize the other roles you mentioned`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
sendSuccessMessage(
|
||||||
|
pluginData,
|
||||||
|
msg.channel,
|
||||||
|
`<@!${msg.author.id}> Removed ${removedRolesStr.join(", ")} ${removedRolesWord}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
sendSuccessMessage(
|
||||||
|
pluginData,
|
||||||
|
msg.channel,
|
||||||
|
`<@!${msg.author.id}> Got an error while trying to remove the roles`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sendErrorMessage(
|
||||||
|
pluginData,
|
||||||
|
msg.channel,
|
||||||
|
`<@!${msg.author.id}> Unknown ${args.roleNames.length === 1 ? "role" : "roles"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
lock.unlock();
|
||||||
|
},
|
||||||
|
});
|
31
backend/src/plugins/SelfGrantableRoles/types.ts
Normal file
31
backend/src/plugins/SelfGrantableRoles/types.ts
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
import * as t from "io-ts";
|
||||||
|
import { BasePluginType, command } from "knub";
|
||||||
|
|
||||||
|
const RoleMap = t.record(t.string, t.array(t.string));
|
||||||
|
|
||||||
|
const SelfGrantableRoleEntry = t.type({
|
||||||
|
roles: RoleMap,
|
||||||
|
can_use: t.boolean,
|
||||||
|
can_ignore_cooldown: t.boolean,
|
||||||
|
max_roles: t.number,
|
||||||
|
});
|
||||||
|
const PartialRoleEntry = t.partial(SelfGrantableRoleEntry.props);
|
||||||
|
export type TSelfGrantableRoleEntry = t.TypeOf<typeof SelfGrantableRoleEntry>;
|
||||||
|
|
||||||
|
export const ConfigSchema = t.type({
|
||||||
|
entries: t.record(t.string, SelfGrantableRoleEntry),
|
||||||
|
mention_roles: t.boolean,
|
||||||
|
});
|
||||||
|
type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
||||||
|
|
||||||
|
export const defaultSelfGrantableRoleEntry: t.TypeOf<typeof PartialRoleEntry> = {
|
||||||
|
can_use: false,
|
||||||
|
can_ignore_cooldown: false,
|
||||||
|
max_roles: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SelfGrantableRolesPluginType extends BasePluginType {
|
||||||
|
config: TConfigSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const selfGrantableRolesCmd = command<SelfGrantableRolesPluginType>();
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { TSelfGrantableRoleEntry } from "../types";
|
||||||
|
|
||||||
|
export function findMatchingRoles(roleNames, entries: TSelfGrantableRoleEntry[]): string[] {
|
||||||
|
const aliasToRoleId = entries.reduce((map, entry) => {
|
||||||
|
for (const [roleId, aliases] of Object.entries(entry.roles)) {
|
||||||
|
for (const alias of aliases) {
|
||||||
|
map.set(alias, roleId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}, new Map());
|
||||||
|
|
||||||
|
return roleNames.map(roleName => aliasToRoleId.get(roleName)).filter(Boolean);
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { TSelfGrantableRoleEntry, SelfGrantableRolesPluginType } from "../types";
|
||||||
|
import { PluginData } from "knub";
|
||||||
|
|
||||||
|
export function getApplyingEntries(
|
||||||
|
pluginData: PluginData<SelfGrantableRolesPluginType>,
|
||||||
|
msg,
|
||||||
|
): TSelfGrantableRoleEntry[] {
|
||||||
|
const config = pluginData.config.getForMessage(msg);
|
||||||
|
return Object.entries(config.entries)
|
||||||
|
.filter(
|
||||||
|
([k, e]) =>
|
||||||
|
e.can_use && !(!e.can_ignore_cooldown && pluginData.state.cooldowns.isOnCooldown(`${k}:${msg.author.id}`)),
|
||||||
|
)
|
||||||
|
.map(pair => pair[1]);
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
export function normalizeRoleNames(roleNames: string[]) {
|
||||||
|
return roleNames.map(v => v.toLowerCase());
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
export function splitRoleNames(roleNames: string[]) {
|
||||||
|
return roleNames
|
||||||
|
.map(v => v.split(/[\s,]+/))
|
||||||
|
.flat()
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
74
backend/src/plugins/Spam/SpamPlugin.ts
Normal file
74
backend/src/plugins/Spam/SpamPlugin.ts
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
import { zeppelinPlugin } from "../ZeppelinPluginBlueprint";
|
||||||
|
import { PluginOptions } from "knub";
|
||||||
|
import { ConfigSchema, SpamPluginType } from "./types";
|
||||||
|
import { GuildLogs } from "src/data/GuildLogs";
|
||||||
|
import { GuildArchives } from "src/data/GuildArchives";
|
||||||
|
import { GuildSavedMessages } from "src/data/GuildSavedMessages";
|
||||||
|
import { GuildMutes } from "src/data/GuildMutes";
|
||||||
|
import { onMessageCreate } from "./util/onMessageCreate";
|
||||||
|
import { clearOldRecentActions } from "./util/clearOldRecentActions";
|
||||||
|
import { SpamVoiceJoinEvt, SpamVoiceSwitchEvt } from "./events/SpamVoiceEvt";
|
||||||
|
|
||||||
|
const defaultOptions: PluginOptions<SpamPluginType> = {
|
||||||
|
config: {
|
||||||
|
max_censor: null,
|
||||||
|
max_messages: null,
|
||||||
|
max_mentions: null,
|
||||||
|
max_links: null,
|
||||||
|
max_attachments: null,
|
||||||
|
max_emojis: null,
|
||||||
|
max_newlines: null,
|
||||||
|
max_duplicates: null,
|
||||||
|
max_characters: null,
|
||||||
|
max_voice_moves: null,
|
||||||
|
},
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
level: ">=50",
|
||||||
|
config: {
|
||||||
|
max_messages: null,
|
||||||
|
max_mentions: null,
|
||||||
|
max_links: null,
|
||||||
|
max_attachments: null,
|
||||||
|
max_emojis: null,
|
||||||
|
max_newlines: null,
|
||||||
|
max_duplicates: null,
|
||||||
|
max_characters: null,
|
||||||
|
max_voice_moves: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SpamPlugin = zeppelinPlugin<SpamPluginType>()("spam", {
|
||||||
|
configSchema: ConfigSchema,
|
||||||
|
defaultOptions,
|
||||||
|
|
||||||
|
// prettier-ignore
|
||||||
|
events: [
|
||||||
|
SpamVoiceJoinEvt,
|
||||||
|
SpamVoiceSwitchEvt,
|
||||||
|
],
|
||||||
|
|
||||||
|
onLoad(pluginData) {
|
||||||
|
const { state, guild } = pluginData;
|
||||||
|
|
||||||
|
state.logs = new GuildLogs(guild.id);
|
||||||
|
state.archives = GuildArchives.getGuildInstance(guild.id);
|
||||||
|
state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id);
|
||||||
|
state.mutes = GuildMutes.getGuildInstance(guild.id);
|
||||||
|
|
||||||
|
state.recentActions = [];
|
||||||
|
state.expiryInterval = setInterval(() => clearOldRecentActions(pluginData), 1000 * 60);
|
||||||
|
state.lastHandledMsgIds = new Map();
|
||||||
|
|
||||||
|
state.spamDetectionQueue = Promise.resolve();
|
||||||
|
|
||||||
|
state.onMessageCreateFn = msg => onMessageCreate(pluginData, msg);
|
||||||
|
state.savedMessages.events.on("create", state.onMessageCreateFn);
|
||||||
|
},
|
||||||
|
|
||||||
|
onUnload(pluginData) {
|
||||||
|
pluginData.state.savedMessages.events.off("create", pluginData.state.onMessageCreateFn);
|
||||||
|
},
|
||||||
|
});
|
52
backend/src/plugins/Spam/events/SpamVoiceEvt.ts
Normal file
52
backend/src/plugins/Spam/events/SpamVoiceEvt.ts
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
import { spamEvent, RecentActionType } from "../types";
|
||||||
|
import { logAndDetectOtherSpam } from "../util/logAndDetectOtherSpam";
|
||||||
|
|
||||||
|
export const SpamVoiceJoinEvt = spamEvent({
|
||||||
|
event: "voiceChannelJoin",
|
||||||
|
|
||||||
|
async listener(meta) {
|
||||||
|
const member = meta.args.member;
|
||||||
|
const channel = meta.args.newChannel;
|
||||||
|
|
||||||
|
const config = meta.pluginData.config.getMatchingConfig({ member, channelId: channel.id });
|
||||||
|
const maxVoiceMoves = config.max_voice_moves;
|
||||||
|
if (maxVoiceMoves) {
|
||||||
|
logAndDetectOtherSpam(
|
||||||
|
meta.pluginData,
|
||||||
|
RecentActionType.VoiceChannelMove,
|
||||||
|
maxVoiceMoves,
|
||||||
|
member.id,
|
||||||
|
1,
|
||||||
|
"0",
|
||||||
|
Date.now(),
|
||||||
|
null,
|
||||||
|
"too many voice channel moves",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SpamVoiceSwitchEvt = spamEvent({
|
||||||
|
event: "voiceChannelSwitch",
|
||||||
|
|
||||||
|
async listener(meta) {
|
||||||
|
const member = meta.args.member;
|
||||||
|
const channel = meta.args.newChannel;
|
||||||
|
|
||||||
|
const config = meta.pluginData.config.getMatchingConfig({ member, channelId: channel.id });
|
||||||
|
const maxVoiceMoves = config.max_voice_moves;
|
||||||
|
if (maxVoiceMoves) {
|
||||||
|
logAndDetectOtherSpam(
|
||||||
|
meta.pluginData,
|
||||||
|
RecentActionType.VoiceChannelMove,
|
||||||
|
maxVoiceMoves,
|
||||||
|
member.id,
|
||||||
|
1,
|
||||||
|
"0",
|
||||||
|
Date.now(),
|
||||||
|
null,
|
||||||
|
"too many voice channel moves",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
78
backend/src/plugins/Spam/types.ts
Normal file
78
backend/src/plugins/Spam/types.ts
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
import * as t from "io-ts";
|
||||||
|
import { BasePluginType, eventListener } from "knub";
|
||||||
|
import { tNullable } from "src/utils";
|
||||||
|
import { GuildLogs } from "src/data/GuildLogs";
|
||||||
|
import { GuildArchives } from "src/data/GuildArchives";
|
||||||
|
import { GuildSavedMessages } from "src/data/GuildSavedMessages";
|
||||||
|
import { GuildMutes } from "src/data/GuildMutes";
|
||||||
|
|
||||||
|
const BaseSingleSpamConfig = t.type({
|
||||||
|
interval: t.number,
|
||||||
|
count: t.number,
|
||||||
|
mute: tNullable(t.boolean),
|
||||||
|
mute_time: tNullable(t.number),
|
||||||
|
clean: tNullable(t.boolean),
|
||||||
|
});
|
||||||
|
export type TBaseSingleSpamConfig = t.TypeOf<typeof BaseSingleSpamConfig>;
|
||||||
|
|
||||||
|
export const ConfigSchema = t.type({
|
||||||
|
max_censor: tNullable(BaseSingleSpamConfig),
|
||||||
|
max_messages: tNullable(BaseSingleSpamConfig),
|
||||||
|
max_mentions: tNullable(BaseSingleSpamConfig),
|
||||||
|
max_links: tNullable(BaseSingleSpamConfig),
|
||||||
|
max_attachments: tNullable(BaseSingleSpamConfig),
|
||||||
|
max_emojis: tNullable(BaseSingleSpamConfig),
|
||||||
|
max_newlines: tNullable(BaseSingleSpamConfig),
|
||||||
|
max_duplicates: tNullable(BaseSingleSpamConfig),
|
||||||
|
max_characters: tNullable(BaseSingleSpamConfig),
|
||||||
|
max_voice_moves: tNullable(BaseSingleSpamConfig),
|
||||||
|
});
|
||||||
|
export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
||||||
|
|
||||||
|
export enum RecentActionType {
|
||||||
|
Message = 1,
|
||||||
|
Mention,
|
||||||
|
Link,
|
||||||
|
Attachment,
|
||||||
|
Emoji,
|
||||||
|
Newline,
|
||||||
|
Censor,
|
||||||
|
Character,
|
||||||
|
VoiceChannelMove,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IRecentAction<T> {
|
||||||
|
type: RecentActionType;
|
||||||
|
userId: string;
|
||||||
|
actionGroupId: string;
|
||||||
|
extraData: T;
|
||||||
|
timestamp: number;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpamPluginType extends BasePluginType {
|
||||||
|
config: TConfigSchema;
|
||||||
|
state: {
|
||||||
|
logs: GuildLogs;
|
||||||
|
archives: GuildArchives;
|
||||||
|
savedMessages: GuildSavedMessages;
|
||||||
|
mutes: GuildMutes;
|
||||||
|
|
||||||
|
onMessageCreateFn;
|
||||||
|
|
||||||
|
// Handle spam detection with a queue so we don't have overlapping detections on the same user
|
||||||
|
spamDetectionQueue: Promise<void>;
|
||||||
|
|
||||||
|
// List of recent potentially-spammy actions
|
||||||
|
recentActions: Array<IRecentAction<any>>;
|
||||||
|
|
||||||
|
// A map of userId => channelId => msgId
|
||||||
|
// Keeps track of the last handled (= spam detected and acted on) message ID per user, per channel
|
||||||
|
// TODO: Prevent this from growing infinitely somehow
|
||||||
|
lastHandledMsgIds: Map<string, Map<string, string>>;
|
||||||
|
|
||||||
|
expiryInterval;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const spamEvent = eventListener<SpamPluginType>();
|
14
backend/src/plugins/Spam/util/addRecentAction.ts
Normal file
14
backend/src/plugins/Spam/util/addRecentAction.ts
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import { PluginData } from "knub";
|
||||||
|
import { SpamPluginType, RecentActionType } from "../types";
|
||||||
|
|
||||||
|
export function addRecentAction(
|
||||||
|
pluginData: PluginData<SpamPluginType>,
|
||||||
|
type: RecentActionType,
|
||||||
|
userId: string,
|
||||||
|
actionGroupId: string,
|
||||||
|
extraData: any,
|
||||||
|
timestamp: number,
|
||||||
|
count = 1,
|
||||||
|
) {
|
||||||
|
pluginData.state.recentActions.push({ type, userId, actionGroupId, extraData, timestamp, count });
|
||||||
|
}
|
7
backend/src/plugins/Spam/util/clearOldRecentActions.ts
Normal file
7
backend/src/plugins/Spam/util/clearOldRecentActions.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
const MAX_INTERVAL = 300;
|
||||||
|
|
||||||
|
export function clearOldRecentActions(pluginData) {
|
||||||
|
// TODO: Figure out expiry time from longest interval in the config?
|
||||||
|
const expiryTimestamp = Date.now() - 1000 * MAX_INTERVAL;
|
||||||
|
pluginData.state.recentActions = pluginData.state.recentActions.filter(action => action.timestamp >= expiryTimestamp);
|
||||||
|
}
|
7
backend/src/plugins/Spam/util/clearRecentUserActions.ts
Normal file
7
backend/src/plugins/Spam/util/clearRecentUserActions.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import { RecentActionType } from "../types";
|
||||||
|
|
||||||
|
export function clearRecentUserActions(pluginData, type: RecentActionType, userId: string, actionGroupId: string) {
|
||||||
|
pluginData.state.recentActions = pluginData.state.recentActions.filter(action => {
|
||||||
|
return action.type !== type || action.userId !== userId || action.actionGroupId !== actionGroupId;
|
||||||
|
});
|
||||||
|
}
|
17
backend/src/plugins/Spam/util/getRecentActionCount.ts
Normal file
17
backend/src/plugins/Spam/util/getRecentActionCount.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import { RecentActionType } from "../types";
|
||||||
|
|
||||||
|
export function getRecentActionCount(
|
||||||
|
pluginData,
|
||||||
|
type: RecentActionType,
|
||||||
|
userId: string,
|
||||||
|
actionGroupId: string,
|
||||||
|
since: number,
|
||||||
|
) {
|
||||||
|
return pluginData.state.recentActions.reduce((count, action) => {
|
||||||
|
if (action.timestamp < since) return count;
|
||||||
|
if (action.type !== type) return count;
|
||||||
|
if (action.actionGroupId !== actionGroupId) return count;
|
||||||
|
if (action.userId !== userId) return false;
|
||||||
|
return count + action.count;
|
||||||
|
}, 0);
|
||||||
|
}
|
17
backend/src/plugins/Spam/util/getRecentActions.ts
Normal file
17
backend/src/plugins/Spam/util/getRecentActions.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import { RecentActionType } from "../types";
|
||||||
|
|
||||||
|
export function getRecentActions(
|
||||||
|
pluginData,
|
||||||
|
type: RecentActionType,
|
||||||
|
userId: string,
|
||||||
|
actionGroupId: string,
|
||||||
|
since: number,
|
||||||
|
) {
|
||||||
|
return pluginData.state.recentActions.filter(action => {
|
||||||
|
if (action.timestamp < since) return false;
|
||||||
|
if (action.type !== type) return false;
|
||||||
|
if (action.actionGroupId !== actionGroupId) return false;
|
||||||
|
if (action.userId !== userId) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
167
backend/src/plugins/Spam/util/logAndDetectMessageSpam.ts
Normal file
167
backend/src/plugins/Spam/util/logAndDetectMessageSpam.ts
Normal file
|
@ -0,0 +1,167 @@
|
||||||
|
import { SavedMessage } from "src/data/entities/SavedMessage";
|
||||||
|
import { RecentActionType, TBaseSingleSpamConfig, SpamPluginType } from "../types";
|
||||||
|
import moment from "moment-timezone";
|
||||||
|
import { MuteResult } from "src/plugins/Mutes/types";
|
||||||
|
import { convertDelayStringToMS, trimLines, stripObjectToScalars, resolveMember, noop } from "src/utils";
|
||||||
|
import { LogType } from "src/data/LogType";
|
||||||
|
import { CaseTypes } from "src/data/CaseTypes";
|
||||||
|
import { logger } from "src/logger";
|
||||||
|
import { PluginData } from "knub";
|
||||||
|
import { MutesPlugin } from "src/plugins/Mutes/MutesPlugin";
|
||||||
|
import { CasesPlugin } from "src/plugins/Cases/CasesPlugin";
|
||||||
|
import { addRecentAction } from "./addRecentAction";
|
||||||
|
import { getRecentActionCount } from "./getRecentActionCount";
|
||||||
|
import { getRecentActions } from "./getRecentActions";
|
||||||
|
import { clearRecentUserActions } from "./clearRecentUserActions";
|
||||||
|
import { saveSpamArchives } from "./saveSpamArchives";
|
||||||
|
|
||||||
|
export async function logAndDetectMessageSpam(
|
||||||
|
pluginData: PluginData<SpamPluginType>,
|
||||||
|
savedMessage: SavedMessage,
|
||||||
|
type: RecentActionType,
|
||||||
|
spamConfig: TBaseSingleSpamConfig,
|
||||||
|
actionCount: number,
|
||||||
|
description: string,
|
||||||
|
) {
|
||||||
|
if (actionCount === 0) return;
|
||||||
|
|
||||||
|
// Make sure we're not handling some messages twice
|
||||||
|
if (pluginData.state.lastHandledMsgIds.has(savedMessage.user_id)) {
|
||||||
|
const channelMap = pluginData.state.lastHandledMsgIds.get(savedMessage.user_id);
|
||||||
|
if (channelMap.has(savedMessage.channel_id)) {
|
||||||
|
const lastHandledMsgId = channelMap.get(savedMessage.channel_id);
|
||||||
|
if (lastHandledMsgId >= savedMessage.id) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pluginData.state.spamDetectionQueue = pluginData.state.spamDetectionQueue.then(
|
||||||
|
async () => {
|
||||||
|
const timestamp = moment(savedMessage.posted_at).valueOf();
|
||||||
|
const member = await resolveMember(pluginData.client, pluginData.guild, savedMessage.user_id);
|
||||||
|
|
||||||
|
// Log this action...
|
||||||
|
addRecentAction(
|
||||||
|
pluginData,
|
||||||
|
type,
|
||||||
|
savedMessage.user_id,
|
||||||
|
savedMessage.channel_id,
|
||||||
|
savedMessage,
|
||||||
|
timestamp,
|
||||||
|
actionCount,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ...and then check if it trips the spam filters
|
||||||
|
const since = timestamp - 1000 * spamConfig.interval;
|
||||||
|
const recentActionsCount = getRecentActionCount(
|
||||||
|
pluginData,
|
||||||
|
type,
|
||||||
|
savedMessage.user_id,
|
||||||
|
savedMessage.channel_id,
|
||||||
|
since,
|
||||||
|
);
|
||||||
|
|
||||||
|
// If the user tripped the spam filter...
|
||||||
|
if (recentActionsCount > spamConfig.count) {
|
||||||
|
const recentActions = getRecentActions(pluginData, type, savedMessage.user_id, savedMessage.channel_id, since);
|
||||||
|
|
||||||
|
// Start by muting them, if enabled
|
||||||
|
let muteResult: MuteResult;
|
||||||
|
if (spamConfig.mute && member) {
|
||||||
|
const mutesPlugin = pluginData.getPlugin(MutesPlugin);
|
||||||
|
const muteTime = spamConfig.mute_time ? convertDelayStringToMS(spamConfig.mute_time.toString()) : 120 * 1000;
|
||||||
|
muteResult = await mutesPlugin.muteUser(member.id, muteTime, "Automatic spam detection", {
|
||||||
|
caseArgs: {
|
||||||
|
modId: pluginData.client.user.id,
|
||||||
|
postInCaseLogOverride: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the offending message IDs
|
||||||
|
// We also get the IDs of any messages after the last offending message, to account for lag before detection
|
||||||
|
const savedMessages = recentActions.map(a => a.extraData as SavedMessage);
|
||||||
|
const msgIds = savedMessages.map(m => m.id);
|
||||||
|
const lastDetectedMsgId = msgIds[msgIds.length - 1];
|
||||||
|
|
||||||
|
const additionalMessages = await pluginData.state.savedMessages.getUserMessagesByChannelAfterId(
|
||||||
|
savedMessage.user_id,
|
||||||
|
savedMessage.channel_id,
|
||||||
|
lastDetectedMsgId,
|
||||||
|
);
|
||||||
|
additionalMessages.forEach(m => msgIds.push(m.id));
|
||||||
|
|
||||||
|
// Then, if enabled, remove the spam messages
|
||||||
|
if (spamConfig.clean !== false) {
|
||||||
|
msgIds.forEach(id => pluginData.state.logs.ignoreLog(LogType.MESSAGE_DELETE, id));
|
||||||
|
pluginData.client.deleteMessages(savedMessage.channel_id, msgIds).catch(noop);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the ID of the last handled message
|
||||||
|
const uniqueMessages = Array.from(new Set([...savedMessages, ...additionalMessages]));
|
||||||
|
uniqueMessages.sort((a, b) => (a.id > b.id ? 1 : -1));
|
||||||
|
const lastHandledMsgId = uniqueMessages.reduce((last: string, m: SavedMessage): string => {
|
||||||
|
return !last || m.id > last ? m.id : last;
|
||||||
|
}, null);
|
||||||
|
|
||||||
|
if (!pluginData.state.lastHandledMsgIds.has(savedMessage.user_id)) {
|
||||||
|
pluginData.state.lastHandledMsgIds.set(savedMessage.user_id, new Map());
|
||||||
|
}
|
||||||
|
|
||||||
|
const channelMap = pluginData.state.lastHandledMsgIds.get(savedMessage.user_id);
|
||||||
|
channelMap.set(savedMessage.channel_id, lastHandledMsgId);
|
||||||
|
|
||||||
|
// Clear the handled actions from recentActions
|
||||||
|
clearRecentUserActions(pluginData, type, savedMessage.user_id, savedMessage.channel_id);
|
||||||
|
|
||||||
|
// Generate a log from the detected messages
|
||||||
|
const channel = pluginData.guild.channels.get(savedMessage.channel_id);
|
||||||
|
const archiveUrl = await saveSpamArchives(pluginData, uniqueMessages);
|
||||||
|
|
||||||
|
// Create a case
|
||||||
|
const casesPlugin = pluginData.getPlugin(CasesPlugin);
|
||||||
|
if (muteResult) {
|
||||||
|
// If the user was muted, the mute already generated a case - in that case, just update the case with extra details
|
||||||
|
// This will also post the case in the case log channel, which we didn't do with the mute initially to avoid
|
||||||
|
// posting the case on the channel twice: once with the initial reason, and then again with the note from here
|
||||||
|
const updateText = trimLines(`
|
||||||
|
Details: ${description} (over ${spamConfig.count} in ${spamConfig.interval}s)
|
||||||
|
${archiveUrl}
|
||||||
|
`);
|
||||||
|
casesPlugin.createCaseNote({
|
||||||
|
caseId: muteResult.case.id,
|
||||||
|
modId: muteResult.case.mod_id,
|
||||||
|
body: updateText,
|
||||||
|
automatic: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// If the user was not muted, create a note case of the detected spam instead
|
||||||
|
const caseText = trimLines(`
|
||||||
|
Automatic spam detection: ${description} (over ${spamConfig.count} in ${spamConfig.interval}s)
|
||||||
|
${archiveUrl}
|
||||||
|
`);
|
||||||
|
|
||||||
|
casesPlugin.createCase({
|
||||||
|
userId: savedMessage.user_id,
|
||||||
|
modId: pluginData.client.user.id,
|
||||||
|
type: CaseTypes.Note,
|
||||||
|
reason: caseText,
|
||||||
|
automatic: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a log entry
|
||||||
|
pluginData.state.logs.log(LogType.MESSAGE_SPAM_DETECTED, {
|
||||||
|
member: stripObjectToScalars(member, ["user", "roles"]),
|
||||||
|
channel: stripObjectToScalars(channel),
|
||||||
|
description,
|
||||||
|
limit: spamConfig.count,
|
||||||
|
interval: spamConfig.interval,
|
||||||
|
archiveUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
err => {
|
||||||
|
logger.error(`Error while detecting spam:\n${err}`);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
66
backend/src/plugins/Spam/util/logAndDetectOtherSpam.ts
Normal file
66
backend/src/plugins/Spam/util/logAndDetectOtherSpam.ts
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
import { PluginData } from "knub";
|
||||||
|
import { SpamPluginType, RecentActionType } from "../types";
|
||||||
|
import { addRecentAction } from "./addRecentAction";
|
||||||
|
import { getRecentActionCount } from "./getRecentActionCount";
|
||||||
|
import { resolveMember, convertDelayStringToMS, stripObjectToScalars } from "src/utils";
|
||||||
|
import { MutesPlugin } from "src/plugins/Mutes/MutesPlugin";
|
||||||
|
import { CasesPlugin } from "src/plugins/Cases/CasesPlugin";
|
||||||
|
import { CaseTypes } from "src/data/CaseTypes";
|
||||||
|
import { clearRecentUserActions } from "./clearRecentUserActions";
|
||||||
|
import { LogType } from "src/data/LogType";
|
||||||
|
|
||||||
|
export async function logAndDetectOtherSpam(
|
||||||
|
pluginData: PluginData<SpamPluginType>,
|
||||||
|
type: RecentActionType,
|
||||||
|
spamConfig: any,
|
||||||
|
userId: string,
|
||||||
|
actionCount: number,
|
||||||
|
actionGroupId: string,
|
||||||
|
timestamp: number,
|
||||||
|
extraData = null,
|
||||||
|
description: string,
|
||||||
|
) {
|
||||||
|
pluginData.state.spamDetectionQueue = pluginData.state.spamDetectionQueue.then(async () => {
|
||||||
|
// Log this action...
|
||||||
|
addRecentAction(pluginData, type, userId, actionGroupId, extraData, timestamp, actionCount);
|
||||||
|
|
||||||
|
// ...and then check if it trips the spam filters
|
||||||
|
const since = timestamp - 1000 * spamConfig.interval;
|
||||||
|
const recentActionsCount = getRecentActionCount(pluginData, type, userId, actionGroupId, since);
|
||||||
|
|
||||||
|
if (recentActionsCount > spamConfig.count) {
|
||||||
|
const member = await resolveMember(pluginData.client, pluginData.guild, userId);
|
||||||
|
const details = `${description} (over ${spamConfig.count} in ${spamConfig.interval}s)`;
|
||||||
|
|
||||||
|
if (spamConfig.mute && member) {
|
||||||
|
const mutesPlugin = pluginData.getPlugin(MutesPlugin);
|
||||||
|
const muteTime = spamConfig.mute_time ? convertDelayStringToMS(spamConfig.mute_time.toString()) : 120 * 1000;
|
||||||
|
await mutesPlugin.muteUser(member.id, muteTime, "Automatic spam detection", {
|
||||||
|
caseArgs: {
|
||||||
|
modId: pluginData.client.user.id,
|
||||||
|
extraNotes: [`Details: ${details}`],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// If we're not muting the user, just add a note on them
|
||||||
|
const casesPlugin = pluginData.getPlugin(CasesPlugin);
|
||||||
|
await casesPlugin.createCase({
|
||||||
|
userId,
|
||||||
|
modId: pluginData.client.user.id,
|
||||||
|
type: CaseTypes.Note,
|
||||||
|
reason: `Automatic spam detection: ${details}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear recent cases
|
||||||
|
clearRecentUserActions(pluginData, RecentActionType.VoiceChannelMove, userId, actionGroupId);
|
||||||
|
|
||||||
|
pluginData.state.logs.log(LogType.OTHER_SPAM_DETECTED, {
|
||||||
|
member: stripObjectToScalars(member, ["user", "roles"]),
|
||||||
|
description,
|
||||||
|
limit: spamConfig.count,
|
||||||
|
interval: spamConfig.interval,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
23
backend/src/plugins/Spam/util/logCensor.ts
Normal file
23
backend/src/plugins/Spam/util/logCensor.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import { PluginData } from "knub";
|
||||||
|
import { SpamPluginType, RecentActionType } from "../types";
|
||||||
|
import { SavedMessage } from "src/data/entities/SavedMessage";
|
||||||
|
import { logAndDetectMessageSpam } from "./logAndDetectMessageSpam";
|
||||||
|
|
||||||
|
export async function logCensor(pluginData: PluginData<SpamPluginType>, savedMessage: SavedMessage) {
|
||||||
|
const config = pluginData.config.getMatchingConfig({
|
||||||
|
userId: savedMessage.user_id,
|
||||||
|
channelId: savedMessage.channel_id,
|
||||||
|
});
|
||||||
|
const spamConfig = config.max_censor;
|
||||||
|
|
||||||
|
if (spamConfig) {
|
||||||
|
logAndDetectMessageSpam(
|
||||||
|
pluginData,
|
||||||
|
savedMessage,
|
||||||
|
RecentActionType.Censor,
|
||||||
|
spamConfig,
|
||||||
|
1,
|
||||||
|
"too many censored messages",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
86
backend/src/plugins/Spam/util/onMessageCreate.ts
Normal file
86
backend/src/plugins/Spam/util/onMessageCreate.ts
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
import { PluginData } from "knub";
|
||||||
|
import { SpamPluginType, RecentActionType } from "../types";
|
||||||
|
import { SavedMessage } from "src/data/entities/SavedMessage";
|
||||||
|
import { getUserMentions, getRoleMentions, getUrlsInString, getEmojiInString } from "src/utils";
|
||||||
|
import { logAndDetectMessageSpam } from "./logAndDetectMessageSpam";
|
||||||
|
|
||||||
|
export async function onMessageCreate(pluginData: PluginData<SpamPluginType>, savedMessage: SavedMessage) {
|
||||||
|
if (savedMessage.is_bot) return;
|
||||||
|
|
||||||
|
const config = pluginData.config.getMatchingConfig({
|
||||||
|
userId: savedMessage.user_id,
|
||||||
|
channelId: savedMessage.channel_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const maxMessages = config.max_messages;
|
||||||
|
if (maxMessages) {
|
||||||
|
logAndDetectMessageSpam(pluginData, savedMessage, RecentActionType.Message, maxMessages, 1, "too many messages");
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxMentions = config.max_mentions;
|
||||||
|
const mentions = savedMessage.data.content
|
||||||
|
? [...getUserMentions(savedMessage.data.content), ...getRoleMentions(savedMessage.data.content)]
|
||||||
|
: [];
|
||||||
|
if (maxMentions && mentions.length) {
|
||||||
|
logAndDetectMessageSpam(
|
||||||
|
pluginData,
|
||||||
|
savedMessage,
|
||||||
|
RecentActionType.Mention,
|
||||||
|
maxMentions,
|
||||||
|
mentions.length,
|
||||||
|
"too many mentions",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxLinks = config.max_links;
|
||||||
|
if (maxLinks && savedMessage.data.content && typeof savedMessage.data.content === "string") {
|
||||||
|
const links = getUrlsInString(savedMessage.data.content);
|
||||||
|
logAndDetectMessageSpam(pluginData, savedMessage, RecentActionType.Link, maxLinks, links.length, "too many links");
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxAttachments = config.max_attachments;
|
||||||
|
if (maxAttachments && savedMessage.data.attachments) {
|
||||||
|
logAndDetectMessageSpam(
|
||||||
|
pluginData,
|
||||||
|
savedMessage,
|
||||||
|
RecentActionType.Attachment,
|
||||||
|
maxAttachments,
|
||||||
|
savedMessage.data.attachments.length,
|
||||||
|
"too many attachments",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxEmojis = config.max_emojis;
|
||||||
|
if (maxEmojis && savedMessage.data.content) {
|
||||||
|
const emojiCount = getEmojiInString(savedMessage.data.content).length;
|
||||||
|
logAndDetectMessageSpam(pluginData, savedMessage, RecentActionType.Emoji, maxEmojis, emojiCount, "too many emoji");
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxNewlines = config.max_newlines;
|
||||||
|
if (maxNewlines && savedMessage.data.content) {
|
||||||
|
const newlineCount = (savedMessage.data.content.match(/\n/g) || []).length;
|
||||||
|
logAndDetectMessageSpam(
|
||||||
|
pluginData,
|
||||||
|
savedMessage,
|
||||||
|
RecentActionType.Newline,
|
||||||
|
maxNewlines,
|
||||||
|
newlineCount,
|
||||||
|
"too many newlines",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxCharacters = config.max_characters;
|
||||||
|
if (maxCharacters && savedMessage.data.content) {
|
||||||
|
const characterCount = [...savedMessage.data.content.trim()].length;
|
||||||
|
logAndDetectMessageSpam(
|
||||||
|
pluginData,
|
||||||
|
savedMessage,
|
||||||
|
RecentActionType.Character,
|
||||||
|
maxCharacters,
|
||||||
|
characterCount,
|
||||||
|
"too many characters",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Max duplicates check
|
||||||
|
}
|
12
backend/src/plugins/Spam/util/saveSpamArchives.ts
Normal file
12
backend/src/plugins/Spam/util/saveSpamArchives.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import { SavedMessage } from "src/data/entities/SavedMessage";
|
||||||
|
import moment from "moment-timezone";
|
||||||
|
import { getBaseUrl } from "src/pluginUtils";
|
||||||
|
|
||||||
|
const SPAM_ARCHIVE_EXPIRY_DAYS = 90;
|
||||||
|
|
||||||
|
export async function saveSpamArchives(pluginData, savedMessages: SavedMessage[]) {
|
||||||
|
const expiresAt = moment().add(SPAM_ARCHIVE_EXPIRY_DAYS, "days");
|
||||||
|
const archiveId = await pluginData.state.archives.createFromSavedMessages(savedMessages, pluginData.guild, expiresAt);
|
||||||
|
|
||||||
|
return pluginData.state.archives.getUrl(getBaseUrl, archiveId);
|
||||||
|
}
|
|
@ -26,12 +26,12 @@ export function zeppelinPlugin<TPluginType extends BasePluginType>(): <
|
||||||
>(
|
>(
|
||||||
name: string,
|
name: string,
|
||||||
blueprint: TPartialBlueprint,
|
blueprint: TPartialBlueprint,
|
||||||
) => TPartialBlueprint & { name: string };
|
) => TPartialBlueprint & { name: string; configPreprocessor: PluginBlueprint<TPluginType>["configPreprocessor"] };
|
||||||
|
|
||||||
export function zeppelinPlugin(...args) {
|
export function zeppelinPlugin(...args) {
|
||||||
if (args.length) {
|
if (args.length) {
|
||||||
const blueprint: ZeppelinPluginBlueprint = plugin(...(args as Parameters<typeof plugin>));
|
const blueprint: ZeppelinPluginBlueprint = plugin(...(args as Parameters<typeof plugin>));
|
||||||
blueprint.configPreprocessor = getPluginConfigPreprocessor(blueprint);
|
blueprint.configPreprocessor = getPluginConfigPreprocessor(blueprint, blueprint.configPreprocessor);
|
||||||
return blueprint;
|
return blueprint;
|
||||||
} else {
|
} else {
|
||||||
return zeppelinPlugin;
|
return zeppelinPlugin;
|
||||||
|
|
|
@ -23,6 +23,10 @@ import { SlowmodePlugin } from "./Slowmode/SlowmodePlugin";
|
||||||
import { StarboardPlugin } from "./Starboard/StarboardPlugin";
|
import { StarboardPlugin } from "./Starboard/StarboardPlugin";
|
||||||
import { ChannelArchiverPlugin } from "./ChannelArchiver/ChannelArchiverPlugin";
|
import { ChannelArchiverPlugin } from "./ChannelArchiver/ChannelArchiverPlugin";
|
||||||
import { LogsPlugin } from "./Logs/LogsPlugin";
|
import { LogsPlugin } from "./Logs/LogsPlugin";
|
||||||
|
import { SelfGrantableRolesPlugin } from "./SelfGrantableRoles/SelfGrantableRolesPlugin";
|
||||||
|
import { SpamPlugin } from "./Spam/SpamPlugin";
|
||||||
|
import { ReactionRolesPlugin } from "./ReactionRoles/ReactionRolesPlugin";
|
||||||
|
import { AutomodPlugin } from "./Automod/AutomodPlugin";
|
||||||
|
|
||||||
// prettier-ignore
|
// prettier-ignore
|
||||||
export const guildPlugins: Array<ZeppelinPluginBlueprint<any>> = [
|
export const guildPlugins: Array<ZeppelinPluginBlueprint<any>> = [
|
||||||
|
@ -36,12 +40,15 @@ export const guildPlugins: Array<ZeppelinPluginBlueprint<any>> = [
|
||||||
PersistPlugin,
|
PersistPlugin,
|
||||||
PingableRolesPlugin,
|
PingableRolesPlugin,
|
||||||
PostPlugin,
|
PostPlugin,
|
||||||
|
ReactionRolesPlugin,
|
||||||
MessageSaverPlugin,
|
MessageSaverPlugin,
|
||||||
ModActionsPlugin,
|
ModActionsPlugin,
|
||||||
NameHistoryPlugin,
|
NameHistoryPlugin,
|
||||||
RemindersPlugin,
|
RemindersPlugin,
|
||||||
RolesPlugin,
|
RolesPlugin,
|
||||||
|
SelfGrantableRolesPlugin,
|
||||||
SlowmodePlugin,
|
SlowmodePlugin,
|
||||||
|
SpamPlugin,
|
||||||
StarboardPlugin,
|
StarboardPlugin,
|
||||||
TagsPlugin,
|
TagsPlugin,
|
||||||
UsernameSaverPlugin,
|
UsernameSaverPlugin,
|
||||||
|
@ -49,6 +56,7 @@ export const guildPlugins: Array<ZeppelinPluginBlueprint<any>> = [
|
||||||
WelcomeMessagePlugin,
|
WelcomeMessagePlugin,
|
||||||
CasesPlugin,
|
CasesPlugin,
|
||||||
MutesPlugin,
|
MutesPlugin,
|
||||||
|
AutomodPlugin,
|
||||||
];
|
];
|
||||||
|
|
||||||
// prettier-ignore
|
// prettier-ignore
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import {
|
import {
|
||||||
|
AnyInvite,
|
||||||
Attachment,
|
Attachment,
|
||||||
ChannelInvite,
|
ChannelInvite,
|
||||||
Client,
|
Client,
|
||||||
|
@ -9,6 +10,7 @@ import {
|
||||||
GuildAuditLog,
|
GuildAuditLog,
|
||||||
GuildAuditLogEntry,
|
GuildAuditLogEntry,
|
||||||
GuildChannel,
|
GuildChannel,
|
||||||
|
GuildInvite,
|
||||||
Member,
|
Member,
|
||||||
Message,
|
Message,
|
||||||
MessageContent,
|
MessageContent,
|
||||||
|
@ -32,6 +34,7 @@ import { either } from "fp-ts/lib/Either";
|
||||||
import moment from "moment-timezone";
|
import moment from "moment-timezone";
|
||||||
import { SimpleCache } from "./SimpleCache";
|
import { SimpleCache } from "./SimpleCache";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
|
import { Awaitable } from "knub/dist/utils";
|
||||||
|
|
||||||
const fsp = fs.promises;
|
const fsp = fs.promises;
|
||||||
|
|
||||||
|
@ -1216,3 +1219,11 @@ export function trimPluginDescription(str) {
|
||||||
export function isFullMessage(msg: PossiblyUncachedMessage): msg is Message {
|
export function isFullMessage(msg: PossiblyUncachedMessage): msg is Message {
|
||||||
return (msg as Message).createdAt != null;
|
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)));
|
||||||
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue