Add case icons. Clean up !cases. Allow customizing case colors and icons.
BIN
assets/icons/case_ban.png
Normal file
After Width: | Height: | Size: 2.8 KiB |
BIN
assets/icons/case_deleted.png
Normal file
After Width: | Height: | Size: 2.5 KiB |
BIN
assets/icons/case_icons.afphoto
Normal file
BIN
assets/icons/case_kick.png
Normal file
After Width: | Height: | Size: 2.8 KiB |
BIN
assets/icons/case_mute.png
Normal file
After Width: | Height: | Size: 2.8 KiB |
BIN
assets/icons/case_note.png
Normal file
After Width: | Height: | Size: 2.8 KiB |
BIN
assets/icons/case_softban.png
Normal file
After Width: | Height: | Size: 2.8 KiB |
BIN
assets/icons/case_unban.png
Normal file
After Width: | Height: | Size: 2.8 KiB |
BIN
assets/icons/case_unmute.png
Normal file
After Width: | Height: | Size: 2.8 KiB |
BIN
assets/icons/case_warn.png
Normal file
After Width: | Height: | Size: 2.8 KiB |
|
@ -9,3 +9,20 @@ export enum CaseTypes {
|
||||||
Deleted,
|
Deleted,
|
||||||
Softban,
|
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<CaseTypes, string>;
|
||||||
|
|
|
@ -19,6 +19,8 @@ const defaultOptions = {
|
||||||
case_log_channel: null,
|
case_log_channel: null,
|
||||||
show_relative_times: true,
|
show_relative_times: true,
|
||||||
relative_time_cutoff: "7d",
|
relative_time_cutoff: "7d",
|
||||||
|
case_colors: null,
|
||||||
|
case_icons: null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
13
backend/src/plugins/Cases/caseAbbreviations.ts
Normal file
|
@ -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",
|
||||||
|
};
|
|
@ -1,12 +1,13 @@
|
||||||
import { CaseTypes } from "./CaseTypes";
|
import { CaseTypes } from "../../data/CaseTypes";
|
||||||
|
|
||||||
export const CaseTypeColors = {
|
export const caseColors: Record<CaseTypes, number> = {
|
||||||
|
[CaseTypes.Ban]: 0xcb4314,
|
||||||
|
[CaseTypes.Unban]: 0x9b59b6,
|
||||||
[CaseTypes.Note]: 0x3498db,
|
[CaseTypes.Note]: 0x3498db,
|
||||||
[CaseTypes.Warn]: 0xdae622,
|
[CaseTypes.Warn]: 0xdae622,
|
||||||
[CaseTypes.Mute]: 0xe6b122,
|
[CaseTypes.Mute]: 0xe6b122,
|
||||||
[CaseTypes.Unmute]: 0xa175b3,
|
[CaseTypes.Unmute]: 0xa175b3,
|
||||||
[CaseTypes.Kick]: 0xe67e22,
|
[CaseTypes.Kick]: 0xe67e22,
|
||||||
|
[CaseTypes.Deleted]: 0x000000,
|
||||||
[CaseTypes.Softban]: 0xe67e22,
|
[CaseTypes.Softban]: 0xe67e22,
|
||||||
[CaseTypes.Ban]: 0xcb4314,
|
|
||||||
[CaseTypes.Unban]: 0x9b59b6,
|
|
||||||
};
|
};
|
13
backend/src/plugins/Cases/caseIcons.ts
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import { CaseTypes } from "../../data/CaseTypes";
|
||||||
|
|
||||||
|
export const caseIcons: Record<CaseTypes, string> = {
|
||||||
|
[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>",
|
||||||
|
};
|
8
backend/src/plugins/Cases/functions/getCaseColor.ts
Normal file
|
@ -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<CasesPluginType>, caseType: CaseTypes) {
|
||||||
|
return pluginData.config.get().case_colors?.[CaseTypeToName[caseType]] ?? caseColors[caseType];
|
||||||
|
}
|
|
@ -4,11 +4,11 @@ import moment from "moment-timezone";
|
||||||
import { CaseTypes } from "../../../data/CaseTypes";
|
import { CaseTypes } from "../../../data/CaseTypes";
|
||||||
import { PluginData, helpers } from "knub";
|
import { PluginData, helpers } from "knub";
|
||||||
import { CasesPluginType } from "../types";
|
import { CasesPluginType } from "../types";
|
||||||
import { CaseTypeColors } from "../../../data/CaseTypeColors";
|
|
||||||
import { resolveCaseId } from "./resolveCaseId";
|
import { resolveCaseId } from "./resolveCaseId";
|
||||||
import { chunkLines, chunkMessageLines, emptyEmbedValue, messageLink } from "../../../utils";
|
import { chunkLines, chunkMessageLines, emptyEmbedValue, messageLink } from "../../../utils";
|
||||||
import { inGuildTz } from "../../../utils/timezones";
|
import { inGuildTz } from "../../../utils/timezones";
|
||||||
import { getDateFormat } from "../../../utils/dateFormats";
|
import { getDateFormat } from "../../../utils/dateFormats";
|
||||||
|
import { getCaseColor } from "./getCaseColor";
|
||||||
|
|
||||||
export async function getCaseEmbed(
|
export async function getCaseEmbed(
|
||||||
pluginData: PluginData<CasesPluginType>,
|
pluginData: PluginData<CasesPluginType>,
|
||||||
|
@ -53,9 +53,7 @@ export async function getCaseEmbed(
|
||||||
embed.title += " (hidden)";
|
embed.title += " (hidden)";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (CaseTypeColors[theCase.type]) {
|
embed.color = getCaseColor(pluginData, theCase.type);
|
||||||
embed.color = CaseTypeColors[theCase.type];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (theCase.notes.length) {
|
if (theCase.notes.length) {
|
||||||
theCase.notes.forEach((note: any) => {
|
theCase.notes.forEach((note: any) => {
|
||||||
|
|
8
backend/src/plugins/Cases/functions/getCaseIcon.ts
Normal file
|
@ -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<CasesPluginType>, caseType: CaseTypes) {
|
||||||
|
return pluginData.config.get().case_icons?.[CaseTypeToName[caseType]] ?? caseIcons[caseType];
|
||||||
|
}
|
|
@ -1,17 +1,19 @@
|
||||||
import { PluginData } from "knub";
|
import { PluginData } from "knub";
|
||||||
import { CasesPluginType } from "../types";
|
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 { DBDateFormat, getDateFormat } from "../../../utils/dateFormats";
|
||||||
import { CaseTypes } from "../../../data/CaseTypes";
|
import { CaseTypes, CaseTypeToName } from "../../../data/CaseTypes";
|
||||||
import moment from "moment-timezone";
|
import moment from "moment-timezone";
|
||||||
import { Case } from "../../../data/entities/Case";
|
import { Case } from "../../../data/entities/Case";
|
||||||
import { inGuildTz } from "../../../utils/timezones";
|
import { inGuildTz } from "../../../utils/timezones";
|
||||||
import humanizeDuration from "humanize-duration";
|
import humanizeDuration from "humanize-duration";
|
||||||
import { humanizeDurationShort } from "../../../humanizeDurationShort";
|
import { humanizeDurationShort } from "../../../humanizeDurationShort";
|
||||||
|
import { caseAbbreviations } from "../caseAbbreviations";
|
||||||
|
import { getCaseIcon } from "./getCaseIcon";
|
||||||
|
|
||||||
const CASE_SUMMARY_REASON_MAX_LENGTH = 300;
|
const CASE_SUMMARY_REASON_MAX_LENGTH = 300;
|
||||||
const INCLUDE_MORE_NOTES_THRESHOLD = 20;
|
const INCLUDE_MORE_NOTES_THRESHOLD = 20;
|
||||||
const UPDATED_STR = "__[Update]__";
|
const UPDATE_STR = "**[Update]**";
|
||||||
|
|
||||||
const RELATIVE_TIME_THRESHOLD = 7 * DAYS;
|
const RELATIVE_TIME_THRESHOLD = 7 * DAYS;
|
||||||
|
|
||||||
|
@ -20,8 +22,9 @@ export async function getCaseSummary(
|
||||||
caseOrCaseId: Case | number,
|
caseOrCaseId: Case | number,
|
||||||
withLinks = false,
|
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 theCase = await pluginData.state.cases.with("notes").find(caseId);
|
||||||
|
|
||||||
const firstNote = theCase.notes[0];
|
const firstNote = theCase.notes[0];
|
||||||
|
@ -29,8 +32,8 @@ export async function getCaseSummary(
|
||||||
let leftoverNotes = Math.max(0, theCase.notes.length - 1);
|
let leftoverNotes = Math.max(0, theCase.notes.length - 1);
|
||||||
|
|
||||||
for (let i = 1; i < theCase.notes.length; i++) {
|
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;
|
if (reason.length >= CASE_SUMMARY_REASON_MAX_LENGTH - UPDATE_STR.length - INCLUDE_MORE_NOTES_THRESHOLD) break;
|
||||||
reason += ` ${UPDATED_STR} ${theCase.notes[i].body}`;
|
reason += ` ${UPDATE_STR} ${theCase.notes[i].body}`;
|
||||||
leftoverNotes--;
|
leftoverNotes--;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -45,23 +48,26 @@ export async function getCaseSummary(
|
||||||
reason = disableLinkPreviews(reason);
|
reason = disableLinkPreviews(reason);
|
||||||
|
|
||||||
const timestamp = moment.utc(theCase.created_at, DBDateFormat);
|
const timestamp = moment.utc(theCase.created_at, DBDateFormat);
|
||||||
const config = pluginData.config.get();
|
|
||||||
const relativeTimeCutoff = convertDelayStringToMS(config.relative_time_cutoff);
|
const relativeTimeCutoff = convertDelayStringToMS(config.relative_time_cutoff);
|
||||||
const useRelativeTime = config.show_relative_times && Date.now() - timestamp.valueOf() < relativeTimeCutoff;
|
const useRelativeTime = config.show_relative_times && Date.now() - timestamp.valueOf() < relativeTimeCutoff;
|
||||||
const prettyTimestamp = useRelativeTime
|
const prettyTimestamp = useRelativeTime
|
||||||
? moment.utc().to(timestamp)
|
? moment.utc().to(timestamp)
|
||||||
: inGuildTz(pluginData, timestamp).format(getDateFormat(pluginData, "date"));
|
: 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) {
|
if (withLinks && theCase.log_message_id) {
|
||||||
const [channelId, messageId] = theCase.log_message_id.split("-");
|
const [channelId, messageId] = theCase.log_message_id.split("-");
|
||||||
caseTitle = `[${caseTitle}](${messageLink(pluginData.guild.id, channelId, messageId)})`;
|
caseTitle = `[${caseTitle}](${messageLink(pluginData.guild.id, channelId, messageId)})`;
|
||||||
} else {
|
} else {
|
||||||
caseTitle = `\`${caseTitle}\``;
|
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) {
|
if (leftoverNotes > 1) {
|
||||||
line += ` *(+${leftoverNotes} ${leftoverNotes === 1 ? "note" : "notes"})*`;
|
line += ` *(+${leftoverNotes} ${leftoverNotes === 1 ? "note" : "notes"})*`;
|
||||||
}
|
}
|
||||||
|
@ -70,5 +76,5 @@ export async function getCaseSummary(
|
||||||
line += " *(hidden)*";
|
line += " *(hidden)*";
|
||||||
}
|
}
|
||||||
|
|
||||||
return line;
|
return line.trim();
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,16 +1,19 @@
|
||||||
import * as t from "io-ts";
|
import * as t from "io-ts";
|
||||||
import { tDelayString, tNullable } from "../../utils";
|
import { tDelayString, tPartialDictionary, tNullable } from "../../utils";
|
||||||
import { CaseTypes } from "../../data/CaseTypes";
|
import { CaseNameToType, CaseTypes } from "../../data/CaseTypes";
|
||||||
import { BasePluginType } from "knub";
|
import { BasePluginType } from "knub";
|
||||||
import { GuildLogs } from "../../data/GuildLogs";
|
import { GuildLogs } from "../../data/GuildLogs";
|
||||||
import { GuildCases } from "../../data/GuildCases";
|
import { GuildCases } from "../../data/GuildCases";
|
||||||
import { GuildArchives } from "../../data/GuildArchives";
|
import { GuildArchives } from "../../data/GuildArchives";
|
||||||
|
import { tColor } from "../../utils/tColor";
|
||||||
|
|
||||||
export const ConfigSchema = t.type({
|
export const ConfigSchema = t.type({
|
||||||
log_automatic_actions: t.boolean,
|
log_automatic_actions: t.boolean,
|
||||||
case_log_channel: tNullable(t.string),
|
case_log_channel: tNullable(t.string),
|
||||||
show_relative_times: t.boolean,
|
show_relative_times: t.boolean,
|
||||||
relative_time_cutoff: tDelayString,
|
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<typeof ConfigSchema>;
|
export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
||||||
|
|
||||||
|
|
|
@ -56,7 +56,7 @@ export const PostEmbedCmd = postCmd({
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(content);
|
parsed = JSON.parse(content);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
sendErrorMessage(pluginData, msg.channel, "Syntax error in embed JSON");
|
sendErrorMessage(pluginData, msg.channel, `Syntax error in embed JSON: ${e.message}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -37,6 +37,7 @@ import { either } from "fp-ts/lib/Either";
|
||||||
import moment from "moment-timezone";
|
import moment from "moment-timezone";
|
||||||
import { SimpleCache } from "./SimpleCache";
|
import { SimpleCache } from "./SimpleCache";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
|
import { unsafeCoerce } from "fp-ts/lib/function";
|
||||||
|
|
||||||
const fsp = fs.promises;
|
const fsp = fs.promises;
|
||||||
|
|
||||||
|
@ -152,6 +153,29 @@ function tDeepPartialProp(prop: any) {
|
||||||
// https://stackoverflow.com/a/49262929/316944
|
// https://stackoverflow.com/a/49262929/316944
|
||||||
export type Not<T, E> = T & Exclude<T, E>;
|
export type Not<T, E> = T & Exclude<T, E>;
|
||||||
|
|
||||||
|
// io-ts partial dictionary type
|
||||||
|
// From https://github.com/gcanti/io-ts/issues/429#issuecomment-655394345
|
||||||
|
export interface PartialDictionaryC<D extends t.Mixed, C extends t.Mixed>
|
||||||
|
extends t.DictionaryType<
|
||||||
|
D,
|
||||||
|
C,
|
||||||
|
{
|
||||||
|
[K in t.TypeOf<D>]?: t.TypeOf<C>;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
[K in t.OutputOf<D>]?: t.OutputOf<C>;
|
||||||
|
},
|
||||||
|
unknown
|
||||||
|
> {}
|
||||||
|
|
||||||
|
export const tPartialDictionary = <D extends t.Mixed, C extends t.Mixed>(
|
||||||
|
domain: D,
|
||||||
|
codomain: C,
|
||||||
|
name?: string,
|
||||||
|
): PartialDictionaryC<D, C> => {
|
||||||
|
return unsafeCoerce(t.record(t.union([domain, t.undefined]), codomain, name));
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mirrors EmbedOptions from Eris
|
* Mirrors EmbedOptions from Eris
|
||||||
*/
|
*/
|
||||||
|
|
6
backend/src/utils/intToRgb.ts
Normal file
|
@ -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];
|
||||||
|
}
|
17
backend/src/utils/tColor.ts
Normal file
|
@ -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<number, string>(
|
||||||
|
"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(","),
|
||||||
|
);
|