diff --git a/backend/src/PluginRuntimeError.ts b/backend/src/PluginRuntimeError.ts deleted file mode 100644 index ce84b83c..00000000 --- a/backend/src/PluginRuntimeError.ts +++ /dev/null @@ -1,21 +0,0 @@ -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}`; - } - - toString() { - return this[util.inspect.custom](); - } -} diff --git a/backend/src/RecoverablePluginError.ts b/backend/src/RecoverablePluginError.ts new file mode 100644 index 00000000..d589f0f0 --- /dev/null +++ b/backend/src/RecoverablePluginError.ts @@ -0,0 +1,28 @@ +import { Guild } from "eris"; + +export enum ERRORS { + NO_MUTE_ROLE_IN_CONFIG = 1, + UNKNOWN_NOTE_CASE, + INVALID_EMOJI, + NO_USER_NOTIFICATION_CHANNEL, + INVALID_USER_NOTIFICATION_CHANNEL, +} + +export const RECOVERABLE_PLUGIN_ERROR_MESSAGES = { + [ERRORS.NO_MUTE_ROLE_IN_CONFIG]: "No mute role specified in config", + [ERRORS.UNKNOWN_NOTE_CASE]: "Tried to add a note to an unknown case", + [ERRORS.INVALID_EMOJI]: "Invalid emoji", + [ERRORS.NO_USER_NOTIFICATION_CHANNEL]: "No user notify channel specified", + [ERRORS.INVALID_USER_NOTIFICATION_CHANNEL]: "Invalid user notify channel specified", +}; + +export class RecoverablePluginError extends Error { + public readonly code: ERRORS; + public readonly guild?: Guild; + + constructor(code: ERRORS, guild?: Guild) { + super(RECOVERABLE_PLUGIN_ERROR_MESSAGES[code]); + this.guild = guild; + this.code = code; + } +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 46a01d7e..fda3457e 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -26,6 +26,19 @@ setInterval(() => (recentDiscordErrors = Math.max(0, recentDiscordErrors - 1)), if (process.env.NODE_ENV === "production") { const errorHandler = err => { + if (err instanceof RecoverablePluginError) { + // Recoverable plugin errors can be, well, recovered from. + // Log it in the console as a warning and post a warning to the guild's log. + + // tslint:disable:no-console + console.warn(`${err.guild.name}: [${err.code}] ${err.message}`); + + const logs = new GuildLogs(err.guild.id); + logs.log(LogType.BOT_ALERT, { body: `\`[${err.code}]\` ${err.message}` }); + + return; + } + // tslint:disable:no-console console.error(err); @@ -76,6 +89,9 @@ import { errorMessage, successMessage } from "./utils"; import { startUptimeCounter } from "./uptime"; import { AllowedGuilds } from "./data/AllowedGuilds"; import { IZeppelinGuildConfig, IZeppelinGlobalConfig } from "./types"; +import { RecoverablePluginError } from "./RecoverablePluginError"; +import { GuildLogs } from "./data/GuildLogs"; +import { LogType } from "./data/LogType"; logger.info("Connecting to database"); connect().then(async conn => { diff --git a/backend/src/plugins/Automod/Automod.ts b/backend/src/plugins/Automod/Automod.ts index ea4c77db..c2f7c345 100644 --- a/backend/src/plugins/Automod/Automod.ts +++ b/backend/src/plugins/Automod/Automod.ts @@ -1,9 +1,10 @@ -import { PluginInfo, trimPluginDescription, ZeppelinPlugin } from "../ZeppelinPlugin"; +import { trimPluginDescription, ZeppelinPlugin } from "../ZeppelinPlugin"; import * as t from "io-ts"; import { convertDelayStringToMS, disableInlineCode, disableLinkPreviews, + disableUserNotificationStrings, getEmojiInString, getInviteCodesInString, getRoleMentions, @@ -15,12 +16,10 @@ import { SECONDS, stripObjectToScalars, tDeepPartial, - tDelayString, - tNullable, - UnknownUser, + UserNotificationMethod, verboseChannelMention, } from "../../utils"; -import { configUtils, CooldownManager, IPluginOptions, decorators as d, logger } from "knub"; +import { configUtils, CooldownManager, decorators as d, IPluginOptions, logger } from "knub"; import { Member, Message, TextChannel, User } from "eris"; import escapeStringRegexp from "escape-string-regexp"; import { SimpleCache } from "../../SimpleCache"; @@ -29,7 +28,6 @@ import { ModActionsPlugin } from "../ModActions"; import { MutesPlugin } from "../Mutes"; import { LogsPlugin } from "../Logs"; import { LogType } from "../../data/LogType"; -import { TSafeRegex } from "../../validatorUtils"; import { GuildSavedMessages } from "../../data/GuildSavedMessages"; import { GuildArchives } from "../../data/GuildArchives"; import { GuildLogs } from "../../data/GuildLogs"; @@ -37,11 +35,9 @@ import { SavedMessage } from "../../data/entities/SavedMessage"; import moment from "moment-timezone"; import { renderTemplate } from "../../templateFormatter"; import { transliterate } from "transliteration"; -import Timeout = NodeJS.Timeout; import { IMatchParams } from "knub/dist/configUtils"; import { GuildAntiraidLevels } from "../../data/GuildAntiraidLevels"; import { - AnySpamTriggerMatchResult, AnyTriggerMatchResult, BaseTextSpamTrigger, MessageInfo, @@ -66,6 +62,8 @@ import { TRule, } from "./types"; import { pluginInfo } from "./info"; +import { ERRORS, RecoverablePluginError } from "../../RecoverablePluginError"; +import Timeout = NodeJS.Timeout; const unactioned = (action: TextRecentAction | OtherRecentAction) => !action.actioned; @@ -878,6 +876,30 @@ export class AutomodPlugin extends ZeppelinPlugin; export const CleanAction = t.boolean; export const WarnAction = t.type({ - reason: t.string, + reason: tNullable(t.string), + notify: tNullable(t.string), + notifyChannel: tNullable(t.string), }); export const MuteAction = t.type({ - duration: t.string, reason: tNullable(t.string), + duration: tNullable(tDelayString), + notify: tNullable(t.string), + notifyChannel: tNullable(t.string), }); export const KickAction = t.type({ reason: tNullable(t.string), + notify: tNullable(t.string), + notifyChannel: tNullable(t.string), }); export const BanAction = t.type({ reason: tNullable(t.string), + notify: tNullable(t.string), + notifyChannel: tNullable(t.string), }); export const AlertAction = t.type({ diff --git a/backend/src/plugins/Cases.ts b/backend/src/plugins/Cases.ts index ac5da024..b8fa1448 100644 --- a/backend/src/plugins/Cases.ts +++ b/backend/src/plugins/Cases.ts @@ -11,6 +11,7 @@ import { GuildLogs } from "../data/GuildLogs"; import { LogType } from "../data/LogType"; import * as t from "io-ts"; import { tNullable } from "../utils"; +import { ERRORS } from "../RecoverablePluginError"; const ConfigSchema = t.type({ log_automatic_actions: t.boolean, @@ -146,7 +147,7 @@ export class CasesPlugin extends ZeppelinPlugin { public async createCaseNote(args: CaseNoteArgs): Promise { const theCase = await this.cases.find(this.resolveCaseId(args.caseId)); if (!theCase) { - this.throwPluginRuntimeError(`Unknown case ID: ${args.caseId}`); + this.throwRecoverablePluginError(ERRORS.UNKNOWN_NOTE_CASE); } const mod = await this.resolveUser(args.modId); diff --git a/backend/src/plugins/GlobalZeppelinPlugin.ts b/backend/src/plugins/GlobalZeppelinPlugin.ts index 252db853..63fad855 100644 --- a/backend/src/plugins/GlobalZeppelinPlugin.ts +++ b/backend/src/plugins/GlobalZeppelinPlugin.ts @@ -1,5 +1,4 @@ import { GlobalPlugin, IBasePluginConfig, IPluginOptions, logger, configUtils } from "knub"; -import { PluginRuntimeError } from "../PluginRuntimeError"; import * as t from "io-ts"; import { pipe } from "fp-ts/lib/pipeable"; import { fold } from "fp-ts/lib/Either"; diff --git a/backend/src/plugins/ModActions.ts b/backend/src/plugins/ModActions.ts index 74042b9f..78713dab 100644 --- a/backend/src/plugins/ModActions.ts +++ b/backend/src/plugins/ModActions.ts @@ -7,20 +7,18 @@ import { GuildCases } from "../data/GuildCases"; import { asSingleLine, createChunkedMessage, + disableUserNotificationStrings, errorMessage, findRelevantAuditLogEntry, - INotifyUserResult, multiSorter, notifyUser, - NotifyUserStatus, stripObjectToScalars, - successMessage, tNullable, - trimEmptyStartEndLines, - trimIndents, trimLines, ucfirst, UnknownUser, + UserNotificationMethod, + UserNotificationResult, } from "../utils"; import { GuildMutes } from "../data/GuildMutes"; import { CaseTypes } from "../data/CaseTypes"; @@ -32,6 +30,7 @@ import { renderTemplate } from "../templateFormatter"; import { CaseArgs, CasesPlugin } from "./Cases"; import { MuteResult, MutesPlugin } from "./Mutes"; import * as t from "io-ts"; +import { ERRORS, RecoverablePluginError } from "../RecoverablePluginError"; const ConfigSchema = t.type({ dm_on_warn: t.boolean, @@ -78,7 +77,7 @@ export type WarnResult = | { status: "success"; case: Case; - notifyResult: INotifyUserResult; + notifyResult: UserNotificationResult; }; export type KickResult = @@ -89,7 +88,7 @@ export type KickResult = | { status: "success"; case: Case; - notifyResult: INotifyUserResult; + notifyResult: UserNotificationResult; }; export type BanResult = @@ -100,11 +99,27 @@ export type BanResult = | { status: "success"; case: Case; - notifyResult: INotifyUserResult; + notifyResult: UserNotificationResult; }; type WarnMemberNotifyRetryCallback = () => boolean | Promise; +export interface WarnOptions { + caseArgs?: Partial; + contactMethods?: UserNotificationMethod[]; + retryPromptChannel?: TextChannel; +} + +export interface KickOptions { + caseArgs?: Partial; + contactMethods?: UserNotificationMethod[]; +} + +export interface BanOptions { + caseArgs?: Partial; + contactMethods?: UserNotificationMethod[]; +} + export class ModActionsPlugin extends ZeppelinPlugin { public static pluginName = "mod_actions"; public static dependencies = ["cases", "mutes"]; @@ -208,6 +223,50 @@ export class ModActionsPlugin extends ZeppelinPlugin { return ((reason || "") + " " + attachmentUrls.join(" ")).trim(); } + getDefaultContactMethods(type: "warn" | "kick" | "ban"): UserNotificationMethod[] { + const methods: UserNotificationMethod[] = []; + const config = this.getConfig(); + + if (config[`dm_on_${type}`]) { + methods.push({ type: "dm" }); + } + + if (config[`message_on_${type}`] && config.message_channel) { + const channel = this.guild.channels.get(config.message_channel); + if (channel instanceof TextChannel) { + methods.push({ + type: "channel", + channel, + }); + } + } + + return methods; + } + + readContactMethodsFromArgs(args: { + notify?: string; + "notify-channel"?: TextChannel; + }): null | UserNotificationMethod[] { + if (args.notify) { + if (args.notify === "dm") { + return [{ type: "dm" }]; + } else if (args.notify === "channel") { + if (!args["notify-channel"]) { + throw new Error("No `-notify-channel` specified"); + } + + return [{ type: "channel", channel: args["notify-channel"] }]; + } else if (disableUserNotificationStrings.includes(args.notify)) { + return []; + } else { + throw new Error("Unknown contact method"); + } + } + + return null; + } + async isBanned(userId): Promise { try { const bans = (await this.guild.getBans()) as any; @@ -373,22 +432,21 @@ export class ModActionsPlugin extends ZeppelinPlugin { /** * Kick the specified server member. Generates a case. */ - async kickMember(member: Member, reason: string = null, caseArgs: Partial = {}): Promise { + async kickMember(member: Member, reason: string = null, kickOptions: KickOptions = {}): Promise { const config = this.getConfig(); // Attempt to message the user *before* kicking them, as doing it after may not be possible - let notifyResult: INotifyUserResult = { status: NotifyUserStatus.Ignored }; + let notifyResult: UserNotificationResult = { method: null, success: true }; if (reason) { const kickMessage = await renderTemplate(config.kick_message, { guildName: this.guild.name, reason, }); - notifyResult = await notifyUser(this.bot, this.guild, member.user, kickMessage, { - useDM: config.dm_on_kick, - useChannel: config.message_on_kick, - channelId: config.message_channel, - }); + const contactMethods = kickOptions?.contactMethods + ? kickOptions.contactMethods + : this.getDefaultContactMethods("kick"); + notifyResult = await notifyUser(member.user, kickMessage, contactMethods); } // Kick the user @@ -406,16 +464,16 @@ export class ModActionsPlugin extends ZeppelinPlugin { // Create a case for this action const casesPlugin = this.getPlugin("cases"); const createdCase = await casesPlugin.createCase({ - ...caseArgs, + ...(kickOptions.caseArgs || {}), userId: member.id, - modId: caseArgs.modId, + modId: kickOptions.caseArgs?.modId, type: CaseTypes.Kick, reason, - noteDetails: notifyResult.status !== NotifyUserStatus.Ignored ? [ucfirst(notifyResult.text)] : [], + noteDetails: notifyResult.text ? [ucfirst(notifyResult.text)] : [], }); // Log the action - const mod = await this.resolveUser(caseArgs.modId); + const mod = await this.resolveUser(kickOptions.caseArgs?.modId); this.serverLogs.log(LogType.MEMBER_KICK, { mod: stripObjectToScalars(mod), user: stripObjectToScalars(member.user), @@ -431,22 +489,22 @@ export class ModActionsPlugin extends ZeppelinPlugin { /** * Ban the specified user id, whether or not they're actually on the server at the time. Generates a case. */ - async banUserId(userId: string, reason: string = null, caseArgs: Partial = {}): Promise { + async banUserId(userId: string, reason: string = null, banOptions: BanOptions = {}): Promise { const config = this.getConfig(); const user = await this.resolveUser(userId); // Attempt to message the user *before* banning them, as doing it after may not be possible - let notifyResult: INotifyUserResult = { status: NotifyUserStatus.Ignored }; + let notifyResult: UserNotificationResult = { method: null, success: true }; if (reason && user instanceof User) { const banMessage = await renderTemplate(config.ban_message, { guildName: this.guild.name, reason, }); - notifyResult = await notifyUser(this.bot, this.guild, user, banMessage, { - useDM: config.dm_on_ban, - useChannel: config.message_on_ban, - channelId: config.message_channel, - }); + + const contactMethods = banOptions?.contactMethods + ? banOptions.contactMethods + : this.getDefaultContactMethods("ban"); + notifyResult = await notifyUser(user, banMessage, contactMethods); } // (Try to) ban the user @@ -464,16 +522,16 @@ export class ModActionsPlugin extends ZeppelinPlugin { // Create a case for this action const casesPlugin = this.getPlugin("cases"); const createdCase = await casesPlugin.createCase({ - ...caseArgs, + ...(banOptions.caseArgs || {}), userId, - modId: caseArgs.modId, + modId: banOptions.caseArgs?.modId, type: CaseTypes.Ban, reason, - noteDetails: notifyResult.status !== NotifyUserStatus.Ignored ? [ucfirst(notifyResult.text)] : [], + noteDetails: notifyResult.text ? [ucfirst(notifyResult.text)] : [], }); // Log the action - const mod = await this.resolveUser(caseArgs.modId); + const mod = await this.resolveUser(banOptions.caseArgs?.modId); this.serverLogs.log(LogType.MEMBER_BAN, { mod: stripObjectToScalars(mod), user: stripObjectToScalars(user), @@ -560,7 +618,11 @@ export class ModActionsPlugin extends ZeppelinPlugin { } @d.command("warn", " ", { - options: [{ name: "mod", type: "member" }], + options: [ + { name: "mod", type: "member" }, + { name: "notify", type: "string" }, + { name: "notify-channel", type: "channel" }, + ], extra: { info: { description: "Send a warning to the specified user", @@ -568,7 +630,10 @@ export class ModActionsPlugin extends ZeppelinPlugin { }, }) @d.permission("can_warn") - async warnCmd(msg: Message, args: { user: string; reason: string; mod?: Member }) { + async warnCmd( + msg: Message, + args: { user: string; reason: string; mod?: Member; notify?: string; "notify-channel"?: TextChannel }, + ) { const user = await this.resolveUser(args.user); if (!user) return this.sendErrorMessage(msg.channel, `User not found`); @@ -605,20 +670,26 @@ export class ModActionsPlugin extends ZeppelinPlugin { const config = this.getConfig(); const reason = this.formatReasonWithAttachments(args.reason, msg.attachments); - const warnMessage = config.warn_message.replace("{guildName}", this.guild.name).replace("{reason}", reason); - const warnResult = await this.warnMember( - memberToWarn, - warnMessage, - { + let contactMethods; + try { + contactMethods = this.readContactMethodsFromArgs(args); + } catch (e) { + this.sendErrorMessage(msg.channel, e.message); + return; + } + + const warnResult = await this.warnMember(memberToWarn, reason, { + contactMethods, + caseArgs: { modId: mod.id, ppId: mod.id !== msg.author.id ? msg.author.id : null, reason, }, - msg.channel as TextChannel, - ); + retryPromptChannel: msg.channel as TextChannel, + }); if (warnResult.status === "failed") { - msg.channel.createMessage(errorMessage("Failed to warn user")); + this.sendErrorMessage(msg.channel, "Failed to warn user"); return; } @@ -630,22 +701,20 @@ export class ModActionsPlugin extends ZeppelinPlugin { ); } - async warnMember( - member: Member, - warnMessage: string, - caseArgs: Partial = {}, - retryPromptChannel: TextChannel = null, - ): Promise { + async warnMember(member: Member, reason: string, warnOptions: WarnOptions = {}): Promise { const config = this.getConfig(); - const notifyResult = await notifyUser(this.bot, this.guild, member.user, warnMessage, { - useDM: config.dm_on_warn, - useChannel: config.message_on_warn, - }); + const warnMessage = config.warn_message.replace("{guildName}", this.guild.name).replace("{reason}", reason); + const contactMethods = warnOptions?.contactMethods + ? warnOptions.contactMethods + : this.getDefaultContactMethods("warn"); + const notifyResult = await notifyUser(member.user, warnMessage, contactMethods); - if (notifyResult.status === NotifyUserStatus.Failed) { - if (retryPromptChannel && this.guild.channels.has(retryPromptChannel.id)) { - const failedMsg = await retryPromptChannel.createMessage("Failed to message the user. Log the warning anyway?"); + if (!notifyResult.success) { + if (warnOptions.retryPromptChannel && this.guild.channels.has(warnOptions.retryPromptChannel.id)) { + const failedMsg = await warnOptions.retryPromptChannel.createMessage( + "Failed to message the user. Log the warning anyway?", + ); const reply = await waitForReaction(this.bot, failedMsg, ["✅", "❌"]); failedMsg.delete(); if (!reply || reply.name === "❌") { @@ -664,15 +733,15 @@ export class ModActionsPlugin extends ZeppelinPlugin { const casesPlugin = this.getPlugin("cases"); const createdCase = await casesPlugin.createCase({ - ...caseArgs, + ...(warnOptions.caseArgs || {}), userId: member.id, - modId: caseArgs.modId, + modId: warnOptions.caseArgs?.modId, type: CaseTypes.Warn, - reason: caseArgs.reason || warnMessage, - noteDetails: notifyResult.status !== NotifyUserStatus.Ignored ? [ucfirst(notifyResult.text)] : [], + reason, + noteDetails: notifyResult.text ? [ucfirst(notifyResult.text)] : [], }); - const mod = await this.resolveUser(caseArgs.modId); + const mod = await this.resolveUser(warnOptions.caseArgs?.modId); this.serverLogs.log(LogType.MEMBER_WARN, { mod: stripObjectToScalars(mod), member: stripObjectToScalars(member, ["user", "roles"]), @@ -689,7 +758,11 @@ export class ModActionsPlugin extends ZeppelinPlugin { * The actual function run by both !mute and !forcemute. * The only difference between the two commands is in target member validation. */ - async actualMuteCmd(user: User | UnknownUser, msg: Message, args: { time?: number; reason?: string; mod: Member }) { + async actualMuteCmd( + user: User | UnknownUser, + msg: Message, + args: { time?: number; reason?: string; mod: Member; notify?: string; "notify-channel"?: TextChannel }, + ) { // The moderator who did the action is the message author or, if used, the specified -mod let mod = msg.member; let pp = null; @@ -710,17 +783,30 @@ export class ModActionsPlugin extends ZeppelinPlugin { let muteResult: MuteResult; const mutesPlugin = this.getPlugin("mutes"); + let contactMethods; + try { + contactMethods = this.readContactMethodsFromArgs(args); + } catch (e) { + this.sendErrorMessage(msg.channel, e.message); + return; + } + try { muteResult = await mutesPlugin.muteUser(user.id, args.time, reason, { - modId: mod.id, - ppId: pp && pp.id, + contactMethods, + caseArgs: { + modId: mod.id, + ppId: pp && pp.id, + }, }); } catch (e) { - if (e instanceof DiscordRESTError && e.code === 10007) { - msg.channel.createMessage(errorMessage("Could not mute the user: unknown member")); + if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) { + this.sendErrorMessage(msg.channel, "Could not mute the user: no mute role set in config"); + } else if (e instanceof DiscordRESTError && e.code === 10007) { + this.sendErrorMessage(msg.channel, "Could not mute the user: unknown member"); } else { logger.error(`Failed to mute user ${user.id}: ${e.stack}`); - msg.channel.createMessage(errorMessage("Could not mute the user")); + this.sendErrorMessage(msg.channel, "Could not mute the user"); } return; @@ -760,7 +846,11 @@ export class ModActionsPlugin extends ZeppelinPlugin { @d.command("mute", " ", { overloads: [" ", " [reason:string$]"], - options: [{ name: "mod", type: "member" }], + options: [ + { name: "mod", type: "member" }, + { name: "notify", type: "string" }, + { name: "notify-channel", type: "channel" }, + ], extra: { info: { description: "Mute the specified member", @@ -768,7 +858,17 @@ export class ModActionsPlugin extends ZeppelinPlugin { }, }) @d.permission("can_mute") - async muteCmd(msg: Message, args: { user: string; time?: number; reason?: string; mod: Member }) { + async muteCmd( + msg: Message, + args: { + user: string; + time?: number; + reason?: string; + mod: Member; + notify?: string; + "notify-channel"?: TextChannel; + }, + ) { const user = await this.resolveUser(args.user); if (!user) return this.sendErrorMessage(msg.channel, `User not found`); @@ -803,7 +903,11 @@ export class ModActionsPlugin extends ZeppelinPlugin { @d.command("forcemute", " ", { overloads: [" ", " [reason:string$]"], - options: [{ name: "mod", type: "member" }], + options: [ + { name: "mod", type: "member" }, + { name: "notify", type: "string" }, + { name: "notify-channel", type: "channel" }, + ], extra: { info: { description: "Force-mute the specified user, even if they're not on the server", @@ -811,7 +915,17 @@ export class ModActionsPlugin extends ZeppelinPlugin { }, }) @d.permission("can_mute") - async forcemuteCmd(msg: Message, args: { user: string; time?: number; reason?: string; mod: Member }) { + async forcemuteCmd( + msg: Message, + args: { + user: string; + time?: number; + reason?: string; + mod: Member; + notify?: string; + "notify-channel"?: TextChannel; + }, + ) { const user = await this.resolveUser(args.user); if (!user) return this.sendErrorMessage(msg.channel, `User not found`); @@ -959,7 +1073,11 @@ export class ModActionsPlugin extends ZeppelinPlugin { } @d.command("kick", " [reason:string$]", { - options: [{ name: "mod", type: "member" }], + options: [ + { name: "mod", type: "member" }, + { name: "notify", type: "string" }, + { name: "notify-channel", type: "channel" }, + ], extra: { info: { description: "Kick the specified member", @@ -967,7 +1085,10 @@ export class ModActionsPlugin extends ZeppelinPlugin { }, }) @d.permission("can_kick") - async kickCmd(msg, args: { user: string; reason: string; mod: Member }) { + async kickCmd( + msg, + args: { user: string; reason: string; mod: Member; notify?: string; "notify-channel"?: TextChannel }, + ) { const user = await this.resolveUser(args.user); if (!user) return this.sendErrorMessage(msg.channel, `User not found`); @@ -1001,10 +1122,21 @@ export class ModActionsPlugin extends ZeppelinPlugin { mod = args.mod; } + let contactMethods; + try { + contactMethods = this.readContactMethodsFromArgs(args); + } catch (e) { + this.sendErrorMessage(msg.channel, e.message); + return; + } + const reason = this.formatReasonWithAttachments(args.reason, msg.attachments); const kickResult = await this.kickMember(memberToKick, reason, { - modId: mod.id, - ppId: mod.id !== msg.author.id ? msg.author.id : null, + contactMethods, + caseArgs: { + modId: mod.id, + ppId: mod.id !== msg.author.id ? msg.author.id : null, + }, }); if (kickResult.status === "failed") { @@ -1020,7 +1152,11 @@ export class ModActionsPlugin extends ZeppelinPlugin { } @d.command("ban", " [reason:string$]", { - options: [{ name: "mod", type: "member" }], + options: [ + { name: "mod", type: "member" }, + { name: "notify", type: "string" }, + { name: "notify-channel", type: "channel" }, + ], extra: { info: { description: "Ban the specified member", @@ -1028,7 +1164,10 @@ export class ModActionsPlugin extends ZeppelinPlugin { }, }) @d.permission("can_ban") - async banCmd(msg, args: { user: string; reason?: string; mod?: Member }) { + async banCmd( + msg, + args: { user: string; reason?: string; mod?: Member; notify?: string; "notify-channel"?: TextChannel }, + ) { const user = await this.resolveUser(args.user); if (!user) return this.sendErrorMessage(msg.channel, `User not found`); @@ -1062,10 +1201,21 @@ export class ModActionsPlugin extends ZeppelinPlugin { mod = args.mod; } + let contactMethods; + try { + contactMethods = this.readContactMethodsFromArgs(args); + } catch (e) { + this.sendErrorMessage(msg.channel, e.message); + return; + } + const reason = this.formatReasonWithAttachments(args.reason, msg.attachments); const banResult = await this.banUserId(memberToBan.id, reason, { - modId: mod.id, - ppId: mod.id !== msg.author.id ? msg.author.id : null, + contactMethods, + caseArgs: { + modId: mod.id, + ppId: mod.id !== msg.author.id ? msg.author.id : null, + }, }); if (banResult.status === "failed") { diff --git a/backend/src/plugins/Mutes.ts b/backend/src/plugins/Mutes.ts index 64169a2a..fdd1d309 100644 --- a/backend/src/plugins/Mutes.ts +++ b/backend/src/plugins/Mutes.ts @@ -1,4 +1,4 @@ -import { Member, Message, User } from "eris"; +import { Member, Message, TextChannel, User } from "eris"; import { GuildCases } from "../data/GuildCases"; import moment from "moment-timezone"; import { ZeppelinPlugin } from "./ZeppelinPlugin"; @@ -7,15 +7,15 @@ import { chunkMessageLines, DBDateFormat, errorMessage, - INotifyUserResult, + UserNotificationResult, noop, notifyUser, - NotifyUserStatus, stripObjectToScalars, successMessage, tNullable, ucfirst, UnknownUser, + UserNotificationMethod, } from "../utils"; import humanizeDuration from "humanize-duration"; import { LogType } from "../data/LogType"; @@ -27,6 +27,7 @@ import { CaseTypes } from "../data/CaseTypes"; import { CaseArgs, CasesPlugin } from "./Cases"; import { Case } from "../data/entities/Case"; import * as t from "io-ts"; +import { ERRORS, RecoverablePluginError } from "../RecoverablePluginError"; const ConfigSchema = t.type({ mute_role: tNullable(t.string), @@ -53,7 +54,7 @@ interface IMuteWithDetails extends Mute { export type MuteResult = { case: Case; - notifyResult: INotifyUserResult; + notifyResult: UserNotificationResult; updatedExistingMute: boolean; }; @@ -61,6 +62,11 @@ export type UnmuteResult = { case: Case; }; +export interface MuteOptions { + caseArgs?: Partial; + contactMethods?: UserNotificationMethod[]; +} + const EXPIRED_MUTE_CHECK_INTERVAL = 60 * 1000; let FIRST_CHECK_TIME = Date.now(); const FIRST_CHECK_INCREMENT = 5 * 1000; @@ -136,16 +142,19 @@ export class MutesPlugin extends ZeppelinPlugin { userId: string, muteTime: number = null, reason: string = null, - caseArgs: Partial = {}, + muteOptions: MuteOptions = {}, ): Promise { const muteRole = this.getConfig().mute_role; - if (!muteRole) return; + if (!muteRole) { + this.throwRecoverablePluginError(ERRORS.NO_MUTE_ROLE_IN_CONFIG); + } const timeUntilUnmute = muteTime ? humanizeDuration(muteTime) : "indefinite"; // No mod specified -> mark Zeppelin as the mod - if (!caseArgs.modId) { - caseArgs.modId = this.bot.user.id; + if (!muteOptions.caseArgs?.modId) { + muteOptions.caseArgs = muteOptions.caseArgs ?? {}; + muteOptions.caseArgs.modId = this.bot.user.id; } const user = await this.resolveUser(userId); @@ -170,7 +179,7 @@ export class MutesPlugin extends ZeppelinPlugin { // If the user is already muted, update the duration of their existing mute const existingMute = await this.mutes.findExistingMuteForUserId(user.id); - let notifyResult: INotifyUserResult = { status: NotifyUserStatus.Ignored }; + let notifyResult: UserNotificationResult = { method: null, success: true }; if (existingMute) { await this.mutes.updateExpiryTime(user.id, muteTime); @@ -192,19 +201,27 @@ export class MutesPlugin extends ZeppelinPlugin { time: timeUntilUnmute, })); - if (muteMessage) { - const useDm = existingMute ? config.dm_on_update : config.dm_on_mute; - const useChannel = existingMute ? config.message_on_update : config.message_on_mute; - if (user instanceof User) { - notifyResult = await notifyUser(this.bot, this.guild, user, muteMessage, { - useDM: useDm, - useChannel, - channelId: config.message_channel, - }); + if (muteMessage && user instanceof User) { + let contactMethods = []; + + if (muteOptions?.contactMethods) { + contactMethods = muteOptions.contactMethods; } else { - notifyResult = { status: NotifyUserStatus.Failed }; + const useDm = existingMute ? config.dm_on_update : config.dm_on_mute; + if (useDm) { + contactMethods.push({ type: "dm" }); + } + + const useChannel = existingMute ? config.message_on_update : config.message_on_mute; + const channel = config.message_channel && this.guild.channels.get(config.message_channel); + if (useChannel && channel instanceof TextChannel) { + contactMethods.push({ type: "channel", channel }); + } } + + notifyResult = await notifyUser(user, muteMessage, contactMethods); } + // Create/update a case const casesPlugin = this.getPlugin("cases"); let theCase; @@ -215,31 +232,31 @@ export class MutesPlugin extends ZeppelinPlugin { // but instead we'll post the entire case afterwards theCase = await this.cases.find(existingMute.case_id); const noteDetails = [`Mute updated to ${muteTime ? timeUntilUnmute : "indefinite"}`]; - const reasons = [reason, ...(caseArgs.extraNotes || [])]; + const reasons = [reason, ...(muteOptions.caseArgs?.extraNotes || [])]; for (const noteReason of reasons) { await casesPlugin.createCaseNote({ caseId: existingMute.case_id, - modId: caseArgs.modId, + modId: muteOptions.caseArgs?.modId, body: noteReason, noteDetails, postInCaseLogOverride: false, }); } - if (caseArgs.postInCaseLogOverride !== false) { + if (muteOptions.caseArgs?.postInCaseLogOverride !== false) { casesPlugin.postCaseToCaseLogChannel(existingMute.case_id); } } else { // Create new case const noteDetails = [`Muted ${muteTime ? `for ${timeUntilUnmute}` : "indefinitely"}`]; - if (notifyResult.status !== NotifyUserStatus.Ignored) { + if (notifyResult.text) { noteDetails.push(ucfirst(notifyResult.text)); } theCase = await casesPlugin.createCase({ - ...caseArgs, + ...(muteOptions.caseArgs || {}), userId, - modId: caseArgs.modId, + modId: muteOptions.caseArgs?.modId, type: CaseTypes.Mute, reason, noteDetails, @@ -248,7 +265,7 @@ export class MutesPlugin extends ZeppelinPlugin { } // Log the action - const mod = await this.resolveUser(caseArgs.modId); + const mod = await this.resolveUser(muteOptions.caseArgs?.modId); if (muteTime) { this.serverLogs.log(LogType.MEMBER_TIMED_MUTE, { mod: stripObjectToScalars(mod), diff --git a/backend/src/plugins/Spam.ts b/backend/src/plugins/Spam.ts index 6447d791..76cff8b2 100644 --- a/backend/src/plugins/Spam.ts +++ b/backend/src/plugins/Spam.ts @@ -254,8 +254,10 @@ export class SpamPlugin extends ZeppelinPlugin { ? convertDelayStringToMS(spamConfig.mute_time.toString()) : 120 * 1000; muteResult = await mutesPlugin.muteUser(member.id, muteTime, "Automatic spam detection", { - modId: this.bot.user.id, - postInCaseLogOverride: false, + caseArgs: { + modId: this.bot.user.id, + postInCaseLogOverride: false, + }, }); } @@ -374,8 +376,10 @@ export class SpamPlugin extends ZeppelinPlugin { 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}`], + caseArgs: { + modId: this.bot.user.id, + extraNotes: [`Details: ${details}`], + }, }); } else { // If we're not muting the user, just add a note on them diff --git a/backend/src/plugins/ZeppelinPlugin.ts b/backend/src/plugins/ZeppelinPlugin.ts index 2fffcd27..3a6804c1 100644 --- a/backend/src/plugins/ZeppelinPlugin.ts +++ b/backend/src/plugins/ZeppelinPlugin.ts @@ -1,30 +1,27 @@ -import { IBasePluginConfig, IPluginOptions, logger, Plugin, configUtils } from "knub"; -import { PluginRuntimeError } from "../PluginRuntimeError"; +import { configUtils, IBasePluginConfig, IPluginOptions, logger, Plugin } from "knub"; import * as t from "io-ts"; -import { pipe } from "fp-ts/lib/pipeable"; -import { fold } from "fp-ts/lib/Either"; -import { PathReporter } from "io-ts/lib/PathReporter"; import { deepKeyIntersect, isSnowflake, isUnicodeEmoji, MINUTES, + Not, resolveMember, + resolveRoleId, resolveUser, resolveUserId, tDeepPartial, trimEmptyStartEndLines, trimIndents, UnknownUser, - resolveRoleId, } from "../utils"; import { Invite, Member, User } from "eris"; import DiscordRESTError from "eris/lib/errors/DiscordRESTError"; // tslint:disable-line import { performance } from "perf_hooks"; import { decodeAndValidateStrict, StrictValidationError, validate } from "../validatorUtils"; import { SimpleCache } from "../SimpleCache"; -import { Knub } from "knub/dist/Knub"; import { TZeppelinKnub } from "../types"; +import { ERRORS, RecoverablePluginError } from "../RecoverablePluginError"; const SLOW_RESOLVE_THRESHOLD = 1500; @@ -74,8 +71,8 @@ export class ZeppelinPlugin< protected readonly knub: TZeppelinKnub; - protected throwPluginRuntimeError(message: string) { - throw new PluginRuntimeError(message, this.runtimePluginName, this.guildId); + protected throwRecoverablePluginError(code: ERRORS) { + throw new RecoverablePluginError(code, this.guild); } protected canActOn(member1: Member, member2: Member, allowSameLevel = false) { @@ -217,7 +214,7 @@ export class ZeppelinPlugin< } } } else { - throw new PluginRuntimeError(`Invalid emoji: ${snowflake}`, this.runtimePluginName, this.guildId); + this.throwRecoverablePluginError(ERRORS.INVALID_EMOJI); } } @@ -237,7 +234,9 @@ export class ZeppelinPlugin< * Resolves a user from the passed string. The passed string can be a user id, a user mention, a full username (with discrim), etc. * If the user is not found in the cache, it's fetched from the API. */ - async resolveUser(userResolvable: string): Promise { + async resolveUser(userResolvable: string): Promise; + async resolveUser(userResolvable: Not): Promise; + async resolveUser(userResolvable) { const start = performance.now(); const user = await resolveUser(this.bot, userResolvable); const time = performance.now() - start; diff --git a/backend/src/utils.ts b/backend/src/utils.ts index b0fef7aa..789c84cd 100644 --- a/backend/src/utils.ts +++ b/backend/src/utils.ts @@ -118,6 +118,9 @@ function tDeepPartialProp(prop: any) { } } +// https://stackoverflow.com/a/49262929/316944 +export type Not = T & Exclude; + /** * Mirrors EmbedOptions from Eris */ @@ -743,63 +746,64 @@ export type CustomEmoji = { id: string; } & Emoji; -export interface INotifyUserConfig { - useDM?: boolean; - useChannel?: boolean; - channelId?: string; -} +export type UserNotificationMethod = { type: "dm" } | { type: "channel"; channel: TextChannel }; -export enum NotifyUserStatus { - Ignored = 1, - Failed, - DirectMessaged, - ChannelMessaged, -} +export const disableUserNotificationStrings = ["no", "none", "off"]; -export interface INotifyUserResult { - status: NotifyUserStatus; +export interface UserNotificationResult { + method: UserNotificationMethod | null; + success: boolean; text?: string; } +/** + * Attempts to notify the user using one of the specified methods. Only the first one that succeeds will be used. + * @param methods List of methods to try, in priority order + */ export async function notifyUser( - bot: Client, - guild: Guild, user: User, body: string, - config: INotifyUserConfig, -): Promise { - if (!config.useDM && !config.useChannel) { - return { status: NotifyUserStatus.Ignored }; + methods: UserNotificationMethod[], +): Promise { + if (methods.length === 0) { + return { method: null, success: true }; } - 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 - } + let lastError: Error = null; - if (config.useChannel && config.channelId) { - try { - const channel = guild.channels.get(config.channelId); - if (channel instanceof TextChannel) { - await channel.createMessage(`<@!${user.id}> ${body}`); + for (const method of methods) { + if (method.type === "dm") { + try { + const dmChannel = await user.getDMChannel(); + await dmChannel.createMessage(body); return { - status: NotifyUserStatus.ChannelMessaged, - text: `user notified in <#${channel.id}>`, + method, + success: true, + text: "user notified with a direct message", }; + } catch (e) { + lastError = e; } - } catch (e) {} // tslint:disable-line + } else if (method.type === "channel") { + try { + await method.channel.createMessage(`<@!${user.id}> ${body}`); + return { + method, + success: true, + text: `user notified in <#${method.channel.id}>`, + }; + } catch (e) { + lastError = e; + } + } } + const errorText = lastError ? `failed to message user: ${lastError.message}` : `failed to message user`; + return { - status: NotifyUserStatus.Failed, - text: "failed to message user", + method: null, + success: false, + text: errorText, }; } @@ -893,8 +897,10 @@ export function resolveUserId(bot: Client, value: string) { return null; } -export async function resolveUser(bot: Client, value: string): Promise { - if (value == null || typeof value !== "string") { +export async function resolveUser(bot: Client, value: string): Promise; +export async function resolveUser(bot: Client, value: Not): Promise; +export async function resolveUser(bot, value) { + if (typeof value !== "string") { return new UnknownUser(); }