diff options
Diffstat (limited to 'lib')
70 files changed, 18458 insertions, 0 deletions
diff --git a/lib/arguments/abbreviatedNumber.ts b/lib/arguments/abbreviatedNumber.ts new file mode 100644 index 0000000..a7d8ce5 --- /dev/null +++ b/lib/arguments/abbreviatedNumber.ts @@ -0,0 +1,13 @@ +import type { BushArgumentTypeCaster } from '#lib'; +import assert from 'assert/strict'; +import numeral from 'numeral'; +assert(typeof numeral === 'function'); + +export const abbreviatedNumber: BushArgumentTypeCaster<number | null> = (_, phrase) => { + if (!phrase) return null; + const num = numeral(phrase?.toLowerCase()).value(); + + if (typeof num !== 'number' || isNaN(num)) return null; + + return num; +}; diff --git a/lib/arguments/contentWithDuration.ts b/lib/arguments/contentWithDuration.ts new file mode 100644 index 0000000..0efba39 --- /dev/null +++ b/lib/arguments/contentWithDuration.ts @@ -0,0 +1,5 @@ +import { parseDuration, type BushArgumentTypeCaster, type ParsedDuration } from '#lib'; + +export const contentWithDuration: BushArgumentTypeCaster<Promise<ParsedDuration>> = async (_, phrase) => { + return parseDuration(phrase); +}; diff --git a/lib/arguments/discordEmoji.ts b/lib/arguments/discordEmoji.ts new file mode 100644 index 0000000..92d6502 --- /dev/null +++ b/lib/arguments/discordEmoji.ts @@ -0,0 +1,14 @@ +import { regex, type BushArgumentTypeCaster } from '#lib'; +import type { Snowflake } from 'discord.js'; + +export const discordEmoji: BushArgumentTypeCaster<DiscordEmojiInfo | null> = (_, phrase) => { + if (!phrase) return null; + const validEmoji: RegExpExecArray | null = regex.discordEmoji.exec(phrase); + if (!validEmoji || !validEmoji.groups) return null; + return { name: validEmoji.groups.name, id: validEmoji.groups.id }; +}; + +export interface DiscordEmojiInfo { + name: string; + id: Snowflake; +} diff --git a/lib/arguments/duration.ts b/lib/arguments/duration.ts new file mode 100644 index 0000000..09dd3d5 --- /dev/null +++ b/lib/arguments/duration.ts @@ -0,0 +1,5 @@ +import { parseDuration, type BushArgumentTypeCaster } from '#lib'; + +export const duration: BushArgumentTypeCaster<number | null> = (_, phrase) => { + return parseDuration(phrase).duration; +}; diff --git a/lib/arguments/durationSeconds.ts b/lib/arguments/durationSeconds.ts new file mode 100644 index 0000000..d8d6749 --- /dev/null +++ b/lib/arguments/durationSeconds.ts @@ -0,0 +1,6 @@ +import { parseDuration, type BushArgumentTypeCaster } from '#lib'; + +export const durationSeconds: BushArgumentTypeCaster<number | null> = (_, phrase) => { + phrase += 's'; + return parseDuration(phrase).duration; +}; diff --git a/lib/arguments/globalUser.ts b/lib/arguments/globalUser.ts new file mode 100644 index 0000000..4324aa9 --- /dev/null +++ b/lib/arguments/globalUser.ts @@ -0,0 +1,7 @@ +import type { BushArgumentTypeCaster } from '#lib'; +import type { User } from 'discord.js'; + +// resolve non-cached users +export const globalUser: BushArgumentTypeCaster<Promise<User | null>> = async (message, phrase) => { + return message.client.users.resolve(phrase) ?? (await message.client.users.fetch(`${phrase}`).catch(() => null)); +}; diff --git a/lib/arguments/index.ts b/lib/arguments/index.ts new file mode 100644 index 0000000..eebf0a2 --- /dev/null +++ b/lib/arguments/index.ts @@ -0,0 +1,10 @@ +export * from './abbreviatedNumber.js'; +export * from './contentWithDuration.js'; +export * from './discordEmoji.js'; +export * from './duration.js'; +export * from './durationSeconds.js'; +export * from './globalUser.js'; +export * from './messageLink.js'; +export * from './permission.js'; +export * from './roleWithDuration.js'; +export * from './snowflake.js'; diff --git a/lib/arguments/messageLink.ts b/lib/arguments/messageLink.ts new file mode 100644 index 0000000..c95e42d --- /dev/null +++ b/lib/arguments/messageLink.ts @@ -0,0 +1,20 @@ +import { BushArgumentTypeCaster, regex } from '#lib'; +import type { Message } from 'discord.js'; + +export const messageLink: BushArgumentTypeCaster<Promise<Message | null>> = async (message, phrase) => { + const match = new RegExp(regex.messageLink).exec(phrase); + if (!match || !match.groups) return null; + + const { guild_id, channel_id, message_id } = match.groups; + + if (!guild_id || !channel_id || message_id) return null; + + const guild = message.client.guilds.cache.get(guild_id); + if (!guild) return null; + + const channel = guild.channels.cache.get(channel_id); + if (!channel || (!channel.isTextBased() && !channel.isThread())) return null; + + const msg = await channel.messages.fetch(message_id).catch(() => null); + return msg; +}; diff --git a/lib/arguments/permission.ts b/lib/arguments/permission.ts new file mode 100644 index 0000000..98bfe74 --- /dev/null +++ b/lib/arguments/permission.ts @@ -0,0 +1,12 @@ +import type { BushArgumentTypeCaster } from '#lib'; +import { PermissionFlagsBits, type PermissionsString } from 'discord.js'; + +export const permission: BushArgumentTypeCaster<PermissionsString | null> = (_, phrase) => { + if (!phrase) return null; + phrase = phrase.toUpperCase().replace(/ /g, '_'); + if (!(phrase in PermissionFlagsBits)) { + return null; + } else { + return phrase as PermissionsString; + } +}; diff --git a/lib/arguments/roleWithDuration.ts b/lib/arguments/roleWithDuration.ts new file mode 100644 index 0000000..b97f205 --- /dev/null +++ b/lib/arguments/roleWithDuration.ts @@ -0,0 +1,17 @@ +import { Arg, BushArgumentTypeCaster, parseDuration } from '#lib'; +import type { Role } from 'discord.js'; + +export const roleWithDuration: BushArgumentTypeCaster<Promise<RoleWithDuration | null>> = async (message, phrase) => { + // eslint-disable-next-line prefer-const + let { duration, content } = parseDuration(phrase); + if (content === null || content === undefined) return null; + content = content.trim(); + const role = await Arg.cast('role', message, content); + if (!role) return null; + return { duration, role }; +}; + +export interface RoleWithDuration { + duration: number | null; + role: Role | null; +} diff --git a/lib/arguments/snowflake.ts b/lib/arguments/snowflake.ts new file mode 100644 index 0000000..b98a20f --- /dev/null +++ b/lib/arguments/snowflake.ts @@ -0,0 +1,8 @@ +import { BushArgumentTypeCaster, regex } from '#lib'; +import type { Snowflake } from 'discord.js'; + +export const snowflake: BushArgumentTypeCaster<Snowflake | null> = (_, phrase) => { + if (!phrase) return null; + if (regex.snowflake.test(phrase)) return phrase; + return null; +}; diff --git a/lib/arguments/tinyColor.ts b/lib/arguments/tinyColor.ts new file mode 100644 index 0000000..148c078 --- /dev/null +++ b/lib/arguments/tinyColor.ts @@ -0,0 +1,10 @@ +import type { BushArgumentTypeCaster } from '#lib'; +import assert from 'assert/strict'; +import tinycolorModule from 'tinycolor2'; +assert(tinycolorModule); + +export const tinyColor: BushArgumentTypeCaster<string | null> = (_message, phrase) => { + // if the phase is a number it converts it to hex incase it could be representing a color in decimal + const newPhase = isNaN(phrase as any) ? phrase : `#${Number(phrase).toString(16)}`; + return tinycolorModule(newPhase).isValid() ? newPhase : null; +}; diff --git a/lib/automod/AutomodShared.ts b/lib/automod/AutomodShared.ts new file mode 100644 index 0000000..5d031d0 --- /dev/null +++ b/lib/automod/AutomodShared.ts @@ -0,0 +1,310 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonInteraction, + ButtonStyle, + GuildMember, + Message, + PermissionFlagsBits, + Snowflake +} from 'discord.js'; +import UnmuteCommand from '../../src/commands/moderation/unmute.js'; +import * as Moderation from '../common/Moderation.js'; +import { unmuteResponse } from '../extensions/discord.js/ExtendedGuildMember.js'; +import { colors, emojis } from '../utils/BushConstants.js'; +import * as Format from '../utils/Format.js'; + +/** + * Handles shared auto moderation functionality. + */ +export abstract class Automod { + /** + * Whether or not a punishment has already been given to the user + */ + protected punished = false; + + /** + * @param member The guild member that the automod is checking + */ + protected constructor(protected readonly member: GuildMember) {} + + /** + * The user + */ + protected get user() { + return this.member.user; + } + + /** + * The client instance + */ + protected get client() { + return this.member.client; + } + + /** + * The guild member that the automod is checking + */ + protected get guild() { + return this.member.guild; + } + + /** + * Whether or not the member should be immune to auto moderation + */ + protected get isImmune() { + if (this.member.user.isOwner()) return true; + if (this.member.guild.ownerId === this.member.id) return true; + if (this.member.permissions.has('Administrator')) return true; + + return false; + } + + protected buttons(userId: Snowflake, reason: string, undo = true): ActionRowBuilder<ButtonBuilder> { + const row = new ActionRowBuilder<ButtonBuilder>().addComponents([ + new ButtonBuilder({ + style: ButtonStyle.Danger, + label: 'Ban User', + customId: `automod;ban;${userId};${reason}` + }) + ]); + + if (undo) { + row.addComponents( + new ButtonBuilder({ + style: ButtonStyle.Success, + label: 'Unmute User', + customId: `automod;unmute;${userId}` + }) + ); + } + + return row; + } + + protected logColor(severity: Severity) { + switch (severity) { + case Severity.DELETE: + return colors.lightGray; + case Severity.WARN: + return colors.yellow; + case Severity.TEMP_MUTE: + return colors.orange; + case Severity.PERM_MUTE: + return colors.red; + } + throw new Error(`Unknown severity: ${severity}`); + } + + /** + * Checks if any of the words provided are in the message + * @param words The words to check for + * @returns The blacklisted words found in the message + */ + protected checkWords(words: BadWordDetails[], str: string): BadWordDetails[] { + if (words.length === 0) return []; + + const matchedWords: BadWordDetails[] = []; + for (const word of words) { + if (word.regex) { + if (new RegExp(word.match).test(this.format(word.match, word))) { + matchedWords.push(word); + } + } else { + if (this.format(str, word).includes(this.format(word.match, word))) { + matchedWords.push(word); + } + } + } + return matchedWords; + } + + /** + * Format a string according to the word options + * @param string The string to format + * @param wordOptions The word options to format with + * @returns The formatted string + */ + protected format(string: string, wordOptions: BadWordDetails) { + const temp = wordOptions.ignoreCapitalization ? string.toLowerCase() : string; + return wordOptions.ignoreSpaces ? temp.replace(/ /g, '') : temp; + } + + /** + * Handles the auto moderation + */ + protected abstract handle(): Promise<void>; +} + +/** + * Handles the ban button in the automod log. + * @param interaction The button interaction. + */ +export async function handleAutomodInteraction(interaction: ButtonInteraction) { + if (!interaction.memberPermissions?.has(PermissionFlagsBits.BanMembers)) + return interaction.reply({ + content: `${emojis.error} You are missing the **Ban Members** permission.`, + ephemeral: true + }); + const [action, userId, reason] = interaction.customId.replace('automod;', '').split(';') as ['ban' | 'unmute', string, string]; + + if (!(['ban', 'unmute'] as const).includes(action)) throw new TypeError(`Invalid automod button action: ${action}`); + + const victim = await interaction.guild!.members.fetch(userId).catch(() => null); + const moderator = + interaction.member instanceof GuildMember ? interaction.member : await interaction.guild!.members.fetch(interaction.user.id); + + switch (action) { + case 'ban': { + if (!interaction.guild?.members.me?.permissions.has('BanMembers')) + return interaction.reply({ + content: `${emojis.error} I do not have permission to ${action} members.`, + ephemeral: true + }); + + const check = victim ? await Moderation.permissionCheck(moderator, victim, 'ban', true) : true; + if (check !== true) return interaction.reply({ content: check, ephemeral: true }); + + const result = await interaction.guild?.bushBan({ + user: userId, + reason, + moderator: interaction.user.id, + evidence: (interaction.message as Message).url ?? undefined + }); + + const victimUserFormatted = (await interaction.client.utils.resolveNonCachedUser(userId))?.tag ?? userId; + + const content = (() => { + if (result === unmuteResponse.SUCCESS) { + return `${emojis.success} Successfully banned ${Format.input(victimUserFormatted)}.`; + } else if (result === unmuteResponse.DM_ERROR) { + return `${emojis.warn} Banned ${Format.input(victimUserFormatted)} however I could not send them a dm.`; + } else { + return `${emojis.error} Could not ban ${Format.input(victimUserFormatted)}: \`${result}\` .`; + } + })(); + + return interaction.reply({ + content: content, + ephemeral: true + }); + } + + case 'unmute': { + if (!victim) + return interaction.reply({ + content: `${emojis.error} Cannot find member, they may have left the server.`, + ephemeral: true + }); + + if (!interaction.guild) + return interaction.reply({ + content: `${emojis.error} This is weird, I don't seem to be in the server...`, + ephemeral: true + }); + + const check = await Moderation.permissionCheck(moderator, victim, 'unmute', true); + if (check !== true) return interaction.reply({ content: check, ephemeral: true }); + + const check2 = await Moderation.checkMutePermissions(interaction.guild); + if (check2 !== true) return interaction.reply({ content: UnmuteCommand.formatCode('/', victim!, check2), ephemeral: true }); + + const result = await victim.bushUnmute({ + reason, + moderator: interaction.member as GuildMember, + evidence: (interaction.message as Message).url ?? undefined + }); + + const victimUserFormatted = victim.user.tag; + + const content = (() => { + if (result === unmuteResponse.SUCCESS) { + return `${emojis.success} Successfully unmuted ${Format.input(victimUserFormatted)}.`; + } else if (result === unmuteResponse.DM_ERROR) { + return `${emojis.warn} Unmuted ${Format.input(victimUserFormatted)} however I could not send them a dm.`; + } else { + return `${emojis.error} Could not unmute ${Format.input(victimUserFormatted)}: \`${result}\` .`; + } + })(); + + return interaction.reply({ + content: content, + ephemeral: true + }); + } + } +} + +/** + * The severity of the blacklisted word + */ +export const enum Severity { + /** + * Delete message + */ + DELETE, + + /** + * Delete message and warn user + */ + WARN, + + /** + * Delete message and mute user for 15 minutes + */ + TEMP_MUTE, + + /** + * Delete message and mute user permanently + */ + PERM_MUTE +} + +/** + * Details about a blacklisted word + */ +export interface BadWordDetails { + /** + * The word that is blacklisted + */ + match: string; + + /** + * The severity of the word + */ + severity: Severity | 1 | 2 | 3; + + /** + * Whether or not to ignore spaces when checking for the word + */ + ignoreSpaces: boolean; + + /** + * Whether or not to ignore case when checking for the word + */ + ignoreCapitalization: boolean; + + /** + * The reason that this word is blacklisted (used for the punishment reason) + */ + reason: string; + + /** + * Whether or not the word is regex + * @default false + */ + regex: boolean; + + /** + * Whether to also check a user's status and username for the phrase + * @default false + */ + userInfo: boolean; +} + +/** + * Blacklisted words mapped to their details + */ +export interface BadWords { + [category: string]: BadWordDetails[]; +} diff --git a/lib/automod/MemberAutomod.ts b/lib/automod/MemberAutomod.ts new file mode 100644 index 0000000..6f71457 --- /dev/null +++ b/lib/automod/MemberAutomod.ts @@ -0,0 +1,72 @@ +import { stripIndent } from '#tags'; +import { EmbedBuilder, GuildMember } from 'discord.js'; +import { Automod, BadWordDetails } from './AutomodShared.js'; + +export class MemberAutomod extends Automod { + /** + * @param member The member that the automod is checking + */ + public constructor(member: GuildMember) { + super(member); + + if (member.id === member.client.user?.id) return; + + void this.handle(); + } + + protected async handle(): Promise<void> { + if (this.member.user.bot) return; + + const badWordsRaw = Object.values(this.client.utils.getShared('badWords')).flat(); + const customAutomodPhrases = (await this.guild.getSetting('autoModPhases')) ?? []; + + const phrases = [...badWordsRaw, ...customAutomodPhrases].filter((p) => p.userInfo); + + const result: BadWordDetails[] = []; + + const str = `${this.member.user.username}${this.member.nickname ? `\n${this.member.nickname}` : ''}`; + const check = this.checkWords(phrases, str); + if (check.length > 0) { + result.push(...check); + } + + if (result.length > 0) { + const highestOffense = result.sort((a, b) => b.severity - a.severity)[0]; + await this.logMessage(highestOffense, result, str); + } + } + + /** + * Log an automod infraction to the guild's specified automod log channel + * @param highestOffense The highest severity word found in the message + * @param offenses The other offenses that were also matched in the message + */ + protected async logMessage(highestOffense: BadWordDetails, offenses: BadWordDetails[], str: string) { + void this.client.console.info( + 'MemberAutomod', + `Detected a severity <<${highestOffense.severity}>> automod phrase in <<${this.user.tag}>>'s (<<${this.user.id}>>) username or nickname in <<${this.guild.name}>>` + ); + + const color = this.logColor(highestOffense.severity); + + await this.guild.sendLogChannel('automod', { + embeds: [ + new EmbedBuilder() + .setTitle(`[Severity ${highestOffense.severity}] Automoderated User Info Detected`) + .setDescription( + stripIndent` + **User:** ${this.user} (${this.user.tag}) + **Blacklisted Words:** ${offenses.map((o) => `\`${o.match}\``).join(', ')}` + ) + .addFields({ + name: 'Info', + value: `${await this.client.utils.codeblock(str, 1024)}` + }) + .setColor(color) + .setTimestamp() + .setAuthor({ name: this.user.tag, url: this.user.displayAvatarURL() }) + ], + components: [this.buttons(this.user.id, highestOffense.reason, false)] + }); + } +} diff --git a/lib/automod/MessageAutomod.ts b/lib/automod/MessageAutomod.ts new file mode 100644 index 0000000..9673adf --- /dev/null +++ b/lib/automod/MessageAutomod.ts @@ -0,0 +1,286 @@ +import { stripIndent } from '#tags'; +import assert from 'assert/strict'; +import chalk from 'chalk'; +import { EmbedBuilder, GuildTextBasedChannel, PermissionFlagsBits, type Message } from 'discord.js'; +import { colors } from '../utils/BushConstants.js'; +import { format, formatError } from '../utils/BushUtils.js'; +import { Automod, BadWordDetails, Severity } from './AutomodShared.js'; + +/** + * Handles message auto moderation functionality. + */ +export class MessageAutomod extends Automod { + /** + * @param message The message to check and potentially perform automod actions on + */ + public constructor(private readonly message: Message) { + assert(message.member); + super(message.member); + + if (message.author.id === message.client.user?.id) return; + void this.handle(); + } + + /** + * Handles the auto moderation + */ + protected async handle() { + if (!this.message.inGuild()) return; + if (!(await this.guild.hasFeature('automod'))) return; + if (this.user.bot) return; + if (!this.message.member) return; + + traditional: { + if (this.isImmune) break traditional; + const badLinksArray = this.client.utils.getShared('badLinks'); + const badLinksSecretArray = this.client.utils.getShared('badLinksSecret'); + const badWordsRaw = this.client.utils.getShared('badWords'); + + const customAutomodPhrases = (await this.guild.getSetting('autoModPhases')) ?? []; + const uniqueLinks = [...new Set([...badLinksArray, ...badLinksSecretArray])]; + + const badLinks: BadWordDetails[] = uniqueLinks.map((link) => ({ + match: link, + severity: Severity.PERM_MUTE, + ignoreSpaces: false, + ignoreCapitalization: true, + reason: 'malicious link', + regex: false, + userInfo: false + })); + + const parsedBadWords = Object.values(badWordsRaw).flat(); + + const result = this.checkWords( + [ + ...customAutomodPhrases, + ...((await this.guild.hasFeature('excludeDefaultAutomod')) ? [] : parsedBadWords), + ...((await this.guild.hasFeature('excludeAutomodScamLinks')) ? [] : badLinks) + ], + this.message.content + ); + + if (result.length === 0) break traditional; + + const highestOffense = result.sort((a, b) => b.severity - a.severity)[0]; + + if (highestOffense.severity === undefined || highestOffense.severity === null) { + void this.guild.sendLogChannel('error', { + embeds: [ + { + title: 'AutoMod Error', + description: `Unable to find severity information for ${format.inlineCode(highestOffense.match)}`, + color: colors.error + } + ] + }); + } else { + this.punish(highestOffense); + void this.logMessage(highestOffense, result); + } + } + + other: { + if (this.isImmune) break other; + if (!this.punished && (await this.guild.hasFeature('delScamMentions'))) void this.checkScamMentions(); + } + + if (!this.punished && (await this.guild.hasFeature('perspectiveApi'))) void this.checkPerspectiveApi(); + } + + /** + * If the message contains '@everyone' or '@here' and it contains a common scam phrase, it will be deleted + * @returns + */ + protected async checkScamMentions() { + const includes = (c: string) => this.message.content.toLocaleLowerCase().includes(c); + if (!includes('@everyone') && !includes('@here')) return; + + // It would be bad if we deleted a message that actually pinged @everyone or @here + if ( + this.member.permissionsIn(this.message.channelId).has(PermissionFlagsBits.MentionEveryone) || + this.message.mentions.everyone + ) + return; + + if ( + includes('steam') || + includes('www.youtube.com') || + includes('youtu.be') || + includes('nitro') || + includes('1 month') || + includes('3 months') || + includes('personalize your profile') || + includes('even more') || + includes('xbox and discord') || + includes('left over') || + includes('check this lol') || + includes('airdrop') + ) { + const color = this.logColor(Severity.PERM_MUTE); + this.punish({ severity: Severity.TEMP_MUTE, reason: 'everyone mention and scam phrase' } as BadWordDetails); + void this.guild!.sendLogChannel('automod', { + embeds: [ + new EmbedBuilder() + .setTitle(`[Severity ${Severity.TEMP_MUTE}] Mention Scam Deleted`) + .setDescription( + stripIndent` + **User:** ${this.user} (${this.user.tag}) + **Sent From:** <#${this.message.channel.id}> [Jump to context](${this.message.url})` + ) + .addFields({ + name: 'Message Content', + value: `${await this.client.utils.codeblock(this.message.content, 1024)}` + }) + .setColor(color) + .setTimestamp() + ], + components: [this.buttons(this.user.id, 'everyone mention and scam phrase')] + }); + } + } + + protected async checkPerspectiveApi() { + return; + if (!this.client.config.isDevelopment) return; + + if (!this.message.content) return; + this.client.perspective.comments.analyze( + { + key: this.client.config.credentials.perspectiveApiKey, + resource: { + comment: { + text: this.message.content + }, + requestedAttributes: { + TOXICITY: {}, + SEVERE_TOXICITY: {}, + IDENTITY_ATTACK: {}, + INSULT: {}, + PROFANITY: {}, + THREAT: {}, + SEXUALLY_EXPLICIT: {}, + FLIRTATION: {} + } + } + }, + (err: any, response: any) => { + if (err) return console.log(err?.message); + + const normalize = (val: number, min: number, max: number) => (val - min) / (max - min); + + const color = (val: number) => { + if (val >= 0.5) { + const x = 194 - Math.round(normalize(val, 0.5, 1) * 194); + return chalk.rgb(194, x, 0)(val); + } else { + const x = Math.round(normalize(val, 0, 0.5) * 194); + return chalk.rgb(x, 194, 0)(val); + } + }; + + console.log(chalk.cyan(this.message.content)); + Object.entries(response.data.attributeScores) + .sort(([a], [b]) => a.localeCompare(b)) + .forEach(([key, value]: any[]) => console.log(chalk.white(key), color(value.summaryScore.value))); + } + ); + } + + /** + * Punishes the user based on the severity of the offense + * @param highestOffense The highest offense to punish the user for + * @returns The color of the embed that the log should, based on the severity of the offense + */ + protected punish(highestOffense: BadWordDetails) { + switch (highestOffense.severity) { + case Severity.DELETE: { + void this.message.delete().catch((e) => deleteError.bind(this, e)); + this.punished = true; + break; + } + case Severity.WARN: { + void this.message.delete().catch((e) => deleteError.bind(this, e)); + void this.member.bushWarn({ + moderator: this.guild!.members.me!, + reason: `[Automod] ${highestOffense.reason}` + }); + this.punished = true; + break; + } + case Severity.TEMP_MUTE: { + void this.message.delete().catch((e) => deleteError.bind(this, e)); + void this.member.bushMute({ + moderator: this.guild!.members.me!, + reason: `[Automod] ${highestOffense.reason}`, + duration: 900_000 // 15 minutes + }); + this.punished = true; + break; + } + case Severity.PERM_MUTE: { + void this.message.delete().catch((e) => deleteError.bind(this, e)); + void this.member.bushMute({ + moderator: this.guild!.members.me!, + reason: `[Automod] ${highestOffense.reason}`, + duration: 0 // permanent + }); + this.punished = true; + break; + } + default: { + throw new Error(`Invalid severity: ${highestOffense.severity}`); + } + } + + async function deleteError(this: MessageAutomod, e: Error | any) { + void this.guild?.sendLogChannel('error', { + embeds: [ + { + title: 'Automod Error', + description: `Unable to delete triggered message.`, + fields: [{ name: 'Error', value: await this.client.utils.codeblock(`${formatError(e)}`, 1024, 'js', true) }], + color: colors.error + } + ] + }); + } + } + + /** + * Log an automod infraction to the guild's specified automod log channel + * @param highestOffense The highest severity word found in the message + * @param offenses The other offenses that were also matched in the message + */ + protected async logMessage(highestOffense: BadWordDetails, offenses: BadWordDetails[]) { + void this.client.console.info( + 'MessageAutomod', + `Severity <<${highestOffense.severity}>> action performed on <<${this.user.tag}>> (<<${this.user.id}>>) in <<#${ + (this.message.channel as GuildTextBasedChannel).name + }>> in <<${this.guild!.name}>>` + ); + + const color = this.logColor(highestOffense.severity); + + await this.guild!.sendLogChannel('automod', { + embeds: [ + new EmbedBuilder() + .setTitle(`[Severity ${highestOffense.severity}] Automod Action Performed`) + .setDescription( + stripIndent` + **User:** ${this.user} (${this.user.tag}) + **Sent From:** <#${this.message.channel.id}> [Jump to context](${this.message.url}) + **Blacklisted Words:** ${offenses.map((o) => `\`${o.match}\``).join(', ')}` + ) + .addFields({ + name: 'Message Content', + value: `${await this.client.utils.codeblock(this.message.content, 1024)}` + }) + .setColor(color) + .setTimestamp() + .setAuthor({ name: this.user.tag, url: this.user.displayAvatarURL() }) + ], + components: highestOffense.severity >= 2 ? [this.buttons(this.user.id, highestOffense.reason)] : undefined + }); + } +} diff --git a/lib/automod/PresenceAutomod.ts b/lib/automod/PresenceAutomod.ts new file mode 100644 index 0000000..70c66d6 --- /dev/null +++ b/lib/automod/PresenceAutomod.ts @@ -0,0 +1,85 @@ +import { stripIndent } from '#tags'; +import { EmbedBuilder, Presence } from 'discord.js'; +import { Automod, BadWordDetails } from './AutomodShared.js'; + +export class PresenceAutomod extends Automod { + /** + * @param presence The presence that the automod is checking + */ + public constructor(public readonly presence: Presence) { + super(presence.member!); + + if (presence.member!.id === presence.client.user?.id) return; + + void this.handle(); + } + + protected async handle(): Promise<void> { + if (this.presence.member!.user.bot) return; + + const badWordsRaw = Object.values(this.client.utils.getShared('badWords')).flat(); + const customAutomodPhrases = (await this.guild.getSetting('autoModPhases')) ?? []; + + const phrases = [...badWordsRaw, ...customAutomodPhrases].filter((p) => p.userInfo); + + const result: BadWordDetails[] = []; + + const strings = []; + + for (const activity of this.presence.activities) { + const str = `${activity.name}${activity.details ? `\n${activity.details}` : ''}${ + activity.buttons.length > 0 ? `\n${activity.buttons.join('\n')}` : '' + }`; + const check = this.checkWords(phrases, str); + if (check.length > 0) { + result.push(...check); + strings.push(str); + } + } + + if (result.length > 0) { + const highestOffense = result.sort((a, b) => b.severity - a.severity)[0]; + await this.logMessage(highestOffense, result, strings); + } + } + + /** + * Log an automod infraction to the guild's specified automod log channel + * @param highestOffense The highest severity word found in the message + * @param offenses The other offenses that were also matched in the message + */ + protected async logMessage(highestOffense: BadWordDetails, offenses: BadWordDetails[], strings: string[]) { + void this.client.console.info( + 'PresenceAutomod', + `Detected a severity <<${highestOffense.severity}>> automod phrase in <<${this.user.tag}>>'s (<<${this.user.id}>>) presence in <<${this.guild.name}>>` + ); + + const color = this.logColor(highestOffense.severity); + + await this.guild.sendLogChannel('automod', { + embeds: [ + new EmbedBuilder() + .setTitle(`[Severity ${highestOffense.severity}] Automoderated Status Detected`) + .setDescription( + stripIndent` + **User:** ${this.user} (${this.user.tag}) + **Blacklisted Words:** ${offenses.map((o) => `\`${o.match}\``).join(', ')}` + ) + .addFields( + ( + await Promise.all( + strings.map(async (s) => ({ + name: 'Status', + value: `${await this.client.utils.codeblock(s, 1024)}` + })) + ) + ).slice(0, 25) + ) + .setColor(color) + .setTimestamp() + .setAuthor({ name: this.user.tag, url: this.user.displayAvatarURL() }) + ], + components: [this.buttons(this.user.id, highestOffense.reason, false)] + }); + } +} diff --git a/lib/badlinks.ts b/lib/badlinks.ts new file mode 100644 index 0000000..3b4cf3b --- /dev/null +++ b/lib/badlinks.ts @@ -0,0 +1,6930 @@ +/* Links in this file are treated as severity 3 offences. + +made in part possible by https://github.com/nacrt/SkyblockClient-REPO/blob/main/files/scamlinks.json */ +export default [ + "//iscord.gift", + "100cs.ru", + "100eshopdeals.com", + "101nitro.com", + "12mon.space", + "1nitro.club", + "2021cs.net.ru", + "2021ga.xyz", + "2021liss.ru", + "2021pn.ru", + "2021y.ru", + "2022p.ru", + "2022yg.com", + "2023g.com", + "23c7481e.hbrex.cn", + "2discord.ru", + "2faceteam.ml", + "3ds-security.xyz", + "3items4rocket.com", + "4drop.ru.com", + "academynaviagg.xyz", + "accountauthorization.xyz", + "acercup.com", + "ach2x.net.ru", + "achnavi.net.ru", + "acid-tournament.ru", + "affix-cup.click", + "affix-cup.link", + "affix-cup.ru", + "affix-sport.ru", + "affixesports.ru", + "affixsport.ru", + "afkskroll.ru", + "ahijeoir.ru", + "airdrop-discord.com", + "airdrop-discord.online", + "airdrop-discord.ru", + "airdrop-nitro.com", + "airdrops.tips", + "akellasport.me", + "aladdinhub.fun", + "alexandrkost.ru", + "alexs1.ru", + "alive-lives.ru", + "allskinz.xyz", + "alm-gaming.com", + "alone18.ru", + "alonemoly.ru", + "amaterasu.pp.ua", + "ano-skinspin.xyz", + "anomalygiveaways.pro", + "anomalyknifes.xyz", + "anomalyskin.xyz", + "anomalyskinz.xyz", + "anoskinzz.xyz", + "antibot.cc", + "aoeah.promo-codes.world", + "aoeah.shop", + "api.code2gether.cf", + "api.innovations-urfu.site", + "app-discord.com", + "app-discord.ru", + "app-nitro.com", + "application-discord.com", + "appnitro-discord.com", + "appnitro-discord.ru.com", + "appnitrodiscord.ru.com", + "apps-discord.org", + "apps-nitro.com", + "arik.pp.ua", + "asprod911.com", + "asstralissport.org.ru", + "astr-teem.net.ru", + "astr-teem.org.ru", + "astralis-gg.com", + "astralis.monster", + "astralis2.net.ru", + "astralis2.org.ru", + "astralisgift.fun", + "astrallis.net.ru", + "astrallis.org.ru", + "astralliscase.org.ru", + "astralteam.org.ru", + "astresports.xyz", + "atomicstore.ru", + "attaxtrade.com", + "aucryptohubs.com", + "authnet.cf", + "autumnbot.cloud", + "avitofast.ru", + "awirabigmoneyroll.xyz", + "awirabigmoneyrolls.xyz", + "azimovcase.tk", + "badge-team.ml", + "ball-chaser.xyz", + "bandycazez.xyz", + "bangbro.ru", + "battiefy.com", + "beast-cup.ru", + "beast-dr0p.ru", + "beast-winer.ru", + "belekevskeigames.xyz", + "berrygamble.com", + "best-cup.com", + "best-cup.ru", + "bestgeeknavi.ru", + "bestshopusaoffers.com", + "bestskins.org.ru", + "beststeam.gq", + "bestwatchstyle.com", + "beta.discorder.app", + "betadiscord.com", + "bets-cup.ru", + "big.org.ru", + "big.pp.ru", + "bigcsgo.pro", + "bigesports.ru", + "bigmoneyrollawira.xyz", + "bigs.monster", + "bigsports.xyz", + "bistripudel.xyz", + "bit-skins.ru", + "bitcoingenerator.cash", + "bitknife.xyz", + "bitskeansell.ru", + "bitskines.ru", + "blockmincnain.com", + "blocknimchain.com", + "blocksilcnain.com", + "blox.land", + "bloxpromo.com", + "blustcoin.com", + "board-nitro.com", + "bondikflas.xyz", + "bonusxcase.xyz", + "books-pash.org.ru", + "boost-discord.com", + "boost-nitro.com", + "boosted-nitro.com", + "boostnitro.com", + "boostnltro.com", + "bountyweek.com", + "box-surprisebynavi.net.ru", + "boxgolg.club", + "boxnode.ru", + "br0ken-fng.xyz", + "bracesports.ru", + "bro-skiils.net.ru", + "brokenfang-csgo.com", + "brokenfangpassfree.pp.ru", + "brokenfant.org.ru", + "brokentournament.xyz", + "bruteclub.ru", + "buff-market.ru", + "buffgames.ru", + "but-three.xyz", + "buxquick.com", + "buzz-cup.ru", + "bycdu.cam", + "bycsdu.cam", + "bysellers.xyz", + "c-you-mamont.ru", + "c2bit.online", + "c2bit.su", + "case-free.com", + "case-gift.com", + "case-give.com", + "case-magic.space", + "casecs.ru", + "casefire.fun", + "casekey.ru.com", + "casesdrop.ru", + "casesdrop.xyz", + "cash.org.ru", + "cash.pp.ru", + "cashcsgo.ru", + "cashout.monster", + "cashy.monster", + "cassesoma.ru", + "cave-nitro.com", + "cawanmei.ru", + "cawanmei99.ru", + "ccomstimoon.org.ru", + "cgsell.ru", + "cgskinky.xyz", + "chainexplo.com", + "challengeme.in", + "challengeme.vip", + "challengme.ru", + "chance-stem.ru", + "chinchopa.pp.ua", + "circus-shop.ru", + "cis-fastcup.ru", + "cis-rankig.ru", + "cityofmydream.pp.ua", + "claim.robuxat.com", + "claimgifts.shop", + "clan-big.ru", + "classic-nitro.com", + "claud9.xyz", + "clck.ru", + "click-mell.pp.ru", + "cliscord-gift.ru.com", + "cllscordapp.fun", + "cloud9.ru.com", + "cloud9team.space", + "cloudeskins.com", + "cloudfox.one", + "cloudteam9.com", + "clove-nitro.com", + "cmepure.com", + "cmskillcup.com", + "cod3r0bux.pw", + "cointradebtc.com", + "comboline.xyz", + "comdiscord.com", + "come-nitro.com", + "communitytradeoffer.com.ru", + "communitytradeoffer.com", + "communltydrop.pp.ua", + "communltyguard.pp.ua", + "comsteamcommunity.com", + "contact-infoservice.com", + "contralav.ru", + "contralav.xyz", + "coolcools.xyz", + "cooldrop.monster", + "copyrightbusinessgroup.com", + "copyrightbussinessgroup.com", + "copyrighthelpbusiness.org", + "cose-lore.ru", + "counter-stricke.ru", + "counter-strlke.site", + "counterbase.ru.com", + "counterpaid.xyz", + "counterspin.top", + "counterstrik.xyz", + "counterstrikegift.xyz", + "cpanel.copyrighthelpbusiness.org", + "cpbldi.com", + "cpp-discord.com", + "crazy-soom.org.ru", + "crazypage.me", + "creack.tk", + "creditscpfree.website", + "crosflah.online", + "crustalcup.ga", + "cs-activit.xyz", + "cs-astria.xyz", + "cs-beast.xyz", + "cs-betway.xyz", + "cs-boom.org.ru", + "cs-cool.net.ru", + "cs-dark.org.ru", + "cs-dump.org.ru", + "cs-esports.link", + "cs-exeword.xyz", + "cs-fail.ru.com", + "cs-fall.ru.com", + "cs-gameis.ru", + "cs-gorun.ru.com", + "cs-grun.ru.com", + "cs-incursed.xyz", + "cs-legend.xyz", + "cs-lucky.xyz", + "cs-moneyy.ru", + "cs-navigiveaway.ru", + "cs-open.link", + "cs-pill.xyz", + "cs-play.org.ru", + "cs-prizeskins.xyz", + "cs-prizeskinz.xyz", + "cs-riptide.com", + "cs-riptide.ru", + "cs-riptide.xyz", + "cs-simpleroll.xyz", + "cs-skins.link", + "cs-skinz.xyz", + "cs-smoke.xyz", + "cs-spinz.xyz", + "cs-toom.pp.ru", + "cs-tournament.link", + "cs-victory.xyz", + "cs11go.space", + "cs4real.pp.ua", + "cs500go.com", + "csallskin.xyz", + "csbuyskins.in", + "cschanse.ru", + "cschecker.ru", + "cscoat.eu", + "cscodes.ru", + "csfair.pp.ua", + "csfix.me", + "csfreedom.me", + "csfreesklns.ru.com", + "csgameik.ru", + "csgdrop.ru", + "csgfocusa.ru", + "csggolg.ru", + "csgif.org.ru", + "csgift.fun", + "csgo-analyst.com", + "csgo-battle.ru", + "csgo-cash.eu", + "csgo-cup.ru", + "csgo-cyber.link", + "csgo-dym.ru", + "csgo-fute.net.ru", + "csgo-game-steam.ru", + "csgo-games.xyz", + "csgo-gamesteam.ru", + "csgo-gifts.com", + "csgo-lute.net.ru", + "csgo-market.ru.com", + "csgo-pell.org.ru", + "csgo-riptide.ru", + "csgo-run.info", + "csgo-run.site", + "csgo-sports.com", + "csgo-st.ru", + "csgo-steam-game.ru", + "csgo-steam-good.ru", + "csgo-steamanalyst.net", + "csgo-steamgame.ru", + "csgo-steamplay.ru", + "csgo-store-steam.ru", + "csgo-storesteam.ru", + "csgo-swapskin.com", + "csgo-trade.net", + "csgo-up.com", + "csgo-z.com", + "csgo.ghservers.cl", + "csgo2021.ru", + "csgo4cases.fun", + "csgobb.xyz", + "csgobccp.ru", + "csgobeats.com", + "csgobelieve.ru", + "csgocase.monster", + "csgocase.one", + "csgocases.monster", + "csgocashs.com", + "csgocheck.ru.com", + "csgocheck.ru", + "csgochinasteam.ru", + "csgocj-steam.work", + "csgocnfocuss.ru", + "csgocompetive.com", + "csgocup.ru", + "csgocupp.ru.com", + "csgocybersport.ru.com", + "csgodetails.info", + "csgodirect.xyz", + "csgodreamer.com", + "csgodrops.monster", + "csgodrs.com", + "csgoeasywin.ru.com", + "csgoelite.xyz", + "csgoencup.com", + "csgoevent.xyz", + "csgofast.xyz", + "csgoflash.net.ru", + "csgofocusc.xyz", + "csgogame-steam.ru", + "csgoganeak.ru", + "csgoganeik.ru", + "csgogf01.xyz", + "csgogf02.xyz", + "csgogf03.xyz", + "csgogf04.xyz", + "csgogf05.xyz", + "csgogf06.xyz", + "csgogf07.xyz", + "csgogf12.xyz", + "csgogf13.xyz", + "csgogf14.xyz", + "csgogf15.xyz", + "csgogift25.xyz", + "csgogift26.xyz", + "csgogift34.xyz", + "csgogift43.xyz", + "csgogift44.xyz", + "csgogift45.xyz", + "csgogift47.xyz", + "csgogift49.xyz", + "csgogift50.xyz", + "csgogift51.xyz", + "csgogift55.xyz", + "csgogift56.xyz", + "csgogift57.xyz", + "csgogift58.xyz", + "csgogift59.xyz", + "csgogift60.xyz", + "csgogift62.xyz", + "csgogift77.xyz", + "csgogpusk.ru", + "csgoindex.ru.com", + "csgoindex.ru", + "csgoitemdetails.com", + "csgoitemsprices.com", + "csgojs.xyz", + "csgojump.ru", + "csgoko.tk", + "csgold.monster", + "csgomarble.xyz", + "csgomarketplace.net", + "csgomarkets.net", + "csgonavi.com", + "csgoorun.ru", + "csgoprocupgo.com", + "csgorcup.com", + "csgoroll.ru", + "csgorose.com", + "csgoroulette.monster", + "csgoroyalskins1.com", + "csgorun-rubonus.ru", + "csgorun.info", + "csgorun.pro-login.ru", + "csgorun.pro-loginn.com", + "csgosell.xyz", + "csgoskill.ru", + "csgoskinprices.com", + "csgoskinsinfo.com", + "csgoskinsroll.com", + "csgosprod.com", + "csgossteam.ru", + "csgossteam.xyz", + "csgostats.fun", + "csgosteam-game.ru", + "csgosteam-play.ru", + "csgosteamanalysis.com", + "csgosteamanalyst.ru", + "csgosteamcom.ru", + "csgosteamgo.ru", + "csgoteammate.gq", + "csgothunby.com", + "csgotournaments.cf", + "csgotrades.net", + "csgotreder.com", + "csgovip.ru", + "csgowans.ru", + "csgowaycup.ru.com", + "csgowincase.xyz", + "csgoworkshops.com", + "csgoxgiveaway.ru", + "csgozone.net.in", + "csgunskins.xyz", + "cslpkmf.ru", + "csm-oney.ru", + "csmarkete.info", + "csmone-y.ru", + "csmoneyskinz.xyz", + "csmvcecup.com", + "csogamech.xyz", + "csogamecm.xyz", + "csogamee.xyz", + "csogamef.xyz", + "csogamegg.ru", + "csogameke.xyz", + "csoggskif.ru", + "csoggskif.xyz", + "csogzhnc.xyz", + "csprices.in", + "csrandom.monster", + "css500gggo.ru", + "csskill.com", + "csskillpro.xyz", + "csskins.space", + "csskinz.xyz", + "csteamskin.ru", + "cstournament.ru", + "cswanmei.ru", + "cswanmei4.ru", + "cswinterpresent.xyz", + "csxrnoney.com", + "cteamcamnynity67823535672.xyz", + "cteamcommunity.xyz", + "cubesmc.ru", + "cupcs.ru", + "cupcsgo.ru", + "cupgoo.xyz", + "cupsul.ru", + "cupwin.xyz", + "cyber-csgo.link", + "cyber-csgo.space", + "cyber-lan.com", + "cyber-roll.club", + "cyber-roll.monster", + "cyber-shok.online", + "cyber-shok.ru", + "cyber-win.ru", + "cyber-x.xyz", + "cybercsgo.link", + "cyberdex.ru", + "cyberegocscom.ru", + "cyberesports-tournaments.ru", + "cybergamearena.ru", + "cyberiaevents.ru", + "cyberlev.ru", + "cybermode.ru", + "cyberscsgo.ru", + "cyberspark.org.ru", + "d-nitro.tk", + "d.iscord.xyz", + "d.myticks.xyz", + "d1scord.xyz", + "d1scrod.site", + "d2csbox.pp.ua", + "d2cups.com", + "d2faceit.com", + "d3l3.tk", + "dac-game.xyz", + "daddsda.xyz", + "dailymegadeal.xyz", + "dawbab.xyz", + "daxrop.xyz", + "dciscord.com", + "ddiscord.com", + "deadisidddde.xyz", + "deamonbets.ru", + "def-dclss.pp.ua", + "demonbets.ru", + "denforapasi.cf", + "der-csgo.ru", + "derimonz.xyz", + "derwoood.xyz", + "desmond.ru.com", + "determined-haslett.45-138-72-103.plesk.page", + "dfiscord.com", + "diablobets.com", + "diacordapp.com", + "diascord.com", + "diccrd.com", + "dicksod.co", + "dicoapp.me", + "dicoapp.pro", + "dicord.gg", + "dicord.gift", + "dicord.site", + "dicord.space", + "dicordapp.com", + "dicordgift.ru.com", + "dicordglfts.ga", + "dicordglfts.gq", + "dicovrd.com", + "dicrod.com", + "dicscordapp.com", + "dicsocrd.com", + "dicsord-airdrop.com", + "dicsord-airdrop.ru", + "dicsord-app.com", + "dicsord-events.com", + "dicsord-gift.com", + "dicsord-gifte.ru.com", + "dicsord-gifted.ru", + "dicsord-gifts.ru", + "dicsord-give.com", + "dicsord-give.ru", + "dicsord-gives.com", + "dicsord-hypesquads.com", + "dicsord-nitro.com", + "dicsord-nitro.ru", + "dicsord-steam.com", + "dicsord-ticket.com", + "dicsord.gg", + "dicsord.gifts", + "dicsord.net", + "dicsord.pl", + "dicsord.pw", + "dicsord.ru", + "dicsord.space", + "dicsord.website", + "dicsordapp.co", + "dicsordgift.club", + "dicsordgift.com", + "dicsordgive.ru.com", + "dicsordnitro.info", + "dicsordnitro.store", + "dicsordr.xyz", + "dicsords-gift.ru", + "dicsords.ru", + "dicsrod.com", + "didiscord.com", + "didscord.com", + "diiiscrod.club", + "diisccord.club", + "diiscord-app.com", + "diiscord-gift.com", + "diiscord-nittro.ru", + "diiscord.com", + "dIiscord.com", + "diiscord.gift", + "diiscord.me", + "diiscordapp.com", + "diisscord.club", + "diisscord.online", + "dijscord.com", + "dilscord.com", + "dioscord.com", + "diqscordapp.com", + "dircode.ru", + "direct-link.net", + "dirolzz.xyz", + "dirscod.com", + "dirscod.gift", + "dirscord-gift.ru", + "dirscordapp.com", + "dis.cord.gifts", + "disbordapp.com", + "disbords.com", + "disbored.com", + "disc-ord.com", + "disc.cool", + "disc.gifts", + "disc0rd-app.ru.com", + "disc0rd-nitro.site", + "disc0rd.org", + "disc0rd.site", + "disc0rd.xyz", + "discapp.info", + "discard.gg", + "discard.gift", + "discard.xyz", + "discardapp.fun", + "disccor.com", + "disccord-apps.com", + "disccord-appss.ru", + "disccord-club.com", + "disccord-gift.com", + "disccord.gg", + "disccord.ru.com", + "disccord.ru", + "disccord.shop", + "disccord.tk", + "disccords.com", + "disccrd.gifts", + "disccrdapp.com", + "disceord.gift", + "discerd.gift", + "discford.com", + "discgrdapp.com", + "dischrd.com", + "discird.gg", + "discird.me", + "discjrd.com", + "disckord.com", + "disckordapp.com", + "disclord.com", + "disclrd.com", + "discnrd.gift", + "discnrdapp.com", + "disco.to", + "disco3d.app", + "disco9rdapp.com", + "discoapps.club", + "discoard.com", + "discocd.com", + "discocdapp.com", + "discocl.xyz", + "discoclapp.xyz", + "discocord.com", + "discocrd-gift.com", + "discocrd-gifts.com", + "discocrd-nitro.com", + "discocrd.gift", + "discocrd.gifts", + "discocrdapp.com", + "discod-hitro.xyz", + "discod-nitro.ru", + "discod.art", + "discod.fun", + "discod.gift", + "discod.gifts", + "discod.info", + "discod.tech", + "discodapp.gift", + "discodapp.net", + "discode.gift", + "discodnitro.info", + "discodnitro.ru", + "discodrd.com", + "discoed.gg", + "discoed.me", + "discoerd.com", + "discoerdapp.com", + "discofd.com", + "discokrd.com", + "discold.online", + "discold.ru", + "discolrd.com", + "discond-nitro.ru", + "discond-njtro.tech", + "discond.gift", + "discond.ru.com", + "discondapp.fun", + "disconrd.com", + "discontro.ru", + "discoogs.com", + "discoord-apps.com", + "discoord-nitro.com", + "discoord.space", + "discor-dnitro.fun", + "discor.de", + "discor.gg", + "discor.link", + "discor.me", + "discorad.com", + "discorapp.gq", + "discorapp.pw", + "discorb-nitro.ru.com", + "discorb.blog", + "discorb.co", + "discorb.com", + "discorb.gift", + "discorb.gifts", + "discorb.ru.com", + "discorc-nitro.site", + "discorcd-apps.com", + "discorcd-gift.com", + "discorcd-nitro.com", + "discorcd.click", + "discorcd.com", + "discorcd.gift", + "discorcd.gifts", + "discorcd.site", + "discorcdapp.com", + "discorci.com", + "discorcl-air.xyz", + "discorcl-app.com", + "discorcl-app.ru", + "discorcl-app.xyz", + "discorcl-boost.ru", + "discorcl-gift.org.ru", + "discorcl-gift.ru.com", + "discorcl-gift.ru", + "discorcl-gift.xyz", + "discorcl-give.site", + "discorcl-nitro.com", + "discorcl-nitro.ru.com", + "discorcl-nitro.site", + "discorcl.app", + "discorcl.art", + "discorcl.click", + "discorcl.club", + "discorcl.fun", + "discorcl.ga", + "discorcl.gift", + "discorcl.gifts", + "discorcl.info", + "discorcl.link", + "discorcl.online", + "discorcl.ru.com", + "discorcl.ru", + "discorcl.shop", + "discorcl.site", + "discorcl.store", + "discorclapp.com", + "discorclapp.fun", + "discorclgift.com", + "discorclgift.xyz", + "discorcll.com", + "discorcll.online", + "discorclnitro.ru", + "discorclsteam.com", + "discorcrd.gift", + "discorcz-booster.ru", + "discord-a.com", + "discord-accept.com", + "discord-accounts.com", + "discord-accounts.ru", + "discord-air.fun", + "discord-air.pw", + "discord-air.xyz", + "discord-airclrop.pw", + "discord-airdop.link", + "discord-airdrop.com", + "discord-airdrop.fun", + "discord-airdrop.info", + "discord-airdrop.me", + "discord-airdrop.pw", + "discord-airdrop.site", + "discord-airdrop.xyz", + "discord-airnitro.xyz", + "discord-alidrop.me", + "discord-alrdrop.com", + "discord-app.cc", + "discord-app.click", + "discord-app.club", + "discord-app.co.uk", + "discord-app.co", + "discord-app.gift", + "discord-app.gifts", + "discord-app.info", + "discord-app.io", + "discord-app.live", + "discord-app.me", + "discord-app.net", + "discord-app.ru.com", + "discord-app.shop", + "discord-app.store", + "discord-app.su", + "discord-app.top", + "discord-app.uk", + "discord-app.us", + "discord-app.xyz", + "discord-application.com", + "discord-applications.com", + "discord-apply.com", + "discord-appnitro.com", + "discord-apps.ru", + "discord-apps.site", + "discord-apps.space", + "discord-apps.xyz", + "discord-best-nitro.xyz", + "discord-bonus.ru", + "discord-boost.com", + "discord-boost.ru.com", + "discord-boost.ru", + "discord-boost.xyz", + "discord-bot.com", + "discord-bot.ru", + "discord-bugs.com", + "discord-claim.com", + "discord-claim.ru.com", + "discord-claim.ru", + "discord-clap.com", + "discord-click.shop", + "discord-club.ru", + "discord-com-free.online", + "discord-com-free.ru", + "discord-control.com", + "discord-controls.com", + "discord-cpp.com", + "discord-develop.com", + "discord-developer.com", + "discord-devs.com", + "discord-do.com", + "discord-dr0p.ru", + "discord-drop.gift", + "discord-drop.info", + "discord-drop.xyz", + "discord-drops.ru", + "discord-egift.com", + "discord-event.com", + "discord-event.info", + "discord-events.com", + "discord-exploits.tk", + "discord-faq.com", + "discord-free-nitro.ru", + "discord-free.com", + "discord-free.site", + "discord-freenitro.online", + "discord-freenitro.pw", + "discord-fun.com", + "discord-game.com", + "discord-games.cf", + "discord-generator.tk", + "discord-get.click", + "discord-get.ru", + "discord-gg.com", + "discord-gg.ru.com", + "discord-gif.xyz", + "discord-gifft.com", + "discord-gift-free-nitro.tk", + "discord-gift-nitro.site", + "discord-gift.app", + "discord-gift.info", + "discord-gift.net.ru", + "discord-gift.online", + "discord-gift.ru.com", + "discord-gift.ru", + "discord-gift.shop", + "discord-gift.site", + "discord-gift.top", + "discord-gift.us", + "discord-gifte.com", + "discord-gifte.ru", + "discord-gifte.xyz", + "discord-gifted.ru.com", + "discord-giftef.xyz", + "discord-gifteh.xyz", + "discord-giftes.com", + "discord-gifts.com.ru", + "discord-gifts.com", + "discord-gifts.me", + "discord-gifts.org", + "discord-gifts.ru.com", + "discord-gifts.shop", + "discord-gifts.site", + "discord-givaewey.ru", + "discord-give.com", + "discord-give.net", + "discord-give.org", + "discord-give.pw", + "discord-give.ru.com", + "discord-give.ru", + "discord-give.xyz", + "discord-giveaway.com", + "discord-giveaways.ru", + "discord-glft.com", + "discord-glft.ru.com", + "discord-glft.xyz", + "discord-halloween-nitro.com", + "discord-halloween.com", + "discord-halloween.link", + "discord-halloween.me", + "discord-halloween.ru.com", + "discord-halloween.ru", + "discord-hallowen.ru.com", + "discord-help.com", + "discord-helpers.com", + "discord-hse.com", + "discord-hype.com", + "discord-hypeevent.com", + "discord-hypes.com", + "discord-hypesquad.com", + "discord-hypesquad.info", + "discord-hypesquade.com", + "discord-hypesquaders.com", + "discord-hypesquads.com", + "discord-hypevent.com", + "discord-i.com", + "discord-info.com", + "discord-infoapp.xyz", + "discord-information.com", + "discord-information.ru", + "discord-informations.com", + "discord-informations.ru", + "discord-install.com", + "discord-invite-link.com", + "discord-job.com", + "discord-jobs.com", + "discord-list.cf", + "discord-load.ru", + "discord-login.cf", + "discord-mega.xyz", + "discord-mod.com", + "discord-moderation.com", + "discord-moderator.com", + "discord-moderator.us", + "discord-mods.com", + "discord-net-labs.com", + "discord-netro.ru", + "discord-news.com", + "discord-niittro.ru", + "discord-nilro.ru", + "discord-niltro.com", + "discord-niltro.ru.com", + "discord-nitr0gift.fun", + "discord-nitre.xyz", + "discord-nitro-boost.xyz", + "discord-nitro-classic.com", + "discord-nitro-free.ml", + "discord-nitro-free.ru", + "discord-nitro-free.xyz", + "discord-nitro.click", + "discord-nitro.cloud", + "discord-nitro.club", + "discord-nitro.co", + "discord-nitro.com", + "discord-nitro.eu", + "discord-nitro.gift", + "discord-nitro.gifts", + "discord-nitro.info", + "discord-nitro.it", + "discord-nitro.link", + "discord-nitro.live", + "discord-nitro.net", + "discord-nitro.online", + "discord-nitro.org", + "discord-nitro.pro", + "discord-nitro.ru.com", + "discord-nitro.services", + "discord-nitro.shop", + "discord-nitro.store", + "discord-nitro.su", + "discord-nitro.tech", + "discord-nitro.tk", + "discord-nitro.website", + "discord-nitroapp.ru", + "discord-nitroapp.xyz", + "discord-nitrodrop.xyz", + "discord-nitroe.xyz", + "discord-nitrogift.com", + "discord-nitrogift.ru", + "discord-nitrogift.xyz", + "discord-nitros.com", + "discord-nitros.ru", + "discord-nitrot.xyz", + "discord-njtro.store", + "discord-nltro.com", + "discord-nltro.fun", + "discord-nltro.info", + "discord-nltro.ru", + "discord-nudes.club", + "discord-nudes.live", + "discord-o.com", + "discord-offer.com", + "discord-partner.com", + "discord-partners.com", + "discord-premium.com", + "discord-present.ru", + "discord-promo.com", + "discord-promo.info", + "discord-promo.ru.com", + "discord-promo.site", + "discord-promo.xyz", + "discord-promotions.com", + "discord-promox.com", + "discord-report.com", + "discord-ro.tk", + "discord-ru.site", + "discord-security.com", + "discord-service.com", + "discord-sex.live", + "discord-shop.fun", + "discord-sms.eu", + "discord-soft.ru", + "discord-spooky.ru", + "discord-staff.com", + "discord-stat.com", + "discord-stats.com", + "discord-stats.org", + "discord-steam.com", + "discord-steam.ru", + "discord-steam.site", + "discord-steams.com", + "discord-stemdrop.me", + "discord-stuff.com", + "discord-sup.com", + "discord-support.com", + "discord-support.org", + "discord-support.tech", + "discord-supports.com", + "discord-team.com", + "discord-tech.com", + "discord-tester.com", + "discord-to.com", + "discord-true.com", + "discord-trustandsafety.com", + "discord-up.ru", + "discord-verif.ga", + "discord-verification.com", + "discord-verifications.com", + "discord-verify-account.ml", + "discord-verify.com", + "discord-verify.ru", + "discord-vetify.com", + "discord-web.co", + "discord-xnitro.com", + "discord.1nitro.club", + "discord.ac", + "discord.app.br", + "discord.app", + "discord.bargains", + "discord.best", + "discord.biz", + "discord.blog", + "discord.cc", + "discord.cloud", + "discord.cm", + "discord.cn.com", + "discord.co.com", + "discord.co.in", + "discord.co.za", + "discord.com.pl", + "discord.com.tw", + "discord.cool", + "discord.creditcard", + "discord.deals", + "discord.download", + "discord.es", + "discord.eu", + "discord.family", + "discord.fit", + "discord.foundation", + "discord.fyi", + "discord.gifte", + "discord.givaeway.com", + "discord.givaewey.com", + "discord.giveawey.com", + "discord.giveaweys.com", + "discord.glfte.com", + "discord.gq", + "discord.homes", + "discord.in", + "discord.istanbul", + "discord.limited", + "discord.ltd", + "discord.luxe", + "discord.marketing", + "discord.moscow", + "discord.my", + "dIscord.net", + "discord.online", + "discord.org.ru", + "discord.porn", + "discord.pp.ru", + "discord.promo", + "discord.pt", + "discord.ru.net", + "discord.shop", + "discord.si", + "discord.team", + "discord.tools", + "discord.tw", + "discord.world", + "discord2fa.com", + "discord404.com", + "discord4nitro.com", + "discordaap.com", + "discordacc2.repl.co", + "discordadp.com", + "discordadpp.com", + "discordaepp.com", + "discordalt4.repl.co", + "discordalt5.repl.co", + "discordalts293.repl.co", + "discordaoo.com", + "discordaop.com", + "discordapp.best", + "discordapp.biz", + "discordapp.click", + "discordapp.cloud", + "discordapp.co.uk", + "discordapp.eu", + "discordapp.gg", + "discordapp.help", + "discordapp.ir", + "discordapp.org", + "discordapp.pages.dev", + "discordapp.pw", + "discordapp.rip", + "discordapp.ru.com", + "discordapp.social", + "discordapp.store", + "discordapp.support", + "discordapp.top", + "discordapp.us", + "discordapp.vercel.app", + "discordapp.vip", + "discordapp.ws", + "discordappi.fun", + "discordapplication.com", + "discordapplication.xyz", + "discordapplications.com", + "discordappo.com", + "discordappp.com", + "discordappp.net", + "discordappporn.chat", + "discordapps.gift", + "discordapps.gifts", + "discordapps.tk", + "discordappss.com", + "discordaspp.com", + "discordbagequiz.cf", + "discordbeta.com", + "discordbetter.app", + "discordboost.net", + "discordbooster.com", + "discordbothost.com", + "discordbotist.com", + "discordbots.app", + "discordbugs.com", + "discordc.gift", + "discordcanary.com", + "discordcdn.sa.com", + "discordcharity.org", + "discordcheats.net", + "discordclgift.net.ru", + "discordcommunlty.com", + "discordcrasher.wtf", + "discordcreators.net", + "discordd.buzz", + "discordd.gg", + "discordd.gift", + "discorddaapp.com", + "discorddev.com", + "discorddevelopment.com", + "discorddevs.com", + "discorddiscord.com", + "discorddrop.com", + "discorde-gift.com", + "discorde-gifte.com", + "discorde-nitro.com", + "discorde.gift", + "discorde.xyz", + "discordevents.com", + "discordf.com", + "discordf.gift", + "discordfree.com", + "discordfrnitro.site", + "discordg.com.ru", + "discordg.link", + "discordgame.com", + "discordgamers.co.uk", + "discordgft.com", + "discordgg.com", + "discordgif.com", + "discordgift.app", + "discordgift.com", + "discordgift.fun", + "discordgift.info", + "discordgift.net.ru", + "discordgift.org", + "discordgift.pw", + "discordgift.ru.com", + "discordgift.ru", + "discordgift.site", + "discordgift.tk", + "discordgift.xyz", + "discordgifte.site", + "discordgifted.xyz", + "discordgiftis.ru", + "discordgifts-pay.ru.com", + "discordgifts-pay.ru", + "discordgifts.co.uk", + "discordgifts.com", + "discordgifts.fun", + "discordgifts.info", + "discordgifts.link", + "discordgifts.me", + "discordgifts.ru.com", + "discordgifts.ru", + "discordgifts.site", + "discordgifts.store", + "discordgiftss.com", + "discordgiftsteam.ru", + "discordgiftz.xyz", + "discordgive.ru.com", + "discordgive.ru", + "discordgiveaway.fun", + "discordgivenitro.com", + "discordgivenitro.ru.com", + "discordglft.com", + "discordglft.ru", + "discordglfts.com", + "discordglfts.xyz", + "discordhalloween.co.uk", + "discordhalloween.com", + "discordhalloween.gift", + "discordhalloween.uk", + "discordi.gift", + "discordiapp.fun", + "discordiatech.co.uk", + "discordicon.com", + "discordimages.com", + "discordinfo.com", + "discordinfo.ru", + "discordinvite.ml", + "discordist.com", + "discordj.gift", + "discordjob.com", + "discordjs.tech", + "discordl-steam.com", + "discordl.com", + "discordl.pw", + "discordl.site", + "discordl.xyz", + "discordlapp.fun", + "discordlgift.com", + "discordlgift.ru.com", + "discordlinks.co.uk", + "discordlist.repl.co", + "discordlive.xyz", + "discordll.gift", + "discordlogin.com", + "discordmac.com", + "discordme.me", + "discordmoderations.com", + "discordn.com", + "discordn.gift", + "discordnitro-gift.com", + "discordnitro-steam.ru", + "discordnitro.altervista.org", + "discordnitro.biz", + "discordnitro.cc", + "discordnitro.click", + "discordnitro.club", + "discordnitro.com", + "dIscordnitro.com", + "discordnitro.fun", + "discordnitro.gift", + "discordnitro.info", + "discordnitro.link", + "discordnitro.ru.com", + "discordnitro.space", + "discordnitro.store", + "discordnitro.su", + "discordnitro9.repl.co", + "discordnitroapp.ru.com", + "discordnitroevent.info", + "discordnitrofree.com", + "discordnitrofree.xyz", + "discordnitrogenerator.com", + "discordnitrogift.com", + "discordnitrogift.ru", + "discordnitrogifts.pl", + "discordnitrolink.tk", + "discordnitropromo.site", + "discordnitros.gifts", + "discordnitros.xyz", + "discordnitrosteam.com", + "discordnltro.com", + "discordobs.com", + "discordp.com", + "discordp.ml", + "discordpap.com", + "discordpp.com", + "discordprize.xyz", + "discordpromo.site", + "discordq.com", + "discordqapp.com", + "discordqpp.com", + "discordqr.com", + "discordre.store", + "discordresearch.com", + "discordrgift.com", + "discordrgift.online", + "discordrgift.ru", + "discords-accounts.ru", + "discords-app.com", + "discords-dev.ga", + "discords-developers.com", + "discords-events.com", + "discords-gift.com", + "discords-gift.ru", + "discords-gifte.ru", + "discords-gifts.club", + "discords-gifts.ru", + "discords-glft.com", + "discords-hypes.com", + "discords-hypesquad.com", + "discords-hypesquads.com", + "discords-moderation.com", + "discords-moderator.com", + "discords-nitro.com", + "discords-nitro.site", + "discords-nitro.xyz", + "discords-nitroapp.xyz", + "discords-nitros.fun", + "discords-nitros.shop", + "discords-premium.com", + "discords-premium.site", + "discords-steam.com", + "discords-support.com", + "discords-teams.com", + "discords.biz", + "discords.co.uk", + "discords.company", + "discords.gifts", + "discords.net", + "discords.ru.com", + "discords.ru", + "discords.us", + "discordsapi.com", + "discordsapp.fun", + "discordsapp.xyz", + "discordsapplication.info", + "discordsatus.com", + "discordsearch.co", + "discordservice.com", + "discordsex.live", + "discordsgift.com", + "discordsgift.info", + "discordshort.ga", + "discordsite.repl.co", + "discordsnitro.com", + "discordsnitro.store", + "discordsnitros.one", + "discordspp.com", + "discordss.ru", + "discordstaff.xyz", + "discordstat.com", + "discordsteam.com", + "discordsteam.ru", + "discordsteams.com", + "discordsub.com", + "discordsupport.gg", + "discordt.gift", + "discordtest.xyz", + "discordtesters.com", + "discordtext.com", + "discordtoken.com", + "discordtokens.shop", + "discordtokens2.repl.co", + "discordtos.com", + "discordtotal.com", + "discordtotal.net", + "discordtts.com", + "discordtw.com", + "discordu.gift", + "discordup.ru", + "discordx.link", + "discordx.ml", + "discordxgift.xyz", + "discordxnitro.xyz", + "discordxsteam.com", + "discoredapp.com", + "discorfd.com", + "discorg.gg", + "discorgift.online", + "discorgift.xyz", + "discorid.gift", + "discoril.com", + "discorl.com", + "discorld-gift.site", + "discorld.com", + "discorld.site", + "discorlgifts.store", + "discorll.com", + "discornd.com", + "discorrd.com", + "discorrd.gift", + "discorrd.link", + "discorrd.ru", + "discorrd.site", + "discorrdapp.com", + "discorrl.com", + "discorsd.com", + "discorsd.gifts", + "discort-nitro.com", + "discort.com", + "discort.site", + "discortnitosteam.online", + "discortnitostem.online", + "discosd.com", + "discosrd.com", + "discotdapp.com", + "discourd.com", + "discourd.info", + "discourd.site", + "discourdapp.com", + "discovd.com", + "discpordapp.com", + "discprd.com", + "discqorcl.com", + "discrd.co", + "discrd.gg", + "discrdapp.cf", + "discrdapp.com", + "discrds.gift", + "discrdspp.com", + "discrocl.xyz", + "discrod-app.com", + "discrod-app.ru", + "discrod-app.site", + "discrod-apps.ru", + "discrod-gift.com", + "discrod-gifte.com", + "discrod-gifts.club", + "discrod-glfts.com", + "discrod-nitro.fun", + "discrod-nitro.info", + "discrod-up.ru", + "discrod.gg", + "discrod.gift", + "discrod.gifts", + "discrod.pw", + "discrod.ru", + "discrodapp.ru", + "discrodapp.site", + "discrodapp.xyz", + "discrode-app.club", + "discrode-app.com", + "discrode-gift.club", + "discrode-gift.com", + "discrode-gifte.club", + "discrode.gift", + "discrodnitro.org", + "discrodnitro.ru", + "discrods.gift", + "discrods.site", + "discrodsteam.online", + "discrodsteam.ru", + "discrodup.ru", + "discrord.com", + "discrordapp.com", + "discsord.com", + "discsrdapp.com", + "discurcd.com", + "discurd.js.org", + "discvordapp.com", + "discxordapp.com", + "disdrop.com.br", + "disinfo.org.ru", + "disiscord.com", + "diskord.gg", + "diskord.org.ru", + "diskord.ru.com", + "dislcord.com", + "disocordapp.com", + "disocr.com", + "disocrd-gift.com", + "disocrd-gift.ru", + "disocrd.co", + "disocrd.codes", + "disocrd.gg", + "disocrd.gifts", + "disocrd.me", + "disocrd.org", + "disocrd.ru", + "disocrd.tk", + "disocrdapp.com", + "disocrde.gift", + "disocrds.gift", + "disorc.com", + "disord.co", + "disord.codes", + "disord.fun", + "disord.gift", + "disord.gifts", + "disordapp.gift", + "disordapp.gifts", + "disorde.gift", + "disordgift.codes", + "disordgifts.com", + "disordglft.com", + "disordnitros.gifts", + "disordnitros.xyz", + "disordnltro.xyz", + "disordnltros.com", + "disordnltros.com", + "disordnltros.gifts", + "disords.gift", + "disordsnitro.gifts", + "disordsnitros.gifts", + "disrcod.com", + "disrcod.gift", + "disrcod.gifts", + "disrcord.com", + "disscord.com", + "disscord.gift", + "disscord.online", + "disscord.ru", + "disscords.club", + "dissord.com", + "dissord.gift", + "dissord.ru", + "diswcord.com", + "disxcord.com", + "disxord.com", + "diszcord.com", + "diszcordapp.com", + "diucord.js.org", + "diuscordapp.com", + "divinegardens.xyx", + "diwcord.com", + "dixcord.com", + "dixscord.com", + "dizcord.app", + "dizcord.com", + "dizcord.gift", + "dizscord.com", + "djiscord.com", + "djscord.com", + "dkscord.com", + "dlcord.gift", + "dlcsorcl.com", + "dlcsorcl.ru", + "dlcsord-airdrop.com", + "dlcsord-gift.com", + "dlicord-glfts.site", + "dlicsord.ru", + "dliscord-gift.com", + "dliscord-gift.ru.com", + "dliscord-gifts.com", + "dliscord-giveaway.ru", + "dliscord-glft.ru.com", + "dliscord-nitro.com", + "dliscord.com", + "dliscord.gift", + "dliscord.us", + "dliscordl.com", + "dliscordnltro.com", + "dliscords.com", + "dliscrd.one", + "dlisocrd.ru", + "dllscord.online", + "dlscard.ru", + "dlsccord-app.club", + "dlsccord-apps.club", + "dlsccrd.com", + "dlscocrd.club", + "dlscocrd.com", + "dlscocrdapp.com", + "dlscorcl-apps.com", + "dlscorcl.gift", + "dlscorcl.info", + "dlscorcl.ru.com", + "dlscorcl.ru", + "dlscorcl.shop", + "dlscorcl.xyz", + "dlscorclapp.fun", + "dlscord-alirdrop.com", + "dlscord-alirdrop.site", + "dlscord-app.com", + "dlscord-app.info", + "dlscord-app.net", + "dlscord-app.ru.com", + "dlscord-app.ru", + "dlscord-app.su", + "dlscord-app.xyz", + "dlscord-apps.com", + "dlscord-boost.fun", + "dlscord-claim.com", + "dlscord-developer.com", + "dlscord-game.com", + "dlscord-gift.com", + "dlscord-gift.one", + "dlscord-gift.ru.com", + "dlscord-gift.xyz", + "dlscord-gifts.com", + "dlscord-gifts.xyz", + "dlscord-glft.pw", + "dlscord-glft.ru.com", + "dlscord-glft.xyz", + "dlscord-glfts.xyz", + "dlscord-halloween.ru", + "dlscord-hypesquad.com", + "dlscord-hypesquads.com", + "dlscord-inventory.fun", + "dlscord-nitro.click", + "dlscord-nitro.fun", + "dlscord-nitro.info", + "dlscord-nitro.link", + "dlscord-nitro.ru.com", + "dlscord-nitro.space", + "dlscord-nitro.store", + "dlscord-nltro.com", + "dlscord-nltro.ru", + "dlscord-nltro.xyz", + "dlscord-promo.xyz", + "dlscord-spooky.ru", + "dlscord-steam.com", + "dlscord-stime-2021.ru", + "dlscord-store.club", + "dlscord-support.com", + "dlscord.app", + "dlscord.art", + "dlscord.blog", + "dlscord.cc", + "dlscord.click", + "dlscord.cloud", + "dlscord.fr", + "dlscord.gg", + "dlscord.gifts", + "dlscord.in", + "dlscord.info", + "dlscord.ink", + "dlscord.live", + "dlscord.net", + "dlscord.online", + "dlscord.org", + "dlscord.press", + "dlscord.pro", + "dlscord.rocks", + "dlscord.ru.com", + "dlscord.shop", + "dlscord.site", + "dlscord.space", + "dlscord.store", + "dlscord.support", + "dlscord.team", + "dlscord.tech", + "dlscord.tips", + "dlscord.wiki", + "dlscord.world", + "dlscordapp.codes", + "dlscordapp.com", + "dlscordapp.fun", + "dlscordapp.info", + "dlscordapp.pw", + "dlscordapp.ru", + "dlscordapp.store", + "dlscordapps.com", + "dlscordboost.com", + "dlscordd.ru", + "dlscordfull.ru", + "dlscordgift.com", + "dlscordgift.shop", + "dlscordgived.xyz", + "dlscordglft.xyz", + "dlscordglfts.xyz", + "dlscordniltro.com", + "dlscordnitro.com", + "dlscordnitro.info", + "dlscordnitro.ru.com", + "dlscordnitro.ru", + "dlscordnitro.store", + "dlscordnitro.us", + "dlscordnitrofree.com", + "dlscordnitros.gifts", + "dlscordnltro.gifts", + "dlscordnltro.online", + "dlscordnltro.ru", + "dlscordrglft.xyz", + "dlscords.gifts", + "dlscords.site", + "dlscordsgift.xyz", + "dlscordsglfts.xyz", + "dlscordsream.pp.ua", + "dlscordsteam.com", + "dlscorldnitro.store", + "dlscorp.com", + "dlscors.gift", + "dlscourd.info", + "dlscrod-app.xyz", + "dlscrod-game.ru", + "dlscrod-gift.com", + "dlscrod.ru.com", + "dlscrodapp.ru", + "dlsordnitro.gifts", + "dlsordnltros.gifts", + "dmarkef.com", + "dmarket-place.pp.ua", + "dmcordsteamnitro.de", + "dnitrogive.com", + "doatgiveaway.top", + "does-small.ru.com", + "dogewarrior-giveaway.info", + "dola.pp.ua", + "domineer.pp.ua", + "dominosllc.com", + "dominospizza-nl.com", + "dominospizzanl.com", + "dopeskins.com", + "doscord.com", + "doscordapp.com", + "dota2fight.net", + "dota2fight.ru", + "dota2giveaway.top", + "dota2giveaways.top", + "dotacommunitu.xyz", + "dotafights.vip", + "dotagift01.xyz", + "dotagift07.xyz", + "dotagift11.xyz", + "dotagift12.xyz", + "dotagift13.xyz", + "dotagift14.xyz", + "dotagift15.xyz", + "dotagiveaway.win", + "douyutv.ru", + "dragon-black.net.ru", + "dragon-up.online", + "dragonary-giveaway.info", + "dreamhacks-fort.site", + "dripa-discord.com", + "driscord.ru.com", + "driscord.ru", + "dro-coad.ru", + "drop-key.ru", + "drop-nitro.com", + "drop-nitro.fun", + "drop-pro.com", + "drop.net.ru", + "drop.org.ru", + "drop.pp.ru", + "dropkeygood.ml", + "drops4all.pp.ru", + "dropskey.com", + "dropskey.ru", + "dropskin.monster", + "drumairabubakar.com", + "ds-nitr.xyz", + "ds-nitro.com", + "ds-nitro.site", + "dscord-generaot.store", + "dscord.gifts", + "dscord.me", + "dscord.nl", + "dscord.xyz", + "dscordapp.com", + "dscordnitro.xyz", + "dscrd.club", + "dsctnitro.site", + "dsicord.gift", + "dsicrod.com", + "dsiscord.com", + "dsnitro.xyz", + "duiscord.com", + "dumdumdum.ru", + "duscord.com", + "duscord.js.org", + "dwaynejon.xyz", + "dwny.org", + "dxiscord.com", + "dzscord.js.org", + "e-giftpremium.com", + "ea-case.com", + "ea-drop.com", + "each-tel.xyz", + "earnskinz.xyz", + "easy-box.site", + "easycases.pw", + "easyopeningpay.online", + "easyopeningpay.ru", + "eazy-game.online", + "eazy-game.ru", + "eazydrop.monster", + "ecnhasports.ru", + "ecyber-tournament.ru", + "ecyber-versus.ru", + "egamerscup.club", + "emeraldbets.ru", + "en-roblox.com", + "ence.net.ru", + "encebrand.xyz", + "encecsport.me", + "encegun.xyz", + "encesports.xyz", + "enceteam.me", + "enceteam.org.ru", + "encewatch.ru", + "epic-request.xyz", + "epicfriendis.xyz", + "epicfriennd.xyz", + "epicgamees.xyz", + "epicgamesnitro.com", + "epicgamess.xyz", + "epicgammes.xyz", + "epicgamnes.xyz", + "epicganmes.xyz", + "epicggames.site", + "epicggames.xyz", + "epicinvite.xyz", + "epicjames.xyz", + "epickgames.xyz", + "epicqames.xyz", + "epicqannes.xyz", + "epicservic.xyz", + "epicservise.xyz", + "epilcgames.xyz", + "epiqgames.xyz", + "eplcgames.xyz", + "eplcups.com", + "eplicgames.xyz", + "eqiccames.xyz", + "eqicgames.xyz", + "esea-mdl.com", + "esl-2020.com", + "esl-drop.com", + "esl-eu.com", + "esl-gamingnetwork.com", + "esl-gamingseries.com", + "esl-lv.com", + "esl-pl.com", + "esl-playglobal.net", + "esl-pro-legue.xyz", + "esl-proleague.net", + "eslcup.xyz", + "eslgamescommunity.com", + "eslgamesworldwide.com", + "eslgaming-play.com", + "eslgaming-world.com", + "eslgamingnetworks.com", + "eslgamingopen.com", + "eslgamingworldwide.net", + "eslhub.xyz", + "eslhubgaming.com", + "eslplaynetworks.com", + "eslplayoneleague.com", + "eslplayworlds.com", + "eslpro.ru", + "eslquickseries.com", + "eslsports.ru", + "eslworldwideplay.com", + "esportgaming.ru", + "esportgift.ru", + "esportpoinl.xyz", + "esportpoint.xyz", + "esports-2go.pp.ua", + "esports-csgo.ru", + "esports-sale.ru", + "esports-trade.net.ru", + "esportscase.online", + "esportscase.ru", + "esportsfast.pp.ua", + "esportsgvay.xyz", + "esportsi.xyz", + "espots-csgo.xyz", + "essenseglow.com", + "etsdrop.monster", + "etssdrop.monster", + "event-discord.com", + "event-games4roll.com", + "events-discord.com", + "evmcups.ru", + "ewqdsa.xyz", + "exaltedbot.xyz", + "exchangeuritems.gq", + "explorerblocks.com", + "extraskinscs.xyz", + "ez-tasty.cyou", + "ezcase.xyz", + "ezclrop.ru", + "ezdiscord.xyz", + "ezdrop.net.ru", + "ezdropss.net.ru", + "ezdrp.ru", + "ezopen.site", + "ezpudge.pp.ua", + "ezwin24.ru", + "ezwithcounter.xyz", + "ezzrun.pp.ua", + "facecup.fun", + "facedrop.one", + "faceit-premium.com", + "faceiteasyleague.ru", + "faceiten.info", + "facepunch-award.com", + "facepunch-gifts.org.ru", + "facepunch-llc.com", + "facepunch-ltd.com", + "facepunch-reward.com", + "facepunch-studio.com", + "facepunch-studio.us", + "facepunch-twitch.com", + "facepunchltd.com", + "facepunchs.com", + "facepunchskins.com", + "facepunchstudio.com", + "facerit.com", + "faceuinuu.com", + "faceuinuz.com", + "faceuinuz.org.ru", + "faceuinuz.ru.com", + "fai-ceite.info", + "faiceit.ru.com", + "fall500.ru", + "fang-operation.ru", + "fannykey.ru", + "farestonpw.ru.com", + "faritkoko.ru", + "farkimagix.xyz", + "fartik.net.ru", + "fasdf.pp.ua", + "fast-cup.site", + "fastcup.ru.com", + "fastcups.xyz", + "fastdrop.win", + "fastgotournaments.xyz", + "fastlucky.ru.com", + "fastlucky.ru", + "fastskins.ru", + "fasttake.space", + "fatown.net", + "fdiscord.com", + "ff.soul-ns.xyz", + "fineleague.fun", + "fineplay.xyz", + "fireopencase.com", + "firtonesroll.ru.com", + "fiscord.com", + "fivetown.net", + "flyes-coin.com", + "fnaatic.org.ru", + "fnatcas.org.ru", + "fnatic-2021.ru", + "fnatic-drop.com", + "fnatic-gg.fun", + "fnatic-go.fun", + "fnatic-ro1ls.ru.com", + "fnatic-s.fun", + "fnatic-team.ru", + "fnatic-time.ru", + "fnatic.pp.ru", + "fnatic.team", + "fnatic1.org.ru", + "fnatic2.org.ru", + "fnaticez.me", + "fnaticforyou.xyz", + "fnaticgit.xyz", + "fnaticteam.org.ru", + "fnnatic.org.ru", + "fnnaticc.org.ru", + "fntc-bd.pp.ua", + "follow-ask.xyz", + "forcedope.xyz", + "forest-host.ru", + "formulaprize.com", + "fornite.best", + "forse-pash.pp.ru", + "forse-wash.pp.ru", + "forsportss.pp.ua", + "fortnight.space", + "fortnite-newswapper.fun", + "fortnite.sswapper.com", + "fortnitebuy.com", + "fortnitecrew.ru.com", + "fortniteswapper.fun", + "fortuneroll.tk", + "fowephwo.ru", + "foxycyber.ru", + "fozzytournaments.fun", + "fplgo.ru", + "fps-booster.pw", + "fr33item.xyz", + "free-discord.ru", + "free-dislcordnitrlos.ru", + "free-niltross.ru", + "free-nitlross.ru", + "free-nitro-sus.pages.dev", + "free-nitro.ru", + "free-nitroi.ru", + "free-nitros.ru", + "free-skins.ru", + "freediscord-nitro.cf", + "freediscordnitro.ru", + "freediscrodnitro.org", + "freediskord-nitro.xyz", + "freedrop0.xyz", + "freefireclaim.club", + "freeinstagramfollowersonline.com", + "freenetflix.io", + "freenitro.ru", + "freenitrogenerator.cf", + "freenitrogenerator.tk", + "freenitroi.ru", + "freenitrol.ru", + "freenitros.com", + "freenitros.ru", + "freenitros.tk", + "freenltro.ru", + "freerobloxgenerator.tk", + "freeskins.online", + "freeskinsfree.pp.ua", + "freespoty.com", + "from-eliasae.ru.com", + "from-puste.xyz", + "from-sparsei.ru.com", + "from-surenseds.xyz", + "ftp.celerone.cf", + "ftp.copyrighthelpbusiness.org", + "ftp.def-dclss.pp.ua", + "ftp.domineer.pp.ua", + "ftp.fasdf.pp.ua", + "ftp.ghostgame.ru", + "ftp.gooditems.pp.ua", + "ftp.greatdrops.pp.ua", + "ftp.legasytour.it", + "ftp.navieslproleagueseason13.pp.ua", + "ftp.ogevtop.ru", + "ftp.scogtopru.pp.ua", + "ftp.steamcommunlty.it", + "ftp.topeasyllucky.pp.ua", + "ftp.versuscsgoplay.pp.ua", + "fulldiscord.com", + "funchest.fun", + "fundro0p.site", + "funjet1.ru.com", + "funnydrop.store", + "furtivhqqc.com", + "furyesports.xyz", + "furyleage.xyz", + "fustcup.ru", + "g-games.store", + "g1veaway-nav1.site", + "g2-cybersport.net", + "g2-cybersport.ru", + "g2-cybersports.net", + "g2-esports.moscow", + "g2-game.ru", + "g2-give.info", + "g2-give.ru", + "g2-pro.shop", + "g2a.ru.com", + "g2cyber-espots.top", + "g2cybergame.fun", + "g2eref.ru", + "g2ezports.xyz", + "g2team-give.top", + "g2team.org", + "g2teams.com", + "g2teamss.ru", + "gaben-seller.pp.ua", + "gamaloft.xyz", + "gambit-cs.com", + "gambit.net.ru", + "gambit.org.ru", + "gambitesports.me", + "gambling1.ru.com", + "gambling1.ru", + "gamdom.ru", + "game-case.ru", + "game-csgo-steam.ru", + "game-csgosteam.ru", + "game-sense.space", + "game-steam-csgo.ru", + "game-steamcsgo.ru", + "game-tournaments.net.ru", + "game-tournaments.ru.com", + "game.schweitzer.io", + "game4roll.com", + "gameb-platform.com", + "gamecsgo-steam.ru", + "gamegowin.xyz", + "gamekere.net.ru", + "gamekor.net.ru", + "gameluck.ru", + "gamemaker.net.ru", + "gamepromo.net.ru", + "gamerich.xyz", + "gameroli.net.ru", + "gamerolls.net.ru", + "games-code.ru.com", + "games-roll.ga", + "games-roll.ml", + "games-roll.ru", + "gamesbuy.net.ru", + "gamesfree.org.ru", + "gamespol.net.ru", + "gamzc-topz.xyz", + "gamzgss-top.org.ru", + "gamzgss-top.xyz", + "garstel.github.io", + "gave-nitro.com", + "gavenitro.com", + "gbauthorization.com", + "gdiscord.com", + "gdr-op.ru.com", + "generator.discordnitrogift.com", + "get-discord.fun", + "get-gamesroll.xyz", + "get-my-nitro.com", + "get-nitro.com", + "get-nitro.fun", + "get-nitro.net", + "get-traded.xyz", + "get.sendmesamples.com", + "getautomendpro.com", + "getcach.monster", + "getfitnos.com", + "getfreediscordnitro.ml", + "getnaturetonics.com", + "getnitro.xyz", + "getnitrogen.org", + "getproviamax.com", + "getriptide.live", + "getskins.monster", + "getstratuswatch.com", + "getv-bucks.site", + "getyouritems.pp.ua", + "gfrtwgfkgc.xyz", + "gg-dr0p.ru", + "ggbolt.ru", + "ggboom.ru", + "ggdrop-gg.xyz", + "ggdrop.org.ru", + "ggdrop.pp.ru", + "ggdrop.space", + "ggdrop1.net.ru", + "ggdrops.net.ru", + "ggdrops.ru.com", + "ggexpert.online", + "ggexpert.ru", + "ggfail.xyz", + "gglootgood.xyz", + "ggnatus.com", + "ggnavincere.xyz", + "ggtour.ru", + "ghostgame.ru", + "gif-discord.com", + "gife-discorde.com", + "gift-discord.online", + "gift-discord.ru", + "gift-discord.shop", + "gift-discord.xyz", + "gift-discords.com", + "gift-g2.online", + "gift-g2.ru", + "gift-nitro.store", + "gift4keys.com", + "giftc-s.ru", + "giftcsogg.ru", + "giftdiscord.info", + "giftdiscord.online", + "giftes-discord.com", + "giftnitro.space", + "giftsdiscord.com", + "giftsdiscord.fun", + "giftsdiscord.online", + "giftsdiscord.ru", + "giftsdiscord.site", + "givaeway.com", + "givaewey.com", + "giveavvay.com", + "giveaway-fpl-navi.net.ru", + "giveaway-fpl.net.ru", + "giveawaynitro.com", + "giveawayskin.com", + "giveaweys.com", + "giveeawayscin.me", + "givenatus.site", + "giveprize.ru", + "giveweay.com", + "givrayawards.xyz", + "glaem.su", + "gleam.su", + "glets-nitro.com", + "glft-discord.com", + "glob21.online", + "globacs.monster", + "global-skins.gq", + "globalcs.monster", + "globalcss.monster", + "globalcsskins.xyz", + "globalmoestro.ru", + "globalskins.tk", + "gnswebservice.com", + "go-cs.ru.com", + "go-cups.ru", + "go.rancah.com", + "go.thefreedailyraffle.com", + "go2-rush.pp.ua", + "go4you.ru", + "gocs8.ru.com", + "gocs8q.ru", + "gocs8v.ru.com", + "gocsx.ru", + "gocsx8.ru", + "gocups.ru", + "godssale.ru", + "goldendota.com", + "goman.ru.com", + "good-csgo-steam.ru", + "gooditems.pp.ua", + "goodskins.gq", + "gool-lex.org.ru", + "gosteamanalyst.com", + "great-drop.xyz", + "greatdrops.pp.ua", + "greatgreat.xyz", + "greenwisedebtrelief.com", + "gtakey.ru", + "gtwoesport-battle.ru", + "guardian-angel.xyz", + "guns-slot.tk", + "halitaoz.cam", + "hallowen-nitro.com", + "haste.monster", + "hdiscord.com", + "hdiscordapp.com", + "hellcase.net.ru", + "hellgiveaway.trade", + "hellstorecoin.site", + "hellstores.xyz", + "help-center-portal.tk", + "help.usabenefitsguide.com", + "help.usalegalguide.com", + "help.verified-badgeform.tk", + "heroic-esports.ru", + "hjoiaeoj.ru", + "hltvcsgo.com", + "hltvgames.net", + "holyawards.xyz", + "hope-nitro.com", + "horizon-up.org.ru", + "horizonup.ru", + "hornetesports.xyz", + "host322.ru", + "howl.monster", + "howls.monster", + "httpdlscordnitro.ru.com", + "humanlifeof.xyz", + "humnchck.co", + "hunts.monster", + "huracancsgo.tk", + "huyatv.ru", + "hydra2018.ru", + "hype-chat.ru", + "hyper-tournament.xyz", + "hypercups.ru", + "hypertracked.com", + "hyperz.monster", + "id-374749.ru", + "idchecker.xyz", + "idealexplore.com", + "idiscord.pro", + "iemcup.com", + "imvu37.blogspot.com", + "in-gives.ru.com", + "indereyn.ru.com", + "information-discord.com", + "inteledirect.com", + "intimki.com", + "into-nitro.com", + "inventtop.com", + "isp3.queryhost.ovh", + "itemcloud.one", + "iwinner.ru.com", + "jet-crash.xyz", + "jetcase.fun", + "jetcase.ru.com", + "jetscup.ru", + "jjdiscord.com", + "joewfpwg.ru", + "jokedrop.ru", + "jope-nitro.com", + "joyskins.xyz", + "juct-case.ru", + "just-roll.ru", + "justcase.net.ru", + "justcause.fun", + "justdior.com", + "justwins.ru", + "kahiotifa.ru", + "kambol-go.ru", + "kaspi-capital.com", + "katowice.ru", + "katowlce.ru", + "kaysdrop.ru", + "key-dr0b.com", + "key-dr0p.com", + "key-drcp.com", + "key-drop-free.com", + "key-dropo.com", + "keydoppler.one", + "keydorp.me", + "keydrop.guru", + "keydrop.org.ru", + "keydrop.ru.com", + "keydropp.one", + "keydrops.xyz", + "keydrup.ru", + "keys-dropes.com", + "keys-loot.com", + "keysdropes.com", + "kievskiyrosdachy-ua.ru", + "kingofqueens2021.github.io", + "kirakiooi.xyz", + "kkgdrops.monster", + "knife-eazy.pp.ua", + "knifespin.top", + "knifespin.xyz", + "knifespins.xyz", + "knifex.ru.com", + "knifez-roll.xyz", + "knifez-win.xyz", + "knmirjdf.ru", + "konicpirg.com", + "kr1ks0w.ru", + "kredo-capital.com", + "ksgogift.pp.ua", + "ksodkcvm.ru", + "l0d4b860.justinstalledpanel.com", + "l1568586.justinstalledpanel.com", + "l23682ce.justinstalledpanel.com", + "l3a32c23.justinstalledpanel.com", + "l4a13998.justinstalledpanel.com", + "l4bbc943.justinstalledpanel.com", + "l95614b0.justinstalledpanel.com", + "l9f009d3.justinstalledpanel.com", + "la622566.justinstalledpanel.com", + "la76c010.justinstalledpanel.com", + "labfbb02.justinstalledpanel.com", + "lakskuns.xyz", + "lan-pro.fun", + "lan-pro.link", + "lan-pro.ru", + "lan-pro.xyz", + "lb4b95f8.justinstalledpanel.com", + "lb6469d3.justinstalledpanel.com", + "lb9d00fb.justinstalledpanel.com", + "lbd74bef.justinstalledpanel.com", + "lc995e52.justinstalledpanel.com", + "lcb2f337.justinstalledpanel.com", + "ld54d414.justinstalledpanel.com", + "ldb9f474.justinstalledpanel.com", + "ldiscord.gift", + "ldiscordapp.com", + "le491879.justinstalledpanel.com", + "league-csgo.com", + "legasytour.it", + "lehatop-01.ru", + "lemesports.ru", + "lf4d4257.justinstalledpanel.com", + "lf5d73bb.justinstalledpanel.com", + "lfa90cb7.justinstalledpanel.com", + "lfd0d93c.justinstalledpanel.com", + "lifegg.xyz", + "linktrade.pp.ua", + "listycommunity.ru", + "litenavi.xyz", + "lkdiscord.com", + "loginprofile.xyz", + "loginrun.info", + "longxrun.online", + "loot-conveyor.com", + "loot-item.xyz", + "loot-rust.com", + "loot.net.ru", + "loot.pp.ru", + "loot4fun.ru", + "lootmake.com", + "lootship.ga", + "lootshunt.org.ru", + "lootsrow.com", + "lootxmarket.com", + "loungeztrade.com", + "low-cups.ru", + "lozt.pp.ua", + "luancort.com", + "lucky-skins.xyz", + "luckycrush.ga", + "luckydrop.site", + "luckyfast.ru.com", + "luckyfast.ru", + "luckygift.net.ru", + "luckygift.space", + "luckygo.ru.com", + "luckygo.ru", + "luckyiwin.ml", + "luckyiwin.tk", + "luxace.ru.com", + "luxerkils.xyz", + "m-discord.pw", + "m.setampowered.com", + "m90694rb.beget.tech", + "made-nitro.com", + "madessk.pp.ua", + "maggicdrop.xyz", + "magic-delfy.net.ru", + "magicdropgift.ru", + "magicdropnew.xyz", + "magicrollslg.com.ru", + "magicrollslw.com.ru", + "magicroulete.ru", + "magicrun.site", + "magictop.ru.com", + "magifcrolrlc.xyz", + "magifcrolrlh.xyz", + "magifrolbiq.xyz", + "magifrolbit.xyz", + "magik-dr0p.fun", + "magikbrop.xyz", + "magnaviroll.xyz", + "magnavirolls.xyz", + "magnavirollz.xyz", + "mail.celerone.cf", + "mail.csgoroll.ru", + "mail.dicsord-airdrop.ru", + "mail.explorerblocks.com", + "mail.fasdf.pp.ua", + "mail.ghostgame.ru", + "mail.gooditems.pp.ua", + "mail.ogevtop.ru", + "mail.scogtopru.pp.ua", + "mail.streamcomuniity.pp.ua", + "mail.versuscsgoplay.pp.ua", + "majestictips.com", + "major-2021.ru", + "makson-gta.ru", + "malibones.buzz", + "marke-tcgo.ru.com", + "marke-tgo.ru.com", + "market-csgo.ru", + "market-subito.site", + "marketsleam.xyz", + "marketsm.pp.ua", + "markt-csgo.ru.com", + "markt-csru.info", + "marktcsgo.ru.com", + "mars-cup.ru", + "master-up.ru", + "maxskins.xyz", + "mcdaonlds.com", + "mcdelivery-offer.com", + "mcdelivery-sale.com", + "mcdelivery24.com", + "mcdonalds-iloveit.com", + "mcdonalds-saudiarabia.com", + "mcdonaldsau.info", + "mdiscord.com", + "medpatrik.ru", + "megacase.monster", + "mekaverse-minting.com", + "mekaversecollection.com", + "mekaversenft.net", + "microsup.net", + "minea.club", + "moderationacademy-exams.com", + "mol4a.pp.ua", + "money.fastcreditmatch.com", + "money.usacashfinder.com", + "mvcsgo.com", + "mvpcup.ru", + "mvptournament.com", + "my-trade-link.ru", + "my-tradelink.ru", + "myccgo.xyz", + "mychaelknight.com", + "mycsgoo.ru", + "mydrop.monster", + "myfast.ru", + "mygames4roll.com", + "myjustcase.ru", + "myrolls.monster", + "myrollz.com", + "mythic-esports.xyz", + "mythiccups.xyz", + "mythicleagues.xyz", + "mythicups.xyz", + "myticks.xyz", + "mytrade-link.ru.com", + "mytradelink.pp.ua", + "mytradelink.ru.com", + "mytradeoffers.ru.com", + "nacybersportvi.ru", + "nagipen.ru", + "nagiver.ru", + "naturespashowerpurifier.com", + "natus-lootbox.net.ru", + "natus-lootbox.org.ru", + "natus-open.net.ru", + "natus-open.org.ru", + "natus-open.pp.ru", + "natus-rolls.xyz", + "natus-space.ru", + "natus-spot.net.ru", + "natus-spot.pp.ru", + "natus-vincere.ru", + "natus-vincere.space", + "natus-vincere.xyz", + "natus-vincery-majors.ru.com", + "natus-vincerygive.xyz", + "natus-vincerygivess.xyz", + "natus-vincerygivesz.xyz", + "natus-vincerygivex.xyz", + "natus-vincerygivezc.xyz", + "natus-vincerygivezr.ru", + "natus-vincerygivezz.xyz", + "natus-win.net.ru", + "natus-win.org.ru", + "natus-win.pp.ru", + "natusforyou.pp.ua", + "natusspot.pp.ru", + "natustop.net.ru", + "natustop.org.ru", + "natusvincerbestmarket.work", + "natusvinceredrop.ru", + "natuswin.org.ru", + "nav-s1.ru", + "navi-21.ru", + "navi-bp.com", + "navi-cis.net.ru", + "navi-cs.com", + "navi-drop.net", + "navi-drop2020.com", + "navi-es.ru", + "navi-esl.ru.com", + "navi-esports.net", + "navi-eu.ru", + "navi-ez.com", + "navi-freedrop.xyz", + "navi-freeskins.com", + "navi-give.net.ru", + "navi-giveaway-simple.net.ru", + "navi-giveaway.net", + "navi-giveaway.xyz", + "navi-gs.com", + "navi-gt.com", + "navi-gv.com", + "navi-hawai.net.ru", + "navi-io.com", + "navi-keep.net.ru", + "navi-lix.xyz", + "navi-ls.com", + "navi-lzx.ru", + "navi-off.us", + "navi-ol.com", + "navi-q.com", + "navi-rt.com", + "navi-russia.ru", + "navi-share.pp.ru", + "navi-skins.org.ru", + "navi-skins.pp.ru", + "navi-sp.com", + "navi-tm.com", + "navi-tm.ru", + "navi-up.com", + "navi-up.ru", + "navi-winners.org.ru", + "navi-wins-skiins.org.ru", + "navi-x.ru", + "navi-youtube.net.ru", + "navi.pp.ru", + "navi2021.net.ru", + "naviback.ru", + "navibase.net.ru", + "navibase.org.ru", + "navibase.pp.ru", + "navicase-2020.org.ru", + "navicase.org", + "navicsg.ru", + "navidonative.ru", + "naviend.xyz", + "navieslproleagueseason13.pp.ua", + "naviesport.net", + "naviesportsgiveaways.pro", + "navifree.ru", + "navifreeskins.ru", + "navifun.me", + "navigg.org.ru", + "navigg.ru", + "naviggcoronagiveaway.ru", + "navigiveaway.ru", + "navign.me", + "navigs.ru", + "navileague.xyz", + "navination.site", + "navipodarok.ru", + "navipresent.xyz", + "naviqq.org.ru", + "navirolls.org.ru", + "navishare.net.ru", + "navishare.pp.ru", + "naviskins.xyz", + "naviteam.net.ru", + "naviteamway.net.ru", + "navitm.ru", + "navvigg.site", + "navviigg.ru", + "navy-freecases.ru", + "navy-loot.xyz", + "nawegate.com", + "nawi-gw.ru", + "nawibest.ru.com", + "nawigiveavay.xyz", + "netfllix-de.com", + "new-collects.xyz", + "new-drop.net.ru", + "new-offer.trade", + "new-steamcommunlty.xyz", + "new.mychaelknight.com", + "newdiscord.online", + "nice-haesh-info.ru", + "nicegg.ru", + "night-skins.com", + "nightz.monster", + "nise-cell.net.ru", + "nise-gell.org.ru", + "nise-well.org.ru", + "nise-win.xyz", + "nitrlooss-free.ru", + "nitro-airdrop.org", + "nitro-all.xyz", + "nitro-app.com", + "nitro-app.fun", + "nitro-discord.fun", + "nitro-discord.info", + "nitro-discord.me", + "nitro-discord.org", + "nitro-discord.ru.com", + "nitro-discordapp.com", + "nitro-discords.com", + "nitro-drop.com", + "nitro-ds.xyz", + "nitro-for-free.com", + "nitro-from-steam.com", + "nitro-gift.ru.com", + "nitro-gift.ru", + "nitro-gift.site", + "nitro-gift.space", + "nitro-gift.store", + "nitro-gift.xyz", + "nitro-give.site", + "nitro-up.com", + "nitro.gift", + "nitroairdrop.com", + "nitroappstore.com", + "nitrochallange.com", + "nitrodiscord.org", + "nitrodlscordl.xyz", + "nitrodlscordx.xyz", + "nitrofgift.xyz", + "nitrofrees.ru", + "nitrogeneral.ru", + "nitrogift.xyz", + "nitrogive.com", + "nitroos-frieie.ru", + "nitroosfree.ru", + "nitropussy.com", + "nitros-gift.com", + "nitrostore.org", + "nitrotypehack.club", + "nltro.site", + "ns1.dns-soul.wtf", + "ns1.dropc.me", + "ns1.navitry.me", + "ns1.peektournament.me", + "ns2.dropc.me", + "ns2.helpform-center.ml", + "nur-electro-05.ml", + "nv-pick.com", + "nvcontest.xyz", + "nwgwroqr.ru", + "offerdealstop.com", + "official-nitro.com", + "official-nitro.fun", + "ogevtop.ru", + "ogfefieibio.ru", + "okdiscord.com", + "oligarph.club", + "onehave.xyz", + "open-case.work", + "opencase.space", + "operation-broken.xyz", + "operation-pass.ru.com", + "operation-riptide.link", + "operation-riptide.ru.com", + "operation-riptide.xyz", + "operationbroken.xyz", + "operationreptide.com", + "operationriptide.tk", + "opinionshareresearch.com", + "order-40.com", + "order-78.com", + "order-87.com", + "order-96.com", + "orderpropods.com", + "ornenaui.ru", + "out-want.xyz", + "output-nitro.com", + "overdrivsa.xyz", + "ovshau.club", + "ownerbets.com", + "p.t67.me", + "paayar.info", + "pandakey.ru", + "pandaskin.ru.com", + "pandaskins.ru.com", + "pandemidestekpaket.cf", + "passjoz.net.ru", + "path.shareyourfreebies.com", + "path.topsurveystoday.com", + "patrool.net.ru", + "pay-18.info", + "payeaer.xyz", + "payear.xyz", + "payeer.life", + "payeer.live", + "payeer.vip", + "pingagency.ru", + "pizzaeria-papajohns.com", + "playcsgo-steam.ru", + "playerskinz.xyz", + "playeslseries.com", + "please.net.ru", + "pltw.com", + "pluswin.ru", + "pluswsports.ru", + "poloname.net.ru", + "pop.ghostgame.ru", + "pop.ogevtop.ru", + "pose1dwin.ru", + "poste.xyz", + "power-sk1n.net.ru", + "ppayeer.ru.com", + "ppayeer.ru", + "prajyoth-reddy-mothi.github.io", + "prajyoth.me", + "prefix.net.ru", + "premium-discord.com", + "premium-discords.com", + "premium-faceit.com", + "premiums-discord.com", + "price-claim.xyz", + "prime-drop.xyz", + "privatexplore.com", + "privatkeyblok.com", + "prizee-good.com", + "profile-2994292.ru", + "profile-442572242.online", + "profiles-7685291049068.me", + "promo-codes.world", + "promo-discord.com", + "promo-discord.site", + "proz.monster", + "psyonix-trade.online", + "psyonix.website", + "psyonlxcodes.com", + "ptbdiscord.com", + "pubg-asia.xyz", + "pubg-steamcommunityyz.top", + "pubg.network", + "pubg.new-collects.xyz", + "pubgclaims.com", + "pubge21.xyz", + "pubgfree77.com", + "pubgfreedownload.org", + "pubgfreeeus.cf", + "pubggf01.xyz", + "pubggf02.xyz", + "pubggf03.xyz", + "pubggf04.xyz", + "pubggf05.xyz", + "pubggf06.xyz", + "pubggf10.xyz", + "pubggf15.xyz", + "pubggf16.xyz", + "pubggf17.xyz", + "pubggf18.xyz", + "pubggf19.xyz", + "pubggf20.xyz", + "pubggf21.xyz", + "pubggf22.xyz", + "pubggf23.xyz", + "pubggf24.xyz", + "pubggf25.xyz", + "pubggf26.xyz", + "pubggf27.xyz", + "pubggf28.xyz", + "pubggf29.xyz", + "pubggf30.xyz", + "pubggf31.xyz", + "pubggf32.xyz", + "pubggf33.xyz", + "pubggf34.xyz", + "pubggf35.xyz", + "pubggf36.xyz", + "pubggf37.xyz", + "pubggf38.xyz", + "pubggf39.xyz", + "pubggf40.xyz", + "pubggf41.xyz", + "pubggf42.xyz", + "pubggift100.xyz", + "pubggift101.xyz", + "pubggift102.xyz", + "pubggift31.xyz", + "pubggift32.xyz", + "pubggift48.xyz", + "pubggift56.xyz", + "pubggift58.xyz", + "pubggift59.xyz", + "pubggift60.xyz", + "pubggift61.xyz", + "pubggift62.xyz", + "pubggift63.xyz", + "pubggift64.xyz", + "pubggift65.xyz", + "pubggift66.xyz", + "pubggift67.xyz", + "pubggift68.xyz", + "pubggift69.xyz", + "pubggift70.xyz", + "pubggift71.xyz", + "pubggift87.xyz", + "pubggift91.xyz", + "pubggift92.xyz", + "pubggift93.xyz", + "pubggift94.xyz", + "pubggift95.xyz", + "pubggift96.xyz", + "pubggift97.xyz", + "pubggift98.xyz", + "pubggift99.xyz", + "pubgmcheats.com", + "pubgmobile2019ucfreeeee.tk", + "pubgmobile365.com", + "pubgmobile365.giftcodehot.net", + "pubgmobile737373.ml", + "pubgmobileskin2020.com", + "pubgmobilespro.my.id", + "pubgmobileuc2020free.cf", + "pubgofficielbcseller.online", + "pubgtoken.io", + "pubguccmobilefree.cf", + "qbt-giveaway.info", + "qcold.club", + "qcoldteam.life", + "qtteddybear.com", + "quantumtac.co", + "quick-cup.xyz", + "quickrobux.net", + "r-andomfloat.ru", + "rainorshine.ru", + "ran-getto.org.ru", + "rangskins.com", + "rave-clup.ru", + "rave-new.ru", + "rblxcorp.work", + "rbux88.com", + "rbux88go.com", + "rdr2code.ru", + "realskins.xyz", + "realtorg.xyz", + "redirectednet.xyz", + "redizzz.xyz", + "rednance.com", + "redskin.monster", + "reports.noodlesawp.ru", + "reslike.net", + "rewardbuddy.me", + "rewards-rl.com", + "rewardsavenue.net", + "rewardspremium-nitro.gq", + "rien.xyz", + "rip-tide.ru", + "ripetide.ru", + "riptid-operation.ru", + "riptide-cs.com", + "riptide-cs.ru", + "riptide-csgo.ru", + "riptide-free-pass.net.ru", + "riptide-free-pass.org.ru", + "riptide-free-pass.pp.ru", + "riptide-gaming.ru", + "riptide-operation.com", + "riptide-operation.ru.com", + "riptide-operation.ru", + "riptide-operation.xyz", + "riptide-operations.ru", + "riptide-pass.org.ru", + "riptide-take.ru", + "riptide-valve.ru", + "riptidefree.ru", + "riptiden.ru", + "riptideoffer.ru", + "riptideoperation.xyz", + "riptidepass.net.ru", + "riptidepass.ru", + "rl-activate.com", + "rl-award.com", + "rl-bounce.com", + "rl-change.ru", + "rl-chaser.com", + "rl-code.com", + "rl-diamond.com", + "rl-epic.com", + "rl-fandrops.com", + "rl-fanprize.com", + "rl-fast.com", + "rl-fastrading.com", + "rl-garage.info", + "rl-garage.online", + "rl-garage.space", + "rl-give.ru.com", + "rl-insidergift.com", + "rl-performance.com", + "rl-positive.com", + "rl-promocode.com", + "rl-promos.com", + "rl-purple.com", + "rl-retail.fun", + "rl-rewards.ru.com", + "rl-tracking.pro", + "rl-traders.com", + "rlatracker.com", + "rlatracker.pro", + "rldrop-gifts.com", + "rldrop.gifts", + "rlexcihnage.com", + "rlgarages.com", + "rlgifts.org", + "rlgtracker.zone", + "rlq-trading.com", + "rlqtrading.com", + "rlshop.fun", + "rlstracker.com", + "rltracken.ru", + "rltrackings.com", + "rlv-trading.com", + "rlz-trading.com", + "robfan.work", + "roblox-collect.com", + "roblox-login.com", + "roblox-porn.com", + "roblox-robux.de", + "roblox.com.so", + "roblox.free.robux.page", + "roblox.help", + "roblox.link.club", + "robloxbing.com", + "robloxdownload.org", + "robloxgamecode.com", + "robloxgiftcardz.com", + "robloxpasssword.com", + "robloxromania.com", + "robloxs.land", + "robloxsecure.com", + "robloxstore.co.uk", + "robloxux.com", + "robloxwheelspin.com", + "robloxxhacks.co", + "robuux1.club", + "robux-codes.ga", + "robux.claimgifts.shop", + "robux20.club", + "robux247.win", + "robux4sex.tk", + "robuxat.com", + "robuxfiends.com", + "robuxfree.us", + "robuxgen.site", + "robuxhach.com", + "robuxhelp.com", + "robuxhelpers.com", + "robuxhelps.com", + "robuxprofiles.com", + "robuxtools.me", + "robuxx.work", + "robx.pw", + "rocket-dealer.com", + "rocket-item.com", + "rocket-leag.com", + "rocket-league.info", + "rocket-retailer.fun", + "rocket-tournament.fun", + "rocket-trader.fun", + "rocket-traders.store", + "rocket-trades.store", + "rocket-trading.site", + "rocket-trading.space", + "rocket-trading.store", + "rocket-tradings.com", + "rocket2pass.com", + "rocketleague-drops.com", + "rocketleagues.site", + "rocketleaque.info", + "rocketradings.com", + "rockets-garages.com", + "rockets-item.com", + "rockets-items.com", + "rockets-sale.com", + "rockets-sales.com", + "rockets-trade.com", + "roleum.buzz", + "roll-gift.fun", + "roll-skins.ga", + "roll-skins.ru", + "roll-skins.tk", + "roll-statedrop.ru", + "roll4knife.xyz", + "roll4tune.com", + "rollcas.ru.com", + "rollgame.net.ru", + "rollkey.ru.com", + "rollknfez.xyz", + "rollskin-simple.xyz", + "rollskin.ru", + "rollskins.monster", + "rollskins.ru", + "rool-skins.xyz", + "roposp12.design", + "roposp14.design", + "ropost15.xyz", + "roulette-prizes.ru.com", + "roulettebk.ru", + "royalegive.pp.ua", + "run2go.ru", + "runwebsite.ru", + "rushbskins.xyz", + "rushskillz.net.ru", + "rushskins.xyz", + "rust-award.com", + "rust-boom.xyz", + "rust-charge.com", + "rust-chest.com", + "rust-code.com", + "rust-code.ru.com", + "rust-codes.com", + "rust-drop.ru.com", + "rust-get.com", + "rust-gitfs.ru", + "rust-giveaways.xyz", + "rust-kit.com", + "rust-llc.com", + "rust-ltd.com", + "rust-reward.com", + "rust-satchel.com", + "rust-skin.com", + "rust.facepunchs.com", + "rustarea.me", + "rustg1ft.com", + "rustg1fts.online", + "rustg1fts.ru", + "rustgame-servers.com", + "rustprize.com", + "rustygift.site", + "rustyit-ems.xyz", + "s-steame.ru", + "s-teame.ru", + "s1cases.site", + "s1cses.site", + "s1mple-give-away.pp.ua", + "s1mple-spin.xyz", + "s1mplesun.design", + "s92673tu.beget.tech", + "sa-mcdonalds.com", + "safe-funds.site", + "said-home.ru.com", + "sakuralive.ru.com", + "sale-steampowered.com", + "savage-growplus.com", + "scale-navi.pp.ru", + "scl-online.ru", + "sclt.xyz", + "scltourments.xyz", + "scogtopru.pp.ua", + "scteamcommunity.com", + "scwanmei.ru", + "sdiscord.com", + "seamcommunity.com", + "seamconmunity.xyz", + "seancommunity.com", + "seancommunlty.ru", + "secure-instagram.ru", + "secure.yourreadytogoproduct.surf", + "seed-nitro.com", + "services.runescape.rs-tt.xyz", + "services.runescape.rs-ui.xyz", + "setamcommunity.com", + "shadowmarket.xyz", + "shadowpay.pp.ru", + "share.nowblox.com", + "shattereddrop.xyz", + "shib.events", + "shimermsc.ru", + "shopy-nitro.tk", + "shroud-cs.com", + "sieamcommunity.net.ru", + "sieamcommunity.org.ru", + "simple-knifez.xyz", + "simple-win.xyz", + "simplegamepro.ru", + "simplegif.ru", + "simpleroll-cs.xyz", + "simplespinz.xyz", + "simplewinz.xyz", + "siriusturnier.pp.ua", + "sitemap.onedrrive.com", + "skill-toom.pp.ru", + "skin-index.com", + "skin888trade.com", + "skincs-spin.top", + "skincs-spin.xyz", + "skincsggtl.xyz", + "skindeyyes.ru", + "skingstgg.ru", + "skingstgo.ru", + "skini-lords.net.ru", + "skinkeens.xyz", + "skinmarkets.net", + "skinnprojet.ru", + "skinpowcs.ru", + "skinpowst.ru", + "skinroll.ru.com", + "skinroll.ru", + "skins-drop.ru", + "skins-hub.top", + "skins-info.net", + "skins-jungle.xyz", + "skins-navi.pp.ru", + "skins.net.ru", + "skins.org.ru", + "skins.pp.ru", + "skins1wallet.xyz", + "skinsbon.com", + "skinsboost.ru", + "skinscsanalyst.ru", + "skinsdatabse.com", + "skinsgo.monster", + "skinsind.com", + "skinslit.com", + "skinsmedia.com", + "skinsmind.ru", + "skinspace.ru", + "skinsplane.com", + "skinsplanes.com", + "skinsplanets.com", + "skinstradehub.com", + "skinsup.monster", + "skinup.monster", + "skinxinfo.net", + "skinxmarket.site", + "skinz-spin.top", + "skinz-spin.xyz", + "skinzjar.ru", + "skinzprize.xyz", + "skinzspin-cs.xyz", + "skinzspinz.xyz", + "sklinsbaron.net", + "sl1pyymyacc.ru", + "slaaeamcrommunity.com.profiles-7685291049068.me", + "sleam-trade.net.ru", + "sleam-trade.org.ru", + "sleam-trade.pp.ru", + "sleamcominnuty.ru", + "sleamcommiinuty.ru", + "sleamcomminity.ru", + "sleamcomminutiycom.ru.com", + "sleamcommmunily.xyz", + "sleamcommmunitiy.ru", + "sleamcommmunity.com", + "sleamcommmuntiy.ru", + "sleamcommnnity.com", + "sleamcommnunity.net", + "sleamcommuiliy.ru.com", + "sleamcommuinity.xyz", + "sleamcommuintiy.ru.com", + "sleamcommuinty.store", + "sleamcommuity.com", + "sleamcommunety.ru", + "sleamcommuniitey.ru.com", + "sleamcommuniity.me", + "sleamcommuniity.ru.com", + "sleamcommuniity.xyz", + "sleamcommuniiy.ru", + "sleamcommunilly.me", + "sleamcommunilly.ru", + "sleamcommunily.net", + "sleamcommunily.org", + "sleamcommunily.ru.com", + "sleamcommuninty.com", + "sleamcommuninty.ru", + "sleamcommuniry.ru", + "sleamcommunitey.com", + "sleamcommuniti.ru", + "sleamcommuniti.xyz", + "sleamcommunitiy.com", + "sleamcommunitty.xyz", + "sleamcommunittyy.me", + "sleamcommunitu.net.ru", + "sleamcommunitu.ru", + "sleamcommunituy.com", + "sleamcommunity.me", + "sleamcommunity.net", + "sleamcommunity.org.ru", + "sleamcommunity.org", + "sleamcommunity.pp.ru", + "sleamcommunityprofiles76561199056426944.ru", + "sleamcommunityy.me", + "sleamcommunlity.xyz", + "sleamcommunlty.net.ru", + "sleamcommunlty.net", + "sleamcommunlty.ru.com", + "sleamcommunlty.space", + "sleamcommunlty.xyz", + "sleamcommunnitu.com", + "sleamcommunnity.net", + "sleamcommunnity.org", + "sleamcommunnity.ru", + "sleamcommuntiny.ru", + "sleamcommuntity.ru", + "sleamcommuntiy.com", + "sleamcommuntly.ru", + "sleamcommunty.com", + "sleamcommunyti.ru", + "sleamcommunytu.ru", + "sleamcommutiny.com", + "sleamcommuunity.com", + "sleamcommynilu.online", + "sleamcommynitu.ru", + "sleamcommynity.ru", + "sleamcommyunity.com", + "sleamcomnnuniity.ru", + "sleamcomnnuniliy.site", + "sleamcomnnunily.site", + "sleamcomnnunily.website", + "sleamcomnnunitiy.ru", + "sleamcomnnunity.ru", + "sleamcomnnunty.website", + "sleamcomnumity.com", + "sleamcomnunily.ru", + "sleamcomnunity.net.ru", + "sleamcomnunity.xyz", + "sleamcomnunlty.me", + "sleamcomrnunity.com", + "sleamcomuniity.ru", + "sleamcomunitly.co", + "sleamcomunity.me", + "sleamcomunity.net.ru", + "sleamcomunity.ru.com", + "sleamcomunuty.ru", + "sleamconmumity.com", + "sleamconmunity.ru", + "sleamconmunity.xyz", + "sleamconmunlity.com", + "sleamconmunnity.com", + "sleamconnmunitiy.com", + "sleamconnunity.net.ru", + "sleamconnunity.net", + "sleamcoommunilty.com", + "sleamcoommunily.com", + "sleamcoommunity.com", + "sleamcoommunlilty.com", + "sleamcoommunlity.com", + "sleamcoomnnunity.xyz", + "sleamcoomunity.com", + "sleamcoomuuntty.xyz", + "sleamcornmunuity.me", + "sleamcornmunyti.ru", + "sleamcornrnunity.host", + "sleamcornrnunity.ru", + "sleamcummunity.me", + "sleammcommunity.ru", + "sleammcommunnity.ru", + "sleampowered.com", + "sleampowereed.ru", + "sleamscommunity.com", + "sleamtrade-offer.xyz", + "sleancommunlty.xyz", + "sleancomninity.xyz", + "sleanmconmunltiy.ru", + "slearncommunity.store", + "sleemcomnuniti.xyz", + "sleepbuster.xyz", + "slemcamunity.ru", + "slemcommunity.com", + "slemommunity.com", + "sleramconnummitti.org", + "slreamcommumnlty.com", + "slreamcommunntiy.org", + "slreamcomnuitly.xyz", + "slreamcomunity.ru", + "slreamcomunntiy.org", + "slteamcommuinity.com", + "slteamcommunity.com", + "slteamconmuniity.com", + "slum-trade.org.ru", + "smartcommunity.net", + "smeacommunity.com.au", + "smitecommunity.org", + "smtp.ghostgame.ru", + "smtp.ogevtop.ru", + "softhack.ru", + "some-other.ru.com", + "sometheir.xyz", + "sp708431.sitebeat.site", + "spacegivewayzr.xyz", + "spacegivewayzw.xyz", + "special4u.xyz", + "speedtrkzone.com", + "spin-games.com", + "spin4skinzcs.top", + "spin4skinzcs.xyz", + "spinforskin.ml", + "spiritsport.xyz", + "sponsored-simple.xyz", + "sports-liquid.com", + "spt-night.ru", + "sreamcomminity.ru", + "sreamcommuniity.com", + "sreamcommunity.com", + "sreamcommunity.net.ru", + "sreamcommunity.org.ru", + "sreamcommunty.com", + "sreammcommuunntileiy.xyz", + "sreampowered.com", + "sreancomunllty.xyz", + "srtreamcomuninitiy.xyz", + "ssteamcommunitry.com", + "ssteamcommunity.com", + "ssteamcommunity.ru.com", + "ssteampowered.com", + "st-csgo.ru", + "st-eam.ru", + "staamcommunity.com", + "staeaemcornmunite.me", + "staeamcomunnityu.me", + "staeamconmuninty.me", + "staeamconnunitly.online", + "staeamconnunitly.ru", + "staeamcromnuninty.com.profiles-76582109509.me", + "staem-communitu.info", + "staemcammunity.com", + "staemcammunity.me", + "staemcammynlty.ru", + "staemccommunnity.net.ru", + "staemcomcommunlty.ru.com", + "staemcomcommunlty.ru", + "staemcomconmunlty.ru.com", + "staemcommintu.ru", + "staemcomminuty.online", + "staemcomminuty.ru", + "staemcommmunity.com", + "staemcommmunity.online", + "staemcommmunity.ru", + "staemcommnity.ru", + "staemcommnuniti.com", + "staemcommnunity.ru.com", + "staemcommnutiy.ru", + "staemcommueneity.com", + "staemcommuinity.com", + "staemcommuneaity.com", + "staemcommunety.com", + "staemcommuneuity.com", + "staemcommuniity.com", + "staemcommunility.com", + "staemcommunily.com", + "staemcommunily.ru.com", + "staemcommuninity.org.ru", + "staemcommuninty.me", + "staemcommunitey.com", + "staemcommunitiy.com", + "staemcommunitu.com", + "staemcommunitu.ru", + "staemcommunity.click", + "staemcommunity.com.ru", + "staemcommunity.info", + "staemcommunity.org", + "staemcommunity.ru", + "staemcommunityi.com", + "staemcommunityu.ru.com", + "staemcommuniunity.com", + "staemcommunlty.com", + "staemcommunlty.fun", + "staemcommunlty.ru", + "staemcommunlty.us", + "staemcommunninty.com", + "staemcommunnity.club", + "staemcommunnity.com", + "staemcommunnity.ru", + "staemcommunniuty.com", + "staemcommunnlty.ru", + "staemcommuntiy.com", + "staemcommuntiy.ru", + "staemcommuntly.ru", + "staemcommunty.com", + "staemcommunty.ru", + "staemcommuntyi.ru", + "staemcommunulty.ru", + "staemcommunyti.ru.com", + "staemcommynity.xyz", + "staemcomnrnunitiy.ru.com", + "staemcomnuinty.ru", + "staemcomnumity.ru", + "staemcomnunity.fun", + "staemcomnunity.org", + "staemcomnunlty.ru", + "staemcomnunyti.club", + "staemcomnunyti.ru", + "staemcomnunyti.xyz", + "staemcomrnunity.ru.com", + "staemcomrnunity.ru", + "staemcomrnunity.store", + "staemcomrrunity.com", + "staemcomumity.com", + "staemcomunetys.ru.com", + "staemcomunitly.xyz", + "staemcomunity.com", + "staemcomunity.ru", + "staemcomunnity.com", + "staemcomunyti.ru", + "staemconmuilty.com", + "staemconmunilty.com", + "staemconmunity.com", + "staemconmunity.ru.com", + "staemconmunity.ru", + "staemconmunity.xyz", + "staemconmunlty.ru", + "staemcoommnunity.ru", + "staemcoommnuty.ru", + "staemcoommunity.ru", + "staemcoommunlty.ru", + "staemcoommuntiy.ru", + "staemcoommunty.ru", + "staemcoomnunlty.ru", + "staemcoomnunty.ru", + "staemcoomunity.ru", + "staemcoomuntiy.ru", + "staemcoomuunity.ru", + "staemcoomuunity.xyz", + "staemcoomuunty.ru", + "staemcormurnity.com", + "staemcornmunity.com", + "staemcornmunity.online", + "staemcornmunity.ru.com", + "staemcornmunity.ru", + "staemcornmunity.xyz", + "staemcornmuntiy.ru", + "staemcorrmunity.com", + "staemcrommuninty.com.profiles-76577258786.ml", + "staemcrommuninty.com", + "staemcrommunity.com.profiles-768590190751377476483.me", + "staemcrornmmunity.com.profiles-75921098086.me", + "staemcummunity.ru.com", + "staemcummunlty.com", + "staemmcommunity.ru", + "staemncrommunity.store", + "staempawered.xyz", + "staemporewed.xyz", + "staempovered.com", + "staempowered.space", + "staempowered.xyz", + "staermcormmunity.com", + "staermcrommunity.me", + "staermcrommunty.me", + "staermnconnumti.com", + "staerncoinunitiy.me", + "staerncormmunity.com", + "staerncornmunity.co", + "staerncornmunity.com", + "staffcups.ru", + "staffstatsgo.com", + "stamcomunnity.pp.ua", + "stamconnunnity.xyz", + "stammcommunity.com", + "stammcornunity.xyz", + "stampowered.com", + "starmcommunity.net", + "starrygamble.com", + "stat-csgo.ru", + "stats-cs.ru", + "stayempowered.org", + "stceamcomminity.com", + "stcommunity.xyz", + "ste-trade.ru.com", + "ste.amcommunity.com", + "stea-me.ru", + "stea-sgplay.ru", + "steaamcammunitiy.com", + "steaamcamunity.com", + "steaamcommmunity.com", + "steaamcommunity.club", + "steaamcommunnity.co", + "steaamcommunnity.com", + "steaamcommunnity.ru.com", + "steaamcomunity.com", + "steaamcomunity.net", + "steaamcomunity.ru.com", + "steaamconnmunlty.com", + "steaamcorrrmunity.com", + "steacmommunity.com", + "steacommnunity.com", + "steacommunilty.ru.com", + "steacommunity.com", + "steacommunity.net.ru", + "steacommunity.org.ru", + "steacommunity.ru.com", + "steacommunity.site", + "steacommunnity.com", + "steacommunty.ru", + "steacomnmunify.fun", + "steacomnmunity.com", + "steacomnunity.ru.com", + "steaemcamunity.xyz", + "steaemcommunity.pp.ru", + "steaemcommunity.ru.com", + "steaemcomunity.com", + "steaimcoimmunity.com", + "steaimcomminnity.ru", + "steaimcommnunity.com", + "steaimcommumitiy.com", + "steaimcommuniity.com", + "steaimcommunitiy.com", + "steaimcommunytiu.com", + "steaimecommintliy.com", + "steaimecommuninitiy.com", + "steaimecommunytiu.com", + "steaimecommunytu.com", + "steaimeecommunity.com", + "stealcommuniti.ru", + "stealcommunity.com", + "stealcommunlti.com", + "stealmcommulnitycom.xyz", + "stealmcommunity.ru", + "steam-account.ru.com", + "steam-account.ru", + "steam-account.site", + "steam-accounts.com", + "steam-analyst.ru", + "steam-announcements1.xyz", + "steam-auth.com", + "steam-auth.ru", + "steam-cammuneti.com", + "steam-communiity.ru", + "steam-community.net.ru", + "steam-community.org.ru", + "steam-community.ru.com", + "steam-community.xyz", + "steam-community1.xyz", + "steam-communitygifts.xyz", + "steam-communitygifts1.xyz", + "steam-communitysource.xyz", + "steam-communitysource1.xyz", + "steam-communitytrade.xyz", + "steam-comunity.me", + "steam-cs-good.ru", + "steam-cs.ru", + "steam-csgo-game.ru", + "steam-csgo-good.ru", + "steam-csgo-store.ru", + "steam-csgo.ru", + "steam-csgocom.ru", + "steam-csgogame.ru", + "steam-csgoplay.ru", + "steam-discord.com", + "steam-discord.ru", + "steam-discords.com", + "steam-dlscord.com", + "steam-free-nitro.ru", + "steam-g5chanaquyufuli.ru", + "steam-game-csgo.ru", + "steam-gametrade.xyz", + "steam-historyoffer.xyz", + "steam-hometrade.xyz", + "steam-hometrades.xyz", + "steam-hype.com", + "steam-login.ru", + "steam-login1.xyz", + "steam-nitro.com", + "steam-nitro.ru", + "steam-nitro.store", + "steam-nitros.com", + "steam-nitros.ru", + "steam-nltro.com", + "steam-nltro.ru", + "steam-nltros.ru", + "steam-offer.com", + "steam-offersgames.xyz", + "steam-offersofficial.xyz", + "steam-offerstore.xyz", + "steam-officialtrade.xyz", + "steam-play-csgo.ru", + "steam-povered.xyz", + "steam-power.xyz", + "steam-power1.xyz", + "steam-powered-games.com", + "steam-powered.xyz", + "steam-powered1.xyz", + "steam-poweredexchange.xyz", + "steam-poweredoffer.xyz", + "steam-poweredoffers.xyz", + "steam-poweredtrades.xyz", + "steam-profile.com", + "steam-promo-page.ml", + "steam-rep.com", + "steam-ru.ru", + "steam-service.ru", + "steam-servicedeals.xyz", + "steam-servicedeals1.xyz", + "steam-site.ru", + "steam-sourcecommunity.xyz", + "steam-sourcecommunity1.xyz", + "steam-storetrade.xyz", + "steam-storetrade1.xyz", + "steam-support.xyz", + "steam-trade.xyz", + "steam-tradegame.xyz", + "steam-tradehome.xyz", + "steam-tradeoffer.com", + "steam-tradeoffer.xyz", + "steam-trades.icu", + "steam-tradeshome.xyz", + "steam-tradestore.xyz", + "steam-tradestore1.xyz", + "steam.99box.com", + "steam.cards", + "steam.cash", + "steam.cheap", + "steam.codes", + "steam.communty.com", + "steam.communyty.worldhosts.ru", + "steam.comnunity.com", + "steam.luancort.com", + "steam.mmosvc.com", + "steam4you.online", + "steamaccount.xyz", + "steamaccountgenerator.ru.com", + "steamaccounts.net", + "steamaccounts.org", + "steamacommunity.com", + "steamanalysts.com", + "steambrowser.xyz", + "steamc0mmunity.com", + "steamc0munnity.site", + "steamcamiutity.com", + "steamcammiuniltty.com", + "steamcammmunity.ru", + "steamcammnuity.com", + "steamcammuinity.com", + "steamcammuniety.com", + "steamcammunitey.com", + "steamcammuniti.ru", + "steamcammunitu.com", + "steamcammunitu.ru.com", + "steamcammunity-profile.ru", + "steamcammunity.net", + "steamcammunity.top", + "steamcammunlty.ru", + "steamcammuntiy.com", + "steamcammunty.com", + "steamcammunuty.com", + "steamcammunyty.fun", + "steamcammunyty.ru", + "steamcamnunity.com.ru", + "steamcamnunity.ru", + "steamcamunite.com", + "steamcamunitey.com", + "steamcamunitu.com", + "steamcamunitu.xyz", + "steamcamunity-profile.ru", + "steamcamunity.com", + "steamcamunity.ru", + "steamcamunity.top", + "steamcamunity.xyz", + "steamcamunlty.com", + "steamcamunnity.xyz", + "steamcannunlty.com", + "steamcard.me", + "steamccommuniity.com", + "steamccommunity.com", + "steamccommunity.net", + "steamccommunity.ru.com", + "steamccommunityy.ru", + "steamccommunyty.ru", + "steamccommurity.ru", + "steamccommyunity.com", + "steamccomunnity.ru.com", + "steamcconmmuunity.co", + "steamchinacsgo.ru", + "steamcmmunuti.ru", + "steamcmmunyti.ru", + "steamcmunity.com", + "steamco.mmunity.com", + "steamco.ru", + "steamcoarnmmnunity.ru.com", + "steamcodesgen.com", + "steamcokmunity.com", + "steamcomannlty.xyz", + "steamcombain.com", + "steamcomcmunlty.com", + "steamcomcunity.ru", + "steamcominity.ru", + "steamcominuty.ru", + "steamcomity.com", + "steamcomiunity.com", + "steamcomiunity.xyz", + "steamcomiynuytiy.net.ru", + "steamcommenitry.ru", + "steamcommenity.ru", + "steamcommeunity.com", + "steamcommhnity.com", + "steamcomminiity.site", + "steamcomminiti.ru", + "steamcomminity.com", + "steamcomminity.ru.com", + "steamcomminity.ru", + "steamcomminnty.com", + "steamcommintty.com", + "steamcomminty.ru", + "steamcomminulty.ru", + "steamcomminuly.com", + "steamcomminuly.ru", + "steamcomminutiiu.ru", + "steamcomminutiu.ru", + "steamcomminutiy.ru", + "steamcomminutty.ru", + "steamcomminuty-offer.ru.com", + "steamcomminuty.click", + "steamcomminuty.com", + "steamcomminuty.link", + "steamcomminuty.me", + "steamcomminuty.nl", + "steamcomminuty.repl.co", + "steamcomminuty.ru.com", + "steamcomminuty.ru", + "steamcomminuty.xyz", + "steamcomminyti.ru", + "steamcomminytiu.com", + "steamcomminytiu.ru", + "steamcomminytiy.ru", + "steamcomminytu.click", + "steamcomminytu.com", + "steamcomminytu.link", + "steamcomminytu.ru", + "steamcomminyty.ru.com", + "steamcommiuinity.com", + "steamcommiunitiy.pp.ru", + "steamcommiunitty.ru", + "steamcommiunity.pp.ru", + "steamcommiunity.ru", + "steamcommiunniutty.net.ru", + "steamcommiunty.ru", + "steamcommiynitiy.net.ru", + "steamcommllty.com", + "steamcommlnuty.com", + "steamcommlunity.com", + "steamcommmuiniity.ru", + "steamcommmunitty.site", + "steamcommmunity.xyz", + "steamcommmunlity.com", + "steamcommmunnity.com", + "steamcommmunty.com", + "steamcommninty.com", + "steamcommnity.com.ru", + "steamcommnity.com", + "steamcommnity.ru", + "steamcommnity.store", + "steamcommnlty.com", + "steamcommnlty.xyz", + "steamcommnmunity.ru", + "steamcommnnity.net.ru", + "steamcommnnunity.ru", + "steamcommnnunnity.world", + "steamcommntiy.xyz", + "steamcommnuitly.com", + "steamcommnuitty.com", + "steamcommnultiy.ru", + "steamcommnulty.com", + "steamcommnulty.store", + "steamcommnunily.com", + "steamcommnunily.xyz", + "steamcommnuninty.com", + "steamcommnuninty.ru.com", + "steamcommnunitlu.com", + "steamcommnunitu.com", + "steamcommnunity.com", + "steamcommnunity.org.ru", + "steamcommnunity.ru.com", + "steamcommnunlty.com", + "steamcommnunlty.icu", + "steamcommnunlty.ru", + "steamcommnunlty.xyz", + "steamcommnunmity.com", + "steamcommnunniiy.net.ru", + "steamcommnuntiy.com", + "steamcommnunty.ru", + "steamcommnunylti.com", + "steamcommnunyti.com", + "steamcommnunytl.com", + "steamcommnutly.ru.com", + "steamcommnutry.com", + "steamcommnutry.ru", + "steamcommnuty.site", + "steamcommnuuntiy.com", + "steamcommonitey.com", + "steamcommonnnity.ru.com", + "steamcommqnity.com", + "steamcommrnunity.com", + "steamcommrunitly.com", + "steamcommrutiny.ru", + "steamcommtity.com", + "steamcommuanity.ru.com", + "steamcommuenity.com", + "steamcommuhity.ru", + "steamcommuhuity.com", + "steamcommuilty.ru", + "steamcommuinilty.com", + "steamcommuininty.com", + "steamcommuinitiycom.ru", + "steamcommuinity.com", + "steamcommuinity.ru", + "steamcommuinty.com.ru", + "steamcommuinuity.com", + "steamcommuiti.ru", + "steamcommuitliy.com", + "steamcommuitly.ru", + "steamcommuity.com", + "steamcommuity.ru", + "steamcommulity.ru", + "steamcommulltty.com", + "steamcommullty.ru", + "steamcommulnity.com", + "steamcommulnt.ru.com", + "steamcommulnty.ru", + "steamcommulty.ru", + "steamcommumilty.com", + "steamcommumitiy.com", + "steamcommumituy.com", + "steamcommumity.biz", + "steamcommumity.net", + "steamcommumiuty.com", + "steamcommumlity.com", + "steamcommumnity.com", + "steamcommumtiy.com", + "steamcommun1ty.ru", + "steamcommunely.ru", + "steamcommuneteiy.com", + "steamcommunetiy.com", + "steamcommunetiy.ru", + "steamcommunetiyi.com", + "steamcommunetiyy.xyz", + "steamcommunetu.com", + "steamcommunety.com", + "steamcommunety.net.ru", + "steamcommunety.online", + "steamcommunety.org.ru", + "steamcommunety.ru", + "steamcommunety1i.com", + "steamcommunetyei.com", + "steamcommuneuity.ru", + "steamcommunhity.com", + "steamcommuni.com", + "steamcommunicty.com", + "steamcommunicty.ru.com", + "steamcommunidy.com", + "steamcommunieityi.com", + "steamcommunieti.ru", + "steamcommunietiy.com", + "steamcommuniety.com", + "steamcommuniety.ru", + "steamcommunifly.ru.com", + "steamcommunify.com", + "steamcommunify.ru", + "steamcommunihty.com", + "steamcommuniiity.com", + "steamcommuniilty.ru", + "steamcommuniitu.site", + "steamcommuniity.com.ru", + "steamcommuniiy.online", + "steamcommuniiy.ru", + "steamcommunikkty.net.ru", + "steamcommunili.xyz", + "steamcommunility.com", + "steamcommunillty.com", + "steamcommunillty.net.ru", + "steamcommunillty.ru.com", + "steamcommunillty.ru", + "steamcommunilly.com", + "steamcommuniltily.ru.com", + "steamcommuniltiy.online", + "steamcommuniltiy.ru", + "steamcommuniltly.com", + "steamcommunilty.buzz", + "steamcommunilty.it", + "steamcommunilty.ru.com", + "steamcommunilty.us", + "steamcommunilty.xyz", + "steamcommuniltys.com", + "steamcommunilv.com", + "steamcommunily.buzz", + "steamcommunily.org", + "steamcommunily.uno", + "steamcommunimty.ru.com", + "steamcommuninity.ru.com", + "steamcommuninthy.com", + "steamcommuninty.ru.com", + "steamcommuninunty.com", + "steamcommunirtly.ru.com", + "steamcommunirty.com", + "steamcommunirty.ru.com", + "steamcommuniry.com", + "steamcommuniry.net.ru", + "steamcommuniry.ru", + "steamcommunit.org.ru", + "steamcommunit.ru.com", + "steamcommunit.ru", + "steamcommunitcy.ru.com", + "steamcommunite.com", + "steamcommunite.ru", + "steamcommunitey.com", + "steamcommunitey.ru", + "steamcommuniteypowered.com", + "steamcommunitfy.com", + "steamcommunitfy.ru.com", + "steamcommunithy.com", + "steamcommuniti.com.ru", + "steamcommuniti.org.ru", + "steamcommuniti.org", + "steamcommuniti.ru.com", + "steamcommunitie.net", + "steamcommunitie.ru.com", + "steamcommunitie.ru", + "steamcommunitie.site", + "steamcommunities.biz", + "steamcommunitii.xyz", + "steamcommunitily.com", + "steamcommunitity.com", + "steamcommunitiu.ru", + "steamcommunitiv.com", + "steamcommunitiy.ru", + "steamcommunitiycom.ru", + "steamcommunitiyu.com", + "steamcommunitiyy.com", + "steamcommunitj.buzz", + "steamcommunitl.com", + "steamcommunitl.net.ru", + "steamcommunitli.ru", + "steamcommunitlil.ru", + "steamcommunitliy.ru.com", + "steamcommunitlly.com", + "steamcommunitlly.net", + "steamcommunitlly.ru.com", + "steamcommunitlu.com", + "steamcommunitluy.com", + "steamcommunitly.com", + "steamcommunitly.me", + "steamcommunitmy.ru.com", + "steamcommunitry.com", + "steamcommunitry.ru", + "steamcommunitte.com", + "steamcommunitte.ru", + "steamcommunittey.com", + "steamcommunittrade.xyz", + "steamcommunittru.co", + "steamcommunittry.xyz", + "steamcommunitty.com.ru", + "steamcommunitty.esplay.eu", + "steamcommunitty.net", + "steamcommunitty.site", + "steamcommunitty.top", + "steamcommunitu.com-profile-poka.biz", + "steamcommunitu.com-profiles-mellenouz.trade", + "steamcommunitu.icu", + "steamcommunitu.net", + "steamcommunitu.ru.com", + "steamcommunitv.ru", + "steamcommunitvs.com", + "steamcommunitx.ru.com", + "steamcommunity-com.xyz", + "steamcommunity-comtradeoffer.ru", + "steamcommunity-gifts.xyz", + "steamcommunity-gifts1.xyz", + "steamcommunity-nitro.ru", + "steamcommunity-nitrogeneral.ru", + "steamcommunity-profile.net", + "steamcommunity-profiles.ru.com", + "steamcommunity-source.xyz", + "steamcommunity-source1.xyz", + "steamcommunity-trade.xyz", + "steamcommunity-tradeoffer.com", + "steamcommunity-tradeoffer.ru.com", + "steamcommunity-tradeoffer4510426522.ru", + "steamcommunity-tradeoffers.com", + "steamcommunity-user.me", + "steamcommunity-xpubg.xyz", + "steamcommunity.at", + "steamcommunity.best", + "steamcommunity.biz", + "steamcommunity.ca", + "steamcommunity.click", + "steamcommunity.cloud", + "steamcommunity.cn", + "steamcommunity.co.ua", + "steamcommunity.com-id-k4tushatwitchbabydota.ru", + "steamcommunity.com.ru", + "steamcommunity.comlappl251490lrust.ru", + "steamcommunity.de", + "steamcommunity.digital", + "steamcommunity.eu", + "steamcommunity.in", + "steamcommunity.link", + "steamcommunity.live", + "steamcommunity.llc", + "steamcommunity.mobi", + "steamcommunity.moscow", + "steamcommunity.net.in", + "steamcommunity.pl", + "steamcommunity.pp.ru", + "steamcommunity.rest", + "steamcommunity.ru.net", + "steamcommunity.ru", + "steamcommunity.site", + "steamcommunity.steams.ga", + "steamcommunity.support", + "steamcommunity.team", + "steamcommunity.trade", + "steamcommunity.us", + "steamcommunity1.com", + "steamcommunitya.com", + "steamcommunityc.com", + "steamcommunityc.ru", + "steamcommunitycom.ru.com", + "steamcommunitycomoffernewpartner989791155tokenjbhldtj6.trade", + "steamcommunitycomtradeoffer.ru.com", + "steamcommunitygames.com", + "steamcommunitygifts.xyz", + "steamcommunitygifts1.xyz", + "steamcommunityi.com", + "steamcommunityi.ru.com", + "steamcommunityi.ru", + "steamcommunityid.ru", + "steamcommunitylink.xyz", + "steamcommunitym.com", + "steamcommunitym.ru", + "steamcommunitynow.com", + "steamcommunityo.com", + "steamcommunityoff.com", + "steamcommunityoffers.org", + "steamcommunitypubg.com", + "steamcommunityr.com.ru", + "steamcommunityru.tk", + "steamcommunityshop.com", + "steamcommunitysource.xyz", + "steamcommunitysource1.xyz", + "steamcommunitytradeofer.com", + "steamcommunitytradeoffer.com", + "steamcommunitytradeoffer.ru", + "steamcommunitytradeoffter.com", + "steamcommunitytradeofter.com", + "steamcommunitytredeoffer.com", + "steamcommunityu.com", + "steamcommunityu.ru", + "steamcommunityw.com", + "steamcommunityw.net.ru", + "steamcommunityw.org.ru", + "steamcommunitywork.com", + "steamcommunitywork.ml", + "steamcommunityx.com", + "steamcommunityy.online", + "steamcommunityy.ru", + "steamcommunityz.com", + "steamcommunityzbn.top", + "steamcommunityzbo.top", + "steamcommunityzbq.top", + "steamcommunityzbr.top", + "steamcommunityzcd.top", + "steamcommunityzce.top", + "steamcommunityzci.top", + "steamcommunityzda.top", + "steamcommunityzdb.top", + "steamcommunityzdd.top", + "steamcommunityzdl.top", + "steamcommunityzdp.top", + "steamcommunityzdq.top", + "steamcommunityzdr.top", + "steamcommunityzds.top", + "steamcommunityzdt.top", + "steamcommuniuity.com", + "steamcommuniutiiy.com", + "steamcommuniutiy.ru", + "steamcommuniuty.ru", + "steamcommuniy.com", + "steamcommuniyt.com", + "steamcommuniytu.com", + "steamcommuniyty.ru", + "steamcommunjti.com", + "steamcommunjtv.xyz", + "steamcommunjty.net", + "steamcommunjty.ru", + "steamcommunlilty.ru.com", + "steamcommunlite.com", + "steamcommunlitily.ru.com", + "steamcommunlitly.ru", + "steamcommunlitty.ru.com", + "steamcommunlitty.ru", + "steamcommunlity.net", + "steamcommunlity.ru.com", + "steamcommunlity.ru", + "steamcommunlityl.ru", + "steamcommunliu.com", + "steamcommunlky.net.ru", + "steamcommunllity.ru.com", + "steamcommunllty.com", + "steamcommunllty.ru", + "steamcommunlte.ru", + "steamcommunltiy.club", + "steamcommunltiy.com", + "steamcommunltty.com", + "steamcommunltu.com", + "steamcommunltuy.com", + "steamcommunltv.buzz", + "steamcommunlty-proflle.com.ru", + "steamcommunlty.biz", + "steamcommunlty.business", + "steamcommunlty.cloud", + "steamcommunlty.company", + "steamcommunlty.info", + "steamcommunlty.link", + "steamcommunlty.pro", + "steamcommunlty.shop", + "steamcommunlty.site", + "steamcommunlty.store", + "steamcommunlty.top", + "steamcommunltyu.ru", + "steamcommunltyy.com", + "steamcommunly.com", + "steamcommunly.net.ru", + "steamcommunmity.com.ru", + "steamcommunniittly.ru", + "steamcommunniitty.com", + "steamcommunniity.com", + "steamcommunniity.net", + "steamcommunniity.ru", + "steamcommunnilty.com", + "steamcommunnilty.ru", + "steamcommunnitey.com", + "steamcommunnitlly.ru", + "steamcommunnitty.ru", + "steamcommunnity.co", + "steamcommunnity.com.ru", + "steamcommunnity.ml", + "steamcommunnity.net", + "steamcommunnity.ru.com", + "steamcommunnity.ru", + "steamcommunnjty.com", + "steamcommunnlity.ru", + "steamcommunnlty.com.ru", + "steamcommunnty.ru", + "steamcommunnuty.ru", + "steamcommunrinty.ru.com", + "steamcommunrity.com", + "steamcommunrity.ru.com", + "steamcommunrlity.com", + "steamcommunrrity.com", + "steamcommunti.com", + "steamcommuntily.ru.com", + "steamcommuntily.ru", + "steamcommuntity.com", + "steamcommuntity.ru.com", + "steamcommuntiv.com", + "steamcommuntiy.com", + "steamcommuntli.ru", + "steamcommuntliy.ru", + "steamcommuntly.com", + "steamcommuntry.com", + "steamcommunty.buzz", + "steamcommunty.com.ru", + "steamcommunty.com", + "steamcommunty.net", + "steamcommunty.pw", + "steamcommunty.ru.com", + "steamcommuntyy.ru", + "steamcommunuaity.xyz", + "steamcommunuety.ru", + "steamcommunuity.net", + "steamcommunuity.ru", + "steamcommununty-con.ru", + "steamcommununty.ru", + "steamcommunury.ru", + "steamcommunute.com", + "steamcommunuti.co", + "steamcommunuti.ru", + "steamcommunutii.ru", + "steamcommunutiy.com", + "steamcommunutry.com", + "steamcommunutry.ru", + "steamcommunutty.com", + "steamcommunutty.ru", + "steamcommunutuy.com", + "steamcommunuty.buzz", + "steamcommunuty.co", + "steamcommunuty.link", + "steamcommunuty.org.ru", + "steamcommunuty.ru", + "steamcommunutyu.com", + "steamcommunvti.ru", + "steamcommunyity.ru", + "steamcommunylty.ru", + "steamcommunyte.com", + "steamcommunyti.com", + "steamcommunyti.info", + "steamcommunytitradeoffer.com", + "steamcommunytiu.com", + "steamcommunytiu.ru", + "steamcommunytiy.ru", + "steamcommunytiy.tk", + "steamcommunytu.ru", + "steamcommunyty.com", + "steamcommunyty.ru.com", + "steamcommunyty.xyz", + "steamcommunytytradeofferphobos.ru", + "steamcommuriity.com", + "steamcommurity.ru", + "steamcommurjty.com", + "steamcommurlity.com", + "steamcommurlty.com", + "steamcommurnity.com", + "steamcommurnuity.com", + "steamcommutinny.ru.com", + "steamcommutiny.com", + "steamcommutiny.ru.com", + "steamcommutiny.ru", + "steamcommutiny.xyz", + "steamcommutry.ru", + "steamcommuty.com", + "steamcommutyniu.com", + "steamcommutyniy.com", + "steamcommuuity.net.ru", + "steamcommuulty.com", + "steamcommuunitey.com", + "steamcommuunitty.ru.com", + "steamcommuunity.net.ru", + "steamcommuunity.pp.ru", + "steamcommuunity.ru.com", + "steamcommuunity.ru", + "steamcommuunjty.com", + "steamcommuunlity.com", + "steamcommuunlty.com", + "steamcommuwunity.com", + "steamcommuynity.ru.com", + "steamcommyinuty.ru", + "steamcommymity.ru", + "steamcommynite.com", + "steamcommyniti.ru", + "steamcommyniti.xyz", + "steamcommynitiu.com", + "steamcommynitry.ru", + "steamcommynitu.com", + "steamcommynitu.net.ru", + "steamcommynitu.ru.com", + "steamcommynitu.ru", + "steamcommynitu.xyz", + "steamcommynituy.com", + "steamcommynity.icu", + "steamcommynity.ru", + "steamcommynity.space", + "steamcommynityprofile.ru", + "steamcommynltu.com", + "steamcommynlty.com", + "steamcommynlty.ru", + "steamcommynnityy.com", + "steamcommynuti.ru", + "steamcommynutiy.ru", + "steamcommynutu.ru", + "steamcommynuty.ru.com", + "steamcommynyti.ru", + "steamcommynyti.site", + "steamcommytiny.com", + "steamcommytuniu.com", + "steamcommyuinity.net.ru", + "steamcommyunity.com", + "steamcomnenity.ru.com", + "steamcomninuty.ru.com", + "steamcomninytiu.com", + "steamcomniunity.com", + "steamcomnmnuty.ru", + "steamcomnmrunity.online", + "steamcomnmrunity.ru", + "steamcomnmufly.ru.com", + "steamcomnmuituy.com", + "steamcomnmuity.ru", + "steamcomnmunity.com.ru", + "steamcomnmunlty.com", + "steamcomnmuntiy.ru.com", + "steamcomnmutly.ru.com", + "steamcomnmuunity.ru.com", + "steamcomnmynitu.com", + "steamcomnnity.net.ru", + "steamcomnnlty.com", + "steamcomnnuity.com", + "steamcomnnunilty.com", + "steamcomnnunity.co", + "steamcomnnunity.ru.com", + "steamcomnnunity.ru", + "steamcomnnunlty.ru", + "steamcomnnunty.ru", + "steamcomnnuty.ru", + "steamcomnnynlty.com", + "steamcomnuenuity.com", + "steamcomnuhity.com", + "steamcomnuiti.xyz", + "steamcomnulty.com", + "steamcomnumilty.com", + "steamcomnumily.com", + "steamcomnumity.com", + "steamcomnumity.org.ru", + "steamcomnumity.ru.com", + "steamcomnumity.ru", + "steamcomnumity.xyz", + "steamcomnumlity.com", + "steamcomnumlty.com", + "steamcomnumlty.ru", + "steamcomnumnity.com", + "steamcomnumty.ru", + "steamcomnuniity.com.ru", + "steamcomnuniity.pp.ru", + "steamcomnuniity.ru.com", + "steamcomnunilty.com", + "steamcomnunilty.ru.com", + "steamcomnunily.co", + "steamcomnunirty.ru", + "steamcomnuniti.com", + "steamcomnunitiy.com", + "steamcomnunitiy.ru", + "steamcomnunitly.com", + "steamcomnunitly.tk", + "steamcomnunitry.ru", + "steamcomnunitty.com", + "steamcomnunity.com", + "steamcomnunity.net", + "steamcomnunity.org.ru", + "steamcomnunity.ru", + "steamcomnunity.site", + "steamcomnunityprofile.ru.com", + "steamcomnunlity.com", + "steamcomnunlity.ru", + "steamcomnunllty.com", + "steamcomnunllty.net", + "steamcomnunlty.ru.com", + "steamcomnunlty.ru", + "steamcomnunluty.ru", + "steamcomnunmity.com", + "steamcomnunnirty.ru", + "steamcomnunniry.ru", + "steamcomnunnity.com", + "steamcomnunnity.net.ru", + "steamcomnunnity.net", + "steamcomnunnlty.ru", + "steamcomnuntiy.com", + "steamcomnuntty.ru.com", + "steamcomnunutiy.ru", + "steamcomnunuty.com", + "steamcomnunuty.ru", + "steamcomnunytu.ru", + "steamcomnurity.com", + "steamcomnurity.xyz", + "steamcomnutiny.online", + "steamcomnutiny.ru.com", + "steamcomnutiny.ru", + "steamcomnuty.com", + "steamcomnuunlty.com", + "steamcomnynlity.ru", + "steamcomonity.com", + "steamcomrmunity.ru.com", + "steamcomrmunnuity.ru.com", + "steamcomrneuneity.com", + "steamcomrninuty.link", + "steamcomrninuty.ru", + "steamcomrninuty.site", + "steamcomrnity.xyz", + "steamcomrnlnuty.site", + "steamcomrnumity.com", + "steamcomrnunite.com", + "steamcomrnuniti.ru.com", + "steamcomrnunitu.ru.com", + "steamcomrnunitu.ru", + "steamcomrnunity.com.ru", + "steamcomrnunity.online", + "steamcomrnunity.ru.com", + "steamcomrnunity.ru", + "steamcomrnunity.site", + "steamcomrnunity.su", + "steamcomrnunity.xyz", + "steamcomrnunlty.com", + "steamcomrnunlty.ru", + "steamcomrnunuity.ru.com", + "steamcomrnyniti.ru.com", + "steamcomrnyniti.ru", + "steamcomrrnunity.com", + "steamcomrrnunity.net.ru", + "steamcomrrnunity.ru", + "steamcomrunily.com", + "steamcomrunity.com", + "steamcomueniity.ru", + "steamcomumity.com", + "steamcomumunty.com", + "steamcomunety.com", + "steamcomunety.ru", + "steamcomuniety.ru", + "steamcomuniiity.com", + "steamcomuniitly.ru.com", + "steamcomuniity.ru.com", + "steamcomunillty.ru.com", + "steamcomuniltu.xyz", + "steamcomunilty.com", + "steamcomunily.ru.com", + "steamcomuninruty.ru", + "steamcomuniti.com", + "steamcomuniti.ru", + "steamcomuniti.xyz", + "steamcomunitly.pp.ru", + "steamcomunitly.ru", + "steamcomunitty.ru.com", + "steamcomunitu.com", + "steamcomunitu.net.ru", + "steamcomunitu.ru", + "steamcomunituy.com", + "steamcomunity-comid12121212123244465.ru", + "steamcomunity-nitro-free.ru", + "steamcomunity.com.ru", + "steamcomunity.com", + "steamcomunity.me", + "steamcomunity.net.ru", + "steamcomunity.org.ru", + "steamcomunity.ru", + "steamcomunity.us", + "steamcomunityo.com", + "steamcomunitytrades.xyz", + "steamcomunityy.com", + "steamcomunlitly.ru.com", + "steamcomunlty.ru.com", + "steamcomunmity.ru.com", + "steamcomunniity.ru", + "steamcomunninuty.com", + "steamcomunnitly.ru.com", + "steamcomunnitu.xyz", + "steamcomunnity.fun", + "steamcomunnity.ru.com", + "steamcomunnity.site", + "steamcomunnity.xyz", + "steamcomunnlty.com", + "steamcomunnuity.com", + "steamcomunnuty.com", + "steamcomunnyti.ru", + "steamcomuntty.com", + "steamcomunty.org.ru", + "steamcomunuty.com", + "steamcomunuty.ru", + "steamcomunyiti.ru.com", + "steamcomunyti.com", + "steamcomunytiu.com", + "steamcomuuniity.com", + "steamcomuunity.com", + "steamcomuunity.ru.com", + "steamcomyniti.xyz", + "steamcomynitu.ru", + "steamcomynity.ru", + "steamcomynlty.com", + "steamcomynnitu.net.ru", + "steamconimmunity.com", + "steamconminuty.ru", + "steamconmiunity.ru", + "steamconmmuntiy.com", + "steamconmnmnunity.ru", + "steamconmnmunity.ru", + "steamconmnunitiy.ru.com", + "steamconmnunitiy.ru", + "steamconmnunity.co", + "steamconmnunity.com", + "steamconmnunity.ru", + "steamconmnunuty.ru.com", + "steamconmnutiny.ru", + "steamconmuhlty.com", + "steamconmumity.com.ru", + "steamconmumity.com", + "steamconmumity.ru.com", + "steamconmumity.ru", + "steamconmumltu.com.ru", + "steamconmummity.ru", + "steamconmumnity.com", + "steamconmuniti.ru", + "steamconmunitly.com", + "steamconmunitty.com", + "steamconmunity.co", + "steamconmunity.com.ru", + "steamconmunity.pp.ru", + "steamconmunity.xyz", + "steamconmunjty.com", + "steamconmunlly.com", + "steamconmunlty.com.ru", + "steamconmunlty.com", + "steamconmunlty.ru", + "steamconmunnitry.ru", + "steamconmunnlty.ru", + "steamconmunuty.ru", + "steamconmunyty.com", + "steamconmunyty.ru", + "steamconnmuhity.com", + "steamconnmunitu.net.ru", + "steamconnmunity.ru", + "steamconnmunlty.com", + "steamconnmunlty.ru.com", + "steamconnmunlty.ru", + "steamconnnnunity.net.ru", + "steamconnnnunity.org.ru", + "steamconnumity.ru.com", + "steamconnummity.ru", + "steamconnumuty.com", + "steamconnuniitty.tk", + "steamconnunirty.ru", + "steamconnunitiy.com", + "steamconnunity.com.ru", + "steamconnunity.com", + "steamconnunity.de", + "steamconnunity.fun", + "steamconnunity.net", + "steamconnunity.pp.ru", + "steamconnunity.ru.com", + "steamconnunlty.com", + "steamconummity.ru", + "steamconunity.cf", + "steamconunity.ru", + "steamconunity.tk", + "steamconunlty.ru", + "steamconynuyty.net.ru", + "steamconynuyty.org.ru", + "steamcoominuty.site", + "steamcoomminuty.site", + "steamcoommunety.com", + "steamcoommuniity.link", + "steamcoommuniity.ru", + "steamcoommunilty.com", + "steamcoommunity.pp.ru", + "steamcoommunity.ru.com", + "steamcoommunllty.com", + "steamcoommunlty.ru", + "steamcoommunuity.com", + "steamcoommunuty.com", + "steamcoomrnmunity.ml", + "steamcoomunity-nitro.site", + "steamcoomunitye.com", + "steamcoomunjty.com", + "steamcoomunlty.com", + "steamcoomunlty.net", + "steamcoomunlty.ru", + "steamcoomunnity.com", + "steamcoomunnity.ru", + "steamcoomynity.ru", + "steamcoonmuntiy.ru", + "steamcoormmunity.com", + "steamcormmmunity.com", + "steamcormmunity.com", + "steamcormmunity.net.ru", + "steamcormmunity.ru.com", + "steamcormmuntiy.com", + "steamcormmuuity.ru", + "steamcormrunity.com", + "steamcormunity.ru", + "steamcormunity.xyz", + "steamcormurnity.com", + "steamcornminity.ru.com", + "steamcornminty.xyz", + "steamcornminuty.com", + "steamcornmmunity.com", + "steamcornmnitu.ru.com", + "steamcornmnuity.com", + "steamcornmunety.com", + "steamcornmunify.ru.com", + "steamcornmuniity.net.ru", + "steamcornmunily.ru", + "steamcornmunit.ru.com", + "steamcornmunite.com", + "steamcornmunity.fun", + "steamcornmunity.net.ru", + "steamcornmunity.org", + "steamcornmunty.com", + "steamcornmunyti.ru", + "steamcornmynitu.ru", + "steamcornmynity.ru", + "steamcornrnuity.com", + "steamcornrnunity.com.ru", + "steamcornrnunity.fun", + "steamcornrrnunity.com", + "steamcorrmunity.com", + "steamcorrnmunity.ru", + "steamcorrnunity.org", + "steamcoummunitiy.com", + "steamcoummunity.com", + "steamcrommunlty.me", + "steamcromnmunity-com.profiles-7685981598976.me", + "steamcronnmmuniry.me", + "steamcsgo-game.ru", + "steamcsgo-play.ru", + "steamcsgo.ru", + "steamcsgoplay.ru", + "steamcummunity.com.ru", + "steamcummunity.com", + "steamcummunity.ru.com", + "steamcummunity.ru", + "steamcummunityy.pp.ua", + "steamcummunnity.com", + "steamcumumunity.com.ru", + "steamdesksupport.com", + "steamdiscord.com", + "steamdiscord.ru", + "steamdiscordi.com", + "steamdiscordj.com", + "steamdiscords.com", + "steamdiscrod.ru", + "steamdlscord.com", + "steamdlscords.com", + "steamdocs.xyz", + "steamdomain.online", + "steamdomain.ru", + "steamdommunity.com", + "steamecommuinty.com", + "steamecommunitiiy.com", + "steamecommunitiy.com", + "steamecommunituiy.com", + "steamecommunity.net", + "steamecommunity.org", + "steamecommunity.pp.ua", + "steamecommunity.ru.com", + "steamecommuniuty.com", + "steamecommunlty.com.ru", + "steamecommunlty.com", + "steamecommunytu.com", + "steamecomunity.com.ru", + "steamedpowered.com", + "steamepowered.com", + "steamescommunity.com", + "steamgame-csgo.ru", + "steamgame-trade.xyz", + "steamgame.net.ru", + "steamgamepowered.net", + "steamgames.net.ru", + "steamgamesroll.ru", + "steamgametrade.xyz", + "steamgiftcards.cf", + "steamgifts.net.ru", + "steamgiveaway.cc", + "steamgiveawayfree.ru", + "steamgivenitro.com", + "steamglft.ru", + "steamguard.ir", + "steamhelp.net", + "steamhome-trade.xyz", + "steamhome-trades.xyz", + "steamhometrade.xyz", + "steamhometrades.xyz", + "steamicommunnity.com", + "steamid.ru", + "steamitem.xyz", + "steamkey.ru", + "steamkommunity.net.ru", + "steamkommunity.org.ru", + "steamlcommunity.net.ru", + "steamlcommunity.org.ru", + "steamlcommunity.ru.com", + "steamm.store", + "steammatily.online", + "steammatily.ru", + "steammcamunitu.com", + "steammcamunity.com", + "steammcamunity.ru.com", + "steammcomminity.ru", + "steammcomminuty.ru", + "steammcommmunlty.pp.ua", + "steammcommunety.com", + "steammcommuniity.ru", + "steammcommunily.net.ru", + "steammcommunitey.com", + "steammcommunitly.ru", + "steammcommunity-trade.xyz", + "steammcommunity.com", + "steammcommunity.ru.com", + "steammcommunity.ru", + "steammcommunnity.ru", + "steammcommunyti.ru", + "steammcommuunityy.ru.com", + "steammcomtradeoff.com", + "steammcomunit.ru", + "steammcomunity.ru", + "steammcomunlty.ru", + "steammcomunnity.com", + "steammcounity.ru.com", + "steammecommunity.com", + "steammncommunty.ru.com", + "steamncommnunity.ru", + "steamncommnunty.ru", + "steamncommuinity.com", + "steamncommumity.ru", + "steamncommuniity.com", + "steamncommunitiy.com", + "steamncommunitu.co", + "steamncommunity.com", + "steamncommunity.pp.ru", + "steamncommunity.ru", + "steamncommunity.xyz", + "steamncommunytu.ru", + "steamncomnunlty.com.ru", + "steamncomunitity.com", + "steamncomunity.com", + "steamncomunity.xyz", + "steamnconmunity.com", + "steamnconmunity.ru.com", + "steamnconmunity.work", + "steamnconnmunity.com", + "steamnitro.com", + "steamnitrol.com", + "steamnitros.com", + "steamnitros.ru", + "steamnitrro.com", + "steamnltro.com", + "steamnltros.com", + "steamnltros.ru", + "steamnmcomunnity.co", + "steamocmmunity.me", + "steamoemmunity.com", + "steamoffer-store.xyz", + "steamoffered.trade", + "steamoffergames.xyz", + "steamommunity.com", + "steamoowered.com", + "steamowered.com", + "steampawared.club", + "steampawered.store", + "steampcwered.com", + "steampewared.com", + "steampewered.com", + "steampiwered.com", + "steampoeer.com", + "steampoeerd.com", + "steampoewred.com", + "steampoiwered.com", + "steampoowered.com", + "steampowaered.com", + "steampoward.com", + "steampowder.com", + "steampowed.com", + "steampoweded.com", + "steampoweeed.com", + "steampowened.ru.com", + "steampower.co", + "steampower.de", + "steampower.space", + "steampowerco.com", + "steampowerd.com", + "steampowerd.net", + "steampowerde.com", + "steampowerded.com", + "steampowerdwallet.com", + "steampowere.com", + "steampoweread.com", + "steampowerec.com", + "steampowered-offer.xyz", + "steampowered-offers.xyz", + "steampowered-swap.xyz", + "steampowered-swap1.xyz", + "steampowered-trades.xyz", + "steampowered.company", + "steampowered.de", + "steampowered.freeskins.ru.com", + "steampowered.help", + "steampowered.irl.com.pk", + "steampowered.jcharante.com", + "steampowered.org", + "steampowered.tw", + "steampowered.us", + "steampowered.xyz", + "steampoweredcinema.com", + "steampoweredcommunity.com", + "steampoweredexchange.xyz", + "steampoweredexchanges.xyz", + "steampoweredkey.com", + "steampoweredmarketing.com", + "steampoweredoffer.xyz", + "steampoweredoffers.xyz", + "steampoweredpoetry.com", + "steampoweredshow.com", + "steampoweredswap.xyz", + "steampoweredtrades.xyz", + "steampowereed.com", + "steampowererd.com", + "steampowerered.com", + "steampowerewd.com", + "steampowerred.com", + "steampowers.com", + "steampowers.org", + "steampowerwd.com", + "steampowerwed.com", + "steampowoereid.com", + "steampowored.com", + "steampowrd.com", + "steampowred.ru", + "steampowwered.com", + "steampowwred.com", + "steamppwrred.com", + "steampromo.net.ru", + "steamproxy.net", + "steampunch-twitch.co", + "steampwered.com", + "steampwoered.com", + "steamrccommunity.com", + "steamrcommuniity.com", + "steamrcommunity.ru", + "steamroll.org.ru", + "steamrolll.net.ru", + "steamrolls.net.ru", + "steamrolls.pp.ru", + "steamrommunily.com", + "steamrommunity.org.ru", + "steamru.org", + "steams-community.ru", + "steams-discord.ru", + "steamscommmunity.com", + "steamscommunitey.com", + "steamscommunity.com", + "steamscommunity.pro", + "steamscommunity.ru", + "steamscommunyti.com", + "steamscommynitu.co", + "steamscomnunity.com", + "steamscomnunyti.com", + "steamsconmunity.com", + "steamsdiscord.com", + "steamservice-deals.xyz", + "steamservice-deals1.xyz", + "steamservicedeals.xyz", + "steamservicedeals1.xyz", + "steamshensu.top", + "steamskincs.ru", + "steamsnitro.ru", + "steamsoftware.info", + "steamsommunity.com", + "steamsommunity.ru", + "steamsomunity.com", + "steamsourcecommunity.xyz", + "steamsourcecommunity1.xyz", + "steamstore.map2.ssl.hwcdn.net", + "steamstore.site", + "steamstorecsgo.com", + "steamstorepowered.com", + "steamstoretrade1.xyz", + "steamstradecommunity.xyz", + "steamsupportpowered.icu", + "steamswap.xyz", + "steamtrade-game.xyz", + "steamtrade-home.xyz", + "steamtrade-store.xyz", + "steamtrade-store1.xyz", + "steamtradecommunity.fun", + "steamtradehome.xyz", + "steamtradeoffeer.com", + "steamtradeoffer.net", + "steamtradeprofile.com", + "steamtrades-home.xyz", + "steamtrades-store.xyz", + "steamtrades.com", + "steamtradeshome.xyz", + "steamtradesofer.com", + "steamtradestore.xyz", + "steamtradestore1.xyz", + "steamunlocked.online", + "steamunlocked.pro", + "steamunpowered.com", + "steamuppowered.com", + "steamuserimages-a.akamaid.net", + "steamwalletbd.com", + "steamwalletcodes.net", + "steamwanmeics.ru", + "steamwcommunity.com", + "steamwcommunity.net", + "steamworkspace.com", + "steamzcommunity.com", + "steanammunuty.ml", + "steancammunity.com", + "steancammunity.ru", + "steancammunlte.com", + "steancammunlty.com", + "steancammunyti.com", + "steanccommunity.ru", + "steancimnunity.ru", + "steancommanty.ru.com", + "steancommeuniliy.ru.com", + "steancomminity.com", + "steancomminity.ru", + "steancomminyty.com", + "steancomminyty.ru.com", + "steancommiuniliy.ru.com", + "steancommiunity.com", + "steancommmunity.com", + "steancommnnity.com", + "steancommnuitty.com", + "steancommnuity.com", + "steancommnulty.com", + "steancommnunity.ru", + "steancommnunitytradeoffer.xyz", + "steancommnunlty.ru", + "steancommounity.com", + "steancommrnity.com", + "steancommueniliy.ru.com", + "steancommuhity.com", + "steancommuhity.ru", + "steancommuineliy.ru.com", + "steancommuiniliy.ru.com", + "steancommuinty.ru", + "steancommuinuty.ru", + "steancommuity.com", + "steancommuity.ru", + "steancommumity.com", + "steancommumity.net", + "steancommumlty.com", + "steancommuncity.ru", + "steancommunety.com", + "steancommunety.ru", + "steancommunify.com", + "steancommuniiity.com", + "steancommuniiliy.ru.com", + "steancommuniit.ru.com", + "steancommuniite-xuz.ru", + "steancommuniite.xyz", + "steancommuniitty.com", + "steancommuniity.com", + "steancommuniity.fun", + "steancommuniity.ru", + "steancommunilly.com", + "steancommunilty.com", + "steancommunilty.ru", + "steancommunily.ru", + "steancommunite.site", + "steancommuniti.com.ru", + "steancommuniti.site", + "steancommunitiy.com.ru", + "steancommunitiy.ru", + "steancommunitry.ru", + "steancommunitty.com", + "steancommunitty.xyz", + "steancommunitv.com", + "steancommunity.cc", + "steancommunity.click", + "steancommunity.host", + "steancommunity.link", + "steancommunity.net.ru", + "steancommunity.pw", + "steancommunity.ru.com", + "steancommunity.ru", + "steancommunitytradeaffer.xyz", + "steancommunlity.ru.com", + "steancommunllty.com", + "steancommunlty.business", + "steancommunlty.com", + "steancommunlty.ru.com", + "steancommunlty.ru", + "steancommunmilty.com", + "steancommunniitly.ru", + "steancommunniity.ru", + "steancommunnilty.ru", + "steancommunnily.ru", + "steancommunnitl.ru", + "steancommunnitlly.ru", + "steancommunnity.co", + "steancommunnity.site", + "steancommunnliity.ru", + "steancommunnlity.ru", + "steancommunnlty.com", + "steancommunnlty.ru", + "steancommunnty.com", + "steancommunnuly.me", + "steancommuntiy.ru.com", + "steancommuntly.com", + "steancommunuity.ru", + "steancommunuty.com", + "steancommunyti.com", + "steancommunyti.ru.com", + "steancommurily.xyz", + "steancommutiny.ru", + "steancommuuity.com", + "steancommuuniliiy.ru.com", + "steancommuuniliy.ru.com", + "steancommuunity.com", + "steancommuvity.com", + "steancommynitu.com", + "steancommynity.org.ru", + "steancommynity.ru.com", + "steancommynuti.ru", + "steancommynyty.ru.com", + "steancomnmunity.ru", + "steancomnnunity.com", + "steancomnnunnity.ru", + "steancomnuilty.ru.com", + "steancomnuity.com", + "steancomnumity.com", + "steancomnumlty.com", + "steancomnumlty.ru", + "steancomnuniiity.ru", + "steancomnuniity.com", + "steancomnunilty.ru", + "steancomnunity.com", + "steancomnunity.ru", + "steancomnunitys.ru", + "steancomnunlty.ru", + "steancomnunnity.xyz", + "steancomnunyti.ru.com", + "steancomnunytu.ru.com", + "steancomnunytu.ru", + "steancomnurity.one", + "steancomnurity.xyz", + "steancomnuuniliy.ru.com", + "steancomrnunitiy.com", + "steancomrnunity.com", + "steancomrnunity.ru", + "steancomrnunuty.ru", + "steancomuniiity.com", + "steancomuniite-xuz.ru", + "steancomuniity.com", + "steancomunite-xuz.ru", + "steancomunitiy.ru.com", + "steancomunitly.ru", + "steancomunity.ru.com", + "steancomunitytradeffer.xyz", + "steancomunnity.ru", + "steancomunnity.tk", + "steancomunnlty.me", + "steancomunnlty.ru.com", + "steancomunyiti.ru", + "steancomunyti.ru.com", + "steancomuunity.com", + "steanconmnuity.com", + "steanconmumity.com", + "steanconmumlty.com", + "steanconmunitiy.co", + "steanconmunitly.ru", + "steanconmunity.ru", + "steanconmunlly.ru", + "steanconmunlty.com", + "steanconmunlty.ru", + "steanconmunuty.ru", + "steanconmunuty.xyz", + "steanconmunyti.ru.com", + "steanconmunyti.ru", + "steanconmynmuti.com", + "steanconnunitly.xyz", + "steanconnunity.com", + "steanconnunlty.com", + "steancoommuniity.xyz", + "steancoommunity.com", + "steancoommunity.xyz", + "steancoommunitytradeofferr.com", + "steancoommunnity.com", + "steancoomnuity.com", + "steancoomnunity.com", + "steancoomunnity.com", + "steancornminuty.com", + "steancornmunuty.ru", + "steancouminnuty.org", + "steanecommunlty.site", + "steanfocuak.ru", + "steanfocusd.xyz", + "steanfocusi.ru", + "steanfocusk.ru", + "steanfocusse.ru", + "steanfocussi.ru", + "steanmcommuniitiy.ru", + "steanmcommunily.ru", + "steanmcommunity.com", + "steanmcommunity.ru.com", + "steanmcommunity.ru", + "steanmcommuniuty.ru.com", + "steanmcommunlty.ru", + "steanmcommunlty.xyz", + "steanmcommzunity.ru", + "steanmcomnuinmty.com", + "steanmcomnuity.com", + "steanmcomnumntiy.com", + "steanmcomnumty.com", + "steanmcomnunitiy.com", + "steanmcomnunity.com", + "steanmcomnynuytiy.org.ru", + "steanmcomrninuty.xyz", + "steanmcomumnity.xyz", + "steanmcomunitly.ru", + "steanmconmunity.com", + "steanmconmunnity.ru", + "steanmconnynuytiy.net.ru", + "steanmconynnuytiy.net.ru", + "steanmconynnuytiy.org.ru", + "steanmecommunity.com", + "steanmncommunity.com", + "steanmncomnunity.com", + "steanncammunlte.com", + "steanncammunlte.ru", + "steanncmmunytiy.ru", + "steanncomminity.ru.com", + "steanncommity.co", + "steanncommiuty.com", + "steanncommnunyti.com", + "steanncommuiniuty.com", + "steanncommunily.com", + "steanncommunitv.com", + "steanncommunity.com", + "steanncommuniuity.com", + "steanncommunlty.com", + "steanncomnmunity.com", + "steanncomnuniity.com", + "steanncomnuniity.online", + "steanncomnuniity.ru", + "steanncomnuniity.xyz", + "steanncomnunity.xyz", + "steanncomunitiy.ru.com", + "steanncomunitli.ru.com", + "steanncomunitly.co", + "steanncomunitly.ru.com", + "steanncomunitly.ru", + "steanncomunitty.site", + "steanncomunity.com", + "steanncomunnity.ru", + "steannconmunity.com", + "steannconnmunity.com", + "steannconnnnunity.net.ru", + "steannconnnunity.com", + "steannconnunynity.ru", + "steannecomunlty.com", + "steanpowered.net.ru", + "steanpowered.xyz", + "steanrcommunitiy.com", + "steapowered.com", + "steappowered.com", + "stearamcomminnity.net", + "stearamcomnunitu.xyz", + "stearcommity.com", + "stearcommuity.com", + "stearcommunitly.com", + "stearmcammunity.com", + "stearmcommnity.com", + "stearmcommnumity.com", + "stearmcommnunity.com", + "stearmcommnunnity.org", + "stearmcommrunity.com", + "stearmcommuniity.com", + "stearmcommuniity.ru.com", + "stearmcommuninty.com", + "stearmcommunitly.ru", + "stearmcommunitry.cf", + "stearmcommunitty.ru.com", + "stearmcommunity.com", + "stearmcommunity.one", + "stearmcommunity.ru.com", + "stearmcommunltly.com", + "stearmcommunnitty.online", + "stearmcommunnity.ru.com", + "stearmcommuunity.ru.com", + "stearmcommuunity.ru", + "stearmcommuunnity.ru", + "stearmcommynity.fun", + "stearmcomrmunity.co", + "stearmcomrmunity.com", + "stearmcomrnunitiy.com", + "stearmcomrnunity.com", + "stearmconmmunity.com", + "stearmconmunity.ru", + "stearmconmunnity.com", + "stearmconnrnunity.com", + "stearmcormmunity.com", + "stearmcornmunitiy.com", + "stearmcornmunity.ru", + "stearmcornmunlty.com", + "stearmcornnnunity.com", + "stearmmcommuniity.ru", + "stearmmcomunitty.ru", + "stearmmcomunity.ru", + "stearmmcomuunity.ru", + "stearncomiunity.ru", + "stearncomminhty.com", + "stearncomminutiu.ru", + "stearncomminuty.click", + "stearncomminuty.com", + "stearncomminuty.link", + "stearncomminuty.ru.com", + "stearncomminuty.ru", + "stearncomminytu.com", + "stearncommiunity.com", + "stearncommiuty.co", + "stearncommmnuity.xyz", + "stearncommmunity.online", + "stearncommmunity.ru", + "stearncommninuty.com", + "stearncommnniity.com", + "stearncommnniity.ru", + "stearncommnnity.co.uk", + "stearncommnnity.com", + "stearncommnuinty.com", + "stearncommnuity.ru.com", + "stearncommnunity.ru.com", + "stearncommonity.ru", + "stearncommrunity.com", + "stearncommubity.com", + "stearncommuinuty.co", + "stearncommumitly.com", + "stearncommumity.com", + "stearncommumlty.com", + "stearncommunety.com", + "stearncommunety.ru", + "stearncommungty.com", + "stearncommunhty.com", + "stearncommunigy.com", + "stearncommuniitty.xyz", + "stearncommuniity.click", + "stearncommuniity.ru", + "stearncommuniity.site", + "stearncommuniityt.click", + "stearncommunilly.site", + "stearncommunilty.ru", + "stearncommunilty.site", + "stearncommunily.ru", + "stearncommunily.website", + "stearncommuninity.com", + "stearncommuniry.com", + "stearncommunite.com", + "stearncommunitey.com", + "stearncommunitey.ru", + "stearncommunitly.ru", + "stearncommunitly.website", + "stearncommunitly.xyz", + "stearncommunity.click", + "stearncommunity.link", + "stearncommunity.net.ru", + "stearncommunity.ru", + "stearncommunivy.com", + "stearncommunjty.com", + "stearncommunlity.com", + "stearncommunlty.ru", + "stearncommunlty.site", + "stearncommunlty.store", + "stearncommunnitty.xyz", + "stearncommunnity.ru", + "stearncommunnity.xyz", + "stearncommunrty.com", + "stearncommuntity.com", + "stearncommuntiy.com", + "stearncommuntty.com", + "stearncommunuitiy.com", + "stearncommunuity.net.ru", + "stearncommunutiy.com", + "stearncommunyti.ru", + "stearncommunytiy.ru", + "stearncommunytiyu.ru", + "stearncommurity.ru", + "stearncommutiny.online", + "stearncommutiny.ru", + "stearncommuty.com", + "stearncommynitu.ru.com", + "stearncommynity.fun", + "stearncommynity.ru.com", + "stearncomnmunity.com", + "stearncomnnunity.fun", + "stearncomnnunity.site", + "stearncomnnunity.website", + "stearncomnnunty.com.ru", + "stearncomnumity.com", + "stearncomnunily.com", + "stearncomnunitu.ru", + "stearncomnunitv.ru.com", + "stearncomnunity.com", + "stearncomnunity.org", + "stearncomnunity.ru.com", + "stearncomnunnity.ru", + "stearncomrmunity.co", + "stearncomrmunity.com", + "stearncomrmynity.fun", + "stearncomrninuty.ru", + "stearncomrninuty.xyz", + "stearncomrnrunity.ru.com", + "stearncomrnrunity.ru", + "stearncomrnunety.com", + "stearncomrnunitly.site", + "stearncomrnunitly.xyz", + "stearncomrnunity.com", + "stearncomrnunity.ru", + "stearncomrnunity.store", + "stearncomrnunlity.ru", + "stearncomrnunlty.site", + "stearncomrnunyti.ru", + "stearncomrrnunity.com", + "stearncomrrunity.com", + "stearncomrunity.ru.com", + "stearncomrunity.ru", + "stearncomunitu.ru", + "stearncomunlty.ru.com", + "stearncomynity.ru", + "stearnconmumity.com", + "stearnconmunity.com", + "stearnconmunity.me", + "stearnconmunity.net", + "stearnconmuntiy.ru", + "stearnconmuuity.com", + "stearnconmuulty.ru", + "stearnconnrnunity.xyz", + "stearnconrmunity.com", + "stearncormmunity.com", + "stearncormmunity.ru", + "stearncormunity.ru", + "stearncormunniti.org", + "stearncornminuty.com", + "stearncornminuty.ru", + "stearncornmnuity.ru", + "stearncornmrunity.ru.com", + "stearncornmunitiy.com", + "stearncornmunitly.com", + "stearncornmunity.com", + "stearncornmunity.net", + "stearncornmunity.ru.com", + "stearncornmunity.ru", + "stearncornmunlty.ru", + "stearncornmunuty.ru", + "stearncornmurnity.ru.com", + "stearncornnumyty.com", + "stearncornnunity.ru", + "stearncornrnnity.ru.com", + "stearncornrnuity.com", + "stearncornrnunity.com", + "stearncornrnunity.ru.com", + "stearncornunity.ru", + "stearncornunity.xyz", + "stearncornurniity.xyz", + "stearncorrmunity.com", + "stearncurnmunity.com", + "stearnmcommunnity.com", + "stearnmcomunity.com", + "stearnncomrnunitiy.com", + "stearnncomrnunity.com", + "stearnporewed.ru.com", + "stearnpovvered.com", + "stearnpowered.online", + "stearnpowered.xyz", + "steasmpowered.com", + "steawcammunity.xyz", + "steawcommunity.com", + "steawcommunity.net", + "steawcomunity.net", + "steawconnunity.xyz", + "steawmcommunity.net", + "steawmcomnunnity.ru", + "steawmcomuunity.ru", + "steawmcowmunnity.ru", + "steawmpowered.com", + "steawncomnunity.ru", + "steawpowered.com", + "steawscommunity.net", + "steaxmcommity.com", + "steeaamcomunity.xyz", + "steeacmcommumitiy.com", + "steeamcommmunety.com", + "steeamcommmunitty.site", + "steeamcommmunity.com", + "steeamcommuinitty.com", + "steeamcommunity.me", + "steeamcommunity.ml", + "steeamcommunity.ru.com", + "steeamcommunlity.com", + "steeamcommunlity.ru", + "steeamcommunllty.xyz", + "steeamcommunlty.com", + "steeamcommunnity.ru.com", + "steeamcommunnity.ru", + "steeamcommunnlty.ru", + "steeamcommunnuity.ru.com", + "steeamcommunyti.com", + "steeamcomnnunity.com", + "steeamcomuneety.com", + "steeamcomunitty.com", + "steeamcomunity.net", + "steeamcomunlty.ru.com", + "steeamcomunlty.ru", + "steeamcomunnlty.com", + "steeamcoommunity.ru", + "steeammcomunity.com", + "steeammcomunlty.com", + "steeampowered.tk", + "steeamwins.xyz", + "steemacommunity.com", + "steemcammunllty.com", + "steemcammunlly.com", + "steemcammunlty.com", + "steemcommmunety.com", + "steemcommmunity.com", + "steemcommnnity.com", + "steemcommnunity.ru", + "steemcommnunnity.ru.com", + "steemcommuinty.com", + "steemcommuniity.com", + "steemcommunily.ru.com", + "steemcommuninity.org.ru", + "steemcommuniry.com", + "steemcommunitey.com", + "steemcommuniti.com", + "steemcommunitry.com", + "steemcommunity.co", + "steemcommunity.com", + "steemcommunity.ru.com", + "steemcommunityy.com", + "steemcommuniy.com", + "steemcommunllty.com", + "steemcommunlty.com", + "steemcommunly.com", + "steemcommunnity.co", + "steemcommunnity.net", + "steemcommuntiy.ru.com", + "steemcommuntiy.ru", + "steemcommunty.net.ru", + "steemcommunty.org.ru", + "steemcommunty.pp.ru", + "steemcommunty.ru", + "steemcommuunity.com", + "steemcommynity.ru", + "steemcomnmunity.com", + "steemcomnrunity.com", + "steemcomrnunity.co", + "steemcomrnunity.com", + "steemcomrunity.ru", + "steemcomunatlytradeoffer40034231.ru", + "steemcomuniti.com", + "steemcomuniti.ru", + "steemcomunity.me", + "steemcomunity.net.ru", + "steemcomunity.org.ru", + "steemcomunity.pp.ru", + "steemcomunnity.com", + "steemconnunity.com", + "steemcoommunity.com", + "steemcoommunity.ru", + "steemcoommunlty.ru", + "steemcoommuntiy.ru", + "steemcoommunty.ru", + "steemcoomnunty.ru", + "steemcoomunity.xyz", + "steemcoomuntiy.ru", + "steemcoomuunity.ru", + "steemcoonmuntiy.ru", + "steemcowwunity.xyz", + "steempowerd.ru", + "steempowered.com", + "steemurl.com", + "steencommunilty.com", + "steencommunityy.xyz", + "steiamcommuinity.com", + "steiamcommunityi.com", + "steimcomnunnity.ru.com", + "stemacommunity.net", + "stemacommunlty.com", + "stemacomunity.com", + "stemapowered.com", + "stemcammuniety.ru", + "stemcammuniity.com", + "stemcammuniity.ru", + "stemcamnunity.com", + "stemcamnunity.ru", + "stemccomnmunity.com", + "stemcomiunity.ru", + "stemcomminity.com", + "stemcomminuty.ru", + "stemcommlunity.com", + "stemcommnuity.ru.com", + "stemcommnunity.com", + "stemcommnunity.ru.com", + "stemcommnunlty.ru", + "stemcommnunnity.com", + "stemcommnunulty.com", + "stemcommnuunity.com", + "stemcommouniity.com", + "stemcommounilty.com", + "stemcommounity.ru.com", + "stemcommuinty.ru", + "stemcommuniby.com", + "stemcommuniety.com", + "stemcommuniity.com", + "stemcommuniity.ru", + "stemcommunilty.com", + "stemcommunilty.ru", + "stemcommunite.pp.ru", + "stemcommuniti.ru", + "stemcommunitiy.com", + "stemcommunitly.com", + "stemcommunitty.com", + "stemcommunitty.ru.com", + "stemcommunity.com.ru", + "stemcommunity.ru.com", + "stemcommunity.ru", + "stemcommunitytraade.xyz", + "stemcommunitytrade.com", + "stemcommunitytrade.fun", + "stemcommunjty.com", + "stemcommunlitly.com", + "stemcommunlity.ru", + "stemcommunlty.com", + "stemcommunlty.ru.com", + "stemcommunlty.space", + "stemcommunniity.com", + "stemcommunnilty.com", + "stemcommunnitiy.net.ru", + "stemcommunnity.com.ru", + "stemcommunnity.com", + "stemcommunuity.com", + "stemcommununity.com", + "stemcommuty.ru", + "stemcommuunity.com.ru", + "stemcommynity.ru.com", + "stemcommyunity.ru", + "stemcomnmnnunity.com", + "stemcomnmnunity.com", + "stemcomnmounity.com", + "stemcomnmuity.com", + "stemcomnmuniity.com", + "stemcomnmuniity.ru.com", + "stemcomnmunity.com.ru", + "stemcomnmunity.ru.com", + "stemcomnmunity.ru", + "stemcomnmunniity.com", + "stemcomnmunnity.com", + "stemcomnmunuity.com", + "stemcomnmununity.com", + "stemcomnmuunity.com", + "stemcomnmuunity.ru.com", + "stemcomnnmunity.com", + "stemcomnnmunnity.com", + "stemcomnnmuunity.ru", + "stemcomnuniti.ru", + "stemcomnunity.com", + "stemcomnunity.ru.com", + "stemcomnunity.ru", + "stemcomnunyti.ru.com", + "stemcomrnmunity.com", + "stemcomrnuniity.ru", + "stemcomuniti.ru", + "stemcomunitiy.com", + "stemcomunity.com", + "stemcomunity.net", + "stemcomunity.ru.com", + "stemcomunnity.com.ru", + "stemcomunnity.com", + "stemcomunnity.ru.com", + "stemconmmnunity.com", + "stemconmmunity.com", + "stemconmmunnity.com", + "stemconmmuunnity.com", + "stemconmnmuunity.com", + "stemconmuite.xyz", + "stemconmumity.ru", + "stemcoominuty-alirdrop.xyz", + "stemcoommounity.com", + "stemcoommuniity.com", + "stemcoommunity.com", + "stemcoommuunnity.com", + "stemcoomnmnunity.com", + "stemcoomnmounity.com", + "stemcoomnmuniity.com", + "stemcoomnmunity.com", + "stemcoomnmunity.ru.com", + "stemcoomnmunnity.com", + "stemcoomnnunity.com", + "stemcormmunity.com", + "stemcormmunlty.ru.com", + "stemcornmunitly.ru.com", + "stemcornmunity.com", + "stemcornmunity.ru.com", + "stemcornmunity.ru", + "stemcornmunlty.xyz", + "stemcummnuity.ru.com", + "stemcummnunity.ru.com", + "stemcummunity.com.ru", + "stemcummunity.ru.com", + "stemcummunnity.com.ru", + "stemcummunnity.ru.com", + "stemcumnmunity.com.ru", + "stemcumnmunity.com", + "stemcumnmunity.ru.com", + "stemcumunnity.ru.com", + "stemecommunlty.com", + "stemmcomunity.xyz", + "stemmcomunnityy.xyz", + "stemncornmunity.com", + "stemsell.ml", + "stencommunity.com", + "stenmcommunilty.ru.com", + "stenmcommunitly.ru.com", + "stenncornmuniy.com", + "stennicommuitun.com", + "steomcommunitey.com", + "steomcommunito.con", + "steomcommunity.com", + "steomcommunity.ru", + "steomcommunlty.ml", + "steomcomnunity.ru.com", + "steomconmunity.com", + "steomcoommynity.ru.com", + "stepmscononnity.com", + "steqmcommunity.com", + "steqmpowered.com", + "steramconmunity.com", + "sterampowered.com", + "stermccommunitty.ru", + "stermcommuniity.com", + "stermcommunilty.ru.com", + "stermcommunity.com", + "stermcommunity.ru.com", + "stermcommunityy.ru", + "stermcommunlity.ru.com", + "stermcommunnitty.ru", + "stermcomunitte.xyz", + "stermcomunniity.ru", + "stermconmmunity.com", + "stermmcomuniity.ru", + "stermncommunity.com", + "sterncommunilty.ru.com", + "sterncommunilty.site", + "sterncommunnity.ru", + "sterncommynuty.ru", + "sterncomnurity.one", + "sternconmunity.ru", + "sterncornmunity.ru", + "sternmcommunity.com", + "sternmconmunity.com", + "sternmcornmmunity.com", + "sternmcornnunity.com", + "sterumcommunity.com", + "stetrncommity.com", + "steumcommunity.com", + "steumcommunity.ru", + "steumcornmunity.com", + "steurmcommunity.com", + "steurmconmunity.com", + "stewie2k-giveaway-150days.pro", + "stewmpowered.com", + "stfriendprofile.ru", + "stg.steamcpowered.com", + "stheamcommnitiy.ru", + "stheamcommuniti.com", + "stheamcommunity.ru", + "stheamcommunutiy.ru", + "stheamcommunutly.ru", + "stheamcomunitly.ru", + "stheamcomunutly.ru", + "stheamconmuniity.com", + "stheamconnmunutly.ru", + "stheamcornmunitiy.ru", + "stiamcammunieti.com", + "stiamcommunitly.xyz", + "stiamcommunity.com", + "stiamcommyunlty.ru.com", + "stiamcomunity.xyz", + "stiamcomunlty.ru", + "stiamcomynity.com", + "stieamcommuinity.com", + "stieamcommuniity.com", + "stieamcommuniity.ru", + "stieamcommunitey.ru", + "stieamcommunitiy.com", + "stieamcommunity.com", + "stieamcommunity.org.ru", + "stieamcommunity.pp.ru", + "stieamcommuunitey.us", + "stieamcommynituy.com", + "stieamcomnnunity.com", + "stieamcomuniiti.ru", + "stieamcomunity.com", + "stieamconmuniity.com", + "stieamconnmunity.com", + "stieamcormnynity.ru.com", + "stiemcommunitty.ru", + "stiemconnumity.xyz", + "stimcommunity.ru", + "stimcommunlty.ru", + "stimiache.ru", + "stjeamcoimmunity.com", + "stjeamcommunity.ru", + "stjeamcomnuminiti.ru", + "stjeamcomnunitiy.ru", + "stjeamcomnunity.ru", + "stjeamcomuniity.ru", + "stjeamconmunnitii.com", + "stleaamcommunity.com", + "stleam-communithy.com", + "stleamcommiunity.ru.com", + "stleamcommiynitu.ru", + "stleamcommiynitu.xyz", + "stleamcommiynity.xyz", + "stleamcommnunity.ru", + "stleamcommulnity.xyz", + "stleamcommulnitycom.xyz", + "stleamcommuneety.com", + "stleamcommuniity.com", + "stleamcommuniity.net", + "stleamcommunilty.com", + "stleamcommunithy.com", + "stleamcommunitiy.com", + "stleamcommunitly.com", + "stleamcommunitty.com", + "stleamcommunity.com", + "stleamcommunity.net", + "stleamcommunlty.com", + "stleamcommunlty.xyz", + "stleamcomnmunity.ru.com", + "stleamcomnunity.ru.com", + "stleamcomunity.com", + "stleamconminity.online", + "stleamconminity.ru", + "stleamconmmunity.ru.com", + "stleamconmmunlty.net.ru", + "stleamconmunity.com", + "stleamconnunlty-tyztradeoffernewpartnhr15902271.xyz", + "stleamcormmunity.ru.com", + "stleamcormmynity.ru.com", + "stleamcormunity.ru.com", + "stleamcornmmunity.ru.com", + "stleammcomnnunitycom.buzz", + "stleamncommunity.ru", + "stleancommunity.ru", + "stleanmcommunity.ru", + "stleaomcoommynity.ru.com", + "stlemamcornmunty.me", + "stmawards.xyz", + "stmcornnunnitty.xyz", + "stmcornumnunitty.xyz", + "stmeacomunnitty.ru", + "stmemcomyunity.com", + "stmencommunity.ru", + "stmtrdoffer.xyz", + "stoacommunity.codes", + "stoemcommunity.com", + "stopify.com", + "store-communitiy.com", + "store-discord.com", + "store-steam-csgo.ru", + "store-steamcomminuty.ru.com", + "store-steamcommunity.xyz", + "store-steamcomnunity", + "store-steampoweered.ru", + "store-steampowereb.com", + "store-steampowered.ru", + "store-stempowered.com", + "store-streampowered.me", + "store.stampowered.com", + "store.stempowerd.com", + "storeesteampowered.ru.com", + "storeesteampowereed.ru.com", + "stores-steampowered.com", + "storesleampowecommunity.store", + "storesteam-csgo.ru", + "straemcommonlity.com", + "straemcomunnitry.ru", + "straemcummonilty.com", + "straemcummonity.com", + "stramconmunity.com", + "strcomnunnitly.xyz", + "streaalcommuunnitu.ru", + "streaemcrommunlty.com.ru", + "stream-conmunlty.ru", + "streamc0mmunnlty.xyz", + "streamcammunitly.com", + "streamccomunilty.com", + "streamcolmnty.xyz", + "streamcomlutitly.me", + "streamcomminuty.pw", + "streamcomminuty.ru.com", + "streamcommiumity.com", + "streamcommiunity.com", + "streamcommiunnity.com", + "streamcommlunity.ru.com", + "streamcommmumnity.ru.com", + "streamcommmunify.ru.com", + "streamcommmunitty.ru.com", + "streamcommmunity.com", + "streamcommmunjty.ru.com", + "streamcommmunlty.ru.com", + "streamcommmunnlty.ru.com", + "streamcommnnity.com", + "streamcommnnuity.com", + "streamcommnnutiy.com", + "streamcommnuity.com", + "streamcommnuity.ru", + "streamcommnunilty.com", + "streamcommnunitly.com", + "streamcommnunity.ru", + "streamcommnunlity.ru", + "streamcommnunnity.ml", + "streamcommnunuty.ru.com", + "streamcommnunuty.ru", + "streamcommonlty.ru.com", + "streamcommounity.com", + "streamcommuinity.com", + "streamcommuinty.com", + "streamcommuiny.ru", + "streamcommulinty.com", + "streamcommulnty.com", + "streamcommumity.ru.com", + "streamcommumninty.com", + "streamcommumnity.com", + "streamcommumtiy.ru", + "streamcommunaly.com", + "streamcommunaty.com", + "streamcommuneiley.net", + "streamcommunetly.com", + "streamcommunety.ru", + "streamcommunicate.ru", + "streamcommunication.com", + "streamcommunify.com", + "streamcommuniiley.net.ru", + "streamcommuniiley.net", + "streamcommuniily.com", + "streamcommuniitty.com", + "streamcommuniitu.com", + "streamcommuniity.org", + "streamcommuniity.ru.com", + "streamcommuniity.ru", + "streamcommuniityy.me", + "streamcommuniley.net.ru", + "streamcommuniley.net", + "streamcommuniliey.net.ru", + "streamcommuniliey.xyz", + "streamcommuniliiey.net.ru", + "streamcommuniliiey.org.ru", + "streamcommuniliiey.pp.ru", + "streamcommuniliiy.org.ru", + "streamcommuniliiy.pp.ru", + "streamcommunillty.com", + "streamcommunilly.com", + "streamcommunilty.com", + "streamcommunilty.xyz", + "streamcommunily.cc", + "streamcommunily.co", + "streamcommunily.com", + "streamcommunily.icu", + "streamcommunily.me", + "streamcommunily.net", + "streamcommunily.ru.com", + "streamcommunimty.com", + "streamcommuninllty.com", + "streamcommuninnity.com", + "streamcommuninnuity.com", + "streamcommuninty.com", + "streamcommuninty.me", + "streamcommuninuty.store", + "streamcommunit.com", + "streamcommunit.ru.com", + "streamcommunite.com", + "streamcommunite.ru.com", + "streamcommunitey.com", + "streamcommuniti.ru", + "streamcommuniti.xyz", + "streamcommunitily.com", + "streamcommunitiy.com", + "streamcommunitiy.net", + "streamcommunitiy.ru.com", + "streamcommunitiy.ru", + "streamcommunitly.net", + "streamcommunitly.ru", + "streamcommunitly.xyz", + "streamcommunitry.ru", + "streamcommunitty.ru.com", + "streamcommunitu.com", + "streamcommunitv.me", + "streamcommunitv.net", + "streamcommunity-user.me", + "streamcommunity.com.ru", + "streamcommunity.me", + "streamcommunity.net.ru", + "streamcommunity.one", + "streamcommunity.org.ru", + "streamcommunity.pl", + "streamcommunity.ru.com", + "streamcommunityi.ru", + "streamcommunityy.me", + "streamcommuniunity.com", + "streamcommuniuty.ru.com", + "streamcommuniuty.store", + "streamcommuniy.ru", + "streamcommunjty.com", + "streamcommunjty.ru.com", + "streamcommunlity.ru", + "streamcommunliy.com", + "streamcommunlte.ru", + "streamcommunltiy.com", + "streamcommunlty.net", + "streamcommunly.com", + "streamcommunly.me", + "streamcommunly.net", + "streamcommunly.ru", + "streamcommunminty.com", + "streamcommunmity.com", + "streamcommunniity.com", + "streamcommunnilty.com", + "streamcommunnitty.com", + "streamcommunnity.org", + "streamcommunnty.com", + "streamcommunnty.me", + "streamcommunnuitty.com", + "streamcommuntiiy.org", + "streamcommuntiy.com", + "streamcommuntly.com", + "streamcommuntly.net.ru", + "streamcommuntly.org.ru", + "streamcommuntly.pp.ru", + "streamcommunttly.com", + "streamcommunty.co", + "streamcommunty.me", + "streamcommunty.ru", + "streamcommunuitty.com", + "streamcommunuity.net", + "streamcommununty.com", + "streamcommuny.ru", + "streamcommunyty.com", + "streamcommutiny.net", + "streamcommuuniity.com", + "streamcommuunilty.ru.com", + "streamcommuunity.com", + "streamcommuunniity.com", + "streamcommuunnity.com", + "streamcommuunnity.net", + "streamcommuuty.ru", + "streamcommynitu.com", + "streamcommynuty.com", + "streamcomninuty.xyz", + "streamcomnmunity.ru.com", + "streamcomnmunnity.ru.com", + "streamcomnnunity.net", + "streamcomnnunity.website", + "streamcomnnunity.xyz", + "streamcomnnunlty.com", + "streamcomnnunuty.com", + "streamcomnully.net.ru", + "streamcomnully.org.ru", + "streamcomnullyty.net.ru", + "streamcomnullyty.org.ru", + "streamcomnullyty.pp.ru", + "streamcomnultyy.net.ru", + "streamcomnultyy.org.ru", + "streamcomnumity.ru", + "streamcomnumnity.ru.com", + "streamcomnunely.com", + "streamcomnunetiy.com", + "streamcomnuniity.com", + "streamcomnuniity.net", + "streamcomnunitiy.ru", + "streamcomnunitly.ru", + "streamcomnunitry.ru", + "streamcomnunitty.com", + "streamcomnunity.ru", + "streamcomnunity.site", + "streamcomnuniuty.com", + "streamcomnunlity.com", + "streamcomnunlty.ru", + "streamcomnunnity.ru", + "streamcomnunuty.com", + "streamcomnunuty.ru", + "streamcomnunyti.xyz", + "streamcomrnunitiy.ru", + "streamcomrnunity.com", + "streamcomrnunity.online", + "streamcomrnunity.ru", + "streamcomulty.net.ru", + "streamcomulty.org.ru", + "streamcomuniitty.ru.com", + "streamcomuniity.cf", + "streamcomuniity.com", + "streamcomuniity.net", + "streamcomuniity.pp.ua", + "streamcomunilty.net.ru", + "streamcomunilty.org.ru", + "streamcomunily.net.ru", + "streamcomunily.org.ru", + "streamcomunily.pp.ru", + "streamcomunitly.com", + "streamcomunitly.net.ru", + "streamcomunitly.net", + "streamcomunitly.ru", + "streamcomunitry.com", + "streamcomunitty.net", + "streamcomunitu.ru", + "streamcomunity.com", + "streamcomunity.fun", + "streamcomunity.net", + "streamcomunity.org", + "streamcomunity.ru.com", + "streamcomunlty.net.ru", + "streamcomunlty.org.ru", + "streamcomunlty.pp.ru", + "streamcomunltyy.org.ru", + "streamcomunltyy.pp.ru", + "streamcomunniity.net.ru", + "streamcomunnity.pp.ua", + "streamcomunnity.ru.com", + "streamcomunnity.xyz", + "streamcomuuniltyy.org.ru", + "streamcomuuniltyy.pp.ru", + "streamcomuunltyy.net.ru", + "streamcomuunltyy.org.ru", + "streamcomuunltyy.pp.ru", + "streamcomynity.com", + "streamcomynity.ru.com", + "streamconmmunity.com", + "streamconmmunity.ru.com", + "streamconmumuty.xyz", + "streamconmunilty.com", + "streamconmunitly.com", + "streamconmunitly.ru", + "streamconmunity.com", + "streamconmunlity.com", + "streamconmunlty.ru", + "streamconmunyti.com", + "streamconnmunity.com", + "streamconnuity.com", + "streamconnumity.com", + "streamconnunitly.com", + "streamconnunity.net.ru", + "streamconnunity.ru", + "streamconnunity.site", + "streamconnunity.us", + "streamconunity.net.ru", + "streamcoommounity.com", + "streamcoommuniity.xyz", + "streamcoommunity.com", + "streamcoommunity.net", + "streamcoommunity.xyz", + "streamcormmunity.com", + "streamcormmunity.ru.com", + "streamcormmunlty.ru.com", + "streamcormmunnity.ru.com", + "streamcormmyniity.ru.com", + "streamcormnmunity.ru.com", + "streamcormunnity.ru.com", + "streamcornnunitly.co", + "streamcornnunitly.com", + "streamcoumunniity.org", + "streamcoumunnity.org", + "streamcrommunify.me", + "streamcummonity.ru.com", + "streamcummunity.ru.com", + "streamcummunlty.com", + "streamcummunlty.xyz", + "streamecommuniity.com", + "streamecommunity.com", + "streammcommunity.ru", + "streammcomunittty.ru", + "streammcomunity.com", + "streammcomunnity.ru", + "streammcomuunity.ru", + "streammcornmunnity.com", + "streamncommnunity.com", + "streamnconmumity.com", + "streamnconmunity.com", + "streamnconmunity.ru", + "streampoered.com", + "streampowered.store", + "streampowereed.com", + "streancommumity.ru.com", + "streancommuniity.ru.com", + "streancommuniliy.ru.com", + "streancommuniliy.ru", + "streancommunitiy.co", + "streancommunitiy.net.ru", + "streancommunitiy.ru", + "streancommunity.ru.com", + "streancommunuty.ru", + "streancomunnitiy.com", + "streancomunnuty.com", + "streancoommunity.com", + "streancoommunity.xyz", + "streanncomminity.ru", + "streanncommunity.space", + "streanncomnnunuty.com", + "streanncomunity.ru", + "strearmcommunity.ru", + "strearmcomunity.ru", + "strearncomuniity.ru.com", + "streawcommunity.xyz", + "streeamcommunuti.ru", + "streemcommunhity.org.ru", + "streemcommunitiy.ru.com", + "strempowered.com", + "streomcommunuty.com", + "strieamcommunniity.com", + "striieamcomnmunniitty.ru", + "stteamcommiunity.com", + "stteamcommunitty.com", + "stteamcommunity.net", + "sttemcomnmuty.ru.com", + "stuamcommnuity.com", + "stuamcommunity.com", + "stuemconmunity.com", + "sturemconmunity.com", + "stwsmarket.ru", + "styamcommunity.com", + "styeampowerd.com", + "styeampowered.com", + "stzeamcomnumiti.ru", + "sueamcommunity.com", + "sueamconmunity.com", + "sufficienttime.rocks", + "summer-rust.xyz", + "sunnygamble.com", + "superbalancednow.com", + "superdealgadgets.com", + "support.verifiedbadgehelp-form.ml", + "supremeskins.cf", + "surveysandpromoonline.com", + "swapskins.ga", + "swapskins.live", + "swapslot.tk", + "sweet-fortune.ru", + "ta-sty.info", + "taceitt.com", + "tacelt.com", + "tacticalusa.com", + "takeit100.xyz", + "takeit101.xyz", + "takeit102.xyz", + "takeit103.xyz", + "takeit104.xyz", + "takeit105.xyz", + "takeit106.xyz", + "takeit107.xyz", + "takeit108.xyz", + "takeit109.xyz", + "takeit110.xyz", + "takeit111.xyz", + "takeit112.xyz", + "takeit113.xyz", + "takeit114.xyz", + "takeit115.xyz", + "takeit116.xyz", + "takeit117.xyz", + "takeit118.xyz", + "takeit119.xyz", + "takeit120.xyz", + "takeit121.xyz", + "takeit122.xyz", + "takeit123.xyz", + "takeit124.xyz", + "takeit125.xyz", + "takeit126.xyz", + "takeit127.xyz", + "takeit128.xyz", + "takeit129.xyz", + "takeit130.xyz", + "takeit131.xyz", + "takeit132.xyz", + "takeit133.xyz", + "takeit134.xyz", + "takeit135.xyz", + "takeit136.xyz", + "takeit137.xyz", + "takeit138.xyz", + "takeit139.xyz", + "takeit140.xyz", + "takeit141.xyz", + "takeit142.xyz", + "takeit143.xyz", + "takeit144.xyz", + "takeit145.xyz", + "takeit146.xyz", + "takeit147.xyz", + "takeit148.xyz", + "takeit149.xyz", + "takeit150.xyz", + "takeit151.xyz", + "takeit152.xyz", + "takeit153.xyz", + "takeit154.xyz", + "takeit155.xyz", + "takeit156.xyz", + "takeit157.xyz", + "takeit158.xyz", + "takeit159.xyz", + "takeit160.xyz", + "takeit161.xyz", + "takeit162.xyz", + "takeit163.xyz", + "takeit164.xyz", + "takeit165.xyz", + "takeit166.xyz", + "takeit167.xyz", + "takeit168.xyz", + "takeit169.xyz", + "takeit170.xyz", + "takeit171.xyz", + "takeit172.xyz", + "takeit173.xyz", + "takeit174.xyz", + "takeit175.xyz", + "takeit176.xyz", + "takeit177.xyz", + "takeit178.xyz", + "takeit179.xyz", + "takeit20.xyz", + "takeit21.xyz", + "takeit22.xyz", + "takeit23.xyz", + "takeit24.xyz", + "takeit25.xyz", + "takeit26.xyz", + "takeit260.xyz", + "takeit261.xyz", + "takeit262.xyz", + "takeit263.xyz", + "takeit264.xyz", + "takeit265.xyz", + "takeit266.xyz", + "takeit267.xyz", + "takeit268.xyz", + "takeit269.xyz", + "takeit27.xyz", + "takeit270.xyz", + "takeit271.xyz", + "takeit272.xyz", + "takeit273.xyz", + "takeit274.xyz", + "takeit275.xyz", + "takeit276.xyz", + "takeit277.xyz", + "takeit278.xyz", + "takeit279.xyz", + "takeit28.xyz", + "takeit280.xyz", + "takeit281.xyz", + "takeit282.xyz", + "takeit283.xyz", + "takeit284.xyz", + "takeit285.xyz", + "takeit286.xyz", + "takeit287.xyz", + "takeit288.xyz", + "takeit289.xyz", + "takeit29.xyz", + "takeit290.xyz", + "takeit291.xyz", + "takeit292.xyz", + "takeit293.xyz", + "takeit294.xyz", + "takeit295.xyz", + "takeit296.xyz", + "takeit297.xyz", + "takeit298.xyz", + "takeit299.xyz", + "takeit30.xyz", + "takeit300.xyz", + "takeit301.xyz", + "takeit302.xyz", + "takeit303.xyz", + "takeit304.xyz", + "takeit305.xyz", + "takeit306.xyz", + "takeit307.xyz", + "takeit308.xyz", + "takeit309.xyz", + "takeit31.xyz", + "takeit310.xyz", + "takeit311.xyz", + "takeit312.xyz", + "takeit313.xyz", + "takeit314.xyz", + "takeit315.xyz", + "takeit316.xyz", + "takeit317.xyz", + "takeit318.xyz", + "takeit319.xyz", + "takeit32.xyz", + "takeit321.xyz", + "takeit322.xyz", + "takeit323.xyz", + "takeit324.xyz", + "takeit325.xyz", + "takeit326.xyz", + "takeit327.xyz", + "takeit328.xyz", + "takeit329.xyz", + "takeit33.xyz", + "takeit330.xyz", + "takeit331.xyz", + "takeit332.xyz", + "takeit333.xyz", + "takeit334.xyz", + "takeit335.xyz", + "takeit336.xyz", + "takeit337.xyz", + "takeit338.xyz", + "takeit339.xyz", + "takeit34.xyz", + "takeit340.xyz", + "takeit341.xyz", + "takeit342.xyz", + "takeit343.xyz", + "takeit344.xyz", + "takeit345.xyz", + "takeit346.xyz", + "takeit347.xyz", + "takeit348.xyz", + "takeit349.xyz", + "takeit35.xyz", + "takeit350.xyz", + "takeit351.xyz", + "takeit352.xyz", + "takeit353.xyz", + "takeit354.xyz", + "takeit355.xyz", + "takeit356.xyz", + "takeit357.xyz", + "takeit358.xyz", + "takeit359.xyz", + "takeit36.xyz", + "takeit360.xyz", + "takeit361.xyz", + "takeit362.xyz", + "takeit363.xyz", + "takeit364.xyz", + "takeit365.xyz", + "takeit366.xyz", + "takeit367.xyz", + "takeit368.xyz", + "takeit369.xyz", + "takeit37.xyz", + "takeit370.xyz", + "takeit371.xyz", + "takeit372.xyz", + "takeit373.xyz", + "takeit374.xyz", + "takeit375.xyz", + "takeit376.xyz", + "takeit377.xyz", + "takeit378.xyz", + "takeit379.xyz", + "takeit38.xyz", + "takeit380.xyz", + "takeit381.xyz", + "takeit382.xyz", + "takeit383.xyz", + "takeit384.xyz", + "takeit385.xyz", + "takeit386.xyz", + "takeit388.xyz", + "takeit389.xyz", + "takeit39.xyz", + "takeit390.xyz", + "takeit391.xyz", + "takeit392.xyz", + "takeit393.xyz", + "takeit394.xyz", + "takeit395.xyz", + "takeit396.xyz", + "takeit397.xyz", + "takeit398.xyz", + "takeit399.xyz", + "takeit40.xyz", + "takeit400.xyz", + "takeit401.xyz", + "takeit402.xyz", + "takeit403.xyz", + "takeit404.xyz", + "takeit405.xyz", + "takeit406.xyz", + "takeit407.xyz", + "takeit408.xyz", + "takeit409.xyz", + "takeit41.xyz", + "takeit410.xyz", + "takeit411.xyz", + "takeit412.xyz", + "takeit413.xyz", + "takeit414.xyz", + "takeit415.xyz", + "takeit416.xyz", + "takeit417.xyz", + "takeit418.xyz", + "takeit419.xyz", + "takeit42.xyz", + "takeit420.xyz", + "takeit422.xyz", + "takeit423.xyz", + "takeit424.xyz", + "takeit425.xyz", + "takeit426.xyz", + "takeit427.xyz", + "takeit428.xyz", + "takeit429.xyz", + "takeit43.xyz", + "takeit430.xyz", + "takeit431.xyz", + "takeit432.xyz", + "takeit433.xyz", + "takeit434.xyz", + "takeit435.xyz", + "takeit436.xyz", + "takeit437.xyz", + "takeit438.xyz", + "takeit439.xyz", + "takeit44.xyz", + "takeit440.xyz", + "takeit441.xyz", + "takeit442.xyz", + "takeit443.xyz", + "takeit444.xyz", + "takeit445.xyz", + "takeit446.xyz", + "takeit447.xyz", + "takeit448.xyz", + "takeit449.xyz", + "takeit45.xyz", + "takeit450.xyz", + "takeit451.xyz", + "takeit452.xyz", + "takeit453.xyz", + "takeit454.xyz", + "takeit455.xyz", + "takeit456.xyz", + "takeit457.xyz", + "takeit458.xyz", + "takeit459.xyz", + "takeit46.xyz", + "takeit460.xyz", + "takeit461.xyz", + "takeit462.xyz", + "takeit463.xyz", + "takeit464.xyz", + "takeit465.xyz", + "takeit466.xyz", + "takeit467.xyz", + "takeit468.xyz", + "takeit469.xyz", + "takeit47.xyz", + "takeit470.xyz", + "takeit471.xyz", + "takeit472.xyz", + "takeit473.xyz", + "takeit474.xyz", + "takeit475.xyz", + "takeit476.xyz", + "takeit477.xyz", + "takeit478.xyz", + "takeit479.xyz", + "takeit48.xyz", + "takeit480.xyz", + "takeit481.xyz", + "takeit482.xyz", + "takeit483.xyz", + "takeit484.xyz", + "takeit485.xyz", + "takeit486.xyz", + "takeit487.xyz", + "takeit488.xyz", + "takeit489.xyz", + "takeit49.xyz", + "takeit490.xyz", + "takeit491.xyz", + "takeit492.xyz", + "takeit493.xyz", + "takeit494.xyz", + "takeit495.xyz", + "takeit496.xyz", + "takeit497.xyz", + "takeit498.xyz", + "takeit499.xyz", + "takeit50.xyz", + "takeit500.xyz", + "takeit501.xyz", + "takeit502.xyz", + "takeit503.xyz", + "takeit504.xyz", + "takeit505.xyz", + "takeit506.xyz", + "takeit507.xyz", + "takeit508.xyz", + "takeit509.xyz", + "takeit51.xyz", + "takeit510.xyz", + "takeit511.xyz", + "takeit512.xyz", + "takeit513.xyz", + "takeit514.xyz", + "takeit515.xyz", + "takeit516.xyz", + "takeit517.xyz", + "takeit518.xyz", + "takeit519.xyz", + "takeit520.xyz", + "takeit521.xyz", + "takeit522.xyz", + "takeit523.xyz", + "takeit524.xyz", + "takeit525.xyz", + "takeit526.xyz", + "takeit527.xyz", + "takeit528.xyz", + "takeit529.xyz", + "takeit53.xyz", + "takeit530.xyz", + "takeit531.xyz", + "takeit533.xyz", + "takeit534.xyz", + "takeit535.xyz", + "takeit536.xyz", + "takeit537.xyz", + "takeit538.xyz", + "takeit539.xyz", + "takeit54.xyz", + "takeit540.xyz", + "takeit541.xyz", + "takeit542.xyz", + "takeit543.xyz", + "takeit544.xyz", + "takeit545.xyz", + "takeit546.xyz", + "takeit547.xyz", + "takeit548.xyz", + "takeit549.xyz", + "takeit55.xyz", + "takeit550.xyz", + "takeit551.xyz", + "takeit552.xyz", + "takeit553.xyz", + "takeit554.xyz", + "takeit555.xyz", + "takeit556.xyz", + "takeit557.xyz", + "takeit558.xyz", + "takeit559.xyz", + "takeit56.xyz", + "takeit560.xyz", + "takeit561.xyz", + "takeit562.xyz", + "takeit563.xyz", + "takeit564.xyz", + "takeit565.xyz", + "takeit566.xyz", + "takeit567.xyz", + "takeit568.xyz", + "takeit569.xyz", + "takeit57.xyz", + "takeit570.xyz", + "takeit571.xyz", + "takeit572.xyz", + "takeit573.xyz", + "takeit574.xyz", + "takeit575.xyz", + "takeit576.xyz", + "takeit577.xyz", + "takeit578.xyz", + "takeit579.xyz", + "takeit58.xyz", + "takeit580.xyz", + "takeit581.xyz", + "takeit582.xyz", + "takeit583.xyz", + "takeit584.xyz", + "takeit586.xyz", + "takeit587.xyz", + "takeit588.xyz", + "takeit589.xyz", + "takeit59.xyz", + "takeit590.xyz", + "takeit591.xyz", + "takeit592.xyz", + "takeit594.xyz", + "takeit596.xyz", + "takeit597.xyz", + "takeit598.xyz", + "takeit599.xyz", + "takeit60.xyz", + "takeit601.xyz", + "takeit602.xyz", + "takeit603.xyz", + "takeit604.xyz", + "takeit605.xyz", + "takeit606.xyz", + "takeit607.xyz", + "takeit608.xyz", + "takeit61.xyz", + "takeit610.xyz", + "takeit611.xyz", + "takeit612.xyz", + "takeit613.xyz", + "takeit614.xyz", + "takeit615.xyz", + "takeit616.xyz", + "takeit617.xyz", + "takeit618.xyz", + "takeit619.xyz", + "takeit62.xyz", + "takeit620.xyz", + "takeit621.xyz", + "takeit622.xyz", + "takeit623.xyz", + "takeit624.xyz", + "takeit625.xyz", + "takeit626.xyz", + "takeit627.xyz", + "takeit628.xyz", + "takeit629.xyz", + "takeit63.xyz", + "takeit630.xyz", + "takeit631.xyz", + "takeit632.xyz", + "takeit633.xyz", + "takeit634.xyz", + "takeit635.xyz", + "takeit636.xyz", + "takeit637.xyz", + "takeit638.xyz", + "takeit639.xyz", + "takeit64.xyz", + "takeit640.xyz", + "takeit641.xyz", + "takeit642.xyz", + "takeit643.xyz", + "takeit644.xyz", + "takeit645.xyz", + "takeit646.xyz", + "takeit647.xyz", + "takeit648.xyz", + "takeit649.xyz", + "takeit650.xyz", + "takeit651.xyz", + "takeit652.xyz", + "takeit653.xyz", + "takeit654.xyz", + "takeit655.xyz", + "takeit656.xyz", + "takeit657.xyz", + "takeit658.xyz", + "takeit659.xyz", + "takeit66.xyz", + "takeit660.xyz", + "takeit661.xyz", + "takeit662.xyz", + "takeit67.xyz", + "takeit68.xyz", + "takeit69.xyz", + "takeit70.xyz", + "takeit71.xyz", + "takeit72.xyz", + "takeit73.xyz", + "takeit74.xyz", + "takeit75.xyz", + "takeit76.xyz", + "takeit77.xyz", + "takeit78.xyz", + "takeit79.xyz", + "takeit80.xyz", + "takeit81.xyz", + "takeit82.xyz", + "takeit83.xyz", + "takeit84.xyz", + "takeit85.xyz", + "takeit86.xyz", + "takeit87.xyz", + "takeit88.xyz", + "takeit89.xyz", + "takeit90.xyz", + "takeit91.xyz", + "takeit92.xyz", + "takeit93.xyz", + "takeit94.xyz", + "takeit95.xyz", + "takeit96.xyz", + "takeit97.xyz", + "takeit98.xyz", + "takeit99.xyz", + "tasty-drop.pp.ua", + "tasty-skill.net.ru", + "tastygo.ru.com", + "tastyskill.net.ru", + "taty-dropp.info", + "team-dream.xyz", + "team.the-shrubbery.co.uk", + "teamastrallis.org.ru", + "teamfnat.net.ru", + "teamfnattic.org.ru", + "teamgog.pp.ua", + "terrifvvev.com", + "test-domuin2.com", + "test-domuin3.ru", + "test-domuin4.ru", + "test-domuin5.ru", + "testbot2021.ru", + "testy-drop.pp.ua", + "tf2market.store", + "thediscordapp.com", + "themekaversed.org", + "themekaverses.org", + "think-when.xyz", + "thor-case.net.ru", + "threemeterssky.ru", + "tigers.pp.ua", + "tik-team-topp.org.ru", + "tiktok.verifiedbadgehelp-form.ml", + "tiktokmagic.ru", + "tiktoksupport.ru.com", + "tini.best", + "tipteamgg.xyz", + "toolprotimenow.com", + "toom-skins.xyz", + "toornirs.pp.ua", + "top-team.org.ru", + "topcase.monster", + "topconsumerproductsonline.com", + "topeasyllucky.pp.ua", + "topgadgetneckmassager.com", + "toprobux.site", + "topstteeamleto2021.net.ru", + "topsweeps.com", + "topvincere.net.ru", + "topvincere.org.ru", + "topvincere.pp.ru", + "topw-gamez.xyz", + "topz-games.xyz", + "tourggesports.ru", + "tournament.ru.com", + "tournamentcs.live", + "tournamentcsgo.ga", + "tournamentcsgo.gq", + "tournaments.ru.com", + "tournamentsplay.site", + "tournamentt.com", + "tournrecruit.xyz", + "trabeoffer.ru", + "trabeoffers.xyz", + "trade-csmoney.ru", + "trade-dexter.xyz", + "trade-leagues.com", + "trade-link-offer.ru", + "trade-linkk.ru", + "trade-offers.link", + "trade-offersz.pp.ua", + "trade-profile.fun", + "trade.ru.com", + "tradeaffix.pp.ua", + "tradeandyou.ru", + "tradecs.ru.com", + "tradelink.live", + "tradeoff.space", + "tradeoffer-link.ru.com", + "tradeoffer-new.ru", + "tradeoffer.com.ru", + "tradeoffers.net.ru", + "tradeoffers11.xyz", + "traderlink.ru.com", + "traders-offers.com", + "trades-league.com", + "trades-offers.xyz", + "tradesoffers.com", + "treader-offer.com", + "tredecsgo.com", + "treders-offers.com", + "treplov.pp.ua", + "triumph.tk", + "true-money.xyz", + "truepnl-giveaway.info", + "trustpool.xyz", + "tryinfinitikloud.com", + "tryultrassenceskin.com", + "tugceyumakogullari.tk", + "twitch-facepanch.com", + "twitch-nude.com", + "twitch-starter.com", + "twitch.facepunch-llc.com", + "twitch.facepunch-ltd.com", + "twitch.facepunchs.com", + "twitch.facepunchstudio.com", + "twitch.rust-ltd.com", + "tylofpcasy.xyz", + "u924157p.beget.tech", + "ultimateskins.xyz", + "ultracup.fun", + "umosleep.ru", + "universityteam.xyz", + "up-discord.ru", + "up-nitro.com", + "up-you.ru", + "upcs.monster", + "us-appmonie.yousweeps.com", + "uspringcup.com", + "ut.ntwrk.yunihost.ru", + "v-roblox.com", + "vbucksminer.ru", + "verifapp.us", + "verification-discord.com", + "verifications-discord.com", + "verifiedbadgehelp-form.ml", + "verify-discord.com", + "verifyaccount-for-bluetick.com", + "versus-cup.ru", + "versus-play.ru", + "versuscs.ru", + "versuscsgoplay.pp.ua", + "versusplay.ru", + "vippobrit.ru", + "vippobrit1.ru.com", + "visaxsteam.ru", + "vitality-cyber.net", + "vitality-playtime.com", + "vitality-top.ru", + "vitalityboxs.com", + "vitalitycamp.ru", + "vitalityesports.net", + "vitalitygg.ru", + "viwwzagul.xyz", + "viwwzaguls.xyz", + "viwwzagulw.xyz", + "viwwzaguly.xyz", + "vkbonus.club", + "vm1189661.firstbyte.club", + "vpitems.xyz", + "vqojiorq.ru", + "waccupzero.ru.com", + "waccupzerow.monster", + "wallet-steam.ml", + "wanmei-hy.ru", + "wanmeics6.ru", + "wanmeicsgo1.ru", + "wanmeipt.ru", + "wanmeizi.ru", + "waterbets.ru", + "waucupsz.monster", + "wavebtc.com", + "we-player.ru", + "wearewinagain.xyz", + "webr-roblox.com", + "weplay.ru.com", + "were-want.ru.com", + "wheel-run.ru", + "white-guns.xyz", + "white-list.live", + "whitelampa.xyz", + "widesdays.com", + "win-lems.org.ru", + "win-skin.top", + "win-skin.xyz", + "win-trader.org.ru", + "winknifespin.xyz", + "winner-roll.ru", + "winrbx1s1.pw", + "wins-navi.com", + "winskin-simple.xyz", + "winskins.top", + "wintheskin.xyz", + "withereum.com", + "word-the.xyz", + "wowfnatic.ru", + "wtf-magic.ru", + "wtf-magic.top", + "wtf-magicru.top", + "wtf-win.net.ru", + "ww1.dicsordapp.com", + "ww1.discordapp.org", + "ww11.steamcommunity.download", + "ww16.discordcanary.com", + "ww8.steamcommmunity.ru.com", + "wwdiscord.com", + "www-steamcommunlty.com", + "www2.c2bit.online", + "wwwlog-in.xyz", + "wyxy.ru", + "x33681t2.beget.tech", + "xdiscord.com", + "xesa-nitro.com", + "xess-nitro.com", + "xfxcheats.online", + "xgamercup.com", + "xn--e1agajgahgxri7a.site", + "xn--steamcommunit-ge3g.com", + "xorialloy.xyz", + "xpro.gift", + "xpro.ws", + "xpromo-discord.com", + "xroll.space", + "xscsgo.com", + "xtradefox.com", + "xtradeskin.com", + "yeppymoll.xyz", + "yolock.site", + "youtubers2021.xyz", + "youtubersrwrds.xyz", + "yummy-nitro.com", + "z93729n9.beget.tech", + "zakat.ntwrk.yunihost.ru", + "zerocup.ru", + "zipsetgo.com", + "zonewarco.org.ru", + "zonewarco.org.ru", + // "steamcommunity.co", +]; diff --git a/lib/badwords.ts b/lib/badwords.ts new file mode 100644 index 0000000..5260264 --- /dev/null +++ b/lib/badwords.ts @@ -0,0 +1,845 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import type { BadWords, Severity as AutomodSeverity } from "./automod/AutomodShared.js"; + +// duplicated here so that this file can be compiled using the `isolatedModules` option +/** + * @see {@link AutomodSeverity} + */ +const enum Severity { + DELETE, + WARN, + TEMP_MUTE, + PERM_MUTE, +} + +export default { + /* -------------------------------------------------------------------------- */ + /* Slurs */ + /* -------------------------------------------------------------------------- */ + "Slurs": [ + { + match: "faggot", + severity: Severity.TEMP_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "homophobic slur", + regex: false, + userInfo: true, + }, + { + match: "nigga", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "racial slur", + regex: false, + userInfo: true, + }, + { + match: "nigger", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "racial slur", + regex: false, + userInfo: true, + }, + { + match: "nigra", + severity: Severity.PERM_MUTE, + ignoreSpaces: false, + ignoreCapitalization: true, + reason: "racial slur", + regex: false, + userInfo: false, + }, + { + match: "retard", + severity: Severity.TEMP_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "ableist slur", + regex: false, + userInfo: true, + }, + { + match: "retarted", + severity: Severity.TEMP_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "ableist slur", + regex: false, + userInfo: false, + }, + { + match: "slut", + severity: Severity.WARN, + ignoreSpaces: false, + ignoreCapitalization: true, + reason: "derogatory term", + regex: false, + userInfo: false, + }, + { + match: "tar baby", + severity: Severity.TEMP_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "racial slur", + regex: false, + }, + { + match: "whore", + severity: Severity.WARN, + ignoreSpaces: false, + ignoreCapitalization: true, + reason: "derogatory term", + regex: false, + userInfo: false, + }, + { + match: "卍", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "racist symbol", + regex: false, + userInfo: true, + }, + { + //? N word + match: "space movie 1992", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "racial slur", + regex: false, + userInfo: false, + }, + { + //? N word + match: "黑鬼", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "racial slur", + regex: false, + userInfo: true, + }, + ], + + /* -------------------------------------------------------------------------- */ + /* Steam Scams */ + /* -------------------------------------------------------------------------- */ + "Steam Scams": [ + { + //? I'm on tilt, in the cop they gave the status "Unreliable" + match: 'Я в тильте, в кс дали статус "Ненадежный"', + severity: Severity.WARN, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "steam scam phrase", + regex: false, + userInfo: false, + }, + { + match: "hello i am leaving cs:go", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "steam scam phrase", + regex: false, + userInfo: false, + }, + { + match: "hello! I'm done with csgo", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "steam scam phrase", + regex: false, + userInfo: false, + }, + { + match: "hi bro, i'm leaving this fucking game, take my skin", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "steam scam phrase", + regex: false, + userInfo: false, + }, + { + match: "hi friend, today i am leaving this fucking game", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "steam scam phrase", + regex: false, + userInfo: false, + }, + { + match: "hi guys, i'm leaving this fucking game, take my", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "steam scam phrase", + regex: false, + userInfo: false, + }, + { + match: "hi, bro h am leaving cs:go and giving away my skin", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "steam scam phrase", + regex: false, + userInfo: false, + }, + { + match: "hi, bro i am leaving cs:go and giving away my skin", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "steam scam phrase", + regex: false, + userInfo: false, + }, + { + match: "i confirm all exchanges, there won't be enough", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "steam scam phrase", + regex: false, + userInfo: false, + }, + { + match: "i quit csgo", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "steam scam phrase", + regex: false, + userInfo: false, + }, + { + match: "the first three who send a trade", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "steam scam phrase", + regex: false, + userInfo: false, + }, + { + match: "you can choose any skin for yourself", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "steam scam phrase", + regex: false, + userInfo: false, + }, + { + match: "Hey, I'm leaving for the army and giving the skins", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "steam scam phrase", + regex: false, + userInfo: false, + }, + { + match: "fuck this trash called CS:GO, deleted,", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "steam scam phrase", + regex: false, + userInfo: false, + }, + { + match: "please take my skins", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "steam scam phrase", + regex: false, + userInfo: false, + }, + { + match: "Hi, I stopped playing CS:GO and decided to giveaway my inventory.", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "steam scam phrase", + regex: false, + userInfo: false, + }, + ], + + /* -------------------------------------------------------------------------- */ + /* Nitro Scams */ + /* -------------------------------------------------------------------------- */ + "Nitro Scams": [ + { + match: "and there is discord hallween's giveaway", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + { + match: "discord nitro for free - steam store", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + { + match: "free 3 months of discord nitro", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + { + match: "free discord nitro airdrop", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + { + match: "get 3 months of discord nitro", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + { + match: "get discord nitro for free", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + { + match: "get free discord nitro from steam", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + { + match: "lol, jahjajha free discord nitro for 3 month!!", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + { + match: "steam is giving away 3 months of discord nitro for free to all no limited steam users", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + { + //? Lol, 1 month free discord nitro! + match: "Лол, бесплатный дискорд нитро на 1 месяц!", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + { + match: "Airdrop Discord FREE NITRO from Steam —", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + { + match: "take nitro faster, it's already running out", + severity: Severity.PERM_MUTE, + ignoreSpaces: false, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + { + match: "only the first 10 people will have time to take nitro", + severity: Severity.PERM_MUTE, + ignoreSpaces: false, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + { + match: "Discord is giving away nitro!", + severity: Severity.PERM_MUTE, + ignoreSpaces: false, + ignoreCapitalization: false, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + { + match: "Free gift discord nitro for 1 month!", + severity: Severity.PERM_MUTE, + ignoreSpaces: false, + ignoreCapitalization: false, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + { + match: "Hi i claim this nitro for free 3 months lol!", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + { + match: "bro watch this, working nitro gen", + severity: Severity.PERM_MUTE, + ignoreSpaces: false, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + { + match: "Free distribution of discord nitro for 3 months from steam!", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + { + match: "Get 3 Months of Discord Nitro. Personalize your profile, screen share in HD, upgrade your emojis, and more!", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + { + match: "Steam is giving away free discord nitro, have time to pick up at my link", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + { + match: "Airdrop Discord NITRO with", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + { + match: "Check this lol, there nitro is handed out for free, take it until everything is sorted out", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + { + match: "A free Discord Nitro | Steam Store Discord Nitro Distribution.", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + { + match: "Xbox gives away discord nitro for free", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + { + match: "airdrop discord nitro by steam", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + { + //? 3 months nitro free from steam, take too + match: "3 месяца нитро бесплатно от стима, забирайте тоже", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + { + // ? includes non-latin characters + match: "Free distributiοn of discοrd nitrο for 3 months from steаm!", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + { + match: "Free discord nitro for 1 month!", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + { + match: "I got some nitro left over here", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + { + match: "Hey, steam gived nitro", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + { + match: "nitro giveaway by steam, take it", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + { + match: "3 months nitro from styme,", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + { + match: "XBOX and DISCORD are giving away free NITRO FULL for a month.", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + { + match: "Hi,take the Discord Nitro for free", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + { + //? Discord nitro got free, take it before it's too late + match: "Дискорд нитро получил бесплатно,забирай пока не поздно", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + { + match: "1 month nitro for free", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + { + match: "Gifts for the new year, nitro for 3 months", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + { + match: "1 month nitro from steam, take it guys", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + { + match: "Hello, discord and steam are giving away nitro, take it away", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + { + match: "Who is first? :)", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + { + match: "Whо is first? :)", + //? This one uses a different o, prob should make some autodelete if includes link and special char + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + { + match: "Discord Nitro distribution from STEAM", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + { + match: "3 month nitro for free, take it ", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + { + match: "3 months nitro from steam, take it guys)", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + { + match: "Gifts from steam nitro, gifts for 3 months", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + { + match: "Free subscription for 3 months DISCORD NITRO", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + { + match: "who will catch this gift?)", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + { + match: "take it guys :)", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + { + match: "Discord and Steam are giving away a free 3-month Discord Gift subscription!", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + { + match: "Discord free nitro from steam", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "discord nitro scam phrase", + regex: false, + userInfo: false, + }, + ], + + /* -------------------------------------------------------------------------- */ + /* Misc Scams */ + /* -------------------------------------------------------------------------- */ + "Misc Scams": [ + { + match: "found a cool software that improves the", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "misc. scam phrase", + regex: false, + userInfo: false, + }, + { + match: + "there is a possible chance tomorrow there will be a cyber-attack event where on all social networks including Discord there will be people trying", + severity: Severity.WARN, + ignoreSpaces: false, + ignoreCapitalization: true, + reason: "annoying copy pasta", + regex: false, + userInfo: false, + }, + { + match: "i made a game can you test play ?", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "malware phrase", + regex: false, + userInfo: false, + }, + { + match: "tell me if something is wrong in the game", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "malware phrase", + regex: false, + userInfo: false, + }, + { + match: "Hi, can you check out the game I created today:)", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "malware phrase", + regex: false, + userInfo: false, + }, + { + match: "Just want to get other people's opinions, what to add and what to remove.", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "malware phrase", + regex: false, + userInfo: false, + }, + { + match: "https://discord.gg/KKnGGvEPVM", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "misc. scam phrase", + regex: false, + userInfo: false, + }, + { + match: "https://discord.gg/rykjvpTGrB", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "misc. scam phrase", + regex: false, + userInfo: false, + }, + { + match: "https://discord.gg/XTDQgJ9YMp", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "misc. scam phrase", + regex: false, + userInfo: false, + }, + ], + + /* -------------------------------------------------------------------------- */ + /* Advertising */ + /* -------------------------------------------------------------------------- */ + "Advertising": [ + { + match: "😀 wow only 13+... 😳 are allowed to see my about me 😏", + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: "advertising", + regex: false, + userInfo: false, + }, + ], +} as BadWords; diff --git a/lib/common/BushCache.ts b/lib/common/BushCache.ts new file mode 100644 index 0000000..22a13ef --- /dev/null +++ b/lib/common/BushCache.ts @@ -0,0 +1,26 @@ +import { BadWords, GlobalModel, SharedModel, type Guild } from '#lib'; +import { Collection, type Snowflake } from 'discord.js'; + +export class BushCache { + public global = new GlobalCache(); + public shared = new SharedCache(); + public guilds = new GuildCache(); +} + +export class GlobalCache implements Omit<GlobalModel, 'environment'> { + public disabledCommands: string[] = []; + public blacklistedChannels: Snowflake[] = []; + public blacklistedGuilds: Snowflake[] = []; + public blacklistedUsers: Snowflake[] = []; +} + +export class SharedCache implements Omit<SharedModel, 'primaryKey'> { + public superUsers: Snowflake[] = []; + public privilegedUsers: Snowflake[] = []; + public badLinksSecret: string[] = []; + public badLinks: string[] = []; + public badWords: BadWords = {}; + public autoBanCode: string | null = null; +} + +export class GuildCache extends Collection<Snowflake, Guild> {} diff --git a/lib/common/ButtonPaginator.ts b/lib/common/ButtonPaginator.ts new file mode 100644 index 0000000..92f3796 --- /dev/null +++ b/lib/common/ButtonPaginator.ts @@ -0,0 +1,224 @@ +import { DeleteButton, type CommandMessage, type SlashMessage } from '#lib'; +import { CommandUtil } from 'discord-akairo'; +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, + type APIEmbed, + type Message, + type MessageComponentInteraction +} from 'discord.js'; + +/** + * Sends multiple embeds with controls to switch between them + */ +export class ButtonPaginator { + /** + * The current page of the paginator + */ + protected curPage: number; + + /** + * The paginator message + */ + protected sentMessage: Message | undefined; + + /** + * @param message The message that triggered the command + * @param embeds The embeds to switch between + * @param text The optional text to send with the paginator + * @param {} [deleteOnExit=true] Whether the paginator message gets deleted when the exit button is pressed + * @param startOn The page to start from (**not** the index) + */ + protected constructor( + protected message: CommandMessage | SlashMessage, + protected embeds: EmbedBuilder[] | APIEmbed[], + protected text: string | null, + protected deleteOnExit: boolean, + startOn: number + ) { + this.curPage = startOn - 1; + + // add footers + for (let i = 0; i < embeds.length; i++) { + if (embeds[i] instanceof EmbedBuilder) { + (embeds[i] as EmbedBuilder).setFooter({ text: `Page ${(i + 1).toLocaleString()}/${embeds.length.toLocaleString()}` }); + } else { + (embeds[i] as APIEmbed).footer = { + text: `Page ${(i + 1).toLocaleString()}/${embeds.length.toLocaleString()}` + }; + } + } + } + + /** + * The number of pages in the paginator + */ + protected get numPages(): number { + return this.embeds.length; + } + + /** + * Sends the paginator message + */ + protected async send() { + this.sentMessage = await this.message.util.reply({ + content: this.text, + embeds: [this.embeds[this.curPage]], + components: [this.getPaginationRow()] + }); + + const collector = this.sentMessage.createMessageComponentCollector({ + filter: (i) => i.customId.startsWith('paginate_'), + time: 300_000 + }); + collector.on('collect', (i) => void this.collect(i)); + collector.on('end', () => void this.end()); + } + + /** + * Handles interactions with the paginator + * @param interaction The interaction received + */ + protected async collect(interaction: MessageComponentInteraction) { + if (interaction.user.id !== this.message.author.id && !this.message.client.config.owners.includes(interaction.user.id)) + return await interaction?.deferUpdate().catch(() => null); + + switch (interaction.customId) { + case 'paginate_beginning': + this.curPage = 0; + await this.edit(interaction); + break; + case 'paginate_back': + this.curPage--; + await this.edit(interaction); + break; + case 'paginate_stop': + if (this.deleteOnExit) { + await interaction.deferUpdate().catch(() => null); + await this.sentMessage!.delete().catch(() => null); + break; + } else { + await interaction + ?.update({ + content: `${ + this.text + ? `${this.text} +` + : '' + }Command closed by user.`, + embeds: [], + components: [] + }) + .catch(() => null); + break; + } + case 'paginate_next': + this.curPage++; + await this.edit(interaction); + break; + case 'paginate_end': + this.curPage = this.embeds.length - 1; + await this.edit(interaction); + break; + } + } + + /** + * Ends the paginator + */ + protected async end() { + if (this.sentMessage && !CommandUtil.deletedMessages.has(this.sentMessage.id)) + await this.sentMessage + .edit({ + content: this.text, + embeds: [this.embeds[this.curPage]], + components: [this.getPaginationRow(true)] + }) + .catch(() => null); + } + + /** + * Edits the paginator message + * @param interaction The interaction received + */ + protected async edit(interaction: MessageComponentInteraction) { + await interaction + ?.update({ + content: this.text, + embeds: [this.embeds[this.curPage]], + components: [this.getPaginationRow()] + }) + .catch(() => null); + } + + /** + * Generates the pagination row based on the class properties + * @param disableAll Whether to disable all buttons + * @returns The generated {@link ActionRow} + */ + protected getPaginationRow(disableAll = false) { + return new ActionRowBuilder<ButtonBuilder>().addComponents( + new ButtonBuilder({ + style: ButtonStyle.Primary, + customId: 'paginate_beginning', + emoji: PaginateEmojis.BEGINNING, + disabled: disableAll || this.curPage === 0 + }), + new ButtonBuilder({ + style: ButtonStyle.Primary, + customId: 'paginate_back', + emoji: PaginateEmojis.BACK, + disabled: disableAll || this.curPage === 0 + }), + new ButtonBuilder({ + style: ButtonStyle.Primary, + customId: 'paginate_stop', + emoji: PaginateEmojis.STOP, + disabled: disableAll + }), + new ButtonBuilder({ + style: ButtonStyle.Primary, + customId: 'paginate_next', + emoji: PaginateEmojis.FORWARD, + disabled: disableAll || this.curPage === this.numPages - 1 + }), + new ButtonBuilder({ + style: ButtonStyle.Primary, + customId: 'paginate_end', + emoji: PaginateEmojis.END, + disabled: disableAll || this.curPage === this.numPages - 1 + }) + ); + } + + /** + * Sends multiple embeds with controls to switch between them + * @param message The message to respond to + * @param embeds The embeds to switch between + * @param text The text send with the embeds (optional) + * @param deleteOnExit Whether to delete the message when the exit button is clicked (defaults to true) + * @param startOn The page to start from (**not** the index) + */ + public static async send( + message: CommandMessage | SlashMessage, + embeds: EmbedBuilder[] | APIEmbed[], + text: string | null = null, + deleteOnExit = true, + startOn = 1 + ) { + // no need to paginate if there is only one page + if (embeds.length === 1) return DeleteButton.send(message, { embeds: embeds }); + + return await new ButtonPaginator(message, embeds, text, deleteOnExit, startOn).send(); + } +} + +export const PaginateEmojis = { + BEGINNING: { id: '853667381335162910', name: 'w_paginate_beginning', animated: false } as const, + BACK: { id: '853667410203770881', name: 'w_paginate_back', animated: false } as const, + STOP: { id: '853667471110570034', name: 'w_paginate_stop', animated: false } as const, + FORWARD: { id: '853667492680564747', name: 'w_paginate_next', animated: false } as const, + END: { id: '853667514915225640', name: 'w_paginate_end', animated: false } as const +} as const; diff --git a/lib/common/CanvasProgressBar.ts b/lib/common/CanvasProgressBar.ts new file mode 100644 index 0000000..fb4f778 --- /dev/null +++ b/lib/common/CanvasProgressBar.ts @@ -0,0 +1,83 @@ +import { CanvasRenderingContext2D } from 'canvas'; + +/** + * I just copy pasted this code from stackoverflow don't yell at me if there is issues for it + * @author @TymanWasTaken + */ +export class CanvasProgressBar { + private readonly x: number; + private readonly y: number; + private readonly w: number; + private readonly h: number; + private readonly color: string; + private percentage: number; + private p?: number; + private ctx: CanvasRenderingContext2D; + + public constructor( + ctx: CanvasRenderingContext2D, + dimension: { x: number; y: number; width: number; height: number }, + color: string, + percentage: number + ) { + ({ x: this.x, y: this.y, width: this.w, height: this.h } = dimension); + this.color = color; + this.percentage = percentage; + this.p = undefined; + this.ctx = ctx; + } + + public draw(): void { + // ----------------- + this.p = this.percentage * this.w; + if (this.p <= this.h) { + this.ctx.beginPath(); + this.ctx.arc( + this.h / 2 + this.x, + this.h / 2 + this.y, + this.h / 2, + Math.PI - Math.acos((this.h - this.p) / this.h), + Math.PI + Math.acos((this.h - this.p) / this.h) + ); + this.ctx.save(); + this.ctx.scale(-1, 1); + this.ctx.arc( + this.h / 2 - this.p - this.x, + this.h / 2 + this.y, + this.h / 2, + Math.PI - Math.acos((this.h - this.p) / this.h), + Math.PI + Math.acos((this.h - this.p) / this.h) + ); + this.ctx.restore(); + this.ctx.closePath(); + } else { + this.ctx.beginPath(); + this.ctx.arc(this.h / 2 + this.x, this.h / 2 + this.y, this.h / 2, Math.PI / 2, (3 / 2) * Math.PI); + this.ctx.lineTo(this.p - this.h + this.x, 0 + this.y); + this.ctx.arc(this.p - this.h / 2 + this.x, this.h / 2 + this.y, this.h / 2, (3 / 2) * Math.PI, Math.PI / 2); + this.ctx.lineTo(this.h / 2 + this.x, this.h + this.y); + this.ctx.closePath(); + } + this.ctx.fillStyle = this.color; + this.ctx.fill(); + } + + // public showWholeProgressBar(){ + // this.ctx.beginPath(); + // this.ctx.arc(this.h / 2 + this.x, this.h / 2 + this.y, this.h / 2, Math.PI / 2, 3 / 2 * Math.PI); + // this.ctx.lineTo(this.w - this.h + this.x, 0 + this.y); + // this.ctx.arc(this.w - this.h / 2 + this.x, this.h / 2 + this.y, this.h / 2, 3 / 2 *Math.PI, Math.PI / 2); + // this.ctx.lineTo(this.h / 2 + this.x, this.h + this.y); + // this.ctx.strokeStyle = '#000000'; + // this.ctx.stroke(); + // this.ctx.closePath(); + // } + + public get PPercentage(): number { + return this.percentage * 100; + } + + public set PPercentage(x: number) { + this.percentage = x / 100; + } +} diff --git a/lib/common/ConfirmationPrompt.ts b/lib/common/ConfirmationPrompt.ts new file mode 100644 index 0000000..b87d9ef --- /dev/null +++ b/lib/common/ConfirmationPrompt.ts @@ -0,0 +1,64 @@ +import { type CommandMessage, type SlashMessage } from '#lib'; +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, type MessageComponentInteraction, type MessageOptions } from 'discord.js'; + +/** + * Sends a message with buttons for the user to confirm or cancel the action. + */ +export class ConfirmationPrompt { + /** + * @param message The message that triggered the command + * @param messageOptions Options for sending the message + */ + protected constructor(protected message: CommandMessage | SlashMessage, protected messageOptions: MessageOptions) {} + + /** + * Sends a message with buttons for the user to confirm or cancel the action. + */ + protected async send(): Promise<boolean> { + this.messageOptions.components = [ + new ActionRowBuilder<ButtonBuilder>().addComponents( + new ButtonBuilder({ style: ButtonStyle.Success, customId: 'confirmationPrompt_confirm', label: 'Yes' }), + new ButtonBuilder({ style: ButtonStyle.Danger, customId: 'confirmationPrompt_cancel', label: 'No' }) + ) + ]; + + const msg = await this.message.channel!.send(this.messageOptions); + + return await new Promise<boolean>((resolve) => { + let responded = false; + const collector = msg.createMessageComponentCollector({ + filter: (interaction) => interaction.message?.id == msg.id, + time: 300_000 + }); + + collector.on('collect', async (interaction: MessageComponentInteraction) => { + await interaction.deferUpdate().catch(() => undefined); + if (interaction.user.id == this.message.author.id || this.message.client.config.owners.includes(interaction.user.id)) { + if (interaction.customId === 'confirmationPrompt_confirm') { + responded = true; + collector.stop(); + resolve(true); + } else if (interaction.customId === 'confirmationPrompt_cancel') { + responded = true; + collector.stop(); + resolve(false); + } + } + }); + + collector.on('end', async () => { + await msg.delete().catch(() => undefined); + if (!responded) resolve(false); + }); + }); + } + + /** + * Sends a message with buttons for the user to confirm or cancel the action. + * @param message The message that triggered the command + * @param sendOptions Options for sending the message + */ + public static async send(message: CommandMessage | SlashMessage, sendOptions: MessageOptions): Promise<boolean> { + return new ConfirmationPrompt(message, sendOptions).send(); + } +} diff --git a/lib/common/DeleteButton.ts b/lib/common/DeleteButton.ts new file mode 100644 index 0000000..340d07f --- /dev/null +++ b/lib/common/DeleteButton.ts @@ -0,0 +1,78 @@ +import { PaginateEmojis, type CommandMessage, type SlashMessage } from '#lib'; +import { CommandUtil } from 'discord-akairo'; +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + MessageComponentInteraction, + MessageEditOptions, + MessagePayload, + type MessageOptions +} from 'discord.js'; + +/** + * Sends a message with a button for the user to delete it. + */ +export class DeleteButton { + /** + * @param message The message to respond to + * @param messageOptions The send message options + */ + protected constructor(protected message: CommandMessage | SlashMessage, protected messageOptions: MessageOptions) {} + + /** + * Sends a message with a button for the user to delete it. + */ + protected async send() { + this.updateComponents(); + + const msg = await this.message.util.reply(this.messageOptions); + + const collector = msg.createMessageComponentCollector({ + filter: (interaction) => interaction.customId == 'paginate__stop' && interaction.message?.id == msg.id, + time: 300000 + }); + + collector.on('collect', async (interaction: MessageComponentInteraction) => { + await interaction.deferUpdate().catch(() => undefined); + if (interaction.user.id == this.message.author.id || this.message.client.config.owners.includes(interaction.user.id)) { + if (msg.deletable && !CommandUtil.deletedMessages.has(msg.id)) await msg.delete(); + } + }); + + collector.on('end', async () => { + this.updateComponents(true, true); + await msg.edit(<string | MessagePayload | MessageEditOptions>this.messageOptions).catch(() => undefined); + }); + } + + /** + * Generates the components for the message + * @param edit Whether or not the message is being edited + * @param disable Whether or not to disable the buttons + */ + protected updateComponents(edit = false, disable = false): void { + this.messageOptions.components = [ + new ActionRowBuilder<ButtonBuilder>().addComponents( + new ButtonBuilder({ + style: ButtonStyle.Primary, + customId: 'paginate__stop', + emoji: PaginateEmojis.STOP, + disabled: disable + }) + ) + ]; + if (edit) { + this.messageOptions.reply = undefined; + } + } + + /** + * Sends a message with a button for the user to delete it. + * @param message The message to respond to + * @param options The send message options + */ + public static async send(message: CommandMessage | SlashMessage, options: Omit<MessageOptions, 'components'>) { + return new DeleteButton(message, options).send(); + } +} diff --git a/lib/common/HighlightManager.ts b/lib/common/HighlightManager.ts new file mode 100644 index 0000000..cc31413 --- /dev/null +++ b/lib/common/HighlightManager.ts @@ -0,0 +1,488 @@ +import { addToArray, format, Highlight, removeFromArray, timestamp, type HighlightWord } from '#lib'; +import assert from 'assert/strict'; +import { + ChannelType, + Collection, + GuildMember, + type Channel, + type Client, + type Message, + type Snowflake, + type TextBasedChannel +} from 'discord.js'; +import { colors, Time } from '../utils/BushConstants.js'; +import { sanitizeInputForDiscord } from '../utils/Format.js'; + +const NOTIFY_COOLDOWN = 5 * Time.Minute; +const OWNER_NOTIFY_COOLDOWN = 5 * Time.Minute; +const LAST_MESSAGE_COOLDOWN = 5 * Time.Minute; + +type users = Set<Snowflake>; +type channels = Set<Snowflake>; +type word = HighlightWord; +type guild = Snowflake; +type user = Snowflake; +type lastMessage = Date; +type lastDM = Message; + +type lastDmInfo = [lastDM: lastDM, guild: guild, channel: Snowflake, highlights: HighlightWord[]]; + +export class HighlightManager { + public static keep = new Set<Snowflake>(); + + /** + * Cached guild highlights. + */ + public readonly guildHighlights = new Collection<guild, Collection<word, users>>(); + + //~ /** + //~ * Cached global highlights. + //~ */ + //~ public readonly globalHighlights = new Collection<word, users>(); + + /** + * A collection of cooldowns of when a user last sent a message in a particular guild. + */ + public readonly userLastTalkedCooldown = new Collection<guild, Collection<user, lastMessage>>(); + + /** + * Users that users have blocked + */ + public readonly userBlocks = new Collection<guild, Collection<user, users>>(); + + /** + * Channels that users have blocked + */ + public readonly channelBlocks = new Collection<guild, Collection<user, channels>>(); + + /** + * A collection of cooldowns of when the bot last sent each user a highlight message. + */ + public readonly lastedDMedUserCooldown = new Collection<user, lastDmInfo>(); + + /** + * @param client The client to use. + */ + public constructor(public readonly client: Client) {} + + /** + * Sync the cache with the database. + */ + public async syncCache(): Promise<void> { + const highlights = await Highlight.findAll(); + + this.guildHighlights.clear(); + + for (const highlight of highlights) { + highlight.words.forEach((word) => { + if (!this.guildHighlights.has(highlight.guild)) this.guildHighlights.set(highlight.guild, new Collection()); + const guildCache = this.guildHighlights.get(highlight.guild)!; + if (!guildCache.get(word)) guildCache.set(word, new Set()); + guildCache.get(word)!.add(highlight.user); + }); + + if (!this.userBlocks.has(highlight.guild)) this.userBlocks.set(highlight.guild, new Collection()); + this.userBlocks.get(highlight.guild)!.set(highlight.user, new Set(highlight.blacklistedUsers)); + + if (!this.channelBlocks.has(highlight.guild)) this.channelBlocks.set(highlight.guild, new Collection()); + this.channelBlocks.get(highlight.guild)!.set(highlight.user, new Set(highlight.blacklistedChannels)); + } + } + + /** + * Checks a message for highlights. + * @param message The message to check. + * @returns A collection users mapped to the highlight matched + */ + public checkMessage(message: Message): Collection<Snowflake, HighlightWord> { + // even if there are multiple matches, only the first one is returned + const ret = new Collection<Snowflake, HighlightWord>(); + if (!message.content || !message.inGuild()) return ret; + if (!this.guildHighlights.has(message.guildId)) return ret; + + const guildCache = this.guildHighlights.get(message.guildId)!; + + for (const [word, users] of guildCache.entries()) { + if (!this.isMatch(message.content, word)) continue; + + for (const user of users) { + if (ret.has(user)) continue; + + if (!message.channel.permissionsFor(user)?.has('ViewChannel')) continue; + + const blockedUsers = this.userBlocks.get(message.guildId)?.get(user) ?? new Set(); + if (blockedUsers.has(message.author.id)) { + void this.client.console.verbose( + 'Highlight', + `Highlight ignored because <<${this.client.users.cache.get(user)?.tag ?? user}>> blocked the user <<${ + message.author.tag + }>>` + ); + continue; + } + const blockedChannels = this.channelBlocks.get(message.guildId)?.get(user) ?? new Set(); + if (blockedChannels.has(message.channel.id)) { + void this.client.console.verbose( + 'Highlight', + `Highlight ignored because <<${this.client.users.cache.get(user)?.tag ?? user}>> blocked the channel <<${ + message.channel.name + }>>` + ); + continue; + } + if (message.mentions.has(user)) { + void this.client.console.verbose( + 'Highlight', + `Highlight ignored because <<${this.client.users.cache.get(user)?.tag ?? user}>> is already mentioned in the message.` + ); + continue; + } + ret.set(user, word); + } + } + + return ret; + } + + /** + * Checks a user provided phrase for their highlights. + * @param guild The guild to check in. + * @param user The user to get the highlights for. + * @param phrase The phrase for highlights in. + * @returns A collection of the user's highlights mapped to weather or not it was matched. + */ + public async checkPhrase(guild: Snowflake, user: Snowflake, phrase: string): Promise<Collection<HighlightWord, boolean>> { + const highlights = await Highlight.findAll({ where: { guild, user } }); + + const results = new Collection<HighlightWord, boolean>(); + + for (const highlight of highlights) { + for (const word of highlight.words) { + results.set(word, this.isMatch(phrase, word)); + } + } + + return results; + } + + /** + * Checks a particular highlight for a match within a phrase. + * @param phrase The phrase to check for the word in. + * @param hl The highlight to check for. + * @returns Whether or not the highlight was matched. + */ + private isMatch(phrase: string, hl: HighlightWord): boolean { + if (hl.regex) { + return new RegExp(hl.word, 'gi').test(phrase); + } else { + if (hl.word.includes(' ')) { + return phrase.toLocaleLowerCase().includes(hl.word.toLocaleLowerCase()); + } else { + const words = phrase.split(/\s*\b\s/); + return words.some((w) => w.toLocaleLowerCase() === hl.word.toLocaleLowerCase()); + } + } + } + + /** + * Adds a new highlight to a user in a particular guild. + * @param guild The guild to add the highlight to. + * @param user The user to add the highlight to. + * @param hl The highlight to add. + * @returns A string representing a user error or a boolean indicating the database success. + */ + public async addHighlight(guild: Snowflake, user: Snowflake, hl: HighlightWord): Promise<string | boolean> { + if (!this.guildHighlights.has(guild)) this.guildHighlights.set(guild, new Collection()); + const guildCache = this.guildHighlights.get(guild)!; + + if (!guildCache.has(hl)) guildCache.set(hl, new Set()); + guildCache.get(hl)!.add(user); + + const [highlight] = await Highlight.findOrCreate({ where: { guild, user } }); + + if (highlight.words.some((w) => w.word === hl.word)) return `You have already highlighted "${hl.word}".`; + + highlight.words = addToArray(highlight.words, hl); + + return Boolean(await highlight.save().catch(() => false)); + } + + /** + * Removes a highlighted word for a user in a particular guild. + * @param guild The guild to remove the highlight from. + * @param user The user to remove the highlight from. + * @param hl The word to remove. + * @returns A string representing a user error or a boolean indicating the database success. + */ + public async removeHighlight(guild: Snowflake, user: Snowflake, hl: string): Promise<string | boolean> { + if (!this.guildHighlights.has(guild)) this.guildHighlights.set(guild, new Collection()); + const guildCache = this.guildHighlights.get(guild)!; + + const wordCache = guildCache.find((_, key) => key.word === hl); + + if (!wordCache?.has(user)) return `You have not highlighted "${hl}".`; + + wordCache!.delete(user); + + const [highlight] = await Highlight.findOrCreate({ where: { guild, user } }); + + const toRemove = highlight.words.find((w) => w.word === hl); + if (!toRemove) return `Uhhhhh... This shouldn't happen.`; + + highlight.words = removeFromArray(highlight.words, toRemove); + + return Boolean(await highlight.save().catch(() => false)); + } + + /** + * Remove all highlight words for a user in a particular guild. + * @param guild The guild to remove the highlights from. + * @param user The user to remove the highlights from. + * @returns A boolean indicating the database success. + */ + public async removeAllHighlights(guild: Snowflake, user: Snowflake): Promise<boolean> { + if (!this.guildHighlights.has(guild)) this.guildHighlights.set(guild, new Collection()); + const guildCache = this.guildHighlights.get(guild)!; + + for (const [word, users] of guildCache.entries()) { + if (users.has(user)) users.delete(user); + if (users.size === 0) guildCache.delete(word); + } + + const highlight = await Highlight.findOne({ where: { guild, user } }); + + if (!highlight) return false; + + highlight.words = []; + + return Boolean(await highlight.save().catch(() => false)); + } + + /** + * Adds a new user or channel block to a user in a particular guild. + * @param guild The guild to add the block to. + * @param user The user that is blocking the target. + * @param target The target that is being blocked. + * @returns The result of the operation. + */ + public async addBlock( + guild: Snowflake, + user: Snowflake, + target: GuildMember | TextBasedChannel + ): Promise<HighlightBlockResult> { + const cacheKey = `${target instanceof GuildMember ? 'user' : 'channel'}Blocks` as const; + const databaseKey = `blacklisted${target instanceof GuildMember ? 'Users' : 'Channels'}` as const; + + const [highlight] = await Highlight.findOrCreate({ where: { guild, user } }); + + if (highlight[databaseKey].includes(target.id)) return HighlightBlockResult.ALREADY_BLOCKED; + + const newBlocks = addToArray(highlight[databaseKey], target.id); + + highlight[databaseKey] = newBlocks; + const res = await highlight.save().catch(() => false); + if (!res) return HighlightBlockResult.ERROR; + + if (!this[cacheKey].has(guild)) this[cacheKey].set(guild, new Collection()); + const guildBlocks = this[cacheKey].get(guild)!; + guildBlocks.set(user, new Set(newBlocks)); + + return HighlightBlockResult.SUCCESS; + } + + /** + * Removes a user or channel block from a user in a particular guild. + * @param guild The guild to remove the block from. + * @param user The user that is unblocking the target. + * @param target The target that is being unblocked. + * @returns The result of the operation. + */ + public async removeBlock(guild: Snowflake, user: Snowflake, target: GuildMember | Channel): Promise<HighlightUnblockResult> { + const cacheKey = `${target instanceof GuildMember ? 'user' : 'channel'}Blocks` as const; + const databaseKey = `blacklisted${target instanceof GuildMember ? 'Users' : 'Channels'}` as const; + + const [highlight] = await Highlight.findOrCreate({ where: { guild, user } }); + + if (!highlight[databaseKey].includes(target.id)) return HighlightUnblockResult.NOT_BLOCKED; + + const newBlocks = removeFromArray(highlight[databaseKey], target.id); + + highlight[databaseKey] = newBlocks; + const res = await highlight.save().catch(() => false); + if (!res) return HighlightUnblockResult.ERROR; + + if (!this[cacheKey].has(guild)) this[cacheKey].set(guild, new Collection()); + const guildBlocks = this[cacheKey].get(guild)!; + guildBlocks.set(user, new Set(newBlocks)); + + return HighlightUnblockResult.SUCCESS; + } + + /** + * Sends a user a direct message to alert them of their highlight being triggered. + * @param message The message that triggered the highlight. + * @param user The user who's highlights was triggered. + * @param hl The highlight that was matched. + * @returns Whether or a dm was sent. + */ + public async notify(message: Message, user: Snowflake, hl: HighlightWord): Promise<boolean> { + assert(message.inGuild()); + + this.client.console.debug(`Notifying ${user} of highlight ${hl.word} in ${message.guild.name}`); + + dmCooldown: { + const lastDM = this.lastedDMedUserCooldown.get(user); + if (!lastDM?.[0]) break dmCooldown; + + const cooldown = this.client.config.owners.includes(user) ? OWNER_NOTIFY_COOLDOWN : NOTIFY_COOLDOWN; + + if (new Date().getTime() - lastDM[0].createdAt.getTime() < cooldown) { + void this.client.console.verbose('Highlight', `User <<${user}>> has been DMed recently.`); + + if (lastDM[0].embeds.length < 10) { + this.client.console.debug(`Trying to add to notification queue for ${user}`); + return this.addToNotification(lastDM, message, hl); + } + + this.client.console.debug(`User has too many embeds (${lastDM[0].embeds.length}).`); + return false; + } + } + + talkCooldown: { + const lastTalked = this.userLastTalkedCooldown.get(message.guildId)?.get(user); + if (!lastTalked) break talkCooldown; + + presence: { + // incase the bot left the guild + if (message.guild) { + const member = message.guild.members.cache.get(user); + if (!member) { + this.client.console.debug(`No member found for ${user} in ${message.guild.name}`); + break presence; + } + + const presence = member.presence ?? (await member.fetch()).presence; + if (!presence) { + this.client.console.debug(`No presence found for ${user} in ${message.guild.name}`); + break presence; + } + + if (presence.status === 'offline') { + void this.client.console.verbose('Highlight', `User <<${user}>> is offline.`); + break talkCooldown; + } + } + } + + const now = new Date().getTime(); + const talked = lastTalked.getTime(); + + if (now - talked < LAST_MESSAGE_COOLDOWN) { + void this.client.console.verbose('Highlight', `User <<${user}>> has talked too recently.`); + + setTimeout(() => { + const newTalked = this.userLastTalkedCooldown.get(message.guildId)?.get(user)?.getTime(); + if (talked !== newTalked) return; + + void this.notify(message, user, hl); + }, LAST_MESSAGE_COOLDOWN).unref(); + + return false; + } + } + + return this.client.users + .send(user, { + // eslint-disable-next-line @typescript-eslint/no-base-to-string + content: `In ${format.input(message.guild.name)} ${message.channel}, your highlight "${hl.word}" was matched:`, + embeds: [this.generateDmEmbed(message, hl)] + }) + .then((dm) => { + this.lastedDMedUserCooldown.set(user, [dm, message.guildId!, message.channelId, [hl]]); + return true; + }) + .catch(() => false); + } + + private async addToNotification( + [originalDm, guild, channel, originalHl]: lastDmInfo, + message: Message, + hl: HighlightWord + ): Promise<boolean> { + assert(originalDm.embeds.length < 10); + assert(originalDm.embeds.length > 0); + assert(originalDm.channel.type === ChannelType.DM); + this.client.console.debug( + `Adding to notification queue for ${originalDm.channel.recipient?.tag ?? originalDm.channel.recipientId}` + ); + + const sameGuild = guild === message.guildId; + const sameChannel = channel === message.channel.id; + const sameWord = originalHl.every((w) => w.word === hl.word); + + /* eslint-disable @typescript-eslint/no-base-to-string */ + return originalDm + .edit({ + content: `In ${sameGuild ? format.input(message.guild?.name ?? '[Unknown]') : 'multiple servers'} ${ + sameChannel ? message.channel ?? '[Unknown]' : 'multiple channels' + }, ${sameWord ? `your highlight "${hl.word}" was matched:` : 'multiple highlights were matched:'}`, + embeds: [...originalDm.embeds.map((e) => e.toJSON()), this.generateDmEmbed(message, hl)] + }) + .then(() => true) + .catch(() => false); + /* eslint-enable @typescript-eslint/no-base-to-string */ + } + + private generateDmEmbed(message: Message, hl: HighlightWord) { + const recentMessages = message.channel.messages.cache + .filter((m) => m.createdTimestamp <= message.createdTimestamp && m.id !== message.id) + .filter((m) => m.cleanContent?.trim().length > 0) + .sort((a, b) => b.createdTimestamp - a.createdTimestamp) + .first(4) + .reverse(); + + return { + description: [ + // eslint-disable-next-line @typescript-eslint/no-base-to-string + message.channel!.toString(), + ...[...recentMessages, message].map( + (m) => `${timestamp(m.createdAt, 't')} ${format.input(`${m.author.tag}:`)} ${m.cleanContent.trim().substring(0, 512)}` + ) + ].join('\n'), + author: { name: hl.regex ? `/${hl.word}/gi` : hl.word }, + fields: [{ name: 'Source message', value: `[Jump to message](${message.url})` }], + color: colors.default, + footer: { text: `Triggered in ${sanitizeInputForDiscord(`${message.guild}`)}` }, + timestamp: message.createdAt.toISOString() + }; + } + + /** + * Updates the time that a user last talked in a particular guild. + * @param message The message the user sent. + */ + public updateLastTalked(message: Message): void { + if (!message.inGuild()) return; + const lastTalked = ( + this.userLastTalkedCooldown.has(message.guildId) + ? this.userLastTalkedCooldown + : this.userLastTalkedCooldown.set(message.guildId, new Collection()) + ).get(message.guildId)!; + + lastTalked.set(message.author.id, new Date()); + if (!HighlightManager.keep.has(message.author.id)) HighlightManager.keep.add(message.author.id); + } +} + +export enum HighlightBlockResult { + ALREADY_BLOCKED, + ERROR, + SUCCESS +} + +export enum HighlightUnblockResult { + NOT_BLOCKED, + ERROR, + SUCCESS +} diff --git a/lib/common/Moderation.ts b/lib/common/Moderation.ts new file mode 100644 index 0000000..60e32c0 --- /dev/null +++ b/lib/common/Moderation.ts @@ -0,0 +1,556 @@ +import { + ActivePunishment, + ActivePunishmentType, + baseMuteResponse, + colors, + emojis, + format, + Guild as GuildDB, + humanizeDuration, + ModLog, + permissionsResponse, + type ModLogType, + type ValueOf +} from '#lib'; +import assert from 'assert/strict'; +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + Client, + EmbedBuilder, + PermissionFlagsBits, + type Guild, + type GuildMember, + type GuildMemberResolvable, + type GuildResolvable, + type Snowflake, + type UserResolvable +} from 'discord.js'; + +enum punishMap { + 'warned' = 'warn', + 'muted' = 'mute', + 'unmuted' = 'unmute', + 'kicked' = 'kick', + 'banned' = 'ban', + 'unbanned' = 'unban', + 'timedout' = 'timeout', + 'untimedout' = 'untimeout', + 'blocked' = 'block', + 'unblocked' = 'unblock' +} +enum reversedPunishMap { + 'warn' = 'warned', + 'mute' = 'muted', + 'unmute' = 'unmuted', + 'kick' = 'kicked', + 'ban' = 'banned', + 'unban' = 'unbanned', + 'timeout' = 'timedout', + 'untimeout' = 'untimedout', + 'block' = 'blocked', + 'unblock' = 'unblocked' +} + +/** + * Checks if a moderator can perform a moderation action on another user. + * @param moderator The person trying to perform the action. + * @param victim The person getting punished. + * @param type The type of punishment - used to format the response. + * @param checkModerator Whether or not to check if the victim is a moderator. + * @param force Override permissions checks. + * @returns `true` if the moderator can perform the action otherwise a reason why they can't. + */ +export async function permissionCheck( + moderator: GuildMember, + victim: GuildMember, + type: + | 'mute' + | 'unmute' + | 'warn' + | 'kick' + | 'ban' + | 'unban' + | 'add a punishment role to' + | 'remove a punishment role from' + | 'block' + | 'unblock' + | 'timeout' + | 'untimeout', + checkModerator = true, + force = false +): Promise<true | string> { + if (force) return true; + + // If the victim is not in the guild anymore it will be undefined + if ((!victim || !victim.guild) && !['ban', 'unban'].includes(type)) return true; + + if (moderator.guild.id !== victim.guild.id) { + throw new Error('moderator and victim not in same guild'); + } + + const isOwner = moderator.guild.ownerId === moderator.id; + if (moderator.id === victim.id && !type.startsWith('un')) { + return `${emojis.error} You cannot ${type} yourself.`; + } + if ( + moderator.roles.highest.position <= victim.roles.highest.position && + !isOwner && + !(type.startsWith('un') && moderator.id === victim.id) + ) { + return `${emojis.error} You cannot ${type} **${victim.user.tag}** because they have higher or equal role hierarchy as you do.`; + } + if ( + victim.roles.highest.position >= victim.guild.members.me!.roles.highest.position && + !(type.startsWith('un') && moderator.id === victim.id) + ) { + return `${emojis.error} You cannot ${type} **${victim.user.tag}** because they have higher or equal role hierarchy as I do.`; + } + if ( + checkModerator && + victim.permissions.has(PermissionFlagsBits.ManageMessages) && + !(type.startsWith('un') && moderator.id === victim.id) + ) { + if (await moderator.guild.hasFeature('modsCanPunishMods')) { + return true; + } else { + return `${emojis.error} You cannot ${type} **${victim.user.tag}** because they are a moderator.`; + } + } + return true; +} + +/** + * Performs permission checks that are required in order to (un)mute a member. + * @param guild The guild to check the mute permissions in. + * @returns A {@link MuteResponse} or true if nothing failed. + */ +export async function checkMutePermissions( + guild: Guild +): Promise<ValueOf<typeof baseMuteResponse> | ValueOf<typeof permissionsResponse> | true> { + if (!guild.members.me!.permissions.has('ManageRoles')) return permissionsResponse.MISSING_PERMISSIONS; + const muteRoleID = await guild.getSetting('muteRole'); + if (!muteRoleID) return baseMuteResponse.NO_MUTE_ROLE; + const muteRole = guild.roles.cache.get(muteRoleID); + if (!muteRole) return baseMuteResponse.MUTE_ROLE_INVALID; + if (muteRole.position >= guild.members.me!.roles.highest.position || muteRole.managed) + return baseMuteResponse.MUTE_ROLE_NOT_MANAGEABLE; + + return true; +} + +/** + * Creates a modlog entry for a punishment. + * @param options Options for creating a modlog entry. + * @param getCaseNumber Whether or not to get the case number of the entry. + * @returns An object with the modlog and the case number. + */ +export async function createModLogEntry( + options: CreateModLogEntryOptions, + getCaseNumber = false +): Promise<{ log: ModLog | null; caseNum: number | null }> { + const user = (await options.client.utils.resolveNonCachedUser(options.user))!.id; + const moderator = (await options.client.utils.resolveNonCachedUser(options.moderator))!.id; + const guild = options.client.guilds.resolveId(options.guild)!; + + return createModLogEntrySimple( + { + ...options, + user: user, + moderator: moderator, + guild: guild + }, + getCaseNumber + ); +} + +/** + * Creates a modlog entry with already resolved ids. + * @param options Options for creating a modlog entry. + * @param getCaseNumber Whether or not to get the case number of the entry. + * @returns An object with the modlog and the case number. + */ +export async function createModLogEntrySimple( + options: SimpleCreateModLogEntryOptions, + getCaseNumber = false +): Promise<{ log: ModLog | null; caseNum: number | null }> { + // If guild does not exist create it so the modlog can reference a guild. + await GuildDB.findOrCreate({ + where: { id: options.guild }, + defaults: { id: options.guild } + }); + + const modLogEntry = ModLog.build({ + type: options.type, + user: options.user, + moderator: options.moderator, + reason: options.reason, + duration: options.duration ? options.duration : undefined, + guild: options.guild, + pseudo: options.pseudo ?? false, + evidence: options.evidence, + hidden: options.hidden ?? false + }); + const saveResult: ModLog | null = await modLogEntry.save().catch(async (e) => { + await options.client.utils.handleError('createModLogEntry', e); + return null; + }); + + if (!getCaseNumber) return { log: saveResult, caseNum: null }; + + const caseNum = ( + await ModLog.findAll({ where: { type: options.type, user: options.user, guild: options.guild, hidden: false } }) + )?.length; + return { log: saveResult, caseNum }; +} + +/** + * Creates a punishment entry. + * @param options Options for creating the punishment entry. + * @returns The database entry, or null if no entry is created. + */ +export async function createPunishmentEntry(options: CreatePunishmentEntryOptions): Promise<ActivePunishment | null> { + const expires = options.duration ? new Date(+new Date() + options.duration ?? 0) : undefined; + const user = (await options.client.utils.resolveNonCachedUser(options.user))!.id; + const guild = options.client.guilds.resolveId(options.guild)!; + const type = findTypeEnum(options.type)!; + + const entry = ActivePunishment.build( + options.extraInfo + ? { user, type, guild, expires, modlog: options.modlog, extraInfo: options.extraInfo } + : { user, type, guild, expires, modlog: options.modlog } + ); + return await entry.save().catch(async (e) => { + await options.client.utils.handleError('createPunishmentEntry', e); + return null; + }); +} + +/** + * Destroys a punishment entry. + * @param options Options for destroying the punishment entry. + * @returns Whether or not the entry was destroyed. + */ +export async function removePunishmentEntry(options: RemovePunishmentEntryOptions): Promise<boolean> { + const user = await options.client.utils.resolveNonCachedUser(options.user); + const guild = options.client.guilds.resolveId(options.guild); + const type = findTypeEnum(options.type); + + if (!user || !guild) return false; + + let success = true; + + const entries = await ActivePunishment.findAll({ + // finding all cases of a certain type incase there were duplicates or something + where: options.extraInfo + ? { user: user.id, guild: guild, type, extraInfo: options.extraInfo } + : { user: user.id, guild: guild, type } + }).catch(async (e) => { + await options.client.utils.handleError('removePunishmentEntry', e); + success = false; + }); + if (entries) { + const promises = entries.map(async (entry) => + entry.destroy().catch(async (e) => { + await options.client.utils.handleError('removePunishmentEntry', e); + success = false; + }) + ); + + await Promise.all(promises); + } + return success; +} + +/** + * Returns the punishment type enum for the given type. + * @param type The type of the punishment. + * @returns The punishment type enum. + */ +function findTypeEnum(type: 'mute' | 'ban' | 'role' | 'block') { + const typeMap = { + ['mute']: ActivePunishmentType.MUTE, + ['ban']: ActivePunishmentType.BAN, + ['role']: ActivePunishmentType.ROLE, + ['block']: ActivePunishmentType.BLOCK + }; + return typeMap[type]; +} + +export function punishmentToPresentTense(punishment: PunishmentTypeDM): PunishmentTypePresent { + return punishMap[punishment]; +} + +export function punishmentToPastTense(punishment: PunishmentTypePresent): PunishmentTypeDM { + return reversedPunishMap[punishment]; +} + +/** + * Notifies the specified user of their punishment. + * @param options Options for notifying the user. + * @returns Whether or not the dm was successfully sent. + */ +export async function punishDM(options: PunishDMOptions): Promise<boolean> { + const ending = await options.guild.getSetting('punishmentEnding'); + const dmEmbed = + ending && ending.length && options.sendFooter + ? new EmbedBuilder().setDescription(ending).setColor(colors.newBlurple) + : undefined; + + const appealsEnabled = !!( + (await options.guild.hasFeature('punishmentAppeals')) && (await options.guild.getLogChannel('appeals')) + ); + + let content = `You have been ${options.punishment} `; + if (options.punishment.includes('blocked')) { + assert(options.channel); + content += `from <#${options.channel}> `; + } + content += `in ${format.input(options.guild.name)} `; + if (options.duration !== null && options.duration !== undefined) + content += options.duration ? `for ${humanizeDuration(options.duration)} ` : 'permanently '; + const reason = options.reason?.trim() ? options.reason?.trim() : 'No reason provided'; + content += `for ${format.input(reason)}.`; + + let components; + if (appealsEnabled && options.modlog) + components = [ + new ActionRowBuilder<ButtonBuilder>({ + components: [ + new ButtonBuilder({ + customId: `appeal;${punishmentToPresentTense(options.punishment)};${ + options.guild.id + };${options.client.users.resolveId(options.user)};${options.modlog}`, + style: ButtonStyle.Primary, + label: 'Appeal' + }).toJSON() + ] + }) + ]; + + const dmSuccess = await options.client.users + .send(options.user, { + content, + embeds: dmEmbed ? [dmEmbed] : undefined, + components + }) + .catch(() => false); + return !!dmSuccess; +} + +interface BaseCreateModLogEntryOptions extends BaseOptions { + /** + * The type of modlog entry. + */ + type: ModLogType; + + /** + * The reason for the punishment. + */ + reason: string | undefined | null; + + /** + * The duration of the punishment. + */ + duration?: number; + + /** + * Whether the punishment is a pseudo punishment. + */ + pseudo?: boolean; + + /** + * The evidence for the punishment. + */ + evidence?: string; + + /** + * Makes the modlog entry hidden. + */ + hidden?: boolean; +} + +/** + * Options for creating a modlog entry. + */ +export interface CreateModLogEntryOptions extends BaseCreateModLogEntryOptions { + /** + * The client. + */ + client: Client; + + /** + * The user that a modlog entry is created for. + */ + user: GuildMemberResolvable; + + /** + * The moderator that created the modlog entry. + */ + moderator: GuildMemberResolvable; + + /** + * The guild that the punishment is created for. + */ + guild: GuildResolvable; +} + +/** + * Simple options for creating a modlog entry. + */ +export interface SimpleCreateModLogEntryOptions extends BaseCreateModLogEntryOptions { + /** + * The user that a modlog entry is created for. + */ + user: Snowflake; + + /** + * The moderator that created the modlog entry. + */ + moderator: Snowflake; + + /** + * The guild that the punishment is created for. + */ + guild: Snowflake; +} + +/** + * Options for creating a punishment entry. + */ +export interface CreatePunishmentEntryOptions extends BaseOptions { + /** + * The type of punishment. + */ + type: 'mute' | 'ban' | 'role' | 'block'; + + /** + * The user that the punishment is created for. + */ + user: GuildMemberResolvable; + + /** + * The length of time the punishment lasts for. + */ + duration: number | undefined; + + /** + * The guild that the punishment is created for. + */ + guild: GuildResolvable; + + /** + * The id of the modlog that is linked to the punishment entry. + */ + modlog: string; + + /** + * Extra information for the punishment. The role for role punishments and the channel for blocks. + */ + extraInfo?: Snowflake; +} + +/** + * Options for removing a punishment entry. + */ +export interface RemovePunishmentEntryOptions extends BaseOptions { + /** + * The type of punishment. + */ + type: 'mute' | 'ban' | 'role' | 'block'; + + /** + * The user that the punishment is destroyed for. + */ + user: GuildMemberResolvable; + + /** + * The guild that the punishment was in. + */ + guild: GuildResolvable; + + /** + * Extra information for the punishment. The role for role punishments and the channel for blocks. + */ + extraInfo?: Snowflake; +} + +/** + * Options for sending a user a punishment dm. + */ +export interface PunishDMOptions extends BaseOptions { + /** + * The modlog case id so the user can make an appeal. + */ + modlog?: string; + + /** + * The guild that the punishment is taking place in. + */ + guild: Guild; + + /** + * The user that is being punished. + */ + user: UserResolvable; + + /** + * The punishment that the user has received. + */ + punishment: PunishmentTypeDM; + + /** + * The reason the user's punishment. + */ + reason?: string; + + /** + * The duration of the punishment. + */ + duration?: number; + + /** + * Whether or not to send the guild's punishment footer with the dm. + * @default true + */ + sendFooter: boolean; + + /** + * The channel that the user was (un)blocked from. + */ + channel?: Snowflake; +} + +interface BaseOptions { + /** + * The client. + */ + client: Client; +} + +export type PunishmentTypeDM = + | 'warned' + | 'muted' + | 'unmuted' + | 'kicked' + | 'banned' + | 'unbanned' + | 'timedout' + | 'untimedout' + | 'blocked' + | 'unblocked'; + +export type PunishmentTypePresent = + | 'warn' + | 'mute' + | 'unmute' + | 'kick' + | 'ban' + | 'unban' + | 'timeout' + | 'untimeout' + | 'block' + | 'unblock'; + +export type AppealButtonId = `appeal;${PunishmentTypePresent};${Snowflake};${Snowflake};${string}`; diff --git a/lib/common/Sentry.ts b/lib/common/Sentry.ts new file mode 100644 index 0000000..446ec27 --- /dev/null +++ b/lib/common/Sentry.ts @@ -0,0 +1,24 @@ +import { RewriteFrames } from '@sentry/integrations'; +import * as SentryNode from '@sentry/node'; +import { Integrations } from '@sentry/node'; +import type { Config } from '../../config/Config.js'; + +export class Sentry { + public constructor(rootdir: string, config: Config) { + if (config.credentials.sentryDsn === null) throw TypeError('sentryDsn cannot be null'); + + SentryNode.init({ + dsn: config.credentials.sentryDsn, + environment: config.environment, + tracesSampleRate: 1.0, + integrations: [ + new RewriteFrames({ + root: rootdir + }), + new Integrations.OnUnhandledRejection({ + mode: 'none' + }) + ] + }); + } +} diff --git a/lib/common/tags.ts b/lib/common/tags.ts new file mode 100644 index 0000000..098cf29 --- /dev/null +++ b/lib/common/tags.ts @@ -0,0 +1,34 @@ +/* these functions are adapted from the common-tags npm package which is licensed under the MIT license */ +/* the js docs are adapted from the @types/common-tags npm package which is licensed under the MIT license */ + +/** + * Strips the **initial** indentation from the beginning of each line in a multiline string. + */ +export function stripIndent(strings: TemplateStringsArray, ...expressions: any[]) { + const str = format(strings, ...expressions); + // remove the shortest leading indentation from each line + const match = str.match(/^[^\S\n]*(?=\S)/gm); + const indent = match && Math.min(...match.map((el) => el.length)); + if (indent) { + const regexp = new RegExp(`^.{${indent}}`, 'gm'); + return str.replace(regexp, ''); + } + return str; +} + +/** + * Strips **all** of the indentation from the beginning of each line in a multiline string. + */ +export function stripIndents(strings: TemplateStringsArray, ...expressions: any[]) { + const str = format(strings, ...expressions); + // remove all indentation from each line + return str.replace(/^[^\S\n]+/gm, ''); +} + +function format(strings: TemplateStringsArray, ...expressions: any[]) { + const str = strings + .reduce((result, string, index) => ''.concat(result, expressions[index - 1], string)) + .replace(/[^\S\n]+$/gm, '') + .replace(/^\n/, ''); + return str; +} diff --git a/lib/extensions/discord-akairo/BushArgumentTypeCaster.ts b/lib/extensions/discord-akairo/BushArgumentTypeCaster.ts new file mode 100644 index 0000000..def7ad6 --- /dev/null +++ b/lib/extensions/discord-akairo/BushArgumentTypeCaster.ts @@ -0,0 +1,3 @@ +import { type CommandMessage } from '#lib'; + +export type BushArgumentTypeCaster<R = unknown> = (message: CommandMessage, phrase: string) => R; diff --git a/lib/extensions/discord-akairo/BushClient.ts b/lib/extensions/discord-akairo/BushClient.ts new file mode 100644 index 0000000..1a6bb8c --- /dev/null +++ b/lib/extensions/discord-akairo/BushClient.ts @@ -0,0 +1,600 @@ +import { + abbreviatedNumber, + contentWithDuration, + discordEmoji, + duration, + durationSeconds, + globalUser, + messageLink, + permission, + roleWithDuration, + snowflake +} from '#args'; +import { BushClientEvents, emojis, formatError, inspect } from '#lib'; +import { patch, type PatchedElements } from '@notenoughupdates/events-intercept'; +import * as Sentry from '@sentry/node'; +import { + AkairoClient, + ArgumentTypeCaster, + ContextMenuCommandHandler, + version as akairoVersion, + type ArgumentPromptData, + type OtherwiseContentSupplier +} from 'discord-akairo'; +import { + ActivityType, + GatewayIntentBits, + MessagePayload, + Options, + Partials, + Structures, + version as discordJsVersion, + type Awaitable, + type If, + type InteractionReplyOptions, + type Message, + type MessageEditOptions, + type MessageOptions, + type ReplyMessageOptions, + type Snowflake, + type UserResolvable, + type WebhookEditMessageOptions +} from 'discord.js'; +import type EventEmitter from 'events'; +import { google } from 'googleapis'; +import path from 'path'; +import readline from 'readline'; +import type { Options as SequelizeOptions, Sequelize as SequelizeType } from 'sequelize'; +import { fileURLToPath } from 'url'; +import type { Config } from '../../../config/Config.js'; +import UpdateCacheTask from '../../../src/tasks/cache/updateCache.js'; +import UpdateStatsTask from '../../../src/tasks/feature/updateStats.js'; +import { tinyColor } from '../../arguments/tinyColor.js'; +import { BushCache } from '../../common/BushCache.js'; +import { HighlightManager } from '../../common/HighlightManager.js'; +import { ActivePunishment } from '../../models/instance/ActivePunishment.js'; +import { Guild as GuildDB } from '../../models/instance/Guild.js'; +import { Highlight } from '../../models/instance/Highlight.js'; +import { Level } from '../../models/instance/Level.js'; +import { ModLog } from '../../models/instance/ModLog.js'; +import { Reminder } from '../../models/instance/Reminder.js'; +import { StickyRole } from '../../models/instance/StickyRole.js'; +import { Global } from '../../models/shared/Global.js'; +import { GuildCount } from '../../models/shared/GuildCount.js'; +import { MemberCount } from '../../models/shared/MemberCount.js'; +import { Shared } from '../../models/shared/Shared.js'; +import { Stat } from '../../models/shared/Stat.js'; +import { AllowedMentions } from '../../utils/AllowedMentions.js'; +import { BushClientUtils } from '../../utils/BushClientUtils.js'; +import { BushLogger } from '../../utils/BushLogger.js'; +import { ExtendedGuild } from '../discord.js/ExtendedGuild.js'; +import { ExtendedGuildMember } from '../discord.js/ExtendedGuildMember.js'; +import { ExtendedMessage } from '../discord.js/ExtendedMessage.js'; +import { ExtendedUser } from '../discord.js/ExtendedUser.js'; +import { BushCommandHandler } from './BushCommandHandler.js'; +import { BushInhibitorHandler } from './BushInhibitorHandler.js'; +import { BushListenerHandler } from './BushListenerHandler.js'; +import { BushTaskHandler } from './BushTaskHandler.js'; +const { Sequelize } = (await import('sequelize')).default; + +declare module 'discord.js' { + export interface Client extends EventEmitter { + /** The ID of the owner(s). */ + ownerID: Snowflake | Snowflake[]; + /** The ID of the superUser(s). */ + superUserID: Snowflake | Snowflake[]; + /** Whether or not the client is ready. */ + customReady: boolean; + /** The configuration for the client. */ + readonly config: Config; + /** Stats for the client. */ + readonly stats: BushStats; + /** The handler for the bot's listeners. */ + readonly listenerHandler: BushListenerHandler; + /** The handler for the bot's command inhibitors. */ + readonly inhibitorHandler: BushInhibitorHandler; + /** The handler for the bot's commands. */ + readonly commandHandler: BushCommandHandler; + /** The handler for the bot's tasks. */ + readonly taskHandler: BushTaskHandler; + /** The handler for the bot's context menu commands. */ + readonly contextMenuCommandHandler: ContextMenuCommandHandler; + /** The database connection for this instance of the bot (production, beta, or development). */ + readonly instanceDB: SequelizeType; + /** The database connection that is shared between all instances of the bot. */ + readonly sharedDB: SequelizeType; + /** A custom logging system for the bot. */ + readonly logger: BushLogger; + /** Cached global and guild database data. */ + readonly cache: BushCache; + /** Sentry error reporting for the bot. */ + readonly sentry: typeof Sentry; + /** Manages most aspects of the highlight command */ + readonly highlightManager: HighlightManager; + /** The perspective api */ + perspective: any; + /** Client utilities. */ + readonly utils: BushClientUtils; + /** A custom logging system for the bot. */ + get console(): BushLogger; + on<K extends keyof BushClientEvents>(event: K, listener: (...args: BushClientEvents[K]) => Awaitable<void>): this; + once<K extends keyof BushClientEvents>(event: K, listener: (...args: BushClientEvents[K]) => Awaitable<void>): this; + emit<K extends keyof BushClientEvents>(event: K, ...args: BushClientEvents[K]): boolean; + off<K extends keyof BushClientEvents>(event: K, listener: (...args: BushClientEvents[K]) => Awaitable<void>): this; + removeAllListeners<K extends keyof BushClientEvents>(event?: K): this; + /** + * Checks if a user is the owner of this bot. + * @param user - User to check. + */ + isOwner(user: UserResolvable): boolean; + /** + * Checks if a user is a super user of this bot. + * @param user - User to check. + */ + isSuperUser(user: UserResolvable): boolean; + } +} + +export type ReplyMessageType = string | MessagePayload | ReplyMessageOptions; +export type EditMessageType = string | MessageEditOptions | MessagePayload; +export type SlashSendMessageType = string | MessagePayload | InteractionReplyOptions; +export type SlashEditMessageType = string | MessagePayload | WebhookEditMessageOptions; +export type SendMessageType = string | MessagePayload | MessageOptions; + +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: false +}); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +/** + * The main hub for interacting with the Discord API. + */ +export class BushClient<Ready extends boolean = boolean> extends AkairoClient<Ready> { + public declare ownerID: Snowflake[]; + public declare superUserID: Snowflake[]; + + /** + * Whether or not the client is ready. + */ + public override customReady = false; + + /** + * Stats for the client. + */ + public override readonly stats: BushStats = { cpu: undefined, commandsUsed: 0n, slashCommandsUsed: 0n }; + + /** + * The handler for the bot's listeners. + */ + public override readonly listenerHandler: BushListenerHandler; + + /** + * The handler for the bot's command inhibitors. + */ + public override readonly inhibitorHandler: BushInhibitorHandler; + + /** + * The handler for the bot's commands. + */ + public override readonly commandHandler: BushCommandHandler; + + /** + * The handler for the bot's tasks. + */ + public override readonly taskHandler: BushTaskHandler; + + /** + * The handler for the bot's context menu commands. + */ + public override readonly contextMenuCommandHandler: ContextMenuCommandHandler; + + /** + * The database connection for this instance of the bot (production, beta, or development). + */ + public override readonly instanceDB: SequelizeType; + + /** + * The database connection that is shared between all instances of the bot. + */ + public override readonly sharedDB: SequelizeType; + + /** + * A custom logging system for the bot. + */ + public override readonly logger: BushLogger = new BushLogger(this); + + /** + * Cached global and guild database data. + */ + public override readonly cache = new BushCache(); + + /** + * Sentry error reporting for the bot. + */ + public override readonly sentry!: typeof Sentry; + + /** + * Manages most aspects of the highlight command + */ + public override readonly highlightManager: HighlightManager = new HighlightManager(this); + + /** + * The perspective api + */ + public override perspective: any; + + /** + * Client utilities. + */ + public override readonly utils: BushClientUtils = new BushClientUtils(this); + + /** + * @param config The configuration for the client. + */ + public constructor( + /** + * The configuration for the client. + */ + public override readonly config: Config + ) { + super({ + ownerID: config.owners, + intents: Object.keys(GatewayIntentBits) + .map((i) => (typeof i === 'string' ? GatewayIntentBits[i as keyof typeof GatewayIntentBits] : i)) + .reduce((acc, p) => acc | p, 0), + partials: Object.keys(Partials).map((p) => Partials[p as keyof typeof Partials]), + presence: { + activities: [{ name: 'Beep Boop', type: ActivityType.Watching }], + status: 'online' + }, + allowedMentions: AllowedMentions.none(), // no mentions by default + makeCache: Options.cacheWithLimits({ + PresenceManager: { + maxSize: 0, + keepOverLimit: (_, key) => { + if (config.owners.includes(key)) return true; + + return HighlightManager.keep.has(key); + } + } + }), + failIfNotExists: false, + rest: { api: 'https://canary.discord.com/api' } + }); + patch(this); + + this.token = config.token as If<Ready, string, string | null>; + + /* =-=-= handlers =-=-= */ + this.listenerHandler = new BushListenerHandler(this, { + directory: path.join(__dirname, '..', '..', '..', 'src', 'listeners'), + extensions: ['.js'], + automateCategories: true + }); + this.inhibitorHandler = new BushInhibitorHandler(this, { + directory: path.join(__dirname, '..', '..', '..', 'src', 'inhibitors'), + extensions: ['.js'], + automateCategories: true + }); + this.taskHandler = new BushTaskHandler(this, { + directory: path.join(__dirname, '..', '..', '..', 'src', 'tasks'), + extensions: ['.js'], + automateCategories: true + }); + + const modify = async ( + message: Message, + text: string | MessagePayload | MessageOptions | OtherwiseContentSupplier, + data: ArgumentPromptData, + replaceError: boolean + ) => { + const ending = '\n\n Type **cancel** to cancel the command'; + const options = typeof text === 'function' ? await text(message, data) : text; + const search = '{error}', + replace = emojis.error; + + if (typeof options === 'string') return (replaceError ? options.replace(search, replace) : options) + ending; + + if (options instanceof MessagePayload) { + if (options.options.content) { + if (replaceError) options.options.content = options.options.content.replace(search, replace); + options.options.content += ending; + } + } else if (options.content) { + if (replaceError) options.content = options.content.replace(search, replace); + options.content += ending; + } + return options; + }; + + this.commandHandler = new BushCommandHandler(this, { + directory: path.join(__dirname, '..', '..', '..', 'src', 'commands'), + extensions: ['.js'], + prefix: async ({ guild }: Message) => { + if (this.config.isDevelopment) return 'dev '; + if (!guild) return this.config.prefix; + const prefix = await guild.getSetting('prefix'); + return (prefix ?? this.config.prefix) as string; + }, + allowMention: true, + handleEdits: true, + commandUtil: true, + commandUtilLifetime: 300_000, // 5 minutes + argumentDefaults: { + prompt: { + start: 'Placeholder argument prompt. **If you see this please tell my developers**.', + retry: 'Placeholder failed argument prompt. **If you see this please tell my developers**.', + modifyStart: (message, text, data) => modify(message, text, data, false), + modifyRetry: (message, text, data) => modify(message, text, data, true), + timeout: ':hourglass: You took too long the command has been cancelled.', + ended: 'You exceeded the maximum amount of tries the command has been cancelled', + cancel: 'The command has been cancelled', + retries: 3, + time: 3e4 + }, + otherwise: '' + }, + automateCategories: false, + autoRegisterSlashCommands: true, + skipBuiltInPostInhibitors: true, + aliasReplacement: /-/g + }); + this.contextMenuCommandHandler = new ContextMenuCommandHandler(this, { + directory: path.join(__dirname, '..', '..', '..', 'src', 'context-menu-commands'), + extensions: ['.js'], + automateCategories: true + }); + + /* =-=-= databases =-=-= */ + const sharedDBOptions: SequelizeOptions = { + username: this.config.db.username, + password: this.config.db.password, + dialect: 'postgres', + host: this.config.db.host, + port: this.config.db.port, + logging: this.config.logging.db ? (sql) => this.logger.debug(sql) : false, + timezone: 'America/New_York' + }; + this.instanceDB = new Sequelize({ + ...sharedDBOptions, + database: this.config.isDevelopment ? 'bushbot-dev' : this.config.isBeta ? 'bushbot-beta' : 'bushbot' + }); + this.sharedDB = new Sequelize({ + ...sharedDBOptions, + database: 'bushbot-shared' + }); + + this.sentry = Sentry; + } + + /** + * A custom logging system for the bot. + */ + public override get console(): BushLogger { + return this.logger; + } + + /** + * Extends discord.js structures before the client is instantiated. + */ + public static extendStructures(): void { + Structures.extend('GuildMember', () => ExtendedGuildMember); + Structures.extend('Guild', () => ExtendedGuild); + Structures.extend('Message', () => ExtendedMessage); + Structures.extend('User', () => ExtendedUser); + } + + /** + * Initializes the bot. + */ + public async init() { + if (parseInt(process.versions.node.split('.')[0]) < 17) { + void (await this.console.error('version', `Please use node <<v17.x.x>>, not <<${process.version}>>.`, false)); + process.exit(2); + } + + this.setMaxListeners(20); + + this.perspective = await google.discoverAPI<any>('https://commentanalyzer.googleapis.com/$discovery/rest?version=v1alpha1'); + + this.commandHandler.useInhibitorHandler(this.inhibitorHandler); + this.commandHandler.useListenerHandler(this.listenerHandler); + this.commandHandler.useTaskHandler(this.taskHandler); + this.commandHandler.useContextMenuCommandHandler(this.contextMenuCommandHandler); + this.commandHandler.ignorePermissions = this.config.owners; + this.commandHandler.ignoreCooldown = [...new Set([...this.config.owners, ...this.cache.shared.superUsers])]; + const emitters: Emitters = { + client: this, + commandHandler: this.commandHandler, + inhibitorHandler: this.inhibitorHandler, + listenerHandler: this.listenerHandler, + taskHandler: this.taskHandler, + contextMenuCommandHandler: this.contextMenuCommandHandler, + process, + stdin: rl, + gateway: this.ws, + rest: this.rest, + ws: this.ws + }; + this.listenerHandler.setEmitters(emitters); + this.commandHandler.resolver.addTypes({ + duration: <ArgumentTypeCaster>duration, + contentWithDuration: <ArgumentTypeCaster>contentWithDuration, + permission: <ArgumentTypeCaster>permission, + snowflake: <ArgumentTypeCaster>snowflake, + discordEmoji: <ArgumentTypeCaster>discordEmoji, + roleWithDuration: <ArgumentTypeCaster>roleWithDuration, + abbreviatedNumber: <ArgumentTypeCaster>abbreviatedNumber, + durationSeconds: <ArgumentTypeCaster>durationSeconds, + globalUser: <ArgumentTypeCaster>globalUser, + messageLink: <ArgumentTypeCaster>messageLink, + tinyColor: <ArgumentTypeCaster>tinyColor + }); + + this.sentry.setTag('process', process.pid.toString()); + this.sentry.setTag('discord.js', discordJsVersion); + this.sentry.setTag('discord-akairo', akairoVersion); + void this.logger.success('startup', `Successfully connected to <<Sentry>>.`, false); + + // loads all the handlers + const handlers = { + commands: this.commandHandler, + contextMenuCommands: this.contextMenuCommandHandler, + listeners: this.listenerHandler, + inhibitors: this.inhibitorHandler, + tasks: this.taskHandler + }; + const handlerPromises = Object.entries(handlers).map(([handlerName, handler]) => + handler + .loadAll() + .then(() => { + void this.logger.success('startup', `Successfully loaded <<${handlerName}>>.`, false); + }) + .catch((e) => { + void this.logger.error('startup', `Unable to load loader <<${handlerName}>> with error:\n${formatError(e)}`, false); + if (process.argv.includes('dry')) process.exit(1); + }) + ); + await Promise.allSettled(handlerPromises); + } + + /** + * Connects to the database, initializes models, and creates tables if they do not exist. + */ + public async dbPreInit() { + try { + await this.instanceDB.authenticate(); + GuildDB.initModel(this.instanceDB, this); + ModLog.initModel(this.instanceDB); + ActivePunishment.initModel(this.instanceDB); + Level.initModel(this.instanceDB); + StickyRole.initModel(this.instanceDB); + Reminder.initModel(this.instanceDB); + Highlight.initModel(this.instanceDB); + await this.instanceDB.sync({ alter: true }); // Sync all tables to fix everything if updated + await this.console.success('startup', `Successfully connected to <<instance database>>.`, false); + } catch (e) { + await this.console.error( + 'startup', + `Failed to connect to <<instance database>> with error:\n${inspect(e, { colors: true, depth: 1 })}`, + false + ); + process.exit(2); + } + try { + await this.sharedDB.authenticate(); + Stat.initModel(this.sharedDB); + Global.initModel(this.sharedDB); + Shared.initModel(this.sharedDB); + MemberCount.initModel(this.sharedDB); + GuildCount.initModel(this.sharedDB); + await this.sharedDB.sync({ + // Sync all tables to fix everything if updated + // if another instance restarts we don't want to overwrite new changes made in development + alter: this.config.isDevelopment + }); + await this.console.success('startup', `Successfully connected to <<shared database>>.`, false); + } catch (e) { + await this.console.error( + 'startup', + `Failed to connect to <<shared database>> with error:\n${inspect(e, { colors: true, depth: 1 })}`, + false + ); + process.exit(2); + } + } + + /** + * Starts the bot + */ + public async start() { + this.intercept('ready', async (arg, done) => { + const promises = this.guilds.cache + .filter((g) => g.large) + .map((guild) => { + return guild.members.fetch(); + }); + await Promise.all(promises); + this.customReady = true; + this.taskHandler.startAll(); + return done(null, `intercepted ${arg}`); + }); + + try { + await this.highlightManager.syncCache(); + await UpdateCacheTask.init(this); + void this.console.success('startup', `Successfully created <<cache>>.`, false); + const stats = await UpdateStatsTask.init(this); + this.stats.commandsUsed = stats.commandsUsed; + this.stats.slashCommandsUsed = stats.slashCommandsUsed; + await this.login(this.token!); + } catch (e) { + await this.console.error('start', inspect(e, { colors: true, depth: 1 }), false); + process.exit(1); + } + } + + /** + * Logs out, terminates the connection to Discord, and destroys the client. + */ + public override destroy(relogin = false): void | Promise<string> { + super.destroy(); + if (relogin) { + return this.login(this.token!); + } + } + + public override isOwner(user: UserResolvable): boolean { + return this.config.owners.includes(this.users.resolveId(user!)!); + } + + public override isSuperUser(user: UserResolvable): boolean { + const userID = this.users.resolveId(user)!; + return this.cache.shared.superUsers.includes(userID) || this.config.owners.includes(userID); + } +} + +export interface BushClient<Ready extends boolean = boolean> extends EventEmitter, PatchedElements, AkairoClient<Ready> { + on<K extends keyof BushClientEvents>(event: K, listener: (...args: BushClientEvents[K]) => Awaitable<void>): this; + once<K extends keyof BushClientEvents>(event: K, listener: (...args: BushClientEvents[K]) => Awaitable<void>): this; + emit<K extends keyof BushClientEvents>(event: K, ...args: BushClientEvents[K]): boolean; + off<K extends keyof BushClientEvents>(event: K, listener: (...args: BushClientEvents[K]) => Awaitable<void>): this; + removeAllListeners<K extends keyof BushClientEvents>(event?: K): this; +} + +/** + * Various statistics + */ +export interface BushStats { + /** + * The average cpu usage of the bot from the past 60 seconds. + */ + cpu: number | undefined; + + /** + * The total number of times any command has been used. + */ + commandsUsed: bigint; + + /** + * The total number of times any slash command has been used. + */ + slashCommandsUsed: bigint; +} + +export interface Emitters { + client: BushClient; + commandHandler: BushClient['commandHandler']; + inhibitorHandler: BushClient['inhibitorHandler']; + listenerHandler: BushClient['listenerHandler']; + taskHandler: BushClient['taskHandler']; + contextMenuCommandHandler: BushClient['contextMenuCommandHandler']; + process: NodeJS.Process; + stdin: readline.Interface; + gateway: BushClient['ws']; + rest: BushClient['rest']; + ws: BushClient['ws']; +} diff --git a/lib/extensions/discord-akairo/BushCommand.ts b/lib/extensions/discord-akairo/BushCommand.ts new file mode 100644 index 0000000..dc2295f --- /dev/null +++ b/lib/extensions/discord-akairo/BushCommand.ts @@ -0,0 +1,586 @@ +import { type DiscordEmojiInfo, type RoleWithDuration } from '#args'; +import { + type BushArgumentTypeCaster, + type BushClient, + type BushCommandHandler, + type BushInhibitor, + type BushListener, + type BushTask, + type ParsedDuration +} from '#lib'; +import { + ArgumentMatch, + Command, + CommandUtil, + type AkairoApplicationCommandAutocompleteOption, + type AkairoApplicationCommandChannelOptionData, + type AkairoApplicationCommandChoicesData, + type AkairoApplicationCommandNonOptionsData, + type AkairoApplicationCommandNumericOptionData, + type AkairoApplicationCommandOptionData, + type AkairoApplicationCommandSubCommandData, + type AkairoApplicationCommandSubGroupData, + type ArgumentOptions, + type ArgumentType, + type ArgumentTypeCaster, + type BaseArgumentType, + type CommandOptions, + type ContextMenuCommand, + type MissingPermissionSupplier, + type SlashOption, + type SlashResolveType +} from 'discord-akairo'; +import { + Message, + User, + type ApplicationCommandOptionChoiceData, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + type ApplicationCommandOptionType, + type PermissionResolvable, + type PermissionsString, + type Snowflake +} from 'discord.js'; +import _ from 'lodash'; +import { SlashMessage } from './SlashMessage.js'; + +export interface OverriddenBaseArgumentType extends BaseArgumentType { + commandAlias: BushCommand | null; + command: BushCommand | null; + inhibitor: BushInhibitor | null; + listener: BushListener | null; + task: BushTask | null; + contextMenuCommand: ContextMenuCommand | null; +} + +export interface BaseBushArgumentType extends OverriddenBaseArgumentType { + duration: number | null; + contentWithDuration: ParsedDuration; + permission: PermissionsString | null; + snowflake: Snowflake | null; + discordEmoji: DiscordEmojiInfo | null; + roleWithDuration: RoleWithDuration | null; + abbreviatedNumber: number | null; + globalUser: User | null; + messageLink: Message | null; + durationSeconds: number | null; + tinyColor: string | null; +} + +export type BushArgumentType = keyof BaseBushArgumentType | RegExp; + +interface BaseBushArgumentOptions extends Omit<ArgumentOptions, 'type' | 'prompt'>, ExtraArgumentOptions { + id: string; + description: string; + + /** + * The message sent for the prompt and the slash command description. + */ + prompt?: string; + + /** + * The message set for the retry prompt. + */ + retry?: string; + + /** + * Whether or not the argument is optional. + */ + optional?: boolean; + + /** + * The type used for slash commands. Set to false to disable this argument for slash commands. + */ + slashType: AkairoApplicationCommandOptionData['type'] | false; + + /** + * Allows you to get a discord resolved object + * + * ex. get the resolved member object when the type is {@link ApplicationCommandOptionType.User User} + */ + slashResolve?: SlashResolveType; + + /** + * The choices of the option for the user to pick from + */ + choices?: ApplicationCommandOptionChoiceData[]; + + /** + * Whether the option is an autocomplete option + */ + autocomplete?: boolean; + + /** + * When the option type is channel, the allowed types of channels that can be selected + */ + channelTypes?: AkairoApplicationCommandChannelOptionData['channelTypes']; + + /** + * The minimum value for an {@link ApplicationCommandOptionType.Integer Integer} or {@link ApplicationCommandOptionType.Number Number} option + */ + minValue?: number; + + /** + * The maximum value for an {@link ApplicationCommandOptionType.Integer Integer} or {@link ApplicationCommandOptionType.Number Number} option + */ + maxValue?: number; +} + +interface ExtraArgumentOptions { + /** + * Restrict this argument to only slash or only text commands. + */ + only?: 'slash' | 'text'; + + /** + * Readable type for the help command. + */ + readableType?: string; + + /** + * Whether the argument is only accessible to the owners. + * @default false + */ + ownerOnly?: boolean; + + /** + * Whether the argument is only accessible to the super users. + * @default false + */ + superUserOnly?: boolean; +} + +export interface BushArgumentOptions extends BaseBushArgumentOptions { + /** + * The type that the argument should be cast to. + * - `string` does not cast to any type. + * - `lowercase` makes the input lowercase. + * - `uppercase` makes the input uppercase. + * - `charCodes` transforms the input to an array of char codes. + * - `number` casts to a number. + * - `integer` casts to an integer. + * - `bigint` casts to a big integer. + * - `url` casts to an `URL` object. + * - `date` casts to a `Date` object. + * - `color` casts a hex code to an integer. + * - `commandAlias` tries to resolve to a command from an alias. + * - `command` matches the ID of a command. + * - `inhibitor` matches the ID of an inhibitor. + * - `listener` matches the ID of a listener. + * + * Possible Discord-related types. + * These types can be plural (add an 's' to the end) and a collection of matching objects will be used. + * - `user` tries to resolve to a user. + * - `member` tries to resolve to a member. + * - `relevant` tries to resolve to a relevant user, works in both guilds and DMs. + * - `channel` tries to resolve to a channel. + * - `textChannel` tries to resolve to a text channel. + * - `voiceChannel` tries to resolve to a voice channel. + * - `stageChannel` tries to resolve to a stage channel. + * - `threadChannel` tries to resolve a thread channel. + * - `role` tries to resolve to a role. + * - `emoji` tries to resolve to a custom emoji. + * - `guild` tries to resolve to a guild. + * - `permission` tries to resolve to a permissions. + * + * Other Discord-related types: + * - `message` tries to fetch a message from an ID within the channel. + * - `guildMessage` tries to fetch a message from an ID within the guild. + * - `relevantMessage` is a combination of the above, works in both guilds and DMs. + * - `invite` tries to fetch an invite object from a link. + * - `userMention` matches a mention of a user. + * - `memberMention` matches a mention of a guild member. + * - `channelMention` matches a mention of a channel. + * - `roleMention` matches a mention of a role. + * - `emojiMention` matches a mention of an emoji. + * + * Misc: + * - `duration` tries to parse duration in milliseconds + * - `contentWithDuration` tries to parse duration in milliseconds and returns the remaining content with the duration + * removed + */ + type?: BushArgumentType | (keyof BaseBushArgumentType)[] | BushArgumentTypeCaster; +} + +export interface CustomBushArgumentOptions extends BaseBushArgumentOptions { + /** + * An array of strings can be used to restrict input to only those strings, case insensitive. + * The array can also contain an inner array of strings, for aliases. + * If so, the first entry of the array will be used as the final argument. + * + * A regular expression can also be used. + * The evaluated argument will be an object containing the `match` and `matches` if global. + */ + customType?: (string | string[])[] | RegExp | string | null; +} + +export type BushMissingPermissionSupplier = (message: CommandMessage | SlashMessage) => Promise<any> | any; + +interface ExtendedCommandOptions { + /** + * Whether the command is hidden from the help command. + */ + hidden?: boolean; + + /** + * The channels the command is limited to run in. + */ + restrictedChannels?: Snowflake[]; + + /** + * The guilds the command is limited to run in. + */ + restrictedGuilds?: Snowflake[]; + + /** + * Show how to use the command. + */ + usage: string[]; + + /** + * Examples for how to use the command. + */ + examples: string[]; + + /** + * A fake command, completely hidden from the help command. + */ + pseudo?: boolean; + + /** + * Allow this command to be run in channels that are blacklisted. + */ + bypassChannelBlacklist?: boolean; + + /** + * Use instead of {@link BaseBushCommandOptions.args} when using argument generators or custom slashOptions + */ + helpArgs?: ArgsInfo[]; + + /** + * Extra information about the command, displayed in the help command. + */ + note?: string; +} + +export interface BaseBushCommandOptions + extends Omit<CommandOptions, 'userPermissions' | 'clientPermissions' | 'args'>, + ExtendedCommandOptions { + /** + * The description of the command. + */ + description: string; + + /** + * The arguments for the command. + */ + args?: BushArgumentOptions[] & CustomBushArgumentOptions[]; + + category: string; + + /** + * Permissions required by the client to run this command. + */ + clientPermissions: bigint | bigint[] | BushMissingPermissionSupplier; + + /** + * Permissions required by the user to run this command. + */ + userPermissions: bigint | bigint[] | BushMissingPermissionSupplier; + + /** + * Whether the argument is only accessible to the owners. + */ + ownerOnly?: boolean; + + /** + * Whether the argument is only accessible to the super users. + */ + superUserOnly?: boolean; +} + +export type BushCommandOptions = Omit<BaseBushCommandOptions, 'helpArgs'> | Omit<BaseBushCommandOptions, 'args'>; + +export interface ArgsInfo { + /** + * The name of the argument. + */ + name: string; + + /** + * The description of the argument. + */ + description: string; + + /** + * Whether the argument is optional. + * @default false + */ + optional?: boolean; + + /** + * Whether or not the argument has autocomplete enabled. + * @default false + */ + autocomplete?: boolean; + + /** + * Whether the argument is restricted a certain command. + * @default 'slash & text' + */ + only?: 'slash & text' | 'slash' | 'text'; + + /** + * The method that arguments are matched for text commands. + * @default 'phrase' + */ + match?: ArgumentMatch; + + /** + * The readable type of the argument. + */ + type: string; + + /** + * If {@link match} is 'flag' or 'option', these are the flags that are matched + * @default [] + */ + flag?: string[]; + + /** + * Whether the argument is only accessible to the owners. + * @default false + */ + ownerOnly?: boolean; + + /** + * Whether the argument is only accessible to the super users. + * @default false + */ + superUserOnly?: boolean; +} + +export abstract class BushCommand extends Command { + public declare client: BushClient; + public declare handler: BushCommandHandler; + public declare description: string; + + /** + * Show how to use the command. + */ + public usage: string[]; + + /** + * Examples for how to use the command. + */ + public examples: string[]; + + /** + * The options sent to the constructor + */ + public options: BushCommandOptions; + + /** + * The options sent to the super call + */ + public parsedOptions: CommandOptions; + + /** + * The channels the command is limited to run in. + */ + public restrictedChannels: Snowflake[] | undefined; + + /** + * The guilds the command is limited to run in. + */ + public restrictedGuilds: Snowflake[] | undefined; + + /** + * Whether the command is hidden from the help command. + */ + public hidden: boolean; + + /** + * A fake command, completely hidden from the help command. + */ + public pseudo: boolean; + + /** + * Allow this command to be run in channels that are blacklisted. + */ + public bypassChannelBlacklist: boolean; + + /** + * Info about the arguments for the help command. + */ + public argsInfo?: ArgsInfo[]; + + /** + * Extra information about the command, displayed in the help command. + */ + public note?: string; + + public constructor(id: string, options: BushCommandOptions) { + const options_ = options as BaseBushCommandOptions; + + if (options_.args && typeof options_.args !== 'function') { + options_.args.forEach((_, index: number) => { + if ('customType' in (options_.args?.[index] ?? {})) { + if (!options_.args![index]['type']) options_.args![index]['type'] = options_.args![index]['customType']! as any; + delete options_.args![index]['customType']; + } + }); + } + + const newOptions: Partial<CommandOptions & ExtendedCommandOptions> = {}; + for (const _key in options_) { + const key = _key as keyof typeof options_; // you got to love typescript + if (key === 'args' && 'args' in options_ && typeof options_.args === 'object') { + const newTextArgs: (ArgumentOptions & ExtraArgumentOptions)[] = []; + const newSlashArgs: SlashOption[] = []; + for (const arg of options_.args) { + if (arg.only !== 'slash' && !options_.slashOnly) { + const newArg: ArgumentOptions & ExtraArgumentOptions = {}; + if ('default' in arg) newArg.default = arg.default; + if ('description' in arg) newArg.description = arg.description; + if ('flag' in arg) newArg.flag = arg.flag; + if ('id' in arg) newArg.id = arg.id; + if ('index' in arg) newArg.index = arg.index; + if ('limit' in arg) newArg.limit = arg.limit; + if ('match' in arg) newArg.match = arg.match; + if ('modifyOtherwise' in arg) newArg.modifyOtherwise = arg.modifyOtherwise; + if ('multipleFlags' in arg) newArg.multipleFlags = arg.multipleFlags; + if ('otherwise' in arg) newArg.otherwise = arg.otherwise; + if ('prompt' in arg || 'retry' in arg || 'optional' in arg) { + newArg.prompt = {}; + if ('prompt' in arg) newArg.prompt.start = arg.prompt; + if ('retry' in arg) newArg.prompt.retry = arg.retry; + if ('optional' in arg) newArg.prompt.optional = arg.optional; + } + if ('type' in arg) newArg.type = arg.type as ArgumentType | ArgumentTypeCaster; + if ('unordered' in arg) newArg.unordered = arg.unordered; + if ('ownerOnly' in arg) newArg.ownerOnly = arg.ownerOnly; + if ('superUserOnly' in arg) newArg.superUserOnly = arg.superUserOnly; + newTextArgs.push(newArg); + } + if ( + arg.only !== 'text' && + !('slashOptions' in options_) && + (options_.slash || options_.slashOnly) && + arg.slashType !== false + ) { + const newArg: { + [key in SlashOptionKeys]?: any; + } = { + name: arg.id, + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + description: arg.prompt || arg.description || 'No description provided.', + type: arg.slashType + }; + if ('slashResolve' in arg) newArg.resolve = arg.slashResolve; + if ('autocomplete' in arg) newArg.autocomplete = arg.autocomplete; + if ('channelTypes' in arg) newArg.channelTypes = arg.channelTypes; + if ('choices' in arg) newArg.choices = arg.choices; + if ('minValue' in arg) newArg.minValue = arg.minValue; + if ('maxValue' in arg) newArg.maxValue = arg.maxValue; + newArg.required = 'optional' in arg ? !arg.optional : true; + newSlashArgs.push(newArg as SlashOption); + } + } + if (newTextArgs.length > 0) newOptions.args = newTextArgs; + if (newSlashArgs.length > 0) newOptions.slashOptions = options_.slashOptions ?? newSlashArgs; + } else if (key === 'clientPermissions' || key === 'userPermissions') { + newOptions[key] = options_[key] as PermissionResolvable | PermissionResolvable[] | MissingPermissionSupplier; + } else { + newOptions[key] = options_[key]; + } + } + + super(id, newOptions); + + if (options_.args ?? options_.helpArgs) { + const argsInfo: ArgsInfo[] = []; + const combined = (options_.args ?? options_.helpArgs)!.map((arg) => { + const norm = options_.args + ? options_.args.find((_arg) => _arg.id === ('id' in arg ? arg.id : arg.name)) ?? ({} as BushArgumentOptions) + : ({} as BushArgumentOptions); + const help = options_.helpArgs + ? options_.helpArgs.find((_arg) => _arg.name === ('id' in arg ? arg.id : arg.name)) ?? ({} as ArgsInfo) + : ({} as ArgsInfo); + return { ...norm, ...help }; + }); + + for (const arg of combined) { + const name = _.camelCase('id' in arg ? arg.id : arg.name), + description = arg.description || '*No description provided.*', + optional = arg.optional ?? false, + autocomplete = arg.autocomplete ?? false, + only = arg.only ?? 'slash & text', + match = arg.match ?? 'phrase', + type = match === 'flag' ? 'flag' : arg.readableType ?? arg.type ?? 'string', + flag = arg.flag ? (Array.isArray(arg.flag) ? arg.flag : [arg.flag]) : [], + ownerOnly = arg.ownerOnly ?? false, + superUserOnly = arg.superUserOnly ?? false; + + argsInfo.push({ name, description, optional, autocomplete, only, match, type, flag, ownerOnly, superUserOnly }); + } + + this.argsInfo = argsInfo; + } + + this.description = options_.description; + this.usage = options_.usage; + this.examples = options_.examples; + this.options = options_; + this.parsedOptions = newOptions; + this.hidden = !!options_.hidden; + this.restrictedChannels = options_.restrictedChannels; + this.restrictedGuilds = options_.restrictedGuilds; + this.pseudo = !!options_.pseudo; + this.bypassChannelBlacklist = !!options_.bypassChannelBlacklist; + this.note = options_.note; + } + + /** + * Executes the command. + * @param message - Message that triggered the command. + * @param args - Evaluated arguments. + */ + public abstract override exec(message: CommandMessage, args: any): any; + /** + * Executes the command. + * @param message - Message that triggered the command. + * @param args - Evaluated arguments. + */ + public abstract override exec(message: CommandMessage | SlashMessage, args: any): any; +} + +type SlashOptionKeys = + | keyof AkairoApplicationCommandSubGroupData + | keyof AkairoApplicationCommandNonOptionsData + | keyof AkairoApplicationCommandChannelOptionData + | keyof AkairoApplicationCommandChoicesData + | keyof AkairoApplicationCommandAutocompleteOption + | keyof AkairoApplicationCommandNumericOptionData + | keyof AkairoApplicationCommandSubCommandData; + +interface PseudoArguments extends BaseBushArgumentType { + boolean: boolean; + flag: boolean; + regex: { match: RegExpMatchArray; matches: RegExpExecArray[] }; +} + +export type ArgType<T extends keyof PseudoArguments> = NonNullable<PseudoArguments[T]>; +export type OptArgType<T extends keyof PseudoArguments> = PseudoArguments[T]; + +/** + * `util` is always defined for messages after `'all'` inhibitors + */ +export type CommandMessage = Message & { + /** + * Extra properties applied to the Discord.js message object. + * Utilities for command responding. + * Available on all messages after 'all' inhibitors and built-in inhibitors (bot, client). + * Not all properties of the util are available, depending on the input. + * */ + util: CommandUtil<Message>; +}; diff --git a/lib/extensions/discord-akairo/BushCommandHandler.ts b/lib/extensions/discord-akairo/BushCommandHandler.ts new file mode 100644 index 0000000..da49af9 --- /dev/null +++ b/lib/extensions/discord-akairo/BushCommandHandler.ts @@ -0,0 +1,37 @@ +import { type BushCommand, type CommandMessage, type SlashMessage } from '#lib'; +import { CommandHandler, type Category, type CommandHandlerEvents, type CommandHandlerOptions } from 'discord-akairo'; +import { type Collection, type Message, type PermissionsString } from 'discord.js'; + +export type BushCommandHandlerOptions = CommandHandlerOptions; + +export interface BushCommandHandlerEvents extends CommandHandlerEvents { + commandBlocked: [message: CommandMessage, command: BushCommand, reason: string]; + commandBreakout: [message: CommandMessage, command: BushCommand, /* no util */ breakMessage: Message]; + commandCancelled: [message: CommandMessage, command: BushCommand, /* no util */ retryMessage?: Message]; + commandFinished: [message: CommandMessage, command: BushCommand, args: any, returnValue: any]; + commandInvalid: [message: CommandMessage, command: BushCommand]; + commandLocked: [message: CommandMessage, command: BushCommand]; + commandStarted: [message: CommandMessage, command: BushCommand, args: any]; + cooldown: [message: CommandMessage | SlashMessage, command: BushCommand, remaining: number]; + error: [error: Error, message: /* no util */ Message, command?: BushCommand]; + inPrompt: [message: /* no util */ Message]; + load: [command: BushCommand, isReload: boolean]; + messageBlocked: [message: /* no util */ Message | CommandMessage | SlashMessage, reason: string]; + messageInvalid: [message: CommandMessage]; + missingPermissions: [message: CommandMessage, command: BushCommand, type: 'client' | 'user', missing: PermissionsString[]]; + remove: [command: BushCommand]; + slashBlocked: [message: SlashMessage, command: BushCommand, reason: string]; + slashError: [error: Error, message: SlashMessage, command: BushCommand]; + slashFinished: [message: SlashMessage, command: BushCommand, args: any, returnValue: any]; + slashMissingPermissions: [message: SlashMessage, command: BushCommand, type: 'client' | 'user', missing: PermissionsString[]]; + slashStarted: [message: SlashMessage, command: BushCommand, args: any]; +} + +export class BushCommandHandler extends CommandHandler { + public declare modules: Collection<string, BushCommand>; + public declare categories: Collection<string, Category<string, BushCommand>>; +} + +export interface BushCommandHandler extends CommandHandler { + findCommand(name: string): BushCommand; +} diff --git a/lib/extensions/discord-akairo/BushInhibitor.ts b/lib/extensions/discord-akairo/BushInhibitor.ts new file mode 100644 index 0000000..be396cf --- /dev/null +++ b/lib/extensions/discord-akairo/BushInhibitor.ts @@ -0,0 +1,19 @@ +import { type BushCommand, type CommandMessage, type SlashMessage } from '#lib'; +import { Inhibitor } from 'discord-akairo'; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { Message } from 'discord.js'; + +export abstract class BushInhibitor extends Inhibitor { + /** + * Checks if message should be blocked. + * A return value of true will block the message. + * If returning a Promise, a resolved value of true will block the message. + * + * **Note:** `'all'` type inhibitors do not have {@link Message.util} defined. + * + * @param message - Message being handled. + * @param command - Command to check. + */ + public abstract override exec(message: CommandMessage, command: BushCommand): any; + public abstract override exec(message: CommandMessage | SlashMessage, command: BushCommand): any; +} diff --git a/lib/extensions/discord-akairo/BushInhibitorHandler.ts b/lib/extensions/discord-akairo/BushInhibitorHandler.ts new file mode 100644 index 0000000..5e4fb6c --- /dev/null +++ b/lib/extensions/discord-akairo/BushInhibitorHandler.ts @@ -0,0 +1,3 @@ +import { InhibitorHandler } from 'discord-akairo'; + +export class BushInhibitorHandler extends InhibitorHandler {} diff --git a/lib/extensions/discord-akairo/BushListener.ts b/lib/extensions/discord-akairo/BushListener.ts new file mode 100644 index 0000000..6917641 --- /dev/null +++ b/lib/extensions/discord-akairo/BushListener.ts @@ -0,0 +1,3 @@ +import { Listener } from 'discord-akairo'; + +export abstract class BushListener extends Listener {} diff --git a/lib/extensions/discord-akairo/BushListenerHandler.ts b/lib/extensions/discord-akairo/BushListenerHandler.ts new file mode 100644 index 0000000..9c3e4af --- /dev/null +++ b/lib/extensions/discord-akairo/BushListenerHandler.ts @@ -0,0 +1,3 @@ +import { ListenerHandler } from 'discord-akairo'; + +export class BushListenerHandler extends ListenerHandler {} diff --git a/lib/extensions/discord-akairo/BushTask.ts b/lib/extensions/discord-akairo/BushTask.ts new file mode 100644 index 0000000..1b70c88 --- /dev/null +++ b/lib/extensions/discord-akairo/BushTask.ts @@ -0,0 +1,3 @@ +import { Task } from 'discord-akairo'; + +export abstract class BushTask extends Task {} diff --git a/lib/extensions/discord-akairo/BushTaskHandler.ts b/lib/extensions/discord-akairo/BushTaskHandler.ts new file mode 100644 index 0000000..6535abb --- /dev/null +++ b/lib/extensions/discord-akairo/BushTaskHandler.ts @@ -0,0 +1,3 @@ +import { TaskHandler } from 'discord-akairo'; + +export class BushTaskHandler extends TaskHandler {} diff --git a/lib/extensions/discord-akairo/SlashMessage.ts b/lib/extensions/discord-akairo/SlashMessage.ts new file mode 100644 index 0000000..0a6669b --- /dev/null +++ b/lib/extensions/discord-akairo/SlashMessage.ts @@ -0,0 +1,3 @@ +import { AkairoMessage } from 'discord-akairo'; + +export class SlashMessage extends AkairoMessage {} diff --git a/lib/extensions/discord.js/BushClientEvents.ts b/lib/extensions/discord.js/BushClientEvents.ts new file mode 100644 index 0000000..22bae65 --- /dev/null +++ b/lib/extensions/discord.js/BushClientEvents.ts @@ -0,0 +1,200 @@ +import type { + BanResponse, + CommandMessage, + Guild as GuildDB, + GuildSettings +} from '#lib'; +import type { AkairoClientEvents } from 'discord-akairo'; +import type { + ButtonInteraction, + Collection, + Guild, + GuildMember, + GuildTextBasedChannel, + Message, + ModalSubmitInteraction, + Role, + SelectMenuInteraction, + Snowflake, + User +} from 'discord.js'; + +export interface BushClientEvents extends AkairoClientEvents { + bushBan: [ + victim: GuildMember | User, + moderator: User, + guild: Guild, + reason: string | undefined, + caseID: string, + duration: number, + dmSuccess?: boolean, + evidence?: string + ]; + bushBlock: [ + victim: GuildMember, + moderator: User, + guild: Guild, + reason: string | undefined, + caseID: string, + duration: number, + dmSuccess: boolean, + channel: GuildTextBasedChannel, + evidence?: string + ]; + bushKick: [ + victim: GuildMember, + moderator: User, + guild: Guild, + reason: string | undefined, + caseID: string, + dmSuccess: boolean, + evidence?: string + ]; + bushMute: [ + victim: GuildMember, + moderator: User, + guild: Guild, + reason: string | undefined, + caseID: string, + duration: number, + dmSuccess: boolean, + evidence?: string + ]; + bushPunishRole: [ + victim: GuildMember, + moderator: User, + guild: Guild, + reason: string | undefined, + caseID: string, + duration: number, + role: Role, + evidence?: string + ]; + bushPunishRoleRemove: [ + victim: GuildMember, + moderator: User, + guild: Guild, + reason: string | undefined, + caseID: string, + role: Role, + evidence?: string + ]; + bushPurge: [ + moderator: User, + guild: Guild, + channel: GuildTextBasedChannel, + messages: Collection<Snowflake, Message> + ]; + bushRemoveTimeout: [ + victim: GuildMember, + moderator: User, + guild: Guild, + reason: string | undefined, + caseID: string, + dmSuccess: boolean, + evidence?: string + ]; + bushTimeout: [ + victim: GuildMember, + moderator: User, + guild: Guild, + reason: string | undefined, + caseID: string, + duration: number, + dmSuccess: boolean, + evidence?: string + ]; + bushUnban: [ + victim: User, + moderator: User, + guild: Guild, + reason: string | undefined, + caseID: string, + dmSuccess: boolean, + evidence?: string + ]; + bushUnblock: [ + victim: GuildMember | User, + moderator: User, + guild: Guild, + reason: string | undefined, + caseID: string, + dmSuccess: boolean, + channel: GuildTextBasedChannel, + evidence?: string + ]; + bushUnmute: [ + victim: GuildMember, + moderator: User, + guild: Guild, + reason: string | undefined, + caseID: string, + dmSuccess: boolean, + evidence?: string + ]; + bushUpdateModlog: [ + moderator: GuildMember, + modlogID: string, + key: 'evidence' | 'hidden', + oldModlog: string | boolean, + newModlog: string | boolean + ]; + bushUpdateSettings: [ + setting: Setting, + guild: Guild, + oldValue: GuildDB[Setting], + newValue: GuildDB[Setting], + moderator?: GuildMember + ]; + bushWarn: [ + victim: GuildMember, + moderator: User, + guild: Guild, + reason: string | undefined, + caseID: string, + dmSuccess: boolean, + evidence?: string + ]; + bushLevelUpdate: [ + member: GuildMember, + oldLevel: number, + newLevel: number, + currentXp: number, + message: CommandMessage + ]; + bushLockdown: [ + moderator: GuildMember, + reason: string | undefined, + channelsSuccessMap: Collection<Snowflake, boolean>, + all?: boolean + ]; + bushUnlockdown: [ + moderator: GuildMember, + reason: string | undefined, + channelsSuccessMap: Collection<Snowflake, boolean>, + all?: boolean + ]; + massBan: [ + moderator: GuildMember, + guild: Guild, + reason: string | undefined, + results: Collection<Snowflake, BanResponse> + ]; + massEvidence: [ + moderator: GuildMember, + guild: Guild, + evidence: string, + lines: string[] + ]; + /* components */ + button: [button: ButtonInteraction]; + selectMenu: [selectMenu: SelectMenuInteraction]; + modal: [modal: ModalSubmitInteraction]; +} + +type Setting = + | GuildSettings + | 'enabledFeatures' + | 'blacklistedChannels' + | 'blacklistedUsers' + | 'disabledCommands'; diff --git a/lib/extensions/discord.js/ExtendedGuild.ts b/lib/extensions/discord.js/ExtendedGuild.ts new file mode 100644 index 0000000..63ee2fd --- /dev/null +++ b/lib/extensions/discord.js/ExtendedGuild.ts @@ -0,0 +1,919 @@ +import { + AllowedMentions, + banResponse, + colors, + dmResponse, + emojis, + permissionsResponse, + punishmentEntryRemove, + type BanResponse, + type GuildFeatures, + type GuildLogType, + type GuildModel +} from '#lib'; +import assert from 'assert/strict'; +import { + AttachmentBuilder, + AttachmentPayload, + Collection, + Guild, + JSONEncodable, + Message, + MessageType, + PermissionFlagsBits, + SnowflakeUtil, + ThreadChannel, + type APIMessage, + type GuildMember, + type GuildMemberResolvable, + type GuildTextBasedChannel, + type MessageOptions, + type MessagePayload, + type NewsChannel, + type Snowflake, + type TextChannel, + type User, + type UserResolvable, + type VoiceChannel, + type Webhook, + type WebhookMessageOptions +} from 'discord.js'; +import _ from 'lodash'; +import * as Moderation from '../../common/Moderation.js'; +import { Guild as GuildDB } from '../../models/instance/Guild.js'; +import { ModLogType } from '../../models/instance/ModLog.js'; +import { addOrRemoveFromArray } from '../../utils/BushUtils.js'; + +declare module 'discord.js' { + export interface Guild { + /** + * Checks if the guild has a certain custom feature. + * @param feature The feature to check for + */ + hasFeature(feature: GuildFeatures): Promise<boolean>; + /** + * Adds a custom feature to the guild. + * @param feature The feature to add + * @param moderator The moderator responsible for adding a feature + */ + addFeature(feature: GuildFeatures, moderator?: GuildMember): Promise<GuildDB['enabledFeatures']>; + /** + * Removes a custom feature from the guild. + * @param feature The feature to remove + * @param moderator The moderator responsible for removing a feature + */ + removeFeature(feature: GuildFeatures, moderator?: GuildMember): Promise<GuildDB['enabledFeatures']>; + /** + * Makes a custom feature the opposite of what it was before + * @param feature The feature to toggle + * @param moderator The moderator responsible for toggling a feature + */ + toggleFeature(feature: GuildFeatures, moderator?: GuildMember): Promise<GuildDB['enabledFeatures']>; + /** + * Fetches a custom setting for the guild + * @param setting The setting to get + */ + getSetting<K extends keyof GuildModel>(setting: K): Promise<GuildModel[K]>; + /** + * Sets a custom setting for the guild + * @param setting The setting to change + * @param value The value to change the setting to + * @param moderator The moderator to responsible for changing the setting + */ + setSetting<K extends Exclude<keyof GuildModel, 'id'>>( + setting: K, + value: GuildModel[K], + moderator?: GuildMember + ): Promise<GuildModel>; + /** + * Get a the log channel configured for a certain log type. + * @param logType The type of log channel to get. + * @returns Either the log channel or undefined if not configured. + */ + getLogChannel(logType: GuildLogType): Promise<TextChannel | undefined>; + /** + * Sends a message to the guild's specified logging channel + * @param logType The corresponding channel that the message will be sent to + * @param message The parameters for {@link BushTextChannel.send} + */ + sendLogChannel(logType: GuildLogType, message: string | MessagePayload | MessageOptions): Promise<Message | null | undefined>; + /** + * Sends a formatted error message in a guild's error log channel + * @param title The title of the error embed + * @param message The description of the error embed + */ + error(title: string, message: string): Promise<void>; + /** + * Bans a user, dms them, creates a mod log entry, and creates a punishment entry. + * @param options Options for banning the user. + * @returns A string status message of the ban. + */ + bushBan(options: GuildBushBanOptions): Promise<BanResponse>; + /** + * {@link bushBan} with less resolving and checks + * @param options Options for banning the user. + * @returns A string status message of the ban. + * **Preconditions:** + * - {@link me} has the `BanMembers` permission + * **Warning:** + * - Doesn't emit bushBan Event + */ + massBanOne(options: GuildMassBanOneOptions): Promise<BanResponse>; + /** + * Unbans a user, dms them, creates a mod log entry, and destroys the punishment entry. + * @param options Options for unbanning the user. + * @returns A status message of the unban. + */ + bushUnban(options: GuildBushUnbanOptions): Promise<UnbanResponse>; + /** + * Denies send permissions in specified channels + * @param options The options for locking down the guild + */ + lockdown(options: LockdownOptions): Promise<LockdownResponse>; + quote(rawQuote: APIMessage, channel: GuildTextBasedChannel): Promise<Message | null>; + } +} + +/** + * Represents a guild (or a server) on Discord. + * <info>It's recommended to see if a guild is available before performing operations or reading data from it. You can + * check this with {@link ExtendedGuild.available}.</info> + */ +export class ExtendedGuild extends Guild { + /** + * Checks if the guild has a certain custom feature. + * @param feature The feature to check for + */ + public override async hasFeature(feature: GuildFeatures): Promise<boolean> { + const features = await this.getSetting('enabledFeatures'); + return features.includes(feature); + } + + /** + * Adds a custom feature to the guild. + * @param feature The feature to add + * @param moderator The moderator responsible for adding a feature + */ + public override async addFeature(feature: GuildFeatures, moderator?: GuildMember): Promise<GuildModel['enabledFeatures']> { + const features = await this.getSetting('enabledFeatures'); + const newFeatures = addOrRemoveFromArray('add', features, feature); + return (await this.setSetting('enabledFeatures', newFeatures, moderator)).enabledFeatures; + } + + /** + * Removes a custom feature from the guild. + * @param feature The feature to remove + * @param moderator The moderator responsible for removing a feature + */ + public override async removeFeature(feature: GuildFeatures, moderator?: GuildMember): Promise<GuildModel['enabledFeatures']> { + const features = await this.getSetting('enabledFeatures'); + const newFeatures = addOrRemoveFromArray('remove', features, feature); + return (await this.setSetting('enabledFeatures', newFeatures, moderator)).enabledFeatures; + } + + /** + * Makes a custom feature the opposite of what it was before + * @param feature The feature to toggle + * @param moderator The moderator responsible for toggling a feature + */ + public override async toggleFeature(feature: GuildFeatures, moderator?: GuildMember): Promise<GuildModel['enabledFeatures']> { + return (await this.hasFeature(feature)) + ? await this.removeFeature(feature, moderator) + : await this.addFeature(feature, moderator); + } + + /** + * Fetches a custom setting for the guild + * @param setting The setting to get + */ + public override async getSetting<K extends keyof GuildModel>(setting: K): Promise<GuildModel[K]> { + return ( + this.client.cache.guilds.get(this.id)?.[setting] ?? + ((await GuildDB.findByPk(this.id)) ?? GuildDB.build({ id: this.id }))[setting] + ); + } + + /** + * Sets a custom setting for the guild + * @param setting The setting to change + * @param value The value to change the setting to + * @param moderator The moderator to responsible for changing the setting + */ + public override async setSetting<K extends Exclude<keyof GuildModel, 'id'>>( + setting: K, + value: GuildDB[K], + moderator?: GuildMember + ): Promise<GuildDB> { + const row = (await GuildDB.findByPk(this.id)) ?? GuildDB.build({ id: this.id }); + const oldValue = row[setting] as GuildDB[K]; + row[setting] = value; + this.client.cache.guilds.set(this.id, row.toJSON() as GuildDB); + this.client.emit('bushUpdateSettings', setting, this, oldValue, row[setting], moderator); + return await row.save(); + } + + /** + * Get a the log channel configured for a certain log type. + * @param logType The type of log channel to get. + * @returns Either the log channel or undefined if not configured. + */ + public override async getLogChannel(logType: GuildLogType): Promise<TextChannel | undefined> { + const channelId = (await this.getSetting('logChannels'))[logType]; + if (!channelId) return undefined; + return ( + (this.channels.cache.get(channelId) as TextChannel | undefined) ?? + ((await this.channels.fetch(channelId)) as TextChannel | null) ?? + undefined + ); + } + + /** + * Sends a message to the guild's specified logging channel + * @param logType The corresponding channel that the message will be sent to + * @param message The parameters for {@link BushTextChannel.send} + */ + public override async sendLogChannel( + logType: GuildLogType, + message: string | MessagePayload | MessageOptions + ): Promise<Message | null | undefined> { + const logChannel = await this.getLogChannel(logType); + if (!logChannel || !logChannel.isTextBased()) { + void this.client.console.warn('sendLogChannel', `No log channel found for <<${logType}<< in <<${this.name}>>.`); + return; + } + if ( + !logChannel + .permissionsFor(this.members.me!.id) + ?.has([PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.EmbedLinks]) + ) + return; + + return await logChannel.send(message).catch(() => null); + } + + /** + * Sends a formatted error message in a guild's error log channel + * @param title The title of the error embed + * @param message The description of the error embed + */ + public override async error(title: string, message: string): Promise<void> { + void this.client.console.info(_.camelCase(title), message.replace(/\*\*(.*?)\*\*/g, '<<$1>>')); + void this.sendLogChannel('error', { embeds: [{ title: title, description: message, color: colors.error }] }); + } + + /** + * Bans a user, dms them, creates a mod log entry, and creates a punishment entry. + * @param options Options for banning the user. + * @returns A string status message of the ban. + */ + public override async bushBan(options: GuildBushBanOptions): Promise<BanResponse> { + // checks + if (!this.members.me!.permissions.has(PermissionFlagsBits.BanMembers)) return banResponse.MISSING_PERMISSIONS; + + let caseID: string | undefined = undefined; + let dmSuccessEvent: boolean | undefined = undefined; + const user = await this.client.utils.resolveNonCachedUser(options.user); + const moderator = this.client.users.resolve(options.moderator ?? this.client.user!); + if (!user || !moderator) return banResponse.CANNOT_RESOLVE_USER; + + if ((await this.bans.fetch()).has(user.id)) return banResponse.ALREADY_BANNED; + + const ret = await (async () => { + // add modlog entry + const { log: modlog } = await Moderation.createModLogEntry({ + client: this.client, + type: options.duration ? ModLogType.TEMP_BAN : ModLogType.PERM_BAN, + user: user, + moderator: moderator.id, + reason: options.reason, + duration: options.duration, + guild: this, + evidence: options.evidence + }); + if (!modlog) return banResponse.MODLOG_ERROR; + caseID = modlog.id; + + // dm user + dmSuccessEvent = await Moderation.punishDM({ + client: this.client, + modlog: modlog.id, + guild: this, + user: user, + punishment: 'banned', + duration: options.duration ?? 0, + reason: options.reason ?? undefined, + sendFooter: true + }); + + // ban + const banSuccess = await this.bans + .create(user?.id ?? options.user, { + reason: `${moderator.tag} | ${options.reason ?? 'No reason provided.'}`, + deleteMessageDays: options.deleteDays + }) + .catch(() => false); + if (!banSuccess) return banResponse.ACTION_ERROR; + + // add punishment entry so they can be unbanned later + const punishmentEntrySuccess = await Moderation.createPunishmentEntry({ + client: this.client, + type: 'ban', + user: user, + guild: this, + duration: options.duration, + modlog: modlog.id + }); + if (!punishmentEntrySuccess) return banResponse.PUNISHMENT_ENTRY_ADD_ERROR; + + if (!dmSuccessEvent) return banResponse.DM_ERROR; + return banResponse.SUCCESS; + })(); + + if (!([banResponse.ACTION_ERROR, banResponse.MODLOG_ERROR, banResponse.PUNISHMENT_ENTRY_ADD_ERROR] as const).includes(ret)) + this.client.emit( + 'bushBan', + user, + moderator, + this, + options.reason ?? undefined, + caseID!, + options.duration ?? 0, + dmSuccessEvent, + options.evidence + ); + return ret; + } + + /** + * {@link bushBan} with less resolving and checks + * @param options Options for banning the user. + * @returns A string status message of the ban. + * **Preconditions:** + * - {@link me} has the `BanMembers` permission + * **Warning:** + * - Doesn't emit bushBan Event + */ + public override async massBanOne(options: GuildMassBanOneOptions): Promise<BanResponse> { + if (this.bans.cache.has(options.user)) return banResponse.ALREADY_BANNED; + + const ret = await (async () => { + // add modlog entry + const { log: modlog } = await Moderation.createModLogEntrySimple({ + client: this.client, + type: ModLogType.PERM_BAN, + user: options.user, + moderator: options.moderator, + reason: options.reason, + duration: 0, + guild: this.id + }); + if (!modlog) return banResponse.MODLOG_ERROR; + + let dmSuccessEvent: boolean | undefined = undefined; + // dm user + if (this.members.cache.has(options.user)) { + dmSuccessEvent = await Moderation.punishDM({ + client: this.client, + modlog: modlog.id, + guild: this, + user: options.user, + punishment: 'banned', + duration: 0, + reason: options.reason ?? undefined, + sendFooter: true + }); + } + + // ban + const banSuccess = await this.bans + .create(options.user, { + reason: `${options.moderator} | ${options.reason}`, + deleteMessageDays: options.deleteDays + }) + .catch(() => false); + if (!banSuccess) return banResponse.ACTION_ERROR; + + // add punishment entry so they can be unbanned later + const punishmentEntrySuccess = await Moderation.createPunishmentEntry({ + client: this.client, + type: 'ban', + user: options.user, + guild: this, + duration: 0, + modlog: modlog.id + }); + if (!punishmentEntrySuccess) return banResponse.PUNISHMENT_ENTRY_ADD_ERROR; + + if (!dmSuccessEvent) return banResponse.DM_ERROR; + return banResponse.SUCCESS; + })(); + return ret; + } + + /** + * Unbans a user, dms them, creates a mod log entry, and destroys the punishment entry. + * @param options Options for unbanning the user. + * @returns A status message of the unban. + */ + public override async bushUnban(options: GuildBushUnbanOptions): Promise<UnbanResponse> { + // checks + if (!this.members.me!.permissions.has(PermissionFlagsBits.BanMembers)) return unbanResponse.MISSING_PERMISSIONS; + + let caseID: string | undefined = undefined; + let dmSuccessEvent: boolean | undefined = undefined; + const user = await this.client.utils.resolveNonCachedUser(options.user); + const moderator = this.client.users.resolve(options.moderator ?? this.client.user!); + if (!user || !moderator) return unbanResponse.CANNOT_RESOLVE_USER; + + const ret = await (async () => { + const bans = await this.bans.fetch(); + + let notBanned = false; + if (!bans.has(user.id)) notBanned = true; + + const unbanSuccess = await this.bans + .remove(user, `${moderator.tag} | ${options.reason ?? 'No reason provided.'}`) + .catch((e) => { + if (e?.code === 'UNKNOWN_BAN') { + notBanned = true; + return true; + } else return false; + }); + + if (notBanned) return unbanResponse.NOT_BANNED; + if (!unbanSuccess) return unbanResponse.ACTION_ERROR; + + // add modlog entry + const { log: modlog } = await Moderation.createModLogEntry({ + client: this.client, + type: ModLogType.UNBAN, + user: user.id, + moderator: moderator.id, + reason: options.reason, + guild: this, + evidence: options.evidence + }); + if (!modlog) return unbanResponse.MODLOG_ERROR; + caseID = modlog.id; + + // remove punishment entry + const removePunishmentEntrySuccess = await Moderation.removePunishmentEntry({ + client: this.client, + type: 'ban', + user: user.id, + guild: this + }); + if (!removePunishmentEntrySuccess) return unbanResponse.PUNISHMENT_ENTRY_REMOVE_ERROR; + + // dm user + dmSuccessEvent = await Moderation.punishDM({ + client: this.client, + guild: this, + user: user, + punishment: 'unbanned', + reason: options.reason ?? undefined, + sendFooter: false + }); + + if (!dmSuccessEvent) return unbanResponse.DM_ERROR; + return unbanResponse.SUCCESS; + })(); + if ( + !([unbanResponse.ACTION_ERROR, unbanResponse.MODLOG_ERROR, unbanResponse.PUNISHMENT_ENTRY_REMOVE_ERROR] as const).includes( + ret + ) + ) + this.client.emit( + 'bushUnban', + user, + moderator, + this, + options.reason ?? undefined, + caseID!, + dmSuccessEvent!, + options.evidence + ); + return ret; + } + + /** + * Denies send permissions in specified channels + * @param options The options for locking down the guild + */ + public override async lockdown(options: LockdownOptions): Promise<LockdownResponse> { + if (!options.all && !options.channel) return 'all not chosen and no channel specified'; + const channelIds = options.all ? await this.getSetting('lockdownChannels') : [options.channel!.id]; + + if (!channelIds.length) return 'no channels configured'; + const mappedChannels = channelIds.map((id) => this.channels.cache.get(id)); + + const invalidChannels = mappedChannels.filter((c) => c === undefined); + if (invalidChannels.length) return `invalid channel configured: ${invalidChannels.join(', ')}`; + + const moderator = this.members.resolve(options.moderator); + if (!moderator) return 'moderator not found'; + + const errors = new Collection<Snowflake, Error>(); + const success = new Collection<Snowflake, boolean>(); + const ret = await (async (): Promise<LockdownResponse> => { + for (const _channel of mappedChannels) { + const channel = _channel!; + if (!channel.isTextBased()) { + errors.set(channel.id, new Error('wrong channel type')); + success.set(channel.id, false); + continue; + } + if (!channel.permissionsFor(this.members.me!.id)?.has([PermissionFlagsBits.ManageChannels])) { + errors.set(channel.id, new Error('client no permission')); + success.set(channel.id, false); + continue; + } else if (!channel.permissionsFor(moderator)?.has([PermissionFlagsBits.ManageChannels])) { + errors.set(channel.id, new Error('moderator no permission')); + success.set(channel.id, false); + continue; + } + + const reason = `[${options.unlock ? 'Unlockdown' : 'Lockdown'}] ${moderator.user.tag} | ${ + options.reason ?? 'No reason provided' + }`; + + const permissionOverwrites = channel.isThread() ? channel.parent!.permissionOverwrites : channel.permissionOverwrites; + const perms = { + SendMessagesInThreads: options.unlock ? null : false, + SendMessages: options.unlock ? null : false + }; + const permsForMe = { + [channel.isThread() ? 'SendMessagesInThreads' : 'SendMessages']: options.unlock ? null : true + }; // so I can send messages in the channel + + const changePermSuccess = await permissionOverwrites.edit(this.id, perms, { reason }).catch((e) => e); + if (changePermSuccess instanceof Error) { + errors.set(channel.id, changePermSuccess); + success.set(channel.id, false); + } else { + success.set(channel.id, true); + await permissionOverwrites.edit(this.members.me!, permsForMe, { reason }); + await channel.send({ + embeds: [ + { + author: { name: moderator.user.tag, icon_url: moderator.displayAvatarURL() }, + title: `This channel has been ${options.unlock ? 'un' : ''}locked`, + description: options.reason ?? 'No reason provided', + color: options.unlock ? colors.Green : colors.Red, + timestamp: new Date().toISOString() + } + ] + }); + } + } + + if (errors.size) return errors; + else return `success: ${success.filter((c) => c === true).size}`; + })(); + + this.client.emit(options.unlock ? 'bushUnlockdown' : 'bushLockdown', moderator, options.reason, success, options.all); + return ret; + } + + public override async quote(rawQuote: APIMessage, channel: GuildTextBasedChannel): Promise<Message | null> { + if (!channel.isTextBased() || channel.isDMBased() || channel.guildId !== this.id || !this.members.me) return null; + if (!channel.permissionsFor(this.members.me).has('ManageWebhooks')) return null; + + const quote = new Message(this.client, rawQuote); + + const target = channel instanceof ThreadChannel ? channel.parent : channel; + if (!target) return null; + + const webhooks: Collection<string, Webhook> = await target.fetchWebhooks().catch((e) => e); + if (!(webhooks instanceof Collection)) return null; + + // find a webhook that we can use + let webhook = webhooks.find((w) => !!w.token) ?? null; + if (!webhook) + webhook = await target + .createWebhook({ + name: `${this.client.user!.username} Quotes #${target.name}`, + avatar: this.client.user!.displayAvatarURL({ size: 2048 }), + reason: 'Creating a webhook for quoting' + }) + .catch(() => null); + + if (!webhook) return null; + + const sendOptions: Omit<WebhookMessageOptions, 'flags'> = {}; + + const displayName = quote.member?.displayName ?? quote.author.username; + + switch (quote.type) { + case MessageType.Default: + case MessageType.Reply: + case MessageType.ChatInputCommand: + case MessageType.ContextMenuCommand: + case MessageType.ThreadStarterMessage: + sendOptions.content = quote.content || undefined; + sendOptions.threadId = channel instanceof ThreadChannel ? channel.id : undefined; + sendOptions.embeds = quote.embeds.length ? quote.embeds : undefined; + //@ts-expect-error: jank + sendOptions.attachments = quote.attachments.size + ? [...quote.attachments.values()].map((a) => AttachmentBuilder.from(a as JSONEncodable<AttachmentPayload>)) + : undefined; + + if (quote.stickers.size && !(quote.content || quote.embeds.length || quote.attachments.size)) + sendOptions.content = '[[This message has a sticker but not content]]'; + + break; + case MessageType.RecipientAdd: { + const recipient = rawQuote.mentions[0]; + if (!recipient) { + sendOptions.content = `${emojis.error} Cannot resolve recipient.`; + break; + } + + if (quote.channel.isThread()) { + const recipientDisplay = quote.guild?.members.cache.get(recipient.id)?.displayName ?? recipient.username; + sendOptions.content = `${emojis.join} ${displayName} added ${recipientDisplay} to the thread.`; + } else { + // this should never happen + sendOptions.content = `${emojis.join} ${displayName} added ${recipient.username} to the group.`; + } + + break; + } + case MessageType.RecipientRemove: { + const recipient = rawQuote.mentions[0]; + if (!recipient) { + sendOptions.content = `${emojis.error} Cannot resolve recipient.`; + break; + } + + if (quote.channel.isThread()) { + const recipientDisplay = quote.guild?.members.cache.get(recipient.id)?.displayName ?? recipient.username; + sendOptions.content = `${emojis.leave} ${displayName} removed ${recipientDisplay} from the thread.`; + } else { + // this should never happen + sendOptions.content = `${emojis.leave} ${displayName} removed ${recipient.username} from the group.`; + } + + break; + } + + case MessageType.ChannelNameChange: + sendOptions.content = `<:pencil:957988608994861118> ${displayName} changed the channel name: **${quote.content}**`; + + break; + + case MessageType.ChannelPinnedMessage: + throw new Error('Not implemented yet: MessageType.ChannelPinnedMessage case'); + case MessageType.UserJoin: { + const messages = [ + '{username} joined the party.', + '{username} is here.', + 'Welcome, {username}. We hope you brought pizza.', + 'A wild {username} appeared.', + '{username} just landed.', + '{username} just slid into the server.', + '{username} just showed up!', + 'Welcome {username}. Say hi!', + '{username} hopped into the server.', + 'Everyone welcome {username}!', + "Glad you're here, {username}.", + 'Good to see you, {username}.', + 'Yay you made it, {username}!' + ]; + + const timestamp = SnowflakeUtil.timestampFrom(quote.id); + + // this is the same way that the discord client decides what message to use. + const message = messages[timestamp % messages.length].replace(/{username}/g, displayName); + + sendOptions.content = `${emojis.join} ${message}`; + break; + } + case MessageType.GuildBoost: + sendOptions.content = `<:NitroBoost:585558042309820447> ${displayName} just boosted the server${ + quote.content ? ` **${quote.content}** times` : '' + }!`; + + break; + case MessageType.GuildBoostTier1: + case MessageType.GuildBoostTier2: + case MessageType.GuildBoostTier3: + sendOptions.content = `<:NitroBoost:585558042309820447> ${displayName} just boosted the server${ + quote.content ? ` **${quote.content}** times` : '' + }! ${quote.guild?.name} has achieved **Level ${quote.type - 8}!**`; + + break; + case MessageType.ChannelFollowAdd: + sendOptions.content = `${displayName} has added **${quote.content}** to this channel. Its most important updates will show up here.`; + + break; + case MessageType.GuildDiscoveryDisqualified: + sendOptions.content = + '<:SystemMessageCross:842172192418693173> This server has been removed from Server Discovery because it no longer passes all the requirements. Check Server Settings for more details.'; + + break; + case MessageType.GuildDiscoveryRequalified: + sendOptions.content = + '<:SystemMessageCheck:842172191801212949> This server is eligible for Server Discovery again and has been automatically relisted!'; + + break; + case MessageType.GuildDiscoveryGracePeriodInitialWarning: + sendOptions.content = + '<:SystemMessageWarn:842172192401915971> This server has failed Discovery activity requirements for 1 week. If this server fails for 4 weeks in a row, it will be automatically removed from Discovery.'; + + break; + case MessageType.GuildDiscoveryGracePeriodFinalWarning: + sendOptions.content = + '<:SystemMessageWarn:842172192401915971> This server has failed Discovery activity requirements for 3 weeks in a row. If this server fails for 1 more week, it will be removed from Discovery.'; + + break; + case MessageType.ThreadCreated: { + const threadId = rawQuote.message_reference?.channel_id; + + sendOptions.content = `<:thread:865033845753249813> ${displayName} started a thread: **[${quote.content}](https://discord.com/channels/${quote.guildId}/${threadId} + )**. See all threads.`; + + break; + } + case MessageType.GuildInviteReminder: + sendOptions.content = 'Wondering who to invite? Start by inviting anyone who can help you build the server!'; + + break; + // todo: use enum for this + case 24 as MessageType: { + const embed = quote.embeds[0]; + // eslint-disable-next-line deprecation/deprecation + assert.equal(embed.data.type, 'auto_moderation_message'); + const ruleName = embed.fields!.find((f) => f.name === 'rule_name')!.value; + const channelId = embed.fields!.find((f) => f.name === 'channel_id')!.value; + const keyword = embed.fields!.find((f) => f.name === 'keyword')!.value; + + sendOptions.username = `AutoMod (${quote.member?.displayName ?? quote.author.username})`; + sendOptions.content = `Automod has blocked a message in <#${channelId}>`; + sendOptions.embeds = [ + { + title: quote.member?.displayName ?? quote.author.username, + description: embed.description ?? 'There is no content???', + footer: { + text: `Keyword: ${keyword} • Rule: ${ruleName}` + }, + color: 0x36393f + } + ]; + + break; + } + case MessageType.ChannelIconChange: + case MessageType.Call: + default: + sendOptions.content = `${emojis.error} I cannot quote messages of type **${ + MessageType[quote.type] || quote.type + }** messages, please report this to my developers.`; + + break; + } + + sendOptions.allowedMentions = AllowedMentions.none(); + sendOptions.username ??= quote.member?.displayName ?? quote.author.username; + sendOptions.avatarURL = quote.member?.displayAvatarURL({ size: 2048 }) ?? quote.author.displayAvatarURL({ size: 2048 }); + + return await webhook.send(sendOptions); /* .catch((e: any) => e); */ + } +} + +/** + * Options for unbanning a user + */ +export interface GuildBushUnbanOptions { + /** + * The user to unban + */ + user: UserResolvable | User; + + /** + * The reason for unbanning the user + */ + reason?: string | null; + + /** + * The moderator who unbanned the user + */ + moderator?: UserResolvable; + + /** + * The evidence for the unban + */ + evidence?: string; +} + +export interface GuildMassBanOneOptions { + /** + * The user to ban + */ + user: Snowflake; + + /** + * The reason to ban the user + */ + reason: string; + + /** + * The moderator who banned the user + */ + moderator: Snowflake; + + /** + * The number of days to delete the user's messages for + */ + deleteDays?: number; +} + +/** + * Options for banning a user + */ +export interface GuildBushBanOptions { + /** + * The user to ban + */ + user: UserResolvable; + + /** + * The reason to ban the user + */ + reason?: string | null; + + /** + * The moderator who banned the user + */ + moderator?: UserResolvable; + + /** + * The duration of the ban + */ + duration?: number; + + /** + * The number of days to delete the user's messages for + */ + deleteDays?: number; + + /** + * The evidence for the ban + */ + evidence?: string; +} + +type ValueOf<T> = T[keyof T]; + +export const unbanResponse = Object.freeze({ + ...dmResponse, + ...permissionsResponse, + ...punishmentEntryRemove, + NOT_BANNED: 'user not banned' +} as const); + +/** + * Response returned when unbanning a user + */ +export type UnbanResponse = ValueOf<typeof unbanResponse>; + +/** + * Options for locking down channel(s) + */ +export interface LockdownOptions { + /** + * The moderator responsible for the lockdown + */ + moderator: GuildMemberResolvable; + + /** + * Whether to lock down all (specified) channels + */ + all: boolean; + + /** + * Reason for the lockdown + */ + reason?: string; + + /** + * A specific channel to lockdown + */ + channel?: ThreadChannel | NewsChannel | TextChannel | VoiceChannel; + + /** + * Whether or not to unlock the channel(s) instead of locking them + */ + unlock?: boolean; +} + +/** + * Response returned when locking down a channel + */ +export type LockdownResponse = + | `success: ${number}` + | 'all not chosen and no channel specified' + | 'no channels configured' + | `invalid channel configured: ${string}` + | 'moderator not found' + | Collection<string, Error>; diff --git a/lib/extensions/discord.js/ExtendedGuildMember.ts b/lib/extensions/discord.js/ExtendedGuildMember.ts new file mode 100644 index 0000000..f8add83 --- /dev/null +++ b/lib/extensions/discord.js/ExtendedGuildMember.ts @@ -0,0 +1,1255 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { formatError, Moderation, ModLogType, Time, type BushClientEvents, type PunishmentTypeDM, type ValueOf } from '#lib'; +import { + ChannelType, + GuildMember, + PermissionFlagsBits, + type GuildChannelResolvable, + type GuildTextBasedChannel, + type Role +} from 'discord.js'; +/* eslint-enable @typescript-eslint/no-unused-vars */ + +declare module 'discord.js' { + export interface GuildMember { + /** + * Send a punishment dm to the user. + * @param punishment The punishment that the user has received. + * @param reason The reason for the user's punishment. + * @param duration The duration of the punishment. + * @param modlog The modlog case id so the user can make an appeal. + * @param sendFooter Whether or not to send the guild's punishment footer with the dm. + * @returns Whether or not the dm was sent successfully. + */ + bushPunishDM( + punishment: PunishmentTypeDM, + reason?: string | null, + duration?: number, + modlog?: string, + sendFooter?: boolean + ): Promise<boolean>; + /** + * Warn the user, create a modlog entry, and send a dm to the user. + * @param options Options for warning the user. + * @returns An object with the result of the warning, and the case number of the warn. + * @emits {@link BushClientEvents.bushWarn} + */ + bushWarn(options: BushPunishmentOptions): Promise<{ result: WarnResponse; caseNum: number | null }>; + /** + * Add a role to the user, if it is a punishment create a modlog entry, and create a punishment entry if it is temporary or a punishment. + * @param options Options for adding a role to the user. + * @returns A status message for adding the add. + * @emits {@link BushClientEvents.bushPunishRole} + */ + bushAddRole(options: AddRoleOptions): Promise<AddRoleResponse>; + /** + * Remove a role from the user, if it is a punishment create a modlog entry, and destroy a punishment entry if it was temporary or a punishment. + * @param options Options for removing a role from the user. + * @returns A status message for removing the role. + * @emits {@link BushClientEvents.bushPunishRoleRemove} + */ + bushRemoveRole(options: RemoveRoleOptions): Promise<RemoveRoleResponse>; + /** + * Mute the user, create a modlog entry, creates a punishment entry, and dms the user. + * @param options Options for muting the user. + * @returns A status message for muting the user. + * @emits {@link BushClientEvents.bushMute} + */ + bushMute(options: BushTimedPunishmentOptions): Promise<MuteResponse>; + /** + * Unmute the user, create a modlog entry, remove the punishment entry, and dm the user. + * @param options Options for unmuting the user. + * @returns A status message for unmuting the user. + * @emits {@link BushClientEvents.bushUnmute} + */ + bushUnmute(options: BushPunishmentOptions): Promise<UnmuteResponse>; + /** + * Kick the user, create a modlog entry, and dm the user. + * @param options Options for kicking the user. + * @returns A status message for kicking the user. + * @emits {@link BushClientEvents.bushKick} + */ + bushKick(options: BushPunishmentOptions): Promise<KickResponse>; + /** + * Ban the user, create a modlog entry, create a punishment entry, and dm the user. + * @param options Options for banning the user. + * @returns A status message for banning the user. + * @emits {@link BushClientEvents.bushBan} + */ + bushBan(options: BushBanOptions): Promise<Exclude<BanResponse, typeof banResponse['ALREADY_BANNED']>>; + /** + * Prevents a user from speaking in a channel. + * @param options Options for blocking the user. + */ + bushBlock(options: BlockOptions): Promise<BlockResponse>; + /** + * Allows a user to speak in a channel. + * @param options Options for unblocking the user. + */ + bushUnblock(options: UnblockOptions): Promise<UnblockResponse>; + /** + * Mutes a user using discord's timeout feature. + * @param options Options for timing out the user. + */ + bushTimeout(options: BushTimeoutOptions): Promise<TimeoutResponse>; + /** + * Removes a timeout from a user. + * @param options Options for removing the timeout. + */ + bushRemoveTimeout(options: BushPunishmentOptions): Promise<RemoveTimeoutResponse>; + /** + * Whether or not the user is an owner of the bot. + */ + isOwner(): boolean; + /** + * Whether or not the user is a super user of the bot. + */ + isSuperUser(): boolean; + } +} + +/** + * Represents a member of a guild on Discord. + */ +export class ExtendedGuildMember extends GuildMember { + /** + * Send a punishment dm to the user. + * @param punishment The punishment that the user has received. + * @param reason The reason for the user's punishment. + * @param duration The duration of the punishment. + * @param modlog The modlog case id so the user can make an appeal. + * @param sendFooter Whether or not to send the guild's punishment footer with the dm. + * @returns Whether or not the dm was sent successfully. + */ + public override async bushPunishDM( + punishment: PunishmentTypeDM, + reason?: string | null, + duration?: number, + modlog?: string, + sendFooter = true + ): Promise<boolean> { + return Moderation.punishDM({ + client: this.client, + modlog, + guild: this.guild, + user: this, + punishment, + reason: reason ?? undefined, + duration, + sendFooter + }); + } + + /** + * Warn the user, create a modlog entry, and send a dm to the user. + * @param options Options for warning the user. + * @returns An object with the result of the warning, and the case number of the warn. + * @emits {@link BushClientEvents.bushWarn} + */ + public override async bushWarn(options: BushPunishmentOptions): Promise<{ result: WarnResponse; caseNum: number | null }> { + let caseID: string | undefined = undefined; + let dmSuccessEvent: boolean | undefined = undefined; + const moderator = await this.client.utils.resolveNonCachedUser(options.moderator ?? this.guild.members.me); + if (!moderator) return { result: warnResponse.CANNOT_RESOLVE_USER, caseNum: null }; + + const ret = await (async (): Promise<{ result: WarnResponse; caseNum: number | null }> => { + // add modlog entry + const result = await Moderation.createModLogEntry( + { + client: this.client, + type: ModLogType.WARN, + user: this, + moderator: moderator.id, + reason: options.reason, + guild: this.guild, + evidence: options.evidence, + hidden: options.silent ?? false + }, + true + ); + caseID = result.log?.id; + if (!result || !result.log) return { result: warnResponse.MODLOG_ERROR, caseNum: null }; + + if (!options.silent) { + // dm user + const dmSuccess = await this.bushPunishDM('warned', options.reason); + dmSuccessEvent = dmSuccess; + if (!dmSuccess) return { result: warnResponse.DM_ERROR, caseNum: result.caseNum }; + } + + return { result: warnResponse.SUCCESS, caseNum: result.caseNum }; + })(); + if (!([warnResponse.MODLOG_ERROR] as const).includes(ret.result) && !options.silent) + this.client.emit('bushWarn', this, moderator, this.guild, options.reason ?? undefined, caseID!, dmSuccessEvent!); + return ret; + } + + /** + * Add a role to the user, if it is a punishment create a modlog entry, and create a punishment entry if it is temporary or a punishment. + * @param options Options for adding a role to the user. + * @returns A status message for adding the add. + * @emits {@link BushClientEvents.bushPunishRole} + */ + public override async bushAddRole(options: AddRoleOptions): Promise<AddRoleResponse> { + // checks + if (!this.guild.members.me!.permissions.has(PermissionFlagsBits.ManageRoles)) return addRoleResponse.MISSING_PERMISSIONS; + const ifShouldAddRole = this.#checkIfShouldAddRole(options.role, options.moderator); + if (ifShouldAddRole !== true) return ifShouldAddRole; + + let caseID: string | undefined = undefined; + const moderator = await this.client.utils.resolveNonCachedUser(options.moderator ?? this.guild.members.me); + if (!moderator) return addRoleResponse.CANNOT_RESOLVE_USER; + + const ret = await (async () => { + if (options.addToModlog || options.duration) { + const { log: modlog } = await Moderation.createModLogEntry({ + client: this.client, + type: options.duration ? ModLogType.TEMP_PUNISHMENT_ROLE : ModLogType.PERM_PUNISHMENT_ROLE, + guild: this.guild, + moderator: moderator.id, + user: this, + reason: 'N/A', + pseudo: !options.addToModlog, + evidence: options.evidence, + hidden: options.silent ?? false + }); + + if (!modlog) return addRoleResponse.MODLOG_ERROR; + caseID = modlog.id; + + if (options.addToModlog || options.duration) { + const punishmentEntrySuccess = await Moderation.createPunishmentEntry({ + client: this.client, + type: 'role', + user: this, + guild: this.guild, + modlog: modlog.id, + duration: options.duration, + extraInfo: options.role.id + }); + if (!punishmentEntrySuccess) return addRoleResponse.PUNISHMENT_ENTRY_ADD_ERROR; + } + } + + const removeRoleSuccess = await this.roles.add(options.role, `${moderator.tag}`); + if (!removeRoleSuccess) return addRoleResponse.ACTION_ERROR; + + return addRoleResponse.SUCCESS; + })(); + if ( + !( + [addRoleResponse.ACTION_ERROR, addRoleResponse.MODLOG_ERROR, addRoleResponse.PUNISHMENT_ENTRY_ADD_ERROR] as const + ).includes(ret) && + options.addToModlog && + !options.silent + ) + this.client.emit( + 'bushPunishRole', + this, + moderator, + this.guild, + options.reason ?? undefined, + caseID!, + options.duration ?? 0, + options.role, + options.evidence + ); + return ret; + } + + /** + * Remove a role from the user, if it is a punishment create a modlog entry, and destroy a punishment entry if it was temporary or a punishment. + * @param options Options for removing a role from the user. + * @returns A status message for removing the role. + * @emits {@link BushClientEvents.bushPunishRoleRemove} + */ + public override async bushRemoveRole(options: RemoveRoleOptions): Promise<RemoveRoleResponse> { + // checks + if (!this.guild.members.me!.permissions.has(PermissionFlagsBits.ManageRoles)) return removeRoleResponse.MISSING_PERMISSIONS; + const ifShouldAddRole = this.#checkIfShouldAddRole(options.role, options.moderator); + if (ifShouldAddRole !== true) return ifShouldAddRole; + + let caseID: string | undefined = undefined; + const moderator = await this.client.utils.resolveNonCachedUser(options.moderator ?? this.guild.members.me); + if (!moderator) return removeRoleResponse.CANNOT_RESOLVE_USER; + + const ret = await (async () => { + if (options.addToModlog) { + const { log: modlog } = await Moderation.createModLogEntry({ + client: this.client, + type: ModLogType.REMOVE_PUNISHMENT_ROLE, + guild: this.guild, + moderator: moderator.id, + user: this, + reason: 'N/A', + evidence: options.evidence, + hidden: options.silent ?? false + }); + + if (!modlog) return removeRoleResponse.MODLOG_ERROR; + caseID = modlog.id; + + const punishmentEntrySuccess = await Moderation.removePunishmentEntry({ + client: this.client, + type: 'role', + user: this, + guild: this.guild, + extraInfo: options.role.id + }); + + if (!punishmentEntrySuccess) return removeRoleResponse.PUNISHMENT_ENTRY_REMOVE_ERROR; + } + + const removeRoleSuccess = await this.roles.remove(options.role, `${moderator.tag}`); + if (!removeRoleSuccess) return removeRoleResponse.ACTION_ERROR; + + return removeRoleResponse.SUCCESS; + })(); + + if ( + !( + [ + removeRoleResponse.ACTION_ERROR, + removeRoleResponse.MODLOG_ERROR, + removeRoleResponse.PUNISHMENT_ENTRY_REMOVE_ERROR + ] as const + ).includes(ret) && + options.addToModlog && + !options.silent + ) + this.client.emit( + 'bushPunishRoleRemove', + this, + moderator, + this.guild, + options.reason ?? undefined, + caseID!, + options.role, + options.evidence + ); + return ret; + } + + /** + * Check whether or not a role should be added/removed from the user based on hierarchy. + * @param role The role to check if can be modified. + * @param moderator The moderator that is trying to add/remove the role. + * @returns `true` if the role should be added/removed or a string for the reason why it shouldn't. + */ + #checkIfShouldAddRole( + role: Role | Role, + moderator?: GuildMember + ): true | 'user hierarchy' | 'role managed' | 'client hierarchy' { + if (moderator && moderator.roles.highest.position <= role.position && this.guild.ownerId !== this.user.id) { + return shouldAddRoleResponse.USER_HIERARCHY; + } else if (role.managed) { + return shouldAddRoleResponse.ROLE_MANAGED; + } else if (this.guild.members.me!.roles.highest.position <= role.position) { + return shouldAddRoleResponse.CLIENT_HIERARCHY; + } + return true; + } + + /** + * Mute the user, create a modlog entry, creates a punishment entry, and dms the user. + * @param options Options for muting the user. + * @returns A status message for muting the user. + * @emits {@link BushClientEvents.bushMute} + */ + public override async bushMute(options: BushTimedPunishmentOptions): Promise<MuteResponse> { + // checks + const checks = await Moderation.checkMutePermissions(this.guild); + if (checks !== true) return checks; + + const muteRoleID = (await this.guild.getSetting('muteRole'))!; + const muteRole = this.guild.roles.cache.get(muteRoleID)!; + + let caseID: string | undefined = undefined; + let dmSuccessEvent: boolean | undefined = undefined; + const moderator = await this.client.utils.resolveNonCachedUser(options.moderator ?? this.guild.members.me); + if (!moderator) return muteResponse.CANNOT_RESOLVE_USER; + + const ret = await (async () => { + // add role + const muteSuccess = await this.roles + .add(muteRole, `[Mute] ${moderator.tag} | ${options.reason ?? 'No reason provided.'}`) + .catch(async (e) => { + await this.client.console.warn('muteRoleAddError', e); + this.client.console.debug(e); + return false; + }); + if (!muteSuccess) return muteResponse.ACTION_ERROR; + + // add modlog entry + const { log: modlog } = await Moderation.createModLogEntry({ + client: this.client, + type: options.duration ? ModLogType.TEMP_MUTE : ModLogType.PERM_MUTE, + user: this, + moderator: moderator.id, + reason: options.reason, + duration: options.duration, + guild: this.guild, + evidence: options.evidence, + hidden: options.silent ?? false + }); + + if (!modlog) return muteResponse.MODLOG_ERROR; + caseID = modlog.id; + + // add punishment entry so they can be unmuted later + const punishmentEntrySuccess = await Moderation.createPunishmentEntry({ + client: this.client, + type: 'mute', + user: this, + guild: this.guild, + duration: options.duration, + modlog: modlog.id + }); + + if (!punishmentEntrySuccess) return muteResponse.PUNISHMENT_ENTRY_ADD_ERROR; + + if (!options.silent) { + // dm user + const dmSuccess = await this.bushPunishDM('muted', options.reason, options.duration ?? 0, modlog.id); + dmSuccessEvent = dmSuccess; + if (!dmSuccess) return muteResponse.DM_ERROR; + } + + return muteResponse.SUCCESS; + })(); + + if ( + !([muteResponse.ACTION_ERROR, muteResponse.MODLOG_ERROR, muteResponse.PUNISHMENT_ENTRY_ADD_ERROR] as const).includes(ret) && + !options.silent + ) + this.client.emit( + 'bushMute', + this, + moderator, + this.guild, + options.reason ?? undefined, + caseID!, + options.duration ?? 0, + dmSuccessEvent!, + options.evidence + ); + return ret; + } + + /** + * Unmute the user, create a modlog entry, remove the punishment entry, and dm the user. + * @param options Options for unmuting the user. + * @returns A status message for unmuting the user. + * @emits {@link BushClientEvents.bushUnmute} + */ + public override async bushUnmute(options: BushPunishmentOptions): Promise<UnmuteResponse> { + // checks + const checks = await Moderation.checkMutePermissions(this.guild); + if (checks !== true) return checks; + + const muteRoleID = (await this.guild.getSetting('muteRole'))!; + const muteRole = this.guild.roles.cache.get(muteRoleID)!; + + let caseID: string | undefined = undefined; + let dmSuccessEvent: boolean | undefined = undefined; + const moderator = await this.client.utils.resolveNonCachedUser(options.moderator ?? this.guild.members.me); + if (!moderator) return unmuteResponse.CANNOT_RESOLVE_USER; + + const ret = await (async () => { + // remove role + const muteSuccess = await this.roles + .remove(muteRole, `[Unmute] ${moderator.tag} | ${options.reason ?? 'No reason provided.'}`) + .catch(async (e) => { + await this.client.console.warn('muteRoleAddError', formatError(e, true)); + return false; + }); + if (!muteSuccess) return unmuteResponse.ACTION_ERROR; + + // add modlog entry + const { log: modlog } = await Moderation.createModLogEntry({ + client: this.client, + type: ModLogType.UNMUTE, + user: this, + moderator: moderator.id, + reason: options.reason, + guild: this.guild, + evidence: options.evidence, + hidden: options.silent ?? false + }); + + if (!modlog) return unmuteResponse.MODLOG_ERROR; + caseID = modlog.id; + + // remove mute entry + const removePunishmentEntrySuccess = await Moderation.removePunishmentEntry({ + client: this.client, + type: 'mute', + user: this, + guild: this.guild + }); + + if (!removePunishmentEntrySuccess) return unmuteResponse.PUNISHMENT_ENTRY_REMOVE_ERROR; + + if (!options.silent) { + // dm user + const dmSuccess = await this.bushPunishDM('unmuted', options.reason, undefined, '', false); + dmSuccessEvent = dmSuccess; + if (!dmSuccess) return unmuteResponse.DM_ERROR; + } + + return unmuteResponse.SUCCESS; + })(); + + if ( + !( + [unmuteResponse.ACTION_ERROR, unmuteResponse.MODLOG_ERROR, unmuteResponse.PUNISHMENT_ENTRY_REMOVE_ERROR] as const + ).includes(ret) && + !options.silent + ) + this.client.emit( + 'bushUnmute', + this, + moderator, + this.guild, + options.reason ?? undefined, + caseID!, + dmSuccessEvent!, + options.evidence + ); + return ret; + } + + /** + * Kick the user, create a modlog entry, and dm the user. + * @param options Options for kicking the user. + * @returns A status message for kicking the user. + * @emits {@link BushClientEvents.bushKick} + */ + public override async bushKick(options: BushPunishmentOptions): Promise<KickResponse> { + // checks + if (!this.guild.members.me?.permissions.has(PermissionFlagsBits.KickMembers) || !this.kickable) + return kickResponse.MISSING_PERMISSIONS; + + let caseID: string | undefined = undefined; + let dmSuccessEvent: boolean | undefined = undefined; + const moderator = await this.client.utils.resolveNonCachedUser(options.moderator ?? this.guild.members.me); + if (!moderator) return kickResponse.CANNOT_RESOLVE_USER; + const ret = await (async () => { + // add modlog entry + const { log: modlog } = await Moderation.createModLogEntry({ + client: this.client, + type: ModLogType.KICK, + user: this, + moderator: moderator.id, + reason: options.reason, + guild: this.guild, + evidence: options.evidence, + hidden: options.silent ?? false + }); + if (!modlog) return kickResponse.MODLOG_ERROR; + caseID = modlog.id; + + // dm user + const dmSuccess = options.silent ? null : await this.bushPunishDM('kicked', options.reason, undefined, modlog.id); + dmSuccessEvent = dmSuccess ?? undefined; + + // kick + const kickSuccess = await this.kick(`${moderator?.tag} | ${options.reason ?? 'No reason provided.'}`).catch(() => false); + if (!kickSuccess) return kickResponse.ACTION_ERROR; + + if (dmSuccess === false) return kickResponse.DM_ERROR; + return kickResponse.SUCCESS; + })(); + if (!([kickResponse.ACTION_ERROR, kickResponse.MODLOG_ERROR] as const).includes(ret) && !options.silent) + this.client.emit( + 'bushKick', + this, + moderator, + this.guild, + options.reason ?? undefined, + caseID!, + dmSuccessEvent!, + options.evidence + ); + return ret; + } + + /** + * Ban the user, create a modlog entry, create a punishment entry, and dm the user. + * @param options Options for banning the user. + * @returns A status message for banning the user. + * @emits {@link BushClientEvents.bushBan} + */ + public override async bushBan(options: BushBanOptions): Promise<Exclude<BanResponse, typeof banResponse['ALREADY_BANNED']>> { + // checks + if (!this.guild.members.me!.permissions.has(PermissionFlagsBits.BanMembers) || !this.bannable) + return banResponse.MISSING_PERMISSIONS; + + let caseID: string | undefined = undefined; + let dmSuccessEvent: boolean | undefined = undefined; + const moderator = await this.client.utils.resolveNonCachedUser(options.moderator ?? this.guild.members.me); + if (!moderator) return banResponse.CANNOT_RESOLVE_USER; + + // ignore result, they should still be banned even if their mute cannot be removed + await this.bushUnmute({ + reason: 'User is about to be banned, a mute is no longer necessary.', + moderator: this.guild.members.me!, + silent: true + }); + + const ret = await (async () => { + // add modlog entry + const { log: modlog } = await Moderation.createModLogEntry({ + client: this.client, + type: options.duration ? ModLogType.TEMP_BAN : ModLogType.PERM_BAN, + user: this, + moderator: moderator.id, + reason: options.reason, + duration: options.duration, + guild: this.guild, + evidence: options.evidence, + hidden: options.silent ?? false + }); + if (!modlog) return banResponse.MODLOG_ERROR; + caseID = modlog.id; + + // dm user + const dmSuccess = options.silent + ? null + : await this.bushPunishDM('banned', options.reason, options.duration ?? 0, modlog.id); + dmSuccessEvent = dmSuccess ?? undefined; + + // ban + const banSuccess = await this.ban({ + reason: `${moderator.tag} | ${options.reason ?? 'No reason provided.'}`, + deleteMessageDays: options.deleteDays + }).catch(() => false); + if (!banSuccess) return banResponse.ACTION_ERROR; + + // add punishment entry so they can be unbanned later + const punishmentEntrySuccess = await Moderation.createPunishmentEntry({ + client: this.client, + type: 'ban', + user: this, + guild: this.guild, + duration: options.duration, + modlog: modlog.id + }); + if (!punishmentEntrySuccess) return banResponse.PUNISHMENT_ENTRY_ADD_ERROR; + + if (!dmSuccess) return banResponse.DM_ERROR; + return banResponse.SUCCESS; + })(); + if ( + !([banResponse.ACTION_ERROR, banResponse.MODLOG_ERROR, banResponse.PUNISHMENT_ENTRY_ADD_ERROR] as const).includes(ret) && + !options.silent + ) + this.client.emit( + 'bushBan', + this, + moderator, + this.guild, + options.reason ?? undefined, + caseID!, + options.duration ?? 0, + dmSuccessEvent!, + options.evidence + ); + return ret; + } + + /** + * Prevents a user from speaking in a channel. + * @param options Options for blocking the user. + */ + public override async bushBlock(options: BlockOptions): Promise<BlockResponse> { + const channel = this.guild.channels.resolve(options.channel); + if (!channel || (!channel.isTextBased() && !channel.isThread())) return blockResponse.INVALID_CHANNEL; + + // checks + if (!channel.permissionsFor(this.guild.members.me!)!.has(PermissionFlagsBits.ManageChannels)) + return blockResponse.MISSING_PERMISSIONS; + + let caseID: string | undefined = undefined; + let dmSuccessEvent: boolean | undefined = undefined; + const moderator = await this.client.utils.resolveNonCachedUser(options.moderator ?? this.guild.members.me); + if (!moderator) return blockResponse.CANNOT_RESOLVE_USER; + + const ret = await (async () => { + // change channel permissions + const channelToUse = channel.isThread() ? channel.parent! : channel; + const perm = channel.isThread() ? { SendMessagesInThreads: false } : { SendMessages: false }; + const blockSuccess = await channelToUse.permissionOverwrites + .edit(this, perm, { reason: `[Block] ${moderator.tag} | ${options.reason ?? 'No reason provided.'}` }) + .catch(() => false); + if (!blockSuccess) return blockResponse.ACTION_ERROR; + + // add modlog entry + const { log: modlog } = await Moderation.createModLogEntry({ + client: this.client, + type: options.duration ? ModLogType.TEMP_CHANNEL_BLOCK : ModLogType.PERM_CHANNEL_BLOCK, + user: this, + moderator: moderator.id, + reason: options.reason, + guild: this.guild, + evidence: options.evidence, + hidden: options.silent ?? false + }); + if (!modlog) return blockResponse.MODLOG_ERROR; + caseID = modlog.id; + + // add punishment entry so they can be unblocked later + const punishmentEntrySuccess = await Moderation.createPunishmentEntry({ + client: this.client, + type: 'block', + user: this, + guild: this.guild, + duration: options.duration, + modlog: modlog.id, + extraInfo: channel.id + }); + if (!punishmentEntrySuccess) return blockResponse.PUNISHMENT_ENTRY_ADD_ERROR; + + // dm user + const dmSuccess = options.silent + ? null + : await Moderation.punishDM({ + client: this.client, + punishment: 'blocked', + reason: options.reason ?? undefined, + duration: options.duration ?? 0, + modlog: modlog.id, + guild: this.guild, + user: this, + sendFooter: true, + channel: channel.id + }); + dmSuccessEvent = !!dmSuccess; + if (!dmSuccess) return blockResponse.DM_ERROR; + + return blockResponse.SUCCESS; + })(); + + if ( + !([blockResponse.ACTION_ERROR, blockResponse.MODLOG_ERROR, blockResponse.PUNISHMENT_ENTRY_ADD_ERROR] as const).includes( + ret + ) && + !options.silent + ) + this.client.emit( + 'bushBlock', + this, + moderator, + this.guild, + options.reason ?? undefined, + caseID!, + options.duration ?? 0, + dmSuccessEvent!, + channel, + options.evidence + ); + return ret; + } + + /** + * Allows a user to speak in a channel. + * @param options Options for unblocking the user. + */ + public override async bushUnblock(options: UnblockOptions): Promise<UnblockResponse> { + const _channel = this.guild.channels.resolve(options.channel); + if (!_channel || (_channel.type !== ChannelType.GuildText && !_channel.isThread())) return unblockResponse.INVALID_CHANNEL; + const channel = _channel as GuildTextBasedChannel; + + // checks + if (!channel.permissionsFor(this.guild.members.me!)!.has(PermissionFlagsBits.ManageChannels)) + return unblockResponse.MISSING_PERMISSIONS; + + let caseID: string | undefined = undefined; + let dmSuccessEvent: boolean | undefined = undefined; + const moderator = await this.client.utils.resolveNonCachedUser(options.moderator ?? this.guild.members.me); + if (!moderator) return unblockResponse.CANNOT_RESOLVE_USER; + + const ret = await (async () => { + // change channel permissions + const channelToUse = channel.isThread() ? channel.parent! : channel; + const perm = channel.isThread() ? { SendMessagesInThreads: null } : { SendMessages: null }; + const blockSuccess = await channelToUse.permissionOverwrites + .edit(this, perm, { reason: `[Unblock] ${moderator.tag} | ${options.reason ?? 'No reason provided.'}` }) + .catch(() => false); + if (!blockSuccess) return unblockResponse.ACTION_ERROR; + + // add modlog entry + const { log: modlog } = await Moderation.createModLogEntry({ + client: this.client, + type: ModLogType.CHANNEL_UNBLOCK, + user: this, + moderator: moderator.id, + reason: options.reason, + guild: this.guild, + evidence: options.evidence, + hidden: options.silent ?? false + }); + if (!modlog) return unblockResponse.MODLOG_ERROR; + caseID = modlog.id; + + // remove punishment entry + const punishmentEntrySuccess = await Moderation.removePunishmentEntry({ + client: this.client, + type: 'block', + user: this, + guild: this.guild, + extraInfo: channel.id + }); + if (!punishmentEntrySuccess) return unblockResponse.ACTION_ERROR; + + // dm user + const dmSuccess = options.silent + ? null + : await Moderation.punishDM({ + client: this.client, + punishment: 'unblocked', + reason: options.reason ?? undefined, + guild: this.guild, + user: this, + sendFooter: false, + channel: channel.id + }); + dmSuccessEvent = !!dmSuccess; + if (!dmSuccess) return blockResponse.DM_ERROR; + + dmSuccessEvent = !!dmSuccess; + if (!dmSuccess) return unblockResponse.DM_ERROR; + + return unblockResponse.SUCCESS; + })(); + + if ( + !([unblockResponse.ACTION_ERROR, unblockResponse.MODLOG_ERROR, unblockResponse.ACTION_ERROR] as const).includes(ret) && + !options.silent + ) + this.client.emit( + 'bushUnblock', + this, + moderator, + this.guild, + options.reason ?? undefined, + caseID!, + dmSuccessEvent!, + channel, + options.evidence + ); + return ret; + } + + /** + * Mutes a user using discord's timeout feature. + * @param options Options for timing out the user. + */ + public override async bushTimeout(options: BushTimeoutOptions): Promise<TimeoutResponse> { + // checks + if (!this.guild.members.me!.permissions.has(PermissionFlagsBits.ModerateMembers)) return timeoutResponse.MISSING_PERMISSIONS; + + const twentyEightDays = Time.Day * 28; + if (options.duration > twentyEightDays) return timeoutResponse.INVALID_DURATION; + + let caseID: string | undefined = undefined; + let dmSuccessEvent: boolean | undefined = undefined; + const moderator = await this.client.utils.resolveNonCachedUser(options.moderator ?? this.guild.members.me); + if (!moderator) return timeoutResponse.CANNOT_RESOLVE_USER; + + const ret = await (async () => { + // timeout + const timeoutSuccess = await this.timeout( + options.duration, + `${moderator.tag} | ${options.reason ?? 'No reason provided.'}` + ).catch(() => false); + if (!timeoutSuccess) return timeoutResponse.ACTION_ERROR; + + // add modlog entry + const { log: modlog } = await Moderation.createModLogEntry({ + client: this.client, + type: ModLogType.TIMEOUT, + user: this, + moderator: moderator.id, + reason: options.reason, + duration: options.duration, + guild: this.guild, + evidence: options.evidence, + hidden: options.silent ?? false + }); + + if (!modlog) return timeoutResponse.MODLOG_ERROR; + caseID = modlog.id; + + if (!options.silent) { + // dm user + const dmSuccess = await this.bushPunishDM('timedout', options.reason, options.duration, modlog.id); + dmSuccessEvent = dmSuccess; + if (!dmSuccess) return timeoutResponse.DM_ERROR; + } + + return timeoutResponse.SUCCESS; + })(); + + if (!([timeoutResponse.ACTION_ERROR, timeoutResponse.MODLOG_ERROR] as const).includes(ret) && !options.silent) + this.client.emit( + 'bushTimeout', + this, + moderator, + this.guild, + options.reason ?? undefined, + caseID!, + options.duration ?? 0, + dmSuccessEvent!, + options.evidence + ); + return ret; + } + + /** + * Removes a timeout from a user. + * @param options Options for removing the timeout. + */ + public override async bushRemoveTimeout(options: BushPunishmentOptions): Promise<RemoveTimeoutResponse> { + // checks + if (!this.guild.members.me!.permissions.has(PermissionFlagsBits.ModerateMembers)) + return removeTimeoutResponse.MISSING_PERMISSIONS; + + let caseID: string | undefined = undefined; + let dmSuccessEvent: boolean | undefined = undefined; + const moderator = await this.client.utils.resolveNonCachedUser(options.moderator ?? this.guild.members.me); + if (!moderator) return removeTimeoutResponse.CANNOT_RESOLVE_USER; + + const ret = await (async () => { + // remove timeout + const timeoutSuccess = await this.timeout(null, `${moderator.tag} | ${options.reason ?? 'No reason provided.'}`).catch( + () => false + ); + if (!timeoutSuccess) return removeTimeoutResponse.ACTION_ERROR; + + // add modlog entry + const { log: modlog } = await Moderation.createModLogEntry({ + client: this.client, + type: ModLogType.REMOVE_TIMEOUT, + user: this, + moderator: moderator.id, + reason: options.reason, + guild: this.guild, + evidence: options.evidence, + hidden: options.silent ?? false + }); + + if (!modlog) return removeTimeoutResponse.MODLOG_ERROR; + caseID = modlog.id; + + if (!options.silent) { + // dm user + const dmSuccess = await this.bushPunishDM('untimedout', options.reason, undefined, '', false); + dmSuccessEvent = dmSuccess; + if (!dmSuccess) return removeTimeoutResponse.DM_ERROR; + } + + return removeTimeoutResponse.SUCCESS; + })(); + + if (!([removeTimeoutResponse.ACTION_ERROR, removeTimeoutResponse.MODLOG_ERROR] as const).includes(ret) && !options.silent) + this.client.emit( + 'bushRemoveTimeout', + this, + moderator, + this.guild, + options.reason ?? undefined, + caseID!, + dmSuccessEvent!, + options.evidence + ); + return ret; + } + + /** + * Whether or not the user is an owner of the bot. + */ + public override isOwner(): boolean { + return this.client.isOwner(this); + } + + /** + * Whether or not the user is a super user of the bot. + */ + public override isSuperUser(): boolean { + return this.client.isSuperUser(this); + } +} + +/** + * Options for punishing a user. + */ +export interface BushPunishmentOptions { + /** + * The reason for the punishment. + */ + reason?: string | null; + + /** + * The moderator who punished the user. + */ + moderator?: GuildMember; + + /** + * Evidence for the punishment. + */ + evidence?: string; + + /** + * Makes the punishment silent by not sending the user a punishment dm and not broadcasting the event to be logged. + */ + silent?: boolean; +} + +/** + * Punishment options for punishments that can be temporary. + */ +export interface BushTimedPunishmentOptions extends BushPunishmentOptions { + /** + * The duration of the punishment. + */ + duration?: number; +} + +/** + * Options for a role add punishment. + */ +export interface AddRoleOptions extends BushTimedPunishmentOptions { + /** + * The role to add to the user. + */ + role: Role; + + /** + * Whether to create a modlog entry for this punishment. + */ + addToModlog: boolean; +} + +/** + * Options for a role remove punishment. + */ +export interface RemoveRoleOptions extends BushTimedPunishmentOptions { + /** + * The role to remove from the user. + */ + role: Role; + + /** + * Whether to create a modlog entry for this punishment. + */ + addToModlog: boolean; +} + +/** + * Options for banning a user. + */ +export interface BushBanOptions extends BushTimedPunishmentOptions { + /** + * The number of days to delete the user's messages for. + */ + deleteDays?: number; +} + +/** + * Options for blocking a user from a channel. + */ +export interface BlockOptions extends BushTimedPunishmentOptions { + /** + * The channel to block the user from. + */ + channel: GuildChannelResolvable; +} + +/** + * Options for unblocking a user from a channel. + */ +export interface UnblockOptions extends BushPunishmentOptions { + /** + * The channel to unblock the user from. + */ + channel: GuildChannelResolvable; +} + +/** + * Punishment options for punishments that can be temporary. + */ +export interface BushTimeoutOptions extends BushPunishmentOptions { + /** + * The duration of the punishment. + */ + duration: number; +} + +export const basePunishmentResponse = Object.freeze({ + SUCCESS: 'success', + MODLOG_ERROR: 'error creating modlog entry', + ACTION_ERROR: 'error performing action', + CANNOT_RESOLVE_USER: 'cannot resolve user' +} as const); + +export const dmResponse = Object.freeze({ + ...basePunishmentResponse, + DM_ERROR: 'failed to dm' +} as const); + +export const permissionsResponse = Object.freeze({ + MISSING_PERMISSIONS: 'missing permissions' +} as const); + +export const punishmentEntryAdd = Object.freeze({ + PUNISHMENT_ENTRY_ADD_ERROR: 'error creating punishment entry' +} as const); + +export const punishmentEntryRemove = Object.freeze({ + PUNISHMENT_ENTRY_REMOVE_ERROR: 'error removing punishment entry' +} as const); + +export const shouldAddRoleResponse = Object.freeze({ + USER_HIERARCHY: 'user hierarchy', + CLIENT_HIERARCHY: 'client hierarchy', + ROLE_MANAGED: 'role managed' +} as const); + +export const baseBlockResponse = Object.freeze({ + INVALID_CHANNEL: 'invalid channel' +} as const); + +export const baseMuteResponse = Object.freeze({ + NO_MUTE_ROLE: 'no mute role', + MUTE_ROLE_INVALID: 'invalid mute role', + MUTE_ROLE_NOT_MANAGEABLE: 'mute role not manageable' +} as const); + +export const warnResponse = Object.freeze({ + ...dmResponse +} as const); + +export const addRoleResponse = Object.freeze({ + ...basePunishmentResponse, + ...permissionsResponse, + ...shouldAddRoleResponse, + ...punishmentEntryAdd +} as const); + +export const removeRoleResponse = Object.freeze({ + ...basePunishmentResponse, + ...permissionsResponse, + ...shouldAddRoleResponse, + ...punishmentEntryRemove +} as const); + +export const muteResponse = Object.freeze({ + ...dmResponse, + ...permissionsResponse, + ...baseMuteResponse, + ...punishmentEntryAdd +} as const); + +export const unmuteResponse = Object.freeze({ + ...dmResponse, + ...permissionsResponse, + ...baseMuteResponse, + ...punishmentEntryRemove +} as const); + +export const kickResponse = Object.freeze({ + ...dmResponse, + ...permissionsResponse +} as const); + +export const banResponse = Object.freeze({ + ...dmResponse, + ...permissionsResponse, + ...punishmentEntryAdd, + ALREADY_BANNED: 'already banned' +} as const); + +export const blockResponse = Object.freeze({ + ...dmResponse, + ...permissionsResponse, + ...baseBlockResponse, + ...punishmentEntryAdd +}); + +export const unblockResponse = Object.freeze({ + ...dmResponse, + ...permissionsResponse, + ...baseBlockResponse, + ...punishmentEntryRemove +}); + +export const timeoutResponse = Object.freeze({ + ...dmResponse, + ...permissionsResponse, + INVALID_DURATION: 'duration too long' +} as const); + +export const removeTimeoutResponse = Object.freeze({ + ...dmResponse, + ...permissionsResponse +} as const); + +/** + * Response returned when warning a user. + */ +export type WarnResponse = ValueOf<typeof warnResponse>; + +/** + * Response returned when adding a role to a user. + */ +export type AddRoleResponse = ValueOf<typeof addRoleResponse>; + +/** + * Response returned when removing a role from a user. + */ +export type RemoveRoleResponse = ValueOf<typeof removeRoleResponse>; + +/** + * Response returned when muting a user. + */ +export type MuteResponse = ValueOf<typeof muteResponse>; + +/** + * Response returned when unmuting a user. + */ +export type UnmuteResponse = ValueOf<typeof unmuteResponse>; + +/** + * Response returned when kicking a user. + */ +export type KickResponse = ValueOf<typeof kickResponse>; + +/** + * Response returned when banning a user. + */ +export type BanResponse = ValueOf<typeof banResponse>; + +/** + * Response returned when blocking a user. + */ +export type BlockResponse = ValueOf<typeof blockResponse>; + +/** + * Response returned when unblocking a user. + */ +export type UnblockResponse = ValueOf<typeof unblockResponse>; + +/** + * Response returned when timing out a user. + */ +export type TimeoutResponse = ValueOf<typeof timeoutResponse>; + +/** + * Response returned when removing a timeout from a user. + */ +export type RemoveTimeoutResponse = ValueOf<typeof removeTimeoutResponse>; + +/** + * @typedef {BushClientEvents} VSCodePleaseDontRemove + */ diff --git a/lib/extensions/discord.js/ExtendedMessage.ts b/lib/extensions/discord.js/ExtendedMessage.ts new file mode 100644 index 0000000..1bb0904 --- /dev/null +++ b/lib/extensions/discord.js/ExtendedMessage.ts @@ -0,0 +1,12 @@ +import { CommandUtil } from 'discord-akairo'; +import { Message, type Client } from 'discord.js'; +import type { RawMessageData } from 'discord.js/typings/rawDataTypes.js'; + +export class ExtendedMessage<Cached extends boolean = boolean> extends Message<Cached> { + public declare util: CommandUtil<Message>; + + public constructor(client: Client, data: RawMessageData) { + super(client, data); + this.util = new CommandUtil(client.commandHandler, this); + } +} diff --git a/lib/extensions/discord.js/ExtendedUser.ts b/lib/extensions/discord.js/ExtendedUser.ts new file mode 100644 index 0000000..23de523 --- /dev/null +++ b/lib/extensions/discord.js/ExtendedUser.ts @@ -0,0 +1,35 @@ +import { User, type Partialize } from 'discord.js'; + +declare module 'discord.js' { + export interface User { + /** + * Indicates whether the user is an owner of the bot. + */ + isOwner(): boolean; + /** + * Indicates whether the user is a superuser of the bot. + */ + isSuperUser(): boolean; + } +} + +export type PartialBushUser = Partialize<ExtendedUser, 'username' | 'tag' | 'discriminator' | 'isOwner' | 'isSuperUser'>; + +/** + * Represents a user on Discord. + */ +export class ExtendedUser extends User { + /** + * Indicates whether the user is an owner of the bot. + */ + public override isOwner(): boolean { + return this.client.isOwner(this); + } + + /** + * Indicates whether the user is a superuser of the bot. + */ + public override isSuperUser(): boolean { + return this.client.isSuperUser(this); + } +} diff --git a/lib/extensions/global.ts b/lib/extensions/global.ts new file mode 100644 index 0000000..a9020d7 --- /dev/null +++ b/lib/extensions/global.ts @@ -0,0 +1,13 @@ +/* eslint-disable no-var */ +declare global { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface ReadonlyArray<T> { + includes<S, R extends `${Extract<S, string>}`>( + this: ReadonlyArray<R>, + searchElement: S, + fromIndex?: number + ): searchElement is R & S; + } +} + +export {}; diff --git a/lib/index.ts b/lib/index.ts new file mode 100644 index 0000000..5a8ecde --- /dev/null +++ b/lib/index.ts @@ -0,0 +1,56 @@ +export * from './automod/AutomodShared.js'; +export * from './automod/MemberAutomod.js'; +export * from './automod/MessageAutomod.js'; +export * from './automod/PresenceAutomod.js'; +export * from './common/BushCache.js'; +export * from './common/ButtonPaginator.js'; +export * from './common/CanvasProgressBar.js'; +export * from './common/ConfirmationPrompt.js'; +export * from './common/DeleteButton.js'; +export * as Moderation from './common/Moderation.js'; +export type { + AppealButtonId, + CreateModLogEntryOptions, + CreatePunishmentEntryOptions, + PunishDMOptions, + PunishmentTypeDM, + PunishmentTypePresent, + RemovePunishmentEntryOptions, + SimpleCreateModLogEntryOptions +} from './common/Moderation.js'; +export * from './extensions/discord-akairo/BushArgumentTypeCaster.js'; +export * from './extensions/discord-akairo/BushClient.js'; +export * from './extensions/discord-akairo/BushCommand.js'; +export * from './extensions/discord-akairo/BushCommandHandler.js'; +export * from './extensions/discord-akairo/BushInhibitor.js'; +export * from './extensions/discord-akairo/BushInhibitorHandler.js'; +export * from './extensions/discord-akairo/BushListener.js'; +export * from './extensions/discord-akairo/BushListenerHandler.js'; +export * from './extensions/discord-akairo/BushTask.js'; +export * from './extensions/discord-akairo/BushTaskHandler.js'; +export * from './extensions/discord-akairo/SlashMessage.js'; +export type { BushClientEvents } from './extensions/discord.js/BushClientEvents.js'; +export * from './extensions/discord.js/ExtendedGuild.js'; +export * from './extensions/discord.js/ExtendedGuildMember.js'; +export * from './extensions/discord.js/ExtendedMessage.js'; +export * from './extensions/discord.js/ExtendedUser.js'; +export * from './models/BaseModel.js'; +export * from './models/instance/ActivePunishment.js'; +export * from './models/instance/Guild.js'; +export * from './models/instance/Highlight.js'; +export * from './models/instance/Level.js'; +export * from './models/instance/ModLog.js'; +export * from './models/instance/Reminder.js'; +export * from './models/instance/StickyRole.js'; +export * from './models/shared/Global.js'; +export * from './models/shared/MemberCount.js'; +export * from './models/shared/Shared.js'; +export * from './models/shared/Stat.js'; +export type { BushInspectOptions } from './types/BushInspectOptions.js'; +export type { CodeBlockLang } from './types/CodeBlockLang.js'; +export * from './utils/AllowedMentions.js'; +export * as Arg from './utils/Arg.js'; +export * from './utils/BushConstants.js'; +export * from './utils/BushLogger.js'; +export * from './utils/BushUtils.js'; +export * as Format from './utils/Format.js'; diff --git a/lib/models/BaseModel.ts b/lib/models/BaseModel.ts new file mode 100644 index 0000000..8fba5e5 --- /dev/null +++ b/lib/models/BaseModel.ts @@ -0,0 +1,13 @@ +import { Model } from 'sequelize'; + +export abstract class BaseModel<A, B> extends Model<A, B> { + /** + * The date when the row was created. + */ + public declare readonly createdAt: Date; + + /** + * The date when the row was last updated. + */ + public declare readonly updatedAt: Date; +} diff --git a/lib/models/instance/ActivePunishment.ts b/lib/models/instance/ActivePunishment.ts new file mode 100644 index 0000000..38012ca --- /dev/null +++ b/lib/models/instance/ActivePunishment.ts @@ -0,0 +1,94 @@ +import { type Snowflake } from 'discord.js'; +import { nanoid } from 'nanoid'; +import { type Sequelize } from 'sequelize'; +import { BaseModel } from '../BaseModel.js'; +const { DataTypes } = (await import('sequelize')).default; + +export enum ActivePunishmentType { + BAN = 'BAN', + MUTE = 'MUTE', + ROLE = 'ROLE', + BLOCK = 'BLOCK' +} + +export interface ActivePunishmentModel { + id: string; + type: ActivePunishmentType; + user: Snowflake; + guild: Snowflake; + extraInfo: Snowflake; + expires: Date | null; + modlog: string; +} + +export interface ActivePunishmentModelCreationAttributes { + id?: string; + type: ActivePunishmentType; + user: Snowflake; + guild: Snowflake; + extraInfo?: Snowflake; + expires?: Date; + modlog: string; +} + +/** + * Keeps track of active punishments so they can be removed later. + */ +export class ActivePunishment + extends BaseModel<ActivePunishmentModel, ActivePunishmentModelCreationAttributes> + implements ActivePunishmentModel +{ + /** + * The ID of this punishment (no real use just for a primary key) + */ + public declare id: string; + + /** + * The type of punishment. + */ + public declare type: ActivePunishmentType; + + /** + * The user who is punished. + */ + public declare user: Snowflake; + + /** + * The guild they are punished in. + */ + public declare guild: Snowflake; + + /** + * Additional info about the punishment if applicable. The channel id for channel blocks and role for punishment roles. + */ + public declare extraInfo: Snowflake; + + /** + * The date when this punishment expires (optional). + */ + public declare expires: Date | null; + + /** + * The reference to the modlog entry. + */ + public declare modlog: string; + + /** + * Initializes the model. + * @param sequelize The sequelize instance. + */ + public static initModel(sequelize: Sequelize): void { + ActivePunishment.init( + { + id: { type: DataTypes.STRING, primaryKey: true, defaultValue: nanoid }, + type: { type: DataTypes.STRING, allowNull: false }, + user: { type: DataTypes.STRING, allowNull: false }, + guild: { type: DataTypes.STRING, allowNull: false, references: { model: 'Guilds', key: 'id' } }, + extraInfo: { type: DataTypes.STRING, allowNull: true }, + expires: { type: DataTypes.DATE, allowNull: true }, + modlog: { type: DataTypes.STRING, allowNull: true, references: { model: 'ModLogs', key: 'id' } } + }, + { sequelize } + ); + } +} diff --git a/lib/models/instance/Guild.ts b/lib/models/instance/Guild.ts new file mode 100644 index 0000000..f258d48 --- /dev/null +++ b/lib/models/instance/Guild.ts @@ -0,0 +1,431 @@ +import { ChannelType, Constants, type Snowflake } from 'discord.js'; +import { type Sequelize } from 'sequelize'; +import { BadWordDetails } from '../../automod/AutomodShared.js'; +import { type BushClient } from '../../extensions/discord-akairo/BushClient.js'; +import { BaseModel } from '../BaseModel.js'; +const { DataTypes } = (await import('sequelize')).default; + +export interface GuildModel { + id: Snowflake; + prefix: string; + autoPublishChannels: Snowflake[]; + blacklistedChannels: Snowflake[]; + blacklistedUsers: Snowflake[]; + welcomeChannel: Snowflake | null; + muteRole: Snowflake | null; + punishmentEnding: string | null; + disabledCommands: string[]; + lockdownChannels: Snowflake[]; + autoModPhases: BadWordDetails[]; + enabledFeatures: GuildFeatures[]; + joinRoles: Snowflake[]; + logChannels: LogChannelDB; + bypassChannelBlacklist: Snowflake[]; + noXpChannels: Snowflake[]; + levelRoles: { [level: number]: Snowflake }; + levelUpChannel: Snowflake | null; +} + +export interface GuildModelCreationAttributes { + id: Snowflake; + prefix?: string; + autoPublishChannels?: Snowflake[]; + blacklistedChannels?: Snowflake[]; + blacklistedUsers?: Snowflake[]; + welcomeChannel?: Snowflake; + muteRole?: Snowflake; + punishmentEnding?: string; + disabledCommands?: string[]; + lockdownChannels?: Snowflake[]; + autoModPhases?: BadWordDetails[]; + enabledFeatures?: GuildFeatures[]; + joinRoles?: Snowflake[]; + logChannels?: LogChannelDB; + bypassChannelBlacklist?: Snowflake[]; + noXpChannels?: Snowflake[]; + levelRoles?: { [level: number]: Snowflake }; + levelUpChannel?: Snowflake; +} + +/** + * Settings for a guild. + */ +export class Guild extends BaseModel<GuildModel, GuildModelCreationAttributes> implements GuildModel { + /** + * The ID of the guild + */ + public declare id: Snowflake; + + /** + * The bot's prefix for the guild + */ + public declare prefix: string; + + /** + * Channels that will have their messages automatically published + */ + public declare autoPublishChannels: Snowflake[]; + + /** + * Channels where the bot won't respond in. + */ + public declare blacklistedChannels: Snowflake[]; + + /** + * Users that the bot ignores in this guild + */ + public declare blacklistedUsers: Snowflake[]; + + /** + * The channels where the welcome messages are sent + */ + public declare welcomeChannel: Snowflake | null; + + /** + * The role given out when muting someone + */ + public declare muteRole: Snowflake | null; + + /** + * The message that gets sent after someone gets a punishment dm + */ + public declare punishmentEnding: string | null; + + /** + * Guild specific disabled commands + */ + public declare disabledCommands: string[]; + + /** + * Channels that should get locked down when the lockdown command gets used. + */ + public declare lockdownChannels: Snowflake[]; + + /** + * Custom automod phases + */ + public declare autoModPhases: BadWordDetails[]; + + /** + * The features enabled in a guild + */ + public declare enabledFeatures: GuildFeatures[]; + + /** + * The roles to assign to a user if they are not assigned sticky roles + */ + public declare joinRoles: Snowflake[]; + + /** + * The channels where logging messages will be sent. + */ + public declare logChannels: LogChannelDB; + + /** + * These users will be able to use commands in channels blacklisted + */ + public declare bypassChannelBlacklist: Snowflake[]; + + /** + * Channels where users will not earn xp for leveling. + */ + public declare noXpChannels: Snowflake[]; + + /** + * What roles get given to users when they reach certain levels. + */ + public declare levelRoles: { [level: number]: Snowflake }; + + /** + * The channel to send level up messages in instead of last channel. + */ + public declare levelUpChannel: Snowflake | null; + + /** + * Initializes the model. + * @param sequelize The sequelize instance. + */ + public static initModel(sequelize: Sequelize, client: BushClient): void { + Guild.init( + { + id: { type: DataTypes.STRING, primaryKey: true }, + prefix: { type: DataTypes.TEXT, allowNull: false, defaultValue: client.config.prefix }, + autoPublishChannels: { type: DataTypes.JSONB, allowNull: false, defaultValue: [] }, + blacklistedChannels: { type: DataTypes.JSONB, allowNull: false, defaultValue: [] }, + blacklistedUsers: { type: DataTypes.JSONB, allowNull: false, defaultValue: [] }, + welcomeChannel: { type: DataTypes.STRING, allowNull: true }, + muteRole: { type: DataTypes.STRING, allowNull: true }, + punishmentEnding: { type: DataTypes.TEXT, allowNull: true }, + disabledCommands: { type: DataTypes.JSONB, allowNull: false, defaultValue: [] }, + lockdownChannels: { type: DataTypes.JSONB, allowNull: false, defaultValue: [] }, + autoModPhases: { type: DataTypes.JSONB, allowNull: false, defaultValue: [] }, + enabledFeatures: { + type: DataTypes.JSONB, + allowNull: false, + defaultValue: Object.keys(guildFeaturesObj).filter( + (key) => guildFeaturesObj[key as keyof typeof guildFeaturesObj].default + ) + }, + joinRoles: { type: DataTypes.JSONB, allowNull: false, defaultValue: [] }, + logChannels: { type: DataTypes.JSONB, allowNull: false, defaultValue: {} }, + bypassChannelBlacklist: { type: DataTypes.JSONB, allowNull: false, defaultValue: [] }, + noXpChannels: { type: DataTypes.JSONB, allowNull: false, defaultValue: [] }, + levelRoles: { type: DataTypes.JSONB, allowNull: false, defaultValue: {} }, + levelUpChannel: { type: DataTypes.STRING, allowNull: true } + }, + { sequelize } + ); + } +} + +export type BaseGuildSetting = 'channel' | 'role' | 'user'; +export type GuildNoArraySetting = 'string' | 'custom' | BaseGuildSetting; +export type GuildSettingType = GuildNoArraySetting | `${BaseGuildSetting}-array`; + +export interface GuildSetting { + name: string; + description: string; + type: GuildSettingType; + subType: ChannelType[] | undefined; + configurable: boolean; + replaceNullWith: () => string | null; +} +const asGuildSetting = <T>(et: { [K in keyof T]: PartialBy<GuildSetting, 'configurable' | 'subType' | 'replaceNullWith'> }) => { + for (const key in et) { + et[key].subType ??= undefined; + et[key].configurable ??= true; + et[key].replaceNullWith ??= () => null; + } + return et as { [K in keyof T]: GuildSetting }; +}; + +const { default: config } = await import('../../../config/options.js'); + +export const guildSettingsObj = asGuildSetting({ + prefix: { + name: 'Prefix', + description: 'The phrase required to trigger text commands in this server.', + type: 'string', + replaceNullWith: () => config.prefix + }, + autoPublishChannels: { + name: 'Auto Publish Channels', + description: 'Channels were every message is automatically published.', + type: 'channel-array', + subType: [ChannelType.GuildNews] + }, + welcomeChannel: { + name: 'Welcome Channel', + description: 'The channel where the bot will send join and leave message.', + type: 'channel', + subType: [ + ChannelType.GuildText, + ChannelType.GuildNews, + ChannelType.GuildNewsThread, + ChannelType.GuildPublicThread, + ChannelType.GuildPrivateThread + ] + }, + muteRole: { + name: 'Mute Role', + description: 'The role assigned when muting someone.', + type: 'role' + }, + punishmentEnding: { + name: 'Punishment Ending', + description: 'The message after punishment information to a user in a dm.', + type: 'string' + }, + lockdownChannels: { + name: 'Lockdown Channels', + description: 'Channels that are locked down when a mass lockdown is specified.', + type: 'channel-array', + subType: [ChannelType.GuildText] + }, + joinRoles: { + name: 'Join Roles', + description: 'Roles assigned to users on join who do not have sticky role information.', + type: 'role-array' + }, + bypassChannelBlacklist: { + name: 'Bypass Channel Blacklist', + description: 'These users will be able to use commands in channels blacklisted.', + type: 'user-array' + }, + logChannels: { + name: 'Log Channels', + description: 'The channel were logs are sent.', + type: 'custom', + subType: [ChannelType.GuildText], + configurable: false + }, + autoModPhases: { + name: 'Automod Phases', + description: 'Custom phrases to be detected by automod.', + type: 'custom', + configurable: false + }, + noXpChannels: { + name: 'No Xp Channels', + description: 'Channels where users will not earn xp for leveling.', + type: 'channel-array', + subType: Constants.TextBasedChannelTypes.filter((type) => type !== ChannelType.DM) + }, + levelRoles: { + name: 'Level Roles', + description: 'What roles get given to users when they reach certain levels.', + type: 'custom', + configurable: false + }, + levelUpChannel: { + name: 'Level Up Channel', + description: 'The channel to send level up messages in instead of last channel.', + type: 'channel', + subType: Constants.TextBasedChannelTypes.filter((type) => type !== ChannelType.DM) + } +}); + +export type GuildSettings = keyof typeof guildSettingsObj; +export const settingsArr = Object.keys(guildSettingsObj).filter( + (s) => guildSettingsObj[s as GuildSettings].configurable +) as GuildSettings[]; + +interface GuildFeature { + name: string; + description: string; + default: boolean; + hidden: boolean; +} + +type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>; + +const asGuildFeature = <T>(gf: { [K in keyof T]: PartialBy<GuildFeature, 'hidden' | 'default'> }): { + [K in keyof T]: GuildFeature; +} => { + for (const key in gf) { + gf[key].hidden ??= false; + gf[key].default ??= false; + } + return gf as { [K in keyof T]: GuildFeature }; +}; + +export const guildFeaturesObj = asGuildFeature({ + automod: { + name: 'Automod', + description: 'Deletes offensive content as well as phishing links.' + }, + excludeDefaultAutomod: { + name: 'Exclude Default Automod', + description: 'Opt out of using the default automod options.' + }, + excludeAutomodScamLinks: { + name: 'Exclude Automod Scam Links', + description: 'Opt out of having automod delete scam links.' + }, + delScamMentions: { + name: 'Delete Scam Mentions', + description: 'Deletes messages with @everyone and @here mentions that have common scam phrases.' + }, + automodPresence: { + name: 'Automod Presence', + description: 'Logs presence changes that trigger automod.', + hidden: true + }, + automodMembers: { + name: 'Automod Members', + description: "Logs members' usernames and nicknames changes if they match automod." + }, + blacklistedFile: { + name: 'Blacklisted File', + description: 'Automatically deletes malicious files.' + }, + autoPublish: { + name: 'Auto Publish', + description: 'Publishes messages in configured announcement channels.' + }, + // todo implement a better auto thread system + autoThread: { + name: 'Auto Thread', + description: 'Creates a new thread for messages in configured channels.', + hidden: true + }, + perspectiveApi: { + name: 'Perspective API', + description: 'Use the Perspective API to detect toxicity.', + hidden: true + }, + boosterMessageReact: { + name: 'Booster Message React', + description: 'Reacts to booster messages with the boost emoji.' + }, + leveling: { + name: 'Leveling', + description: "Tracks users' messages and assigns them xp." + }, + sendLevelUpMessages: { + name: 'Send Level Up Messages', + description: 'Send a message when a user levels up.', + default: true + }, + stickyRoles: { + name: 'Sticky Roles', + description: 'Restores past roles to a user when they rejoin.' + }, + reporting: { + name: 'Reporting', + description: 'Allow users to make reports.' + }, + modsCanPunishMods: { + name: 'Mods Can Punish Mods', + description: 'Allow moderators to punish other moderators.' + }, + logManualPunishments: { + name: 'Log Manual Punishments', + description: "Adds manual punishment to the user's modlogs and the logging channels.", + default: true + }, + punishmentAppeals: { + name: 'Punishment Appeals', + description: 'Allow users to appeal their punishments and send the appeal to the configured channel.', + hidden: true + }, + highlight: { + name: 'Highlight', + description: 'Allows the highlight command to be used.', + default: true + } +}); + +export const guildLogsObj = { + automod: { + description: 'Sends a message in this channel every time automod is activated.', + configurable: true + }, + moderation: { + description: 'Sends a message in this channel every time a moderation action is performed.', + configurable: true + }, + report: { + description: 'Logs user reports.', + configurable: true + }, + error: { + description: 'Logs errors that occur with the bot.', + configurable: true + }, + appeals: { + description: 'Where punishment appeals are sent.', + configurable: false + } +}; + +export type GuildLogType = keyof typeof guildLogsObj; +export const guildLogsArr = Object.keys(guildLogsObj).filter( + (s) => guildLogsObj[s as GuildLogType].configurable +) as GuildLogType[]; +type LogChannelDB = { [x in keyof typeof guildLogsObj]?: Snowflake }; + +export type GuildFeatures = keyof typeof guildFeaturesObj; +export const guildFeaturesArr: GuildFeatures[] = Object.keys(guildFeaturesObj).filter( + (f) => !guildFeaturesObj[f as keyof typeof guildFeaturesObj].hidden +) as GuildFeatures[]; diff --git a/lib/models/instance/Highlight.ts b/lib/models/instance/Highlight.ts new file mode 100644 index 0000000..5889fad --- /dev/null +++ b/lib/models/instance/Highlight.ts @@ -0,0 +1,81 @@ +import { type Snowflake } from 'discord.js'; +import { nanoid } from 'nanoid'; +import { type Sequelize } from 'sequelize'; +import { BaseModel } from '../BaseModel.js'; +const { DataTypes } = (await import('sequelize')).default; + +export interface HighlightModel { + pk: string; + user: Snowflake; + guild: Snowflake; + words: HighlightWord[]; + blacklistedChannels: Snowflake[]; + blacklistedUsers: Snowflake[]; +} + +export interface HighLightCreationAttributes { + pk?: string; + user: Snowflake; + guild: Snowflake; + words?: HighlightWord[]; + blacklistedChannels?: Snowflake[]; + blacklistedUsers?: Snowflake[]; +} + +export interface HighlightWord { + word: string; + regex: boolean; +} + +/** + * List of words that should cause the user to be notified for if found in the specified guild. + */ +export class Highlight extends BaseModel<HighlightModel, HighLightCreationAttributes> implements HighlightModel { + /** + * The primary key of the highlight. + */ + public declare pk: string; + + /** + * The user that the highlight is for. + */ + public declare user: Snowflake; + + /** + * The guild to look for highlights in. + */ + public declare guild: Snowflake; + + /** + * The words to look for. + */ + public declare words: HighlightWord[]; + + /** + * Channels that the user choose to ignore highlights in. + */ + public declare blacklistedChannels: Snowflake[]; + + /** + * Users that the user choose to ignore highlights from. + */ + public declare blacklistedUsers: Snowflake[]; + + /** + * Initializes the model. + * @param sequelize The sequelize instance. + */ + public static initModel(sequelize: Sequelize): void { + Highlight.init( + { + pk: { type: DataTypes.STRING, primaryKey: true, defaultValue: nanoid }, + user: { type: DataTypes.STRING, allowNull: false }, + guild: { type: DataTypes.STRING, allowNull: false }, + words: { type: DataTypes.JSONB, allowNull: false, defaultValue: [] }, + blacklistedChannels: { type: DataTypes.JSONB, allowNull: false, defaultValue: [] }, + blacklistedUsers: { type: DataTypes.JSONB, allowNull: false, defaultValue: [] } + }, + { sequelize } + ); + } +} diff --git a/lib/models/instance/Level.ts b/lib/models/instance/Level.ts new file mode 100644 index 0000000..d8d16f0 --- /dev/null +++ b/lib/models/instance/Level.ts @@ -0,0 +1,70 @@ +import { type Snowflake } from 'discord.js'; +import { type Sequelize } from 'sequelize'; +import { BaseModel } from '../BaseModel.js'; +const { DataTypes } = (await import('sequelize')).default; + +export interface LevelModel { + user: Snowflake; + guild: Snowflake; + xp: number; +} + +export interface LevelModelCreationAttributes { + user: Snowflake; + guild: Snowflake; + xp?: number; +} + +/** + * Leveling information for a user in a guild. + */ +export class Level extends BaseModel<LevelModel, LevelModelCreationAttributes> implements LevelModel { + /** + * The user's id. + */ + public declare user: Snowflake; + + /** + * The guild where the user is gaining xp. + */ + public declare guild: Snowflake; + + /** + * The user's xp. + */ + public declare xp: number; + + /** + * The user's level. + */ + public get level(): number { + return Level.convertXpToLevel(this.xp); + } + + /** + * Initializes the model. + * @param sequelize The sequelize instance. + */ + public static initModel(sequelize: Sequelize): void { + Level.init( + { + user: { type: DataTypes.STRING, allowNull: false }, + guild: { type: DataTypes.STRING, allowNull: false }, + xp: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0 } + }, + { sequelize } + ); + } + + public static convertXpToLevel(xp: number): number { + return Math.floor((-25 + Math.sqrt(625 + 200 * xp)) / 100); + } + + public static convertLevelToXp(level: number): number { + return 50 * level * level + 25 * level; // 50x² + 25x + } + + public static genRandomizedXp(): number { + return Math.floor(Math.random() * (40 - 15 + 1)) + 15; + } +} diff --git a/lib/models/instance/ModLog.ts b/lib/models/instance/ModLog.ts new file mode 100644 index 0000000..c25f043 --- /dev/null +++ b/lib/models/instance/ModLog.ts @@ -0,0 +1,127 @@ +import { type Snowflake } from 'discord.js'; +import { nanoid } from 'nanoid'; +import { type Sequelize } from 'sequelize'; +import { BaseModel } from '../BaseModel.js'; +const { DataTypes } = (await import('sequelize')).default; + +export enum ModLogType { + PERM_BAN = 'PERM_BAN', + TEMP_BAN = 'TEMP_BAN', + UNBAN = 'UNBAN', + KICK = 'KICK', + PERM_MUTE = 'PERM_MUTE', + TEMP_MUTE = 'TEMP_MUTE', + UNMUTE = 'UNMUTE', + WARN = 'WARN', + PERM_PUNISHMENT_ROLE = 'PERM_PUNISHMENT_ROLE', + TEMP_PUNISHMENT_ROLE = 'TEMP_PUNISHMENT_ROLE', + REMOVE_PUNISHMENT_ROLE = 'REMOVE_PUNISHMENT_ROLE', + PERM_CHANNEL_BLOCK = 'PERM_CHANNEL_BLOCK', + TEMP_CHANNEL_BLOCK = 'TEMP_CHANNEL_BLOCK', + CHANNEL_UNBLOCK = 'CHANNEL_UNBLOCK', + TIMEOUT = 'TIMEOUT', + REMOVE_TIMEOUT = 'REMOVE_TIMEOUT' +} + +export interface ModLogModel { + id: string; + type: ModLogType; + user: Snowflake; + moderator: Snowflake; + reason: string | null; + duration: number | null; + guild: Snowflake; + evidence: string; + pseudo: boolean; + hidden: boolean; +} + +export interface ModLogModelCreationAttributes { + id?: string; + type: ModLogType; + user: Snowflake; + moderator: Snowflake; + reason?: string | null; + duration?: number; + guild: Snowflake; + evidence?: string; + pseudo?: boolean; + hidden?: boolean; +} + +/** + * A mod log case. + */ +export class ModLog extends BaseModel<ModLogModel, ModLogModelCreationAttributes> implements ModLogModel { + /** + * The primary key of the modlog entry. + */ + public declare id: string; + + /** + * The type of punishment. + */ + public declare type: ModLogType; + + /** + * The user being punished. + */ + public declare user: Snowflake; + + /** + * The user carrying out the punishment. + */ + public declare moderator: Snowflake; + + /** + * The reason the user is getting punished. + */ + public declare reason: string | null; + + /** + * The amount of time the user is getting punished for. + */ + public declare duration: number | null; + + /** + * The guild the user is getting punished in. + */ + public declare guild: Snowflake; + + /** + * Evidence of what the user is getting punished for. + */ + public declare evidence: string; + + /** + * Not an actual modlog just used so a punishment entry can be made. + */ + public declare pseudo: boolean; + + /** + * Hides from the modlog command unless show hidden is specified. + */ + public declare hidden: boolean; + + /** + * Initializes the model. + * @param sequelize The sequelize instance. + */ + public static initModel(sequelize: Sequelize): void { + ModLog.init( + { + id: { type: DataTypes.STRING, primaryKey: true, allowNull: false, defaultValue: nanoid }, + type: { type: DataTypes.STRING, allowNull: false }, //? This is not an enum because of a sequelize issue: https://github.com/sequelize/sequelize/issues/2554 + user: { type: DataTypes.STRING, allowNull: false }, + moderator: { type: DataTypes.STRING, allowNull: false }, + duration: { type: DataTypes.STRING, allowNull: true }, + reason: { type: DataTypes.TEXT, allowNull: true }, + guild: { type: DataTypes.STRING, references: { model: 'Guilds', key: 'id' } }, + evidence: { type: DataTypes.TEXT, allowNull: true }, + pseudo: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false }, + hidden: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false } + }, + { sequelize } + ); + } +} diff --git a/lib/models/instance/Reminder.ts b/lib/models/instance/Reminder.ts new file mode 100644 index 0000000..964ea63 --- /dev/null +++ b/lib/models/instance/Reminder.ts @@ -0,0 +1,84 @@ +import { Snowflake } from 'discord.js'; +import { nanoid } from 'nanoid'; +import { type Sequelize } from 'sequelize'; +import { BaseModel } from '../BaseModel.js'; +const { DataTypes } = (await import('sequelize')).default; + +export interface ReminderModel { + id: string; + user: Snowflake; + messageUrl: string; + content: string; + created: Date; + expires: Date; + notified: boolean; +} + +export interface ReminderModelCreationAttributes { + id?: string; + user: Snowflake; + messageUrl: string; + content: string; + created: Date; + expires: Date; + notified?: boolean; +} + +/** + * Represents a reminder the a user has set. + */ +export class Reminder extends BaseModel<ReminderModel, ReminderModelCreationAttributes> implements ReminderModel { + /** + * The id of the reminder. + */ + public declare id: string; + + /** + * The user that the reminder is for. + */ + public declare user: Snowflake; + + /** + * The url of the message where the reminder was created. + */ + public declare messageUrl: string; + + /** + * The content of the reminder. + */ + public declare content: string; + + /** + * The date the reminder was created. + */ + public declare created: Date; + + /** + * The date when the reminder expires. + */ + public declare expires: Date; + + /** + * Whether the user has been notified about the reminder. + */ + public declare notified: boolean; + + /** + * Initializes the model. + * @param sequelize The sequelize instance. + */ + public static initModel(sequelize: Sequelize): void { + Reminder.init( + { + id: { type: DataTypes.STRING, primaryKey: true, defaultValue: nanoid }, + user: { type: DataTypes.STRING, allowNull: false }, + messageUrl: { type: DataTypes.STRING, allowNull: false }, + content: { type: DataTypes.TEXT, allowNull: false }, + created: { type: DataTypes.DATE, allowNull: false }, + expires: { type: DataTypes.DATE, allowNull: false }, + notified: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false } + }, + { sequelize } + ); + } +} diff --git a/lib/models/instance/StickyRole.ts b/lib/models/instance/StickyRole.ts new file mode 100644 index 0000000..00e98ce --- /dev/null +++ b/lib/models/instance/StickyRole.ts @@ -0,0 +1,58 @@ +import { type Snowflake } from 'discord.js'; +import { type Sequelize } from 'sequelize'; +import { BaseModel } from '../BaseModel.js'; +const { DataTypes } = (await import('sequelize')).default; + +export interface StickyRoleModel { + user: Snowflake; + guild: Snowflake; + roles: Snowflake[]; + nickname: string; +} +export interface StickyRoleModelCreationAttributes { + user: Snowflake; + guild: Snowflake; + roles: Snowflake[]; + nickname?: string; +} + +/** + * Information about a user's roles and nickname when they leave a guild. + */ +export class StickyRole extends BaseModel<StickyRoleModel, StickyRoleModelCreationAttributes> implements StickyRoleModel { + /** + * The id of the user the roles belongs to. + */ + public declare user: Snowflake; + + /** + * The guild where this should happen. + */ + public declare guild: Snowflake; + + /** + * The roles that the user should have returned + */ + public declare roles: Snowflake[]; + + /** + * The user's previous nickname + */ + public declare nickname: string; + + /** + * Initializes the model. + * @param sequelize The sequelize instance. + */ + public static initModel(sequelize: Sequelize): void { + StickyRole.init( + { + user: { type: DataTypes.STRING, allowNull: false }, + guild: { type: DataTypes.STRING, allowNull: false }, + roles: { type: DataTypes.JSONB, allowNull: false, defaultValue: [] }, + nickname: { type: DataTypes.STRING, allowNull: true } + }, + { sequelize } + ); + } +} diff --git a/lib/models/shared/Global.ts b/lib/models/shared/Global.ts new file mode 100644 index 0000000..b1aa0cc --- /dev/null +++ b/lib/models/shared/Global.ts @@ -0,0 +1,67 @@ +import { type Snowflake } from 'discord.js'; +import { type Sequelize } from 'sequelize'; +import { BaseModel } from '../BaseModel.js'; +const { DataTypes } = (await import('sequelize')).default; + +export interface GlobalModel { + environment: 'production' | 'development' | 'beta'; + disabledCommands: string[]; + blacklistedUsers: Snowflake[]; + blacklistedGuilds: Snowflake[]; + blacklistedChannels: Snowflake[]; +} + +export interface GlobalModelCreationAttributes { + environment: 'production' | 'development' | 'beta'; + disabledCommands?: string[]; + blacklistedUsers?: Snowflake[]; + blacklistedGuilds?: Snowflake[]; + blacklistedChannels?: Snowflake[]; +} + +/** + * Data specific to a certain instance of the bot. + */ +export class Global extends BaseModel<GlobalModel, GlobalModelCreationAttributes> implements GlobalModel { + /** + * The bot's environment. + */ + public declare environment: 'production' | 'development' | 'beta'; + + /** + * Globally disabled commands. + */ + public declare disabledCommands: string[]; + + /** + * Globally blacklisted users. + */ + public declare blacklistedUsers: Snowflake[]; + + /** + * Guilds blacklisted from using the bot. + */ + public declare blacklistedGuilds: Snowflake[]; + + /** + * Channels where the bot is prevented from running commands in. + */ + public declare blacklistedChannels: Snowflake[]; + + /** + * Initializes the model. + * @param sequelize The sequelize instance. + */ + public static initModel(sequelize: Sequelize): void { + Global.init( + { + environment: { type: DataTypes.STRING, primaryKey: true }, + disabledCommands: { type: DataTypes.JSONB, allowNull: false, defaultValue: [] }, + blacklistedUsers: { type: DataTypes.JSONB, allowNull: false, defaultValue: [] }, + blacklistedGuilds: { type: DataTypes.JSONB, allowNull: false, defaultValue: [] }, + blacklistedChannels: { type: DataTypes.JSONB, allowNull: false, defaultValue: [] } + }, + { sequelize } + ); + } +} diff --git a/lib/models/shared/GuildCount.ts b/lib/models/shared/GuildCount.ts new file mode 100644 index 0000000..7afef56 --- /dev/null +++ b/lib/models/shared/GuildCount.ts @@ -0,0 +1,38 @@ +import { DataTypes, Model, type Sequelize } from 'sequelize'; +import { Environment } from '../../../config/Config.js'; + +export interface GuildCountModel { + timestamp: Date; + environment: Environment; + guildCount: number; +} + +export interface GuildCountCreationAttributes { + timestamp?: Date; + environment: Environment; + guildCount: number; +} + +/** + * The number of guilds that the bot is in for each environment. + */ +export class GuildCount extends Model<GuildCountModel, GuildCountCreationAttributes> implements GuildCountModel { + public declare timestamp: Date; + public declare environment: Environment; + public declare guildCount: number; + + /** + * Initializes the model. + * @param sequelize The sequelize instance. + */ + public static initModel(sequelize: Sequelize): void { + GuildCount.init( + { + timestamp: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, + environment: { type: DataTypes.STRING, allowNull: false }, + guildCount: { type: DataTypes.BIGINT, allowNull: false } + }, + { sequelize, timestamps: false } + ); + } +} diff --git a/lib/models/shared/MemberCount.ts b/lib/models/shared/MemberCount.ts new file mode 100644 index 0000000..200a58e --- /dev/null +++ b/lib/models/shared/MemberCount.ts @@ -0,0 +1,37 @@ +import { DataTypes, Model, type Sequelize } from 'sequelize'; + +export interface MemberCountModel { + timestamp: Date; + guildId: string; + memberCount: number; +} + +export interface MemberCountCreationAttributes { + timestamp?: Date; + guildId: string; + memberCount: number; +} + +/** + * The member count of each guild that the bot is in that have over 100 members. + */ +export class MemberCount extends Model<MemberCountModel, MemberCountCreationAttributes> implements MemberCountModel { + public declare timestamp: Date; + public declare guildId: string; + public declare memberCount: number; + + /** + * Initializes the model. + * @param sequelize The sequelize instance. + */ + public static initModel(sequelize: Sequelize): void { + MemberCount.init( + { + timestamp: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, + guildId: { type: DataTypes.STRING, allowNull: false }, + memberCount: { type: DataTypes.BIGINT, allowNull: false } + }, + { sequelize, timestamps: false } + ); + } +} diff --git a/lib/models/shared/Shared.ts b/lib/models/shared/Shared.ts new file mode 100644 index 0000000..dec77d1 --- /dev/null +++ b/lib/models/shared/Shared.ts @@ -0,0 +1,84 @@ +import { Snowflake } from 'discord.js'; +import type { Sequelize } from 'sequelize'; +import { BadWords } from '../../automod/AutomodShared.js'; +import { BaseModel } from '../BaseModel.js'; +const { DataTypes } = (await import('sequelize')).default; + +export interface SharedModel { + primaryKey: 0; + superUsers: Snowflake[]; + privilegedUsers: Snowflake[]; + badLinksSecret: string[]; + badLinks: string[]; + badWords: BadWords; + autoBanCode: string | null; +} + +export interface SharedModelCreationAttributes { + primaryKey?: 0; + superUsers?: Snowflake[]; + privilegedUsers?: Snowflake[]; + badLinksSecret?: string[]; + badLinks?: string[]; + badWords?: BadWords; + autoBanCode?: string; +} + +/** + * Data shared between all bot instances. + */ +export class Shared extends BaseModel<SharedModel, SharedModelCreationAttributes> implements SharedModel { + /** + * The primary key of the shared model. + */ + public declare primaryKey: 0; + + /** + * Trusted users. + */ + public declare superUsers: Snowflake[]; + + /** + * Users that have all permissions that devs have except eval. + */ + public declare privilegedUsers: Snowflake[]; + + /** + * Non-public bad links. + */ + public declare badLinksSecret: string[]; + + /** + * Public Bad links. + */ + public declare badLinks: string[]; + + /** + * Bad words. + */ + public declare badWords: BadWords; + + /** + * Code that is used to match for auto banning users in moulberry's bush + */ + public declare autoBanCode: string; + + /** + * Initializes the model. + * @param sequelize The sequelize instance. + */ + public static initModel(sequelize: Sequelize): void { + Shared.init( + { + primaryKey: { type: DataTypes.INTEGER, primaryKey: true, validate: { min: 0, max: 0 } }, + superUsers: { type: DataTypes.JSONB, allowNull: false, defaultValue: [] }, + privilegedUsers: { type: DataTypes.JSONB, allowNull: false, defaultValue: [] }, + badLinksSecret: { type: DataTypes.JSONB, allowNull: false, defaultValue: [] }, + badLinks: { type: DataTypes.JSONB, allowNull: false, defaultValue: [] }, + badWords: { type: DataTypes.JSONB, allowNull: false, defaultValue: {} }, + autoBanCode: { type: DataTypes.TEXT } + }, + { sequelize, freezeTableName: true } + ); + } +} diff --git a/lib/models/shared/Stat.ts b/lib/models/shared/Stat.ts new file mode 100644 index 0000000..8e2e0b3 --- /dev/null +++ b/lib/models/shared/Stat.ts @@ -0,0 +1,72 @@ +import { type Sequelize } from 'sequelize'; +import { BaseModel } from '../BaseModel.js'; +const { DataTypes } = (await import('sequelize')).default; + +type Environment = 'production' | 'development' | 'beta'; + +export interface StatModel { + environment: Environment; + commandsUsed: bigint; + slashCommandsUsed: bigint; +} + +export interface StatModelCreationAttributes { + environment: Environment; + commandsUsed?: bigint; + slashCommandsUsed?: bigint; +} + +/** + * Statistics for each instance of the bot. + */ +export class Stat extends BaseModel<StatModel, StatModelCreationAttributes> implements StatModel { + /** + * The bot's environment. + */ + public declare environment: Environment; + + /** + * The number of commands used + */ + public declare commandsUsed: bigint; + + /** + * The number of slash commands used + */ + public declare slashCommandsUsed: bigint; + + /** + * Initializes the model. + * @param sequelize The sequelize instance. + */ + public static initModel(sequelize: Sequelize): void { + Stat.init( + { + environment: { type: DataTypes.STRING, primaryKey: true }, + commandsUsed: { + type: DataTypes.TEXT, + get: function (): bigint { + return BigInt(this.getDataValue('commandsUsed')); + }, + set: function (val: bigint) { + return this.setDataValue('commandsUsed', <any>`${val}`); + }, + allowNull: false, + defaultValue: `${0n}` + }, + slashCommandsUsed: { + type: DataTypes.TEXT, + get: function (): bigint { + return BigInt(this.getDataValue('slashCommandsUsed')); + }, + set: function (val: bigint) { + return this.setDataValue('slashCommandsUsed', <any>`${val}`); + }, + allowNull: false, + defaultValue: `${0n}` + } + }, + { sequelize } + ); + } +} diff --git a/lib/tsconfig.json b/lib/tsconfig.json new file mode 100644 index 0000000..e6d554e --- /dev/null +++ b/lib/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "outDir": "../../dist/lib", + "composite": true + }, + "include": ["lib/**/*.ts"], + "references": [{ "path": "../config" }] +} diff --git a/lib/types/BushInspectOptions.ts b/lib/types/BushInspectOptions.ts new file mode 100644 index 0000000..30ed01a --- /dev/null +++ b/lib/types/BushInspectOptions.ts @@ -0,0 +1,123 @@ +import { type InspectOptions } from 'util'; + +/** + * {@link https://nodejs.org/api/util.html#utilinspectobject-showhidden-depth-colors util.inspect Options Documentation} + */ +export interface BushInspectOptions extends InspectOptions { + /** + * If `true`, object's non-enumerable symbols and properties are included in the + * formatted result. [`WeakMap`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakMap) + * and [`WeakSet`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakSet) entries + * are also included as well as user defined prototype properties (excluding method properties). + * + * @default false + */ + showHidden?: boolean | undefined; + + /** + * Specifies the number of times to recurse while formatting `object`. This is useful + * for inspecting large objects. To recurse up to the maximum call stack size pass + * `Infinity` or `null`. + * + * @default 2 + */ + depth?: number | null | undefined; + + /** + * If `true`, the output is styled with ANSI color codes. Colors are customizable. See + * [Customizing util.inspect colors](https://nodejs.org/api/util.html#util_customizing_util_inspect_colors). + * + * @default false + */ + colors?: boolean | undefined; + + /** + * If `false`, `[util.inspect.custom](depth, opts)` functions are not invoked. + * + * @default true + */ + customInspect?: boolean | undefined; + + /** + * If `true`, `Proxy` inspection includes the + * [`target` and `handler`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy#Terminology) + * objects. + * + * @default false + */ + showProxy?: boolean | undefined; + + /** + * Specifies the maximum number of `Array`, [`TypedArray`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypedArray), + * [`WeakMap`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakMap) and + * [`WeakSet`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakSet) elements to + * include when formatting. Set to `null` or `Infinity` to show all elements. + * Set to `0` or negative to show no elements. + * + * @default 100 + */ + maxArrayLength?: number | null | undefined; + + /** + * Specifies the maximum number of characters to include when formatting. Set to + * `null` or `Infinity` to show all elements. Set to `0` or negative to show no + * characters. + * + * @default 10000 + */ + maxStringLength?: number | null | undefined; + + /** + * The length at which input values are split across multiple lines. Set to + * `Infinity` to format the input as a single line (in combination with compact set + * to `true` or any number >= `1`). + * + * @default 80 + */ + breakLength?: number | undefined; + + /** + * Setting this to `false` causes each object key to be displayed on a new line. It + * will break on new lines in text that is longer than `breakLength`. If set to a + * number, the most `n` inner elements are united on a single line as long as all + * properties fit into `breakLength`. Short array elements are also grouped together. + * + * @default 3 + */ + compact?: boolean | number | undefined; + + /** + * If set to `true` or a function, all properties of an object, and `Set` and `Map` + * entries are sorted in the resulting string. If set to `true` the + * [default sort](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort) is used. + * If set to a function, it is used as a + * [compare function](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#parameters). + * + * @default false + */ + sorted?: boolean | ((a: string, b: string) => number) | undefined; + + /** + * If set to `true`, getters are inspected. If set to `'get'`, only getters without a + * corresponding setter are inspected. If set to `'set'`, only getters with a + * corresponding setter are inspected. This might cause side effects depending on + * the getter function. + * + * @default false + */ + getters?: 'get' | 'set' | boolean | undefined; + + /** + * If set to `true`, an underscore is used to separate every three digits in all bigints and numbers. + * + * @default false + */ + numericSeparator?: boolean; + + /** + * Whether or not to inspect strings. + * + * @default false + */ + inspectStrings?: boolean; +} diff --git a/lib/types/CodeBlockLang.ts b/lib/types/CodeBlockLang.ts new file mode 100644 index 0000000..d0eb4f3 --- /dev/null +++ b/lib/types/CodeBlockLang.ts @@ -0,0 +1,311 @@ +export type CodeBlockLang = + | '1c' + | 'abnf' + | 'accesslog' + | 'actionscript' + | 'ada' + | 'arduino' + | 'ino' + | 'armasm' + | 'arm' + | 'avrasm' + | 'actionscript' + | 'as' + | 'angelscript' + | 'asc' + | 'apache' + | 'apacheconf' + | 'applescript' + | 'osascript' + | 'arcade' + | 'asciidoc' + | 'adoc' + | 'aspectj' + | 'autohotkey' + | 'autoit' + | 'awk' + | 'mawk' + | 'nawk' + | 'gawk' + | 'bash' + | 'sh' + | 'zsh' + | 'basic' + | 'bnf' + | 'brainfuck' + | 'bf' + | 'csharp' + | 'cs' + | 'c' + | 'h' + | 'cpp' + | 'hpp' + | 'cc' + | 'hh' + | 'c++' + | 'h++' + | 'cxx' + | 'hxx' + | 'cal' + | 'cos' + | 'cls' + | 'cmake' + | 'cmake.in' + | 'coq' + | 'csp' + | 'css' + | 'capnproto' + | 'capnp' + | 'clojure' + | 'clj' + | 'coffeescript' + | 'coffee' + | 'cson' + | 'iced' + | 'crmsh' + | 'crm' + | 'pcmk' + | 'crystal' + | 'cr' + | 'd' + | 'dns' + | 'zone' + | 'bind' + | 'dos' + | 'bat' + | 'cmd' + | 'dart' + | 'dpr' + | 'dfm' + | 'pas' + | 'pascal' + | 'diff' + | 'patch' + | 'django' + | 'jinja' + | 'dockerfile' + | 'docker' + | 'dsconfig' + | 'dts' + | 'dust' + | 'dst' + | 'ebnf' + | 'elixir' + | 'elm' + | 'erlang' + | 'erl' + | 'excel' + | 'xls' + | 'xlsx' + | 'fsharp' + | 'fs' + | 'fix' + | 'fortran' + | 'f90' + | 'f95' + | 'gcode' + | 'nc' + | 'gams' + | 'gms' + | 'gauss' + | 'gss' + | 'gherkin' + | 'go' + | 'golang' + | 'golo' + | 'gololang' + | 'gradle' + | 'groovy' + | 'xml' + | 'html' + | 'xhtml' + | 'rss' + | 'atom' + | 'xjb' + | 'xsd' + | 'xsl' + | 'plist' + | 'svg' + | 'http' + | 'https' + | 'haml' + | 'handlebars' + | 'hbs' + | 'html.hbs' + | 'html.handlebars' + | 'haskell' + | 'hs' + | 'haxe' + | 'hx' + | 'hlsl' + | 'hy' + | 'hylang' + | 'ini' + | 'toml' + | 'inform7' + | 'i7' + | 'irpf90' + | 'json' + | 'java' + | 'jsp' + | 'javascript' + | 'js' + | 'jsx' + | 'julia' + | 'julia-repl' + | 'kotlin' + | 'kt' + | 'tex' + | 'leaf' + | 'lasso' + | 'ls' + | 'lassoscript' + | 'less' + | 'ldif' + | 'lisp' + | 'livecodeserver' + | 'livescript' + | 'ls' + | 'lua' + | 'makefile' + | 'mk' + | 'mak' + | 'make' + | 'markdown' + | 'md' + | 'mkdown' + | 'mkd' + | 'mathematica' + | 'mma' + | 'wl' + | 'matlab' + | 'maxima' + | 'mel' + | 'mercury' + | 'mizar' + | 'mojolicious' + | 'monkey' + | 'moonscript' + | 'moon' + | 'n1ql' + | 'nsis' + | 'nginx' + | 'nginxconf' + | 'nim' + | 'nimrod' + | 'nix' + | 'ocaml' + | 'ml' + | 'objectivec' + | 'mm' + | 'objc' + | 'obj-c' + | 'obj-c++' + | 'objective-c++' + | 'glsl' + | 'openscad' + | 'scad' + | 'ruleslanguage' + | 'oxygene' + | 'pf' + | 'pf.conf' + | 'php' + | 'parser3' + | 'perl' + | 'pl' + | 'pm' + | 'plaintext' + | 'txt' + | 'text' + | 'pony' + | 'pgsql' + | 'postgres' + | 'postgresql' + | 'powershell' + | 'ps' + | 'ps1' + | 'processing' + | 'prolog' + | 'properties' + | 'protobuf' + | 'puppet' + | 'pp' + | 'python' + | 'py' + | 'gyp' + | 'profile' + | 'python-repl' + | 'pycon' + | 'k' + | 'kdb' + | 'qml' + | 'r' + | 'reasonml' + | 're' + | 'rib' + | 'rsl' + | 'graph' + | 'instances' + | 'ruby' + | 'rb' + | 'gemspec' + | 'podspec' + | 'thor' + | 'irb' + | 'rust' + | 'rs' + | 'sas' + | 'scss' + | 'sql' + | 'p21' + | 'step' + | 'stp' + | 'scala' + | 'scheme' + | 'scilab' + | 'sci' + | 'shell' + | 'console' + | 'smali' + | 'smalltalk' + | 'st' + | 'sml' + | 'ml' + | 'stan' + | 'stanfuncs' + | 'stata' + | 'stylus' + | 'styl' + | 'subunit' + | 'swift' + | 'tcl' + | 'tk' + | 'tap' + | 'thrift' + | 'tp' + | 'twig' + | 'craftcms' + | 'typescript' + | 'ts' + | 'vbnet' + | 'vb' + | 'vbscript' + | 'vbs' + | 'vhdl' + | 'vala' + | 'verilog' + | 'v' + | 'vim' + | 'axapta' + | 'x++' + | 'x86asm' + | 'xl' + | 'tao' + | 'xquery' + | 'xpath' + | 'xq' + | 'yml' + | 'yaml' + | 'zephir' + | 'zep' + | 'ansi'; diff --git a/lib/utils/AllowedMentions.ts b/lib/utils/AllowedMentions.ts new file mode 100644 index 0000000..d2eb030 --- /dev/null +++ b/lib/utils/AllowedMentions.ts @@ -0,0 +1,68 @@ +import { type MessageMentionOptions, type MessageMentionTypes } from 'discord.js'; + +/** + * A utility class for creating allowed mentions. + */ +export class AllowedMentions { + /** + * @param everyone Whether everyone and here should be mentioned. + * @param roles Whether roles should be mentioned. + * @param users Whether users should be mentioned. + * @param repliedUser Whether the author of the Message being replied to should be mentioned. + */ + public constructor(public everyone = false, public roles = false, public users = true, public repliedUser = true) {} + + /** + * Don't mention anyone. + * @param repliedUser Whether the author of the Message being replied to should be mentioned. + */ + public static none(repliedUser = true): MessageMentionOptions { + return { parse: [], repliedUser }; + } + + /** + * Mention @everyone and @here, roles, and users. + * @param repliedUser Whether the author of the Message being replied to should be mentioned. + */ + public static all(repliedUser = true): MessageMentionOptions { + return { parse: ['everyone', 'roles', 'users'], repliedUser }; + } + + /** + * Mention users. + * @param repliedUser Whether the author of the Message being replied to should be mentioned. + */ + public static users(repliedUser = true): MessageMentionOptions { + return { parse: ['users'], repliedUser }; + } + + /** + * Mention everyone and here. + * @param repliedUser Whether the author of the Message being replied to should be mentioned. + */ + public static everyone(repliedUser = true): MessageMentionOptions { + return { parse: ['everyone'], repliedUser }; + } + + /** + * Mention roles. + * @param repliedUser Whether the author of the Message being replied to should be mentioned. + */ + public static roles(repliedUser = true): MessageMentionOptions { + return { parse: ['roles'], repliedUser }; + } + + /** + * Converts this into a MessageMentionOptions object. + */ + public toObject(): MessageMentionOptions { + return { + parse: [ + ...(this.users ? ['users'] : []), + ...(this.roles ? ['roles'] : []), + ...(this.everyone ? ['everyone'] : []) + ] as MessageMentionTypes[], + repliedUser: this.repliedUser + }; + } +} diff --git a/lib/utils/Arg.ts b/lib/utils/Arg.ts new file mode 100644 index 0000000..d362225 --- /dev/null +++ b/lib/utils/Arg.ts @@ -0,0 +1,192 @@ +import { + type BaseBushArgumentType, + type BushArgumentType, + type BushArgumentTypeCaster, + type CommandMessage, + type SlashMessage +} from '#lib'; +import { Argument, type Command, type Flag, type ParsedValuePredicate } from 'discord-akairo'; +import { type Message } from 'discord.js'; + +/** + * Casts a phrase to this argument's type. + * @param type - The type to cast to. + * @param message - Message that called the command. + * @param phrase - Phrase to process. + */ +export async function cast<T extends ATC>(type: T, message: CommandMessage | SlashMessage, phrase: string): Promise<ATCR<T>>; +export async function cast<T extends KBAT>(type: T, message: CommandMessage | SlashMessage, phrase: string): Promise<BAT[T]>; +export async function cast(type: AT | ATC, message: CommandMessage | SlashMessage, phrase: string): Promise<any>; +export async function cast( + this: ThisType<Command>, + type: ATC | AT, + message: CommandMessage | SlashMessage, + phrase: string +): Promise<any> { + return Argument.cast.call(this, type as any, message.client.commandHandler.resolver, message as Message, phrase); +} + +/** + * Creates a type that is the left-to-right composition of the given types. + * If any of the types fails, the entire composition fails. + * @param types - Types to use. + */ +export function compose<T extends ATC>(...types: T[]): ATCATCR<T>; +export function compose<T extends KBAT>(...types: T[]): ATCBAT<T>; +export function compose(...types: (AT | ATC)[]): ATC; +export function compose(...types: (AT | ATC)[]): ATC { + return Argument.compose(...(types as any)); +} + +/** + * Creates a type that is the left-to-right composition of the given types. + * If any of the types fails, the composition still continues with the failure passed on. + * @param types - Types to use. + */ +export function composeWithFailure<T extends ATC>(...types: T[]): ATCATCR<T>; +export function composeWithFailure<T extends KBAT>(...types: T[]): ATCBAT<T>; +export function composeWithFailure(...types: (AT | ATC)[]): ATC; +export function composeWithFailure(...types: (AT | ATC)[]): ATC { + return Argument.composeWithFailure(...(types as any)); +} + +/** + * Checks if something is null, undefined, or a fail flag. + * @param value - Value to check. + */ +export function isFailure(value: any): value is null | undefined | (Flag & { value: any }) { + return Argument.isFailure(value); +} + +/** + * Creates a type from multiple types (product type). + * Only inputs where each type resolves with a non-void value are valid. + * @param types - Types to use. + */ +export function product<T extends ATC>(...types: T[]): ATCATCR<T>; +export function product<T extends KBAT>(...types: T[]): ATCBAT<T>; +export function product(...types: (AT | ATC)[]): ATC; +export function product(...types: (AT | ATC)[]): ATC { + return Argument.product(...(types as any)); +} + +/** + * Creates a type where the parsed value must be within a range. + * @param type - The type to use. + * @param min - Minimum value. + * @param max - Maximum value. + * @param inclusive - Whether or not to be inclusive on the upper bound. + */ +export function range<T extends ATC>(type: T, min: number, max: number, inclusive?: boolean): ATCATCR<T>; +export function range<T extends KBAT>(type: T, min: number, max: number, inclusive?: boolean): ATCBAT<T>; +export function range(type: AT | ATC, min: number, max: number, inclusive?: boolean): ATC; +export function range(type: AT | ATC, min: number, max: number, inclusive?: boolean): ATC { + return Argument.range(type as any, min, max, inclusive); +} + +/** + * Creates a type that parses as normal but also tags it with some data. + * Result is in an object `{ tag, value }` and wrapped in `Flag.fail` when failed. + * @param type - The type to use. + * @param tag - Tag to add. Defaults to the `type` argument, so useful if it is a string. + */ +export function tagged<T extends ATC>(type: T, tag?: any): ATCATCR<T>; +export function tagged<T extends KBAT>(type: T, tag?: any): ATCBAT<T>; +export function tagged(type: AT | ATC, tag?: any): ATC; +export function tagged(type: AT | ATC, tag?: any): ATC { + return Argument.tagged(type as any, tag); +} + +/** + * Creates a type from multiple types (union type). + * The first type that resolves to a non-void value is used. + * Each type will also be tagged using `tagged` with themselves. + * @param types - Types to use. + */ +export function taggedUnion<T extends ATC>(...types: T[]): ATCATCR<T>; +export function taggedUnion<T extends KBAT>(...types: T[]): ATCBAT<T>; +export function taggedUnion(...types: (AT | ATC)[]): ATC; +export function taggedUnion(...types: (AT | ATC)[]): ATC { + return Argument.taggedUnion(...(types as any)); +} + +/** + * Creates a type that parses as normal but also tags it with some data and carries the original input. + * Result is in an object `{ tag, input, value }` and wrapped in `Flag.fail` when failed. + * @param type - The type to use. + * @param tag - Tag to add. Defaults to the `type` argument, so useful if it is a string. + */ +export function taggedWithInput<T extends ATC>(type: T, tag?: any): ATCATCR<T>; +export function taggedWithInput<T extends KBAT>(type: T, tag?: any): ATCBAT<T>; +export function taggedWithInput(type: AT | ATC, tag?: any): ATC; +export function taggedWithInput(type: AT | ATC, tag?: any): ATC { + return Argument.taggedWithInput(type as any, tag); +} + +/** + * Creates a type from multiple types (union type). + * The first type that resolves to a non-void value is used. + * @param types - Types to use. + */ +export function union<T extends ATC>(...types: T[]): ATCATCR<T>; +export function union<T extends KBAT>(...types: T[]): ATCBAT<T>; +export function union(...types: (AT | ATC)[]): ATC; +export function union(...types: (AT | ATC)[]): ATC { + return Argument.union(...(types as any)); +} + +/** + * Creates a type with extra validation. + * If the predicate is not true, the value is considered invalid. + * @param type - The type to use. + * @param predicate - The predicate function. + */ +export function validate<T extends ATC>(type: T, predicate: ParsedValuePredicate): ATCATCR<T>; +export function validate<T extends KBAT>(type: T, predicate: ParsedValuePredicate): ATCBAT<T>; +export function validate(type: AT | ATC, predicate: ParsedValuePredicate): ATC; +export function validate(type: AT | ATC, predicate: ParsedValuePredicate): ATC { + return Argument.validate(type as any, predicate); +} + +/** + * Creates a type that parses as normal but also carries the original input. + * Result is in an object `{ input, value }` and wrapped in `Flag.fail` when failed. + * @param type - The type to use. + */ +export function withInput<T extends ATC>(type: T): ATC<ATCR<T>>; +export function withInput<T extends KBAT>(type: T): ATCBAT<T>; +export function withInput(type: AT | ATC): ATC; +export function withInput(type: AT | ATC): ATC { + return Argument.withInput(type as any); +} + +type BushArgumentTypeCasterReturn<R> = R extends BushArgumentTypeCaster<infer S> ? S : R; +/** ```ts + * <R = unknown> = BushArgumentTypeCaster<R> + * ``` */ +type ATC<R = unknown> = BushArgumentTypeCaster<R>; +/** ```ts + * keyof BaseBushArgumentType + * ``` */ +type KBAT = keyof BaseBushArgumentType; +/** ```ts + * <R> = BushArgumentTypeCasterReturn<R> + * ``` */ +type ATCR<R> = BushArgumentTypeCasterReturn<R>; +/** ```ts + * BushArgumentType + * ``` */ +type AT = BushArgumentType; +/** ```ts + * BaseBushArgumentType + * ``` */ +type BAT = BaseBushArgumentType; + +/** ```ts + * <T extends BushArgumentTypeCaster> = BushArgumentTypeCaster<BushArgumentTypeCasterReturn<T>> + * ``` */ +type ATCATCR<T extends BushArgumentTypeCaster> = BushArgumentTypeCaster<BushArgumentTypeCasterReturn<T>>; +/** ```ts + * <T extends keyof BaseBushArgumentType> = BushArgumentTypeCaster<BaseBushArgumentType[T]> + * ``` */ +type ATCBAT<T extends keyof BaseBushArgumentType> = BushArgumentTypeCaster<BaseBushArgumentType[T]>; diff --git a/lib/utils/BushClientUtils.ts b/lib/utils/BushClientUtils.ts new file mode 100644 index 0000000..68a1dc3 --- /dev/null +++ b/lib/utils/BushClientUtils.ts @@ -0,0 +1,499 @@ +import assert from 'assert/strict'; +import { + cleanCodeBlockContent, + DMChannel, + escapeCodeBlock, + GuildMember, + Message, + PartialDMChannel, + Routes, + TextBasedChannel, + ThreadMember, + User, + type APIMessage, + type Client, + type Snowflake, + type UserResolvable +} from 'discord.js'; +import got from 'got'; +import _ from 'lodash'; +import { ConfigChannelKey } from '../../config/Config.js'; +import CommandErrorListener from '../../src/listeners/commands/commandError.js'; +import { GlobalCache, SharedCache } from '../common/BushCache.js'; +import { CommandMessage } from '../extensions/discord-akairo/BushCommand.js'; +import { SlashMessage } from '../extensions/discord-akairo/SlashMessage.js'; +import { Global } from '../models/shared/Global.js'; +import { Shared } from '../models/shared/Shared.js'; +import { BushInspectOptions } from '../types/BushInspectOptions.js'; +import { CodeBlockLang } from '../types/CodeBlockLang.js'; +import { emojis, Pronoun, PronounCode, pronounMapping, regex } from './BushConstants.js'; +import { addOrRemoveFromArray, formatError, inspect } from './BushUtils.js'; + +/** + * Utilities that require access to the client. + */ +export class BushClientUtils { + /** + * The hastebin urls used to post to hastebin, attempts to post in order + */ + #hasteURLs: string[] = [ + 'https://hst.sh', + // 'https://hasteb.in', + 'https://hastebin.com', + 'https://mystb.in', + 'https://haste.clicksminuteper.net', + 'https://paste.pythondiscord.com', + 'https://haste.unbelievaboat.com' + // 'https://haste.tyman.tech' + ]; + + public constructor(private readonly client: Client) {} + + /** + * Maps an array of user ids to user objects. + * @param ids The list of IDs to map + * @returns The list of users mapped + */ + public async mapIDs(ids: Snowflake[]): Promise<User[]> { + return await Promise.all(ids.map((id) => this.client.users.fetch(id))); + } + + /** + * Posts text to hastebin + * @param content The text to post + * @returns The url of the posted text + */ + public async haste(content: string, substr = false): Promise<HasteResults> { + let isSubstr = false; + if (content.length > 400_000 && !substr) { + void this.handleError('haste', new Error(`content over 400,000 characters (${content.length.toLocaleString()})`)); + return { error: 'content too long' }; + } else if (content.length > 400_000) { + content = content.substring(0, 400_000); + isSubstr = true; + } + for (const url of this.#hasteURLs) { + try { + const res: HastebinRes = await got.post(`${url}/documents`, { body: content }).json(); + return { url: `${url}/${res.key}`, error: isSubstr ? 'substr' : undefined }; + } catch { + void this.client.console.error('haste', `Unable to upload haste to ${url}`); + } + } + return { error: 'unable to post' }; + } + + /** + * Resolves a user-provided string into a user object, if possible + * @param text The text to try and resolve + * @returns The user resolved or null + */ + public async resolveUserAsync(text: string): Promise<User | null> { + const idReg = /\d{17,19}/; + const idMatch = text.match(idReg); + if (idMatch) { + try { + return await this.client.users.fetch(text as Snowflake); + } catch {} + } + const mentionReg = /<@!?(?<id>\d{17,19})>/; + const mentionMatch = text.match(mentionReg); + if (mentionMatch) { + try { + return await this.client.users.fetch(mentionMatch.groups!.id as Snowflake); + } catch {} + } + const user = this.client.users.cache.find((u) => u.username === text); + if (user) return user; + return null; + } + + /** + * Surrounds text in a code block with the specified language and puts it in a hastebin if its too long. + * * Embed Description Limit = 4096 characters + * * Embed Field Limit = 1024 characters + * @param code The content of the code block. + * @param length The maximum length of the code block. + * @param language The language of the code. + * @param substr Whether or not to substring the code if it is too long. + * @returns The generated code block + */ + public async codeblock(code: string, length: number, language: CodeBlockLang | '' = '', substr = false): Promise<string> { + let hasteOut = ''; + code = escapeCodeBlock(code); + const prefix = `\`\`\`${language}\n`; + const suffix = '\n```'; + if (code.length + (prefix + suffix).length >= length) { + const haste_ = await this.haste(code, substr); + hasteOut = `Too large to display. ${ + haste_.url + ? `Hastebin: ${haste_.url}${language ? `.${language}` : ''}${haste_.error ? ` - ${haste_.error}` : ''}` + : `${emojis.error} Hastebin: ${haste_.error}` + }`; + } + + const FormattedHaste = hasteOut.length ? `\n${hasteOut}` : ''; + const shortenedCode = hasteOut ? code.substring(0, length - (prefix + FormattedHaste + suffix).length) : code; + const code3 = code.length ? prefix + shortenedCode + suffix + FormattedHaste : prefix + suffix; + if (code3.length > length) { + void this.client.console.warn(`codeblockError`, `Required Length: ${length}. Actual Length: ${code3.length}`, true); + void this.client.console.warn(`codeblockError`, code3, true); + throw new Error('code too long'); + } + return code3; + } + + /** + * Maps the key of a credential with a readable version when redacting. + * @param key The key of the credential. + * @returns The readable version of the key or the original key if there isn't a mapping. + */ + #mapCredential(key: string): string { + return ( + { + token: 'Main Token', + devToken: 'Dev Token', + betaToken: 'Beta Token', + hypixelApiKey: 'Hypixel Api Key', + wolframAlphaAppId: 'Wolfram|Alpha App ID', + dbPassword: 'Database Password' + }[key] ?? key + ); + } + + /** + * Redacts credentials from a string. + * @param text The text to redact credentials from. + * @returns The redacted text. + */ + public redact(text: string) { + for (const credentialName in { ...this.client.config.credentials, dbPassword: this.client.config.db.password }) { + const credential = { ...this.client.config.credentials, dbPassword: this.client.config.db.password }[ + credentialName as keyof typeof this.client.config.credentials + ]; + if (credential === null || credential === '') continue; + const replacement = this.#mapCredential(credentialName); + const escapeRegex = /[.*+?^${}()|[\]\\]/g; + text = text.replace(new RegExp(credential.toString().replace(escapeRegex, '\\$&'), 'g'), `[${replacement} Omitted]`); + text = text.replace( + new RegExp([...credential.toString()].reverse().join('').replace(escapeRegex, '\\$&'), 'g'), + `[${replacement} Omitted]` + ); + } + return text; + } + + /** + * Takes an any value, inspects it, redacts credentials, and puts it in a codeblock + * (and uploads to hast if the content is too long). + * @param input The object to be inspect, redacted, and put into a codeblock. + * @param language The language to make the codeblock. + * @param inspectOptions The options for {@link BushClientUtil.inspect}. + * @param length The maximum length that the codeblock can be. + * @returns The generated codeblock. + */ + public async inspectCleanRedactCodeblock( + input: any, + language?: CodeBlockLang | '', + inspectOptions?: BushInspectOptions, + length = 1024 + ) { + input = inspect(input, inspectOptions ?? undefined); + if (inspectOptions) inspectOptions.inspectStrings = undefined; + input = cleanCodeBlockContent(input); + input = this.redact(input); + return this.codeblock(input, length, language, true); + } + + /** + * Takes an any value, inspects it, redacts credentials, and uploads it to haste. + * @param input The object to be inspect, redacted, and upload. + * @param inspectOptions The options for {@link BushClientUtil.inspect}. + * @returns The {@link HasteResults}. + */ + public async inspectCleanRedactHaste(input: any, inspectOptions?: BushInspectOptions): Promise<HasteResults> { + input = inspect(input, inspectOptions ?? undefined); + input = this.redact(input); + return this.haste(input, true); + } + + /** + * Takes an any value, inspects it and redacts credentials. + * @param input The object to be inspect and redacted. + * @param inspectOptions The options for {@link BushClientUtil.inspect}. + * @returns The redacted and inspected object. + */ + public inspectAndRedact(input: any, inspectOptions?: BushInspectOptions): string { + input = inspect(input, inspectOptions ?? undefined); + return this.redact(input); + } + + /** + * Get the global cache. + */ + public getGlobal(): GlobalCache; + /** + * Get a key from the global cache. + * @param key The key to get in the global cache. + */ + public getGlobal<K extends keyof GlobalCache>(key: K): GlobalCache[K]; + public getGlobal(key?: keyof GlobalCache) { + return key ? this.client.cache.global[key] : this.client.cache.global; + } + + /** + * Get the shared cache. + */ + public getShared(): SharedCache; + /** + * Get a key from the shared cache. + * @param key The key to get in the shared cache. + */ + public getShared<K extends keyof SharedCache>(key: K): SharedCache[K]; + public getShared(key?: keyof SharedCache) { + return key ? this.client.cache.shared[key] : this.client.cache.shared; + } + + /** + * Add or remove an element from an array stored in the Globals database. + * @param action Either `add` or `remove` an element. + * @param key The key of the element in the global cache to update. + * @param value The value to add/remove from the array. + */ + public async insertOrRemoveFromGlobal<K extends keyof Client['cache']['global']>( + action: 'add' | 'remove', + key: K, + value: Client['cache']['global'][K][0] + ): Promise<Global | void> { + const row = + (await Global.findByPk(this.client.config.environment)) ?? + (await Global.create({ environment: this.client.config.environment })); + const oldValue: any[] = row[key]; + const newValue = addOrRemoveFromArray(action, oldValue, value); + row[key] = newValue; + this.client.cache.global[key] = newValue; + return await row.save().catch((e) => this.handleError('insertOrRemoveFromGlobal', e)); + } + + /** + * Add or remove an element from an array stored in the Shared database. + * @param action Either `add` or `remove` an element. + * @param key The key of the element in the shared cache to update. + * @param value The value to add/remove from the array. + */ + public async insertOrRemoveFromShared<K extends Exclude<keyof Client['cache']['shared'], 'badWords' | 'autoBanCode'>>( + action: 'add' | 'remove', + key: K, + value: Client['cache']['shared'][K][0] + ): Promise<Shared | void> { + const row = (await Shared.findByPk(0)) ?? (await Shared.create()); + const oldValue: any[] = row[key]; + const newValue = addOrRemoveFromArray(action, oldValue, value); + row[key] = newValue; + this.client.cache.shared[key] = newValue; + return await row.save().catch((e) => this.handleError('insertOrRemoveFromShared', e)); + } + + /** + * Updates an element in the Globals database. + * @param key The key in the global cache to update. + * @param value The value to set the key to. + */ + public async setGlobal<K extends keyof Client['cache']['global']>( + key: K, + value: Client['cache']['global'][K] + ): Promise<Global | void> { + const row = + (await Global.findByPk(this.client.config.environment)) ?? + (await Global.create({ environment: this.client.config.environment })); + row[key] = value; + this.client.cache.global[key] = value; + return await row.save().catch((e) => this.handleError('setGlobal', e)); + } + + /** + * Updates an element in the Shared database. + * @param key The key in the shared cache to update. + * @param value The value to set the key to. + */ + public async setShared<K extends Exclude<keyof Client['cache']['shared'], 'badWords' | 'autoBanCode'>>( + key: K, + value: Client['cache']['shared'][K] + ): Promise<Shared | void> { + const row = (await Shared.findByPk(0)) ?? (await Shared.create()); + row[key] = value; + this.client.cache.shared[key] = value; + return await row.save().catch((e) => this.handleError('setShared', e)); + } + + /** + * Send a message in the error logging channel and console for an error. + * @param context + * @param error + */ + public async handleError(context: string, error: Error) { + await this.client.console.error(_.camelCase(context), `An error occurred:\n${formatError(error, false)}`, false); + await this.client.console.channelError({ + embeds: await CommandErrorListener.generateErrorEmbed(this.client, { type: 'unhandledRejection', error: error, context }) + }); + } + + /** + * Fetches a user from discord. + * @param user The user to fetch + * @returns Undefined if the user is not found, otherwise the user. + */ + public async resolveNonCachedUser(user: UserResolvable | undefined | null): Promise<User | undefined> { + if (user == null) return undefined; + const resolvedUser = + user instanceof User + ? user + : user instanceof GuildMember + ? user.user + : user instanceof ThreadMember + ? user.user + : user instanceof Message + ? user.author + : undefined; + + return resolvedUser ?? (await this.client.users.fetch(user as Snowflake).catch(() => undefined)); + } + + /** + * Get the pronouns of a discord user from pronoundb.org + * @param user The user to retrieve the promises of. + * @returns The human readable pronouns of the user, or undefined if they do not have any. + */ + public async getPronounsOf(user: User | Snowflake): Promise<Pronoun | undefined> { + const _user = await this.resolveNonCachedUser(user); + if (!_user) throw new Error(`Cannot find user ${user}`); + const apiRes = (await got + .get(`https://pronoundb.org/api/v1/lookup?platform=discord&id=${_user.id}`) + .json() + .catch(() => undefined)) as { pronouns: PronounCode } | undefined; + + if (!apiRes) return undefined; + assert(apiRes.pronouns); + + return pronounMapping[apiRes.pronouns!]!; + } + + /** + * Uploads an image to imgur. + * @param image The image to upload. + * @returns The url of the imgur. + */ + public async uploadImageToImgur(image: string) { + const clientId = this.client.config.credentials.imgurClientId; + + const resp = (await got + .post('https://api.imgur.com/3/upload', { + headers: { + Authorization: `Client-ID ${clientId}`, + Accept: 'application/json' + }, + form: { + image: image, + type: 'base64' + }, + followRedirect: true + }) + .json() + .catch(() => null)) as { data: { link: string } | undefined }; + + return resp.data?.link ?? null; + } + + /** + * Gets the prefix based off of the message. + * @param message The message to get the prefix from. + * @returns The prefix. + */ + public prefix(message: CommandMessage | SlashMessage): string { + return message.util.isSlash + ? '/' + : this.client.config.isDevelopment + ? 'dev ' + : message.util.parsed?.prefix ?? this.client.config.prefix; + } + + public async resolveMessageLinks(content: string | null): Promise<MessageLinkParts[]> { + const res: MessageLinkParts[] = []; + + if (!content) return res; + + const regex_ = new RegExp(regex.messageLink); + let match: RegExpExecArray | null; + while (((match = regex_.exec(content)), match !== null)) { + const input = match.input; + if (!match.groups || !input) continue; + if (input.startsWith('<') && input.endsWith('>')) continue; + + const { guild_id, channel_id, message_id } = match.groups; + if (!guild_id || !channel_id || !message_id) continue; + + res.push({ guild_id, channel_id, message_id }); + } + + return res; + } + + public async resolveMessagesFromLinks(content: string): Promise<APIMessage[]> { + const res: APIMessage[] = []; + + const links = await this.resolveMessageLinks(content); + if (!links.length) return []; + + for (const { guild_id, channel_id, message_id } of links) { + const guild = this.client.guilds.cache.get(guild_id); + if (!guild) continue; + const channel = guild.channels.cache.get(channel_id); + if (!channel || (!channel.isTextBased() && !channel.isThread())) continue; + + const message = (await this.client.rest + .get(Routes.channelMessage(channel_id, message_id)) + .catch(() => null)) as APIMessage | null; + if (!message) continue; + + res.push(message); + } + + return res; + } + + /** + * Resolves a channel from the config and ensures it is a non-dm-based-text-channel. + * @param channel The channel to retrieve. + */ + public async getConfigChannel( + channel: ConfigChannelKey + ): Promise<Exclude<TextBasedChannel, DMChannel | PartialDMChannel> | null> { + const channels = this.client.config.channels; + if (!(channel in channels)) + throw new TypeError(`Invalid channel provided (${channel}), must be one of ${Object.keys(channels).join(' ')}`); + + const channelId = channels[channel]; + if (channelId === '') return null; + + const res = await this.client.channels.fetch(channelId); + + if (!res?.isTextBased() || res.isDMBased()) return null; + + return res; + } +} + +interface HastebinRes { + key: string; +} + +export interface HasteResults { + url?: string; + error?: 'content too long' | 'substr' | 'unable to post'; +} + +export interface MessageLinkParts { + guild_id: Snowflake; + channel_id: Snowflake; + message_id: Snowflake; +} diff --git a/lib/utils/BushConstants.ts b/lib/utils/BushConstants.ts new file mode 100644 index 0000000..090616c --- /dev/null +++ b/lib/utils/BushConstants.ts @@ -0,0 +1,531 @@ +import deepLock from 'deep-lock'; +import { + ArgumentMatches as AkairoArgumentMatches, + ArgumentTypes as AkairoArgumentTypes, + BuiltInReasons, + CommandHandlerEvents as AkairoCommandHandlerEvents +} from 'discord-akairo/dist/src/util/Constants.js'; +import { Colors, GuildFeature } from 'discord.js'; + +const rawCapeUrl = 'https://raw.githubusercontent.com/NotEnoughUpdates/capes/master/'; + +/** + * Time units in milliseconds + */ +export const enum Time { + /** + * One millisecond (1 ms). + */ + Millisecond = 1, + + /** + * One second (1,000 ms). + */ + Second = Millisecond * 1000, + + /** + * One minute (60,000 ms). + */ + Minute = Second * 60, + + /** + * One hour (3,600,000 ms). + */ + Hour = Minute * 60, + + /** + * One day (86,400,000 ms). + */ + Day = Hour * 24, + + /** + * One week (604,800,000 ms). + */ + Week = Day * 7, + + /** + * One month (2,629,800,000 ms). + */ + Month = Day * 30.4375, // average of days in a month (including leap years) + + /** + * One year (31,557,600,000 ms). + */ + Year = Day * 365.25 // average with leap years +} + +export const emojis = Object.freeze({ + success: '<:success:837109864101707807>', + warn: '<:warn:848726900876247050>', + error: '<:error:837123021016924261>', + successFull: '<:success_full:850118767576088646>', + warnFull: '<:warn_full:850118767391539312>', + errorFull: '<:error_full:850118767295201350>', + mad: '<:mad:783046135392239626>', + join: '<:join:850198029809614858>', + leave: '<:leave:850198048205307919>', + loading: '<a:Loading:853419254619963392>', + offlineCircle: '<:offline:787550565382750239>', + dndCircle: '<:dnd:787550487633330176>', + idleCircle: '<:idle:787550520956551218>', + onlineCircle: '<:online:787550449435803658>', + cross: '<:cross:878319362539421777>', + check: '<:check:878320135297961995>' +} as const); + +export const emojisRaw = Object.freeze({ + success: '837109864101707807', + warn: '848726900876247050', + error: '837123021016924261', + successFull: '850118767576088646', + warnFull: '850118767391539312', + errorFull: '850118767295201350', + mad: '783046135392239626', + join: '850198029809614858', + leave: '850198048205307919', + loading: '853419254619963392', + offlineCircle: '787550565382750239', + dndCircle: '787550487633330176', + idleCircle: '787550520956551218', + onlineCircle: '787550449435803658', + cross: '878319362539421777', + check: '878320135297961995' +} as const); + +export const colors = Object.freeze({ + default: 0x1fd8f1, + error: 0xef4947, + warn: 0xfeba12, + success: 0x3bb681, + info: 0x3b78ff, + red: 0xff0000, + blue: 0x0055ff, + aqua: 0x00bbff, + purple: 0x8400ff, + blurple: 0x5440cd, + newBlurple: 0x5865f2, + pink: 0xff00e6, + green: 0x00ff1e, + darkGreen: 0x008f11, + gold: 0xb59400, + yellow: 0xffff00, + white: 0xffffff, + gray: 0xa6a6a6, + lightGray: 0xcfcfcf, + darkGray: 0x7a7a7a, + black: 0x000000, + orange: 0xe86100, + ...Colors +} as const); + +// Somewhat stolen from @Mzato0001 +export const timeUnits = deepLock({ + milliseconds: { + match: / (?:(?<milliseconds>-?(?:\d+)?\.?\d+) *(?:milliseconds?|msecs?|ms))/im, + value: Time.Millisecond + }, + seconds: { + match: / (?:(?<seconds>-?(?:\d+)?\.?\d+) *(?:seconds?|secs?|s))/im, + value: Time.Second + }, + minutes: { + match: / (?:(?<minutes>-?(?:\d+)?\.?\d+) *(?:minutes?|mins?|m))/im, + value: Time.Minute + }, + hours: { + match: / (?:(?<hours>-?(?:\d+)?\.?\d+) *(?:hours?|hrs?|h))/im, + value: Time.Hour + }, + days: { + match: / (?:(?<days>-?(?:\d+)?\.?\d+) *(?:days?|d))/im, + value: Time.Day + }, + weeks: { + match: / (?:(?<weeks>-?(?:\d+)?\.?\d+) *(?:weeks?|w))/im, + value: Time.Week + }, + months: { + match: / (?:(?<months>-?(?:\d+)?\.?\d+) *(?:months?|mon|mo))/im, + value: Time.Month + }, + years: { + match: / (?:(?<years>-?(?:\d+)?\.?\d+) *(?:years?|y))/im, + value: Time.Year + } +} as const); + +export const regex = deepLock({ + snowflake: /^\d{15,21}$/im, + + discordEmoji: /<a?:(?<name>[a-zA-Z0-9_]+):(?<id>\d{15,21})>/im, + + /* + * Taken with permission from Geek: + * https://github.com/FireDiscordBot/bot/blob/5d1990e5f8b52fcc72261d786aa3c7c7c65ab5e8/lib/util/constants.ts#L276 + */ + /** **This has the global flag, make sure to handle it correctly.** */ + messageLink: + /<?https:\/\/(?:ptb\.|canary\.|staging\.)?discord(?:app)?\.com?\/channels\/(?<guild_id>\d{15,21})\/(?<channel_id>\d{15,21})\/(?<message_id>\d{15,21})>?/gim +} as const); + +/** + * Maps the response from pronoundb.org to a readable format + */ +export const pronounMapping = Object.freeze({ + unspecified: 'Unspecified', + hh: 'He/Him', + hi: 'He/It', + hs: 'He/She', + ht: 'He/They', + ih: 'It/Him', + ii: 'It/Its', + is: 'It/She', + it: 'It/They', + shh: 'She/He', + sh: 'She/Her', + si: 'She/It', + st: 'She/They', + th: 'They/He', + ti: 'They/It', + ts: 'They/She', + tt: 'They/Them', + any: 'Any pronouns', + other: 'Other pronouns', + ask: 'Ask me my pronouns', + avoid: 'Avoid pronouns, use my name' +} as const); + +/** + * A bunch of mappings + */ +export const mappings = deepLock({ + guilds: { + "Moulberry's Bush": '516977525906341928', + "Moulberry's Tree": '767448775450820639', + 'MB Staff': '784597260465995796', + "IRONM00N's Space Ship": '717176538717749358' + }, + + channels: { + 'neu-support': '714332750156660756', + 'giveaways': '767782084981817344' + }, + + users: { + IRONM00N: '322862723090219008', + Moulberry: '211288288055525376', + nopo: '384620942577369088', + Bestower: '496409778822709251' + }, + + permissions: { + CreateInstantInvite: { name: 'Create Invite', important: false }, + KickMembers: { name: 'Kick Members', important: true }, + BanMembers: { name: 'Ban Members', important: true }, + Administrator: { name: 'Administrator', important: true }, + ManageChannels: { name: 'Manage Channels', important: true }, + ManageGuild: { name: 'Manage Server', important: true }, + AddReactions: { name: 'Add Reactions', important: false }, + ViewAuditLog: { name: 'View Audit Log', important: true }, + PrioritySpeaker: { name: 'Priority Speaker', important: true }, + Stream: { name: 'Video', important: false }, + ViewChannel: { name: 'View Channel', important: false }, + SendMessages: { name: 'Send Messages', important: false }, + SendTTSMessages: { name: 'Send Text-to-Speech Messages', important: true }, + ManageMessages: { name: 'Manage Messages', important: true }, + EmbedLinks: { name: 'Embed Links', important: false }, + AttachFiles: { name: 'Attach Files', important: false }, + ReadMessageHistory: { name: 'Read Message History', important: false }, + MentionEveryone: { name: 'Mention @\u200Beveryone, @\u200Bhere, and All Roles', important: true }, // name has a zero-width space to prevent accidents + UseExternalEmojis: { name: 'Use External Emoji', important: false }, + ViewGuildInsights: { name: 'View Server Insights', important: true }, + Connect: { name: 'Connect', important: false }, + Speak: { name: 'Speak', important: false }, + MuteMembers: { name: 'Mute Members', important: true }, + DeafenMembers: { name: 'Deafen Members', important: true }, + MoveMembers: { name: 'Move Members', important: true }, + UseVAD: { name: 'Use Voice Activity', important: false }, + ChangeNickname: { name: 'Change Nickname', important: false }, + ManageNicknames: { name: 'Change Nicknames', important: true }, + ManageRoles: { name: 'Manage Roles', important: true }, + ManageWebhooks: { name: 'Manage Webhooks', important: true }, + ManageEmojisAndStickers: { name: 'Manage Emojis and Stickers', important: true }, + UseApplicationCommands: { name: 'Use Slash Commands', important: false }, + RequestToSpeak: { name: 'Request to Speak', important: false }, + ManageEvents: { name: 'Manage Events', important: true }, + ManageThreads: { name: 'Manage Threads', important: true }, + CreatePublicThreads: { name: 'Create Public Threads', important: false }, + CreatePrivateThreads: { name: 'Create Private Threads', important: false }, + UseExternalStickers: { name: 'Use External Stickers', important: false }, + SendMessagesInThreads: { name: 'Send Messages In Threads', important: false }, + StartEmbeddedActivities: { name: 'Start Activities', important: false }, + ModerateMembers: { name: 'Timeout Members', important: true }, + UseEmbeddedActivities: { name: 'Use Activities', important: false } + }, + + // prettier-ignore + features: { + [GuildFeature.Verified]: { name: 'Verified', important: true, emoji: '<:verified:850795049817473066>', weight: 0 }, + [GuildFeature.Partnered]: { name: 'Partnered', important: true, emoji: '<:partneredServer:850794851955507240>', weight: 1 }, + [GuildFeature.MoreStickers]: { name: 'More Stickers', important: true, emoji: null, weight: 2 }, + MORE_EMOJIS: { name: 'More Emoji', important: true, emoji: '<:moreEmoji:850786853497602080>', weight: 3 }, + [GuildFeature.Featurable]: { name: 'Featurable', important: true, emoji: '<:featurable:850786776372084756>', weight: 4 }, + [GuildFeature.RelayEnabled]: { name: 'Relay Enabled', important: true, emoji: '<:relayEnabled:850790531441229834>', weight: 5 }, + [GuildFeature.Discoverable]: { name: 'Discoverable', important: true, emoji: '<:discoverable:850786735360966656>', weight: 6 }, + ENABLED_DISCOVERABLE_BEFORE: { name: 'Enabled Discovery Before', important: false, emoji: '<:enabledDiscoverableBefore:850786754670624828>', weight: 7 }, + [GuildFeature.MonetizationEnabled]: { name: 'Monetization Enabled', important: true, emoji: null, weight: 8 }, + [GuildFeature.TicketedEventsEnabled]: { name: 'Ticketed Events Enabled', important: true, emoji: null, weight: 9 }, + [GuildFeature.PreviewEnabled]: { name: 'Preview Enabled', important: true, emoji: '<:previewEnabled:850790508266913823>', weight: 10 }, + COMMERCE: { name: 'Store Channels', important: true, emoji: '<:storeChannels:850786692432396338>', weight: 11 }, + [GuildFeature.VanityURL]: { name: 'Vanity URL', important: false, emoji: '<:vanityURL:850790553079644160>', weight: 12 }, + [GuildFeature.VIPRegions]: { name: 'VIP Regions', important: false, emoji: '<:VIPRegions:850794697496854538>', weight: 13 }, + [GuildFeature.AnimatedIcon]: { name: 'Animated Icon', important: false, emoji: '<:animatedIcon:850774498071412746>', weight: 14 }, + [GuildFeature.Banner]: { name: 'Banner', important: false, emoji: '<:banner:850786673150787614>', weight: 15 }, + [GuildFeature.InviteSplash]: { name: 'Invite Splash', important: false, emoji: '<:inviteSplash:850786798246559754>', weight: 16 }, + [GuildFeature.PrivateThreads]: { name: 'Private Threads', important: false, emoji: '<:privateThreads:869763711894700093>', weight: 17 }, + THREE_DAY_THREAD_ARCHIVE: { name: 'Three Day Thread Archive', important: false, emoji: '<:threeDayThreadArchive:869767841652564008>', weight: 19 }, + SEVEN_DAY_THREAD_ARCHIVE: { name: 'Seven Day Thread Archive', important: false, emoji: '<:sevenDayThreadArchive:869767896123998288>', weight: 20 }, + [GuildFeature.RoleIcons]: { name: 'Role Icons', important: false, emoji: '<:roleIcons:876993381929222175>', weight: 21 }, + [GuildFeature.News]: { name: 'Announcement Channels', important: false, emoji: '<:announcementChannels:850790491796013067>', weight: 22 }, + [GuildFeature.MemberVerificationGateEnabled]: { name: 'Membership Verification Gate', important: false, emoji: '<:memberVerificationGateEnabled:850786829984858212>', weight: 23 }, + [GuildFeature.WelcomeScreenEnabled]: { name: 'Welcome Screen Enabled', important: false, emoji: '<:welcomeScreenEnabled:850790575875817504>', weight: 24 }, + [GuildFeature.Community]: { name: 'Community', important: false, emoji: '<:community:850786714271875094>', weight: 25 }, + THREADS_ENABLED: {name: 'Threads Enabled', important: false, emoji: '<:threadsEnabled:869756035345317919>', weight: 26 }, + THREADS_ENABLED_TESTING: {name: 'Threads Enabled Testing', important: false, emoji: null, weight: 27 }, + [GuildFeature.AnimatedBanner]: { name: 'Animated Banner', important: false, emoji: null, weight: 28 }, + [GuildFeature.HasDirectoryEntry]: { name: 'Has Directory Entry', important: true, emoji: null, weight: 29 }, + [GuildFeature.Hub]: { name: 'Hub', important: true, emoji: null, weight: 30 }, + [GuildFeature.LinkedToHub]: { name: 'Linked To Hub', important: true, emoji: null, weight: 31 }, + }, + + regions: { + 'automatic': ':united_nations: Automatic', + 'brazil': ':flag_br: Brazil', + 'europe': ':flag_eu: Europe', + 'hongkong': ':flag_hk: Hongkong', + 'india': ':flag_in: India', + 'japan': ':flag_jp: Japan', + 'russia': ':flag_ru: Russia', + 'singapore': ':flag_sg: Singapore', + 'southafrica': ':flag_za: South Africa', + 'sydney': ':flag_au: Sydney', + 'us-central': ':flag_us: US Central', + 'us-east': ':flag_us: US East', + 'us-south': ':flag_us: US South', + 'us-west': ':flag_us: US West' + }, + + otherEmojis: { + ServerBooster1: '<:serverBooster1:848740052091142145>', + ServerBooster2: '<:serverBooster2:848740090506510388>', + ServerBooster3: '<:serverBooster3:848740124992077835>', + ServerBooster6: '<:serverBooster6:848740155245461514>', + ServerBooster9: '<:serverBooster9:848740188846030889>', + ServerBooster12: '<:serverBooster12:848740304365551668>', + ServerBooster15: '<:serverBooster15:848740354890137680>', + ServerBooster18: '<:serverBooster18:848740402886606868>', + ServerBooster24: '<:serverBooster24:848740444628320256>', + Nitro: '<:nitro:848740498054971432>', + Booster: '<:booster:848747775020892200>', + Owner: '<:owner:848746439311753286>', + Admin: '<:admin:848963914628333598>', + Superuser: '<:superUser:848947986326224926>', + Developer: '<:developer:848954538111139871>', + Bot: '<:bot:1006929813203853427>', + BushVerified: '<:verfied:853360152090771497>', + BoostTier1: '<:boostitle:853363736679940127>', + BoostTier2: '<:boostitle:853363752728789075>', + BoostTier3: '<:boostitle:853363769132056627>', + ChannelText: '<:text:853375537791893524>', + ChannelNews: '<:announcements:853375553531674644>', + ChannelVoice: '<:voice:853375566735212584>', + ChannelStage: '<:stage:853375583521210468>', + // ChannelStore: '<:store:853375601175691266>', + ChannelCategory: '<:category:853375615260819476>', + ChannelThread: '<:thread:865033845753249813>' + }, + + userFlags: { + Staff: '<:discordEmployee:848742947826434079>', + Partner: '<:partneredServerOwner:848743051593777152>', + Hypesquad: '<:hypeSquadEvents:848743108283072553>', + BugHunterLevel1: '<:bugHunter:848743239850393640>', + HypeSquadOnlineHouse1: '<:hypeSquadBravery:848742910563844127>', + HypeSquadOnlineHouse2: '<:hypeSquadBrilliance:848742840649646101>', + HypeSquadOnlineHouse3: '<:hypeSquadBalance:848742877537370133>', + PremiumEarlySupporter: '<:earlySupporter:848741030102171648>', + TeamPseudoUser: 'TeamPseudoUser', + BugHunterLevel2: '<:bugHunterGold:848743283080822794>', + VerifiedBot: '<:verifiedbot_rebrand1:938928232667947028><:verifiedbot_rebrand2:938928355707879475>', + VerifiedDeveloper: '<:earlyVerifiedBotDeveloper:848741079875846174>', + CertifiedModerator: '<:discordCertifiedModerator:877224285901582366>', + BotHTTPInteractions: 'BotHTTPInteractions', + Spammer: 'Spammer', + Quarantined: 'Quarantined' + }, + + status: { + online: '<:online:848937141639577690>', + idle: '<:idle:848937158261211146>', + dnd: '<:dnd:848937173780135986>', + offline: '<:offline:848939387277672448>', + streaming: '<:streaming:848937187479519242>' + }, + + maybeNitroDiscrims: ['1111', '2222', '3333', '4444', '5555', '6666', '6969', '7777', '8888', '9999'], + + capes: [ + /* supporter capes */ + { name: 'patreon1', purchasable: false /* moulberry no longer offers */ }, + { name: 'patreon2', purchasable: false /* moulberry no longer offers */ }, + { name: 'fade', custom: `${rawCapeUrl}fade.gif`, purchasable: true }, + { name: 'lava', custom: `${rawCapeUrl}lava.gif`, purchasable: true }, + { name: 'mcworld', custom: `${rawCapeUrl}mcworld_compressed.gif`, purchasable: true }, + { name: 'negative', custom: `${rawCapeUrl}negative_compressed.gif`, purchasable: true }, + { name: 'space', custom: `${rawCapeUrl}space_compressed.gif`, purchasable: true }, + { name: 'void', custom: `${rawCapeUrl}void.gif`, purchasable: true }, + { name: 'tunnel', custom: `${rawCapeUrl}tunnel.gif`, purchasable: true }, + /* Staff capes */ + { name: 'contrib' }, + { name: 'mbstaff' }, + { name: 'ironmoon' }, + { name: 'gravy' }, + { name: 'nullzee' }, + /* partner capes */ + { name: 'thebakery' }, + { name: 'dsm' }, + { name: 'packshq' }, + { name: 'furf' }, + { name: 'skytils' }, + { name: 'sbp' }, + { name: 'subreddit_light' }, + { name: 'subreddit_dark' }, + { name: 'skyclient' }, + { name: 'sharex' }, + { name: 'sharex_white' }, + /* streamer capes */ + { name: 'alexxoffi' }, + { name: 'jakethybro' }, + { name: 'krusty' }, + { name: 'krusty_day' }, + { name: 'krusty_night' }, + { name: 'krusty_sunset' }, + { name: 'soldier' }, + { name: 'zera' }, + { name: 'secondpfirsisch' }, + { name: 'stormy_lh' } + ].map((value, index) => ({ ...value, index })), + + roleMap: [ + { name: '*', id: '792453550768390194' }, + { name: 'Admin Perms', id: '746541309853958186' }, + { name: 'Sr. Moderator', id: '782803470205190164' }, + { name: 'Moderator', id: '737308259823910992' }, + { name: 'Helper', id: '737440116230062091' }, + { name: 'Trial Helper', id: '783537091946479636' }, + { name: 'Contributor', id: '694431057532944425' }, + { name: 'Giveaway Donor', id: '784212110263451649' }, + { name: 'Giveaway (200m)', id: '810267756426690601' }, + { name: 'Giveaway (100m)', id: '801444430522613802' }, + { name: 'Giveaway (50m)', id: '787497512981757982' }, + { name: 'Giveaway (25m)', id: '787497515771232267' }, + { name: 'Giveaway (10m)', id: '787497518241153025' }, + { name: 'Giveaway (5m)', id: '787497519768403989' }, + { name: 'Giveaway (1m)', id: '787497521084891166' }, + { name: 'Suggester', id: '811922322767609877' }, + { name: 'Partner', id: '767324547312779274' }, + { name: 'Level Locked', id: '784248899044769792' }, + { name: 'No Files', id: '786421005039173633' }, + { name: 'No Reactions', id: '786421270924361789' }, + { name: 'No Links', id: '786421269356740658' }, + { name: 'No Bots', id: '786804858765312030' }, + { name: 'No VC', id: '788850482554208267' }, + { name: 'No Giveaways', id: '808265422334984203' }, + { name: 'No Support', id: '790247359824396319' } + ], + + roleWhitelist: { + 'Partner': ['*', 'Admin Perms', 'Sr. Moderator', 'Moderator'], + 'Suggester': ['*', 'Admin Perms', 'Sr. Moderator', 'Moderator', 'Helper', 'Trial Helper', 'Contributor'], + 'Level Locked': ['*', 'Admin Perms', 'Sr. Moderator', 'Moderator'], + 'No Files': ['*', 'Admin Perms', 'Sr. Moderator', 'Moderator'], + 'No Reactions': ['*', 'Admin Perms', 'Sr. Moderator', 'Moderator'], + 'No Links': ['*', 'Admin Perms', 'Sr. Moderator', 'Moderator'], + 'No Bots': ['*', 'Admin Perms', 'Sr. Moderator', 'Moderator'], + 'No VC': ['*', 'Admin Perms', 'Sr. Moderator', 'Moderator'], + 'No Giveaways': ['*', 'Admin Perms', 'Sr. Moderator', 'Moderator', 'Helper'], + 'No Support': ['*', 'Admin Perms', 'Sr. Moderator', 'Moderator'], + 'Giveaway Donor': ['*', 'Admin Perms', 'Sr. Moderator', 'Moderator'], + 'Giveaway (200m)': ['*', 'Admin Perms', 'Sr. Moderator', 'Moderator'], + 'Giveaway (100m)': ['*', 'Admin Perms', 'Sr. Moderator', 'Moderator'], + 'Giveaway (50m)': ['*', 'Admin Perms', 'Sr. Moderator', 'Moderator'], + 'Giveaway (25m)': ['*', 'Admin Perms', 'Sr. Moderator', 'Moderator'], + 'Giveaway (10m)': ['*', 'Admin Perms', 'Sr. Moderator', 'Moderator'], + 'Giveaway (5m)': ['*', 'Admin Perms', 'Sr. Moderator', 'Moderator'], + 'Giveaway (1m)': ['*', 'Admin Perms', 'Sr. Moderator', 'Moderator'] + } +} as const); + +export const ArgumentMatches = Object.freeze({ + ...AkairoArgumentMatches +} as const); + +export const ArgumentTypes = Object.freeze({ + ...AkairoArgumentTypes, + DURATION: 'duration', + CONTENT_WITH_DURATION: 'contentWithDuration', + PERMISSION: 'permission', + SNOWFLAKE: 'snowflake', + DISCORD_EMOJI: 'discordEmoji', + ROLE_WITH_DURATION: 'roleWithDuration', + ABBREVIATED_NUMBER: 'abbreviatedNumber', + GLOBAL_USER: 'globalUser' +} as const); + +export const BlockedReasons = Object.freeze({ + ...BuiltInReasons, + DISABLED_GUILD: 'disabledGuild', + DISABLED_GLOBAL: 'disabledGlobal', + ROLE_BLACKLIST: 'roleBlacklist', + USER_GUILD_BLACKLIST: 'userGuildBlacklist', + USER_GLOBAL_BLACKLIST: 'userGlobalBlacklist', + RESTRICTED_GUILD: 'restrictedGuild', + CHANNEL_GUILD_BLACKLIST: 'channelGuildBlacklist', + CHANNEL_GLOBAL_BLACKLIST: 'channelGlobalBlacklist', + RESTRICTED_CHANNEL: 'restrictedChannel' +} as const); + +export const CommandHandlerEvents = Object.freeze({ + ...AkairoCommandHandlerEvents +} as const); + +export const moulberryBushRoleMap = deepLock([ + { name: '*', id: '792453550768390194' }, + { name: 'Admin Perms', id: '746541309853958186' }, + { name: 'Sr. Moderator', id: '782803470205190164' }, + { name: 'Moderator', id: '737308259823910992' }, + { name: 'Helper', id: '737440116230062091' }, + { name: 'Trial Helper', id: '783537091946479636' }, + { name: 'Contributor', id: '694431057532944425' }, + { name: 'Giveaway Donor', id: '784212110263451649' }, + { name: 'Giveaway (200m)', id: '810267756426690601' }, + { name: 'Giveaway (100m)', id: '801444430522613802' }, + { name: 'Giveaway (50m)', id: '787497512981757982' }, + { name: 'Giveaway (25m)', id: '787497515771232267' }, + { name: 'Giveaway (10m)', id: '787497518241153025' }, + { name: 'Giveaway (5m)', id: '787497519768403989' }, + { name: 'Giveaway (1m)', id: '787497521084891166' }, + { name: 'Suggester', id: '811922322767609877' }, + { name: 'Partner', id: '767324547312779274' }, + { name: 'Level Locked', id: '784248899044769792' }, + { name: 'No Files', id: '786421005039173633' }, + { name: 'No Reactions', id: '786421270924361789' }, + { name: 'No Links', id: '786421269356740658' }, + { name: 'No Bots', id: '786804858765312030' }, + { name: 'No VC', id: '788850482554208267' }, + { name: 'No Giveaways', id: '808265422334984203' }, + { name: 'No Support', id: '790247359824396319' } +] as const); + +export type PronounCode = keyof typeof pronounMapping; +export type Pronoun = typeof pronounMapping[PronounCode]; diff --git a/lib/utils/BushLogger.ts b/lib/utils/BushLogger.ts new file mode 100644 index 0000000..4acda69 --- /dev/null +++ b/lib/utils/BushLogger.ts @@ -0,0 +1,315 @@ +import chalk from 'chalk'; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { bold, Client, EmbedBuilder, escapeMarkdown, PartialTextBasedChannelFields, type Message } from 'discord.js'; +import { stripVTControlCharacters as stripColor } from 'node:util'; +import repl, { REPLServer, REPL_MODE_STRICT } from 'repl'; +import { WriteStream } from 'tty'; +import { type SendMessageType } from '../extensions/discord-akairo/BushClient.js'; +import { colors } from './BushConstants.js'; +import { inspect } from './BushUtils.js'; + +let REPL: REPLServer; +let replGone = false; + +export function init() { + const kFormatForStdout = Object.getOwnPropertySymbols(console).find((sym) => sym.toString() === 'Symbol(kFormatForStdout)')!; + const kFormatForStderr = Object.getOwnPropertySymbols(console).find((sym) => sym.toString() === 'Symbol(kFormatForStderr)')!; + + REPL = repl.start({ + useColors: true, + terminal: true, + useGlobal: true, + replMode: REPL_MODE_STRICT, + breakEvalOnSigint: true, + ignoreUndefined: true + }); + + const apply = (stream: WriteStream, symbol: symbol): ProxyHandler<typeof console['log']>['apply'] => + function apply(target, thisArg, args) { + if (stream.isTTY) { + stream.moveCursor(0, -1); + stream.write('\n'); + stream.clearLine(0); + } + + const ret = target(...args); + + if (stream.isTTY) { + const formatted = (console as any)[symbol](args) as string; + + stream.moveCursor(0, formatted.split('\n').length); + if (!replGone) { + REPL.displayPrompt(true); + } + } + + return ret; + }; + + global.console.log = new Proxy(console.log, { + apply: apply(process.stdout, kFormatForStdout) + }); + + global.console.warn = new Proxy(console.warn, { + apply: apply(process.stderr, kFormatForStderr) + }); + + REPL.on('exit', () => { + replGone = true; + process.exit(0); + }); +} + +/** + * Parses the content surrounding by `<<>>` and emphasizes it with the given color or by making it bold. + * @param content The content to parse. + * @param color The color to emphasize the content with. + * @param discordFormat Whether or not to format the content for discord. + * @returns The formatted content. + */ +function parseFormatting( + content: any, + color: 'blueBright' | 'blackBright' | 'redBright' | 'yellowBright' | 'greenBright' | '', + discordFormat = false +): string | typeof content { + if (typeof content !== 'string') return content; + return content + .split(/<<|>>/) + .map((value, index) => { + if (discordFormat) { + return index % 2 === 0 ? escapeMarkdown(value) : bold(escapeMarkdown(value)); + } else { + return index % 2 === 0 || !color ? value : chalk[color](value); + } + }) + .join(''); +} + +/** + * Inspects the content and returns a string. + * @param content The content to inspect. + * @param depth The depth the content will inspected. Defaults to `2`. + * @param colors Whether or not to use colors in the output. Defaults to `true`. + * @returns The inspected content. + */ +function inspectContent(content: any, depth = 2, colors = true): string { + if (typeof content !== 'string') { + return inspect(content, { depth, colors }); + } + return content; +} + +/** + * Generates a formatted timestamp for logging. + * @returns The formatted timestamp. + */ +function getTimeStamp(): string { + const now = new Date(); + const minute = pad(now.getMinutes()); + const hour = pad(now.getHours()); + const date = `${pad(now.getMonth() + 1)}/${pad(now.getDate())}`; + return `${date} ${hour}:${minute}`; +} + +/** + * Pad a two-digit number. + */ +function pad(num: number) { + return num.toString().padStart(2, '0'); +} + +/** + * Custom logging utility for the bot. + */ +export class BushLogger { + /** + * @param client The client. + */ + public constructor(public client: Client) {} + + /** + * Logs information. Highlight information by surrounding it in `<<>>`. + * @param header The header displayed before the content, displayed in cyan. + * @param content The content to log, highlights displayed in bright blue. + * @param sendChannel Should this also be logged to discord? Defaults to false. + * @param depth The depth the content will inspected. Defaults to 0. + */ + public get log() { + return this.info; + } + + /** + * Sends a message to the log channel. + * @param message The parameter to pass to {@link PartialTextBasedChannelFields.send}. + * @returns The message sent. + */ + public async channelLog(message: SendMessageType): Promise<Message | null> { + const channel = await this.client.utils.getConfigChannel('log'); + if (channel === null) return null; + return await channel.send(message).catch(() => null); + } + + /** + * Sends a message to the error channel. + * @param message The parameter to pass to {@link PartialTextBasedChannelFields.send}. + * @returns The message sent. + */ + public async channelError(message: SendMessageType): Promise<Message | null> { + const channel = await this.client.utils.getConfigChannel('error'); + if (!channel) { + void this.error( + 'BushLogger', + `Could not find error channel, was originally going to send: \n${inspect(message, { + colors: true + })}\n${new Error().stack?.substring(8)}`, + false + ); + return null; + } + return await channel.send(message); + } + + /** + * Logs debug information. Only works in dev is enabled in the config. + * @param content The content to log. + * @param depth The depth the content will inspected. Defaults to `0`. + */ + public debug(content: any, depth = 0): void { + if (!this.client.config.isDevelopment) return; + const newContent = inspectContent(content, depth, true); + console.log(`${chalk.bgMagenta(getTimeStamp())} ${chalk.magenta('[Debug]')} ${newContent}`); + } + + /** + * Logs raw debug information. Only works in dev is enabled in the config. + * @param content The content to log. + */ + public debugRaw(...content: any): void { + if (!this.client.config.isDevelopment) return; + console.log(`${chalk.bgMagenta(getTimeStamp())} ${chalk.magenta('[Debug]')}`, ...content); + } + + /** + * Logs verbose information. Highlight information by surrounding it in `<<>>`. + * @param header The header printed before the content, displayed in grey. + * @param content The content to log, highlights displayed in bright black. + * @param sendChannel Should this also be logged to discord? Defaults to `false`. + * @param depth The depth the content will inspected. Defaults to `0`. + */ + public async verbose(header: string, content: any, sendChannel = false, depth = 0): Promise<void> { + if (!this.client.config.logging.verbose) return; + const newContent = inspectContent(content, depth, true); + console.log(`${chalk.bgGrey(getTimeStamp())} ${chalk.grey(`[${header}]`)} ${parseFormatting(newContent, 'blackBright')}`); + if (!sendChannel) return; + const embed = new EmbedBuilder() + .setDescription(`**[${header}]** ${parseFormatting(stripColor(newContent), '', true)}`) + .setColor(colors.gray) + .setTimestamp(); + await this.channelLog({ embeds: [embed] }); + } + + /** + * Logs very verbose information. Highlight information by surrounding it in `<<>>`. + * @param header The header printed before the content, displayed in purple. + * @param content The content to log, highlights displayed in bright black. + * @param depth The depth the content will inspected. Defaults to `0`. + */ + public async superVerbose(header: string, content: any, depth = 0): Promise<void> { + if (!this.client.config.logging.verbose) return; + const newContent = inspectContent(content, depth, true); + console.log( + `${chalk.bgHex('#949494')(getTimeStamp())} ${chalk.hex('#949494')(`[${header}]`)} ${chalk.hex('#b3b3b3')(newContent)}` + ); + } + + /** + * Logs raw very verbose information. + * @param header The header printed before the content, displayed in purple. + * @param content The content to log. + */ + public async superVerboseRaw(header: string, ...content: any[]): Promise<void> { + if (!this.client.config.logging.verbose) return; + console.log(`${chalk.bgHex('#a3a3a3')(getTimeStamp())} ${chalk.hex('#a3a3a3')(`[${header}]`)}`, ...content); + } + + /** + * Logs information. Highlight information by surrounding it in `<<>>`. + * @param header The header displayed before the content, displayed in cyan. + * @param content The content to log, highlights displayed in bright blue. + * @param sendChannel Should this also be logged to discord? Defaults to `false`. + * @param depth The depth the content will inspected. Defaults to `0`. + */ + public async info(header: string, content: any, sendChannel = true, depth = 0): Promise<void> { + if (!this.client.config.logging.info) return; + const newContent = inspectContent(content, depth, true); + console.log(`${chalk.bgCyan(getTimeStamp())} ${chalk.cyan(`[${header}]`)} ${parseFormatting(newContent, 'blueBright')}`); + if (!sendChannel) return; + const embed = new EmbedBuilder() + .setDescription(`**[${header}]** ${parseFormatting(stripColor(newContent), '', true)}`) + .setColor(colors.info) + .setTimestamp(); + await this.channelLog({ embeds: [embed] }); + } + + /** + * Logs warnings. Highlight information by surrounding it in `<<>>`. + * @param header The header displayed before the content, displayed in yellow. + * @param content The content to log, highlights displayed in bright yellow. + * @param sendChannel Should this also be logged to discord? Defaults to `false`. + * @param depth The depth the content will inspected. Defaults to `0`. + */ + public async warn(header: string, content: any, sendChannel = true, depth = 0): Promise<void> { + const newContent = inspectContent(content, depth, true); + console.warn( + `${chalk.bgYellow(getTimeStamp())} ${chalk.yellow(`[${header}]`)} ${parseFormatting(newContent, 'yellowBright')}` + ); + + if (!sendChannel) return; + const embed = new EmbedBuilder() + .setDescription(`**[${header}]** ${parseFormatting(stripColor(newContent), '', true)}`) + .setColor(colors.warn) + .setTimestamp(); + await this.channelError({ embeds: [embed] }); + } + + /** + * Logs errors. Highlight information by surrounding it in `<<>>`. + * @param header The header displayed before the content, displayed in bright red. + * @param content The content to log, highlights displayed in bright red. + * @param sendChannel Should this also be logged to discord? Defaults to `false`. + * @param depth The depth the content will inspected. Defaults to `0`. + */ + public async error(header: string, content: any, sendChannel = true, depth = 0): Promise<void> { + const newContent = inspectContent(content, depth, true); + console.warn( + `${chalk.bgRedBright(getTimeStamp())} ${chalk.redBright(`[${header}]`)} ${parseFormatting(newContent, 'redBright')}` + ); + if (!sendChannel) return; + const embed = new EmbedBuilder() + .setDescription(`**[${header}]** ${parseFormatting(stripColor(newContent), '', true)}`) + .setColor(colors.error) + .setTimestamp(); + await this.channelError({ embeds: [embed] }); + return; + } + + /** + * Logs successes. Highlight information by surrounding it in `<<>>`. + * @param header The header displayed before the content, displayed in green. + * @param content The content to log, highlights displayed in bright green. + * @param sendChannel Should this also be logged to discord? Defaults to `false`. + * @param depth The depth the content will inspected. Defaults to `0`. + */ + public async success(header: string, content: any, sendChannel = true, depth = 0): Promise<void> { + const newContent = inspectContent(content, depth, true); + console.log( + `${chalk.bgGreen(getTimeStamp())} ${chalk.greenBright(`[${header}]`)} ${parseFormatting(newContent, 'greenBright')}` + ); + if (!sendChannel) return; + const embed = new EmbedBuilder() + .setDescription(`**[${header}]** ${parseFormatting(stripColor(newContent), '', true)}`) + .setColor(colors.success) + .setTimestamp(); + await this.channelLog({ embeds: [embed] }).catch(() => {}); + } +} diff --git a/lib/utils/BushUtils.ts b/lib/utils/BushUtils.ts new file mode 100644 index 0000000..34ea461 --- /dev/null +++ b/lib/utils/BushUtils.ts @@ -0,0 +1,613 @@ +import { + Arg, + BushClient, + CommandMessage, + SlashEditMessageType, + SlashSendMessageType, + timeUnits, + type BaseBushArgumentType, + type BushInspectOptions, + type SlashMessage +} from '#lib'; +import { humanizeDuration as humanizeDurationMod } from '@notenoughupdates/humanize-duration'; +import assert from 'assert/strict'; +import cp from 'child_process'; +import deepLock from 'deep-lock'; +import { Util as AkairoUtil } from 'discord-akairo'; +import { + Constants as DiscordConstants, + EmbedBuilder, + Message, + OAuth2Scopes, + PermissionFlagsBits, + PermissionsBitField, + type APIEmbed, + type APIMessage, + type CommandInteraction, + type InteractionReplyOptions, + type PermissionsString +} from 'discord.js'; +import got from 'got'; +import { DeepWritable } from 'ts-essentials'; +import { inspect as inspectUtil, promisify } from 'util'; +import * as Format from './Format.js'; + +export type StripPrivate<T> = { [K in keyof T]: T[K] extends Record<string, any> ? StripPrivate<T[K]> : T[K] }; +export type ValueOf<T> = T[keyof T]; + +/** + * Capitalizes the first letter of the given text + * @param text The text to capitalize + * @returns The capitalized text + */ +export function capitalize(text: string): string { + return text.charAt(0).toUpperCase() + text.slice(1); +} + +export const exec = promisify(cp.exec); + +/** + * Runs a shell command and gives the output + * @param command The shell command to run + * @returns The stdout and stderr of the shell command + */ +export async function shell(command: string): Promise<{ stdout: string; stderr: string }> { + return await exec(command); +} + +/** + * Appends the correct ordinal to the given number + * @param n The number to append an ordinal to + * @returns The number with the ordinal + */ +export function ordinal(n: number): string { + const s = ['th', 'st', 'nd', 'rd'], + v = n % 100; + return n + (s[(v - 20) % 10] || s[v] || s[0]); +} + +/** + * Chunks an array to the specified size + * @param arr The array to chunk + * @param perChunk The amount of items per chunk + * @returns The chunked array + */ +export function chunk<T>(arr: T[], perChunk: number): T[][] { + return arr.reduce((all, one, i) => { + const ch: number = Math.floor(i / perChunk); + (all as any[])[ch] = [].concat(all[ch] || [], one as any); + return all; + }, []); +} + +/** + * Fetches a user's uuid from the mojang api. + * @param username The username to get the uuid of. + * @returns The the uuid of the user. + */ +export async function mcUUID(username: string, dashed = false): Promise<string> { + const apiRes = (await got.get(`https://api.ashcon.app/mojang/v2/user/${username}`).json()) as UuidRes; + + return dashed ? apiRes.uuid : apiRes.uuid.replace(/-/g, ''); +} + +export interface UuidRes { + uuid: string; + username: string; + username_history?: { username: string }[] | null; + textures: { + custom: boolean; + slim: boolean; + skin: { + url: string; + data: string; + }; + raw: { + value: string; + signature: string; + }; + }; + created_at: string; +} + +/** + * Generate defaults for {@link inspect}. + * @param options The options to create defaults with. + * @returns The default options combined with the specified options. + */ +function getDefaultInspectOptions(options?: BushInspectOptions): BushInspectOptions { + return { + showHidden: options?.showHidden ?? false, + depth: options?.depth ?? 2, + colors: options?.colors ?? false, + customInspect: options?.customInspect ?? true, + showProxy: options?.showProxy ?? false, + maxArrayLength: options?.maxArrayLength ?? Infinity, + maxStringLength: options?.maxStringLength ?? Infinity, + breakLength: options?.breakLength ?? 80, + compact: options?.compact ?? 3, + sorted: options?.sorted ?? false, + getters: options?.getters ?? true, + numericSeparator: options?.numericSeparator ?? true + }; +} + +/** + * Uses {@link inspect} with custom defaults. + * @param object - The object you would like to inspect. + * @param options - The options you would like to use to inspect the object. + * @returns The inspected object. + */ +export function inspect(object: any, options?: BushInspectOptions): string { + const optionsWithDefaults = getDefaultInspectOptions(options); + + if (!optionsWithDefaults.inspectStrings && typeof object === 'string') return object; + + return inspectUtil(object, optionsWithDefaults); +} + +/** + * Responds to a slash command interaction. + * @param interaction The interaction to respond to. + * @param responseOptions The options for the response. + * @returns The message sent. + */ +export async function slashRespond( + interaction: CommandInteraction, + responseOptions: SlashSendMessageType | SlashEditMessageType +): Promise<Message | APIMessage | undefined> { + const newResponseOptions = typeof responseOptions === 'string' ? { content: responseOptions } : responseOptions; + if (interaction.replied || interaction.deferred) { + delete (newResponseOptions as InteractionReplyOptions).ephemeral; // Cannot change a preexisting message to be ephemeral + return (await interaction.editReply(newResponseOptions)) as Message | APIMessage; + } else { + await interaction.reply(newResponseOptions); + return await interaction.fetchReply().catch(() => undefined); + } +} + +/** + * Takes an array and combines the elements using the supplied conjunction. + * @param array The array to combine. + * @param conjunction The conjunction to use. + * @param ifEmpty What to return if the array is empty. + * @returns The combined elements or `ifEmpty`. + * + * @example + * const permissions = oxford(['Administrator', 'SendMessages', 'ManageMessages'], 'and', 'none'); + * console.log(permissions); // Administrator, SendMessages and ManageMessages + */ +export function oxford(array: string[], conjunction: string, ifEmpty?: string): string | undefined { + const l = array.length; + if (!l) return ifEmpty; + if (l < 2) return array[0]; + if (l < 3) return array.join(` ${conjunction} `); + array = array.slice(); + array[l - 1] = `${conjunction} ${array[l - 1]}`; + return array.join(', '); +} + +/** + * Add or remove an item from an array. All duplicates will be removed. + * @param action Either `add` or `remove` an element. + * @param array The array to add/remove an element from. + * @param value The element to add/remove from the array. + */ +export function addOrRemoveFromArray<T>(action: 'add' | 'remove', array: T[], value: T): T[] { + const set = new Set(array); + action === 'add' ? set.add(value) : set.delete(value); + return [...set]; +} + +/** + * Remove an item from an array. All duplicates will be removed. + * @param array The array to remove an element from. + * @param value The element to remove from the array. + */ +export function removeFromArray<T>(array: T[], value: T): T[] { + return addOrRemoveFromArray('remove', array, value); +} + +/** + * Add an item from an array. All duplicates will be removed. + * @param array The array to add an element to. + * @param value The element to add to the array. + */ +export function addToArray<T>(array: T[], value: T): T[] { + return addOrRemoveFromArray('add', array, value); +} + +/** + * Surrounds a string to the begging an end of each element in an array. + * @param array The array you want to surround. + * @param surroundChar1 The character placed in the beginning of the element. + * @param surroundChar2 The character placed in the end of the element. Defaults to `surroundChar1`. + */ +export function surroundArray(array: string[], surroundChar1: string, surroundChar2?: string): string[] { + return array.map((a) => `${surroundChar1}${a}${surroundChar2 ?? surroundChar1}`); +} + +/** + * Gets the duration from a specified string. + * @param content The string to look for a duration in. + * @param remove Whether or not to remove the duration from the original string. + * @returns The {@link ParsedDuration}. + */ +export function parseDuration(content: string, remove = true): ParsedDuration { + if (!content) return { duration: 0, content: null }; + + // eslint-disable-next-line prefer-const + let duration: number | null = null; + // Try to reduce false positives by requiring a space before the duration, this makes sure it still matches if it is + // in the beginning of the argument + let contentWithoutTime = ` ${content}`; + + for (const unit in timeUnits) { + const regex = timeUnits[unit as keyof typeof timeUnits].match; + const match = regex.exec(contentWithoutTime); + const value = Number(match?.groups?.[unit]); + if (!isNaN(value)) duration! += value * timeUnits[unit as keyof typeof timeUnits].value; + + if (remove) contentWithoutTime = contentWithoutTime.replace(regex, ''); + } + // remove the space added earlier + if (contentWithoutTime.startsWith(' ')) contentWithoutTime.replace(' ', ''); + return { duration, content: contentWithoutTime }; +} + +export interface ParsedDuration { + duration: number | null; + content: string | null; +} + +/** + * Converts a duration in milliseconds to a human readable form. + * @param duration The duration in milliseconds to convert. + * @param largest The maximum number of units to display for the duration. + * @param round Whether or not to round the smallest unit displayed. + * @returns A humanized string of the duration. + */ +export function humanizeDuration(duration: number, largest?: number, round = true): string { + if (largest) return humanizeDurationMod(duration, { language: 'en', maxDecimalPoints: 2, largest, round })!; + else return humanizeDurationMod(duration, { language: 'en', maxDecimalPoints: 2, round })!; +} + +/** + * Creates a formatted relative timestamp from a duration in milliseconds. + * @param duration The duration in milliseconds. + * @returns The formatted relative timestamp. + */ +export function timestampDuration(duration: number): string { + return `<t:${Math.round(new Date().getTime() / 1_000 + duration / 1_000)}:R>`; +} + +/** + * Creates a timestamp from a date. + * @param date The date to create a timestamp from. + * @param style The style of the timestamp. + * @returns The formatted timestamp. + * + * @see + * **Styles:** + * - **t**: Short Time ex. `16:20` + * - **T**: Long Time ex. `16:20:30 ` + * - **d**: Short Date ex. `20/04/2021` + * - **D**: Long Date ex. `20 April 2021` + * - **f**: Short Date/Time ex. `20 April 2021 16:20` + * - **F**: Long Date/Time ex. `Tuesday, 20 April 2021 16:20` + * - **R**: Relative Time ex. `2 months ago` + */ +export function timestamp<D extends Date | undefined | null>( + date: D, + style: TimestampStyle = 'f' +): D extends Date ? string : undefined { + if (!date) return date as unknown as D extends Date ? string : undefined; + return `<t:${Math.round(date.getTime() / 1_000)}:${style}>` as unknown as D extends Date ? string : undefined; +} + +export type TimestampStyle = 't' | 'T' | 'd' | 'D' | 'f' | 'F' | 'R'; + +/** + * Creates a human readable representation between a date and the current time. + * @param date The date to be compared with the current time. + * @param largest The maximum number of units to display for the duration. + * @param round Whether or not to round the smallest unit displayed. + * @returns A humanized string of the delta. + */ +export function dateDelta(date: Date, largest = 3, round = true): string { + return humanizeDuration(new Date().getTime() - date.getTime(), largest, round); +} + +/** + * Combines {@link timestamp} and {@link dateDelta} + * @param date The date to be compared with the current time. + * @param style The style of the timestamp. + * @returns The formatted timestamp. + * + * @see + * **Styles:** + * - **t**: Short Time ex. `16:20` + * - **T**: Long Time ex. `16:20:30 ` + * - **d**: Short Date ex. `20/04/2021` + * - **D**: Long Date ex. `20 April 2021` + * - **f**: Short Date/Time ex. `20 April 2021 16:20` + * - **F**: Long Date/Time ex. `Tuesday, 20 April 2021 16:20` + * - **R**: Relative Time ex. `2 months ago` + */ +export function timestampAndDelta(date: Date, style: TimestampStyle = 'D'): string { + return `${timestamp(date, style)} (${dateDelta(date)} ago)`; +} + +/** + * Convert a hex code to an rbg value. + * @param hex The hex code to convert. + * @returns The rbg value. + */ +export function hexToRgb(hex: string): string { + const arrBuff = new ArrayBuffer(4); + const vw = new DataView(arrBuff); + vw.setUint32(0, parseInt(hex, 16), false); + const arrByte = new Uint8Array(arrBuff); + + return `${arrByte[1]}, ${arrByte[2]}, ${arrByte[3]}`; +} + +/** + * Wait an amount in milliseconds. + * @returns A promise that resolves after the specified amount of milliseconds + */ +export const sleep = promisify(setTimeout); + +/** + * List the methods of an object. + * @param obj The object to get the methods of. + * @returns A string with each method on a new line. + */ +export function getMethods(obj: Record<string, any>): string { + // modified from https://stackoverflow.com/questions/31054910/get-functions-methods-of-a-class/31055217#31055217 + let props: string[] = []; + let obj_: Record<string, any> = new Object(obj); + + do { + const l = Object.getOwnPropertyNames(obj_) + .concat(Object.getOwnPropertySymbols(obj_).map((s) => s.toString())) + .sort() + .filter( + (p, i, arr) => + typeof Object.getOwnPropertyDescriptor(obj_, p)?.['get'] !== 'function' && // ignore getters + typeof Object.getOwnPropertyDescriptor(obj_, p)?.['set'] !== 'function' && // ignore setters + typeof obj_[p] === 'function' && // only the methods + p !== 'constructor' && // not the constructor + (i == 0 || p !== arr[i - 1]) && // not overriding in this prototype + props.indexOf(p) === -1 // not overridden in a child + ); + + const reg = /\(([\s\S]*?)\)/; + props = props.concat( + l.map( + (p) => + `${obj_[p] && obj_[p][Symbol.toStringTag] === 'AsyncFunction' ? 'async ' : ''}function ${p}(${ + reg.exec(obj_[p].toString())?.[1] + ? reg + .exec(obj_[p].toString())?.[1] + .split(', ') + .map((arg) => arg.split('=')[0].trim()) + .join(', ') + : '' + });` + ) + ); + } while ( + (obj_ = Object.getPrototypeOf(obj_)) && // walk-up the prototype chain + Object.getPrototypeOf(obj_) // not the the Object prototype methods (hasOwnProperty, etc...) + ); + + return props.join('\n'); +} + +/** + * List the symbols of an object. + * @param obj The object to get the symbols of. + * @returns An array of the symbols of the object. + */ +export function getSymbols(obj: Record<string, any>): symbol[] { + let symbols: symbol[] = []; + let obj_: Record<string, any> = new Object(obj); + + do { + const l = Object.getOwnPropertySymbols(obj_).sort(); + + symbols = [...symbols, ...l]; + } while ( + (obj_ = Object.getPrototypeOf(obj_)) && // walk-up the prototype chain + Object.getPrototypeOf(obj_) // not the the Object prototype methods (hasOwnProperty, etc...) + ); + + return symbols; +} + +/** + * Checks if a user has a certain guild permission (doesn't check channel permissions). + * @param message The message to check the user from. + * @param permissions The permissions to check for. + * @returns The missing permissions or null if none are missing. + */ +export function userGuildPermCheck( + message: CommandMessage | SlashMessage, + permissions: typeof PermissionFlagsBits[keyof typeof PermissionFlagsBits][] +): PermissionsString[] | null { + if (!message.inGuild()) return null; + const missing = message.member?.permissions.missing(permissions) ?? []; + + return missing.length ? missing : null; +} + +/** + * Check if the client has certain permissions in the guild (doesn't check channel permissions). + * @param message The message to check the client user from. + * @param permissions The permissions to check for. + * @returns The missing permissions or null if none are missing. + */ +export function clientGuildPermCheck(message: CommandMessage | SlashMessage, permissions: bigint[]): PermissionsString[] | null { + const missing = message.guild?.members.me?.permissions.missing(permissions) ?? []; + + return missing.length ? missing : null; +} + +/** + * Check if the client has permission to send messages in the channel as well as check if they have other permissions + * in the guild (or the channel if `checkChannel` is `true`). + * @param message The message to check the client user from. + * @param permissions The permissions to check for. + * @param checkChannel Whether to check the channel permissions instead of the guild permissions. + * @returns The missing permissions or null if none are missing. + */ +export function clientSendAndPermCheck( + message: CommandMessage | SlashMessage, + permissions: bigint[] = [], + checkChannel = false +): PermissionsString[] | null { + if (!message.inGuild() || !message.channel) return null; + + const missing: PermissionsString[] = []; + const sendPerm = message.channel.isThread() ? 'SendMessages' : 'SendMessagesInThreads'; + + // todo: remove once forum channels are fixed + if (message.channel.parent === null && message.channel.isThread()) return null; + + if (!message.guild.members.me!.permissionsIn(message.channel!.id).has(sendPerm)) missing.push(sendPerm); + + missing.push( + ...(checkChannel + ? message.guild!.members.me!.permissionsIn(message.channel!.id!).missing(permissions) + : clientGuildPermCheck(message, permissions) ?? []) + ); + + return missing.length ? missing : null; +} + +export { deepLock as deepFreeze }; +export { Arg as arg }; +export { Format as format }; +export { DiscordConstants as discordConstants }; +export { AkairoUtil as akairo }; + +/** + * The link to invite the bot with all permissions. + */ +export function invite(client: BushClient) { + return client.generateInvite({ + permissions: + PermissionsBitField.All - + PermissionFlagsBits.UseEmbeddedActivities - + PermissionFlagsBits.ViewGuildInsights - + PermissionFlagsBits.Stream, + scopes: [OAuth2Scopes.Bot, OAuth2Scopes.ApplicationsCommands] + }); +} + +/** + * Asset multiple statements at a time. + * @param args + */ +export function assertAll(...args: any[]): void { + for (let i = 0; i < args.length; i++) { + assert(args[i], `assertAll index ${i} failed`); + } +} + +/** + * Casts a string to a duration and reason for slash commands. + * @param arg The argument received. + * @param message The message that triggered the command. + * @returns The casted argument. + */ +export async function castDurationContent( + arg: string | ParsedDuration | null, + message: CommandMessage | SlashMessage +): Promise<ParsedDurationRes> { + const res = typeof arg === 'string' ? await Arg.cast('contentWithDuration', message, arg) : arg; + + return { duration: res?.duration ?? 0, content: res?.content ?? '' }; +} + +export interface ParsedDurationRes { + duration: number; + content: string; +} + +/** + * Casts a string to a the specified argument type. + * @param type The type of the argument to cast to. + * @param arg The argument received. + * @param message The message that triggered the command. + * @returns The casted argument. + */ +export async function cast<T extends keyof BaseBushArgumentType>( + type: T, + arg: BaseBushArgumentType[T] | string, + message: CommandMessage | SlashMessage +) { + return typeof arg === 'string' ? await Arg.cast(type, message, arg) : arg; +} + +/** + * Overflows the description of an embed into multiple embeds. + * @param embed The options to be applied to the (first) embed. + * @param lines Each line of the description as an element in an array. + */ +export function overflowEmbed(embed: Omit<APIEmbed, 'description'>, lines: string[], maxLength = 4096): EmbedBuilder[] { + const embeds: EmbedBuilder[] = []; + + const makeEmbed = () => { + embeds.push(new EmbedBuilder().setColor(embed.color ?? null)); + return embeds.at(-1)!; + }; + + for (const line of lines) { + let current = embeds.length ? embeds.at(-1)! : makeEmbed(); + let joined = current.data.description ? `${current.data.description}\n${line}` : line; + if (joined.length > maxLength) { + current = makeEmbed(); + joined = line; + } + + current.setDescription(joined); + } + + if (!embeds.length) makeEmbed(); + + if (embed.author) embeds.at(0)?.setAuthor(embed.author); + if (embed.title) embeds.at(0)?.setTitle(embed.title); + if (embed.url) embeds.at(0)?.setURL(embed.url); + if (embed.fields) embeds.at(-1)?.setFields(embed.fields); + if (embed.thumbnail) embeds.at(-1)?.setThumbnail(embed.thumbnail.url); + if (embed.footer) embeds.at(-1)?.setFooter(embed.footer); + if (embed.image) embeds.at(-1)?.setImage(embed.image.url); + if (embed.timestamp) embeds.at(-1)?.setTimestamp(new Date(embed.timestamp)); + + return embeds; +} + +/** + * Formats an error into a string. + * @param error The error to format. + * @param colors Whether to use colors in the output. + * @returns The formatted error. + */ +export function formatError(error: Error | any, colors = false): string { + if (!error) return error; + if (typeof error !== 'object') return String.prototype.toString.call(error); + if ( + getSymbols(error) + .map((s) => s.toString()) + .includes('Symbol(nodejs.util.inspect.custom)') + ) + return inspect(error, { colors }); + + return error.stack; +} + +export function deepWriteable<T>(obj: T): DeepWritable<T> { + return obj as DeepWritable<T>; +} diff --git a/lib/utils/Format.ts b/lib/utils/Format.ts new file mode 100644 index 0000000..debaf4b --- /dev/null +++ b/lib/utils/Format.ts @@ -0,0 +1,119 @@ +import { type CodeBlockLang } from '#lib'; +import { + bold as discordBold, + codeBlock as discordCodeBlock, + escapeBold as discordEscapeBold, + escapeCodeBlock as discordEscapeCodeBlock, + escapeInlineCode as discordEscapeInlineCode, + escapeItalic as discordEscapeItalic, + escapeMarkdown, + escapeSpoiler as discordEscapeSpoiler, + escapeStrikethrough as discordEscapeStrikethrough, + escapeUnderline as discordEscapeUnderline, + inlineCode as discordInlineCode, + italic as discordItalic, + spoiler as discordSpoiler, + strikethrough as discordStrikethrough, + underscore as discordUnderscore +} from 'discord.js'; + +/** + * Wraps the content inside a codeblock with no language. + * @param content The content to wrap. + */ +export function codeBlock(content: string): string; + +/** + * Wraps the content inside a codeblock with the specified language. + * @param language The language for the codeblock. + * @param content The content to wrap. + */ +export function codeBlock(language: CodeBlockLang, content: string): string; +export function codeBlock(languageOrContent: string, content?: string): string { + return typeof content === 'undefined' + ? discordCodeBlock(discordEscapeCodeBlock(`${languageOrContent}`)) + : discordCodeBlock(`${languageOrContent}`, discordEscapeCodeBlock(`${content}`)); +} + +/** + * Wraps the content inside \`backticks\`, which formats it as inline code. + * @param content The content to wrap. + */ +export function inlineCode(content: string): string { + return discordInlineCode(discordEscapeInlineCode(`${content}`)); +} + +/** + * Formats the content into italic text. + * @param content The content to wrap. + */ +export function italic(content: string): string { + return discordItalic(discordEscapeItalic(`${content}`)); +} + +/** + * Formats the content into bold text. + * @param content The content to wrap. + */ +export function bold(content: string): string { + return discordBold(discordEscapeBold(`${content}`)); +} + +/** + * Formats the content into underscored text. + * @param content The content to wrap. + */ +export function underscore(content: string): string { + return discordUnderscore(discordEscapeUnderline(`${content}`)); +} + +/** + * Formats the content into strike-through text. + * @param content The content to wrap. + */ +export function strikethrough(content: string): string { + return discordStrikethrough(discordEscapeStrikethrough(`${content}`)); +} + +/** + * Wraps the content inside spoiler (hidden text). + * @param content The content to wrap. + */ +export function spoiler(content: string): string { + return discordSpoiler(discordEscapeSpoiler(`${content}`)); +} + +/** + * Formats input: makes it bold and escapes any other markdown + * @param text The input + */ +export function input(text: string): string { + return bold(sanitizeInputForDiscord(`${text}`)); +} + +/** + * Formats input for logs: makes it highlighted + * @param text The input + */ +export function inputLog(text: string): string { + return `<<${sanitizeWtlAndControl(`${text}`)}>>`; +} + +/** + * Removes all characters in a string that are either control characters or change the direction of text etc. + * @param str The string you would like sanitized + */ +export function sanitizeWtlAndControl(str: string) { + // eslint-disable-next-line no-control-regex + return `${str}`.replace(/[\u0000-\u001F\u007F-\u009F\u200B]/g, ''); +} + +/** + * Removed wtl and control characters and escapes any other markdown + * @param text The input + */ +export function sanitizeInputForDiscord(text: string): string { + return escapeMarkdown(sanitizeWtlAndControl(`${text}`)); +} + +export { escapeMarkdown } from 'discord.js'; diff --git a/lib/utils/Minecraft.ts b/lib/utils/Minecraft.ts new file mode 100644 index 0000000..bb5fbfe --- /dev/null +++ b/lib/utils/Minecraft.ts @@ -0,0 +1,351 @@ +/* eslint-disable */ + +import { Byte, Int, parse } from '@ironm00n/nbt-ts'; +import { BitField } from 'discord.js'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export enum FormattingCodes { + Black = '§0', + DarkBlue = '§1', + DarkGreen = '§2', + DarkAqua = '§3', + DarkRed = '§4', + DarkPurple = '§5', + Gold = '§6', + Gray = '§7', + DarkGray = '§8', + Blue = '§9', + Green = '§a', + Aqua = '§b', + Red = '§c', + LightPurple = '§d', + Yellow = '§e', + White = '§f', + + Obfuscated = '§k', + Bold = '§l', + Strikethrough = '§m', + Underline = '§n', + Italic = '§o', + Reset = '§r' +} + +// https://minecraft.fandom.com/wiki/Formatting_codes +export const formattingInfo = { + [FormattingCodes.Black]: { + foreground: 'rgb(0, 0, 0)', + foregroundDarker: 'rgb(0, 0, 0)', + background: 'rgb(0, 0, 0)', + backgroundDarker: 'rgb(0, 0, 0)', + ansi: '\u001b[0;30m' + }, + [FormattingCodes.DarkBlue]: { + foreground: 'rgb(0, 0, 170)', + foregroundDarker: 'rgb(0, 0, 118)', + background: 'rgb(0, 0, 42)', + backgroundDarker: 'rgb(0, 0, 29)', + ansi: '\u001b[0;34m' + }, + [FormattingCodes.DarkGreen]: { + foreground: 'rgb(0, 170, 0)', + foregroundDarker: 'rgb(0, 118, 0)', + background: 'rgb(0, 42, 0)', + backgroundDarker: 'rgb(0, 29, 0)', + ansi: '\u001b[0;32m' + }, + [FormattingCodes.DarkAqua]: { + foreground: 'rgb(0, 170, 170)', + foregroundDarker: 'rgb(0, 118, 118)', + background: 'rgb(0, 42, 42)', + backgroundDarker: 'rgb(0, 29, 29)', + ansi: '\u001b[0;36m' + }, + [FormattingCodes.DarkRed]: { + foreground: 'rgb(170, 0, 0)', + foregroundDarker: 'rgb(118, 0, 0)', + background: 'rgb(42, 0, 0)', + backgroundDarker: 'rgb(29, 0, 0)', + ansi: '\u001b[0;31m' + }, + [FormattingCodes.DarkPurple]: { + foreground: 'rgb(170, 0, 170)', + foregroundDarker: 'rgb(118, 0, 118)', + background: 'rgb(42, 0, 42)', + backgroundDarker: 'rgb(29, 0, 29)', + ansi: '\u001b[0;35m' + }, + [FormattingCodes.Gold]: { + foreground: 'rgb(255, 170, 0)', + foregroundDarker: 'rgb(178, 118, 0)', + background: 'rgb(42, 42, 0)', + backgroundDarker: 'rgb(29, 29, 0)', + ansi: '\u001b[0;33m' + }, + [FormattingCodes.Gray]: { + foreground: 'rgb(170, 170, 170)', + foregroundDarker: 'rgb(118, 118, 118)', + background: 'rgb(42, 42, 42)', + backgroundDarker: 'rgb(29, 29, 29)', + ansi: '\u001b[0;37m' + }, + [FormattingCodes.DarkGray]: { + foreground: 'rgb(85, 85, 85)', + foregroundDarker: 'rgb(59, 59, 59)', + background: 'rgb(21, 21, 21)', + backgroundDarker: 'rgb(14, 14, 14)', + ansi: '\u001b[0;90m' + }, + [FormattingCodes.Blue]: { + foreground: 'rgb(85, 85, 255)', + foregroundDarker: 'rgb(59, 59, 178)', + background: 'rgb(21, 21, 63)', + backgroundDarker: 'rgb(14, 14, 44)', + ansi: '\u001b[0;94m' + }, + [FormattingCodes.Green]: { + foreground: 'rgb(85, 255, 85)', + foregroundDarker: 'rgb(59, 178, 59)', + background: 'rgb(21, 63, 21)', + backgroundDarker: 'rgb(14, 44, 14)', + ansi: '\u001b[0;92m' + }, + [FormattingCodes.Aqua]: { + foreground: 'rgb(85, 255, 255)', + foregroundDarker: 'rgb(59, 178, 178)', + background: 'rgb(21, 63, 63)', + backgroundDarker: 'rgb(14, 44, 44)', + ansi: '\u001b[0;96m' + }, + [FormattingCodes.Red]: { + foreground: 'rgb(255, 85, 85)', + foregroundDarker: 'rgb(178, 59, 59)', + background: 'rgb(63, 21, 21)', + backgroundDarker: 'rgb(44, 14, 14)', + ansi: '\u001b[0;91m' + }, + [FormattingCodes.LightPurple]: { + foreground: 'rgb(255, 85, 255)', + foregroundDarker: 'rgb(178, 59, 178)', + background: 'rgb(63, 21, 63)', + backgroundDarker: 'rgb(44, 14, 44)', + ansi: '\u001b[0;95m' + }, + [FormattingCodes.Yellow]: { + foreground: 'rgb(255, 255, 85)', + foregroundDarker: 'rgb(178, 178, 59)', + background: 'rgb(63, 63, 21)', + backgroundDarker: 'rgb(44, 44, 14)', + ansi: '\u001b[0;93m' + }, + [FormattingCodes.White]: { + foreground: 'rgb(255, 255, 255)', + foregroundDarker: 'rgb(178, 178, 178)', + background: 'rgb(63, 63, 63)', + backgroundDarker: 'rgb(44, 44, 44)', + ansi: '\u001b[0;97m' + }, + + [FormattingCodes.Obfuscated]: { ansi: '\u001b[8m' }, + [FormattingCodes.Bold]: { ansi: '\u001b[1m' }, + [FormattingCodes.Strikethrough]: { ansi: '\u001b[9m' }, + [FormattingCodes.Underline]: { ansi: '\u001b[4m' }, + [FormattingCodes.Italic]: { ansi: '\u001b[3m' }, + [FormattingCodes.Reset]: { ansi: '\u001b[0m' } +} as const; + +export type McItemId = Lowercase<string>; +export type SbItemId = Uppercase<string>; +export type MojangJson = string; +export type SbRecipeItem = `${SbItemId}:${number}` | ''; +export type SbRecipe = { + [Location in `${'A' | 'B' | 'C'}${1 | 2 | 3}`]: SbRecipeItem; +}; +export type InfoType = 'WIKI_URL' | ''; + +export type Slayer = `${'WOLF' | 'BLAZE' | 'EMAN'}_${1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9}`; + +export interface RawNeuItem { + itemid: McItemId; + displayname: string; + nbttag: MojangJson; + damage: number; + lore: string[]; + recipe?: SbRecipe; + internalname: SbItemId; + modver: string; + infoType: InfoType; + info?: string[]; + crafttext: string; + vanilla?: boolean; + useneucraft?: boolean; + slayer_req?: Slayer; + clickcommand?: string; + x?: number; + y?: number; + z?: number; + island?: string; + recipes?: { type: string; cost: any[]; result: SbItemId }[]; + /** @deprecated */ + parent?: SbItemId; + noseal?: boolean; +} + +export enum HideFlagsBits { + Enchantments = 1, + AttributeModifiers = 2, + Unbreakable = 4, + CanDestroy = 8, + CanPlaceOn = 16, + /** + * potion effects, shield pattern info, "StoredEnchantments", written book + * "generation" and "author", "Explosion", "Fireworks", and map tooltips + */ + OtherInformation = 32, + Dyed = 64 +} + +export type HideFlagsString = keyof typeof HideFlagsBits; + +export class HideFlags extends BitField<HideFlagsString> { + public static override Flags = HideFlagsBits; +} + +export const formattingCode = new RegExp( + `§[${Object.values(FormattingCodes) + .filter((v) => v.startsWith('§')) + .map((v) => v.substring(1)) + .join('')}]` +); + +export function removeMCFormatting(str: string) { + return str.replaceAll(formattingCode, ''); +} + +const repo = path.join(__dirname, '..', '..', '..', 'neu-item-repo-dangerous'); + +export interface NbtTag { + overrideMeta?: Byte; + Unbreakable?: Int; + ench?: string[]; + HideFlags?: HideFlags; + SkullOwner?: SkullOwner; + display?: NbtTagDisplay; + ExtraAttributes?: ExtraAttributes; +} + +export interface SkullOwner { + Id?: string; + Properties?: { + textures?: { Value?: string }[]; + }; +} + +export interface NbtTagDisplay { + Lore?: string[]; + color?: Int; + Name?: string; +} + +export type RuneId = string; + +export interface ExtraAttributes { + originTag?: Origin; + id?: string; + generator_tier?: Int; + boss_tier?: Int; + enchantments?: { hardened_mana?: Int }; + dungeon_item_level?: Int; + runes?: { [key: RuneId]: Int }; + petInfo?: PetInfo; +} + +export interface PetInfo { + type: 'ZOMBIE'; + active: boolean; + exp: number; + tier: 'COMMON' | 'UNCOMMON' | 'RARE' | 'EPIC' | 'LEGENDARY'; + hideInfo: boolean; + candyUsed: number; +} + +export type Origin = 'SHOP_PURCHASE'; + +const neuConstantsPath = path.join(repo, 'constants'); +const neuPetsPath = path.join(neuConstantsPath, 'pets.json'); +const neuPets = (await import(neuPetsPath, { assert: { type: 'json' } })) as PetsConstants; +const neuPetNumsPath = path.join(neuConstantsPath, 'petnums.json'); +const neuPetNums = (await import(neuPetNumsPath, { assert: { type: 'json' } })) as PetNums; + +export interface PetsConstants { + pet_rarity_offset: Record<string, number>; + pet_levels: number[]; + custom_pet_leveling: Record<string, { type: number; pet_levels: number[]; max_level: number }>; + pet_types: Record<string, string>; +} + +export interface PetNums { + [key: string]: { + [key: string]: { + '1': { + otherNums: number[]; + statNums: Record<string, number>; + }; + '100': { + otherNums: number[]; + statNums: Record<string, number>; + }; + 'stats_levelling_curve'?: `${number};${number};${number}`; + }; + }; +} + +export class NeuItem { + public itemId: McItemId; + public displayName: string; + public nbtTag: NbtTag; + public internalName: SbItemId; + public lore: string[]; + + public constructor(raw: RawNeuItem) { + this.itemId = raw.itemid; + this.nbtTag = <NbtTag>parse(raw.nbttag); + this.displayName = raw.displayname; + this.internalName = raw.internalname; + this.lore = raw.lore; + + this.petLoreReplacements(); + } + + private petLoreReplacements(level = -1) { + if (/.*?;[0-5]$/.test(this.internalName) && this.displayName.includes('LVL')) { + const maxLevel = neuPets?.custom_pet_leveling?.[this.internalName]?.max_level ?? 100; + this.displayName = this.displayName.replace('LVL', `1➡${maxLevel}`); + + const nums = neuPetNums[this.internalName]; + if (!nums) throw new Error(`Pet (${this.internalName}) has no pet nums.`); + + const teir = ['COMMON', 'UNCOMMON', 'RARE', 'EPIC', 'LEGENDARY', 'MYTHIC'][+this.internalName.at(-1)!]; + const petInfoTier = nums[teir]; + if (!petInfoTier) throw new Error(`Pet (${this.internalName}) has no pet nums for ${teir} rarity.`); + + const curve = petInfoTier?.stats_levelling_curve?.split(';'); + + // todo: finish copying from neu + + const minStatsLevel = parseInt(curve?.[0] ?? '0'); + const maxStatsLevel = parseInt(curve?.[0] ?? '100'); + + const lore = ''; + } + } +} + +export function mcToAnsi(str: string) { + for (const format in formattingInfo) { + str = str.replaceAll(format, formattingInfo[format as keyof typeof formattingInfo].ansi); + } + return `${str}\u001b[0m`; +} diff --git a/lib/utils/Minecraft_Test.ts b/lib/utils/Minecraft_Test.ts new file mode 100644 index 0000000..26ca648 --- /dev/null +++ b/lib/utils/Minecraft_Test.ts @@ -0,0 +1,86 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { mcToAnsi, RawNeuItem } from './Minecraft.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repo = path.join(__dirname, '..', '..', '..', '..', '..', 'neu-item-repo-dangerous'); +const itemPath = path.join(repo, 'items'); +const items = await fs.readdir(itemPath); + +// for (let i = 0; i < 5; i++) { +for (const path_ of items) { + // const randomItem = items[Math.floor(Math.random() * items.length)]; + // console.log(randomItem); + const item = (await import(path.join(itemPath, /* randomItem */ path_), { assert: { type: 'json' } })).default as RawNeuItem; + if (/.*?((_MONSTER)|(_NPC)|(_ANIMAL)|(_MINIBOSS)|(_BOSS)|(_SC))$/.test(item.internalname)) continue; + if (!/.*?;[0-5]$/.test(item.internalname)) continue; + /* console.log(path_); + console.dir(item, { depth: Infinity }); */ + + /* console.log('==========='); */ + // const nbt = parse(item.nbttag) as NbtTag; + + // if (nbt?.SkullOwner?.Properties?.textures?.[0]?.Value) { + // nbt.SkullOwner.Properties.textures[0].Value = parse( + // Buffer.from(nbt.SkullOwner.Properties.textures[0].Value, 'base64').toString('utf-8') + // ) as string; + // } + + // if (nbt.ExtraAttributes?.petInfo) { + // nbt.ExtraAttributes.petInfo = JSON.parse(nbt.ExtraAttributes.petInfo as any as string); + // } + + // delete nbt.display?.Lore; + + // console.dir(nbt, { depth: Infinity }); + // console.log('==========='); + + /* if (nbt?.display && nbt.display.Name !== item.displayname) + console.log(`${path_} display name mismatch: ${mcToAnsi(nbt.display.Name)} != ${mcToAnsi(item.displayname)}`); + + if (nbt?.ExtraAttributes && nbt?.ExtraAttributes.id !== item.internalname) + console.log(`${path_} internal name mismatch: ${mcToAnsi(nbt?.ExtraAttributes.id)} != ${mcToAnsi(item.internalname)}`); */ + + // console.log('==========='); + + console.log(mcToAnsi(item.displayname)); + console.log(item.lore.map((l) => mcToAnsi(l)).join('\n')); + + /* const keys = [ + 'itemid', + 'displayname', + 'nbttag', + 'damage', + 'lore', + 'recipe', + 'internalname', + 'modver', + 'infoType', + 'info', + 'crafttext', + 'vanilla', + 'useneucraft', + 'slayer_req', + 'clickcommand', + 'x', + 'y', + 'z', + 'island', + 'recipes', + 'parent', + 'noseal' + ]; + + Object.keys(item).forEach((k) => { + if (!keys.includes(k)) throw new Error(`Unknown key: ${k}`); + }); + + if ( + 'slayer_req' in item && + !new Array(10).flatMap((_, i) => ['WOLF', 'BLAZE', 'EMAN'].map((e) => e + (i + 1)).includes(item.slayer_req!)) + ) + throw new Error(`Unknown slayer req: ${item.slayer_req!}`); */ + + /* console.log('=-=-=-=-=-=-=-=-=-=-=-=-=-=-\n=-=-=-=-=-=-=-=-=-=-=-=-=-=-'); */ +} |