aboutsummaryrefslogtreecommitdiff
path: root/lib/common/HighlightManager.ts
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/HighlightManager.ts
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/HighlightManager.ts')
-rw-r--r--lib/common/HighlightManager.ts488
1 files changed, 488 insertions, 0 deletions
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
+}