diff --git a/src/data/GuildActions.ts b/src/data/GuildActions.ts deleted file mode 100644 index f41c91fb..00000000 --- a/src/data/GuildActions.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { BaseRepository } from "./BaseRepository"; -import { Member, TextableChannel } from "eris"; -import { CaseTypes } from "./CaseTypes"; -import { ICaseDetails } from "./GuildCases"; -import { Case } from "./entities/Case"; -import { INotifyUserResult } from "../utils"; - -type KnownActions = "mute" | "unmute"; - -// https://github.com/Microsoft/TypeScript/issues/4183#issuecomment-382867018 -type UnknownAction = T extends KnownActions ? never : T; - -type ActionFn = (args: T) => any | Promise; - -type MuteActionArgs = { userId: string; muteTime?: number; reason?: string; caseDetails?: ICaseDetails }; -type UnmuteActionArgs = { userId: string; unmuteTime?: number; reason?: string; caseDetails?: ICaseDetails }; -type CreateCaseActionArgs = ICaseDetails; -type CreateCaseNoteActionArgs = { - caseId: number; - modId: string; - note: string; - automatic?: boolean; - postInCaseLog?: boolean; - noteDetails?: string[]; -}; -type PostCaseActionArgs = { - caseId: number; - channel: TextableChannel; -}; - -export type MuteActionResult = { - case: Case; - notifyResult: INotifyUserResult; - updatedExistingMute: boolean; -}; - -export type UnmuteActionResult = { - case: Case; -}; - -export class GuildActions extends BaseRepository { - private actions: Map>; - - constructor(guildId) { - super(guildId); - this.actions = new Map(); - } - - public register(actionName: "mute", actionFn: ActionFn): void; - public register(actionName: "unmute", actionFn: ActionFn): void; - public register(actionName: "createCase", actionFn: ActionFn): void; - public register(actionName: "createCaseNote", actionFn: ActionFn): void; - public register(actionName: "postCase", actionFn: ActionFn): void; - // https://github.com/Microsoft/TypeScript/issues/4183#issuecomment-382867018 - public register, U extends string = T>( - actionName: T, - actionFn: ActionFn, - ): void; - public register(actionName, actionFn): void { - if (this.actions.has(actionName)) { - throw new Error("Action is already registered!"); - } - - this.actions.set(actionName, actionFn); - } - - public unregister(actionName: string): void { - this.actions.delete(actionName); - } - - public fire(actionName: "mute", args: MuteActionArgs): Promise; - public fire(actionName: "unmute", args: UnmuteActionArgs): Promise; - public fire(actionName: "createCase", args: CreateCaseActionArgs): Promise; - public fire(actionName: "createCaseNote", args: CreateCaseNoteActionArgs): Promise; - public fire(actionName: "postCase", args: PostCaseActionArgs): Promise; - // https://github.com/Microsoft/TypeScript/issues/4183#issuecomment-382867018 - public fire, U extends string = T>(actionName: T, args: any): Promise; - public fire(actionName, args): Promise { - return this.actions.has(actionName) ? this.actions.get(actionName)(args) : null; - } -} diff --git a/src/data/GuildCases.ts b/src/data/GuildCases.ts index a49f72bc..64849bb2 100644 --- a/src/data/GuildCases.ts +++ b/src/data/GuildCases.ts @@ -8,22 +8,6 @@ import moment = require("moment-timezone"); const CASE_SUMMARY_REASON_MAX_LENGTH = 300; -/** - * Used as a config object for functions that create cases - */ -export interface ICaseDetails { - userId?: string; - modId?: string; - ppId?: string; - type?: CaseTypes; - auditLogId?: string; - reason?: string; - automatic?: boolean; - postInCaseLogOverride?: boolean; - noteDetails?: string[]; - extraNotes?: string[]; -} - export class GuildCases extends BaseRepository { private cases: Repository; private caseNotes: Repository; diff --git a/src/index.ts b/src/index.ts index 7b70cd5e..e1e9fa5b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,7 @@ import yaml from "js-yaml"; import fs from "fs"; const fsp = fs.promises; -import { Knub, logger, PluginError, CommandArgumentTypeError } from "knub"; +import { Knub, logger, PluginError, CommandArgumentTypeError, Plugin } from "knub"; import { SimpleError } from "./SimpleError"; require("dotenv").config(); @@ -75,6 +75,7 @@ import { PingableRolesPlugin } from "./plugins/PingableRolesPlugin"; import { SelfGrantableRolesPlugin } from "./plugins/SelfGrantableRolesPlugin"; import { RemindersPlugin } from "./plugins/Reminders"; import { convertDelayStringToMS, errorMessage, successMessage } from "./utils"; +import { ZeppelinPlugin } from "./plugins/ZeppelinPlugin"; // Run latest database migrations logger.info("Running database migrations"); @@ -125,11 +126,30 @@ connect().then(async conn => { options: { getEnabledPlugins(guildId, guildConfig): string[] { - const plugins = guildConfig.plugins || {}; - const keys: string[] = Array.from(this.plugins.keys()); - return keys.filter(pluginName => { - return basePlugins.includes(pluginName) || (plugins[pluginName] && plugins[pluginName].enabled !== false); + const configuredPlugins = guildConfig.plugins || {}; + const pluginNames: string[] = Array.from(this.plugins.keys()); + const plugins: Array = Array.from(this.plugins.values()); + const zeppelinPlugins: Array = plugins.filter( + p => p.prototype instanceof ZeppelinPlugin, + ) as Array; + + const enabledBasePlugins = pluginNames.filter(n => basePlugins.includes(n)); + const explicitlyEnabledPlugins = pluginNames.filter(pluginName => { + return configuredPlugins[pluginName] && configuredPlugins[pluginName].enabled !== false; }); + const enabledPlugins = new Set([...enabledBasePlugins, ...explicitlyEnabledPlugins]); + + const pluginsEnabledAsDependencies = zeppelinPlugins.reduce((arr, pluginClass) => { + if (!enabledPlugins.has(pluginClass.pluginName)) return arr; + return arr.concat(pluginClass.dependencies); + }, []); + + const finalEnabledPlugins = new Set([ + ...basePlugins, + ...pluginsEnabledAsDependencies, + ...explicitlyEnabledPlugins, + ]); + return Array.from(finalEnabledPlugins.values()); }, async getConfig(id) { diff --git a/src/plugins/Cases.ts b/src/plugins/Cases.ts index bb12d400..d8070856 100644 --- a/src/plugins/Cases.ts +++ b/src/plugins/Cases.ts @@ -1,11 +1,10 @@ -import { Message, MessageContent, MessageFile, TextableChannel, TextChannel } from "eris"; -import { GuildCases, ICaseDetails } from "../data/GuildCases"; +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 { ZeppelinPlugin } from "./ZeppelinPlugin"; -import { GuildActions } from "../data/GuildActions"; import { GuildArchives } from "../data/GuildArchives"; import { IPluginOptions } from "knub"; @@ -14,10 +13,34 @@ interface ICasesPluginConfig { case_log_channel: string; } +/** + * 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 ZeppelinPlugin { public static pluginName = "cases"; - protected actions: GuildActions; protected cases: GuildCases; protected archives: GuildArchives; @@ -31,35 +54,13 @@ export class CasesPlugin extends ZeppelinPlugin { } onLoad() { - this.actions = GuildActions.getInstance(this.guildId); this.cases = GuildCases.getInstance(this.guildId); this.archives = GuildArchives.getInstance(this.guildId); - this.actions.register("createCase", args => { - return this.createCase(args); - }); - - this.actions.register("createCaseNote", args => { - return this.createCaseNote( - args.caseId, - args.modId, - args.note, - args.automatic, - args.postInCaseLog, - args.noteDetails, - ); - }); - - this.actions.register("postCase", async args => { - const embed = await this.getCaseEmbed(args.caseId); - return (args.channel as TextableChannel).createMessage(embed); - }); - } - - onUnload() { - this.actions.unregister("createCase"); - this.actions.unregister("createCaseNote"); - this.actions.unregister("postCase"); + // this.actions.register("postCase", async args => { + // const embed = await this.getCaseEmbed(args.caseId); + // return (args.channel as TextableChannel).createMessage(embed); + // }); } protected resolveCaseId(caseOrCaseId: Case | number): number { @@ -68,39 +69,51 @@ export class CasesPlugin extends ZeppelinPlugin { /** * Creates a new case and, depending on config, posts it in the case log channel - * @return {Number} The ID of the created case */ - public async createCase(opts: ICaseDetails): Promise { - const user = this.bot.users.get(opts.userId); - const userName = user ? `${user.username}#${user.discriminator}` : "Unknown#0000"; + public async createCase(args: CaseArgs): Promise { + const user = await this.resolveUser(args.userId); + const userName = `${user.username}#${user.discriminator}`; - const mod = this.bot.users.get(opts.modId); - const modName = mod ? `${mod.username}#${mod.discriminator}` : "Unknown#0000"; + const mod = await this.resolveUser(args.modId); + const modName = `${mod.username}#${mod.discriminator}`; let ppName = null; - if (opts.ppId) { - const pp = this.bot.users.get(opts.ppId); - ppName = pp ? `${pp.username}#${pp.discriminator}` : "Unknown#0000"; + if (args.ppId) { + const pp = await this.resolveUser(args.ppId); + ppName = `${pp.username}#${pp.discriminator}`; } const createdCase = await this.cases.create({ - type: opts.type, - user_id: opts.userId, + type: args.type, + user_id: args.userId, user_name: userName, - mod_id: opts.modId, + mod_id: args.modId, mod_name: modName, - audit_log_id: opts.auditLogId, - pp_id: opts.ppId, + audit_log_id: args.auditLogId, + pp_id: args.ppId, pp_name: ppName, }); - if (opts.reason || (opts.noteDetails && opts.noteDetails.length)) { - await this.createCaseNote(createdCase, opts.modId, opts.reason || "", opts.automatic, false, opts.noteDetails); + 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 (opts.extraNotes) { - for (const extraNote of opts.extraNotes) { - await this.createCaseNote(createdCase, opts.modId, extraNote, opts.automatic, false); + 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, + }); } } @@ -108,8 +121,8 @@ export class CasesPlugin extends ZeppelinPlugin { if ( config.case_log_channel && - (!opts.automatic || config.log_automatic_actions) && - opts.postInCaseLogOverride !== false + (!args.automatic || config.log_automatic_actions) && + args.postInCaseLogOverride !== false ) { try { await this.postCaseToCaseLogChannel(createdCase); @@ -122,29 +135,24 @@ export class CasesPlugin extends ZeppelinPlugin { /** * Adds a case note to an existing case and, depending on config, posts the updated case in the case log channel */ - public async createCaseNote( - caseOrCaseId: Case | number, - modId: string, - body: string, - automatic = false, - postInCaseLogOverride = null, - noteDetails: string[] = null, - ): Promise { - const mod = this.bot.users.get(modId); - const modName = mod ? `${mod.username}#${mod.discriminator}` : "Unknown#0000"; - - const theCase = await this.cases.find(this.resolveCaseId(caseOrCaseId)); + public async createCaseNote(args: CaseNoteArgs): Promise { + const theCase = await this.cases.find(this.resolveCaseId(args.caseId)); if (!theCase) { - this.throwPluginRuntimeError(`Unknown case ID: ${caseOrCaseId}`); + this.throwPluginRuntimeError(`Unknown case ID: ${args.caseId}`); } + 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 (noteDetails && noteDetails.length) { - body = noteDetails.map(d => `__[${d}]__`).join(" ") + " " + body; + if (args.noteDetails && args.noteDetails.length) { + body = args.noteDetails.map(d => `__[${d}]__`).join(" ") + " " + body; } await this.cases.createNote(theCase.id, { - mod_id: modId, + mod_id: mod.id, mod_name: modName, body: body || "", }); @@ -152,7 +160,7 @@ export class CasesPlugin extends ZeppelinPlugin { 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: modId, + mod_id: mod.id, mod_name: modName, }); } @@ -163,7 +171,7 @@ export class CasesPlugin extends ZeppelinPlugin { this.archives.makePermanent(archiveId); } - if ((!automatic || this.getConfig().log_automatic_actions) && postInCaseLogOverride !== false) { + if ((!args.automatic || this.getConfig().log_automatic_actions) && args.postInCaseLogOverride !== false) { try { await this.postCaseToCaseLogChannel(theCase.id); } catch (e) {} // tslint:disable-line diff --git a/src/plugins/ModActions.ts b/src/plugins/ModActions.ts index 96f10593..6addd326 100644 --- a/src/plugins/ModActions.ts +++ b/src/plugins/ModActions.ts @@ -5,7 +5,6 @@ import { GuildCases } from "../data/GuildCases"; import { asSingleLine, createChunkedMessage, - createUnknownUser, errorMessage, findRelevantAuditLogEntry, INotifyUserResult, @@ -22,9 +21,10 @@ import { CaseTypes } from "../data/CaseTypes"; import { GuildLogs } from "../data/GuildLogs"; import { LogType } from "../data/LogType"; import { ZeppelinPlugin } from "./ZeppelinPlugin"; -import { GuildActions, MuteActionResult } from "../data/GuildActions"; import { Case } from "../data/entities/Case"; import { renderTemplate } from "../templateFormatter"; +import { CasesPlugin } from "./Cases"; +import { MuteResult, MutesPlugin } from "./Mutes"; enum IgnoredEventType { Ban = 1, @@ -65,8 +65,8 @@ interface IModActionsPluginConfig { export class ModActionsPlugin extends ZeppelinPlugin { public static pluginName = "mod_actions"; + public static dependencies = ["cases", "mutes"]; - protected actions: GuildActions; protected mutes: GuildMutes; protected cases: GuildCases; protected serverLogs: GuildLogs; @@ -74,7 +74,6 @@ export class ModActionsPlugin extends ZeppelinPlugin { protected ignoredEvents: IIgnoredEvent[]; async onLoad() { - this.actions = GuildActions.getInstance(this.guildId); this.mutes = GuildMutes.getInstance(this.guildId); this.cases = GuildCases.getInstance(this.guildId); this.serverLogs = new GuildLogs(this.guildId); @@ -156,62 +155,6 @@ export class ModActionsPlugin extends ZeppelinPlugin { return ((reason || "") + " " + attachmentUrls.join(" ")).trim(); } - /** - * Resolves a user from the passed string. The passed string can be a user id, a user mention, a full username (with discrim), etc. - */ - async resolveUser(userResolvable: string): Promise { - let userId; - - // A user mention? - const mentionMatch = userResolvable.match(/^<@!?(\d+)>$/); - if (mentionMatch) { - userId = mentionMatch[1]; - } - - // A non-mention, full username? - if (!userId) { - const usernameMatch = userResolvable.match(/^@?([^#]+)#(\d{4})$/); - if (usernameMatch) { - const user = this.bot.users.find(u => u.username === usernameMatch[1] && u.discriminator === usernameMatch[2]); - userId = user.id; - } - } - - // Just a user ID? - if (!userId) { - const idMatch = userResolvable.match(/^\d+$/); - if (!idMatch) { - return null; - } - - userId = userResolvable; - } - - const cachedUser = this.bot.users.find(u => u.id === userId); - if (cachedUser) return cachedUser; - - try { - const freshUser = await this.bot.getRESTUser(userId); - return freshUser; - } catch (e) {} // tslint:disable-line - - return createUnknownUser({ id: userId }); - } - - async getMember(userId: string): Promise { - // See if we have the member cached... - let member = this.guild.members.get(userId); - - // If not, fetch it from the API - if (!member) { - try { - member = await this.bot.getRESTGuildMember(this.guildId, userId); - } catch (e) {} // tslint:disable-line - } - - return member; - } - async isBanned(userId): Promise { const bans = (await this.guild.getBans()) as any; return bans.some(b => b.user.id === userId); @@ -234,11 +177,12 @@ export class ModActionsPlugin extends ZeppelinPlugin { user.id, ); + const casesPlugin = this.getPlugin("cases"); if (relevantAuditLogEntry) { const modId = relevantAuditLogEntry.user.id; const auditLogId = relevantAuditLogEntry.id; - this.actions.fire("createCase", { + casesPlugin.createCase({ userId: user.id, modId, type: CaseTypes.Ban, @@ -247,8 +191,9 @@ export class ModActionsPlugin extends ZeppelinPlugin { automatic: true, }); } else { - this.actions.fire("createCase", { + casesPlugin.createCase({ userId: user.id, + modId: null, type: CaseTypes.Ban, }); } @@ -271,11 +216,12 @@ export class ModActionsPlugin extends ZeppelinPlugin { user.id, ); + const casesPlugin = this.getPlugin("cases"); if (relevantAuditLogEntry) { const modId = relevantAuditLogEntry.user.id; const auditLogId = relevantAuditLogEntry.id; - this.actions.fire("createCase", { + casesPlugin.createCase({ userId: user.id, modId, type: CaseTypes.Unban, @@ -283,8 +229,9 @@ export class ModActionsPlugin extends ZeppelinPlugin { automatic: true, }); } else { - this.actions.fire("createCase", { + casesPlugin.createCase({ userId: user.id, + modId: null, type: CaseTypes.Unban, automatic: true, }); @@ -337,7 +284,8 @@ export class ModActionsPlugin extends ZeppelinPlugin { }`, ); } else { - this.actions.fire("createCase", { + const casesPlugin = this.getPlugin("cases"); + casesPlugin.createCase({ userId: member.id, modId: kickAuditLogEntry.user.id, type: CaseTypes.Kick, @@ -374,10 +322,11 @@ export class ModActionsPlugin extends ZeppelinPlugin { return; } - await this.actions.fire("createCaseNote", { + const casesPlugin = this.getPlugin("cases"); + await casesPlugin.createCaseNote({ caseId: theCase.id, modId: msg.author.id, - note: args.note, + body: args.note, }); this.serverLogs.log(LogType.CASE_UPDATE, { @@ -399,7 +348,8 @@ export class ModActionsPlugin extends ZeppelinPlugin { const userName = `${user.username}#${user.discriminator}`; const reason = this.formatReasonWithAttachments(args.note, msg.attachments); - const createdCase = await this.actions.fire("createCase", { + const casesPlugin = this.getPlugin("cases"); + const createdCase = await casesPlugin.createCase({ userId: user.id, modId: msg.author.id, type: CaseTypes.Note, @@ -466,7 +416,8 @@ export class ModActionsPlugin extends ZeppelinPlugin { } } - const createdCase: Case = await this.actions.fire("createCase", { + const casesPlugin = this.getPlugin("cases"); + const createdCase = await casesPlugin.createCase({ userId: memberToWarn.id, modId: mod.id, type: CaseTypes.Warn, @@ -513,17 +464,13 @@ export class ModActionsPlugin extends ZeppelinPlugin { const timeUntilUnmute = args.time && humanizeDuration(args.time); const reason = this.formatReasonWithAttachments(args.reason, msg.attachments); - let muteResult: MuteActionResult; + let muteResult: MuteResult; + const mutesPlugin = this.getPlugin("mutes"); try { - muteResult = await this.actions.fire("mute", { - userId: user.id, - muteTime: args.time, - reason, - caseDetails: { - modId: mod.id, - ppId: pp && pp.id, - }, + muteResult = await mutesPlugin.muteUser(user.id, args.time, reason, { + modId: mod.id, + ppId: pp && pp.id, }); } catch (e) { logger.error(`Failed to mute user ${user.id}: ${e.stack}`); @@ -646,14 +593,11 @@ export class ModActionsPlugin extends ZeppelinPlugin { const reason = this.formatReasonWithAttachments(args.reason, msg.attachments); - const result = await this.actions.fire("unmute", { - userId: user.id, - unmuteTime: args.time, - caseDetails: { - modId: mod.id, - ppId: pp && pp.id, - reason, - }, + 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 @@ -810,7 +754,8 @@ export class ModActionsPlugin extends ZeppelinPlugin { memberToKick.kick(reason); // Create a case for this action - const createdCase = await this.actions.fire("createCase", { + const casesPlugin = this.getPlugin("cases"); + const createdCase = await casesPlugin.createCase({ userId: memberToKick.id, modId: mod.id, type: CaseTypes.Kick, @@ -896,7 +841,8 @@ export class ModActionsPlugin extends ZeppelinPlugin { memberToBan.ban(1, reason); // Create a case for this action - const createdCase = await this.actions.fire("createCase", { + const casesPlugin = this.getPlugin("cases"); + const createdCase = await casesPlugin.createCase({ userId: memberToBan.id, modId: mod.id, type: CaseTypes.Ban, @@ -970,7 +916,8 @@ export class ModActionsPlugin extends ZeppelinPlugin { await this.guild.unbanMember(memberToSoftban.id); // Create a case for this action - const createdCase = await this.actions.fire("createCase", { + const casesPlugin = this.getPlugin("cases"); + const createdCase = await casesPlugin.createCase({ userId: memberToSoftban.id, modId: mod.id, type: CaseTypes.Softban, @@ -1026,7 +973,8 @@ export class ModActionsPlugin extends ZeppelinPlugin { const reason = this.formatReasonWithAttachments(args.reason, msg.attachments); // Create a case - const createdCase = await this.actions.fire("createCase", { + const casesPlugin = this.getPlugin("cases"); + const createdCase = await casesPlugin.createCase({ userId: user.id, modId: mod.id, type: CaseTypes.Unban, @@ -1083,7 +1031,8 @@ export class ModActionsPlugin extends ZeppelinPlugin { } // Create a case - const createdCase = await this.actions.fire("createCase", { + const casesPlugin = this.getPlugin("cases"); + const createdCase = await casesPlugin.createCase({ userId: user.id, modId: mod.id, type: CaseTypes.Ban, @@ -1142,16 +1091,17 @@ export class ModActionsPlugin extends ZeppelinPlugin { // 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); - await this.actions.fire("createCase", { + await casesPlugin.createCase({ userId, modId: msg.author.id, type: CaseTypes.Ban, reason: `Mass ban: ${banReason}`, - postInCaseLog: false, + postInCaseLogOverride: false, }); } catch (e) { failedBans.push(userId); @@ -1218,7 +1168,8 @@ export class ModActionsPlugin extends ZeppelinPlugin { const reason = this.formatReasonWithAttachments(args.reason, msg.attachments); // Create the case - const theCase: Case = await this.actions.fire("createCase", { + const casesPlugin = this.getPlugin("cases"); + const theCase: Case = await casesPlugin.createCase({ userId: user.id, modId: mod.id, type: CaseTypes[type], @@ -1259,10 +1210,9 @@ export class ModActionsPlugin extends ZeppelinPlugin { return; } - await this.actions.fire("postCase", { - caseId: theCase.id, - channel: msg.channel, - }); + const casesPlugin = this.getPlugin("cases"); + const embed = await casesPlugin.getCaseEmbed(theCase.id); + msg.channel.createMessage(embed); } @d.command("cases", " [opts:string$]", { @@ -1303,11 +1253,10 @@ export class ModActionsPlugin extends ZeppelinPlugin { } // Expanded view (= individual case embeds) + const casesPlugin = this.getPlugin("cases"); for (const theCase of casesToDisplay) { - await this.actions.fire("postCase", { - caseId: theCase.id, - channel: msg.channel, - }); + const embed = await casesPlugin.getCaseEmbed(theCase.id); + msg.channel.createMessage(embed); } } else { // Compact view (= regular message with a preview of each case) diff --git a/src/plugins/Mutes.ts b/src/plugins/Mutes.ts index d16bb4b6..285248e3 100644 --- a/src/plugins/Mutes.ts +++ b/src/plugins/Mutes.ts @@ -1,8 +1,7 @@ import { Member, Message, User } from "eris"; -import { GuildCases, ICaseDetails } from "../data/GuildCases"; +import { GuildCases } from "../data/GuildCases"; import moment from "moment-timezone"; import { ZeppelinPlugin } from "./ZeppelinPlugin"; -import { GuildActions } from "../data/GuildActions"; import { GuildMutes } from "../data/GuildMutes"; import { chunkMessageLines, @@ -14,7 +13,6 @@ import { stripObjectToScalars, successMessage, ucfirst, - unknownUser, } from "../utils"; import humanizeDuration from "humanize-duration"; import { LogType } from "../data/LogType"; @@ -23,6 +21,8 @@ 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"; interface IMuteWithDetails extends Mute { member?: Member; @@ -43,10 +43,19 @@ interface IMutesPluginConfig { can_cleanup: boolean; } +export type MuteResult = { + case: Case; + notifyResult: INotifyUserResult; + updatedExistingMute: boolean; +}; + +export type UnmuteResult = { + case: Case; +}; + export class MutesPlugin extends ZeppelinPlugin { public static pluginName = "mutes"; - protected actions: GuildActions; protected mutes: GuildMutes; protected cases: GuildCases; protected serverLogs: GuildLogs; @@ -84,62 +93,38 @@ export class MutesPlugin extends ZeppelinPlugin { }; } - onLoad() { - this.actions = GuildActions.getInstance(this.guildId); + protected onLoad() { this.mutes = GuildMutes.getInstance(this.guildId); this.cases = GuildCases.getInstance(this.guildId); this.serverLogs = new GuildLogs(this.guildId); - this.actions.register("mute", args => { - return this.muteUser(args.userId, args.muteTime, args.reason, args.caseDetails); - }); - this.actions.register("unmute", args => { - return this.unmuteUser(args.userId, args.unmuteTime, args.caseDetails); - }); - // Check for expired mutes every 5s this.clearExpiredMutes(); this.muteClearIntervalId = setInterval(() => this.clearExpiredMutes(), 5000); } - onUnload() { - this.actions.unregister("mute"); - this.actions.unregister("unmute"); - + protected onUnload() { clearInterval(this.muteClearIntervalId); } - async resolveMember(userId: string) { - if (this.guild.members.has(userId)) { - return this.guild.members.get(userId); - } - - try { - const member = await this.bot.getRESTGuildMember(this.guildId, userId); - return member; - } catch (e) { - return null; - } - } - public async muteUser( userId: string, muteTime: number = null, reason: string = null, - caseDetails: ICaseDetails = {}, - ) { + caseArgs: Partial = {}, + ): Promise { const muteRole = this.getConfig().mute_role; if (!muteRole) return; const timeUntilUnmute = muteTime && humanizeDuration(muteTime); // No mod specified -> mark Zeppelin as the mod - if (!caseDetails.modId) { - caseDetails.modId = this.bot.user.id; + if (!caseArgs.modId) { + caseArgs.modId = this.bot.user.id; } - const user = this.bot.users.get(userId) || { ...unknownUser, id: userId }; - const member = await this.resolveMember(userId); + const user = await this.resolveUser(userId); + const member = await this.getMember(user.id); if (member) { // Apply mute role if it's missing @@ -193,17 +178,29 @@ export class MutesPlugin extends ZeppelinPlugin { } // 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"}`]; - await this.actions.fire("createCaseNote", { - caseId: existingMute.case_id, - modId: caseDetails.modId, - note: reason, - noteDetails, - }); + const reasons = [reason, ...(caseArgs.extraNotes || [])]; + for (const noteReason of reasons) { + await casesPlugin.createCaseNote({ + caseId: existingMute.case_id, + modId: caseArgs.modId, + body: noteReason, + noteDetails, + postInCaseLogOverride: false, + }); + } + + if (caseArgs.postInCaseLogOverride !== false) { + casesPlugin.postCaseToCaseLogChannel(existingMute.case_id); + } } else { // Create new case const noteDetails = [`Muted ${muteTime ? `for ${timeUntilUnmute}` : "indefinitely"}`]; @@ -211,20 +208,19 @@ export class MutesPlugin extends ZeppelinPlugin { noteDetails.push(ucfirst(notifyResult.text)); } - theCase = await this.actions.fire("createCase", { + theCase = await casesPlugin.createCase({ + ...caseArgs, userId, - modId: caseDetails.modId, + modId: caseArgs.modId, type: CaseTypes.Mute, reason, - ppId: caseDetails.ppId, noteDetails, - extraNotes: caseDetails.extraNotes, }); await this.mutes.setCaseId(user.id, theCase.id); } // Log the action - const mod = this.bot.users.get(caseDetails.modId) || { ...unknownUser, id: caseDetails.modId }; + const mod = await this.resolveUser(caseArgs.modId); if (muteTime) { this.serverLogs.log(LogType.MEMBER_TIMED_MUTE, { mod: stripObjectToScalars(mod), @@ -245,12 +241,16 @@ export class MutesPlugin extends ZeppelinPlugin { }; } - public async unmuteUser(userId: string, unmuteTime: number = null, caseDetails: ICaseDetails = {}) { + public async unmuteUser( + userId: string, + unmuteTime: number = null, + caseArgs: Partial = {}, + ): Promise { const existingMute = await this.mutes.findExistingMuteForUserId(userId); if (!existingMute) return; - const user = this.bot.users.get(userId) || { ...unknownUser, id: userId }; - const member = await this.resolveMember(userId); + const user = await this.resolveUser(userId); + const member = await this.getMember(userId); if (unmuteTime) { // Schedule timed unmute (= just set the mute's duration) @@ -277,17 +277,17 @@ export class MutesPlugin extends ZeppelinPlugin { noteDetails.push(`Unmuted immediately`); } - const createdCase = await this.actions.fire("createCase", { + const casesPlugin = this.getPlugin("cases"); + const createdCase = await casesPlugin.createCase({ + ...caseArgs, userId, - modId: caseDetails.modId, + modId: caseArgs.modId, type: CaseTypes.Unmute, - reason: caseDetails.reason, - ppId: caseDetails.ppId, noteDetails, }); // Log the action - const mod = this.bot.users.get(caseDetails.modId); + const mod = this.bot.users.get(caseArgs.modId); if (unmuteTime) { this.serverLogs.log(LogType.MEMBER_TIMED_UNMUTE, { mod: stripObjectToScalars(mod), @@ -310,7 +310,7 @@ export class MutesPlugin extends ZeppelinPlugin { options: [{ name: "age", type: "delay" }, { name: "left", type: "boolean" }], }) @d.permission("can_view_list") - public async muteListCmd(msg: Message, args: { age?: number; left?: boolean }) { + protected async muteListCmd(msg: Message, args: { age?: number; left?: boolean }) { const lines = []; // Active, logged mutes @@ -449,7 +449,7 @@ export class MutesPlugin extends ZeppelinPlugin { * Reapply active mutes on join */ @d.event("guildMemberAdd") - async onGuildMemberAdd(_, member: Member) { + protected async onGuildMemberAdd(_, member: Member) { const mute = await this.mutes.findExistingMuteForUserId(member.id); if (mute) { const muteRole = this.getConfig().mute_role; @@ -465,7 +465,7 @@ export class MutesPlugin extends ZeppelinPlugin { * Clear active mute from the member if the member is banned */ @d.event("guildBanAdd") - async onGuildBanAdd(_, user: User) { + protected async onGuildBanAdd(_, user: User) { const mute = await this.mutes.findExistingMuteForUserId(user.id); if (mute) { this.mutes.clear(user.id); @@ -477,7 +477,7 @@ export class MutesPlugin extends ZeppelinPlugin { */ @d.command("clear_banned_mutes") @d.permission("can_cleanup") - async clearBannedMutesCmd(msg: Message) { + protected async clearBannedMutesCmd(msg: Message) { await msg.channel.createMessage("Clearing mutes from banned users..."); const activeMutes = await this.mutes.getActiveMutes(); @@ -505,7 +505,7 @@ export class MutesPlugin extends ZeppelinPlugin { * Clear active mute from the member if the mute role is removed */ @d.event("guildMemberUpdate") - async onGuildMemberUpdate(_, member: Member) { + protected async onGuildMemberUpdate(_, member: Member) { const muteRole = this.getConfig().mute_role; if (!muteRole) return; @@ -522,7 +522,7 @@ export class MutesPlugin extends ZeppelinPlugin { */ @d.command("clear_mutes_without_role") @d.permission("can_cleanup") - async clearMutesWithoutRoleCmd(msg: Message) { + protected async clearMutesWithoutRoleCmd(msg: Message) { const activeMutes = await this.mutes.getActiveMutes(); const muteRole = this.getConfig().mute_role; if (!muteRole) return; @@ -545,7 +545,7 @@ export class MutesPlugin extends ZeppelinPlugin { @d.command("clear_mute", "") @d.permission("can_cleanup") - async clearMuteCmd(msg: Message, args: { userId: string }) { + protected async clearMuteCmd(msg: Message, args: { userId: string }) { const mute = await this.mutes.findExistingMuteForUserId(args.userId); if (!mute) { msg.channel.createMessage(errorMessage("No active mutes found for that user id")); diff --git a/src/plugins/Slowmode.ts b/src/plugins/Slowmode.ts index 0810e765..a4bd3eda 100644 --- a/src/plugins/Slowmode.ts +++ b/src/plugins/Slowmode.ts @@ -8,6 +8,7 @@ import { SavedMessage } from "../data/entities/SavedMessage"; import { GuildSavedMessages } from "../data/GuildSavedMessages"; const NATIVE_SLOWMODE_LIMIT = 6 * 60 * 60; // 6 hours +const MAX_SLOWMODE = 60 * 60 * 24 * 365 * 100; // 100 years interface ISlowmodePluginConfig { use_native_slowmode: boolean; @@ -238,6 +239,15 @@ export class SlowmodePlugin extends ZeppelinPlugin { 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: args.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 diff --git a/src/plugins/Spam.ts b/src/plugins/Spam.ts index e3b26dc4..ea8a72a4 100644 --- a/src/plugins/Spam.ts +++ b/src/plugins/Spam.ts @@ -17,10 +17,10 @@ import { GuildArchives } from "../data/GuildArchives"; import moment from "moment-timezone"; import { SavedMessage } from "../data/entities/SavedMessage"; import { GuildSavedMessages } from "../data/GuildSavedMessages"; -import { GuildActions, MuteActionResult } from "../data/GuildActions"; -import { Case } from "../data/entities/Case"; import { GuildMutes } from "../data/GuildMutes"; import { ZeppelinPlugin } from "./ZeppelinPlugin"; +import { MuteResult, MutesPlugin } from "./Mutes"; +import { CasesPlugin } from "./Cases"; enum RecentActionType { Message = 1, @@ -71,7 +71,6 @@ interface ISpamPluginConfig { export class SpamPlugin extends ZeppelinPlugin { public static pluginName = "spam"; - protected actions: GuildActions; protected logs: GuildLogs; protected archives: GuildArchives; protected savedMessages: GuildSavedMessages; @@ -128,7 +127,6 @@ export class SpamPlugin extends ZeppelinPlugin { } onLoad() { - this.actions = GuildActions.getInstance(this.guildId); this.logs = new GuildLogs(this.guildId); this.archives = GuildArchives.getInstance(this.guildId); this.savedMessages = GuildSavedMessages.getInstance(this.guildId); @@ -240,18 +238,15 @@ export class SpamPlugin extends ZeppelinPlugin { const recentActions = this.getRecentActions(type, savedMessage.user_id, savedMessage.channel_id, since); // Start by muting them, if enabled - let muteResult: MuteActionResult; + 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 this.actions.fire("mute", { - userId: member.id, - muteTime, - reason: "Automatic spam detection", - caseDetails: { - modId: this.bot.user.id, - }, + muteResult = await mutesPlugin.muteUser(member.id, muteTime, "Automatic spam detection", { + modId: this.bot.user.id, + postInCaseLogOverride: false, }); } @@ -296,18 +291,20 @@ export class SpamPlugin extends ZeppelinPlugin { 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} `); - this.actions.fire("createCaseNote", { + casesPlugin.createCaseNote({ caseId: muteResult.case.id, modId: muteResult.case.mod_id, - note: updateText, + body: updateText, automatic: true, - postInCaseLogOverride: false, }); } else { // If the user was not muted, create a note case of the detected spam instead @@ -316,7 +313,7 @@ export class SpamPlugin extends ZeppelinPlugin { ${archiveUrl} `); - this.actions.fire("createCase", { + casesPlugin.createCase({ userId: savedMessage.user_id, modId: this.bot.user.id, type: CaseTypes.Note, @@ -362,42 +359,35 @@ export class SpamPlugin extends ZeppelinPlugin { if (recentActionsCount > spamConfig.count) { const member = this.guild.members.get(userId); + const details = `${description} (over ${spamConfig.count} in ${spamConfig.interval}s)`; - // Start by muting them, if enabled if (spamConfig.mute && member) { - const muteTime = spamConfig.mute_time ? spamConfig.mute_time * 60 * 1000 : 120 * 1000; - this.logs.ignoreLog(LogType.MEMBER_ROLE_ADD, userId); - this.actions.fire("mute", { userId: member.id, muteTime, reason: "Automatic spam detection" }); + 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", { + 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); - // Create a case and log the actions taken above - const caseType = spamConfig.mute ? CaseTypes.Mute : CaseTypes.Note; - const caseText = trimLines(` - Automatic spam detection: ${description} (over ${spamConfig.count} in ${spamConfig.interval}s) - `); - this.logs.log(LogType.OTHER_SPAM_DETECTED, { member: stripObjectToScalars(member, ["user"]), description, limit: spamConfig.count, interval: spamConfig.interval, }); - - const theCase: Case = await this.actions.fire("createCase", { - userId, - modId: this.bot.user.id, - type: caseType, - reason: caseText, - automatic: true, - }); - - // For mutes, also set the mute's case id (for !mutes) - if (spamConfig.mute && member) { - await this.mutes.setCaseId(userId, theCase.id); - } } }); } diff --git a/src/plugins/ZeppelinPlugin.ts b/src/plugins/ZeppelinPlugin.ts index cf5c7395..7a51e714 100644 --- a/src/plugins/ZeppelinPlugin.ts +++ b/src/plugins/ZeppelinPlugin.ts @@ -1,12 +1,15 @@ import { IBasePluginConfig, IPluginOptions, Plugin } from "knub"; import { PluginRuntimeError } from "../PluginRuntimeError"; import Ajv, { ErrorObject } from "ajv"; -import { isSnowflake, isUnicodeEmoji } from "../utils"; +import { createUnknownUser, isSnowflake, isUnicodeEmoji, UnknownUser } from "../utils"; +import { Member, User } from "eris"; export class ZeppelinPlugin extends Plugin { protected configSchema: any; protected permissionsSchema: any; + public static dependencies = []; + protected throwPluginRuntimeError(message: string) { throw new PluginRuntimeError(message, this.runtimePluginName, this.guildId); } @@ -73,4 +76,64 @@ export class ZeppelinPlugin extends Plug public getRegisteredCommands() { return this.commands.commands; } + + /** + * Resolves a user from the passed string. The passed string can be a user id, a user mention, a full username (with discrim), etc. + */ + async resolveUser(userResolvable: string): Promise { + if (userResolvable == null) { + return createUnknownUser(); + } + + let userId; + + // A user mention? + const mentionMatch = userResolvable.match(/^<@!?(\d+)>$/); + if (mentionMatch) { + userId = mentionMatch[1]; + } + + // A non-mention, full username? + if (!userId) { + const usernameMatch = userResolvable.match(/^@?([^#]+)#(\d{4})$/); + if (usernameMatch) { + const user = this.bot.users.find(u => u.username === usernameMatch[1] && u.discriminator === usernameMatch[2]); + userId = user.id; + } + } + + // Just a user ID? + if (!userId) { + const idMatch = userResolvable.match(/^\d+$/); + if (!idMatch) { + return null; + } + + userId = userResolvable; + } + + const cachedUser = this.bot.users.find(u => u.id === userId); + if (cachedUser) return cachedUser; + + try { + const freshUser = await this.bot.getRESTUser(userId); + return freshUser; + } catch (e) {} // tslint:disable-line + + return createUnknownUser({ id: userId }); + } + + async getMember(userId: string): Promise { + // See if we have the member cached... + let member = this.guild.members.get(userId); + + // If not, fetch it from the API + if (!member) { + try { + member = await this.bot.getRESTGuildMember(this.guildId, userId); + } catch (e) {} // tslint:disable-line + } + + return member; + } } diff --git a/src/utils.ts b/src/utils.ts index 06eeda8d..22d561a2 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -504,7 +504,7 @@ export type UnknownUser = { }; export const unknownUser: UnknownUser = { - id: "0", + id: null, username: "Unknown", discriminator: "0000", };