diff options
Diffstat (limited to 'lib/extensions/discord-akairo/BushClient.ts')
-rw-r--r-- | lib/extensions/discord-akairo/BushClient.ts | 600 |
1 files changed, 600 insertions, 0 deletions
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']; +} |