diff --git a/backend/src/pluginUtils.ts b/backend/src/pluginUtils.ts index 89184e7e..6e776961 100644 --- a/backend/src/pluginUtils.ts +++ b/backend/src/pluginUtils.ts @@ -8,6 +8,7 @@ import { decodeAndValidateStrict, StrictValidationError } from "./validatorUtils import { deepKeyIntersect, errorMessage, successMessage } from "./utils"; import { ZeppelinPluginBlueprint } from "./plugins/ZeppelinPluginBlueprint"; import { TZeppelinKnub } from "./types"; +import { ExtendedMatchParams } from "knub/dist/config/PluginConfigManager"; // TODO: Export from Knub index const { getMemberLevel } = helpers; @@ -21,6 +22,11 @@ export function canActOn(pluginData: PluginData, member1: Member, member2: return allowSameLevel ? ourLevel >= memberLevel : ourLevel > memberLevel; } +export function hasPermission(pluginData: PluginData, permission: string, matchParams: ExtendedMatchParams) { + const config = pluginData.config.getMatchingConfig(matchParams); + return helpers.hasPermission(config, permission); +} + export function getPluginConfigPreprocessor(blueprint: ZeppelinPluginBlueprint) { return (options: PluginOptions) => { const decodedConfig = blueprint.configSchema @@ -66,3 +72,13 @@ export function getBaseUrl(pluginData: PluginData) { const knub = pluginData.getKnubInstance() as TZeppelinKnub; return knub.getGlobalConfig().url; } + +export function isOwner(pluginData: PluginData, userId: string) { + const knub = pluginData.getKnubInstance() as TZeppelinKnub; + const owners = knub.getGlobalConfig().owners; + if (!owners) { + return false; + } + + return owners.includes(userId); +} diff --git a/backend/src/plugins/Cases/CasesPlugin.ts b/backend/src/plugins/Cases/CasesPlugin.ts index 5a10b0f8..260ca987 100644 --- a/backend/src/plugins/Cases/CasesPlugin.ts +++ b/backend/src/plugins/Cases/CasesPlugin.ts @@ -7,6 +7,8 @@ import { GuildCases } from "../../data/GuildCases"; import { createCaseNote } from "./functions/createCaseNote"; import { Case } from "../../data/entities/Case"; import { postCaseToCaseLogChannel } from "./functions/postToCaseLogChannel"; +import { CaseTypes } from "../../data/CaseTypes"; +import { getCaseTypeAmountForUserId } from "./functions/getCaseTypeAmountForUserId"; const defaultOptions = { config: { @@ -21,22 +23,28 @@ export const CasesPlugin = zeppelinPlugin()("cases", { public: { createCase(pluginData) { - return async (args: CaseArgs) => { + return (args: CaseArgs) => { return createCase(pluginData, args); }; }, createCaseNote(pluginData) { - return async (args: CaseNoteArgs) => { + return (args: CaseNoteArgs) => { return createCaseNote(pluginData, args); }; }, postCaseToCaseLogChannel(pluginData) { - return async (caseOrCaseId: Case | number) => { + return (caseOrCaseId: Case | number) => { return postCaseToCaseLogChannel(pluginData, caseOrCaseId); }; }, + + getCaseTypeAmountForUserId(pluginData) { + return (userID: string, type: CaseTypes) => { + return getCaseTypeAmountForUserId(pluginData, userID, type); + }; + }, }, onLoad(pluginData) { diff --git a/backend/src/plugins/Cases/functions/getCaseTypeAmountForUserId.ts b/backend/src/plugins/Cases/functions/getCaseTypeAmountForUserId.ts new file mode 100644 index 00000000..f5f1319e --- /dev/null +++ b/backend/src/plugins/Cases/functions/getCaseTypeAmountForUserId.ts @@ -0,0 +1,22 @@ +import { PluginData } from "knub"; +import { CasesPluginType } from "../types"; +import { CaseTypes } from "../../../data/CaseTypes"; + +export async function getCaseTypeAmountForUserId( + pluginData: PluginData, + userID: string, + type: CaseTypes, +): Promise { + const cases = (await pluginData.state.cases.getByUserId(userID)).filter(c => !c.is_hidden); + let typeAmount = 0; + + if (cases.length > 0) { + cases.forEach(singleCase => { + if (singleCase.type === type.valueOf()) { + typeAmount++; + } + }); + } + + return typeAmount; +} diff --git a/backend/src/plugins/ChannelArchiver/ChannelArchiverPlugin.ts b/backend/src/plugins/ChannelArchiver/ChannelArchiverPlugin.ts new file mode 100644 index 00000000..599977db --- /dev/null +++ b/backend/src/plugins/ChannelArchiver/ChannelArchiverPlugin.ts @@ -0,0 +1,16 @@ +import { zeppelinPlugin } from "../ZeppelinPluginBlueprint"; +import { ChannelArchiverPluginType } from "./types"; +import { ArchiveChannelCmd } from "./commands/ArchiveChannelCmd"; + +export const ChannelArchiverPlugin = zeppelinPlugin()("channel_archiver", { + showInDocs: false, + + // prettier-ignore + commands: [ + ArchiveChannelCmd, + ], + + onLoad(pluginData) { + const { state, guild } = pluginData; + }, +}); diff --git a/backend/src/plugins/ChannelArchiver/commands/ArchiveChannelCmd.ts b/backend/src/plugins/ChannelArchiver/commands/ArchiveChannelCmd.ts new file mode 100644 index 00000000..61eff881 --- /dev/null +++ b/backend/src/plugins/ChannelArchiver/commands/ArchiveChannelCmd.ts @@ -0,0 +1,110 @@ +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { channelArchiverCmd } from "../types"; +import { isOwner, sendErrorMessage } from "src/pluginUtils"; +import { confirm, SECONDS, noop } from "src/utils"; +import moment from "moment-timezone"; +import { rehostAttachment } from "../rehostAttachment"; + +const MAX_ARCHIVED_MESSAGES = 5000; +const MAX_MESSAGES_PER_FETCH = 100; +const PROGRESS_UPDATE_INTERVAL = 5 * SECONDS; + +export const ArchiveChannelCmd = channelArchiverCmd({ + trigger: "archive_channel", + permission: null, + + config: { + preFilters: [ + (command, context) => { + return isOwner(context.pluginData, context.message.author.id); + }, + ], + }, + + signature: { + channel: ct.textChannel(), + + "attachment-channel": ct.textChannel({ option: true }), + messages: ct.number({ option: true }), + }, + + async run({ message: msg, args, pluginData }) { + if (!args["attachment-channel"]) { + const confirmed = await confirm( + pluginData.client, + msg.channel, + msg.author.id, + "No `-attachment-channel` specified. Continue? Attachments will not be available in the log if their message is deleted.", + ); + if (!confirmed) { + sendErrorMessage(pluginData, msg.channel, "Canceled"); + return; + } + } + + const maxMessagesToArchive = args.messages ? Math.min(args.messages, MAX_ARCHIVED_MESSAGES) : MAX_ARCHIVED_MESSAGES; + if (maxMessagesToArchive <= 0) return; + + const archiveLines = []; + let archivedMessages = 0; + let previousId; + + const startTime = Date.now(); + const progressMsg = await msg.channel.createMessage("Creating archive..."); + const progressUpdateInterval = setInterval(() => { + const secondsSinceStart = Math.round((Date.now() - startTime) / 1000); + progressMsg + .edit(`Creating archive...\n**Status:** ${archivedMessages} messages archived in ${secondsSinceStart} seconds`) + .catch(() => clearInterval(progressUpdateInterval)); + }, PROGRESS_UPDATE_INTERVAL); + + while (archivedMessages < maxMessagesToArchive) { + const messagesToFetch = Math.min(MAX_MESSAGES_PER_FETCH, maxMessagesToArchive - archivedMessages); + const messages = await args.channel.getMessages(messagesToFetch, previousId); + if (messages.length === 0) break; + + for (const message of messages) { + const ts = moment.utc(message.timestamp).format("YYYY-MM-DD HH:mm:ss"); + let content = `[${ts}] [${message.author.id}] [${message.author.username}#${ + message.author.discriminator + }]: ${message.content || ""}`; + + if (message.attachments.length) { + if (args["attachment-channel"]) { + const rehostedAttachmentUrl = await rehostAttachment(message.attachments[0], args["attachment-channel"]); + content += `\n-- Attachment: ${rehostedAttachmentUrl}`; + } else { + content += `\n-- Attachment: ${message.attachments[0].url}`; + } + } + + if (message.reactions && Object.keys(message.reactions).length > 0) { + const reactionCounts = []; + for (const [emoji, info] of Object.entries(message.reactions)) { + reactionCounts.push(`${info.count}x ${emoji}`); + } + content += `\n-- Reactions: ${reactionCounts.join(", ")}`; + } + + archiveLines.push(content); + previousId = message.id; + archivedMessages++; + } + } + + clearInterval(progressUpdateInterval); + + archiveLines.reverse(); + + const nowTs = moment().format("YYYY-MM-DD HH:mm:ss"); + + let result = `Archived ${archiveLines.length} messages from #${args.channel.name} at ${nowTs}`; + result += `\n\n${archiveLines.join("\n")}\n`; + + progressMsg.delete().catch(noop); + msg.channel.createMessage("Archive created!", { + file: Buffer.from(result), + name: `archive-${args.channel.name}-${moment().format("YYYY-MM-DD-HH-mm-ss")}.txt`, + }); + }, +}); diff --git a/backend/src/plugins/ChannelArchiver/rehostAttachment.ts b/backend/src/plugins/ChannelArchiver/rehostAttachment.ts new file mode 100644 index 00000000..0b12e360 --- /dev/null +++ b/backend/src/plugins/ChannelArchiver/rehostAttachment.ts @@ -0,0 +1,29 @@ +import { Attachment, TextChannel } from "eris"; +import { downloadFile } from "src/utils"; +import fs from "fs"; +const fsp = fs.promises; + +const MAX_ATTACHMENT_REHOST_SIZE = 1024 * 1024 * 8; + +export async function rehostAttachment(attachment: Attachment, targetChannel: TextChannel): Promise { + if (attachment.size > MAX_ATTACHMENT_REHOST_SIZE) { + return "Attachment too big to rehost"; + } + + let downloaded; + try { + downloaded = await downloadFile(attachment.url, 3); + } catch (e) { + return "Failed to download attachment after 3 tries"; + } + + try { + const rehostMessage = await targetChannel.createMessage(`Rehost of attachment ${attachment.id}`, { + name: attachment.filename, + file: await fsp.readFile(downloaded.path), + }); + return rehostMessage.attachments[0].url; + } catch (e) { + return "Failed to rehost attachment"; + } +} diff --git a/backend/src/plugins/ChannelArchiver/types.ts b/backend/src/plugins/ChannelArchiver/types.ts new file mode 100644 index 00000000..cbed3e52 --- /dev/null +++ b/backend/src/plugins/ChannelArchiver/types.ts @@ -0,0 +1,7 @@ +import { BasePluginType, command } from "knub"; + +export interface ChannelArchiverPluginType extends BasePluginType { + state: {}; +} + +export const channelArchiverCmd = command(); diff --git a/backend/src/plugins/ModActions/ModActionsPlugin.ts b/backend/src/plugins/ModActions/ModActionsPlugin.ts new file mode 100644 index 00000000..1135abed --- /dev/null +++ b/backend/src/plugins/ModActions/ModActionsPlugin.ts @@ -0,0 +1,77 @@ +import { zeppelinPlugin } from "../ZeppelinPluginBlueprint"; +import { CasesPlugin } from "../Cases/CasesPlugin"; +import { MutesPlugin } from "../Mutes/MutesPlugin"; +import { ConfigSchema, ModActionsPluginType } from "./types"; +import { CreateBanCaseOnManualBanEvt } from "./events/CreateBanCaseOnManualBanEvt"; +import { CreateUnbanCaseOnManualUnbanEvt } from "./events/CreateUnbanCaseOnManualUnbanEvt"; +import { CreateKickCaseOnManualKickEvt } from "./events/CreateKickCaseOnManualKickEvt"; +import { UpdateCmd } from "./commands/UpdateCmd"; +import { NoteCmd } from "./commands/NoteCmd"; +import { WarnCmd } from "./commands/WarnCmd"; +import { MuteCmd } from "./commands/MuteCmd"; + +const defaultOptions = { + config: { + dm_on_warn: true, + dm_on_kick: false, + dm_on_ban: false, + message_on_warn: false, + message_on_kick: false, + message_on_ban: false, + message_channel: null, + warn_message: "You have received a warning on the {guildName} server: {reason}", + kick_message: "You have been kicked from the {guildName} server. Reason given: {reason}", + ban_message: "You have been banned from the {guildName} server. Reason given: {reason}", + alert_on_rejoin: false, + alert_channel: null, + warn_notify_enabled: false, + warn_notify_threshold: 5, + warn_notify_message: + "The user already has **{priorWarnings}** warnings!\n Please check their prior cases and assess whether or not to warn anyways.\n Proceed with the warning?", + ban_delete_message_days: 1, + + can_note: false, + can_warn: false, + can_mute: false, + can_kick: false, + can_ban: false, + can_view: false, + can_addcase: false, + can_massban: false, + can_hidecase: false, + can_act_as_other: false, + }, + overrides: [ + { + level: ">=50", + config: { + can_note: true, + can_warn: true, + can_mute: true, + can_kick: true, + can_ban: true, + can_view: true, + can_addcase: true, + }, + }, + { + level: ">=100", + config: { + can_massban: true, + can_hidecase: true, + can_act_as_other: true, + }, + }, + ], +}; + +export const ModActionsPlugin = zeppelinPlugin()("mod_actions", { + configSchema: ConfigSchema, + defaultOptions, + + dependencies: [CasesPlugin, MutesPlugin], + + events: [CreateBanCaseOnManualBanEvt, CreateUnbanCaseOnManualUnbanEvt, CreateKickCaseOnManualKickEvt], + + commands: [UpdateCmd, NoteCmd, WarnCmd, MuteCmd], +}); diff --git a/backend/src/plugins/ModActions/commands/MuteCmd.ts b/backend/src/plugins/ModActions/commands/MuteCmd.ts new file mode 100644 index 00000000..e97ad983 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/MuteCmd.ts @@ -0,0 +1,78 @@ +import { modActionsCommand } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { Case } from "../../../data/entities/Case"; +import { canActOn, hasPermission, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; +import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments"; +import { CasesPlugin } from "../../Cases/CasesPlugin"; +import { LogType } from "../../../data/LogType"; +import { CaseTypes } from "../../../data/CaseTypes"; +import { errorMessage, resolveMember, resolveUser, stripObjectToScalars } from "../../../utils"; +import { isBanned } from "../functions/isBanned"; +import { waitForReaction } from "knub/dist/helpers"; +import { readContactMethodsFromArgs } from "../functions/readContactMethodsFromArgs"; +import { warnMember } from "../functions/warnMember"; +import { TextChannel } from "eris"; +import { actualMuteUserCmd } from "../functions/actualMuteUserCmd"; + +const opts = { + mod: ct.member({ option: true }), + notify: ct.string({ option: true }), + "notify-channel": ct.textChannel({ option: true }), +}; + +export const MuteCmd = modActionsCommand({ + trigger: "mute", + permission: "can_mute", + description: "Mute the specified member", + + signature: [ + { + user: ct.string(), + time: ct.delay(), + reason: ct.string({ required: false, catchAll: true }), + + ...opts, + }, + { + user: ct.string(), + reason: ct.string({ required: false, catchAll: true }), + + ...opts, + }, + ], + + async run({ pluginData, message: msg, args }) { + const user = await resolveUser(pluginData.client, args.user); + if (!user) return sendErrorMessage(pluginData, msg.channel, `User not found`); + + const memberToMute = await resolveMember(pluginData.client, pluginData.guild, user.id); + + if (!memberToMute) { + const _isBanned = await isBanned(pluginData, user.id); + const prefix = pluginData.guildConfig.prefix; + if (_isBanned) { + sendErrorMessage( + pluginData, + msg.channel, + `User is banned. Use \`${prefix}forcemute\` if you want to mute them anyway.`, + ); + } else { + sendErrorMessage( + pluginData, + msg.channel, + `User is not on the server. Use \`${prefix}forcemute\` if you want to mute them anyway.`, + ); + } + + return; + } + + // Make sure we're allowed to mute this member + if (memberToMute && !canActOn(pluginData, msg.member, memberToMute)) { + sendErrorMessage(pluginData, msg.channel, "Cannot mute: insufficient permissions"); + return; + } + + actualMuteUserCmd(pluginData, user, msg, args); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/NoteCmd.ts b/backend/src/plugins/ModActions/commands/NoteCmd.ts new file mode 100644 index 00000000..c07eab6f --- /dev/null +++ b/backend/src/plugins/ModActions/commands/NoteCmd.ts @@ -0,0 +1,44 @@ +import { modActionsCommand } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { Case } from "../../../data/entities/Case"; +import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; +import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments"; +import { CasesPlugin } from "../../Cases/CasesPlugin"; +import { LogType } from "../../../data/LogType"; +import { CaseTypes } from "../../../data/CaseTypes"; +import { resolveUser, stripObjectToScalars } from "../../../utils"; + +export const NoteCmd = modActionsCommand({ + trigger: "note", + permission: "can_note", + description: "Add a note to the specified user", + + signature: { + user: ct.string(), + note: ct.string({ catchAll: true }), + }, + + async run({ pluginData, message: msg, args }) { + const user = await resolveUser(pluginData.client, args.user); + if (!user) return sendErrorMessage(pluginData, msg.channel, `User not found`); + + const userName = `${user.username}#${user.discriminator}`; + const reason = formatReasonWithAttachments(args.note, msg.attachments); + + const casesPlugin = pluginData.getPlugin(CasesPlugin); + const createdCase = await casesPlugin.createCase({ + userId: user.id, + modId: msg.author.id, + type: CaseTypes.Note, + reason, + }); + + pluginData.state.serverLogs.log(LogType.MEMBER_NOTE, { + mod: stripObjectToScalars(msg.author), + user: stripObjectToScalars(user, ["user", "roles"]), + reason, + }); + + sendSuccessMessage(pluginData, msg.channel, `Note added on **${userName}** (Case #${createdCase.case_number})`); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/UpdateCmd.ts b/backend/src/plugins/ModActions/commands/UpdateCmd.ts new file mode 100644 index 00000000..e1211c23 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/UpdateCmd.ts @@ -0,0 +1,57 @@ +import { modActionsCommand } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { Case } from "../../../data/entities/Case"; +import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; +import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments"; +import { CasesPlugin } from "../../Cases/CasesPlugin"; +import { LogType } from "../../../data/LogType"; +import { CaseTypes } from "../../../data/CaseTypes"; + +export const UpdateCmd = modActionsCommand({ + trigger: "update", + permission: "can_note", + description: + "Update the specified case (or, if case number is omitted, your latest case) by adding more notes/details to it", + + signature: { + caseNumber: ct.number(), + note: ct.string({ required: false, catchAll: true }), + }, + + async run({ pluginData, message: msg, args }) { + let theCase: Case; + if (args.caseNumber != null) { + theCase = await pluginData.state.cases.findByCaseNumber(args.caseNumber); + } else { + theCase = await pluginData.state.cases.findLatestByModId(msg.author.id); + } + + if (!theCase) { + sendErrorMessage(pluginData, msg.channel, "Case not found"); + return; + } + + if (!args.note && msg.attachments.length === 0) { + sendErrorMessage(pluginData, msg.channel, "Text or attachment required"); + return; + } + + const note = formatReasonWithAttachments(args.note, msg.attachments); + + const casesPlugin = pluginData.getPlugin(CasesPlugin); + await casesPlugin.createCaseNote({ + caseId: theCase.id, + modId: msg.author.id, + body: note, + }); + + pluginData.state.serverLogs.log(LogType.CASE_UPDATE, { + mod: msg.author, + caseNumber: theCase.case_number, + caseType: CaseTypes[theCase.type], + note, + }); + + sendSuccessMessage(pluginData, msg.channel, `Case \`#${theCase.case_number}\` updated`); + }, +}); diff --git a/backend/src/plugins/ModActions/commands/WarnCmd.ts b/backend/src/plugins/ModActions/commands/WarnCmd.ts new file mode 100644 index 00000000..19dc2fbf --- /dev/null +++ b/backend/src/plugins/ModActions/commands/WarnCmd.ts @@ -0,0 +1,113 @@ +import { modActionsCommand } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { Case } from "../../../data/entities/Case"; +import { canActOn, hasPermission, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; +import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments"; +import { CasesPlugin } from "../../Cases/CasesPlugin"; +import { LogType } from "../../../data/LogType"; +import { CaseTypes } from "../../../data/CaseTypes"; +import { errorMessage, resolveMember, resolveUser, stripObjectToScalars } from "../../../utils"; +import { isBanned } from "../functions/isBanned"; +import { waitForReaction } from "knub/dist/helpers"; +import { readContactMethodsFromArgs } from "../functions/readContactMethodsFromArgs"; +import { warnMember } from "../functions/warnMember"; +import { TextChannel } from "eris"; + +export const WarnCmd = modActionsCommand({ + trigger: "warn", + permission: "can_warn", + description: "Send a warning to the specified user", + + signature: { + user: ct.string(), + reason: ct.string({ catchAll: true }), + + mod: ct.member({ option: true }), + notify: ct.string({ option: true }), + "notify-channel": ct.textChannel({ option: true }), + }, + + async run({ pluginData, message: msg, args }) { + const user = await resolveUser(pluginData.client, args.user); + if (!user) return sendErrorMessage(pluginData, msg.channel, `User not found`); + + const memberToWarn = await resolveMember(pluginData.client, pluginData.guild, user.id); + + if (!memberToWarn) { + const _isBanned = await isBanned(pluginData, user.id); + if (_isBanned) { + sendErrorMessage(pluginData, msg.channel, `User is banned`); + } else { + sendErrorMessage(pluginData, msg.channel, `User not found on the server`); + } + + return; + } + + // Make sure we're allowed to warn this member + if (!canActOn(pluginData, msg.member, memberToWarn)) { + sendErrorMessage(pluginData, msg.channel, "Cannot warn: insufficient permissions"); + return; + } + + // The moderator who did the action is the message author or, if used, the specified -mod + let mod = msg.member; + if (args.mod) { + if (!hasPermission(pluginData, "can_act_as_other", { message: msg })) { + msg.channel.createMessage(errorMessage("No permission for -mod")); + return; + } + + mod = args.mod; + } + + const config = pluginData.config.get(); + const reason = formatReasonWithAttachments(args.reason, msg.attachments); + + const casesPlugin = pluginData.getPlugin(CasesPlugin); + const priorWarnAmount = await casesPlugin.getCaseTypeAmountForUserId(memberToWarn.id, CaseTypes.Warn); + if (config.warn_notify_enabled && priorWarnAmount >= config.warn_notify_threshold) { + const tooManyWarningsMsg = await msg.channel.createMessage( + config.warn_notify_message.replace("{priorWarnings}", `${priorWarnAmount}`), + ); + + const reply = await waitForReaction(pluginData.client, tooManyWarningsMsg, ["✅", "❌"]); + tooManyWarningsMsg.delete(); + if (!reply || reply.name === "❌") { + msg.channel.createMessage(errorMessage("Warn cancelled by moderator")); + return; + } + } + + let contactMethods; + try { + contactMethods = readContactMethodsFromArgs(args); + } catch (e) { + sendErrorMessage(pluginData, msg.channel, e.message); + return; + } + + const warnResult = await warnMember(pluginData, memberToWarn, reason, { + contactMethods, + caseArgs: { + modId: mod.id, + ppId: mod.id !== msg.author.id ? msg.author.id : null, + reason, + }, + retryPromptChannel: msg.channel as TextChannel, + }); + + if (warnResult.status === "failed") { + sendErrorMessage(pluginData, msg.channel, "Failed to warn user"); + return; + } + + const messageResultText = warnResult.notifyResult.text ? ` (${warnResult.notifyResult.text})` : ""; + + sendSuccessMessage( + pluginData, + msg.channel, + `Warned **${memberToWarn.user.username}#${memberToWarn.user.discriminator}** (Case #${warnResult.case.case_number})${messageResultText}`, + ); + }, +}); diff --git a/backend/src/plugins/ModActions/events/CreateBanCaseOnManualBanEvt.ts b/backend/src/plugins/ModActions/events/CreateBanCaseOnManualBanEvt.ts new file mode 100644 index 00000000..a41aa27f --- /dev/null +++ b/backend/src/plugins/ModActions/events/CreateBanCaseOnManualBanEvt.ts @@ -0,0 +1,49 @@ +import { eventListener } from "knub"; +import { IgnoredEventType, ModActionsPluginType } from "../types"; +import { isEventIgnored } from "../functions/isEventIgnored"; +import { clearIgnoredEvent } from "../functions/clearIgnoredEvents"; +import { Constants as ErisConstants } from "eris"; +import { safeFindRelevantAuditLogEntry } from "../functions/safeFindRelevantAuditLogEntry"; +import { CasesPlugin } from "../../Cases/CasesPlugin"; +import { CaseTypes } from "../../../data/CaseTypes"; + +/** + * Create a BAN case automatically when a user is banned manually. + * Attempts to find the ban's details in the audit log. + */ +export const CreateBanCaseOnManualBanEvt = eventListener()( + "guildBanAdd", + async ({ pluginData, args: { guild, user } }) => { + if (isEventIgnored(pluginData, IgnoredEventType.Ban, user.id)) { + clearIgnoredEvent(pluginData, IgnoredEventType.Ban, user.id); + return; + } + + const relevantAuditLogEntry = await safeFindRelevantAuditLogEntry( + pluginData, + ErisConstants.AuditLogActions.MEMBER_BAN_ADD, + user.id, + ); + + const casesPlugin = pluginData.getPlugin(CasesPlugin); + if (relevantAuditLogEntry) { + const modId = relevantAuditLogEntry.user.id; + const auditLogId = relevantAuditLogEntry.id; + + casesPlugin.createCase({ + userId: user.id, + modId, + type: CaseTypes.Ban, + auditLogId, + reason: relevantAuditLogEntry.reason, + automatic: true, + }); + } else { + casesPlugin.createCase({ + userId: user.id, + modId: null, + type: CaseTypes.Ban, + }); + } + }, +); diff --git a/backend/src/plugins/ModActions/events/CreateKickCaseOnManualKickEvt.ts b/backend/src/plugins/ModActions/events/CreateKickCaseOnManualKickEvt.ts new file mode 100644 index 00000000..82618174 --- /dev/null +++ b/backend/src/plugins/ModActions/events/CreateKickCaseOnManualKickEvt.ts @@ -0,0 +1,55 @@ +import { eventListener } from "knub"; +import { IgnoredEventType, ModActionsPluginType } from "../types"; +import { isEventIgnored } from "../functions/isEventIgnored"; +import { clearIgnoredEvent } from "../functions/clearIgnoredEvents"; +import { Constants as ErisConstants } from "eris"; +import { safeFindRelevantAuditLogEntry } from "../functions/safeFindRelevantAuditLogEntry"; +import { CasesPlugin } from "../../Cases/CasesPlugin"; +import { CaseTypes } from "../../../data/CaseTypes"; +import { logger } from "../../../logger"; +import { LogType } from "../../../data/LogType"; +import { stripObjectToScalars } from "../../../utils"; + +/** + * Create a KICK case automatically when a user is kicked manually. + * Attempts to find the kick's details in the audit log. + */ +export const CreateKickCaseOnManualKickEvt = eventListener()( + "guildMemberRemove", + async ({ pluginData, args: { member } }) => { + if (isEventIgnored(pluginData, IgnoredEventType.Kick, member.id)) { + clearIgnoredEvent(pluginData, IgnoredEventType.Kick, member.id); + return; + } + + const kickAuditLogEntry = await safeFindRelevantAuditLogEntry( + pluginData, + ErisConstants.AuditLogActions.MEMBER_KICK, + member.id, + ); + + if (kickAuditLogEntry) { + const existingCaseForThisEntry = await pluginData.state.cases.findByAuditLogId(kickAuditLogEntry.id); + if (existingCaseForThisEntry) { + logger.warn( + `Tried to create duplicate case for audit log entry ${kickAuditLogEntry.id}, existing case id ${existingCaseForThisEntry.id}`, + ); + } else { + const casesPlugin = pluginData.getPlugin(CasesPlugin); + casesPlugin.createCase({ + userId: member.id, + modId: kickAuditLogEntry.user.id, + type: CaseTypes.Kick, + auditLogId: kickAuditLogEntry.id, + reason: kickAuditLogEntry.reason, + automatic: true, + }); + } + + pluginData.state.serverLogs.log(LogType.MEMBER_KICK, { + user: stripObjectToScalars(member.user), + mod: stripObjectToScalars(kickAuditLogEntry.user), + }); + } + }, +); diff --git a/backend/src/plugins/ModActions/events/CreateUnbanCaseOnManualUnbanEvt.ts b/backend/src/plugins/ModActions/events/CreateUnbanCaseOnManualUnbanEvt.ts new file mode 100644 index 00000000..2a1ac3aa --- /dev/null +++ b/backend/src/plugins/ModActions/events/CreateUnbanCaseOnManualUnbanEvt.ts @@ -0,0 +1,49 @@ +import { eventListener } from "knub"; +import { IgnoredEventType, ModActionsPluginType } from "../types"; +import { isEventIgnored } from "../functions/isEventIgnored"; +import { clearIgnoredEvent } from "../functions/clearIgnoredEvents"; +import { Constants as ErisConstants } from "eris"; +import { safeFindRelevantAuditLogEntry } from "../functions/safeFindRelevantAuditLogEntry"; +import { CasesPlugin } from "../../Cases/CasesPlugin"; +import { CaseTypes } from "../../../data/CaseTypes"; + +/** + * Create an UNBAN case automatically when a user is unbanned manually. + * Attempts to find the unban's details in the audit log. + */ +export const CreateUnbanCaseOnManualUnbanEvt = eventListener()( + "guildBanRemove", + async ({ pluginData, args: { guild, user } }) => { + if (isEventIgnored(pluginData, IgnoredEventType.Unban, user.id)) { + clearIgnoredEvent(pluginData, IgnoredEventType.Unban, user.id); + return; + } + + const relevantAuditLogEntry = await safeFindRelevantAuditLogEntry( + pluginData, + ErisConstants.AuditLogActions.MEMBER_BAN_REMOVE, + user.id, + ); + + const casesPlugin = pluginData.getPlugin(CasesPlugin); + if (relevantAuditLogEntry) { + const modId = relevantAuditLogEntry.user.id; + const auditLogId = relevantAuditLogEntry.id; + + casesPlugin.createCase({ + userId: user.id, + modId, + type: CaseTypes.Unban, + auditLogId, + automatic: true, + }); + } else { + casesPlugin.createCase({ + userId: user.id, + modId: null, + type: CaseTypes.Unban, + automatic: true, + }); + } + }, +); diff --git a/backend/src/plugins/ModActions/functions/actualMuteUserCmd.ts b/backend/src/plugins/ModActions/functions/actualMuteUserCmd.ts new file mode 100644 index 00000000..c56786ed --- /dev/null +++ b/backend/src/plugins/ModActions/functions/actualMuteUserCmd.ts @@ -0,0 +1,107 @@ +import { Member, Message, TextChannel, User } from "eris"; +import { asSingleLine, isDiscordRESTError, UnknownUser } from "../../../utils"; +import { hasPermission, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; +import { PluginData } from "knub"; +import { ModActionsPluginType } from "../types"; +import humanizeDuration from "humanize-duration"; +import { formatReasonWithAttachments } from "./formatReasonWithAttachments"; +import { MuteResult } from "../../Mutes/types"; +import { MutesPlugin } from "../../Mutes/MutesPlugin"; +import { readContactMethodsFromArgs } from "./readContactMethodsFromArgs"; +import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError"; +import { logger } from "../../../logger"; + +/** + * The actual function run by both !mute and !forcemute. + * The only difference between the two commands is in target member validation. + */ +export async function actualMuteUserCmd( + pluginData: PluginData, + user: User | UnknownUser, + msg: Message, + args: { time?: number; reason?: string; mod: Member; notify?: string; "notify-channel"?: TextChannel }, +) { + // The moderator who did the action is the message author or, if used, the specified -mod + let mod = msg.member; + let pp = null; + + if (args.mod) { + if (!hasPermission(pluginData, "can_act_as_other", { message: msg })) { + sendErrorMessage(pluginData, msg.channel, "No permission for -mod"); + return; + } + + mod = args.mod; + pp = msg.author; + } + + const timeUntilUnmute = args.time && humanizeDuration(args.time); + const reason = formatReasonWithAttachments(args.reason, msg.attachments); + + let muteResult: MuteResult; + const mutesPlugin = pluginData.getPlugin(MutesPlugin); + + let contactMethods; + try { + contactMethods = readContactMethodsFromArgs(args); + } catch (e) { + sendErrorMessage(pluginData, msg.channel, e.message); + return; + } + + try { + muteResult = await mutesPlugin.muteUser(user.id, args.time, reason, { + contactMethods, + caseArgs: { + modId: mod.id, + ppId: pp && pp.id, + }, + }); + } catch (e) { + if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) { + sendErrorMessage(pluginData, msg.channel, "Could not mute the user: no mute role set in config"); + } else if (isDiscordRESTError(e) && e.code === 10007) { + sendErrorMessage(pluginData, msg.channel, "Could not mute the user: unknown member"); + } else { + logger.error(`Failed to mute user ${user.id}: ${e.stack}`); + if (user.id == null) { + // tslint-disable-next-line:no-console + console.trace("[DEBUG] Null user.id for mute"); + } + sendErrorMessage(pluginData, msg.channel, "Could not mute the user"); + } + + return; + } + + // Confirm the action to the moderator + let response; + if (args.time) { + if (muteResult.updatedExistingMute) { + response = asSingleLine(` + Updated **${user.username}#${user.discriminator}**'s + mute to ${timeUntilUnmute} (Case #${muteResult.case.case_number}) + `); + } else { + response = asSingleLine(` + Muted **${user.username}#${user.discriminator}** + for ${timeUntilUnmute} (Case #${muteResult.case.case_number}) + `); + } + } else { + if (muteResult.updatedExistingMute) { + response = asSingleLine(` + Updated **${user.username}#${user.discriminator}**'s + mute to indefinite (Case #${muteResult.case.case_number}) + `); + } else { + response = asSingleLine(` + Muted **${user.username}#${user.discriminator}** + indefinitely (Case #${muteResult.case.case_number}) + `); + } + } + + if (muteResult.notifyResult.text) response += ` (${muteResult.notifyResult.text})`; + sendSuccessMessage(pluginData, msg.channel, response); +} diff --git a/backend/src/plugins/ModActions/functions/banUserId.ts b/backend/src/plugins/ModActions/functions/banUserId.ts new file mode 100644 index 00000000..7c8a4f94 --- /dev/null +++ b/backend/src/plugins/ModActions/functions/banUserId.ts @@ -0,0 +1,75 @@ +import { PluginData } from "knub"; +import { BanOptions, BanResult, IgnoredEventType, ModActionsPluginType } from "../types"; +import { notifyUser, resolveUser, stripObjectToScalars, ucfirst, UserNotificationResult } from "../../../utils"; +import { User } from "eris"; +import { renderTemplate } from "../../../templateFormatter"; +import { getDefaultContactMethods } from "./getDefaultContactMethods"; +import { LogType } from "../../../data/LogType"; +import { ignoreEvent } from "./ignoreEvent"; +import { CasesPlugin } from "../../Cases/CasesPlugin"; +import { CaseTypes } from "../../../data/CaseTypes"; + +/** + * Ban the specified user id, whether or not they're actually on the server at the time. Generates a case. + */ +export async function banUserId( + pluginData: PluginData, + userId: string, + reason: string = null, + banOptions: BanOptions = {}, +): Promise { + const config = pluginData.config.get(); + const user = await resolveUser(pluginData.client, userId); + + // Attempt to message the user *before* banning them, as doing it after may not be possible + let notifyResult: UserNotificationResult = { method: null, success: true }; + if (reason && user instanceof User) { + const banMessage = await renderTemplate(config.ban_message, { + guildName: pluginData.guild.name, + reason, + }); + + const contactMethods = banOptions?.contactMethods + ? banOptions.contactMethods + : getDefaultContactMethods(pluginData, "ban"); + notifyResult = await notifyUser(user, banMessage, contactMethods); + } + + // (Try to) ban the user + pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_BAN, userId); + ignoreEvent(pluginData, IgnoredEventType.Ban, userId); + try { + const deleteMessageDays = Math.min(30, Math.max(0, banOptions.deleteMessageDays ?? 1)); + await pluginData.guild.banMember(userId, deleteMessageDays); + } catch (e) { + return { + status: "failed", + error: e.message, + }; + } + + // Create a case for this action + const casesPlugin = pluginData.getPlugin(CasesPlugin); + const createdCase = await casesPlugin.createCase({ + ...(banOptions.caseArgs || {}), + userId, + modId: banOptions.caseArgs?.modId, + type: CaseTypes.Ban, + reason, + noteDetails: notifyResult.text ? [ucfirst(notifyResult.text)] : [], + }); + + // Log the action + const mod = await resolveUser(pluginData.client, banOptions.caseArgs?.modId); + pluginData.state.serverLogs.log(LogType.MEMBER_BAN, { + mod: stripObjectToScalars(mod), + user: stripObjectToScalars(user), + reason, + }); + + return { + status: "success", + case: createdCase, + notifyResult, + }; +} diff --git a/backend/src/plugins/ModActions/functions/clearIgnoredEvents.ts b/backend/src/plugins/ModActions/functions/clearIgnoredEvents.ts new file mode 100644 index 00000000..612ae58a --- /dev/null +++ b/backend/src/plugins/ModActions/functions/clearIgnoredEvents.ts @@ -0,0 +1,13 @@ +import { PluginData } from "knub"; +import { IgnoredEventType, ModActionsPluginType } from "../types"; + +export function clearIgnoredEvent( + pluginData: PluginData, + type: IgnoredEventType, + userId: string, +) { + pluginData.state.ignoredEvents.splice( + pluginData.state.ignoredEvents.findIndex(info => type === info.type && userId === info.userId), + 1, + ); +} diff --git a/backend/src/plugins/ModActions/functions/formatReasonWithAttachments.ts b/backend/src/plugins/ModActions/functions/formatReasonWithAttachments.ts new file mode 100644 index 00000000..d8a6b4e4 --- /dev/null +++ b/backend/src/plugins/ModActions/functions/formatReasonWithAttachments.ts @@ -0,0 +1,6 @@ +import { Attachment } from "eris"; + +export function formatReasonWithAttachments(reason: string, attachments: Attachment[]) { + const attachmentUrls = attachments.map(a => a.url); + return ((reason || "") + " " + attachmentUrls.join(" ")).trim(); +} diff --git a/backend/src/plugins/ModActions/functions/getDefaultContactMethods.ts b/backend/src/plugins/ModActions/functions/getDefaultContactMethods.ts new file mode 100644 index 00000000..0343e55b --- /dev/null +++ b/backend/src/plugins/ModActions/functions/getDefaultContactMethods.ts @@ -0,0 +1,28 @@ +import { PluginData } from "knub"; +import { ModActionsPluginType } from "../types"; +import { UserNotificationMethod } from "../../../utils"; +import { TextChannel } from "eris"; + +export function getDefaultContactMethods( + pluginData: PluginData, + type: "warn" | "kick" | "ban", +): UserNotificationMethod[] { + const methods: UserNotificationMethod[] = []; + const config = pluginData.config.get(); + + if (config[`dm_on_${type}`]) { + methods.push({ type: "dm" }); + } + + if (config[`message_on_${type}`] && config.message_channel) { + const channel = pluginData.guild.channels.get(config.message_channel); + if (channel instanceof TextChannel) { + methods.push({ + type: "channel", + channel, + }); + } + } + + return methods; +} diff --git a/backend/src/plugins/ModActions/functions/ignoreEvent.ts b/backend/src/plugins/ModActions/functions/ignoreEvent.ts new file mode 100644 index 00000000..1617e390 --- /dev/null +++ b/backend/src/plugins/ModActions/functions/ignoreEvent.ts @@ -0,0 +1,20 @@ +import { PluginData } from "knub"; +import { IgnoredEventType, ModActionsPluginType } from "../types"; +import { SECONDS } from "../../../utils"; +import { clearIgnoredEvent } from "./clearIgnoredEvents"; + +const DEFAULT_TIMEOUT = 15 * SECONDS; + +export function ignoreEvent( + pluginData: PluginData, + type: IgnoredEventType, + userId: string, + timeout = DEFAULT_TIMEOUT, +) { + pluginData.state.ignoredEvents.push({ type, userId }); + + // Clear after expiry (15sec by default) + setTimeout(() => { + clearIgnoredEvent(pluginData, type, userId); + }, timeout); +} diff --git a/backend/src/plugins/ModActions/functions/isBanned.ts b/backend/src/plugins/ModActions/functions/isBanned.ts new file mode 100644 index 00000000..4aed74b3 --- /dev/null +++ b/backend/src/plugins/ModActions/functions/isBanned.ts @@ -0,0 +1,16 @@ +import { PluginData } from "knub"; +import { ModActionsPluginType } from "../types"; +import { isDiscordHTTPError } from "../../../utils"; + +export async function isBanned(pluginData: PluginData, userId: string): Promise { + try { + const bans = await pluginData.guild.getBans(); + return bans.some(b => b.user.id === userId); + } catch (e) { + if (isDiscordHTTPError(e) && e.code === 500) { + return false; + } + + throw e; + } +} diff --git a/backend/src/plugins/ModActions/functions/isEventIgnored.ts b/backend/src/plugins/ModActions/functions/isEventIgnored.ts new file mode 100644 index 00000000..0a28a8b2 --- /dev/null +++ b/backend/src/plugins/ModActions/functions/isEventIgnored.ts @@ -0,0 +1,6 @@ +import { PluginData } from "knub"; +import { IgnoredEventType, ModActionsPluginType } from "../types"; + +export function isEventIgnored(pluginData: PluginData, type: IgnoredEventType, userId: string) { + return pluginData.state.ignoredEvents.some(info => type === info.type && userId === info.userId); +} diff --git a/backend/src/plugins/ModActions/functions/kickMember.ts b/backend/src/plugins/ModActions/functions/kickMember.ts new file mode 100644 index 00000000..f11145c7 --- /dev/null +++ b/backend/src/plugins/ModActions/functions/kickMember.ts @@ -0,0 +1,73 @@ +import { PluginData } from "knub"; +import { IgnoredEventType, KickOptions, KickResult, ModActionsPluginType } from "../types"; +import { Member } from "eris"; +import { notifyUser, resolveUser, stripObjectToScalars, ucfirst, UserNotificationResult } from "../../../utils"; +import { renderTemplate } from "../../../templateFormatter"; +import { getDefaultContactMethods } from "./getDefaultContactMethods"; +import { LogType } from "../../../data/LogType"; +import { ignoreEvent } from "./ignoreEvent"; +import { CaseTypes } from "../../../data/CaseTypes"; +import { CasesPlugin } from "../../Cases/CasesPlugin"; + +/** + * Kick the specified server member. Generates a case. + */ +export async function kickMember( + pluginData: PluginData, + member: Member, + reason: string = null, + kickOptions: KickOptions = {}, +): Promise { + const config = pluginData.config.get(); + + // Attempt to message the user *before* kicking them, as doing it after may not be possible + let notifyResult: UserNotificationResult = { method: null, success: true }; + if (reason) { + const kickMessage = await renderTemplate(config.kick_message, { + guildName: pluginData.guild.name, + reason, + }); + + const contactMethods = kickOptions?.contactMethods + ? kickOptions.contactMethods + : getDefaultContactMethods(pluginData, "kick"); + notifyResult = await notifyUser(member.user, kickMessage, contactMethods); + } + + // Kick the user + pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_KICK, member.id); + ignoreEvent(pluginData, IgnoredEventType.Kick, member.id); + try { + await member.kick(); + } catch (e) { + return { + status: "failed", + error: e.message, + }; + } + + // Create a case for this action + const casesPlugin = pluginData.getPlugin(CasesPlugin); + const createdCase = await casesPlugin.createCase({ + ...(kickOptions.caseArgs || {}), + userId: member.id, + modId: kickOptions.caseArgs?.modId, + type: CaseTypes.Kick, + reason, + noteDetails: notifyResult.text ? [ucfirst(notifyResult.text)] : [], + }); + + // Log the action + const mod = await resolveUser(pluginData.client, kickOptions.caseArgs?.modId); + pluginData.state.serverLogs.log(LogType.MEMBER_KICK, { + mod: stripObjectToScalars(mod), + user: stripObjectToScalars(member.user), + reason, + }); + + return { + status: "success", + case: createdCase, + notifyResult, + }; +} diff --git a/backend/src/plugins/ModActions/functions/readContactMethodsFromArgs.ts b/backend/src/plugins/ModActions/functions/readContactMethodsFromArgs.ts new file mode 100644 index 00000000..c5bc7009 --- /dev/null +++ b/backend/src/plugins/ModActions/functions/readContactMethodsFromArgs.ts @@ -0,0 +1,25 @@ +import { TextChannel } from "eris"; +import { disableUserNotificationStrings, UserNotificationMethod } from "../../../utils"; + +export function 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; +} diff --git a/backend/src/plugins/ModActions/functions/safeFindRelevantAuditLogEntry.ts b/backend/src/plugins/ModActions/functions/safeFindRelevantAuditLogEntry.ts new file mode 100644 index 00000000..0a497c1f --- /dev/null +++ b/backend/src/plugins/ModActions/functions/safeFindRelevantAuditLogEntry.ts @@ -0,0 +1,27 @@ +import { findRelevantAuditLogEntry, isDiscordRESTError } from "../../../utils"; +import { PluginData } from "knub"; +import { ModActionsPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; + +/** + * Wrapper for findRelevantAuditLogEntry() that handles permission errors gracefully + */ +export async function safeFindRelevantAuditLogEntry( + pluginData: PluginData, + actionType: number, + userId: string, + attempts?: number, + attemptDelay?: number, +) { + try { + return await findRelevantAuditLogEntry(pluginData.guild, actionType, userId, attempts, attemptDelay); + } catch (e) { + if (isDiscordRESTError(e) && e.code === 50013) { + pluginData.state.serverLogs.log(LogType.BOT_ALERT, { + body: "Missing permissions to read audit log", + }); + } else { + throw e; + } + } +} diff --git a/backend/src/plugins/ModActions/functions/warnMember.ts b/backend/src/plugins/ModActions/functions/warnMember.ts new file mode 100644 index 00000000..f958bd44 --- /dev/null +++ b/backend/src/plugins/ModActions/functions/warnMember.ts @@ -0,0 +1,68 @@ +import { PluginData } from "knub"; +import { ModActionsPluginType, WarnOptions, WarnResult } from "../types"; +import { Member } from "eris"; +import { getDefaultContactMethods } from "./getDefaultContactMethods"; +import { notifyUser, resolveUser, stripObjectToScalars, ucfirst } from "../../../utils"; +import { waitForReaction } from "knub/dist/helpers"; +import { CasesPlugin } from "../../Cases/CasesPlugin"; +import { CaseTypes } from "../../../data/CaseTypes"; +import { LogType } from "../../../data/LogType"; + +export async function warnMember( + pluginData: PluginData, + member: Member, + reason: string, + warnOptions: WarnOptions = {}, +): Promise { + const config = pluginData.config.get(); + + const warnMessage = config.warn_message.replace("{guildName}", pluginData.guild.name).replace("{reason}", reason); + const contactMethods = warnOptions?.contactMethods + ? warnOptions.contactMethods + : getDefaultContactMethods(pluginData, "warn"); + const notifyResult = await notifyUser(member.user, warnMessage, contactMethods); + + if (!notifyResult.success) { + if (warnOptions.retryPromptChannel && pluginData.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(pluginData.client, failedMsg, ["✅", "❌"]); + failedMsg.delete(); + if (!reply || reply.name === "❌") { + return { + status: "failed", + error: "Failed to message user", + }; + } + } else { + return { + status: "failed", + error: "Failed to message user", + }; + } + } + + const casesPlugin = pluginData.getPlugin(CasesPlugin); + const createdCase = await casesPlugin.createCase({ + ...(warnOptions.caseArgs || {}), + userId: member.id, + modId: warnOptions.caseArgs?.modId, + type: CaseTypes.Warn, + reason, + noteDetails: notifyResult.text ? [ucfirst(notifyResult.text)] : [], + }); + + const mod = await resolveUser(pluginData.client, warnOptions.caseArgs?.modId); + pluginData.state.serverLogs.log(LogType.MEMBER_WARN, { + mod: stripObjectToScalars(mod), + member: stripObjectToScalars(member, ["user", "roles"]), + reason, + }); + + return { + status: "success", + case: createdCase, + notifyResult, + }; +} diff --git a/backend/src/plugins/ModActions/types.ts b/backend/src/plugins/ModActions/types.ts new file mode 100644 index 00000000..98525b8b --- /dev/null +++ b/backend/src/plugins/ModActions/types.ts @@ -0,0 +1,116 @@ +import * as t from "io-ts"; +import { tNullable, UserNotificationMethod, UserNotificationResult } from "../../utils"; +import { BasePluginType, command } from "knub"; +import { GuildMutes } from "../../data/GuildMutes"; +import { GuildCases } from "../../data/GuildCases"; +import { GuildLogs } from "../../data/GuildLogs"; +import { GuildArchives } from "../../data/GuildArchives"; +import { Case } from "../../data/entities/Case"; +import { CaseArgs } from "../Cases/types"; +import { TextChannel } from "eris"; + +export const ConfigSchema = t.type({ + dm_on_warn: t.boolean, + dm_on_kick: t.boolean, + dm_on_ban: t.boolean, + message_on_warn: t.boolean, + message_on_kick: t.boolean, + message_on_ban: t.boolean, + message_channel: tNullable(t.string), + warn_message: tNullable(t.string), + kick_message: tNullable(t.string), + ban_message: tNullable(t.string), + alert_on_rejoin: t.boolean, + alert_channel: tNullable(t.string), + warn_notify_enabled: t.boolean, + warn_notify_threshold: t.number, + warn_notify_message: t.string, + ban_delete_message_days: t.number, + can_note: t.boolean, + can_warn: t.boolean, + can_mute: t.boolean, + can_kick: t.boolean, + can_ban: t.boolean, + can_view: t.boolean, + can_addcase: t.boolean, + can_massban: t.boolean, + can_hidecase: t.boolean, + can_act_as_other: t.boolean, +}); +export type TConfigSchema = t.TypeOf; + +export interface ModActionsPluginType extends BasePluginType { + config: TConfigSchema; + state: { + mutes: GuildMutes; + cases: GuildCases; + serverLogs: GuildLogs; + + ignoredEvents: IIgnoredEvent[]; + }; +} + +export enum IgnoredEventType { + Ban = 1, + Unban, + Kick, +} + +export interface IIgnoredEvent { + type: IgnoredEventType; + userId: string; +} + +export type WarnResult = + | { + status: "failed"; + error: string; + } + | { + status: "success"; + case: Case; + notifyResult: UserNotificationResult; + }; + +export type KickResult = + | { + status: "failed"; + error: string; + } + | { + status: "success"; + case: Case; + notifyResult: UserNotificationResult; + }; + +export type BanResult = + | { + status: "failed"; + error: string; + } + | { + status: "success"; + case: Case; + notifyResult: UserNotificationResult; + }; + +export type WarnMemberNotifyRetryCallback = () => boolean | Promise; + +export interface WarnOptions { + caseArgs?: Partial; + contactMethods?: UserNotificationMethod[]; + retryPromptChannel?: TextChannel; +} + +export interface KickOptions { + caseArgs?: Partial; + contactMethods?: UserNotificationMethod[]; +} + +export interface BanOptions { + caseArgs?: Partial; + contactMethods?: UserNotificationMethod[]; + deleteMessageDays?: number; +} + +export const modActionsCommand = command(); diff --git a/backend/src/plugins/Mutes/MutesPlugin.ts b/backend/src/plugins/Mutes/MutesPlugin.ts index ded80831..1d1a6d9e 100644 --- a/backend/src/plugins/Mutes/MutesPlugin.ts +++ b/backend/src/plugins/Mutes/MutesPlugin.ts @@ -1,5 +1,5 @@ import { zeppelinPlugin } from "../ZeppelinPluginBlueprint"; -import { ConfigSchema, MutesPluginType } from "./types"; +import { ConfigSchema, MuteOptions, MutesPluginType } from "./types"; import { CasesPlugin } from "../Cases/CasesPlugin"; import { GuildMutes } from "../../data/GuildMutes"; import { GuildCases } from "../../data/GuildCases"; @@ -11,6 +11,7 @@ import { ClearBannedMutesCmd } from "./commands/ClearBannedMutesCmd"; import { ClearActiveMuteOnRoleRemovalEvt } from "./events/ClearActiveMuteOnRoleRemovalEvt"; import { ClearMutesWithoutRoleCmd } from "./commands/ClearMutesWithoutRoleCmd"; import { ClearMutesCmd } from "./commands/ClearMutesCmd"; +import { muteUser } from "./functions/muteUser"; const defaultOptions = { config: { @@ -55,9 +56,26 @@ export const MutesPlugin = zeppelinPlugin()("mutes", { dependencies: [CasesPlugin], - commands: [MutesCmd, ClearBannedMutesCmd, ClearMutesWithoutRoleCmd, ClearMutesCmd], + // prettier-ignore + commands: [ + MutesCmd, + ClearBannedMutesCmd, + ClearMutesWithoutRoleCmd, + ClearMutesCmd, + ], - events: [ClearActiveMuteOnRoleRemovalEvt], + // prettier-ignore + events: [ + ClearActiveMuteOnRoleRemovalEvt, + ], + + public: { + muteUser(pluginData) { + return (userId: string, muteTime: number = null, reason: string = null, muteOptions: MuteOptions = {}) => { + return muteUser(pluginData, userId, muteTime, reason, muteOptions); + }; + }, + }, onLoad(pluginData) { pluginData.state.mutes = GuildMutes.getGuildInstance(pluginData.guild.id); diff --git a/backend/src/plugins/availablePlugins.ts b/backend/src/plugins/availablePlugins.ts index 06042b89..09a8a73e 100644 --- a/backend/src/plugins/availablePlugins.ts +++ b/backend/src/plugins/availablePlugins.ts @@ -14,10 +14,12 @@ import { CasesPlugin } from "./Cases/CasesPlugin"; import { MutesPlugin } from "./Mutes/MutesPlugin"; import { TagsPlugin } from "./Tags/TagsPlugin"; import { StarboardPlugin } from "./Starboard/StarboardPlugin"; +import { ChannelArchiverPlugin } from "./ChannelArchiver/ChannelArchiverPlugin"; // prettier-ignore export const guildPlugins: Array> = [ AutoReactionsPlugin, + ChannelArchiverPlugin, LocateUserPlugin, PersistPlugin, PingableRolesPlugin,