diff --git a/backend/src/configValidator.ts b/backend/src/configValidator.ts index b5b2610c..854851e5 100644 --- a/backend/src/configValidator.ts +++ b/backend/src/configValidator.ts @@ -3,12 +3,12 @@ import { IPluginOptions } from "knub"; import { pipe } from "fp-ts/lib/pipeable"; import { fold } from "fp-ts/lib/Either"; import { PathReporter } from "io-ts/lib/PathReporter"; -import { availablePlugins } from "./plugins/availablePlugins"; +import { guildPlugins } from "./plugins/availablePlugins"; import { ZeppelinPluginClass } from "./plugins/ZeppelinPluginClass"; import { decodeAndValidateStrict, StrictValidationError } from "./validatorUtils"; const pluginNameToClass = new Map(); -for (const pluginClass of availablePlugins) { +for (const pluginClass of guildPlugins) { // @ts-ignore pluginNameToClass.set(pluginClass.pluginName, pluginClass); } diff --git a/backend/src/index.ts b/backend/src/index.ts index 642e42ca..89187f4e 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -11,6 +11,14 @@ import { Configs } from "./data/Configs"; require("dotenv").config({ path: path.resolve(process.cwd(), "bot.env") }); +declare global { + // This is here so TypeScript doesn't give an error when importing twemoji + // since one of the signatures of twemoji.parse() takes an HTMLElement but + // we're not in a browser environment so including the DOM lib would not make + // sense + type HTMLElement = unknown; +} + // Error handling let recentPluginErrors = 0; const RECENT_PLUGIN_ERROR_EXIT_THRESHOLD = 5; @@ -79,7 +87,7 @@ moment.tz.setDefault("UTC"); import { Client, TextChannel } from "eris"; import { connect } from "./data/db"; -import { availablePlugins, availableGlobalPlugins, basePlugins } from "./plugins/availablePlugins"; +import { guildPlugins, globalPlugins } from "./plugins/availablePlugins"; import { errorMessage, isDiscordHTTPError, isDiscordRESTError, successMessage } from "./utils"; import { startUptimeCounter } from "./uptime"; import { AllowedGuilds } from "./data/AllowedGuilds"; @@ -107,8 +115,8 @@ connect().then(async () => { const guildConfigs = new Configs(); const bot = new Knub(client, { - guildPlugins: availablePlugins, - globalPlugins: availableGlobalPlugins, + guildPlugins, + globalPlugins, options: { canLoadGuild(guildId): Promise { @@ -124,19 +132,11 @@ connect().then(async () => { async getEnabledPlugins(this: Knub, guildId, guildConfig): Promise { const configuredPlugins = guildConfig.plugins || {}; const pluginNames: string[] = Array.from(this.guildPlugins.keys()); - const plugins: Array = Array.from(this.guildPlugins.values()); + const plugins: ZeppelinPlugin[] = Array.from(this.guildPlugins.values()); - const enabledBasePlugins = pluginNames.filter(n => basePlugins.includes(n)); - const explicitlyEnabledPlugins = pluginNames.filter(pluginName => { + return pluginNames.filter(pluginName => { return configuredPlugins[pluginName] && configuredPlugins[pluginName].enabled !== false; }); - const enabledPlugins = new Set([...enabledBasePlugins, ...explicitlyEnabledPlugins]); - - const finalEnabledPlugins = new Set([ - ...basePlugins, - ...explicitlyEnabledPlugins, - ]); - return Array.from(finalEnabledPlugins.values()); }, async getConfig(id) { diff --git a/backend/src/plugins/AntiRaid.ts b/backend/src/plugins/AntiRaid.ts deleted file mode 100644 index 999c3c0d..00000000 --- a/backend/src/plugins/AntiRaid.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { IPluginOptions, logger } from "knub"; -import * as t from "io-ts"; -import { ZeppelinPluginClass } from "./ZeppelinPluginClass"; -import { GuildSavedMessages } from "../data/GuildSavedMessages"; -import { SavedMessage } from "../data/entities/SavedMessage"; -import { convertDelayStringToMS, MINUTES, sorter, stripObjectToScalars, tDelayString } from "../utils"; -import { GuildLogs } from "../data/GuildLogs"; -import { LogType } from "../data/LogType"; -import moment from "moment-timezone"; - -const AntiRaidLevel = t.type({ - on_join: t.type({ - kick: t.boolean, - ban: t.boolean, - }), -}); - -const ConfigSchema = t.type({ - enabled: t.boolean, - delay: tDelayString, -}); -type TConfigSchema = t.TypeOf; - -interface IDeletionQueueItem { - deleteAt: number; - message: SavedMessage; -} - -const MAX_DELAY = 5 * MINUTES; - -export class AntiRaid extends ZeppelinPluginClass { - public static pluginName = "auto_delete"; - public static showInDocs = true; - - public static configSchema = ConfigSchema; - - public static pluginInfo = { - prettyName: "Auto-delete", - description: "Allows Zeppelin to auto-delete messages from a channel after a delay", - configurationGuide: "Maximum deletion delay is currently 5 minutes", - }; - - protected guildSavedMessages: GuildSavedMessages; - protected guildLogs: GuildLogs; - - protected onMessageCreateFn; - protected onMessageDeleteFn; - protected onMessageDeleteBulkFn; - - protected deletionQueue: IDeletionQueueItem[]; - protected nextDeletion: number; - protected nextDeletionTimeout; - - protected maxDelayWarningSent = false; - - public static getStaticDefaultOptions(): IPluginOptions { - return { - config: { - enabled: false, - delay: "5s", - }, - }; - } - - protected onLoad() { - this.guildSavedMessages = GuildSavedMessages.getGuildInstance(this.guildId); - this.guildLogs = new GuildLogs(this.guildId); - - this.deletionQueue = []; - - this.onMessageCreateFn = this.onMessageCreate.bind(this); - this.onMessageDeleteFn = this.onMessageDelete.bind(this); - this.onMessageDeleteBulkFn = this.onMessageDeleteBulk.bind(this); - - this.guildSavedMessages.events.on("create", this.onMessageCreateFn); - this.guildSavedMessages.events.on("delete", this.onMessageDeleteFn); - this.guildSavedMessages.events.on("deleteBulk", this.onMessageDeleteBulkFn); - } - - protected onUnload() { - this.guildSavedMessages.events.off("create", this.onMessageCreateFn); - this.guildSavedMessages.events.off("delete", this.onMessageDeleteFn); - this.guildSavedMessages.events.off("deleteBulk", this.onMessageDeleteFn); - clearTimeout(this.nextDeletionTimeout); - } - - protected addMessageToDeletionQueue(msg: SavedMessage, delay: number) { - const deleteAt = Date.now() + delay; - this.deletionQueue.push({ deleteAt, message: msg }); - this.deletionQueue.sort(sorter("deleteAt")); - - this.scheduleNextDeletion(); - } - - protected scheduleNextDeletion() { - if (this.deletionQueue.length === 0) { - clearTimeout(this.nextDeletionTimeout); - return; - } - - const firstDeleteAt = this.deletionQueue[0].deleteAt; - clearTimeout(this.nextDeletionTimeout); - this.nextDeletionTimeout = setTimeout(() => this.deleteNextItem(), firstDeleteAt - Date.now()); - } - - protected async deleteNextItem() { - const [itemToDelete] = this.deletionQueue.splice(0, 1); - if (!itemToDelete) return; - - this.guildLogs.ignoreLog(LogType.MESSAGE_DELETE, itemToDelete.message.id); - this.bot.deleteMessage(itemToDelete.message.channel_id, itemToDelete.message.id).catch(logger.warn); - - this.scheduleNextDeletion(); - - const user = await this.resolveUser(itemToDelete.message.user_id); - const channel = this.guild.channels.get(itemToDelete.message.channel_id); - const messageDate = moment(itemToDelete.message.data.timestamp, "x").format("YYYY-MM-DD HH:mm:ss"); - - this.guildLogs.log(LogType.MESSAGE_DELETE_AUTO, { - message: itemToDelete.message, - user: stripObjectToScalars(user), - channel: stripObjectToScalars(channel), - messageDate, - }); - } - - protected onMessageCreate(msg: SavedMessage) { - const config = this.getConfigForMemberIdAndChannelId(msg.user_id, msg.channel_id); - if (config.enabled) { - let delay = convertDelayStringToMS(config.delay); - - if (delay > MAX_DELAY) { - delay = MAX_DELAY; - if (!this.maxDelayWarningSent) { - this.guildLogs.log(LogType.BOT_ALERT, { - body: `Clamped auto-deletion delay in <#${msg.channel_id}> to 5 minutes`, - }); - this.maxDelayWarningSent = true; - } - } - - this.addMessageToDeletionQueue(msg, delay); - } - } - - protected onMessageDelete(msg: SavedMessage) { - const indexToDelete = this.deletionQueue.findIndex(item => item.message.id === msg.id); - if (indexToDelete > -1) { - this.deletionQueue.splice(indexToDelete, 1); - this.scheduleNextDeletion(); - } - } - - protected onMessageDeleteBulk(messages: SavedMessage[]) { - for (const msg of messages) { - this.onMessageDelete(msg); - } - } -} diff --git a/backend/src/plugins/AutoDelete.ts b/backend/src/plugins/AutoDelete.ts deleted file mode 100644 index 608313c2..00000000 --- a/backend/src/plugins/AutoDelete.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { IPluginOptions, logger } from "knub"; -import * as t from "io-ts"; -import { ZeppelinPluginClass } from "./ZeppelinPluginClass"; -import { GuildSavedMessages } from "../data/GuildSavedMessages"; -import { SavedMessage } from "../data/entities/SavedMessage"; -import { convertDelayStringToMS, MINUTES, sorter, stripObjectToScalars, tDelayString } from "../utils"; -import { GuildLogs } from "../data/GuildLogs"; -import { LogType } from "../data/LogType"; -import moment from "moment-timezone"; - -const ConfigSchema = t.type({ - enabled: t.boolean, - delay: tDelayString, -}); -type TConfigSchema = t.TypeOf; - -interface IDeletionQueueItem { - deleteAt: number; - message: SavedMessage; -} - -const MAX_DELAY = 5 * MINUTES; - -export class AutoDeletePlugin extends ZeppelinPluginClass { - public static pluginName = "auto_delete"; - public static showInDocs = true; - - public static configSchema = ConfigSchema; - - public static pluginInfo = { - prettyName: "Auto-delete", - description: "Allows Zeppelin to auto-delete messages from a channel after a delay", - configurationGuide: "Maximum deletion delay is currently 5 minutes", - }; - - protected guildSavedMessages: GuildSavedMessages; - protected guildLogs: GuildLogs; - - protected onMessageCreateFn; - protected onMessageDeleteFn; - protected onMessageDeleteBulkFn; - - protected deletionQueue: IDeletionQueueItem[]; - protected nextDeletion: number; - protected nextDeletionTimeout; - - protected maxDelayWarningSent = false; - - public static getStaticDefaultOptions(): IPluginOptions { - return { - config: { - enabled: false, - delay: "5s", - }, - }; - } - - protected onLoad() { - this.guildSavedMessages = GuildSavedMessages.getGuildInstance(this.guildId); - this.guildLogs = new GuildLogs(this.guildId); - - this.deletionQueue = []; - - this.onMessageCreateFn = this.onMessageCreate.bind(this); - this.onMessageDeleteFn = this.onMessageDelete.bind(this); - this.onMessageDeleteBulkFn = this.onMessageDeleteBulk.bind(this); - - this.guildSavedMessages.events.on("create", this.onMessageCreateFn); - this.guildSavedMessages.events.on("delete", this.onMessageDeleteFn); - this.guildSavedMessages.events.on("deleteBulk", this.onMessageDeleteBulkFn); - } - - protected onUnload() { - this.guildSavedMessages.events.off("create", this.onMessageCreateFn); - this.guildSavedMessages.events.off("delete", this.onMessageDeleteFn); - this.guildSavedMessages.events.off("deleteBulk", this.onMessageDeleteFn); - clearTimeout(this.nextDeletionTimeout); - } - - protected addMessageToDeletionQueue(msg: SavedMessage, delay: number) { - const deleteAt = Date.now() + delay; - this.deletionQueue.push({ deleteAt, message: msg }); - this.deletionQueue.sort(sorter("deleteAt")); - - this.scheduleNextDeletion(); - } - - protected scheduleNextDeletion() { - if (this.deletionQueue.length === 0) { - clearTimeout(this.nextDeletionTimeout); - return; - } - - const firstDeleteAt = this.deletionQueue[0].deleteAt; - clearTimeout(this.nextDeletionTimeout); - this.nextDeletionTimeout = setTimeout(() => this.deleteNextItem(), firstDeleteAt - Date.now()); - } - - protected async deleteNextItem() { - const [itemToDelete] = this.deletionQueue.splice(0, 1); - if (!itemToDelete) return; - - this.guildLogs.ignoreLog(LogType.MESSAGE_DELETE, itemToDelete.message.id); - this.bot.deleteMessage(itemToDelete.message.channel_id, itemToDelete.message.id).catch(logger.warn); - - this.scheduleNextDeletion(); - - const user = await this.resolveUser(itemToDelete.message.user_id); - const channel = this.guild.channels.get(itemToDelete.message.channel_id); - const messageDate = moment(itemToDelete.message.data.timestamp, "x").format("YYYY-MM-DD HH:mm:ss"); - - this.guildLogs.log(LogType.MESSAGE_DELETE_AUTO, { - message: itemToDelete.message, - user: stripObjectToScalars(user), - channel: stripObjectToScalars(channel), - messageDate, - }); - } - - protected onMessageCreate(msg: SavedMessage) { - const config = this.getConfigForMemberIdAndChannelId(msg.user_id, msg.channel_id); - if (config.enabled) { - let delay = convertDelayStringToMS(config.delay); - - if (delay > MAX_DELAY) { - delay = MAX_DELAY; - if (!this.maxDelayWarningSent) { - this.guildLogs.log(LogType.BOT_ALERT, { - body: `Clamped auto-deletion delay in <#${msg.channel_id}> to 5 minutes`, - }); - this.maxDelayWarningSent = true; - } - } - - this.addMessageToDeletionQueue(msg, delay); - } - } - - protected onMessageDelete(msg: SavedMessage) { - const indexToDelete = this.deletionQueue.findIndex(item => item.message.id === msg.id); - if (indexToDelete > -1) { - this.deletionQueue.splice(indexToDelete, 1); - this.scheduleNextDeletion(); - } - } - - protected onMessageDeleteBulk(messages: SavedMessage[]) { - for (const msg of messages) { - this.onMessageDelete(msg); - } - } -} diff --git a/backend/src/plugins/AutoReactionsPlugin.ts b/backend/src/plugins/AutoReactionsPlugin.ts deleted file mode 100644 index c46f6386..00000000 --- a/backend/src/plugins/AutoReactionsPlugin.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { decorators as d, IPluginOptions, logger } from "knub"; -import { GuildSavedMessages } from "../data/GuildSavedMessages"; -import { SavedMessage } from "../data/entities/SavedMessage"; -import { GuildAutoReactions } from "../data/GuildAutoReactions"; -import { Message } from "eris"; -import { customEmojiRegex, errorMessage, isDiscordRESTError, isEmoji } from "../utils"; -import { CommandInfo, trimPluginDescription, ZeppelinPluginClass } from "./ZeppelinPluginClass"; -import * as t from "io-ts"; -import { GuildLogs } from "../data/GuildLogs"; -import { LogType } from "../data/LogType"; - -const ConfigSchema = t.type({ - can_manage: t.boolean, -}); -type TConfigSchema = t.TypeOf; - -export class AutoReactionsPlugin extends ZeppelinPluginClass { - public static pluginName = "auto_reactions"; - public static configSchema = ConfigSchema; - - public static pluginInfo = { - prettyName: "Auto-reactions", - description: trimPluginDescription(` - Allows setting up automatic reactions to all new messages on a channel - `), - }; - - protected savedMessages: GuildSavedMessages; - protected autoReactions: GuildAutoReactions; - protected logs: GuildLogs; - - private onMessageCreateFn; - - public static getStaticDefaultOptions(): IPluginOptions { - return { - config: { - can_manage: false, - }, - - overrides: [ - { - level: ">=100", - config: { - can_manage: true, - }, - }, - ], - }; - } - - onLoad() { - this.logs = new GuildLogs(this.guildId); - this.savedMessages = GuildSavedMessages.getGuildInstance(this.guildId); - this.autoReactions = GuildAutoReactions.getGuildInstance(this.guildId); - - this.onMessageCreateFn = this.savedMessages.events.on("create", this.onMessageCreate.bind(this)); - } - - onUnload() { - this.savedMessages.events.off("create", this.onMessageCreateFn); - } - - @d.command("auto_reactions", " ", { - extra: { - info: { - basicUsage: "!auto_reactions 629990160477585428 👍 👎", - }, - }, - }) - @d.permission("can_manage") - async setAutoReactionsCmd(msg: Message, args: { channelId: string; reactions: string[] }) { - const finalReactions = []; - - for (const reaction of args.reactions) { - if (!isEmoji(reaction)) { - msg.channel.createMessage(errorMessage("One or more of the specified reactions were invalid!")); - return; - } - - let savedValue; - - const customEmojiMatch = reaction.match(customEmojiRegex); - if (customEmojiMatch) { - // Custom emoji - if (!this.canUseEmoji(customEmojiMatch[2])) { - msg.channel.createMessage(errorMessage("I can only use regular emojis and custom emojis from this server")); - return; - } - - savedValue = `${customEmojiMatch[1]}:${customEmojiMatch[2]}`; - } else { - // Unicode emoji - savedValue = reaction; - } - - finalReactions.push(savedValue); - } - - await this.autoReactions.set(args.channelId, finalReactions); - this.sendSuccessMessage(msg.channel, `Auto-reactions set for <#${args.channelId}>`); - } - - @d.command("auto_reactions disable", "", { - extra: { - info: { - basicUsage: "!auto_reactions disable 629990160477585428", - }, - }, - }) - @d.permission("can_manage") - async disableAutoReactionsCmd(msg: Message, args: { channelId: string }) { - const autoReaction = await this.autoReactions.getForChannel(args.channelId); - if (!autoReaction) { - msg.channel.createMessage(errorMessage(`Auto-reactions aren't enabled in <#${args.channelId}>`)); - return; - } - - await this.autoReactions.removeFromChannel(args.channelId); - this.sendSuccessMessage(msg.channel, `Auto-reactions disabled in <#${args.channelId}>`); - } - - async onMessageCreate(msg: SavedMessage) { - const autoReaction = await this.autoReactions.getForChannel(msg.channel_id); - if (!autoReaction) return; - - let realMsg; - try { - realMsg = await this.bot.getMessage(msg.channel_id, msg.id); - } catch (e) { - if (isDiscordRESTError(e)) { - logger.warn( - `Could not load auto-reaction message ${msg.channel_id}/${msg.id} in guild ${this.guild.name} (${this.guildId}) (error code ${e.code})`, - ); - - if (e.code === 50001) { - // Missing access - this.logs.log(LogType.BOT_ALERT, { - body: `Could not load auto-reaction message \`${msg.id}\` in <#${msg.channel_id}>. Make sure the bot has **Read Message History** permissions on the channel.`, - }); - } else if (e.code === 10008) { - this.logs.log(LogType.BOT_ALERT, { - body: `Could not load auto-reaction message \`${msg.id}\` in <#${msg.channel_id}>. Make sure nothing is deleting the message immediately.`, - }); - } else { - this.logs.log(LogType.BOT_ALERT, { - body: `Could not load auto-reaction message \`${msg.id}\` in <#${msg.channel_id}>. Error code ${e.code}.`, - }); - } - return; - } else { - throw e; - } - } - - for (const reaction of autoReaction.reactions) { - try { - await realMsg.addReaction(reaction); - } catch (e) { - if (isDiscordRESTError(e)) { - logger.warn( - `Could not apply auto-reaction to ${msg.channel_id}/${msg.id} in guild ${this.guild.name} (${this.guildId}) (error code ${e.code})`, - ); - - if (e.code === 10008) { - this.logs.log(LogType.BOT_ALERT, { - body: `Could not apply auto-reactions in <#${msg.channel_id}> for message \`${msg.id}\`. Make sure nothing is deleting the message before the reactions are applied.`, - }); - } else { - this.logs.log(LogType.BOT_ALERT, { - body: `Could not apply auto-reactions in <#${msg.channel_id}> for message \`${msg.id}\`. Error code ${e.code}.`, - }); - } - - return; - } else { - throw e; - } - } - } - } -} diff --git a/backend/src/plugins/Automod/Automod.ts b/backend/src/plugins/Automod/Automod.ts deleted file mode 100644 index ca2a8678..00000000 --- a/backend/src/plugins/Automod/Automod.ts +++ /dev/null @@ -1,1679 +0,0 @@ -import { trimPluginDescription, ZeppelinPluginClass } from "../ZeppelinPluginClass"; -import * as t from "io-ts"; -import { - convertDelayStringToMS, - disableInlineCode, - disableLinkPreviews, - disableUserNotificationStrings, - getEmojiInString, - getInviteCodesInString, - getRoleMentions, - getUrlsInString, - getUserMentions, - messageSummary, - MINUTES, - noop, - renderRecursively, - SECONDS, - stripObjectToScalars, - tDeepPartial, - UserNotificationMethod, - verboseChannelMention, -} from "../../utils"; -import { configUtils, CooldownManager, decorators as d, IPluginOptions, logger } from "knub"; -import { Member, Message, TextChannel, User } from "eris"; -import escapeStringRegexp from "escape-string-regexp"; -import { SimpleCache } from "../../SimpleCache"; -import { Queue } from "../../Queue"; -import { ModActionsPlugin } from "../ModActions"; -import { MutesPlugin } from "../Mutes"; -import { LogsPlugin } from "../Logs"; -import { LogType } from "../../data/LogType"; -import { GuildSavedMessages } from "../../data/GuildSavedMessages"; -import { GuildArchives } from "../../data/GuildArchives"; -import { GuildLogs } from "../../data/GuildLogs"; -import { SavedMessage } from "../../data/entities/SavedMessage"; -import moment from "moment-timezone"; -import { renderTemplate } from "../../templateFormatter"; -import { transliterate } from "transliteration"; -import { IMatchParams } from "knub/dist/configUtils"; -import { GuildAntiraidLevels } from "../../data/GuildAntiraidLevels"; -import { - AnyTriggerMatchResult, - BaseTextSpamTrigger, - MessageInfo, - OtherRecentAction, - OtherSpamTriggerMatchResult, - OtherTriggerMatchResult, - RecentAction, - RecentActionType, - RecentSpam, - Rule, - TBaseSpamTrigger, - TBaseTextSpamTrigger, - TextRecentAction, - TextSpamTriggerMatchResult, - TextTriggerMatchResult, - TextTriggerWithMultipleMatchTypes, - TMatchInvitesTrigger, - TMatchLinksTrigger, - TMatchRegexTrigger, - TMatchWordsTrigger, - TMemberJoinTrigger, - TRule, - TMatchAttachmentTypeTrigger, -} from "./types"; -import { pluginInfo } from "./info"; -import { ERRORS, RecoverablePluginError } from "../../RecoverablePluginError"; -import Timeout = NodeJS.Timeout; -import { StrictValidationError } from "src/validatorUtils"; - -const unactioned = (action: TextRecentAction | OtherRecentAction) => !action.actioned; - -/** - * DEFAULTS - */ - -const defaultMatchWordsTrigger: Partial = { - 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, -}; - -const defaultMatchRegexTrigger: Partial = { - 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, -}; - -const defaultMatchInvitesTrigger: Partial = { - 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, -}; - -const defaultMatchLinksTrigger: Partial = { - 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, -}; - -const defaultMatchAttachmentTypeTrigger: Partial = { - filetype_blacklist: [], - blacklist_enabled: false, - filetype_whitelist: [], - whitelist_enabled: false, -}; - -const defaultTextSpamTrigger: Partial> = { - per_channel: true, -}; - -const defaultMemberJoinTrigger: Partial = { - only_new: false, - new_threshold: "1h", -}; - -const defaultTriggers = { - match_words: defaultMatchWordsTrigger, - match_regex: defaultMatchRegexTrigger, - match_invites: defaultMatchInvitesTrigger, - match_links: defaultMatchLinksTrigger, - match_attachment_type: defaultMatchAttachmentTypeTrigger, - message_spam: defaultTextSpamTrigger, - mention_spam: defaultTextSpamTrigger, - link_spam: defaultTextSpamTrigger, - attachment_spam: defaultTextSpamTrigger, - emoji_spam: defaultTextSpamTrigger, - line_spam: defaultTextSpamTrigger, - character_spam: defaultTextSpamTrigger, - member_join: defaultMemberJoinTrigger, -}; - -/** - * CONFIG - */ - -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, -}); -type TConfigSchema = t.TypeOf; - -const PartialConfigSchema = tDeepPartial(ConfigSchema); - -interface ICustomOverrides { - antiraid_level: string; -} - -/** - * MISC - */ - -const RECENT_SPAM_EXPIRY_TIME = 30 * SECONDS; -const RECENT_ACTION_EXPIRY_TIME = 5 * MINUTES; -const RECENT_NICKNAME_CHANGE_EXPIRY_TIME = 5 * MINUTES; - -const inviteCache = new SimpleCache(10 * MINUTES); - -const RAID_SPAM_IDENTIFIER = "raid"; - -/** - * General plugin flow: - * - * - Message based triggers: - * 1. matchRuleToMessage() - * 2. if match -> applyActionsOnMatch() - * 3. if spam -> clearTextSpamRecentActions() - * - * - Non-message based non-spam triggers: - * 1. bespoke match function - * 2. if match -> applyActionsOnMatch() - * - * - Non-message based spam triggers: - * 1. matchOtherSpamInRule() - * 2. if match -> applyActionsOnMatch() - * 3. -> clearOtherSpamRecentActions() - * - * To log actions for spam detection, logRecentActionsForMessage() is called for each message, and several other events - * call addRecentAction() directly. These are then checked by matchRuleToMessage() and matchOtherSpamInRule() to detect - * spam. - */ -export class AutomodPlugin extends ZeppelinPluginClass { - public static pluginName = "automod"; - public static configSchema = ConfigSchema; - public static dependencies = ["mod_actions", "mutes", "logs"]; - - public static pluginInfo = pluginInfo; - - protected unloaded = false; - - // Handle automod checks/actions in a queue so we don't get overlap on the same user - protected automodQueue: Queue; - - // Recent actions are used to detect spam triggers - protected recentActions: RecentAction[]; - protected recentActionClearInterval: 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 - protected recentSpam: Map; - protected recentSpamClearInterval: Timeout; - - protected recentNicknameChanges: Map; - protected recentNicknameChangesClearInterval: Timeout; - - protected cooldownManager: CooldownManager; - - protected onMessageCreateFn; - protected onMessageUpdateFn; - protected actionedMessageIds: string[]; - protected actionedMessageMax = 50; - - protected savedMessages: GuildSavedMessages; - protected archives: GuildArchives; - protected guildLogs: GuildLogs; - protected antiraidLevels: GuildAntiraidLevels; - - protected loadedAntiraidLevel: boolean; - protected cachedAntiraidLevel: string | null; - - protected static preprocessStaticConfig(config: t.TypeOf) { - if (config.rules) { - // Loop through each rule - for (const [name, rule] of Object.entries(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 trigger of rule["triggers"]) { - // Apply default config to the triggers used in this rule - for (const [defaultTriggerName, defaultTrigger] of Object.entries(defaultTriggers)) { - if (trigger[defaultTriggerName]) { - trigger[defaultTriggerName] = configUtils.mergeConfig({}, defaultTrigger, trigger[defaultTriggerName]); - } - } - - if (trigger.match_attachment_type) { - const white = trigger.match_attachment_type.whitelist_enabled; - const black = trigger.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"]) { - if (rule["actions"]["log"] == null) { - rule["actions"]["log"] = true; - } - } - } - } - - return config; - } - - public static getStaticDefaultOptions(): IPluginOptions { - return { - 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, - }, - }, - ], - }; - } - - protected matchCustomOverrideCriteria(criteria: ICustomOverrides, matchParams: IMatchParams) { - return criteria?.antiraid_level && criteria.antiraid_level === this.cachedAntiraidLevel; - } - - protected async onLoad() { - this.automodQueue = new Queue(); - - this.recentActions = []; - this.recentActionClearInterval = setInterval(() => this.clearOldRecentActions(), 1 * MINUTES); - - this.recentSpam = new Map(); - this.recentSpamClearInterval = setInterval(() => this.clearExpiredRecentSpam(), 1 * SECONDS); - - this.recentNicknameChanges = new Map(); - this.recentNicknameChangesClearInterval = setInterval(() => this.clearExpiredRecentNicknameChanges(), 30 * SECONDS); - - this.cooldownManager = new CooldownManager(); - - this.savedMessages = GuildSavedMessages.getGuildInstance(this.guildId); - this.archives = GuildArchives.getGuildInstance(this.guildId); - this.guildLogs = new GuildLogs(this.guildId); - this.antiraidLevels = GuildAntiraidLevels.getGuildInstance(this.guildId); - - this.cachedAntiraidLevel = await this.antiraidLevels.get(); - - this.onMessageCreateFn = msg => this.runAutomodOnMessage(msg, false); - this.savedMessages.events.on("create", this.onMessageCreateFn); - - this.onMessageUpdateFn = msg => this.runAutomodOnMessage(msg, true); - this.savedMessages.events.on("update", this.onMessageUpdateFn); - - this.actionedMessageIds = []; - } - - protected getModActions(): ModActionsPlugin { - return this.getPlugin("mod_actions"); - } - - protected getLogs(): LogsPlugin { - return this.getPlugin("logs"); - } - - protected getMutes(): MutesPlugin { - return this.getPlugin("mutes"); - } - - protected onUnload() { - this.unloaded = true; - this.savedMessages.events.off("create", this.onMessageCreateFn); - this.savedMessages.events.off("update", this.onMessageUpdateFn); - clearInterval(this.recentActionClearInterval); - clearInterval(this.recentSpamClearInterval); - clearInterval(this.recentNicknameChangesClearInterval); - } - - /** - * @return Matched word - */ - protected evaluateMatchWordsTrigger(trigger: TMatchWordsTrigger, str: string): null | string { - 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 word; - } - - return null; - } - - /** - * @return Matched regex pattern - */ - protected evaluateMatchRegexTrigger(trigger: TMatchRegexTrigger, str: string): null | string { - if (trigger.normalize) { - str = transliterate(str); - } - - // TODO: Time limit regexes - for (const pattern of trigger.patterns) { - const regex = new RegExp(pattern, trigger.case_sensitive ? "" : "i"); - const test = regex.test(str); - if (test) return regex.source; - } - - return null; - } - - /** - * @return Info about matched invite - */ - protected async evaluateMatchInvitesTrigger(trigger: TMatchInvitesTrigger, str: string): Promise { - 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 { code }; - } - if (trigger.exclude_invite_codes && !trigger.exclude_invite_codes.includes(code)) { - return { code }; - } - } - - for (const inviteCode of uniqueInviteCodes) { - const invite = await this.resolveInvite(inviteCode); - if (!invite) return { code: inviteCode }; - - if (trigger.include_guilds && trigger.include_guilds.includes(invite.guild.id)) { - return { code: inviteCode, invite }; - } - if (trigger.exclude_guilds && !trigger.exclude_guilds.includes(invite.guild.id)) { - return { code: inviteCode, invite }; - } - } - - return null; - } - - /** - * @return Matched link - */ - protected evaluateMatchLinksTrigger(trigger: TMatchLinksTrigger, str: string): null | string { - 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)) { - return null; - } - } - } - - if (trigger.include_regex) { - for (const pattern of trigger.include_regex) { - if (pattern.test(link.input)) { - return 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)) { - return null; - } - } - } - - if (trigger.include_words) { - for (const word of trigger.include_words) { - const regex = new RegExp(escapeStringRegexp(word), "i"); - if (regex.test(link.input)) { - return link.input; - } - } - } - - if (trigger.exclude_domains) { - for (const domain of trigger.exclude_domains) { - const normalizedDomain = domain.toLowerCase(); - if (normalizedDomain === normalizedHostname) { - return null; - } - if (trigger.include_subdomains && normalizedHostname.endsWith(`.${domain}`)) { - return null; - } - } - - return link.toString(); - } - - if (trigger.include_domains) { - for (const domain of trigger.include_domains) { - const normalizedDomain = domain.toLowerCase(); - if (normalizedDomain === normalizedHostname) { - return domain; - } - if (trigger.include_subdomains && normalizedHostname.endsWith(`.${domain}`)) { - return domain; - } - } - } - } - - return null; - } - - protected evaluateMatchAttachmentTypeTrigger( - trigger: TMatchAttachmentTypeTrigger, - msg: SavedMessage, - ): null | { str: string; matchedValue: string } { - if (!msg.data.attachments) return null; - const attachments: any[] = msg.data.attachments; - - for (const attachment of attachments) { - const attachment_type = attachment.filename.split(`.`).pop(); - if (trigger.blacklist_enabled && trigger.filetype_blacklist.includes(attachment_type)) { - return { - str: attachment.filename, - matchedValue: `${attachment_type} - blacklisted`, - }; - } - if (trigger.whitelist_enabled && !trigger.filetype_whitelist.includes(attachment_type)) { - return { - str: attachment.filename, - matchedValue: `${attachment_type} - blacklisted`, - }; - } - } - - return null; - } - - protected matchTextSpamTrigger( - recentActionType: TextRecentAction["type"], - trigger: TBaseTextSpamTrigger, - msg: SavedMessage, - ): Omit { - const since = moment.utc(msg.posted_at).valueOf() - convertDelayStringToMS(trigger.within); - const to = moment.utc(msg.posted_at).valueOf(); - const identifier = trigger.per_channel ? `${msg.channel_id}-${msg.user_id}` : msg.user_id; - const recentActions = this.getMatchingRecentActions(recentActionType, identifier, since, to); - const totalCount = recentActions.reduce((total, action) => { - return total + action.count; - }, 0); - - if (totalCount >= trigger.amount) { - return { - type: "textspam", - actionType: recentActionType, - recentActions: recentActions as TextRecentAction[], - identifier, - }; - } - - return null; - } - - protected matchOtherSpamTrigger( - recentActionType: RecentActionType, - trigger: TBaseSpamTrigger, - identifier: string | null, - ): Omit { - const since = moment.utc().valueOf() - convertDelayStringToMS(trigger.within); - const to = moment.utc().valueOf(); - const recentActions = this.getMatchingRecentActions(recentActionType, identifier, since, to) as OtherRecentAction[]; - const totalCount = recentActions.reduce((total, action) => { - return total + action.count; - }, 0); - - if (totalCount >= trigger.amount) { - return { - type: "otherspam", - actionType: recentActionType, - recentActions, - identifier, - }; - } - - return null; - } - - protected async matchMultipleTextTypesOnMessage( - trigger: TextTriggerWithMultipleMatchTypes, - msg: SavedMessage, - matchFn: (str: string) => T | Promise | null, - ): Promise>> { - const messageInfo: MessageInfo = { channelId: msg.channel_id, messageId: msg.id, userId: msg.user_id }; - const member = await this.getMember(msg.user_id); - if (!member) return; - - if (trigger.match_messages && msg.data.content) { - const str = msg.data.content; - const matchResult = await matchFn(str); - if (matchResult) { - return { type: "message", str, userId: msg.user_id, messageInfo, matchedValue: matchResult }; - } - } - - 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 - } - const str = JSON.stringify(copiedEmbed); - const matchResult = await matchFn(str); - if (matchResult) { - return { type: "embed", str, userId: msg.user_id, messageInfo, matchedValue: matchResult }; - } - } - - if (trigger.match_visible_names) { - const str = member.nick || msg.data.author.username; - const matchResult = await matchFn(str); - if (matchResult) { - return { type: "visiblename", str, userId: msg.user_id, matchedValue: matchResult }; - } - } - - if (trigger.match_usernames) { - const str = `${msg.data.author.username}#${msg.data.author.discriminator}`; - const matchResult = await matchFn(str); - if (matchResult) { - return { type: "username", str, userId: msg.user_id, matchedValue: matchResult }; - } - } - - if (trigger.match_nicknames && member.nick) { - const str = member.nick; - const matchResult = await matchFn(str); - if (matchResult) { - return { type: "nickname", str, userId: msg.user_id, matchedValue: matchResult }; - } - } - - // type 4 = custom status - if (trigger.match_custom_status && member.game?.type === 4 && member.game?.state) { - const str = member.game.state; - const matchResult = await matchFn(str); - if (matchResult) { - return { type: "customstatus", str, userId: msg.user_id, matchedValue: matchResult }; - } - } - - return null; - } - - protected async matchMultipleTextTypesOnMember( - trigger: TextTriggerWithMultipleMatchTypes, - member: Member, - matchFn: (str: string) => T | Promise | null, - ): Promise>> { - if (trigger.match_usernames) { - const str = `${member.user.username}#${member.user.discriminator}`; - const matchResult = await matchFn(str); - if (matchResult) { - return { type: "username", str, userId: member.id, matchedValue: matchResult }; - } - } - - if (trigger.match_nicknames && member.nick) { - const str = member.nick; - const matchResult = await matchFn(str); - if (matchResult) { - return { type: "nickname", str, userId: member.id, matchedValue: matchResult }; - } - } - - return null; - } - - /** - * Returns whether the triggers in the rule match the given message - */ - protected async matchRuleToMessage( - rule: TRule, - msg: SavedMessage, - ): Promise { - if (!rule.enabled) return; - - for (const trigger of rule.triggers) { - if (trigger.match_words) { - const match = await this.matchMultipleTextTypesOnMessage(trigger.match_words, msg, str => { - return this.evaluateMatchWordsTrigger(trigger.match_words, str); - }); - if (match) return { ...match, trigger: "match_words" } as TextTriggerMatchResult; - } - - if (trigger.match_regex) { - const match = await this.matchMultipleTextTypesOnMessage(trigger.match_regex, msg, str => { - return this.evaluateMatchRegexTrigger(trigger.match_regex, str); - }); - if (match) return { ...match, trigger: "match_regex" } as TextTriggerMatchResult; - } - - if (trigger.match_invites) { - const match = await this.matchMultipleTextTypesOnMessage(trigger.match_invites, msg, str => { - return this.evaluateMatchInvitesTrigger(trigger.match_invites, str); - }); - if (match) return { ...match, trigger: "match_invites" } as TextTriggerMatchResult; - } - - if (trigger.match_links) { - const match = await this.matchMultipleTextTypesOnMessage(trigger.match_links, msg, str => { - return this.evaluateMatchLinksTrigger(trigger.match_links, str); - }); - if (match) return { ...match, trigger: "match_links" } as TextTriggerMatchResult; - } - - if (trigger.match_attachment_type) { - const match = this.evaluateMatchAttachmentTypeTrigger(trigger.match_attachment_type, msg); - // TODO: Add "attachment" type - if (match) { - const messageInfo: MessageInfo = { channelId: msg.channel_id, messageId: msg.id, userId: msg.user_id }; - return { - type: "message", - userId: msg.user_id, - messageInfo, - ...match, - trigger: "match_attachment_type", - }; - } - } - - if (trigger.message_spam) { - const match = this.matchTextSpamTrigger(RecentActionType.Message, trigger.message_spam, msg); - if (match) return { ...match, rule, trigger: "message_spam" }; - } - - if (trigger.mention_spam) { - const match = this.matchTextSpamTrigger(RecentActionType.Mention, trigger.mention_spam, msg); - if (match) return { ...match, rule, trigger: "mention_spam" }; - } - - if (trigger.link_spam) { - const match = this.matchTextSpamTrigger(RecentActionType.Link, trigger.link_spam, msg); - if (match) return { ...match, rule, trigger: "link_spam" }; - } - - if (trigger.attachment_spam) { - const match = this.matchTextSpamTrigger(RecentActionType.Attachment, trigger.attachment_spam, msg); - if (match) return { ...match, rule, trigger: "attachment_spam" }; - } - - if (trigger.emoji_spam) { - const match = this.matchTextSpamTrigger(RecentActionType.Emoji, trigger.emoji_spam, msg); - if (match) return { ...match, rule, trigger: "emoji_spam" }; - } - - if (trigger.line_spam) { - const match = this.matchTextSpamTrigger(RecentActionType.Line, trigger.line_spam, msg); - if (match) return { ...match, rule, trigger: "line_spam" }; - } - - if (trigger.character_spam) { - const match = this.matchTextSpamTrigger(RecentActionType.Character, trigger.character_spam, msg); - if (match) return { ...match, rule, trigger: "character_spam" }; - } - } - - return null; - } - - protected async matchOtherSpamInRule(rule: TRule, userId: string): Promise { - if (!rule.enabled) return; - - for (const trigger of rule.triggers) { - if (trigger.member_join_spam) { - const match = this.matchOtherSpamTrigger(RecentActionType.MemberJoin, trigger.member_join_spam, null); - if (match) return { ...match, rule, trigger: "member_join_spam" }; - } - } - - return null; - } - - protected async matchMemberJoinTriggerInRule(rule: TRule, member: Member): Promise { - if (!rule.enabled) return; - - const result: OtherTriggerMatchResult = { trigger: "member_join", type: "other", userId: member.id }; - - for (const trigger of rule.triggers) { - if (trigger.member_join) { - if (trigger.member_join.only_new) { - const threshold = Date.now() - convertDelayStringToMS(trigger.member_join.new_threshold); - if (member.createdAt >= threshold) { - return result; - } - } else { - return result; - } - } - } - - return null; - } - - protected async addRecentMessageAction(action: TextRecentAction) { - this.recentActions.push({ - ...action, - expiresAt: Date.now() + RECENT_ACTION_EXPIRY_TIME, - }); - } - - protected async addRecentAction(action: OtherRecentAction) { - this.recentActions.push({ - ...action, - expiresAt: Date.now() + RECENT_ACTION_EXPIRY_TIME, - }); - } - - /** - * Logs recent actions for spam detection purposes - */ - protected logRecentActionsForMessage(msg: SavedMessage) { - const timestamp = moment.utc(msg.posted_at).valueOf(); - const globalIdentifier = msg.user_id; - const perChannelIdentifier = `${msg.channel_id}-${msg.user_id}`; - const messageInfo: MessageInfo = { channelId: msg.channel_id, messageId: msg.id, userId: msg.user_id }; - - this.addRecentMessageAction({ - type: RecentActionType.Message, - identifier: globalIdentifier, - timestamp, - count: 1, - messageInfo, - }); - this.addRecentMessageAction({ - type: RecentActionType.Message, - identifier: perChannelIdentifier, - timestamp, - count: 1, - messageInfo, - }); - - const mentionCount = - getUserMentions(msg.data.content || "").length + getRoleMentions(msg.data.content || "").length; - if (mentionCount) { - this.addRecentMessageAction({ - type: RecentActionType.Mention, - identifier: globalIdentifier, - timestamp, - count: mentionCount, - messageInfo, - }); - this.addRecentMessageAction({ - type: RecentActionType.Mention, - identifier: perChannelIdentifier, - timestamp, - count: mentionCount, - messageInfo, - }); - } - - const linkCount = getUrlsInString(msg.data.content || "").length; - if (linkCount) { - this.addRecentMessageAction({ - type: RecentActionType.Link, - identifier: globalIdentifier, - timestamp, - count: linkCount, - messageInfo, - }); - this.addRecentMessageAction({ - type: RecentActionType.Link, - identifier: perChannelIdentifier, - timestamp, - count: linkCount, - messageInfo, - }); - } - - const attachmentCount = msg.data.attachments && msg.data.attachments.length; - if (attachmentCount) { - this.addRecentMessageAction({ - type: RecentActionType.Attachment, - identifier: globalIdentifier, - timestamp, - count: attachmentCount, - messageInfo, - }); - this.addRecentMessageAction({ - type: RecentActionType.Attachment, - identifier: perChannelIdentifier, - timestamp, - count: attachmentCount, - messageInfo, - }); - } - - const emojiCount = getEmojiInString(msg.data.content || "").length; - if (emojiCount) { - this.addRecentMessageAction({ - type: RecentActionType.Emoji, - identifier: globalIdentifier, - timestamp, - count: emojiCount, - messageInfo, - }); - this.addRecentMessageAction({ - type: RecentActionType.Emoji, - identifier: perChannelIdentifier, - timestamp, - count: emojiCount, - messageInfo, - }); - } - - // + 1 is for the first line of the message (which doesn't have a line break) - const lineCount = msg.data.content ? (msg.data.content.match(/\n/g) || []).length + 1 : 0; - if (lineCount) { - this.addRecentMessageAction({ - type: RecentActionType.Line, - identifier: globalIdentifier, - timestamp, - count: lineCount, - messageInfo, - }); - this.addRecentMessageAction({ - type: RecentActionType.Line, - identifier: perChannelIdentifier, - timestamp, - count: lineCount, - messageInfo, - }); - } - - const characterCount = [...(msg.data.content || "")].length; - if (characterCount) { - this.addRecentMessageAction({ - type: RecentActionType.Character, - identifier: globalIdentifier, - timestamp, - count: characterCount, - messageInfo, - }); - this.addRecentMessageAction({ - type: RecentActionType.Character, - identifier: perChannelIdentifier, - timestamp, - count: characterCount, - messageInfo, - }); - } - } - - protected getMatchingRecentActions(type: RecentActionType, identifier: string | null, since: number, to: number) { - return this.recentActions.filter(action => { - return ( - action.type === type && - (!identifier || action.identifier === identifier) && - action.timestamp >= since && - action.timestamp <= to - ); - }); - } - - protected clearRecentActionsForMessage(messageId: string) { - this.recentActions = this.recentActions.filter(info => { - return !((info as TextRecentAction).messageInfo?.messageId === messageId); - }); - } - - protected async clearExpiredRecentSpam() { - for (const [key, info] of this.recentSpam.entries()) { - if (info.expiresAt <= Date.now()) { - this.recentSpam.delete(key); - } - } - } - - protected async clearOldRecentActions() { - this.recentActions = this.recentActions.filter(info => { - return info.expiresAt <= Date.now(); - }); - } - - protected async clearExpiredRecentNicknameChanges() { - for (const [key, info] of this.recentNicknameChanges.entries()) { - if (info.expiresAt <= Date.now()) { - this.recentNicknameChanges.delete(key); - } - } - } - - protected readContactMethodsFromAction(action: { - notify?: string; - notifyChannel?: string; - }): UserNotificationMethod[] | null { - if (action.notify === "dm") { - return [{ type: "dm" }]; - } else if (action.notify === "channel") { - if (!action.notifyChannel) { - throw new RecoverablePluginError(ERRORS.NO_USER_NOTIFICATION_CHANNEL); - } - - const channel = this.guild.channels.get(action.notifyChannel); - if (!(channel instanceof TextChannel)) { - throw new RecoverablePluginError(ERRORS.INVALID_USER_NOTIFICATION_CHANNEL); - } - - return [{ type: "channel", channel }]; - } else if (action.notify && disableUserNotificationStrings.includes(action.notify)) { - return []; - } - - return null; - } - - /** - * Apply the actions of the specified rule on the matched message/member - */ - protected async applyActionsOnMatch(rule: TRule, matchResult: AnyTriggerMatchResult) { - if (rule.cooldown && this.checkAndUpdateCooldown(rule, matchResult)) { - return; - } - - const actionsTaken = []; - - let recentSpamKey: string = null; - let recentSpam: RecentSpam = null; - let spamUserIdsToAction: string[] = []; - - if (matchResult.type === "textspam" || matchResult.type === "otherspam") { - recentSpamKey = `${rule.name}-${matchResult.identifier}`; - recentSpam = this.recentSpam.get(recentSpamKey); - - if (matchResult.type === "textspam") { - spamUserIdsToAction = matchResult.recentActions.map(action => action.messageInfo.userId); - } else if (matchResult.type === "otherspam") { - spamUserIdsToAction = matchResult.recentActions.map(action => action.userId); - } - - spamUserIdsToAction = Array.from(new Set(spamUserIdsToAction)).filter(id => !recentSpam?.actionedUsers.has(id)); - } - - let archiveId = recentSpam?.archiveId; - if (matchResult.type === "textspam") { - const messageInfos = matchResult.recentActions.filter(unactioned).map(a => a.messageInfo); - if (messageInfos.length) { - const savedMessages = await this.savedMessages.getMultiple(messageInfos.map(info => info.messageId)); - - if (archiveId) { - await this.archives.addSavedMessagesToArchive(archiveId, savedMessages, this.guild); - } else { - archiveId = await this.archives.createFromSavedMessages(savedMessages, this.guild); - } - } - } - - const matchSummary = await this.getMatchSummary(matchResult, archiveId); - - let caseExtraNote = `Matched automod rule "${rule.name}"`; - if (matchSummary) { - caseExtraNote += `\n${matchSummary}`; - } - - if (rule.actions.clean) { - const messagesToDelete: MessageInfo[] = []; - - if (matchResult.type === "message" || matchResult.type === "embed") { - messagesToDelete.push(matchResult.messageInfo); - } else if (matchResult.type === "textspam") { - messagesToDelete.push(...matchResult.recentActions.filter(unactioned).map(a => a.messageInfo)); - } - - for (const { channelId, messageId } of messagesToDelete) { - await this.guildLogs.ignoreLog(LogType.MESSAGE_DELETE, messageId); - await this.bot.deleteMessage(channelId, messageId).catch(noop); - } - - actionsTaken.push("clean"); - } - - if (rule.actions.warn) { - const reason = rule.actions.warn.reason || "Warned automatically"; - const contactMethods = this.readContactMethodsFromAction(rule.actions.warn); - - const caseArgs = { - modId: this.bot.user.id, - extraNotes: [caseExtraNote], - }; - - let membersToWarn = []; - if ( - matchResult.type === "message" || - matchResult.type === "embed" || - matchResult.type === "other" || - matchResult.type === "username" || - matchResult.type === "nickname" || - matchResult.type === "customstatus" - ) { - membersToWarn = [await this.getMember(matchResult.userId)]; - } else if (matchResult.type === "textspam" || matchResult.type === "otherspam") { - for (const id of spamUserIdsToAction) { - membersToWarn.push(await this.getMember(id)); - } - } - - if (membersToWarn.length) { - for (const member of membersToWarn) { - await this.getModActions().warnMember(member, reason, { contactMethods, caseArgs }); - } - - actionsTaken.push("warn"); - } - } - - if (rule.actions.mute) { - const duration = rule.actions.mute.duration ? convertDelayStringToMS(rule.actions.mute.duration) : null; - const reason = rule.actions.mute.reason || "Muted automatically"; - const caseArgs = { - modId: this.bot.user.id, - extraNotes: [caseExtraNote], - }; - const contactMethods = this.readContactMethodsFromAction(rule.actions.mute); - - let userIdsToMute = []; - if ( - matchResult.type === "message" || - matchResult.type === "embed" || - matchResult.type === "other" || - matchResult.type === "username" || - matchResult.type === "nickname" || - matchResult.type === "customstatus" - ) { - userIdsToMute = [matchResult.userId]; - } else if (matchResult.type === "textspam" || matchResult.type === "otherspam") { - userIdsToMute.push(...spamUserIdsToAction); - } - - if (userIdsToMute.length) { - for (const userId of userIdsToMute) { - await this.getMutes().muteUser(userId, duration, reason, { contactMethods, caseArgs }); - } - - actionsTaken.push("mute"); - } - } - - if (rule.actions.kick) { - const reason = rule.actions.kick.reason || "Kicked automatically"; - const caseArgs = { - modId: this.bot.user.id, - extraNotes: [caseExtraNote], - }; - const contactMethods = this.readContactMethodsFromAction(rule.actions.kick); - - let membersToKick = []; - if ( - matchResult.type === "message" || - matchResult.type === "embed" || - matchResult.type === "other" || - matchResult.type === "username" || - matchResult.type === "nickname" || - matchResult.type === "customstatus" - ) { - membersToKick = [await this.getMember(matchResult.userId)]; - } else if (matchResult.type === "textspam" || matchResult.type === "otherspam") { - for (const id of spamUserIdsToAction) { - membersToKick.push(await this.getMember(id)); - } - } - - if (membersToKick.length) { - for (const member of membersToKick) { - await this.getModActions().kickMember(member, reason, { contactMethods, caseArgs }); - } - - actionsTaken.push("kick"); - } - } - - if (rule.actions.ban) { - const reason = rule.actions.ban.reason || "Banned automatically"; - const caseArgs = { - modId: this.bot.user.id, - extraNotes: [caseExtraNote], - }; - const contactMethods = this.readContactMethodsFromAction(rule.actions.ban); - const deleteMessageDays = rule.actions.ban.deleteMessageDays; - - let userIdsToBan = []; - if ( - matchResult.type === "message" || - matchResult.type === "embed" || - matchResult.type === "other" || - matchResult.type === "username" || - matchResult.type === "nickname" || - matchResult.type === "customstatus" - ) { - userIdsToBan = [matchResult.userId]; - } else if (matchResult.type === "textspam" || matchResult.type === "otherspam") { - userIdsToBan.push(...spamUserIdsToAction); - } - - if (userIdsToBan.length) { - for (const userId of userIdsToBan) { - await this.getModActions().banUserId(userId, reason, { - contactMethods, - caseArgs, - deleteMessageDays, - }); - } - - actionsTaken.push("ban"); - } - } - - if (rule.actions.change_nickname) { - const userIdsToChange = - matchResult.type === "textspam" || matchResult.type === "otherspam" - ? [...spamUserIdsToAction] - : [matchResult.userId]; - - for (const userId of userIdsToChange) { - if (this.recentNicknameChanges.has(userId)) continue; - this.guild - .editMember(userId, { - nick: rule.actions.change_nickname.name, - }) - .catch(() => { - this.getLogs().log(LogType.BOT_ALERT, { - body: `Failed to change the nickname of \`${userId}\``, - }); - }); - this.recentNicknameChanges.set(userId, { expiresAt: RECENT_NICKNAME_CHANGE_EXPIRY_TIME }); - } - - actionsTaken.push("nickname"); - } - - if (rule.actions.add_roles) { - const userIdsToChange = - matchResult.type === "textspam" || matchResult.type === "otherspam" - ? [...spamUserIdsToAction] - : [matchResult.userId]; - - for (const userId of userIdsToChange) { - const member = await this.getMember(userId); - if (!member) continue; - - const memberRoles = new Set(member.roles); - for (const roleId of rule.actions.add_roles) { - memberRoles.add(roleId); - } - - if (memberRoles.size === member.roles.length) { - // No role changes - continue; - } - - 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 - } - - actionsTaken.push("add roles"); - } - - if (rule.actions.remove_roles) { - const userIdsToChange = - matchResult.type === "textspam" || matchResult.type === "otherspam" - ? [...spamUserIdsToAction] - : [matchResult.userId]; - - for (const userId of userIdsToChange) { - const member = await this.getMember(userId); - if (!member) continue; - - const memberRoles = new Set(member.roles); - for (const roleId of rule.actions.remove_roles) { - memberRoles.delete(roleId); - } - - if (memberRoles.size === member.roles.length) { - // No role changes - continue; - } - - 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 - } - - actionsTaken.push("remove roles"); - } - - if (rule.actions.set_antiraid_level !== undefined) { - await this.setAntiraidLevel(rule.actions.set_antiraid_level); - actionsTaken.push("set antiraid level"); - } - - if (rule.actions.reply && matchResult.type === "message") { - const channelId = matchResult.messageInfo.channelId; - const channel = this.guild.channels.get(channelId); - if (channel && channel instanceof TextChannel) { - const user = await this.resolveUser(matchResult.userId); - const renderReplyText = async str => - renderTemplate(str, { - user: stripObjectToScalars(user), - }); - const formatted = - typeof rule.actions.reply === "string" - ? await renderReplyText(rule.actions.reply) - : await renderRecursively(rule.actions.reply.text, renderReplyText); - - if (formatted) { - const replyMsg = await channel.createMessage(formatted); - actionsTaken.push("reply"); - - if (typeof rule.actions.reply === "object" && rule.actions.reply.auto_delete != null) { - const delay = convertDelayStringToMS(String(rule.actions.reply.auto_delete)); - setTimeout(() => replyMsg.delete().catch(noop), delay); - console.log("deleting in", delay); - } - } - } - } - - if (matchResult.type === "textspam" || matchResult.type === "otherspam") { - for (const action of matchResult.recentActions) { - action.actioned = true; - } - - if (recentSpam) { - for (const id of spamUserIdsToAction) { - recentSpam.actionedUsers.add(id); - } - } else { - const newRecentSpamEntry: RecentSpam = { - actionedUsers: new Set(spamUserIdsToAction), - expiresAt: Date.now() + RECENT_SPAM_EXPIRY_TIME, - archiveId, - }; - this.recentSpam.set(recentSpamKey, newRecentSpamEntry); - } - } - - // Don't wait for the rest before continuing to other automod items in the queue - (async () => { - let user; - let users; - let safeUser; - let safeUsers; - - if (matchResult.type === "textspam" || matchResult.type === "otherspam") { - users = spamUserIdsToAction.map(id => this.getUser(id)); - } else { - user = this.getUser(matchResult.userId); - users = [user]; - } - - safeUser = user ? stripObjectToScalars(user) : null; - safeUsers = users.map(u => stripObjectToScalars(u)); - - const logData = { - rule: rule.name, - user: safeUser, - users: safeUsers, - actionsTaken: actionsTaken.length ? actionsTaken.join(", ") : "", - matchSummary, - }; - - if (recentSpam && !spamUserIdsToAction.length) { - // This action was part of a recent spam match and we didn't find any new users to action i.e. the only users - // who triggered this match had already been actioned. In that case, we don't need to post any new log messages. - return; - } - - const logMessage = this.getLogs().getLogMessage(LogType.AUTOMOD_ACTION, logData); - - if (rule.actions.alert) { - const channel = this.guild.channels.get(rule.actions.alert.channel); - if (channel && channel instanceof TextChannel) { - const text = rule.actions.alert.text; - let messageLink = ""; - if (!actionsTaken.includes("clean")) { - messageLink = this.getActionedMessageLink(matchResult); - } else { - messageLink = "*Message cleaned - no link*"; - } - const rendered = await renderTemplate(rule.actions.alert.text, { - rule: rule.name, - user: safeUser, - users: safeUsers, - text, - matchSummary, - messageLink, - logMessage, - }); - channel.createMessage(rendered); - actionsTaken.push("alert"); - } else { - this.getLogs().log(LogType.BOT_ALERT, { - body: `Invalid channel id \`${rule.actions.alert.channel}\` for alert action in automod rule **${rule.name}**`, - }); - } - } - - if (rule.actions.log) { - this.getLogs().log(LogType.AUTOMOD_ACTION, logData); - } - })(); - } - - /** - * Check if the rule's on cooldown and bump its usage count towards the cooldown up - * @return Whether the rule's on cooldown - */ - protected checkAndUpdateCooldown(rule: TRule, matchResult: AnyTriggerMatchResult): boolean { - let cooldownKey = rule.name + "-"; - - if (matchResult.type === "textspam" || matchResult.type === "otherspam") { - logger.warn("Spam cooldowns are WIP and not currently functional"); - } - - if (matchResult.type === "message" || matchResult.type === "embed") { - cooldownKey += matchResult.userId; - } else if ( - matchResult.type === "username" || - matchResult.type === "nickname" || - matchResult.type === "visiblename" || - matchResult.type === "customstatus" - ) { - cooldownKey += matchResult.userId; - } else { - cooldownKey = null; - } - - if (cooldownKey) { - if (this.cooldownManager.isOnCooldown(cooldownKey)) { - return true; - } - - const cooldownTime = convertDelayStringToMS(rule.cooldown, "s"); - if (cooldownTime) { - this.cooldownManager.setCooldown(cooldownKey, cooldownTime); - } - } - - return false; - } - - /** - * Returns a text summary for the match result for use in logs/alerts - */ - protected async getMatchSummary(matchResult: AnyTriggerMatchResult, archiveId: string = null): Promise { - if (matchResult.type === "message" || matchResult.type === "embed") { - const message = await this.savedMessages.find(matchResult.messageInfo.messageId); - const channel = this.guild.channels.get(matchResult.messageInfo.channelId); - const channelMention = channel ? verboseChannelMention(channel) : `\`#${message.channel_id}\``; - - return ( - trimPluginDescription(` - Matched ${this.getMatchedValueText(matchResult)} in message in ${channelMention}: - `) + - "\n" + - messageSummary(message) - ); - } else if (matchResult.type === "textspam") { - const baseUrl = this.knub.getGlobalConfig().url; - const archiveUrl = this.archives.getUrl(baseUrl, archiveId); - - return trimPluginDescription(` - Matched spam: ${disableLinkPreviews(archiveUrl)} - `); - } else if (matchResult.type === "username") { - return `Matched ${this.getMatchedValueText(matchResult)} in username: ${matchResult.str}`; - } else if (matchResult.type === "nickname") { - return `Matched ${this.getMatchedValueText(matchResult)} in nickname: ${matchResult.str}`; - } else if (matchResult.type === "visiblename") { - return `Matched ${this.getMatchedValueText(matchResult)} in visible name: ${matchResult.str}`; - } else if (matchResult.type === "customstatus") { - return `Matched ${this.getMatchedValueText(matchResult)} in custom status: ${matchResult.str}`; - } else if (matchResult.type === "otherspam") { - return `Matched other spam`; - } - - return ""; - } - - /** - * Returns a formatted version of the matched value (word, regex pattern, link, etc.) for use in the match summary - */ - protected getMatchedValueText(matchResult: TextTriggerMatchResult): string | null { - if (matchResult.trigger === "match_words") { - return `word \`${disableInlineCode(matchResult.matchedValue)}\``; - } else if (matchResult.trigger === "match_regex") { - return `regex \`${disableInlineCode(matchResult.matchedValue)}\``; - } else if (matchResult.trigger === "match_invites") { - if (matchResult.matchedValue.invite) { - return `invite code \`${matchResult.matchedValue.code}\` (**${matchResult.matchedValue.invite.guild.name}**, \`${matchResult.matchedValue.invite.guild.id}\`)`; - } - return `invite code \`${disableInlineCode(matchResult.matchedValue.code)}\``; - } else if (matchResult.trigger === "match_links") { - return `link \`${disableInlineCode(matchResult.matchedValue)}\``; - } else if (matchResult.trigger === "match_attachment_type") { - return `attachment type \`${disableInlineCode(matchResult.matchedValue)}\``; - } - - return typeof matchResult.matchedValue === "string" ? `\`${disableInlineCode(matchResult.matchedValue)}\`` : null; - } - - /** - * Run automod actions on new messages - */ - protected runAutomodOnMessage(msg: SavedMessage, isEdit: boolean) { - if (this.actionedMessageIds.includes(msg.id)) return; - - this.automodQueue.add(async () => { - if (this.unloaded) return; - - if (isEdit) { - this.clearRecentActionsForMessage(msg.id); - } - this.logRecentActionsForMessage(msg); - - const member = this.guild.members.get(msg.user_id); - const config = this.getMatchingConfig({ - member, - userId: msg.user_id, - channelId: msg.channel_id, - }); - for (const [name, rule] of Object.entries(config.rules)) { - if (msg.is_bot && !rule.affects_bots) continue; - - const matchResult = await this.matchRuleToMessage(rule, msg); - if (matchResult) { - // Make sure the message still exists in our database when we try to apply actions on it. - // In high stress situations, such as raids, detection can be delayed and automod might try to act on messages - // we no longer have in our database, i.e. messages deleted over 5 minutes go. Since this is an edge case and - // undefined behaviour, don't apply actions on that message. - const savedMsg = await this.savedMessages.find(msg.id); - if (!savedMsg) return; - - await this.applyActionsOnMatch(rule, matchResult); - - // Add message ID to actioned messages to prevent alert spam on small edits - this.actionedMessageIds.push(msg.id); - while (this.actionedMessageIds.length > this.actionedMessageMax) { - this.actionedMessageIds.shift(); - } - - break; // Don't apply multiple rules to the same message - } - } - }); - } - - /** - * When a new member joins, check for both join triggers and join spam triggers - */ - @d.event("guildMemberAdd") - protected onMemberJoin(_, member: Member) { - this.automodQueue.add(async () => { - if (this.unloaded) return; - - await this.addRecentAction({ - identifier: RAID_SPAM_IDENTIFIER, - type: RecentActionType.MemberJoin, - userId: member.id, - timestamp: Date.now(), - count: 1, - }); - - const config = this.getConfigForMember(member); - - for (const [name, rule] of Object.entries(config.rules)) { - if (member.user.bot && !rule.affects_bots) continue; - - const spamMatch = await this.matchOtherSpamInRule(rule, member.id); - if (spamMatch) { - await this.applyActionsOnMatch(rule, spamMatch); - break; // Don't apply multiple rules to the same join - } - - const joinMatch = await this.matchMemberJoinTriggerInRule(rule, member); - if (joinMatch) { - await this.applyActionsOnMatch(rule, joinMatch); - break; // Don't apply multiple rules to the same join - } - } - }); - } - - protected async setAntiraidLevel(level: string | null, user?: User) { - this.cachedAntiraidLevel = level; - await this.antiraidLevels.set(level); - - if (user) { - this.guildLogs.log(LogType.SET_ANTIRAID_USER, { - level: level ?? "off", - user: stripObjectToScalars(user), - }); - } else { - this.guildLogs.log(LogType.SET_ANTIRAID_AUTO, { - level: level ?? "off", - }); - } - } - - @d.command("antiraid clear", [], { - aliases: ["antiraid reset", "antiraid none", "antiraid off"], - }) - @d.permission("can_set_antiraid") - public async clearAntiraidCmd(msg: Message) { - await this.setAntiraidLevel(null, msg.author); - this.sendSuccessMessage(msg.channel, "Anti-raid turned **off**"); - } - - @d.command("antiraid", "") - @d.permission("can_set_antiraid") - public async setAntiraidCmd(msg: Message, args: { level: string }) { - if (!this.getConfig().antiraid_levels.includes(args.level)) { - this.sendErrorMessage(msg.channel, "Unknown anti-raid level"); - return; - } - - await this.setAntiraidLevel(args.level, msg.author); - this.sendSuccessMessage(msg.channel, `Anti-raid set to **${args.level}**`); - } - - @d.command("antiraid") - @d.permission("can_view_antiraid") - public async viewAntiraidCmd(msg: Message, args: { level: string }) { - if (this.cachedAntiraidLevel) { - msg.channel.createMessage(`Anti-raid is set to **${this.cachedAntiraidLevel}**`); - } else { - msg.channel.createMessage("Anti-raid is off!"); - } - } - - public getActionedMessageLink(matchResult: AnyTriggerMatchResult): string { - if (matchResult.type === "message" || matchResult.type === "embed") { - return `https://discord.com/channels/${this.guild.id}/${matchResult.messageInfo.channelId}/${matchResult.messageInfo.messageId}`; - } else { - return ``; - } - } -} diff --git a/backend/src/plugins/Automod/info.ts b/backend/src/plugins/Automod/info.ts deleted file mode 100644 index 47938ec2..00000000 --- a/backend/src/plugins/Automod/info.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { ZeppelinPluginInfo, trimPluginDescription } from "../ZeppelinPluginClass"; - -export const pluginInfo: PluginInfo = { - prettyName: "Automod", - description: trimPluginDescription(` - Allows specifying automated actions in response to triggers. Example use cases include word filtering and spam prevention. - `), - configurationGuide: trimPluginDescription(` - The automod plugin is very customizable. For a full list of available triggers, actions, and their options, see Config schema at the bottom of this page. - - ### Simple word filter - Removes any messages that contain the word 'banana' and sends a warning to the user. - Moderators (level >= 50) are ignored by the filter based on the override. - - ~~~yml - automod: - config: - rules: - my_filter: - triggers: - - match_words: - words: ['banana'] - case_sensitive: false - only_full_words: true - actions: - clean: true - warn: - reason: 'Do not talk about bananas!' - overrides: - - level: '>=50' - config: - rules: - my_filter: - enabled: false - ~~~ - - ### Spam detection - This example includes 2 filters: - - - The first one is triggered if a user sends 5 messages within 10 seconds OR 3 attachments within 60 seconds. - The messages are deleted and the user is muted for 5 minutes. - - The second filter is triggered if a user sends more than 2 emoji within 5 seconds. - The messages are deleted but the user is not muted. - - Moderators are ignored by both filters based on the override. - - ~~~yml - automod: - config: - rules: - my_spam_filter: - triggers: - - message_spam: - amount: 5 - within: 10s - - attachment_spam: - amount: 3 - within: 60s - actions: - clean: true - mute: - duration: 5m - reason: 'Auto-muted for spam' - my_second_filter: - triggers: - - message_spam: - amount: 5 - within: 10s - actions: - clean: true - overrides: - - level: '>=50' - config: - rules: - my_spam_filter: - enabled: false - my_second_filter: - enabled: false - ~~~ - - ### Custom status alerts - This example sends an alert any time a user with a matching custom status sends a message. - - ~~~yml - automod: - config: - rules: - bad_custom_statuses: - triggers: - - match_words: - words: ['banana'] - match_custom_status: true - actions: - alert: - channel: "473087035574321152" - text: |- - Bad custom status on user <@!{user.id}>: - {matchSummary} - ~~~ - `), -}; diff --git a/backend/src/plugins/Automod/types.ts b/backend/src/plugins/Automod/types.ts deleted file mode 100644 index dbda6dad..00000000 --- a/backend/src/plugins/Automod/types.ts +++ /dev/null @@ -1,336 +0,0 @@ -import * as t from "io-ts"; -import { TSafeRegex } from "../../validatorUtils"; -import { tDelayString, tMessageContent, tNullable } from "../../utils"; - -export enum RecentActionType { - Message = 1, - Mention, - Link, - Attachment, - Emoji, - Line, - Character, - VoiceChannelMove, - MemberJoin, -} - -export interface BaseRecentAction { - identifier: string; - timestamp: number; - count: number; - actioned?: boolean; -} - -export type TextRecentAction = BaseRecentAction & { - type: - | RecentActionType.Message - | RecentActionType.Mention - | RecentActionType.Link - | RecentActionType.Attachment - | RecentActionType.Emoji - | RecentActionType.Line - | RecentActionType.Character; - messageInfo: MessageInfo; -}; - -export type OtherRecentAction = BaseRecentAction & { - type: RecentActionType.VoiceChannelMove | RecentActionType.MemberJoin; - userId: string; -}; - -export type RecentAction = (TextRecentAction | OtherRecentAction) & { expiresAt: number }; - -export interface RecentSpam { - archiveId: string; - actionedUsers: Set; - expiresAt: number; -} - -export type MessageInfo = { channelId: string; messageId: string; userId: string }; - -export type TextTriggerWithMultipleMatchTypes = { - match_messages: boolean; - match_embeds: boolean; - match_visible_names: boolean; - match_usernames: boolean; - match_nicknames: boolean; - match_custom_status: boolean; -}; - -export interface TriggerMatchResult { - trigger: string; - type: string; -} - -export interface MessageTextTriggerMatchResult extends TriggerMatchResult { - type: "message" | "embed"; - str: string; - userId: string; - messageInfo: MessageInfo; - matchedValue: T; -} - -export interface OtherTextTriggerMatchResult extends TriggerMatchResult { - type: "username" | "nickname" | "visiblename" | "customstatus"; - str: string; - userId: string; - matchedValue: T; -} - -export type TextTriggerMatchResult = MessageTextTriggerMatchResult | OtherTextTriggerMatchResult; - -export interface TextSpamTriggerMatchResult extends TriggerMatchResult { - type: "textspam"; - actionType: RecentActionType; - recentActions: TextRecentAction[]; - - // Rule that specified the criteria used for matching the spam - rule: TRule; - - // The identifier used to match the recentActions above. - // If not null, this should match the identifier of each of the recentActions above. - identifier: string; -} - -export interface OtherSpamTriggerMatchResult extends TriggerMatchResult { - type: "otherspam"; - actionType: RecentActionType; - recentActions: OtherRecentAction[]; - - // Rule that specified the criteria used for matching the spam - rule: TRule; - - // The identifier used to match the recentActions above. - // If not null, this should match the identifier of each of the recentActions above. - identifier: string; -} - -export interface OtherTriggerMatchResult extends TriggerMatchResult { - type: "other"; - userId: string; -} - -export type AnyTriggerMatchResult = - | TextTriggerMatchResult - | OtherTriggerMatchResult - | TextSpamTriggerMatchResult - | OtherSpamTriggerMatchResult; - -export type AnySpamTriggerMatchResult = TextSpamTriggerMatchResult | OtherSpamTriggerMatchResult; - -/** - * TRIGGERS - */ - -export const MatchWordsTrigger = t.type({ - words: t.array(t.string), - case_sensitive: t.boolean, - only_full_words: t.boolean, - normalize: t.boolean, - loose_matching: t.boolean, - loose_matching_threshold: t.number, - match_messages: t.boolean, - match_embeds: t.boolean, - match_visible_names: t.boolean, - match_usernames: t.boolean, - match_nicknames: t.boolean, - match_custom_status: t.boolean, -}); -export type TMatchWordsTrigger = t.TypeOf; - -export const MatchRegexTrigger = t.type({ - patterns: t.array(TSafeRegex), - case_sensitive: t.boolean, - normalize: t.boolean, - match_messages: t.boolean, - match_embeds: t.boolean, - match_visible_names: t.boolean, - match_usernames: t.boolean, - match_nicknames: t.boolean, - match_custom_status: t.boolean, -}); -export type TMatchRegexTrigger = t.TypeOf; - -export const MatchInvitesTrigger = t.type({ - include_guilds: tNullable(t.array(t.string)), - exclude_guilds: tNullable(t.array(t.string)), - include_invite_codes: tNullable(t.array(t.string)), - exclude_invite_codes: tNullable(t.array(t.string)), - allow_group_dm_invites: t.boolean, - match_messages: t.boolean, - match_embeds: t.boolean, - match_visible_names: t.boolean, - match_usernames: t.boolean, - match_nicknames: t.boolean, - match_custom_status: t.boolean, -}); -export type TMatchInvitesTrigger = t.TypeOf; - -export const MatchLinksTrigger = 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, -}); -export type TMatchLinksTrigger = t.TypeOf; - -export const MatchAttachmentTypeTrigger = t.type({ - filetype_blacklist: t.array(t.string), - blacklist_enabled: t.boolean, - filetype_whitelist: t.array(t.string), - whitelist_enabled: t.boolean, -}); -export type TMatchAttachmentTypeTrigger = t.TypeOf; - -export const BaseSpamTrigger = t.type({ - amount: t.number, - within: t.string, -}); -export type TBaseSpamTrigger = t.TypeOf; - -export const BaseTextSpamTrigger = t.intersection([ - BaseSpamTrigger, - t.type({ - per_channel: t.boolean, - }), -]); -export type TBaseTextSpamTrigger = t.TypeOf; - -export const MessageSpamTrigger = BaseTextSpamTrigger; -export type TMessageSpamTrigger = t.TypeOf; -export const MentionSpamTrigger = BaseTextSpamTrigger; -export type TMentionSpamTrigger = t.TypeOf; -export const LinkSpamTrigger = BaseTextSpamTrigger; -export type TLinkSpamTrigger = t.TypeOf; -export const AttachmentSpamTrigger = BaseTextSpamTrigger; -export type TAttachmentSpamTrigger = t.TypeOf; -export const EmojiSpamTrigger = BaseTextSpamTrigger; -export type TEmojiSpamTrigger = t.TypeOf; -export const LineSpamTrigger = BaseTextSpamTrigger; -export type TLineSpamTrigger = t.TypeOf; -export const CharacterSpamTrigger = BaseTextSpamTrigger; -export type TCharacterSpamTrigger = t.TypeOf; -export const VoiceMoveSpamTrigger = BaseSpamTrigger; -export type TVoiceMoveSpamTrigger = t.TypeOf; - -export const MemberJoinTrigger = t.type({ - only_new: t.boolean, - new_threshold: tDelayString, -}); -export type TMemberJoinTrigger = t.TypeOf; - -export const MemberJoinSpamTrigger = BaseSpamTrigger; -export type TMemberJoinSpamTrigger = t.TypeOf; - -/** - * ACTIONS - */ - -export const CleanAction = t.boolean; - -export const WarnAction = t.type({ - reason: tNullable(t.string), - notify: tNullable(t.string), - notifyChannel: tNullable(t.string), -}); - -export const MuteAction = t.type({ - reason: tNullable(t.string), - duration: tNullable(tDelayString), - notify: tNullable(t.string), - notifyChannel: tNullable(t.string), -}); - -export const KickAction = t.type({ - reason: tNullable(t.string), - notify: tNullable(t.string), - notifyChannel: tNullable(t.string), -}); - -export const BanAction = t.type({ - reason: tNullable(t.string), - notify: tNullable(t.string), - notifyChannel: tNullable(t.string), - deleteMessageDays: tNullable(t.number), -}); - -export const AlertAction = t.type({ - channel: t.string, - text: t.string, -}); - -export const ChangeNicknameAction = t.type({ - name: t.string, -}); - -export const LogAction = t.boolean; - -export const AddRolesAction = t.array(t.string); -export const RemoveRolesAction = t.array(t.string); - -export const SetAntiraidLevelAction = t.string; - -export const ReplyAction = t.union([ - t.string, - t.type({ - text: tMessageContent, - auto_delete: tNullable(t.union([t.string, t.number])), - }), -]); - -/** - * RULES - */ - -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.type({ - match_words: tNullable(MatchWordsTrigger), - match_regex: tNullable(MatchRegexTrigger), - match_invites: tNullable(MatchInvitesTrigger), - match_links: tNullable(MatchLinksTrigger), - match_attachment_type: tNullable(MatchAttachmentTypeTrigger), - message_spam: tNullable(MessageSpamTrigger), - mention_spam: tNullable(MentionSpamTrigger), - link_spam: tNullable(LinkSpamTrigger), - attachment_spam: tNullable(AttachmentSpamTrigger), - emoji_spam: tNullable(EmojiSpamTrigger), - line_spam: tNullable(LineSpamTrigger), - character_spam: tNullable(CharacterSpamTrigger), - member_join: tNullable(MemberJoinTrigger), - member_join_spam: tNullable(MemberJoinSpamTrigger), - // voice_move_spam: tNullable(VoiceMoveSpamTrigger), // TODO - // TODO: Duplicates trigger - }), - ), - actions: t.type({ - clean: tNullable(CleanAction), - warn: tNullable(WarnAction), - mute: tNullable(MuteAction), - kick: tNullable(KickAction), - ban: tNullable(BanAction), - alert: tNullable(AlertAction), - change_nickname: tNullable(ChangeNicknameAction), - log: tNullable(LogAction), - add_roles: tNullable(AddRolesAction), - remove_roles: tNullable(RemoveRolesAction), - set_antiraid_level: tNullable(SetAntiraidLevelAction), - reply: tNullable(ReplyAction), - }), - cooldown: tNullable(t.string), -}); -export type TRule = t.TypeOf; diff --git a/backend/src/plugins/BotControl.ts b/backend/src/plugins/BotControl.ts deleted file mode 100644 index 6c246b6d..00000000 --- a/backend/src/plugins/BotControl.ts +++ /dev/null @@ -1,288 +0,0 @@ -import { decorators as d, IPluginOptions } from "knub"; -import child_process from "child_process"; -import { GuildChannel, Message, TextChannel } from "eris"; -import moment from "moment-timezone"; -import { createChunkedMessage, errorMessage, noop, sorter, successMessage, tNullable } from "../utils"; -import { ReactionRolesPlugin } from "./ReactionRoles"; -import { ZeppelinPluginClass } from "./ZeppelinPluginClass"; -import { GuildArchives } from "../data/GuildArchives"; -import { GlobalZeppelinPlugin } from "./GlobalZeppelinPlugin"; -import * as t from "io-ts"; -import escapeStringRegexp from "escape-string-regexp"; - -let activeReload: [string, string] = null; - -const ConfigSchema = t.type({ - can_use: t.boolean, - owners: t.array(t.string), - update_cmd: tNullable(t.string), -}); -type TConfigSchema = t.TypeOf; - -/** - * A global plugin that allows bot owners to control the bot - */ -export class BotControlPlugin extends GlobalZeppelinPlugin { - public static pluginName = "bot_control"; - public static configSchema = ConfigSchema; - - protected archives: GuildArchives; - - public static getStaticDefaultOptions() { - return { - config: { - can_use: false, - owners: [], - update_cmd: null, - }, - overrides: [ - { - level: ">=100", - config: { - can_use: true, - }, - }, - ], - }; - } - - protected getMemberLevel(member) { - return this.isOwner(member.id) ? 100 : 0; - } - - async onLoad() { - this.archives = new GuildArchives(0); - - if (activeReload) { - const [guildId, channelId] = activeReload; - activeReload = null; - - const guild = this.bot.guilds.get(guildId); - if (guild) { - const channel = guild.channels.get(channelId); - if (channel instanceof TextChannel) { - this.sendSuccessMessage(channel, "Global plugins reloaded!"); - } - } - } - } - - @d.command("bot_full_update") - @d.permission("can_use") - async fullUpdateCmd(msg: Message) { - const updateCmd = this.getConfig().update_cmd; - if (!updateCmd) { - msg.channel.createMessage(errorMessage("Update command not specified!")); - return; - } - - msg.channel.createMessage("Updating..."); - const updater = child_process.exec(updateCmd, { cwd: process.cwd() }); - updater.stderr.on("data", data => { - // tslint:disable-next-line - console.error(data); - }); - } - - @d.command("bot_reload_global_plugins") - @d.permission("can_use") - async reloadGlobalPluginsCmd(msg: Message) { - if (activeReload) return; - - if (msg.channel) { - activeReload = [(msg.channel as GuildChannel).guild.id, msg.channel.id]; - await msg.channel.createMessage("Reloading global plugins..."); - } - - this.knub.reloadAllGlobalPlugins(); - } - - @d.command("perf") - @d.permission("can_use") - async perfCmd(msg: Message) { - const perfItems = this.knub.getPerformanceDebugItems(); - - if (perfItems.length) { - const content = "```" + perfItems.join("\n") + "```"; - msg.channel.createMessage(content); - } else { - msg.channel.createMessage(errorMessage("No performance data")); - } - } - - @d.command("refresh_reaction_roles_globally") - @d.permission("can_use") - async refreshAllReactionRolesCmd(msg: Message) { - const guilds = this.knub.getLoadedGuilds(); - for (const guild of guilds) { - if (guild.loadedPlugins.has("reaction_roles")) { - const rrPlugin = (guild.loadedPlugins.get("reaction_roles") as unknown) as ReactionRolesPlugin; - rrPlugin.runAutoRefresh().catch(noop); - } - } - } - - @d.command("guilds", "[search:string$]", { - aliases: ["servers"], - options: [ - { - name: "all", - shortcut: "a", - isSwitch: true, - }, - { - name: "initialized", - shortcut: "i", - isSwitch: true, - }, - { - name: "uninitialized", - shortcut: "u", - isSwitch: true, - }, - ], - }) - @d.permission("can_use") - async serversCmd( - msg: Message, - args: { search?: string; all?: boolean; initialized?: boolean; uninitialized?: boolean }, - ) { - const showList = Boolean(args.all || args.initialized || args.uninitialized || args.search); - const search = args.search && new RegExp([...args.search].map(s => escapeStringRegexp(s)).join(".*"), "i"); - - const joinedGuilds = Array.from(this.bot.guilds.values()); - const loadedGuilds = this.knub.getLoadedGuilds(); - const loadedGuildsMap = loadedGuilds.reduce((map, guildData) => map.set(guildData.id, guildData), new Map()); - - if (showList) { - let filteredGuilds = Array.from(joinedGuilds); - - if (args.initialized) { - filteredGuilds = filteredGuilds.filter(g => loadedGuildsMap.has(g.id)); - } - - if (args.uninitialized) { - filteredGuilds = filteredGuilds.filter(g => !loadedGuildsMap.has(g.id)); - } - - if (args.search) { - filteredGuilds = filteredGuilds.filter(g => search.test(`${g.id} ${g.name}`)); - } - - if (filteredGuilds.length) { - filteredGuilds.sort(sorter(g => g.name.toLowerCase())); - const longestId = filteredGuilds.reduce((longest, guild) => Math.max(longest, guild.id.length), 0); - const lines = filteredGuilds.map(g => { - const paddedId = g.id.padEnd(longestId, " "); - return `\`${paddedId}\` **${g.name}** (${loadedGuildsMap.has(g.id) ? "initialized" : "not initialized"}) (${ - g.memberCount - } members)`; - }); - createChunkedMessage(msg.channel, lines.join("\n")); - } else { - msg.channel.createMessage("No servers matched the filters"); - } - } else { - const total = joinedGuilds.length; - const initialized = joinedGuilds.filter(g => loadedGuildsMap.has(g.id)).length; - const unInitialized = total - initialized; - - msg.channel.createMessage( - `I am on **${total} total servers**, of which **${initialized} are initialized** and **${unInitialized} are not initialized**`, - ); - } - } - - @d.command("leave_guild", "") - @d.permission("can_use") - async leaveGuildCmd(msg: Message, args: { guildId: string }) { - if (!this.bot.guilds.has(args.guildId)) { - msg.channel.createMessage(errorMessage("I am not in that guild")); - return; - } - - const guildToLeave = this.bot.guilds.get(args.guildId); - const guildName = guildToLeave.name; - - try { - await this.bot.leaveGuild(args.guildId); - } catch (e) { - msg.channel.createMessage(errorMessage(`Failed to leave guild: ${e.message}`)); - return; - } - - this.sendSuccessMessage(msg.channel, `Left guild **${guildName}**`); - } - - @d.command("reload_guild", "") - @d.permission("can_use") - async reloadGuildCmd(msg: Message, args: { guildId: string }) { - if (!this.bot.guilds.has(args.guildId)) { - msg.channel.createMessage(errorMessage("I am not in that guild")); - return; - } - - try { - await this.knub.reloadGuild(args.guildId); - } catch (e) { - msg.channel.createMessage(errorMessage(`Failed to reload guild: ${e.message}`)); - return; - } - - const guild = this.bot.guilds.get(args.guildId); - this.sendSuccessMessage(msg.channel, `Reloaded guild **${guild.name}**`); - } - - @d.command("reload_all_guilds") - @d.permission("can_use") - async reloadAllGuilds(msg: Message) { - const failedReloads: Map = new Map(); - let reloadCount = 0; - - const loadedGuilds = this.knub.getLoadedGuilds(); - for (const guildData of loadedGuilds) { - try { - await this.knub.reloadGuild(guildData.id); - reloadCount++; - } catch (e) { - failedReloads.set(guildData.id, e.message); - } - } - - if (failedReloads.size) { - const errorLines = Array.from(failedReloads.entries()).map(([guildId, err]) => { - const guild = this.bot.guilds.get(guildId); - const guildName = guild ? guild.name : "Unknown"; - return `${guildName} (${guildId}): ${err}`; - }); - createChunkedMessage(msg.channel, `Reloaded ${reloadCount} guild(s). Errors:\n${errorLines.join("\n")}`); - } else { - this.sendSuccessMessage(msg.channel, `Reloaded ${reloadCount} guild(s)`); - } - } - - @d.command("show_plugin_config", " ") - @d.permission("can_use") - async showPluginConfig(msg: Message, args: { guildId: string; pluginName: string }) { - const guildData = this.knub.getGuildData(args.guildId); - if (!guildData) { - msg.channel.createMessage(errorMessage(`Guild not loaded`)); - return; - } - - const pluginInstance = guildData.loadedPlugins.get(args.pluginName); - if (!pluginInstance) { - msg.channel.createMessage(errorMessage(`Plugin not loaded`)); - return; - } - - if (!(pluginInstance instanceof ZeppelinPluginClass)) { - msg.channel.createMessage(errorMessage(`Plugin is not a Zeppelin plugin`)); - return; - } - - const opts = pluginInstance.getRuntimeOptions(); - const archiveId = await this.archives.create(JSON.stringify(opts, null, 2), moment().add(15, "minutes")); - msg.channel.createMessage(this.archives.getUrl(this.knub.getGlobalConfig().url, archiveId)); - } -} diff --git a/backend/src/plugins/Cases.ts b/backend/src/plugins/Cases.ts deleted file mode 100644 index c372c22b..00000000 --- a/backend/src/plugins/Cases.ts +++ /dev/null @@ -1,321 +0,0 @@ -import { Message, MessageContent, MessageFile, TextChannel } from "eris"; -import { GuildCases } from "../data/GuildCases"; -import { CaseTypes } from "../data/CaseTypes"; -import { Case } from "../data/entities/Case"; -import moment from "moment-timezone"; -import { CaseTypeColors } from "../data/CaseTypeColors"; -import { ZeppelinPluginInfo, trimPluginDescription, ZeppelinPluginClass } from "./ZeppelinPluginClass"; -import { GuildArchives } from "../data/GuildArchives"; -import { IPluginOptions, logger } from "knub"; -import { GuildLogs } from "../data/GuildLogs"; -import { LogType } from "../data/LogType"; -import * as t from "io-ts"; -import { isDiscordRESTError, tNullable } from "../utils"; -import { ERRORS } from "../RecoverablePluginError"; - -const ConfigSchema = t.type({ - log_automatic_actions: t.boolean, - case_log_channel: tNullable(t.string), -}); -type TConfigSchema = t.TypeOf; - -/** - * Can also be used as a config object for functions that create cases - */ -export type CaseArgs = { - userId: string; - modId: string; - ppId?: string; - type: CaseTypes; - auditLogId?: string; - reason?: string; - automatic?: boolean; - postInCaseLogOverride?: boolean; - noteDetails?: string[]; - extraNotes?: string[]; -}; - -export type CaseNoteArgs = { - caseId: number; - modId: string; - body: string; - automatic?: boolean; - postInCaseLogOverride?: boolean; - noteDetails?: string[]; -}; - -export class CasesPlugin extends ZeppelinPluginClass { - public static pluginName = "cases"; - public static configSchema = ConfigSchema; - - public static pluginInfo: PluginInfo = { - prettyName: "Cases", - description: trimPluginDescription(` - This plugin contains basic configuration for cases created by other plugins - `), - }; - - protected cases: GuildCases; - protected archives: GuildArchives; - protected logs: GuildLogs; - - public static getStaticDefaultOptions(): IPluginOptions { - return { - config: { - log_automatic_actions: true, - case_log_channel: null, - }, - }; - } - - onLoad() { - this.cases = GuildCases.getGuildInstance(this.guildId); - this.archives = GuildArchives.getGuildInstance(this.guildId); - this.logs = new GuildLogs(this.guildId); - } - - protected resolveCaseId(caseOrCaseId: Case | number): number { - return caseOrCaseId instanceof Case ? caseOrCaseId.id : caseOrCaseId; - } - - /** - * Creates a new case and, depending on config, posts it in the case log channel - */ - public async createCase(args: CaseArgs): Promise { - const user = await this.resolveUser(args.userId); - const userName = `${user.username}#${user.discriminator}`; - - const mod = await this.resolveUser(args.modId); - const modName = `${mod.username}#${mod.discriminator}`; - - let ppName = null; - if (args.ppId) { - const pp = await this.resolveUser(args.ppId); - ppName = `${pp.username}#${pp.discriminator}`; - } - - if (args.auditLogId) { - const existingAuditLogCase = await this.cases.findByAuditLogId(args.auditLogId); - if (existingAuditLogCase) { - delete args.auditLogId; - logger.warn(`Duplicate audit log ID for mod case: ${args.auditLogId}`); - } - } - - const createdCase = await this.cases.create({ - type: args.type, - user_id: args.userId, - user_name: userName, - mod_id: args.modId, - mod_name: modName, - audit_log_id: args.auditLogId, - pp_id: args.ppId, - pp_name: ppName, - }); - - if (args.reason || (args.noteDetails && args.noteDetails.length)) { - await this.createCaseNote({ - caseId: createdCase.id, - modId: args.modId, - body: args.reason || "", - automatic: args.automatic, - postInCaseLogOverride: false, - noteDetails: args.noteDetails, - }); - } - - if (args.extraNotes) { - for (const extraNote of args.extraNotes) { - await this.createCaseNote({ - caseId: createdCase.id, - modId: args.modId, - body: extraNote, - automatic: args.automatic, - postInCaseLogOverride: false, - }); - } - } - - const config = this.getConfig(); - - if ( - config.case_log_channel && - (!args.automatic || config.log_automatic_actions) && - args.postInCaseLogOverride !== false - ) { - await this.postCaseToCaseLogChannel(createdCase); - } - - return createdCase; - } - - /** - * Adds a case note to an existing case and, depending on config, posts the updated case in the case log channel - */ - public async createCaseNote(args: CaseNoteArgs): Promise { - const theCase = await this.cases.find(this.resolveCaseId(args.caseId)); - if (!theCase) { - this.throwRecoverablePluginError(ERRORS.UNKNOWN_NOTE_CASE); - } - - const mod = await this.resolveUser(args.modId); - const modName = `${mod.username}#${mod.discriminator}`; - - let body = args.body; - - // Add note details to the beginning of the note - if (args.noteDetails && args.noteDetails.length) { - body = args.noteDetails.map(d => `__[${d}]__`).join(" ") + " " + body; - } - - await this.cases.createNote(theCase.id, { - mod_id: mod.id, - mod_name: modName, - body: body || "", - }); - - if (theCase.mod_id == null) { - // If the case has no moderator information, assume the first one to add a note to it did the action - await this.cases.update(theCase.id, { - mod_id: mod.id, - mod_name: modName, - }); - } - - const archiveLinkMatch = body && body.match(/(?<=\/archives\/)[a-zA-Z0-9\-]+/g); - if (archiveLinkMatch) { - for (const archiveId of archiveLinkMatch) { - this.archives.makePermanent(archiveId); - } - } - - if ((!args.automatic || this.getConfig().log_automatic_actions) && args.postInCaseLogOverride !== false) { - await this.postCaseToCaseLogChannel(theCase.id); - } - } - - /** - * Returns a Discord embed for the specified case - */ - public async getCaseEmbed(caseOrCaseId: Case | number): Promise { - const theCase = await this.cases.with("notes").find(this.resolveCaseId(caseOrCaseId)); - if (!theCase) return null; - - const createdAt = moment(theCase.created_at); - const actionTypeStr = CaseTypes[theCase.type].toUpperCase(); - - const embed: any = { - title: `${actionTypeStr} - Case #${theCase.case_number}`, - footer: { - text: `Case created at ${createdAt.format("YYYY-MM-DD [at] HH:mm")}`, - }, - fields: [ - { - name: "User", - value: `${theCase.user_name}\n<@!${theCase.user_id}>`, - inline: true, - }, - { - name: "Moderator", - value: `${theCase.mod_name}\n<@!${theCase.mod_id}>`, - inline: true, - }, - ], - }; - - if (theCase.pp_id) { - embed.fields[1].value += `\np.p. ${theCase.pp_name}\n<@!${theCase.pp_id}>`; - } - - if (theCase.is_hidden) { - embed.title += " (hidden)"; - } - - if (CaseTypeColors[theCase.type]) { - embed.color = CaseTypeColors[theCase.type]; - } - - if (theCase.notes.length) { - theCase.notes.forEach((note: any) => { - const noteDate = moment(note.created_at); - embed.fields.push({ - name: `${note.mod_name} at ${noteDate.format("YYYY-MM-DD [at] HH:mm")}:`, - value: note.body, - }); - }); - } else { - embed.fields.push({ - name: "!!! THIS CASE HAS NO NOTES !!!", - value: "\u200B", - }); - } - - return { embed }; - } - - public async getCaseTypeAmountForUserId(userID: string, type: CaseTypes): Promise { - const cases = (await this.cases.getByUserId(userID)).filter(c => !c.is_hidden); - let typeAmount = 0; - - if (cases.length > 0) { - cases.forEach(singleCase => { - if (singleCase.type === type.valueOf()) { - typeAmount++; - } - }); - } - - return typeAmount; - } - - /** - * A helper for posting to the case log channel. - * Returns silently if the case log channel isn't specified or is invalid. - */ - public async postToCaseLogChannel(content: MessageContent, file: MessageFile = null): Promise { - const caseLogChannelId = this.getConfig().case_log_channel; - if (!caseLogChannelId) return; - - const caseLogChannel = this.guild.channels.get(caseLogChannelId); - if (!caseLogChannel || !(caseLogChannel instanceof TextChannel)) return; - - let result; - try { - result = await caseLogChannel.createMessage(content, file); - } catch (e) { - if (isDiscordRESTError(e) && (e.code === 50013 || e.code === 50001)) { - logger.warn( - `Missing permissions to post mod cases in <#${caseLogChannel.id}> in guild ${this.guild.name} (${this.guild.id})`, - ); - this.logs.log(LogType.BOT_ALERT, { - body: `Missing permissions to post mod cases in <#${caseLogChannel.id}>`, - }); - return; - } - - throw e; - } - - return result; - } - - /** - * A helper to post a case embed to the case log channel - */ - public async postCaseToCaseLogChannel(caseOrCaseId: Case | number): Promise { - const theCase = await this.cases.find(this.resolveCaseId(caseOrCaseId)); - if (!theCase) return; - - const caseEmbed = await this.getCaseEmbed(caseOrCaseId); - if (!caseEmbed) return; - - try { - return this.postToCaseLogChannel(caseEmbed); - } catch (e) { - this.logs.log(LogType.BOT_ALERT, { - body: `Failed to post case #${theCase.case_number} to the case log channel`, - }); - return null; - } - } -} diff --git a/backend/src/plugins/Censor.ts b/backend/src/plugins/Censor.ts deleted file mode 100644 index 551571cc..00000000 --- a/backend/src/plugins/Censor.ts +++ /dev/null @@ -1,297 +0,0 @@ -import { IPluginOptions, logger } from "knub"; -import { Invite, Embed } from "eris"; -import escapeStringRegexp from "escape-string-regexp"; -import { GuildLogs } from "../data/GuildLogs"; -import { LogType } from "../data/LogType"; -import { - deactivateMentions, - disableCodeBlocks, - getInviteCodesInString, - getUrlsInString, - stripObjectToScalars, - tNullable, -} from "../utils"; -import { ZalgoRegex } from "../data/Zalgo"; -import { GuildSavedMessages } from "../data/GuildSavedMessages"; -import { SavedMessage } from "../data/entities/SavedMessage"; -import { trimPluginDescription, ZeppelinPluginClass } from "./ZeppelinPluginClass"; -import cloneDeep from "lodash.clonedeep"; -import * as t from "io-ts"; -import { TSafeRegex } from "../validatorUtils"; - -const ConfigSchema = t.type({ - filter_zalgo: t.boolean, - filter_invites: t.boolean, - invite_guild_whitelist: tNullable(t.array(t.string)), - invite_guild_blacklist: tNullable(t.array(t.string)), - invite_code_whitelist: tNullable(t.array(t.string)), - invite_code_blacklist: tNullable(t.array(t.string)), - allow_group_dm_invites: t.boolean, - filter_domains: t.boolean, - domain_whitelist: tNullable(t.array(t.string)), - domain_blacklist: tNullable(t.array(t.string)), - blocked_tokens: tNullable(t.array(t.string)), - blocked_words: tNullable(t.array(t.string)), - blocked_regex: tNullable(t.array(TSafeRegex)), -}); -type TConfigSchema = t.TypeOf; - -export class CensorPlugin extends ZeppelinPluginClass { - public static pluginName = "censor"; - public static configSchema = ConfigSchema; - - public static pluginInfo = { - prettyName: "Censor", - description: trimPluginDescription(` - Censor words, tokens, links, regex, etc. - For more advanced filtering, check out the Automod plugin! - `), - }; - - protected serverLogs: GuildLogs; - protected savedMessages: GuildSavedMessages; - - private onMessageCreateFn; - private onMessageUpdateFn; - - public static getStaticDefaultOptions(): IPluginOptions { - return { - config: { - filter_zalgo: false, - filter_invites: false, - invite_guild_whitelist: null, - invite_guild_blacklist: null, - invite_code_whitelist: null, - invite_code_blacklist: null, - allow_group_dm_invites: false, - - filter_domains: false, - domain_whitelist: null, - domain_blacklist: null, - - blocked_tokens: null, - blocked_words: null, - blocked_regex: null, - }, - - overrides: [ - { - level: ">=50", - config: { - filter_zalgo: false, - filter_invites: false, - filter_domains: false, - blocked_tokens: null, - blocked_words: null, - blocked_regex: null, - }, - }, - ], - }; - } - - onLoad() { - this.serverLogs = new GuildLogs(this.guildId); - this.savedMessages = GuildSavedMessages.getGuildInstance(this.guildId); - - this.onMessageCreateFn = this.onMessageCreate.bind(this); - this.onMessageUpdateFn = this.onMessageUpdate.bind(this); - this.savedMessages.events.on("create", this.onMessageCreateFn); - this.savedMessages.events.on("update", this.onMessageUpdateFn); - } - - onUnload() { - this.savedMessages.events.off("create", this.onMessageCreateFn); - this.savedMessages.events.off("update", this.onMessageUpdateFn); - } - - async censorMessage(savedMessage: SavedMessage, reason: string) { - this.serverLogs.ignoreLog(LogType.MESSAGE_DELETE, savedMessage.id); - - try { - await this.bot.deleteMessage(savedMessage.channel_id, savedMessage.id, "Censored"); - } catch (e) { - return; - } - - const user = await this.resolveUser(savedMessage.user_id); - const channel = this.guild.channels.get(savedMessage.channel_id); - - this.serverLogs.log(LogType.CENSOR, { - user: stripObjectToScalars(user), - channel: stripObjectToScalars(channel), - reason, - message: savedMessage, - messageText: disableCodeBlocks(deactivateMentions(savedMessage.data.content)), - }); - } - - /** - * Applies word censor filters to the message, if any apply. - * @return {boolean} Indicates whether the message was removed - */ - async applyFiltersToMsg(savedMessage: SavedMessage): Promise { - const config = this.getConfigForMemberIdAndChannelId(savedMessage.user_id, savedMessage.channel_id); - - let messageContent = savedMessage.data.content || ""; - if (savedMessage.data.attachments) messageContent += " " + JSON.stringify(savedMessage.data.attachments); - if (savedMessage.data.embeds) { - const embeds = (savedMessage.data.embeds as Embed[]).map(e => cloneDeep(e)); - for (const embed of embeds) { - if (embed.type === "video") { - // Ignore video descriptions as they're not actually shown on the embed - delete embed.description; - } - } - - messageContent += " " + JSON.stringify(embeds); - } - - // Filter zalgo - const filterZalgo = config.filter_zalgo; - if (filterZalgo) { - const result = ZalgoRegex.exec(messageContent); - if (result) { - this.censorMessage(savedMessage, "zalgo detected"); - return true; - } - } - - // Filter invites - const filterInvites = config.filter_invites; - if (filterInvites) { - const inviteGuildWhitelist = config.invite_guild_whitelist; - const inviteGuildBlacklist = config.invite_guild_blacklist; - const inviteCodeWhitelist = config.invite_code_whitelist; - const inviteCodeBlacklist = config.invite_code_blacklist; - const allowGroupDMInvites = config.allow_group_dm_invites; - - const inviteCodes = getInviteCodesInString(messageContent); - - const invites: Array = await Promise.all(inviteCodes.map(code => this.resolveInvite(code))); - - for (const invite of invites) { - // Always filter unknown invites if invite filtering is enabled - if (invite == null) { - this.censorMessage(savedMessage, `unknown invite not found in whitelist`); - return true; - } - - if (!invite.guild && !allowGroupDMInvites) { - this.censorMessage(savedMessage, `group dm invites are not allowed`); - return true; - } - - if (inviteGuildWhitelist && !inviteGuildWhitelist.includes(invite.guild.id)) { - this.censorMessage( - savedMessage, - `invite guild (**${invite.guild.name}** \`${invite.guild.id}\`) not found in whitelist`, - ); - return true; - } - - if (inviteGuildBlacklist && inviteGuildBlacklist.includes(invite.guild.id)) { - this.censorMessage( - savedMessage, - `invite guild (**${invite.guild.name}** \`${invite.guild.id}\`) found in blacklist`, - ); - return true; - } - - if (inviteCodeWhitelist && !inviteCodeWhitelist.includes(invite.code)) { - this.censorMessage(savedMessage, `invite code (\`${invite.code}\`) not found in whitelist`); - return true; - } - - if (inviteCodeBlacklist && inviteCodeBlacklist.includes(invite.code)) { - this.censorMessage(savedMessage, `invite code (\`${invite.code}\`) found in blacklist`); - return true; - } - } - } - - // Filter domains - const filterDomains = config.filter_domains; - if (filterDomains) { - const domainWhitelist = config.domain_whitelist; - const domainBlacklist = config.domain_blacklist; - - const urls = getUrlsInString(messageContent); - for (const thisUrl of urls) { - if (domainWhitelist && !domainWhitelist.includes(thisUrl.hostname)) { - this.censorMessage(savedMessage, `domain (\`${thisUrl.hostname}\`) not found in whitelist`); - return true; - } - - if (domainBlacklist && domainBlacklist.includes(thisUrl.hostname)) { - this.censorMessage(savedMessage, `domain (\`${thisUrl.hostname}\`) found in blacklist`); - return true; - } - } - } - - // Filter tokens - const blockedTokens = config.blocked_tokens || []; - for (const token of blockedTokens) { - if (messageContent.toLowerCase().includes(token.toLowerCase())) { - this.censorMessage(savedMessage, `blocked token (\`${token}\`) found`); - return true; - } - } - - // Filter words - const blockedWords = config.blocked_words || []; - for (const word of blockedWords) { - const regex = new RegExp(`\\b${escapeStringRegexp(word)}\\b`, "i"); - if (regex.test(messageContent)) { - this.censorMessage(savedMessage, `blocked word (\`${word}\`) found`); - return true; - } - } - - // Filter regex - const blockedRegex: RegExp[] = config.blocked_regex || []; - for (const [i, regex] of blockedRegex.entries()) { - if (typeof regex.test !== "function") { - logger.info( - `[DEBUG] Regex <${regex}> was not a regex; index ${i} of censor.blocked_regex for guild ${this.guild.name} (${this.guild.id})`, - ); - continue; - } - - // We're testing both the original content and content + attachments/embeds here so regexes that use ^ and $ still match the regular content properly - if (regex.test(savedMessage.data.content) || regex.test(messageContent)) { - this.censorMessage(savedMessage, `blocked regex (\`${regex.source}\`) found`); - return true; - } - } - - return false; - } - - async onMessageCreate(savedMessage: SavedMessage) { - if (savedMessage.is_bot) return; - const lock = await this.locks.acquire(`message-${savedMessage.id}`); - - const wasDeleted = await this.applyFiltersToMsg(savedMessage); - - if (wasDeleted) { - lock.interrupt(); - } else { - lock.unlock(); - } - } - - async onMessageUpdate(savedMessage: SavedMessage) { - if (savedMessage.is_bot) return; - const lock = await this.locks.acquire(`message-${savedMessage.id}`); - - const wasDeleted = await this.applyFiltersToMsg(savedMessage); - - if (wasDeleted) { - lock.interrupt(); - } else { - lock.unlock(); - } - } -} diff --git a/backend/src/plugins/ChannelArchiver.ts b/backend/src/plugins/ChannelArchiver.ts deleted file mode 100644 index cf2bc961..00000000 --- a/backend/src/plugins/ChannelArchiver.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { decorators as d, ICommandContext, logger } from "knub"; -import { GlobalZeppelinPlugin } from "./GlobalZeppelinPlugin"; -import { Attachment, GuildChannel, Message, TextChannel } from "eris"; -import { confirm, downloadFile, errorMessage, noop, SECONDS, trimLines } from "../utils"; -import { ZeppelinPluginClass } from "./ZeppelinPluginClass"; -import moment from "moment-timezone"; -import https from "https"; -import fs from "fs"; -const fsp = fs.promises; - -const MAX_ARCHIVED_MESSAGES = 5000; -const MAX_MESSAGES_PER_FETCH = 100; -const PROGRESS_UPDATE_INTERVAL = 5 * SECONDS; -const MAX_ATTACHMENT_REHOST_SIZE = 1024 * 1024 * 8; - -export class ChannelArchiverPlugin extends ZeppelinPluginClass { - public static pluginName = "channel_archiver"; - public static showInDocs = false; - - protected isOwner(userId) { - const owners = this.knub.getGlobalConfig().owners || []; - return owners.includes(userId); - } - - protected async rehostAttachment(attachment: Attachment, targetChannel: TextChannel): Promise { - if (attachment.size > MAX_ATTACHMENT_REHOST_SIZE) { - return "Attachment too big to rehost"; - } - - let downloaded; - try { - downloaded = await downloadFile(attachment.url, 3); - } catch (e) { - return "Failed to download attachment after 3 tries"; - } - - try { - const rehostMessage = await targetChannel.createMessage(`Rehost of attachment ${attachment.id}`, { - name: attachment.filename, - file: await fsp.readFile(downloaded.path), - }); - return rehostMessage.attachments[0].url; - } catch (e) { - return "Failed to rehost attachment"; - } - } - - @d.command("archive_channel", "", { - options: [ - { - name: "attachment-channel", - type: "textChannel", - }, - { - name: "messages", - type: "number", - }, - ], - preFilters: [ - (command, context: ICommandContext) => { - return (context.plugin as ChannelArchiverPlugin).isOwner(context.message.author.id); - }, - ], - }) - protected async archiveCmd( - msg: Message, - args: { channel: TextChannel; "attachment-channel"?: TextChannel; messages?: number }, - ) { - if (!this.isOwner(msg.author.id)) return; - - if (!args["attachment-channel"]) { - const confirmed = await confirm( - this.bot, - msg.channel, - msg.author.id, - "No `-attachment-channel` specified. Continue? Attachments will not be available in the log if their message is deleted.", - ); - if (!confirmed) { - msg.channel.createMessage(errorMessage("Canceled")); - return; - } - } - - const maxMessagesToArchive = args.messages ? Math.min(args.messages, MAX_ARCHIVED_MESSAGES) : MAX_ARCHIVED_MESSAGES; - if (maxMessagesToArchive <= 0) return; - - const archiveLines = []; - let archivedMessages = 0; - let previousId; - - const startTime = Date.now(); - const progressMsg = await msg.channel.createMessage("Creating archive..."); - const progressUpdateInterval = setInterval(() => { - const secondsSinceStart = Math.round((Date.now() - startTime) / 1000); - progressMsg - .edit(`Creating archive...\n**Status:** ${archivedMessages} messages archived in ${secondsSinceStart} seconds`) - .catch(() => clearInterval(progressUpdateInterval)); - }, PROGRESS_UPDATE_INTERVAL); - - while (archivedMessages < maxMessagesToArchive) { - const messagesToFetch = Math.min(MAX_MESSAGES_PER_FETCH, maxMessagesToArchive - archivedMessages); - const messages = await args.channel.getMessages(messagesToFetch, previousId); - if (messages.length === 0) break; - - for (const message of messages) { - const ts = moment.utc(message.timestamp).format("YYYY-MM-DD HH:mm:ss"); - let content = `[${ts}] [${message.author.id}] [${message.author.username}#${ - message.author.discriminator - }]: ${message.content || ""}`; - - if (message.attachments.length) { - if (args["attachment-channel"]) { - const rehostedAttachmentUrl = await this.rehostAttachment( - message.attachments[0], - args["attachment-channel"], - ); - content += `\n-- Attachment: ${rehostedAttachmentUrl}`; - } else { - content += `\n-- Attachment: ${message.attachments[0].url}`; - } - } - - if (message.reactions && Object.keys(message.reactions).length > 0) { - const reactionCounts = []; - for (const [emoji, info] of Object.entries(message.reactions)) { - reactionCounts.push(`${info.count}x ${emoji}`); - } - content += `\n-- Reactions: ${reactionCounts.join(", ")}`; - } - - archiveLines.push(content); - previousId = message.id; - archivedMessages++; - } - } - - clearInterval(progressUpdateInterval); - - archiveLines.reverse(); - - const nowTs = moment().format("YYYY-MM-DD HH:mm:ss"); - - let result = `Archived ${archiveLines.length} messages from #${args.channel.name} at ${nowTs}`; - result += `\n\n${archiveLines.join("\n")}\n`; - - progressMsg.delete().catch(noop); - msg.channel.createMessage("Archive created!", { - file: Buffer.from(result), - name: `archive-${args.channel.name}-${moment().format("YYYY-MM-DD-HH-mm-ss")}.txt`, - }); - } -} diff --git a/backend/src/plugins/CompanionChannels.ts b/backend/src/plugins/CompanionChannels.ts deleted file mode 100644 index d09bcb3e..00000000 --- a/backend/src/plugins/CompanionChannels.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { decorators as d, IPluginOptions, logger } from "knub"; -import { trimPluginDescription, ZeppelinPluginClass } from "./ZeppelinPluginClass"; -import { Member, Channel, GuildChannel, PermissionOverwrite, Permission, Message, TextChannel } from "eris"; -import * as t from "io-ts"; -import { tNullable } from "../utils"; - -// Permissions using these numbers: https://abal.moe/Eris/docs/reference (add all allowed/denied ones up) -const CompanionChannelOpts = t.type({ - voice_channel_ids: t.array(t.string), - text_channel_ids: t.array(t.string), - permissions: t.number, - enabled: tNullable(t.boolean), -}); -type TCompanionChannelOpts = t.TypeOf; - -const ConfigSchema = t.type({ - entries: t.record(t.string, CompanionChannelOpts), -}); -type TConfigSchema = t.TypeOf; - -interface ICompanionChannelMap { - [channelId: string]: TCompanionChannelOpts; -} - -const defaultCompanionChannelOpts: Partial = { - enabled: true, -}; - -export class CompanionChannelPlugin extends ZeppelinPluginClass { - public static pluginName = "companion_channels"; - public static configSchema = ConfigSchema; - - public static pluginInfo = { - prettyName: "Companion channels", - description: trimPluginDescription(` - Set up 'companion channels' between text and voice channels. - Once set up, any time a user joins one of the specified voice channels, - they'll get channel permissions applied to them for the text channels. - `), - }; - - public static getStaticDefaultOptions(): IPluginOptions { - return { - config: { - entries: {}, - }, - }; - } - - /** - * Returns an array of companion channel opts that match the given userId and voiceChannelId, - * with default companion channel opts applied as well - */ - protected getCompanionChannelOptsForVoiceChannelId(userId, voiceChannelId): TCompanionChannelOpts[] { - const config = this.getConfigForMemberIdAndChannelId(userId, voiceChannelId); - return Object.values(config.entries) - .filter(opts => opts.voice_channel_ids.includes(voiceChannelId)) - .map(opts => Object.assign({}, defaultCompanionChannelOpts, opts)); - } - - async handleCompanionPermissions(userId: string, voiceChannelId?: string, oldChannelId?: string) { - const permsToDelete: Set = new Set(); // channelId[] - const oldPerms: Map = new Map(); // channelId => permissions - const permsToSet: Map = new Map(); // channelId => permissions - - const oldChannelOptsArr: TCompanionChannelOpts[] = oldChannelId - ? this.getCompanionChannelOptsForVoiceChannelId(userId, oldChannelId) - : []; - const newChannelOptsArr: TCompanionChannelOpts[] = voiceChannelId - ? this.getCompanionChannelOptsForVoiceChannelId(userId, voiceChannelId) - : []; - - for (const oldChannelOpts of oldChannelOptsArr) { - for (const channelId of oldChannelOpts.text_channel_ids) { - oldPerms.set(channelId, oldChannelOpts.permissions); - permsToDelete.add(channelId); - } - } - - for (const newChannelOpts of newChannelOptsArr) { - for (const channelId of newChannelOpts.text_channel_ids) { - if (oldPerms.get(channelId) !== newChannelOpts.permissions) { - // Update text channel perms if the channel we transitioned from didn't already have the same text channel perms - permsToSet.set(channelId, newChannelOpts.permissions); - } - if (permsToDelete.has(channelId)) { - permsToDelete.delete(channelId); - } - } - } - - for (const channelId of permsToDelete) { - const channel = this.guild.channels.get(channelId); - if (!channel || !(channel instanceof TextChannel)) continue; - channel.deletePermission(userId, `Companion Channel for ${oldChannelId} | User Left`); - } - - for (const [channelId, permissions] of permsToSet) { - const channel = this.guild.channels.get(channelId); - if (!channel || !(channel instanceof TextChannel)) continue; - channel.editPermission(userId, permissions, 0, "member", `Companion Channel for ${voiceChannelId} | User Joined`); - } - } - - @d.event("voiceChannelJoin") - onVoiceChannelJoin(member: Member, voiceChannel: Channel) { - this.handleCompanionPermissions(member.id, voiceChannel.id); - } - - @d.event("voiceChannelSwitch") - onVoiceChannelSwitch(member: Member, newChannel: Channel, oldChannel: Channel) { - this.handleCompanionPermissions(member.id, newChannel.id, oldChannel.id); - } - - @d.event("voiceChannelLeave") - onVoiceChannelLeave(member: Member, voiceChannel: Channel) { - this.handleCompanionPermissions(member.id, null, voiceChannel.id); - } -} diff --git a/backend/src/plugins/CustomEvents.ts b/backend/src/plugins/CustomEvents.ts deleted file mode 100644 index 849465cc..00000000 --- a/backend/src/plugins/CustomEvents.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { ZeppelinPluginClass } from "./ZeppelinPluginClass"; -import { IPluginOptions } from "knub"; -import { Message, TextChannel, VoiceChannel } from "eris"; -import { renderTemplate } from "../templateFormatter"; -import { stripObjectToScalars } from "../utils"; -import { CasesPlugin } from "./Cases"; -import { CaseTypes } from "../data/CaseTypes"; -import * as t from "io-ts"; - -// Triggers -const CommandTrigger = t.type({ - type: t.literal("command"), - name: t.string, - params: t.string, - can_use: t.boolean, -}); -type TCommandTrigger = t.TypeOf; - -const AnyTrigger = CommandTrigger; // TODO: Make into a union once we have more triggers -type TAnyTrigger = t.TypeOf; - -// Actions -const AddRoleAction = t.type({ - type: t.literal("add_role"), - target: t.string, - role: t.union([t.string, t.array(t.string)]), -}); -type TAddRoleAction = t.TypeOf; - -const CreateCaseAction = t.type({ - type: t.literal("create_case"), - case_type: t.string, - mod: t.string, - target: t.string, - reason: t.string, -}); -type TCreateCaseAction = t.TypeOf; - -const MoveToVoiceChannelAction = t.type({ - type: t.literal("move_to_vc"), - target: t.string, - channel: t.string, -}); -type TMoveToVoiceChannelAction = t.TypeOf; - -const MessageAction = t.type({ - type: t.literal("message"), - channel: t.string, - content: t.string, -}); -type TMessageAction = t.TypeOf; - -const AnyAction = t.union([AddRoleAction, CreateCaseAction, MoveToVoiceChannelAction, MessageAction]); -type TAnyAction = t.TypeOf; - -// Full config schema -const CustomEvent = t.type({ - name: t.string, - trigger: AnyTrigger, - actions: t.array(AnyAction), -}); -type TCustomEvent = t.TypeOf; - -const ConfigSchema = t.type({ - events: t.record(t.string, CustomEvent), -}); -type TConfigSchema = t.TypeOf; - -class ActionError extends Error {} - -export class CustomEventsPlugin extends ZeppelinPluginClass { - public static pluginName = "custom_events"; - public static showInDocs = false; - public static dependencies = ["cases"]; - public static configSchema = ConfigSchema; - - private clearTriggers: () => void; - - public static getStaticDefaultOptions(): IPluginOptions { - return { - config: { - events: {}, - }, - }; - } - - onLoad() { - for (const [key, event] of Object.entries(this.getConfig().events)) { - if (event.trigger.type === "command") { - this.addCommand( - event.trigger.name, - event.trigger.params, - (msg, args) => { - const strippedMsg = stripObjectToScalars(msg, ["channel", "author"]); - this.runEvent(event, { msg, args }, { args, msg: strippedMsg }); - }, - { - extra: { - requiredPermission: `events.${key}.trigger.can_use`, - }, - }, - ); - } - } - } - - onUnload() { - // TODO: Run this.clearTriggers() once we actually have something there - } - - async runEvent(event: TCustomEvent, eventData: any, values: any) { - try { - for (const action of event.actions) { - if (action.type === "add_role") { - await this.addRoleAction(action, values, event, eventData); - } else if (action.type === "create_case") { - await this.createCaseAction(action, values, event, eventData); - } else if (action.type === "move_to_vc") { - await this.moveToVoiceChannelAction(action, values, event, eventData); - } else if (action.type === "message") { - await this.messageAction(action, values); - } - } - } catch (e) { - if (e instanceof ActionError) { - if (event.trigger.type === "command") { - this.sendErrorMessage((eventData.msg as Message).channel, e.message); - } else { - // TODO: Where to log action errors from other kinds of triggers? - } - - return; - } - - throw e; - } - } - - async addRoleAction(action: TAddRoleAction, values: any, event: TCustomEvent, eventData: any) { - const targetId = await renderTemplate(action.target, values, false); - const target = await this.getMember(targetId); - if (!target) throw new ActionError(`Unknown target member: ${targetId}`); - - if (event.trigger.type === "command" && !this.canActOn((eventData.msg as Message).member, target)) { - throw new ActionError("Missing permissions"); - } - - const rolesToAdd = Array.isArray(action.role) ? action.role : [action.role]; - await target.edit({ - roles: Array.from(new Set([...target.roles, ...rolesToAdd])), - }); - } - - async createCaseAction(action: TCreateCaseAction, values: any, event: TCustomEvent, eventData: any) { - const modId = await renderTemplate(action.mod, values, false); - const targetId = await renderTemplate(action.target, values, false); - - const reason = await renderTemplate(action.reason, values, false); - - if (CaseTypes[action.case_type] == null) { - throw new ActionError(`Invalid case type: ${action.type}`); - } - - const casesPlugin = this.getPlugin("cases"); - await casesPlugin.createCase({ - userId: targetId, - modId, - type: CaseTypes[action.case_type], - reason: `__[${event.name}]__ ${reason}`, - }); - } - - async moveToVoiceChannelAction(action: TMoveToVoiceChannelAction, values: any, event: TCustomEvent, eventData: any) { - const targetId = await renderTemplate(action.target, values, false); - const target = await this.getMember(targetId); - if (!target) throw new ActionError("Unknown target member"); - - if (event.trigger.type === "command" && !this.canActOn((eventData.msg as Message).member, target)) { - throw new ActionError("Missing permissions"); - } - - const targetChannelId = await renderTemplate(action.channel, values, false); - const targetChannel = this.guild.channels.get(targetChannelId); - if (!targetChannel) throw new ActionError("Unknown target channel"); - if (!(targetChannel instanceof VoiceChannel)) throw new ActionError("Target channel is not a voice channel"); - - if (!target.voiceState.channelID) return; - await target.edit({ - channelID: targetChannel.id, - }); - } - - async messageAction(action: TMessageAction, values: any) { - const targetChannelId = await renderTemplate(action.channel, values, false); - const targetChannel = this.guild.channels.get(targetChannelId); - if (!targetChannel) throw new ActionError("Unknown target channel"); - if (!(targetChannel instanceof TextChannel)) throw new ActionError("Target channel is not a text channel"); - - await targetChannel.createMessage({ content: action.content }); - } -} diff --git a/backend/src/plugins/GlobalZeppelinPlugin.ts b/backend/src/plugins/GlobalZeppelinPlugin.ts deleted file mode 100644 index 63fad855..00000000 --- a/backend/src/plugins/GlobalZeppelinPlugin.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { GlobalPlugin, IBasePluginConfig, IPluginOptions, logger, configUtils } from "knub"; -import * as t from "io-ts"; -import { pipe } from "fp-ts/lib/pipeable"; -import { fold } from "fp-ts/lib/Either"; -import { PathReporter } from "io-ts/lib/PathReporter"; -import { deepKeyIntersect, isSnowflake, isUnicodeEmoji, resolveMember, resolveUser, UnknownUser } from "../utils"; -import { Member, User } from "eris"; -import { performance } from "perf_hooks"; -import { decodeAndValidateStrict, StrictValidationError } from "../validatorUtils"; -import { TZeppelinKnub } from "../types"; - -const SLOW_RESOLVE_THRESHOLD = 1500; - -export class GlobalZeppelinPlugin extends GlobalPlugin { - public static configSchema: t.TypeC; - public static dependencies = []; - - protected readonly knub: TZeppelinKnub; - - /** - * Since we want to do type checking without creating instances of every plugin, - * we need a static version of getDefaultOptions(). This static version is then, - * by turn, called from getDefaultOptions() so everything still works as expected. - */ - public static getStaticDefaultOptions() { - // Implemented by plugin - return {}; - } - - /** - * Wrapper to fetch the real default options from getStaticDefaultOptions() - */ - protected getDefaultOptions(): IPluginOptions { - return (this.constructor as typeof GlobalZeppelinPlugin).getStaticDefaultOptions() as IPluginOptions; - } - - /** - * Merges the given options and default options and decodes them according to the config schema of the plugin (if any). - * Throws on any decoding/validation errors. - * - * Intended as an augmented, static replacement for Plugin.getMergedConfig() which is why this is also called from - * getMergedConfig(). - * - * Like getStaticDefaultOptions(), we also want to use this function for type checking without creating an instance of - * the plugin, which is why this has to be a static function. - */ - protected static mergeAndDecodeStaticOptions(options: any): IPluginOptions { - const defaultOptions: any = this.getStaticDefaultOptions(); - const mergedConfig = configUtils.mergeConfig({}, defaultOptions.config || {}, options.config || {}); - const mergedOverrides = options.replaceDefaultOverrides - ? options.overrides - : (defaultOptions.overrides || []).concat(options.overrides || []); - - const decodedConfig = this.configSchema ? decodeAndValidateStrict(this.configSchema, mergedConfig) : mergedConfig; - if (decodedConfig instanceof StrictValidationError) { - throw decodedConfig; - } - - const decodedOverrides = []; - for (const override of mergedOverrides) { - const overrideConfigMergedWithBaseConfig = configUtils.mergeConfig({}, mergedConfig, override.config); - const decodedOverrideConfig = this.configSchema - ? decodeAndValidateStrict(this.configSchema, overrideConfigMergedWithBaseConfig) - : overrideConfigMergedWithBaseConfig; - if (decodedOverrideConfig instanceof StrictValidationError) { - throw decodedOverrideConfig; - } - decodedOverrides.push({ ...override, config: deepKeyIntersect(decodedOverrideConfig, override.config) }); - } - - return { - config: decodedConfig, - overrides: decodedOverrides, - }; - } - - /** - * Wrapper that calls mergeAndValidateStaticOptions() - */ - protected getMergedOptions(): IPluginOptions { - if (!this.mergedPluginOptions) { - this.mergedPluginOptions = ((this - .constructor as unknown) as typeof GlobalZeppelinPlugin).mergeAndDecodeStaticOptions(this.pluginOptions); - } - - return this.mergedPluginOptions as IPluginOptions; - } - - /** - * Run static type checks and other validations on the given options - */ - public static validateOptions(options: any): string[] | null { - // Validate config values - if (this.configSchema) { - try { - this.mergeAndDecodeStaticOptions(options); - } catch (e) { - if (e instanceof StrictValidationError) { - return e.getErrors(); - } - - throw e; - } - } - - // No errors, return null - return null; - } - - public async runLoad(): Promise { - const mergedOptions = this.getMergedOptions(); // This implicitly also validates the config - return super.runLoad(); - } - - protected isOwner(userId) { - const owners = this.knub.getGlobalConfig().owners || []; - return owners.includes(userId); - } -} diff --git a/backend/src/plugins/GuildConfigReloader.ts b/backend/src/plugins/GuildConfigReloader.ts deleted file mode 100644 index c18a2604..00000000 --- a/backend/src/plugins/GuildConfigReloader.ts +++ /dev/null @@ -1,52 +0,0 @@ -import moment from "moment-timezone"; -import { ZeppelinPluginClass } from "./ZeppelinPluginClass"; -import { Configs } from "../data/Configs"; -import { logger } from "knub"; -import { GlobalZeppelinPlugin } from "./GlobalZeppelinPlugin"; -import { DBDateFormat } from "../utils"; - -const CHECK_INTERVAL = 1000; - -/** - * Temporary solution to reloading guilds when their config changes - * And you know what they say about temporary solutions... - */ -export class GuildConfigReloader extends GlobalZeppelinPlugin { - public static pluginName = "guild_config_reloader"; - - protected guildConfigs: Configs; - private unloaded = false; - private highestConfigId; - private nextCheckTimeout; - - async onLoad() { - this.guildConfigs = new Configs(); - - this.highestConfigId = await this.guildConfigs.getHighestId(); - this.reloadChangedGuilds(); - } - - onUnload() { - clearTimeout(this.nextCheckTimeout); - this.unloaded = true; - } - - protected async reloadChangedGuilds() { - if (this.unloaded) return; - - const changedConfigs = await this.guildConfigs.getActiveLargerThanId(this.highestConfigId); - for (const item of changedConfigs) { - if (!item.key.startsWith("guild-")) continue; - - const guildId = item.key.slice("guild-".length); - logger.info(`Config changed, reloading guild ${guildId}`); - await this.knub.reloadGuild(guildId); - - if (item.id > this.highestConfigId) { - this.highestConfigId = item.id; - } - } - - this.nextCheckTimeout = setTimeout(() => this.reloadChangedGuilds(), CHECK_INTERVAL); - } -} diff --git a/backend/src/plugins/GuildInfoSaver.ts b/backend/src/plugins/GuildInfoSaver.ts deleted file mode 100644 index f09ad317..00000000 --- a/backend/src/plugins/GuildInfoSaver.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { ZeppelinPluginClass } from "./ZeppelinPluginClass"; -import { AllowedGuilds } from "../data/AllowedGuilds"; -import { MINUTES } from "../utils"; - -export class GuildInfoSaverPlugin extends ZeppelinPluginClass { - public static pluginName = "guild_info_saver"; - public static showInDocs = false; - protected allowedGuilds: AllowedGuilds; - private updateInterval; - - onLoad() { - this.allowedGuilds = new AllowedGuilds(); - - this.updateGuildInfo(); - this.updateInterval = setInterval(() => this.updateGuildInfo(), 60 * MINUTES); - } - - onUnload() { - clearInterval(this.updateInterval); - } - - protected updateGuildInfo() { - this.allowedGuilds.updateInfo(this.guildId, this.guild.name, this.guild.iconURL, this.guild.ownerID); - } -} diff --git a/backend/src/plugins/LocateUser.ts b/backend/src/plugins/LocateUser.ts deleted file mode 100644 index d7b5b167..00000000 --- a/backend/src/plugins/LocateUser.ts +++ /dev/null @@ -1,341 +0,0 @@ -import { decorators as d, IPluginOptions, getInviteLink, logger } from "knub"; -import { trimPluginDescription, ZeppelinPluginClass, CommandInfo } from "./ZeppelinPluginClass"; -import humanizeDuration from "humanize-duration"; -import { Message, Member, Guild, TextableChannel, VoiceChannel, Channel, User } from "eris"; -import { GuildVCAlerts } from "../data/GuildVCAlerts"; -import moment from "moment-timezone"; -import { resolveMember, sorter, createChunkedMessage, MINUTES, SECONDS } from "../utils"; -import * as t from "io-ts"; - -const ConfigSchema = t.type({ - can_where: t.boolean, - can_alert: t.boolean, -}); -type TConfigSchema = t.TypeOf; - -const ALERT_LOOP_TIME = 30 * SECONDS; - -export class LocatePlugin extends ZeppelinPluginClass { - public static pluginName = "locate_user"; - public static configSchema = ConfigSchema; - - public static pluginInfo = { - prettyName: "Locate user", - description: trimPluginDescription(` - This plugin allows users with access to the commands the following: - * Instantly receive an invite to the voice channel of a user - * Be notified as soon as a user switches or joins a voice channel - `), - }; - - private alerts: GuildVCAlerts; - private outdatedAlertsTimeout: NodeJS.Timeout; - private usersWithAlerts: string[] = []; - private unloaded = false; - - public static getStaticDefaultOptions(): IPluginOptions { - return { - config: { - can_where: false, - can_alert: false, - }, - overrides: [ - { - level: ">=50", - config: { - can_where: true, - can_alert: true, - }, - }, - ], - }; - } - - onLoad() { - this.alerts = GuildVCAlerts.getGuildInstance(this.guildId); - this.outdatedAlertsLoop(); - this.fillActiveAlertsList(); - } - - onUnload() { - clearTimeout(this.outdatedAlertsTimeout); - this.unloaded = true; - } - - async outdatedAlertsLoop() { - const outdatedAlerts = await this.alerts.getOutdatedAlerts(); - - for (const alert of outdatedAlerts) { - await this.alerts.delete(alert.id); - await this.removeUserIdFromActiveAlerts(alert.user_id); - } - - if (!this.unloaded) { - this.outdatedAlertsTimeout = setTimeout(() => this.outdatedAlertsLoop(), ALERT_LOOP_TIME); - } - } - - async fillActiveAlertsList() { - const allAlerts = await this.alerts.getAllGuildAlerts(); - - allAlerts.forEach(alert => { - if (!this.usersWithAlerts.includes(alert.user_id)) { - this.usersWithAlerts.push(alert.user_id); - } - }); - } - - @d.command("where", "", { - aliases: ["w"], - extra: { - info: { - description: "Posts an instant invite to the voice channel that `` is in", - basicUsage: "!w 108552944961454080", - parameterDescriptions: { - member: "The member that we want to find", - }, - }, - }, - }) - @d.permission("can_where") - async whereCmd(msg: Message, args: { member: Member }) { - const member = await resolveMember(this.bot, this.guild, args.member.id); - sendWhere.call(this, this.guild, member, msg.channel, `${msg.member.mention} | `); - } - - @d.command("follow", " [reminder:string$]", { - aliases: ["f", "vcalert", "vca"], - options: [ - { - name: "duration", - shortcut: "d", - type: "delay", - }, - { - name: "active", - shortcut: "a", - isSwitch: true, - }, - ], - extra: { - info: { - description: "Sets up an alert that notifies you any time `` switches or joins voice channels", - basicUsage: "!f 108552944961454080", - examples: trimPluginDescription(` - To get an alert for 1 hour: - \`!f 108552944961454080 -d 1h\` - - To get an alert for 2 hours and 30 minutes with the reminder "Earrape": - \`!f 108552944961454080 -d 2h30m Earrape\` - *Note: The duration must be specified before the reminder, otherwise it will be part of it* - - To get an alert for 3 days and be moved to the channel: - \`!f 108552944961454080 -d 3d -a\` - *Note: As with the duration, active must be specified before the rminder, otherwise it will be part of it* - `), - optionDescriptions: { - duration: "How long the alert shall be active. The alert will be automatically deleted after this time", - active: "A switch that, when true, will move you to the channel the user joined", - }, - parameterDescriptions: { - member: "The server member we want to set as the alerts target", - reminder: "Any text that will be displayed every time the alert triggers", - }, - }, - }, - }) - @d.permission("can_alert") - async followCmd(msg: Message, args: { member: Member; reminder?: string; duration?: number; active?: boolean }) { - const time = args.duration || 10 * MINUTES; - const alertTime = moment().add(time, "millisecond"); - const body = args.reminder || "None"; - const active = args.active || false; - - if (time < 30 * SECONDS) { - this.sendErrorMessage(msg.channel, "Sorry, but the minimum duration for an alert is 30 seconds!"); - return; - } - - await this.alerts.add( - msg.author.id, - args.member.id, - msg.channel.id, - alertTime.format("YYYY-MM-DD HH:mm:ss"), - body, - active, - ); - if (!this.usersWithAlerts.includes(args.member.id)) { - this.usersWithAlerts.push(args.member.id); - } - - if (active) { - this.sendSuccessMessage( - msg.channel, - `Every time ${args.member.mention} joins or switches VC in the next ${humanizeDuration( - time, - )} i will notify and move you.\nPlease make sure to be in a voice channel, otherwise i cannot move you!`, - ); - } else { - this.sendSuccessMessage( - msg.channel, - `Every time ${args.member.mention} joins or switches VC in the next ${humanizeDuration( - time, - )} i will notify you`, - ); - } - } - - @d.command("follows", [], { - aliases: ["fs", "vcalerts", "vca"], - extra: { - info: { - description: "Displays all of your active alerts ordered by expiration time", - }, - }, - }) - @d.permission("can_alert") - async listFollowCmd(msg: Message) { - const alerts = await this.alerts.getAlertsByRequestorId(msg.member.id); - if (alerts.length === 0) { - this.sendErrorMessage(msg.channel, "You have no active alerts!"); - return; - } - - alerts.sort(sorter("expires_at")); - const longestNum = (alerts.length + 1).toString().length; - const lines = Array.from(alerts.entries()).map(([i, alert]) => { - const num = i + 1; - const paddedNum = num.toString().padStart(longestNum, " "); - return `\`${paddedNum}.\` \`${alert.expires_at}\` **Target:** <@!${alert.user_id}> **Reminder:** \`${ - alert.body - }\` **Active:** ${alert.active.valueOf()}`; - }); - await createChunkedMessage(msg.channel, lines.join("\n")); - } - - @d.command("follows delete", "", { - aliases: ["fs d", "vcalerts delete", "vcalerts d", "vca d"], - extra: { - info: { - description: - "Deletes the alert at the position .\nThe value needed for can be found using `!follows` (`!fs`)", - }, - }, - }) - @d.permission("can_alert") - async deleteFollowCmd(msg: Message, args: { num: number }) { - const alerts = await this.alerts.getAlertsByRequestorId(msg.member.id); - alerts.sort(sorter("expires_at")); - - if (args.num > alerts.length || args.num <= 0) { - this.sendErrorMessage(msg.channel, "Unknown alert!"); - return; - } - - const toDelete = alerts[args.num - 1]; - await this.alerts.delete(toDelete.id); - - this.sendSuccessMessage(msg.channel, "Alert deleted"); - } - - @d.event("voiceChannelJoin") - async userJoinedVC(member: Member, channel: Channel) { - if (this.usersWithAlerts.includes(member.id)) { - this.sendAlerts(member.id); - } - } - - @d.event("voiceChannelSwitch") - async userSwitchedVC(member: Member, newChannel: Channel, oldChannel: Channel) { - if (this.usersWithAlerts.includes(member.id)) { - this.sendAlerts(member.id); - } - } - - @d.event("voiceChannelLeave") - async userLeftVC(member: Member, channel: Channel) { - const triggeredAlerts = await this.alerts.getAlertsByUserId(member.id); - const voiceChannel = channel as VoiceChannel; - - triggeredAlerts.forEach(alert => { - const txtChannel = this.bot.getChannel(alert.channel_id) as TextableChannel; - txtChannel.createMessage( - `🔴 <@!${alert.requestor_id}> the user <@!${alert.user_id}> disconnected out of \`${voiceChannel.name}\``, - ); - }); - } - - @d.event("guildBanAdd") - async onGuildBanAdd(_, user: User) { - const alerts = await this.alerts.getAlertsByUserId(user.id); - alerts.forEach(alert => { - this.alerts.delete(alert.id); - }); - } - - async sendAlerts(userId: string) { - const triggeredAlerts = await this.alerts.getAlertsByUserId(userId); - const member = await resolveMember(this.bot, this.guild, userId); - - triggeredAlerts.forEach(alert => { - const prepend = `<@!${alert.requestor_id}>, an alert requested by you has triggered!\nReminder: \`${alert.body}\`\n`; - const txtChannel = this.bot.getChannel(alert.channel_id) as TextableChannel; - sendWhere.call(this, this.guild, member, txtChannel, prepend); - if (alert.active) { - this.moveMember(alert.requestor_id, member, txtChannel); - } - }); - } - - async removeUserIdFromActiveAlerts(userId: string) { - const index = this.usersWithAlerts.indexOf(userId); - if (index > -1) { - this.usersWithAlerts.splice(index, 1); - } - } - - async moveMember(toMoveID: string, target: Member, errorChannel: TextableChannel) { - const modMember: Member = await this.bot.getRESTGuildMember(this.guildId, toMoveID); - if (modMember.voiceState.channelID != null) { - try { - await modMember.edit({ - channelID: target.voiceState.channelID, - }); - } catch (e) { - this.sendErrorMessage(errorChannel, "Failed to move you. Are you in a voice channel?"); - return; - } - } else { - this.sendErrorMessage(errorChannel, "Failed to move you. Are you in a voice channel?"); - } - } -} - -async function sendWhere(guild: Guild, member: Member, channel: TextableChannel, prepend: string) { - const voice = guild.channels.get(member.voiceState.channelID) as VoiceChannel; - - if (voice == null) { - channel.createMessage(prepend + "That user is not in a channel"); - } else { - let invite = null; - try { - invite = await createInvite(voice); - } catch (e) { - this.sendErrorMessage(channel, "Cannot create an invite to that channel!"); - return; - } - channel.createMessage( - prepend + ` ${member.mention} is in the following channel: \`${voice.name}\` ${getInviteLink(invite)}`, - ); - } -} - -async function createInvite(vc: VoiceChannel) { - const existingInvites = await vc.getInvites(); - - if (existingInvites.length !== 0) { - return existingInvites[0]; - } else { - return vc.createInvite(undefined); - } -} diff --git a/backend/src/plugins/Logs.ts b/backend/src/plugins/Logs.ts deleted file mode 100644 index a842a506..00000000 --- a/backend/src/plugins/Logs.ts +++ /dev/null @@ -1,637 +0,0 @@ -import { decorators as d, IPluginOptions, logger } from "knub"; -import { GuildLogs } from "../data/GuildLogs"; -import { LogType } from "../data/LogType"; -import { Attachment, Channel, Constants as ErisConstants, Embed, Member, TextChannel, User } from "eris"; -import { - createChunkedMessage, - findRelevantAuditLogEntry, - isDiscordRESTError, - messageSummary, - noop, - stripObjectToScalars, - UnknownUser, - useMediaUrls, - verboseChannelMention, - verboseUserMention, - verboseUserName, -} from "../utils"; -import DefaultLogMessages from "../data/DefaultLogMessages.json"; -import moment from "moment-timezone"; -import humanizeDuration from "humanize-duration"; -import isEqual from "lodash.isequal"; -import diff from "lodash.difference"; -import { GuildSavedMessages } from "../data/GuildSavedMessages"; -import { SavedMessage } from "../data/entities/SavedMessage"; -import { GuildArchives } from "../data/GuildArchives"; -import { GuildCases } from "../data/GuildCases"; -import { ZeppelinPluginClass } from "./ZeppelinPluginClass"; -import { renderTemplate, TemplateParseError } from "../templateFormatter"; -import cloneDeep from "lodash.clonedeep"; -import * as t from "io-ts"; -import { TSafeRegex } from "../validatorUtils"; - -const LogChannel = t.partial({ - include: t.array(t.string), - exclude: t.array(t.string), - batched: t.boolean, - batch_time: t.number, - excluded_users: t.array(t.string), - excluded_message_regexes: t.array(TSafeRegex), - excluded_channels: t.array(t.string), -}); -type TLogChannel = t.TypeOf; - -const LogChannelMap = t.record(t.string, LogChannel); -type TLogChannelMap = t.TypeOf; - -const ConfigSchema = t.type({ - channels: LogChannelMap, - format: t.intersection([ - t.record(t.string, t.string), - t.type({ - timestamp: t.string, - }), - ]), - ping_user: t.boolean, -}); -type TConfigSchema = t.TypeOf; - -export class LogsPlugin extends ZeppelinPluginClass { - public static pluginName = "logs"; - public static configSchema = ConfigSchema; - - public static pluginInfo = { - prettyName: "Logs", - }; - - protected guildLogs: GuildLogs; - protected savedMessages: GuildSavedMessages; - protected archives: GuildArchives; - protected cases: GuildCases; - - protected logListener; - - protected batches: Map; - - private onMessageDeleteFn; - private onMessageDeleteBulkFn; - private onMessageUpdateFn; - - private excludedUserProps = ["user", "member", "mod"]; - - public static getStaticDefaultOptions(): IPluginOptions { - return { - config: { - channels: {}, - format: { - timestamp: "YYYY-MM-DD HH:mm:ss", - ...DefaultLogMessages, - }, - ping_user: true, - }, - - overrides: [ - { - level: ">=50", - config: { - ping_user: false, - }, - }, - ], - }; - } - - onLoad() { - this.guildLogs = new GuildLogs(this.guildId); - this.savedMessages = GuildSavedMessages.getGuildInstance(this.guildId); - this.archives = GuildArchives.getGuildInstance(this.guildId); - this.cases = GuildCases.getGuildInstance(this.guildId); - - this.logListener = ({ type, data }) => this.log(type, data); - this.guildLogs.on("log", this.logListener); - - this.batches = new Map(); - - this.onMessageDeleteFn = this.onMessageDelete.bind(this); - this.savedMessages.events.on("delete", this.onMessageDeleteFn); - - this.onMessageDeleteBulkFn = this.onMessageDeleteBulk.bind(this); - this.savedMessages.events.on("deleteBulk", this.onMessageDeleteBulkFn); - - this.onMessageUpdateFn = this.onMessageUpdate.bind(this); - this.savedMessages.events.on("update", this.onMessageUpdateFn); - } - - onUnload() { - this.guildLogs.removeListener("log", this.logListener); - - this.savedMessages.events.off("delete", this.onMessageDeleteFn); - this.savedMessages.events.off("deleteBulk", this.onMessageDeleteBulkFn); - this.savedMessages.events.off("update", this.onMessageUpdateFn); - } - - async log(type, data) { - const logChannels: TLogChannelMap = this.getConfig().channels; - const typeStr = LogType[type]; - - logChannelLoop: for (const [channelId, opts] of Object.entries(logChannels)) { - const channel = this.guild.channels.get(channelId); - if (!channel || !(channel instanceof TextChannel)) continue; - - if ((opts.include && opts.include.includes(typeStr)) || (opts.exclude && !opts.exclude.includes(typeStr))) { - // If this log entry is about an excluded user, skip it - // TODO: Quick and dirty solution, look into changing at some point - if (opts.excluded_users) { - for (const prop of this.excludedUserProps) { - if (data && data[prop] && opts.excluded_users.includes(data[prop].id)) { - continue logChannelLoop; - } - } - } - - // If this entry is from an excluded channel, skip it - if (opts.excluded_channels) { - if ( - type === LogType.MESSAGE_DELETE || - type === LogType.MESSAGE_DELETE_BARE || - type === LogType.MESSAGE_EDIT || - type === LogType.MESSAGE_SPAM_DETECTED || - type === LogType.CENSOR || - type === LogType.CLEAN - ) { - if (opts.excluded_channels.includes(data.channel.id)) { - continue logChannelLoop; - } - } - } - - // If this entry contains a message with an excluded regex, skip it - if (type === LogType.MESSAGE_DELETE && opts.excluded_message_regexes && data.message.data.content) { - for (const regex of opts.excluded_message_regexes) { - if (regex.test(data.message.data.content)) { - continue logChannelLoop; - } - } - } - - if (type === LogType.MESSAGE_EDIT && opts.excluded_message_regexes && data.before.data.content) { - for (const regex of opts.excluded_message_regexes) { - if (regex.test(data.before.data.content)) { - continue logChannelLoop; - } - } - } - - const message = await this.getLogMessage(type, data); - if (message) { - const batched = opts.batched ?? true; // Default to batched unless explicitly disabled - const batchTime = opts.batch_time ?? 1000; - - if (batched) { - // If we're batching log messages, gather all log messages within the set batch_time into a single message - if (!this.batches.has(channel.id)) { - this.batches.set(channel.id, []); - setTimeout(async () => { - const batchedMessage = this.batches.get(channel.id).join("\n"); - this.batches.delete(channel.id); - createChunkedMessage(channel, batchedMessage).catch(noop); - }, batchTime); - } - - this.batches.get(channel.id).push(message); - } else { - // If we're not batching log messages, just send them immediately - await createChunkedMessage(channel, message).catch(noop); - } - } - } - } - } - - async getLogMessage(type, data): Promise { - const config = this.getConfig(); - const format = config.format[LogType[type]] || ""; - if (format === "") return; - - let formatted; - try { - const values = { - ...data, - userMention: async inputUserOrMember => { - if (!inputUserOrMember) return ""; - - const usersOrMembers = Array.isArray(inputUserOrMember) ? inputUserOrMember : [inputUserOrMember]; - - const mentions = []; - for (const userOrMember of usersOrMembers) { - let user; - let member; - - if (userOrMember.user) { - member = userOrMember; - user = member.user; - } else { - user = userOrMember; - member = this.guild.members.get(user.id) || { id: user.id, user }; - } - - const memberConfig = this.getMatchingConfig({ member, userId: user.id }) || ({} as any); - - mentions.push(memberConfig.ping_user ? verboseUserMention(user) : verboseUserName(user)); - } - - return mentions.join(", "); - }, - channelMention: channel => { - if (!channel) return ""; - return verboseChannelMention(channel); - }, - messageSummary: (msg: SavedMessage) => { - if (!msg) return ""; - return messageSummary(msg); - }, - }; - - if (type === LogType.BOT_ALERT) { - const valuesWithoutTmplEval = { ...values }; - values.tmplEval = str => { - return renderTemplate(str, valuesWithoutTmplEval); - }; - } - - formatted = await renderTemplate(format, values); - } catch (e) { - if (e instanceof TemplateParseError) { - logger.error(`Error when parsing template:\nError: ${e.message}\nTemplate: ${format}`); - return; - } else { - throw e; - } - } - - formatted = formatted.trim(); - - const timestampFormat = config.format.timestamp; - if (timestampFormat) { - const timestamp = moment().format(timestampFormat); - return `\`[${timestamp}]\` ${formatted}`; - } else { - return formatted; - } - } - - async findRelevantAuditLogEntry(actionType: number, userId: string, attempts?: number, attemptDelay?: number) { - try { - return await findRelevantAuditLogEntry(this.guild, actionType, userId, attempts, attemptDelay); - } catch (e) { - if (isDiscordRESTError(e) && e.code === 50013) { - this.guildLogs.log(LogType.BOT_ALERT, { - body: "Missing permissions to read audit log", - }); - } else { - throw e; - } - } - } - - @d.event("guildMemberAdd") - async onMemberJoin(_, member) { - const newThreshold = moment().valueOf() - 1000 * 60 * 60; - const accountAge = humanizeDuration(moment().valueOf() - member.createdAt, { - largest: 2, - round: true, - }); - - this.guildLogs.log(LogType.MEMBER_JOIN, { - member: stripObjectToScalars(member, ["user", "roles"]), - new: member.createdAt >= newThreshold ? " :new:" : "", - account_age: accountAge, - }); - - const cases = (await this.cases.with("notes").getByUserId(member.id)).filter(c => !c.is_hidden); - cases.sort((a, b) => (a.created_at > b.created_at ? -1 : 1)); - - if (cases.length) { - const recentCaseLines = []; - const recentCases = cases.slice(0, 2); - for (const theCase of recentCases) { - recentCaseLines.push(this.cases.getSummaryText(theCase)); - } - - let recentCaseSummary = recentCaseLines.join("\n"); - if (recentCases.length < cases.length) { - const remaining = cases.length - recentCases.length; - if (remaining === 1) { - recentCaseSummary += `\n*+${remaining} case*`; - } else { - recentCaseSummary += `\n*+${remaining} cases*`; - } - } - - this.guildLogs.log(LogType.MEMBER_JOIN_WITH_PRIOR_RECORDS, { - member: stripObjectToScalars(member, ["user", "roles"]), - recentCaseSummary, - }); - } - } - - @d.event("guildMemberRemove") - onMemberLeave(_, member) { - this.guildLogs.log(LogType.MEMBER_LEAVE, { - member: stripObjectToScalars(member, ["user", "roles"]), - }); - } - - @d.event("guildBanAdd") - async onMemberBan(_, user) { - const relevantAuditLogEntry = await this.findRelevantAuditLogEntry( - ErisConstants.AuditLogActions.MEMBER_BAN_ADD, - user.id, - ); - const mod = relevantAuditLogEntry ? relevantAuditLogEntry.user : new UnknownUser(); - - this.guildLogs.log( - LogType.MEMBER_BAN, - { - mod: stripObjectToScalars(mod), - user: stripObjectToScalars(user), - }, - user.id, - ); - } - - @d.event("guildBanRemove") - async onMemberUnban(_, user) { - const relevantAuditLogEntry = await this.findRelevantAuditLogEntry( - ErisConstants.AuditLogActions.MEMBER_BAN_REMOVE, - user.id, - ); - const mod = relevantAuditLogEntry ? relevantAuditLogEntry.user : new UnknownUser(); - - this.guildLogs.log( - LogType.MEMBER_UNBAN, - { - mod: stripObjectToScalars(mod), - userId: user.id, - }, - user.id, - ); - } - - @d.event("guildMemberUpdate") - async onMemberUpdate(_, member: Member, oldMember: Member) { - if (!oldMember) return; - - const logMember = stripObjectToScalars(member, ["user", "roles"]); - - if (member.nick !== oldMember.nick) { - this.guildLogs.log(LogType.MEMBER_NICK_CHANGE, { - member: logMember, - oldNick: oldMember.nick != null ? oldMember.nick : "", - newNick: member.nick != null ? member.nick : "", - }); - } - - if (!isEqual(oldMember.roles, member.roles)) { - const addedRoles = diff(member.roles, oldMember.roles); - const removedRoles = diff(oldMember.roles, member.roles); - let skip = false; - - if ( - addedRoles.length && - removedRoles.length && - this.guildLogs.isLogIgnored(LogType.MEMBER_ROLE_CHANGES, member.id) - ) { - skip = true; - } else if (addedRoles.length && this.guildLogs.isLogIgnored(LogType.MEMBER_ROLE_ADD, member.id)) { - skip = true; - } else if (removedRoles.length && this.guildLogs.isLogIgnored(LogType.MEMBER_ROLE_REMOVE, member.id)) { - skip = true; - } - - if (!skip) { - const relevantAuditLogEntry = await this.findRelevantAuditLogEntry( - ErisConstants.AuditLogActions.MEMBER_ROLE_UPDATE, - member.id, - ); - const mod = relevantAuditLogEntry ? relevantAuditLogEntry.user : new UnknownUser(); - - if (addedRoles.length && removedRoles.length) { - // Roles added *and* removed - this.guildLogs.log( - LogType.MEMBER_ROLE_CHANGES, - { - member: logMember, - addedRoles: addedRoles - .map(roleId => this.guild.roles.get(roleId) || { id: roleId, name: `Unknown (${roleId})` }) - .map(r => r.name) - .join(", "), - removedRoles: removedRoles - .map(roleId => this.guild.roles.get(roleId) || { id: roleId, name: `Unknown (${roleId})` }) - .map(r => r.name) - .join(", "), - mod: stripObjectToScalars(mod), - }, - member.id, - ); - } else if (addedRoles.length) { - // Roles added - this.guildLogs.log( - LogType.MEMBER_ROLE_ADD, - { - member: logMember, - roles: addedRoles - .map(roleId => this.guild.roles.get(roleId) || { id: roleId, name: `Unknown (${roleId})` }) - .map(r => r.name) - .join(", "), - mod: stripObjectToScalars(mod), - }, - member.id, - ); - } else if (removedRoles.length && !addedRoles.length) { - // Roles removed - this.guildLogs.log( - LogType.MEMBER_ROLE_REMOVE, - { - member: logMember, - roles: removedRoles - .map(roleId => this.guild.roles.get(roleId) || { id: roleId, name: `Unknown (${roleId})` }) - .map(r => r.name) - .join(", "), - mod: stripObjectToScalars(mod), - }, - member.id, - ); - } - } - } - } - - @d.event("userUpdate", null, false) - async onUserUpdate(user: User, oldUser: User) { - if (!oldUser) return; - - if (!this.guild.members.has(user.id)) return; - - if (user.username !== oldUser.username || user.discriminator !== oldUser.discriminator) { - this.guildLogs.log(LogType.MEMBER_USERNAME_CHANGE, { - user: stripObjectToScalars(user), - oldName: `${oldUser.username}#${oldUser.discriminator}`, - newName: `${user.username}#${user.discriminator}`, - }); - } - } - - @d.event("channelCreate") - onChannelCreate(channel) { - this.guildLogs.log(LogType.CHANNEL_CREATE, { - channel: stripObjectToScalars(channel), - }); - } - - @d.event("channelDelete") - onChannelDelete(channel) { - this.guildLogs.log(LogType.CHANNEL_DELETE, { - channel: stripObjectToScalars(channel), - }); - } - - @d.event("guildRoleCreate") - onRoleCreate(_, role) { - this.guildLogs.log(LogType.ROLE_CREATE, { - role: stripObjectToScalars(role), - }); - } - - @d.event("guildRoleDelete") - onRoleDelete(_, role) { - this.guildLogs.log(LogType.ROLE_DELETE, { - role: stripObjectToScalars(role), - }); - } - - // Uses events from savesMessages - async onMessageUpdate(savedMessage: SavedMessage, oldSavedMessage: SavedMessage) { - // To log a message update, either the message content or a rich embed has to change - let logUpdate = false; - - const oldEmbedsToCompare = ((oldSavedMessage.data.embeds || []) as Embed[]) - .map(e => cloneDeep(e)) - .filter(e => (e as Embed).type === "rich"); - - const newEmbedsToCompare = ((savedMessage.data.embeds || []) as Embed[]) - .map(e => cloneDeep(e)) - .filter(e => (e as Embed).type === "rich"); - - for (const embed of [...oldEmbedsToCompare, ...newEmbedsToCompare]) { - if (embed.thumbnail) { - delete embed.thumbnail.width; - delete embed.thumbnail.height; - } - - if (embed.image) { - delete embed.image.width; - delete embed.image.height; - } - } - - if ( - oldSavedMessage.data.content !== savedMessage.data.content || - oldEmbedsToCompare.length !== newEmbedsToCompare.length || - JSON.stringify(oldEmbedsToCompare) !== JSON.stringify(newEmbedsToCompare) - ) { - logUpdate = true; - } - - if (!logUpdate) { - return; - } - - const user = await this.resolveUser(savedMessage.user_id); - const channel = this.guild.channels.get(savedMessage.channel_id); - - this.guildLogs.log(LogType.MESSAGE_EDIT, { - user: stripObjectToScalars(user), - channel: stripObjectToScalars(channel), - before: oldSavedMessage, - after: savedMessage, - }); - } - - // Uses events from savesMessages - async onMessageDelete(savedMessage: SavedMessage) { - const user = await this.resolveUser(savedMessage.user_id); - const channel = this.guild.channels.get(savedMessage.channel_id); - - if (user) { - // Replace attachment URLs with media URLs - if (savedMessage.data.attachments) { - for (const attachment of savedMessage.data.attachments as Attachment[]) { - attachment.url = useMediaUrls(attachment.url); - } - } - - this.guildLogs.log( - LogType.MESSAGE_DELETE, - { - user: stripObjectToScalars(user), - channel: stripObjectToScalars(channel), - messageDate: moment(savedMessage.data.timestamp, "x").format(this.getConfig().format.timestamp), - message: savedMessage, - }, - savedMessage.id, - ); - } else { - this.guildLogs.log( - LogType.MESSAGE_DELETE_BARE, - { - messageId: savedMessage.id, - channel: stripObjectToScalars(channel), - }, - savedMessage.id, - ); - } - } - - // Uses events from savesMessages - async onMessageDeleteBulk(savedMessages: SavedMessage[]) { - const channel = this.guild.channels.get(savedMessages[0].channel_id); - const archiveId = await this.archives.createFromSavedMessages(savedMessages, this.guild); - const archiveUrl = this.archives.getUrl(this.knub.getGlobalConfig().url, archiveId); - - this.guildLogs.log( - LogType.MESSAGE_DELETE_BULK, - { - count: savedMessages.length, - channel, - archiveUrl, - }, - savedMessages[0].id, - ); - } - - @d.event("voiceChannelJoin") - onVoiceChannelJoin(member: Member, channel: Channel) { - this.guildLogs.log(LogType.VOICE_CHANNEL_JOIN, { - member: stripObjectToScalars(member, ["user", "roles"]), - channel: stripObjectToScalars(channel), - }); - } - - @d.event("voiceChannelLeave") - onVoiceChannelLeave(member: Member, channel: Channel) { - this.guildLogs.log(LogType.VOICE_CHANNEL_LEAVE, { - member: stripObjectToScalars(member, ["user", "roles"]), - channel: stripObjectToScalars(channel), - }); - } - - @d.event("voiceChannelSwitch") - onVoiceChannelSwitch(member: Member, newChannel: Channel, oldChannel: Channel) { - this.guildLogs.log(LogType.VOICE_CHANNEL_MOVE, { - member: stripObjectToScalars(member, ["user", "roles"]), - oldChannel: stripObjectToScalars(oldChannel), - newChannel: stripObjectToScalars(newChannel), - }); - } -} diff --git a/backend/src/plugins/MessageSaver.ts b/backend/src/plugins/MessageSaver.ts deleted file mode 100644 index 116bbb9d..00000000 --- a/backend/src/plugins/MessageSaver.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { Plugin, decorators as d, IPluginOptions } from "knub"; -import { GuildChannel, Message, TextChannel } from "eris"; -import { GuildSavedMessages } from "../data/GuildSavedMessages"; -import { successMessage } from "../utils"; -import { ZeppelinPluginClass } from "./ZeppelinPluginClass"; -import * as t from "io-ts"; - -const ConfigSchema = t.type({ - can_manage: t.boolean, -}); -type TConfigSchema = t.TypeOf; - -export class MessageSaverPlugin extends ZeppelinPluginClass { - public static pluginName = "message_saver"; - public static showInDocs = false; - public static configSchema = ConfigSchema; - - protected savedMessages: GuildSavedMessages; - - public static getStaticDefaultOptions(): IPluginOptions { - return { - config: { - can_manage: false, - }, - - overrides: [ - { - level: ">=100", - config: { - can_manage: true, - }, - }, - ], - }; - } - - onLoad() { - this.savedMessages = GuildSavedMessages.getGuildInstance(this.guildId); - } - - @d.event("messageCreate", "guild", false) - async onMessageCreate(msg: Message) { - // Only save regular chat messages - if (msg.type !== 0) { - return; - } - - await this.savedMessages.createFromMsg(msg); - } - - @d.event("messageDelete", "guild", false) - async onMessageDelete(msg: Message) { - if (msg.type != null && msg.type !== 0) { - return; - } - - await this.savedMessages.markAsDeleted(msg.id); - } - - @d.event("messageUpdate", "guild", false) - async onMessageUpdate(msg: Message) { - if (msg.type !== 0) { - return; - } - - await this.savedMessages.saveEditFromMsg(msg); - } - - @d.event("messageDeleteBulk", "guild", false) - async onMessageBulkDelete(messages: Message[]) { - const ids = messages.map(m => m.id); - await this.savedMessages.markBulkAsDeleted(ids); - } - - async saveMessagesToDB(channel: GuildChannel & TextChannel, ids: string[]) { - const failed = []; - for (const id of ids) { - const savedMessage = await this.savedMessages.find(id); - if (savedMessage) continue; - - let thisMsg: Message; - - try { - thisMsg = await channel.getMessage(id); - - if (!thisMsg) { - failed.push(id); - continue; - } - - await this.savedMessages.createFromMsg(thisMsg, { is_permanent: true }); - } catch (e) { - failed.push(id); - } - } - - return { - savedCount: ids.length - failed.length, - failed, - }; - } - - @d.command("save_messages_to_db", " ") - @d.permission("can_manage") - async saveMessageCmd(msg: Message, args: { channel: GuildChannel & TextChannel; ids: string[] }) { - await msg.channel.createMessage("Saving specified messages..."); - - const { savedCount, failed } = await this.saveMessagesToDB(args.channel, args.ids); - - if (failed.length) { - this.sendSuccessMessage( - msg.channel, - `Saved ${savedCount} messages. The following messages could not be saved: ${failed.join(", ")}`, - ); - } else { - this.sendSuccessMessage(msg.channel, `Saved ${savedCount} messages!`); - } - } - - @d.command("save_pins_to_db", "") - @d.permission("can_manage") - async savePinsCmd(msg: Message, args: { channel: GuildChannel & TextChannel }) { - await msg.channel.createMessage(`Saving pins from <#${args.channel.id}>...`); - - const pins = await args.channel.getPins(); - const { savedCount, failed } = await this.saveMessagesToDB( - args.channel, - pins.map(m => m.id), - ); - - if (failed.length) { - this.sendSuccessMessage( - msg.channel, - `Saved ${savedCount} messages. The following messages could not be saved: ${failed.join(", ")}`, - ); - } else { - this.sendSuccessMessage(msg.channel, `Saved ${savedCount} messages!`); - } - } -} diff --git a/backend/src/plugins/ModActions.ts b/backend/src/plugins/ModActions.ts deleted file mode 100644 index a7b5d9c1..00000000 --- a/backend/src/plugins/ModActions.ts +++ /dev/null @@ -1,1812 +0,0 @@ -import { decorators as d, IPluginOptions, logger, waitForReaction, waitForReply } from "knub"; -import { Attachment, Constants as ErisConstants, Guild, Member, Message, TextChannel, User } from "eris"; -import humanizeDuration from "humanize-duration"; -import { GuildCases } from "../data/GuildCases"; -import { - asSingleLine, - createChunkedMessage, - disableUserNotificationStrings, - errorMessage, - findRelevantAuditLogEntry, - isDiscordHTTPError, - isDiscordRESTError, - MINUTES, - multiSorter, - notifyUser, - stripObjectToScalars, - tNullable, - trimLines, - ucfirst, - UnknownUser, - UserNotificationMethod, - UserNotificationResult, -} from "../utils"; -import { GuildMutes } from "../data/GuildMutes"; -import { CaseTypes } from "../data/CaseTypes"; -import { GuildLogs } from "../data/GuildLogs"; -import { LogType } from "../data/LogType"; -import { trimPluginDescription, ZeppelinPluginClass } from "./ZeppelinPluginClass"; -import { Case } from "../data/entities/Case"; -import { renderTemplate } from "../templateFormatter"; -import { CaseArgs, CasesPlugin } from "./Cases"; -import { MuteResult, MutesPlugin } from "./Mutes"; -import * as t from "io-ts"; -import { ERRORS, RecoverablePluginError } from "../RecoverablePluginError"; - -const ConfigSchema = t.type({ - dm_on_warn: t.boolean, - dm_on_kick: t.boolean, - dm_on_ban: t.boolean, - message_on_warn: t.boolean, - message_on_kick: t.boolean, - message_on_ban: t.boolean, - message_channel: tNullable(t.string), - warn_message: tNullable(t.string), - kick_message: tNullable(t.string), - ban_message: tNullable(t.string), - alert_on_rejoin: t.boolean, - alert_channel: tNullable(t.string), - warn_notify_enabled: t.boolean, - warn_notify_threshold: t.number, - warn_notify_message: t.string, - ban_delete_message_days: t.number, - can_note: t.boolean, - can_warn: t.boolean, - can_mute: t.boolean, - can_kick: t.boolean, - can_ban: t.boolean, - can_view: t.boolean, - can_addcase: t.boolean, - can_massban: t.boolean, - can_hidecase: t.boolean, - can_act_as_other: t.boolean, -}); -type TConfigSchema = t.TypeOf; - -enum IgnoredEventType { - Ban = 1, - Unban, - Kick, -} - -interface IIgnoredEvent { - type: IgnoredEventType; - userId: string; -} - -export type WarnResult = - | { - status: "failed"; - error: string; - } - | { - status: "success"; - case: Case; - notifyResult: UserNotificationResult; - }; - -export type KickResult = - | { - status: "failed"; - error: string; - } - | { - status: "success"; - case: Case; - notifyResult: UserNotificationResult; - }; - -export type BanResult = - | { - status: "failed"; - error: string; - } - | { - status: "success"; - case: Case; - notifyResult: UserNotificationResult; - }; - -type WarnMemberNotifyRetryCallback = () => boolean | Promise; - -export interface WarnOptions { - caseArgs?: Partial; - contactMethods?: UserNotificationMethod[]; - retryPromptChannel?: TextChannel; -} - -export interface KickOptions { - caseArgs?: Partial; - contactMethods?: UserNotificationMethod[]; -} - -export interface BanOptions { - caseArgs?: Partial; - contactMethods?: UserNotificationMethod[]; - deleteMessageDays?: number; -} - -export class ModActionsPlugin extends ZeppelinPluginClass { - public static pluginName = "mod_actions"; - public static dependencies = ["cases", "mutes"]; - public static configSchema = ConfigSchema; - - public static pluginInfo = { - prettyName: "Mod actions", - description: trimPluginDescription(` - This plugin contains the 'typical' mod actions such as warning, muting, kicking, banning, etc. - `), - }; - - protected mutes: GuildMutes; - protected cases: GuildCases; - protected serverLogs: GuildLogs; - - protected ignoredEvents: IIgnoredEvent[]; - - async onLoad() { - this.mutes = GuildMutes.getGuildInstance(this.guildId); - this.cases = GuildCases.getGuildInstance(this.guildId); - this.serverLogs = new GuildLogs(this.guildId); - - this.ignoredEvents = []; - } - - public static getStaticDefaultOptions(): IPluginOptions { - return { - config: { - dm_on_warn: true, - dm_on_kick: false, - dm_on_ban: false, - message_on_warn: false, - message_on_kick: false, - message_on_ban: false, - message_channel: null, - warn_message: "You have received a warning on the {guildName} server: {reason}", - kick_message: "You have been kicked from the {guildName} server. Reason given: {reason}", - ban_message: "You have been banned from the {guildName} server. Reason given: {reason}", - alert_on_rejoin: false, - alert_channel: null, - warn_notify_enabled: false, - warn_notify_threshold: 5, - warn_notify_message: - "The user already has **{priorWarnings}** warnings!\n Please check their prior cases and assess whether or not to warn anyways.\n Proceed with the warning?", - ban_delete_message_days: 1, - - can_note: false, - can_warn: false, - can_mute: false, - can_kick: false, - can_ban: false, - can_view: false, - can_addcase: false, - can_massban: false, - can_hidecase: false, - can_act_as_other: false, - }, - overrides: [ - { - level: ">=50", - config: { - can_note: true, - can_warn: true, - can_mute: true, - can_kick: true, - can_ban: true, - can_view: true, - can_addcase: true, - }, - }, - { - level: ">=100", - config: { - can_massban: true, - can_hidecase: true, - can_act_as_other: true, - }, - }, - ], - }; - } - - ignoreEvent(type: IgnoredEventType, userId: any, timeout: number = null) { - this.ignoredEvents.push({ type, userId }); - - // Clear after expiry (15sec by default) - setTimeout(() => { - this.clearIgnoredEvent(type, userId); - }, timeout || 1000 * 15); - } - - isEventIgnored(type: IgnoredEventType, userId: any) { - return this.ignoredEvents.some(info => type === info.type && userId === info.userId); - } - - clearIgnoredEvent(type: IgnoredEventType, userId: any) { - this.ignoredEvents.splice( - this.ignoredEvents.findIndex(info => type === info.type && userId === info.userId), - 1, - ); - } - - formatReasonWithAttachments(reason: string, attachments: Attachment[]) { - const attachmentUrls = attachments.map(a => a.url); - return ((reason || "") + " " + attachmentUrls.join(" ")).trim(); - } - - getDefaultContactMethods(type: "warn" | "kick" | "ban"): UserNotificationMethod[] { - const methods: UserNotificationMethod[] = []; - const config = this.getConfig(); - - if (config[`dm_on_${type}`]) { - methods.push({ type: "dm" }); - } - - if (config[`message_on_${type}`] && config.message_channel) { - const channel = this.guild.channels.get(config.message_channel); - if (channel instanceof TextChannel) { - methods.push({ - type: "channel", - channel, - }); - } - } - - return methods; - } - - readContactMethodsFromArgs(args: { - notify?: string; - "notify-channel"?: TextChannel; - }): null | UserNotificationMethod[] { - if (args.notify) { - if (args.notify === "dm") { - return [{ type: "dm" }]; - } else if (args.notify === "channel") { - if (!args["notify-channel"]) { - throw new Error("No `-notify-channel` specified"); - } - - return [{ type: "channel", channel: args["notify-channel"] }]; - } else if (disableUserNotificationStrings.includes(args.notify)) { - return []; - } else { - throw new Error("Unknown contact method"); - } - } - - return null; - } - - async isBanned(userId): Promise { - try { - const bans = (await this.guild.getBans()) as any; - return bans.some(b => b.user.id === userId); - } catch (e) { - if (isDiscordHTTPError(e) && e.code === 500) { - return false; - } - - throw e; - } - } - - async findRelevantAuditLogEntry(actionType: number, userId: string, attempts?: number, attemptDelay?: number) { - try { - return await findRelevantAuditLogEntry(this.guild, actionType, userId, attempts, attemptDelay); - } catch (e) { - if (isDiscordRESTError(e) && e.code === 50013) { - this.serverLogs.log(LogType.BOT_ALERT, { - body: "Missing permissions to read audit log", - }); - } else { - throw e; - } - } - } - - /** - * Add a BAN action automatically when a user is banned. - * Attempts to find the ban's details in the audit log. - */ - @d.event("guildBanAdd") - async onGuildBanAdd(guild: Guild, user: User) { - if (this.isEventIgnored(IgnoredEventType.Ban, user.id)) { - this.clearIgnoredEvent(IgnoredEventType.Ban, user.id); - return; - } - - const relevantAuditLogEntry = await this.findRelevantAuditLogEntry( - ErisConstants.AuditLogActions.MEMBER_BAN_ADD, - user.id, - ); - - const casesPlugin = this.getPlugin("cases"); - if (relevantAuditLogEntry) { - const modId = relevantAuditLogEntry.user.id; - const auditLogId = relevantAuditLogEntry.id; - - casesPlugin.createCase({ - userId: user.id, - modId, - type: CaseTypes.Ban, - auditLogId, - reason: relevantAuditLogEntry.reason, - automatic: true, - }); - } else { - casesPlugin.createCase({ - userId: user.id, - modId: null, - type: CaseTypes.Ban, - }); - } - } - - /** - * Add an UNBAN mod action automatically when a user is unbanned. - * Attempts to find the unban's details in the audit log. - */ - @d.event("guildBanRemove") - async onGuildBanRemove(guild: Guild, user: User) { - if (this.isEventIgnored(IgnoredEventType.Unban, user.id)) { - this.clearIgnoredEvent(IgnoredEventType.Unban, user.id); - return; - } - - const relevantAuditLogEntry = await this.findRelevantAuditLogEntry( - ErisConstants.AuditLogActions.MEMBER_BAN_REMOVE, - user.id, - ); - - const casesPlugin = this.getPlugin("cases"); - if (relevantAuditLogEntry) { - const modId = relevantAuditLogEntry.user.id; - const auditLogId = relevantAuditLogEntry.id; - - casesPlugin.createCase({ - userId: user.id, - modId, - type: CaseTypes.Unban, - auditLogId, - automatic: true, - }); - } else { - casesPlugin.createCase({ - userId: user.id, - modId: null, - type: CaseTypes.Unban, - automatic: true, - }); - } - } - - /** - * Show an alert if a member with prior notes joins the server - */ - @d.event("guildMemberAdd") - async onGuildMemberAdd(_, member: Member) { - const config = this.getConfig(); - - if (!config.alert_on_rejoin) return; - - const alertChannelId = config.alert_channel; - if (!alertChannelId) return; - - const actions = await this.cases.getByUserId(member.id); - - if (actions.length) { - const alertChannel: any = this.guild.channels.get(alertChannelId); - alertChannel.send( - `<@!${member.id}> (${member.user.username}#${member.user.discriminator} \`${member.id}\`) joined with ${actions.length} prior record(s)`, - ); - } - } - - @d.event("guildMemberRemove") - async onGuildMemberRemove(_, member: Member) { - if (this.isEventIgnored(IgnoredEventType.Kick, member.id)) { - this.clearIgnoredEvent(IgnoredEventType.Kick, member.id); - return; - } - - const kickAuditLogEntry = await this.findRelevantAuditLogEntry( - ErisConstants.AuditLogActions.MEMBER_KICK, - member.id, - ); - - if (kickAuditLogEntry) { - const existingCaseForThisEntry = await this.cases.findByAuditLogId(kickAuditLogEntry.id); - if (existingCaseForThisEntry) { - logger.warn( - `Tried to create duplicate case for audit log entry ${kickAuditLogEntry.id}, existing case id ${existingCaseForThisEntry.id}`, - ); - } else { - const casesPlugin = this.getPlugin("cases"); - casesPlugin.createCase({ - userId: member.id, - modId: kickAuditLogEntry.user.id, - type: CaseTypes.Kick, - auditLogId: kickAuditLogEntry.id, - reason: kickAuditLogEntry.reason, - automatic: true, - }); - } - - this.serverLogs.log(LogType.MEMBER_KICK, { - user: stripObjectToScalars(member.user), - mod: stripObjectToScalars(kickAuditLogEntry.user), - }); - } - } - - /** - * Kick the specified server member. Generates a case. - */ - async kickMember(member: Member, reason: string = null, kickOptions: KickOptions = {}): Promise { - const config = this.getConfig(); - - // Attempt to message the user *before* kicking them, as doing it after may not be possible - let notifyResult: UserNotificationResult = { method: null, success: true }; - if (reason) { - const kickMessage = await renderTemplate(config.kick_message, { - guildName: this.guild.name, - reason, - }); - - const contactMethods = kickOptions?.contactMethods - ? kickOptions.contactMethods - : this.getDefaultContactMethods("kick"); - notifyResult = await notifyUser(member.user, kickMessage, contactMethods); - } - - // Kick the user - this.serverLogs.ignoreLog(LogType.MEMBER_KICK, member.id); - this.ignoreEvent(IgnoredEventType.Kick, member.id); - try { - await member.kick(); - } catch (e) { - return { - status: "failed", - error: e.message, - }; - } - - // Create a case for this action - const casesPlugin = this.getPlugin("cases"); - const createdCase = await casesPlugin.createCase({ - ...(kickOptions.caseArgs || {}), - userId: member.id, - modId: kickOptions.caseArgs?.modId, - type: CaseTypes.Kick, - reason, - noteDetails: notifyResult.text ? [ucfirst(notifyResult.text)] : [], - }); - - // Log the action - const mod = await this.resolveUser(kickOptions.caseArgs?.modId); - this.serverLogs.log(LogType.MEMBER_KICK, { - mod: stripObjectToScalars(mod), - user: stripObjectToScalars(member.user), - reason, - }); - - return { - status: "success", - case: createdCase, - notifyResult, - }; - } - - /** - * Ban the specified user id, whether or not they're actually on the server at the time. Generates a case. - */ - async banUserId(userId: string, reason: string = null, banOptions: BanOptions = {}): Promise { - const config = this.getConfig(); - const user = await this.resolveUser(userId); - - // Attempt to message the user *before* banning them, as doing it after may not be possible - let notifyResult: UserNotificationResult = { method: null, success: true }; - if (reason && user instanceof User) { - const banMessage = await renderTemplate(config.ban_message, { - guildName: this.guild.name, - reason, - }); - - const contactMethods = banOptions?.contactMethods - ? banOptions.contactMethods - : this.getDefaultContactMethods("ban"); - notifyResult = await notifyUser(user, banMessage, contactMethods); - } - - // (Try to) ban the user - this.serverLogs.ignoreLog(LogType.MEMBER_BAN, userId); - this.ignoreEvent(IgnoredEventType.Ban, userId); - try { - const deleteMessageDays = Math.min(30, Math.max(0, banOptions.deleteMessageDays ?? 1)); - await this.guild.banMember(userId, deleteMessageDays); - } catch (e) { - return { - status: "failed", - error: e.message, - }; - } - - // Create a case for this action - const casesPlugin = this.getPlugin("cases"); - const createdCase = await casesPlugin.createCase({ - ...(banOptions.caseArgs || {}), - userId, - modId: banOptions.caseArgs?.modId, - type: CaseTypes.Ban, - reason, - noteDetails: notifyResult.text ? [ucfirst(notifyResult.text)] : [], - }); - - // Log the action - const mod = await this.resolveUser(banOptions.caseArgs?.modId); - this.serverLogs.log(LogType.MEMBER_BAN, { - mod: stripObjectToScalars(mod), - user: stripObjectToScalars(user), - reason, - }); - - return { - status: "success", - case: createdCase, - notifyResult, - }; - } - - @d.command("update", " [note:string$]", { - overloads: ["[note:string$]"], - extra: { - info: { - description: - "Update the specified case (or, if case number is omitted, your latest case) by adding more notes/details to it", - }, - }, - }) - @d.permission("can_note") - async updateCmd(msg: Message, args: { caseNumber?: number; note?: string }) { - let theCase: Case; - if (args.caseNumber != null) { - theCase = await this.cases.findByCaseNumber(args.caseNumber); - } else { - theCase = await this.cases.findLatestByModId(msg.author.id); - } - - if (!theCase) { - this.sendErrorMessage(msg.channel, "Case not found"); - return; - } - - if (!args.note && msg.attachments.length === 0) { - this.sendErrorMessage(msg.channel, "Text or attachment required"); - return; - } - - const note = this.formatReasonWithAttachments(args.note, msg.attachments); - - const casesPlugin = this.getPlugin("cases"); - await casesPlugin.createCaseNote({ - caseId: theCase.id, - modId: msg.author.id, - body: note, - }); - - this.serverLogs.log(LogType.CASE_UPDATE, { - mod: msg.author, - caseNumber: theCase.case_number, - caseType: CaseTypes[theCase.type], - note, - }); - - this.sendSuccessMessage(msg.channel, `Case \`#${theCase.case_number}\` updated`); - } - - @d.command("note", " ", { - extra: { - info: { - description: "Add a note to the specified user", - }, - }, - }) - @d.permission("can_note") - async noteCmd(msg: Message, args: { user: string; note: string }) { - const user = await this.resolveUser(args.user); - if (!user) return this.sendErrorMessage(msg.channel, `User not found`); - - const userName = `${user.username}#${user.discriminator}`; - const reason = this.formatReasonWithAttachments(args.note, msg.attachments); - - const casesPlugin = this.getPlugin("cases"); - const createdCase = await casesPlugin.createCase({ - userId: user.id, - modId: msg.author.id, - type: CaseTypes.Note, - reason, - }); - - this.serverLogs.log(LogType.MEMBER_NOTE, { - mod: stripObjectToScalars(msg.author), - user: stripObjectToScalars(user, ["user", "roles"]), - reason, - }); - - this.sendSuccessMessage(msg.channel, `Note added on **${userName}** (Case #${createdCase.case_number})`); - } - - @d.command("warn", " ", { - options: [ - { name: "mod", type: "member" }, - { name: "notify", type: "string" }, - { name: "notify-channel", type: "channel" }, - ], - extra: { - info: { - description: "Send a warning to the specified user", - }, - }, - }) - @d.permission("can_warn") - async warnCmd( - msg: Message, - args: { user: string; reason: string; mod?: Member; notify?: string; "notify-channel"?: TextChannel }, - ) { - const user = await this.resolveUser(args.user); - if (!user) return this.sendErrorMessage(msg.channel, `User not found`); - - const memberToWarn = await this.getMember(user.id); - - if (!memberToWarn) { - const isBanned = await this.isBanned(user.id); - if (isBanned) { - this.sendErrorMessage(msg.channel, `User is banned`); - } else { - this.sendErrorMessage(msg.channel, `User not found on the server`); - } - - return; - } - - // Make sure we're allowed to warn this member - if (!this.canActOn(msg.member, memberToWarn)) { - msg.channel.createMessage(errorMessage("Cannot warn: insufficient permissions")); - return; - } - - // The moderator who did the action is the message author or, if used, the specified -mod - let mod = msg.member; - if (args.mod) { - if (!this.hasPermission("can_act_as_other", { message: msg })) { - msg.channel.createMessage(errorMessage("No permission for -mod")); - return; - } - - mod = args.mod; - } - - const config = this.getConfig(); - const reason = this.formatReasonWithAttachments(args.reason, msg.attachments); - - const casesPlugin = this.getPlugin("cases"); - const priorWarnAmount = await casesPlugin.getCaseTypeAmountForUserId(memberToWarn.id, CaseTypes.Warn); - if (config.warn_notify_enabled && priorWarnAmount >= config.warn_notify_threshold) { - const tooManyWarningsMsg = await msg.channel.createMessage( - config.warn_notify_message.replace("{priorWarnings}", `${priorWarnAmount}`), - ); - - const reply = await waitForReaction(this.bot, tooManyWarningsMsg, ["✅", "❌"]); - tooManyWarningsMsg.delete(); - if (!reply || reply.name === "❌") { - msg.channel.createMessage(errorMessage("Warn cancelled by moderator")); - return; - } - } - - let contactMethods; - try { - contactMethods = this.readContactMethodsFromArgs(args); - } catch (e) { - this.sendErrorMessage(msg.channel, e.message); - return; - } - - const warnResult = await this.warnMember(memberToWarn, reason, { - contactMethods, - caseArgs: { - modId: mod.id, - ppId: mod.id !== msg.author.id ? msg.author.id : null, - reason, - }, - retryPromptChannel: msg.channel as TextChannel, - }); - - if (warnResult.status === "failed") { - this.sendErrorMessage(msg.channel, "Failed to warn user"); - return; - } - - const messageResultText = warnResult.notifyResult.text ? ` (${warnResult.notifyResult.text})` : ""; - - this.sendSuccessMessage( - msg.channel, - `Warned **${memberToWarn.user.username}#${memberToWarn.user.discriminator}** (Case #${warnResult.case.case_number})${messageResultText}`, - ); - } - - async warnMember(member: Member, reason: string, warnOptions: WarnOptions = {}): Promise { - const config = this.getConfig(); - - const warnMessage = config.warn_message.replace("{guildName}", this.guild.name).replace("{reason}", reason); - const contactMethods = warnOptions?.contactMethods - ? warnOptions.contactMethods - : this.getDefaultContactMethods("warn"); - const notifyResult = await notifyUser(member.user, warnMessage, contactMethods); - - if (!notifyResult.success) { - if (warnOptions.retryPromptChannel && this.guild.channels.has(warnOptions.retryPromptChannel.id)) { - const failedMsg = await warnOptions.retryPromptChannel.createMessage( - "Failed to message the user. Log the warning anyway?", - ); - const reply = await waitForReaction(this.bot, failedMsg, ["✅", "❌"]); - failedMsg.delete(); - if (!reply || reply.name === "❌") { - return { - status: "failed", - error: "Failed to message user", - }; - } - } else { - return { - status: "failed", - error: "Failed to message user", - }; - } - } - - const casesPlugin = this.getPlugin("cases"); - const createdCase = await casesPlugin.createCase({ - ...(warnOptions.caseArgs || {}), - userId: member.id, - modId: warnOptions.caseArgs?.modId, - type: CaseTypes.Warn, - reason, - noteDetails: notifyResult.text ? [ucfirst(notifyResult.text)] : [], - }); - - const mod = await this.resolveUser(warnOptions.caseArgs?.modId); - this.serverLogs.log(LogType.MEMBER_WARN, { - mod: stripObjectToScalars(mod), - member: stripObjectToScalars(member, ["user", "roles"]), - reason, - }); - - return { - status: "success", - case: createdCase, - notifyResult, - }; - } - - /** - * The actual function run by both !mute and !forcemute. - * The only difference between the two commands is in target member validation. - */ - async actualMuteCmd( - user: User | UnknownUser, - msg: Message, - args: { time?: number; reason?: string; mod: Member; notify?: string; "notify-channel"?: TextChannel }, - ) { - // The moderator who did the action is the message author or, if used, the specified -mod - let mod = msg.member; - let pp = null; - - if (args.mod) { - if (!this.hasPermission("can_act_as_other", { message: msg })) { - msg.channel.createMessage(errorMessage("No permission for -mod")); - return; - } - - mod = args.mod; - pp = msg.author; - } - - const timeUntilUnmute = args.time && humanizeDuration(args.time); - const reason = this.formatReasonWithAttachments(args.reason, msg.attachments); - - let muteResult: MuteResult; - const mutesPlugin = this.getPlugin("mutes"); - - let contactMethods; - try { - contactMethods = this.readContactMethodsFromArgs(args); - } catch (e) { - this.sendErrorMessage(msg.channel, e.message); - return; - } - - try { - muteResult = await mutesPlugin.muteUser(user.id, args.time, reason, { - contactMethods, - caseArgs: { - modId: mod.id, - ppId: pp && pp.id, - }, - }); - } catch (e) { - if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) { - this.sendErrorMessage(msg.channel, "Could not mute the user: no mute role set in config"); - } else if (isDiscordRESTError(e) && e.code === 10007) { - this.sendErrorMessage(msg.channel, "Could not mute the user: unknown member"); - } else { - logger.error(`Failed to mute user ${user.id}: ${e.stack}`); - if (user.id == null) { - console.trace("[DEBUG] Null user.id for mute"); - } - this.sendErrorMessage(msg.channel, "Could not mute the user"); - } - - return; - } - - // Confirm the action to the moderator - let response; - if (args.time) { - if (muteResult.updatedExistingMute) { - response = asSingleLine(` - Updated **${user.username}#${user.discriminator}**'s - mute to ${timeUntilUnmute} (Case #${muteResult.case.case_number}) - `); - } else { - response = asSingleLine(` - Muted **${user.username}#${user.discriminator}** - for ${timeUntilUnmute} (Case #${muteResult.case.case_number}) - `); - } - } else { - if (muteResult.updatedExistingMute) { - response = asSingleLine(` - Updated **${user.username}#${user.discriminator}**'s - mute to indefinite (Case #${muteResult.case.case_number}) - `); - } else { - response = asSingleLine(` - Muted **${user.username}#${user.discriminator}** - indefinitely (Case #${muteResult.case.case_number}) - `); - } - } - - if (muteResult.notifyResult.text) response += ` (${muteResult.notifyResult.text})`; - this.sendSuccessMessage(msg.channel, response); - } - - @d.command("mute", " ", { - overloads: [" ", " [reason:string$]"], - options: [ - { name: "mod", type: "member" }, - { name: "notify", type: "string" }, - { name: "notify-channel", type: "channel" }, - ], - extra: { - info: { - description: "Mute the specified member", - }, - }, - }) - @d.permission("can_mute") - async muteCmd( - msg: Message, - args: { - user: string; - time?: number; - reason?: string; - mod: Member; - notify?: string; - "notify-channel"?: TextChannel; - }, - ) { - const user = await this.resolveUser(args.user); - if (!user) return this.sendErrorMessage(msg.channel, `User not found`); - - const memberToMute = await this.getMember(user.id); - - if (!memberToMute) { - const isBanned = await this.isBanned(user.id); - const prefix = this.guildConfig.prefix; - if (isBanned) { - this.sendErrorMessage( - msg.channel, - `User is banned. Use \`${prefix}forcemute\` if you want to mute them anyway.`, - ); - } else { - this.sendErrorMessage( - msg.channel, - `User is not on the server. Use \`${prefix}forcemute\` if you want to mute them anyway.`, - ); - } - - return; - } - - // Make sure we're allowed to mute this member - if (memberToMute && !this.canActOn(msg.member, memberToMute)) { - this.sendErrorMessage(msg.channel, "Cannot mute: insufficient permissions"); - return; - } - - this.actualMuteCmd(user, msg, args); - } - - @d.command("forcemute", " ", { - overloads: [" ", " [reason:string$]"], - options: [ - { name: "mod", type: "member" }, - { name: "notify", type: "string" }, - { name: "notify-channel", type: "channel" }, - ], - extra: { - info: { - description: "Force-mute the specified user, even if they're not on the server", - }, - }, - }) - @d.permission("can_mute") - async forcemuteCmd( - msg: Message, - args: { - user: string; - time?: number; - reason?: string; - mod: Member; - notify?: string; - "notify-channel"?: TextChannel; - }, - ) { - const user = await this.resolveUser(args.user); - if (!user) return this.sendErrorMessage(msg.channel, `User not found`); - - const memberToMute = await this.getMember(user.id); - - // Make sure we're allowed to mute this user - if (memberToMute && !this.canActOn(msg.member, memberToMute)) { - this.sendErrorMessage(msg.channel, "Cannot mute: insufficient permissions"); - return; - } - - this.actualMuteCmd(user, msg, args); - } - - /** - * The actual function run by both !unmute and !forceunmute. - * The only difference between the two commands is in target member validation. - */ - async actualUnmuteCmd( - user: User | UnknownUser, - msg: Message, - args: { time?: number; reason?: string; mod?: Member }, - ) { - // The moderator who did the action is the message author or, if used, the specified -mod - let mod = msg.author; - let pp = null; - - if (args.mod) { - if (!this.hasPermission("can_act_as_other", { message: msg })) { - this.sendErrorMessage(msg.channel, "No permission for -mod"); - return; - } - - mod = args.mod.user; - pp = msg.author; - } - - const reason = this.formatReasonWithAttachments(args.reason, msg.attachments); - - const mutesPlugin = this.getPlugin("mutes"); - const result = await mutesPlugin.unmuteUser(user.id, args.time, { - modId: mod.id, - ppId: pp && pp.id, - reason, - }); - - // Confirm the action to the moderator - if (args.time) { - const timeUntilUnmute = args.time && humanizeDuration(args.time); - this.sendSuccessMessage( - msg.channel, - asSingleLine(` - Unmuting **${user.username}#${user.discriminator}** - in ${timeUntilUnmute} (Case #${result.case.case_number}) - `), - ); - } else { - this.sendSuccessMessage( - msg.channel, - asSingleLine(` - Unmuted **${user.username}#${user.discriminator}** - (Case #${result.case.case_number}) - `), - ); - } - } - - @d.command("unmute", " ", { - overloads: [" ", " [reason:string$]"], - options: [{ name: "mod", type: "member" }], - extra: { - info: { - description: "Unmute the specified member", - }, - }, - }) - @d.permission("can_mute") - async unmuteCmd(msg: Message, args: { user: string; time?: number; reason?: string; mod?: Member }) { - const user = await this.resolveUser(args.user); - if (!user) return this.sendErrorMessage(msg.channel, `User not found`); - const memberToUnmute = await this.getMember(user.id); - const mutesPlugin = this.getPlugin("mutes"); - const hasMuteRole = memberToUnmute && mutesPlugin.hasMutedRole(memberToUnmute); - - // Check if they're muted in the first place - if (!(await this.mutes.isMuted(args.user)) && !hasMuteRole) { - this.sendErrorMessage(msg.channel, "Cannot unmute: member is not muted"); - return; - } - - if (!memberToUnmute) { - const isBanned = await this.isBanned(user.id); - const prefix = this.guildConfig.prefix; - if (isBanned) { - this.sendErrorMessage(msg.channel, `User is banned. Use \`${prefix}forceunmute\` to unmute them anyway.`); - } else { - this.sendErrorMessage( - msg.channel, - `User is not on the server. Use \`${prefix}forceunmute\` to unmute them anyway.`, - ); - } - - return; - } - - // Make sure we're allowed to unmute this member - if (memberToUnmute && !this.canActOn(msg.member, memberToUnmute)) { - this.sendErrorMessage(msg.channel, "Cannot unmute: insufficient permissions"); - return; - } - - this.actualUnmuteCmd(user, msg, args); - } - - @d.command("forceunmute", " ", { - overloads: [" ", " [reason:string$]"], - options: [{ name: "mod", type: "member" }], - extra: { - info: { - description: "Force-unmute the specified user, even if they're not on the server", - }, - }, - }) - @d.permission("can_mute") - async forceunmuteCmd(msg: Message, args: { user: string; time?: number; reason?: string; mod?: Member }) { - const user = await this.resolveUser(args.user); - if (!user) return this.sendErrorMessage(msg.channel, `User not found`); - - // Check if they're muted in the first place - if (!(await this.mutes.isMuted(user.id))) { - this.sendErrorMessage(msg.channel, "Cannot unmute: member is not muted"); - return; - } - - // Find the server member to unmute - const memberToUnmute = await this.getMember(user.id); - - // Make sure we're allowed to unmute this member - if (memberToUnmute && !this.canActOn(msg.member, memberToUnmute)) { - this.sendErrorMessage(msg.channel, "Cannot unmute: insufficient permissions"); - return; - } - - this.actualUnmuteCmd(user, msg, args); - } - - @d.command("kick", " [reason:string$]", { - options: [ - { name: "mod", type: "member" }, - { name: "notify", type: "string" }, - { name: "notify-channel", type: "channel" }, - { name: "clean", isSwitch: true }, - ], - extra: { - info: { - description: "Kick the specified member", - }, - }, - }) - @d.permission("can_kick") - async kickCmd( - msg, - args: { - user: string; - reason: string; - mod: Member; - notify?: string; - "notify-channel"?: TextChannel; - clean?: boolean; - }, - ) { - const user = await this.resolveUser(args.user); - if (!user) return this.sendErrorMessage(msg.channel, `User not found`); - - const memberToKick = await this.getMember(user.id); - - if (!memberToKick) { - const isBanned = await this.isBanned(user.id); - if (isBanned) { - this.sendErrorMessage(msg.channel, `User is banned`); - } else { - this.sendErrorMessage(msg.channel, `User not found on the server`); - } - - return; - } - - // Make sure we're allowed to kick this member - if (!this.canActOn(msg.member, memberToKick)) { - this.sendErrorMessage(msg.channel, "Cannot kick: insufficient permissions"); - return; - } - - // The moderator who did the action is the message author or, if used, the specified -mod - let mod = msg.member; - if (args.mod) { - if (!this.hasPermission("can_act_as_other", { message: msg })) { - this.sendErrorMessage(msg.channel, "No permission for -mod"); - return; - } - - mod = args.mod; - } - - let contactMethods; - try { - contactMethods = this.readContactMethodsFromArgs(args); - } catch (e) { - this.sendErrorMessage(msg.channel, e.message); - return; - } - - const reason = this.formatReasonWithAttachments(args.reason, msg.attachments); - - const kickResult = await this.kickMember(memberToKick, reason, { - contactMethods, - caseArgs: { - modId: mod.id, - ppId: mod.id !== msg.author.id ? msg.author.id : null, - }, - }); - - if (args.clean) { - this.serverLogs.ignoreLog(LogType.MEMBER_BAN, memberToKick.id); - this.ignoreEvent(IgnoredEventType.Ban, memberToKick.id); - - try { - await memberToKick.ban(1); - } catch (e) { - this.sendErrorMessage(msg.channel, "Failed to ban the user to clean messages (-clean)"); - } - - this.serverLogs.ignoreLog(LogType.MEMBER_UNBAN, memberToKick.id); - this.ignoreEvent(IgnoredEventType.Unban, memberToKick.id); - - try { - await this.guild.unbanMember(memberToKick.id); - } catch (e) { - this.sendErrorMessage(msg.channel, "Failed to unban the user after banning them (-clean)"); - } - } - - if (kickResult.status === "failed") { - msg.channel.createMessage(errorMessage(`Failed to kick user`)); - return; - } - - // Confirm the action to the moderator - let response = `Kicked **${memberToKick.user.username}#${memberToKick.user.discriminator}** (Case #${kickResult.case.case_number})`; - - if (kickResult.notifyResult.text) response += ` (${kickResult.notifyResult.text})`; - this.sendSuccessMessage(msg.channel, response); - } - - @d.command("ban", " [reason:string$]", { - options: [ - { name: "mod", type: "member" }, - { name: "notify", type: "string" }, - { name: "notify-channel", type: "channel" }, - { name: "delete-days", type: "number", shortcut: "d" }, - ], - extra: { - info: { - description: "Ban the specified member", - }, - }, - }) - @d.permission("can_ban") - async banCmd( - msg, - args: { - user: string; - reason?: string; - mod?: Member; - notify?: string; - "notify-channel"?: TextChannel; - "delete-days"?: number; - }, - ) { - const user = await this.resolveUser(args.user); - if (!user) return this.sendErrorMessage(msg.channel, `User not found`); - - const memberToBan = await this.getMember(user.id); - - if (!memberToBan) { - const isBanned = await this.isBanned(user.id); - if (isBanned) { - this.sendErrorMessage(msg.channel, `User is already banned`); - } else { - this.sendErrorMessage(msg.channel, `User not found on the server`); - } - - return; - } - - // Make sure we're allowed to ban this member - if (!this.canActOn(msg.member, memberToBan)) { - this.sendErrorMessage(msg.channel, "Cannot ban: insufficient permissions"); - return; - } - - // The moderator who did the action is the message author or, if used, the specified -mod - let mod = msg.member; - if (args.mod) { - if (!this.hasPermission("can_act_as_other", { message: msg })) { - this.sendErrorMessage(msg.channel, "No permission for -mod"); - return; - } - - mod = args.mod; - } - - let contactMethods; - try { - contactMethods = this.readContactMethodsFromArgs(args); - } catch (e) { - this.sendErrorMessage(msg.channel, e.message); - return; - } - - const deleteMessageDays = args["delete-days"] ?? this.getConfigForMsg(msg).ban_delete_message_days; - const reason = this.formatReasonWithAttachments(args.reason, msg.attachments); - const banResult = await this.banUserId(memberToBan.id, reason, { - contactMethods, - caseArgs: { - modId: mod.id, - ppId: mod.id !== msg.author.id ? msg.author.id : null, - }, - deleteMessageDays, - }); - - if (banResult.status === "failed") { - msg.channel.createMessage(errorMessage(`Failed to ban member`)); - return; - } - - // Confirm the action to the moderator - let response = `Banned **${memberToBan.user.username}#${memberToBan.user.discriminator}** (Case #${banResult.case.case_number})`; - - if (banResult.notifyResult.text) response += ` (${banResult.notifyResult.text})`; - this.sendSuccessMessage(msg.channel, response); - } - - @d.command("softban", " [reason:string$]", { - options: [ - { name: "mod", type: "member" }, - { name: "notify", type: "string" }, - { name: "notify-channel", type: "channel" }, - ], - extra: { - info: { - description: - '"Softban" the specified user by banning and immediately unbanning them. Effectively a kick with message deletions.' + - "This command will be removed in the future, please use kick with the `-clean` argument instead", - }, - }, - }) - @d.permission("can_kick") - async softbanCmd( - msg: Message, - args: { - user: string; - reason: string; - mod?: Member; - notify?: string; - "notify-channel"?: TextChannel; - }, - ) { - await this.kickCmd(msg, { - user: args.user, - mod: args.mod ? args.mod : msg.member, - reason: args.reason, - clean: true, - notify: args.notify, - "notify-channel": args["notify-channel"], - }); - - await msg.channel.createMessage( - "Softban will be removed in the future - please use the kick command with the `-clean` argument instead!", - ); - } - - @d.command("unban", " [reason:string$]", { - options: [{ name: "mod", type: "member" }], - extra: { - info: { - description: "Unban the specified member", - }, - }, - }) - @d.permission("can_ban") - async unbanCmd(msg: Message, args: { user: string; reason: string; mod: Member }) { - const user = await this.resolveUser(args.user); - if (!user) return this.sendErrorMessage(msg.channel, `User not found`); - - // The moderator who did the action is the message author or, if used, the specified -mod - let mod = msg.member; - if (args.mod) { - if (!this.hasPermission("can_act_as_other", { message: msg })) { - this.sendErrorMessage(msg.channel, "No permission for -mod"); - return; - } - - mod = args.mod; - } - - this.serverLogs.ignoreLog(LogType.MEMBER_UNBAN, user.id); - - try { - this.ignoreEvent(IgnoredEventType.Unban, user.id); - await this.guild.unbanMember(user.id); - } catch (e) { - this.sendErrorMessage(msg.channel, "Failed to unban member; are you sure they're banned?"); - return; - } - - const reason = this.formatReasonWithAttachments(args.reason, msg.attachments); - - // Create a case - const casesPlugin = this.getPlugin("cases"); - const createdCase = await casesPlugin.createCase({ - userId: user.id, - modId: mod.id, - type: CaseTypes.Unban, - reason, - ppId: mod.id !== msg.author.id ? msg.author.id : null, - }); - - // Confirm the action - this.sendSuccessMessage(msg.channel, `Member unbanned (Case #${createdCase.case_number})`); - - // Log the action - this.serverLogs.log(LogType.MEMBER_UNBAN, { - mod: stripObjectToScalars(mod.user), - userId: user.id, - reason, - }); - } - - @d.command("forceban", " [reason:string$]", { - options: [{ name: "mod", type: "member" }], - extra: { - info: { - description: "Force-ban the specified user, even if they aren't on the server", - }, - }, - }) - @d.permission("can_ban") - async forcebanCmd(msg: Message, args: { user: string; reason?: string; mod?: Member }) { - const user = await this.resolveUser(args.user); - if (!user) return this.sendErrorMessage(msg.channel, `User not found`); - - // If the user exists as a guild member, make sure we can act on them first - const member = await this.getMember(user.id); - if (member && !this.canActOn(msg.member, member)) { - this.sendErrorMessage(msg.channel, "Cannot forceban this user: insufficient permissions"); - return; - } - - // Make sure the user isn't already banned - const isBanned = await this.isBanned(user.id); - if (isBanned) { - this.sendErrorMessage(msg.channel, `User is already banned`); - return; - } - - // The moderator who did the action is the message author or, if used, the specified -mod - let mod = msg.member; - if (args.mod) { - if (!this.hasPermission("can_act_as_other", { message: msg })) { - this.sendErrorMessage(msg.channel, "No permission for -mod"); - return; - } - - mod = args.mod; - } - - const reason = this.formatReasonWithAttachments(args.reason, msg.attachments); - - this.ignoreEvent(IgnoredEventType.Ban, user.id); - this.serverLogs.ignoreLog(LogType.MEMBER_BAN, user.id); - - try { - await this.guild.banMember(user.id, 1); - } catch (e) { - this.sendErrorMessage(msg.channel, "Failed to forceban member"); - return; - } - - // Create a case - const casesPlugin = this.getPlugin("cases"); - const createdCase = await casesPlugin.createCase({ - userId: user.id, - modId: mod.id, - type: CaseTypes.Ban, - reason, - ppId: mod.id !== msg.author.id ? msg.author.id : null, - }); - - // Confirm the action - this.sendSuccessMessage(msg.channel, `Member forcebanned (Case #${createdCase.case_number})`); - - // Log the action - this.serverLogs.log(LogType.MEMBER_FORCEBAN, { - mod: stripObjectToScalars(mod.user), - userId: user.id, - reason, - }); - } - - @d.command("massban", "", { - extra: { - info: { - description: "Mass-ban a list of user IDs", - }, - }, - }) - @d.permission("can_massban") - async massbanCmd(msg: Message, args: { userIds: string[] }) { - // Limit to 100 users at once (arbitrary?) - if (args.userIds.length > 100) { - this.sendErrorMessage(msg.channel, `Can only massban max 100 users at once`); - return; - } - - // Ask for ban reason (cleaner this way instead of trying to cram it into the args) - msg.channel.createMessage("Ban reason? `cancel` to cancel"); - const banReasonReply = await waitForReply(this.bot, msg.channel as TextChannel, msg.author.id); - if (!banReasonReply || !banReasonReply.content || banReasonReply.content.toLowerCase().trim() === "cancel") { - this.sendErrorMessage(msg.channel, "Cancelled"); - return; - } - - const banReason = this.formatReasonWithAttachments(banReasonReply.content, msg.attachments); - - // Verify we can act on each of the users specified - for (const userId of args.userIds) { - const member = this.guild.members.get(userId); // TODO: Get members on demand? - if (member && !this.canActOn(msg.member, member)) { - this.sendErrorMessage(msg.channel, "Cannot massban one or more users: insufficient permissions"); - return; - } - } - - // Ignore automatic ban cases and logs for these users - // We'll create our own cases below and post a single "mass banned" log instead - args.userIds.forEach(userId => { - // Use longer timeouts since this can take a while - this.ignoreEvent(IgnoredEventType.Ban, userId, 120 * 1000); - this.serverLogs.ignoreLog(LogType.MEMBER_BAN, userId, 120 * 1000); - }); - - // Show a loading indicator since this can take a while - const loadingMsg = await msg.channel.createMessage("Banning..."); - - // Ban each user and count failed bans (if any) - const failedBans = []; - const casesPlugin = this.getPlugin("cases"); - for (const userId of args.userIds) { - try { - await this.guild.banMember(userId, 1); - - await casesPlugin.createCase({ - userId, - modId: msg.author.id, - type: CaseTypes.Ban, - reason: `Mass ban: ${banReason}`, - postInCaseLogOverride: false, - }); - } catch (e) { - failedBans.push(userId); - } - } - - // Clear loading indicator - loadingMsg.delete(); - - const successfulBanCount = args.userIds.length - failedBans.length; - if (successfulBanCount === 0) { - // All bans failed - don't create a log entry and notify the user - this.sendErrorMessage(msg.channel, "All bans failed. Make sure the IDs are valid."); - } else { - // Some or all bans were successful. Create a log entry for the mass ban and notify the user. - this.serverLogs.log(LogType.MASSBAN, { - mod: stripObjectToScalars(msg.author), - count: successfulBanCount, - reason: banReason, - }); - - if (failedBans.length) { - this.sendSuccessMessage( - msg.channel, - `Banned ${successfulBanCount} users, ${failedBans.length} failed: ${failedBans.join(" ")}`, - ); - } else { - this.sendSuccessMessage(msg.channel, `Banned ${successfulBanCount} users successfully`); - } - } - } - - @d.command("addcase", " [reason:string$]", { - options: [{ name: "mod", type: "member" }], - extra: { - info: { - description: "Add an arbitrary case to the specified user without taking any action", - }, - }, - }) - @d.permission("can_addcase") - async addcaseCmd(msg: Message, args: { type: string; user: string; reason?: string; mod?: Member }) { - const user = await this.resolveUser(args.user); - if (!user) return this.sendErrorMessage(msg.channel, `User not found`); - - // If the user exists as a guild member, make sure we can act on them first - const member = await this.getMember(user.id); - if (member && !this.canActOn(msg.member, member)) { - this.sendErrorMessage(msg.channel, "Cannot add case on this user: insufficient permissions"); - return; - } - - // The moderator who did the action is the message author or, if used, the specified -mod - let mod = msg.member; - if (args.mod) { - if (!this.hasPermission("can_act_as_other", { message: msg })) { - this.sendErrorMessage(msg.channel, "No permission for -mod"); - return; - } - - mod = args.mod; - } - - // Verify the case type is valid - const type: string = args.type[0].toUpperCase() + args.type.slice(1).toLowerCase(); - if (!CaseTypes[type]) { - this.sendErrorMessage(msg.channel, "Cannot add case: invalid case type"); - return; - } - - const reason = this.formatReasonWithAttachments(args.reason, msg.attachments); - - // Create the case - const casesPlugin = this.getPlugin("cases"); - const theCase: Case = await casesPlugin.createCase({ - userId: user.id, - modId: mod.id, - type: CaseTypes[type], - reason, - ppId: mod.id !== msg.author.id ? msg.author.id : null, - }); - - if (user) { - this.sendSuccessMessage( - msg.channel, - `Case #${theCase.case_number} created for **${user.username}#${user.discriminator}**`, - ); - } else { - this.sendSuccessMessage(msg.channel, `Case #${theCase.case_number} created`); - } - - // Log the action - this.serverLogs.log(LogType.CASE_CREATE, { - mod: stripObjectToScalars(mod.user), - userId: user.id, - caseNum: theCase.case_number, - caseType: type.toUpperCase(), - reason, - }); - } - - @d.command("case", "", { - extra: { - info: { - description: "Show information about a specific case", - }, - }, - }) - @d.permission("can_view") - async showCaseCmd(msg: Message, args: { caseNumber: number }) { - const theCase = await this.cases.findByCaseNumber(args.caseNumber); - - if (!theCase) { - this.sendErrorMessage(msg.channel, "Case not found"); - return; - } - - const casesPlugin = this.getPlugin("cases"); - const embed = await casesPlugin.getCaseEmbed(theCase.id); - msg.channel.createMessage(embed); - } - - @d.command("cases", "", { - options: [ - { - name: "expand", - shortcut: "e", - isSwitch: true, - }, - { - name: "hidden", - shortcut: "h", - isSwitch: true, - }, - ], - extra: { - info: { - description: "Show a list of cases the specified user has", - }, - }, - }) - @d.permission("can_view") - async userCasesCmd(msg: Message, args: { user: string; expand?: boolean; hidden?: boolean }) { - const user = await this.resolveUser(args.user); - if (!user) return this.sendErrorMessage(msg.channel, `User not found`); - - const cases = await this.cases.with("notes").getByUserId(user.id); - const normalCases = cases.filter(c => !c.is_hidden); - const hiddenCases = cases.filter(c => c.is_hidden); - - const userName = - user instanceof UnknownUser && cases.length - ? cases[cases.length - 1].user_name - : `${user.username}#${user.discriminator}`; - - if (cases.length === 0) { - msg.channel.createMessage(`No cases found for **${userName}**`); - } else { - const casesToDisplay = args.hidden ? cases : normalCases; - - if (args.expand) { - if (casesToDisplay.length > 8) { - msg.channel.createMessage("Too many cases for expanded view. Please use compact view instead."); - return; - } - - // Expanded view (= individual case embeds) - const casesPlugin = this.getPlugin("cases"); - for (const theCase of casesToDisplay) { - const embed = await casesPlugin.getCaseEmbed(theCase.id); - msg.channel.createMessage(embed); - } - } else { - // Compact view (= regular message with a preview of each case) - const lines = []; - for (const theCase of casesToDisplay) { - theCase.notes.sort(multiSorter(["created_at", "id"])); - const caseSummary = this.cases.getSummaryText(theCase); - lines.push(caseSummary); - } - - if (!args.hidden && hiddenCases.length) { - if (hiddenCases.length === 1) { - lines.push(`*+${hiddenCases.length} hidden case, use "-hidden" to show it*`); - } else { - lines.push(`*+${hiddenCases.length} hidden cases, use "-hidden" to show them*`); - } - } - - const finalMessage = trimLines(` - Cases for **${userName}**: - - ${lines.join("\n")} - - Use the \`case \` command to see more info about individual cases - `); - - createChunkedMessage(msg.channel, finalMessage); - } - } - } - - @d.command("cases", null, { - options: [{ name: "mod", type: "Member" }], - extra: { - info: { - description: "Show the most recent 5 cases by the specified -mod", - }, - }, - }) - @d.permission("can_view") - async recentCasesCmd(msg: Message, args: { mod?: Member }) { - const modId = args.mod ? args.mod.id : msg.author.id; - const recentCases = await this.cases.with("notes").getRecentByModId(modId, 5); - - const mod = this.bot.users.get(modId); - const modName = mod ? `${mod.username}#${mod.discriminator}` : modId; - - if (recentCases.length === 0) { - msg.channel.createMessage(errorMessage(`No cases by **${modName}**`)); - } else { - const lines = recentCases.map(c => this.cases.getSummaryText(c)); - const finalMessage = trimLines(` - Most recent 5 cases by **${modName}**: - - ${lines.join("\n")} - - Use the \`case \` command to see more info about individual cases - Use the \`cases \` command to see a specific user's cases - `); - createChunkedMessage(msg.channel, finalMessage); - } - } - - @d.command("hide", "", { - aliases: ["hidecase", "hide_case"], - extra: { - info: { - description: "Hide the specified case so it doesn't appear in !cases or !info", - }, - }, - }) - @d.permission("can_hidecase") - async hideCaseCmd(msg: Message, args: { caseNum: number }) { - const theCase = await this.cases.findByCaseNumber(args.caseNum); - if (!theCase) { - this.sendErrorMessage(msg.channel, "Case not found!"); - return; - } - - await this.cases.setHidden(theCase.id, true); - this.sendSuccessMessage( - msg.channel, - `Case #${theCase.case_number} is now hidden! Use \`unhidecase\` to unhide it.`, - ); - } - - @d.command("unhide", "", { - aliases: ["unhidecase", "unhide_case"], - extra: { - info: { - description: "Un-hide the specified case, making it appear in !cases and !info again", - }, - }, - }) - @d.permission("can_hidecase") - async unhideCaseCmd(msg: Message, args: { caseNum: number }) { - const theCase = await this.cases.findByCaseNumber(args.caseNum); - if (!theCase) { - this.sendErrorMessage(msg.channel, "Case not found!"); - return; - } - - await this.cases.setHidden(theCase.id, false); - this.sendSuccessMessage(msg.channel, `Case #${theCase.case_number} is no longer hidden!`); - } -} diff --git a/backend/src/plugins/Mutes.ts b/backend/src/plugins/Mutes.ts deleted file mode 100644 index 659adc2a..00000000 --- a/backend/src/plugins/Mutes.ts +++ /dev/null @@ -1,762 +0,0 @@ -import { Member, Message, TextChannel, User } from "eris"; -import { GuildCases } from "../data/GuildCases"; -import moment from "moment-timezone"; -import { ZeppelinPluginClass } from "./ZeppelinPluginClass"; -import { GuildMutes } from "../data/GuildMutes"; -import { - chunkMessageLines, - DBDateFormat, - errorMessage, - UserNotificationResult, - noop, - notifyUser, - stripObjectToScalars, - successMessage, - tNullable, - ucfirst, - UnknownUser, - UserNotificationMethod, - trimLines, - MINUTES, -} from "../utils"; -import humanizeDuration from "humanize-duration"; -import { LogType } from "../data/LogType"; -import { GuildLogs } from "../data/GuildLogs"; -import { decorators as d, IPluginOptions, logger } from "knub"; -import { Mute } from "../data/entities/Mute"; -import { renderTemplate } from "../templateFormatter"; -import { CaseTypes } from "../data/CaseTypes"; -import { CaseArgs, CasesPlugin } from "./Cases"; -import { Case } from "../data/entities/Case"; -import * as t from "io-ts"; -import { ERRORS, RecoverablePluginError } from "../RecoverablePluginError"; -import { GuildArchives } from "src/data/GuildArchives"; -import { humanizeDurationShort } from "../humanizeDurationShort"; - -const ConfigSchema = t.type({ - mute_role: tNullable(t.string), - move_to_voice_channel: tNullable(t.string), - - dm_on_mute: t.boolean, - dm_on_update: t.boolean, - message_on_mute: t.boolean, - message_on_update: t.boolean, - message_channel: tNullable(t.string), - mute_message: tNullable(t.string), - timed_mute_message: tNullable(t.string), - update_mute_message: tNullable(t.string), - - can_view_list: t.boolean, - can_cleanup: t.boolean, -}); -type TConfigSchema = t.TypeOf; - -interface IMuteWithDetails extends Mute { - member?: Member; - banned?: boolean; -} - -export type MuteResult = { - case: Case; - notifyResult: UserNotificationResult; - updatedExistingMute: boolean; -}; - -export type UnmuteResult = { - case: Case; -}; - -export interface MuteOptions { - caseArgs?: Partial; - contactMethods?: UserNotificationMethod[]; -} - -const EXPIRED_MUTE_CHECK_INTERVAL = 60 * 1000; -let FIRST_CHECK_TIME = Date.now(); -const FIRST_CHECK_INCREMENT = 5 * 1000; - -export class MutesPlugin extends ZeppelinPluginClass { - public static pluginName = "mutes"; - public static configSchema = ConfigSchema; - - public static pluginInfo = { - prettyName: "Mutes", - }; - - protected mutes: GuildMutes; - protected cases: GuildCases; - protected serverLogs: GuildLogs; - private muteClearIntervalId: NodeJS.Timer; - archives: GuildArchives; - - public static getStaticDefaultOptions(): IPluginOptions { - return { - config: { - mute_role: null, - move_to_voice_channel: null, - - dm_on_mute: false, - dm_on_update: false, - message_on_mute: false, - message_on_update: false, - message_channel: null, - mute_message: "You have been muted on the {guildName} server. Reason given: {reason}", - timed_mute_message: "You have been muted on the {guildName} server for {time}. Reason given: {reason}", - update_mute_message: "Your mute on the {guildName} server has been updated to {time}.", - - can_view_list: false, - can_cleanup: false, - }, - overrides: [ - { - level: ">=50", - config: { - can_view_list: true, - }, - }, - { - level: ">=100", - config: { - can_cleanup: true, - }, - }, - ], - }; - } - - protected onLoad() { - this.mutes = GuildMutes.getGuildInstance(this.guildId); - this.cases = GuildCases.getGuildInstance(this.guildId); - this.serverLogs = new GuildLogs(this.guildId); - this.archives = GuildArchives.getGuildInstance(this.guildId); - - // Check for expired mutes every 5s - const firstCheckTime = Math.max(Date.now(), FIRST_CHECK_TIME) + FIRST_CHECK_INCREMENT; - FIRST_CHECK_TIME = firstCheckTime; - - setTimeout(() => { - this.clearExpiredMutes(); - this.muteClearIntervalId = setInterval(() => this.clearExpiredMutes(), EXPIRED_MUTE_CHECK_INTERVAL); - }, firstCheckTime - Date.now()); - } - - protected onUnload() { - clearInterval(this.muteClearIntervalId); - } - - public async muteUser( - userId: string, - muteTime: number = null, - reason: string = null, - muteOptions: MuteOptions = {}, - ): Promise { - const lock = await this.locks.acquire(`mute-${userId}`); - - const muteRole = this.getConfig().mute_role; - if (!muteRole) { - lock.unlock(); - this.throwRecoverablePluginError(ERRORS.NO_MUTE_ROLE_IN_CONFIG); - } - - const timeUntilUnmute = muteTime ? humanizeDuration(muteTime) : "indefinite"; - - // No mod specified -> mark Zeppelin as the mod - if (!muteOptions.caseArgs?.modId) { - muteOptions.caseArgs = muteOptions.caseArgs ?? {}; - muteOptions.caseArgs.modId = this.bot.user.id; - } - - const user = await this.resolveUser(userId); - const member = await this.getMember(user.id, true); // Grab the fresh member so we don't have stale role info - const config = this.getMatchingConfig({ member, userId }); - - if (member) { - // Apply mute role if it's missing - if (!member.roles.includes(muteRole)) { - await member.addRole(muteRole); - } - - // If enabled, move the user to the mute voice channel (e.g. afk - just to apply the voice perms from the mute role) - const moveToVoiceChannelId = this.getConfig().move_to_voice_channel; - if (moveToVoiceChannelId) { - // TODO: Add back the voiceState check once we figure out how to get voice state for guild members that are loaded on-demand - try { - await member.edit({ channelID: moveToVoiceChannelId }); - } catch (e) {} // tslint:disable-line - } - } - - // If the user is already muted, update the duration of their existing mute - const existingMute = await this.mutes.findExistingMuteForUserId(user.id); - let notifyResult: UserNotificationResult = { method: null, success: true }; - - if (existingMute) { - await this.mutes.updateExpiryTime(user.id, muteTime); - } else { - await this.mutes.addMute(user.id, muteTime); - } - - const template = existingMute - ? config.update_mute_message - : muteTime - ? config.timed_mute_message - : config.mute_message; - - const muteMessage = - template && - (await renderTemplate(template, { - guildName: this.guild.name, - reason: reason || "None", - time: timeUntilUnmute, - })); - - if (muteMessage && user instanceof User) { - let contactMethods = []; - - if (muteOptions?.contactMethods) { - contactMethods = muteOptions.contactMethods; - } else { - const useDm = existingMute ? config.dm_on_update : config.dm_on_mute; - if (useDm) { - contactMethods.push({ type: "dm" }); - } - - const useChannel = existingMute ? config.message_on_update : config.message_on_mute; - const channel = config.message_channel && this.guild.channels.get(config.message_channel); - if (useChannel && channel instanceof TextChannel) { - contactMethods.push({ type: "channel", channel }); - } - } - - notifyResult = await notifyUser(user, muteMessage, contactMethods); - } - - // Create/update a case - const casesPlugin = this.getPlugin("cases"); - let theCase; - - if (existingMute && existingMute.case_id) { - // Update old case - // Since mutes can often have multiple notes (extraNotes), we won't post each case note individually, - // but instead we'll post the entire case afterwards - theCase = await this.cases.find(existingMute.case_id); - const noteDetails = [`Mute updated to ${muteTime ? timeUntilUnmute : "indefinite"}`]; - const reasons = [reason, ...(muteOptions.caseArgs?.extraNotes || [])]; - for (const noteReason of reasons) { - await casesPlugin.createCaseNote({ - caseId: existingMute.case_id, - modId: muteOptions.caseArgs?.modId, - body: noteReason, - noteDetails, - postInCaseLogOverride: false, - }); - } - - if (muteOptions.caseArgs?.postInCaseLogOverride !== false) { - casesPlugin.postCaseToCaseLogChannel(existingMute.case_id); - } - } else { - // Create new case - const noteDetails = [`Muted ${muteTime ? `for ${timeUntilUnmute}` : "indefinitely"}`]; - if (notifyResult.text) { - noteDetails.push(ucfirst(notifyResult.text)); - } - - theCase = await casesPlugin.createCase({ - ...(muteOptions.caseArgs || {}), - userId, - modId: muteOptions.caseArgs?.modId, - type: CaseTypes.Mute, - reason, - noteDetails, - }); - await this.mutes.setCaseId(user.id, theCase.id); - } - - // Log the action - const mod = await this.resolveUser(muteOptions.caseArgs?.modId); - if (muteTime) { - this.serverLogs.log(LogType.MEMBER_TIMED_MUTE, { - mod: stripObjectToScalars(mod), - user: stripObjectToScalars(user), - time: timeUntilUnmute, - reason, - }); - } else { - this.serverLogs.log(LogType.MEMBER_MUTE, { - mod: stripObjectToScalars(mod), - user: stripObjectToScalars(user), - reason, - }); - } - - lock.unlock(); - - return { - case: theCase, - notifyResult, - updatedExistingMute: !!existingMute, - }; - } - - public async unmuteUser( - userId: string, - unmuteTime: number = null, - caseArgs: Partial = {}, - ): Promise { - const existingMute = await this.mutes.findExistingMuteForUserId(userId); - const user = await this.resolveUser(userId); - const member = await this.getMember(userId, true); // Grab the fresh member so we don't have stale role info - - if (!existingMute && !this.hasMutedRole(member)) return; - - if (unmuteTime) { - // Schedule timed unmute (= just set the mute's duration) - if (!existingMute) { - await this.mutes.addMute(userId, unmuteTime); - } else { - await this.mutes.updateExpiryTime(userId, unmuteTime); - } - } else { - // Unmute immediately - if (member) { - const muteRole = this.getConfig().mute_role; - if (member.roles.includes(muteRole)) { - await member.removeRole(muteRole); - } - } else { - logger.warn( - `Member ${userId} not found in guild ${this.guild.name} (${this.guildId}) when attempting to unmute`, - ); - } - if (existingMute) { - await this.mutes.clear(userId); - } - } - - const timeUntilUnmute = unmuteTime && humanizeDuration(unmuteTime); - - // Create a case - const noteDetails = []; - if (unmuteTime) { - noteDetails.push(`Scheduled unmute in ${timeUntilUnmute}`); - } else { - noteDetails.push(`Unmuted immediately`); - } - if (!existingMute) { - noteDetails.push(`Removed external mute`); - } - - const casesPlugin = this.getPlugin("cases"); - const createdCase = await casesPlugin.createCase({ - ...caseArgs, - userId, - modId: caseArgs.modId, - type: CaseTypes.Unmute, - noteDetails, - }); - - // Log the action - const mod = this.bot.users.get(caseArgs.modId); - if (unmuteTime) { - this.serverLogs.log(LogType.MEMBER_TIMED_UNMUTE, { - mod: stripObjectToScalars(mod), - user: stripObjectToScalars(user), - time: timeUntilUnmute, - reason: caseArgs.reason, - }); - } else { - this.serverLogs.log(LogType.MEMBER_UNMUTE, { - mod: stripObjectToScalars(mod), - user: stripObjectToScalars(user), - reason: caseArgs.reason, - }); - } - - return { - case: createdCase, - }; - } - - public hasMutedRole(member: Member) { - if (member.roles.includes(this.getConfig().mute_role)) { - return true; - } - return false; - } - - @d.command("mutes", [], { - options: [ - { - name: "age", - shortcut: "a", - type: "delay", - }, - { - name: "left", - shortcut: "l", - isSwitch: true, - }, - { - name: "manual", - shortcut: "m", - isSwitch: true, - }, - { - name: "export", - shortcut: "e", - isSwitch: true, - }, - ], - }) - @d.permission("can_view_list") - protected async muteListCmd( - msg: Message, - args: { age?: number; left?: boolean; manual?: boolean; export?: boolean }, - ) { - const listMessagePromise = msg.channel.createMessage("Loading mutes..."); - const mutesPerPage = 10; - let totalMutes = 0; - let hasFilters = false; - - let hasReactions = false; - let clearReactionsFn; - let clearReactionsTimeout; - const clearReactionsDebounce = 5 * MINUTES; - - let lines = []; - - // Active, logged mutes - const activeMutes = await this.mutes.getActiveMutes(); - activeMutes.sort((a, b) => { - if (a.expires_at == null && b.expires_at != null) return 1; - if (b.expires_at == null && a.expires_at != null) return -1; - if (a.expires_at == null && b.expires_at == null) { - return a.created_at > b.created_at ? -1 : 1; - } - return a.expires_at > b.expires_at ? 1 : -1; - }); - - if (args.manual) { - // Show only manual mutes (i.e. "Muted" role added without a logged mute) - const muteUserIds = new Set(activeMutes.map(m => m.user_id)); - const manuallyMutedMembers = []; - const muteRole = this.getConfig().mute_role; - - if (muteRole) { - this.guild.members.forEach(member => { - if (muteUserIds.has(member.id)) return; - if (member.roles.includes(muteRole)) manuallyMutedMembers.push(member); - }); - } - - totalMutes = manuallyMutedMembers.length; - - lines = manuallyMutedMembers.map(member => { - return `<@!${member.id}> (**${member.user.username}#${member.user.discriminator}**, \`${member.id}\`) 🔧 Manual mute`; - }); - } else { - // Show filtered active mutes (but not manual mutes) - let filteredMutes: IMuteWithDetails[] = activeMutes; - let bannedIds: string[] = null; - - // Filter: mute age - if (args.age) { - const cutoff = moment() - .subtract(args.age, "ms") - .format(DBDateFormat); - filteredMutes = filteredMutes.filter(m => m.created_at <= cutoff); - hasFilters = true; - } - - // Fetch some extra details for each mute: the muted member, and whether they've been banned - for (const [index, mute] of filteredMutes.entries()) { - const muteWithDetails = { ...mute }; - - const member = await this.getMember(mute.user_id); - - if (!member) { - if (!bannedIds) { - const bans = await this.guild.getBans(); - bannedIds = bans.map(u => u.user.id); - } - - muteWithDetails.banned = bannedIds.includes(mute.user_id); - } else { - muteWithDetails.member = member; - } - - filteredMutes[index] = muteWithDetails; - } - - // Filter: left the server - if (args.left != null) { - filteredMutes = filteredMutes.filter(m => (args.left && !m.member) || (!args.left && m.member)); - hasFilters = true; - } - - totalMutes = filteredMutes.length; - - // Create a message line for each mute - const caseIds = filteredMutes.map(m => m.case_id).filter(v => !!v); - const muteCases = caseIds.length ? await this.cases.get(caseIds) : []; - const muteCasesById = muteCases.reduce((map, c) => map.set(c.id, c), new Map()); - - lines = filteredMutes.map(mute => { - const user = this.bot.users.get(mute.user_id); - const username = user ? `${user.username}#${user.discriminator}` : "Unknown#0000"; - const theCase = muteCasesById.get(mute.case_id); - const caseName = theCase ? `Case #${theCase.case_number}` : "No case"; - - let line = `<@!${mute.user_id}> (**${username}**, \`${mute.user_id}\`) 📋 ${caseName}`; - - if (mute.expires_at) { - const timeUntilExpiry = moment().diff(moment(mute.expires_at, DBDateFormat)); - const humanizedTime = humanizeDurationShort(timeUntilExpiry, { largest: 2, round: true }); - line += ` ⏰ Expires in ${humanizedTime}`; - } else { - line += ` ⏰ Indefinite`; - } - - const timeFromMute = moment(mute.created_at, DBDateFormat).diff(moment()); - const humanizedTimeFromMute = humanizeDurationShort(timeFromMute, { largest: 2, round: true }); - line += ` 🕒 Muted ${humanizedTimeFromMute} ago`; - - if (mute.banned) { - line += ` 🔨 Banned`; - } else if (!mute.member) { - line += ` ❌ Left server`; - } - - return line; - }); - } - - const listMessage = await listMessagePromise; - - let currentPage = 1; - const totalPages = Math.ceil(lines.length / mutesPerPage); - - const drawListPage = async page => { - page = Math.max(1, Math.min(totalPages, page)); - currentPage = page; - - const pageStart = (page - 1) * mutesPerPage; - const pageLines = lines.slice(pageStart, pageStart + mutesPerPage); - - const pageRangeText = `${pageStart + 1}–${pageStart + pageLines.length} of ${totalMutes}`; - - let message; - if (args.manual) { - message = `Showing manual mutes ${pageRangeText}:`; - } else if (hasFilters) { - message = `Showing filtered active mutes ${pageRangeText}:`; - } else { - message = `Showing active mutes ${pageRangeText}:`; - } - - message += "\n\n" + pageLines.join("\n"); - - listMessage.edit(message); - bumpClearReactionsTimeout(); - }; - - const bumpClearReactionsTimeout = () => { - if (!hasReactions) return; - clearTimeout(clearReactionsTimeout); - clearReactionsTimeout = setTimeout(clearReactionsFn, clearReactionsDebounce); - }; - - if (totalMutes === 0) { - if (args.manual) { - listMessage.edit("No manual mutes found!"); - } else if (hasFilters) { - listMessage.edit("No mutes found with the specified filters!"); - } else { - listMessage.edit("No active mutes!"); - } - } else if (args.export) { - const archiveId = await this.archives.create(lines.join("\n"), moment().add(1, "hour")); - const url = await this.archives.getUrl(this.knub.getGlobalConfig().url, archiveId); - - await listMessage.edit(`Exported mutes: ${url}`); - } else { - drawListPage(1); - - if (totalPages > 1) { - hasReactions = true; - listMessage.addReaction("⬅"); - listMessage.addReaction("➡"); - - const removeListenerFn = this.on("messageReactionAdd", (rMsg: Message, emoji, userId) => { - if (rMsg.id !== listMessage.id) return; - if (userId !== msg.author.id) return; - if (!["⬅", "➡"].includes(emoji.name)) return; - - if (emoji.name === "⬅" && currentPage > 1) { - drawListPage(currentPage - 1); - } else if (emoji.name === "➡" && currentPage < totalPages) { - drawListPage(currentPage + 1); - } - - rMsg.removeReaction(emoji.name, userId).catch(noop); - }); - - clearReactionsFn = () => { - listMessage.removeReactions().catch(noop); - removeListenerFn(); - }; - bumpClearReactionsTimeout(); - } - } - } - - /** - * Reapply active mutes on join - */ - @d.event("guildMemberAdd") - protected async onGuildMemberAdd(_, member: Member) { - const mute = await this.mutes.findExistingMuteForUserId(member.id); - if (mute) { - const muteRole = this.getConfig().mute_role; - await member.addRole(muteRole); - - this.serverLogs.log(LogType.MEMBER_MUTE_REJOIN, { - member: stripObjectToScalars(member, ["user", "roles"]), - }); - } - } - - /** - * Clear active mute from the member if the member is banned - */ - @d.event("guildBanAdd") - protected async onGuildBanAdd(_, user: User) { - const mute = await this.mutes.findExistingMuteForUserId(user.id); - if (mute) { - this.mutes.clear(user.id); - } - } - - /** - * COMMAND: Clear dangling mutes for members who have been banned - */ - @d.command("clear_banned_mutes") - @d.permission("can_cleanup") - protected async clearBannedMutesCmd(msg: Message) { - await msg.channel.createMessage("Clearing mutes from banned users..."); - - const activeMutes = await this.mutes.getActiveMutes(); - - // Mismatch in Eris docs and actual result here, based on Eris's code comments anyway - const bans: Array<{ reason: string; user: User }> = (await this.guild.getBans()) as any; - const bannedIds = bans.map(b => b.user.id); - - await msg.channel.createMessage( - `Found ${activeMutes.length} mutes and ${bannedIds.length} bans, cross-referencing...`, - ); - - let cleared = 0; - for (const mute of activeMutes) { - if (bannedIds.includes(mute.user_id)) { - await this.mutes.clear(mute.user_id); - cleared++; - } - } - - this.sendSuccessMessage(msg.channel, `Cleared ${cleared} mutes from banned users!`); - } - - /** - * Clear active mute from the member if the mute role is removed - */ - @d.event("guildMemberUpdate") - protected async onGuildMemberUpdate(_, member: Member) { - const muteRole = this.getConfig().mute_role; - if (!muteRole) return; - - const mute = await this.mutes.findExistingMuteForUserId(member.id); - if (!mute) return; - - if (!member.roles.includes(muteRole)) { - await this.mutes.clear(muteRole); - } - } - - /** - * COMMAND: Clear dangling mutes for members whose mute role was removed by other means - */ - @d.command("clear_mutes_without_role") - @d.permission("can_cleanup") - protected async clearMutesWithoutRoleCmd(msg: Message) { - const activeMutes = await this.mutes.getActiveMutes(); - const muteRole = this.getConfig().mute_role; - if (!muteRole) return; - - await msg.channel.createMessage("Clearing mutes from members that don't have the mute role..."); - - let cleared = 0; - for (const mute of activeMutes) { - const member = await this.getMember(mute.user_id); - if (!member) continue; - - if (!member.roles.includes(muteRole)) { - await this.mutes.clear(mute.user_id); - cleared++; - } - } - - this.sendSuccessMessage(msg.channel, `Cleared ${cleared} mutes from members that don't have the mute role`); - } - - @d.command("clear_mute", "") - @d.permission("can_cleanup") - protected async clearMuteCmd(msg: Message, args: { userIds: string[] }) { - const failed = []; - for (const id of args.userIds) { - const mute = await this.mutes.findExistingMuteForUserId(id); - if (!mute) { - failed.push(id); - continue; - } - await this.mutes.clear(id); - } - - if (failed.length !== args.userIds.length) { - this.sendSuccessMessage(msg.channel, `**${args.userIds.length - failed.length} active mute(s) cleared**`); - } - - if (failed.length) { - this.sendErrorMessage( - msg.channel, - `**${failed.length}/${args.userIds.length} IDs failed**, they are not muted: ${failed.join(" ")}`, - ); - } - } - - protected async clearExpiredMutes() { - const expiredMutes = await this.mutes.getExpiredMutes(); - for (const mute of expiredMutes) { - const member = await this.getMember(mute.user_id); - - if (member) { - try { - await member.removeRole(this.getConfig().mute_role); - } catch (e) { - this.serverLogs.log(LogType.BOT_ALERT, { - body: `Failed to remove mute role from {userMention(member)}`, - member: stripObjectToScalars(member), - }); - } - } - - await this.mutes.clear(mute.user_id); - - this.serverLogs.log(LogType.MEMBER_MUTE_EXPIRED, { - member: member - ? stripObjectToScalars(member, ["user", "roles"]) - : { id: mute.user_id, user: new UnknownUser({ id: mute.user_id }) }, - }); - } - } -} diff --git a/backend/src/plugins/NameHistory.ts b/backend/src/plugins/NameHistory.ts deleted file mode 100644 index ebf979d2..00000000 --- a/backend/src/plugins/NameHistory.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { decorators as d, IPluginOptions } from "knub"; -import { GuildNicknameHistory, MAX_NICKNAME_ENTRIES_PER_USER } from "../data/GuildNicknameHistory"; -import { Member, Message } from "eris"; -import { ZeppelinPluginClass } from "./ZeppelinPluginClass"; -import { createChunkedMessage, DAYS, disableCodeBlocks } from "../utils"; -import { MAX_USERNAME_ENTRIES_PER_USER, UsernameHistory } from "../data/UsernameHistory"; -import * as t from "io-ts"; -import { NICKNAME_RETENTION_PERIOD } from "../data/cleanup/nicknames"; -import moment from "moment-timezone"; -import { USERNAME_RETENTION_PERIOD } from "../data/cleanup/usernames"; -import { Queue } from "../Queue"; - -const ConfigSchema = t.type({ - can_view: t.boolean, -}); -type TConfigSchema = t.TypeOf; - -export class NameHistoryPlugin extends ZeppelinPluginClass { - public static pluginName = "name_history"; - public static showInDocs = false; - public static configSchema = ConfigSchema; - - protected nicknameHistory: GuildNicknameHistory; - protected usernameHistory: UsernameHistory; - - protected updateQueue: Queue; - - public static getStaticDefaultOptions(): IPluginOptions { - return { - config: { - can_view: false, - }, - - overrides: [ - { - level: ">=50", - config: { - can_view: true, - }, - }, - ], - }; - } - - onLoad() { - this.nicknameHistory = GuildNicknameHistory.getGuildInstance(this.guildId); - this.usernameHistory = new UsernameHistory(); - this.updateQueue = new Queue(); - } - - @d.command("names", "") - @d.permission("can_view") - async namesCmd(msg: Message, args: { userId: string }) { - const nicknames = await this.nicknameHistory.getByUserId(args.userId); - const usernames = await this.usernameHistory.getByUserId(args.userId); - - if (nicknames.length === 0 && usernames.length === 0) { - return this.sendErrorMessage(msg.channel, "No name history found"); - } - - const nicknameRows = nicknames.map( - r => `\`[${r.timestamp}]\` ${r.nickname ? `**${disableCodeBlocks(r.nickname)}**` : "*None*"}`, - ); - const usernameRows = usernames.map(r => `\`[${r.timestamp}]\` **${disableCodeBlocks(r.username)}**`); - - const user = this.bot.users.get(args.userId); - const currentUsername = user ? `${user.username}#${user.discriminator}` : args.userId; - - const nicknameDays = Math.round(NICKNAME_RETENTION_PERIOD / DAYS); - const usernameDays = Math.round(USERNAME_RETENTION_PERIOD / DAYS); - - let message = `Name history for **${currentUsername}**:`; - if (nicknameRows.length) { - message += `\n\n__Last ${MAX_NICKNAME_ENTRIES_PER_USER} nicknames within ${nicknameDays} days:__\n${nicknameRows.join( - "\n", - )}`; - } - if (usernameRows.length) { - message += `\n\n__Last ${MAX_USERNAME_ENTRIES_PER_USER} usernames within ${usernameDays} days:__\n${usernameRows.join( - "\n", - )}`; - } - - createChunkedMessage(msg.channel, message); - } - - async updateNickname(member: Member) { - if (!member) return; - const latestEntry = await this.nicknameHistory.getLastEntry(member.id); - if (!latestEntry || latestEntry.nickname !== member.nick) { - if (!latestEntry && member.nick == null) return; // No need to save "no nickname" if there's no previous data - await this.nicknameHistory.addEntry(member.id, member.nick); - } - } - - @d.event("messageCreate") - async onMessage(msg: Message) { - this.updateQueue.add(() => this.updateNickname(msg.member)); - } - - @d.event("voiceChannelJoin") - async onVoiceChannelJoin(member: Member) { - this.updateQueue.add(() => this.updateNickname(member)); - } -} diff --git a/backend/src/plugins/Persist.ts b/backend/src/plugins/Persist.ts deleted file mode 100644 index 9d110215..00000000 --- a/backend/src/plugins/Persist.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { decorators as d, IPluginOptions } from "knub"; -import { GuildPersistedData, IPartialPersistData } from "../data/GuildPersistedData"; -import intersection from "lodash.intersection"; -import { Member, MemberOptions } from "eris"; -import { GuildLogs } from "../data/GuildLogs"; -import { LogType } from "../data/LogType"; -import { stripObjectToScalars } from "../utils"; -import { trimPluginDescription, ZeppelinPluginClass } from "./ZeppelinPluginClass"; -import * as t from "io-ts"; - -const ConfigSchema = t.type({ - persisted_roles: t.array(t.string), - persist_nicknames: t.boolean, - persist_voice_mutes: t.boolean, // Deprecated, here to not break old configs -}); -type TConfigSchema = t.TypeOf; - -export class PersistPlugin extends ZeppelinPluginClass { - public static pluginName = "persist"; - public static configSchema = ConfigSchema; - - public static pluginInfo = { - prettyName: "Persist", - description: trimPluginDescription(` - Blah - `), - }; - - protected persistedData: GuildPersistedData; - protected logs: GuildLogs; - - public static getStaticDefaultOptions(): IPluginOptions { - return { - config: { - persisted_roles: [], - persist_nicknames: false, - persist_voice_mutes: false, - }, - }; - } - - onLoad() { - this.persistedData = GuildPersistedData.getGuildInstance(this.guildId); - this.logs = new GuildLogs(this.guildId); - } - - @d.event("guildMemberRemove") - onGuildMemberRemove(_, member: Member) { - let persist = false; - const persistData: IPartialPersistData = {}; - const config = this.getConfig(); - - const persistedRoles = config.persisted_roles; - if (persistedRoles.length && member.roles) { - const rolesToPersist = intersection(persistedRoles, member.roles); - if (rolesToPersist.length) { - persist = true; - persistData.roles = rolesToPersist; - } - } - - if (config.persist_nicknames && member.nick) { - persist = true; - persistData.nickname = member.nick; - } - - if (persist) { - this.persistedData.set(member.id, persistData); - } - } - - @d.event("guildMemberAdd") - async onGuildMemberAdd(_, member: Member) { - const memberRolesLock = await this.locks.acquire(`member-roles-${member.id}`); - - const persistedData = await this.persistedData.find(member.id); - if (!persistedData) { - memberRolesLock.unlock(); - return; - } - - const toRestore: MemberOptions = {}; - const config = this.getConfig(); - const restoredData = []; - - const persistedRoles = config.persisted_roles; - if (persistedRoles.length) { - const rolesToRestore = intersection(persistedRoles, persistedData.roles); - if (rolesToRestore.length) { - restoredData.push("roles"); - toRestore.roles = Array.from(new Set([...rolesToRestore, ...member.roles])); - } - } - - if (config.persist_nicknames && persistedData.nickname) { - restoredData.push("nickname"); - toRestore.nick = persistedData.nickname; - } - - if (restoredData.length) { - await member.edit(toRestore, "Restored upon rejoin"); - await this.persistedData.clear(member.id); - - this.logs.log(LogType.MEMBER_RESTORE, { - member: stripObjectToScalars(member, ["user", "roles"]), - restoredData: restoredData.join(", "), - }); - } - - memberRolesLock.unlock(); - } -} diff --git a/backend/src/plugins/PingableRolesPlugin.ts b/backend/src/plugins/PingableRolesPlugin.ts deleted file mode 100644 index 482b34de..00000000 --- a/backend/src/plugins/PingableRolesPlugin.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { decorators as d, IPluginOptions } from "knub"; -import { Message, Role, TextableChannel } from "eris"; -import { GuildPingableRoles } from "../data/GuildPingableRoles"; -import { PingableRole } from "../data/entities/PingableRole"; -import { errorMessage, successMessage } from "../utils"; -import { ZeppelinPluginClass } from "./ZeppelinPluginClass"; -import * as t from "io-ts"; - -const ConfigSchema = t.type({ - can_manage: t.boolean, -}); -type TConfigSchema = t.TypeOf; - -const TIMEOUT = 10 * 1000; - -export class PingableRolesPlugin extends ZeppelinPluginClass { - public static pluginName = "pingable_roles"; - public static configSchema = ConfigSchema; - - public static pluginInfo = { - prettyName: "Pingable roles", - }; - - protected pingableRoles: GuildPingableRoles; - protected cache: Map; - protected timeouts: Map; - - public static getStaticDefaultOptions(): IPluginOptions { - return { - config: { - can_manage: false, - }, - - overrides: [ - { - level: ">=100", - config: { - can_manage: true, - }, - }, - ], - }; - } - - onLoad() { - this.pingableRoles = GuildPingableRoles.getGuildInstance(this.guildId); - - this.cache = new Map(); - this.timeouts = new Map(); - } - - protected async getPingableRolesForChannel(channelId: string): Promise { - if (!this.cache.has(channelId)) { - this.cache.set(channelId, await this.pingableRoles.getForChannel(channelId)); - } - - return this.cache.get(channelId); - } - - @d.command("pingable_role disable", " ") - @d.permission("can_manage") - async disablePingableRoleCmd(msg: Message, args: { channelId: string; role: Role }) { - const pingableRole = await this.pingableRoles.getByChannelAndRoleId(args.channelId, args.role.id); - if (!pingableRole) { - msg.channel.createMessage(errorMessage(`**${args.role.name}** is not set as pingable in <#${args.channelId}>`)); - return; - } - - await this.pingableRoles.delete(args.channelId, args.role.id); - this.cache.delete(args.channelId); - - this.sendSuccessMessage(msg.channel, `**${args.role.name}** is no longer set as pingable in <#${args.channelId}>`); - } - - @d.command("pingable_role", " ") - @d.permission("can_manage") - async setPingableRoleCmd(msg: Message, args: { channelId: string; role: Role }) { - const existingPingableRole = await this.pingableRoles.getByChannelAndRoleId(args.channelId, args.role.id); - if (existingPingableRole) { - msg.channel.createMessage( - errorMessage(`**${args.role.name}** is already set as pingable in <#${args.channelId}>`), - ); - return; - } - - await this.pingableRoles.add(args.channelId, args.role.id); - this.cache.delete(args.channelId); - - this.sendSuccessMessage(msg.channel, `**${args.role.name}** has been set as pingable in <#${args.channelId}>`); - } - - @d.event("typingStart") - async onTypingStart(channel: TextableChannel) { - const pingableRoles = await this.getPingableRolesForChannel(channel.id); - if (pingableRoles.length === 0) return; - - if (this.timeouts.has(channel.id)) { - clearTimeout(this.timeouts.get(channel.id)); - } - - this.enablePingableRoles(pingableRoles); - - const timeout = setTimeout(() => { - this.disablePingableRoles(pingableRoles); - }, TIMEOUT); - this.timeouts.set(channel.id, timeout); - } - - @d.event("messageCreate") - async onMessageCreate(msg: Message) { - const pingableRoles = await this.getPingableRolesForChannel(msg.channel.id); - if (pingableRoles.length === 0) return; - - if (this.timeouts.has(msg.channel.id)) { - clearTimeout(this.timeouts.get(msg.channel.id)); - } - - this.disablePingableRoles(pingableRoles); - } - - protected enablePingableRoles(pingableRoles: PingableRole[]) { - for (const pingableRole of pingableRoles) { - const role = this.guild.roles.get(pingableRole.role_id); - if (!role) continue; - - role.edit( - { - mentionable: true, - }, - "Enable pingable role", - ); - } - } - - protected disablePingableRoles(pingableRoles: PingableRole[]) { - for (const pingableRole of pingableRoles) { - const role = this.guild.roles.get(pingableRole.role_id); - if (!role) continue; - - role.edit( - { - mentionable: false, - }, - "Disable pingable role", - ); - } - } -} diff --git a/backend/src/plugins/Post.ts b/backend/src/plugins/Post.ts deleted file mode 100644 index 258867ef..00000000 --- a/backend/src/plugins/Post.ts +++ /dev/null @@ -1,698 +0,0 @@ -import { decorators as d, IPluginOptions, logger } from "knub"; -import { Attachment, Channel, EmbedBase, Message, MessageContent, Role, TextChannel, User } from "eris"; -import { - errorMessage, - downloadFile, - getRoleMentions, - trimLines, - DBDateFormat, - convertDelayStringToMS, - SECONDS, - sorter, - disableCodeBlocks, - deactivateMentions, - createChunkedMessage, - stripObjectToScalars, - isValidEmbed, - MINUTES, - StrictMessageContent, - DAYS, -} from "../utils"; -import { GuildSavedMessages } from "../data/GuildSavedMessages"; -import { ZeppelinPluginClass } from "./ZeppelinPluginClass"; - -import fs from "fs"; -import { GuildScheduledPosts } from "../data/GuildScheduledPosts"; -import moment, { Moment } from "moment-timezone"; -import { GuildLogs } from "../data/GuildLogs"; -import { LogType } from "../data/LogType"; -import * as t from "io-ts"; -import humanizeDuration from "humanize-duration"; - -const ConfigSchema = t.type({ - can_post: t.boolean, -}); -type TConfigSchema = t.TypeOf; - -const fsp = fs.promises; - -const COLOR_MATCH_REGEX = /^#?([0-9a-f]{6})$/; - -const SCHEDULED_POST_CHECK_INTERVAL = 5 * SECONDS; -const SCHEDULED_POST_PREVIEW_TEXT_LENGTH = 50; - -const MIN_REPEAT_TIME = 5 * MINUTES; -const MAX_REPEAT_TIME = Math.pow(2, 32); -const MAX_REPEAT_UNTIL = moment().add(100, "years"); - -export class PostPlugin extends ZeppelinPluginClass { - public static pluginName = "post"; - public static configSchema = ConfigSchema; - - public static pluginInfo = { - prettyName: "Post", - }; - - protected savedMessages: GuildSavedMessages; - protected scheduledPosts: GuildScheduledPosts; - protected logs: GuildLogs; - - private scheduledPostLoopTimeout; - - onLoad() { - this.savedMessages = GuildSavedMessages.getGuildInstance(this.guildId); - this.scheduledPosts = GuildScheduledPosts.getGuildInstance(this.guildId); - this.logs = new GuildLogs(this.guildId); - - this.scheduledPostLoop(); - } - - onUnload() { - clearTimeout(this.scheduledPostLoopTimeout); - } - - public static getStaticDefaultOptions(): IPluginOptions { - return { - config: { - can_post: false, - }, - - overrides: [ - { - level: ">=100", - config: { - can_post: true, - }, - }, - ], - }; - } - - protected formatContent(str) { - return str.replace(/\\n/g, "\n"); - } - - protected async postMessage( - channel: TextChannel, - content: MessageContent, - attachments: Attachment[] = [], - enableMentions: boolean = false, - ): Promise { - if (typeof content === "string") { - content = { content }; - } - - if (content && content.content) { - content.content = this.formatContent(content.content); - } - - let downloadedAttachment; - let file; - if (attachments.length) { - downloadedAttachment = await downloadFile(attachments[0].url); - file = { - name: attachments[0].filename, - file: await fsp.readFile(downloadedAttachment.path), - }; - } - - const rolesMadeMentionable: Role[] = []; - if (enableMentions && content.content) { - const mentionedRoleIds = getRoleMentions(content.content); - if (mentionedRoleIds != null) { - for (const roleId of mentionedRoleIds) { - const role = this.guild.roles.get(roleId); - if (role && !role.mentionable) { - await role.edit({ - mentionable: true, - }); - rolesMadeMentionable.push(role); - } - } - } - - content.disableEveryone = false; - } - - const createdMsg = await channel.createMessage(content, file); - this.savedMessages.setPermanent(createdMsg.id); - - for (const role of rolesMadeMentionable) { - role.edit({ - mentionable: false, - }); - } - - if (downloadedAttachment) { - downloadedAttachment.deleteFn(); - } - - return createdMsg; - } - - protected parseScheduleTime(str): Moment { - const dt1 = moment(str, "YYYY-MM-DD HH:mm:ss"); - if (dt1 && dt1.isValid()) return dt1; - - const dt2 = moment(str, "YYYY-MM-DD HH:mm"); - if (dt2 && dt2.isValid()) return dt2; - - const date = moment(str, "YYYY-MM-DD"); - if (date && date.isValid()) return date; - - const t1 = moment(str, "HH:mm:ss"); - if (t1 && t1.isValid()) { - if (t1.isBefore(moment())) t1.add(1, "day"); - return t1; - } - - const t2 = moment(str, "HH:mm"); - if (t2 && t2.isValid()) { - if (t2.isBefore(moment())) t2.add(1, "day"); - return t2; - } - - const delayStringMS = convertDelayStringToMS(str, "m"); - if (delayStringMS) { - return moment().add(delayStringMS, "ms"); - } - - return null; - } - - protected async scheduledPostLoop() { - const duePosts = await this.scheduledPosts.getDueScheduledPosts(); - for (const post of duePosts) { - const channel = this.guild.channels.get(post.channel_id); - if (channel instanceof TextChannel) { - const [username, discriminator] = post.author_name.split("#"); - const author: Partial = this.bot.users.get(post.author_id) || { - id: post.author_id, - username, - discriminator, - }; - - try { - const postedMessage = await this.postMessage(channel, post.content, post.attachments, post.enable_mentions); - this.logs.log(LogType.POSTED_SCHEDULED_MESSAGE, { - author: stripObjectToScalars(author), - channel: stripObjectToScalars(channel), - messageId: postedMessage.id, - }); - } catch (e) { - this.logs.log(LogType.BOT_ALERT, { - body: `Failed to post scheduled message by {userMention(author)} to {channelMention(channel)}`, - channel: stripObjectToScalars(channel), - author: stripObjectToScalars(author), - }); - logger.warn( - `Failed to post scheduled message to #${channel.name} (${channel.id}) on ${this.guild.name} (${this.guildId})`, - ); - } - } - - let shouldClear = true; - - if (post.repeat_interval) { - const nextPostAt = moment().add(post.repeat_interval, "ms"); - - if (post.repeat_until) { - const repeatUntil = moment(post.repeat_until, DBDateFormat); - if (nextPostAt.isSameOrBefore(repeatUntil)) { - await this.scheduledPosts.update(post.id, { - post_at: nextPostAt.format(DBDateFormat), - }); - shouldClear = false; - } - } else if (post.repeat_times) { - if (post.repeat_times > 1) { - await this.scheduledPosts.update(post.id, { - post_at: nextPostAt.format(DBDateFormat), - repeat_times: post.repeat_times - 1, - }); - shouldClear = false; - } - } - } - - if (shouldClear) { - await this.scheduledPosts.delete(post.id); - } - } - - this.scheduledPostLoopTimeout = setTimeout(() => this.scheduledPostLoop(), SCHEDULED_POST_CHECK_INTERVAL); - } - - /** - * Since !post and !post_embed have a lot of overlap for post scheduling, repeating, etc., that functionality is abstracted out to here - */ - async actualPostCmd( - msg: Message, - targetChannel: Channel, - content: StrictMessageContent, - opts?: { - "enable-mentions"?: boolean; - schedule?: string; - repeat?: number; - "repeat-until"?: string; - "repeat-times"?: number; - }, - ) { - if (!(targetChannel instanceof TextChannel)) { - msg.channel.createMessage(errorMessage("Channel is not a text channel")); - return; - } - - if (content == null && msg.attachments.length === 0) { - msg.channel.createMessage(errorMessage("Message content or attachment required")); - return; - } - - if (opts.repeat) { - if (opts.repeat < MIN_REPEAT_TIME) { - return this.sendErrorMessage(msg.channel, `Minimum time for -repeat is ${humanizeDuration(MIN_REPEAT_TIME)}`); - } - if (opts.repeat > MAX_REPEAT_TIME) { - return this.sendErrorMessage(msg.channel, `Max time for -repeat is ${humanizeDuration(MAX_REPEAT_TIME)}`); - } - } - - // If this is a scheduled or repeated post, figure out the next post date - let postAt; - if (opts.schedule) { - // Schedule the post to be posted later - postAt = this.parseScheduleTime(opts.schedule); - if (!postAt) { - return this.sendErrorMessage(msg.channel, "Invalid schedule time"); - } - } else if (opts.repeat) { - postAt = moment().add(opts.repeat, "ms"); - } - - // For repeated posts, make sure repeat-until or repeat-times is specified - let repeatUntil: moment.Moment = null; - let repeatTimes: number = null; - let repeatDetailsStr: string = null; - - if (opts["repeat-until"]) { - repeatUntil = this.parseScheduleTime(opts["repeat-until"]); - - // Invalid time - if (!repeatUntil) { - return this.sendErrorMessage(msg.channel, "Invalid time specified for -repeat-until"); - } - if (repeatUntil.isBefore(moment())) { - return this.sendErrorMessage(msg.channel, "You can't set -repeat-until in the past"); - } - if (repeatUntil.isAfter(MAX_REPEAT_UNTIL)) { - return this.sendErrorMessage( - msg.channel, - "Unfortunately, -repeat-until can only be at most 100 years into the future. Maybe 99 years would be enough?", - ); - } - } else if (opts["repeat-times"]) { - repeatTimes = opts["repeat-times"]; - if (repeatTimes <= 0) { - return this.sendErrorMessage(msg.channel, "-repeat-times must be 1 or more"); - } - } - - if (repeatUntil && repeatTimes) { - return this.sendErrorMessage(msg.channel, "You can only use one of -repeat-until or -repeat-times at once"); - } - - if (opts.repeat && !repeatUntil && !repeatTimes) { - return this.sendErrorMessage( - msg.channel, - "You must specify -repeat-until or -repeat-times for repeated messages", - ); - } - - if (opts.repeat) { - repeatDetailsStr = repeatUntil - ? `every ${humanizeDuration(opts.repeat)} until ${repeatUntil.format(DBDateFormat)}` - : `every ${humanizeDuration(opts.repeat)}, ${repeatTimes} times in total`; - } - - // Save schedule/repeat information in DB - if (postAt) { - if (postAt < moment()) { - return this.sendErrorMessage(msg.channel, "Post can't be scheduled to be posted in the past"); - } - - await this.scheduledPosts.create({ - author_id: msg.author.id, - author_name: `${msg.author.username}#${msg.author.discriminator}`, - channel_id: targetChannel.id, - content, - attachments: msg.attachments, - post_at: postAt.format(DBDateFormat), - enable_mentions: opts["enable-mentions"], - repeat_interval: opts.repeat, - repeat_until: repeatUntil ? repeatUntil.format(DBDateFormat) : null, - repeat_times: repeatTimes ?? null, - }); - - if (opts.repeat) { - this.logs.log(LogType.SCHEDULED_REPEATED_MESSAGE, { - author: stripObjectToScalars(msg.author), - channel: stripObjectToScalars(targetChannel), - date: postAt.format("YYYY-MM-DD"), - time: postAt.format("HH:mm:ss"), - repeatInterval: humanizeDuration(opts.repeat), - repeatDetails: repeatDetailsStr, - }); - } else { - this.logs.log(LogType.SCHEDULED_MESSAGE, { - author: stripObjectToScalars(msg.author), - channel: stripObjectToScalars(targetChannel), - date: postAt.format("YYYY-MM-DD"), - time: postAt.format("HH:mm:ss"), - }); - } - } - - // When the message isn't scheduled for later, post it immediately - if (!opts.schedule) { - await this.postMessage(targetChannel, content, msg.attachments, opts["enable-mentions"]); - } - - if (opts.repeat) { - this.logs.log(LogType.REPEATED_MESSAGE, { - author: stripObjectToScalars(msg.author), - channel: stripObjectToScalars(targetChannel), - date: postAt.format("YYYY-MM-DD"), - time: postAt.format("HH:mm:ss"), - repeatInterval: humanizeDuration(opts.repeat), - repeatDetails: repeatDetailsStr, - }); - } - - // Bot reply schenanigans - let successMessage = opts.schedule - ? `Message scheduled to be posted in <#${targetChannel.id}> on ${postAt.format("YYYY-MM-DD [at] HH:mm:ss")} (UTC)` - : `Message posted in <#${targetChannel.id}>`; - - if (opts.repeat) { - successMessage += `. Message will be automatically reposted every ${humanizeDuration(opts.repeat)}`; - - if (repeatUntil) { - successMessage += ` until ${repeatUntil.format("YYYY-MM-DD [at] HH:mm:ss")} (UTC)`; - } else if (repeatTimes) { - successMessage += `, ${repeatTimes} times in total`; - } - - successMessage += "."; - } - - if (targetChannel.id !== msg.channel.id || opts.schedule || opts.repeat) { - this.sendSuccessMessage(msg.channel, successMessage); - } - } - - /** - * COMMAND: Post a regular text message as the bot to the specified channel - */ - @d.command("post", " [content:string$]", { - options: [ - { - name: "enable-mentions", - isSwitch: true, - }, - { - name: "schedule", - type: "string", - }, - { - name: "repeat", - type: "delay", - }, - { - name: "repeat-until", - type: "string", - }, - { - name: "repeat-times", - type: "number", - }, - ], - }) - @d.permission("can_post") - async postCmd( - msg: Message, - args: { - channel: Channel; - content?: string; - "enable-mentions": boolean; - schedule?: string; - repeat?: number; - "repeat-until"?: string; - "repeat-times"?: number; - }, - ) { - this.actualPostCmd(msg, args.channel, { content: args.content }, args); - } - - /** - * COMMAND: Post a message with an embed as the bot to the specified channel - */ - @d.command("post_embed", " [maincontent:string$]", { - options: [ - { name: "title", type: "string" }, - { name: "content", type: "string" }, - { name: "color", type: "string" }, - { name: "schedule", type: "string" }, - { name: "raw", isSwitch: true, shortcut: "r" }, - { - name: "repeat", - type: "delay", - }, - { - name: "repeat-until", - type: "string", - }, - { - name: "repeat-times", - type: "number", - }, - ], - }) - @d.permission("can_post") - async postEmbedCmd( - msg: Message, - args: { - channel: Channel; - title?: string; - maincontent?: string; - content?: string; - color?: string; - schedule?: string; - raw?: boolean; - repeat?: number; - "repeat-until"?: string; - "repeat-times"?: number; - }, - ) { - const content = args.content || args.maincontent; - - if (!args.title && !content) { - msg.channel.createMessage(errorMessage("Title or content required")); - return; - } - - let color = null; - if (args.color) { - const colorMatch = args.color.match(COLOR_MATCH_REGEX); - if (!colorMatch) { - msg.channel.createMessage(errorMessage("Invalid color specified, use hex colors")); - return; - } - - color = parseInt(colorMatch[1], 16); - } - - let embed: EmbedBase = {}; - if (args.title) embed.title = args.title; - if (color) embed.color = color; - - if (content) { - if (args.raw) { - let parsed; - try { - parsed = JSON.parse(content); - } catch (e) { - this.sendErrorMessage(msg.channel, "Syntax error in embed JSON"); - return; - } - - if (!isValidEmbed(parsed)) { - this.sendErrorMessage(msg.channel, "Embed is not valid"); - return; - } - - embed = Object.assign({}, embed, parsed); - } else { - embed.description = this.formatContent(content); - } - } - - this.actualPostCmd(msg, args.channel, { embed }, args); - } - - /** - * COMMAND: Edit the specified message posted by the bot - */ - @d.command("edit", " ") - @d.permission("can_post") - async editCmd(msg, args: { messageId: string; content: string }) { - const savedMessage = await this.savedMessages.find(args.messageId); - if (!savedMessage) { - msg.channel.createMessage(errorMessage("Unknown message")); - return; - } - - if (savedMessage.user_id !== this.bot.user.id) { - msg.channel.createMessage(errorMessage("Message wasn't posted by me")); - return; - } - - await this.bot.editMessage(savedMessage.channel_id, savedMessage.id, this.formatContent(args.content)); - this.sendSuccessMessage(msg.channel, "Message edited"); - } - - /** - * COMMAND: Edit the specified message with an embed posted by the bot - */ - @d.command("edit_embed", " [maincontent:string$]", { - options: [ - { name: "title", type: "string" }, - { name: "content", type: "string" }, - { name: "color", type: "string" }, - ], - }) - @d.permission("can_post") - async editEmbedCmd( - msg: Message, - args: { messageId: string; title?: string; maincontent?: string; content?: string; color?: string }, - ) { - const savedMessage = await this.savedMessages.find(args.messageId); - if (!savedMessage) { - msg.channel.createMessage(errorMessage("Unknown message")); - return; - } - - const content = args.content || args.maincontent; - - let color = null; - if (args.color) { - const colorMatch = args.color.match(COLOR_MATCH_REGEX); - if (!colorMatch) { - msg.channel.createMessage(errorMessage("Invalid color specified, use hex colors")); - return; - } - - color = parseInt(colorMatch[1], 16); - } - - const embed: EmbedBase = savedMessage.data.embeds[0]; - if (args.title) embed.title = args.title; - if (content) embed.description = this.formatContent(content); - if (color) embed.color = color; - - await this.bot.editMessage(savedMessage.channel_id, savedMessage.id, { embed }); - await this.sendSuccessMessage(msg.channel, "Embed edited"); - - if (args.content) { - const prefix = this.guildConfig.prefix || "!"; - msg.channel.createMessage( - trimLines(` - <@!${msg.author.id}> You can now specify an embed's content directly at the end of the command: - \`${prefix}edit_embed -title "Some title" content goes here\` - The \`-content\` option will soon be removed in favor of this. - `), - ); - } - } - - @d.command("scheduled_posts", [], { - aliases: ["scheduled_posts list"], - }) - @d.permission("can_post") - async scheduledPostListCmd(msg: Message) { - const scheduledPosts = await this.scheduledPosts.all(); - if (scheduledPosts.length === 0) { - msg.channel.createMessage("No scheduled posts"); - return; - } - - scheduledPosts.sort(sorter("post_at")); - - let i = 1; - const postLines = scheduledPosts.map(p => { - let previewText = - p.content.content || (p.content.embed && (p.content.embed.description || p.content.embed.title)) || ""; - - const isTruncated = previewText.length > SCHEDULED_POST_PREVIEW_TEXT_LENGTH; - - previewText = disableCodeBlocks(deactivateMentions(previewText)) - .replace(/\s+/g, " ") - .slice(0, SCHEDULED_POST_PREVIEW_TEXT_LENGTH); - - const parts = [`\`#${i++}\` \`[${p.post_at}]\` ${previewText}${isTruncated ? "..." : ""}`]; - if (p.attachments.length) parts.push("*(with attachment)*"); - if (p.content.embed) parts.push("*(embed)*"); - if (p.repeat_until) { - parts.push(`*(repeated every ${humanizeDuration(p.repeat_interval)} until ${p.repeat_until})*`); - } - if (p.repeat_times) { - parts.push( - `*(repeated every ${humanizeDuration(p.repeat_interval)}, ${p.repeat_times} more ${ - p.repeat_times === 1 ? "time" : "times" - })*`, - ); - } - parts.push(`*(${p.author_name})*`); - - return parts.join(" "); - }); - - const finalMessage = trimLines(` - ${postLines.join("\n")} - - Use \`scheduled_posts \` to view a scheduled post in full - Use \`scheduled_posts delete \` to delete a scheduled post - `); - createChunkedMessage(msg.channel, finalMessage); - } - - @d.command("scheduled_posts delete", "", { - aliases: ["scheduled_posts d"], - }) - @d.permission("can_post") - async scheduledPostDeleteCmd(msg: Message, args: { num: number }) { - const scheduledPosts = await this.scheduledPosts.all(); - scheduledPosts.sort(sorter("post_at")); - const post = scheduledPosts[args.num - 1]; - if (!post) { - return this.sendErrorMessage(msg.channel, "Scheduled post not found"); - } - - await this.scheduledPosts.delete(post.id); - this.sendSuccessMessage(msg.channel, "Scheduled post deleted!"); - } - - @d.command("scheduled_posts", "", { - aliases: ["scheduled_posts show"], - }) - @d.permission("can_post") - async scheduledPostShowCmd(msg: Message, args: { num: number }) { - const scheduledPosts = await this.scheduledPosts.all(); - scheduledPosts.sort(sorter("post_at")); - const post = scheduledPosts[args.num - 1]; - if (!post) { - return this.sendErrorMessage(msg.channel, "Scheduled post not found"); - } - - this.postMessage(msg.channel as TextChannel, post.content, post.attachments, post.enable_mentions); - } -} diff --git a/backend/src/plugins/ReactionRoles.ts b/backend/src/plugins/ReactionRoles.ts deleted file mode 100644 index 99f79209..00000000 --- a/backend/src/plugins/ReactionRoles.ts +++ /dev/null @@ -1,433 +0,0 @@ -import { decorators as d, IPluginOptions, logger } from "knub"; -import { CustomEmoji, errorMessage, isDiscordRESTError, isSnowflake, noop, sleep } from "../utils"; -import { GuildReactionRoles } from "../data/GuildReactionRoles"; -import { Message, TextChannel } from "eris"; -import { ZeppelinPluginClass } from "./ZeppelinPluginClass"; -import { GuildSavedMessages } from "../data/GuildSavedMessages"; -import { Queue } from "../Queue"; -import { ReactionRole } from "../data/entities/ReactionRole"; -import * as t from "io-ts"; -import { ERRORS, RecoverablePluginError } from "../RecoverablePluginError"; -import Timeout = NodeJS.Timeout; - -/** - * Either of: - * [emojiId, roleId] - * [emojiId, roleId, emojiName] - * Where emojiId is either the snowflake of a custom emoji, or the actual unicode emoji - */ -const ReactionRolePair = t.union([t.tuple([t.string, t.string, t.string]), t.tuple([t.string, t.string])]); -type TReactionRolePair = t.TypeOf; - -const ConfigSchema = t.type({ - auto_refresh_interval: t.number, - can_manage: t.boolean, -}); -type TConfigSchema = t.TypeOf; - -type ReactionRolePair = [string, string, string?]; - -const MIN_AUTO_REFRESH = 1000 * 60 * 15; // 15min minimum, let's not abuse the API -const CLEAR_ROLES_EMOJI = "❌"; -const ROLE_CHANGE_BATCH_DEBOUNCE_TIME = 1500; - -type RoleChangeMode = "+" | "-"; - -type PendingMemberRoleChanges = { - timeout: Timeout; - applyFn: () => void; - changes: Array<{ - mode: RoleChangeMode; - roleId: string; - }>; -}; - -export class ReactionRolesPlugin extends ZeppelinPluginClass { - public static pluginName = "reaction_roles"; - public static configSchema = ConfigSchema; - - public static pluginInfo = { - prettyName: "Reaction roles", - }; - - protected reactionRoles: GuildReactionRoles; - protected savedMessages: GuildSavedMessages; - - protected reactionRemoveQueue: Queue; - protected roleChangeQueue: Queue; - protected pendingRoleChanges: Map; - protected pendingRefreshes: Set; - - private autoRefreshTimeout; - - public static getStaticDefaultOptions(): IPluginOptions { - return { - config: { - auto_refresh_interval: MIN_AUTO_REFRESH, - - can_manage: false, - }, - - overrides: [ - { - level: ">=100", - config: { - can_manage: true, - }, - }, - ], - }; - } - - async onLoad() { - this.reactionRoles = GuildReactionRoles.getGuildInstance(this.guildId); - this.savedMessages = GuildSavedMessages.getGuildInstance(this.guildId); - this.reactionRemoveQueue = new Queue(); - this.roleChangeQueue = new Queue(); - this.pendingRoleChanges = new Map(); - this.pendingRefreshes = new Set(); - - let autoRefreshInterval = this.getConfig().auto_refresh_interval; - if (autoRefreshInterval != null) { - autoRefreshInterval = Math.max(MIN_AUTO_REFRESH, autoRefreshInterval); - this.autoRefreshLoop(autoRefreshInterval); - } - } - - async onUnload() { - if (this.autoRefreshTimeout) { - clearTimeout(this.autoRefreshTimeout); - } - } - - async autoRefreshLoop(interval: number) { - this.autoRefreshTimeout = setTimeout(async () => { - await this.runAutoRefresh(); - this.autoRefreshLoop(interval); - }, interval); - } - - async runAutoRefresh() { - // Refresh reaction roles on all reaction role messages - const reactionRoles = await this.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 this.refreshReactionRoles(channelId, messageId); - } - } - - /** - * Refreshes the reaction roles in a message. Basically just calls applyReactionRoleReactionsToMessage(). - */ - async refreshReactionRoles(channelId: string, messageId: string) { - const pendingKey = `${channelId}-${messageId}`; - if (this.pendingRefreshes.has(pendingKey)) return; - this.pendingRefreshes.add(pendingKey); - - try { - const reactionRoles = await this.reactionRoles.getForMessage(messageId); - await this.applyReactionRoleReactionsToMessage(channelId, messageId, reactionRoles); - } finally { - this.pendingRefreshes.delete(pendingKey); - } - } - - /** - * Applies the reactions from the specified reaction roles to a message - */ - async applyReactionRoleReactionsToMessage(channelId: string, messageId: string, reactionRoles: ReactionRole[]) { - const channel = this.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 ${this.guild.name} (${this.guildId})`, - ); - await this.reactionRoles.removeFromMessage(messageId); - } else { - logger.warn( - `Error when applying reaction roles to message ${channelId}/${messageId} in guild ${this.guild.name} (${this.guildId}), 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); - } - - /** - * Adds a pending role change for a member. After a delay, all pending role changes for a member are applied at once. - * This delay is refreshed any time new pending changes are added (i.e. "debounced"). - */ - async addMemberPendingRoleChange(memberId: string, mode: RoleChangeMode, roleId: string) { - if (!this.pendingRoleChanges.has(memberId)) { - const newPendingRoleChangeObj: PendingMemberRoleChanges = { - timeout: null, - changes: [], - applyFn: async () => { - this.pendingRoleChanges.delete(memberId); - - const lock = await this.locks.acquire(`member-roles-${memberId}`); - - const member = await this.getMember(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(); - }, - }; - - this.pendingRoleChanges.set(memberId, newPendingRoleChangeObj); - } - - const pendingRoleChangeObj = this.pendingRoleChanges.get(memberId); - pendingRoleChangeObj.changes.push({ mode, roleId }); - - if (pendingRoleChangeObj.timeout) clearTimeout(pendingRoleChangeObj.timeout); - pendingRoleChangeObj.timeout = setTimeout( - () => this.roleChangeQueue.add(pendingRoleChangeObj.applyFn), - ROLE_CHANGE_BATCH_DEBOUNCE_TIME, - ); - } - - /** - * COMMAND: Clear reaction roles from the specified message - */ - @d.command("reaction_roles clear", "") - @d.permission("can_manage") - async clearReactionRolesCmd(msg: Message, args: { messageId: string }) { - const savedMessage = await this.savedMessages.find(args.messageId); - if (!savedMessage) { - msg.channel.createMessage(errorMessage("Unknown message")); - return; - } - - const existingReactionRoles = this.reactionRoles.getForMessage(args.messageId); - if (!existingReactionRoles) { - msg.channel.createMessage(errorMessage("Message doesn't have reaction roles on it")); - return; - } - - this.reactionRoles.removeFromMessage(args.messageId); - - const channel = this.guild.channels.get(savedMessage.channel_id) as TextChannel; - const targetMessage = await channel.getMessage(savedMessage.id); - await targetMessage.removeReactions(); - - this.sendSuccessMessage(msg.channel, "Reaction roles cleared"); - } - - /** - * COMMAND: Refresh reaction roles in the specified message by removing all reactions and re-adding them - */ - @d.command("reaction_roles refresh", "") - @d.permission("can_manage") - async refreshReactionRolesCmd(msg: Message, args: { messageId: string }) { - const savedMessage = await this.savedMessages.find(args.messageId); - if (!savedMessage) { - msg.channel.createMessage(errorMessage("Unknown message")); - return; - } - - if (this.pendingRefreshes.has(`${savedMessage.channel_id}-${savedMessage.id}`)) { - msg.channel.createMessage(errorMessage("Another refresh in progress")); - return; - } - - await this.refreshReactionRoles(savedMessage.channel_id, savedMessage.id); - - this.sendSuccessMessage(msg.channel, "Reaction roles refreshed"); - } - - /** - * COMMAND: Initialize reaction roles on a message. - * The second parameter, reactionRolePairs, is a list of emoji/role pairs separated by a newline. For example: - * :zep_twitch: = 473086848831455234 - * :zep_ps4: = 543184300250759188 - */ - @d.command("reaction_roles", " ", { - options: [ - { - name: "exclusive", - shortcut: "e", - isSwitch: true, - }, - ], - }) - @d.permission("can_manage") - async reactionRolesCmd(msg: Message, args: { messageId: string; reactionRolePairs: string; exclusive?: boolean }) { - const savedMessage = await this.savedMessages.find(args.messageId); - if (!savedMessage) { - msg.channel.createMessage(errorMessage("Unknown message")); - return; - } - - const channel = await this.guild.channels.get(savedMessage.channel_id); - if (!channel || !(channel instanceof TextChannel)) { - msg.channel.createMessage(errorMessage("Channel no longer exists")); - return; - } - - const targetMessage = await channel.getMessage(args.messageId); - if (!targetMessage) { - msg.channel.createMessage(errorMessage("Unknown message (2)")); - return; - } - - // Clear old reaction roles for the message from the DB - await this.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(/^$/); - 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) { - msg.channel.createMessage( - errorMessage(`The emoji for clearing roles (${CLEAR_ROLES_EMOJI}) is reserved and cannot be used`), - ); - return; - } - - try { - if (!this.canUseEmoji(pair[0])) { - msg.channel.createMessage( - errorMessage("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) { - msg.channel.createMessage(errorMessage(`Invalid emoji: ${pair[0]}`)); - return; - } - - throw e; - } - - if (!this.guild.roles.has(pair[1])) { - msg.channel.createMessage(errorMessage(`Unknown role ${pair[1]}`)); - return; - } - } - - // Save the new reaction roles to the database - for (const pair of emojiRolePairs) { - await this.reactionRoles.add(channel.id, targetMessage.id, pair[0], pair[1], args.exclusive); - } - - // Apply the reactions themselves - const reactionRoles = await this.reactionRoles.getForMessage(targetMessage.id); - await this.applyReactionRoleReactionsToMessage(targetMessage.channel.id, targetMessage.id, reactionRoles); - - this.sendSuccessMessage(msg.channel, "Reaction roles added"); - } - - /** - * When a reaction is added to a message with reaction roles, see which role that reaction matches (if any) and queue - * those role changes for the member. Multiple role changes in rapid succession are batched and applied at once. - * Reacting with CLEAR_ROLES_EMOJI will queue a removal of all roles granted by this message's reaction roles. - */ - @d.event("messageReactionAdd") - async onAddReaction(msg: Message, emoji: CustomEmoji, userId: string) { - // Make sure this message has reaction roles on it - const reactionRoles = await this.reactionRoles.getForMessage(msg.id); - if (reactionRoles.length === 0) return; - - const member = await this.getMember(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) { - this.addMemberPendingRoleChange(userId, "-", roleId); - } - - this.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 this.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 this.reactionRoles.getForMessage(msg.id); - for (const reactionRole of messageReactionRoles) { - this.addMemberPendingRoleChange(userId, "-", reactionRole.role_id); - } - } - - this.addMemberPendingRoleChange(userId, "+", matchingReactionRole.role_id); - } - - // Remove the reaction after a small delay - setTimeout(() => { - this.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); - } -} diff --git a/backend/src/plugins/Reminders.ts b/backend/src/plugins/Reminders.ts deleted file mode 100644 index 44fbd614..00000000 --- a/backend/src/plugins/Reminders.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { decorators as d, IPluginOptions } from "knub"; -import { ZeppelinPluginClass } from "./ZeppelinPluginClass"; -import { GuildReminders } from "../data/GuildReminders"; -import { Message, TextChannel } from "eris"; -import moment from "moment-timezone"; -import humanizeDuration from "humanize-duration"; -import { - convertDelayStringToMS, - createChunkedMessage, - disableLinkPreviews, - errorMessage, - sorter, - successMessage, -} from "../utils"; -import * as t from "io-ts"; - -const ConfigSchema = t.type({ - can_use: t.boolean, -}); -type TConfigSchema = t.TypeOf; - -const REMINDER_LOOP_TIME = 10 * 1000; -const MAX_TRIES = 3; - -export class RemindersPlugin extends ZeppelinPluginClass { - public static pluginName = "reminders"; - public static configSchema = ConfigSchema; - - public static pluginInfo = { - prettyName: "Reminders", - }; - - protected reminders: GuildReminders; - protected tries: Map; - - private postRemindersTimeout; - private unloaded = false; - - public static getStaticDefaultOptions(): IPluginOptions { - return { - config: { - can_use: false, - }, - - overrides: [ - { - level: ">=50", - config: { - can_use: true, - }, - }, - ], - }; - } - - onLoad() { - this.reminders = GuildReminders.getGuildInstance(this.guildId); - this.tries = new Map(); - this.postDueRemindersLoop(); - } - - onUnload() { - clearTimeout(this.postRemindersTimeout); - this.unloaded = true; - } - - async postDueRemindersLoop() { - const pendingReminders = await this.reminders.getDueReminders(); - for (const reminder of pendingReminders) { - const channel = this.guild.channels.get(reminder.channel_id); - if (channel && channel instanceof TextChannel) { - try { - // Only show created at date if one exists - if (moment(reminder.created_at).isValid()) { - const target = moment(); - const diff = target.diff(moment(reminder.created_at, "YYYY-MM-DD HH:mm:ss")); - const result = humanizeDuration(diff, { largest: 2, round: true }); - await channel.createMessage( - disableLinkPreviews( - `Reminder for <@!${reminder.user_id}>: ${reminder.body} \n\`Set at ${reminder.created_at} (${result} ago)\``, - ), - ); - } else { - await channel.createMessage(disableLinkPreviews(`Reminder for <@!${reminder.user_id}>: ${reminder.body}`)); - } - } catch (e) { - // Probably random Discord internal server error or missing permissions or somesuch - // Try again next round unless we've already tried to post this a bunch of times - const tries = this.tries.get(reminder.id) || 0; - if (tries < MAX_TRIES) { - this.tries.set(reminder.id, tries + 1); - continue; - } - } - } - - await this.reminders.delete(reminder.id); - } - - if (!this.unloaded) { - this.postRemindersTimeout = setTimeout(() => this.postDueRemindersLoop(), REMINDER_LOOP_TIME); - } - } - - @d.command("remind", " [reminder:string$]", { - aliases: ["remindme"], - }) - @d.permission("can_use") - async addReminderCmd(msg: Message, args: { time: string; reminder?: string }) { - const now = moment(); - - let reminderTime; - if (args.time.match(/^\d{4}-\d{1,2}-\d{1,2}$/)) { - // Date in YYYY-MM-DD format, remind at current time on that date - reminderTime = moment(args.time, "YYYY-M-D").set({ - hour: now.hour(), - minute: now.minute(), - second: now.second(), - }); - } else if (args.time.match(/^\d{4}-\d{1,2}-\d{1,2}T\d{2}:\d{2}$/)) { - // Date and time in YYYY-MM-DD[T]HH:mm format - reminderTime = moment(args.time, "YYYY-M-D[T]HH:mm").second(0); - } else { - // "Delay string" i.e. e.g. "2h30m" - const ms = convertDelayStringToMS(args.time); - if (ms === null) { - msg.channel.createMessage(errorMessage("Invalid reminder time")); - return; - } - - reminderTime = moment().add(ms, "millisecond"); - } - - if (!reminderTime.isValid() || reminderTime.isBefore(now)) { - msg.channel.createMessage(errorMessage("Invalid reminder time")); - return; - } - - const reminderBody = args.reminder || `https://discord.com/channels/${this.guildId}/${msg.channel.id}/${msg.id}`; - await this.reminders.add( - msg.author.id, - msg.channel.id, - reminderTime.format("YYYY-MM-DD HH:mm:ss"), - reminderBody, - moment().format("YYYY-MM-DD HH:mm:ss"), - ); - - const msUntilReminder = reminderTime.diff(now); - const timeUntilReminder = humanizeDuration(msUntilReminder, { largest: 2, round: true }); - this.sendSuccessMessage( - msg.channel, - `I will remind you in **${timeUntilReminder}** at **${reminderTime.format("YYYY-MM-DD, HH:mm")}**`, - ); - } - - @d.command("reminders") - @d.permission("can_use") - async reminderListCmd(msg: Message) { - const reminders = await this.reminders.getRemindersByUserId(msg.author.id); - if (reminders.length === 0) { - msg.channel.createMessage(errorMessage("No reminders")); - return; - } - - reminders.sort(sorter("remind_at")); - const longestNum = (reminders.length + 1).toString().length; - const lines = Array.from(reminders.entries()).map(([i, reminder]) => { - const num = i + 1; - const paddedNum = num.toString().padStart(longestNum, " "); - const target = moment(reminder.remind_at, "YYYY-MM-DD HH:mm:ss"); - const diff = target.diff(moment()); - const result = humanizeDuration(diff, { largest: 2, round: true }); - return `\`${paddedNum}.\` \`${reminder.remind_at} (${result})\` ${reminder.body}`; - }); - - createChunkedMessage(msg.channel, lines.join("\n")); - } - - @d.command("reminders delete", "", { - aliases: ["reminders d"], - }) - @d.permission("can_use") - async deleteReminderCmd(msg: Message, args: { num: number }) { - const reminders = await this.reminders.getRemindersByUserId(msg.author.id); - reminders.sort(sorter("remind_at")); - const lastNum = reminders.length + 1; - - if (args.num > lastNum || args.num < 0) { - msg.channel.createMessage(errorMessage("Unknown reminder")); - return; - } - - const toDelete = reminders[args.num - 1]; - await this.reminders.delete(toDelete.id); - - this.sendSuccessMessage(msg.channel, "Reminder deleted"); - } -} diff --git a/backend/src/plugins/Roles.ts b/backend/src/plugins/Roles.ts deleted file mode 100644 index e179b53e..00000000 --- a/backend/src/plugins/Roles.ts +++ /dev/null @@ -1,321 +0,0 @@ -import { trimPluginDescription, ZeppelinPluginClass } from "./ZeppelinPluginClass"; -import * as t from "io-ts"; -import { resolveMember, stripObjectToScalars, successMessage, verboseUserMention } from "../utils"; -import { decorators as d, IPluginOptions, logger } from "knub"; -import { GuildChannel, Member, Message } from "eris"; -import { GuildLogs } from "../data/GuildLogs"; -import { LogType } from "../data/LogType"; - -const ConfigSchema = t.type({ - can_assign: t.boolean, - can_mass_assign: t.boolean, - assignable_roles: t.array(t.string), -}); -type TConfigSchema = t.TypeOf; - -export class RolesPlugin extends ZeppelinPluginClass { - public static pluginName = "roles"; - public static configSchema = ConfigSchema; - - public static pluginInfo = { - prettyName: "Roles", - description: trimPluginDescription(` - Enables authorised users to add and remove whitelisted roles with a command. - `), - }; - - protected logs: GuildLogs; - - onLoad() { - this.logs = new GuildLogs(this.guildId); - } - - public static getStaticDefaultOptions(): IPluginOptions { - return { - config: { - can_assign: false, - can_mass_assign: false, - assignable_roles: [], - }, - overrides: [ - { - level: ">=50", - config: { - can_assign: true, - }, - }, - { - level: ">=100", - config: { - can_mass_assign: true, - }, - }, - ], - }; - } - - @d.command("addrole", " ", { - extra: { - info: { - description: "Add a role to the specified member", - }, - }, - }) - @d.permission("can_assign") - async addRoleCmd(msg: Message, args: { member: Member; role: string }) { - if (!this.canActOn(msg.member, args.member, true)) { - return this.sendErrorMessage(msg.channel, "Cannot add roles to this user: insufficient permissions"); - } - - const roleId = await this.resolveRoleId(args.role); - if (!roleId) { - return this.sendErrorMessage(msg.channel, "Invalid role id"); - } - - const config = this.getConfigForMsg(msg); - if (!config.assignable_roles.includes(roleId)) { - return this.sendErrorMessage(msg.channel, "You cannot assign that role"); - } - - // Sanity check: make sure the role is configured properly - const role = (msg.channel as GuildChannel).guild.roles.get(roleId); - if (!role) { - this.logs.log(LogType.BOT_ALERT, { - body: `Unknown role configured for 'roles' plugin: ${roleId}`, - }); - return this.sendErrorMessage(msg.channel, "You cannot assign that role"); - } - - if (args.member.roles.includes(roleId)) { - return this.sendErrorMessage(msg.channel, "Member already has that role"); - } - - this.logs.ignoreLog(LogType.MEMBER_ROLE_ADD, args.member.id); - - await args.member.addRole(roleId); - - this.logs.log(LogType.MEMBER_ROLE_ADD, { - member: stripObjectToScalars(args.member, ["user", "roles"]), - roles: role.name, - mod: stripObjectToScalars(msg.author), - }); - - this.sendSuccessMessage(msg.channel, `Added role **${role.name}** to ${verboseUserMention(args.member.user)}!`); - } - - @d.command("massaddrole", " ") - @d.permission("can_mass_assign") - async massAddRoleCmd(msg: Message, args: { role: string; members: string[] }) { - msg.channel.createMessage(`Resolving members...`); - - const members = []; - const unknownMembers = []; - for (const memberId of args.members) { - const member = await resolveMember(this.bot, this.guild, memberId); - if (member) members.push(member); - else unknownMembers.push(memberId); - } - - for (const member of members) { - if (!this.canActOn(msg.member, member, true)) { - return this.sendErrorMessage( - msg.channel, - "Cannot add roles to 1 or more specified members: insufficient permissions", - ); - } - } - - const roleId = await this.resolveRoleId(args.role); - if (!roleId) { - return this.sendErrorMessage(msg.channel, "Invalid role id"); - } - - const config = this.getConfigForMsg(msg); - if (!config.assignable_roles.includes(roleId)) { - return this.sendErrorMessage(msg.channel, "You cannot assign that role"); - } - - const role = this.guild.roles.get(roleId); - if (!role) { - this.logs.log(LogType.BOT_ALERT, { - body: `Unknown role configured for 'roles' plugin: ${roleId}`, - }); - return this.sendErrorMessage(msg.channel, "You cannot assign that role"); - } - - const membersWithoutTheRole = members.filter(m => !m.roles.includes(roleId)); - let assigned = 0; - const failed = []; - const alreadyHadRole = members.length - membersWithoutTheRole.length; - - msg.channel.createMessage( - `Adding role **${role.name}** to ${membersWithoutTheRole.length} ${ - membersWithoutTheRole.length === 1 ? "member" : "members" - }...`, - ); - - for (const member of membersWithoutTheRole) { - try { - this.logs.ignoreLog(LogType.MEMBER_ROLE_ADD, member.id); - await member.addRole(roleId); - this.logs.log(LogType.MEMBER_ROLE_ADD, { - member: stripObjectToScalars(member, ["user", "roles"]), - roles: role.name, - mod: stripObjectToScalars(msg.author), - }); - assigned++; - } catch (e) { - logger.warn(`Error when adding role via !massaddrole: ${e.message}`); - failed.push(member.id); - } - } - - let resultMessage = `Added role **${role.name}** to ${assigned} ${assigned === 1 ? "member" : "members"}!`; - if (alreadyHadRole) { - resultMessage += ` ${alreadyHadRole} ${alreadyHadRole === 1 ? "member" : "members"} already had the role.`; - } - - if (failed.length) { - resultMessage += `\nFailed to add the role to the following members: ${failed.join(", ")}`; - } - - if (unknownMembers.length) { - resultMessage += `\nUnknown members: ${unknownMembers.join(", ")}`; - } - - msg.channel.createMessage(successMessage(resultMessage)); - } - - @d.command("removerole", " ", { - extra: { - info: { - description: "Remove a role from the specified member", - }, - }, - }) - @d.permission("can_assign") - async removeRoleCmd(msg: Message, args: { member: Member; role: string }) { - if (!this.canActOn(msg.member, args.member, true)) { - return this.sendErrorMessage(msg.channel, "Cannot remove roles from this user: insufficient permissions"); - } - - const roleId = await this.resolveRoleId(args.role); - if (!roleId) { - return this.sendErrorMessage(msg.channel, "Invalid role id"); - } - - const config = this.getConfigForMsg(msg); - if (!config.assignable_roles.includes(roleId)) { - return this.sendErrorMessage(msg.channel, "You cannot remove that role"); - } - - // Sanity check: make sure the role is configured properly - const role = (msg.channel as GuildChannel).guild.roles.get(roleId); - if (!role) { - this.logs.log(LogType.BOT_ALERT, { - body: `Unknown role configured for 'roles' plugin: ${roleId}`, - }); - return this.sendErrorMessage(msg.channel, "You cannot remove that role"); - } - - if (!args.member.roles.includes(roleId)) { - return this.sendErrorMessage(msg.channel, "Member doesn't have that role"); - } - - this.logs.ignoreLog(LogType.MEMBER_ROLE_REMOVE, args.member.id); - - await args.member.removeRole(roleId); - - this.logs.log(LogType.MEMBER_ROLE_REMOVE, { - member: stripObjectToScalars(args.member, ["user", "roles"]), - roles: role.name, - mod: stripObjectToScalars(msg.author), - }); - - this.sendSuccessMessage( - msg.channel, - `Removed role **${role.name}** removed from ${verboseUserMention(args.member.user)}!`, - ); - } - - @d.command("massremoverole", " ") - @d.permission("can_mass_assign") - async massRemoveRoleCmd(msg: Message, args: { role: string; members: string[] }) { - const members = []; - const unknownMembers = []; - for (const memberId of args.members) { - const member = await resolveMember(this.bot, this.guild, memberId); - if (member) members.push(member); - else unknownMembers.push(memberId); - } - - for (const member of members) { - if (!this.canActOn(msg.member, member, true)) { - return this.sendErrorMessage( - msg.channel, - "Cannot add roles to 1 or more specified members: insufficient permissions", - ); - } - } - - const roleId = await this.resolveRoleId(args.role); - if (!roleId) { - return this.sendErrorMessage(msg.channel, "Invalid role id"); - } - - const config = this.getConfigForMsg(msg); - if (!config.assignable_roles.includes(roleId)) { - return this.sendErrorMessage(msg.channel, "You cannot remove that role"); - } - - const role = this.guild.roles.get(roleId); - if (!role) { - this.logs.log(LogType.BOT_ALERT, { - body: `Unknown role configured for 'roles' plugin: ${roleId}`, - }); - return this.sendErrorMessage(msg.channel, "You cannot remove that role"); - } - - const membersWithTheRole = members.filter(m => m.roles.includes(roleId)); - let assigned = 0; - const failed = []; - const didNotHaveRole = members.length - membersWithTheRole.length; - - msg.channel.createMessage( - `Removing role **${role.name}** from ${membersWithTheRole.length} ${ - membersWithTheRole.length === 1 ? "member" : "members" - }...`, - ); - - for (const member of membersWithTheRole) { - try { - this.logs.ignoreLog(LogType.MEMBER_ROLE_REMOVE, member.id); - await member.removeRole(roleId); - this.logs.log(LogType.MEMBER_ROLE_REMOVE, { - member: stripObjectToScalars(member, ["user", "roles"]), - roles: role.name, - mod: stripObjectToScalars(msg.author), - }); - assigned++; - } catch (e) { - logger.warn(`Error when removing role via !massremoverole: ${e.message}`); - failed.push(member.id); - } - } - - let resultMessage = `Removed role **${role.name}** from ${assigned} ${assigned === 1 ? "member" : "members"}!`; - if (didNotHaveRole) { - resultMessage += ` ${didNotHaveRole} ${didNotHaveRole === 1 ? "member" : "members"} didn't have the role.`; - } - - if (failed.length) { - resultMessage += `\nFailed to remove the role from the following members: ${failed.join(", ")}`; - } - - if (unknownMembers.length) { - resultMessage += `\nUnknown members: ${unknownMembers.join(", ")}`; - } - - msg.channel.createMessage(successMessage(resultMessage)); - } -} diff --git a/backend/src/plugins/SelfGrantableRolesPlugin.ts b/backend/src/plugins/SelfGrantableRolesPlugin.ts deleted file mode 100644 index fc34ef91..00000000 --- a/backend/src/plugins/SelfGrantableRolesPlugin.ts +++ /dev/null @@ -1,349 +0,0 @@ -import { decorators as d, IPluginOptions } from "knub"; -import { GuildChannel, Message, Role, TextChannel } from "eris"; -import { asSingleLine, chunkArray, errorMessage, sorter, successMessage, tDeepPartial, trimLines } from "../utils"; -import { trimPluginDescription, ZeppelinPluginClass } from "./ZeppelinPluginClass"; -import * as t from "io-ts"; - -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); -type TSelfGrantableRoleEntry = t.TypeOf; - -const ConfigSchema = t.type({ - entries: t.record(t.string, SelfGrantableRoleEntry), - mention_roles: t.boolean, -}); -type TConfigSchema = t.TypeOf; - -const PartialConfigSchema = tDeepPartial(ConfigSchema); - -const defaultSelfGrantableRoleEntry: t.TypeOf = { - can_use: false, - can_ignore_cooldown: false, - max_roles: 0, -}; - -export class SelfGrantableRolesPlugin extends ZeppelinPluginClass { - public static pluginName = "self_grantable_roles"; - public static showInDocs = true; - public static configSchema = ConfigSchema; - - public static pluginInfo = { - 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 - ~~~ - `), - }; - - public static getStaticDefaultOptions(): IPluginOptions { - return { - config: { - entries: {}, - mention_roles: false, - }, - }; - } - - protected static preprocessStaticConfig(config: t.TypeOf) { - 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 config; - } - - protected splitRoleNames(roleNames: string[]) { - return roleNames - .map(v => v.split(/[\s,]+/)) - .flat() - .filter(Boolean); - } - - protected normalizeRoleNames(roleNames: string[]) { - return roleNames.map(v => v.toLowerCase()); - } - - protected getApplyingEntries(msg): TSelfGrantableRoleEntry[] { - const config = this.getConfigForMsg(msg); - return Object.entries(config.entries) - .filter( - ([k, e]) => e.can_use && !(!e.can_ignore_cooldown && this.cooldowns.isOnCooldown(`${k}:${msg.author.id}`)), - ) - .map(pair => pair[1]); - } - - protected 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); - } - - @d.command("role help", [], { - aliases: ["role"], - }) - async roleHelpCmd(msg: Message) { - const applyingEntries = this.getApplyingEntries(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 = this.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 }); - } - - @d.command("role remove", "") - async roleRemoveCmd(msg: Message, args: { roleNames: string[] }) { - const lock = await this.locks.acquire(`grantableRoles:${msg.author.id}`); - - const applyingEntries = this.getApplyingEntries(msg); - if (applyingEntries.length === 0) { - lock.unlock(); - return; - } - - const roleNames = this.normalizeRoleNames(this.splitRoleNames(args.roleNames)); - const matchedRoleIds = this.findMatchingRoles(roleNames, applyingEntries); - - const rolesToRemove = Array.from(matchedRoleIds.values()).map(id => this.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) { - this.sendSuccessMessage( - msg.channel, - `<@!${msg.author.id}> Removed ${removedRolesStr.join(", ")} ${removedRolesWord};` + - ` couldn't recognize the other roles you mentioned`, - ); - } else { - this.sendSuccessMessage( - msg.channel, - `<@!${msg.author.id}> Removed ${removedRolesStr.join(", ")} ${removedRolesWord}`, - ); - } - } catch (e) { - this.sendSuccessMessage(msg.channel, `<@!${msg.author.id}> Got an error while trying to remove the roles`); - } - } else { - msg.channel.createMessage( - errorMessage(`<@!${msg.author.id}> Unknown ${args.roleNames.length === 1 ? "role" : "roles"}`), - ); - } - - lock.unlock(); - } - - @d.command("role", "") - async roleCmd(msg: Message, args: { roleNames: string[] }) { - const lock = await this.locks.acquire(`grantableRoles:${msg.author.id}`); - - const applyingEntries = this.getApplyingEntries(msg); - if (applyingEntries.length === 0) { - lock.unlock(); - return; - } - - const roleNames = this.normalizeRoleNames(this.splitRoleNames(args.roleNames)); - const matchedRoleIds = this.findMatchingRoles(roleNames, applyingEntries); - - const hasUnknownRoles = matchedRoleIds.length !== roleNames.length; - - const rolesToAdd: Map = Array.from(matchedRoleIds.values()) - .map(id => this.guild.roles.get(id)) - .filter(Boolean) - .reduce((map, role) => { - map.set(role.id, role); - return map; - }, new Map()); - - if (!rolesToAdd.size) { - this.sendErrorMessage( - 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 = new Set(); - const removed: Set = 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(this.guild.roles.get(roleId)); - } else { - skipped.add(this.guild.roles.get(roleId)); - } - } - } - } - } - - try { - await msg.member.edit({ - roles: Array.from(newRoleIds), - }); - } catch (e) { - this.sendErrorMessage(msg.channel, `<@!${msg.author.id}> Got an error while trying to grant you the roles`); - return; - } - - const mentionRoles = this.getConfig().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"); - } - - this.sendSuccessMessage(msg.channel, `<@!${msg.author.id}> ${messageParts.join("; ")}`); - - lock.unlock(); - } -} diff --git a/backend/src/plugins/Slowmode.ts b/backend/src/plugins/Slowmode.ts deleted file mode 100644 index ae059ea5..00000000 --- a/backend/src/plugins/Slowmode.ts +++ /dev/null @@ -1,440 +0,0 @@ -import { decorators as d, IPluginOptions, logger } from "knub"; -import { GuildChannel, Message, TextChannel, Constants as ErisConstants, User } from "eris"; -import { - convertDelayStringToMS, - createChunkedMessage, - errorMessage, - isDiscordRESTError, - noop, - stripObjectToScalars, - successMessage, - UnknownUser, -} from "../utils"; -import { GuildSlowmodes } from "../data/GuildSlowmodes"; -import humanizeDuration from "humanize-duration"; -import { ZeppelinPluginClass } from "./ZeppelinPluginClass"; -import { SavedMessage } from "../data/entities/SavedMessage"; -import { GuildSavedMessages } from "../data/GuildSavedMessages"; -import { GuildLogs } from "../data/GuildLogs"; -import { LogType } from "../data/LogType"; -import * as t from "io-ts"; - -const ConfigSchema = t.type({ - use_native_slowmode: t.boolean, - - can_manage: t.boolean, - is_affected: t.boolean, -}); -type TConfigSchema = t.TypeOf; - -const NATIVE_SLOWMODE_LIMIT = 6 * 60 * 60; // 6 hours -const MAX_SLOWMODE = 60 * 60 * 24 * 365 * 100; // 100 years -const BOT_SLOWMODE_CLEAR_INTERVAL = 60 * 1000; - -export class SlowmodePlugin extends ZeppelinPluginClass { - public static pluginName = "slowmode"; - public static configSchema = ConfigSchema; - - public static pluginInfo = { - prettyName: "Slowmode", - }; - - protected slowmodes: GuildSlowmodes; - protected savedMessages: GuildSavedMessages; - protected logs: GuildLogs; - protected clearInterval; - - private onMessageCreateFn; - - public static getStaticDefaultOptions(): IPluginOptions { - return { - config: { - use_native_slowmode: true, - - can_manage: false, - is_affected: true, - }, - - overrides: [ - { - level: ">=50", - config: { - can_manage: true, - is_affected: false, - }, - }, - ], - }; - } - - onLoad() { - this.slowmodes = GuildSlowmodes.getGuildInstance(this.guildId); - this.savedMessages = GuildSavedMessages.getGuildInstance(this.guildId); - this.logs = new GuildLogs(this.guildId); - this.clearInterval = setInterval(() => this.clearExpiredSlowmodes(), BOT_SLOWMODE_CLEAR_INTERVAL); - - this.onMessageCreateFn = this.onMessageCreate.bind(this); - this.savedMessages.events.on("create", this.onMessageCreateFn); - } - - onUnload() { - clearInterval(this.clearInterval); - this.savedMessages.events.off("create", this.onMessageCreateFn); - } - - /** - * Applies a bot-maintained slowmode to the specified user id on the specified channel. - * This sets the channel permissions so the user is unable to send messages there, and saves the slowmode in the db. - */ - async applyBotSlowmodeToUserId(channel: GuildChannel & TextChannel, userId: string) { - // Deny sendMessage permission from the user. If there are existing permission overwrites, take those into account. - const existingOverride = channel.permissionOverwrites.get(userId); - const newDeniedPermissions = - (existingOverride ? existingOverride.deny : 0) | ErisConstants.Permissions.sendMessages; - const newAllowedPermissions = - (existingOverride ? existingOverride.allow : 0) & ~ErisConstants.Permissions.sendMessages; - - try { - await channel.editPermission(userId, newAllowedPermissions, newDeniedPermissions, "member"); - } catch (e) { - const user = this.bot.users.get(userId) || new UnknownUser({ id: userId }); - - if (isDiscordRESTError(e) && e.code === 50013) { - logger.warn( - `Missing permissions to apply bot slowmode to user ${userId} on channel ${channel.name} (${channel.id}) on server ${this.guild.name} (${this.guildId})`, - ); - this.logs.log(LogType.BOT_ALERT, { - body: `Missing permissions to apply bot slowmode to {userMention(user)} in {channelMention(channel)}`, - user: stripObjectToScalars(user), - channel: stripObjectToScalars(channel), - }); - } else { - this.logs.log(LogType.BOT_ALERT, { - body: `Failed to apply bot slowmode to {userMention(user)} in {channelMention(channel)}`, - user: stripObjectToScalars(user), - channel: stripObjectToScalars(channel), - }); - throw e; - } - } - - await this.slowmodes.addSlowmodeUser(channel.id, userId); - } - - /** - * Clears bot-maintained slowmode from the specified user id on the specified channel. - * This reverts the channel permissions changed above and clears the database entry. - */ - async clearBotSlowmodeFromUserId(channel: GuildChannel & TextChannel, userId: string, force = false) { - try { - // Remove permission overrides from the channel for this user - // Previously we diffed the overrides so we could clear the "send messages" override without touching other - // overrides. Unfortunately, it seems that was a bit buggy - we didn't always receive the event for the changed - // overrides and then we also couldn't diff against them. For consistency's sake, we just delete the override now. - await channel.deletePermission(userId); - } catch (e) { - if (!force) { - throw e; - } - } - - await this.slowmodes.clearSlowmodeUser(channel.id, userId); - } - - /** - * Disable slowmode on the specified channel. Clears any existing slowmode perms. - */ - async disableBotSlowmodeForChannel(channel: GuildChannel & TextChannel) { - // Disable channel slowmode - await this.slowmodes.deleteChannelSlowmode(channel.id); - - // Remove currently applied slowmodes - const users = await this.slowmodes.getChannelSlowmodeUsers(channel.id); - const failedUsers = []; - - for (const slowmodeUser of users) { - try { - await this.clearBotSlowmodeFromUserId(channel, slowmodeUser.user_id); - } catch (e) { - // Removing the slowmode failed. Record this so the permissions can be changed manually, and remove the database entry. - failedUsers.push(slowmodeUser.user_id); - await this.slowmodes.clearSlowmodeUser(channel.id, slowmodeUser.user_id); - } - } - - return { failedUsers }; - } - - /** - * COMMAND: Disable slowmode on the specified channel - */ - @d.command("slowmode disable", "") - @d.permission("can_manage") - async disableSlowmodeCmd(msg: Message, args: { channel: GuildChannel & TextChannel }) { - const botSlowmode = await this.slowmodes.getChannelSlowmode(args.channel.id); - const hasNativeSlowmode = args.channel.rateLimitPerUser; - - if (!botSlowmode && hasNativeSlowmode === 0) { - msg.channel.createMessage(errorMessage("Channel is not on slowmode!")); - return; - } - - const initMsg = await msg.channel.createMessage("Disabling slowmode..."); - - // Disable bot-maintained slowmode - let failedUsers = []; - if (botSlowmode) { - const result = await this.disableBotSlowmodeForChannel(args.channel); - failedUsers = result.failedUsers; - } - - // Disable native slowmode - if (hasNativeSlowmode) { - await args.channel.edit({ rateLimitPerUser: 0 }); - } - - if (failedUsers.length) { - this.sendSuccessMessage( - msg.channel, - `Slowmode disabled! Failed to clear slowmode from the following users:\n\n<@!${failedUsers.join(">\n<@!")}>`, - ); - } else { - this.sendSuccessMessage(msg.channel, "Slowmode disabled!"); - initMsg.delete().catch(noop); - } - } - - /** - * COMMAND: Clear slowmode from a specific user on a specific channel - */ - @d.command("slowmode clear", " ", { - options: [ - { - name: "force", - isSwitch: true, - }, - ], - }) - @d.permission("can_manage") - async clearSlowmodeCmd(msg: Message, args: { channel: GuildChannel & TextChannel; user: User; force?: boolean }) { - const channelSlowmode = await this.slowmodes.getChannelSlowmode(args.channel.id); - if (!channelSlowmode) { - msg.channel.createMessage(errorMessage("Channel doesn't have slowmode!")); - return; - } - - try { - await this.clearBotSlowmodeFromUserId(args.channel, args.user.id, args.force); - } catch (e) { - return this.sendErrorMessage( - msg.channel, - `Failed to clear slowmode from **${args.user.username}#${args.user.discriminator}** in <#${args.channel.id}>`, - ); - } - - this.sendSuccessMessage( - msg.channel, - `Slowmode cleared from **${args.user.username}#${args.user.discriminator}** in <#${args.channel.id}>`, - ); - } - - @d.command("slowmode list") - @d.permission("can_manage") - async slowmodeListCmd(msg: Message) { - const channels = this.guild.channels; - const slowmodes: Array<{ channel: GuildChannel; seconds: number; native: boolean }> = []; - - for (const channel of channels.values()) { - if (!(channel instanceof TextChannel)) continue; - - // Bot slowmode - const botSlowmode = await this.slowmodes.getChannelSlowmode(channel.id); - if (botSlowmode) { - slowmodes.push({ channel, seconds: botSlowmode.slowmode_seconds, native: false }); - continue; - } - - // Native slowmode - if (channel.rateLimitPerUser) { - slowmodes.push({ channel, seconds: channel.rateLimitPerUser, native: true }); - continue; - } - } - - if (slowmodes.length) { - const lines = slowmodes.map(slowmode => { - const humanized = humanizeDuration(slowmode.seconds * 1000); - - const type = slowmode.native ? "native slowmode" : "bot slowmode"; - - return `<#${slowmode.channel.id}> **${humanized}** ${type}`; - }); - - createChunkedMessage(msg.channel, lines.join("\n")); - } else { - msg.channel.createMessage(errorMessage("No active slowmodes!")); - } - } - - @d.command("slowmode", "[channel:channel]") - @d.permission("can_manage") - async showSlowmodeCmd(msg: Message, args: { channel: GuildChannel & TextChannel }) { - const channel = args.channel || msg.channel; - - if (channel == null || !(channel instanceof TextChannel)) { - msg.channel.createMessage(errorMessage("Channel must be a text channel")); - return; - } - - let currentSlowmode = channel.rateLimitPerUser; - let isNative = true; - - if (!currentSlowmode) { - const botSlowmode = await this.slowmodes.getChannelSlowmode(channel.id); - if (botSlowmode) { - currentSlowmode = botSlowmode.slowmode_seconds; - isNative = false; - } - } - - if (currentSlowmode) { - const humanized = humanizeDuration(channel.rateLimitPerUser * 1000); - const slowmodeType = isNative ? "native" : "bot-maintained"; - msg.channel.createMessage(`The current slowmode of <#${channel.id}> is **${humanized}** (${slowmodeType})`); - } else { - msg.channel.createMessage("Channel is not on slowmode"); - } - } - - /** - * COMMAND: Set slowmode for the specified channel - */ - @d.command("slowmode", " ", { - overloads: [""], - }) - @d.permission("can_manage") - async slowmodeCmd(msg: Message, args: { channel?: GuildChannel & TextChannel; time: string }) { - const channel = args.channel || msg.channel; - - if (channel == null || !(channel instanceof TextChannel)) { - msg.channel.createMessage(errorMessage("Channel must be a text channel")); - return; - } - - const seconds = Math.ceil(convertDelayStringToMS(args.time, "s") / 1000); - const useNativeSlowmode = this.getConfigForChannel(channel).use_native_slowmode && seconds <= NATIVE_SLOWMODE_LIMIT; - - if (seconds === 0) { - return this.disableSlowmodeCmd(msg, { channel }); - } - - if (seconds > MAX_SLOWMODE) { - this.sendErrorMessage(msg.channel, `Sorry, slowmodes can be at most 100 years long. Maybe 99 would be enough?`); - return; - } - - if (useNativeSlowmode) { - // Native slowmode - - // If there is an existing bot-maintained slowmode, disable that first - const existingBotSlowmode = await this.slowmodes.getChannelSlowmode(channel.id); - if (existingBotSlowmode) { - await this.disableBotSlowmodeForChannel(channel); - } - - // Set slowmode - try { - await channel.edit({ - rateLimitPerUser: seconds, - }); - } catch (e) { - return this.sendErrorMessage(msg.channel, "Failed to set native slowmode (check permissions)"); - } - } else { - // Bot-maintained slowmode - - // If there is an existing native slowmode, disable that first - if (channel.rateLimitPerUser) { - await channel.edit({ - rateLimitPerUser: 0, - }); - } - - await this.slowmodes.setChannelSlowmode(channel.id, seconds); - } - - const humanizedSlowmodeTime = humanizeDuration(seconds * 1000); - const slowmodeType = useNativeSlowmode ? "native slowmode" : "bot-maintained slowmode"; - this.sendSuccessMessage( - msg.channel, - `Set ${humanizedSlowmodeTime} slowmode for <#${channel.id}> (${slowmodeType})`, - ); - } - - /** - * EVENT: On every message, check if the channel has a bot-maintained slowmode. If it does, apply slowmode to the user. - * If the user already had slowmode but was still able to send a message (e.g. sending a lot of messages at once), - * remove the messages sent after slowmode was applied. - */ - async onMessageCreate(msg: SavedMessage) { - if (msg.is_bot) return; - - const channel = this.guild.channels.get(msg.channel_id) as GuildChannel & TextChannel; - if (!channel) return; - - // Don't apply slowmode if the lock was interrupted earlier (e.g. the message was caught by word filters) - const thisMsgLock = await this.locks.acquire(`message-${msg.id}`); - if (thisMsgLock.interrupted) return; - - // Check if this channel even *has* a bot-maintained slowmode - const channelSlowmode = await this.slowmodes.getChannelSlowmode(channel.id); - if (!channelSlowmode) return thisMsgLock.unlock(); - - // Make sure this user is affected by the slowmode - const member = await this.getMember(msg.user_id); - const isAffected = this.hasPermission("is_affected", { channelId: channel.id, userId: msg.user_id, member }); - if (!isAffected) return thisMsgLock.unlock(); - - // Delete any extra messages sent after a slowmode was already applied - const userHasSlowmode = await this.slowmodes.userHasSlowmode(channel.id, msg.user_id); - if (userHasSlowmode) { - const message = await channel.getMessage(msg.id); - if (message) { - message.delete(); - return thisMsgLock.interrupt(); - } - - return thisMsgLock.unlock(); - } - - await this.applyBotSlowmodeToUserId(channel, msg.user_id); - thisMsgLock.unlock(); - } - - /** - * Clears all expired bot-maintained user slowmodes in this guild - */ - async clearExpiredSlowmodes() { - const expiredSlowmodeUsers = await this.slowmodes.getExpiredSlowmodeUsers(); - for (const user of expiredSlowmodeUsers) { - const channel = this.guild.channels.get(user.channel_id); - if (!channel) { - await this.slowmodes.clearSlowmodeUser(user.channel_id, user.user_id); - continue; - } - - try { - await this.clearBotSlowmodeFromUserId(channel as GuildChannel & TextChannel, user.user_id); - } catch (e) { - logger.error(e); - - const realUser = this.bot.users.get(user.user_id) || new UnknownUser({ id: user.user_id }); - this.logs.log(LogType.BOT_ALERT, { - body: `Failed to clear slowmode permissions from {userMention(user)} in {channelMention(channel)}`, - user: stripObjectToScalars(realUser), - channel: stripObjectToScalars(channel), - }); - } - } - } -} diff --git a/backend/src/plugins/Spam.ts b/backend/src/plugins/Spam.ts deleted file mode 100644 index 19434387..00000000 --- a/backend/src/plugins/Spam.ts +++ /dev/null @@ -1,514 +0,0 @@ -import { decorators as d, IPluginOptions, logger } from "knub"; -import { Channel, Member } from "eris"; -import { - convertDelayStringToMS, - getEmojiInString, - getRoleMentions, - getUrlsInString, - getUserMentions, - noop, - stripObjectToScalars, - tNullable, - trimLines, -} from "../utils"; -import { LogType } from "../data/LogType"; -import { GuildLogs } from "../data/GuildLogs"; -import { CaseTypes } from "../data/CaseTypes"; -import { GuildArchives } from "../data/GuildArchives"; -import moment from "moment-timezone"; -import { SavedMessage } from "../data/entities/SavedMessage"; -import { GuildSavedMessages } from "../data/GuildSavedMessages"; -import { GuildMutes } from "../data/GuildMutes"; -import { trimPluginDescription, ZeppelinPluginClass } from "./ZeppelinPluginClass"; -import { MuteResult, MutesPlugin } from "./Mutes"; -import { CasesPlugin } from "./Cases"; -import * as t from "io-ts"; - -const BaseSingleSpamConfig = t.type({ - interval: t.number, - count: t.number, - mute: tNullable(t.boolean), - mute_time: tNullable(t.number), - clean: tNullable(t.boolean), -}); -type TBaseSingleSpamConfig = t.TypeOf; - -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), -}); -type TConfigSchema = t.TypeOf; - -enum RecentActionType { - Message = 1, - Mention, - Link, - Attachment, - Emoji, - Newline, - Censor, - Character, - VoiceChannelMove, -} - -interface IRecentAction { - type: RecentActionType; - userId: string; - actionGroupId: string; - extraData: T; - timestamp: number; - count: number; -} - -const MAX_INTERVAL = 300; - -const SPAM_ARCHIVE_EXPIRY_DAYS = 90; - -export class SpamPlugin extends ZeppelinPluginClass { - public static pluginName = "spam"; - public static configSchema = ConfigSchema; - - public static pluginInfo = { - prettyName: "Spam protection", - description: trimPluginDescription(` - Basic spam detection and auto-muting. - For more advanced spam filtering, check out the Automod plugin! - `), - }; - - protected logs: GuildLogs; - protected archives: GuildArchives; - protected savedMessages: GuildSavedMessages; - protected mutes: GuildMutes; - - private onMessageCreateFn; - - // Handle spam detection with a queue so we don't have overlapping detections on the same user - protected spamDetectionQueue: Promise; - - // List of recent potentially-spammy actions - protected recentActions: Array>; - - // 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 - protected lastHandledMsgIds: Map>; - - private expiryInterval; - - public static getStaticDefaultOptions(): IPluginOptions { - return { - 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, - }, - - // Default override to make mods immune to the spam filter - 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, - }, - }, - ], - }; - } - - onLoad() { - this.logs = new GuildLogs(this.guildId); - this.archives = GuildArchives.getGuildInstance(this.guildId); - this.savedMessages = GuildSavedMessages.getGuildInstance(this.guildId); - this.mutes = GuildMutes.getGuildInstance(this.guildId); - - this.recentActions = []; - this.expiryInterval = setInterval(() => this.clearOldRecentActions(), 1000 * 60); - this.lastHandledMsgIds = new Map(); - - this.spamDetectionQueue = Promise.resolve(); - - this.onMessageCreateFn = this.onMessageCreate.bind(this); - this.savedMessages.events.on("create", this.onMessageCreateFn); - } - - onUnload() { - clearInterval(this.expiryInterval); - this.savedMessages.events.off("create", this.onMessageCreateFn); - } - - addRecentAction( - type: RecentActionType, - userId: string, - actionGroupId: string, - extraData: any, - timestamp: number, - count = 1, - ) { - this.recentActions.push({ type, userId, actionGroupId, extraData, timestamp, count }); - } - - getRecentActions(type: RecentActionType, userId: string, actionGroupId: string, since: number) { - return this.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; - }); - } - - getRecentActionCount(type: RecentActionType, userId: string, actionGroupId: string, since: number) { - return this.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); - } - - clearRecentUserActions(type: RecentActionType, userId: string, actionGroupId: string) { - this.recentActions = this.recentActions.filter(action => { - return action.type !== type || action.userId !== userId || action.actionGroupId !== actionGroupId; - }); - } - - clearOldRecentActions() { - // TODO: Figure out expiry time from longest interval in the config? - const expiryTimestamp = Date.now() - 1000 * MAX_INTERVAL; - this.recentActions = this.recentActions.filter(action => action.timestamp >= expiryTimestamp); - } - - async saveSpamArchives(savedMessages: SavedMessage[]) { - const expiresAt = moment().add(SPAM_ARCHIVE_EXPIRY_DAYS, "days"); - const archiveId = await this.archives.createFromSavedMessages(savedMessages, this.guild, expiresAt); - - const baseUrl = this.knub.getGlobalConfig().url; - return this.archives.getUrl(baseUrl, archiveId); - } - - async logAndDetectMessageSpam( - savedMessage: SavedMessage, - type: RecentActionType, - spamConfig: TBaseSingleSpamConfig, - actionCount: number, - description: string, - ) { - if (actionCount === 0) return; - - // Make sure we're not handling some messages twice - if (this.lastHandledMsgIds.has(savedMessage.user_id)) { - const channelMap = this.lastHandledMsgIds.get(savedMessage.user_id); - if (channelMap.has(savedMessage.channel_id)) { - const lastHandledMsgId = channelMap.get(savedMessage.channel_id); - if (lastHandledMsgId >= savedMessage.id) return; - } - } - - this.spamDetectionQueue = this.spamDetectionQueue.then( - async () => { - const timestamp = moment(savedMessage.posted_at).valueOf(); - const member = await this.getMember(savedMessage.user_id); - - // Log this action... - this.addRecentAction(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 = this.getRecentActionCount( - type, - savedMessage.user_id, - savedMessage.channel_id, - since, - ); - - // If the user tripped the spam filter... - if (recentActionsCount > spamConfig.count) { - const recentActions = this.getRecentActions(type, savedMessage.user_id, savedMessage.channel_id, since); - - // Start by muting them, if enabled - let muteResult: MuteResult; - if (spamConfig.mute && member) { - const mutesPlugin = this.getPlugin("mutes"); - const muteTime = spamConfig.mute_time - ? convertDelayStringToMS(spamConfig.mute_time.toString()) - : 120 * 1000; - muteResult = await mutesPlugin.muteUser(member.id, muteTime, "Automatic spam detection", { - caseArgs: { - modId: this.bot.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 this.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 => this.logs.ignoreLog(LogType.MESSAGE_DELETE, id)); - this.bot.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 (!this.lastHandledMsgIds.has(savedMessage.user_id)) { - this.lastHandledMsgIds.set(savedMessage.user_id, new Map()); - } - - const channelMap = this.lastHandledMsgIds.get(savedMessage.user_id); - channelMap.set(savedMessage.channel_id, lastHandledMsgId); - - // Clear the handled actions from recentActions - this.clearRecentUserActions(type, savedMessage.user_id, savedMessage.channel_id); - - // Generate a log from the detected messages - const channel = this.guild.channels.get(savedMessage.channel_id); - const archiveUrl = await this.saveSpamArchives(uniqueMessages); - - // Create a case - const casesPlugin = this.getPlugin("cases"); - 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: this.bot.user.id, - type: CaseTypes.Note, - reason: caseText, - automatic: true, - }); - } - - // Create a log entry - this.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}`); - }, - ); - } - - async logAndDetectOtherSpam( - type: RecentActionType, - spamConfig: any, - userId: string, - actionCount: number, - actionGroupId: string, - timestamp: number, - extraData = null, - description: string, - ) { - this.spamDetectionQueue = this.spamDetectionQueue.then(async () => { - // Log this action... - this.addRecentAction(type, userId, actionGroupId, extraData, timestamp, actionCount); - - // ...and then check if it trips the spam filters - const since = timestamp - 1000 * spamConfig.interval; - const recentActionsCount = this.getRecentActionCount(type, userId, actionGroupId, since); - - if (recentActionsCount > spamConfig.count) { - const member = await this.getMember(userId); - const details = `${description} (over ${spamConfig.count} in ${spamConfig.interval}s)`; - - if (spamConfig.mute && member) { - const mutesPlugin = this.getPlugin("mutes"); - const muteTime = spamConfig.mute_time ? convertDelayStringToMS(spamConfig.mute_time.toString()) : 120 * 1000; - await mutesPlugin.muteUser(member.id, muteTime, "Automatic spam detection", { - caseArgs: { - modId: this.bot.user.id, - extraNotes: [`Details: ${details}`], - }, - }); - } else { - // If we're not muting the user, just add a note on them - const casesPlugin = this.getPlugin("cases"); - await casesPlugin.createCase({ - userId, - modId: this.bot.user.id, - type: CaseTypes.Note, - reason: `Automatic spam detection: ${details}`, - }); - } - - // Clear recent cases - this.clearRecentUserActions(RecentActionType.VoiceChannelMove, userId, actionGroupId); - - this.logs.log(LogType.OTHER_SPAM_DETECTED, { - member: stripObjectToScalars(member, ["user", "roles"]), - description, - limit: spamConfig.count, - interval: spamConfig.interval, - }); - } - }); - } - - // For interoperability with the Censor plugin - async logCensor(savedMessage: SavedMessage) { - const config = this.getConfigForMemberIdAndChannelId(savedMessage.user_id, savedMessage.channel_id); - const spamConfig = config.max_censor; - - if (spamConfig) { - this.logAndDetectMessageSpam(savedMessage, RecentActionType.Censor, spamConfig, 1, "too many censored messages"); - } - } - - async onMessageCreate(savedMessage: SavedMessage) { - if (savedMessage.is_bot) return; - - const config = this.getConfigForMemberIdAndChannelId(savedMessage.user_id, savedMessage.channel_id); - - const maxMessages = config.max_messages; - if (maxMessages) { - this.logAndDetectMessageSpam(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) { - this.logAndDetectMessageSpam( - 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); - this.logAndDetectMessageSpam(savedMessage, RecentActionType.Link, maxLinks, links.length, "too many links"); - } - - const maxAttachments = config.max_attachments; - if (maxAttachments && savedMessage.data.attachments) { - this.logAndDetectMessageSpam( - 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; - this.logAndDetectMessageSpam(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; - this.logAndDetectMessageSpam( - 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; - this.logAndDetectMessageSpam( - savedMessage, - RecentActionType.Character, - maxCharacters, - characterCount, - "too many characters", - ); - } - - // TODO: Max duplicates check - } - - @d.event("voiceChannelJoin") - @d.event("voiceChannelSwitch") - onVoiceChannelSwitch(member: Member, channel: Channel) { - const config = this.getConfigForMemberIdAndChannelId(member.id, channel.id); - const maxVoiceMoves = config.max_voice_moves; - if (maxVoiceMoves) { - this.logAndDetectOtherSpam( - RecentActionType.VoiceChannelMove, - maxVoiceMoves, - member.id, - 1, - "0", - Date.now(), - null, - "too many voice channel moves", - ); - } - } -} diff --git a/backend/src/plugins/Starboard.ts b/backend/src/plugins/Starboard.ts deleted file mode 100644 index db74fe64..00000000 --- a/backend/src/plugins/Starboard.ts +++ /dev/null @@ -1,386 +0,0 @@ -import { decorators as d, IPluginOptions } from "knub"; -import { ZeppelinPluginClass, trimPluginDescription } from "./ZeppelinPluginClass"; -import { Embed, EmbedBase, GuildChannel, Message, TextChannel } from "eris"; -import { - errorMessage, - getUrlsInString, - messageLink, - noop, - successMessage, - TDeepPartialProps, - tNullable, - tDeepPartial, - UnknownUser, - EMPTY_CHAR, -} from "../utils"; -import path from "path"; -import moment from "moment-timezone"; -import { GuildSavedMessages } from "../data/GuildSavedMessages"; -import { SavedMessage } from "../data/entities/SavedMessage"; -import * as t from "io-ts"; -import { GuildStarboardMessages } from "../data/GuildStarboardMessages"; -import { StarboardMessage } from "../data/entities/StarboardMessage"; -import { GuildStarboardReactions } from "../data/GuildStarboardReactions"; - -const StarboardOpts = t.type({ - channel_id: t.string, - stars_required: t.number, - star_emoji: tNullable(t.array(t.string)), - copy_full_embed: tNullable(t.boolean), - enabled: tNullable(t.boolean), -}); -type TStarboardOpts = t.TypeOf; - -const ConfigSchema = t.type({ - boards: t.record(t.string, StarboardOpts), - can_migrate: t.boolean, -}); -type TConfigSchema = t.TypeOf; - -const PartialConfigSchema = tDeepPartial(ConfigSchema); - -const defaultStarboardOpts: Partial = { - star_emoji: ["⭐"], - enabled: true, -}; - -export class StarboardPlugin extends ZeppelinPluginClass { - public static pluginName = "starboard"; - public static configSchema = ConfigSchema; - - public static pluginInfo = { - prettyName: "Starboard", - description: trimPluginDescription(` - This plugin allows you to set up starboards on your server. Starboards are like user voted pins where messages with enough reactions get immortalized on a "starboard" channel. - `), - configurationGuide: trimPluginDescription(` - ### Note on emojis - To specify emoji in the config, you need to use the emoji's "raw form". - To obtain this, post the emoji with a backslash in front of it. - - - Example with a default emoji: "\:star:" => "⭐" - - Example with a custom emoji: "\:mrvnSmile:" => "<:mrvnSmile:543000534102310933>" - - ### Basic starboard - Any message on the server that gets 5 star reactions will be posted into the starboard channel (604342689038729226). - - ~~~yml - starboard: - config: - boards: - basic: - channel_id: "604342689038729226" - stars_required: 5 - ~~~ - - ### Custom star emoji - This is identical to the basic starboard above, but accepts two emoji: the regular star and a custom :mrvnSmile: emoji - - ~~~yml - starboard: - config: - boards: - basic: - channel_id: "604342689038729226" - star_emoji: ["⭐", "<:mrvnSmile:543000534102310933>"] - stars_required: 5 - ~~~ - - ### Limit starboard to a specific channel - This is identical to the basic starboard above, but only works from a specific channel (473087035574321152). - - ~~~yml - starboard: - config: - boards: - basic: - enabled: false # The starboard starts disabled and is then enabled in a channel override below - channel_id: "604342689038729226" - stars_required: 5 - overrides: - - channel: "473087035574321152" - config: - boards: - basic: - enabled: true - ~~~ - `), - }; - - protected savedMessages: GuildSavedMessages; - protected starboardMessages: GuildStarboardMessages; - protected starboardReactions: GuildStarboardReactions; - - private onMessageDeleteFn; - - public static getStaticDefaultOptions(): IPluginOptions { - return { - config: { - can_migrate: false, - boards: {}, - }, - - overrides: [ - { - level: ">=100", - config: { - can_migrate: true, - }, - }, - ], - }; - } - - protected static preprocessStaticConfig(config: t.TypeOf) { - if (config.boards) { - for (const [name, opts] of Object.entries(config.boards)) { - config.boards[name] = Object.assign({}, defaultStarboardOpts, config.boards[name]); - } - } - - return config; - } - - protected getStarboardOptsForStarboardChannel(starboardChannel): TStarboardOpts[] { - const config = this.getConfigForChannel(starboardChannel); - - const configs = Object.values(config.boards).filter(opts => opts.channel_id === starboardChannel.id); - configs.forEach(cfg => { - if (cfg.enabled == null) cfg.enabled = defaultStarboardOpts.enabled; - if (cfg.star_emoji == null) cfg.star_emoji = defaultStarboardOpts.star_emoji; - if (cfg.stars_required == null) cfg.stars_required = defaultStarboardOpts.stars_required; - if (cfg.copy_full_embed == null) cfg.copy_full_embed = false; - }); - - return configs; - } - - onLoad() { - this.savedMessages = GuildSavedMessages.getGuildInstance(this.guildId); - this.starboardMessages = GuildStarboardMessages.getGuildInstance(this.guildId); - this.starboardReactions = GuildStarboardReactions.getGuildInstance(this.guildId); - - this.onMessageDeleteFn = this.onMessageDelete.bind(this); - this.savedMessages.events.on("delete", this.onMessageDeleteFn); - } - - onUnload() { - this.savedMessages.events.off("delete", this.onMessageDeleteFn); - } - - /** - * When a reaction is added to a message, check if there are any applicable starboards and if the reactions reach - * the required threshold. If they do, post the message in the starboard channel. - */ - @d.event("messageReactionAdd") - @d.lock("starboardReaction") - async onMessageReactionAdd(msg: Message, emoji: { id: string; name: string }, userId: string) { - if (!msg.author) { - // Message is not cached, fetch it - try { - msg = await msg.channel.getMessage(msg.id); - } catch (e) { - // Sometimes we get this event for messages we can't fetch with getMessage; ignore silently - return; - } - } - - // No self-votes! - if (msg.author.id === userId) return; - - const user = await this.resolveUser(userId); - if (user instanceof UnknownUser) return; - if (user.bot) return; - - const config = this.getConfigForMemberIdAndChannelId(userId, msg.channel.id); - const applicableStarboards = Object.values(config.boards) - .filter(board => board.enabled) - // Can't star messages in the starboard channel itself - .filter(board => board.channel_id !== msg.channel.id) - // Matching emoji - .filter(board => { - return board.star_emoji.some((boardEmoji: string) => { - if (emoji.id) { - // Custom emoji - const customEmojiMatch = boardEmoji.match(/^?$/); - if (customEmojiMatch) { - return customEmojiMatch[1] === emoji.id; - } - - return boardEmoji === emoji.id; - } else { - // Unicode emoji - return emoji.name === boardEmoji; - } - }); - }); - - for (const starboard of applicableStarboards) { - // Save reaction into the database - await this.starboardReactions.createStarboardReaction(msg.id, userId).catch(noop); - - // If the message has already been posted to this starboard, we don't need to do anything else - const starboardMessages = await this.starboardMessages.getMatchingStarboardMessages(starboard.channel_id, msg.id); - if (starboardMessages.length > 0) continue; - - const reactions = await this.starboardReactions.getAllReactionsForMessageId(msg.id); - const reactionsCount = reactions.length; - if (reactionsCount >= starboard.stars_required) { - await this.saveMessageToStarboard(msg, starboard); - } - } - } - - @d.event("messageReactionRemove") - async onStarboardReactionRemove(msg: Message, emoji: { id: string; name: string }, userId: string) { - await this.starboardReactions.deleteStarboardReaction(msg.id, userId); - } - - @d.event("messageReactionRemoveAll") - async onMessageReactionRemoveAll(msg: Message) { - await this.starboardReactions.deleteAllStarboardReactionsForMessageId(msg.id); - } - - /** - * Saves/posts a message to the specified starboard. - * The message is posted as an embed and image attachments are included as the embed image. - */ - async saveMessageToStarboard(msg: Message, starboard: TStarboardOpts) { - const channel = this.guild.channels.get(starboard.channel_id); - if (!channel) return; - - const time = moment(msg.timestamp, "x").format("YYYY-MM-DD [at] HH:mm:ss [UTC]"); - - const embed: EmbedBase = { - footer: { - text: `#${(msg.channel as GuildChannel).name}`, - }, - author: { - name: `${msg.author.username}#${msg.author.discriminator}`, - }, - fields: [], - timestamp: new Date(msg.timestamp).toISOString(), - }; - - if (msg.author.avatarURL) { - embed.author.icon_url = msg.author.avatarURL; - } - - if (msg.content) { - embed.description = msg.content; - } - - // Merge media and - if copy_full_embed is enabled - fields and title from the first embed in the original message - if (msg.embeds.length > 0) { - if (msg.embeds[0].image) embed.image = msg.embeds[0].image; - - if (starboard.copy_full_embed) { - if (msg.embeds[0].title) { - const titleText = msg.embeds[0].url ? `[${msg.embeds[0].title}](${msg.embeds[0].url})` : msg.embeds[0].title; - embed.fields.push({ name: EMPTY_CHAR, value: titleText }); - } - - if (msg.embeds[0].fields) embed.fields.push(...msg.embeds[0].fields); - } - } - - // If there are no embeds, add the first image attachment explicitly - else if (msg.attachments.length) { - for (const attachment of msg.attachments) { - const ext = path - .extname(attachment.filename) - .slice(1) - .toLowerCase(); - if (!["jpeg", "jpg", "png", "gif", "webp"].includes(ext)) continue; - - embed.image = { url: attachment.url }; - break; - } - } - - embed.fields.push({ name: EMPTY_CHAR, value: `[Jump to message](${messageLink(msg)})` }); - - const starboardMessage = await (channel as TextChannel).createMessage({ embed }); - await this.starboardMessages.createStarboardMessage(channel.id, msg.id, starboardMessage.id); - } - - /** - * Remove a message from the specified starboard - */ - async removeMessageFromStarboard(msg: StarboardMessage) { - await this.bot.deleteMessage(msg.starboard_channel_id, msg.starboard_message_id).catch(noop); - } - - async removeMessageFromStarboardMessages(starboard_message_id: string, channel_id: string) { - await this.starboardMessages.deleteStarboardMessage(starboard_message_id, channel_id); - } - - /** - * When a message is deleted, also delete it from any starboards it's been posted in. - * Likewise, if a starboard message (i.e. the bot message in the starboard) is deleted, remove it from the database. - * This function is called in response to GuildSavedMessages events. - */ - async onMessageDelete(msg: SavedMessage) { - // Deleted source message - const starboardMessages = await this.starboardMessages.getStarboardMessagesForMessageId(msg.id); - for (const starboardMessage of starboardMessages) { - this.removeMessageFromStarboard(starboardMessage); - } - - // Deleted message from the starboard - const deletedStarboardMessages = await this.starboardMessages.getStarboardMessagesForStarboardMessageId(msg.id); - if (deletedStarboardMessages.length === 0) return; - - for (const starboardMessage of deletedStarboardMessages) { - this.removeMessageFromStarboardMessages( - starboardMessage.starboard_message_id, - starboardMessage.starboard_channel_id, - ); - } - } - - @d.command("starboard migrate_pins", " ", { - extra: { - info: { - description: - "Posts all pins from a channel to the specified starboard. The pins are NOT unpinned automatically.", - }, - }, - }) - @d.permission("can_migrate") - async migratePinsCmd(msg: Message, args: { pinChannel: GuildChannel; starboardName: string }) { - const config = await this.getConfig(); - const starboard = config.boards[args.starboardName]; - if (!starboard) { - this.sendErrorMessage(msg.channel, "Unknown starboard specified"); - return; - } - - if (!(args.pinChannel instanceof TextChannel)) { - this.sendErrorMessage(msg.channel, "Unknown/invalid pin channel id"); - return; - } - - const starboardChannel = this.guild.channels.get(starboard.channel_id); - if (!starboardChannel || !(starboardChannel instanceof TextChannel)) { - this.sendErrorMessage(msg.channel, "Starboard has an unknown/invalid channel id"); - return; - } - - msg.channel.createMessage(`Migrating pins from <#${args.pinChannel.id}> to <#${starboardChannel.id}>...`); - - const pins = await args.pinChannel.getPins(); - pins.reverse(); // Migrate pins starting from the oldest message - - for (const pin of pins) { - const existingStarboardMessage = await this.starboardMessages.getMatchingStarboardMessages( - starboardChannel.id, - pin.id, - ); - if (existingStarboardMessage.length > 0) continue; - await this.saveMessageToStarboard(pin, starboard); - } - - this.sendSuccessMessage(msg.channel, `Pins migrated from <#${args.pinChannel.id}> to <#${starboardChannel.id}>!`); - } -} diff --git a/backend/src/plugins/Stats.ts b/backend/src/plugins/Stats.ts deleted file mode 100644 index 93ba7209..00000000 --- a/backend/src/plugins/Stats.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { ZeppelinPluginClass } from "./ZeppelinPluginClass"; -import * as t from "io-ts"; -import { convertDelayStringToMS, DAYS, HOURS, tAlphanumeric, tDateTime, tDeepPartial, tDelayString } from "../utils"; -import { IPluginOptions } from "knub"; -import moment from "moment-timezone"; -import { GuildStats } from "../data/GuildStats"; -import { Message } from "eris"; -import escapeStringRegexp from "escape-string-regexp"; -import { SavedMessage } from "../data/entities/SavedMessage"; -import { GuildSavedMessages } from "../data/GuildSavedMessages"; - -//region TYPES - -const tBaseSource = t.type({ - name: tAlphanumeric, - track: t.boolean, - retention_period: tDelayString, -}); - -const tMemberMessagesSource = t.intersection([ - tBaseSource, - t.type({ - type: t.literal("member_messages"), - }), -]); -type TMemberMessagesSource = t.TypeOf; - -const tChannelMessagesSource = t.intersection([ - tBaseSource, - t.type({ - type: t.literal("channel_messages"), - }), -]); -type TChannelMessagesSource = t.TypeOf; - -const tKeywordsSource = t.intersection([ - tBaseSource, - t.type({ - type: t.literal("keywords"), - keywords: t.array(t.string), - }), -]); -type TKeywordsSource = t.TypeOf; - -const tSource = t.union([tMemberMessagesSource, tChannelMessagesSource, tKeywordsSource]); -type TSource = t.TypeOf; - -const tConfigSchema = t.type({ - sources: t.record(tAlphanumeric, tSource), -}); - -type TConfigSchema = t.TypeOf; -const tPartialConfigSchema = tDeepPartial(tConfigSchema); - -//endregion -//region CONSTANTS - -const DEFAULT_RETENTION_PERIOD = "4w"; - -//endregion -//region PLUGIN - -export class StatsPlugin extends ZeppelinPluginClass { - public static pluginName = "stats"; - public static configSchema = tConfigSchema; - public static showInDocs = false; - - protected stats: GuildStats; - protected savedMessages: GuildSavedMessages; - - private onMessageCreateFn; - private cleanStatsInterval; - - public static getStaticDefaultOptions(): IPluginOptions { - return { - config: { - sources: {}, - }, - }; - } - - protected static applyDefaultsToSource(source: Partial) { - if (source.track == null) { - source.track = true; - } - - if (source.retention_period == null) { - source.retention_period = DEFAULT_RETENTION_PERIOD; - } - } - - protected static preprocessStaticConfig(config: t.TypeOf) { - if (config.sources) { - for (const [key, source] of Object.entries(config.sources)) { - source.name = key; - this.applyDefaultsToSource(source); - } - } - - return config; - } - - protected onLoad() { - this.stats = GuildStats.getGuildInstance(this.guildId); - this.savedMessages = GuildSavedMessages.getGuildInstance(this.guildId); - - this.onMessageCreateFn = this.savedMessages.events.on("create", msg => this.onMessageCreate(msg)); - - this.cleanOldStats(); - this.cleanStatsInterval = setInterval(() => this.cleanOldStats(), 1 * DAYS); - } - - protected onUnload() { - this.savedMessages.events.off("create", this.onMessageCreateFn); - clearInterval(this.cleanStatsInterval); - } - - protected async cleanOldStats() { - const config = this.getConfig(); - for (const source of Object.values(config.sources)) { - const cutoffMS = convertDelayStringToMS(source.retention_period); - const cutoff = moment() - .subtract(cutoffMS, "ms") - .format("YYYY-MM-DD HH:mm:ss"); - await this.stats.deleteOldValues(source.name, cutoff); - } - } - - protected saveMemberMessagesStats(source: TMemberMessagesSource, msg: SavedMessage) { - this.stats.saveValue(source.name, msg.user_id, 1); - } - - protected saveChannelMessagesStats(source: TChannelMessagesSource, msg: SavedMessage) { - this.stats.saveValue(source.name, msg.channel_id, 1); - } - - protected saveKeywordsStats(source: TKeywordsSource, msg: SavedMessage) { - const content = msg.data.content; - if (!content) return; - - for (const keyword of source.keywords) { - const regex = new RegExp(`\\b${escapeStringRegexp(keyword)}\\b`, "i"); - if (content.match(regex)) { - this.stats.saveValue(source.name, "keyword", 1); - break; - } - } - } - - onMessageCreate(msg: SavedMessage) { - const config = this.getConfigForMemberIdAndChannelId(msg.user_id, msg.channel_id); - for (const source of Object.values(config.sources)) { - if (!source.track) continue; - - if (source.type === "member_messages") { - this.saveMemberMessagesStats(source, msg); - } else if (source.type === "channel_messages") { - this.saveChannelMessagesStats(source, msg); - } else if (source.type === "keywords") { - this.saveKeywordsStats(source, msg); - } - } - } -} diff --git a/backend/src/plugins/Tags.ts b/backend/src/plugins/Tags.ts deleted file mode 100644 index b4b4ff2f..00000000 --- a/backend/src/plugins/Tags.ts +++ /dev/null @@ -1,548 +0,0 @@ -import { decorators as d, IPluginOptions, logger } from "knub"; -import { Member, Message, TextChannel } from "eris"; -import { - convertDelayStringToMS, - errorMessage, - renderRecursively, - StrictMessageContent, - stripObjectToScalars, - tEmbed, - tNullable, - tStrictMessageContent, -} from "../utils"; -import { GuildTags } from "../data/GuildTags"; -import { GuildSavedMessages } from "../data/GuildSavedMessages"; -import { SavedMessage } from "../data/entities/SavedMessage"; -import moment from "moment-timezone"; -import humanizeDuration from "humanize-duration"; -import { ZeppelinPluginClass } from "./ZeppelinPluginClass"; -import { parseTemplate, renderTemplate, TemplateParseError } from "../templateFormatter"; -import { GuildArchives } from "../data/GuildArchives"; -import * as t from "io-ts"; -import { parseArguments } from "knub-command-manager"; -import escapeStringRegexp from "escape-string-regexp"; -import { validate } from "../validatorUtils"; -import { GuildLogs } from "../data/GuildLogs"; -import { LogType } from "../data/LogType"; - -const Tag = t.union([t.string, tEmbed]); - -const TagCategory = t.type({ - prefix: tNullable(t.string), - delete_with_command: tNullable(t.boolean), - - user_tag_cooldown: tNullable(t.union([t.string, t.number])), // Per user, per tag - user_category_cooldown: tNullable(t.union([t.string, t.number])), // Per user, per tag category - global_tag_cooldown: tNullable(t.union([t.string, t.number])), // Any user, per tag - global_category_cooldown: tNullable(t.union([t.string, t.number])), // Any user, per category - - tags: t.record(t.string, Tag), - - can_use: tNullable(t.boolean), -}); - -const ConfigSchema = t.type({ - prefix: t.string, - delete_with_command: t.boolean, - - user_tag_cooldown: tNullable(t.union([t.string, t.number])), // Per user, per tag - global_tag_cooldown: tNullable(t.union([t.string, t.number])), // Any user, per tag - user_cooldown: tNullable(t.union([t.string, t.number])), // Per user - global_cooldown: tNullable(t.union([t.string, t.number])), // Any tag use - - categories: t.record(t.string, TagCategory), - - can_create: t.boolean, - can_use: t.boolean, - can_list: t.boolean, -}); -type TConfigSchema = t.TypeOf; - -export class TagsPlugin extends ZeppelinPluginClass { - public static pluginName = "tags"; - public static configSchema = ConfigSchema; - - public static pluginInfo = { - prettyName: "Tags", - }; - - protected archives: GuildArchives; - protected tags: GuildTags; - protected savedMessages: GuildSavedMessages; - protected logs: GuildLogs; - - private onMessageCreateFn; - private onMessageDeleteFn; - - protected tagFunctions; - - public static getStaticDefaultOptions(): IPluginOptions { - return { - config: { - prefix: "!!", - delete_with_command: true, - - user_tag_cooldown: null, - global_tag_cooldown: null, - user_cooldown: null, - global_cooldown: null, - - categories: {}, - - can_create: false, - can_use: false, - can_list: false, - }, - - overrides: [ - { - level: ">=50", - config: { - can_use: true, - can_create: true, - can_list: true, - }, - }, - ], - }; - } - - onLoad() { - this.archives = GuildArchives.getGuildInstance(this.guildId); - this.tags = GuildTags.getGuildInstance(this.guildId); - this.savedMessages = GuildSavedMessages.getGuildInstance(this.guildId); - this.logs = new GuildLogs(this.guildId); - - this.onMessageCreateFn = this.onMessageCreate.bind(this); - this.savedMessages.events.on("create", this.onMessageCreateFn); - - this.onMessageDeleteFn = this.onMessageDelete.bind(this); - this.savedMessages.events.on("delete", this.onMessageDeleteFn); - - this.tagFunctions = { - parseDateTime(str) { - if (typeof str === "number") { - return str; // Unix timestamp - } - - if (typeof str !== "string") { - return Date.now(); - } - - return moment(str, "YYYY-MM-DD HH:mm:ss").valueOf(); - }, - - countdown(toDate) { - const target = moment(this.parseDateTime(toDate)); - - const now = moment(); - if (!target.isValid()) return ""; - - const diff = target.diff(now); - const result = humanizeDuration(diff, { largest: 2, round: true }); - return diff >= 0 ? result : `${result} ago`; - }, - - now() { - return Date.now(); - }, - - timeAdd(...args) { - let reference; - let delay; - - if (args.length >= 2) { - // (time, delay) - reference = this.parseDateTime(args[0]); - delay = args[1]; - } else { - // (delay), implicit "now" as time - reference = Date.now(); - delay = args[0]; - } - - const delayMS = convertDelayStringToMS(delay); - return moment(reference) - .add(delayMS) - .valueOf(); - }, - - timeSub(...args) { - let reference; - let delay; - - if (args.length >= 2) { - // (time, delay) - reference = this.parseDateTime(args[0]); - delay = args[1]; - } else { - // (delay), implicit "now" as time - reference = Date.now(); - delay = args[0]; - } - - const delayMS = convertDelayStringToMS(delay); - return moment(reference) - .subtract(delayMS) - .valueOf(); - }, - - timeAgo(delay) { - return this.timeSub(delay); - }, - - formatTime(time, format) { - const parsed = this.parseDateTime(time); - return moment(parsed).format(format); - }, - - discordDateFormat(time) { - const parsed = time ? this.parseDateTime(time) : Date.now(); - - return moment(parsed).format("YYYY-MM-DD"); - }, - - mention: input => { - if (typeof input !== "string") { - return ""; - } - - if (input.match(/^<(@#)(!&)\d+>$/)) { - return input; - } - - if (this.guild.members.has(input) || this.bot.users.has(input)) { - return `<@!${input}>`; - } - - if (this.guild.channels.has(input) || this.bot.channelGuildMap[input]) { - return `<#${input}>`; - } - - return ""; - }, - }; - - for (const [name, fn] of Object.entries(this.tagFunctions)) { - this.tagFunctions[name] = (fn as any).bind(this.tagFunctions); - } - } - - onUnload() { - this.savedMessages.events.off("create", this.onMessageCreateFn); - this.savedMessages.events.off("delete", this.onMessageDeleteFn); - } - - @d.command("tag list", [], { - aliases: ["tags", "taglist"], - }) - @d.permission("can_list") - async tagListCmd(msg: Message) { - const tags = await this.tags.all(); - if (tags.length === 0) { - msg.channel.createMessage(`No tags created yet! Use \`tag create\` command to create one.`); - return; - } - - const prefix = this.getConfigForMsg(msg).prefix; - const tagNames = tags.map(tag => tag.tag).sort(); - msg.channel.createMessage(` - Available tags (use with ${prefix}tag): \`\`\`${tagNames.join(", ")}\`\`\` - `); - } - - @d.command("tag delete", "") - @d.permission("can_create") - async deleteTagCmd(msg: Message, args: { tag: string }) { - const tag = await this.tags.find(args.tag); - if (!tag) { - msg.channel.createMessage(errorMessage("No tag with that name")); - return; - } - - await this.tags.delete(args.tag); - this.sendSuccessMessage(msg.channel, "Tag deleted!"); - } - - @d.command("tag eval", "") - @d.permission("can_create") - async evalTagCmd(msg: Message, args: { body: string }) { - const rendered = await this.renderTag(args.body); - msg.channel.createMessage(rendered); - } - - @d.command("tag", " ") - @d.permission("can_create") - async tagCmd(msg: Message, args: { tag: string; body: string }) { - try { - parseTemplate(args.body); - } catch (e) { - if (e instanceof TemplateParseError) { - msg.channel.createMessage(errorMessage(`Invalid tag syntax: ${e.message}`)); - return; - } else { - throw e; - } - } - - await this.tags.createOrUpdate(args.tag, args.body, msg.author.id); - - const prefix = this.getConfig().prefix; - this.sendSuccessMessage(msg.channel, `Tag set! Use it with: \`${prefix}${args.tag}\``); - } - - @d.command("tag", "", { - options: [ - { - name: "delete", - shortcut: "d", - isSwitch: true, - }, - ], - }) - @d.permission("can_create") - async tagSourceCmd(msg: Message, args: { tag: string; delete?: boolean }) { - if (args.delete) { - return this.deleteTagCmd(msg, { tag: args.tag }); - } - - const tag = await this.tags.find(args.tag); - if (!tag) { - msg.channel.createMessage(errorMessage("No tag with that name")); - return; - } - - const archiveId = await this.archives.create(tag.body, moment().add(10, "minutes")); - const url = this.archives.getUrl(this.knub.getGlobalConfig().url, archiveId); - - msg.channel.createMessage(`Tag source:\n${url}`); - } - - async renderTag(body, args = [], extraData = {}) { - const dynamicVars = {}; - const maxTagFnCalls = 25; - let tagFnCalls = 0; - - const data = { - args, - ...extraData, - ...this.tagFunctions, - set(name, val) { - if (typeof name !== "string") return; - dynamicVars[name] = val; - }, - get(name) { - return dynamicVars[name] == null ? "" : dynamicVars[name]; - }, - tag: async (name, ...subTagArgs) => { - if (tagFnCalls++ > maxTagFnCalls) return "\\_recursion\\_"; - if (typeof name !== "string") return ""; - if (name === "") return ""; - // TODO: Incorporate tag categories here - const subTag = await this.tags.find(name); - if (!subTag) return ""; - return renderTemplate(subTag.body, { ...data, args: subTagArgs }); - }, - }; - - return renderTemplate(body, data); - } - - async renderSafeTagFromMessage( - str: string, - prefix: string, - tagName: string, - tagBody: t.TypeOf, - member: Member, - ): Promise { - const variableStr = str.slice(prefix.length + tagName.length).trim(); - const tagArgs = parseArguments(variableStr).map(v => v.value); - - const renderTagString = async _str => { - let rendered = await this.renderTag(_str, tagArgs, { - member: stripObjectToScalars(member, ["user"]), - user: stripObjectToScalars(member.user), - }); - rendered = rendered.trim(); - - return rendered; - }; - - // Format the string - try { - return typeof tagBody === "string" - ? { content: await renderTagString(tagBody) } - : await renderRecursively(tagBody, renderTagString); - } catch (e) { - if (e instanceof TemplateParseError) { - logger.warn(`Invalid tag format!\nError: ${e.message}\nFormat: ${tagBody}`); - return null; - } else { - throw e; - } - } - } - - async onMessageCreate(msg: SavedMessage) { - if (msg.is_bot) return; - if (!msg.data.content) return; - - const member = await this.getMember(msg.user_id); - if (!member) return; - - const config = this.getConfigForMemberIdAndChannelId(msg.user_id, msg.channel_id); - let deleteWithCommand = false; - - // Find potential matching tag, looping through categories first and checking dynamic tags last - let renderedTag = null; - let matchedTagName; - const cooldowns = []; - - for (const [name, category] of Object.entries(config.categories)) { - const canUse = category.can_use != null ? category.can_use : config.can_use; - if (canUse !== true) continue; - - const prefix = category.prefix != null ? category.prefix : config.prefix; - if (prefix !== "" && !msg.data.content.startsWith(prefix)) continue; - - const withoutPrefix = msg.data.content.slice(prefix.length); - - for (const [tagName, tagBody] of Object.entries(category.tags)) { - const regex = new RegExp(`^${escapeStringRegexp(tagName)}(?:\s|$)`); - if (regex.test(withoutPrefix)) { - renderedTag = await this.renderSafeTagFromMessage( - msg.data.content, - prefix, - tagName, - category.tags[tagName], - member, - ); - if (renderedTag) { - matchedTagName = tagName; - break; - } - } - } - - if (renderedTag) { - if (category.user_tag_cooldown) { - const delay = convertDelayStringToMS(String(category.user_tag_cooldown), "s"); - cooldowns.push([`tags-category-${name}-user-${msg.user_id}-tag-${matchedTagName}`, delay]); - } - if (category.global_tag_cooldown) { - const delay = convertDelayStringToMS(String(category.global_tag_cooldown), "s"); - cooldowns.push([`tags-category-${name}-tag-${matchedTagName}`, delay]); - } - if (category.user_category_cooldown) { - const delay = convertDelayStringToMS(String(category.user_category_cooldown), "s"); - cooldowns.push([`tags-category-${name}-user--${msg.user_id}`, delay]); - } - if (category.global_category_cooldown) { - const delay = convertDelayStringToMS(String(category.global_category_cooldown), "s"); - cooldowns.push([`tags-category-${name}`, delay]); - } - - deleteWithCommand = - category.delete_with_command != null ? category.delete_with_command : config.delete_with_command; - - break; - } - } - - // Matching tag was not found from the config, try a dynamic tag - if (!renderedTag) { - if (config.can_use !== true) return; - - const prefix = config.prefix; - if (!msg.data.content.startsWith(prefix)) return; - - const tagNameMatch = msg.data.content.slice(prefix.length).match(/^\S+/); - if (tagNameMatch === null) return; - - const tagName = tagNameMatch[0]; - const tag = await this.tags.find(tagName); - if (!tag) return; - - matchedTagName = tagName; - - renderedTag = await this.renderSafeTagFromMessage(msg.data.content, prefix, tagName, tag.body, member); - } - - if (!renderedTag) return; - - if (config.user_tag_cooldown) { - const delay = convertDelayStringToMS(String(config.user_tag_cooldown), "s"); - cooldowns.push([`tags-user-${msg.user_id}-tag-${matchedTagName}`, delay]); - } - - if (config.global_tag_cooldown) { - const delay = convertDelayStringToMS(String(config.global_tag_cooldown), "s"); - cooldowns.push([`tags-tag-${matchedTagName}`, delay]); - } - - if (config.user_cooldown) { - const delay = convertDelayStringToMS(String(config.user_cooldown), "s"); - cooldowns.push([`tags-user-${matchedTagName}`, delay]); - } - - if (config.global_cooldown) { - const delay = convertDelayStringToMS(String(config.global_cooldown), "s"); - cooldowns.push([`tags`, delay]); - } - - const isOnCooldown = cooldowns.some(cd => this.cooldowns.isOnCooldown(cd[0])); - if (isOnCooldown) return; - - for (const cd of cooldowns) { - this.cooldowns.setCooldown(cd[0], cd[1]); - } - - deleteWithCommand = config.delete_with_command; - - const validationError = await validate(tStrictMessageContent, renderedTag); - if (validationError) { - this.logs.log(LogType.BOT_ALERT, { - body: `Rendering tag ${matchedTagName} resulted in an invalid message: ${validationError.message}`, - }); - return; - } - - const channel = this.guild.channels.get(msg.channel_id) as TextChannel; - const responseMsg = await channel.createMessage(renderedTag); - - // Save the command-response message pair once the message is in our database - if (deleteWithCommand) { - this.savedMessages.onceMessageAvailable(responseMsg.id, async () => { - await this.tags.addResponse(msg.id, responseMsg.id); - }); - } - } - - async onMessageDelete(msg: SavedMessage) { - // Command message was deleted -> delete the response as well - const commandMsgResponse = await this.tags.findResponseByCommandMessageId(msg.id); - if (commandMsgResponse) { - const channel = this.guild.channels.get(msg.channel_id) as TextChannel; - if (!channel) return; - - const responseMsg = await this.savedMessages.find(commandMsgResponse.response_message_id); - if (!responseMsg || responseMsg.deleted_at != null) return; - - await channel.deleteMessage(commandMsgResponse.response_message_id); - return; - } - - // Response was deleted -> delete the command message as well - const responseMsgResponse = await this.tags.findResponseByResponseMessageId(msg.id); - if (responseMsgResponse) { - const channel = this.guild.channels.get(msg.channel_id) as TextChannel; - if (!channel) return; - - const commandMsg = await this.savedMessages.find(responseMsgResponse.command_message_id); - if (!commandMsg || commandMsg.deleted_at != null) return; - - await channel.deleteMessage(responseMsgResponse.command_message_id); - return; - } - } -} diff --git a/backend/src/plugins/UsernameSaver.ts b/backend/src/plugins/UsernameSaver.ts deleted file mode 100644 index 25cdcde4..00000000 --- a/backend/src/plugins/UsernameSaver.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { decorators as d, GlobalPlugin } from "knub"; -import { UsernameHistory } from "../data/UsernameHistory"; -import { Member, Message, User } from "eris"; -import { GlobalZeppelinPlugin } from "./GlobalZeppelinPlugin"; -import { Queue } from "../Queue"; - -export class UsernameSaver extends GlobalZeppelinPlugin { - public static pluginName = "username_saver"; - - protected usernameHistory: UsernameHistory; - protected updateQueue: Queue; - - async onLoad() { - this.usernameHistory = new UsernameHistory(); - this.updateQueue = new Queue(); - } - - protected async updateUsername(user: User) { - if (!user) return; - const newUsername = `${user.username}#${user.discriminator}`; - const latestEntry = await this.usernameHistory.getLastEntry(user.id); - if (!latestEntry || newUsername !== latestEntry.username) { - await this.usernameHistory.addEntry(user.id, newUsername); - } - } - - @d.event("messageCreate", null) - async onMessage(msg: Message) { - if (msg.author.bot) return; - this.updateQueue.add(() => this.updateUsername(msg.author)); - } - - @d.event("voiceChannelJoin", null) - async onVoiceChannelJoin(member: Member) { - if (member.user.bot) return; - this.updateQueue.add(() => this.updateUsername(member.user)); - } -} diff --git a/backend/src/plugins/Utility.ts b/backend/src/plugins/Utility.ts deleted file mode 100644 index 70df7b34..00000000 --- a/backend/src/plugins/Utility.ts +++ /dev/null @@ -1,1815 +0,0 @@ -import { - decorators as d, - getCommandSignature, - ICommandContext, - ICommandExtraData, - IPluginOptions, - waitForReaction, -} from "knub"; -import { - CategoryChannel, - Channel, - EmbedOptions, - GuildChannel, - Member, - Message, - MessageContent, - Role, - TextChannel, - User, - VoiceChannel, -} from "eris"; -import { - channelMentionRegex, - chunkArray, - createChunkedMessage, - DAYS, - embedPadding, - errorMessage, - formatNumber, - get, - getInviteCodesInString, - isSnowflake, - messageLink, - MINUTES, - multiSorter, - noop, - resolveMember, - SECONDS, - simpleClosestStringMatch, - sleep, - sorter, - stripObjectToScalars, - successMessage, - trimLines, - UnknownUser, - downloadFile, - memoize, -} from "../utils"; -import { GuildLogs } from "../data/GuildLogs"; -import { LogType } from "../data/LogType"; -import moment from "moment-timezone"; -import humanizeDuration from "humanize-duration"; -import { GuildCases } from "../data/GuildCases"; -import { CaseTypes } from "../data/CaseTypes"; -import { SavedMessage } from "../data/entities/SavedMessage"; -import { GuildSavedMessages } from "../data/GuildSavedMessages"; -import { GuildArchives } from "../data/GuildArchives"; -import { CommandInfo, trimPluginDescription, ZeppelinPluginClass } from "./ZeppelinPluginClass"; -import { getCurrentUptime } from "../uptime"; -import LCL from "last-commit-log"; -import * as t from "io-ts"; -import { ICommandDefinition } from "knub-command-manager"; -import path from "path"; -import escapeStringRegexp from "escape-string-regexp"; -import safeRegex from "safe-regex"; -import fs from "fs"; -import sharp from "sharp"; -import twemoji from "twemoji"; - -declare global { - // This is here so TypeScript doesn't give an error when importing twemoji - // since one of the signatures of twemoji.parse() takes an HTMLElement but - // we're not in a browser environment so including the DOM lib would not make - // sense - type HTMLElement = unknown; -} - -import { Url, URL, URLSearchParams } from "url"; -import { Supporters } from "../data/Supporters"; -const ConfigSchema = t.type({ - can_roles: t.boolean, - can_level: t.boolean, - can_search: t.boolean, - can_clean: t.boolean, - can_info: t.boolean, - can_server: t.boolean, - can_reload_guild: t.boolean, - can_nickname: t.boolean, - can_ping: t.boolean, - can_source: t.boolean, - can_vcmove: t.boolean, - can_help: t.boolean, - can_about: t.boolean, - can_context: t.boolean, - can_jumbo: t.boolean, - jumbo_size: t.Integer, - can_avatar: t.boolean, -}); -type TConfigSchema = t.TypeOf; - -const { performance } = require("perf_hooks"); - -const SEARCH_RESULTS_PER_PAGE = 15; -const SEARCH_ID_RESULTS_PER_PAGE = 50; - -const MAX_CLEAN_COUNT = 150; -const MAX_CLEAN_TIME = 1 * DAYS; -const CLEAN_COMMAND_DELETE_DELAY = 5 * SECONDS; -const MEMBER_REFRESH_FREQUENCY = 10 * MINUTES; // How often to do a full member refresh when using commands that need it -const SEARCH_EXPORT_LIMIT = 1_000_000; - -const activeReloads: Map = new Map(); -const fsp = fs.promises; -const CDN_URL = "https://twemoji.maxcdn.com/2/svg"; - -type MemberSearchParams = { - query?: string; - role?: string; - voice?: boolean; - bot?: boolean; - sort?: string; - "case-sensitive"?: boolean; - regex?: boolean; - "status-search"?: boolean; -}; - -type BanSearchParams = { - query?: string; - sort?: string; - "case-sensitive"?: boolean; - regex?: boolean; -}; - -enum SearchType { - MemberSearch, - BanSearch, -} -class SearchError extends Error {} - -export class UtilityPlugin extends ZeppelinPluginClass { - public static pluginName = "utility"; - public static configSchema = ConfigSchema; - - public static pluginInfo = { - prettyName: "Utility", - }; - - protected logs: GuildLogs; - protected cases: GuildCases; - protected savedMessages: GuildSavedMessages; - protected archives: GuildArchives; - protected supporters: Supporters; - - protected lastFullMemberRefresh = 0; - protected fullMemberRefreshPromise; - protected lastReload; - - public static getStaticDefaultOptions(): IPluginOptions { - return { - config: { - can_roles: false, - can_level: false, - can_search: false, - can_clean: false, - can_info: false, - can_server: false, - can_reload_guild: false, - can_nickname: false, - can_ping: false, - can_source: false, - can_vcmove: false, - can_help: false, - can_about: false, - can_context: false, - can_jumbo: false, - jumbo_size: 128, - can_avatar: false, - }, - overrides: [ - { - level: ">=50", - config: { - can_roles: true, - can_level: true, - can_search: true, - can_clean: true, - can_info: true, - can_server: true, - can_nickname: true, - can_vcmove: true, - can_help: true, - can_context: true, - can_jumbo: true, - can_avatar: true, - }, - }, - { - level: ">=100", - config: { - can_reload_guild: true, - can_ping: true, - can_source: true, - can_about: true, - }, - }, - ], - }; - } - - onLoad() { - this.logs = new GuildLogs(this.guildId); - this.cases = GuildCases.getGuildInstance(this.guildId); - this.savedMessages = GuildSavedMessages.getGuildInstance(this.guildId); - this.archives = GuildArchives.getGuildInstance(this.guildId); - this.supporters = new Supporters(); - - this.lastReload = Date.now(); - - if (activeReloads && activeReloads.has(this.guildId)) { - this.sendSuccessMessage(activeReloads.get(this.guildId), "Reloaded!"); - activeReloads.delete(this.guildId); - } - } - - protected async refreshMembersIfNeeded() { - if (Date.now() < this.lastFullMemberRefresh + MEMBER_REFRESH_FREQUENCY) { - return this.fullMemberRefreshPromise; - } - - this.lastFullMemberRefresh = Date.now(); - this.fullMemberRefreshPromise = this.guild.fetchAllMembers(); - - return this.fullMemberRefreshPromise; - } - - @d.command("roles", "[search:string$]", { - options: [ - { - name: "counts", - isSwitch: true, - }, - { - name: "sort", - type: "string", - }, - ], - extra: { - info: { - description: "List all roles or roles matching a search", - basicUsage: "!roles mod", - }, - }, - }) - @d.permission("can_roles") - async rolesCmd(msg: Message, args: { search?: string; counts?: boolean; sort?: string }) { - let roles: Array<{ _memberCount?: number } & Role> = Array.from((msg.channel as TextChannel).guild.roles.values()); - let sort = args.sort; - - if (args.search) { - const searchStr = args.search.toLowerCase(); - roles = roles.filter(r => r.name.toLowerCase().includes(searchStr) || r.id === searchStr); - } - - if (args.counts) { - this.refreshMembersIfNeeded(); - - // If the user requested role member counts as well, calculate them and sort the roles by their member count - const roleCounts: Map = Array.from(this.guild.members.values()).reduce((map, member) => { - for (const roleId of member.roles) { - if (!map.has(roleId)) map.set(roleId, 0); - map.set(roleId, map.get(roleId) + 1); - } - - return map; - }, new Map()); - - // The "everyone" role always has all members in it - roleCounts.set(this.guildId, this.guild.memberCount); - - for (const role of roles) { - role._memberCount = roleCounts.has(role.id) ? roleCounts.get(role.id) : 0; - } - - if (!sort) sort = "-memberCount"; - roles.sort((a, b) => { - if (a._memberCount > b._memberCount) return -1; - if (a._memberCount < b._memberCount) return 1; - return 0; - }); - } else { - // Otherwise sort by name - roles.sort((a, b) => { - if (a.name.toLowerCase() > b.name.toLowerCase()) return 1; - if (a.name.toLowerCase() < b.name.toLowerCase()) return -1; - return 0; - }); - } - - if (!sort) sort = "name"; - - let sortDir: "ASC" | "DESC" = "ASC"; - if (sort && sort[0] === "-") { - sort = sort.slice(1); - sortDir = "DESC"; - } - - if (sort === "position" || sort === "order") { - roles.sort(sorter("position", sortDir)); - } else if (sort === "memberCount" && args.counts) { - roles.sort(sorter("_memberCount", sortDir)); - } else if (sort === "name") { - roles.sort(sorter(r => r.name.toLowerCase(), sortDir)); - } else { - this.sendErrorMessage(msg.channel, "Unknown sorting method"); - return; - } - - const longestId = roles.reduce((longest, role) => Math.max(longest, role.id.length), 0); - - const chunks = chunkArray(roles, 20); - for (const [i, chunk] of chunks.entries()) { - const roleLines = chunk.map(role => { - const paddedId = role.id.padEnd(longestId, " "); - let line = `${paddedId} ${role.name}`; - if (role._memberCount != null) { - line += role._memberCount === 1 ? ` (${role._memberCount} member)` : ` (${role._memberCount} members)`; - } - return line; - }); - - if (i === 0) { - msg.channel.createMessage( - trimLines(` - ${args.search ? "Total roles found" : "Total roles"}: ${roles.length} - \`\`\`py\n${roleLines.join("\n")}\`\`\` - `), - ); - } else { - msg.channel.createMessage("```py\n" + roleLines.join("\n") + "```"); - } - } - } - - @d.command("level", "[member:resolvedMember]", { - extra: { - info: { - description: "Show the permission level of a user", - basicUsage: "!level 106391128718245888", - }, - }, - }) - @d.permission("can_level") - async levelCmd(msg: Message, args: { member?: Member }) { - const member = args.member || msg.member; - const level = this.getMemberLevel(member); - msg.channel.createMessage(`The permission level of ${member.username}#${member.discriminator} is **${level}**`); - } - - protected async performBanSearch( - args: BanSearchParams, - page = 1, - perPage = SEARCH_RESULTS_PER_PAGE, - ): Promise<{ results: User[]; totalResults: number; page: number; lastPage: number; from: number; to: number }> { - let matchingBans = (await this.guild.getBans()).map(x => x.user); - - if (args.query) { - let queryRegex: RegExp; - if (args.regex) { - queryRegex = new RegExp(args.query.trimStart(), args["case-sensitive"] ? "" : "i"); - } else { - queryRegex = new RegExp(escapeStringRegexp(args.query.trimStart()), args["case-sensitive"] ? "" : "i"); - } - - if (!safeRegex(queryRegex)) { - throw new SearchError("Unsafe/too complex regex (star depth is limited to 1)"); - } - - matchingBans = matchingBans.filter(user => { - const fullUsername = `${user.username}#${user.discriminator}`; - if (fullUsername.match(queryRegex)) return true; - }); - } - - const [, sortDir, sortBy] = args.sort ? args.sort.match(/^(-?)(.*)$/) : [null, "ASC", "name"]; - const realSortDir = sortDir === "-" ? "DESC" : "ASC"; - - if (sortBy === "id") { - matchingBans.sort(sorter(m => BigInt(m.id), realSortDir)); - } else { - matchingBans.sort( - multiSorter([ - [m => m.username.toLowerCase(), realSortDir], - [m => m.discriminator, realSortDir], - ]), - ); - } - - const lastPage = Math.max(1, Math.ceil(matchingBans.length / perPage)); - page = Math.min(lastPage, Math.max(1, page)); - - const from = (page - 1) * perPage; - const to = Math.min(from + perPage, matchingBans.length); - - const pageMembers = matchingBans.slice(from, to); - - return { - results: pageMembers, - totalResults: matchingBans.length, - page, - lastPage, - from: from + 1, - to, - }; - } - - protected async performMemberSearch( - args: MemberSearchParams, - page = 1, - perPage = SEARCH_RESULTS_PER_PAGE, - ): Promise<{ results: Member[]; totalResults: number; page: number; lastPage: number; from: number; to: number }> { - this.refreshMembersIfNeeded(); - - let matchingMembers = Array.from(this.guild.members.values()); - - if (args.role) { - const roleIds = args.role.split(","); - matchingMembers = matchingMembers.filter(member => { - for (const role of roleIds) { - if (!member.roles.includes(role)) return false; - } - - return true; - }); - } - - if (args.voice) { - matchingMembers = matchingMembers.filter(m => m.voiceState.channelID != null); - } - - if (args.bot) { - matchingMembers = matchingMembers.filter(m => m.bot); - } - - if (args.query) { - let queryRegex: RegExp; - if (args.regex) { - queryRegex = new RegExp(args.query.trimStart(), args["case-sensitive"] ? "" : "i"); - } else { - queryRegex = new RegExp(escapeStringRegexp(args.query.trimStart()), args["case-sensitive"] ? "" : "i"); - } - - if (!safeRegex(queryRegex)) { - throw new SearchError("Unsafe/too complex regex (star depth is limited to 1)"); - } - - if (args["status-search"]) { - matchingMembers = matchingMembers.filter(member => { - if (member.game) { - if (member.game.name && member.game.name.match(queryRegex)) { - return true; - } - - if (member.game.state && member.game.state.match(queryRegex)) { - return true; - } - - if (member.game.details && member.game.details.match(queryRegex)) { - return true; - } - - if (member.game.assets) { - if (member.game.assets.small_text && member.game.assets.small_text.match(queryRegex)) { - return true; - } - - if (member.game.assets.large_text && member.game.assets.large_text.match(queryRegex)) { - return true; - } - } - - if (member.game.emoji && member.game.emoji.name.match(queryRegex)) { - return true; - } - } - return false; - }); - } else { - matchingMembers = matchingMembers.filter(member => { - if (member.nick && member.nick.match(queryRegex)) return true; - - const fullUsername = `${member.user.username}#${member.user.discriminator}`; - if (fullUsername.match(queryRegex)) return true; - - return false; - }); - } - } - - const [, sortDir, sortBy] = args.sort ? args.sort.match(/^(-?)(.*)$/) : [null, "ASC", "name"]; - const realSortDir = sortDir === "-" ? "DESC" : "ASC"; - - if (sortBy === "id") { - matchingMembers.sort(sorter(m => BigInt(m.id), realSortDir)); - } else { - matchingMembers.sort( - multiSorter([ - [m => m.username.toLowerCase(), realSortDir], - [m => m.discriminator, realSortDir], - ]), - ); - } - - const lastPage = Math.max(1, Math.ceil(matchingMembers.length / perPage)); - page = Math.min(lastPage, Math.max(1, page)); - - const from = (page - 1) * perPage; - const to = Math.min(from + perPage, matchingMembers.length); - - const pageMembers = matchingMembers.slice(from, to); - - return { - results: pageMembers, - totalResults: matchingMembers.length, - page, - lastPage, - from: from + 1, - to, - }; - } - - protected formatSearchResultList(members: Array): string { - const longestId = members.reduce((longest, member) => Math.max(longest, member.id.length), 0); - const lines = members.map(member => { - const paddedId = member.id.padEnd(longestId, " "); - let line; - if (member instanceof Member) { - line = `${paddedId} ${member.user.username}#${member.user.discriminator}`; - if (member.nick) line += ` (${member.nick})`; - } else { - line = `${paddedId} ${member.username}#${member.discriminator}`; - } - return line; - }); - return lines.join("\n"); - } - - protected formatSearchResultIdList(members: Array): string { - return members.map(m => m.id).join(" "); - } - - @d.command("search", "[query:string$]", { - aliases: ["s"], - options: [ - { - name: "page", - shortcut: "p", - type: "number", - }, - { - name: "role", - shortcut: "r", - type: "string", - }, - { - name: "voice", - shortcut: "v", - isSwitch: true, - }, - { - name: "bot", - shortcut: "b", - isSwitch: true, - }, - { - name: "sort", - type: "string", - }, - { - name: "case-sensitive", - shortcut: "cs", - isSwitch: true, - }, - { - name: "export", - shortcut: "e", - isSwitch: true, - }, - { - name: "ids", - isSwitch: true, - }, - { - name: "regex", - shortcut: "re", - isSwitch: true, - }, - { - name: "status-search", - shortcut: "ss", - isSwitch: true, - }, - ], - extra: { - info: { - description: "Search server members", - basicUsage: "!search dragory", - optionDescriptions: { - role: - "Only include members with a specific role. Multiple roles can be specified by separating them with a comma.", - voice: "Only include members currently in a voice channel", - sort: - "Change how the results are sorted. Possible values are 'id' and 'name'. Prefix with a dash, e.g. '-id', to reverse sorting.", - "case-sensitive": "By default, the search is case-insensitive. Use this to make it case-sensitive instead.", - export: "If set, the full search results are exported as an archive", - }, - }, - }, - }) - @d.permission("can_search") - async searchCmd( - msg: Message, - args: { - query?: string; - page?: number; - role?: string; - voice?: boolean; - bot?: boolean; - sort?: string; - "case-sensitive"?: boolean; - export?: boolean; - ids?: boolean; - regex?: boolean; - "status-search"?: boolean; - }, - ) { - // If we're exporting the results, we don't need all the fancy schmancy pagination stuff. - // Just get the results and dump them in an archive. - if (args.export) { - return this.archiveSearch(args, SearchType.MemberSearch, msg); - } else { - return this.displaySearch(args, SearchType.MemberSearch, msg); - } - } - - @d.command("bansearch", "[query:string$]", { - aliases: ["bs"], - options: [ - { - name: "page", - shortcut: "p", - type: "number", - }, - { - name: "sort", - type: "string", - }, - { - name: "case-sensitive", - shortcut: "cs", - isSwitch: true, - }, - { - name: "export", - shortcut: "e", - isSwitch: true, - }, - { - name: "ids", - isSwitch: true, - }, - { - name: "regex", - shortcut: "re", - isSwitch: true, - }, - ], - extra: { - info: { - description: "Search banned users", - basicUsage: "!bansearch dragory", - optionDescriptions: { - sort: - "Change how the results are sorted. Possible values are 'id' and 'name'. Prefix with a dash, e.g. '-id', to reverse sorting.", - "case-sensitive": "By default, the search is case-insensitive. Use this to make it case-sensitive instead.", - export: "If set, the full search results are exported as an archive", - }, - }, - }, - }) - @d.permission("can_search") - async banSearchCmd( - msg: Message, - args: { - query?: string; - page?: number; - sort?: string; - "case-sensitive"?: boolean; - export?: boolean; - ids?: boolean; - regex?: boolean; - }, - ) { - if (args.export) { - return this.archiveSearch(args, SearchType.BanSearch, msg); - } else { - return this.displaySearch(args, SearchType.BanSearch, msg); - } - } - - async cleanMessages(channel: Channel, savedMessages: SavedMessage[], mod: User) { - this.logs.ignoreLog(LogType.MESSAGE_DELETE, savedMessages[0].id); - this.logs.ignoreLog(LogType.MESSAGE_DELETE_BULK, savedMessages[0].id); - - // Delete & archive in ID order - savedMessages = Array.from(savedMessages).sort((a, b) => (a.id > b.id ? 1 : -1)); - const idsToDelete = savedMessages.map(m => m.id); - - // Make sure the deletions aren't double logged - idsToDelete.forEach(id => this.logs.ignoreLog(LogType.MESSAGE_DELETE, id)); - this.logs.ignoreLog(LogType.MESSAGE_DELETE_BULK, idsToDelete[0]); - - // Actually delete the messages - await this.bot.deleteMessages(channel.id, idsToDelete); - await this.savedMessages.markBulkAsDeleted(idsToDelete); - - // Create an archive - const archiveId = await this.archives.createFromSavedMessages(savedMessages, this.guild); - const archiveUrl = this.archives.getUrl(this.knub.getGlobalConfig().url, archiveId); - - this.logs.log(LogType.CLEAN, { - mod: stripObjectToScalars(mod), - channel: stripObjectToScalars(channel), - count: savedMessages.length, - archiveUrl, - }); - - return { archiveUrl }; - } - - async archiveSearch(args: any, searchType: SearchType, msg: Message) { - let results; - try { - switch (searchType) { - case SearchType.MemberSearch: - results = await this.performMemberSearch(args, 1, SEARCH_EXPORT_LIMIT); - break; - case SearchType.BanSearch: - results = await this.performBanSearch(args, 1, SEARCH_EXPORT_LIMIT); - break; - } - } catch (e) { - if (e instanceof SearchError) { - return this.sendErrorMessage(msg.channel, e.message); - } - - throw e; - } - - if (results.totalResults === 0) { - return this.sendErrorMessage(msg.channel, "No results found"); - } - - const resultList = args.ids - ? this.formatSearchResultIdList(results.results) - : this.formatSearchResultList(results.results); - - const archiveId = await this.archives.create( - trimLines(` - Search results (total ${results.totalResults}): - - ${resultList} - `), - moment().add(1, "hour"), - ); - const url = await this.archives.getUrl(this.knub.getGlobalConfig().url, archiveId); - - msg.channel.createMessage(`Exported search results: ${url}`); - - return; - } - - async displaySearch(args: any, searchType: SearchType, msg: Message) { - // If we're not exporting, load 1 page of search results at a time and allow the user to switch pages with reactions - let originalSearchMsg: Message = null; - let searching = false; - let currentPage = args.page || 1; - let hasReactions = false; - let clearReactionsFn = null; - let clearReactionsTimeout = null; - - const perPage = args.ids ? SEARCH_ID_RESULTS_PER_PAGE : SEARCH_RESULTS_PER_PAGE; - - const loadSearchPage = async page => { - if (searching) return; - searching = true; - - // The initial message is created here, as well as edited to say "Searching..." on subsequent requests - // We don't "await" this so we can start loading the search results immediately instead of after the message has been created/edited - let searchMsgPromise: Promise; - if (originalSearchMsg) { - searchMsgPromise = originalSearchMsg.edit("Searching..."); - } else { - searchMsgPromise = msg.channel.createMessage("Searching..."); - searchMsgPromise.then(m => (originalSearchMsg = m)); - } - - let searchResult; - try { - switch (searchType) { - case SearchType.MemberSearch: - searchResult = await this.performMemberSearch(args, page, perPage); - break; - case SearchType.BanSearch: - searchResult = await this.performBanSearch(args, page, perPage); - break; - } - } catch (e) { - if (e instanceof SearchError) { - return this.sendErrorMessage(msg.channel, e.message); - } - - throw e; - } - - if (searchResult.totalResults === 0) { - return this.sendErrorMessage(msg.channel, "No results found"); - } - - const resultWord = searchResult.totalResults === 1 ? "matching member" : "matching members"; - const headerText = - searchResult.totalResults > perPage - ? trimLines(` - **Page ${searchResult.page}** (${searchResult.from}-${searchResult.to}) (total ${searchResult.totalResults}) - `) - : `Found ${searchResult.totalResults} ${resultWord}`; - - const resultList = args.ids - ? this.formatSearchResultIdList(searchResult.results) - : this.formatSearchResultList(searchResult.results); - - const result = trimLines(` - ${headerText} - \`\`\`js - ${resultList} - \`\`\` - `); - - const searchMsg = await searchMsgPromise; - searchMsg.edit(result); - - // Set up pagination reactions if needed. The reactions are cleared after a timeout. - if (searchResult.totalResults > perPage) { - if (!hasReactions) { - hasReactions = true; - searchMsg.addReaction("⬅"); - searchMsg.addReaction("➡"); - searchMsg.addReaction("🔄"); - - const removeListenerFn = this.on("messageReactionAdd", (rMsg: Message, emoji, userId) => { - if (rMsg.id !== searchMsg.id) return; - if (userId !== msg.author.id) return; - if (!["⬅", "➡", "🔄"].includes(emoji.name)) return; - - if (emoji.name === "⬅" && currentPage > 1) { - loadSearchPage(currentPage - 1); - } else if (emoji.name === "➡" && currentPage < searchResult.lastPage) { - loadSearchPage(currentPage + 1); - } else if (emoji.name === "🔄") { - loadSearchPage(currentPage); - } - - rMsg.removeReaction(emoji.name, userId); - }); - - clearReactionsFn = async () => { - searchMsg.removeReactions().catch(noop); - removeListenerFn(); - }; - } - - clearTimeout(clearReactionsTimeout); - clearReactionsTimeout = setTimeout(clearReactionsFn, 5 * MINUTES); - } - - currentPage = searchResult.page; - searching = false; - }; - - loadSearchPage(currentPage); - } - - @d.command("clean", "", { - options: [ - { - name: "user", - type: "userId", - shortcut: "u", - }, - { - name: "channel", - type: "channelId", - shortcut: "c", - }, - { - name: "bots", - isSwitch: true, - shortcut: "b", - }, - { - name: "has-invites", - isSwitch: true, - shortcut: "i", - }, - ], - extra: { - info: { - description: "Remove a number of recent messages", - basicUsage: "!clean 20", - examples: trimPluginDescription(` - To clean 20 messages from a specific user: - \`!clean -user 106391128718245888 20\` - - To clean messages from another channel: - \`!clean -channel #other-channel 20\` - `), - parameterDescriptions: { - count: "Number of messages to remove", - }, - optionDescriptions: { - user: "Only remove messages from the specified user", - channel: - "By default, messages are removed from the channel where the command is used. You can clean a different channel by specifying it with this option.", - bots: "Only remove messages sent by bots", - "has-invites": "Only remove messages that contain invites", - }, - }, - }, - }) - @d.permission("can_clean") - async cleanCmd( - msg: Message, - args: { - count: number; - user?: string; - channel?: string; - bots?: boolean; - "has-invites"?: boolean; - fresh?: boolean; - }, - ) { - if (args.count > MAX_CLEAN_COUNT || args.count <= 0) { - msg.channel.createMessage(errorMessage(`Clean count must be between 1 and ${MAX_CLEAN_COUNT}`)); - return; - } - - const targetChannel = args.channel ? this.guild.channels.get(args.channel) : msg.channel; - if (!targetChannel || !(targetChannel instanceof TextChannel)) { - msg.channel.createMessage(errorMessage(`Invalid channel specified`)); - return; - } - - if (targetChannel.id !== msg.channel.id) { - const configForTargetChannel = this.getConfigForMemberIdAndChannelId(msg.member.id, targetChannel.id); - if (configForTargetChannel.can_clean !== true) { - msg.channel.createMessage(errorMessage(`Missing permissions to use clean on that channel`)); - return; - } - } - - const messagesToClean = []; - let beforeId = msg.id; - const timeCutoff = msg.timestamp - MAX_CLEAN_TIME; - - while (messagesToClean.length < args.count) { - const potentialMessagesToClean = await this.savedMessages.getLatestByChannelBeforeId( - targetChannel.id, - beforeId, - args.count, - ); - if (potentialMessagesToClean.length === 0) break; - - const filtered = potentialMessagesToClean.filter(message => { - if (args.user && message.user_id !== args.user) return false; - if (args.bots && !message.is_bot) return false; - if (args["has-invites"] && getInviteCodesInString(message.data.content || "").length === 0) return false; - if (moment.utc(message.posted_at).valueOf() < timeCutoff) return false; - return true; - }); - const remaining = args.count - messagesToClean.length; - const withoutOverflow = filtered.slice(0, remaining); - messagesToClean.push(...withoutOverflow); - - beforeId = potentialMessagesToClean[potentialMessagesToClean.length - 1].id; - - if (moment.utc(potentialMessagesToClean[potentialMessagesToClean.length - 1].posted_at).valueOf() < timeCutoff) { - break; - } - } - - let responseMsg: Message; - if (messagesToClean.length > 0) { - const cleanResult = await this.cleanMessages(targetChannel, messagesToClean, msg.author); - - let responseText = `Cleaned ${messagesToClean.length} ${messagesToClean.length === 1 ? "message" : "messages"}`; - if (targetChannel.id !== msg.channel.id) { - responseText += ` in <#${targetChannel.id}>\n${cleanResult.archiveUrl}`; - } - - responseMsg = await msg.channel.createMessage(successMessage(`<:zep_check:650361014180904971>`, responseText)); - } else { - responseMsg = await msg.channel.createMessage(errorMessage(`Found no messages to clean!`)); - } - - if (targetChannel.id === msg.channel.id) { - // Delete the !clean command and the bot response if a different channel wasn't specified - // (so as not to spam the cleaned channel with the command itself) - setTimeout(() => { - msg.delete().catch(noop); - responseMsg.delete().catch(noop); - }, CLEAN_COMMAND_DELETE_DELAY); - } - } - - @d.command("info", "[user:resolvedUserLoose]", { - extra: { - info: { - description: "Show basic information about a user", - basicUsage: "!info 106391128718245888", - }, - }, - options: [ - { - name: "compact", - shortcut: "c", - isSwitch: true, - }, - ], - }) - @d.permission("can_info") - async infoCmd(msg: Message, args: { user?: User | UnknownUser; compact?: boolean }) { - const user = args.user || msg.author; - - let member; - if (!(user instanceof UnknownUser)) { - member = await this.getMember(user.id, true); - } - - const embed: EmbedOptions = { - fields: [], - }; - - if (user && !(user instanceof UnknownUser)) { - const createdAt = moment(user.createdAt); - const accountAge = humanizeDuration(moment().valueOf() - user.createdAt, { - largest: 2, - round: true, - }); - - embed.title = `${user.username}#${user.discriminator}`; - embed.thumbnail = { url: user.avatarURL }; - - if (args.compact) { - embed.fields.push({ - name: "User information", - value: trimLines(` - Profile: <@!${user.id}> - Created: **${accountAge} ago (${createdAt.format("YYYY-MM-DD[T]HH:mm:ss")})** - `), - }); - if (member) { - const joinedAt = moment(member.joinedAt); - const joinAge = humanizeDuration(moment().valueOf() - member.joinedAt, { - largest: 2, - round: true, - }); - embed.fields[0].value += `\nJoined: **${joinAge} ago (${joinedAt.format("YYYY-MM-DD[T]HH:mm:ss")})**`; - } else { - embed.fields.push({ - name: "!! USER IS NOT ON THE SERVER !!", - value: embedPadding, - }); - } - msg.channel.createMessage({ embed }); - return; - } else { - embed.fields.push({ - name: "User information", - value: - trimLines(` - ID: **${user.id}** - Profile: <@!${user.id}> - Created: **${accountAge} ago (${createdAt.format("YYYY-MM-DD[T]HH:mm:ss")})** - `) + embedPadding, - }); - } - } else { - embed.title = `Unknown user`; - } - - if (member) { - const joinedAt = moment(member.joinedAt); - const joinAge = humanizeDuration(moment().valueOf() - member.joinedAt, { - largest: 2, - round: true, - }); - const roles = member.roles.map(id => this.guild.roles.get(id)).filter(r => !!r); - - embed.fields.push({ - name: "Member information", - value: - trimLines(` - Joined: **${joinAge} ago (${joinedAt.format("YYYY-MM-DD[T]HH:mm:ss")})** - ${roles.length > 0 ? "Roles: " + roles.map(r => r.name).join(", ") : ""} - `) + embedPadding, - }); - - const voiceChannel = member.voiceState.channelID ? this.guild.channels.get(member.voiceState.channelID) : null; - if (voiceChannel || member.voiceState.mute || member.voiceState.deaf) { - embed.fields.push({ - name: "Voice information", - value: - trimLines(` - ${voiceChannel ? `Current voice channel: **${voiceChannel ? voiceChannel.name : "None"}**` : ""} - ${member.voiceState.mute ? "Server voice muted: **Yes**" : ""} - ${member.voiceState.deaf ? "Server voice deafened: **Yes**" : ""} - `) + embedPadding, - }); - } - } else { - embed.fields.push({ - name: "!! USER IS NOT ON THE SERVER !!", - value: embedPadding, - }); - } - const cases = (await this.cases.getByUserId(user.id)).filter(c => !c.is_hidden); - - if (cases.length > 0) { - cases.sort((a, b) => { - return a.created_at < b.created_at ? 1 : -1; - }); - - const caseSummary = cases.slice(0, 3).map(c => { - return `${CaseTypes[c.type]} (#${c.case_number})`; - }); - - const summaryText = cases.length > 3 ? "Last 3 cases" : "Summary"; - - embed.fields.push({ - name: "Cases", - value: trimLines(` - Total cases: **${cases.length}** - ${summaryText}: ${caseSummary.join(", ")} - `), - }); - } - - msg.channel.createMessage({ embed }); - } - - @d.command("nickname reset", "", { - aliases: ["nick reset"], - extra: { - info: { - description: "Reset a member's nickname to their username", - basicUsage: "!nickname reset 106391128718245888", - }, - }, - }) - @d.permission("can_nickname") - async nicknameResetCmd(msg: Message, args: { member: Member }) { - if (msg.member.id !== args.member.id && !this.canActOn(msg.member, args.member)) { - msg.channel.createMessage(errorMessage("Cannot reset nickname: insufficient permissions")); - return; - } - - try { - await args.member.edit({ - nick: "", - }); - } catch (e) { - msg.channel.createMessage(errorMessage("Failed to reset nickname")); - return; - } - - this.sendSuccessMessage(msg.channel, `The nickname of <@!${args.member.id}> has been reset`); - } - - @d.command("nickname", " ", { - aliases: ["nick"], - extra: { - info: { - description: "Set a member's nickname", - basicUsage: "!nickname 106391128718245888 Drag", - }, - }, - }) - @d.permission("can_nickname") - async nicknameCmd(msg: Message, args: { member: Member; nickname: string }) { - if (msg.member.id !== args.member.id && !this.canActOn(msg.member, args.member)) { - msg.channel.createMessage(errorMessage("Cannot change nickname: insufficient permissions")); - return; - } - - const nicknameLength = [...args.nickname].length; - if (nicknameLength < 2 || nicknameLength > 32) { - msg.channel.createMessage(errorMessage("Nickname must be between 2 and 32 characters long")); - return; - } - - const oldNickname = args.member.nick || ""; - - try { - await args.member.edit({ - nick: args.nickname, - }); - } catch (e) { - msg.channel.createMessage(errorMessage("Failed to change nickname")); - return; - } - - this.sendSuccessMessage( - msg.channel, - `Changed nickname of <@!${args.member.id}> from **${oldNickname}** to **${args.nickname}**`, - ); - } - - @d.command("server", "", { - extra: { - info: { - description: "Show information about the server", - basicUsage: "!server", - }, - }, - }) - @d.permission("can_server") - async serverCmd(msg: Message) { - const embed: EmbedOptions = { - fields: [], - color: parseInt("6b80cf", 16), - }; - - embed.thumbnail = { url: this.guild.iconURL }; - - const createdAt = moment(this.guild.createdAt); - const serverAge = humanizeDuration(moment().valueOf() - this.guild.createdAt, { - largest: 2, - round: true, - }); - - const owner = this.bot.users.get(this.guild.ownerID); - const ownerName = owner ? `${owner.username}#${owner.discriminator}` : "Unknown#0000"; - - embed.fields.push({ - name: `Server information - ${this.guild.name}`, - value: - trimLines(` - Created: **${serverAge} ago** (${createdAt.format("YYYY-MM-DD[T]HH:mm:ss")}) - Owner: **${ownerName}** (${this.guild.ownerID}) - Voice region: **${this.guild.region}** - ${this.guild.features.length > 0 ? "Features: " + this.guild.features.join(", ") : ""} - `) + embedPadding, - }); - - const restGuild = await memoize( - () => this.bot.getRESTGuild(this.guildId), - `getRESTGuild_${this.guildId}`, - 10 * MINUTES, - ); - - // For servers with a vanity URL, we can use the numbers from the invite for online count - // (which is nowadays usually more accurate for large servers) - const invite = this.guild.vanityURL - ? await memoize( - () => this.bot.getInvite(this.guild.vanityURL, true), - `getInvite_${this.guild.vanityURL}`, - 10 * MINUTES, - ) - : null; - - const totalMembers = invite ? invite.memberCount : this.guild.memberCount; - - const onlineMemberCount = invite - ? invite.presenceCount - : this.guild.members.filter(m => m.status !== "offline").length; - const offlineMemberCount = this.guild.memberCount - onlineMemberCount; - - let memberCountTotalLines = `Total: **${formatNumber(totalMembers)}**`; - if (restGuild.maxMembers) { - memberCountTotalLines += `\nMax: **${formatNumber(restGuild.maxMembers)}**`; - } - - let memberCountOnlineLines = `Online: **${formatNumber(onlineMemberCount)}**`; - if (restGuild.maxPresences) { - memberCountOnlineLines += `\nMax online: **${formatNumber(restGuild.maxPresences)}**`; - } - - embed.fields.push({ - name: "Members", - inline: true, - value: trimLines(` - ${memberCountTotalLines} - ${memberCountOnlineLines} - Offline: **${formatNumber(offlineMemberCount)}** - `), - }); - - const totalChannels = this.guild.channels.size; - const categories = this.guild.channels.filter(channel => channel instanceof CategoryChannel); - const textChannels = this.guild.channels.filter(channel => channel instanceof TextChannel); - const voiceChannels = this.guild.channels.filter(channel => channel instanceof VoiceChannel); - - embed.fields.push({ - name: "Channels", - inline: true, - value: - trimLines(` - Total: **${totalChannels}** / 500 - Categories: **${categories.length}** - Text: **${textChannels.length}** - Voice: **${voiceChannels.length}** - `) + embedPadding, - }); - - const maxEmojis = - { - 0: 50, - 1: 100, - 2: 150, - 3: 250, - }[this.guild.premiumTier] || 50; - - embed.fields.push({ - name: "Other stats", - inline: true, - value: - trimLines(` - Roles: **${this.guild.roles.size}** / 250 - Emojis: **${this.guild.emojis.length}** / ${maxEmojis} - Boosts: **${this.guild.premiumSubscriptionCount ?? 0}** (level ${this.guild.premiumTier}) - `) + embedPadding, - }); - - msg.channel.createMessage({ embed }); - } - - @d.command("ping", "", { - extra: { - info: { - description: "Test the bot's ping to the Discord API", - }, - }, - }) - @d.permission("can_ping") - async pingCmd(msg: Message) { - const times = []; - const messages: Message[] = []; - let msgToMsgDelay = null; - - for (let i = 0; i < 4; i++) { - const start = performance.now(); - const message = await msg.channel.createMessage(`Calculating ping... ${i + 1}`); - times.push(performance.now() - start); - messages.push(message); - - if (msgToMsgDelay === null) { - msgToMsgDelay = message.timestamp - msg.timestamp; - } - } - - const highest = Math.round(Math.max(...times)); - const lowest = Math.round(Math.min(...times)); - const mean = Math.round(times.reduce((total, ms) => total + ms, 0) / times.length); - - const shard = this.bot.shards.get(this.bot.guildShardMap[this.guildId]); - - msg.channel.createMessage( - trimLines(` - **Ping:** - Lowest: **${lowest}ms** - Highest: **${highest}ms** - Mean: **${mean}ms** - Time between ping command and first reply: **${msgToMsgDelay}ms** - Shard latency: **${shard.latency}ms** - `), - ); - - // Clean up test messages - this.bot - .deleteMessages( - messages[0].channel.id, - messages.map(m => m.id), - ) - .catch(noop); - } - - @d.command("source", "", { - extra: { - info: { - description: "View the message source of the specified message id", - basicUsage: "!source 534722219696455701", - }, - }, - }) - @d.permission("can_source") - async sourceCmd(msg: Message, args: { messageId: string }) { - const savedMessage = await this.savedMessages.find(args.messageId); - if (!savedMessage) { - msg.channel.createMessage(errorMessage("Unknown message")); - return; - } - - const source = - (savedMessage.data.content || "") + "\n\nSource:\n\n" + JSON.stringify(savedMessage.data); - - const archiveId = await this.archives.create(source, moment().add(1, "hour")); - const url = this.archives.getUrl(this.knub.getGlobalConfig().url, archiveId); - msg.channel.createMessage(`Message source: ${url}`); - } - - @d.command("context", " ", { - extra: { - info: { - description: "Get a link to the context of the specified message", - basicUsage: "!context 94882524378968064 650391267720822785", - }, - }, - }) - @d.permission("can_context") - async contextCmd(msg: Message, args: { channel: Channel; messageId: string }) { - if (!(args.channel instanceof TextChannel)) { - this.sendErrorMessage(msg.channel, "Channel must be a text channel"); - return; - } - - const previousMessage = (await this.bot.getMessages(args.channel.id, 1, args.messageId))[0]; - if (!previousMessage) { - this.sendErrorMessage(msg.channel, "Message context not found"); - return; - } - - msg.channel.createMessage(messageLink(this.guildId, previousMessage.channel.id, previousMessage.id)); - } - - @d.command("vcmove", " ", { - extra: { - info: { - description: "Move a member to another voice channel", - basicUsage: "!vcmove @Dragory 473223047822704651", - }, - }, - }) - @d.permission("can_vcmove") - async vcmoveCmd(msg: Message, args: { member: Member; channel: string }) { - let channel: VoiceChannel; - - if (isSnowflake(args.channel)) { - // Snowflake -> resolve channel directly - const potentialChannel = this.guild.channels.get(args.channel); - if (!potentialChannel || !(potentialChannel instanceof VoiceChannel)) { - msg.channel.createMessage(errorMessage("Unknown or non-voice channel")); - return; - } - - channel = potentialChannel; - } else if (channelMentionRegex.test(args.channel)) { - // Channel mention -> parse channel id and resolve channel from that - const channelId = args.channel.match(channelMentionRegex)[1]; - const potentialChannel = this.guild.channels.get(channelId); - if (!potentialChannel || !(potentialChannel instanceof VoiceChannel)) { - msg.channel.createMessage(errorMessage("Unknown or non-voice channel")); - return; - } - - channel = potentialChannel; - } else { - // Search string -> find closest matching voice channel name - const voiceChannels = this.guild.channels.filter(theChannel => { - return theChannel instanceof VoiceChannel; - }) as VoiceChannel[]; - const closestMatch = simpleClosestStringMatch(args.channel, voiceChannels, ch => ch.name); - if (!closestMatch) { - msg.channel.createMessage(errorMessage("No matching voice channels")); - return; - } - - channel = closestMatch; - } - - if (!args.member.voiceState || !args.member.voiceState.channelID) { - msg.channel.createMessage(errorMessage("Member is not in a voice channel")); - return; - } - - if (args.member.voiceState.channelID === channel.id) { - msg.channel.createMessage(errorMessage("Member is already on that channel!")); - return; - } - - const oldVoiceChannel = this.guild.channels.get(args.member.voiceState.channelID); - - try { - await args.member.edit({ - channelID: channel.id, - }); - } catch (e) { - msg.channel.createMessage(errorMessage("Failed to move member")); - return; - } - - this.logs.log(LogType.VOICE_CHANNEL_FORCE_MOVE, { - mod: stripObjectToScalars(msg.author), - member: stripObjectToScalars(args.member, ["user", "roles"]), - oldChannel: stripObjectToScalars(oldVoiceChannel), - newChannel: stripObjectToScalars(channel), - }); - - this.sendSuccessMessage( - msg.channel, - `**${args.member.user.username}#${args.member.user.discriminator}** moved to **${channel.name}**`, - ); - } - - @d.command("help", "", { - extra: { - info: { - description: "Show a quick reference for the specified command's usage", - basicUsage: "!help clean", - }, - }, - }) - @d.permission("can_help") - helpCmd(msg: Message, args: { command: string }) { - const searchStr = args.command.toLowerCase(); - - const matchingCommands: Array<{ - plugin: ZeppelinPluginClass; - command: ICommandDefinition; - }> = []; - - const guildData = this.knub.getGuildData(this.guildId); - for (const plugin of guildData.loadedPlugins.values()) { - if (!(plugin instanceof ZeppelinPluginClass)) continue; - - const registeredCommands = plugin.getRegisteredCommands(); - for (const registeredCommand of registeredCommands) { - for (const trigger of registeredCommand.command.originalTriggers) { - const strTrigger = typeof trigger === "string" ? trigger : trigger.source; - - if (strTrigger.startsWith(searchStr)) { - matchingCommands.push({ - plugin, - command: registeredCommand.command, - }); - } - } - } - } - - const totalResults = matchingCommands.length; - const limitedResults = matchingCommands.slice(0, 3); - const commandSnippets = limitedResults.map(({ plugin, command }) => { - const prefix: string = command.originalPrefix - ? typeof command.originalPrefix === "string" - ? command.originalPrefix - : command.originalPrefix.source - : ""; - - const originalTrigger = command.originalTriggers[0]; - const trigger: string = originalTrigger - ? typeof originalTrigger === "string" - ? originalTrigger - : originalTrigger.source - : ""; - - const description = get(command, "config.extra.info.description"); - const basicUsage = get(command, "config.extra.info.basicUsage"); - const commandSlug = trigger - .trim() - .toLowerCase() - .replace(/\s/g, "-"); - - let snippet = `**${prefix}${trigger}**`; - if (description) snippet += `\n${description}`; - if (basicUsage) snippet += `\nBasic usage: \`${basicUsage}\``; - snippet += `\n`; - - return snippet; - }); - - if (totalResults === 0) { - msg.channel.createMessage("No matching commands found!"); - return; - } - - let message = - totalResults !== limitedResults.length - ? `Results (${totalResults} total, showing first ${limitedResults.length}):\n\n` - : ""; - - message += `${commandSnippets.join("\n\n")}`; - createChunkedMessage(msg.channel, message); - } - - @d.command("about", "", { - extra: { - info: { - description: "Show information about Zeppelin's status on the server", - }, - }, - }) - @d.permission("can_about") - async aboutCmd(msg: Message) { - const uptime = getCurrentUptime(); - const prettyUptime = humanizeDuration(uptime, { largest: 2, round: true }); - - let lastCommit; - - try { - // From project root - // FIXME: Store these paths properly somewhere - const lcl = new LCL(path.resolve(__dirname, "..", "..", "..")); - lastCommit = await lcl.getLastCommit(); - } catch (e) {} // tslint:disable-line:no-empty - - let lastUpdate; - let version; - - if (lastCommit) { - lastUpdate = moment(lastCommit.committer.date, "X").format("LL [at] H:mm [(UTC)]"); - version = lastCommit.shortHash; - } else { - lastUpdate = "?"; - version = "?"; - } - - const shard = this.bot.shards.get(this.bot.guildShardMap[this.guildId]); - - const lastReload = humanizeDuration(Date.now() - this.lastReload, { - largest: 2, - round: true, - }); - - const basicInfoRows = [ - ["Uptime", prettyUptime], - ["Last reload", `${lastReload} ago`], - ["Last update", lastUpdate], - ["Version", version], - ["API latency", `${shard.latency}ms`], - ]; - - const loadedPlugins = Array.from(this.knub.getGuildData(this.guildId).loadedPlugins.keys()); - loadedPlugins.sort(); - - const aboutContent: MessageContent = { - embed: { - title: `About ${this.bot.user.username}`, - fields: [ - { - name: "Status", - value: basicInfoRows - .map(([label, value]) => { - return `${label}: **${value}**`; - }) - .join("\n"), - }, - { - name: `Loaded plugins on this server (${loadedPlugins.length})`, - value: loadedPlugins.join(", "), - }, - ], - }, - }; - - const supporters = await this.supporters.getAll(); - supporters.sort( - multiSorter([ - [r => r.amount, "DESC"], - [r => r.name.toLowerCase(), "ASC"], - ]), - ); - - if (supporters.length) { - aboutContent.embed.fields.push({ - name: "Zeppelin supporters 🎉", - value: supporters.map(s => `**${s.name}** ${s.amount ? `${s.amount}€/mo` : ""}`.trim()).join("\n"), - }); - } - - // For the embed color, find the highest colored role the bot has - this is their color on the server as well - const botMember = await resolveMember(this.bot, this.guild, this.bot.user.id); - let botRoles = botMember.roles.map(r => (msg.channel as GuildChannel).guild.roles.get(r)); - botRoles = botRoles.filter(r => !!r); // Drop any unknown roles - botRoles = botRoles.filter(r => r.color); // Filter to those with a color - botRoles.sort(sorter("position", "DESC")); // Sort by position (highest first) - if (botRoles.length) { - aboutContent.embed.color = botRoles[0].color; - } - - // Use the bot avatar as the embed image - if (this.bot.user.avatarURL) { - aboutContent.embed.thumbnail = { url: this.bot.user.avatarURL }; - } - - msg.channel.createMessage(aboutContent); - } - - @d.command("reload_guild", "", { - extra: { - info: { - description: "Reload the Zeppelin configuration and all plugins for the server. This can sometimes fix issues.", - }, - }, - }) - @d.permission("can_reload_guild") - reloadGuildCmd(msg: Message) { - if (activeReloads.has(this.guildId)) return; - activeReloads.set(this.guildId, msg.channel as TextChannel); - - msg.channel.createMessage("Reloading..."); - this.knub.reloadGuild(this.guildId); - } - - @d.command("jumbo", "", { - extra: { - info: { - description: "Makes an emoji jumbo", - }, - }, - }) - @d.permission("can_jumbo") - @d.cooldown(5 * SECONDS) - async jumboCmd(msg: Message, args: { emoji: string }) { - // Get emoji url - const config = this.getConfig(); - const size = config.jumbo_size > 2048 ? 2048 : config.jumbo_size; - const emojiRegex = new RegExp(`(<.*:).*:(\\d+)`); - const results = emojiRegex.exec(args.emoji); - let extention = ".png"; - let file; - - if (results) { - let url = "https://cdn.discordapp.com/emojis/"; - if (results[1] === "{ - description: "Retrieves a users profile picture", - }, - }, - }) - @d.permission("can_avatar") - async avatarCmd(msg: Message, args: { user?: User | UnknownUser }) { - const user = args.user || msg.author; - if (!(user instanceof UnknownUser)) { - const extention = user.avatarURL.slice(user.avatarURL.lastIndexOf("."), user.avatarURL.lastIndexOf("?")); - const avatarUrl = user.avatarURL.slice(0, user.avatarURL.lastIndexOf(".")); - const embed: EmbedOptions = { - image: { url: avatarUrl + `${extention}?size=2048` }, - }; - embed.title = `Avatar of ${user.username}#${user.discriminator}:`; - msg.channel.createMessage({ embed }); - } else { - this.sendErrorMessage(msg.channel, "Invalid user ID"); - } - } - - async resizeBuffer(input: Buffer, width: number, height: number): Promise { - return sharp(input, { density: 800 }) - .resize(width, height, { - fit: "inside", - }) - .toBuffer(); - } - - async getBufferFromUrl(url: string): Promise { - const downloadedEmoji = await downloadFile(url); - return fsp.readFile(downloadedEmoji.path); - } -} diff --git a/backend/src/plugins/WelcomeMessage.ts b/backend/src/plugins/WelcomeMessage.ts deleted file mode 100644 index 20189a81..00000000 --- a/backend/src/plugins/WelcomeMessage.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { ZeppelinPluginClass } from "./ZeppelinPluginClass"; -import { decorators as d, IPluginOptions } from "knub"; -import { Member, TextChannel } from "eris"; -import { renderTemplate } from "../templateFormatter"; -import { createChunkedMessage, stripObjectToScalars, tNullable } from "../utils"; -import { LogType } from "../data/LogType"; -import { GuildLogs } from "../data/GuildLogs"; -import * as t from "io-ts"; - -const ConfigSchema = t.type({ - send_dm: t.boolean, - send_to_channel: tNullable(t.string), - message: t.string, -}); -type TConfigSchema = t.TypeOf; - -export class WelcomeMessagePlugin extends ZeppelinPluginClass { - public static pluginName = "welcome_message"; - public static configSchema = ConfigSchema; - - public static pluginInfo = { - prettyName: "Welcome message", - }; - - protected logs: GuildLogs; - - public static getStaticDefaultOptions(): IPluginOptions { - return { - config: { - send_dm: false, - send_to_channel: null, - message: null, - }, - }; - } - - protected onLoad() { - this.logs = new GuildLogs(this.guildId); - } - - @d.event("guildMemberAdd") - async onGuildMemberAdd(_, member: Member) { - const config = this.getConfig(); - if (!config.message) return; - if (!config.send_dm && !config.send_to_channel) return; - - const formatted = await renderTemplate(config.message, { - member: stripObjectToScalars(member, ["user"]), - }); - - if (config.send_dm) { - const dmChannel = await member.user.getDMChannel(); - if (!dmChannel) return; - - try { - await createChunkedMessage(dmChannel, formatted); - } catch (e) { - this.logs.log(LogType.BOT_ALERT, { - body: `Failed send a welcome DM to {userMention(member)}`, - member: stripObjectToScalars(member), - }); - } - } - - if (config.send_to_channel) { - const channel = this.guild.channels.get(config.send_to_channel); - if (!channel || !(channel instanceof TextChannel)) return; - - try { - await createChunkedMessage(channel, formatted); - } catch (e) { - this.logs.log(LogType.BOT_ALERT, { - body: `Failed send a welcome message for {userMention(member)} to {channelMention(channel)}`, - member: stripObjectToScalars(member), - channel: stripObjectToScalars(channel), - }); - } - } - } -} diff --git a/backend/src/plugins/availablePlugins.ts b/backend/src/plugins/availablePlugins.ts index 8dbb5816..f604fbdf 100644 --- a/backend/src/plugins/availablePlugins.ts +++ b/backend/src/plugins/availablePlugins.ts @@ -1,89 +1,8 @@ -import { MessageSaverPlugin } from "./MessageSaver"; -import { NameHistoryPlugin } from "./NameHistory"; -import { CasesPlugin } from "./Cases"; -import { MutesPlugin } from "./Mutes"; -import { UtilityPlugin } from "./Utility"; -import { ModActionsPlugin } from "./ModActions"; -import { LogsPlugin } from "./Logs"; -import { PostPlugin } from "./Post"; -import { ReactionRolesPlugin } from "./ReactionRoles"; -import { CensorPlugin } from "./Censor"; -import { PersistPlugin } from "./Persist"; -import { SpamPlugin } from "./Spam"; -import { TagsPlugin } from "./Tags"; -import { SlowmodePlugin } from "./Slowmode"; -import { StarboardPlugin } from "./Starboard"; -import { AutoReactionsPlugin } from "./AutoReactionsPlugin"; -import { PingableRolesPlugin } from "./PingableRolesPlugin"; -import { SelfGrantableRolesPlugin } from "./SelfGrantableRolesPlugin"; -import { RemindersPlugin } from "./Reminders"; -import { WelcomeMessagePlugin } from "./WelcomeMessage"; -import { BotControlPlugin } from "./BotControl"; -import { UsernameSaver } from "./UsernameSaver"; -import { CustomEventsPlugin } from "./CustomEvents"; -import { GuildInfoSaverPlugin } from "./GuildInfoSaver"; -import { CompanionChannelPlugin } from "./CompanionChannels"; -import { LocatePlugin } from "./LocateUser"; -import { GuildConfigReloader } from "./GuildConfigReloader"; -import { ChannelArchiverPlugin } from "./ChannelArchiver"; -import { AutomodPlugin } from "./Automod/Automod"; -import { RolesPlugin } from "./Roles"; -import { AutoDeletePlugin } from "./AutoDelete"; +import { UtilityPlugin } from "./Utility/UtilityPlugin"; -/** - * Plugins available to be loaded for individual guilds - */ -export const oldAvailablePlugins = [ - AutomodPlugin, - MessageSaverPlugin, - NameHistoryPlugin, - CasesPlugin, - MutesPlugin, - UtilityPlugin, - ModActionsPlugin, - LogsPlugin, - PostPlugin, - ReactionRolesPlugin, - CensorPlugin, - PersistPlugin, - SpamPlugin, - TagsPlugin, - SlowmodePlugin, - StarboardPlugin, - AutoReactionsPlugin, - PingableRolesPlugin, - SelfGrantableRolesPlugin, - RemindersPlugin, - WelcomeMessagePlugin, - CustomEventsPlugin, - GuildInfoSaverPlugin, - CompanionChannelPlugin, - LocatePlugin, - ChannelArchiverPlugin, - RolesPlugin, - AutoDeletePlugin, -]; - -/** - * Plugins that are always loaded (subset of the names of the plugins in availablePlugins) - */ -export const oldBasePlugins = [ - GuildInfoSaverPlugin.pluginName, - MessageSaverPlugin.pluginName, - NameHistoryPlugin.pluginName, - CasesPlugin.pluginName, - MutesPlugin.pluginName, -]; - -/** - * Available global plugins (can't be loaded per-guild, only globally) - */ -export const oldAvailableGlobalPlugins = [BotControlPlugin, UsernameSaver, GuildConfigReloader]; - -export const availablePlugins = [ +// prettier-ignore +export const guildPlugins = [ UtilityPlugin, ]; -export const basePlugins = []; - -export const availableGlobalPlugins = []; +export const globalPlugins = [];