diff options
author | IRONM00N <64110067+IRONM00N@users.noreply.github.com> | 2022-08-18 22:42:12 -0400 |
---|---|---|
committer | IRONM00N <64110067+IRONM00N@users.noreply.github.com> | 2022-08-18 22:42:12 -0400 |
commit | 2356d2c44736fb83021dacb551625852111c8ce6 (patch) | |
tree | 10408d22fdd7a358d2f5c5917c3b59e55aa4c19d /lib/extensions/discord-akairo | |
parent | 8aed6f93f7740c592cbc0e2f9fd3269c05286077 (diff) | |
download | tanzanite-2356d2c44736fb83021dacb551625852111c8ce6.tar.gz tanzanite-2356d2c44736fb83021dacb551625852111c8ce6.tar.bz2 tanzanite-2356d2c44736fb83021dacb551625852111c8ce6.zip |
restructure, experimental presence and member automod, fixed bugs probably made some more bugs
Diffstat (limited to 'lib/extensions/discord-akairo')
-rw-r--r-- | lib/extensions/discord-akairo/BushArgumentTypeCaster.ts | 3 | ||||
-rw-r--r-- | lib/extensions/discord-akairo/BushClient.ts | 600 | ||||
-rw-r--r-- | lib/extensions/discord-akairo/BushCommand.ts | 586 | ||||
-rw-r--r-- | lib/extensions/discord-akairo/BushCommandHandler.ts | 37 | ||||
-rw-r--r-- | lib/extensions/discord-akairo/BushInhibitor.ts | 19 | ||||
-rw-r--r-- | lib/extensions/discord-akairo/BushInhibitorHandler.ts | 3 | ||||
-rw-r--r-- | lib/extensions/discord-akairo/BushListener.ts | 3 | ||||
-rw-r--r-- | lib/extensions/discord-akairo/BushListenerHandler.ts | 3 | ||||
-rw-r--r-- | lib/extensions/discord-akairo/BushTask.ts | 3 | ||||
-rw-r--r-- | lib/extensions/discord-akairo/BushTaskHandler.ts | 3 | ||||
-rw-r--r-- | lib/extensions/discord-akairo/SlashMessage.ts | 3 |
11 files changed, 1263 insertions, 0 deletions
diff --git a/lib/extensions/discord-akairo/BushArgumentTypeCaster.ts b/lib/extensions/discord-akairo/BushArgumentTypeCaster.ts new file mode 100644 index 0000000..def7ad6 --- /dev/null +++ b/lib/extensions/discord-akairo/BushArgumentTypeCaster.ts @@ -0,0 +1,3 @@ +import { type CommandMessage } from '#lib'; + +export type BushArgumentTypeCaster<R = unknown> = (message: CommandMessage, phrase: string) => R; diff --git a/lib/extensions/discord-akairo/BushClient.ts b/lib/extensions/discord-akairo/BushClient.ts new file mode 100644 index 0000000..1a6bb8c --- /dev/null +++ b/lib/extensions/discord-akairo/BushClient.ts @@ -0,0 +1,600 @@ +import { + abbreviatedNumber, + contentWithDuration, + discordEmoji, + duration, + durationSeconds, + globalUser, + messageLink, + permission, + roleWithDuration, + snowflake +} from '#args'; +import { BushClientEvents, emojis, formatError, inspect } from '#lib'; +import { patch, type PatchedElements } from '@notenoughupdates/events-intercept'; +import * as Sentry from '@sentry/node'; +import { + AkairoClient, + ArgumentTypeCaster, + ContextMenuCommandHandler, + version as akairoVersion, + type ArgumentPromptData, + type OtherwiseContentSupplier +} from 'discord-akairo'; +import { + ActivityType, + GatewayIntentBits, + MessagePayload, + Options, + Partials, + Structures, + version as discordJsVersion, + type Awaitable, + type If, + type InteractionReplyOptions, + type Message, + type MessageEditOptions, + type MessageOptions, + type ReplyMessageOptions, + type Snowflake, + type UserResolvable, + type WebhookEditMessageOptions +} from 'discord.js'; +import type EventEmitter from 'events'; +import { google } from 'googleapis'; +import path from 'path'; +import readline from 'readline'; +import type { Options as SequelizeOptions, Sequelize as SequelizeType } from 'sequelize'; +import { fileURLToPath } from 'url'; +import type { Config } from '../../../config/Config.js'; +import UpdateCacheTask from '../../../src/tasks/cache/updateCache.js'; +import UpdateStatsTask from '../../../src/tasks/feature/updateStats.js'; +import { tinyColor } from '../../arguments/tinyColor.js'; +import { BushCache } from '../../common/BushCache.js'; +import { HighlightManager } from '../../common/HighlightManager.js'; +import { ActivePunishment } from '../../models/instance/ActivePunishment.js'; +import { Guild as GuildDB } from '../../models/instance/Guild.js'; +import { Highlight } from '../../models/instance/Highlight.js'; +import { Level } from '../../models/instance/Level.js'; +import { ModLog } from '../../models/instance/ModLog.js'; +import { Reminder } from '../../models/instance/Reminder.js'; +import { StickyRole } from '../../models/instance/StickyRole.js'; +import { Global } from '../../models/shared/Global.js'; +import { GuildCount } from '../../models/shared/GuildCount.js'; +import { MemberCount } from '../../models/shared/MemberCount.js'; +import { Shared } from '../../models/shared/Shared.js'; +import { Stat } from '../../models/shared/Stat.js'; +import { AllowedMentions } from '../../utils/AllowedMentions.js'; +import { BushClientUtils } from '../../utils/BushClientUtils.js'; +import { BushLogger } from '../../utils/BushLogger.js'; +import { ExtendedGuild } from '../discord.js/ExtendedGuild.js'; +import { ExtendedGuildMember } from '../discord.js/ExtendedGuildMember.js'; +import { ExtendedMessage } from '../discord.js/ExtendedMessage.js'; +import { ExtendedUser } from '../discord.js/ExtendedUser.js'; +import { BushCommandHandler } from './BushCommandHandler.js'; +import { BushInhibitorHandler } from './BushInhibitorHandler.js'; +import { BushListenerHandler } from './BushListenerHandler.js'; +import { BushTaskHandler } from './BushTaskHandler.js'; +const { Sequelize } = (await import('sequelize')).default; + +declare module 'discord.js' { + export interface Client extends EventEmitter { + /** The ID of the owner(s). */ + ownerID: Snowflake | Snowflake[]; + /** The ID of the superUser(s). */ + superUserID: Snowflake | Snowflake[]; + /** Whether or not the client is ready. */ + customReady: boolean; + /** The configuration for the client. */ + readonly config: Config; + /** Stats for the client. */ + readonly stats: BushStats; + /** The handler for the bot's listeners. */ + readonly listenerHandler: BushListenerHandler; + /** The handler for the bot's command inhibitors. */ + readonly inhibitorHandler: BushInhibitorHandler; + /** The handler for the bot's commands. */ + readonly commandHandler: BushCommandHandler; + /** The handler for the bot's tasks. */ + readonly taskHandler: BushTaskHandler; + /** The handler for the bot's context menu commands. */ + readonly contextMenuCommandHandler: ContextMenuCommandHandler; + /** The database connection for this instance of the bot (production, beta, or development). */ + readonly instanceDB: SequelizeType; + /** The database connection that is shared between all instances of the bot. */ + readonly sharedDB: SequelizeType; + /** A custom logging system for the bot. */ + readonly logger: BushLogger; + /** Cached global and guild database data. */ + readonly cache: BushCache; + /** Sentry error reporting for the bot. */ + readonly sentry: typeof Sentry; + /** Manages most aspects of the highlight command */ + readonly highlightManager: HighlightManager; + /** The perspective api */ + perspective: any; + /** Client utilities. */ + readonly utils: BushClientUtils; + /** A custom logging system for the bot. */ + get console(): BushLogger; + on<K extends keyof BushClientEvents>(event: K, listener: (...args: BushClientEvents[K]) => Awaitable<void>): this; + once<K extends keyof BushClientEvents>(event: K, listener: (...args: BushClientEvents[K]) => Awaitable<void>): this; + emit<K extends keyof BushClientEvents>(event: K, ...args: BushClientEvents[K]): boolean; + off<K extends keyof BushClientEvents>(event: K, listener: (...args: BushClientEvents[K]) => Awaitable<void>): this; + removeAllListeners<K extends keyof BushClientEvents>(event?: K): this; + /** + * Checks if a user is the owner of this bot. + * @param user - User to check. + */ + isOwner(user: UserResolvable): boolean; + /** + * Checks if a user is a super user of this bot. + * @param user - User to check. + */ + isSuperUser(user: UserResolvable): boolean; + } +} + +export type ReplyMessageType = string | MessagePayload | ReplyMessageOptions; +export type EditMessageType = string | MessageEditOptions | MessagePayload; +export type SlashSendMessageType = string | MessagePayload | InteractionReplyOptions; +export type SlashEditMessageType = string | MessagePayload | WebhookEditMessageOptions; +export type SendMessageType = string | MessagePayload | MessageOptions; + +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: false +}); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +/** + * The main hub for interacting with the Discord API. + */ +export class BushClient<Ready extends boolean = boolean> extends AkairoClient<Ready> { + public declare ownerID: Snowflake[]; + public declare superUserID: Snowflake[]; + + /** + * Whether or not the client is ready. + */ + public override customReady = false; + + /** + * Stats for the client. + */ + public override readonly stats: BushStats = { cpu: undefined, commandsUsed: 0n, slashCommandsUsed: 0n }; + + /** + * The handler for the bot's listeners. + */ + public override readonly listenerHandler: BushListenerHandler; + + /** + * The handler for the bot's command inhibitors. + */ + public override readonly inhibitorHandler: BushInhibitorHandler; + + /** + * The handler for the bot's commands. + */ + public override readonly commandHandler: BushCommandHandler; + + /** + * The handler for the bot's tasks. + */ + public override readonly taskHandler: BushTaskHandler; + + /** + * The handler for the bot's context menu commands. + */ + public override readonly contextMenuCommandHandler: ContextMenuCommandHandler; + + /** + * The database connection for this instance of the bot (production, beta, or development). + */ + public override readonly instanceDB: SequelizeType; + + /** + * The database connection that is shared between all instances of the bot. + */ + public override readonly sharedDB: SequelizeType; + + /** + * A custom logging system for the bot. + */ + public override readonly logger: BushLogger = new BushLogger(this); + + /** + * Cached global and guild database data. + */ + public override readonly cache = new BushCache(); + + /** + * Sentry error reporting for the bot. + */ + public override readonly sentry!: typeof Sentry; + + /** + * Manages most aspects of the highlight command + */ + public override readonly highlightManager: HighlightManager = new HighlightManager(this); + + /** + * The perspective api + */ + public override perspective: any; + + /** + * Client utilities. + */ + public override readonly utils: BushClientUtils = new BushClientUtils(this); + + /** + * @param config The configuration for the client. + */ + public constructor( + /** + * The configuration for the client. + */ + public override readonly config: Config + ) { + super({ + ownerID: config.owners, + intents: Object.keys(GatewayIntentBits) + .map((i) => (typeof i === 'string' ? GatewayIntentBits[i as keyof typeof GatewayIntentBits] : i)) + .reduce((acc, p) => acc | p, 0), + partials: Object.keys(Partials).map((p) => Partials[p as keyof typeof Partials]), + presence: { + activities: [{ name: 'Beep Boop', type: ActivityType.Watching }], + status: 'online' + }, + allowedMentions: AllowedMentions.none(), // no mentions by default + makeCache: Options.cacheWithLimits({ + PresenceManager: { + maxSize: 0, + keepOverLimit: (_, key) => { + if (config.owners.includes(key)) return true; + + return HighlightManager.keep.has(key); + } + } + }), + failIfNotExists: false, + rest: { api: 'https://canary.discord.com/api' } + }); + patch(this); + + this.token = config.token as If<Ready, string, string | null>; + + /* =-=-= handlers =-=-= */ + this.listenerHandler = new BushListenerHandler(this, { + directory: path.join(__dirname, '..', '..', '..', 'src', 'listeners'), + extensions: ['.js'], + automateCategories: true + }); + this.inhibitorHandler = new BushInhibitorHandler(this, { + directory: path.join(__dirname, '..', '..', '..', 'src', 'inhibitors'), + extensions: ['.js'], + automateCategories: true + }); + this.taskHandler = new BushTaskHandler(this, { + directory: path.join(__dirname, '..', '..', '..', 'src', 'tasks'), + extensions: ['.js'], + automateCategories: true + }); + + const modify = async ( + message: Message, + text: string | MessagePayload | MessageOptions | OtherwiseContentSupplier, + data: ArgumentPromptData, + replaceError: boolean + ) => { + const ending = '\n\n Type **cancel** to cancel the command'; + const options = typeof text === 'function' ? await text(message, data) : text; + const search = '{error}', + replace = emojis.error; + + if (typeof options === 'string') return (replaceError ? options.replace(search, replace) : options) + ending; + + if (options instanceof MessagePayload) { + if (options.options.content) { + if (replaceError) options.options.content = options.options.content.replace(search, replace); + options.options.content += ending; + } + } else if (options.content) { + if (replaceError) options.content = options.content.replace(search, replace); + options.content += ending; + } + return options; + }; + + this.commandHandler = new BushCommandHandler(this, { + directory: path.join(__dirname, '..', '..', '..', 'src', 'commands'), + extensions: ['.js'], + prefix: async ({ guild }: Message) => { + if (this.config.isDevelopment) return 'dev '; + if (!guild) return this.config.prefix; + const prefix = await guild.getSetting('prefix'); + return (prefix ?? this.config.prefix) as string; + }, + allowMention: true, + handleEdits: true, + commandUtil: true, + commandUtilLifetime: 300_000, // 5 minutes + argumentDefaults: { + prompt: { + start: 'Placeholder argument prompt. **If you see this please tell my developers**.', + retry: 'Placeholder failed argument prompt. **If you see this please tell my developers**.', + modifyStart: (message, text, data) => modify(message, text, data, false), + modifyRetry: (message, text, data) => modify(message, text, data, true), + timeout: ':hourglass: You took too long the command has been cancelled.', + ended: 'You exceeded the maximum amount of tries the command has been cancelled', + cancel: 'The command has been cancelled', + retries: 3, + time: 3e4 + }, + otherwise: '' + }, + automateCategories: false, + autoRegisterSlashCommands: true, + skipBuiltInPostInhibitors: true, + aliasReplacement: /-/g + }); + this.contextMenuCommandHandler = new ContextMenuCommandHandler(this, { + directory: path.join(__dirname, '..', '..', '..', 'src', 'context-menu-commands'), + extensions: ['.js'], + automateCategories: true + }); + + /* =-=-= databases =-=-= */ + const sharedDBOptions: SequelizeOptions = { + username: this.config.db.username, + password: this.config.db.password, + dialect: 'postgres', + host: this.config.db.host, + port: this.config.db.port, + logging: this.config.logging.db ? (sql) => this.logger.debug(sql) : false, + timezone: 'America/New_York' + }; + this.instanceDB = new Sequelize({ + ...sharedDBOptions, + database: this.config.isDevelopment ? 'bushbot-dev' : this.config.isBeta ? 'bushbot-beta' : 'bushbot' + }); + this.sharedDB = new Sequelize({ + ...sharedDBOptions, + database: 'bushbot-shared' + }); + + this.sentry = Sentry; + } + + /** + * A custom logging system for the bot. + */ + public override get console(): BushLogger { + return this.logger; + } + + /** + * Extends discord.js structures before the client is instantiated. + */ + public static extendStructures(): void { + Structures.extend('GuildMember', () => ExtendedGuildMember); + Structures.extend('Guild', () => ExtendedGuild); + Structures.extend('Message', () => ExtendedMessage); + Structures.extend('User', () => ExtendedUser); + } + + /** + * Initializes the bot. + */ + public async init() { + if (parseInt(process.versions.node.split('.')[0]) < 17) { + void (await this.console.error('version', `Please use node <<v17.x.x>>, not <<${process.version}>>.`, false)); + process.exit(2); + } + + this.setMaxListeners(20); + + this.perspective = await google.discoverAPI<any>('https://commentanalyzer.googleapis.com/$discovery/rest?version=v1alpha1'); + + this.commandHandler.useInhibitorHandler(this.inhibitorHandler); + this.commandHandler.useListenerHandler(this.listenerHandler); + this.commandHandler.useTaskHandler(this.taskHandler); + this.commandHandler.useContextMenuCommandHandler(this.contextMenuCommandHandler); + this.commandHandler.ignorePermissions = this.config.owners; + this.commandHandler.ignoreCooldown = [...new Set([...this.config.owners, ...this.cache.shared.superUsers])]; + const emitters: Emitters = { + client: this, + commandHandler: this.commandHandler, + inhibitorHandler: this.inhibitorHandler, + listenerHandler: this.listenerHandler, + taskHandler: this.taskHandler, + contextMenuCommandHandler: this.contextMenuCommandHandler, + process, + stdin: rl, + gateway: this.ws, + rest: this.rest, + ws: this.ws + }; + this.listenerHandler.setEmitters(emitters); + this.commandHandler.resolver.addTypes({ + duration: <ArgumentTypeCaster>duration, + contentWithDuration: <ArgumentTypeCaster>contentWithDuration, + permission: <ArgumentTypeCaster>permission, + snowflake: <ArgumentTypeCaster>snowflake, + discordEmoji: <ArgumentTypeCaster>discordEmoji, + roleWithDuration: <ArgumentTypeCaster>roleWithDuration, + abbreviatedNumber: <ArgumentTypeCaster>abbreviatedNumber, + durationSeconds: <ArgumentTypeCaster>durationSeconds, + globalUser: <ArgumentTypeCaster>globalUser, + messageLink: <ArgumentTypeCaster>messageLink, + tinyColor: <ArgumentTypeCaster>tinyColor + }); + + this.sentry.setTag('process', process.pid.toString()); + this.sentry.setTag('discord.js', discordJsVersion); + this.sentry.setTag('discord-akairo', akairoVersion); + void this.logger.success('startup', `Successfully connected to <<Sentry>>.`, false); + + // loads all the handlers + const handlers = { + commands: this.commandHandler, + contextMenuCommands: this.contextMenuCommandHandler, + listeners: this.listenerHandler, + inhibitors: this.inhibitorHandler, + tasks: this.taskHandler + }; + const handlerPromises = Object.entries(handlers).map(([handlerName, handler]) => + handler + .loadAll() + .then(() => { + void this.logger.success('startup', `Successfully loaded <<${handlerName}>>.`, false); + }) + .catch((e) => { + void this.logger.error('startup', `Unable to load loader <<${handlerName}>> with error:\n${formatError(e)}`, false); + if (process.argv.includes('dry')) process.exit(1); + }) + ); + await Promise.allSettled(handlerPromises); + } + + /** + * Connects to the database, initializes models, and creates tables if they do not exist. + */ + public async dbPreInit() { + try { + await this.instanceDB.authenticate(); + GuildDB.initModel(this.instanceDB, this); + ModLog.initModel(this.instanceDB); + ActivePunishment.initModel(this.instanceDB); + Level.initModel(this.instanceDB); + StickyRole.initModel(this.instanceDB); + Reminder.initModel(this.instanceDB); + Highlight.initModel(this.instanceDB); + await this.instanceDB.sync({ alter: true }); // Sync all tables to fix everything if updated + await this.console.success('startup', `Successfully connected to <<instance database>>.`, false); + } catch (e) { + await this.console.error( + 'startup', + `Failed to connect to <<instance database>> with error:\n${inspect(e, { colors: true, depth: 1 })}`, + false + ); + process.exit(2); + } + try { + await this.sharedDB.authenticate(); + Stat.initModel(this.sharedDB); + Global.initModel(this.sharedDB); + Shared.initModel(this.sharedDB); + MemberCount.initModel(this.sharedDB); + GuildCount.initModel(this.sharedDB); + await this.sharedDB.sync({ + // Sync all tables to fix everything if updated + // if another instance restarts we don't want to overwrite new changes made in development + alter: this.config.isDevelopment + }); + await this.console.success('startup', `Successfully connected to <<shared database>>.`, false); + } catch (e) { + await this.console.error( + 'startup', + `Failed to connect to <<shared database>> with error:\n${inspect(e, { colors: true, depth: 1 })}`, + false + ); + process.exit(2); + } + } + + /** + * Starts the bot + */ + public async start() { + this.intercept('ready', async (arg, done) => { + const promises = this.guilds.cache + .filter((g) => g.large) + .map((guild) => { + return guild.members.fetch(); + }); + await Promise.all(promises); + this.customReady = true; + this.taskHandler.startAll(); + return done(null, `intercepted ${arg}`); + }); + + try { + await this.highlightManager.syncCache(); + await UpdateCacheTask.init(this); + void this.console.success('startup', `Successfully created <<cache>>.`, false); + const stats = await UpdateStatsTask.init(this); + this.stats.commandsUsed = stats.commandsUsed; + this.stats.slashCommandsUsed = stats.slashCommandsUsed; + await this.login(this.token!); + } catch (e) { + await this.console.error('start', inspect(e, { colors: true, depth: 1 }), false); + process.exit(1); + } + } + + /** + * Logs out, terminates the connection to Discord, and destroys the client. + */ + public override destroy(relogin = false): void | Promise<string> { + super.destroy(); + if (relogin) { + return this.login(this.token!); + } + } + + public override isOwner(user: UserResolvable): boolean { + return this.config.owners.includes(this.users.resolveId(user!)!); + } + + public override isSuperUser(user: UserResolvable): boolean { + const userID = this.users.resolveId(user)!; + return this.cache.shared.superUsers.includes(userID) || this.config.owners.includes(userID); + } +} + +export interface BushClient<Ready extends boolean = boolean> extends EventEmitter, PatchedElements, AkairoClient<Ready> { + on<K extends keyof BushClientEvents>(event: K, listener: (...args: BushClientEvents[K]) => Awaitable<void>): this; + once<K extends keyof BushClientEvents>(event: K, listener: (...args: BushClientEvents[K]) => Awaitable<void>): this; + emit<K extends keyof BushClientEvents>(event: K, ...args: BushClientEvents[K]): boolean; + off<K extends keyof BushClientEvents>(event: K, listener: (...args: BushClientEvents[K]) => Awaitable<void>): this; + removeAllListeners<K extends keyof BushClientEvents>(event?: K): this; +} + +/** + * Various statistics + */ +export interface BushStats { + /** + * The average cpu usage of the bot from the past 60 seconds. + */ + cpu: number | undefined; + + /** + * The total number of times any command has been used. + */ + commandsUsed: bigint; + + /** + * The total number of times any slash command has been used. + */ + slashCommandsUsed: bigint; +} + +export interface Emitters { + client: BushClient; + commandHandler: BushClient['commandHandler']; + inhibitorHandler: BushClient['inhibitorHandler']; + listenerHandler: BushClient['listenerHandler']; + taskHandler: BushClient['taskHandler']; + contextMenuCommandHandler: BushClient['contextMenuCommandHandler']; + process: NodeJS.Process; + stdin: readline.Interface; + gateway: BushClient['ws']; + rest: BushClient['rest']; + ws: BushClient['ws']; +} diff --git a/lib/extensions/discord-akairo/BushCommand.ts b/lib/extensions/discord-akairo/BushCommand.ts new file mode 100644 index 0000000..dc2295f --- /dev/null +++ b/lib/extensions/discord-akairo/BushCommand.ts @@ -0,0 +1,586 @@ +import { type DiscordEmojiInfo, type RoleWithDuration } from '#args'; +import { + type BushArgumentTypeCaster, + type BushClient, + type BushCommandHandler, + type BushInhibitor, + type BushListener, + type BushTask, + type ParsedDuration +} from '#lib'; +import { + ArgumentMatch, + Command, + CommandUtil, + type AkairoApplicationCommandAutocompleteOption, + type AkairoApplicationCommandChannelOptionData, + type AkairoApplicationCommandChoicesData, + type AkairoApplicationCommandNonOptionsData, + type AkairoApplicationCommandNumericOptionData, + type AkairoApplicationCommandOptionData, + type AkairoApplicationCommandSubCommandData, + type AkairoApplicationCommandSubGroupData, + type ArgumentOptions, + type ArgumentType, + type ArgumentTypeCaster, + type BaseArgumentType, + type CommandOptions, + type ContextMenuCommand, + type MissingPermissionSupplier, + type SlashOption, + type SlashResolveType +} from 'discord-akairo'; +import { + Message, + User, + type ApplicationCommandOptionChoiceData, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + type ApplicationCommandOptionType, + type PermissionResolvable, + type PermissionsString, + type Snowflake +} from 'discord.js'; +import _ from 'lodash'; +import { SlashMessage } from './SlashMessage.js'; + +export interface OverriddenBaseArgumentType extends BaseArgumentType { + commandAlias: BushCommand | null; + command: BushCommand | null; + inhibitor: BushInhibitor | null; + listener: BushListener | null; + task: BushTask | null; + contextMenuCommand: ContextMenuCommand | null; +} + +export interface BaseBushArgumentType extends OverriddenBaseArgumentType { + duration: number | null; + contentWithDuration: ParsedDuration; + permission: PermissionsString | null; + snowflake: Snowflake | null; + discordEmoji: DiscordEmojiInfo | null; + roleWithDuration: RoleWithDuration | null; + abbreviatedNumber: number | null; + globalUser: User | null; + messageLink: Message | null; + durationSeconds: number | null; + tinyColor: string | null; +} + +export type BushArgumentType = keyof BaseBushArgumentType | RegExp; + +interface BaseBushArgumentOptions extends Omit<ArgumentOptions, 'type' | 'prompt'>, ExtraArgumentOptions { + id: string; + description: string; + + /** + * The message sent for the prompt and the slash command description. + */ + prompt?: string; + + /** + * The message set for the retry prompt. + */ + retry?: string; + + /** + * Whether or not the argument is optional. + */ + optional?: boolean; + + /** + * The type used for slash commands. Set to false to disable this argument for slash commands. + */ + slashType: AkairoApplicationCommandOptionData['type'] | false; + + /** + * Allows you to get a discord resolved object + * + * ex. get the resolved member object when the type is {@link ApplicationCommandOptionType.User User} + */ + slashResolve?: SlashResolveType; + + /** + * The choices of the option for the user to pick from + */ + choices?: ApplicationCommandOptionChoiceData[]; + + /** + * Whether the option is an autocomplete option + */ + autocomplete?: boolean; + + /** + * When the option type is channel, the allowed types of channels that can be selected + */ + channelTypes?: AkairoApplicationCommandChannelOptionData['channelTypes']; + + /** + * The minimum value for an {@link ApplicationCommandOptionType.Integer Integer} or {@link ApplicationCommandOptionType.Number Number} option + */ + minValue?: number; + + /** + * The maximum value for an {@link ApplicationCommandOptionType.Integer Integer} or {@link ApplicationCommandOptionType.Number Number} option + */ + maxValue?: number; +} + +interface ExtraArgumentOptions { + /** + * Restrict this argument to only slash or only text commands. + */ + only?: 'slash' | 'text'; + + /** + * Readable type for the help command. + */ + readableType?: string; + + /** + * Whether the argument is only accessible to the owners. + * @default false + */ + ownerOnly?: boolean; + + /** + * Whether the argument is only accessible to the super users. + * @default false + */ + superUserOnly?: boolean; +} + +export interface BushArgumentOptions extends BaseBushArgumentOptions { + /** + * The type that the argument should be cast to. + * - `string` does not cast to any type. + * - `lowercase` makes the input lowercase. + * - `uppercase` makes the input uppercase. + * - `charCodes` transforms the input to an array of char codes. + * - `number` casts to a number. + * - `integer` casts to an integer. + * - `bigint` casts to a big integer. + * - `url` casts to an `URL` object. + * - `date` casts to a `Date` object. + * - `color` casts a hex code to an integer. + * - `commandAlias` tries to resolve to a command from an alias. + * - `command` matches the ID of a command. + * - `inhibitor` matches the ID of an inhibitor. + * - `listener` matches the ID of a listener. + * + * Possible Discord-related types. + * These types can be plural (add an 's' to the end) and a collection of matching objects will be used. + * - `user` tries to resolve to a user. + * - `member` tries to resolve to a member. + * - `relevant` tries to resolve to a relevant user, works in both guilds and DMs. + * - `channel` tries to resolve to a channel. + * - `textChannel` tries to resolve to a text channel. + * - `voiceChannel` tries to resolve to a voice channel. + * - `stageChannel` tries to resolve to a stage channel. + * - `threadChannel` tries to resolve a thread channel. + * - `role` tries to resolve to a role. + * - `emoji` tries to resolve to a custom emoji. + * - `guild` tries to resolve to a guild. + * - `permission` tries to resolve to a permissions. + * + * Other Discord-related types: + * - `message` tries to fetch a message from an ID within the channel. + * - `guildMessage` tries to fetch a message from an ID within the guild. + * - `relevantMessage` is a combination of the above, works in both guilds and DMs. + * - `invite` tries to fetch an invite object from a link. + * - `userMention` matches a mention of a user. + * - `memberMention` matches a mention of a guild member. + * - `channelMention` matches a mention of a channel. + * - `roleMention` matches a mention of a role. + * - `emojiMention` matches a mention of an emoji. + * + * Misc: + * - `duration` tries to parse duration in milliseconds + * - `contentWithDuration` tries to parse duration in milliseconds and returns the remaining content with the duration + * removed + */ + type?: BushArgumentType | (keyof BaseBushArgumentType)[] | BushArgumentTypeCaster; +} + +export interface CustomBushArgumentOptions extends BaseBushArgumentOptions { + /** + * An array of strings can be used to restrict input to only those strings, case insensitive. + * The array can also contain an inner array of strings, for aliases. + * If so, the first entry of the array will be used as the final argument. + * + * A regular expression can also be used. + * The evaluated argument will be an object containing the `match` and `matches` if global. + */ + customType?: (string | string[])[] | RegExp | string | null; +} + +export type BushMissingPermissionSupplier = (message: CommandMessage | SlashMessage) => Promise<any> | any; + +interface ExtendedCommandOptions { + /** + * Whether the command is hidden from the help command. + */ + hidden?: boolean; + + /** + * The channels the command is limited to run in. + */ + restrictedChannels?: Snowflake[]; + + /** + * The guilds the command is limited to run in. + */ + restrictedGuilds?: Snowflake[]; + + /** + * Show how to use the command. + */ + usage: string[]; + + /** + * Examples for how to use the command. + */ + examples: string[]; + + /** + * A fake command, completely hidden from the help command. + */ + pseudo?: boolean; + + /** + * Allow this command to be run in channels that are blacklisted. + */ + bypassChannelBlacklist?: boolean; + + /** + * Use instead of {@link BaseBushCommandOptions.args} when using argument generators or custom slashOptions + */ + helpArgs?: ArgsInfo[]; + + /** + * Extra information about the command, displayed in the help command. + */ + note?: string; +} + +export interface BaseBushCommandOptions + extends Omit<CommandOptions, 'userPermissions' | 'clientPermissions' | 'args'>, + ExtendedCommandOptions { + /** + * The description of the command. + */ + description: string; + + /** + * The arguments for the command. + */ + args?: BushArgumentOptions[] & CustomBushArgumentOptions[]; + + category: string; + + /** + * Permissions required by the client to run this command. + */ + clientPermissions: bigint | bigint[] | BushMissingPermissionSupplier; + + /** + * Permissions required by the user to run this command. + */ + userPermissions: bigint | bigint[] | BushMissingPermissionSupplier; + + /** + * Whether the argument is only accessible to the owners. + */ + ownerOnly?: boolean; + + /** + * Whether the argument is only accessible to the super users. + */ + superUserOnly?: boolean; +} + +export type BushCommandOptions = Omit<BaseBushCommandOptions, 'helpArgs'> | Omit<BaseBushCommandOptions, 'args'>; + +export interface ArgsInfo { + /** + * The name of the argument. + */ + name: string; + + /** + * The description of the argument. + */ + description: string; + + /** + * Whether the argument is optional. + * @default false + */ + optional?: boolean; + + /** + * Whether or not the argument has autocomplete enabled. + * @default false + */ + autocomplete?: boolean; + + /** + * Whether the argument is restricted a certain command. + * @default 'slash & text' + */ + only?: 'slash & text' | 'slash' | 'text'; + + /** + * The method that arguments are matched for text commands. + * @default 'phrase' + */ + match?: ArgumentMatch; + + /** + * The readable type of the argument. + */ + type: string; + + /** + * If {@link match} is 'flag' or 'option', these are the flags that are matched + * @default [] + */ + flag?: string[]; + + /** + * Whether the argument is only accessible to the owners. + * @default false + */ + ownerOnly?: boolean; + + /** + * Whether the argument is only accessible to the super users. + * @default false + */ + superUserOnly?: boolean; +} + +export abstract class BushCommand extends Command { + public declare client: BushClient; + public declare handler: BushCommandHandler; + public declare description: string; + + /** + * Show how to use the command. + */ + public usage: string[]; + + /** + * Examples for how to use the command. + */ + public examples: string[]; + + /** + * The options sent to the constructor + */ + public options: BushCommandOptions; + + /** + * The options sent to the super call + */ + public parsedOptions: CommandOptions; + + /** + * The channels the command is limited to run in. + */ + public restrictedChannels: Snowflake[] | undefined; + + /** + * The guilds the command is limited to run in. + */ + public restrictedGuilds: Snowflake[] | undefined; + + /** + * Whether the command is hidden from the help command. + */ + public hidden: boolean; + + /** + * A fake command, completely hidden from the help command. + */ + public pseudo: boolean; + + /** + * Allow this command to be run in channels that are blacklisted. + */ + public bypassChannelBlacklist: boolean; + + /** + * Info about the arguments for the help command. + */ + public argsInfo?: ArgsInfo[]; + + /** + * Extra information about the command, displayed in the help command. + */ + public note?: string; + + public constructor(id: string, options: BushCommandOptions) { + const options_ = options as BaseBushCommandOptions; + + if (options_.args && typeof options_.args !== 'function') { + options_.args.forEach((_, index: number) => { + if ('customType' in (options_.args?.[index] ?? {})) { + if (!options_.args![index]['type']) options_.args![index]['type'] = options_.args![index]['customType']! as any; + delete options_.args![index]['customType']; + } + }); + } + + const newOptions: Partial<CommandOptions & ExtendedCommandOptions> = {}; + for (const _key in options_) { + const key = _key as keyof typeof options_; // you got to love typescript + if (key === 'args' && 'args' in options_ && typeof options_.args === 'object') { + const newTextArgs: (ArgumentOptions & ExtraArgumentOptions)[] = []; + const newSlashArgs: SlashOption[] = []; + for (const arg of options_.args) { + if (arg.only !== 'slash' && !options_.slashOnly) { + const newArg: ArgumentOptions & ExtraArgumentOptions = {}; + if ('default' in arg) newArg.default = arg.default; + if ('description' in arg) newArg.description = arg.description; + if ('flag' in arg) newArg.flag = arg.flag; + if ('id' in arg) newArg.id = arg.id; + if ('index' in arg) newArg.index = arg.index; + if ('limit' in arg) newArg.limit = arg.limit; + if ('match' in arg) newArg.match = arg.match; + if ('modifyOtherwise' in arg) newArg.modifyOtherwise = arg.modifyOtherwise; + if ('multipleFlags' in arg) newArg.multipleFlags = arg.multipleFlags; + if ('otherwise' in arg) newArg.otherwise = arg.otherwise; + if ('prompt' in arg || 'retry' in arg || 'optional' in arg) { + newArg.prompt = {}; + if ('prompt' in arg) newArg.prompt.start = arg.prompt; + if ('retry' in arg) newArg.prompt.retry = arg.retry; + if ('optional' in arg) newArg.prompt.optional = arg.optional; + } + if ('type' in arg) newArg.type = arg.type as ArgumentType | ArgumentTypeCaster; + if ('unordered' in arg) newArg.unordered = arg.unordered; + if ('ownerOnly' in arg) newArg.ownerOnly = arg.ownerOnly; + if ('superUserOnly' in arg) newArg.superUserOnly = arg.superUserOnly; + newTextArgs.push(newArg); + } + if ( + arg.only !== 'text' && + !('slashOptions' in options_) && + (options_.slash || options_.slashOnly) && + arg.slashType !== false + ) { + const newArg: { + [key in SlashOptionKeys]?: any; + } = { + name: arg.id, + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + description: arg.prompt || arg.description || 'No description provided.', + type: arg.slashType + }; + if ('slashResolve' in arg) newArg.resolve = arg.slashResolve; + if ('autocomplete' in arg) newArg.autocomplete = arg.autocomplete; + if ('channelTypes' in arg) newArg.channelTypes = arg.channelTypes; + if ('choices' in arg) newArg.choices = arg.choices; + if ('minValue' in arg) newArg.minValue = arg.minValue; + if ('maxValue' in arg) newArg.maxValue = arg.maxValue; + newArg.required = 'optional' in arg ? !arg.optional : true; + newSlashArgs.push(newArg as SlashOption); + } + } + if (newTextArgs.length > 0) newOptions.args = newTextArgs; + if (newSlashArgs.length > 0) newOptions.slashOptions = options_.slashOptions ?? newSlashArgs; + } else if (key === 'clientPermissions' || key === 'userPermissions') { + newOptions[key] = options_[key] as PermissionResolvable | PermissionResolvable[] | MissingPermissionSupplier; + } else { + newOptions[key] = options_[key]; + } + } + + super(id, newOptions); + + if (options_.args ?? options_.helpArgs) { + const argsInfo: ArgsInfo[] = []; + const combined = (options_.args ?? options_.helpArgs)!.map((arg) => { + const norm = options_.args + ? options_.args.find((_arg) => _arg.id === ('id' in arg ? arg.id : arg.name)) ?? ({} as BushArgumentOptions) + : ({} as BushArgumentOptions); + const help = options_.helpArgs + ? options_.helpArgs.find((_arg) => _arg.name === ('id' in arg ? arg.id : arg.name)) ?? ({} as ArgsInfo) + : ({} as ArgsInfo); + return { ...norm, ...help }; + }); + + for (const arg of combined) { + const name = _.camelCase('id' in arg ? arg.id : arg.name), + description = arg.description || '*No description provided.*', + optional = arg.optional ?? false, + autocomplete = arg.autocomplete ?? false, + only = arg.only ?? 'slash & text', + match = arg.match ?? 'phrase', + type = match === 'flag' ? 'flag' : arg.readableType ?? arg.type ?? 'string', + flag = arg.flag ? (Array.isArray(arg.flag) ? arg.flag : [arg.flag]) : [], + ownerOnly = arg.ownerOnly ?? false, + superUserOnly = arg.superUserOnly ?? false; + + argsInfo.push({ name, description, optional, autocomplete, only, match, type, flag, ownerOnly, superUserOnly }); + } + + this.argsInfo = argsInfo; + } + + this.description = options_.description; + this.usage = options_.usage; + this.examples = options_.examples; + this.options = options_; + this.parsedOptions = newOptions; + this.hidden = !!options_.hidden; + this.restrictedChannels = options_.restrictedChannels; + this.restrictedGuilds = options_.restrictedGuilds; + this.pseudo = !!options_.pseudo; + this.bypassChannelBlacklist = !!options_.bypassChannelBlacklist; + this.note = options_.note; + } + + /** + * Executes the command. + * @param message - Message that triggered the command. + * @param args - Evaluated arguments. + */ + public abstract override exec(message: CommandMessage, args: any): any; + /** + * Executes the command. + * @param message - Message that triggered the command. + * @param args - Evaluated arguments. + */ + public abstract override exec(message: CommandMessage | SlashMessage, args: any): any; +} + +type SlashOptionKeys = + | keyof AkairoApplicationCommandSubGroupData + | keyof AkairoApplicationCommandNonOptionsData + | keyof AkairoApplicationCommandChannelOptionData + | keyof AkairoApplicationCommandChoicesData + | keyof AkairoApplicationCommandAutocompleteOption + | keyof AkairoApplicationCommandNumericOptionData + | keyof AkairoApplicationCommandSubCommandData; + +interface PseudoArguments extends BaseBushArgumentType { + boolean: boolean; + flag: boolean; + regex: { match: RegExpMatchArray; matches: RegExpExecArray[] }; +} + +export type ArgType<T extends keyof PseudoArguments> = NonNullable<PseudoArguments[T]>; +export type OptArgType<T extends keyof PseudoArguments> = PseudoArguments[T]; + +/** + * `util` is always defined for messages after `'all'` inhibitors + */ +export type CommandMessage = Message & { + /** + * Extra properties applied to the Discord.js message object. + * Utilities for command responding. + * Available on all messages after 'all' inhibitors and built-in inhibitors (bot, client). + * Not all properties of the util are available, depending on the input. + * */ + util: CommandUtil<Message>; +}; diff --git a/lib/extensions/discord-akairo/BushCommandHandler.ts b/lib/extensions/discord-akairo/BushCommandHandler.ts new file mode 100644 index 0000000..da49af9 --- /dev/null +++ b/lib/extensions/discord-akairo/BushCommandHandler.ts @@ -0,0 +1,37 @@ +import { type BushCommand, type CommandMessage, type SlashMessage } from '#lib'; +import { CommandHandler, type Category, type CommandHandlerEvents, type CommandHandlerOptions } from 'discord-akairo'; +import { type Collection, type Message, type PermissionsString } from 'discord.js'; + +export type BushCommandHandlerOptions = CommandHandlerOptions; + +export interface BushCommandHandlerEvents extends CommandHandlerEvents { + commandBlocked: [message: CommandMessage, command: BushCommand, reason: string]; + commandBreakout: [message: CommandMessage, command: BushCommand, /* no util */ breakMessage: Message]; + commandCancelled: [message: CommandMessage, command: BushCommand, /* no util */ retryMessage?: Message]; + commandFinished: [message: CommandMessage, command: BushCommand, args: any, returnValue: any]; + commandInvalid: [message: CommandMessage, command: BushCommand]; + commandLocked: [message: CommandMessage, command: BushCommand]; + commandStarted: [message: CommandMessage, command: BushCommand, args: any]; + cooldown: [message: CommandMessage | SlashMessage, command: BushCommand, remaining: number]; + error: [error: Error, message: /* no util */ Message, command?: BushCommand]; + inPrompt: [message: /* no util */ Message]; + load: [command: BushCommand, isReload: boolean]; + messageBlocked: [message: /* no util */ Message | CommandMessage | SlashMessage, reason: string]; + messageInvalid: [message: CommandMessage]; + missingPermissions: [message: CommandMessage, command: BushCommand, type: 'client' | 'user', missing: PermissionsString[]]; + remove: [command: BushCommand]; + slashBlocked: [message: SlashMessage, command: BushCommand, reason: string]; + slashError: [error: Error, message: SlashMessage, command: BushCommand]; + slashFinished: [message: SlashMessage, command: BushCommand, args: any, returnValue: any]; + slashMissingPermissions: [message: SlashMessage, command: BushCommand, type: 'client' | 'user', missing: PermissionsString[]]; + slashStarted: [message: SlashMessage, command: BushCommand, args: any]; +} + +export class BushCommandHandler extends CommandHandler { + public declare modules: Collection<string, BushCommand>; + public declare categories: Collection<string, Category<string, BushCommand>>; +} + +export interface BushCommandHandler extends CommandHandler { + findCommand(name: string): BushCommand; +} diff --git a/lib/extensions/discord-akairo/BushInhibitor.ts b/lib/extensions/discord-akairo/BushInhibitor.ts new file mode 100644 index 0000000..be396cf --- /dev/null +++ b/lib/extensions/discord-akairo/BushInhibitor.ts @@ -0,0 +1,19 @@ +import { type BushCommand, type CommandMessage, type SlashMessage } from '#lib'; +import { Inhibitor } from 'discord-akairo'; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { Message } from 'discord.js'; + +export abstract class BushInhibitor extends Inhibitor { + /** + * Checks if message should be blocked. + * A return value of true will block the message. + * If returning a Promise, a resolved value of true will block the message. + * + * **Note:** `'all'` type inhibitors do not have {@link Message.util} defined. + * + * @param message - Message being handled. + * @param command - Command to check. + */ + public abstract override exec(message: CommandMessage, command: BushCommand): any; + public abstract override exec(message: CommandMessage | SlashMessage, command: BushCommand): any; +} diff --git a/lib/extensions/discord-akairo/BushInhibitorHandler.ts b/lib/extensions/discord-akairo/BushInhibitorHandler.ts new file mode 100644 index 0000000..5e4fb6c --- /dev/null +++ b/lib/extensions/discord-akairo/BushInhibitorHandler.ts @@ -0,0 +1,3 @@ +import { InhibitorHandler } from 'discord-akairo'; + +export class BushInhibitorHandler extends InhibitorHandler {} diff --git a/lib/extensions/discord-akairo/BushListener.ts b/lib/extensions/discord-akairo/BushListener.ts new file mode 100644 index 0000000..6917641 --- /dev/null +++ b/lib/extensions/discord-akairo/BushListener.ts @@ -0,0 +1,3 @@ +import { Listener } from 'discord-akairo'; + +export abstract class BushListener extends Listener {} diff --git a/lib/extensions/discord-akairo/BushListenerHandler.ts b/lib/extensions/discord-akairo/BushListenerHandler.ts new file mode 100644 index 0000000..9c3e4af --- /dev/null +++ b/lib/extensions/discord-akairo/BushListenerHandler.ts @@ -0,0 +1,3 @@ +import { ListenerHandler } from 'discord-akairo'; + +export class BushListenerHandler extends ListenerHandler {} diff --git a/lib/extensions/discord-akairo/BushTask.ts b/lib/extensions/discord-akairo/BushTask.ts new file mode 100644 index 0000000..1b70c88 --- /dev/null +++ b/lib/extensions/discord-akairo/BushTask.ts @@ -0,0 +1,3 @@ +import { Task } from 'discord-akairo'; + +export abstract class BushTask extends Task {} diff --git a/lib/extensions/discord-akairo/BushTaskHandler.ts b/lib/extensions/discord-akairo/BushTaskHandler.ts new file mode 100644 index 0000000..6535abb --- /dev/null +++ b/lib/extensions/discord-akairo/BushTaskHandler.ts @@ -0,0 +1,3 @@ +import { TaskHandler } from 'discord-akairo'; + +export class BushTaskHandler extends TaskHandler {} diff --git a/lib/extensions/discord-akairo/SlashMessage.ts b/lib/extensions/discord-akairo/SlashMessage.ts new file mode 100644 index 0000000..0a6669b --- /dev/null +++ b/lib/extensions/discord-akairo/SlashMessage.ts @@ -0,0 +1,3 @@ +import { AkairoMessage } from 'discord-akairo'; + +export class SlashMessage extends AkairoMessage {} |