diff --git a/src/PluginRuntimeError.ts b/src/PluginRuntimeError.ts new file mode 100644 index 00000000..ac2e7d16 --- /dev/null +++ b/src/PluginRuntimeError.ts @@ -0,0 +1,17 @@ +import util from "util"; + +export class PluginRuntimeError { + public message: string; + public pluginName: string; + public guildId: string; + + constructor(message: string, pluginName: string, guildId: string) { + this.message = message; + this.pluginName = pluginName; + this.guildId = guildId; + } + + [util.inspect.custom](depth, options) { + return `PRE [${this.pluginName}] [${this.guildId}] ${this.message}`; + } +} diff --git a/src/QueuedEventEmitter.ts b/src/QueuedEventEmitter.ts index e5f1d80b..62c4f542 100644 --- a/src/QueuedEventEmitter.ts +++ b/src/QueuedEventEmitter.ts @@ -28,11 +28,14 @@ export class QueuedEventEmitter { listeners.splice(listeners.indexOf(listener), 1); } - emit(eventName: string, args: any[] = []) { + emit(eventName: string, args: any[] = []): Promise { const listeners = [...(this.listeners.get(eventName) || []), ...(this.listeners.get("*") || [])]; + let promise: Promise = Promise.resolve(); listeners.forEach(listener => { - this.queue.add(listener.bind(null, ...args)); + promise = this.queue.add(listener.bind(null, ...args)); }); + + return promise; } } diff --git a/src/data/GuildActions.ts b/src/data/GuildActions.ts new file mode 100644 index 00000000..2a8ceebd --- /dev/null +++ b/src/data/GuildActions.ts @@ -0,0 +1,24 @@ +import { BaseRepository } from "./BaseRepository"; + +type ActionFn = (...args: any[]) => any | Promise; + +export class GuildActions extends BaseRepository { + private actions: Map; + + constructor(guildId) { + super(guildId); + this.actions = new Map(); + } + + public register(actionName: string, actionFn: ActionFn) { + this.actions.set(actionName, actionFn); + } + + public unregister(actionName: string) { + this.actions.delete(actionName); + } + + public fire(actionName: string, ...args: any[]): 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 9d7cc93c..3f52c25e 100644 --- a/src/data/GuildCases.ts +++ b/src/data/GuildCases.ts @@ -53,26 +53,24 @@ export class GuildCases extends BaseRepository { }); } - async create(data): Promise { + async create(data): Promise { const result = await this.cases.insert({ ...data, guild_id: this.guildId, case_number: () => `(SELECT IFNULL(MAX(case_number)+1, 1) FROM cases AS ma2 WHERE guild_id = ${this.guildId})` }); - return result.identifiers[0].id; + return this.find(result.identifiers[0].id); } update(id, data) { return this.cases.update(id, data); } - async createNote(caseId: number, data: any): Promise { - const result = await this.caseNotes.insert({ + async createNote(caseId: number, data: any): Promise { + await this.caseNotes.insert({ ...data, case_id: caseId }); - - return result.identifiers[0].id; } } diff --git a/src/data/GuildEvents.ts b/src/data/GuildEvents.ts new file mode 100644 index 00000000..3f36b60f --- /dev/null +++ b/src/data/GuildEvents.ts @@ -0,0 +1,42 @@ +import { BaseRepository } from "./BaseRepository"; +import { QueuedEventEmitter } from "../QueuedEventEmitter"; + +export class GuildEvents extends BaseRepository { + private queuedEventEmitter: QueuedEventEmitter; + private pluginListeners: Map>; + + constructor(guildId) { + super(guildId); + this.queuedEventEmitter = new QueuedEventEmitter(); + } + + public on(pluginName: string, eventName: string, fn) { + this.queuedEventEmitter.on(eventName, fn); + + if (!this.pluginListeners.has(pluginName)) { + this.pluginListeners.set(pluginName, new Map()); + } + + const pluginListeners = this.pluginListeners.get(pluginName); + if (!pluginListeners.has(eventName)) { + pluginListeners.set(eventName, []); + } + + const pluginEventListeners = pluginListeners.get(eventName); + pluginEventListeners.push(fn); + } + + public offPlugin(pluginName: string) { + const pluginListeners = this.pluginListeners.get(pluginName) || new Map(); + for (const [eventName, listeners] of Array.from(pluginListeners.entries())) { + for (const listener of listeners) { + this.queuedEventEmitter.off(eventName, listener); + } + } + this.pluginListeners.delete(pluginName); + } + + public emit(eventName: string, args: any[] = []) { + return this.queuedEventEmitter.emit(eventName, args); + } +} diff --git a/src/data/GuildMutes.ts b/src/data/GuildMutes.ts index cd5f519e..88360d01 100644 --- a/src/data/GuildMutes.ts +++ b/src/data/GuildMutes.ts @@ -29,18 +29,20 @@ export class GuildMutes extends BaseRepository { }); } - async addMute(userId, expiryTime) { + async addMute(userId, expiryTime): Promise { const expiresAt = expiryTime ? moment() .add(expiryTime, "ms") .format("YYYY-MM-DD HH:mm:ss") : null; - return this.mutes.insert({ + const result = await this.mutes.insert({ guild_id: this.guildId, user_id: userId, expires_at: expiresAt }); + + return this.mutes.findOne(result.identifiers[0].id); } async updateExpiryTime(userId, newExpiryTime) { @@ -61,11 +63,12 @@ export class GuildMutes extends BaseRepository { ); } - async addOrUpdateMute(userId, expiryTime) { + async addOrUpdateMute(userId, expiryTime): Promise { const existingMute = await this.findExistingMuteForUserId(userId); if (existingMute) { - return this.updateExpiryTime(userId, expiryTime); + await this.updateExpiryTime(userId, expiryTime); + return this.findExistingMuteForUserId(userId); } else { return this.addMute(userId, expiryTime); } @@ -83,7 +86,7 @@ export class GuildMutes extends BaseRepository { .getMany(); } - async setCaseId(userId, caseId) { + async setCaseId(userId: string, caseId: number) { await this.mutes.update( { guild_id: this.guildId, diff --git a/src/index.ts b/src/index.ts index b940e0e6..c80b436d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -45,6 +45,8 @@ import { PersistPlugin } from "./plugins/Persist"; import { SpamPlugin } from "./plugins/Spam"; import { TagsPlugin } from "./plugins/Tags"; import { MessageSaverPlugin } from "./plugins/MessageSaver"; +import { CasesPlugin } from "./plugins/Cases"; +import { MutesPlugin } from "./plugins/Mutes"; // Run latest database migrations logger.info("Running database migrations"); @@ -56,10 +58,16 @@ connect().then(async conn => { }); client.setMaxListeners(100); + const basePlugins = ["message_saver", "cases", "mutes"]; + const bot = new Knub(client, { plugins: { - messageSaver: MessageSaverPlugin, + // Base plugins (always enabled) + message_saver: MessageSaverPlugin, + cases: CasesPlugin, + mutes: MutesPlugin, + // Regular plugins utility: UtilityPlugin, mod_actions: ModActionsPlugin, logs: LogsPlugin, @@ -80,7 +88,7 @@ connect().then(async conn => { const plugins = guildConfig.plugins || {}; const keys: string[] = Array.from(this.plugins.keys()); return keys.filter(pluginName => { - return (plugins[pluginName] && plugins[pluginName].enabled !== false) || pluginName === "messageSaver"; + return basePlugins.includes(pluginName) || (plugins[pluginName] && plugins[pluginName].enabled !== false); }); }, diff --git a/src/plugins/Cases.ts b/src/plugins/Cases.ts new file mode 100644 index 00000000..c58a792f --- /dev/null +++ b/src/plugins/Cases.ts @@ -0,0 +1,217 @@ +import { Message, MessageContent, MessageFile, TextableChannel, 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"; + +export class CasesPlugin extends ZeppelinPlugin { + protected actions: GuildActions; + protected cases: GuildCases; + + getDefaultOptions() { + return { + config: { + log_automatic_actions: true, + case_log_channel: null + } + }; + } + + onLoad() { + this.actions = GuildActions.getInstance(this.guildId); + this.cases = GuildCases.getInstance(this.guildId); + + this.actions.register("createCase", args => { + return this.createCase( + args.userId, + args.modId, + args.type, + args.auditLogId, + args.reason, + args.automatic, + args.postInCaseLog + ); + }); + + this.actions.register("createCaseNote", args => { + return this.createCaseNote(args.case || args.caseId, args.modId, args.note, args.automatic, args.postInCaseLog); + }); + + this.actions.register("postCase", async args => { + const embed = await this.getCaseEmbed(args.case || args.caseId); + return (args.channel as TextableChannel).createMessage(embed); + }); + } + + onUnload() { + this.actions.unregister("createCase"); + this.actions.unregister("createCaseNote"); + this.actions.unregister("postCase"); + } + + protected async resolveCase(caseOrCaseId: Case | number): Promise { + return caseOrCaseId instanceof Case ? caseOrCaseId : this.cases.with("notes").find(caseOrCaseId); + } + + /** + * 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( + userId: string, + modId: string, + type: CaseTypes, + auditLogId: string = null, + reason: string = null, + automatic = false, + postInCaseLogOverride = null + ): Promise { + const user = this.bot.users.get(userId); + const userName = user ? `${user.username}#${user.discriminator}` : "Unknown#0000"; + + const mod = this.bot.users.get(modId); + const modName = mod ? `${mod.username}#${mod.discriminator}` : "Unknown#0000"; + + const createdCase = await this.cases.create({ + type, + user_id: userId, + user_name: userName, + mod_id: modId, + mod_name: modName, + audit_log_id: auditLogId + }); + + if (reason) { + await this.createCaseNote(createdCase, modId, reason); + } + + if ( + this.configValue("case_log_channel") && + (!automatic || this.configValue("log_automatic_actions")) && + postInCaseLogOverride !== false + ) { + try { + await this.postCaseToCaseLogChannel(createdCase); + } catch (e) {} // tslint:disable-line + } + + 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( + caseOrCaseId: Case | number, + modId: string, + body: string, + automatic = false, + postInCaseLogOverride = null + ): Promise { + const mod = this.bot.users.get(modId); + const modName = mod ? `${mod.username}#${mod.discriminator}` : "Unknown#0000"; + + const theCase = await this.resolveCase(caseOrCaseId); + if (!theCase) { + this.throwPluginRuntimeError(`Unknown case ID: ${caseOrCaseId}`); + } + + await this.cases.createNote(theCase.id, { + mod_id: modId, + 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: modId, + mod_name: modName + }); + } + + if ((!automatic || this.configValue("log_automatic_actions")) && postInCaseLogOverride !== false) { + try { + await this.postCaseToCaseLogChannel(theCase.id); + } catch (e) {} // tslint:disable-line + } + } + + /** + * Returns a Discord embed for the specified case + */ + public async getCaseEmbed(caseOrCaseId: Case | number): Promise { + const theCase = await this.resolveCase(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 (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 }; + } + + /** + * A helper for posting to the case log channel. + * Returns silently if the case log channel isn't specified or is invalid. + */ + public postToCaseLogChannel(content: MessageContent, file: MessageFile = null): Promise { + const caseLogChannelId = this.configValue("case_log_channel"); + if (!caseLogChannelId) return; + + const caseLogChannel = this.guild.channels.get(caseLogChannelId); + if (!caseLogChannel || !(caseLogChannel instanceof TextChannel)) return; + + return caseLogChannel.createMessage(content, file); + } + + /** + * A helper to post a case embed to the case log channel + */ + public async postCaseToCaseLogChannel(caseOrCaseId: Case | number): Promise { + const caseEmbed = await this.getCaseEmbed(caseOrCaseId); + if (!caseEmbed) return; + + return this.postToCaseLogChannel(caseEmbed); + } +} diff --git a/src/plugins/Censor.ts b/src/plugins/Censor.ts index bb8ecfe9..cdd0198c 100644 --- a/src/plugins/Censor.ts +++ b/src/plugins/Censor.ts @@ -3,7 +3,13 @@ import { Invite, Message } from "eris"; import escapeStringRegexp from "escape-string-regexp"; import { GuildLogs } from "../data/GuildLogs"; import { LogType } from "../data/LogType"; -import { getInviteCodesInString, getUrlsInString, stripObjectToScalars } from "../utils"; +import { + deactivateMentions, + disableCodeBlocks, + getInviteCodesInString, + getUrlsInString, + stripObjectToScalars +} from "../utils"; import { ZalgoRegex } from "../data/Zalgo"; import { GuildSavedMessages } from "../data/GuildSavedMessages"; import { SavedMessage } from "../data/entities/SavedMessage"; @@ -81,7 +87,7 @@ export class CensorPlugin extends Plugin { member: stripObjectToScalars(member, ["user"]), channel: stripObjectToScalars(channel), reason, - messageText: savedMessage.data.content + messageText: disableCodeBlocks(deactivateMentions(savedMessage.data.content)) }); } diff --git a/src/plugins/ModActions.ts b/src/plugins/ModActions.ts index f404c31b..94b7c44e 100644 --- a/src/plugins/ModActions.ts +++ b/src/plugins/ModActions.ts @@ -1,14 +1,10 @@ -import { decorators as d, Plugin, waitForReaction, waitForReply } from "knub"; +import { decorators as d, waitForReaction, waitForReply } from "knub"; import { Constants as ErisConstants, Guild, Member, Message, TextChannel, User } from "eris"; -import moment from "moment-timezone"; import humanizeDuration from "humanize-duration"; -import chunk from "lodash.chunk"; import { GuildCases } from "../data/GuildCases"; import { - chunkLines, chunkMessageLines, convertDelayStringToMS, - DBDateFormat, disableLinkPreviews, errorMessage, findRelevantAuditLogEntry, @@ -18,12 +14,14 @@ import { trimLines } from "../utils"; import { GuildMutes } from "../data/GuildMutes"; -import { Case } from "../data/entities/Case"; import { CaseTypes } from "../data/CaseTypes"; import { GuildLogs } from "../data/GuildLogs"; import { LogType } from "../data/LogType"; import Timer = NodeJS.Timer; -import { CaseTypeColors } from "../data/CaseTypeColors"; +import { ZeppelinPlugin } from "./ZeppelinPlugin"; +import { GuildActions } from "../data/GuildActions"; +import { Case } from "../data/entities/Case"; +import { Mute } from "../data/entities/Mute"; enum IgnoredEventType { Ban = 1, @@ -38,35 +36,26 @@ interface IIgnoredEvent { const CASE_LIST_REASON_MAX_LENGTH = 80; -export class ModActionsPlugin extends Plugin { - public mutes: GuildMutes; +export class ModActionsPlugin extends ZeppelinPlugin { + protected actions: GuildActions; + protected mutes: GuildMutes; protected cases: GuildCases; protected serverLogs: GuildLogs; - protected muteClearIntervalId: Timer; - protected ignoredEvents: IIgnoredEvent[]; async onLoad() { - this.cases = GuildCases.getInstance(this.guildId); + this.actions = GuildActions.getInstance(this.guildId); this.mutes = GuildMutes.getInstance(this.guildId); + this.cases = GuildCases.getInstance(this.guildId); this.serverLogs = new GuildLogs(this.guildId); this.ignoredEvents = []; - - // Check for expired mutes every 5s - this.clearExpiredMutes(); - this.muteClearIntervalId = setInterval(() => this.clearExpiredMutes(), 5000); - } - - async onUnload() { - clearInterval(this.muteClearIntervalId); } getDefaultOptions() { return { config: { - mute_role: null, dm_on_warn: true, dm_on_mute: false, dm_on_kick: false, @@ -81,8 +70,6 @@ export class ModActionsPlugin extends Plugin { timed_mute_message: "You have been muted on {guildName} for {time}. Reason given: {reason}", kick_message: "You have been kicked from {guildName}. Reason given: {reason}", ban_message: "You have been banned from {guildName}. Reason given: {reason}", - log_automatic_actions: true, - case_log_channel: null, alert_on_rejoin: false, alert_channel: null }, @@ -157,9 +144,19 @@ export class ModActionsPlugin extends Plugin { const modId = relevantAuditLogEntry.user.id; const auditLogId = relevantAuditLogEntry.id; - await this.createCase(user.id, modId, CaseTypes.Ban, auditLogId, relevantAuditLogEntry.reason, true); + this.actions.fire("createCase", { + userId: user.id, + modId, + type: CaseTypes.Ban, + auditLogId, + reason: relevantAuditLogEntry.reason, + automatic: true + }); } else { - await this.createCase(user.id, null, CaseTypes.Ban); + this.actions.fire("createCase", { + userId: user.id, + type: CaseTypes.Ban + }); } } @@ -184,9 +181,19 @@ export class ModActionsPlugin extends Plugin { const modId = relevantAuditLogEntry.user.id; const auditLogId = relevantAuditLogEntry.id; - await this.createCase(user.id, modId, CaseTypes.Unban, auditLogId, null, true); + this.actions.fire("createCase", { + userId: user.id, + modId, + type: CaseTypes.Unban, + auditLogId, + automatic: true + }); } else { - await this.createCase(user.id, null, CaseTypes.Unban); + this.actions.fire("createCase", { + userId: user.id, + type: CaseTypes.Unban, + automatic: true + }); } } @@ -226,14 +233,15 @@ export class ModActionsPlugin extends Plugin { ); if (kickAuditLogEntry) { - this.createCase( - member.id, - kickAuditLogEntry.user.id, - CaseTypes.Kick, - kickAuditLogEntry.id, - kickAuditLogEntry.reason, - true - ); + this.actions.fire("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) @@ -249,24 +257,16 @@ export class ModActionsPlugin extends Plugin { async updateCmd(msg: Message, args: any) { const theCase = await this.cases.findByCaseNumber(args.caseNumber); if (!theCase) { - msg.channel.createMessage("Case not found!"); + msg.channel.createMessage(errorMessage("Case not found")); return; } - if (theCase.mod_id === null) { - // If the action has no moderator information, assume the first one to update it did the action - await this.cases.update(theCase.id, { - mod_id: msg.author.id, - mod_name: `${msg.author.username}#${msg.author.discriminator}` - }); - } + await this.actions.fire("createCaseNote", theCase, { + modId: msg.author.id, + note: args.note + }); - await this.createCaseNote(theCase.id, msg.author.id, args.note); - this.postCaseToCaseLog(theCase.id); // Post updated case to case log - - if (msg.channel.id !== this.configValue("case_log_channel")) { - msg.channel.createMessage(successMessage(`Case \`#${theCase.case_number}\` updated`)); - } + msg.channel.createMessage(successMessage(`Case \`#${theCase.case_number}\` updated`)); } @d.command("note", " ") @@ -275,7 +275,13 @@ export class ModActionsPlugin extends Plugin { const user = await this.bot.users.get(args.userId); const userName = user ? `${user.username}#${user.discriminator}` : "member"; - await this.createCase(args.userId, msg.author.id, CaseTypes.Note, null, args.note); + await this.actions.fire("createCase", { + userId: args.userId, + modId: msg.author.id, + type: CaseTypes.Note, + reason: args.note + }); + msg.channel.createMessage(successMessage(`Note added on ${userName}`)); } @@ -309,7 +315,12 @@ export class ModActionsPlugin extends Plugin { } } - await this.createCase(args.member.id, msg.author.id, CaseTypes.Warn, null, args.reason); + await this.actions.fire("createCase", { + userId: args.member.id, + modId: msg.author.id, + type: CaseTypes.Warn, + reason: args.reason + }); msg.channel.createMessage( successMessage(`Warned **${args.member.user.username}#${args.member.user.discriminator}**`) @@ -321,11 +332,6 @@ export class ModActionsPlugin extends Plugin { }); } - public async muteMember(member: Member, muteTime: number = null, reason: string = null) { - await member.addRole(this.configValue("mute_role")); - await this.mutes.addOrUpdateMute(member.id, muteTime); - } - @d.command("mute", " [time:string] [reason:string$]") @d.permission("mute") async muteCmd(msg: Message, args: any) { @@ -353,20 +359,35 @@ export class ModActionsPlugin extends Plugin { // Apply "muted" role this.serverLogs.ignoreLog(LogType.MEMBER_ROLE_ADD, args.member.id); - await this.muteMember(args.member, muteTime, args.reason); + const mute: Mute = await this.actions.fire("mute", { + member: args.member, + muteTime + }); - const mute = await this.mutes.findExistingMuteForUserId(args.member.id); - const hasOldCase = mute && mute.case_id != null; + if (!mute) { + msg.channel.createMessage(errorMessage("Could not mute the user")); + return; + } + + const hasOldCase = mute.case_id != null; if (hasOldCase) { if (args.reason) { - await this.createCaseNote(mute.case_id, msg.author.id, args.reason); - this.postCaseToCaseLog(mute.case_id); + // Update old case + await this.actions.fire("createCaseNote", mute.case_id, { + modId: msg.author.id, + note: args.reason + }); } } else { - // Create a case - const caseId = await this.createCase(args.member.id, msg.author.id, CaseTypes.Mute, null, args.reason); - await this.mutes.setCaseId(args.member.id, caseId); + // Create new case + const theCase: Case = await this.actions.fire("createCase", { + userId: args.member.id, + modId: msg.author.id, + type: CaseTypes.Mute, + reason: args.reason + }); + await this.mutes.setCaseId(args.member.id, theCase.id); } // Message the user informing them of the mute @@ -438,7 +459,7 @@ export class ModActionsPlugin extends Plugin { if (unmuteTime) { // If we have an unmute time, just update the old mute to expire in that time const timeUntilUnmute = unmuteTime && humanizeDuration(unmuteTime); - this.mutes.addOrUpdateMute(args.member.id, unmuteTime); + await this.actions.fire("unmute", { member: args.member, unmuteTime }); args.reason = args.reason ? `Timed unmute: ${args.reason}` : "Timed unmute"; // Confirm the action to the moderator @@ -450,8 +471,7 @@ export class ModActionsPlugin extends Plugin { } else { // Otherwise remove "muted" role immediately this.serverLogs.ignoreLog(LogType.MEMBER_ROLE_REMOVE, args.member.id); - await args.member.removeRole(this.configValue("mute_role")); - await this.mutes.clear(args.member.id); + await this.actions.fire("unmute", { member: args.member }); // Confirm the action to the moderator msg.channel.createMessage( @@ -460,7 +480,12 @@ export class ModActionsPlugin extends Plugin { } // Create a case - await this.createCase(args.member.id, msg.author.id, CaseTypes.Unmute, null, args.reason); + await this.actions.fire("createCase", { + userId: args.member.id, + modId: msg.author.id, + type: CaseTypes.Unmute, + reason: args.reason + }); // Log the action this.serverLogs.log(LogType.MEMBER_UNMUTE, { @@ -472,76 +497,12 @@ export class ModActionsPlugin extends Plugin { @d.command("mutes") @d.permission("view") async mutesCmd(msg: Message) { - const 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; - }); - - const caseIds = activeMutes.map(m => m.case_id).filter(v => !!v); - const cases = caseIds.length ? await this.cases.get(caseIds) : []; - const casesById = cases.reduce((map, c) => map.set(c.id, c), new Map()); - - lines.push( - ...activeMutes.map(mute => { - const user = this.bot.users.get(mute.user_id); - const username = user ? `${user.username}#${user.discriminator}` : "Unknown#0000"; - const theCase = casesById[mute.case_id] || null; - const caseName = theCase ? `Case #${theCase.case_number}` : "No case"; - - let line = `\`${caseName}\` **${username}** (\`${mute.user_id}\`)`; - - if (mute.expires_at) { - const timeUntilExpiry = moment().diff(moment(mute.expires_at, DBDateFormat)); - const humanizedTime = humanizeDuration(timeUntilExpiry, { largest: 2, round: true }); - line += ` (expires in ${humanizedTime})`; - } else { - line += ` (doesn't expire)`; - } - - const mutedAt = moment(mute.created_at, DBDateFormat); - line += ` (muted at ${mutedAt.format("YYYY-MM-DD HH:mm:ss")})`; - - return line; - }) - ); - - // Manually added mute roles - const muteUserIds = activeMutes.reduce((set, m) => set.add(m.user_id), new Set()); - const manuallyMutedMembers = []; - const muteRole = this.configValue("mute_role"); - - if (muteRole) { - this.guild.members.forEach(member => { - if (muteUserIds.has(member.id)) return; - if (member.roles.includes(muteRole)) manuallyMutedMembers.push(member); - }); - } - - lines.push( - ...manuallyMutedMembers.map(member => { - return `\`Manual mute\` **${member.user.username}#${member.user.discriminator}** (\`${member.id}\`)`; - }) - ); - - const chunks = chunk(lines, 15); - for (const [i, chunkLines] of chunks.entries()) { - let body = chunkLines.join("\n"); - if (i === 0) body = `Active mutes:\n\n${body}`; - msg.channel.createMessage(body); - } + this.actions.fire("postMuteList", msg.channel); } @d.command("kick", " [reason:string$]") @d.permission("kick") - async kickCmd(msg, args) { + async kickCmd(msg, args: { member: Member; reason: string }) { // Make sure we're allowed to kick this member if (!this.canActOn(msg.member, args.member)) { msg.channel.createMessage(errorMessage("Cannot kick: insufficient permissions")); @@ -570,7 +531,12 @@ export class ModActionsPlugin extends Plugin { args.member.kick(args.reason); // Create a case for this action - await this.createCase(args.member.id, msg.author.id, CaseTypes.Kick, null, args.reason); + await this.actions.fire("createCase", { + userId: args.member.id, + modId: msg.author.id, + type: CaseTypes.Kick, + reason: args.reason + }); // Confirm the action to the moderator let response = `Kicked **${args.member.user.username}#${args.member.user.discriminator}**`; @@ -615,7 +581,12 @@ export class ModActionsPlugin extends Plugin { args.member.ban(1, args.reason); // Create a case for this action - await this.createCase(args.member.id, msg.author.id, CaseTypes.Ban, null, args.reason); + await this.actions.fire("createCase", { + userId: args.member.id, + modId: msg.author.id, + type: CaseTypes.Ban, + reason: args.reason + }); // Confirm the action to the moderator let response = `Banned **${args.member.user.username}#${args.member.user.discriminator}**`; @@ -648,7 +619,12 @@ export class ModActionsPlugin extends Plugin { await this.guild.unbanMember(args.member.id); // Create a case for this action - await this.createCase(args.member.id, msg.author.id, CaseTypes.Softban, null, args.reason); + await this.actions.fire("createCase", { + userId: args.member.id, + modId: msg.author.id, + type: CaseTypes.Softban, + reason: args.reason + }); // Confirm the action to the moderator msg.channel.createMessage( @@ -679,7 +655,12 @@ export class ModActionsPlugin extends Plugin { msg.channel.createMessage(successMessage("Member unbanned!")); // Create a case - this.createCase(args.userId, msg.author.id, CaseTypes.Unban, null, args.reason); + await this.actions.fire("createCase", { + userId: args.member.id, + modId: msg.author.id, + type: CaseTypes.Unban, + reason: args.reason + }); // Log the action this.serverLogs.log(LogType.MEMBER_UNBAN, { @@ -712,7 +693,12 @@ export class ModActionsPlugin extends Plugin { msg.channel.createMessage(successMessage("Member forcebanned!")); // Create a case - this.createCase(args.userId, msg.author.id, CaseTypes.Ban, null, args.reason); + await this.actions.fire("createCase", { + userId: args.userId, + modId: msg.author.id, + type: CaseTypes.Ban, + reason: args.reason + }); // Log the action this.serverLogs.log(LogType.MEMBER_FORCEBAN, { @@ -766,7 +752,14 @@ export class ModActionsPlugin extends Plugin { for (const userId of args.userIds) { try { await this.guild.banMember(userId); - await this.createCase(userId, msg.author.id, CaseTypes.Ban, null, `Mass ban: ${banReason}`, false, false); + + await this.actions.fire("createCase", { + userId, + modId: msg.author.id, + type: CaseTypes.Ban, + reason: `Mass ban: ${banReason}`, + postInCaseLog: false + }); } catch (e) { failedBans.push(userId); } @@ -820,11 +813,16 @@ export class ModActionsPlugin extends Plugin { } // Create the case - const caseId = await this.createCase(args.target, msg.author.id, CaseTypes[type], null, args.reason); - const theCase = await this.cases.find(caseId); + const theCase: Case = await this.actions.fire("createCase", { + userId: args.target, + modId: msg.author.id, + type: CaseTypes[type], + reason: args.reason + }); + + msg.channel.createMessage(successMessage("Case created!")); // Log the action - msg.channel.createMessage(successMessage("Case created!")); this.serverLogs.log(LogType.CASE_CREATE, { mod: stripObjectToScalars(msg.member.user), userId: args.userId, @@ -845,11 +843,14 @@ export class ModActionsPlugin extends Plugin { const theCase = await this.cases.findByCaseNumber(args.caseNumber); if (!theCase) { - msg.channel.createMessage("Case not found!"); + msg.channel.createMessage(errorMessage("Case not found")); return; } - this.displayCase(theCase.id, msg.channel.id); + await this.actions.fire("postCase", { + caseId: theCase.id, + channel: msg.channel + }); } @d.command(/cases|usercases/, " [expanded:string]") @@ -871,7 +872,10 @@ export class ModActionsPlugin extends Plugin { // Expanded view (= individual case embeds) for (const theCase of cases) { - await this.displayCase(theCase.id, msg.channel.id); + await this.actions.fire("postCase", { + caseId: theCase.id, + channel: msg.channel + }); } } else { // Compact view (= regular message with a preview of each case) @@ -949,146 +953,4 @@ export class ModActionsPlugin extends Plugin { return messageSent; } - - /** - * Shows information about the specified action in a message embed. - * If no channelId is specified, uses the channel id from config. - */ - protected async displayCase(caseOrCaseId: Case | number, channelId: string) { - let theCase: Case; - if (typeof caseOrCaseId === "number") { - theCase = await this.cases.with("notes").find(caseOrCaseId); - } else { - theCase = caseOrCaseId; - } - - if (!theCase) return; - if (!this.guild.channels.get(channelId)) return; - - 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 (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" - }); - } - - const channel = this.guild.channels.get(channelId) as TextChannel; - await channel.createMessage({ embed }); - } - - /** - * Posts the specified mod action to the guild's action log channel - */ - protected async postCaseToCaseLog(caseOrCaseId: Case | number) { - const caseLogChannelId = this.configValue("case_log_channel"); - if (!caseLogChannelId) return; - if (!this.guild.channels.get(caseLogChannelId)) return; - - return this.displayCase(caseOrCaseId, caseLogChannelId); - } - - public async createCase( - userId: string, - modId: string, - caseType: CaseTypes, - auditLogId: string = null, - reason: string = null, - automatic = false, - postInCaseLogOverride = null - ): Promise { - const user = this.bot.users.get(userId); - const userName = user ? `${user.username}#${user.discriminator}` : "Unknown#0000"; - - const mod = this.bot.users.get(modId); - const modName = mod ? `${mod.username}#${mod.discriminator}` : "Unknown#0000"; - - const createdId = await this.cases.create({ - type: caseType, - user_id: userId, - user_name: userName, - mod_id: modId, - mod_name: modName, - audit_log_id: auditLogId - }); - - if (reason) { - await this.createCaseNote(createdId, modId, reason); - } - - if ( - this.configValue("case_log_channel") && - (!automatic || this.configValue("log_automatic_actions")) && - postInCaseLogOverride !== false - ) { - try { - await this.postCaseToCaseLog(createdId); - } catch (e) {} // tslint:disable-line - } - - return createdId; - } - - protected async createCaseNote(caseId: number, modId: string, body: string) { - const mod = this.bot.users.get(modId); - const modName = mod ? `${mod.username}#${mod.discriminator}` : "Unknown#0000"; - - return this.cases.createNote(caseId, { - mod_id: modId, - mod_name: modName, - body: body || "" - }); - } - - protected async clearExpiredMutes() { - const expiredMutes = await this.mutes.getExpiredMutes(); - for (const mute of expiredMutes) { - const member = this.guild.members.get(mute.user_id); - if (!member) continue; - - try { - this.serverLogs.ignoreLog(LogType.MEMBER_ROLE_REMOVE, member.id); - await member.removeRole(this.configValue("mute_role")); - } catch (e) {} // tslint:disable-line - - await this.mutes.clear(member.id); - - this.serverLogs.log(LogType.MEMBER_MUTE_EXPIRED, { - member: stripObjectToScalars(member, ["user"]) - }); - } - } } diff --git a/src/plugins/Mutes.ts b/src/plugins/Mutes.ts new file mode 100644 index 00000000..4dbb3c48 --- /dev/null +++ b/src/plugins/Mutes.ts @@ -0,0 +1,158 @@ +import { Member, TextableChannel } from "eris"; +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 { DBDateFormat, chunkMessageLines, stripObjectToScalars } from "../utils"; +import humanizeDuration from "humanize-duration"; +import { LogType } from "../data/LogType"; +import { GuildLogs } from "../data/GuildLogs"; + +export class MutesPlugin extends ZeppelinPlugin { + protected actions: GuildActions; + protected mutes: GuildMutes; + protected cases: GuildCases; + protected serverLogs: GuildLogs; + private muteClearIntervalId: NodeJS.Timer; + + getDefaultOptions() { + return { + config: { + mute_role: null + } + }; + } + + 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); + + this.actions.register("mute", args => { + return this.muteMember(args.member, args.muteTime); + }); + this.actions.register("unmute", args => { + return this.unmuteMember(args.member, args.unmuteTime); + }); + this.actions.register("postMuteList", args => { + return this.postMuteList(args.channel); + }); + + // Check for expired mutes every 5s + this.clearExpiredMutes(); + this.muteClearIntervalId = setInterval(() => this.clearExpiredMutes(), 5000); + } + + onUnload() { + this.actions.unregister("mute"); + this.actions.unregister("unmute"); + this.actions.unregister("postMuteList"); + + clearInterval(this.muteClearIntervalId); + } + + public async muteMember(member: Member, muteTime: number = null) { + const muteRole = this.configValue("mute_role"); + if (!muteRole) return; + + await member.addRole(muteRole); + return this.mutes.addOrUpdateMute(member.id, muteTime); + } + + public async unmuteMember(member: Member, unmuteTime: number = null) { + if (unmuteTime) { + await this.mutes.addOrUpdateMute(member.id, unmuteTime); + } else { + await member.removeRole(this.configValue("mute_role")); + await this.mutes.clear(member.id); + } + } + + public async postMuteList(channel: TextableChannel) { + const 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; + }); + + const caseIds = activeMutes.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.push( + ...activeMutes.map(mute => { + const user = this.bot.users.get(mute.user_id); + const username = user ? `${user.username}#${user.discriminator}` : "Unknown#0000"; + const theCase = muteCasesById[mute.case_id] || null; + const caseName = theCase ? `Case #${theCase.case_number}` : "No case"; + + let line = `\`${caseName}\` **${username}** (\`${mute.user_id}\`)`; + + if (mute.expires_at) { + const timeUntilExpiry = moment().diff(moment(mute.expires_at, DBDateFormat)); + const humanizedTime = humanizeDuration(timeUntilExpiry, { largest: 2, round: true }); + line += ` (expires in ${humanizedTime})`; + } else { + line += ` (doesn't expire)`; + } + + const mutedAt = moment(mute.created_at, DBDateFormat); + line += ` (muted at ${mutedAt.format("YYYY-MM-DD HH:mm:ss")})`; + + return line; + }) + ); + + // Manually added mute roles + const muteUserIds = activeMutes.reduce((set, m) => set.add(m.user_id), new Set()); + const manuallyMutedMembers = []; + const muteRole = this.configValue("mute_role"); + + if (muteRole) { + this.guild.members.forEach(member => { + if (muteUserIds.has(member.id)) return; + if (member.roles.includes(muteRole)) manuallyMutedMembers.push(member); + }); + } + + lines.push( + ...manuallyMutedMembers.map(member => { + return `\`Manual mute\` **${member.user.username}#${member.user.discriminator}** (\`${member.id}\`)`; + }) + ); + + const message = `Active mutes:\n\n${lines.join("\n")}`; + const chunks = chunkMessageLines(message); + for (const chunk of chunks) { + channel.createMessage(chunk); + } + } + + protected async clearExpiredMutes() { + const expiredMutes = await this.mutes.getExpiredMutes(); + for (const mute of expiredMutes) { + const member = this.guild.members.get(mute.user_id); + if (!member) continue; + + try { + this.serverLogs.ignoreLog(LogType.MEMBER_ROLE_REMOVE, member.id); + await member.removeRole(this.configValue("mute_role")); + } catch (e) {} // tslint:disable-line + + await this.mutes.clear(member.id); + + this.serverLogs.log(LogType.MEMBER_MUTE_EXPIRED, { + member: stripObjectToScalars(member, ["user"]) + }); + } + } +} diff --git a/src/plugins/Post.ts b/src/plugins/Post.ts index e1f8c965..29231b7c 100644 --- a/src/plugins/Post.ts +++ b/src/plugins/Post.ts @@ -63,6 +63,6 @@ export class PostPlugin extends Plugin { return; } - const edited = await this.bot.editMessage(savedMessage.channel_id, savedMessage.id, args.content); + await this.bot.editMessage(savedMessage.channel_id, savedMessage.id, args.content); } } diff --git a/src/plugins/Spam.ts b/src/plugins/Spam.ts index 1fbb7ef3..400e3127 100644 --- a/src/plugins/Spam.ts +++ b/src/plugins/Spam.ts @@ -1,7 +1,6 @@ -import { decorators as d, Plugin } from "knub"; -import { Channel, Message, User } from "eris"; +import { Plugin } from "knub"; +import { Channel, User } from "eris"; import { - formatTemplateString, getEmojiInString, getRoleMentions, getUrlsInString, @@ -11,12 +10,14 @@ import { } from "../utils"; import { LogType } from "../data/LogType"; import { GuildLogs } from "../data/GuildLogs"; -import { ModActionsPlugin } from "./ModActions"; 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 { GuildActions } from "../data/GuildActions"; +import { Case } from "../data/entities/Case"; +import { GuildMutes } from "../data/GuildMutes"; enum RecentActionType { Message = 1, @@ -42,9 +43,11 @@ const MAX_INTERVAL = 300; const SPAM_ARCHIVE_EXPIRY_DAYS = 90; export class SpamPlugin extends Plugin { + protected actions: GuildActions; protected logs: GuildLogs; protected archives: GuildArchives; protected savedMessages: GuildSavedMessages; + protected mutes: GuildMutes; private onMessageCreateFn; @@ -90,9 +93,11 @@ export class SpamPlugin extends Plugin { } 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); + this.mutes = GuildMutes.getInstance(this.guildId); this.recentActions = []; this.expiryInterval = setInterval(() => this.clearOldRecentActions(), 1000 * 60); @@ -198,22 +203,12 @@ export class SpamPlugin extends Plugin { // If the user tripped the spam filter... if (recentActionsCount > spamConfig.count) { const recentActions = this.getRecentActions(type, savedMessage.user_id, savedMessage.channel_id, since); - let modActionsPlugin; // Start by muting them, if enabled - if (spamConfig.mute) { - // We use the ModActions plugin for muting the user - // This means that spam mute functionality requires the ModActions plugin to be loaded - const guildData = this.knub.getGuildData(this.guildId); - modActionsPlugin = guildData.loadedPlugins.get("mod_actions") as ModActionsPlugin; - if (!modActionsPlugin) return; - + if (spamConfig.mute && member) { const muteTime = spamConfig.mute_time ? spamConfig.mute_time * 60 * 1000 : 120 * 1000; - - if (member) { - this.logs.ignoreLog(LogType.MEMBER_ROLE_ADD, savedMessage.user_id); - modActionsPlugin.muteMember(member, muteTime, "Automatic spam detection"); - } + this.logs.ignoreLog(LogType.MEMBER_ROLE_ADD, savedMessage.user_id); + this.actions.fire("mute", { member, muteTime, reason: "Automatic spam detection" }); } // Get the offending message IDs @@ -273,18 +268,17 @@ export class SpamPlugin extends Plugin { archiveUrl }); - const caseId = await modActionsPlugin.createCase( - savedMessage.user_id, - this.bot.user.id, - caseType, - null, - caseText, - true - ); + const theCase: Case = await this.actions.fire("createCase", { + userId: savedMessage.user_id, + 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 modActionsPlugin.mutes.setCaseId(savedMessage.user_id, caseId); + await this.mutes.setCaseId(savedMessage.user_id, theCase.id); } } }, diff --git a/src/plugins/ZeppelinPlugin.ts b/src/plugins/ZeppelinPlugin.ts new file mode 100644 index 00000000..d313303d --- /dev/null +++ b/src/plugins/ZeppelinPlugin.ts @@ -0,0 +1,10 @@ +import { Plugin } from "knub"; +import { PluginRuntimeError } from "../PluginRuntimeError"; +import { TextableChannel } from "eris"; +import { errorMessage, successMessage } from "../utils"; + +export class ZeppelinPlugin extends Plugin { + protected throwPluginRuntimeError(message: string) { + throw new PluginRuntimeError(message, this.pluginName, this.guildId); + } +}