aboutsummaryrefslogtreecommitdiff
path: root/lib/common
diff options
context:
space:
mode:
authorIRONM00N <64110067+IRONM00N@users.noreply.github.com>2022-08-18 22:42:12 -0400
committerIRONM00N <64110067+IRONM00N@users.noreply.github.com>2022-08-18 22:42:12 -0400
commit2356d2c44736fb83021dacb551625852111c8ce6 (patch)
tree10408d22fdd7a358d2f5c5917c3b59e55aa4c19d /lib/common
parent8aed6f93f7740c592cbc0e2f9fd3269c05286077 (diff)
downloadtanzanite-2356d2c44736fb83021dacb551625852111c8ce6.tar.gz
tanzanite-2356d2c44736fb83021dacb551625852111c8ce6.tar.bz2
tanzanite-2356d2c44736fb83021dacb551625852111c8ce6.zip
restructure, experimental presence and member automod, fixed bugs probably made some more bugs
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,