From e2d8e315882730ed6f30835f845bbcb0ad00a289 Mon Sep 17 00:00:00 2001 From: "Navy.gif" Date: Wed, 6 Dec 2023 13:09:51 +0200 Subject: [PATCH] Proper shutdown sequence, upgrade logger --- package.json | 2 +- src/client/DiscordClient.ts | 1356 +++++++++-------- src/client/components/EventHooker.ts | 2 +- src/client/components/Intercom.ts | 6 +- .../components/settings/moderation/Mute.ts | 2 +- .../components/wrappers/GuildWrapper.ts | 2 +- src/client/storage/StorageManager.ts | 4 +- src/client/storage/interfaces/Provider.ts | 2 +- .../storage/providers/MariaDBProvider.ts | 2 +- .../storage/providers/MongoDBProvider.ts | 3 +- src/middleware/Controller.ts | 764 +++++----- src/middleware/shard/Shard.ts | 23 +- yarn.lock | 10 +- 13 files changed, 1105 insertions(+), 1073 deletions(-) diff --git a/package.json b/package.json index 3101aa5..34c81c9 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "dependencies": { "@discordjs/collection": "^1.5.1", "@discordjs/rest": "^1.7.1", - "@navy.gif/logger": "^2.5.2", + "@navy.gif/logger": "^2.5.3", "@navy.gif/timestring": "^6.0.6", "@types/node": "^18.15.11", "chalk": "^5.3.0", diff --git a/src/client/DiscordClient.ts b/src/client/DiscordClient.ts index ffba20b..6d4f508 100644 --- a/src/client/DiscordClient.ts +++ b/src/client/DiscordClient.ts @@ -1,676 +1,682 @@ -import { - Client, - Collection, - DataResolver, - ActivityType, - Partials, - Invite, - Guild, - InviteResolvable, - ClientFetchInviteOptions, - Channel, -} from 'discord.js'; -import chalk from 'chalk'; -import { inspect } from 'node:util'; - -import { - LoggerClient as Logger, - LoggerClientOptions -} from '@navy.gif/logger'; - -import { - Intercom, - EventHooker, - LocaleLoader, - Registry, - Dispatcher, - Resolver, - ModerationManager, - RateLimiter, -} from './components/index.js'; - -import { - Observer, - Command, - Setting, - Inhibitor -} from './interfaces/index.js'; - -import { - GuildWrapper, - UserWrapper -} from './components/wrappers/index.js'; - -import { DefaultGuild, DefaultUser } from '../constants/index.js'; -import { ChannelResolveable, ClientOptions, EventHook, FormatOpts, FormatParams, ManagerEvalOptions, UserResolveable } from '../../@types/Client.js'; -import { Util } from '../utilities/index.js'; -import { IPCMessage } from '../../@types/Shared.js'; -import StorageManager from './storage/StorageManager.js'; -import Permissions from './components/inhibitors/Permissions.js'; -import { ClientEvents } from '../../@types/Events.js'; -import Component from './interfaces/Component.js'; -import Controller from '../middleware/Controller.js'; - -const Constants: { - ComponentTypes: { - [key: string]: string - } -} = { - ComponentTypes: { - LOAD: 'loaded', - UNLOAD: 'unloaded', - RELOAD: 'reloaded', - ENABLE: 'enabled', - DISABLE: 'disabled' - } -}; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type PendingPromise = { - resolve: (result: T) => void, - reject: (error?: Error) => void -} - -declare interface DiscordClient extends Client { - on(event: K, listener: EventHook): this -} - -class DiscordClient extends Client -{ - #logger: Logger; - #miscLogger: Logger; - #eventHooker: EventHooker; - #intercom: Intercom; - #dispatcher: Dispatcher; - #localeLoader: LocaleLoader; - #storageManager: StorageManager; - #registry: Registry; - #resolver: Resolver; - #rateLimiter: RateLimiter; - #moderationManager: ModerationManager; - - // #wrapperClasses: {[key: string]: }; - - #guildWrappers: Collection; - #userWrappers: Collection; - #invites: Collection; - #evals: Collection; - - #activity: number; - #built: boolean; - - #options: ClientOptions; - #defaultConfig: { [key: string]: unknown }; - #activityInterval?: NodeJS.Timer; - #permissionCheck?: Permissions; - - constructor (options: ClientOptions) - { - if (!options) - throw Util.fatal(new Error('Missing options')); - - const partials: number[] = []; - for (const partial of options.libraryOptions?.partials || []) - { - if (partial in Partials) - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - partials.push(Partials[partial]); - else - throw Util.fatal(`Invalid partial ${partial}`); - } - - if (!options.invite || !options.storage || !options.logger) - throw Util.fatal('Missing invite, storage or logger options'); - - const opts = { - ...options.libraryOptions, - partials - }; - - if (!opts.intents) - throw Util.fatal(new Error('Missing intents')); - - super(opts); - this.#options = options; - - this.#built = false; - this.#defaultConfig = {}; - this.#activity = 0; - - this.#evals = new Collection(); - this.#guildWrappers = new Collection(); - this.#userWrappers = new Collection(); - this.#invites = new Collection(); - - this.#logger = new Logger({ name: 'Client' }); - this.#miscLogger = new Logger({ name: 'Misc' }); - this.#eventHooker = new EventHooker(this); - this.#intercom = new Intercom(this); - this.#dispatcher = new Dispatcher(this); - this.#localeLoader = new LocaleLoader(this); - this.#storageManager = new StorageManager(this, options.storage); - this.#registry = new Registry(this); - this.#resolver = new Resolver(this); - this.#rateLimiter = new RateLimiter(this); - this.#moderationManager = new ModerationManager(this); - - - // As of d.js v14 these events are emitted from the rest manager, rebinding them to the client - // this.rest.on('request', (...args) => - // { - // this.emit('apiRequest', ...args); - // }); - - this.rest.on('response', (...args) => - { - this.emit('apiResponse', ...args); - }); - - this.rest.on('rateLimited', (...args) => - { - this.emit('rateLimit', ...args); - }); - - this.rest.on('invalidRequestWarning', (...args) => - { - this.emit('invalidRequestWarning', ...args); - }); - - // this.once('ready', () => { - // this._setActivity(); - - // setInterval(() => { - // this._setActivity(); - // }, 1800000); // I think this is 30 minutes. I could be wrong. - // }); - - this.#loadEevents(); - - // process.on('uncaughtException', (err) => { - // this.logger.error(`Uncaught exception:\n${err.stack || err}`); - // }); - - process.on('unhandledRejection', (err: Error) => - { - this.#logger.error(`Unhandled rejection:\n${err?.stack || err}`); - }); - - process.on('message', this.#handleMessage.bind(this)); - } - - async build () - { - if (this.#built) - return; - - const beforeTime = Date.now(); - - // Initialize components, localization, and observers. - - await this.#localeLoader.loadLanguages(); - await this.#storageManager.initialize(); - - await this.#registry.loadComponents('components/inhibitors', Inhibitor); - await this.#registry.loadComponents('components/observers', Observer); - await this.#registry.loadComponents('components/settings', Setting); - - // Build settings constructors for Settings command - - await this.#registry.loadComponents('components/commands', Command); - - await this.#dispatcher.dispatch(); - - this.#logger.info(`Built client in ${Date.now() - beforeTime}ms.`); - - const ready = this.#ready(); - await super.login(); - await ready; - - // Needs to load in after connecting to discord - await this.#moderationManager.initialise(); - - this.#setActivity(); - this.#activityInterval = setInterval(() => - { - this.#setActivity(); - }, 1800_000); // 30 min - - this.#built = true; - this.emit('built'); - return this; - } - - async destroy () - { - await this.#storageManager.destroy(); - await super.destroy(); - clearInterval(this.#activityInterval); - } - - async #handleMessage (message: IPCMessage) - { - // Handle misc. messages. - if (message._mEvalResult) - this.evalResult(message); - } - - // eslint-disable-next-line @typescript-eslint/ban-types - async managerEval (script: ((controller: Controller) => Promise | Result) | string, options: ManagerEvalOptions = {}) - : Promise - { - if (typeof script === 'function') - script = `(${script})(this, ${JSON.stringify(options.context)})`; - return new Promise((resolve, reject) => - { - if (!process.send) - return reject(new Error('No parent process')); - this.#evals.set(script as string, { resolve, reject }); - process.send({ _mEval: true, script, debug: options.debug || process.env.NODE_ENV === 'development' }); - }); - } - - evalResult ({ script, _result: result, _error: error }: IPCMessage) - { - if (!script) - throw new Error('Missing script??'); - const promise = this.#evals.get(script)!; - if (result) - promise.resolve(result); - else - promise.reject(error && Util.makeError(error)); - this.#evals.delete(script); - } - - // Wait until the client is actually ready, i.e. all structures from discord are created - #ready () - { - return new Promise((resolve) => - { - if (this.#built) - return resolve(); - this.once('ready', () => - { - this.intercom.send('ready'); - this.#createWrappers(); - resolve(); - }); - }); - } - - get permissions () - { - if (!this.#permissionCheck) - this.#permissionCheck = this.#registry.get('inhibitor:permissions'); - return this.#permissionCheck; - } - - defaultConfig (type: string) - { - if (this.#defaultConfig[type]) - return JSON.parse(JSON.stringify(this.#defaultConfig[type])); - const settings = this.#registry.filter((c: Setting) => c.type === 'setting' && c.resolve === type); - let def = type === 'GUILD' ? DefaultGuild : DefaultUser; - - for (const setting of settings.values()) - { - if (setting.default !== null) - { - def = { - ...def, - ...setting.default - }; - } - } - this.#defaultConfig[type] = def; - return JSON.parse(JSON.stringify(def)); - } - - // Helper function to pass options to the logger in a unified way - createLogger (comp: object, options: LoggerClientOptions = {}) - { - return new Logger({ name: comp.constructor.name, ...this.#options.logger, ...options }); - } - - get loggerOptions (): LoggerClientOptions | undefined - { - return this.#options.logger; - } - - async #setActivity () - { - if (!this.shard || !this.user) - throw new Error('Missing shard or user'); - const activities: { - [key: number]: () => Promise - } = { - 2: async () => - { - const result = await this.shard?.broadcastEval((client) => client.guilds.cache.size).catch(() => null); - if (!result) - return; - const guildCount = result.reduce((p, v) => p + v, 0); - this.user?.setActivity(`${guildCount} servers`, { type: ActivityType.Watching }); - }, - 1: async () => - { - const result = await this.shard?.broadcastEval((client) => client.users.cache.size).catch(() => null); - if (!result) - return; - const userCount = result.reduce((p, v) => p + v, 0); - this.user?.setActivity(`${userCount} users`, { type: ActivityType.Listening }); - }, - 0: async () => - { - this.user?.setActivity('for /help', { type: ActivityType.Listening }); - } - }; - - if (!this.shard) - return; - await activities[this.#activity](); - if (this.#activity === Math.max(...Object.keys(activities).map(val => parseInt(val)))) - this.#activity = 0; - else - this.#activity++; - - } - - get singleton () - { - if (!this.shard) - return true; - return this.shard.count === 1; - } - - get shardId () - { - if (!this.shard) - return 0; - return this.shard.ids[0]; - } - - // on(event: K, listener: (...args: unknown[]) => Awaitable): this; - // on (event: K, listener: (...args: unknown[]) => Awaitable) - // { - // // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // // @ts-ignore - // return super.on(event, listener); - // } - - /** - * @private - */ - #loadEevents () - { - this.#eventHooker.hook('ready', () => - { - const guilds = this.guilds.cache.size; - this.#logger.status(`Client ready, connected to ${chalk.bold(this.user!.tag)} with ${chalk.bold(`${guilds} guild${guilds === 1 ? '' : 's'}`)}.`); - }); - - this.#eventHooker.hook('componentUpdate', ({ component, type }: {component: Component, type: string}) => - { - this.#logger.info(`Component ${chalk.bold(component.resolveable)} was ${chalk.bold(Constants.ComponentTypes[type])}.`); // eslint-disable-line no-use-before-define - }); - - this.#eventHooker.hook('guildCreate', (guild: Guild) => - { - this.#logger.debug(`${chalk.bold('[GUILD]')} Joined guild ${chalk.bold(guild.name)} (${guild.id}).`); - this.#guildWrappers.set(guild.id, new GuildWrapper(this, guild)); - }); - - this.#eventHooker.hook('guildDelete', (guild: Guild) => - { - this.#logger.debug(`${chalk.bold('[GUILD]')} Left guild ${chalk.bold(guild.name)} (${guild.id}).`); - this.#guildWrappers.delete(guild.id); - }); - - this.#eventHooker.hook('shardDisconnect', () => - { - this.#logger.status('Shard disconnected.'); - }); - - this.#eventHooker.hook('shardError', (error: Error) => - { - this.#logger.status(`Shard errored:\n${error.stack ?? error}`); - }); - - this.#eventHooker.hook('shardReady', (_id: number, unavailableGuilds?: Set) => - { - this.#logger.status(`Shard is ready${unavailableGuilds ? ` with ${chalk.bold(`${unavailableGuilds?.size} unavailable guilds`)}` : ''}.`); - }); - - this.#eventHooker.hook('shardReconnecting', () => - { - this.#logger.status('Shard is reconnecting.'); - }); - - this.#eventHooker.hook('shardResume', () => - { - this.#logger.status('Shard resumed.'); - }); - - this.#eventHooker.hook('invalidRequestWarning', (warning: object) => - { - this.#logger.warn(`Invalid requests warning:\n${inspect(warning)}`); - }); - - this.#eventHooker.hook('rateLimit', (limit: object) => - { - this.#logger.debug(`Client hit a rate limit:\n${inspect(limit)}`); - }); - - } - - /** - * @private - */ - #createWrappers () - { - this.guilds.cache.forEach((guild) => - { - const wrapper = new GuildWrapper(this, guild); - this.#guildWrappers.set(guild.id, wrapper); - wrapper.loadCallbacks(); - }); - this.logger.info('Created guild wrappers'); - } - - format (index: string, params: FormatParams = {}, opts: FormatOpts = {}) - { - const { - code = false, - language = 'en_gb' - } = opts; - return this.localeLoader.format(language, index, params, code); - } - - getGuildWrapper (id: string) - { - if (this.#guildWrappers.has(id)) - return this.#guildWrappers.get(id); - if (!this.guilds.cache.has(id)) - throw new Error('Guild is not present on client'); - - const wrapper = new GuildWrapper(this, this.guilds.cache.get(id) as Guild); - this.#guildWrappers.set(id, wrapper); - return wrapper; - } - - // Signatures for typescript inferral - getUserWrapper(resolveable: UserResolveable, fetch?: false): UserWrapper | null; - getUserWrapper(resolveable: UserResolveable, fetch?: true): Promise; - getUserWrapper (resolveable: UserResolveable, fetch = true) - : Promise | UserWrapper | null - { - const id = typeof resolveable === 'string' ? resolveable : resolveable.id; - - if (this.#userWrappers.has(id)) - return this.#userWrappers.get(id) || null; - - if (!fetch) - { - const user = this.users.cache.get(id); - if (!user) - return null; - const wrapper = new UserWrapper(this, user); - this.#userWrappers.set(id, wrapper); - return wrapper; - } - - return new Promise((resolve) => - { - this.users.fetch(id).then((user) => - { - const wrapper = new UserWrapper(this, user); - this.#userWrappers.set(id, wrapper); - resolve(wrapper); - }); - }); - } - - async fetchInvite (invite: InviteResolvable, opts?: ClientFetchInviteOptions) - { - const code = DataResolver.resolveInviteCode(invite); - const existing = this.#invites.get(code); - if (existing && (!existing.expiresTimestamp || existing.expiresTimestamp > Date.now())) - return existing; - - const fetched = await super.fetchInvite(code, opts); - this.#invites.set(fetched.code, fetched); - return fetched; - } - - resolveUsers (...opts: [user: UserResolveable[], foce?: boolean]) - { - return this.#resolver.resolveUsers(...opts); - } - - resolveUser (...opts: [user: UserResolveable, foce?: boolean]) - { - return this.#resolver.resolveUser(...opts); - } - - resolveChannel (resolveable: ChannelResolveable, strict = false) - { - return this.#resolver.resolveChannel(resolveable, strict); - } - - get ready () - { - return this.#built; - } - - // override get user () - // { - // if (!super.user) - // throw new Error('User not set'); - // return super.user; - // } - - get prefix () - { - return this.#options.prefix; - } - - get registry () - { - return this.#registry; - } - - get storageManager () - { - return this.#storageManager; - } - - get storage () - { - return this.storageManager; - } - - get mongodb () - { - return this.#storageManager.mongodb; - } - - get moderation () - { - return this.#moderationManager; - } - - get developers () - { - return this.#options.developers ?? []; - } - - get developmentMode () - { - return this.#options.developmentMode; - } - get localeLoader () - { - return this.#localeLoader; - } - - get eventHooker () - { - return this.#eventHooker; - } - - get resolver () - { - return this.#resolver; - } - - get supportInvite () - { - return this.#options.invite; - } - - get opts () - { - return this.#options; - } - - get logger () - { - return this.#miscLogger; - } - - get rateLimiter () - { - return this.#rateLimiter; - } - - get intercom () - { - return this.#intercom; - } - - get rootDir () - { - return this.#options.rootDir; - } - - get version () - { - return this.#options.version; - } - -} - -process.once('message', (msg: IPCMessage) => -{ - if (msg._start) - { - const client = new DiscordClient(msg._start); - client.build(); - } -}); - -export default DiscordClient; - -// process.on("unhandledRejection", (error) => { -// console.error("[DiscordClient.js] Unhandled promise rejection:", error); //eslint-disable-line no-console +import { + Client, + Collection, + DataResolver, + ActivityType, + Partials, + Invite, + Guild, + InviteResolvable, + ClientFetchInviteOptions, + Channel, +} from 'discord.js'; +import chalk from 'chalk'; +import { inspect } from 'node:util'; + +import { + LoggerClient as Logger, + LoggerClientOptions +} from '@navy.gif/logger'; + +import { + Intercom, + EventHooker, + LocaleLoader, + Registry, + Dispatcher, + Resolver, + ModerationManager, + RateLimiter, +} from './components/index.js'; + +import { + Observer, + Command, + Setting, + Inhibitor +} from './interfaces/index.js'; + +import { + GuildWrapper, + UserWrapper +} from './components/wrappers/index.js'; + +import { DefaultGuild, DefaultUser } from '../constants/index.js'; +import { ChannelResolveable, ClientOptions, EventHook, FormatOpts, FormatParams, ManagerEvalOptions, UserResolveable } from '../../@types/Client.js'; +import { Util } from '../utilities/index.js'; +import { IPCMessage } from '../../@types/Shared.js'; +import StorageManager from './storage/StorageManager.js'; +import Permissions from './components/inhibitors/Permissions.js'; +import { ClientEvents } from '../../@types/Events.js'; +import Component from './interfaces/Component.js'; +import Controller from '../middleware/Controller.js'; + +const Constants: { + ComponentTypes: { + [key: string]: string + } +} = { + ComponentTypes: { + LOAD: 'loaded', + UNLOAD: 'unloaded', + RELOAD: 'reloaded', + ENABLE: 'enabled', + DISABLE: 'disabled' + } +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type PendingPromise = { + resolve: (result: T) => void, + reject: (error?: Error) => void +} + +declare interface DiscordClient extends Client { + on(event: K, listener: EventHook): this +} + +class DiscordClient extends Client +{ + #logger: Logger; + #miscLogger: Logger; + #eventHooker: EventHooker; + #intercom: Intercom; + #dispatcher: Dispatcher; + #localeLoader: LocaleLoader; + #storageManager: StorageManager; + #registry: Registry; + #resolver: Resolver; + #rateLimiter: RateLimiter; + #moderationManager: ModerationManager; + + // #wrapperClasses: {[key: string]: }; + + #guildWrappers: Collection; + #userWrappers: Collection; + #invites: Collection; + #evals: Collection; + + #activity: number; + #built: boolean; + + #options: ClientOptions; + #defaultConfig: { [key: string]: unknown }; + #activityInterval?: NodeJS.Timer; + #permissionCheck?: Permissions; + + constructor (options: ClientOptions) + { + if (!options) + throw Util.fatal(new Error('Missing options')); + + const partials: number[] = []; + for (const partial of options.libraryOptions?.partials || []) + { + if (partial in Partials) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + partials.push(Partials[partial]); + else + throw Util.fatal(`Invalid partial ${partial}`); + } + + if (!options.invite || !options.storage || !options.logger) + throw Util.fatal('Missing invite, storage or logger options'); + + const opts = { + ...options.libraryOptions, + partials + }; + + if (!opts.intents) + throw Util.fatal(new Error('Missing intents')); + + super(opts); + this.#options = options; + + this.#built = false; + this.#defaultConfig = {}; + this.#activity = 0; + + this.#evals = new Collection(); + this.#guildWrappers = new Collection(); + this.#userWrappers = new Collection(); + this.#invites = new Collection(); + + this.#logger = new Logger({ name: 'Client' }); + this.#miscLogger = new Logger({ name: 'Misc' }); + this.#eventHooker = new EventHooker(this); + this.#intercom = new Intercom(this); + this.#dispatcher = new Dispatcher(this); + this.#localeLoader = new LocaleLoader(this); + this.#storageManager = new StorageManager(this, options.storage); + this.#registry = new Registry(this); + this.#resolver = new Resolver(this); + this.#rateLimiter = new RateLimiter(this); + this.#moderationManager = new ModerationManager(this); + + + // As of d.js v14 these events are emitted from the rest manager, rebinding them to the client + // this.rest.on('request', (...args) => + // { + // this.emit('apiRequest', ...args); + // }); + + this.rest.on('response', (...args) => + { + this.emit('apiResponse', ...args); + }); + + this.rest.on('rateLimited', (...args) => + { + this.emit('rateLimit', ...args); + }); + + this.rest.on('invalidRequestWarning', (...args) => + { + this.emit('invalidRequestWarning', ...args); + }); + + // this.once('ready', () => { + // this._setActivity(); + + // setInterval(() => { + // this._setActivity(); + // }, 1800000); // I think this is 30 minutes. I could be wrong. + // }); + + this.#loadEevents(); + + // process.on('uncaughtException', (err) => { + // this.logger.error(`Uncaught exception:\n${err.stack || err}`); + // }); + + process.on('unhandledRejection', (err: Error) => + { + this.#logger.error(`Unhandled rejection:\n${err?.stack || err}`); + }); + + process.on('message', this.#handleMessage.bind(this)); + process.on('SIGINT', () => this.shutdown()); + } + + async build () + { + if (this.#built) + return; + + const beforeTime = Date.now(); + + // Initialize components, localization, and observers. + + await this.#localeLoader.loadLanguages(); + await this.#storageManager.initialize(); + + await this.#registry.loadComponents('components/inhibitors', Inhibitor); + await this.#registry.loadComponents('components/observers', Observer); + await this.#registry.loadComponents('components/settings', Setting); + + // Build settings constructors for Settings command + + await this.#registry.loadComponents('components/commands', Command); + + await this.#dispatcher.dispatch(); + + this.#logger.info(`Built client in ${Date.now() - beforeTime}ms.`); + + const ready = this.#ready(); + await super.login(); + await ready; + + // Needs to load in after connecting to discord + await this.#moderationManager.initialise(); + + this.#setActivity(); + this.#activityInterval = setInterval(() => + { + this.#setActivity(); + }, 1800_000); // 30 min + + this.#built = true; + this.emit('built'); + return this; + } + + async shutdown (code = 0) + { + this.logger.status('Shutdown order received, closing down'); + this.intercom.send('shutdown'); + await this.#storageManager.close(); + await super.destroy(); + clearInterval(this.#activityInterval); + this.removeAllListeners(); + // eslint-disable-next-line no-process-exit + process.exit(code); + } + + async #handleMessage (message: IPCMessage) + { + // Handle misc. messages. + if (message._mEvalResult) + this.evalResult(message); + } + + // eslint-disable-next-line @typescript-eslint/ban-types + async managerEval (script: ((controller: Controller) => Promise | Result) | string, options: ManagerEvalOptions = {}) + : Promise + { + if (typeof script === 'function') + script = `(${script})(this, ${JSON.stringify(options.context)})`; + return new Promise((resolve, reject) => + { + if (!process.send) + return reject(new Error('No parent process')); + this.#evals.set(script as string, { resolve, reject }); + process.send({ _mEval: true, script, debug: options.debug || process.env.NODE_ENV === 'development' }); + }); + } + + evalResult ({ script, _result: result, _error: error }: IPCMessage) + { + if (!script) + throw new Error('Missing script??'); + const promise = this.#evals.get(script)!; + if (result) + promise.resolve(result); + else + promise.reject(error && Util.makeError(error)); + this.#evals.delete(script); + } + + // Wait until the client is actually ready, i.e. all structures from discord are created + #ready () + { + return new Promise((resolve) => + { + if (this.#built) + return resolve(); + this.once('ready', () => + { + this.intercom.send('ready'); + this.#createWrappers(); + resolve(); + }); + }); + } + + get permissions () + { + if (!this.#permissionCheck) + this.#permissionCheck = this.#registry.get('inhibitor:permissions'); + return this.#permissionCheck; + } + + defaultConfig (type: string) + { + if (this.#defaultConfig[type]) + return JSON.parse(JSON.stringify(this.#defaultConfig[type])); + const settings = this.#registry.filter((c: Setting) => c.type === 'setting' && c.resolve === type); + let def = type === 'GUILD' ? DefaultGuild : DefaultUser; + + for (const setting of settings.values()) + { + if (setting.default !== null) + { + def = { + ...def, + ...setting.default + }; + } + } + this.#defaultConfig[type] = def; + return JSON.parse(JSON.stringify(def)); + } + + // Helper function to pass options to the logger in a unified way + createLogger (comp: object, options: LoggerClientOptions = {}) + { + return new Logger({ name: comp.constructor.name, ...this.#options.logger, ...options }); + } + + get loggerOptions (): LoggerClientOptions | undefined + { + return this.#options.logger; + } + + async #setActivity () + { + if (!this.shard || !this.user) + throw new Error('Missing shard or user'); + const activities: { + [key: number]: () => Promise + } = { + 2: async () => + { + const result = await this.shard?.broadcastEval((client) => client.guilds.cache.size).catch(() => null); + if (!result) + return; + const guildCount = result.reduce((p, v) => p + v, 0); + this.user?.setActivity(`${guildCount} servers`, { type: ActivityType.Watching }); + }, + 1: async () => + { + const result = await this.shard?.broadcastEval((client) => client.users.cache.size).catch(() => null); + if (!result) + return; + const userCount = result.reduce((p, v) => p + v, 0); + this.user?.setActivity(`${userCount} users`, { type: ActivityType.Listening }); + }, + 0: async () => + { + this.user?.setActivity('for /help', { type: ActivityType.Listening }); + } + }; + + if (!this.shard) + return; + await activities[this.#activity](); + if (this.#activity === Math.max(...Object.keys(activities).map(val => parseInt(val)))) + this.#activity = 0; + else + this.#activity++; + + } + + get singleton () + { + if (!this.shard) + return true; + return this.shard.count === 1; + } + + get shardId () + { + if (!this.shard) + return 0; + return this.shard.ids[0]; + } + + // on(event: K, listener: (...args: unknown[]) => Awaitable): this; + // on (event: K, listener: (...args: unknown[]) => Awaitable) + // { + // // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // // @ts-ignore + // return super.on(event, listener); + // } + + /** + * @private + */ + #loadEevents () + { + this.#eventHooker.hook('ready', () => + { + const guilds = this.guilds.cache.size; + this.#logger.status(`Client ready, connected to ${chalk.bold(this.user!.tag)} with ${chalk.bold(`${guilds} guild${guilds === 1 ? '' : 's'}`)}.`); + }); + + this.#eventHooker.hook('componentUpdate', ({ component, type }: {component: Component, type: string}) => + { + this.#logger.info(`Component ${chalk.bold(component.resolveable)} was ${chalk.bold(Constants.ComponentTypes[type])}.`); // eslint-disable-line no-use-before-define + }); + + this.#eventHooker.hook('guildCreate', (guild: Guild) => + { + this.#logger.debug(`${chalk.bold('[GUILD]')} Joined guild ${chalk.bold(guild.name)} (${guild.id}).`); + this.#guildWrappers.set(guild.id, new GuildWrapper(this, guild)); + }); + + this.#eventHooker.hook('guildDelete', (guild: Guild) => + { + this.#logger.debug(`${chalk.bold('[GUILD]')} Left guild ${chalk.bold(guild.name)} (${guild.id}).`); + this.#guildWrappers.delete(guild.id); + }); + + this.#eventHooker.hook('shardDisconnect', () => + { + this.#logger.status('Shard disconnected.'); + }); + + this.#eventHooker.hook('shardError', (error: Error) => + { + this.#logger.status(`Shard errored:\n${error.stack ?? error}`); + }); + + this.#eventHooker.hook('shardReady', (_id: number, unavailableGuilds?: Set) => + { + this.#logger.status(`Shard is ready${unavailableGuilds ? ` with ${chalk.bold(`${unavailableGuilds?.size} unavailable guilds`)}` : ''}.`); + }); + + this.#eventHooker.hook('shardReconnecting', () => + { + this.#logger.status('Shard is reconnecting.'); + }); + + this.#eventHooker.hook('shardResume', () => + { + this.#logger.status('Shard resumed.'); + }); + + this.#eventHooker.hook('invalidRequestWarning', (warning: object) => + { + this.#logger.warn(`Invalid requests warning:\n${inspect(warning)}`); + }); + + this.#eventHooker.hook('rateLimit', (limit: object) => + { + this.#logger.debug(`Client hit a rate limit:\n${inspect(limit)}`); + }); + + } + + /** + * @private + */ + #createWrappers () + { + this.guilds.cache.forEach((guild) => + { + const wrapper = new GuildWrapper(this, guild); + this.#guildWrappers.set(guild.id, wrapper); + wrapper.loadCallbacks(); + }); + this.logger.info('Created guild wrappers'); + } + + format (index: string, params: FormatParams = {}, opts: FormatOpts = {}) + { + const { + code = false, + language = 'en_gb' + } = opts; + return this.localeLoader.format(language, index, params, code); + } + + getGuildWrapper (id: string) + { + if (this.#guildWrappers.has(id)) + return this.#guildWrappers.get(id); + if (!this.guilds.cache.has(id)) + throw new Error('Guild is not present on client'); + + const wrapper = new GuildWrapper(this, this.guilds.cache.get(id) as Guild); + this.#guildWrappers.set(id, wrapper); + return wrapper; + } + + // Signatures for typescript inferral + getUserWrapper(resolveable: UserResolveable, fetch?: false): UserWrapper | null; + getUserWrapper(resolveable: UserResolveable, fetch?: true): Promise; + getUserWrapper (resolveable: UserResolveable, fetch = true) + : Promise | UserWrapper | null + { + const id = typeof resolveable === 'string' ? resolveable : resolveable.id; + + if (this.#userWrappers.has(id)) + return this.#userWrappers.get(id) || null; + + if (!fetch) + { + const user = this.users.cache.get(id); + if (!user) + return null; + const wrapper = new UserWrapper(this, user); + this.#userWrappers.set(id, wrapper); + return wrapper; + } + + return new Promise((resolve) => + { + this.users.fetch(id).then((user) => + { + const wrapper = new UserWrapper(this, user); + this.#userWrappers.set(id, wrapper); + resolve(wrapper); + }); + }); + } + + async fetchInvite (invite: InviteResolvable, opts?: ClientFetchInviteOptions) + { + const code = DataResolver.resolveInviteCode(invite); + const existing = this.#invites.get(code); + if (existing && (!existing.expiresTimestamp || existing.expiresTimestamp > Date.now())) + return existing; + + const fetched = await super.fetchInvite(code, opts); + this.#invites.set(fetched.code, fetched); + return fetched; + } + + resolveUsers (...opts: [user: UserResolveable[], foce?: boolean]) + { + return this.#resolver.resolveUsers(...opts); + } + + resolveUser (...opts: [user: UserResolveable, foce?: boolean]) + { + return this.#resolver.resolveUser(...opts); + } + + resolveChannel (resolveable: ChannelResolveable, strict = false) + { + return this.#resolver.resolveChannel(resolveable, strict); + } + + get ready () + { + return this.#built; + } + + // override get user () + // { + // if (!super.user) + // throw new Error('User not set'); + // return super.user; + // } + + get prefix () + { + return this.#options.prefix; + } + + get registry () + { + return this.#registry; + } + + get storageManager () + { + return this.#storageManager; + } + + get storage () + { + return this.storageManager; + } + + get mongodb () + { + return this.#storageManager.mongodb; + } + + get moderation () + { + return this.#moderationManager; + } + + get developers () + { + return this.#options.developers ?? []; + } + + get developmentMode () + { + return this.#options.developmentMode; + } + get localeLoader () + { + return this.#localeLoader; + } + + get eventHooker () + { + return this.#eventHooker; + } + + get resolver () + { + return this.#resolver; + } + + get supportInvite () + { + return this.#options.invite; + } + + get opts () + { + return this.#options; + } + + get logger () + { + return this.#miscLogger; + } + + get rateLimiter () + { + return this.#rateLimiter; + } + + get intercom () + { + return this.#intercom; + } + + get rootDir () + { + return this.#options.rootDir; + } + + get version () + { + return this.#options.version; + } + +} + +process.once('message', (msg: IPCMessage) => +{ + if (msg._start) + { + const client = new DiscordClient(msg._start); + client.build(); + } +}); + +export default DiscordClient; + +// process.on("unhandledRejection", (error) => { +// console.error("[DiscordClient.js] Unhandled promise rejection:", error); //eslint-disable-line no-console // }); \ No newline at end of file diff --git a/src/client/components/EventHooker.ts b/src/client/components/EventHooker.ts index 4b7794c..e331a7a 100644 --- a/src/client/components/EventHooker.ts +++ b/src/client/components/EventHooker.ts @@ -65,7 +65,7 @@ class EventHooker this.#logger.debug(`Setting up handler for ${eventName}`); this.#target.on(eventName, async (...args) => { - if (!this.#target.ready && !this.#safeEvents.includes(eventName)) + if (!this.#target.ready && !this.#safeEvents.includes(eventName)) { this.#logger.warn(`Client not ready to handle events, event: ${eventName}`); return; diff --git a/src/client/components/Intercom.ts b/src/client/components/Intercom.ts index 299226e..7e01814 100644 --- a/src/client/components/Intercom.ts +++ b/src/client/components/Intercom.ts @@ -12,12 +12,12 @@ class Intercom { this.#client.eventHooker.hook('built', () => { - this._transportCommands(); + this.#transportCommands(); }); } } - send (type: string, message = {}) + send (type: string, message = {}) { if (typeof message !== 'object') throw new Error('Invalid message object'); @@ -29,7 +29,7 @@ class Intercom }); } - _transportCommands () + #transportCommands () { if (!this.#client.application) throw new Error('Missing client application'); diff --git a/src/client/components/settings/moderation/Mute.ts b/src/client/components/settings/moderation/Mute.ts index f4d778a..71b62e6 100644 --- a/src/client/components/settings/moderation/Mute.ts +++ b/src/client/components/settings/moderation/Mute.ts @@ -358,7 +358,7 @@ class MuteSetting extends Setting } catch (err) { - this.client.logger.error(err); + this.client.logger.error(err as Error); } } diff --git a/src/client/components/wrappers/GuildWrapper.ts b/src/client/components/wrappers/GuildWrapper.ts index f89d6c0..4fb1dc2 100644 --- a/src/client/components/wrappers/GuildWrapper.ts +++ b/src/client/components/wrappers/GuildWrapper.ts @@ -71,7 +71,7 @@ class GuildWrapper throw new Error('Already a wrapper'); this.#client = client; - this.#logger = client.createLogger({ name: `Guild: ${guild.id}` }); + this.#logger = client.createLogger(this, { name: `Guild: ${guild.id}` }); this.#guild = guild; this.#webhooks = new Collection(); this.#memberWrappers = new Collection(); diff --git a/src/client/storage/StorageManager.ts b/src/client/storage/StorageManager.ts index 96b7a3a..f67de4b 100644 --- a/src/client/storage/StorageManager.ts +++ b/src/client/storage/StorageManager.ts @@ -50,11 +50,11 @@ class StorageManager return this; } - async destroy () + async close () { const keys = Object.keys(this.#providers); for (const provider of keys) - await this.#providers[provider].destroy(); + await this.#providers[provider].close(); } _getName (instance: Provider | Table) diff --git a/src/client/storage/interfaces/Provider.ts b/src/client/storage/interfaces/Provider.ts index ea251b9..8aa327b 100644 --- a/src/client/storage/interfaces/Provider.ts +++ b/src/client/storage/interfaces/Provider.ts @@ -115,7 +115,7 @@ abstract class Provider implements Initialisable } - abstract destroy(): Promise; + abstract close(): Promise; get name () { diff --git a/src/client/storage/providers/MariaDBProvider.ts b/src/client/storage/providers/MariaDBProvider.ts index d081e3d..78bfe0e 100644 --- a/src/client/storage/providers/MariaDBProvider.ts +++ b/src/client/storage/providers/MariaDBProvider.ts @@ -126,7 +126,7 @@ class MariaDBProvider extends Provider } - async destroy () + async close () { this.logger.status('Shutting down database connections'); if (!this.ready) diff --git a/src/client/storage/providers/MongoDBProvider.ts b/src/client/storage/providers/MongoDBProvider.ts index 04a64e8..16e1d03 100644 --- a/src/client/storage/providers/MongoDBProvider.ts +++ b/src/client/storage/providers/MongoDBProvider.ts @@ -61,12 +61,13 @@ class MongoDBProvider extends Provider this.logger.info('DB connected'); } - async destroy () + async close () { if (!this.initialised) return this.logger.warn('Database already closed'); this.logger.status('Closing DB connection'); await this.#client?.close(); + this.#client?.removeAllListeners(); this._initialised = false; this.#db = null; this.logger.status('Database closed'); diff --git a/src/middleware/Controller.ts b/src/middleware/Controller.ts index 7528174..bfe835b 100644 --- a/src/middleware/Controller.ts +++ b/src/middleware/Controller.ts @@ -1,375 +1,391 @@ -import { EventEmitter } from 'node:events'; -import { inspect } from 'node:util'; -import path from 'node:path'; - -import { CommandsDef, IPCMessage } from '../../@types/Shared.js'; -import { BroadcastEvalOptions, ShardMethod, ShardingOptions } from '../../@types/Shard.js'; -import { ControllerOptions } from '../../@types/Controller.js'; - -import { MasterLogger } from '@navy.gif/logger'; -import { Collection } from 'discord.js'; - -// Available for evals -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore -import ClientUtils from './ClientUtils.js'; -import Metrics from './Metrics.js'; -// import ApiClientUtil from './ApiClientUtil.js'; -import SlashCommandManager from './rest/SlashCommandManager.js'; -import { Shard } from './shard/index.js'; -import { existsSync } from 'node:fs'; -import Util from '../utilities/Util.js'; - -// Placeholder -type GalacticAPI = { - init: () => Promise -} - -class Controller extends EventEmitter -{ - // #shardingManager: ShardingManager; - #slashCommandManager: SlashCommandManager; - #logger: MasterLogger; - #metrics: Metrics; - #options: ControllerOptions; - #shardingOptions: ShardingOptions; - // #apiClientUtil: ApiClientUtil; - - #shards: Collection; - - #version: string; - #readyAt: number | null; - #built: boolean; - - #api?: GalacticAPI; - - constructor (options: ControllerOptions, version: string) - { - super(); - - // Sharding - const respawn = process.env.NODE_ENV !== 'development'; - const clientPath = path.join(options.rootDir, 'client/DiscordClient.js'); - if (!existsSync(clientPath)) - throw new Error(`Client path does not seem to exist: ${clientPath}`); - - this.#options = options; - const { shardList, totalShards } = Controller.parseShardOptions(options.shardOptions); - - options.discord.rootDir = options.rootDir; - options.discord.logger = options.logger; - options.discord.storage = options.storage; - options.discord.version = version; - this.#shardingOptions = { - path: clientPath, - totalShards, - shardList, - respawn, - shardArgs: [], - execArgv: [], - token: process.env.DISCORD_TOKEN, - clientOptions: options.discord, - }; - - // Other - this.#slashCommandManager = new SlashCommandManager(this); - - this.#logger = new MasterLogger(options.logger); - this.#metrics = new Metrics(this); - // this.#apiClientUtil = new ApiClientUtil(this); - - this.#version = version; - this.#readyAt = null; - this.#built = false; - - this.#shards = new Collection(); - // this.#shardingManager.on('message', this._handleMessage.bind(this)); - } - - get version () - { - return this.#version; - } - - get ready () - { - return this.#built; - } - - get readyAt () - { - return this.#readyAt || -1; - } - - get totalShards () - { - return this.#shardingOptions.totalShards as number; - } - - get developerGuilds () - { - return this.#options.discord.slashCommands?.developerGuilds; - } - - get logger () - { - return this.#logger; - } - - get api () - { - return this.#api; - } - - get shards () - { - return this.#shards.clone(); - } - - async build () - { - const start = Date.now(); - // const API = this._options.api.load ? await import('/Documents/My programs/GBot/api/index.js') - // .catch(() => this.logger.warn(`Error importing API files, continuing without`)) : null; - - // let API = null; - // if (this.#options.api.load) - // API = await import('../../api/index.js').catch(() => this.#logger.warn(`Error importing API files, continuing without`)); - // if (API) { - // // TODO: this needs to be fixed up - // this.#logger.info('Booting up API'); - // const { default: APIManager } = API; - // this.#api = new APIManager(this, this.#options.api) as GalacticAPI; - // await this.#api.init(); - // const now = Date.now(); - // this.#logger.info(`API ready. Took ${now - start} ms`); - // start = now; - // } - - this.#logger.status('Starting bot shards'); - // await this.shardingManager.spawn().catch((error) => { - // this.#logger.error(`Fatal error during shard spawning:\n${error.stack || inspect(error)}`); - // // eslint-disable-next-line no-process-exit - // process.exit(); // Prevent a boot loop when shards die due to an error in the client - // }); - - const { totalShards, token } = this.#shardingOptions; - let shardCount = 0; - if (totalShards === 'auto') - { - if (!token) - throw new Error('Missing token'); - shardCount = await Util.fetchRecommendedShards(token); - } - else - { - if (typeof shardCount !== 'number' || isNaN(shardCount)) - throw new TypeError('Amount of shards must be a number.'); - if (shardCount < 1) - throw new RangeError('Amount of shards must be at least one.'); - if (!Number.isInteger(shardCount)) - throw new TypeError('Amount of shards must be an integer.'); - } - - const promises = []; - for (let i = 0; i < shardCount; i++) - { - const shard = this.createShard(shardCount); - promises.push(shard.spawn()); - } - - await Promise.all(promises); - - this.#logger.status(`Shards spawned, spawned ${this.#shards.size} shards. Took ${Date.now() - start} ms`); - - this.#built = true; - this.#readyAt = Date.now(); - } - - createShard (totalShards: number) - { - const ids = this.#shards.map(s => s.id); - const id = ids.length ? Math.max(...ids) + 1 : 0; - - const { path: file, token, respawn, execArgv, shardArgs: args, clientOptions: discordOptions } = this.#shardingOptions; - if (!file) - throw new Error('File seems to be missing'); - if (!discordOptions) - throw new Error('Missing discord options'); - const shard = new Shard(this, id, { - file, - token, - respawn, - args, - execArgv, - totalShards, - clientOptions: discordOptions - }); - this.#shards.set(shard.id, shard); - this.#logger.attach(shard); - this.#setListeners(shard); - return shard; - } - - #setListeners (shard: Shard) - { - shard.on('death', () => this.#logger.info(`Shard ${shard.id} has died`)); - shard.on('fatal', ({ error }) => this.#logger.warn(`Shard ${shard.id} has died fatally: ${inspect(error) ?? ''}`)); - shard.on('shutdown', () => this.#logger.info(`Shard ${shard.id} is shutting down gracefully`)); - shard.on('ready', () => this.#logger.info(`Shard ${shard.id} is ready`)); - shard.on('disconnect', () => this.#logger.warn(`Shard ${shard.id} has disconnected`)); - shard.on('processDisconnect', () => this.#logger.warn(`Process for ${shard.id} has disconnected`)); - shard.on('spawn', () => this.#logger.info(`Shard ${shard.id} spawned`)); - shard.on('error', (err) => this.#logger.error(`Shard ${shard.id} ran into an error:\n${err.stack}`)); - shard.on('warn', (msg) => this.#logger.warn(`Warning from shard ${shard.id}: ${msg}`, { broadcast: true })); - - shard.on('message', (msg) => this.#handleMessage(shard, msg)); - } - - #handleMessage (shard: Shard, message: IPCMessage) - { - if (message._logger) - return; - // this.logger.debug(`New message from ${shard ? `${message._api ? 'api-' : ''}shard ${shard.id}`: 'manager'}: ${inspect(message)}`); - - if (message._mEval) - return this.eval(shard, { script: message._mEval, debug: message.debug || false }); - if (message._commands) - return this.#slashCommandManager._handleMessage(message as CommandsDef); - if (message._api) - return this.apiRequest(shard, message); - } - - apiRequest (shard: Shard, message: IPCMessage) - { - const { type } = message; - switch (type) - { - case 'stats': - this.#metrics.aggregateStatistics(shard, message); - break; - default: - // this.#apiClientUtil.handleMessage(shard, message); - } - } - - /** - * @param {*} shard The shard from which the eval came and to which it will be returned - * @param {*} script The script to be executed - * @memberof Manager - * @private - */ - async eval (shard: Shard, { script, debug }: {script: string, debug: boolean}) - { - this.#logger.info(`Incoming manager eval from shard ${shard.id}:\n${script}`); - let result = null, - error = null; - - const response: IPCMessage = { - script, _mEvalResult: true - }; - - try - { - // eslint-disable-next-line no-eval - result = await eval(script); - response._result = result; - // if(typeof result !== 'string') result = inspect(result); - if (debug) - this.#logger.debug(`Eval result: ${inspect(result)}`); - } - catch (e) - { - const err = e as Error; - error = Util.makePlainError(err); - response._error = error; - } - return shard.send(response); - } - - // eslint-disable-next-line @typescript-eslint/ban-types - broadcastEval (script: string | Function, options: BroadcastEvalOptions = {}) - { - if (typeof script !== 'function') - return Promise.reject(new TypeError('[shardmanager] Provided eval must be a function.')); - return this._performOnShards('eval', [ `(${script})(this, ${JSON.stringify(options.context)})` ], options.shard); - } - - fetchClientValues (prop: string, shard?: number) - { - return this._performOnShards('fetchClientValue', [ prop ], shard); - } - - _performOnShards (method: ShardMethod, args: [string, object?], shard?: number): Promise - { - if (this.#shards.size === 0) - return Promise.reject(new Error('No shards available.')); - - if (!this.ready) - return Promise.reject(new Error('Controller not ready')); - - if (typeof shard === 'number') - { - if (!this.#shards.has(shard)) - Promise.reject(new Error('Shard not found.')); - - const s = this.#shards.get(shard) as Shard; - if (method === 'eval') - return s.eval(...args); - else if (method === 'fetchClientValue') - return s.eval(args[0]); - } - - const promises = []; - for (const sh of this.#shards.values()) - { - if (method === 'eval') - promises.push(sh.eval(...args)); - else if (method === 'fetchClientValue') - promises.push(sh.eval(args[0])); - } - return Promise.all(promises); - } - - async respawnAll ({ shardDelay = 5000, respawnDelay = 500, timeout = 30000 } = {}) - { - let s = 0; - for (const shard of this.#shards.values()) - { - const promises: Promise[] = [ shard.respawn({ delay: respawnDelay, timeout }) ]; - if (++s < this.#shards.size && shardDelay > 0) - promises.push(Util.delayFor(shardDelay)); - await Promise.all(promises); // eslint-disable-line no-await-in-loop - } - return this.#shards; - } - - static parseShardOptions (options: ShardingOptions) - { - let shardList = options.shardList ?? 'auto'; - if (shardList !== 'auto') - { - if (!Array.isArray(shardList)) - throw new Error('ShardList must be an array.'); - shardList = [ ...new Set(shardList) ]; - if (shardList.length < 1) - throw new Error('ShardList must have at least one ID.'); - if (shardList.some((shardId) => typeof shardId !== 'number' || isNaN(shardId) || !Number.isInteger(shardId) || shardId < 0)) - throw new Error('ShardList must be an array of positive integers.'); - } - - const totalShards = options.totalShards || 'auto'; - if (totalShards !== 'auto') - { - if (typeof totalShards !== 'number' || isNaN(totalShards)) - throw new Error('TotalShards must be an integer.'); - if (totalShards < 1) - throw new Error('TotalShards must be at least one.'); - if (!Number.isInteger(totalShards)) - throw new Error('TotalShards must be an integer.'); - } - return { shardList, totalShards }; - } -} - +import { EventEmitter } from 'node:events'; +import { inspect } from 'node:util'; +import path from 'node:path'; + +import { CommandsDef, IPCMessage } from '../../@types/Shared.js'; +import { BroadcastEvalOptions, ShardMethod, ShardingOptions } from '../../@types/Shard.js'; +import { ControllerOptions } from '../../@types/Controller.js'; + +import { MasterLogger } from '@navy.gif/logger'; +import { Collection } from 'discord.js'; + +// Available for evals +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import ClientUtils from './ClientUtils.js'; +import Metrics from './Metrics.js'; +// import ApiClientUtil from './ApiClientUtil.js'; +import SlashCommandManager from './rest/SlashCommandManager.js'; +import { Shard } from './shard/index.js'; +import { existsSync } from 'node:fs'; +import Util from '../utilities/Util.js'; + +// Placeholder +type GalacticAPI = { + init: () => Promise +} + +class Controller extends EventEmitter +{ + // #shardingManager: ShardingManager; + #slashCommandManager: SlashCommandManager; + #logger: MasterLogger; + #metrics: Metrics; + #options: ControllerOptions; + #shardingOptions: ShardingOptions; + // #apiClientUtil: ApiClientUtil; + + #shards: Collection; + + #version: string; + #readyAt: number | null; + #built: boolean; + + #api?: GalacticAPI; + + constructor (options: ControllerOptions, version: string) + { + super(); + + // Sharding + const respawn = process.env.NODE_ENV !== 'development'; + const clientPath = path.join(options.rootDir, 'client/DiscordClient.js'); + if (!existsSync(clientPath)) + throw new Error(`Client path does not seem to exist: ${clientPath}`); + + this.#options = options; + const { shardList, totalShards } = Controller.parseShardOptions(options.shardOptions); + + options.discord.rootDir = options.rootDir; + options.discord.logger = options.logger; + options.discord.storage = options.storage; + options.discord.version = version; + this.#shardingOptions = { + path: clientPath, + totalShards, + shardList, + respawn, + shardArgs: [], + execArgv: [], + token: process.env.DISCORD_TOKEN, + clientOptions: options.discord, + }; + + // Other + this.#slashCommandManager = new SlashCommandManager(this); + + this.#logger = new MasterLogger(options.logger); + this.#metrics = new Metrics(this); + // this.#apiClientUtil = new ApiClientUtil(this); + + this.#version = version; + this.#readyAt = null; + this.#built = false; + + this.#shards = new Collection(); + // this.#shardingManager.on('message', this._handleMessage.bind(this)); + + process.on('SIGINT', this.shutdown.bind(this)); + } + + async build () + { + const start = Date.now(); + // const API = this._options.api.load ? await import('/Documents/My programs/GBot/api/index.js') + // .catch(() => this.logger.warn(`Error importing API files, continuing without`)) : null; + + // let API = null; + // if (this.#options.api.load) + // API = await import('../../api/index.js').catch(() => this.#logger.warn(`Error importing API files, continuing without`)); + // if (API) { + // // TODO: this needs to be fixed up + // this.#logger.info('Booting up API'); + // const { default: APIManager } = API; + // this.#api = new APIManager(this, this.#options.api) as GalacticAPI; + // await this.#api.init(); + // const now = Date.now(); + // this.#logger.info(`API ready. Took ${now - start} ms`); + // start = now; + // } + + this.#logger.status('Starting bot shards'); + // await this.shardingManager.spawn().catch((error) => { + // this.#logger.error(`Fatal error during shard spawning:\n${error.stack || inspect(error)}`); + // // eslint-disable-next-line no-process-exit + // process.exit(); // Prevent a boot loop when shards die due to an error in the client + // }); + + const { totalShards, token } = this.#shardingOptions; + let shardCount = 0; + if (totalShards === 'auto') + { + if (!token) + throw new Error('Missing token'); + shardCount = await Util.fetchRecommendedShards(token); + } + else + { + if (typeof shardCount !== 'number' || isNaN(shardCount)) + throw new TypeError('Amount of shards must be a number.'); + if (shardCount < 1) + throw new RangeError('Amount of shards must be at least one.'); + if (!Number.isInteger(shardCount)) + throw new TypeError('Amount of shards must be an integer.'); + } + + const promises = []; + for (let i = 0; i < shardCount; i++) + { + const shard = this.createShard(shardCount); + promises.push(shard.spawn()); + } + + await Promise.all(promises); + + this.#logger.status(`Shards spawned, spawned ${this.#shards.size} shards. Took ${Date.now() - start} ms`); + + this.#built = true; + this.#readyAt = Date.now(); + } + + async shutdown () + { + this.logger.info('Received SIGINT, shutting down'); + setTimeout(process.exit, 90_000); + const promises = this.shards + .filter(shard => shard.ready) + .map(shard => shard.awaitShutdown() + .then(() => shard.removeAllListeners())); + if (promises.length) + await Promise.all(promises); + this.logger.status('Shutdown complete, goodbye'); + this.logger.close(); + } + + createShard (totalShards: number) + { + const ids = this.#shards.map(s => s.id); + const id = ids.length ? Math.max(...ids) + 1 : 0; + + const { path: file, token, respawn, execArgv, shardArgs: args, clientOptions: discordOptions } = this.#shardingOptions; + if (!file) + throw new Error('File seems to be missing'); + if (!discordOptions) + throw new Error('Missing discord options'); + const shard = new Shard(this, id, { + file, + token, + respawn, + args, + execArgv, + totalShards, + clientOptions: discordOptions + }); + this.#shards.set(shard.id, shard); + this.#logger.attach(shard); + this.#setListeners(shard); + return shard; + } + + #setListeners (shard: Shard) + { + shard.on('death', () => this.#logger.info(`Shard ${shard.id} has died`)); + shard.on('fatal', ({ error }) => this.#logger.warn(`Shard ${shard.id} has died fatally: ${inspect(error) ?? ''}`)); + shard.on('shutdown', () => this.#logger.info(`Shard ${shard.id} is shutting down gracefully`)); + shard.on('ready', () => this.#logger.info(`Shard ${shard.id} is ready`)); + shard.on('disconnect', () => this.#logger.warn(`Shard ${shard.id} has disconnected`)); + shard.on('processDisconnect', () => this.#logger.warn(`Process for ${shard.id} has disconnected`)); + shard.on('spawn', () => this.#logger.info(`Shard ${shard.id} spawned`)); + shard.on('error', (err) => this.#logger.error(`Shard ${shard.id} ran into an error:\n${err.stack}`)); + shard.on('warn', (msg) => this.#logger.warn(`Warning from shard ${shard.id}: ${msg}`, { broadcast: true })); + + shard.on('message', (msg) => this.#handleMessage(shard, msg)); + } + + #handleMessage (shard: Shard, message: IPCMessage) + { + if (message._logger) + return; + // this.logger.debug(`New message from ${shard ? `${message._api ? 'api-' : ''}shard ${shard.id}`: 'manager'}: ${inspect(message)}`); + + if (message._mEval) + return this.eval(shard, { script: message._mEval, debug: message.debug || false }); + if (message._commands) + return this.#slashCommandManager._handleMessage(message as CommandsDef); + if (message._api) + return this.apiRequest(shard, message); + } + + apiRequest (shard: Shard, message: IPCMessage) + { + const { type } = message; + switch (type) + { + case 'stats': + this.#metrics.aggregateStatistics(shard, message); + break; + default: + // this.#apiClientUtil.handleMessage(shard, message); + } + } + + /** + * @param {*} shard The shard from which the eval came and to which it will be returned + * @param {*} script The script to be executed + * @memberof Manager + * @private + */ + async eval (shard: Shard, { script, debug }: {script: string, debug: boolean}) + { + this.#logger.info(`Incoming manager eval from shard ${shard.id}:\n${script}`); + let result = null, + error = null; + + const response: IPCMessage = { + script, _mEvalResult: true + }; + + try + { + // eslint-disable-next-line no-eval + result = await eval(script); + response._result = result; + // if(typeof result !== 'string') result = inspect(result); + if (debug) + this.#logger.debug(`Eval result: ${inspect(result)}`); + } + catch (e) + { + const err = e as Error; + error = Util.makePlainError(err); + response._error = error; + } + return shard.send(response); + } + + // eslint-disable-next-line @typescript-eslint/ban-types + broadcastEval (script: string | Function, options: BroadcastEvalOptions = {}) + { + if (typeof script !== 'function') + return Promise.reject(new TypeError('[shardmanager] Provided eval must be a function.')); + return this._performOnShards('eval', [ `(${script})(this, ${JSON.stringify(options.context)})` ], options.shard); + } + + fetchClientValues (prop: string, shard?: number) + { + return this._performOnShards('fetchClientValue', [ prop ], shard); + } + + _performOnShards (method: ShardMethod, args: [string, object?], shard?: number): Promise + { + if (this.#shards.size === 0) + return Promise.reject(new Error('No shards available.')); + + if (!this.ready) + return Promise.reject(new Error('Controller not ready')); + + if (typeof shard === 'number') + { + if (!this.#shards.has(shard)) + Promise.reject(new Error('Shard not found.')); + + const s = this.#shards.get(shard) as Shard; + if (method === 'eval') + return s.eval(...args); + else if (method === 'fetchClientValue') + return s.eval(args[0]); + } + + const promises = []; + for (const sh of this.#shards.values()) + { + if (method === 'eval') + promises.push(sh.eval(...args)); + else if (method === 'fetchClientValue') + promises.push(sh.eval(args[0])); + } + return Promise.all(promises); + } + + async respawnAll ({ shardDelay = 5000, respawnDelay = 500, timeout = 30000 } = {}) + { + let s = 0; + for (const shard of this.#shards.values()) + { + const promises: Promise[] = [ shard.respawn({ delay: respawnDelay, timeout }) ]; + if (++s < this.#shards.size && shardDelay > 0) + promises.push(Util.delayFor(shardDelay)); + await Promise.all(promises); // eslint-disable-line no-await-in-loop + } + return this.#shards; + } + + static parseShardOptions (options: ShardingOptions) + { + let shardList = options.shardList ?? 'auto'; + if (shardList !== 'auto') + { + if (!Array.isArray(shardList)) + throw new Error('ShardList must be an array.'); + shardList = [ ...new Set(shardList) ]; + if (shardList.length < 1) + throw new Error('ShardList must have at least one ID.'); + if (shardList.some((shardId) => typeof shardId !== 'number' || isNaN(shardId) || !Number.isInteger(shardId) || shardId < 0)) + throw new Error('ShardList must be an array of positive integers.'); + } + + const totalShards = options.totalShards || 'auto'; + if (totalShards !== 'auto') + { + if (typeof totalShards !== 'number' || isNaN(totalShards)) + throw new Error('TotalShards must be an integer.'); + if (totalShards < 1) + throw new Error('TotalShards must be at least one.'); + if (!Number.isInteger(totalShards)) + throw new Error('TotalShards must be an integer.'); + } + return { shardList, totalShards }; + } + + get version () + { + return this.#version; + } + + get ready () + { + return this.#built; + } + + get readyAt () + { + return this.#readyAt || -1; + } + + get totalShards () + { + return this.#shardingOptions.totalShards as number; + } + + get developerGuilds () + { + return this.#options.discord.slashCommands?.developerGuilds; + } + + get logger () + { + return this.#logger; + } + + get api () + { + return this.#api; + } + + get shards () + { + return this.#shards.clone(); + } +} + export default Controller; \ No newline at end of file diff --git a/src/middleware/shard/Shard.ts b/src/middleware/shard/Shard.ts index 5aca0f4..51b9362 100644 --- a/src/middleware/shard/Shard.ts +++ b/src/middleware/shard/Shard.ts @@ -342,28 +342,28 @@ class Shard extends EventEmitter { if (message) { - if (message._ready) + if (message._ready) { this.#ready = true; this.emit('ready'); return; } - if (message._disconnect) + if (message._disconnect) { this.#ready = false; this.emit('disconnect'); return; } - if (message._reconnecting) + if (message._reconnecting) { this.#ready = false; this.emit('reconnecting'); return; } - if (message._sFetchProp) + if (message._sFetchProp) { const resp = { _sFetchProp: message._sFetchProp, _sFetchPropShard: message._sFetchPropShard }; this.#manager.fetchClientValues(message._sFetchProp, message._sFetchPropShard).then( @@ -373,7 +373,7 @@ class Shard extends EventEmitter return; } - if (message._sEval) + if (message._sEval) { const resp = { _sEval: message._sEval, _sEvalShard: message._sEvalShard }; this.#manager._performOnShards('eval', [ message._sEval ], message._sEvalShard).then( @@ -383,7 +383,7 @@ class Shard extends EventEmitter return; } - if (message._sRespawnAll) + if (message._sRespawnAll) { const { shardDelay, respawnDelay, timeout } = message._sRespawnAll; this.#manager.respawnAll({ shardDelay, respawnDelay, timeout }).catch(() => @@ -393,7 +393,16 @@ class Shard extends EventEmitter return; } - if (message._fatal) + if (message._shutdown) + { + const TO = setTimeout(() => this.#process?.kill('SIGKILL'), KillTO); + this.#process?.once('exit', () => clearTimeout(TO)); + this.#ready = false; + this.emit('shutdown'); + return; + } + + if (message._fatal) { this.#process?.removeAllListeners(); this.#ready = false; diff --git a/yarn.lock b/yarn.lock index cf57410..8e6b2db 100644 --- a/yarn.lock +++ b/yarn.lock @@ -940,14 +940,14 @@ __metadata: languageName: node linkType: hard -"@navy.gif/logger@npm:^2.5.2": - version: 2.5.2 - resolution: "@navy.gif/logger@npm:2.5.2" +"@navy.gif/logger@npm:^2.5.3": + version: 2.5.3 + resolution: "@navy.gif/logger@npm:2.5.3" dependencies: "@navy.gif/discord-webhook": ^1.0.0 chalk: ^4.1.2 moment: ^2.29.4 - checksum: b0cede9024333016a525d0a82b9c45ebad2ba1c9484fb2c1cf0743c2f5d8b83812b4d1f603837984c0a2b2742b8ae62a60787e90b242d8545282a60899eaa2ba + checksum: 0936998e000f55dbe573db27b25f82fc02c4f906abb6f933634622f99e11035bf6f5f37047421d55db19a54e67a6f8f59962c60353862954f629c9dd79ff846d languageName: node linkType: hard @@ -4087,7 +4087,7 @@ __metadata: dependencies: "@discordjs/collection": ^1.5.1 "@discordjs/rest": ^1.7.1 - "@navy.gif/logger": ^2.5.2 + "@navy.gif/logger": ^2.5.3 "@navy.gif/timestring": ^6.0.6 "@types/common-tags": ^1.8.1 "@types/humanize-duration": ^3.27.1