aboutsummaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/arguments/abbreviatedNumber.ts13
-rw-r--r--lib/arguments/contentWithDuration.ts5
-rw-r--r--lib/arguments/discordEmoji.ts14
-rw-r--r--lib/arguments/duration.ts5
-rw-r--r--lib/arguments/durationSeconds.ts6
-rw-r--r--lib/arguments/globalUser.ts7
-rw-r--r--lib/arguments/index.ts10
-rw-r--r--lib/arguments/messageLink.ts20
-rw-r--r--lib/arguments/permission.ts12
-rw-r--r--lib/arguments/roleWithDuration.ts17
-rw-r--r--lib/arguments/snowflake.ts8
-rw-r--r--lib/arguments/tinyColor.ts10
-rw-r--r--lib/automod/AutomodShared.ts310
-rw-r--r--lib/automod/MemberAutomod.ts72
-rw-r--r--lib/automod/MessageAutomod.ts286
-rw-r--r--lib/automod/PresenceAutomod.ts85
-rw-r--r--lib/badlinks.ts6930
-rw-r--r--lib/badwords.ts845
-rw-r--r--lib/common/BushCache.ts26
-rw-r--r--lib/common/ButtonPaginator.ts224
-rw-r--r--lib/common/CanvasProgressBar.ts83
-rw-r--r--lib/common/ConfirmationPrompt.ts64
-rw-r--r--lib/common/DeleteButton.ts78
-rw-r--r--lib/common/HighlightManager.ts488
-rw-r--r--lib/common/Moderation.ts556
-rw-r--r--lib/common/Sentry.ts24
-rw-r--r--lib/common/tags.ts34
-rw-r--r--lib/extensions/discord-akairo/BushArgumentTypeCaster.ts3
-rw-r--r--lib/extensions/discord-akairo/BushClient.ts600
-rw-r--r--lib/extensions/discord-akairo/BushCommand.ts586
-rw-r--r--lib/extensions/discord-akairo/BushCommandHandler.ts37
-rw-r--r--lib/extensions/discord-akairo/BushInhibitor.ts19
-rw-r--r--lib/extensions/discord-akairo/BushInhibitorHandler.ts3
-rw-r--r--lib/extensions/discord-akairo/BushListener.ts3
-rw-r--r--lib/extensions/discord-akairo/BushListenerHandler.ts3
-rw-r--r--lib/extensions/discord-akairo/BushTask.ts3
-rw-r--r--lib/extensions/discord-akairo/BushTaskHandler.ts3
-rw-r--r--lib/extensions/discord-akairo/SlashMessage.ts3
-rw-r--r--lib/extensions/discord.js/BushClientEvents.ts200
-rw-r--r--lib/extensions/discord.js/ExtendedGuild.ts919
-rw-r--r--lib/extensions/discord.js/ExtendedGuildMember.ts1255
-rw-r--r--lib/extensions/discord.js/ExtendedMessage.ts12
-rw-r--r--lib/extensions/discord.js/ExtendedUser.ts35
-rw-r--r--lib/extensions/global.ts13
-rw-r--r--lib/index.ts56
-rw-r--r--lib/models/BaseModel.ts13
-rw-r--r--lib/models/instance/ActivePunishment.ts94
-rw-r--r--lib/models/instance/Guild.ts431
-rw-r--r--lib/models/instance/Highlight.ts81
-rw-r--r--lib/models/instance/Level.ts70
-rw-r--r--lib/models/instance/ModLog.ts127
-rw-r--r--lib/models/instance/Reminder.ts84
-rw-r--r--lib/models/instance/StickyRole.ts58
-rw-r--r--lib/models/shared/Global.ts67
-rw-r--r--lib/models/shared/GuildCount.ts38
-rw-r--r--lib/models/shared/MemberCount.ts37
-rw-r--r--lib/models/shared/Shared.ts84
-rw-r--r--lib/models/shared/Stat.ts72
-rw-r--r--lib/tsconfig.json9
-rw-r--r--lib/types/BushInspectOptions.ts123
-rw-r--r--lib/types/CodeBlockLang.ts311
-rw-r--r--lib/utils/AllowedMentions.ts68
-rw-r--r--lib/utils/Arg.ts192
-rw-r--r--lib/utils/BushClientUtils.ts499
-rw-r--r--lib/utils/BushConstants.ts531
-rw-r--r--lib/utils/BushLogger.ts315
-rw-r--r--lib/utils/BushUtils.ts613
-rw-r--r--lib/utils/Format.ts119
-rw-r--r--lib/utils/Minecraft.ts351
-rw-r--r--lib/utils/Minecraft_Test.ts86
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=-=-=-=-=-=-=-=-=-=-=-=-=-=-'); */
+}