import { addToArray, format, Highlight, removeFromArray, timestamp, type HighlightWord } from '#lib'; import assert from 'assert/strict'; import { Collection, GuildMember, type Channel, type Client, type Message, type Snowflake, type TextBasedChannel } from 'discord.js'; import { colors, Time } from '../utils/BushConstants.js'; const NOTIFY_COOLDOWN = 5 * Time.Minute; const OWNER_NOTIFY_COOLDOWN = 1 * Time.Minute; const LAST_MESSAGE_COOLDOWN = 5 * Time.Minute; type users = Set; type channels = Set; type word = HighlightWord; type guild = Snowflake; type user = Snowflake; type lastMessage = Date; type lastDM = Date; export class HighlightManager { /** * Cached guild highlights. */ public readonly guildHighlights = new Collection>(); // /** // * Cached global highlights. // */ // public readonly globalHighlights = new Collection(); /** * A collection of cooldowns of when a user last sent a message in a particular guild. */ public readonly userLastTalkedCooldown = new Collection>(); /** * Users that users have blocked */ public readonly userBlocks = new Collection>(); /** * Channels that users have blocked */ public readonly channelBlocks = new Collection>(); /** * A collection of cooldowns of when the bot last sent each user a highlight message. */ public readonly lastedDMedUserCooldown = new Collection(); /** * @param client The client to use. */ public constructor(public client: Client) {} /** * Sync the cache with the database. */ public async syncCache(): Promise { 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 { // even if there are multiple matches, only the first one is returned const ret = new Collection(); 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 <<${user}>> blocked the user <<${message.author.id}>>` ); 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 <<${user}>> blocked the channel <<${message.channel.id}>>` ); continue; } if (message.mentions.has(user)) { void this.client.console.verbose( 'Highlight', `Highlight ignored because <<${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> { const highlights = await Highlight.findAll({ where: { guild, user } }); const results = new Collection(); 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 { 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 { 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 { 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 { 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 { 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 { assert(message.inGuild()); dmCooldown: { const lastDM = this.lastedDMedUserCooldown.get(user); if (!lastDM) break dmCooldown; const cooldown = message.client.config.owners.includes(user) ? OWNER_NOTIFY_COOLDOWN : NOTIFY_COOLDOWN; if (new Date().getTime() - lastDM.getTime() < cooldown) { void message.client.console.verbose('Highlight', `User <<${user}>> has been dmed recently.`); 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.client.guilds.cache.has(message.guildId)) { const guild = message.client.guilds.cache.get(message.guildId)!; const member = guild.members.cache.get(user); if (!member) break presence; const presence = member.presence; if (!presence) break presence; if (presence.status === 'offline') { void message.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 message.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; } } 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 message.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: [ { description: [...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' }, timestamp: message.createdAt.toISOString() } ] }) .then(() => { this.lastedDMedUserCooldown.set(user, new Date()); return true; }) .catch(() => false); } /** * 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()); } } export enum HighlightBlockResult { ALREADY_BLOCKED, ERROR, SUCCESS } export enum HighlightUnblockResult { NOT_BLOCKED, ERROR, SUCCESS }