From 479cb56928c6c09453740ef5ee46f6ed877718f0 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Wed, 22 Jul 2020 22:08:49 +0300 Subject: [PATCH] Port Cases plugin --- backend/src/plugins/Cases/CasesPlugin.ts | 48 ++++++++++++ .../src/plugins/Cases/functions/createCase.ts | 73 +++++++++++++++++++ .../plugins/Cases/functions/createCaseNote.ts | 48 ++++++++++++ .../plugins/Cases/functions/getCaseEmbed.ts | 67 +++++++++++++++++ .../Cases/functions/postToCaseLogChannel.ts | 59 +++++++++++++++ .../plugins/Cases/functions/resolveCaseId.ts | 5 ++ backend/src/plugins/Cases/types.ts | 49 +++++++++++++ 7 files changed, 349 insertions(+) create mode 100644 backend/src/plugins/Cases/CasesPlugin.ts create mode 100644 backend/src/plugins/Cases/functions/createCase.ts create mode 100644 backend/src/plugins/Cases/functions/createCaseNote.ts create mode 100644 backend/src/plugins/Cases/functions/getCaseEmbed.ts create mode 100644 backend/src/plugins/Cases/functions/postToCaseLogChannel.ts create mode 100644 backend/src/plugins/Cases/functions/resolveCaseId.ts create mode 100644 backend/src/plugins/Cases/types.ts diff --git a/backend/src/plugins/Cases/CasesPlugin.ts b/backend/src/plugins/Cases/CasesPlugin.ts new file mode 100644 index 00000000..5ee1eb28 --- /dev/null +++ b/backend/src/plugins/Cases/CasesPlugin.ts @@ -0,0 +1,48 @@ +import { zeppelinPlugin } from "../ZeppelinPluginBlueprint"; +import { CaseArgs, CaseNoteArgs, CasesPluginType, ConfigSchema } from "./types"; +import { resolveUser } from "../../utils"; +import { createCase } from "./functions/createCase"; +import { GuildLogs } from "../../data/GuildLogs"; +import { GuildArchives } from "../../data/GuildArchives"; +import { GuildCases } from "../../data/GuildCases"; +import { createCaseNote } from "./functions/createCaseNote"; +import { Case } from "../../data/entities/Case"; +import { postCaseToCaseLogChannel } from "./functions/postToCaseLogChannel"; + +const defaultOptions = { + config: { + log_automatic_actions: true, + case_log_channel: null, + }, +}; + +export const CasesPlugin = zeppelinPlugin()("cases", { + configSchema: ConfigSchema, + defaultOptions, + + public: { + createCase(pluginData) { + return async (args: CaseArgs) => { + return createCase(pluginData, args); + }; + }, + + createCaseNote(pluginData) { + return async (args: CaseNoteArgs) => { + return createCaseNote(pluginData, args); + }; + }, + + postCaseToCaseLogChannel(pluginData) { + return async (caseOrCaseId: Case | number) => { + return postCaseToCaseLogChannel(pluginData, caseOrCaseId); + }; + }, + }, + + onLoad(pluginData) { + pluginData.state.logs = new GuildLogs(pluginData.guild.id); + pluginData.state.archives = GuildArchives.getGuildInstance(pluginData.guild.id); + pluginData.state.cases = GuildCases.getGuildInstance(pluginData.guild.id); + }, +}); diff --git a/backend/src/plugins/Cases/functions/createCase.ts b/backend/src/plugins/Cases/functions/createCase.ts new file mode 100644 index 00000000..c6610b46 --- /dev/null +++ b/backend/src/plugins/Cases/functions/createCase.ts @@ -0,0 +1,73 @@ +import { CaseArgs, CasesPluginType } from "../types"; +import { resolveUser } from "../../../utils"; +import { PluginData } from "knub"; +import { createCaseNote } from "./createCaseNote"; +import { postToCaseLogChannel } from "./postToCaseLogChannel"; + +export async function createCase(pluginData: PluginData, args: CaseArgs) { + const user = await resolveUser(pluginData.client, args.userId); + const userName = `${user.username}#${user.discriminator}`; + + const mod = await resolveUser(pluginData.client, args.modId); + const modName = `${mod.username}#${mod.discriminator}`; + + let ppName = null; + if (args.ppId) { + const pp = await resolveUser(pluginData.client, args.ppId); + ppName = `${pp.username}#${pp.discriminator}`; + } + + if (args.auditLogId) { + const existingAuditLogCase = await pluginData.state.cases.findByAuditLogId(args.auditLogId); + if (existingAuditLogCase) { + delete args.auditLogId; + console.warn(`Duplicate audit log ID for mod case: ${args.auditLogId}`); + } + } + + const createdCase = await pluginData.state.cases.create({ + type: args.type, + user_id: args.userId, + user_name: userName, + mod_id: args.modId, + mod_name: modName, + audit_log_id: args.auditLogId, + pp_id: args.ppId, + pp_name: ppName, + }); + + if (args.reason || (args.noteDetails && args.noteDetails.length)) { + await createCaseNote(pluginData, { + caseId: createdCase.id, + modId: args.modId, + body: args.reason || "", + automatic: args.automatic, + postInCaseLogOverride: false, + noteDetails: args.noteDetails, + }); + } + + if (args.extraNotes) { + for (const extraNote of args.extraNotes) { + await createCaseNote(pluginData, { + caseId: createdCase.id, + modId: args.modId, + body: extraNote, + automatic: args.automatic, + postInCaseLogOverride: false, + }); + } + } + + const config = pluginData.config.get(); + + if ( + config.case_log_channel && + (!args.automatic || config.log_automatic_actions) && + args.postInCaseLogOverride !== false + ) { + await postToCaseLogChannel(pluginData, createdCase); + } + + return createdCase; +} diff --git a/backend/src/plugins/Cases/functions/createCaseNote.ts b/backend/src/plugins/Cases/functions/createCaseNote.ts new file mode 100644 index 00000000..ecea600e --- /dev/null +++ b/backend/src/plugins/Cases/functions/createCaseNote.ts @@ -0,0 +1,48 @@ +import { CaseNoteArgs, CasesPluginType } from "../types"; +import { PluginData } from "knub"; +import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError"; +import { resolveCaseId } from "./resolveCaseId"; +import { postCaseToCaseLogChannel } from "./postToCaseLogChannel"; +import { resolveUser } from "../../../utils"; + +export async function createCaseNote(pluginData: PluginData, args: CaseNoteArgs): Promise { + const theCase = await pluginData.state.cases.find(resolveCaseId(args.caseId)); + if (!theCase) { + throw new RecoverablePluginError(ERRORS.UNKNOWN_NOTE_CASE); + } + + const mod = await resolveUser(pluginData.client, args.modId); + const modName = `${mod.username}#${mod.discriminator}`; + + let body = args.body; + + // Add note details to the beginning of the note + if (args.noteDetails && args.noteDetails.length) { + body = args.noteDetails.map(d => `__[${d}]__`).join(" ") + " " + body; + } + + await pluginData.state.cases.createNote(theCase.id, { + mod_id: mod.id, + mod_name: modName, + body: body || "", + }); + + if (theCase.mod_id == null) { + // If the case has no moderator information, assume the first one to add a note to it did the action + await pluginData.state.cases.update(theCase.id, { + mod_id: mod.id, + mod_name: modName, + }); + } + + const archiveLinkMatch = body && body.match(/(?<=\/archives\/)[a-zA-Z0-9\-]+/g); + if (archiveLinkMatch) { + for (const archiveId of archiveLinkMatch) { + pluginData.state.archives.makePermanent(archiveId); + } + } + + if ((!args.automatic || pluginData.config.get().log_automatic_actions) && args.postInCaseLogOverride !== false) { + await postCaseToCaseLogChannel(pluginData, theCase.id); + } +} diff --git a/backend/src/plugins/Cases/functions/getCaseEmbed.ts b/backend/src/plugins/Cases/functions/getCaseEmbed.ts new file mode 100644 index 00000000..6b5376b7 --- /dev/null +++ b/backend/src/plugins/Cases/functions/getCaseEmbed.ts @@ -0,0 +1,67 @@ +import { Case } from "../../../data/entities/Case"; +import { MessageContent } from "eris"; +import moment from "moment-timezone"; +import { CaseTypes } from "../../../data/CaseTypes"; +import { PluginData } from "knub"; +import { CasesPluginType } from "../types"; +import { CaseTypeColors } from "../../../data/CaseTypeColors"; +import { resolveCaseId } from "./resolveCaseId"; + +export async function getCaseEmbed( + pluginData: PluginData, + caseOrCaseId: Case | number, +): Promise { + const theCase = await pluginData.state.cases.with("notes").find(resolveCaseId(caseOrCaseId)); + if (!theCase) return null; + + const createdAt = moment(theCase.created_at); + const actionTypeStr = CaseTypes[theCase.type].toUpperCase(); + + const embed: any = { + title: `${actionTypeStr} - Case #${theCase.case_number}`, + footer: { + text: `Case created at ${createdAt.format("YYYY-MM-DD [at] HH:mm")}`, + }, + fields: [ + { + name: "User", + value: `${theCase.user_name}\n<@!${theCase.user_id}>`, + inline: true, + }, + { + name: "Moderator", + value: `${theCase.mod_name}\n<@!${theCase.mod_id}>`, + inline: true, + }, + ], + }; + + if (theCase.pp_id) { + embed.fields[1].value += `\np.p. ${theCase.pp_name}\n<@!${theCase.pp_id}>`; + } + + if (theCase.is_hidden) { + embed.title += " (hidden)"; + } + + if (CaseTypeColors[theCase.type]) { + embed.color = CaseTypeColors[theCase.type]; + } + + if (theCase.notes.length) { + theCase.notes.forEach((note: any) => { + const noteDate = moment(note.created_at); + embed.fields.push({ + name: `${note.mod_name} at ${noteDate.format("YYYY-MM-DD [at] HH:mm")}:`, + value: note.body, + }); + }); + } else { + embed.fields.push({ + name: "!!! THIS CASE HAS NO NOTES !!!", + value: "\u200B", + }); + } + + return { embed }; +} diff --git a/backend/src/plugins/Cases/functions/postToCaseLogChannel.ts b/backend/src/plugins/Cases/functions/postToCaseLogChannel.ts new file mode 100644 index 00000000..68ea216e --- /dev/null +++ b/backend/src/plugins/Cases/functions/postToCaseLogChannel.ts @@ -0,0 +1,59 @@ +import { plugin, PluginData } from "knub"; +import { CasesPluginType } from "../types"; +import { Message, MessageContent, MessageFile, TextChannel } from "eris"; +import { isDiscordRESTError } from "../../../utils"; +import { LogType } from "../../../data/LogType"; +import { Case } from "../../../data/entities/Case"; +import { getCaseEmbed } from "./getCaseEmbed"; +import { resolveCaseId } from "./resolveCaseId"; + +export async function postToCaseLogChannel( + pluginData: PluginData, + content: MessageContent, + file: MessageFile = null, +): Promise { + const caseLogChannelId = pluginData.config.get().case_log_channel; + if (!caseLogChannelId) return; + + const caseLogChannel = pluginData.guild.channels.get(caseLogChannelId); + if (!caseLogChannel || !(caseLogChannel instanceof TextChannel)) return; + + let result; + try { + result = await caseLogChannel.createMessage(content, file); + } catch (e) { + if (isDiscordRESTError(e) && (e.code === 50013 || e.code === 50001)) { + console.warn( + `Missing permissions to post mod cases in <#${caseLogChannel.id}> in guild ${pluginData.guild.name} (${pluginData.guild.id})`, + ); + pluginData.state.logs.log(LogType.BOT_ALERT, { + body: `Missing permissions to post mod cases in <#${caseLogChannel.id}>`, + }); + return; + } + + throw e; + } + + return result; +} + +export async function postCaseToCaseLogChannel( + pluginData: PluginData, + caseOrCaseId: Case | number, +): Promise { + const theCase = await pluginData.state.cases.find(resolveCaseId(caseOrCaseId)); + if (!theCase) return; + + const caseEmbed = await getCaseEmbed(pluginData, caseOrCaseId); + if (!caseEmbed) return; + + try { + return postToCaseLogChannel(pluginData, caseEmbed); + } catch (e) { + pluginData.state.logs.log(LogType.BOT_ALERT, { + body: `Failed to post case #${theCase.case_number} to the case log channel`, + }); + return null; + } +} diff --git a/backend/src/plugins/Cases/functions/resolveCaseId.ts b/backend/src/plugins/Cases/functions/resolveCaseId.ts new file mode 100644 index 00000000..8d5ca13d --- /dev/null +++ b/backend/src/plugins/Cases/functions/resolveCaseId.ts @@ -0,0 +1,5 @@ +import { Case } from "../../../data/entities/Case"; + +export function resolveCaseId(caseOrCaseId: Case | number): number { + return caseOrCaseId instanceof Case ? caseOrCaseId.id : caseOrCaseId; +} diff --git a/backend/src/plugins/Cases/types.ts b/backend/src/plugins/Cases/types.ts new file mode 100644 index 00000000..79ce1220 --- /dev/null +++ b/backend/src/plugins/Cases/types.ts @@ -0,0 +1,49 @@ +import * as t from "io-ts"; +import { tNullable } from "../../utils"; +import { CaseTypes } from "../../data/CaseTypes"; +import { BasePluginType } from "knub"; +import { GuildLogs } from "../../data/GuildLogs"; +import { GuildCases } from "../../data/GuildCases"; +import { GuildSavedMessages } from "../../data/GuildSavedMessages"; +import { GuildArchives } from "../../data/GuildArchives"; +import { Supporters } from "../../data/Supporters"; + +export const ConfigSchema = t.type({ + log_automatic_actions: t.boolean, + case_log_channel: tNullable(t.string), +}); +export type TConfigSchema = t.TypeOf; + +export interface CasesPluginType extends BasePluginType { + config: TConfigSchema; + state: { + logs: GuildLogs; + cases: GuildCases; + archives: GuildArchives; + }; +} + +/** + * Can also be used as a config object for functions that create cases + */ +export type CaseArgs = { + userId: string; + modId: string; + ppId?: string; + type: CaseTypes; + auditLogId?: string; + reason?: string; + automatic?: boolean; + postInCaseLogOverride?: boolean; + noteDetails?: string[]; + extraNotes?: string[]; +}; + +export type CaseNoteArgs = { + caseId: number; + modId: string; + body: string; + automatic?: boolean; + postInCaseLogOverride?: boolean; + noteDetails?: string[]; +};