diff --git a/assets/icons/case_ban.png b/assets/icons/case_ban.png new file mode 100644 index 00000000..de7539c3 Binary files /dev/null and b/assets/icons/case_ban.png differ diff --git a/assets/icons/case_deleted.png b/assets/icons/case_deleted.png new file mode 100644 index 00000000..b29ac6b0 Binary files /dev/null and b/assets/icons/case_deleted.png differ diff --git a/assets/icons/case_icons.afphoto b/assets/icons/case_icons.afphoto new file mode 100644 index 00000000..c8713524 Binary files /dev/null and b/assets/icons/case_icons.afphoto differ diff --git a/assets/icons/case_kick.png b/assets/icons/case_kick.png new file mode 100644 index 00000000..ccd6e8c4 Binary files /dev/null and b/assets/icons/case_kick.png differ diff --git a/assets/icons/case_mute.png b/assets/icons/case_mute.png new file mode 100644 index 00000000..bab4decb Binary files /dev/null and b/assets/icons/case_mute.png differ diff --git a/assets/icons/case_note.png b/assets/icons/case_note.png new file mode 100644 index 00000000..77c9ec5f Binary files /dev/null and b/assets/icons/case_note.png differ diff --git a/assets/icons/case_softban.png b/assets/icons/case_softban.png new file mode 100644 index 00000000..c94abf76 Binary files /dev/null and b/assets/icons/case_softban.png differ diff --git a/assets/icons/case_unban.png b/assets/icons/case_unban.png new file mode 100644 index 00000000..b92a4eda Binary files /dev/null and b/assets/icons/case_unban.png differ diff --git a/assets/icons/case_unmute.png b/assets/icons/case_unmute.png new file mode 100644 index 00000000..c46be400 Binary files /dev/null and b/assets/icons/case_unmute.png differ diff --git a/assets/icons/case_warn.png b/assets/icons/case_warn.png new file mode 100644 index 00000000..fd3f4d36 Binary files /dev/null and b/assets/icons/case_warn.png differ diff --git a/backend/src/data/CaseTypes.ts b/backend/src/data/CaseTypes.ts index 6b1cad5b..e403e92f 100644 --- a/backend/src/data/CaseTypes.ts +++ b/backend/src/data/CaseTypes.ts @@ -9,3 +9,20 @@ export enum CaseTypes { Deleted, Softban, } + +export const CaseNameToType = { + ban: CaseTypes.Ban, + unban: CaseTypes.Unban, + note: CaseTypes.Note, + warn: CaseTypes.Warn, + kick: CaseTypes.Kick, + mute: CaseTypes.Mute, + unmute: CaseTypes.Unmute, + deleted: CaseTypes.Deleted, + softban: CaseTypes.Softban, +}; + +export const CaseTypeToName = Object.entries(CaseNameToType).reduce((map, [name, type]) => { + map[type] = name; + return map; +}, {}) as Record; diff --git a/backend/src/plugins/Cases/CasesPlugin.ts b/backend/src/plugins/Cases/CasesPlugin.ts index fef387b2..07fbf862 100644 --- a/backend/src/plugins/Cases/CasesPlugin.ts +++ b/backend/src/plugins/Cases/CasesPlugin.ts @@ -19,6 +19,8 @@ const defaultOptions = { case_log_channel: null, show_relative_times: true, relative_time_cutoff: "7d", + case_colors: null, + case_icons: null, }, }; diff --git a/backend/src/plugins/Cases/caseAbbreviations.ts b/backend/src/plugins/Cases/caseAbbreviations.ts new file mode 100644 index 00000000..91ef6c76 --- /dev/null +++ b/backend/src/plugins/Cases/caseAbbreviations.ts @@ -0,0 +1,13 @@ +import { CaseTypes } from "../../data/CaseTypes"; + +export const caseAbbreviations = { + [CaseTypes.Ban]: "BAN", + [CaseTypes.Unban]: "UNBN", + [CaseTypes.Note]: "NOTE", + [CaseTypes.Warn]: "WARN", + [CaseTypes.Kick]: "KICK", + [CaseTypes.Mute]: "MUTE", + [CaseTypes.Unmute]: "UNMT", + [CaseTypes.Deleted]: "DEL", + [CaseTypes.Softban]: "SFTB", +}; diff --git a/backend/src/data/CaseTypeColors.ts b/backend/src/plugins/Cases/caseColors.ts similarity index 64% rename from backend/src/data/CaseTypeColors.ts rename to backend/src/plugins/Cases/caseColors.ts index 707c7f49..3284010f 100644 --- a/backend/src/data/CaseTypeColors.ts +++ b/backend/src/plugins/Cases/caseColors.ts @@ -1,12 +1,13 @@ -import { CaseTypes } from "./CaseTypes"; +import { CaseTypes } from "../../data/CaseTypes"; -export const CaseTypeColors = { +export const caseColors: Record = { + [CaseTypes.Ban]: 0xcb4314, + [CaseTypes.Unban]: 0x9b59b6, [CaseTypes.Note]: 0x3498db, [CaseTypes.Warn]: 0xdae622, [CaseTypes.Mute]: 0xe6b122, [CaseTypes.Unmute]: 0xa175b3, [CaseTypes.Kick]: 0xe67e22, + [CaseTypes.Deleted]: 0x000000, [CaseTypes.Softban]: 0xe67e22, - [CaseTypes.Ban]: 0xcb4314, - [CaseTypes.Unban]: 0x9b59b6, }; diff --git a/backend/src/plugins/Cases/caseIcons.ts b/backend/src/plugins/Cases/caseIcons.ts new file mode 100644 index 00000000..3c685804 --- /dev/null +++ b/backend/src/plugins/Cases/caseIcons.ts @@ -0,0 +1,13 @@ +import { CaseTypes } from "../../data/CaseTypes"; + +export const caseIcons: Record = { + [CaseTypes.Ban]: "<:case_ban:742540201443721317>", + [CaseTypes.Unban]: "<:case_unban:742540201670475846>", + [CaseTypes.Note]: "<:case_note:742540201368485950>", + [CaseTypes.Warn]: "<:case_warn:742540201624338454>", + [CaseTypes.Kick]: "<:case_kick:742540201661825165>", + [CaseTypes.Mute]: "<:case_mute:742540201817145364>", + [CaseTypes.Unmute]: "<:case_unmute:742540201489858643>", + [CaseTypes.Deleted]: "<:case_deleted:742540201473343529>", + [CaseTypes.Softban]: "<:case_softban:742540201766813747>", +}; diff --git a/backend/src/plugins/Cases/functions/getCaseColor.ts b/backend/src/plugins/Cases/functions/getCaseColor.ts new file mode 100644 index 00000000..9e574cb6 --- /dev/null +++ b/backend/src/plugins/Cases/functions/getCaseColor.ts @@ -0,0 +1,8 @@ +import { PluginData } from "knub"; +import { CasesPluginType } from "../types"; +import { CaseTypes, CaseTypeToName } from "../../../data/CaseTypes"; +import { caseColors } from "../caseColors"; + +export function getCaseColor(pluginData: PluginData, caseType: CaseTypes) { + return pluginData.config.get().case_colors?.[CaseTypeToName[caseType]] ?? caseColors[caseType]; +} diff --git a/backend/src/plugins/Cases/functions/getCaseEmbed.ts b/backend/src/plugins/Cases/functions/getCaseEmbed.ts index e1879885..385c6720 100644 --- a/backend/src/plugins/Cases/functions/getCaseEmbed.ts +++ b/backend/src/plugins/Cases/functions/getCaseEmbed.ts @@ -4,11 +4,11 @@ import moment from "moment-timezone"; import { CaseTypes } from "../../../data/CaseTypes"; import { PluginData, helpers } from "knub"; import { CasesPluginType } from "../types"; -import { CaseTypeColors } from "../../../data/CaseTypeColors"; import { resolveCaseId } from "./resolveCaseId"; import { chunkLines, chunkMessageLines, emptyEmbedValue, messageLink } from "../../../utils"; import { inGuildTz } from "../../../utils/timezones"; import { getDateFormat } from "../../../utils/dateFormats"; +import { getCaseColor } from "./getCaseColor"; export async function getCaseEmbed( pluginData: PluginData, @@ -53,9 +53,7 @@ export async function getCaseEmbed( embed.title += " (hidden)"; } - if (CaseTypeColors[theCase.type]) { - embed.color = CaseTypeColors[theCase.type]; - } + embed.color = getCaseColor(pluginData, theCase.type); if (theCase.notes.length) { theCase.notes.forEach((note: any) => { diff --git a/backend/src/plugins/Cases/functions/getCaseIcon.ts b/backend/src/plugins/Cases/functions/getCaseIcon.ts new file mode 100644 index 00000000..0cf0758f --- /dev/null +++ b/backend/src/plugins/Cases/functions/getCaseIcon.ts @@ -0,0 +1,8 @@ +import { PluginData } from "knub"; +import { CasesPluginType } from "../types"; +import { CaseTypes, CaseTypeToName } from "../../../data/CaseTypes"; +import { caseIcons } from "../caseIcons"; + +export function getCaseIcon(pluginData: PluginData, caseType: CaseTypes) { + return pluginData.config.get().case_icons?.[CaseTypeToName[caseType]] ?? caseIcons[caseType]; +} diff --git a/backend/src/plugins/Cases/functions/getCaseSummary.ts b/backend/src/plugins/Cases/functions/getCaseSummary.ts index 95b801d1..4c7fc322 100644 --- a/backend/src/plugins/Cases/functions/getCaseSummary.ts +++ b/backend/src/plugins/Cases/functions/getCaseSummary.ts @@ -1,17 +1,19 @@ import { PluginData } from "knub"; import { CasesPluginType } from "../types"; -import { convertDelayStringToMS, DAYS, disableLinkPreviews, messageLink } from "../../../utils"; +import { convertDelayStringToMS, DAYS, disableLinkPreviews, emptyEmbedValue, messageLink } from "../../../utils"; import { DBDateFormat, getDateFormat } from "../../../utils/dateFormats"; -import { CaseTypes } from "../../../data/CaseTypes"; +import { CaseTypes, CaseTypeToName } from "../../../data/CaseTypes"; import moment from "moment-timezone"; import { Case } from "../../../data/entities/Case"; import { inGuildTz } from "../../../utils/timezones"; import humanizeDuration from "humanize-duration"; import { humanizeDurationShort } from "../../../humanizeDurationShort"; +import { caseAbbreviations } from "../caseAbbreviations"; +import { getCaseIcon } from "./getCaseIcon"; const CASE_SUMMARY_REASON_MAX_LENGTH = 300; const INCLUDE_MORE_NOTES_THRESHOLD = 20; -const UPDATED_STR = "__[Update]__"; +const UPDATE_STR = "**[Update]**"; const RELATIVE_TIME_THRESHOLD = 7 * DAYS; @@ -20,8 +22,9 @@ export async function getCaseSummary( caseOrCaseId: Case | number, withLinks = false, ) { - const caseId = caseOrCaseId instanceof Case ? caseOrCaseId.id : caseOrCaseId; + const config = pluginData.config.get(); + const caseId = caseOrCaseId instanceof Case ? caseOrCaseId.id : caseOrCaseId; const theCase = await pluginData.state.cases.with("notes").find(caseId); const firstNote = theCase.notes[0]; @@ -29,8 +32,8 @@ export async function getCaseSummary( let leftoverNotes = Math.max(0, theCase.notes.length - 1); for (let i = 1; i < theCase.notes.length; i++) { - if (reason.length >= CASE_SUMMARY_REASON_MAX_LENGTH - UPDATED_STR.length - INCLUDE_MORE_NOTES_THRESHOLD) break; - reason += ` ${UPDATED_STR} ${theCase.notes[i].body}`; + if (reason.length >= CASE_SUMMARY_REASON_MAX_LENGTH - UPDATE_STR.length - INCLUDE_MORE_NOTES_THRESHOLD) break; + reason += ` ${UPDATE_STR} ${theCase.notes[i].body}`; leftoverNotes--; } @@ -45,23 +48,26 @@ export async function getCaseSummary( reason = disableLinkPreviews(reason); const timestamp = moment.utc(theCase.created_at, DBDateFormat); - const config = pluginData.config.get(); const relativeTimeCutoff = convertDelayStringToMS(config.relative_time_cutoff); const useRelativeTime = config.show_relative_times && Date.now() - timestamp.valueOf() < relativeTimeCutoff; const prettyTimestamp = useRelativeTime ? moment.utc().to(timestamp) : inGuildTz(pluginData, timestamp).format(getDateFormat(pluginData, "date")); - let caseTitle = `\`Case #${theCase.case_number}\``; + const icon = getCaseIcon(pluginData, theCase.type); + + let caseTitle = `\`#${theCase.case_number}\``; if (withLinks && theCase.log_message_id) { const [channelId, messageId] = theCase.log_message_id.split("-"); caseTitle = `[${caseTitle}](${messageLink(pluginData.guild.id, channelId, messageId)})`; } else { caseTitle = `\`${caseTitle}\``; } - const caseType = `__${CaseTypes[theCase.type]}__`; - let line = `\`[${prettyTimestamp}]\` ${caseTitle} ${caseType} ${reason}`; + let caseType = (caseAbbreviations[theCase.type] || String(theCase.type)).toUpperCase(); + caseType = (caseType + " ").slice(0, 4); + + let line = `${icon} **\`${caseType}\`** \`[${prettyTimestamp}]\` ${caseTitle} ${reason}`; if (leftoverNotes > 1) { line += ` *(+${leftoverNotes} ${leftoverNotes === 1 ? "note" : "notes"})*`; } @@ -70,5 +76,5 @@ export async function getCaseSummary( line += " *(hidden)*"; } - return line; + return line.trim(); } diff --git a/backend/src/plugins/Cases/types.ts b/backend/src/plugins/Cases/types.ts index a732eeaf..c5b44981 100644 --- a/backend/src/plugins/Cases/types.ts +++ b/backend/src/plugins/Cases/types.ts @@ -1,16 +1,19 @@ import * as t from "io-ts"; -import { tDelayString, tNullable } from "../../utils"; -import { CaseTypes } from "../../data/CaseTypes"; +import { tDelayString, tPartialDictionary, tNullable } from "../../utils"; +import { CaseNameToType, CaseTypes } from "../../data/CaseTypes"; import { BasePluginType } from "knub"; import { GuildLogs } from "../../data/GuildLogs"; import { GuildCases } from "../../data/GuildCases"; import { GuildArchives } from "../../data/GuildArchives"; +import { tColor } from "../../utils/tColor"; export const ConfigSchema = t.type({ log_automatic_actions: t.boolean, case_log_channel: tNullable(t.string), show_relative_times: t.boolean, relative_time_cutoff: tDelayString, + case_colors: tNullable(tPartialDictionary(t.keyof(CaseNameToType), tColor)), + case_icons: tNullable(tPartialDictionary(t.keyof(CaseNameToType), t.string)), }); export type TConfigSchema = t.TypeOf; diff --git a/backend/src/plugins/Post/commands/PostEmbedCmd.ts b/backend/src/plugins/Post/commands/PostEmbedCmd.ts index 1fc64571..06c57b20 100644 --- a/backend/src/plugins/Post/commands/PostEmbedCmd.ts +++ b/backend/src/plugins/Post/commands/PostEmbedCmd.ts @@ -56,7 +56,7 @@ export const PostEmbedCmd = postCmd({ try { parsed = JSON.parse(content); } catch (e) { - sendErrorMessage(pluginData, msg.channel, "Syntax error in embed JSON"); + sendErrorMessage(pluginData, msg.channel, `Syntax error in embed JSON: ${e.message}`); return; } diff --git a/backend/src/utils.ts b/backend/src/utils.ts index 5560af60..9be52c74 100644 --- a/backend/src/utils.ts +++ b/backend/src/utils.ts @@ -37,6 +37,7 @@ import { either } from "fp-ts/lib/Either"; import moment from "moment-timezone"; import { SimpleCache } from "./SimpleCache"; import { logger } from "./logger"; +import { unsafeCoerce } from "fp-ts/lib/function"; const fsp = fs.promises; @@ -152,6 +153,29 @@ function tDeepPartialProp(prop: any) { // https://stackoverflow.com/a/49262929/316944 export type Not = T & Exclude; +// io-ts partial dictionary type +// From https://github.com/gcanti/io-ts/issues/429#issuecomment-655394345 +export interface PartialDictionaryC + extends t.DictionaryType< + D, + C, + { + [K in t.TypeOf]?: t.TypeOf; + }, + { + [K in t.OutputOf]?: t.OutputOf; + }, + unknown + > {} + +export const tPartialDictionary = ( + domain: D, + codomain: C, + name?: string, +): PartialDictionaryC => { + return unsafeCoerce(t.record(t.union([domain, t.undefined]), codomain, name)); +}; + /** * Mirrors EmbedOptions from Eris */ diff --git a/backend/src/utils/intToRgb.ts b/backend/src/utils/intToRgb.ts new file mode 100644 index 00000000..198218e9 --- /dev/null +++ b/backend/src/utils/intToRgb.ts @@ -0,0 +1,6 @@ +export function intToRgb(int: number): [number, number, number] { + const r = int >> 16; + const g = (int - (r << 16)) >> 8; + const b = int - (r << 16) - (g << 8); + return [r, g, b]; +} diff --git a/backend/src/utils/tColor.ts b/backend/src/utils/tColor.ts new file mode 100644 index 00000000..df61f579 --- /dev/null +++ b/backend/src/utils/tColor.ts @@ -0,0 +1,17 @@ +import * as t from "io-ts"; +import { either } from "fp-ts/lib/Either"; +import { convertDelayStringToMS } from "../utils"; +import { parseColor } from "./parseColor"; +import { rgbToInt } from "./rgbToInt"; +import { intToRgb } from "./intToRgb"; + +export const tColor = new t.Type( + "tColor", + (s): s is number => typeof s === "number", + (from, to) => + either.chain(t.string.validate(from, to), input => { + const parsedColor = parseColor(input); + return parsedColor == null ? t.failure(from, to, "Invalid color") : t.success(rgbToInt(parsedColor)); + }), + s => intToRgb(s).join(","), +);