diff options
| author | IRONM00N <64110067+IRONM00N@users.noreply.github.com> | 2022-08-18 22:42:12 -0400 |
|---|---|---|
| committer | IRONM00N <64110067+IRONM00N@users.noreply.github.com> | 2022-08-18 22:42:12 -0400 |
| commit | 2356d2c44736fb83021dacb551625852111c8ce6 (patch) | |
| tree | 10408d22fdd7a358d2f5c5917c3b59e55aa4c19d /lib/common | |
| parent | 8aed6f93f7740c592cbc0e2f9fd3269c05286077 (diff) | |
| download | tanzanite-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.ts | 26 | ||||
| -rw-r--r-- | lib/common/ButtonPaginator.ts | 224 | ||||
| -rw-r--r-- | lib/common/CanvasProgressBar.ts | 83 | ||||
| -rw-r--r-- | lib/common/ConfirmationPrompt.ts | 64 | ||||
| -rw-r--r-- | lib/common/DeleteButton.ts | 78 | ||||
| -rw-r--r-- | lib/common/HighlightManager.ts | 488 | ||||
| -rw-r--r-- | lib/common/Moderation.ts | 556 | ||||
| -rw-r--r-- | lib/common/Sentry.ts | 24 | ||||
| -rw-r--r-- | lib/common/tags.ts | 34 |
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, |
