diff options
author | IRONM00N <64110067+IRONM00N@users.noreply.github.com> | 2022-06-16 14:32:18 -0400 |
---|---|---|
committer | IRONM00N <64110067+IRONM00N@users.noreply.github.com> | 2022-06-16 14:32:18 -0400 |
commit | 0e87bbd3940d89defcb04926587b35c8f4d1947f (patch) | |
tree | e50860d4dc25a11d4c3977b583284c4bcad1b077 /src/lib | |
parent | 661e4c9935aeb8760dafc7ced4bbec6cc356a033 (diff) | |
download | tanzanite-0e87bbd3940d89defcb04926587b35c8f4d1947f.tar.gz tanzanite-0e87bbd3940d89defcb04926587b35c8f4d1947f.tar.bz2 tanzanite-0e87bbd3940d89defcb04926587b35c8f4d1947f.zip |
remove util classes, move config out of src
Diffstat (limited to 'src/lib')
22 files changed, 2219 insertions, 2493 deletions
diff --git a/src/lib/common/AutoMod.ts b/src/lib/common/AutoMod.ts index 982e0e8..7f19e63 100644 --- a/src/lib/common/AutoMod.ts +++ b/src/lib/common/AutoMod.ts @@ -1,4 +1,4 @@ -import { banResponse, Moderation } from '#lib'; +import { banResponse, codeblock, colors, emojis, format, formatError, getShared, Moderation, resolveNonCachedUser } from '#lib'; import assert from 'assert'; import chalk from 'chalk'; import { @@ -18,11 +18,6 @@ import { */ export class AutoMod { /** - * The message to check for blacklisted phrases on - */ - private message: Message; - - /** * Whether or not a punishment has already been given to the user */ private punished = false; @@ -30,8 +25,12 @@ export class AutoMod { /** * @param message The message to check and potentially perform automod actions to */ - public constructor(message: Message) { - this.message = message; + public constructor( + /** + * The message to check for blacklisted phrases on + */ + private message: Message + ) { if (message.author.id === client.user?.id) return; void this.handle(); } @@ -57,9 +56,9 @@ export class AutoMod { traditional: { if (this.isImmune) break traditional; - const badLinksArray = util.getShared('badLinks'); - const badLinksSecretArray = util.getShared('badLinksSecret'); - const badWordsRaw = util.getShared('badWords'); + const badLinksArray = getShared('badLinks'); + const badLinksSecretArray = getShared('badLinksSecret'); + const badWordsRaw = getShared('badWords'); const customAutomodPhrases = (await this.message.guild.getSetting('autoModPhases')) ?? []; const uniqueLinks = [...new Set([...badLinksArray, ...badLinksSecretArray])]; @@ -90,8 +89,8 @@ export class AutoMod { embeds: [ { title: 'AutoMod Error', - description: `Unable to find severity information for ${util.format.inlineCode(highestOffence.match)}`, - color: util.colors.error + description: `Unable to find severity information for ${format.inlineCode(highestOffence.match)}`, + color: colors.error } ] }); @@ -168,7 +167,7 @@ export class AutoMod { .setDescription( `**User:** ${this.message.author} (${this.message.author.tag})\n**Sent From:** <#${this.message.channel.id}> [Jump to context](${this.message.url})` ) - .addFields([{ name: 'Message Content', value: `${await util.codeblock(this.message.content, 1024)}` }]) + .addFields([{ name: 'Message Content', value: `${await codeblock(this.message.content, 1024)}` }]) .setColor(color) .setTimestamp() ], @@ -252,13 +251,13 @@ export class AutoMod { let color; switch (highestOffence.severity) { case Severity.DELETE: { - color = util.colors.lightGray; + color = colors.lightGray; void this.message.delete().catch((e) => deleteError.bind(this, e)); this.punished = true; break; } case Severity.WARN: { - color = util.colors.yellow; + color = colors.yellow; void this.message.delete().catch((e) => deleteError.bind(this, e)); void this.message.member?.bushWarn({ moderator: this.message.guild!.members.me!, @@ -268,7 +267,7 @@ export class AutoMod { break; } case Severity.TEMP_MUTE: { - color = util.colors.orange; + color = colors.orange; void this.message.delete().catch((e) => deleteError.bind(this, e)); void this.message.member?.bushMute({ moderator: this.message.guild!.members.me!, @@ -279,7 +278,7 @@ export class AutoMod { break; } case Severity.PERM_MUTE: { - color = util.colors.red; + color = colors.red; void this.message.delete().catch((e) => deleteError.bind(this, e)); void this.message.member?.bushMute({ moderator: this.message.guild!.members.me!, @@ -302,8 +301,8 @@ export class AutoMod { { title: 'AutoMod Error', description: `Unable to delete triggered message.`, - fields: [{ name: 'Error', value: await util.codeblock(`${util.formatError(e)}`, 1024, 'js', true) }], - color: util.colors.error + fields: [{ name: 'Error', value: await codeblock(`${formatError(e)}`, 1024, 'js', true) }], + color: colors.error } ] }); @@ -333,7 +332,7 @@ export class AutoMod { this.message.channel.id }> [Jump to context](${this.message.url})\n**Blacklisted Words:** ${offences.map((o) => `\`${o.match}\``).join(', ')}` ) - .addFields([{ name: 'Message Content', value: `${await util.codeblock(this.message.content, 1024)}` }]) + .addFields([{ name: 'Message Content', value: `${await codeblock(this.message.content, 1024)}` }]) .setColor(color) .setTimestamp() .setAuthor({ name: this.message.author.tag, url: this.message.author.displayAvatarURL() }) @@ -360,7 +359,7 @@ export class AutoMod { public static async handleInteraction(interaction: ButtonInteraction) { if (!interaction.memberPermissions?.has(PermissionFlagsBits.BanMembers)) return interaction.reply({ - content: `${util.emojis.error} You are missing the **Ban Members** permission.`, + content: `${emojis.error} You are missing the **Ban Members** permission.`, ephemeral: true }); const [action, userId, reason] = interaction.customId.replace('automod;', '').split(';'); @@ -387,20 +386,20 @@ export class AutoMod { evidence: (interaction.message as Message).url ?? undefined }); - const victimUserFormatted = (await util.resolveNonCachedUser(userId))?.tag ?? userId; + const victimUserFormatted = (await resolveNonCachedUser(userId))?.tag ?? userId; if (result === banResponse.SUCCESS) return interaction.reply({ - content: `${util.emojis.success} Successfully banned **${victimUserFormatted}**.`, + content: `${emojis.success} Successfully banned **${victimUserFormatted}**.`, ephemeral: true }); else if (result === banResponse.DM_ERROR) return interaction.reply({ - content: `${util.emojis.warn} Banned ${victimUserFormatted} however I could not send them a dm.`, + content: `${emojis.warn} Banned ${victimUserFormatted} however I could not send them a dm.`, ephemeral: true }); else return interaction.reply({ - content: `${util.emojis.error} Could not ban **${victimUserFormatted}**: \`${result}\` .`, + content: `${emojis.error} Could not ban **${victimUserFormatted}**: \`${result}\` .`, ephemeral: true }); } diff --git a/src/lib/common/ButtonPaginator.ts b/src/lib/common/ButtonPaginator.ts index 64870cf..9560247 100644 --- a/src/lib/common/ButtonPaginator.ts +++ b/src/lib/common/ButtonPaginator.ts @@ -15,26 +15,6 @@ import { */ export class ButtonPaginator { /** - * The message that triggered the command - */ - protected message: CommandMessage | SlashMessage; - - /** - * The embeds to paginate - */ - protected embeds: EmbedBuilder[] | APIEmbed[]; - - /** - * The optional text to send with the paginator - */ - protected text: string | null; - - /** - * Whether the paginator message gets deleted when the exit button is pressed - */ - protected deleteOnExit: boolean; - - /** * The current page of the paginator */ protected curPage: number; @@ -52,16 +32,27 @@ export class ButtonPaginator { * @param startOn The page to start from (**not** the index) */ protected constructor( - message: CommandMessage | SlashMessage, - embeds: EmbedBuilder[] | APIEmbed[], - text: string | null, - deleteOnExit: boolean, + /** + * The message that triggered the command + */ + protected message: CommandMessage | SlashMessage, + + /** + * The embeds to paginate + */ + protected embeds: EmbedBuilder[] | APIEmbed[], + + /** + * The optional text to send with the paginator + */ + protected text: string | null, + + /** + * Whether the paginator message gets deleted when the exit button is pressed + */ + protected deleteOnExit: boolean, startOn: number ) { - this.message = message; - this.embeds = embeds; - this.text = text ? text : null; - this.deleteOnExit = deleteOnExit; this.curPage = startOn - 1; // add footers diff --git a/src/lib/common/ConfirmationPrompt.ts b/src/lib/common/ConfirmationPrompt.ts index c95dbbc..4593d24 100644 --- a/src/lib/common/ConfirmationPrompt.ts +++ b/src/lib/common/ConfirmationPrompt.ts @@ -6,23 +6,20 @@ import { ActionRowBuilder, ButtonBuilder, ButtonStyle, type MessageComponentInte */ export class ConfirmationPrompt { /** - * Options for sending the message - */ - protected messageOptions: MessageOptions; - - /** - * The message that triggered the command - */ - protected message: CommandMessage | SlashMessage; - - /** * @param message The message to respond to - * @param options The send message options + * @param messageOptions The send message options */ - protected constructor(message: CommandMessage | SlashMessage, messageOptions: MessageOptions) { - this.message = message; - this.messageOptions = messageOptions; - } + protected constructor( + /** + * The message that triggered the command + */ + protected message: CommandMessage | SlashMessage, + + /** + * Options for sending the message + */ + protected messageOptions: MessageOptions + ) {} /** * Sends a message with buttons for the user to confirm or cancel the action. diff --git a/src/lib/common/DeleteButton.ts b/src/lib/common/DeleteButton.ts index 91f4bfa..b561d94 100644 --- a/src/lib/common/DeleteButton.ts +++ b/src/lib/common/DeleteButton.ts @@ -15,23 +15,20 @@ import { */ export class DeleteButton { /** - * Options for sending the message - */ - protected messageOptions: MessageOptions; - - /** - * The message that triggered the command - */ - protected message: CommandMessage | SlashMessage; - - /** * @param message The message to respond to - * @param options The send message options + * @param messageOptions The send message options */ - protected constructor(message: CommandMessage | SlashMessage, options: MessageOptions) { - this.message = message; - this.messageOptions = options; - } + protected constructor( + /** + * The message that triggered the command + */ + protected message: CommandMessage | SlashMessage, + + /** + * Options for sending the message + */ + protected messageOptions: MessageOptions + ) {} /** * Sends a message with a button for the user to delete it. diff --git a/src/lib/common/HighlightManager.ts b/src/lib/common/HighlightManager.ts index fdec322..caaa6a5 100644 --- a/src/lib/common/HighlightManager.ts +++ b/src/lib/common/HighlightManager.ts @@ -1,7 +1,7 @@ -import { Highlight, type HighlightWord } from '#lib'; +import { addToArray, format, Highlight, removeFromArray, timestamp, type HighlightWord } from '#lib'; import assert from 'assert'; import { Collection, type Message, type Snowflake } from 'discord.js'; -import { Time } from '../utils/BushConstants.js'; +import { colors, Time } from '../utils/BushConstants.js'; const NOTIFY_COOLDOWN = 5 * Time.Minute; const OWNER_NOTIFY_COOLDOWN = 1 * Time.Minute; @@ -162,7 +162,7 @@ export class HighlightManager { if (highlight.words.some((w) => w.word === hl.word)) return `You have already highlighted "${hl.word}".`; - highlight.words = util.addToArray(highlight.words, hl); + highlight.words = addToArray(highlight.words, hl); return Boolean(await highlight.save().catch(() => false)); } @@ -189,7 +189,7 @@ export class HighlightManager { const toRemove = highlight.words.find((w) => w.word === hl); if (!toRemove) return `Uhhhhh... This shouldn't happen.`; - highlight.words = util.removeFromArray(highlight.words, toRemove); + highlight.words = removeFromArray(highlight.words, toRemove); return Boolean(await highlight.save().catch(() => false)); } @@ -271,20 +271,18 @@ export class HighlightManager { return client.users .send(user, { // eslint-disable-next-line @typescript-eslint/no-base-to-string - content: `In ${util.format.input(message.guild.name)} ${message.channel}, your highlight "${hl.word}" was matched:`, + content: `In ${format.input(message.guild.name)} ${message.channel}, your highlight "${hl.word}" was matched:`, embeds: [ { description: [...recentMessages, message] .map( (m) => - `${util.timestamp(m.createdAt, 't')} ${util.format.input(`${m.author.tag}:`)} ${m.cleanContent - .trim() - .substring(0, 512)}` + `${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: util.colors.default, + color: colors.default, footer: { text: 'Triggered' }, timestamp: message.createdAt.toISOString() } diff --git a/src/lib/common/Sentry.ts b/src/lib/common/Sentry.ts index e18555b..34bc06f 100644 --- a/src/lib/common/Sentry.ts +++ b/src/lib/common/Sentry.ts @@ -1,7 +1,7 @@ import { RewriteFrames } from '@sentry/integrations'; import * as SentryNode from '@sentry/node'; import { Integrations } from '@sentry/node'; -import config from './../../config/options.js'; +import config from '../../../config/options.js'; export class Sentry { public constructor(rootdir: string) { diff --git a/src/lib/common/util/Arg.ts b/src/lib/common/util/Arg.ts index 51d8065..a7795b1 100644 --- a/src/lib/common/util/Arg.ts +++ b/src/lib/common/util/Arg.ts @@ -9,155 +9,150 @@ import { Argument, type Flag, type ParsedValuePredicate } from 'discord-akairo'; import { type Message } from 'discord.js'; /** - * A wrapper for the {@link Argument} class that adds custom typings. + * 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 class Arg { - /** - * 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. - */ - public static async cast<T extends ATC>(type: T, message: CommandMessage | SlashMessage, phrase: string): Promise<ATCR<T>>; - public static async cast<T extends KBAT>(type: T, message: CommandMessage | SlashMessage, phrase: string): Promise<BAT[T]>; - public static async cast(type: AT | ATC, message: CommandMessage | SlashMessage, phrase: string): Promise<any>; - public static async cast(type: ATC | AT, message: CommandMessage | SlashMessage, phrase: string): Promise<any> { - return Argument.cast(type as any, client.commandHandler.resolver, message as Message, phrase); - } +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(type: ATC | AT, message: CommandMessage | SlashMessage, phrase: string): Promise<any> { + return Argument.cast(type as any, 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. - */ - public static compose<T extends ATC>(...types: T[]): ATCATCR<T>; - public static compose<T extends KBAT>(...types: T[]): ATCBAT<T>; - public static compose(...types: (AT | ATC)[]): ATC; - public static 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 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. - */ - public static composeWithFailure<T extends ATC>(...types: T[]): ATCATCR<T>; - public static composeWithFailure<T extends KBAT>(...types: T[]): ATCBAT<T>; - public static composeWithFailure(...types: (AT | ATC)[]): ATC; - public static composeWithFailure(...types: (AT | ATC)[]): ATC { - return Argument.composeWithFailure(...(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. - */ - public static isFailure(value: any): value is null | undefined | (Flag & { value: any }) { - return Argument.isFailure(value); - } +/** + * 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. - */ - public static product<T extends ATC>(...types: T[]): ATCATCR<T>; - public static product<T extends KBAT>(...types: T[]): ATCBAT<T>; - public static product(...types: (AT | ATC)[]): ATC; - public static product(...types: (AT | ATC)[]): ATC { - return Argument.product(...(types as any)); - } +/** + * 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. - */ - public static range<T extends ATC>(type: T, min: number, max: number, inclusive?: boolean): ATCATCR<T>; - public static range<T extends KBAT>(type: T, min: number, max: number, inclusive?: boolean): ATCBAT<T>; - public static range(type: AT | ATC, min: number, max: number, inclusive?: boolean): ATC; - public static range(type: AT | ATC, min: number, max: number, inclusive?: boolean): ATC { - return Argument.range(type as any, min, max, inclusive); - } +/** + * 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. - */ - public static tagged<T extends ATC>(type: T, tag?: any): ATCATCR<T>; - public static tagged<T extends KBAT>(type: T, tag?: any): ATCBAT<T>; - public static tagged(type: AT | ATC, tag?: any): ATC; - public static tagged(type: AT | ATC, tag?: any): ATC { - return Argument.tagged(type as any, tag); - } +/** + * 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. - */ - public static taggedUnion<T extends ATC>(...types: T[]): ATCATCR<T>; - public static taggedUnion<T extends KBAT>(...types: T[]): ATCBAT<T>; - public static taggedUnion(...types: (AT | ATC)[]): ATC; - public static taggedUnion(...types: (AT | ATC)[]): ATC { - return Argument.taggedUnion(...(types as any)); - } +/** + * 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. - */ - public static taggedWithInput<T extends ATC>(type: T, tag?: any): ATCATCR<T>; - public static taggedWithInput<T extends KBAT>(type: T, tag?: any): ATCBAT<T>; - public static taggedWithInput(type: AT | ATC, tag?: any): ATC; - public static taggedWithInput(type: AT | ATC, tag?: any): ATC { - return Argument.taggedWithInput(type as any, tag); - } +/** + * 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. - */ - public static union<T extends ATC>(...types: T[]): ATCATCR<T>; - public static union<T extends KBAT>(...types: T[]): ATCBAT<T>; - public static union(...types: (AT | ATC)[]): ATC; - public static union(...types: (AT | ATC)[]): ATC { - return Argument.union(...(types as any)); - } +/** + * 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. - */ - public static validate<T extends ATC>(type: T, predicate: ParsedValuePredicate): ATCATCR<T>; - public static validate<T extends KBAT>(type: T, predicate: ParsedValuePredicate): ATCBAT<T>; - public static validate(type: AT | ATC, predicate: ParsedValuePredicate): ATC; - public static validate(type: AT | ATC, predicate: ParsedValuePredicate): ATC { - return Argument.validate(type as any, predicate); - } +/** + * 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. - */ - public static withInput<T extends ATC>(type: T): ATC<ATCR<T>>; - public static withInput<T extends KBAT>(type: T): ATCBAT<T>; - public static withInput(type: AT | ATC): ATC; - public static withInput(type: AT | ATC): ATC { - return Argument.withInput(type as any); - } +/** + * 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; diff --git a/src/lib/common/util/Format.ts b/src/lib/common/util/Format.ts index 6cb6edc..260a0be 100644 --- a/src/lib/common/util/Format.ts +++ b/src/lib/common/util/Format.ts @@ -1,107 +1,112 @@ import { type CodeBlockLang } from '#lib'; -import { EscapeMarkdownOptions, Formatters, Util } from 'discord.js'; +import { + escapeBold, + escapeCodeBlock, + escapeInlineCode, + escapeItalic, + EscapeMarkdownOptions, + escapeSpoiler, + escapeStrikethrough, + escapeUnderline, + Formatters +} from 'discord.js'; /** - * Formats and escapes content for formatting + * Wraps the content inside a codeblock with no language. + * @param content The content to wrap. */ -export class Format { - /** - * Wraps the content inside a codeblock with no language. - * @param content The content to wrap. - */ - public static codeBlock(content: string): string; +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. - */ - public static codeBlock(language: CodeBlockLang, content: string): string; - public static codeBlock(languageOrContent: string, content?: string): string { - return typeof content === 'undefined' - ? Formatters.codeBlock(Util.escapeCodeBlock(`${languageOrContent}`)) - : Formatters.codeBlock(`${languageOrContent}`, Util.escapeCodeBlock(`${content}`)); - } +/** + * 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' + ? Formatters.codeBlock(escapeCodeBlock(`${languageOrContent}`)) + : Formatters.codeBlock(`${languageOrContent}`, escapeCodeBlock(`${content}`)); +} - /** - * Wraps the content inside \`backticks\`, which formats it as inline code. - * @param content The content to wrap. - */ - public static inlineCode(content: string): string { - return Formatters.inlineCode(Util.escapeInlineCode(`${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 Formatters.inlineCode(escapeInlineCode(`${content}`)); +} - /** - * Formats the content into italic text. - * @param content The content to wrap. - */ - public static italic(content: string): string { - return Formatters.italic(Util.escapeItalic(`${content}`)); - } +/** + * Formats the content into italic text. + * @param content The content to wrap. + */ +export function italic(content: string): string { + return Formatters.italic(escapeItalic(`${content}`)); +} - /** - * Formats the content into bold text. - * @param content The content to wrap. - */ - public static bold(content: string): string { - return Formatters.bold(Util.escapeBold(`${content}`)); - } +/** + * Formats the content into bold text. + * @param content The content to wrap. + */ +export function bold(content: string): string { + return Formatters.bold(escapeBold(`${content}`)); +} - /** - * Formats the content into underscored text. - * @param content The content to wrap. - */ - public static underscore(content: string): string { - return Formatters.underscore(Util.escapeUnderline(`${content}`)); - } +/** + * Formats the content into underscored text. + * @param content The content to wrap. + */ +export function underscore(content: string): string { + return Formatters.underscore(escapeUnderline(`${content}`)); +} - /** - * Formats the content into strike-through text. - * @param content The content to wrap. - */ - public static strikethrough(content: string): string { - return Formatters.strikethrough(Util.escapeStrikethrough(`${content}`)); - } +/** + * Formats the content into strike-through text. + * @param content The content to wrap. + */ +export function strikethrough(content: string): string { + return Formatters.strikethrough(escapeStrikethrough(`${content}`)); +} - /** - * Wraps the content inside spoiler (hidden text). - * @param content The content to wrap. - */ - public static spoiler(content: string): string { - return Formatters.spoiler(Util.escapeSpoiler(`${content}`)); - } +/** + * Wraps the content inside spoiler (hidden text). + * @param content The content to wrap. + */ +export function spoiler(content: string): string { + return Formatters.spoiler(escapeSpoiler(`${content}`)); +} - /** - * Escapes any Discord-flavour markdown in a string. - * @param text Content to escape - * @param options Options for escaping the markdown - */ - public static escapeMarkdown(text: string, options?: EscapeMarkdownOptions): string { - return Util.escapeMarkdown(`${text}`, options); - } +/** + * Escapes any Discord-flavour markdown in a string. + * @param text Content to escape + * @param options Options for escaping the markdown + */ +export function escapeMarkdown(text: string, options?: EscapeMarkdownOptions): string { + return escapeMarkdown(`${text}`, options); +} - /** - * Formats input: makes it bold and escapes any other markdown - * @param text The input - */ - public static input(text: string): string { - return this.bold(this.escapeMarkdown(this.sanitizeWtlAndControl(`${text}`))); - } +/** + * Formats input: makes it bold and escapes any other markdown + * @param text The input + */ +export function input(text: string): string { + return bold(escapeMarkdown(sanitizeWtlAndControl(`${text}`))); +} - /** - * Formats input for logs: makes it highlighted - * @param text The input - */ - public static inputLog(text: string): string { - return `<<${this.sanitizeWtlAndControl(`${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 - */ - public static sanitizeWtlAndControl(str: string) { - // eslint-disable-next-line no-control-regex - return `${str}`.replace(/[\u0000-\u001F\u007F-\u009F\u200B]/g, ''); - } +/** + * 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, ''); } diff --git a/src/lib/common/util/Moderation.ts b/src/lib/common/util/Moderation.ts index 6cdc141..a08dfa4 100644 --- a/src/lib/common/util/Moderation.ts +++ b/src/lib/common/util/Moderation.ts @@ -1,4 +1,16 @@ -import { ActivePunishment, ActivePunishmentType, Guild as GuildDB, ModLog, type ModLogType } from '#lib'; +import { + ActivePunishment, + ActivePunishmentType, + colors, + emojis, + format, + Guild as GuildDB, + handleError, + humanizeDuration, + ModLog, + resolveNonCachedUser, + type ModLogType +} from '#lib'; import assert from 'assert'; import { ActionRowBuilder, @@ -40,275 +52,270 @@ enum reversedPunishMap { } /** - * A utility class with moderation-related methods. + * 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 class Moderation { - /** - * 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. - */ - public static async 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 `${util.emojis.error} You cannot ${type} yourself.`; - } - if ( - moderator.roles.highest.position <= victim.roles.highest.position && - !isOwner && - !(type.startsWith('un') && moderator.id === victim.id) - ) { - return `${util.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 `${util.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 `${util.emojis.error} You cannot ${type} **${victim.user.tag}** because they are a moderator.`; - } - } - return true; +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'); } - /** - * 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. - */ - public static async createModLogEntry( - options: CreateModLogEntryOptions, - getCaseNumber = false - ): Promise<{ log: ModLog | null; caseNum: number | null }> { - const user = (await util.resolveNonCachedUser(options.user))!.id; - const moderator = (await util.resolveNonCachedUser(options.moderator))!.id; - const guild = client.guilds.resolveId(options.guild)!; - - return this.createModLogEntrySimple( - { - ...options, - user: user, - moderator: moderator, - guild: guild - }, - getCaseNumber - ); + const isOwner = moderator.guild.ownerId === moderator.id; + if (moderator.id === victim.id && !type.startsWith('un')) { + return `${emojis.error} You cannot ${type} yourself.`; } - - /** - * 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. - */ - public static async 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 util.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 }; + 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.`; } - - /** - * Creates a punishment entry. - * @param options Options for creating the punishment entry. - * @returns The database entry, or null if no entry is created. - */ - public static async createPunishmentEntry(options: CreatePunishmentEntryOptions): Promise<ActivePunishment | null> { - const expires = options.duration ? new Date(+new Date() + options.duration ?? 0) : undefined; - const user = (await util.resolveNonCachedUser(options.user))!.id; - const guild = client.guilds.resolveId(options.guild)!; - const type = this.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 util.handleError('createPunishmentEntry', e); - return null; - }); + 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.`; } - - /** - * Destroys a punishment entry. - * @param options Options for destroying the punishment entry. - * @returns Whether or not the entry was destroyed. - */ - public static async removePunishmentEntry(options: RemovePunishmentEntryOptions): Promise<boolean> { - const user = await util.resolveNonCachedUser(options.user); - const guild = client.guilds.resolveId(options.guild); - const type = this.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 util.handleError('removePunishmentEntry', e); - success = false; - }); - if (entries) { - const promises = entries.map(async (entry) => - entry.destroy().catch(async (e) => { - await util.handleError('removePunishmentEntry', e); - success = false; - }) - ); - - await Promise.all(promises); + 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 success; } + return true; +} - /** - * Returns the punishment type enum for the given type. - * @param type The type of the punishment. - * @returns The punishment type enum. - */ - private static findTypeEnum(type: 'mute' | 'ban' | 'role' | 'block') { - const typeMap = { - ['mute']: ActivePunishmentType.MUTE, - ['ban']: ActivePunishmentType.BAN, - ['role']: ActivePunishmentType.ROLE, - ['block']: ActivePunishmentType.BLOCK - }; - return typeMap[type]; - } +/** + * 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 resolveNonCachedUser(options.user))!.id; + const moderator = (await resolveNonCachedUser(options.moderator))!.id; + const guild = client.guilds.resolveId(options.guild)!; + + return createModLogEntrySimple( + { + ...options, + user: user, + moderator: moderator, + guild: guild + }, + getCaseNumber + ); +} - public static punishmentToPresentTense(punishment: PunishmentTypeDM): PunishmentTypePresent { - return punishMap[punishment]; - } +/** + * 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 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 }; +} - public static punishmentToPastTense(punishment: PunishmentTypePresent): PunishmentTypeDM { - return reversedPunishMap[punishment]; - } +/** + * 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 resolveNonCachedUser(options.user))!.id; + const guild = 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 handleError('createPunishmentEntry', e); + return null; + }); +} - /** - * Notifies the specified user of their punishment. - * @param options Options for notifying the user. - * @returns Whether or not the dm was successfully sent. - */ - public static async punishDM(options: PunishDMOptions): Promise<boolean> { - const ending = await options.guild.getSetting('punishmentEnding'); - const dmEmbed = - ending && ending.length && options.sendFooter - ? new EmbedBuilder().setDescription(ending).setColor(util.colors.newBlurple) - : undefined; - - const appealsEnabled = !!( - (await options.guild.hasFeature('punishmentAppeals')) && (await options.guild.getLogChannel('appeals')) +/** + * 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 resolveNonCachedUser(options.user); + const guild = 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 handleError('removePunishmentEntry', e); + success = false; + }); + if (entries) { + const promises = entries.map(async (entry) => + entry.destroy().catch(async (e) => { + await handleError('removePunishmentEntry', e); + success = false; + }) ); - let content = `You have been ${options.punishment} `; - if (options.punishment.includes('blocked')) { - assert(options.channel); - content += `from <#${options.channel}> `; - } - content += `in ${util.format.input(options.guild.name)} `; - if (options.duration !== null && options.duration !== undefined) - content += options.duration ? `for ${util.humanizeDuration(options.duration)} ` : 'permanently '; - const reason = options.reason?.trim() ? options.reason?.trim() : 'No reason provided'; - content += `for ${util.format.input(reason)}.`; - - let components; - if (appealsEnabled && options.modlog) - components = [ - new ActionRowBuilder<ButtonBuilder>({ - components: [ - new ButtonBuilder({ - customId: `appeal;${this.punishmentToPresentTense(options.punishment)};${options.guild.id};${client.users.resolveId( - options.user - )};${options.modlog}`, - style: ButtonStyle.Primary, - label: 'Appeal' - }).toJSON() - ] - }) - ]; - - const dmSuccess = await client.users - .send(options.user, { - content, - embeds: dmEmbed ? [dmEmbed] : undefined, - components - }) - .catch(() => false); - return !!dmSuccess; + 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};${client.users.resolveId( + options.user + )};${options.modlog}`, + style: ButtonStyle.Primary, + label: 'Appeal' + }).toJSON() + ] + }) + ]; + + const dmSuccess = await client.users + .send(options.user, { + content, + embeds: dmEmbed ? [dmEmbed] : undefined, + components + }) + .catch(() => false); + return !!dmSuccess; } interface BaseCreateModLogEntryOptions { diff --git a/src/lib/extensions/discord-akairo/BushClient.ts b/src/lib/extensions/discord-akairo/BushClient.ts index 2644231..b382121 100644 --- a/src/lib/extensions/discord-akairo/BushClient.ts +++ b/src/lib/extensions/discord-akairo/BushClient.ts @@ -10,7 +10,7 @@ import { roleWithDuration, snowflake } from '#args'; -import type { BushClientEvents, Config } from '#lib'; +import { BushClientEvents, emojis, formatError, inspect } from '#lib'; import { patch, type PatchedElements } from '@notenoughupdates/events-intercept'; import * as Sentry from '@sentry/node'; import { @@ -18,7 +18,6 @@ import { ContextMenuCommandHandler, version as akairoVersion, type ArgumentPromptData, - type ClientUtil, type OtherwiseContentSupplier } from 'discord-akairo'; import { @@ -46,6 +45,7 @@ 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 { tinyColor } from '../../../arguments/tinyColor.js'; import UpdateCacheTask from '../../../tasks/updateCache.js'; import UpdateStatsTask from '../../../tasks/updateStats.js'; @@ -63,13 +63,11 @@ import { Shared } from '../../models/shared/Shared.js'; import { Stat } from '../../models/shared/Stat.js'; import { AllowedMentions } from '../../utils/AllowedMentions.js'; import { BushCache } from '../../utils/BushCache.js'; -import { BushConstants } from '../../utils/BushConstants.js'; -import { BushLogger } from '../../utils/BushLogger.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 { BushClientUtil } from './BushClientUtil.js'; import { BushCommandHandler } from './BushCommandHandler.js'; import { BushInhibitorHandler } from './BushInhibitorHandler.js'; import { BushListenerHandler } from './BushListenerHandler.js'; @@ -86,10 +84,6 @@ declare module 'discord.js' { * The ID of the superUser(s). */ superUserID: Snowflake | Snowflake[]; - /** - * Utility methods. - */ - util: ClientUtil | BushClientUtil; 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; @@ -128,7 +122,6 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); export class BushClient<Ready extends boolean = boolean> extends AkairoClient<Ready> { public declare ownerID: Snowflake[]; public declare superUserID: Snowflake[]; - public declare util: BushClientUtil; /** * Whether or not the client is ready. @@ -141,11 +134,6 @@ export class BushClient<Ready extends boolean = boolean> extends AkairoClient<Re public stats: BushStats = { cpu: undefined, commandsUsed: 0n, slashCommandsUsed: 0n }; /** - * The configuration for the client. - */ - public config: Config; - - /** * The handler for the bot's listeners. */ public listenerHandler: BushListenerHandler; @@ -186,11 +174,6 @@ export class BushClient<Ready extends boolean = boolean> extends AkairoClient<Re public logger = BushLogger; /** - * Constants for the bot. - */ - public constants = BushConstants; - - /** * Cached global and guild database data. */ public cache = new BushCache(); @@ -213,7 +196,12 @@ export class BushClient<Ready extends boolean = boolean> extends AkairoClient<Re /** * @param config The configuration for the bot. */ - public constructor(config: Config) { + public constructor( + /** + * The configuration for the client. + */ + public config: Config + ) { super({ ownerID: config.owners, intents: Object.keys(GatewayIntentBits) @@ -233,7 +221,6 @@ export class BushClient<Ready extends boolean = boolean> extends AkairoClient<Re this.token = config.token as If<Ready, string, string | null>; this.config = config; - this.util = new BushClientUtil(this); /* =-=-= handlers =-=-= */ this.listenerHandler = new BushListenerHandler(this, { @@ -258,7 +245,7 @@ export class BushClient<Ready extends boolean = boolean> extends AkairoClient<Re const ending = '\n\n Type **cancel** to cancel the command'; const options = typeof text === 'function' ? await text(message, data) : text; const search = '{error}', - replace = this.consts.emojis.error; + replace = emojis.error; if (typeof options === 'string') return (replaceError ? options.replace(search, replace) : options) + ending; @@ -338,13 +325,6 @@ export class BushClient<Ready extends boolean = boolean> extends AkairoClient<Re } /** - * Constants for the bot. - */ - public get consts(): typeof BushConstants { - return this.constants; - } - - /** * Extends discord.js structures before the client is instantiated. */ public static extendStructures(): void { @@ -422,11 +402,7 @@ export class BushClient<Ready extends boolean = boolean> extends AkairoClient<Re void this.logger.success('startup', `Successfully loaded <<${handlerName}>>.`, false); }) .catch((e) => { - void this.logger.error( - 'startup', - `Unable to load loader <<${handlerName}>> with error:\n${util.formatError(e)}`, - false - ); + void this.logger.error('startup', `Unable to load loader <<${handlerName}>> with error:\n${formatError(e)}`, false); if (process.argv.includes('dry')) process.exit(1); }) ); @@ -451,7 +427,7 @@ export class BushClient<Ready extends boolean = boolean> extends AkairoClient<Re } catch (e) { await this.console.error( 'startup', - `Failed to connect to <<instance database>> with error:\n${util.inspect(e, { colors: true, depth: 1 })}`, + `Failed to connect to <<instance database>> with error:\n${inspect(e, { colors: true, depth: 1 })}`, false ); process.exit(2); @@ -471,7 +447,7 @@ export class BushClient<Ready extends boolean = boolean> extends AkairoClient<Re } catch (e) { await this.console.error( 'startup', - `Failed to connect to <<shared database>> with error:\n${util.inspect(e, { colors: true, depth: 1 })}`, + `Failed to connect to <<shared database>> with error:\n${inspect(e, { colors: true, depth: 1 })}`, false ); process.exit(2); @@ -503,7 +479,7 @@ export class BushClient<Ready extends boolean = boolean> extends AkairoClient<Re this.stats.slashCommandsUsed = stats.slashCommandsUsed; await this.login(this.token!); } catch (e) { - await this.console.error('start', util.inspect(e, { colors: true, depth: 1 }), false); + await this.console.error('start', inspect(e, { colors: true, depth: 1 }), false); process.exit(1); } } diff --git a/src/lib/extensions/discord-akairo/BushClientUtil.ts b/src/lib/extensions/discord-akairo/BushClientUtil.ts deleted file mode 100644 index 19810bd..0000000 --- a/src/lib/extensions/discord-akairo/BushClientUtil.ts +++ /dev/null @@ -1,1187 +0,0 @@ -import { - Arg, - BushConstants, - CommandMessage, - Global, - Shared, - type BaseBushArgumentType, - type BushClient, - type BushInspectOptions, - type CodeBlockLang, - type GlobalCache, - type Pronoun, - type PronounCode, - type SharedCache, - type SlashEditMessageType, - type SlashMessage, - type SlashSendMessageType -} from '#lib'; -import { humanizeDuration } from '@notenoughupdates/humanize-duration'; -import assert from 'assert'; -import { exec } from 'child_process'; -import deepLock from 'deep-lock'; -import { ClientUtil, Util as AkairoUtil } from 'discord-akairo'; -import { - Constants as DiscordConstants, - EmbedBuilder, - GuildMember, - Message, - OAuth2Scopes, - PermissionFlagsBits, - PermissionsBitField, - Routes, - ThreadMember, - User, - Util as DiscordUtil, - type APIEmbed, - type APIMessage, - type CommandInteraction, - type InteractionReplyOptions, - type PermissionsString, - type Snowflake, - type TextChannel, - type UserResolvable -} from 'discord.js'; -import got from 'got'; -import _ from 'lodash'; -import { inspect, promisify } from 'util'; -import CommandErrorListener from '../../../listeners/commands/commandError.js'; -import { Format } from '../../common/util/Format.js'; - -export type StripPrivate<T> = { [K in keyof T]: T[K] extends Record<string, any> ? StripPrivate<T[K]> : T[K] }; - -export class BushClientUtil extends ClientUtil { - /** - * The client. - */ - public declare readonly client: BushClient; - - /** - * 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' - ]; - - /** - * Creates this client util - * @param client The client to initialize with - */ - public constructor(client: BushClient) { - super(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) => client.users.fetch(id))); - } - - /** - * Capitalizes the first letter of the given text - * @param text The text to capitalize - * @returns The capitalized text - */ - public capitalize(text: string): string { - return text.charAt(0).toUpperCase() + text.slice(1); - } - - /** - * Runs a shell command and gives the output - * @param command The shell command to run - * @returns The stdout and stderr of the shell command - */ - public async shell(command: string): Promise<{ - stdout: string; - stderr: string; - }> { - return await promisify(exec)(command); - } - - /** - * 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 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 client.users.fetch(text as Snowflake); - } catch { - // pass - } - } - const mentionReg = /<@!?(?<id>\d{17,19})>/; - const mentionMatch = text.match(mentionReg); - if (mentionMatch) { - try { - return await client.users.fetch(mentionMatch.groups!.id as Snowflake); - } catch { - // pass - } - } - const user = client.users.cache.find((u) => u.username === text); - if (user) return user; - return null; - } - - /** - * Appends the correct ordinal to the given number - * @param n The number to append an ordinal to - * @returns The number with the ordinal - */ - public 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 - */ - public 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; - }, []); - } - - /** - * Commonly Used Colors - */ - public get colors() { - return client.consts.colors; - } - - /** - * Commonly Used Emojis - */ - public get emojis() { - return client.consts.emojis; - } - - /** - * Just the ids of Commonly Used Emojis - */ - public get emojisRaw() { - return client.consts.emojisRaw; - } - - /** - * 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. - */ - public async 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, ''); - } - - /** - * 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 = this.discord.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}` : ''}` - : `${this.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 client.console.warn(`codeblockError`, `Required Length: ${length}. Actual Length: ${code3.length}`, true); - void client.console.warn(`codeblockError`, code3, true); - throw new Error('code too long'); - } - return code3; - } - - /** - * Generate defaults for {@link inspect}. - * @param options The options to create defaults with. - * @returns The default options combined with the specified options. - */ - #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 - }; - } - - /** - * 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 { - const mapping = { - token: 'Main Token', - devToken: 'Dev Token', - betaToken: 'Beta Token', - hypixelApiKey: 'Hypixel Api Key', - wolframAlphaAppId: 'Wolfram|Alpha App ID', - dbPassword: 'Database Password' - }; - return mapping[key as keyof typeof mapping] || 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 { ...client.config.credentials, dbPassword: client.config.db.password }) { - const credential = { ...client.config.credentials, dbPassword: client.config.db.password }[ - credentialName as keyof typeof client.config.credentials - ]; - 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; - } - - /** - * 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. - */ - public inspect(object: any, options?: BushInspectOptions): string { - const optionsWithDefaults = this.#getDefaultInspectOptions(options); - - if (!optionsWithDefaults.inspectStrings && typeof object === 'string') return object; - - return inspect(object, optionsWithDefaults); - } - - /** - * 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 = this.inspect(input, inspectOptions ?? undefined); - if (inspectOptions) inspectOptions.inspectStrings = undefined; - input = this.discord.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 = this.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 = this.inspect(input, inspectOptions ?? undefined); - return this.redact(input); - } - - /** - * Responds to a slash command interaction. - * @param interaction The interaction to respond to. - * @param responseOptions The options for the response. - * @returns The message sent. - */ - public async 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); - } - } - - /** - * Gets a a configured channel as a TextChannel. - * @channel The channel to retrieve. - */ - public async getConfigChannel(channel: keyof typeof client['config']['channels']): Promise<TextChannel> { - return (await client.channels.fetch(client.config.channels[channel])) as unknown as TextChannel; - } - - /** - * 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 - */ - public 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(', '); - } - - /** - * 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 ? client.cache.global[key] : client.cache.global; - } - - public getShared(): SharedCache; - public getShared<K extends keyof SharedCache>(key: K): SharedCache[K]; - public getShared(key?: keyof SharedCache) { - return key ? client.cache.shared[key] : 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 typeof client['cache']['global']>( - action: 'add' | 'remove', - key: K, - value: typeof client['cache']['global'][K][0] - ): Promise<Global | void> { - const row = - (await Global.findByPk(client.config.environment)) ?? (await Global.create({ environment: client.config.environment })); - const oldValue: any[] = row[key]; - const newValue = this.addOrRemoveFromArray(action, oldValue, value); - row[key] = newValue; - 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 typeof client['cache']['shared'], 'badWords' | 'autoBanCode'>>( - action: 'add' | 'remove', - key: K, - value: typeof client['cache']['shared'][K][0] - ): Promise<Shared | void> { - const row = (await Shared.findByPk(0)) ?? (await Shared.create()); - const oldValue: any[] = row[key]; - const newValue = this.addOrRemoveFromArray(action, oldValue, value); - row[key] = newValue; - 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 typeof client['cache']['global']>( - key: K, - value: typeof client['cache']['global'][K] - ): Promise<Global | void> { - const row = - (await Global.findByPk(client.config.environment)) ?? (await Global.create({ environment: client.config.environment })); - row[key] = value; - 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 typeof client['cache']['shared'], 'badWords' | 'autoBanCode'>>( - key: K, - value: typeof client['cache']['shared'][K] - ): Promise<Shared | void> { - const row = (await Shared.findByPk(0)) ?? (await Shared.create()); - row[key] = value; - client.cache.shared[key] = value; - return await row.save().catch((e) => this.handleError('setShared', e)); - } - - /** - * 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. - */ - public 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. - */ - public removeFromArray<T>(array: T[], value: T): T[] { - return this.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. - */ - public addToArray<T>(array: T[], value: T): T[] { - return this.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`. - */ - public 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}. - */ - public 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 BushConstants.timeUnits) { - const regex = BushConstants.timeUnits[unit as keyof typeof BushConstants.timeUnits].match; - const match = regex.exec(contentWithoutTime); - const value = Number(match?.groups?.[unit]); - if (!isNaN(value)) duration! += value * BushConstants.timeUnits[unit as keyof typeof BushConstants.timeUnits].value; - - if (remove) contentWithoutTime = contentWithoutTime.replace(regex, ''); - } - // remove the space added earlier - if (contentWithoutTime.startsWith(' ')) contentWithoutTime.replace(' ', ''); - return { duration, content: contentWithoutTime }; - } - - /** - * 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. - */ - public humanizeDuration(duration: number, largest?: number, round = true): string { - if (largest) return humanizeDuration(duration, { language: 'en', maxDecimalPoints: 2, largest, round })!; - else return humanizeDuration(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. - */ - public 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` - */ - public 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; - } - - /** - * 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. - */ - public dateDelta(date: Date, largest?: number, round = true): string { - return this.humanizeDuration(new Date().getTime() - date.getTime(), largest ?? 3, 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` - */ - public timestampAndDelta(date: Date, style: TimestampStyle = 'D'): string { - return `${this.timestamp(date, style)} (${this.dateDelta(date)} ago)`; - } - - /** - * Convert a hex code to an rbg value. - * @param hex The hex code to convert. - * @returns The rbg value. - */ - public 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]}`; - } - - /** - * Capitalize the first letter of a string. - * @param string The string to capitalize the first letter of. - * @returns The string with the first letter capitalized. - */ - public capitalizeFirstLetter(string: string): string { - return string.charAt(0)?.toUpperCase() + string.slice(1); - } - - /** - * Wait an amount in milliseconds. - * @returns A promise that resolves after the specified amount of milliseconds - */ - public get sleep() { - return promisify(setTimeout); - } - - /** - * 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 client.console.error(_.camelCase(context), `An error occurred:\n${util.formatError(error, false)}`, false); - await client.console.channelError({ - embeds: await CommandErrorListener.generateErrorEmbed({ 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 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 client.constants.pronounMapping[apiRes.pronouns!]!; - } - - /** - * 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. - */ - public 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. - */ - public 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; - } - - /** - * 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()) as { data: { link: string } }; - - return resp.data.link; - } - - /** - * 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. - */ - public 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. - */ - public 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. - */ - public clientSendAndPermCheck( - message: CommandMessage | SlashMessage, - permissions: bigint[] = [], - checkChannel = false - ): PermissionsString[] | null { - const missing: PermissionsString[] = []; - const sendPerm = message.channel!.isThread() ? 'SendMessages' : 'SendMessagesInThreads'; - if (!message.inGuild()) 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) - : this.clientGuildPermCheck(message, permissions) ?? []) - ); - - return missing.length ? missing : 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 - ? '/' - : client.config.isDevelopment - ? 'dev ' - : message.util.parsed?.prefix ?? client.config.prefix; - } - - /** - * Recursively apply provided options operations on object - * and all of the object properties that are either object or function. - * - * By default freezes object. - * - * @param obj - The object to which will be applied `freeze`, `seal` or `preventExtensions` - * @param options default `{ action: 'freeze' }` - * @param options.action - * ``` - * | action | Add | Modify | Delete | Reconfigure | - * | ----------------- | --- | ------ | ------ | ----------- | - * | preventExtensions | - | + | + | + | - * | seal | - | + | - | - | - * | freeze | - | - | - | - | - * ``` - * - * @returns Initial object with applied options action - */ - public get deepFreeze() { - return deepLock; - } - - /** - * Recursively apply provided options operations on object - * and all of the object properties that are either object or function. - * - * By default freezes object. - * - * @param obj - The object to which will be applied `freeze`, `seal` or `preventExtensions` - * @param options default `{ action: 'freeze' }` - * @param options.action - * ``` - * | action | Add | Modify | Delete | Reconfigure | - * | ----------------- | --- | ------ | ------ | ----------- | - * | preventExtensions | - | + | + | + | - * | seal | - | + | - | - | - * | freeze | - | - | - | - | - * ``` - * - * @returns Initial object with applied options action - */ - public static get deepFreeze() { - return deepLock; - } - - /** - * The link to invite the bot with all permissions. - */ - public get invite() { - 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 - */ - public 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. - */ - public async castDurationContent( - arg: string | ParsedDuration | null, - message: CommandMessage | SlashMessage - ): Promise<ParsedDurationRes> { - const res = typeof arg === 'string' ? await util.arg.cast('contentWithDuration', message, arg) : arg; - - return { duration: res?.duration ?? 0, content: res?.content ?? '' }; - } - - /** - * 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. - */ - public async cast<T extends keyof BaseBushArgumentType>( - type: T, - arg: BaseBushArgumentType[T] | string, - message: CommandMessage | SlashMessage - ) { - return typeof arg === 'string' ? await util.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. - */ - public 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; - } - - public async resolveMessageLinks(content: string | null): Promise<MessageLinkParts[]> { - const res: MessageLinkParts[] = []; - - if (!content) return res; - - const regex = new RegExp(this.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 = 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 client.rest - .get(Routes.channelMessage(channel_id, message_id)) - .catch(() => null)) as APIMessage | null; - if (!message) continue; - - res.push(message); - } - - return res; - } - - /** - * 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. - */ - public formatError(error: Error | any, colors = false): string { - if (!error) return error; - if (typeof error !== 'object') return String.prototype.toString.call(error); - if ( - this.getSymbols(error) - .map((s) => s.toString()) - .includes('Symbol(nodejs.util.inspect.custom)') - ) - return this.inspect(error, { colors }); - - return error.stack; - } - - /** - * A wrapper for the Argument class that adds custom typings. - */ - public get arg() { - return Arg; - } - - /** - * Formats and escapes content for formatting - */ - public get format() { - return Format; - } - - /** - * Discord.js's Util class - */ - public get discord() { - return DiscordUtil; - } - - /** - * Discord.js's Util constants - */ - public get discordConstants() { - return DiscordConstants; - } - - /** - * discord-akairo's Util class - */ - public get akairo() { - return AkairoUtil; - } - - public get consts() { - return client.consts; - } - - public get regex() { - return client.consts.regex; - } -} - -interface HastebinRes { - key: string; -} - -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; -} - -export interface HasteResults { - url?: string; - error?: 'content too long' | 'substr' | 'unable to post'; -} - -export interface ParsedDuration { - duration: number | null; - content: string | null; -} - -export interface ParsedDurationRes { - duration: number; - content: string; -} - -export type TimestampStyle = 't' | 'T' | 'd' | 'D' | 'f' | 'F' | 'R'; - -export interface MessageLinkParts { - guild_id: Snowflake; - channel_id: Snowflake; - message_id: Snowflake; -} diff --git a/src/lib/extensions/discord-akairo/BushInhibitor.ts b/src/lib/extensions/discord-akairo/BushInhibitor.ts index 12b2baf..b4e6797 100644 --- a/src/lib/extensions/discord-akairo/BushInhibitor.ts +++ b/src/lib/extensions/discord-akairo/BushInhibitor.ts @@ -1,11 +1,9 @@ import { type BushClient, type BushCommand, type CommandMessage, type SlashMessage } from '#lib'; import { Inhibitor } from 'discord-akairo'; -export class BushInhibitor extends Inhibitor { +export abstract class BushInhibitor extends Inhibitor { public declare client: BushClient; -} -export interface BushInhibitor { /** * Checks if message should be blocked. * A return value of true will block the message. @@ -16,6 +14,6 @@ export interface BushInhibitor { * @param message - Message being handled. * @param command - Command to check. */ - exec(message: CommandMessage, command: BushCommand): any; - exec(message: CommandMessage | SlashMessage, command: BushCommand): any; + public abstract override exec(message: CommandMessage, command: BushCommand): any; + public abstract override exec(message: CommandMessage | SlashMessage, command: BushCommand): any; } diff --git a/src/lib/extensions/discord-akairo/BushListener.ts b/src/lib/extensions/discord-akairo/BushListener.ts index 3efe527..6917641 100644 --- a/src/lib/extensions/discord-akairo/BushListener.ts +++ b/src/lib/extensions/discord-akairo/BushListener.ts @@ -1,16 +1,3 @@ import { Listener } from 'discord-akairo'; -import type EventEmitter from 'events'; -export class BushListener extends Listener { - public constructor( - id: string, - options: { - emitter: string | EventEmitter; - event: string; - type?: 'on' | 'once' | 'prependListener' | 'prependOnceListener'; - category?: string; - } - ) { - super(id, options); - } -} +export abstract class BushListener extends Listener {} diff --git a/src/lib/extensions/discord-akairo/BushTask.ts b/src/lib/extensions/discord-akairo/BushTask.ts index 9f5c0cd..1b70c88 100644 --- a/src/lib/extensions/discord-akairo/BushTask.ts +++ b/src/lib/extensions/discord-akairo/BushTask.ts @@ -1,3 +1,3 @@ import { Task } from 'discord-akairo'; -export class BushTask extends Task {} +export abstract class BushTask extends Task {} diff --git a/src/lib/extensions/discord.js/ExtendedGuild.ts b/src/lib/extensions/discord.js/ExtendedGuild.ts index b8b7b22..c199899 100644 --- a/src/lib/extensions/discord.js/ExtendedGuild.ts +++ b/src/lib/extensions/discord.js/ExtendedGuild.ts @@ -1,7 +1,9 @@ import { AllowedMentions, banResponse, + colors, dmResponse, + emojis, permissionsResponse, punishmentEntryRemove, type BanResponse, @@ -36,9 +38,10 @@ import { type WebhookMessageOptions } from 'discord.js'; import _ from 'lodash'; -import { Moderation } from '../../common/util/Moderation.js'; +import * as Moderation from '../../common/util/Moderation.js'; import { Guild as GuildDB } from '../../models/instance/Guild.js'; import { ModLogType } from '../../models/instance/ModLog.js'; +import { addOrRemoveFromArray, resolveNonCachedUser } from '../../utils/BushUtils.js'; declare module 'discord.js' { export interface Guild { @@ -152,7 +155,7 @@ export class ExtendedGuild extends Guild { */ public override async addFeature(feature: GuildFeatures, moderator?: GuildMember): Promise<GuildModel['enabledFeatures']> { const features = await this.getSetting('enabledFeatures'); - const newFeatures = util.addOrRemoveFromArray('add', features, feature); + const newFeatures = addOrRemoveFromArray('add', features, feature); return (await this.setSetting('enabledFeatures', newFeatures, moderator)).enabledFeatures; } @@ -163,7 +166,7 @@ export class ExtendedGuild extends Guild { */ public override async removeFeature(feature: GuildFeatures, moderator?: GuildMember): Promise<GuildModel['enabledFeatures']> { const features = await this.getSetting('enabledFeatures'); - const newFeatures = util.addOrRemoveFromArray('remove', features, feature); + const newFeatures = addOrRemoveFromArray('remove', features, feature); return (await this.setSetting('enabledFeatures', newFeatures, moderator)).enabledFeatures; } @@ -251,7 +254,7 @@ export class ExtendedGuild extends Guild { */ public override async error(title: string, message: string): Promise<void> { void client.console.info(_.camelCase(title), message.replace(/\*\*(.*?)\*\*/g, '<<$1>>')); - void this.sendLogChannel('error', { embeds: [{ title: title, description: message, color: util.colors.error }] }); + void this.sendLogChannel('error', { embeds: [{ title: title, description: message, color: colors.error }] }); } /** @@ -265,7 +268,7 @@ export class ExtendedGuild extends Guild { let caseID: string | undefined = undefined; let dmSuccessEvent: boolean | undefined = undefined; - const user = await util.resolveNonCachedUser(options.user); + const user = await resolveNonCachedUser(options.user); const moderator = client.users.resolve(options.moderator ?? client.user!); if (!user || !moderator) return banResponse.CANNOT_RESOLVE_USER; @@ -408,7 +411,7 @@ export class ExtendedGuild extends Guild { let caseID: string | undefined = undefined; let dmSuccessEvent: boolean | undefined = undefined; - const user = await util.resolveNonCachedUser(options.user); + const user = await resolveNonCachedUser(options.user); const moderator = client.users.resolve(options.moderator ?? client.user!); if (!user || !moderator) return unbanResponse.CANNOT_RESOLVE_USER; @@ -534,7 +537,7 @@ export class ExtendedGuild extends Guild { 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 ? util.colors.Green : util.colors.Red, + color: options.unlock ? colors.Green : colors.Red, timestamp: new Date().toISOString() } ] @@ -600,16 +603,16 @@ export class ExtendedGuild extends Guild { case MessageType.RecipientAdd: { const recipient = rawQuote.mentions[0]; if (!recipient) { - sendOptions.content = `${util.emojis.error} Cannot resolve 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 = `${util.emojis.join} ${displayName} added ${recipientDisplay} to the thread.`; + sendOptions.content = `${emojis.join} ${displayName} added ${recipientDisplay} to the thread.`; } else { // this should never happen - sendOptions.content = `${util.emojis.join} ${displayName} added ${recipient.username} to the group.`; + sendOptions.content = `${emojis.join} ${displayName} added ${recipient.username} to the group.`; } break; @@ -617,16 +620,16 @@ export class ExtendedGuild extends Guild { case MessageType.RecipientRemove: { const recipient = rawQuote.mentions[0]; if (!recipient) { - sendOptions.content = `${util.emojis.error} Cannot resolve 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 = `${util.emojis.leave} ${displayName} removed ${recipientDisplay} from the thread.`; + sendOptions.content = `${emojis.leave} ${displayName} removed ${recipientDisplay} from the thread.`; } else { // this should never happen - sendOptions.content = `${util.emojis.leave} ${displayName} removed ${recipient.username} from the group.`; + sendOptions.content = `${emojis.leave} ${displayName} removed ${recipient.username} from the group.`; } break; @@ -661,7 +664,7 @@ export class ExtendedGuild extends Guild { // 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 = `${util.emojis.join} ${message}`; + sendOptions.content = `${emojis.join} ${message}`; break; } case MessageType.UserPremiumGuildSubscription: @@ -717,7 +720,7 @@ export class ExtendedGuild extends Guild { case MessageType.ChannelIconChange: case MessageType.Call: default: - sendOptions.content = `${util.emojis.error} I cannot quote **${ + sendOptions.content = `${emojis.error} I cannot quote **${ MessageType[quote.type] || quote.type }** messages, please report this to my developers.`; diff --git a/src/lib/extensions/discord.js/ExtendedGuildMember.ts b/src/lib/extensions/discord.js/ExtendedGuildMember.ts index 28acc1a..ad29236 100644 --- a/src/lib/extensions/discord.js/ExtendedGuildMember.ts +++ b/src/lib/extensions/discord.js/ExtendedGuildMember.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -import { BushClientEvents, Moderation, ModLogType, PunishmentTypeDM, Time } from '#lib'; +import { BushClientEvents, formatError, Moderation, ModLogType, PunishmentTypeDM, resolveNonCachedUser, Time } from '#lib'; import { ChannelType, GuildChannelResolvable, @@ -148,7 +148,7 @@ export class ExtendedGuildMember extends GuildMember { 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 util.resolveNonCachedUser(options.moderator ?? this.guild.members.me); + const moderator = await 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 }> => { @@ -195,7 +195,7 @@ export class ExtendedGuildMember extends GuildMember { if (ifShouldAddRole !== true) return ifShouldAddRole; let caseID: string | undefined = undefined; - const moderator = await util.resolveNonCachedUser(options.moderator ?? this.guild.members.me); + const moderator = await resolveNonCachedUser(options.moderator ?? this.guild.members.me); if (!moderator) return addRoleResponse.CANNOT_RESOLVE_USER; const ret = await (async () => { @@ -266,7 +266,7 @@ export class ExtendedGuildMember extends GuildMember { if (ifShouldAddRole !== true) return ifShouldAddRole; let caseID: string | undefined = undefined; - const moderator = await util.resolveNonCachedUser(options.moderator ?? this.guild.members.me); + const moderator = await resolveNonCachedUser(options.moderator ?? this.guild.members.me); if (!moderator) return removeRoleResponse.CANNOT_RESOLVE_USER; const ret = await (async () => { @@ -362,7 +362,7 @@ export class ExtendedGuildMember extends GuildMember { let caseID: string | undefined = undefined; let dmSuccessEvent: boolean | undefined = undefined; - const moderator = await util.resolveNonCachedUser(options.moderator ?? this.guild.members.me); + const moderator = await resolveNonCachedUser(options.moderator ?? this.guild.members.me); if (!moderator) return muteResponse.CANNOT_RESOLVE_USER; const ret = await (async () => { @@ -448,7 +448,7 @@ export class ExtendedGuildMember extends GuildMember { let caseID: string | undefined = undefined; let dmSuccessEvent: boolean | undefined = undefined; - const moderator = await util.resolveNonCachedUser(options.moderator ?? this.guild.members.me); + const moderator = await resolveNonCachedUser(options.moderator ?? this.guild.members.me); if (!moderator) return unmuteResponse.CANNOT_RESOLVE_USER; const ret = await (async () => { @@ -456,7 +456,7 @@ export class ExtendedGuildMember extends GuildMember { const muteSuccess = await this.roles .remove(muteRole, `[Unmute] ${moderator.tag} | ${options.reason ?? 'No reason provided.'}`) .catch(async (e) => { - await client.console.warn('muteRoleAddError', util.formatError(e, true)); + await client.console.warn('muteRoleAddError', formatError(e, true)); return false; }); if (!muteSuccess) return unmuteResponse.ACTION_ERROR; @@ -526,7 +526,7 @@ export class ExtendedGuildMember extends GuildMember { let caseID: string | undefined = undefined; let dmSuccessEvent: boolean | undefined = undefined; - const moderator = await util.resolveNonCachedUser(options.moderator ?? this.guild.members.me); + const moderator = await resolveNonCachedUser(options.moderator ?? this.guild.members.me); if (!moderator) return kickResponse.CANNOT_RESOLVE_USER; const ret = await (async () => { // add modlog entry @@ -580,7 +580,7 @@ export class ExtendedGuildMember extends GuildMember { let caseID: string | undefined = undefined; let dmSuccessEvent: boolean | undefined = undefined; - const moderator = await util.resolveNonCachedUser(options.moderator ?? this.guild.members.me); + const moderator = await 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 @@ -663,7 +663,7 @@ export class ExtendedGuildMember extends GuildMember { let caseID: string | undefined = undefined; let dmSuccessEvent: boolean | undefined = undefined; - const moderator = await util.resolveNonCachedUser(options.moderator ?? this.guild.members.me); + const moderator = await resolveNonCachedUser(options.moderator ?? this.guild.members.me); if (!moderator) return blockResponse.CANNOT_RESOLVE_USER; const ret = await (async () => { @@ -754,7 +754,7 @@ export class ExtendedGuildMember extends GuildMember { let caseID: string | undefined = undefined; let dmSuccessEvent: boolean | undefined = undefined; - const moderator = await util.resolveNonCachedUser(options.moderator ?? this.guild.members.me); + const moderator = await resolveNonCachedUser(options.moderator ?? this.guild.members.me); if (!moderator) return unblockResponse.CANNOT_RESOLVE_USER; const ret = await (async () => { @@ -839,7 +839,7 @@ export class ExtendedGuildMember extends GuildMember { let caseID: string | undefined = undefined; let dmSuccessEvent: boolean | undefined = undefined; - const moderator = await util.resolveNonCachedUser(options.moderator ?? this.guild.members.me); + const moderator = await resolveNonCachedUser(options.moderator ?? this.guild.members.me); if (!moderator) return timeoutResponse.CANNOT_RESOLVE_USER; const ret = await (async () => { @@ -901,7 +901,7 @@ export class ExtendedGuildMember extends GuildMember { let caseID: string | undefined = undefined; let dmSuccessEvent: boolean | undefined = undefined; - const moderator = await util.resolveNonCachedUser(options.moderator ?? this.guild.members.me); + const moderator = await resolveNonCachedUser(options.moderator ?? this.guild.members.me); if (!moderator) return removeTimeoutResponse.CANNOT_RESOLVE_USER; const ret = await (async () => { diff --git a/src/lib/extensions/global.ts b/src/lib/extensions/global.ts index a6f2b5a..d9cfaec 100644 --- a/src/lib/extensions/global.ts +++ b/src/lib/extensions/global.ts @@ -1,16 +1,11 @@ /* eslint-disable no-var */ -import type { BushClient, BushClientUtil } from '#lib'; +import type { BushClient } from '#lib'; declare global { /** * The bushbot client. */ var client: BushClient; - /** - * The bushbot client util. - */ - var util: BushClientUtil; - // eslint-disable-next-line @typescript-eslint/no-unused-vars interface ReadonlyArray<T> { includes<S, R extends `${Extract<S, string>}`>( diff --git a/src/lib/index.ts b/src/lib/index.ts index 221f360..3e57f9e 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -4,12 +4,21 @@ export * from './common/ConfirmationPrompt.js'; export * from './common/DeleteButton.js'; export type { BushInspectOptions } from './common/typings/BushInspectOptions.js'; export type { CodeBlockLang } from './common/typings/CodeBlockLang.js'; -export * from './common/util/Arg.js'; -export * from './common/util/Format.js'; -export * from './common/util/Moderation.js'; +export * as Arg from './common/util/Arg.js'; +export * as Format from './common/util/Format.js'; +export * as Moderation from './common/util/Moderation.js'; +export type { + AppealButtonId, + CreateModLogEntryOptions, + CreatePunishmentEntryOptions, + PunishDMOptions, + PunishmentTypeDM, + PunishmentTypePresent, + RemovePunishmentEntryOptions, + SimpleCreateModLogEntryOptions +} from './common/util/Moderation.js'; export * from './extensions/discord-akairo/BushArgumentTypeCaster.js'; export * from './extensions/discord-akairo/BushClient.js'; -export * from './extensions/discord-akairo/BushClientUtil.js'; export * from './extensions/discord-akairo/BushCommand.js'; export * from './extensions/discord-akairo/BushCommandHandler.js'; export * from './extensions/discord-akairo/BushInhibitor.js'; @@ -40,5 +49,5 @@ export * from './utils/AllowedMentions.js'; export * from './utils/BushCache.js'; export * from './utils/BushConstants.js'; export * from './utils/BushLogger.js'; +export * from './utils/BushUtils.js'; export * from './utils/CanvasProgressBar.js'; -export * from './utils/Config.js'; diff --git a/src/lib/utils/BushConstants.ts b/src/lib/utils/BushConstants.ts index 8c3d27f..0f9311f 100644 --- a/src/lib/utils/BushConstants.ts +++ b/src/lib/utils/BushConstants.ts @@ -1,6 +1,11 @@ -import { ArgumentMatches, ArgumentTypes, BuiltInReasons, CommandHandlerEvents } from 'discord-akairo/dist/src/util/Constants.js'; +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'; -import { BushClientUtil } from '../extensions/discord-akairo/BushClientUtil.js'; const rawCapeUrl = 'https://raw.githubusercontent.com/NotEnoughUpdates/capes/master/'; @@ -49,207 +54,206 @@ export const enum Time { Year = Day * 365.25 // average with leap years } -export class BushConstants { - public static 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); - - public static 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); - - public static 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 - public static timeUnits = BushClientUtil.deepFreeze({ - 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); - - public static regex = BushClientUtil.deepFreeze({ - 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: - /<?(?: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 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 */ - public static 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); + /** **This has the global flag, make sure to handle it correctly.** */ + messageLink: + /<?(?: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); - /** - * A bunch of mappings - */ - public static mappings = BushClientUtil.deepFreeze({ - guilds: { - bush: '516977525906341928', - tree: '767448775450820639', - staff: '784597260465995796', - space_ship: '717176538717749358', - sbr: '839287012409999391' - }, - - 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 } - }, +/** + * 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); - // prettier-ignore - features: { +/** + * A bunch of mappings + */ +export const mappings = deepLock({ + guilds: { + bush: '516977525906341928', + tree: '767448775450820639', + staff: '784597260465995796', + space_ship: '717176538717749358', + sbr: '839287012409999391' + }, + + 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 }, @@ -283,206 +287,123 @@ export class BushConstants { [GuildFeature.LinkedToHub]: { name: 'Linked To Hub', important: false, 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>', - 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' - }, - - 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); - - public static ArgumentMatches = Object.freeze({ - ...ArgumentMatches - } as const); - - public static ArgumentTypes = Object.freeze({ - ...ArgumentTypes, - 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); - - public static 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); - - public static CommandHandlerEvents = Object.freeze({ - ...CommandHandlerEvents - } as const); - - public static moulberryBushRoleMap = BushClientUtil.deepFreeze([ + 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>', + 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' + }, + + 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' }, @@ -508,8 +429,90 @@ export class BushConstants { { name: 'No VC', id: '788850482554208267' }, { name: 'No Giveaways', id: '808265422334984203' }, { name: 'No Support', id: '790247359824396319' } - ] as const); -} - -export type PronounCode = keyof typeof BushConstants['pronounMapping']; -export type Pronoun = typeof BushConstants['pronounMapping'][PronounCode]; + ], + + 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/src/lib/utils/BushLogger.ts b/src/lib/utils/BushLogger.ts index 073b8e2..7d42574 100644 --- a/src/lib/utils/BushLogger.ts +++ b/src/lib/utils/BushLogger.ts @@ -1,10 +1,11 @@ import chalk from 'chalk'; // eslint-disable-next-line @typescript-eslint/no-unused-vars -import { EmbedBuilder, Util, type Message, type PartialTextBasedChannelFields } from 'discord.js'; +import { EmbedBuilder, escapeMarkdown, PartialTextBasedChannelFields, type Message } from 'discord.js'; import repl, { REPLServer, REPL_MODE_STRICT } from 'repl'; import { WriteStream } from 'tty'; -import { inspect } from 'util'; import { type SendMessageType } from '../extensions/discord-akairo/BushClient.js'; +import { colors } from './BushConstants.js'; +import { getConfigChannel, inspect } from './BushUtils.js'; let REPL: REPLServer; let replGone = false; @@ -59,78 +60,78 @@ export function init() { } /** - * Custom logging utility for the bot. + * 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. */ -export class BushLogger { - /** - * 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. - */ - static #parseFormatting( - content: any, - color: 'blueBright' | 'blackBright' | 'redBright' | 'yellowBright' | 'greenBright' | '', - discordFormat = false - ): string | typeof content { - if (typeof content !== 'string') return content; - const newContent: Array<string> = content.split(/<<|>>/); - const tempParsedArray: Array<string> = []; - newContent.forEach((value, index) => { - if (index % 2 !== 0) { - tempParsedArray.push(discordFormat ? `**${Util.escapeMarkdown(value)}**` : color ? chalk[color](value) : value); - } else { - tempParsedArray.push(discordFormat ? Util.escapeMarkdown(value) : value); - } - }); - return tempParsedArray.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. - */ - static #inspectContent(content: any, depth = 2, colors = true): string { - if (typeof content !== 'string') { - return inspect(content, { depth, colors }); +function parseFormatting( + content: any, + color: 'blueBright' | 'blackBright' | 'redBright' | 'yellowBright' | 'greenBright' | '', + discordFormat = false +): string | typeof content { + if (typeof content !== 'string') return content; + const newContent: Array<string> = content.split(/<<|>>/); + const tempParsedArray: Array<string> = []; + newContent.forEach((value, index) => { + if (index % 2 !== 0) { + tempParsedArray.push(discordFormat ? `**${escapeMarkdown(value)}**` : color ? chalk[color](value) : value); + } else { + tempParsedArray.push(discordFormat ? escapeMarkdown(value) : value); } - return content; - } + }); + return tempParsedArray.join(''); +} - /** - * Strips ANSI color codes from a string. - * @param text The string to strip color codes from. - * @returns A string without ANSI color codes. - */ - static #stripColor(text: string): string { - return text.replace( - // eslint-disable-next-line no-control-regex - /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, - '' - ); +/** + * 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. - */ - static #getTimeStamp(): string { - const now = new Date(); - const hours = now.getHours(); - const minute = now.getMinutes(); - let hour = hours; - let amOrPm: 'AM' | 'PM' = 'AM'; - if (hour > 12) { - amOrPm = 'PM'; - hour = hour - 12; - } - return `${hour >= 10 ? hour : `0${hour}`}:${minute >= 10 ? minute : `0${minute}`} ${amOrPm}`; +/** + * Strips ANSI color codes from a string. + * @param text The string to strip color codes from. + * @returns A string without ANSI color codes. + */ +function stripColor(text: string): string { + return text.replace( + // eslint-disable-next-line no-control-regex + /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, + '' + ); +} + +/** + * Generates a formatted timestamp for logging. + * @returns The formatted timestamp. + */ +function getTimeStamp(): string { + const now = new Date(); + const hours = now.getHours(); + const minute = now.getMinutes(); + let hour = hours; + let amOrPm: 'AM' | 'PM' = 'AM'; + if (hour > 12) { + amOrPm = 'PM'; + hour = hour - 12; } + return `${hour >= 10 ? hour : `0${hour}`}:${minute >= 10 ? minute : `0${minute}`} ${amOrPm}`; +} +/** + * Custom logging utility for the bot. + */ +export default { /** * Logs information. Highlight information by surrounding it in `<<>>`. * @param header The header displayed before the content, displayed in cyan. @@ -138,31 +139,31 @@ export class BushLogger { * @param sendChannel Should this also be logged to discord? Defaults to false. * @param depth The depth the content will inspected. Defaults to 0. */ - public static get log() { - return BushLogger.info; - } + 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 static async channelLog(message: SendMessageType): Promise<Message | null> { - const channel = await util.getConfigChannel('log'); + async channelLog(message: SendMessageType): Promise<Message | null> { + const channel = await getConfigChannel('log'); 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 static async channelError(message: SendMessageType): Promise<Message | null> { - const channel = await util.getConfigChannel('error'); + async channelError(message: SendMessageType): Promise<Message | null> { + const channel = await getConfigChannel('error'); if (!channel) { void this.error( 'BushLogger', - `Could not find error channel, was originally going to send: \n${util.inspect(message, { + `Could not find error channel, was originally going to send: \n${inspect(message, { colors: true })}\n${new Error().stack?.substring(8)}`, false @@ -170,27 +171,27 @@ export class BushLogger { 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 static debug(content: any, depth = 0): void { + debug(content: any, depth = 0): void { if (!client.config.isDevelopment) return; - const newContent = this.#inspectContent(content, depth, true); - console.log(`${chalk.bgMagenta(this.#getTimeStamp())} ${chalk.magenta('[Debug]')} ${newContent}`); - } + 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 static debugRaw(...content: any): void { + debugRaw(...content: any): void { if (!client.config.isDevelopment) return; - console.log(`${chalk.bgMagenta(this.#getTimeStamp())} ${chalk.magenta('[Debug]')}`, ...content); - } + console.log(`${chalk.bgMagenta(getTimeStamp())} ${chalk.magenta('[Debug]')}`, ...content); + }, /** * Logs verbose information. Highlight information by surrounding it in `<<>>`. @@ -199,19 +200,17 @@ export class BushLogger { * @param sendChannel Should this also be logged to discord? Defaults to `false`. * @param depth The depth the content will inspected. Defaults to `0`. */ - public static async verbose(header: string, content: any, sendChannel = false, depth = 0): Promise<void> { + async verbose(header: string, content: any, sendChannel = false, depth = 0): Promise<void> { if (!client.config.logging.verbose) return; - const newContent = this.#inspectContent(content, depth, true); - console.log( - `${chalk.bgGrey(this.#getTimeStamp())} ${chalk.grey(`[${header}]`)} ${this.#parseFormatting(newContent, 'blackBright')}` - ); + 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}]** ${this.#parseFormatting(this.#stripColor(newContent), '', true)}`) - .setColor(util.colors.gray) + .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 `<<>>`. @@ -219,23 +218,23 @@ export class BushLogger { * @param content The content to log, highlights displayed in bright black. * @param depth The depth the content will inspected. Defaults to `0`. */ - public static async superVerbose(header: string, content: any, depth = 0): Promise<void> { + async superVerbose(header: string, content: any, depth = 0): Promise<void> { if (!client.config.logging.verbose) return; - const newContent = this.#inspectContent(content, depth, true); + const newContent = inspectContent(content, depth, true); console.log( - `${chalk.bgHex('#949494')(this.#getTimeStamp())} ${chalk.hex('#949494')(`[${header}]`)} ${chalk.hex('#b3b3b3')(newContent)}` + `${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 static async superVerboseRaw(header: string, ...content: any[]): Promise<void> { + async superVerboseRaw(header: string, ...content: any[]): Promise<void> { if (!client.config.logging.verbose) return; - console.log(`${chalk.bgHex('#a3a3a3')(this.#getTimeStamp())} ${chalk.hex('#a3a3a3')(`[${header}]`)}`, ...content); - } + console.log(`${chalk.bgHex('#a3a3a3')(getTimeStamp())} ${chalk.hex('#a3a3a3')(`[${header}]`)}`, ...content); + }, /** * Logs information. Highlight information by surrounding it in `<<>>`. @@ -244,19 +243,17 @@ export class BushLogger { * @param sendChannel Should this also be logged to discord? Defaults to `false`. * @param depth The depth the content will inspected. Defaults to `0`. */ - public static async info(header: string, content: any, sendChannel = true, depth = 0): Promise<void> { + async info(header: string, content: any, sendChannel = true, depth = 0): Promise<void> { if (!client.config.logging.info) return; - const newContent = this.#inspectContent(content, depth, true); - console.log( - `${chalk.bgCyan(this.#getTimeStamp())} ${chalk.cyan(`[${header}]`)} ${this.#parseFormatting(newContent, 'blueBright')}` - ); + 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}]** ${this.#parseFormatting(this.#stripColor(newContent), '', true)}`) - .setColor(util.colors.info) + .setDescription(`**[${header}]** ${parseFormatting(stripColor(newContent), '', true)}`) + .setColor(colors.info) .setTimestamp(); await this.channelLog({ embeds: [embed] }); - } + }, /** * Logs warnings. Highlight information by surrounding it in `<<>>`. @@ -265,22 +262,19 @@ export class BushLogger { * @param sendChannel Should this also be logged to discord? Defaults to `false`. * @param depth The depth the content will inspected. Defaults to `0`. */ - public static async warn(header: string, content: any, sendChannel = true, depth = 0): Promise<void> { - const newContent = this.#inspectContent(content, depth, true); + async warn(header: string, content: any, sendChannel = true, depth = 0): Promise<void> { + const newContent = inspectContent(content, depth, true); console.warn( - `${chalk.bgYellow(this.#getTimeStamp())} ${chalk.yellow(`[${header}]`)} ${this.#parseFormatting( - newContent, - 'yellowBright' - )}` + `${chalk.bgYellow(getTimeStamp())} ${chalk.yellow(`[${header}]`)} ${parseFormatting(newContent, 'yellowBright')}` ); if (!sendChannel) return; const embed = new EmbedBuilder() - .setDescription(`**[${header}]** ${this.#parseFormatting(this.#stripColor(newContent), '', true)}`) - .setColor(util.colors.warn) + .setDescription(`**[${header}]** ${parseFormatting(stripColor(newContent), '', true)}`) + .setColor(colors.warn) .setTimestamp(); await this.channelError({ embeds: [embed] }); - } + }, /** * Logs errors. Highlight information by surrounding it in `<<>>`. @@ -289,22 +283,19 @@ export class BushLogger { * @param sendChannel Should this also be logged to discord? Defaults to `false`. * @param depth The depth the content will inspected. Defaults to `0`. */ - public static async error(header: string, content: any, sendChannel = true, depth = 0): Promise<void> { - const newContent = this.#inspectContent(content, depth, true); + async error(header: string, content: any, sendChannel = true, depth = 0): Promise<void> { + const newContent = inspectContent(content, depth, true); console.warn( - `${chalk.bgRedBright(this.#getTimeStamp())} ${chalk.redBright(`[${header}]`)} ${this.#parseFormatting( - newContent, - 'redBright' - )}` + `${chalk.bgRedBright(getTimeStamp())} ${chalk.redBright(`[${header}]`)} ${parseFormatting(newContent, 'redBright')}` ); if (!sendChannel) return; const embed = new EmbedBuilder() - .setDescription(`**[${header}]** ${this.#parseFormatting(this.#stripColor(newContent), '', true)}`) - .setColor(util.colors.error) + .setDescription(`**[${header}]** ${parseFormatting(stripColor(newContent), '', true)}`) + .setColor(colors.error) .setTimestamp(); await this.channelError({ embeds: [embed] }); return; - } + }, /** * Logs successes. Highlight information by surrounding it in `<<>>`. @@ -313,21 +304,18 @@ export class BushLogger { * @param sendChannel Should this also be logged to discord? Defaults to `false`. * @param depth The depth the content will inspected. Defaults to `0`. */ - public static async success(header: string, content: any, sendChannel = true, depth = 0): Promise<void> { - const newContent = this.#inspectContent(content, depth, true); + async success(header: string, content: any, sendChannel = true, depth = 0): Promise<void> { + const newContent = inspectContent(content, depth, true); console.log( - `${chalk.bgGreen(this.#getTimeStamp())} ${chalk.greenBright(`[${header}]`)} ${this.#parseFormatting( - newContent, - 'greenBright' - )}` + `${chalk.bgGreen(getTimeStamp())} ${chalk.greenBright(`[${header}]`)} ${parseFormatting(newContent, 'greenBright')}` ); if (!sendChannel) return; const embed = new EmbedBuilder() - .setDescription(`**[${header}]** ${this.#parseFormatting(this.#stripColor(newContent), '', true)}`) - .setColor(util.colors.success) + .setDescription(`**[${header}]** ${parseFormatting(stripColor(newContent), '', true)}`) + .setColor(colors.success) .setTimestamp(); await this.channelLog({ embeds: [embed] }).catch(() => {}); } -} +}; /** @typedef {PartialTextBasedChannelFields} vscodeDontDeleteMyImportTy */ diff --git a/src/lib/utils/BushUtils.ts b/src/lib/utils/BushUtils.ts new file mode 100644 index 0000000..8a84d80 --- /dev/null +++ b/src/lib/utils/BushUtils.ts @@ -0,0 +1,1058 @@ +import { + Arg, + BushClient, + CodeBlockLang, + CommandMessage, + emojis, + Global, + Pronoun, + pronounMapping, + regex, + Shared, + SlashEditMessageType, + SlashSendMessageType, + timeUnits, + type BaseBushArgumentType, + type BushInspectOptions, + type GlobalCache, + type PronounCode, + type SharedCache, + type SlashMessage +} from '#lib'; +import { humanizeDuration as humanizeDurationMod } from '@notenoughupdates/humanize-duration'; +import assert from 'assert'; +import { exec } from 'child_process'; +import deepLock from 'deep-lock'; +import { Util as AkairoUtil } from 'discord-akairo'; +import { + cleanCodeBlockContent, + Constants as DiscordConstants, + EmbedBuilder, + escapeCodeBlock, + GuildMember, + Message, + OAuth2Scopes, + PermissionFlagsBits, + PermissionsBitField, + Routes, + ThreadMember, + User, + UserResolvable, + type APIEmbed, + type APIMessage, + type CommandInteraction, + type InteractionReplyOptions, + type PermissionsString, + type Snowflake, + type TextChannel +} from 'discord.js'; +import got from 'got'; +import _ from 'lodash'; +import { inspect as inspectUtil, promisify } from 'util'; +import CommandErrorListener from '../../listeners/commands/commandError.js'; +import * as Format from '../common/util/Format.js'; + +export type StripPrivate<T> = { [K in keyof T]: T[K] extends Record<string, any> ? StripPrivate<T[K]> : T[K] }; + +/** + * The hastebin urls used to post to hastebin, attempts to post in order + */ +const 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' +]; + +/** + * Maps an array of user ids to user objects. + * @param ids The list of IDs to map + * @returns The list of users mapped + */ +export async function mapIDs(ids: Snowflake[]): Promise<User[]> { + return await Promise.all(ids.map((id) => client.users.fetch(id))); +} + +/** + * 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); +} + +/** + * 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 promisify(exec)(command); +} + +/** + * Posts text to hastebin + * @param content The text to post + * @returns The url of the posted text + */ +export async function haste(content: string, substr = false): Promise<HasteResults> { + let isSubstr = false; + if (content.length > 400_000 && !substr) { + void 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 hasteURLs) { + try { + const res: HastebinRes = await got.post(`${url}/documents`, { body: content }).json(); + return { url: `${url}/${res.key}`, error: isSubstr ? 'substr' : undefined }; + } catch { + void 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 + */ +export async function resolveUserAsync(text: string): Promise<User | null> { + const idReg = /\d{17,19}/; + const idMatch = text.match(idReg); + if (idMatch) { + try { + return await client.users.fetch(text as Snowflake); + } catch { + // pass + } + } + const mentionReg = /<@!?(?<id>\d{17,19})>/; + const mentionMatch = text.match(mentionReg); + if (mentionMatch) { + try { + return await client.users.fetch(mentionMatch.groups!.id as Snowflake); + } catch { + // pass + } + } + const user = client.users.cache.find((u) => u.username === text); + if (user) return user; + return null; +} + +/** + * 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, ''); +} + +/** + * 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 + */ +export async function 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 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 client.console.warn(`codeblockError`, `Required Length: ${length}. Actual Length: ${code3.length}`, true); + void client.console.warn(`codeblockError`, code3, true); + throw new Error('code too long'); + } + return code3; +} + +/** + * 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 + }; +} + +/** + * 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. + */ +function mapCredential(key: string): string { + const mapping = { + token: 'Main Token', + devToken: 'Dev Token', + betaToken: 'Beta Token', + hypixelApiKey: 'Hypixel Api Key', + wolframAlphaAppId: 'Wolfram|Alpha App ID', + dbPassword: 'Database Password' + }; + return mapping[key as keyof typeof mapping] || key; +} + +/** + * Redacts credentials from a string. + * @param text The text to redact credentials from. + * @returns The redacted text. + */ +export function redact(text: string) { + for (const credentialName in { ...client.config.credentials, dbPassword: client.config.db.password }) { + const credential = { ...client.config.credentials, dbPassword: client.config.db.password }[ + credentialName as keyof typeof client.config.credentials + ]; + const replacement = 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; +} + +/** + * 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); +} + +/** + * 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. + */ +export async function inspectCleanRedactCodeblock( + input: any, + language?: CodeBlockLang | '', + inspectOptions?: BushInspectOptions, + length = 1024 +) { + input = inspect(input, inspectOptions ?? undefined); + if (inspectOptions) inspectOptions.inspectStrings = undefined; + input = cleanCodeBlockContent(input); + input = redact(input); + return 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}. + */ +export async function inspectCleanRedactHaste(input: any, inspectOptions?: BushInspectOptions): Promise<HasteResults> { + input = inspect(input, inspectOptions ?? undefined); + input = redact(input); + return 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. + */ +export function inspectAndRedact(input: any, inspectOptions?: BushInspectOptions): string { + input = inspect(input, inspectOptions ?? undefined); + return redact(input); +} + +/** + * 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); + } +} + +/** + * Gets a a configured channel as a TextChannel. + * @channel The channel to retrieve. + */ +export async function getConfigChannel(channel: keyof typeof client['config']['channels']): Promise<TextChannel> { + return (await client.channels.fetch(client.config.channels[channel])) as unknown as TextChannel; +} + +/** + * 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(', '); +} + +/** + * Get the global cache. + */ +export function getGlobal(): GlobalCache; +/** + * Get a key from the global cache. + * @param key The key to get in the global cache. + */ +export function getGlobal<K extends keyof GlobalCache>(key: K): GlobalCache[K]; +export function getGlobal(key?: keyof GlobalCache) { + return key ? client.cache.global[key] : client.cache.global; +} + +export function getShared(): SharedCache; +export function getShared<K extends keyof SharedCache>(key: K): SharedCache[K]; +export function getShared(key?: keyof SharedCache) { + return key ? client.cache.shared[key] : 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. + */ +export async function insertOrRemoveFromGlobal<K extends keyof typeof client['cache']['global']>( + action: 'add' | 'remove', + key: K, + value: typeof client['cache']['global'][K][0] +): Promise<Global | void> { + const row = + (await Global.findByPk(client.config.environment)) ?? (await Global.create({ environment: client.config.environment })); + const oldValue: any[] = row[key]; + const newValue = addOrRemoveFromArray(action, oldValue, value); + row[key] = newValue; + client.cache.global[key] = newValue; + return await row.save().catch((e) => 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. + */ +export async function insertOrRemoveFromShared< + K extends Exclude<keyof typeof client['cache']['shared'], 'badWords' | 'autoBanCode'> +>(action: 'add' | 'remove', key: K, value: typeof 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; + client.cache.shared[key] = newValue; + return await row.save().catch((e) => 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. + */ +export async function setGlobal<K extends keyof typeof client['cache']['global']>( + key: K, + value: typeof client['cache']['global'][K] +): Promise<Global | void> { + const row = + (await Global.findByPk(client.config.environment)) ?? (await Global.create({ environment: client.config.environment })); + row[key] = value; + client.cache.global[key] = value; + return await row.save().catch((e) => 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. + */ +export async function setShared<K extends Exclude<keyof typeof client['cache']['shared'], 'badWords' | 'autoBanCode'>>( + key: K, + value: typeof client['cache']['shared'][K] +): Promise<Shared | void> { + const row = (await Shared.findByPk(0)) ?? (await Shared.create()); + row[key] = value; + client.cache.shared[key] = value; + return await row.save().catch((e) => handleError('setShared', e)); +} + +/** + * 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 }; +} + +/** + * 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; +} + +/** + * 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?: number, round = true): string { + return humanizeDuration(new Date().getTime() - date.getTime(), largest ?? 3, 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); + +/** + * Send a message in the error logging channel and console for an error. + * @param context + * @param error + */ +export async function handleError(context: string, error: Error) { + await client.console.error(_.camelCase(context), `An error occurred:\n${formatError(error, false)}`, false); + await client.console.channelError({ + embeds: await CommandErrorListener.generateErrorEmbed({ 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. + */ +export async function 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 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. + */ +export async function getPronounsOf(user: User | Snowflake): Promise<Pronoun | undefined> { + const _user = await 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!]!; +} + +/** + * 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; +} + +/** + * Uploads an image to imgur. + * @param image The image to upload. + * @returns The url of the imgur. + */ +export async function uploadImageToImgur(image: string) { + const clientId = 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()) as { data: { link: string } }; + + return resp.data.link; +} + +/** + * 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 { + const missing: PermissionsString[] = []; + const sendPerm = message.channel!.isThread() ? 'SendMessages' : 'SendMessagesInThreads'; + if (!message.inGuild()) 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; +} + +/** + * Gets the prefix based off of the message. + * @param message The message to get the prefix from. + * @returns The prefix. + */ +export function prefix(message: CommandMessage | SlashMessage): string { + return message.util.isSlash ? '/' : client.config.isDevelopment ? 'dev ' : message.util.parsed?.prefix ?? client.config.prefix; +} + +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 ?? '' }; +} + +/** + * 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; +} + +export async function 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; +} + +export async function resolveMessagesFromLinks(content: string): Promise<APIMessage[]> { + const res: APIMessage[] = []; + + const links = await resolveMessageLinks(content); + if (!links.length) return []; + + for (const { guild_id, channel_id, message_id } of links) { + const guild = 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 client.rest.get(Routes.channelMessage(channel_id, message_id)).catch(() => null)) as APIMessage | null; + if (!message) continue; + + res.push(message); + } + + return res; +} + +/** + * 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; +} + +interface HastebinRes { + key: string; +} + +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; +} + +export interface HasteResults { + url?: string; + error?: 'content too long' | 'substr' | 'unable to post'; +} + +export interface ParsedDuration { + duration: number | null; + content: string | null; +} + +export interface ParsedDurationRes { + duration: number; + content: string; +} + +export type TimestampStyle = 't' | 'T' | 'd' | 'D' | 'f' | 'F' | 'R'; + +export interface MessageLinkParts { + guild_id: Snowflake; + channel_id: Snowflake; + message_id: Snowflake; +} diff --git a/src/lib/utils/Config.ts b/src/lib/utils/Config.ts deleted file mode 100644 index ce5ec06..0000000 --- a/src/lib/utils/Config.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { type Snowflake } from 'discord.js'; - -export class Config { - public credentials: Credentials; - public environment: Environment; - public owners: Snowflake[]; - public prefix: string; - public channels: Channels; - public db: DataBase; - public logging: Logging; - public supportGuild: SupportGuild; - - public constructor(options: ConfigOptions) { - this.credentials = options.credentials; - this.environment = options.environment; - this.owners = options.owners; - this.prefix = options.prefix; - this.channels = options.channels; - this.db = options.db; - this.logging = options.logging; - this.supportGuild = options.supportGuild; - } - - public get token(): string { - return this.environment === 'production' - ? this.credentials.token - : this.environment === 'beta' - ? this.credentials.betaToken - : this.credentials.devToken; - } - - public get isProduction(): boolean { - return this.environment === 'production'; - } - - public get isBeta(): boolean { - return this.environment === 'beta'; - } - - public get isDevelopment(): boolean { - return this.environment === 'development'; - } -} - -export interface ConfigOptions { - credentials: Credentials; - environment: Environment; - owners: Snowflake[]; - prefix: string; - channels: Channels; - db: DataBase; - logging: Logging; - supportGuild: SupportGuild; -} - -interface Credentials { - token: string; - betaToken: string; - devToken: string; - hypixelApiKey: string; - wolframAlphaAppId: string; - imgurClientId: string; - imgurClientSecret: string; - sentryDsn: string; - perspectiveApiKey: string; -} - -type Environment = 'production' | 'beta' | 'development'; - -interface Channels { - log: Snowflake; - error: Snowflake; - dm: Snowflake; - servers: Snowflake; -} - -interface DataBase { - host: string; - port: number; - username: string; - password: string; -} - -interface Logging { - db: boolean; - verbose: boolean; - info: boolean; -} - -interface SupportGuild { - id: Snowflake; - invite: string; -} |