diff options
Diffstat (limited to 'src/lib/utils/BushUtils.ts')
-rw-r--r-- | src/lib/utils/BushUtils.ts | 1058 |
1 files changed, 1058 insertions, 0 deletions
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; +} |