aboutsummaryrefslogtreecommitdiff
path: root/lib/common
diff options
context:
space:
mode:
Diffstat (limited to 'lib/common')
-rw-r--r--lib/common/BushCache.ts26
-rw-r--r--lib/common/ButtonPaginator.ts224
-rw-r--r--lib/common/CanvasProgressBar.ts83
-rw-r--r--lib/common/ConfirmationPrompt.ts64
-rw-r--r--lib/common/DeleteButton.ts78
-rw-r--r--lib/common/HighlightManager.ts488
-rw-r--r--lib/common/Moderation.ts556
-rw-r--r--lib/common/Sentry.ts24
-rw-r--r--lib/common/tags.ts34
9 files changed, 1577 insertions, 0 deletions
diff --git a/lib/common/BushCache.ts b/lib/common/BushCache.ts
new file mode 100644
index 0000000..22a13ef
--- /dev/null
+++ b/lib/common/BushCache.ts
@@ -0,0 +1,26 @@
+import { BadWords, GlobalModel, SharedModel, type Guild } from '#lib';
+import { Collection, type Snowflake } from 'discord.js';
+
+export class BushCache {
+ public global = new GlobalCache();
+ public shared = new SharedCache();
+ public guilds = new GuildCache();
+}
+
+export class GlobalCache implements Omit<GlobalModel, 'environment'> {
+ public disabledCommands: string[] = [];
+ public blacklistedChannels: Snowflake[] = [];
+ public blacklistedGuilds: Snowflake[] = [];
+ public blacklistedUsers: Snowflake[] = [];
+}
+
+export class SharedCache implements Omit<SharedModel, 'primaryKey'> {
+ public superUsers: Snowflake[] = [];
+ public privilegedUsers: Snowflake[] = [];
+ public badLinksSecret: string[] = [];
+ public badLinks: string[] = [];
+ public badWords: BadWords = {};
+ public autoBanCode: string | null = null;
+}
+
+export class GuildCache extends Collection<Snowflake, Guild> {}
diff --git a/lib/common/ButtonPaginator.ts b/lib/common/ButtonPaginator.ts
new file mode 100644
index 0000000..92f3796
--- /dev/null
+++ b/lib/common/ButtonPaginator.ts
@@ -0,0 +1,224 @@
+import { DeleteButton, type CommandMessage, type SlashMessage } from '#lib';
+import { CommandUtil } from 'discord-akairo';
+import {
+ ActionRowBuilder,
+ ButtonBuilder,
+ ButtonStyle,
+ EmbedBuilder,
+ type APIEmbed,
+ type Message,
+ type MessageComponentInteraction
+} from 'discord.js';
+
+/**
+ * Sends multiple embeds with controls to switch between them
+ */
+export class ButtonPaginator {
+ /**
+ * The current page of the paginator
+ */
+ protected curPage: number;
+
+ /**
+ * The paginator message
+ */
+ protected sentMessage: Message | undefined;
+
+ /**
+ * @param message The message that triggered the command
+ * @param embeds The embeds to switch between
+ * @param text The optional text to send with the paginator
+ * @param {} [deleteOnExit=true] Whether the paginator message gets deleted when the exit button is pressed
+ * @param startOn The page to start from (**not** the index)
+ */
+ protected constructor(
+ protected message: CommandMessage | SlashMessage,
+ protected embeds: EmbedBuilder[] | APIEmbed[],
+ protected text: string | null,
+ protected deleteOnExit: boolean,
+ startOn: number
+ ) {
+ this.curPage = startOn - 1;
+
+ // add footers
+ for (let i = 0; i < embeds.length; i++) {
+ if (embeds[i] instanceof EmbedBuilder) {
+ (embeds[i] as EmbedBuilder).setFooter({ text: `Page ${(i + 1).toLocaleString()}/${embeds.length.toLocaleString()}` });
+ } else {
+ (embeds[i] as APIEmbed).footer = {
+ text: `Page ${(i + 1).toLocaleString()}/${embeds.length.toLocaleString()}`
+ };
+ }
+ }
+ }
+
+ /**
+ * The number of pages in the paginator
+ */
+ protected get numPages(): number {
+ return this.embeds.length;
+ }
+
+ /**
+ * Sends the paginator message
+ */
+ protected async send() {
+ this.sentMessage = await this.message.util.reply({
+ content: this.text,
+ embeds: [this.embeds[this.curPage]],
+ components: [this.getPaginationRow()]
+ });
+
+ const collector = this.sentMessage.createMessageComponentCollector({
+ filter: (i) => i.customId.startsWith('paginate_'),
+ time: 300_000
+ });
+ collector.on('collect', (i) => void this.collect(i));
+ collector.on('end', () => void this.end());
+ }
+
+ /**
+ * Handles interactions with the paginator
+ * @param interaction The interaction received
+ */
+ protected async collect(interaction: MessageComponentInteraction) {
+ if (interaction.user.id !== this.message.author.id && !this.message.client.config.owners.includes(interaction.user.id))
+ return await interaction?.deferUpdate().catch(() => null);
+
+ switch (interaction.customId) {
+ case 'paginate_beginning':
+ this.curPage = 0;
+ await this.edit(interaction);
+ break;
+ case 'paginate_back':
+ this.curPage--;
+ await this.edit(interaction);
+ break;
+ case 'paginate_stop':
+ if (this.deleteOnExit) {
+ await interaction.deferUpdate().catch(() => null);
+ await this.sentMessage!.delete().catch(() => null);
+ break;
+ } else {
+ await interaction
+ ?.update({
+ content: `${
+ this.text
+ ? `${this.text}
+`
+ : ''
+ }Command closed by user.`,
+ embeds: [],
+ components: []
+ })
+ .catch(() => null);
+ break;
+ }
+ case 'paginate_next':
+ this.curPage++;
+ await this.edit(interaction);
+ break;
+ case 'paginate_end':
+ this.curPage = this.embeds.length - 1;
+ await this.edit(interaction);
+ break;
+ }
+ }
+
+ /**
+ * Ends the paginator
+ */
+ protected async end() {
+ if (this.sentMessage && !CommandUtil.deletedMessages.has(this.sentMessage.id))
+ await this.sentMessage
+ .edit({
+ content: this.text,
+ embeds: [this.embeds[this.curPage]],
+ components: [this.getPaginationRow(true)]
+ })
+ .catch(() => null);
+ }
+
+ /**
+ * Edits the paginator message
+ * @param interaction The interaction received
+ */
+ protected async edit(interaction: MessageComponentInteraction) {
+ await interaction
+ ?.update({
+ content: this.text,
+ embeds: [this.embeds[this.curPage]],
+ components: [this.getPaginationRow()]
+ })
+ .catch(() => null);
+ }
+
+ /**
+ * Generates the pagination row based on the class properties
+ * @param disableAll Whether to disable all buttons
+ * @returns The generated {@link ActionRow}
+ */
+ protected getPaginationRow(disableAll = false) {
+ return new ActionRowBuilder<ButtonBuilder>().addComponents(
+ new ButtonBuilder({
+ style: ButtonStyle.Primary,
+ customId: 'paginate_beginning',
+ emoji: PaginateEmojis.BEGINNING,
+ disabled: disableAll || this.curPage === 0
+ }),
+ new ButtonBuilder({
+ style: ButtonStyle.Primary,
+ customId: 'paginate_back',
+ emoji: PaginateEmojis.BACK,
+ disabled: disableAll || this.curPage === 0
+ }),
+ new ButtonBuilder({
+ style: ButtonStyle.Primary,
+ customId: 'paginate_stop',
+ emoji: PaginateEmojis.STOP,
+ disabled: disableAll
+ }),
+ new ButtonBuilder({
+ style: ButtonStyle.Primary,
+ customId: 'paginate_next',
+ emoji: PaginateEmojis.FORWARD,
+ disabled: disableAll || this.curPage === this.numPages - 1
+ }),
+ new ButtonBuilder({
+ style: ButtonStyle.Primary,
+ customId: 'paginate_end',
+ emoji: PaginateEmojis.END,
+ disabled: disableAll || this.curPage === this.numPages - 1
+ })
+ );
+ }
+
+ /**
+ * Sends multiple embeds with controls to switch between them
+ * @param message The message to respond to
+ * @param embeds The embeds to switch between
+ * @param text The text send with the embeds (optional)
+ * @param deleteOnExit Whether to delete the message when the exit button is clicked (defaults to true)
+ * @param startOn The page to start from (**not** the index)
+ */
+ public static async send(
+ message: CommandMessage | SlashMessage,
+ embeds: EmbedBuilder[] | APIEmbed[],
+ text: string | null = null,
+ deleteOnExit = true,
+ startOn = 1
+ ) {
+ // no need to paginate if there is only one page
+ if (embeds.length === 1) return DeleteButton.send(message, { embeds: embeds });
+
+ return await new ButtonPaginator(message, embeds, text, deleteOnExit, startOn).send();
+ }
+}
+
+export const PaginateEmojis = {
+ BEGINNING: { id: '853667381335162910', name: 'w_paginate_beginning', animated: false } as const,
+ BACK: { id: '853667410203770881', name: 'w_paginate_back', animated: false } as const,
+ STOP: { id: '853667471110570034', name: 'w_paginate_stop', animated: false } as const,
+ FORWARD: { id: '853667492680564747', name: 'w_paginate_next', animated: false } as const,
+ END: { id: '853667514915225640', name: 'w_paginate_end', animated: false } as const
+} as const;
diff --git a/lib/common/CanvasProgressBar.ts b/lib/common/CanvasProgressBar.ts
new file mode 100644
index 0000000..fb4f778
--- /dev/null
+++ b/lib/common/CanvasProgressBar.ts
@@ -0,0 +1,83 @@
+import { CanvasRenderingContext2D } from 'canvas';
+
+/**
+ * I just copy pasted this code from stackoverflow don't yell at me if there is issues for it
+ * @author @TymanWasTaken
+ */
+export class CanvasProgressBar {
+ private readonly x: number;
+ private readonly y: number;
+ private readonly w: number;
+ private readonly h: number;
+ private readonly color: string;
+ private percentage: number;
+ private p?: number;
+ private ctx: CanvasRenderingContext2D;
+
+ public constructor(
+ ctx: CanvasRenderingContext2D,
+ dimension: { x: number; y: number; width: number; height: number },
+ color: string,
+ percentage: number
+ ) {
+ ({ x: this.x, y: this.y, width: this.w, height: this.h } = dimension);
+ this.color = color;
+ this.percentage = percentage;
+ this.p = undefined;
+ this.ctx = ctx;
+ }
+
+ public draw(): void {
+ // -----------------
+ this.p = this.percentage * this.w;
+ if (this.p <= this.h) {
+ this.ctx.beginPath();
+ this.ctx.arc(
+ this.h / 2 + this.x,
+ this.h / 2 + this.y,
+ this.h / 2,
+ Math.PI - Math.acos((this.h - this.p) / this.h),
+ Math.PI + Math.acos((this.h - this.p) / this.h)
+ );
+ this.ctx.save();
+ this.ctx.scale(-1, 1);
+ this.ctx.arc(
+ this.h / 2 - this.p - this.x,
+ this.h / 2 + this.y,
+ this.h / 2,
+ Math.PI - Math.acos((this.h - this.p) / this.h),
+ Math.PI + Math.acos((this.h - this.p) / this.h)
+ );
+ this.ctx.restore();
+ this.ctx.closePath();
+ } else {
+ this.ctx.beginPath();
+ this.ctx.arc(this.h / 2 + this.x, this.h / 2 + this.y, this.h / 2, Math.PI / 2, (3 / 2) * Math.PI);
+ this.ctx.lineTo(this.p - this.h + this.x, 0 + this.y);
+ this.ctx.arc(this.p - this.h / 2 + this.x, this.h / 2 + this.y, this.h / 2, (3 / 2) * Math.PI, Math.PI / 2);
+ this.ctx.lineTo(this.h / 2 + this.x, this.h + this.y);
+ this.ctx.closePath();
+ }
+ this.ctx.fillStyle = this.color;
+ this.ctx.fill();
+ }
+
+ // public showWholeProgressBar(){
+ // this.ctx.beginPath();
+ // this.ctx.arc(this.h / 2 + this.x, this.h / 2 + this.y, this.h / 2, Math.PI / 2, 3 / 2 * Math.PI);
+ // this.ctx.lineTo(this.w - this.h + this.x, 0 + this.y);
+ // this.ctx.arc(this.w - this.h / 2 + this.x, this.h / 2 + this.y, this.h / 2, 3 / 2 *Math.PI, Math.PI / 2);
+ // this.ctx.lineTo(this.h / 2 + this.x, this.h + this.y);
+ // this.ctx.strokeStyle = '#000000';
+ // this.ctx.stroke();
+ // this.ctx.closePath();
+ // }
+
+ public get PPercentage(): number {
+ return this.percentage * 100;
+ }
+
+ public set PPercentage(x: number) {
+ this.percentage = x / 100;
+ }
+}
diff --git a/lib/common/ConfirmationPrompt.ts b/lib/common/ConfirmationPrompt.ts
new file mode 100644
index 0000000..b87d9ef
--- /dev/null
+++ b/lib/common/ConfirmationPrompt.ts
@@ -0,0 +1,64 @@
+import { type CommandMessage, type SlashMessage } from '#lib';
+import { ActionRowBuilder, ButtonBuilder, ButtonStyle, type MessageComponentInteraction, type MessageOptions } from 'discord.js';
+
+/**
+ * Sends a message with buttons for the user to confirm or cancel the action.
+ */
+export class ConfirmationPrompt {
+ /**
+ * @param message The message that triggered the command
+ * @param messageOptions Options for sending the message
+ */
+ protected constructor(protected message: CommandMessage | SlashMessage, protected messageOptions: MessageOptions) {}
+
+ /**
+ * Sends a message with buttons for the user to confirm or cancel the action.
+ */
+ protected async send(): Promise<boolean> {
+ this.messageOptions.components = [
+ new ActionRowBuilder<ButtonBuilder>().addComponents(
+ new ButtonBuilder({ style: ButtonStyle.Success, customId: 'confirmationPrompt_confirm', label: 'Yes' }),
+ new ButtonBuilder({ style: ButtonStyle.Danger, customId: 'confirmationPrompt_cancel', label: 'No' })
+ )
+ ];
+
+ const msg = await this.message.channel!.send(this.messageOptions);
+
+ return await new Promise<boolean>((resolve) => {
+ let responded = false;
+ const collector = msg.createMessageComponentCollector({
+ filter: (interaction) => interaction.message?.id == msg.id,
+ time: 300_000
+ });
+
+ collector.on('collect', async (interaction: MessageComponentInteraction) => {
+ await interaction.deferUpdate().catch(() => undefined);
+ if (interaction.user.id == this.message.author.id || this.message.client.config.owners.includes(interaction.user.id)) {
+ if (interaction.customId === 'confirmationPrompt_confirm') {
+ responded = true;
+ collector.stop();
+ resolve(true);
+ } else if (interaction.customId === 'confirmationPrompt_cancel') {
+ responded = true;
+ collector.stop();
+ resolve(false);
+ }
+ }
+ });
+
+ collector.on('end', async () => {
+ await msg.delete().catch(() => undefined);
+ if (!responded) resolve(false);
+ });
+ });
+ }
+
+ /**
+ * Sends a message with buttons for the user to confirm or cancel the action.
+ * @param message The message that triggered the command
+ * @param sendOptions Options for sending the message
+ */
+ public static async send(message: CommandMessage | SlashMessage, sendOptions: MessageOptions): Promise<boolean> {
+ return new ConfirmationPrompt(message, sendOptions).send();
+ }
+}
diff --git a/lib/common/DeleteButton.ts b/lib/common/DeleteButton.ts
new file mode 100644
index 0000000..340d07f
--- /dev/null
+++ b/lib/common/DeleteButton.ts
@@ -0,0 +1,78 @@
+import { PaginateEmojis, type CommandMessage, type SlashMessage } from '#lib';
+import { CommandUtil } from 'discord-akairo';
+import {
+ ActionRowBuilder,
+ ButtonBuilder,
+ ButtonStyle,
+ MessageComponentInteraction,
+ MessageEditOptions,
+ MessagePayload,
+ type MessageOptions
+} from 'discord.js';
+
+/**
+ * Sends a message with a button for the user to delete it.
+ */
+export class DeleteButton {
+ /**
+ * @param message The message to respond to
+ * @param messageOptions The send message options
+ */
+ protected constructor(protected message: CommandMessage | SlashMessage, protected messageOptions: MessageOptions) {}
+
+ /**
+ * Sends a message with a button for the user to delete it.
+ */
+ protected async send() {
+ this.updateComponents();
+
+ const msg = await this.message.util.reply(this.messageOptions);
+
+ const collector = msg.createMessageComponentCollector({
+ filter: (interaction) => interaction.customId == 'paginate__stop' && interaction.message?.id == msg.id,
+ time: 300000
+ });
+
+ collector.on('collect', async (interaction: MessageComponentInteraction) => {
+ await interaction.deferUpdate().catch(() => undefined);
+ if (interaction.user.id == this.message.author.id || this.message.client.config.owners.includes(interaction.user.id)) {
+ if (msg.deletable && !CommandUtil.deletedMessages.has(msg.id)) await msg.delete();
+ }
+ });
+
+ collector.on('end', async () => {
+ this.updateComponents(true, true);
+ await msg.edit(<string | MessagePayload | MessageEditOptions>this.messageOptions).catch(() => undefined);
+ });
+ }
+
+ /**
+ * Generates the components for the message
+ * @param edit Whether or not the message is being edited
+ * @param disable Whether or not to disable the buttons
+ */
+ protected updateComponents(edit = false, disable = false): void {
+ this.messageOptions.components = [
+ new ActionRowBuilder<ButtonBuilder>().addComponents(
+ new ButtonBuilder({
+ style: ButtonStyle.Primary,
+ customId: 'paginate__stop',
+ emoji: PaginateEmojis.STOP,
+ disabled: disable
+ })
+ )
+ ];
+ if (edit) {
+ this.messageOptions.reply = undefined;
+ }
+ }
+
+ /**
+ * Sends a message with a button for the user to delete it.
+ * @param message The message to respond to
+ * @param options The send message options
+ */
+ public static async send(message: CommandMessage | SlashMessage, options: Omit<MessageOptions, 'components'>) {
+ return new DeleteButton(message, options).send();
+ }
+}
diff --git a/lib/common/HighlightManager.ts b/lib/common/HighlightManager.ts
new file mode 100644
index 0000000..cc31413
--- /dev/null
+++ b/lib/common/HighlightManager.ts
@@ -0,0 +1,488 @@
+import { addToArray, format, Highlight, removeFromArray, timestamp, type HighlightWord } from '#lib';
+import assert from 'assert/strict';
+import {
+ ChannelType,
+ Collection,
+ GuildMember,
+ type Channel,
+ type Client,
+ type Message,
+ type Snowflake,
+ type TextBasedChannel
+} from 'discord.js';
+import { colors, Time } from '../utils/BushConstants.js';
+import { sanitizeInputForDiscord } from '../utils/Format.js';
+
+const NOTIFY_COOLDOWN = 5 * Time.Minute;
+const OWNER_NOTIFY_COOLDOWN = 5 * Time.Minute;
+const LAST_MESSAGE_COOLDOWN = 5 * Time.Minute;
+
+type users = Set<Snowflake>;
+type channels = Set<Snowflake>;
+type word = HighlightWord;
+type guild = Snowflake;
+type user = Snowflake;
+type lastMessage = Date;
+type lastDM = Message;
+
+type lastDmInfo = [lastDM: lastDM, guild: guild, channel: Snowflake, highlights: HighlightWord[]];
+
+export class HighlightManager {
+ public static keep = new Set<Snowflake>();
+
+ /**
+ * Cached guild highlights.
+ */
+ public readonly guildHighlights = new Collection<guild, Collection<word, users>>();
+
+ //~ /**
+ //~ * Cached global highlights.
+ //~ */
+ //~ public readonly globalHighlights = new Collection<word, users>();
+
+ /**
+ * A collection of cooldowns of when a user last sent a message in a particular guild.
+ */
+ public readonly userLastTalkedCooldown = new Collection<guild, Collection<user, lastMessage>>();
+
+ /**
+ * Users that users have blocked
+ */
+ public readonly userBlocks = new Collection<guild, Collection<user, users>>();
+
+ /**
+ * Channels that users have blocked
+ */
+ public readonly channelBlocks = new Collection<guild, Collection<user, channels>>();
+
+ /**
+ * A collection of cooldowns of when the bot last sent each user a highlight message.
+ */
+ public readonly lastedDMedUserCooldown = new Collection<user, lastDmInfo>();
+
+ /**
+ * @param client The client to use.
+ */
+ public constructor(public readonly client: Client) {}
+
+ /**
+ * Sync the cache with the database.
+ */
+ public async syncCache(): Promise<void> {
+ const highlights = await Highlight.findAll();
+
+ this.guildHighlights.clear();
+
+ for (const highlight of highlights) {
+ highlight.words.forEach((word) => {
+ if (!this.guildHighlights.has(highlight.guild)) this.guildHighlights.set(highlight.guild, new Collection());
+ const guildCache = this.guildHighlights.get(highlight.guild)!;
+ if (!guildCache.get(word)) guildCache.set(word, new Set());
+ guildCache.get(word)!.add(highlight.user);
+ });
+
+ if (!this.userBlocks.has(highlight.guild)) this.userBlocks.set(highlight.guild, new Collection());
+ this.userBlocks.get(highlight.guild)!.set(highlight.user, new Set(highlight.blacklistedUsers));
+
+ if (!this.channelBlocks.has(highlight.guild)) this.channelBlocks.set(highlight.guild, new Collection());
+ this.channelBlocks.get(highlight.guild)!.set(highlight.user, new Set(highlight.blacklistedChannels));
+ }
+ }
+
+ /**
+ * Checks a message for highlights.
+ * @param message The message to check.
+ * @returns A collection users mapped to the highlight matched
+ */
+ public checkMessage(message: Message): Collection<Snowflake, HighlightWord> {
+ // even if there are multiple matches, only the first one is returned
+ const ret = new Collection<Snowflake, HighlightWord>();
+ if (!message.content || !message.inGuild()) return ret;
+ if (!this.guildHighlights.has(message.guildId)) return ret;
+
+ const guildCache = this.guildHighlights.get(message.guildId)!;
+
+ for (const [word, users] of guildCache.entries()) {
+ if (!this.isMatch(message.content, word)) continue;
+
+ for (const user of users) {
+ if (ret.has(user)) continue;
+
+ if (!message.channel.permissionsFor(user)?.has('ViewChannel')) continue;
+
+ const blockedUsers = this.userBlocks.get(message.guildId)?.get(user) ?? new Set();
+ if (blockedUsers.has(message.author.id)) {
+ void this.client.console.verbose(
+ 'Highlight',
+ `Highlight ignored because <<${this.client.users.cache.get(user)?.tag ?? user}>> blocked the user <<${
+ message.author.tag
+ }>>`
+ );
+ continue;
+ }
+ const blockedChannels = this.channelBlocks.get(message.guildId)?.get(user) ?? new Set();
+ if (blockedChannels.has(message.channel.id)) {
+ void this.client.console.verbose(
+ 'Highlight',
+ `Highlight ignored because <<${this.client.users.cache.get(user)?.tag ?? user}>> blocked the channel <<${
+ message.channel.name
+ }>>`
+ );
+ continue;
+ }
+ if (message.mentions.has(user)) {
+ void this.client.console.verbose(
+ 'Highlight',
+ `Highlight ignored because <<${this.client.users.cache.get(user)?.tag ?? user}>> is already mentioned in the message.`
+ );
+ continue;
+ }
+ ret.set(user, word);
+ }
+ }
+
+ return ret;
+ }
+
+ /**
+ * Checks a user provided phrase for their highlights.
+ * @param guild The guild to check in.
+ * @param user The user to get the highlights for.
+ * @param phrase The phrase for highlights in.
+ * @returns A collection of the user's highlights mapped to weather or not it was matched.
+ */
+ public async checkPhrase(guild: Snowflake, user: Snowflake, phrase: string): Promise<Collection<HighlightWord, boolean>> {
+ const highlights = await Highlight.findAll({ where: { guild, user } });
+
+ const results = new Collection<HighlightWord, boolean>();
+
+ for (const highlight of highlights) {
+ for (const word of highlight.words) {
+ results.set(word, this.isMatch(phrase, word));
+ }
+ }
+
+ return results;
+ }
+
+ /**
+ * Checks a particular highlight for a match within a phrase.
+ * @param phrase The phrase to check for the word in.
+ * @param hl The highlight to check for.
+ * @returns Whether or not the highlight was matched.
+ */
+ private isMatch(phrase: string, hl: HighlightWord): boolean {
+ if (hl.regex) {
+ return new RegExp(hl.word, 'gi').test(phrase);
+ } else {
+ if (hl.word.includes(' ')) {
+ return phrase.toLocaleLowerCase().includes(hl.word.toLocaleLowerCase());
+ } else {
+ const words = phrase.split(/\s*\b\s/);
+ return words.some((w) => w.toLocaleLowerCase() === hl.word.toLocaleLowerCase());
+ }
+ }
+ }
+
+ /**
+ * Adds a new highlight to a user in a particular guild.
+ * @param guild The guild to add the highlight to.
+ * @param user The user to add the highlight to.
+ * @param hl The highlight to add.
+ * @returns A string representing a user error or a boolean indicating the database success.
+ */
+ public async addHighlight(guild: Snowflake, user: Snowflake, hl: HighlightWord): Promise<string | boolean> {
+ if (!this.guildHighlights.has(guild)) this.guildHighlights.set(guild, new Collection());
+ const guildCache = this.guildHighlights.get(guild)!;
+
+ if (!guildCache.has(hl)) guildCache.set(hl, new Set());
+ guildCache.get(hl)!.add(user);
+
+ const [highlight] = await Highlight.findOrCreate({ where: { guild, user } });
+
+ if (highlight.words.some((w) => w.word === hl.word)) return `You have already highlighted "${hl.word}".`;
+
+ highlight.words = addToArray(highlight.words, hl);
+
+ return Boolean(await highlight.save().catch(() => false));
+ }
+
+ /**
+ * Removes a highlighted word for a user in a particular guild.
+ * @param guild The guild to remove the highlight from.
+ * @param user The user to remove the highlight from.
+ * @param hl The word to remove.
+ * @returns A string representing a user error or a boolean indicating the database success.
+ */
+ public async removeHighlight(guild: Snowflake, user: Snowflake, hl: string): Promise<string | boolean> {
+ if (!this.guildHighlights.has(guild)) this.guildHighlights.set(guild, new Collection());
+ const guildCache = this.guildHighlights.get(guild)!;
+
+ const wordCache = guildCache.find((_, key) => key.word === hl);
+
+ if (!wordCache?.has(user)) return `You have not highlighted "${hl}".`;
+
+ wordCache!.delete(user);
+
+ const [highlight] = await Highlight.findOrCreate({ where: { guild, user } });
+
+ const toRemove = highlight.words.find((w) => w.word === hl);
+ if (!toRemove) return `Uhhhhh... This shouldn't happen.`;
+
+ highlight.words = removeFromArray(highlight.words, toRemove);
+
+ return Boolean(await highlight.save().catch(() => false));
+ }
+
+ /**
+ * Remove all highlight words for a user in a particular guild.
+ * @param guild The guild to remove the highlights from.
+ * @param user The user to remove the highlights from.
+ * @returns A boolean indicating the database success.
+ */
+ public async removeAllHighlights(guild: Snowflake, user: Snowflake): Promise<boolean> {
+ if (!this.guildHighlights.has(guild)) this.guildHighlights.set(guild, new Collection());
+ const guildCache = this.guildHighlights.get(guild)!;
+
+ for (const [word, users] of guildCache.entries()) {
+ if (users.has(user)) users.delete(user);
+ if (users.size === 0) guildCache.delete(word);
+ }
+
+ const highlight = await Highlight.findOne({ where: { guild, user } });
+
+ if (!highlight) return false;
+
+ highlight.words = [];
+
+ return Boolean(await highlight.save().catch(() => false));
+ }
+
+ /**
+ * Adds a new user or channel block to a user in a particular guild.
+ * @param guild The guild to add the block to.
+ * @param user The user that is blocking the target.
+ * @param target The target that is being blocked.
+ * @returns The result of the operation.
+ */
+ public async addBlock(
+ guild: Snowflake,
+ user: Snowflake,
+ target: GuildMember | TextBasedChannel
+ ): Promise<HighlightBlockResult> {
+ const cacheKey = `${target instanceof GuildMember ? 'user' : 'channel'}Blocks` as const;
+ const databaseKey = `blacklisted${target instanceof GuildMember ? 'Users' : 'Channels'}` as const;
+
+ const [highlight] = await Highlight.findOrCreate({ where: { guild, user } });
+
+ if (highlight[databaseKey].includes(target.id)) return HighlightBlockResult.ALREADY_BLOCKED;
+
+ const newBlocks = addToArray(highlight[databaseKey], target.id);
+
+ highlight[databaseKey] = newBlocks;
+ const res = await highlight.save().catch(() => false);
+ if (!res) return HighlightBlockResult.ERROR;
+
+ if (!this[cacheKey].has(guild)) this[cacheKey].set(guild, new Collection());
+ const guildBlocks = this[cacheKey].get(guild)!;
+ guildBlocks.set(user, new Set(newBlocks));
+
+ return HighlightBlockResult.SUCCESS;
+ }
+
+ /**
+ * Removes a user or channel block from a user in a particular guild.
+ * @param guild The guild to remove the block from.
+ * @param user The user that is unblocking the target.
+ * @param target The target that is being unblocked.
+ * @returns The result of the operation.
+ */
+ public async removeBlock(guild: Snowflake, user: Snowflake, target: GuildMember | Channel): Promise<HighlightUnblockResult> {
+ const cacheKey = `${target instanceof GuildMember ? 'user' : 'channel'}Blocks` as const;
+ const databaseKey = `blacklisted${target instanceof GuildMember ? 'Users' : 'Channels'}` as const;
+
+ const [highlight] = await Highlight.findOrCreate({ where: { guild, user } });
+
+ if (!highlight[databaseKey].includes(target.id)) return HighlightUnblockResult.NOT_BLOCKED;
+
+ const newBlocks = removeFromArray(highlight[databaseKey], target.id);
+
+ highlight[databaseKey] = newBlocks;
+ const res = await highlight.save().catch(() => false);
+ if (!res) return HighlightUnblockResult.ERROR;
+
+ if (!this[cacheKey].has(guild)) this[cacheKey].set(guild, new Collection());
+ const guildBlocks = this[cacheKey].get(guild)!;
+ guildBlocks.set(user, new Set(newBlocks));
+
+ return HighlightUnblockResult.SUCCESS;
+ }
+
+ /**
+ * Sends a user a direct message to alert them of their highlight being triggered.
+ * @param message The message that triggered the highlight.
+ * @param user The user who's highlights was triggered.
+ * @param hl The highlight that was matched.
+ * @returns Whether or a dm was sent.
+ */
+ public async notify(message: Message, user: Snowflake, hl: HighlightWord): Promise<boolean> {
+ assert(message.inGuild());
+
+ this.client.console.debug(`Notifying ${user} of highlight ${hl.word} in ${message.guild.name}`);
+
+ dmCooldown: {
+ const lastDM = this.lastedDMedUserCooldown.get(user);
+ if (!lastDM?.[0]) break dmCooldown;
+
+ const cooldown = this.client.config.owners.includes(user) ? OWNER_NOTIFY_COOLDOWN : NOTIFY_COOLDOWN;
+
+ if (new Date().getTime() - lastDM[0].createdAt.getTime() < cooldown) {
+ void this.client.console.verbose('Highlight', `User <<${user}>> has been DMed recently.`);
+
+ if (lastDM[0].embeds.length < 10) {
+ this.client.console.debug(`Trying to add to notification queue for ${user}`);
+ return this.addToNotification(lastDM, message, hl);
+ }
+
+ this.client.console.debug(`User has too many embeds (${lastDM[0].embeds.length}).`);
+ return false;
+ }
+ }
+
+ talkCooldown: {
+ const lastTalked = this.userLastTalkedCooldown.get(message.guildId)?.get(user);
+ if (!lastTalked) break talkCooldown;
+
+ presence: {
+ // incase the bot left the guild
+ if (message.guild) {
+ const member = message.guild.members.cache.get(user);
+ if (!member) {
+ this.client.console.debug(`No member found for ${user} in ${message.guild.name}`);
+ break presence;
+ }
+
+ const presence = member.presence ?? (await member.fetch()).presence;
+ if (!presence) {
+ this.client.console.debug(`No presence found for ${user} in ${message.guild.name}`);
+ break presence;
+ }
+
+ if (presence.status === 'offline') {
+ void this.client.console.verbose('Highlight', `User <<${user}>> is offline.`);
+ break talkCooldown;
+ }
+ }
+ }
+
+ const now = new Date().getTime();
+ const talked = lastTalked.getTime();
+
+ if (now - talked < LAST_MESSAGE_COOLDOWN) {
+ void this.client.console.verbose('Highlight', `User <<${user}>> has talked too recently.`);
+
+ setTimeout(() => {
+ const newTalked = this.userLastTalkedCooldown.get(message.guildId)?.get(user)?.getTime();
+ if (talked !== newTalked) return;
+
+ void this.notify(message, user, hl);
+ }, LAST_MESSAGE_COOLDOWN).unref();
+
+ return false;
+ }
+ }
+
+ return this.client.users
+ .send(user, {
+ // eslint-disable-next-line @typescript-eslint/no-base-to-string
+ content: `In ${format.input(message.guild.name)} ${message.channel}, your highlight "${hl.word}" was matched:`,
+ embeds: [this.generateDmEmbed(message, hl)]
+ })
+ .then((dm) => {
+ this.lastedDMedUserCooldown.set(user, [dm, message.guildId!, message.channelId, [hl]]);
+ return true;
+ })
+ .catch(() => false);
+ }
+
+ private async addToNotification(
+ [originalDm, guild, channel, originalHl]: lastDmInfo,
+ message: Message,
+ hl: HighlightWord
+ ): Promise<boolean> {
+ assert(originalDm.embeds.length < 10);
+ assert(originalDm.embeds.length > 0);
+ assert(originalDm.channel.type === ChannelType.DM);
+ this.client.console.debug(
+ `Adding to notification queue for ${originalDm.channel.recipient?.tag ?? originalDm.channel.recipientId}`
+ );
+
+ const sameGuild = guild === message.guildId;
+ const sameChannel = channel === message.channel.id;
+ const sameWord = originalHl.every((w) => w.word === hl.word);
+
+ /* eslint-disable @typescript-eslint/no-base-to-string */
+ return originalDm
+ .edit({
+ content: `In ${sameGuild ? format.input(message.guild?.name ?? '[Unknown]') : 'multiple servers'} ${
+ sameChannel ? message.channel ?? '[Unknown]' : 'multiple channels'
+ }, ${sameWord ? `your highlight "${hl.word}" was matched:` : 'multiple highlights were matched:'}`,
+ embeds: [...originalDm.embeds.map((e) => e.toJSON()), this.generateDmEmbed(message, hl)]
+ })
+ .then(() => true)
+ .catch(() => false);
+ /* eslint-enable @typescript-eslint/no-base-to-string */
+ }
+
+ private generateDmEmbed(message: Message, hl: HighlightWord) {
+ const recentMessages = message.channel.messages.cache
+ .filter((m) => m.createdTimestamp <= message.createdTimestamp && m.id !== message.id)
+ .filter((m) => m.cleanContent?.trim().length > 0)
+ .sort((a, b) => b.createdTimestamp - a.createdTimestamp)
+ .first(4)
+ .reverse();
+
+ return {
+ description: [
+ // eslint-disable-next-line @typescript-eslint/no-base-to-string
+ message.channel!.toString(),
+ ...[...recentMessages, message].map(
+ (m) => `${timestamp(m.createdAt, 't')} ${format.input(`${m.author.tag}:`)} ${m.cleanContent.trim().substring(0, 512)}`
+ )
+ ].join('\n'),
+ author: { name: hl.regex ? `/${hl.word}/gi` : hl.word },
+ fields: [{ name: 'Source message', value: `[Jump to message](${message.url})` }],
+ color: colors.default,
+ footer: { text: `Triggered in ${sanitizeInputForDiscord(`${message.guild}`)}` },
+ timestamp: message.createdAt.toISOString()
+ };
+ }
+
+ /**
+ * Updates the time that a user last talked in a particular guild.
+ * @param message The message the user sent.
+ */
+ public updateLastTalked(message: Message): void {
+ if (!message.inGuild()) return;
+ const lastTalked = (
+ this.userLastTalkedCooldown.has(message.guildId)
+ ? this.userLastTalkedCooldown
+ : this.userLastTalkedCooldown.set(message.guildId, new Collection())
+ ).get(message.guildId)!;
+
+ lastTalked.set(message.author.id, new Date());
+ if (!HighlightManager.keep.has(message.author.id)) HighlightManager.keep.add(message.author.id);
+ }
+}
+
+export enum HighlightBlockResult {
+ ALREADY_BLOCKED,
+ ERROR,
+ SUCCESS
+}
+
+export enum HighlightUnblockResult {
+ NOT_BLOCKED,
+ ERROR,
+ SUCCESS
+}
diff --git a/lib/common/Moderation.ts b/lib/common/Moderation.ts
new file mode 100644
index 0000000..60e32c0
--- /dev/null
+++ b/lib/common/Moderation.ts
@@ -0,0 +1,556 @@
+import {
+ ActivePunishment,
+ ActivePunishmentType,
+ baseMuteResponse,
+ colors,
+ emojis,
+ format,
+ Guild as GuildDB,
+ humanizeDuration,
+ ModLog,
+ permissionsResponse,
+ type ModLogType,
+ type ValueOf
+} from '#lib';
+import assert from 'assert/strict';
+import {
+ ActionRowBuilder,
+ ButtonBuilder,
+ ButtonStyle,
+ Client,
+ EmbedBuilder,
+ PermissionFlagsBits,
+ type Guild,
+ type GuildMember,
+ type GuildMemberResolvable,
+ type GuildResolvable,
+ type Snowflake,
+ type UserResolvable
+} from 'discord.js';
+
+enum punishMap {
+ 'warned' = 'warn',
+ 'muted' = 'mute',
+ 'unmuted' = 'unmute',
+ 'kicked' = 'kick',
+ 'banned' = 'ban',
+ 'unbanned' = 'unban',
+ 'timedout' = 'timeout',
+ 'untimedout' = 'untimeout',
+ 'blocked' = 'block',
+ 'unblocked' = 'unblock'
+}
+enum reversedPunishMap {
+ 'warn' = 'warned',
+ 'mute' = 'muted',
+ 'unmute' = 'unmuted',
+ 'kick' = 'kicked',
+ 'ban' = 'banned',
+ 'unban' = 'unbanned',
+ 'timeout' = 'timedout',
+ 'untimeout' = 'untimedout',
+ 'block' = 'blocked',
+ 'unblock' = 'unblocked'
+}
+
+/**
+ * Checks if a moderator can perform a moderation action on another user.
+ * @param moderator The person trying to perform the action.
+ * @param victim The person getting punished.
+ * @param type The type of punishment - used to format the response.
+ * @param checkModerator Whether or not to check if the victim is a moderator.
+ * @param force Override permissions checks.
+ * @returns `true` if the moderator can perform the action otherwise a reason why they can't.
+ */
+export async function permissionCheck(
+ moderator: GuildMember,
+ victim: GuildMember,
+ type:
+ | 'mute'
+ | 'unmute'
+ | 'warn'
+ | 'kick'
+ | 'ban'
+ | 'unban'
+ | 'add a punishment role to'
+ | 'remove a punishment role from'
+ | 'block'
+ | 'unblock'
+ | 'timeout'
+ | 'untimeout',
+ checkModerator = true,
+ force = false
+): Promise<true | string> {
+ if (force) return true;
+
+ // If the victim is not in the guild anymore it will be undefined
+ if ((!victim || !victim.guild) && !['ban', 'unban'].includes(type)) return true;
+
+ if (moderator.guild.id !== victim.guild.id) {
+ throw new Error('moderator and victim not in same guild');
+ }
+
+ const isOwner = moderator.guild.ownerId === moderator.id;
+ if (moderator.id === victim.id && !type.startsWith('un')) {
+ return `${emojis.error} You cannot ${type} yourself.`;
+ }
+ if (
+ moderator.roles.highest.position <= victim.roles.highest.position &&
+ !isOwner &&
+ !(type.startsWith('un') && moderator.id === victim.id)
+ ) {
+ return `${emojis.error} You cannot ${type} **${victim.user.tag}** because they have higher or equal role hierarchy as you do.`;
+ }
+ if (
+ victim.roles.highest.position >= victim.guild.members.me!.roles.highest.position &&
+ !(type.startsWith('un') && moderator.id === victim.id)
+ ) {
+ return `${emojis.error} You cannot ${type} **${victim.user.tag}** because they have higher or equal role hierarchy as I do.`;
+ }
+ if (
+ checkModerator &&
+ victim.permissions.has(PermissionFlagsBits.ManageMessages) &&
+ !(type.startsWith('un') && moderator.id === victim.id)
+ ) {
+ if (await moderator.guild.hasFeature('modsCanPunishMods')) {
+ return true;
+ } else {
+ return `${emojis.error} You cannot ${type} **${victim.user.tag}** because they are a moderator.`;
+ }
+ }
+ return true;
+}
+
+/**
+ * Performs permission checks that are required in order to (un)mute a member.
+ * @param guild The guild to check the mute permissions in.
+ * @returns A {@link MuteResponse} or true if nothing failed.
+ */
+export async function checkMutePermissions(
+ guild: Guild
+): Promise<ValueOf<typeof baseMuteResponse> | ValueOf<typeof permissionsResponse> | true> {
+ if (!guild.members.me!.permissions.has('ManageRoles')) return permissionsResponse.MISSING_PERMISSIONS;
+ const muteRoleID = await guild.getSetting('muteRole');
+ if (!muteRoleID) return baseMuteResponse.NO_MUTE_ROLE;
+ const muteRole = guild.roles.cache.get(muteRoleID);
+ if (!muteRole) return baseMuteResponse.MUTE_ROLE_INVALID;
+ if (muteRole.position >= guild.members.me!.roles.highest.position || muteRole.managed)
+ return baseMuteResponse.MUTE_ROLE_NOT_MANAGEABLE;
+
+ return true;
+}
+
+/**
+ * Creates a modlog entry for a punishment.
+ * @param options Options for creating a modlog entry.
+ * @param getCaseNumber Whether or not to get the case number of the entry.
+ * @returns An object with the modlog and the case number.
+ */
+export async function createModLogEntry(
+ options: CreateModLogEntryOptions,
+ getCaseNumber = false
+): Promise<{ log: ModLog | null; caseNum: number | null }> {
+ const user = (await options.client.utils.resolveNonCachedUser(options.user))!.id;
+ const moderator = (await options.client.utils.resolveNonCachedUser(options.moderator))!.id;
+ const guild = options.client.guilds.resolveId(options.guild)!;
+
+ return createModLogEntrySimple(
+ {
+ ...options,
+ user: user,
+ moderator: moderator,
+ guild: guild
+ },
+ getCaseNumber
+ );
+}
+
+/**
+ * Creates a modlog entry with already resolved ids.
+ * @param options Options for creating a modlog entry.
+ * @param getCaseNumber Whether or not to get the case number of the entry.
+ * @returns An object with the modlog and the case number.
+ */
+export async function createModLogEntrySimple(
+ options: SimpleCreateModLogEntryOptions,
+ getCaseNumber = false
+): Promise<{ log: ModLog | null; caseNum: number | null }> {
+ // If guild does not exist create it so the modlog can reference a guild.
+ await GuildDB.findOrCreate({
+ where: { id: options.guild },
+ defaults: { id: options.guild }
+ });
+
+ const modLogEntry = ModLog.build({
+ type: options.type,
+ user: options.user,
+ moderator: options.moderator,
+ reason: options.reason,
+ duration: options.duration ? options.duration : undefined,
+ guild: options.guild,
+ pseudo: options.pseudo ?? false,
+ evidence: options.evidence,
+ hidden: options.hidden ?? false
+ });
+ const saveResult: ModLog | null = await modLogEntry.save().catch(async (e) => {
+ await options.client.utils.handleError('createModLogEntry', e);
+ return null;
+ });
+
+ if (!getCaseNumber) return { log: saveResult, caseNum: null };
+
+ const caseNum = (
+ await ModLog.findAll({ where: { type: options.type, user: options.user, guild: options.guild, hidden: false } })
+ )?.length;
+ return { log: saveResult, caseNum };
+}
+
+/**
+ * Creates a punishment entry.
+ * @param options Options for creating the punishment entry.
+ * @returns The database entry, or null if no entry is created.
+ */
+export async function createPunishmentEntry(options: CreatePunishmentEntryOptions): Promise<ActivePunishment | null> {
+ const expires = options.duration ? new Date(+new Date() + options.duration ?? 0) : undefined;
+ const user = (await options.client.utils.resolveNonCachedUser(options.user))!.id;
+ const guild = options.client.guilds.resolveId(options.guild)!;
+ const type = findTypeEnum(options.type)!;
+
+ const entry = ActivePunishment.build(
+ options.extraInfo
+ ? { user, type, guild, expires, modlog: options.modlog, extraInfo: options.extraInfo }
+ : { user, type, guild, expires, modlog: options.modlog }
+ );
+ return await entry.save().catch(async (e) => {
+ await options.client.utils.handleError('createPunishmentEntry', e);
+ return null;
+ });
+}
+
+/**
+ * Destroys a punishment entry.
+ * @param options Options for destroying the punishment entry.
+ * @returns Whether or not the entry was destroyed.
+ */
+export async function removePunishmentEntry(options: RemovePunishmentEntryOptions): Promise<boolean> {
+ const user = await options.client.utils.resolveNonCachedUser(options.user);
+ const guild = options.client.guilds.resolveId(options.guild);
+ const type = findTypeEnum(options.type);
+
+ if (!user || !guild) return false;
+
+ let success = true;
+
+ const entries = await ActivePunishment.findAll({
+ // finding all cases of a certain type incase there were duplicates or something
+ where: options.extraInfo
+ ? { user: user.id, guild: guild, type, extraInfo: options.extraInfo }
+ : { user: user.id, guild: guild, type }
+ }).catch(async (e) => {
+ await options.client.utils.handleError('removePunishmentEntry', e);
+ success = false;
+ });
+ if (entries) {
+ const promises = entries.map(async (entry) =>
+ entry.destroy().catch(async (e) => {
+ await options.client.utils.handleError('removePunishmentEntry', e);
+ success = false;
+ })
+ );
+
+ await Promise.all(promises);
+ }
+ return success;
+}
+
+/**
+ * Returns the punishment type enum for the given type.
+ * @param type The type of the punishment.
+ * @returns The punishment type enum.
+ */
+function findTypeEnum(type: 'mute' | 'ban' | 'role' | 'block') {
+ const typeMap = {
+ ['mute']: ActivePunishmentType.MUTE,
+ ['ban']: ActivePunishmentType.BAN,
+ ['role']: ActivePunishmentType.ROLE,
+ ['block']: ActivePunishmentType.BLOCK
+ };
+ return typeMap[type];
+}
+
+export function punishmentToPresentTense(punishment: PunishmentTypeDM): PunishmentTypePresent {
+ return punishMap[punishment];
+}
+
+export function punishmentToPastTense(punishment: PunishmentTypePresent): PunishmentTypeDM {
+ return reversedPunishMap[punishment];
+}
+
+/**
+ * Notifies the specified user of their punishment.
+ * @param options Options for notifying the user.
+ * @returns Whether or not the dm was successfully sent.
+ */
+export async function punishDM(options: PunishDMOptions): Promise<boolean> {
+ const ending = await options.guild.getSetting('punishmentEnding');
+ const dmEmbed =
+ ending && ending.length && options.sendFooter
+ ? new EmbedBuilder().setDescription(ending).setColor(colors.newBlurple)
+ : undefined;
+
+ const appealsEnabled = !!(
+ (await options.guild.hasFeature('punishmentAppeals')) && (await options.guild.getLogChannel('appeals'))
+ );
+
+ let content = `You have been ${options.punishment} `;
+ if (options.punishment.includes('blocked')) {
+ assert(options.channel);
+ content += `from <#${options.channel}> `;
+ }
+ content += `in ${format.input(options.guild.name)} `;
+ if (options.duration !== null && options.duration !== undefined)
+ content += options.duration ? `for ${humanizeDuration(options.duration)} ` : 'permanently ';
+ const reason = options.reason?.trim() ? options.reason?.trim() : 'No reason provided';
+ content += `for ${format.input(reason)}.`;
+
+ let components;
+ if (appealsEnabled && options.modlog)
+ components = [
+ new ActionRowBuilder<ButtonBuilder>({
+ components: [
+ new ButtonBuilder({
+ customId: `appeal;${punishmentToPresentTense(options.punishment)};${
+ options.guild.id
+ };${options.client.users.resolveId(options.user)};${options.modlog}`,
+ style: ButtonStyle.Primary,
+ label: 'Appeal'
+ }).toJSON()
+ ]
+ })
+ ];
+
+ const dmSuccess = await options.client.users
+ .send(options.user, {
+ content,
+ embeds: dmEmbed ? [dmEmbed] : undefined,
+ components
+ })
+ .catch(() => false);
+ return !!dmSuccess;
+}
+
+interface BaseCreateModLogEntryOptions extends BaseOptions {
+ /**
+ * The type of modlog entry.
+ */
+ type: ModLogType;
+
+ /**
+ * The reason for the punishment.
+ */
+ reason: string | undefined | null;
+
+ /**
+ * The duration of the punishment.
+ */
+ duration?: number;
+
+ /**
+ * Whether the punishment is a pseudo punishment.
+ */
+ pseudo?: boolean;
+
+ /**
+ * The evidence for the punishment.
+ */
+ evidence?: string;
+
+ /**
+ * Makes the modlog entry hidden.
+ */
+ hidden?: boolean;
+}
+
+/**
+ * Options for creating a modlog entry.
+ */
+export interface CreateModLogEntryOptions extends BaseCreateModLogEntryOptions {
+ /**
+ * The client.
+ */
+ client: Client;
+
+ /**
+ * The user that a modlog entry is created for.
+ */
+ user: GuildMemberResolvable;
+
+ /**
+ * The moderator that created the modlog entry.
+ */
+ moderator: GuildMemberResolvable;
+
+ /**
+ * The guild that the punishment is created for.
+ */
+ guild: GuildResolvable;
+}
+
+/**
+ * Simple options for creating a modlog entry.
+ */
+export interface SimpleCreateModLogEntryOptions extends BaseCreateModLogEntryOptions {
+ /**
+ * The user that a modlog entry is created for.
+ */
+ user: Snowflake;
+
+ /**
+ * The moderator that created the modlog entry.
+ */
+ moderator: Snowflake;
+
+ /**
+ * The guild that the punishment is created for.
+ */
+ guild: Snowflake;
+}
+
+/**
+ * Options for creating a punishment entry.
+ */
+export interface CreatePunishmentEntryOptions extends BaseOptions {
+ /**
+ * The type of punishment.
+ */
+ type: 'mute' | 'ban' | 'role' | 'block';
+
+ /**
+ * The user that the punishment is created for.
+ */
+ user: GuildMemberResolvable;
+
+ /**
+ * The length of time the punishment lasts for.
+ */
+ duration: number | undefined;
+
+ /**
+ * The guild that the punishment is created for.
+ */
+ guild: GuildResolvable;
+
+ /**
+ * The id of the modlog that is linked to the punishment entry.
+ */
+ modlog: string;
+
+ /**
+ * Extra information for the punishment. The role for role punishments and the channel for blocks.
+ */
+ extraInfo?: Snowflake;
+}
+
+/**
+ * Options for removing a punishment entry.
+ */
+export interface RemovePunishmentEntryOptions extends BaseOptions {
+ /**
+ * The type of punishment.
+ */
+ type: 'mute' | 'ban' | 'role' | 'block';
+
+ /**
+ * The user that the punishment is destroyed for.
+ */
+ user: GuildMemberResolvable;
+
+ /**
+ * The guild that the punishment was in.
+ */
+ guild: GuildResolvable;
+
+ /**
+ * Extra information for the punishment. The role for role punishments and the channel for blocks.
+ */
+ extraInfo?: Snowflake;
+}
+
+/**
+ * Options for sending a user a punishment dm.
+ */
+export interface PunishDMOptions extends BaseOptions {
+ /**
+ * The modlog case id so the user can make an appeal.
+ */
+ modlog?: string;
+
+ /**
+ * The guild that the punishment is taking place in.
+ */
+ guild: Guild;
+
+ /**
+ * The user that is being punished.
+ */
+ user: UserResolvable;
+
+ /**
+ * The punishment that the user has received.
+ */
+ punishment: PunishmentTypeDM;
+
+ /**
+ * The reason the user's punishment.
+ */
+ reason?: string;
+
+ /**
+ * The duration of the punishment.
+ */
+ duration?: number;
+
+ /**
+ * Whether or not to send the guild's punishment footer with the dm.
+ * @default true
+ */
+ sendFooter: boolean;
+
+ /**
+ * The channel that the user was (un)blocked from.
+ */
+ channel?: Snowflake;
+}
+
+interface BaseOptions {
+ /**
+ * The client.
+ */
+ client: Client;
+}
+
+export type PunishmentTypeDM =
+ | 'warned'
+ | 'muted'
+ | 'unmuted'
+ | 'kicked'
+ | 'banned'
+ | 'unbanned'
+ | 'timedout'
+ | 'untimedout'
+ | 'blocked'
+ | 'unblocked';
+
+export type PunishmentTypePresent =
+ | 'warn'
+ | 'mute'
+ | 'unmute'
+ | 'kick'
+ | 'ban'
+ | 'unban'
+ | 'timeout'
+ | 'untimeout'
+ | 'block'
+ | 'unblock';
+
+export type AppealButtonId = `appeal;${PunishmentTypePresent};${Snowflake};${Snowflake};${string}`;
diff --git a/lib/common/Sentry.ts b/lib/common/Sentry.ts
new file mode 100644
index 0000000..446ec27
--- /dev/null
+++ b/lib/common/Sentry.ts
@@ -0,0 +1,24 @@
+import { RewriteFrames } from '@sentry/integrations';
+import * as SentryNode from '@sentry/node';
+import { Integrations } from '@sentry/node';
+import type { Config } from '../../config/Config.js';
+
+export class Sentry {
+ public constructor(rootdir: string, config: Config) {
+ if (config.credentials.sentryDsn === null) throw TypeError('sentryDsn cannot be null');
+
+ SentryNode.init({
+ dsn: config.credentials.sentryDsn,
+ environment: config.environment,
+ tracesSampleRate: 1.0,
+ integrations: [
+ new RewriteFrames({
+ root: rootdir
+ }),
+ new Integrations.OnUnhandledRejection({
+ mode: 'none'
+ })
+ ]
+ });
+ }
+}
diff --git a/lib/common/tags.ts b/lib/common/tags.ts
new file mode 100644
index 0000000..098cf29
--- /dev/null
+++ b/lib/common/tags.ts
@@ -0,0 +1,34 @@
+/* these functions are adapted from the common-tags npm package which is licensed under the MIT license */
+/* the js docs are adapted from the @types/common-tags npm package which is licensed under the MIT license */
+
+/**
+ * Strips the **initial** indentation from the beginning of each line in a multiline string.
+ */
+export function stripIndent(strings: TemplateStringsArray, ...expressions: any[]) {
+ const str = format(strings, ...expressions);
+ // remove the shortest leading indentation from each line
+ const match = str.match(/^[^\S\n]*(?=\S)/gm);
+ const indent = match && Math.min(...match.map((el) => el.length));
+ if (indent) {
+ const regexp = new RegExp(`^.{${indent}}`, 'gm');
+ return str.replace(regexp, '');
+ }
+ return str;
+}
+
+/**
+ * Strips **all** of the indentation from the beginning of each line in a multiline string.
+ */
+export function stripIndents(strings: TemplateStringsArray, ...expressions: any[]) {
+ const str = format(strings, ...expressions);
+ // remove all indentation from each line
+ return str.replace(/^[^\S\n]+/gm, '');
+}
+
+function format(strings: TemplateStringsArray, ...expressions: any[]) {
+ const str = strings
+ .reduce((result, string, index) => ''.concat(result, expressions[index - 1], string))
+ .replace(/[^\S\n]+$/gm, '')
+ .replace(/^\n/, '');
+ return str;
+}