diff --git a/src/data/DefaultLogMessages.json b/src/data/DefaultLogMessages.json index d871f76f..18ef4021 100644 --- a/src/data/DefaultLogMessages.json +++ b/src/data/DefaultLogMessages.json @@ -48,5 +48,7 @@ "MASSBAN": "⚒ {userMention(mod)} massbanned {count} users", - "MEMBER_JOIN_WITH_PRIOR_RECORDS": "⚠ {userMention(member)} joined with prior records. Recent cases:\n{recentCaseSummary}" + "MEMBER_JOIN_WITH_PRIOR_RECORDS": "⚠ {userMention(member)} joined with prior records. Recent cases:\n{recentCaseSummary}", + + "CASE_UPDATE": "✏ {userMention(mod)} updated case #{caseNumber} ({caseType}) with note:\n```{note}```" } diff --git a/src/data/GuildActions.ts b/src/data/GuildActions.ts index 5e874419..de5a2a4a 100644 --- a/src/data/GuildActions.ts +++ b/src/data/GuildActions.ts @@ -1,6 +1,9 @@ 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"; @@ -9,30 +12,32 @@ type UnknownAction = T extends KnownActions ? never : T; type ActionFn = (args: T) => any | Promise; -type MuteActionArgs = { member: Member; muteTime?: number; reason?: string }; -type UnmuteActionArgs = { member: Member; unmuteTime?: number; reason?: string }; -type CreateCaseActionArgs = { - userId: string; - modId: string; - type: CaseTypes; - auditLogId?: string; - reason?: string; - automatic?: boolean; - postInCaseLog?: boolean; - ppId?: string; -}; +type MuteActionArgs = { member: Member; muteTime?: number; reason?: string; caseDetails?: ICaseDetails }; +type UnmuteActionArgs = { member: Member; 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>; @@ -63,8 +68,8 @@ export class GuildActions extends BaseRepository { this.actions.delete(actionName); } - public fire(actionName: "mute", args: MuteActionArgs): Promise; - public fire(actionName: "unmute", args: UnmuteActionArgs): Promise; + 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; diff --git a/src/data/GuildCases.ts b/src/data/GuildCases.ts index c9472076..b4ed2a7d 100644 --- a/src/data/GuildCases.ts +++ b/src/data/GuildCases.ts @@ -8,6 +8,22 @@ 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/data/GuildMutes.ts b/src/data/GuildMutes.ts index a16e42ca..156bbe35 100644 --- a/src/data/GuildMutes.ts +++ b/src/data/GuildMutes.ts @@ -24,11 +24,16 @@ export class GuildMutes extends BaseRepository { return this.mutes.findOne({ where: { guild_id: this.guildId, - user_id: userId - } + user_id: userId, + }, }); } + async isMuted(userId: string): Promise { + const mute = await this.findExistingMuteForUserId(userId); + return mute != null; + } + async addMute(userId, expiryTime): Promise { const expiresAt = expiryTime ? moment() @@ -39,7 +44,7 @@ export class GuildMutes extends BaseRepository { const result = await this.mutes.insert({ guild_id: this.guildId, user_id: userId, - expires_at: expiresAt + expires_at: expiresAt, }); return this.mutes.findOne({ where: result.identifiers[0] }); @@ -55,25 +60,14 @@ export class GuildMutes extends BaseRepository { return this.mutes.update( { guild_id: this.guildId, - user_id: userId + user_id: userId, }, { - expires_at: expiresAt - } + expires_at: expiresAt, + }, ); } - async addOrUpdateMute(userId, expiryTime): Promise { - const existingMute = await this.findExistingMuteForUserId(userId); - - if (existingMute) { - await this.updateExpiryTime(userId, expiryTime); - return this.findExistingMuteForUserId(userId); - } else { - return this.addMute(userId, expiryTime); - } - } - async getActiveMutes(): Promise { return this.mutes .createQueryBuilder("mutes") @@ -81,7 +75,7 @@ export class GuildMutes extends BaseRepository { .andWhere( new Brackets(qb => { qb.where("expires_at > NOW()").orWhere("expires_at IS NULL"); - }) + }), ) .getMany(); } @@ -90,18 +84,18 @@ export class GuildMutes extends BaseRepository { await this.mutes.update( { guild_id: this.guildId, - user_id: userId + user_id: userId, }, { - case_id: caseId - } + case_id: caseId, + }, ); } async clear(userId) { await this.mutes.delete({ guild_id: this.guildId, - user_id: userId + user_id: userId, }); } } diff --git a/src/data/LogType.ts b/src/data/LogType.ts index 9956b36f..5d726dfe 100644 --- a/src/data/LogType.ts +++ b/src/data/LogType.ts @@ -49,4 +49,6 @@ export enum LogType { MEMBER_ROLE_CHANGES, VOICE_CHANNEL_FORCE_MOVE, + + CASE_UPDATE, } diff --git a/src/plugins/Cases.ts b/src/plugins/Cases.ts index a5a6aaa3..eb3ed3e0 100644 --- a/src/plugins/Cases.ts +++ b/src/plugins/Cases.ts @@ -1,5 +1,5 @@ import { Message, MessageContent, MessageFile, TextableChannel, TextChannel } from "eris"; -import { GuildCases } from "../data/GuildCases"; +import { GuildCases, ICaseDetails } from "../data/GuildCases"; import { CaseTypes } from "../data/CaseTypes"; import { Case } from "../data/entities/Case"; import moment from "moment-timezone"; @@ -36,20 +36,18 @@ export class CasesPlugin extends ZeppelinPlugin { this.archives = GuildArchives.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, - args.ppId, - ); + return this.createCase(args); }); this.actions.register("createCaseNote", args => { - return this.createCaseNote(args.caseId, args.modId, args.note, args.automatic, args.postInCaseLog); + return this.createCaseNote( + args.caseId, + args.modId, + args.note, + args.automatic, + args.postInCaseLog, + args.noteDetails, + ); }); this.actions.register("postCase", async args => { @@ -72,46 +70,47 @@ 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( - userId: string, - modId: string, - type: CaseTypes, - auditLogId: string = null, - reason: string = null, - automatic = false, - postInCaseLogOverride = null, - ppId = null, - ): Promise { - const user = this.bot.users.get(userId); + public async createCase(opts: ICaseDetails): Promise { + const user = this.bot.users.get(opts.userId); const userName = user ? `${user.username}#${user.discriminator}` : "Unknown#0000"; - const mod = this.bot.users.get(modId); + const mod = this.bot.users.get(opts.modId); const modName = mod ? `${mod.username}#${mod.discriminator}` : "Unknown#0000"; let ppName = null; - if (ppId) { - const pp = this.bot.users.get(ppId); + if (opts.ppId) { + const pp = this.bot.users.get(opts.ppId); ppName = pp ? `${pp.username}#${pp.discriminator}` : "Unknown#0000"; } const createdCase = await this.cases.create({ - type, - user_id: userId, + type: opts.type, + user_id: opts.userId, user_name: userName, - mod_id: modId, + mod_id: opts.modId, mod_name: modName, - audit_log_id: auditLogId, - pp_id: ppId, + audit_log_id: opts.auditLogId, + pp_id: opts.ppId, pp_name: ppName, }); - if (reason) { - await this.createCaseNote(createdCase, modId, reason, automatic, false); + if (opts.reason || opts.noteDetails.length) { + await this.createCaseNote(createdCase, opts.modId, opts.reason || "", opts.automatic, false, opts.noteDetails); + } + + if (opts.extraNotes) { + for (const extraNote of opts.extraNotes) { + await this.createCaseNote(createdCase, opts.modId, extraNote, opts.automatic, false); + } } const config = this.getConfig(); - if (config.case_log_channel && (!automatic || config.log_automatic_actions) && postInCaseLogOverride !== false) { + if ( + config.case_log_channel && + (!opts.automatic || config.log_automatic_actions) && + opts.postInCaseLogOverride !== false + ) { try { await this.postCaseToCaseLogChannel(createdCase); } catch (e) {} // tslint:disable-line @@ -129,6 +128,7 @@ export class CasesPlugin extends ZeppelinPlugin { 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"; @@ -138,6 +138,11 @@ export class CasesPlugin extends ZeppelinPlugin { this.throwPluginRuntimeError(`Unknown case ID: ${caseOrCaseId}`); } + // Add note details to the beginning of the note + if (noteDetails && noteDetails.length) { + body = noteDetails.map(d => `__[${d}]__`).join(" ") + " " + body; + } + await this.cases.createNote(theCase.id, { mod_id: modId, mod_name: modName, diff --git a/src/plugins/ModActions.ts b/src/plugins/ModActions.ts index b921c673..a87e0313 100644 --- a/src/plugins/ModActions.ts +++ b/src/plugins/ModActions.ts @@ -3,11 +3,13 @@ import { Attachment, Constants as ErisConstants, Guild, Member, Message, TextCha import humanizeDuration from "humanize-duration"; import { GuildCases } from "../data/GuildCases"; import { - convertDelayStringToMS, + asSingleLine, createChunkedMessage, errorMessage, findRelevantAuditLogEntry, - asSingleLine, + INotifyUserResult, + notifyUser, + NotifyUserStatus, stripObjectToScalars, successMessage, trimLines, @@ -17,9 +19,8 @@ import { CaseTypes } from "../data/CaseTypes"; import { GuildLogs } from "../data/GuildLogs"; import { LogType } from "../data/LogType"; import { ZeppelinPlugin } from "./ZeppelinPlugin"; -import { GuildActions } from "../data/GuildActions"; +import { GuildActions, MuteActionResult } from "../data/GuildActions"; import { Case } from "../data/entities/Case"; -import { Mute } from "../data/entities/Mute"; import { renderTemplate } from "../templateFormatter"; enum IgnoredEventType { @@ -33,31 +34,15 @@ interface IIgnoredEvent { userId: string; } -enum MessageResultStatus { - Ignored = 1, - Failed, - DirectMessaged, - ChannelMessaged, -} - -interface IMessageResult { - status: MessageResultStatus; - text?: string; -} - interface IModActionsPluginConfig { dm_on_warn: boolean; - dm_on_mute: boolean; dm_on_kick: boolean; dm_on_ban: boolean; message_on_warn: boolean; - message_on_mute: boolean; message_on_kick: boolean; message_on_ban: boolean; message_channel: string; warn_message: string; - mute_message: string; - timed_mute_message: string; kick_message: string; ban_message: string; alert_on_rejoin: boolean; @@ -98,17 +83,13 @@ export class ModActionsPlugin extends ZeppelinPlugin { return { config: { dm_on_warn: true, - dm_on_mute: false, dm_on_kick: false, dm_on_ban: false, message_on_warn: false, - message_on_mute: false, message_on_kick: false, message_on_ban: false, message_channel: null, warn_message: "You have received a warning on {guildName}: {reason}", - mute_message: "You have been muted on {guildName}. Reason given: {reason}", - 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}", alert_on_rejoin: false, @@ -307,7 +288,7 @@ export class ModActionsPlugin extends ZeppelinPlugin { overloads: [""], }) @d.permission("can_note") - async updateSpecificCmd(msg: Message, args: { caseNumber?: number; note: string }) { + async updateCmd(msg: Message, args: { caseNumber?: number; note: string }) { let theCase: Case; if (args.caseNumber != null) { theCase = await this.cases.findByCaseNumber(args.caseNumber); @@ -326,6 +307,13 @@ export class ModActionsPlugin extends ZeppelinPlugin { note: args.note, }); + this.serverLogs.log(LogType.CASE_UPDATE, { + mod: msg.author, + caseNumber: theCase.case_number, + caseType: CaseTypes[theCase.type], + note: args.note, + }); + msg.channel.createMessage(successMessage(`Case \`#${theCase.case_number}\` updated`)); } @@ -373,14 +361,12 @@ export class ModActionsPlugin extends ZeppelinPlugin { const warnMessage = config.warn_message.replace("{guildName}", this.guild.name).replace("{reason}", reason); - const userMessageResult = await this.tryToMessageUser( - args.member.user, - warnMessage, - config.dm_on_warn, - config.message_on_warn, - ); + const userMessageResult = await notifyUser(this.bot, this.guild, args.member.user, warnMessage, { + useDM: config.dm_on_warn, + useChannel: config.message_on_warn, + }); - if (userMessageResult.status === MessageResultStatus.Failed) { + if (userMessageResult.status === NotifyUserStatus.Failed) { const failedMsg = await msg.channel.createMessage("Failed to message the user. Log the warning anyway?"); const reply = await waitForReaction(this.bot, failedMsg, ["✅", "❌"], msg.author.id); failedMsg.delete(); @@ -427,6 +413,8 @@ export class ModActionsPlugin extends ZeppelinPlugin { // 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")); @@ -434,116 +422,60 @@ export class ModActionsPlugin extends ZeppelinPlugin { } mod = args.mod; + pp = msg.author; } - let userMessageResult: IMessageResult = { status: MessageResultStatus.Ignored }; - const timeUntilUnmute = args.time && humanizeDuration(args.time); const reason = this.formatReasonWithAttachments(args.reason, msg.attachments); - // Apply "muted" role - this.serverLogs.ignoreLog(LogType.MEMBER_ROLE_ADD, args.member.id); - const mute: Mute = await this.actions.fire("mute", { - member: args.member, - muteTime: args.time, - }); + let muteResult: MuteActionResult; - if (!mute) { + try { + muteResult = await this.actions.fire("mute", { + member: args.member, + muteTime: args.time, + reason, + caseDetails: { + modId: mod.id, + ppId: pp && pp.id, + }, + }); + } catch (e) { + logger.error(`Failed to mute user ${args.member.id}: ${e.message}`); msg.channel.createMessage(errorMessage("Could not mute the user")); return; } - const hasOldCase = mute.case_id != null; - - let theCase; - - if (hasOldCase) { - // Update old case - theCase = await this.cases.find(mute.case_id); - const caseNote = `__[Mute updated to ${args.time ? timeUntilUnmute : "indefinite"}]__ ${reason}`.trim(); - await this.actions.fire("createCaseNote", { - caseId: mute.case_id, - modId: mod.id, - note: caseNote, - }); - } else { - // Create new case - const caseNote = `__[Muted ${args.time ? `for ${timeUntilUnmute}` : "indefinitely"}]__ ${reason}`.trim(); - theCase = await this.actions.fire("createCase", { - userId: args.member.id, - modId: mod.id, - type: CaseTypes.Mute, - reason: caseNote, - ppId: mod.id !== msg.author.id ? msg.author.id : null, - }); - await this.mutes.setCaseId(args.member.id, theCase.id); - } - - const config = this.getConfig(); - - // Message the user informing them of the mute - // Don't message them if we're updating an old mute - if (reason && !hasOldCase) { - const template = args.time ? config.timed_mute_message : config.mute_message; - - const muteMessage = await renderTemplate(template, { - guildName: this.guild.name, - reason, - time: timeUntilUnmute, - }); - - userMessageResult = await this.tryToMessageUser( - args.member.user, - muteMessage, - config.dm_on_mute, - config.message_on_mute, - ); - } - // Confirm the action to the moderator let response; if (args.time) { - if (hasOldCase) { + if (muteResult.updatedExistingMute) { response = asSingleLine(` Updated **${args.member.user.username}#${args.member.user.discriminator}**'s - mute to ${timeUntilUnmute} (Case #${theCase.case_number}) + mute to ${timeUntilUnmute} (Case #${muteResult.case.case_number}) `); } else { response = asSingleLine(` Muted **${args.member.user.username}#${args.member.user.discriminator}** - for ${timeUntilUnmute} (Case #${theCase.case_number}) + for ${timeUntilUnmute} (Case #${muteResult.case.case_number}) `); } } else { - if (hasOldCase) { + if (muteResult.updatedExistingMute) { response = asSingleLine(` Updated **${args.member.user.username}#${args.member.user.discriminator}**'s - mute to indefinite (Case #${theCase.case_number}) + mute to indefinite (Case #${muteResult.case.case_number}) `); } else { response = asSingleLine(` Muted **${args.member.user.username}#${args.member.user.discriminator}** - indefinitely (Case #${theCase.case_number}) + indefinitely (Case #${muteResult.case.case_number}) `); } } - if (userMessageResult.text) response += ` (${userMessageResult.text})`; + if (muteResult.notifyResult.text) response += ` (${muteResult.notifyResult.text})`; msg.channel.createMessage(successMessage(response)); - - // Log the action - if (args.time) { - this.serverLogs.log(LogType.MEMBER_TIMED_MUTE, { - mod: stripObjectToScalars(mod.user), - member: stripObjectToScalars(args.member, ["user"]), - time: timeUntilUnmute, - }); - } else { - this.serverLogs.log(LogType.MEMBER_MUTE, { - mod: stripObjectToScalars(mod.user), - member: stripObjectToScalars(args.member, ["user"]), - }); - } } @d.command("unmute", " ", { @@ -559,76 +491,57 @@ export class ModActionsPlugin extends ZeppelinPlugin { } // The moderator who did the action is the message author or, if used, the specified --mod - let mod = msg.member; + let mod = msg.author; + 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; + mod = args.mod.user; + pp = msg.author; } // Check if they're muted in the first place - const mute = await this.mutes.findExistingMuteForUserId(args.member.id); - if (!mute) { + if (!(await this.mutes.isMuted(args.member.id))) { msg.channel.createMessage(errorMessage("Cannot unmute: member is not muted")); return; } - // Convert unmute time from e.g. "2h30m" to milliseconds - const timeUntilUnmute = args.time && humanizeDuration(args.time); - const reason = this.formatReasonWithAttachments(args.reason, msg.attachments); - const caseNote = args.time ? `__[Scheduled unmute in ${timeUntilUnmute}]__ ${reason}` : reason; - // Create a case - const createdCase = await this.actions.fire("createCase", { - userId: args.member.id, - modId: mod.id, - type: CaseTypes.Unmute, - reason: caseNote, - ppId: mod.id !== msg.author.id ? msg.author.id : null, + const result = await this.actions.fire("unmute", { + member: args.member, + unmuteTime: args.time, + caseDetails: { + modId: mod.id, + ppId: pp && pp.id, + reason, + }, }); + // Confirm the action to the moderator if (args.time) { - // If we have an unmute time, just update the old mute to expire in that time - await this.actions.fire("unmute", { member: args.member, unmuteTime: args.time }); - - // Confirm the action to the moderator + const timeUntilUnmute = args.time && humanizeDuration(args.time); msg.channel.createMessage( successMessage( - `Unmuting **${args.member.user.username}#${args.member.user.discriminator}** in ${timeUntilUnmute} (Case #${ - createdCase.case_number - })`, + asSingleLine(` + Unmuting **${args.member.user.username}#${args.member.user.discriminator}** + in ${timeUntilUnmute} (Case #${result.case.case_number}) + `), ), ); - - // Log the action - this.serverLogs.log(LogType.MEMBER_TIMED_UNMUTE, { - mod: stripObjectToScalars(mod.user), - member: stripObjectToScalars(args.member, ["user"]), - time: timeUntilUnmute, - }); } else { - // Otherwise remove "muted" role immediately - this.serverLogs.ignoreLog(LogType.MEMBER_ROLE_REMOVE, args.member.id); - await this.actions.fire("unmute", { member: args.member }); - - // Confirm the action to the moderator msg.channel.createMessage( successMessage( - `Unmuted **${args.member.user.username}#${args.member.user.discriminator}** (Case #${ - createdCase.case_number - })`, + asSingleLine(` + Unmuted **${args.member.user.username}#${args.member.user.discriminator}** + (Case #${result.case.case_number}) + `), ), ); - - // Log the action - this.serverLogs.log(LogType.MEMBER_UNMUTE, { - mod: stripObjectToScalars(msg.member.user), - member: stripObjectToScalars(args.member, ["user"]), - }); } } @@ -658,19 +571,18 @@ export class ModActionsPlugin extends ZeppelinPlugin { const reason = this.formatReasonWithAttachments(args.reason, msg.attachments); // Attempt to message the user *before* kicking them, as doing it after may not be possible - let userMessageResult: IMessageResult = { status: MessageResultStatus.Ignored }; + let userMessageResult: INotifyUserResult = { status: NotifyUserStatus.Ignored }; if (args.reason) { const kickMessage = await renderTemplate(config.kick_message, { guildName: this.guild.name, reason, }); - userMessageResult = await this.tryToMessageUser( - args.member.user, - kickMessage, - config.dm_on_kick, - config.message_on_kick, - ); + userMessageResult = await notifyUser(this.bot, this.guild, args.member.user, kickMessage, { + useDM: config.dm_on_kick, + useChannel: config.message_on_kick, + channelId: config.message_channel, + }); } // Kick the user @@ -728,19 +640,18 @@ export class ModActionsPlugin extends ZeppelinPlugin { const reason = this.formatReasonWithAttachments(args.reason, msg.attachments); // Attempt to message the user *before* banning them, as doing it after may not be possible - let userMessageResult: IMessageResult = { status: MessageResultStatus.Ignored }; + let userMessageResult: INotifyUserResult = { status: NotifyUserStatus.Ignored }; if (reason) { const banMessage = await renderTemplate(config.ban_message, { guildName: this.guild.name, reason, }); - userMessageResult = await this.tryToMessageUser( - args.member.user, - banMessage, - config.dm_on_ban, - config.message_on_ban, - ); + userMessageResult = await notifyUser(this.bot, this.guild, args.member.user, banMessage, { + useDM: config.dm_on_ban, + useChannel: config.message_on_ban, + channelId: config.message_channel, + }); } // Ban the user @@ -1233,49 +1144,4 @@ export class ModActionsPlugin extends ZeppelinPlugin { await this.cases.setHidden(theCase.id, false); msg.channel.createMessage(successMessage(`Case #${theCase.case_number} is no longer hidden!`)); } - - /** - * Attempts to message the specified user through DMs and/or the message channel. - * Returns a promise that resolves to a status constant indicating the result. - */ - protected async tryToMessageUser( - user: User, - str: string, - useDM: boolean, - useChannel: boolean, - ): Promise { - if (!useDM && !useChannel) { - return { status: MessageResultStatus.Ignored }; - } - - if (useDM) { - try { - const dmChannel = await this.bot.getDMChannel(user.id); - await dmChannel.createMessage(str); - logger.info(`Sent DM to ${user.id}: ${str}`); - return { - status: MessageResultStatus.DirectMessaged, - text: "user notified with a direct message", - }; - } catch (e) {} // tslint:disable-line - } - - const messageChannel = this.getConfig().message_channel; - - if (useChannel && messageChannel) { - try { - const channel = this.guild.channels.get(messageChannel) as TextChannel; - await channel.createMessage(`<@!${user.id}> ${str}`); - return { - status: MessageResultStatus.ChannelMessaged, - text: `user notified in <#${channel.id}>`, - }; - } catch (e) {} // tslint:disable-line - } - - return { - status: MessageResultStatus.Failed, - text: "failed to message user", - }; - } } diff --git a/src/plugins/Mutes.ts b/src/plugins/Mutes.ts index 19407797..397a5b27 100644 --- a/src/plugins/Mutes.ts +++ b/src/plugins/Mutes.ts @@ -1,15 +1,27 @@ import { Member, Message, User } from "eris"; -import { GuildCases } from "../data/GuildCases"; +import { GuildCases, ICaseDetails } 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, successMessage, errorMessage, sleep } from "../utils"; +import { + chunkMessageLines, + DBDateFormat, + errorMessage, + INotifyUserResult, + notifyUser, + NotifyUserStatus, + stripObjectToScalars, + successMessage, + ucfirst, +} 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"; interface IMuteWithDetails extends Mute { member?: Member; @@ -20,6 +32,12 @@ interface IMutesPluginConfig { mute_role: string; move_to_voice_channel: string; + dm_on_mute: boolean; + message_on_mute: boolean; + message_channel: string; + mute_message: string; + timed_mute_message: string; + can_view_list: boolean; can_cleanup: boolean; } @@ -39,6 +57,12 @@ export class MutesPlugin extends ZeppelinPlugin { mute_role: null, move_to_voice_channel: null, + dm_on_mute: false, + message_on_mute: false, + message_channel: null, + mute_message: "You have been muted on {guildName}. Reason given: {reason}", + timed_mute_message: "You have been muted on {guildName} for {time}. Reason given: {reason}", + can_view_list: false, can_cleanup: false, }, @@ -66,10 +90,10 @@ export class MutesPlugin extends ZeppelinPlugin { this.serverLogs = new GuildLogs(this.guildId); this.actions.register("mute", args => { - return this.muteMember(args.member, args.muteTime); + return this.muteMember(args.member, args.muteTime, args.reason, args.caseDetails); }); this.actions.register("unmute", args => { - return this.unmuteMember(args.member, args.unmuteTime); + return this.unmuteMember(args.member, args.unmuteTime, args.caseDetails); }); // Check for expired mutes every 5s @@ -84,33 +108,128 @@ export class MutesPlugin extends ZeppelinPlugin { clearInterval(this.muteClearIntervalId); } - public async muteMember(member: Member, muteTime: number = null) { + public async muteMember( + member: Member, + muteTime: number = null, + reason: string = null, + caseDetails: ICaseDetails = {}, + ) { const muteRole = this.getConfig().mute_role; if (!muteRole) return; - // Add muted role - await member.addRole(muteRole); + const timeUntilUnmute = muteTime && humanizeDuration(muteTime); + + // No mod specified -> mark Zeppelin as the mod + if (!caseDetails.modId) { + caseDetails.modId = this.bot.user.id; + } + + // 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 && member.voiceState.channelID) { try { - await member.edit({ - channelID: moveToVoiceChannelId, - }); + await member.edit({ channelID: moveToVoiceChannelId }); } catch (e) { logger.warn(`Could not move user ${member.id} to voice channel ${moveToVoiceChannelId} when muting`); } } - // Create & return mute record - return this.mutes.addOrUpdateMute(member.id, muteTime); + // If the user is already muted, update the duration of their existing mute + const existingMute = await this.mutes.findExistingMuteForUserId(member.id); + let notifyResult: INotifyUserResult = { status: NotifyUserStatus.Ignored }; + + if (existingMute) { + await this.mutes.updateExpiryTime(member.id, muteTime); + } else { + await this.mutes.addMute(member.id, muteTime); + + // If it's a new mute, attempt to message the user + const config = this.getMatchingConfig({ member }); + const template = muteTime ? config.timed_mute_message : config.mute_message; + + const muteMessage = + template && + (await renderTemplate(template, { + guildName: this.guild.name, + reason, + time: timeUntilUnmute, + })); + + if (muteMessage) { + notifyResult = await notifyUser(this.bot, this.guild, member.user, muteMessage, { + useDM: config.dm_on_mute, + useChannel: config.message_on_mute, + channelId: config.message_channel, + }); + } + } + + // Create/update a case + let theCase; + if (existingMute && existingMute.case_id) { + // Update old case + 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, + }); + } else { + // Create new case + const noteDetails = [`Muted ${muteTime ? `for ${timeUntilUnmute}` : "indefinitely"}`]; + if (notifyResult.status !== NotifyUserStatus.Ignored) { + noteDetails.push(ucfirst(notifyResult.text)); + } + + theCase = await this.actions.fire("createCase", { + userId: member.id, + modId: caseDetails.modId, + type: CaseTypes.Mute, + reason, + ppId: caseDetails.ppId, + noteDetails, + extraNotes: caseDetails.extraNotes, + }); + await this.mutes.setCaseId(member.id, theCase.id); + } + + // Log the action + if (muteTime) { + this.serverLogs.log(LogType.MEMBER_TIMED_MUTE, { + mod: stripObjectToScalars(caseDetails.modId), + member: stripObjectToScalars(member, ["user"]), + time: timeUntilUnmute, + }); + } else { + this.serverLogs.log(LogType.MEMBER_MUTE, { + mod: stripObjectToScalars(caseDetails.modId), + member: stripObjectToScalars(member, ["user"]), + }); + } + + return { + case: theCase, + notifyResult, + updatedExistingMute: !!existingMute, + }; } - public async unmuteMember(member: Member, unmuteTime: number = null) { + public async unmuteMember(member: Member, unmuteTime: number = null, caseDetails: ICaseDetails = {}) { + const existingMute = await this.mutes.findExistingMuteForUserId(member.id); + if (!existingMute) return; + if (unmuteTime) { - await this.mutes.addOrUpdateMute(member.id, unmuteTime); + // Schedule timed unmute (= just set the mute's duration) + await this.mutes.updateExpiryTime(member.id, unmuteTime); } else { + // Unmute immediately const muteRole = this.getConfig().mute_role; if (member.roles.includes(muteRole)) { await member.removeRole(muteRole); @@ -118,6 +237,44 @@ export class MutesPlugin extends ZeppelinPlugin { await this.mutes.clear(member.id); } + + const timeUntilUnmute = unmuteTime && humanizeDuration(unmuteTime); + + // Create a case + const noteDetails = []; + if (unmuteTime) { + noteDetails.push(`Scheduled unmute in ${timeUntilUnmute}`); + } else { + noteDetails.push(`Unmuted immediately`); + } + + const createdCase = await this.actions.fire("createCase", { + userId: member.id, + modId: caseDetails.modId, + type: CaseTypes.Unmute, + reason: caseDetails.reason, + ppId: caseDetails.ppId, + noteDetails, + }); + + // Log the action + const mod = this.bot.users.get(caseDetails.modId); + if (unmuteTime) { + this.serverLogs.log(LogType.MEMBER_TIMED_UNMUTE, { + mod: stripObjectToScalars(mod), + member: stripObjectToScalars(member, ["user"]), + time: timeUntilUnmute, + }); + } else { + this.serverLogs.log(LogType.MEMBER_UNMUTE, { + mod: stripObjectToScalars(mod), + member: stripObjectToScalars(member, ["user"]), + }); + } + + return { + case: createdCase, + }; } @d.command("mutes", [], { @@ -361,7 +518,6 @@ export class MutesPlugin extends ZeppelinPlugin { if (!member) continue; try { - this.serverLogs.ignoreLog(LogType.MEMBER_ROLE_REMOVE, member.id); await member.removeRole(this.getConfig().mute_role); } catch (e) {} // tslint:disable-line diff --git a/src/plugins/Spam.ts b/src/plugins/Spam.ts index 9164dee3..4b93f482 100644 --- a/src/plugins/Spam.ts +++ b/src/plugins/Spam.ts @@ -1,7 +1,7 @@ -import { decorators as d, IPluginOptions } from "knub"; +import { decorators as d, IPluginOptions, logger } from "knub"; import { Channel, Member } from "eris"; -import humanizeDuration from "humanize-duration"; import { + convertDelayStringToMS, getEmojiInString, getRoleMentions, getUrlsInString, @@ -16,7 +16,7 @@ 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 { GuildActions, MuteActionResult } from "../data/GuildActions"; import { Case } from "../data/entities/Case"; import { GuildMutes } from "../data/GuildMutes"; import { ZeppelinPlugin } from "./ZeppelinPlugin"; @@ -191,7 +191,7 @@ export class SpamPlugin extends ZeppelinPlugin { this.recentActions = this.recentActions.filter(action => action.timestamp >= expiryTimestamp); } - async saveSpamArchives(savedMessages: SavedMessage[], channel: Channel) { + async saveSpamArchives(savedMessages: SavedMessage[]) { const expiresAt = moment().add(SPAM_ARCHIVE_EXPIRY_DAYS, "days"); const archiveId = await this.archives.createFromSavedMessages(savedMessages, this.guild, expiresAt); @@ -239,12 +239,17 @@ export class SpamPlugin extends ZeppelinPlugin { const recentActions = this.getRecentActions(type, savedMessage.user_id, savedMessage.channel_id, since); // Start by muting them, if enabled - let timeUntilUnmute; + let muteResult: MuteActionResult; if (spamConfig.mute && member) { - const muteTime = spamConfig.mute_time ? spamConfig.mute_time * 60 * 1000 : 120 * 1000; - timeUntilUnmute = humanizeDuration(muteTime); - this.logs.ignoreLog(LogType.MEMBER_ROLE_ADD, savedMessage.user_id); - this.actions.fire("mute", { member, muteTime, reason: "Automatic spam detection" }); + const muteTime = spamConfig.mute_time ? convertDelayStringToMS(spamConfig.mute_time) : 120 * 1000; + muteResult = await this.actions.fire("mute", { + member, + muteTime, + reason: "Automatic spam detection", + caseDetails: { + modId: this.bot.user.id, + }, + }); } // Get the offending message IDs @@ -285,19 +290,39 @@ export class SpamPlugin extends ZeppelinPlugin { // Generate a log from the detected messages const channel = this.guild.channels.get(savedMessage.channel_id); - const archiveUrl = await this.saveSpamArchives(uniqueMessages, channel); + const archiveUrl = await this.saveSpamArchives(uniqueMessages); - // Create a case and log the actions taken above - const caseType = spamConfig.mute ? CaseTypes.Mute : CaseTypes.Note; - let caseText = trimLines(` - Automatic spam detection: ${description} (over ${spamConfig.count} in ${spamConfig.interval}s) - ${archiveUrl} - `); + // Create a case + if (muteResult) { + // If the user was muted, the mute already generated a case - in that case, just update the case with extra details + const updateText = trimLines(` + Details: ${description} (over ${spamConfig.count} in ${spamConfig.interval}s) + ${archiveUrl} + `); + this.actions.fire("createCaseNote", { + caseId: muteResult.case.id, + modId: muteResult.case.mod_id, + note: updateText, + automatic: true, + postInCaseLogOverride: false, + }); + } 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} + `); - if (spamConfig.mute) { - caseText = `__[Muted for ${timeUntilUnmute}]__ ${caseText}`; + this.actions.fire("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"]), channel: stripObjectToScalars(channel), @@ -306,24 +331,10 @@ export class SpamPlugin extends ZeppelinPlugin { interval: spamConfig.interval, archiveUrl, }); - - 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 this.mutes.setCaseId(savedMessage.user_id, theCase.id); - } } }, err => { - console.error("Error while detecting spam:"); - console.error(err); + logger.error(`Error while detecting spam:\n${err}`); }, ); } @@ -469,7 +480,7 @@ export class SpamPlugin extends ZeppelinPlugin { ); } - // TODO: Max duplicates + // TODO: Max duplicates check } @d.event("voiceChannelJoin") diff --git a/src/utils.ts b/src/utils.ts index c1db09f2..31983c92 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,4 @@ -import { Emoji, Guild, GuildAuditLogEntry, TextableChannel } from "eris"; +import { Client, Emoji, Guild, GuildAuditLogEntry, TextableChannel, TextChannel, User } from "eris"; import url from "url"; import tlds from "tlds"; import emojiRegex from "emoji-regex"; @@ -8,6 +8,7 @@ const fsp = fs.promises; import https from "https"; import tmp from "tmp"; +import { logger } from "knub"; /** * Turns a "delay string" such as "1h30m" to milliseconds @@ -428,3 +429,68 @@ export const DBDateFormat = "YYYY-MM-DD HH:mm:ss"; export type CustomEmoji = { id: string; } & Emoji; + +export interface INotifyUserConfig { + useDM?: boolean; + useChannel?: boolean; + channelId?: string; +} + +export enum NotifyUserStatus { + Ignored = 1, + Failed, + DirectMessaged, + ChannelMessaged, +} + +export interface INotifyUserResult { + status: NotifyUserStatus; + text?: string; +} + +export async function notifyUser( + bot: Client, + guild: Guild, + user: User, + body: string, + config: INotifyUserConfig, +): Promise { + if (!config.useDM && !config.useChannel) { + return { status: NotifyUserStatus.Ignored }; + } + + if (config.useDM) { + try { + const dmChannel = await bot.getDMChannel(user.id); + await dmChannel.createMessage(body); + logger.info(`Notified ${user.id} via DM: ${body}`); + return { + status: NotifyUserStatus.DirectMessaged, + text: "user notified with a direct message", + }; + } catch (e) {} // tslint:disable-line + } + + if (config.useChannel && config.channelId) { + try { + const channel = guild.channels.get(config.channelId); + if (channel instanceof TextChannel) { + await channel.createMessage(`<@!${user.id}> ${body}`); + return { + status: NotifyUserStatus.ChannelMessaged, + text: `user notified in <#${channel.id}>`, + }; + } + } catch (e) {} // tslint:disable-line + } + + return { + status: NotifyUserStatus.Failed, + text: "failed to message user", + }; +} + +export function ucfirst(str) { + if (typeof str !== "string" || str === "") return str; + return str[0].toUpperCase() + str.slice(1); +}