From f65604ec2bb25a08fb7a8f61325641d6f884c72d Mon Sep 17 00:00:00 2001 From: D3vision Date: Tue, 5 Dec 2023 22:00:49 +0100 Subject: [PATCH] Fixing warn command to use MemberWrapper --- options.json | 6 +- src/client/DiscordClient.ts | 1350 +++++++------- src/client/components/Intercom.ts | 100 +- .../components/commands/moderation/Warn.ts | 8 +- .../components/observers/Automoderation.ts | 1642 ++++++++--------- .../components/wrappers/GuildWrapper.ts | 1464 +++++++-------- src/client/interfaces/commands/Command.ts | 802 ++++---- src/middleware/Controller.ts | 748 ++++---- src/middleware/rest/SlashCommandManager.ts | 368 ++-- 9 files changed, 3245 insertions(+), 3243 deletions(-) diff --git a/options.json b/options.json index 8ef44a5..8cae10d 100644 --- a/options.json +++ b/options.json @@ -7,7 +7,8 @@ "discord": { "prefix": "!", "developers": [ - "132777808362471424" + "132777808362471424", + "187613017733726210" ], "developmentMode": true, "libraryOptions": { @@ -39,7 +40,8 @@ "264527028751958016", "207880433432657920", "992757341848080486", - "1069272779100266598" + "1069272779100266598", + "1086433147073331340" ] } }, diff --git a/src/client/DiscordClient.ts b/src/client/DiscordClient.ts index 37a41da..ffba20b 100644 --- a/src/client/DiscordClient.ts +++ b/src/client/DiscordClient.ts @@ -1,676 +1,676 @@ -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)); + } + + 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 // }); \ No newline at end of file diff --git a/src/client/components/Intercom.ts b/src/client/components/Intercom.ts index 299226e..cb6d7a7 100644 --- a/src/client/components/Intercom.ts +++ b/src/client/components/Intercom.ts @@ -1,51 +1,51 @@ -import DiscordClient from '../DiscordClient.js'; -import SlashCommand from '../interfaces/commands/SlashCommand.js'; - -class Intercom -{ - #client: DiscordClient; - constructor (client: DiscordClient) - { - this.#client = client; - - if (client.singleton || client!.shard?.ids[0] === 0) - { - this.#client.eventHooker.hook('built', () => - { - this._transportCommands(); - }); - } - } - - send (type: string, message = {}) - { - if (typeof message !== 'object') - throw new Error('Invalid message object'); - if (!process.send) - return; // Nowhere to send, the client was not spawned as a shard - return process.send({ - [`_${type}`]: true, - ...message - }); - } - - _transportCommands () - { - if (!this.#client.application) - throw new Error('Missing client application'); - const clientId = this.#client.application.id; - const commands = this.#client.registry - .filter((c: SlashCommand) => c.type === 'command' && c.slash) - .map((c) => c.shape); - - // console.log(inspect(commands, { depth: 25 })); - if (process.env.NODE_ENV === 'development') - return this.send('commands', { type: 'guild', commands, clientId }); - - this.send('commands', { type: 'global', commands, clientId }); - // this.send('commands', { type: 'guild', commands, clientId }); - } - -} - +import DiscordClient from '../DiscordClient.js'; +import SlashCommand from '../interfaces/commands/SlashCommand.js'; + +class Intercom +{ + #client: DiscordClient; + constructor (client: DiscordClient) + { + this.#client = client; + + if (client.singleton || client!.shard?.ids[0] === 0) + { + this.#client.eventHooker.hook('built', () => + { + this._transportCommands(); + }); + } + } + + send (type: string, message = {}) + { + if (typeof message !== 'object') + throw new Error('Invalid message object'); + if (!process.send) + return; // Nowhere to send, the client was not spawned as a shard + return process.send({ + [`_${type}`]: true, + ...message + }); + } + + _transportCommands () + { + if (!this.#client.application) + throw new Error('Missing client application'); + const clientId = this.#client.application.id; + const commands = this.#client.registry + .filter((c: SlashCommand) => c.type === 'command' && c.slash) + .map((c) => c.shape); + + // console.log(inspect(commands, { depth: 25 })); + if (process.env.NODE_ENV === 'development') + return this.send('commands', { type: 'guild', commands, clientId }); + + this.send('commands', { type: 'global', commands, clientId }); + // this.send('commands', { type: 'guild', commands, clientId }); + } + +} + export default Intercom; \ No newline at end of file diff --git a/src/client/components/commands/moderation/Warn.ts b/src/client/components/commands/moderation/Warn.ts index e358c4d..1b58517 100644 --- a/src/client/components/commands/moderation/Warn.ts +++ b/src/client/components/commands/moderation/Warn.ts @@ -3,7 +3,7 @@ import DiscordClient from '../../../DiscordClient.js'; import { Warn } from '../../../infractions/index.js'; import { ModerationCommand } from '../../../interfaces/index.js'; import InvokerWrapper from '../../wrappers/InvokerWrapper.js'; -import UserWrapper from '../../wrappers/UserWrapper.js'; +import MemberWrapper from '../../wrappers/MemberWrapper.js'; class WarnCommand extends ModerationCommand { @@ -28,11 +28,11 @@ class WarnCommand extends ModerationCommand }); } - async execute (invoker: InvokerWrapper, { users, ...args }: CommandParams) + async execute (invoker: InvokerWrapper, { users, ...args }: CommandParams) { - const wrappers = await Promise.all(users!.asUsers.map(user => this.client.getUserWrapper(user))); + const wrappers = await Promise.all(users!.asUsers.map(user => invoker.guild.memberWrapper(user))); return this.client.moderation.handleInfraction(Warn, invoker, { - targets: wrappers.filter(Boolean) as UserWrapper[], + targets: wrappers.filter(Boolean) as MemberWrapper[], args }); } diff --git a/src/client/components/observers/Automoderation.ts b/src/client/components/observers/Automoderation.ts index f850e6c..9f60841 100644 --- a/src/client/components/observers/Automoderation.ts +++ b/src/client/components/observers/Automoderation.ts @@ -1,822 +1,822 @@ -import { APIEmbed, APIEmbedField, ButtonComponentData, ButtonStyle, ComponentType, Interaction, MessageCreateOptions, PermissionsString, TextChannel } from 'discord.js'; -import { Ban, Kick, Mute, Softban, Warn } from '../../infractions/index.js'; -import Observer from '../../interfaces/Observer.js'; -import Initialisable from '../../interfaces/Initialisable.js'; -import DiscordClient from '../../DiscordClient.js'; -import BinaryTree from '../../../utilities/BinaryTree.js'; -import Util from '../../../utilities/Util.js'; -import { inspect } from 'util'; -import { FilterUtil } from '../../../utilities/index.js'; -import { FilterResult, Nullable } from '../../../../@types/Utils.js'; -import { ExtendedGuildMember, ExtendedMessage, FormatParams, SettingAction } from '../../../../@types/Client.js'; -import { stripIndents } from 'common-tags'; -import { ZeroWidthChar } from '../../../constants/Constants.js'; -import Infraction from '../../interfaces/Infraction.js'; -import InteractionWrapper from '../wrappers/InteractionWrapper.js'; -import InvokerWrapper from '../wrappers/InvokerWrapper.js'; -import GuildWrapper from '../wrappers/GuildWrapper.js'; -import MemberWrapper from '../wrappers/MemberWrapper.js'; - - -const CONSTANTS: { - Infractions: { [key: string]: typeof Infraction }, - ButtonStyles: { [key: string]: ButtonStyle.Danger }, - Permissions: {[key: string]: PermissionsString} -} = { - Infractions: { - WARN: Warn, - MUTE: Mute, - KICK: Kick, - SOFTBAN: Softban, - BAN: Ban - }, - ButtonStyles: { - BAN: ButtonStyle.Danger, - }, - Permissions: { - WARN: 'KickMembers', - MUTE: 'ModerateMembers', - KICK: 'KickMembers', - SOFTBAN: 'KickMembers', - BAN: 'BanMembers', - DELETE: 'ManageMessages' - } -}; - -export default class AutoModeration extends Observer implements Initialisable -{ - regex: { invite: RegExp; linkRegG: RegExp; linkReg: RegExp; mention: RegExp; mentionG: RegExp; }; - topLevelDomains!: BinaryTree; - executing: { [key: string]: string[] }; - constructor (client: DiscordClient) - { - super(client, { - name: 'autoModeration', - priority: 1, - disabled: false - }); - - this.hooks = [ - [ 'messageCreate', this.filterWords.bind(this) ], - [ 'messageUpdate', this.filterWords.bind(this) ], - [ 'messageCreate', this.filterInvites.bind(this) ], - [ 'messageUpdate', this.filterInvites.bind(this) ], - [ 'messageCreate', this.flagMessages.bind(this) ], - [ 'messageUpdate', this.flagMessages.bind(this) ], - [ 'messageCreate', this.filterLinks.bind(this) ], - [ 'messageUpdate', this.filterLinks.bind(this) ], - [ 'messageCreate', this.filterMentions.bind(this) ], - [ 'interactionCreate', this.flagAction.bind(this) ] - ]; - - this.executing = {}; - - this.regex = { - invite: /((discord\s*\.?\s*gg\s*)|discord(app)?\.com\/invite)\/\s?(?[a-z0-9]+)/iu, - linkRegG: /(https?:\/\/(www\.)?)?(?([a-z0-9-]{1,63}\.)?([a-z0-9-]{2,63})(\.[a-z0-9-]{2,63})(\.[a-z0-9-]{2,63})?)(\/\S*)?/iug, - linkReg: /(https?:\/\/(www\.)?)?(?([a-z0-9-]{1,63}\.)?([a-z0-9-]{2,63})(\.[a-z0-9-]{2,63})(\.[a-z0-9-]{2,63})?)(\/\S*)?/iu, - mention: /<@!?(?[0-9]{18,22})>/u, - mentionG: /<@!?(?[0-9]{18,22})>/gu, - }; - } - - async initialise () - { - // Fetch a list of TLDs from iana - const tldList = await this.client.managerEval(` - (() => { - return ClientUtils.fetchTlds() - })() - `).catch(this.logger.error.bind(this.logger)) as string[] | undefined; - - if (!tldList) - throw Util.fatal('Failed to initialise automod'); - const middlePoint = Math.floor(tldList.length / 2); - const [ midEntry ] = tldList.splice(middlePoint, 1); - tldList.splice(0, 0, midEntry); - this.topLevelDomains = new BinaryTree(this.client, tldList); - this.topLevelDomains.add('onion'); - } - - async _moderate ( - action: SettingAction, guild: GuildWrapper, channel: TextChannel, - member: MemberWrapper, reason: string, filterResult: FilterResult, moderator?: MemberWrapper - ): Promise - { - // Prevent simultaneous execution of the same filter on the same user when spamming - if (!this.executing[filterResult.filter!]) - this.executing[filterResult.filter!] = []; - if (this.executing[filterResult.filter!].includes(member.id)) - return false; - this.executing[filterResult.filter!].push(member.id); - - // Setting this true initially and negate if it fails, otherwise it won't show up as sanctioned in the msg logs - filterResult.sanctioned = true; - const InfractionClass = CONSTANTS.Infractions[action.type]; - const executor = moderator ?? await guild.memberWrapper(this.client.user!); - if (!executor) - throw new Error('Missing executor??'); - const result = await this.client.moderation.handleAutomod(InfractionClass, member, { - guild, - channel, - executor, - reason, - duration: action.duration ? action.duration * 1000 : null, - points: action.points, - expiration: action.expiration, - silent: false, - force: false, - prune: action.prune, - data: { - automoderation: filterResult - } - }).catch(this.logger.error.bind(this.logger)); - filterResult.sanctioned = !result?.error; - - await Util.wait(5000); - this.executing[filterResult.filter!].splice(this.executing[filterResult.filter!].indexOf(member.id), 1); - return !result?.error; - } - - async filterWords (message: ExtendedMessage, edited: ExtendedMessage) - { - const { guildWrapper: guild, author } = message; - let channel = message.channel as TextChannel; - if (channel.partial) - channel = await channel.fetch(); - if (!guild || author.bot || message.filtered) - return; - - const member = await guild.memberWrapper(message.author); - if (!member) - return; - const settings = await guild.settings(); - const { wordfilter: setting } = settings; - const { bypass, ignore, enabled, silent, explicit, fuzzy, regex, whitelist, actions } = setting; - const roles = member?.roles.cache.map((r) => r.id) || []; - - if (!enabled || roles.some((r) => bypass.includes(r)) || ignore.includes(channel.id)) - return; - - const perms = channel.permissionsFor(this.client.user!); - const missing = perms?.missing('ManageMessages') || []; - if (missing.length) - { - this.client.emit('filterMissingPermissions', { channel, guild, filter: 'word', permissions: missing }); - return; - } - - // Which message obj to work with - const msg = edited || message; - if (!msg.content) - return; - let log = 'Message filter debug:'; - log += `\nPre norm: ${msg.cleanContent}`; - let content = null; - try - { // NOTE: Remove try-catch after debug - content = Util.removeMarkdown(msg.cleanContent); - if (!content) - return; - content = FilterUtil.normalise(content); - } - catch (err) - { - const error = err as Error; - this.logger.error(`Error in message filtering:\n${error.stack}\n${msg.cleanContent}`); - return; - } - log += `\nNormalised: ${content}`; - - // match: what was matched | - // matched: did it match at all ? | - // matcher: what gets shown in the message logs | - // _matcher: locally used variable for which word in the list triggered it | - // type: which detection type matched it - let filterResult: Partial> = { filter: 'word', match: null, matched: false, matcher: null, _matcher: null }; - const words = content.toLowerCase().replace(/[,?.!]/gu, '').split(' ').filter((elem) => elem.length); - // Remove any potential bypass characters - // const _words = words.map((word) => word.replace(/[.'*_?+"#%&=-]/gu, '')); - - // 2. Filter explicit - no bypass checking (unless you count normalising the text, i.e. emoji letters => normal letters) - if (explicit.length && !filterResult.matched) - { - const result = FilterUtil.filterExplicit(words, explicit); - if (result) - { - log += `\nMessage matched with "${result.match}" in the explicit list.\nFull content: ${content}`; - filterResult = result; - } - } - - // 3. Filter regex - if (regex.length && !filterResult.matched) - { - const result = FilterUtil.filterRegex(content, regex, whitelist); - if (result) - { - log += `\nMessage matched with "${result.matcher}" in the regex list.\nMatch: ${result.raw}, Full word: ${result.match}\nFull content: ${content}`; - filterResult = result; - } - } - - // 4. Filter fuzzy - if (fuzzy.length && !filterResult.matched) - { - const result = FilterUtil.filterFuzzy(words, fuzzy, whitelist); - if (result) - { - filterResult = result; - log += `\nMessage matched with "${result._matcher}" in fuzzy.\nMatched word: ${result.match}\nFull content: ${content}\nSimilarity: ${result.sim}\nThreshold: ${result.threshold}`; - } - } - - // 5. Remove message, inline response and add a reason to msg object - if (!filterResult.matched) - return; - msg.filtered = filterResult as FilterResult; - filterResult.filter = 'word'; - log += `\nFilter result: ${inspect(filterResult)}`; - - if (!silent && perms?.has('SendMessages')) - { - const res = await this.client.rateLimiter.limitSend(msg.channel as TextChannel, guild.format('W_FILTER_DELETE', { user: author.id }), null, 'wordFilter').catch(() => null); - // const res = await msg.formattedRespond('W_FILTER_DELETE', { params: { user: author.id } }); - // if (res) res.delete({ timeout: 10000 }).catch(catcher(240)); - if (res) - setTimeout(() => - { - res?.delete?.().catch(() => - { /**/ }); - }, 10000); - } - this.client.rateLimiter.queueDelete(channel, msg).catch(() => null); - - // 6. Automated actions - if (actions.length) - { - let action = actions.find((act) => - { - return (act.trigger as string).includes(filterResult._matcher!); - }); - if (!action) - action = actions.find((act) => - { - return act.trigger === filterResult.type; - }); - if (!action) - action = actions.find((act) => - { - return act.trigger === 'generic'; - }); - - if (action) - { - log += '\nSanctioned'; - await this._moderate(action, guild, channel, member, guild.format('W_FILTER_ACTION'), filterResult as FilterResult); - } - } - this.logger.debug(`${guild.name} WF DEBUG: ${log}`); - } - - async flagMessages (message: ExtendedMessage, edited: ExtendedMessage) - { - const { guild, author, guildWrapper: wrapper } = message; - let { channel } = message; - if (channel.partial) - channel = await channel.fetch(); - if (!guild || author.bot) - return; - - const member = message.member || await guild.members.fetch(author.id).catch(() => null); - const settings = await wrapper.settings(); - const { wordwatcher: setting } = settings; - const { words, regex, bypass, ignore, channel: _logChannel, actions } = setting; - const roles = member?.roles.cache.map((r) => r.id) || []; - - if (!_logChannel || words.length === 0 || roles.some((r) => bypass.includes(r)) || ignore.includes(channel.id)) - return; - - const logChannel = await wrapper.resolveChannel(_logChannel); - const msg = edited || message; - if (!msg.content || !logChannel) - return; - let content = null; - try - { - content = FilterUtil.normalise(msg.cleanContent); - } - catch (err) - { - const error = err as Error; - this.logger.error(`Error in message flag:\n${error.stack}\n${msg.cleanContent}`); - return; - } - let match: RegExpMatchArray | string | null = null; - - for (const reg of regex) - { - // match = content.match(new RegExp(`(?:^|\\s)(${reg})`, 'iu')); - match = content.match(new RegExp(reg, 'iu')); - if (match) - { - [ match ] = match; - break; - } - } - - if (!match) - for (const word of words) - { - if (!content.includes(word)) - continue; - match = content.substring(content.indexOf(word), word.length); - break; - } - - if (!match) - return; - - const context = channel.messages.cache.sort((m1, m2) => m2.createdTimestamp - m1.createdTimestamp).first(5); - const embed: APIEmbed = { - title: `⚠️ Word trigger in **#${channel.name}**`, - description: stripIndents` - **[Jump to message](${msg.url})** - `, // ** User:** <@${ author.id }> - color: 15120384, - fields: context.reverse().reduce((acc, val) => - { - const text = val.content.length ? Util.escapeMarkdown(val.content).replace(match as string, '**__$&__**') : '**NO CONTENT**'; - acc.push({ - name: `${val.author.tag} (${val.author.id}) - ${val.id}`, - value: text.length < 1024 ? text : text.substring(0, 1013) + '...' - }); - if (text.length > 1024) - acc.push({ - name: ZeroWidthChar, - value: '...' + text.substring(1013, 2034) - }); - return acc; - }, [] as APIEmbedField[]) - }; - - const components: ButtonComponentData[] = []; - for (const action of actions) - { - components.push({ - type: ComponentType.Button, - label: action.type, - // eslint-disable-next-line camelcase - customId: `WORDWATCHER_${action.trigger}`, - style: CONSTANTS.ButtonStyles[action.type] || ButtonStyle.Primary - }); - } - - const actionRow = components.length ? [{ - type: ComponentType.ActionRow, - components - }] : null; - const opts: MessageCreateOptions = { - embeds: [ embed ], - components: [] - }; - if (actionRow) - opts.components = actionRow; - const sent = await logChannel.send(opts).catch((err) => - { - this.logger.error('Error in message flag:\n' + err.stack); - }); - - // Only insert if actions are defined - if (actionRow && sent) - await this.client.storageManager.mongodb.wordwatcher.insertOne({ - message: sent.id, - target: msg.id, - channel: msg.channel.id, - timestamp: Date.now() - }); - } - - async flagAction (interaction: Interaction) - { - // console.log(interaction); - if (!interaction.isButton() || !interaction.inGuild()) - return; - const { message, customId } = interaction; - if (!customId.startsWith('WORDWATCHER_')) - return; - const { components } = message; - const [ , actionType ] = customId.split('_'); - const { permissions } = this.client; - - const guild = this.client.getGuildWrapper(interaction.guildId); - if (!guild) - return; - const moderator = await guild.memberWrapper(interaction.user.id); - if (!moderator) - return; - const settings = await guild.settings(); - const { wordwatcher } = settings; - const { actions } = wordwatcher; - - await interaction.deferUpdate(); - const fail = (index: string, opts?: FormatParams) => - { - this.client.emit('wordWatcherError', { - warning: true, - guild, - message: guild.format(index, opts) - }); - (components[0].components.find((comp) => comp.customId === customId) as ButtonComponentData).style = ButtonStyle.Danger; - return message.edit({ components }); - }; - - // TODO: finish perm checks, need to figure out a workaround for `command:delete` - // actionType === 'DELETE' ? - // { error: false } : - const wrapper = new InteractionWrapper(this.client, interaction); - const invoker = new InvokerWrapper(this.client, wrapper); - const { inhibitor, error, params } - = await permissions.execute( - invoker, - { resolveable: `command:${actionType.toLowerCase()}`, memberPermissions: [] }, - [ CONSTANTS.Permissions[actionType] ] - ); - - if (error) - { - const missing = moderator.permissions.missing('ManageMessages'); - if (params.missing !== 'command:delete') - return fail(inhibitor.index, { command: actionType, missing: params.missing }); - else if (missing.length) - return fail(inhibitor.index, { missing: missing.join(', ') }); - } - - const log = await this.client.storageManager.mongodb.wordwatcher.findOne({ - message: message.id - }); - if (!log) - return fail('WORDWATCHER_MISSING_LOG'); - - const action = actions.find((act) => act.trigger === actionType); - if (!action) - return fail('WORDWATCHER_MISSING_ACTION', { actionType }); - - const targetChannel = await guild.resolveChannel(log.channel).catch(() => null); - if (!targetChannel) - return fail('WORDWATCHER_MISSING_CHANNEL'); - - const filterObj = { - filter: 'wordwatcher', - action: action.type - }; - const msg = await targetChannel.messages.fetch(log.target).catch(() => null) as ExtendedMessage; - if (msg) - { - await msg.delete().catch(() => null); - msg.filtered = filterObj; - } - - await this.client.storageManager.mongodb.wordwatcher.deleteOne({ - message: message.id - }); - - let success = false; - if (action.type === 'DELETE') - { - success = true; - } - else - { - const member = await guild.memberWrapper(message.author); - if (member) - success = await this._moderate(action, guild, targetChannel, member, guild.format('WORDWATCHER_ACTION'), filterObj, moderator); - else - this.client.emit('wordWatcherError', { warning: true, guild, message: guild.format('WORDWATCHER_MISSING_MEMBER', { actionType }) }); - } - - (components[0].components.find((comp) => comp.customId === customId) as ButtonComponentData).style = success - ? ButtonStyle.Success : ButtonStyle.Secondary; - await message.edit({ components }).catch(() => null); - } - - // eslint-disable-next-line max-lines-per-function - async filterLinks (message: ExtendedMessage, edited: ExtendedMessage) - { - const { author, guildWrapper: guild, channel } = message; - if (!channel) - { - this.logger.warn(`Missing channel?\nChannelId: ${message.channelId}\nGuild: ${message.guildId}\nAuthor: ${inspect(author)}`, { broadcast: true }); - return; - } - if (!guild || author.bot || message.filtered) - return; - - const member = await guild.memberWrapper(message.author.id); - const { resolver } = this.client; - const settings = await guild.settings(); - const { linkfilter: setting } = settings; - const { bypass, ignore, actions, silent, enabled, blacklist, whitelist, whitelistMode, greylist } = setting; - if (!enabled || !member) - return; - const roles = member?.roles.cache.map((r) => r.id) || []; - - if (roles.some((r) => bypass.includes(r)) || ignore.includes(channel.id)) - return; - - const perms = channel.permissionsFor(this.client.user!); - const missing = perms?.missing('ManageMessages') || []; - if (missing.length) - { - this.client.emit('filterMissingPermissions', { channel, guild, filter: 'link', permissions: missing }); - return; - } - - const msg = edited || message; - if (!msg.content) - return; - const content = msg.content.split('').join(''); // Copy the string... - let matches = content.match(this.regex.linkRegG); - let removedWhitespace = false; - if (!matches) - { - matches = content.replace(/\s/u, '').match(this.regex.linkRegG); - removedWhitespace = true; - } - - if (!matches) - return; - let remove = false; - const filterResult: FilterResult = {}; - let log = `${guild.name} Link filter debug:`; - - for (const match of matches) - { - let domain = match.match(this.regex.linkReg)!.groups?.domain; - if (!domain) - continue; - domain = domain.toLowerCase(); - // Invites are filtered separately - if (domain === 'discord.gg') - continue; - log += `\nMatched link ${match}: `; - - const predicate = (dom: string) => dom.toLowerCase().includes(domain!) || domain!.includes(dom.toLowerCase()); - - if (blacklist.some(predicate)) - { - log += 'in blacklist'; - filterResult.match = domain; - filterResult.matcher = 'link blacklist'; - remove = true; - break; - } - else if (greylist.some(predicate)) - { - log += 'in greylist'; - filterResult.match = domain; - filterResult.matcher = 'link greylist'; - remove = true; - break; - } - else if (whitelistMode) - { - if (whitelist.some(predicate)) - { - log += 'in whitelist'; - continue; - } - - const parts = domain.split('.'); - const validTld = this.topLevelDomains.find(parts[parts.length - 1]); - // console.log(parts, validTld); - if (!validTld) - continue; - const valid = await resolver.validateDomain(domain); - - if (removedWhitespace && !match.includes(`${domain}/`)) - continue; - - if (!valid) - { - // eslint-disable-next-line max-depth - if (match.includes(`${domain}/`)) - this.client.emit('linkFilterWarn', { guild, message: guild.format('LINKFILTER_WARN', { domain, link: match }) }); - continue; - } - - filterResult.match = domain; - filterResult.matcher = 'link whitelist'; - remove = true; - break; - } - } - - log += `\nFilter result: ${inspect(filterResult)}\nRemove: ${remove}`; - if (!remove) - return; - msg.filtered = filterResult; - filterResult.filter = 'link'; - - if (!silent && perms?.has('SendMessages')) - { - const res = await this.client.rateLimiter.limitSend(channel as TextChannel, guild.format('L_FILTER_DELETE', { user: author.id }), null, 'linkFilter'); - // const res = await msg.formattedRespond(`L_FILTER_DELETE`, { params: { user: author.id } }); - // if (res) res.delete({ timeout: 10000 }); - if (res) - setTimeout(() => - { - res.delete?.().catch(() => - { /**/ }); - }, 10000); - } - - this.client.rateLimiter.queueDelete(msg.channel as TextChannel, msg).catch(() => null); - if (actions.length) - { - let action = actions.find((act) => - { - return (act.trigger as string).includes(filterResult.match!); - }); - if (!action) - action = actions.find((act) => - { - return act.trigger === filterResult.matcher!.split(' ')[1]; - }); - if (!action) - action = actions.find((act) => - { - return act.trigger === 'generic'; - }); - - if (action) - { - log += '\nSanctioned'; - await this._moderate(action, guild, channel as TextChannel, member, guild.format('L_FILTER_ACTION', { domain: filterResult.match }), filterResult); - } - } - this.logger.debug(log); - } - - async filterInvites (message: ExtendedMessage, edited: ExtendedMessage) - { - const { author, guildWrapper: guild } = message; - let channel = message.channel as TextChannel; - if (channel.partial) - channel = await channel.fetch(); - if (!guild || author.bot || message.filtered) - return; - - const member = await guild.memberWrapper(message.author.id); - const settings = await guild.settings(); - const { invitefilter: setting } = settings; - const { bypass = [], ignore = [], actions, silent, enabled, whitelist = [] } = setting; - if (!enabled || !member) - return; - const roles = member.roles.cache.map((r) => r.id) || []; - - if (roles.some((r) => bypass.includes(r)) || ignore.includes(channel.id)) - return; - - const perms = channel.permissionsFor(this.client.user!); - const missing = perms?.missing('ManageMessages') || []; - if (missing.length) - { - this.client.emit('filterMissingPermissions', { channel, guild, filter: 'invite', permissions: missing }); - return; - } - - const msg = edited || message; - const { content } = msg; - if (!content) - return; - - const match = content.match(this.regex.invite); - if (!match) - return; - - const invite = await this.client.fetchInvite(match.groups!.code).catch(() => null); - const result = await guild.checkInvite(match.groups!.code) || invite?.guild?.id === guild.id; - if (invite && invite.guild && whitelist.includes(invite.guild.id) || result) - return; - - if (!result) - { // Doesn't resolve to the origin server - let action = null; - if (actions.length) - [ action ] = actions; - - msg.filtered = { - match: match[0], - matcher: 'invites', - filter: 'invite' - }; - if (!action) - return this.client.rateLimiter.queueDelete(channel, msg).catch(() => null); // msg.delete(); - if (!silent && perms?.has('SendMessages')) - { - const res = await this.client.rateLimiter.limitSend(channel, guild.format('I_FILTER_DELETE', { user: author.id }), null, 'inviteFilter'); - // if (res) res.delete({ timeout: 10000 }); - if (res) - setTimeout(() => - { - res.delete().catch(() => - { /**/ }); - }, 10000); - } - // msg.filtered.sactioned = true; - this.client.rateLimiter.queueDelete(channel, msg); - await this._moderate(action, guild, channel, member, guild.format('I_FILTER_ACTION'), msg.filtered); - } - } - - async filterMentions (message: ExtendedMessage) - { - const { author, guildWrapper: guild } = message; - let channel = message.channel as TextChannel; - if (!channel) - return; // Idk how or why but the channel is sometimes null? I have absolutely no clue how this could ever be the case - if (channel.partial) - channel = await channel.fetch(); - if (!guild || author.bot || message.filtered) - return; - - const member = await guild.memberWrapper(message.author); - const settings = await guild.settings(); - const { mentionfilter: setting } = settings; - const { bypass, ignore, enabled, silent, unique, limit, actions } = setting; - const roles = member?.roles.cache.map((r) => r.id) || []; - - if (!member || !enabled || roles.some((r) => bypass.includes(r)) || ignore.includes(channel.id)) - return; - - const perms = channel.permissionsFor(this.client.user!); - const missing = perms?.missing('ManageMessages') || []; - if (missing.length) - { - this.client.emit('filterMissingPermissions', { channel, guild, filter: 'mention', permissions: missing }); - return; - } - - const { content } = message; - if (!content) - return; - - const matches = content.match(this.regex.mentionG); - if (!matches) - return; - - let ids = matches.map((match) => match.match(this.regex.mention)?.groups!.id).filter(Boolean) as string[]; - if (unique) - { - const set = new Set(ids); - ids = [ ...set ]; - } - - if (ids.length < limit) - return; - if (!silent && perms?.has('SendMessages')) - { - const res = await this.client.rateLimiter.limitSend(channel, guild.format('M_FILTER_DELETE', { user: author.id }), null, 'mentionFilter'); - if (res) - setTimeout(() => - { - res.delete().catch(() => - { /**/ }); - }, 10000); - } - - this.client.rateLimiter.queueDelete(channel, message).catch(() => null); - const filterResult = { - filter: 'mention', - amount: ids.length - }; - message.filtered = filterResult; - - if (actions.length) - { - - let action = actions.find((act) => - { - return (act.trigger as number) <= ids.length; - }); - - if (!action) - action = actions.find((act) => - { - return act.trigger === 'generic'; - }); - - if (!action) - return; - - await this._moderate(action, guild, channel, member, guild.format('M_FILTER_ACTION'), filterResult); - } - } - - async raidProtection (_member: ExtendedGuildMember) - { - // - } - +import { APIEmbed, APIEmbedField, ButtonComponentData, ButtonStyle, ComponentType, Interaction, MessageCreateOptions, PermissionsString, TextChannel } from 'discord.js'; +import { Ban, Kick, Mute, Softban, Warn } from '../../infractions/index.js'; +import Observer from '../../interfaces/Observer.js'; +import Initialisable from '../../interfaces/Initialisable.js'; +import DiscordClient from '../../DiscordClient.js'; +import BinaryTree from '../../../utilities/BinaryTree.js'; +import Util from '../../../utilities/Util.js'; +import { inspect } from 'util'; +import { FilterUtil } from '../../../utilities/index.js'; +import { FilterResult, Nullable } from '../../../../@types/Utils.js'; +import { ExtendedGuildMember, ExtendedMessage, FormatParams, SettingAction } from '../../../../@types/Client.js'; +import { stripIndents } from 'common-tags'; +import { ZeroWidthChar } from '../../../constants/Constants.js'; +import Infraction from '../../interfaces/Infraction.js'; +import InteractionWrapper from '../wrappers/InteractionWrapper.js'; +import InvokerWrapper from '../wrappers/InvokerWrapper.js'; +import GuildWrapper from '../wrappers/GuildWrapper.js'; +import MemberWrapper from '../wrappers/MemberWrapper.js'; + + +const CONSTANTS: { + Infractions: { [key: string]: typeof Infraction }, + ButtonStyles: { [key: string]: ButtonStyle.Danger }, + Permissions: {[key: string]: PermissionsString} +} = { + Infractions: { + WARN: Warn, + MUTE: Mute, + KICK: Kick, + SOFTBAN: Softban, + BAN: Ban + }, + ButtonStyles: { + BAN: ButtonStyle.Danger, + }, + Permissions: { + WARN: 'KickMembers', + MUTE: 'ModerateMembers', + KICK: 'KickMembers', + SOFTBAN: 'KickMembers', + BAN: 'BanMembers', + DELETE: 'ManageMessages' + } +}; + +export default class AutoModeration extends Observer implements Initialisable +{ + regex: { invite: RegExp; linkRegG: RegExp; linkReg: RegExp; mention: RegExp; mentionG: RegExp; }; + topLevelDomains!: BinaryTree; + executing: { [key: string]: string[] }; + constructor (client: DiscordClient) + { + super(client, { + name: 'autoModeration', + priority: 1, + disabled: false + }); + + this.hooks = [ + [ 'messageCreate', this.filterWords.bind(this) ], + [ 'messageUpdate', this.filterWords.bind(this) ], + [ 'messageCreate', this.filterInvites.bind(this) ], + [ 'messageUpdate', this.filterInvites.bind(this) ], + [ 'messageCreate', this.flagMessages.bind(this) ], + [ 'messageUpdate', this.flagMessages.bind(this) ], + [ 'messageCreate', this.filterLinks.bind(this) ], + [ 'messageUpdate', this.filterLinks.bind(this) ], + [ 'messageCreate', this.filterMentions.bind(this) ], + [ 'interactionCreate', this.flagAction.bind(this) ] + ]; + + this.executing = {}; + + this.regex = { + invite: /((discord\s*\.?\s*gg\s*)|discord(app)?\.com\/invite)\/\s?(?[a-z0-9]+)/iu, + linkRegG: /(https?:\/\/(www\.)?)?(?([a-z0-9-]{1,63}\.)?([a-z0-9-]{2,63})(\.[a-z0-9-]{2,63})(\.[a-z0-9-]{2,63})?)(\/\S*)?/iug, + linkReg: /(https?:\/\/(www\.)?)?(?([a-z0-9-]{1,63}\.)?([a-z0-9-]{2,63})(\.[a-z0-9-]{2,63})(\.[a-z0-9-]{2,63})?)(\/\S*)?/iu, + mention: /<@!?(?[0-9]{18,22})>/u, + mentionG: /<@!?(?[0-9]{18,22})>/gu, + }; + } + + async initialise () + { + // Fetch a list of TLDs from iana + const tldList = await this.client.managerEval(` + (() => { + return ClientUtils.fetchTlds() + })() + `).catch(this.logger.error.bind(this.logger)) as string[] | undefined; + + if (!tldList) + throw Util.fatal('Failed to initialise automod'); + const middlePoint = Math.floor(tldList.length / 2); + const [ midEntry ] = tldList.splice(middlePoint, 1); + tldList.splice(0, 0, midEntry); + this.topLevelDomains = new BinaryTree(this.client, tldList); + this.topLevelDomains.add('onion'); + } + + async _moderate ( + action: SettingAction, guild: GuildWrapper, channel: TextChannel, + member: MemberWrapper, reason: string, filterResult: FilterResult, moderator?: MemberWrapper + ): Promise + { + // Prevent simultaneous execution of the same filter on the same user when spamming + if (!this.executing[filterResult.filter!]) + this.executing[filterResult.filter!] = []; + if (this.executing[filterResult.filter!].includes(member.id)) + return false; + this.executing[filterResult.filter!].push(member.id); + + // Setting this true initially and negate if it fails, otherwise it won't show up as sanctioned in the msg logs + filterResult.sanctioned = true; + const InfractionClass = CONSTANTS.Infractions[action.type]; + const executor = moderator ?? await guild.memberWrapper(this.client.user!); + if (!executor) + throw new Error('Missing executor??'); + const result = await this.client.moderation.handleAutomod(InfractionClass, member, { + guild, + channel, + executor, + reason, + duration: action.duration ? action.duration * 1000 : null, + points: action.points, + expiration: action.expiration, + silent: false, + force: false, + prune: action.prune, + data: { + automoderation: filterResult + } + }).catch(this.logger.error.bind(this.logger)); + filterResult.sanctioned = !result?.error; + + await Util.wait(5000); + this.executing[filterResult.filter!].splice(this.executing[filterResult.filter!].indexOf(member.id), 1); + return !result?.error; + } + + async filterWords (message: ExtendedMessage, edited: ExtendedMessage) + { + const { guildWrapper: guild, author } = message; + let channel = message.channel as TextChannel; + if (channel.partial) + channel = await channel.fetch(); + if (!guild || author.bot || message.filtered) + return; + + const member = await guild.memberWrapper(message.author); + if (!member) + return; + const settings = await guild.settings(); + const { wordfilter: setting } = settings; + const { bypass, ignore, enabled, silent, explicit, fuzzy, regex, whitelist, actions } = setting; + const roles = member?.roles.cache.map((r) => r.id) || []; + + if (!enabled || roles.some((r) => bypass.includes(r)) || ignore.includes(channel.id)) + return; + + const perms = channel.permissionsFor(this.client.user!); + const missing = perms?.missing('ManageMessages') || []; + if (missing.length) + { + this.client.emit('filterMissingPermissions', { channel, guild, filter: 'word', permissions: missing }); + return; + } + + // Which message obj to work with + const msg = edited || message; + if (!msg.content) + return; + let log = 'Message filter debug:'; + log += `\nPre norm: ${msg.cleanContent}`; + let content = null; + try + { // NOTE: Remove try-catch after debug + content = Util.removeMarkdown(msg.cleanContent); + if (!content) + return; + content = FilterUtil.normalise(content); + } + catch (err) + { + const error = err as Error; + this.logger.error(`Error in message filtering:\n${error.stack}\n${msg.cleanContent}`); + return; + } + log += `\nNormalised: ${content}`; + + // match: what was matched | + // matched: did it match at all ? | + // matcher: what gets shown in the message logs | + // _matcher: locally used variable for which word in the list triggered it | + // type: which detection type matched it + let filterResult: Partial> = { filter: 'word', match: null, matched: false, matcher: null, _matcher: null }; + const words = content.toLowerCase().replace(/[,?.!]/gu, '').split(' ').filter((elem) => elem.length); + // Remove any potential bypass characters + // const _words = words.map((word) => word.replace(/[.'*_?+"#%&=-]/gu, '')); + + // 2. Filter explicit - no bypass checking (unless you count normalising the text, i.e. emoji letters => normal letters) + if (explicit.length && !filterResult.matched) + { + const result = FilterUtil.filterExplicit(words, explicit); + if (result) + { + log += `\nMessage matched with "${result.match}" in the explicit list.\nFull content: ${content}`; + filterResult = result; + } + } + + // 3. Filter regex + if (regex.length && !filterResult.matched) + { + const result = FilterUtil.filterRegex(content, regex, whitelist); + if (result) + { + log += `\nMessage matched with "${result.matcher}" in the regex list.\nMatch: ${result.raw}, Full word: ${result.match}\nFull content: ${content}`; + filterResult = result; + } + } + + // 4. Filter fuzzy + if (fuzzy.length && !filterResult.matched) + { + const result = FilterUtil.filterFuzzy(words, fuzzy, whitelist); + if (result) + { + filterResult = result; + log += `\nMessage matched with "${result._matcher}" in fuzzy.\nMatched word: ${result.match}\nFull content: ${content}\nSimilarity: ${result.sim}\nThreshold: ${result.threshold}`; + } + } + + // 5. Remove message, inline response and add a reason to msg object + if (!filterResult.matched) + return; + msg.filtered = filterResult as FilterResult; + filterResult.filter = 'word'; + log += `\nFilter result: ${inspect(filterResult)}`; + + if (!silent && perms?.has('SendMessages')) + { + const res = await this.client.rateLimiter.limitSend(msg.channel as TextChannel, guild.format('W_FILTER_DELETE', { user: author.id }), null, 'wordFilter').catch(() => null); + // const res = await msg.formattedRespond('W_FILTER_DELETE', { params: { user: author.id } }); + // if (res) res.delete({ timeout: 10000 }).catch(catcher(240)); + if (res) + setTimeout(() => + { + res?.delete?.().catch(() => + { /**/ }); + }, 10000); + } + this.client.rateLimiter.queueDelete(channel, msg).catch(() => null); + + // 6. Automated actions + if (actions.length) + { + let action = actions.find((act) => + { + return (act.trigger as string).includes(filterResult._matcher!); + }); + if (!action) + action = actions.find((act) => + { + return act.trigger === filterResult.type; + }); + if (!action) + action = actions.find((act) => + { + return act.trigger === 'generic'; + }); + + if (action) + { + log += '\nSanctioned'; + await this._moderate(action, guild, channel, member, guild.format('W_FILTER_ACTION'), filterResult as FilterResult); + } + } + this.logger.debug(`${guild.name} WF DEBUG: ${log}`); + } + + async flagMessages (message: ExtendedMessage, edited: ExtendedMessage) + { + const { guild, author, guildWrapper: wrapper } = message; + let { channel } = message; + if (channel.partial) + channel = await channel.fetch(); + if (!guild || author.bot) + return; + + const member = message.member || await guild.members.fetch(author.id).catch(() => null); + const settings = await wrapper.settings(); + const { wordwatcher: setting } = settings; + const { words, regex, bypass, ignore, channel: _logChannel, actions } = setting; + const roles = member?.roles.cache.map((r) => r.id) || []; + + if (!_logChannel || words.length === 0 || roles.some((r) => bypass.includes(r)) || ignore.includes(channel.id)) + return; + + const logChannel = await wrapper.resolveChannel(_logChannel); + const msg = edited || message; + if (!msg.content || !logChannel) + return; + let content = null; + try + { + content = FilterUtil.normalise(msg.cleanContent); + } + catch (err) + { + const error = err as Error; + this.logger.error(`Error in message flag:\n${error.stack}\n${msg.cleanContent}`); + return; + } + let match: RegExpMatchArray | string | null = null; + + for (const reg of regex) + { + // match = content.match(new RegExp(`(?:^|\\s)(${reg})`, 'iu')); + match = content.match(new RegExp(reg, 'iu')); + if (match) + { + [ match ] = match; + break; + } + } + + if (!match) + for (const word of words) + { + if (!content.includes(word)) + continue; + match = content.substring(content.indexOf(word), word.length); + break; + } + + if (!match) + return; + + const context = channel.messages.cache.sort((m1, m2) => m2.createdTimestamp - m1.createdTimestamp).first(5); + const embed: APIEmbed = { + title: `⚠️ Word trigger in **#${channel.name}**`, + description: stripIndents` + **[Jump to message](${msg.url})** + `, // ** User:** <@${ author.id }> + color: 15120384, + fields: context.reverse().reduce((acc, val) => + { + const text = val.content.length ? Util.escapeMarkdown(val.content).replace(match as string, '**__$&__**') : '**NO CONTENT**'; + acc.push({ + name: `${val.author.tag} (${val.author.id}) - ${val.id}`, + value: text.length < 1024 ? text : text.substring(0, 1013) + '...' + }); + if (text.length > 1024) + acc.push({ + name: ZeroWidthChar, + value: '...' + text.substring(1013, 2034) + }); + return acc; + }, [] as APIEmbedField[]) + }; + + const components: ButtonComponentData[] = []; + for (const action of actions) + { + components.push({ + type: ComponentType.Button, + label: action.type, + // eslint-disable-next-line camelcase + customId: `WORDWATCHER_${action.trigger}`, + style: CONSTANTS.ButtonStyles[action.type] || ButtonStyle.Primary + }); + } + + const actionRow = components.length ? [{ + type: ComponentType.ActionRow, + components + }] : null; + const opts: MessageCreateOptions = { + embeds: [ embed ], + components: [] + }; + if (actionRow) + opts.components = actionRow; + const sent = await logChannel.send(opts).catch((err) => + { + this.logger.error('Error in message flag:\n' + err.stack); + }); + + // Only insert if actions are defined + if (actionRow && sent) + await this.client.storageManager.mongodb.wordwatcher.insertOne({ + message: sent.id, + target: msg.id, + channel: msg.channel.id, + timestamp: Date.now() + }); + } + + async flagAction (interaction: Interaction) + { + // console.log(interaction); + if (!interaction.isButton() || !interaction.inGuild()) + return; + const { message, customId } = interaction; + if (!customId.startsWith('WORDWATCHER_')) + return; + const { components } = message; + const [ , actionType ] = customId.split('_'); + const { permissions } = this.client; + + const guild = this.client.getGuildWrapper(interaction.guildId); + if (!guild) + return; + const moderator = await guild.memberWrapper(interaction.user.id); + if (!moderator) + return; + const settings = await guild.settings(); + const { wordwatcher } = settings; + const { actions } = wordwatcher; + + await interaction.deferUpdate(); + const fail = (index: string, opts?: FormatParams) => + { + this.client.emit('wordWatcherError', { + warning: true, + guild, + message: guild.format(index, opts) + }); + (components[0].components.find((comp) => comp.customId === customId) as ButtonComponentData).style = ButtonStyle.Danger; + return message.edit({ components }); + }; + + // TODO: finish perm checks, need to figure out a workaround for `command:delete` + // actionType === 'DELETE' ? + // { error: false } : + const wrapper = new InteractionWrapper(this.client, interaction); + const invoker = new InvokerWrapper(this.client, wrapper); + const { inhibitor, error, params } + = await permissions.execute( + invoker, + { resolveable: `command:${actionType.toLowerCase()}`, memberPermissions: [] }, + [ CONSTANTS.Permissions[actionType] ] + ); + + if (error) + { + const missing = moderator.permissions.missing('ManageMessages'); + if (params.missing !== 'command:delete') + return fail(inhibitor.index, { command: actionType, missing: params.missing }); + else if (missing.length) + return fail(inhibitor.index, { missing: missing.join(', ') }); + } + + const log = await this.client.storageManager.mongodb.wordwatcher.findOne({ + message: message.id + }); + if (!log) + return fail('WORDWATCHER_MISSING_LOG'); + + const action = actions.find((act) => act.trigger === actionType); + if (!action) + return fail('WORDWATCHER_MISSING_ACTION', { actionType }); + + const targetChannel = await guild.resolveChannel(log.channel).catch(() => null); + if (!targetChannel) + return fail('WORDWATCHER_MISSING_CHANNEL'); + + const filterObj = { + filter: 'wordwatcher', + action: action.type + }; + const msg = await targetChannel.messages.fetch(log.target).catch(() => null) as ExtendedMessage; + if (msg) + { + await msg.delete().catch(() => null); + msg.filtered = filterObj; + } + + await this.client.storageManager.mongodb.wordwatcher.deleteOne({ + message: message.id + }); + + let success = false; + if (action.type === 'DELETE') + { + success = true; + } + else + { + const member = await guild.memberWrapper(message.author); + if (member) + success = await this._moderate(action, guild, targetChannel, member, guild.format('WORDWATCHER_ACTION'), filterObj, moderator); + else + this.client.emit('wordWatcherError', { warning: true, guild, message: guild.format('WORDWATCHER_MISSING_MEMBER', { actionType }) }); + } + + (components[0].components.find((comp) => comp.customId === customId) as ButtonComponentData).style = success + ? ButtonStyle.Success : ButtonStyle.Secondary; + await message.edit({ components }).catch(() => null); + } + + // eslint-disable-next-line max-lines-per-function + async filterLinks (message: ExtendedMessage, edited: ExtendedMessage) + { + const { author, guildWrapper: guild, channel } = message; + if (!channel) + { + this.logger.warn(`Missing channel?\nChannelId: ${message.channelId}\nGuild: ${message.guildId}\nAuthor: ${inspect(author)}`, { broadcast: true }); + return; + } + if (!guild || author.bot || message.filtered) + return; + + const member = await guild.memberWrapper(message.author.id); + const { resolver } = this.client; + const settings = await guild.settings(); + const { linkfilter: setting } = settings; + const { bypass, ignore, actions, silent, enabled, blacklist, whitelist, whitelistMode, greylist } = setting; + if (!enabled || !member) + return; + const roles = member?.roles.cache.map((r) => r.id) || []; + + if (roles.some((r) => bypass.includes(r)) || ignore.includes(channel.id)) + return; + + const perms = channel.permissionsFor(this.client.user!); + const missing = perms?.missing('ManageMessages') || []; + if (missing.length) + { + this.client.emit('filterMissingPermissions', { channel, guild, filter: 'link', permissions: missing }); + return; + } + + const msg = edited || message; + if (!msg.content) + return; + const content = msg.content.split('').join(''); // Copy the string... + let matches = content.match(this.regex.linkRegG); + let removedWhitespace = false; + if (!matches) + { + matches = content.replace(/\s/u, '').match(this.regex.linkRegG); + removedWhitespace = true; + } + + if (!matches) + return; + let remove = false; + const filterResult: FilterResult = {}; + let log = `${guild.name} Link filter debug:`; + + for (const match of matches) + { + let domain = match.match(this.regex.linkReg)!.groups?.domain; + if (!domain) + continue; + domain = domain.toLowerCase(); + // Invites are filtered separately + if (domain === 'discord.gg') + continue; + log += `\nMatched link ${match}: `; + + const predicate = (dom: string) => dom.toLowerCase().includes(domain!) || domain!.includes(dom.toLowerCase()); + + if (blacklist.some(predicate)) + { + log += 'in blacklist'; + filterResult.match = domain; + filterResult.matcher = 'link blacklist'; + remove = true; + break; + } + else if (greylist.some(predicate)) + { + log += 'in greylist'; + filterResult.match = domain; + filterResult.matcher = 'link greylist'; + remove = true; + break; + } + else if (whitelistMode) + { + if (whitelist.some(predicate)) + { + log += 'in whitelist'; + continue; + } + + const parts = domain.split('.'); + const validTld = this.topLevelDomains.find(parts[parts.length - 1]); + // console.log(parts, validTld); + if (!validTld) + continue; + const valid = await resolver.validateDomain(domain); + + if (removedWhitespace && !match.includes(`${domain}/`)) + continue; + + if (!valid) + { + // eslint-disable-next-line max-depth + if (match.includes(`${domain}/`)) + this.client.emit('linkFilterWarn', { guild, message: guild.format('LINKFILTER_WARN', { domain, link: match }) }); + continue; + } + + filterResult.match = domain; + filterResult.matcher = 'link whitelist'; + remove = true; + break; + } + } + + log += `\nFilter result: ${inspect(filterResult)}\nRemove: ${remove}`; + if (!remove) + return; + msg.filtered = filterResult; + filterResult.filter = 'link'; + + if (!silent && perms?.has('SendMessages')) + { + const res = await this.client.rateLimiter.limitSend(channel as TextChannel, guild.format('L_FILTER_DELETE', { user: author.id }), null, 'linkFilter'); + // const res = await msg.formattedRespond(`L_FILTER_DELETE`, { params: { user: author.id } }); + // if (res) res.delete({ timeout: 10000 }); + if (res) + setTimeout(() => + { + res.delete?.().catch(() => + { /**/ }); + }, 10000); + } + + this.client.rateLimiter.queueDelete(msg.channel as TextChannel, msg).catch(() => null); + if (actions.length) + { + let action = actions.find((act) => + { + return (act.trigger as string).includes(filterResult.match!); + }); + if (!action) + action = actions.find((act) => + { + return act.trigger === filterResult.matcher!.split(' ')[1]; + }); + if (!action) + action = actions.find((act) => + { + return act.trigger === 'generic'; + }); + + if (action) + { + log += '\nSanctioned'; + await this._moderate(action, guild, channel as TextChannel, member, guild.format('L_FILTER_ACTION', { domain: filterResult.match }), filterResult); + } + } + this.logger.debug(log); + } + + async filterInvites (message: ExtendedMessage, edited: ExtendedMessage) + { + const { author, guildWrapper: guild } = message; + let channel = message.channel as TextChannel; + if (channel.partial) + channel = await channel.fetch(); + if (!guild || author.bot || message.filtered) + return; + + const member = await guild.memberWrapper(message.author.id); + const settings = await guild.settings(); + const { invitefilter: setting } = settings; + const { bypass = [], ignore = [], actions, silent, enabled, whitelist = [] } = setting; + if (!enabled || !member) + return; + const roles = member.roles.cache.map((r) => r.id) || []; + + if (roles.some((r) => bypass.includes(r)) || ignore.includes(channel.id)) + return; + + const perms = channel.permissionsFor(this.client.user!); + const missing = perms?.missing('ManageMessages') || []; + if (missing.length) + { + this.client.emit('filterMissingPermissions', { channel, guild, filter: 'invite', permissions: missing }); + return; + } + + const msg = edited || message; + const { content } = msg; + if (!content) + return; + + const match = content.match(this.regex.invite); + if (!match) + return; + + const invite = await this.client.fetchInvite(match.groups!.code).catch(() => null); + const result = await guild.checkInvite(match.groups!.code) || invite?.guild?.id === guild.id; + if (invite && invite.guild && whitelist.includes(invite.guild.id) || result) + return; + + if (!result) + { // Doesn't resolve to the origin server + let action = null; + if (actions.length) + [ action ] = actions; + + msg.filtered = { + match: match[0], + matcher: 'invites', + filter: 'invite' + }; + if (!action) + return this.client.rateLimiter.queueDelete(channel, msg).catch(() => null); // msg.delete(); + if (!silent && perms?.has('SendMessages')) + { + const res = await this.client.rateLimiter.limitSend(channel, guild.format('I_FILTER_DELETE', { user: author.id }), null, 'inviteFilter'); + // if (res) res.delete({ timeout: 10000 }); + if (res) + setTimeout(() => + { + res.delete().catch(() => + { /**/ }); + }, 10000); + } + // msg.filtered.sactioned = true; + this.client.rateLimiter.queueDelete(channel, msg); + await this._moderate(action, guild, channel, member, guild.format('I_FILTER_ACTION'), msg.filtered); + } + } + + async filterMentions (message: ExtendedMessage) + { + const { author, guildWrapper: guild } = message; + let channel = message.channel as TextChannel; + if (!channel) + return; // Idk how or why but the channel is sometimes null? I have absolutely no clue how this could ever be the case + if (channel.partial) + channel = await channel.fetch(); + if (!guild || author.bot || message.filtered) + return; + + const member = await guild.memberWrapper(message.author); + const settings = await guild.settings(); + const { mentionfilter: setting } = settings; + const { bypass, ignore, enabled, silent, unique, limit, actions } = setting; + const roles = member?.roles.cache.map((r) => r.id) || []; + + if (!member || !enabled || roles.some((r) => bypass.includes(r)) || ignore.includes(channel.id)) + return; + + const perms = channel.permissionsFor(this.client.user!); + const missing = perms?.missing('ManageMessages') || []; + if (missing.length) + { + this.client.emit('filterMissingPermissions', { channel, guild, filter: 'mention', permissions: missing }); + return; + } + + const { content } = message; + if (!content) + return; + + const matches = content.match(this.regex.mentionG); + if (!matches) + return; + + let ids = matches.map((match) => match.match(this.regex.mention)?.groups!.id).filter(Boolean) as string[]; + if (unique) + { + const set = new Set(ids); + ids = [ ...set ]; + } + + if (ids.length < limit) + return; + if (!silent && perms?.has('SendMessages')) + { + const res = await this.client.rateLimiter.limitSend(channel, guild.format('M_FILTER_DELETE', { user: author.id }), null, 'mentionFilter'); + if (res) + setTimeout(() => + { + res.delete().catch(() => + { /**/ }); + }, 10000); + } + + this.client.rateLimiter.queueDelete(channel, message).catch(() => null); + const filterResult = { + filter: 'mention', + amount: ids.length + }; + message.filtered = filterResult; + + if (actions.length) + { + + let action = actions.find((act) => + { + return (act.trigger as number) <= ids.length; + }); + + if (!action) + action = actions.find((act) => + { + return act.trigger === 'generic'; + }); + + if (!action) + return; + + await this._moderate(action, guild, channel, member, guild.format('M_FILTER_ACTION'), filterResult); + } + } + + async raidProtection (_member: ExtendedGuildMember) + { + // + } + } \ No newline at end of file diff --git a/src/client/components/wrappers/GuildWrapper.ts b/src/client/components/wrappers/GuildWrapper.ts index efe3b27..39ec765 100644 --- a/src/client/components/wrappers/GuildWrapper.ts +++ b/src/client/components/wrappers/GuildWrapper.ts @@ -1,733 +1,733 @@ -import { ChannelResolveable, FormatOpts, FormatParams, MemberResolveable, UserResolveable } from '../../../../@types/Client.js'; -import { - CallbackData, - ChannelJSON, - GuildData, - GuildJSON, - GuildPermissions, - GuildSettings, - PartialGuildSettings, - PollData, - ReminderData, - RoleJSON -} from '../../../../@types/Guild.js'; -import DiscordClient from '../../DiscordClient.js'; - -// const { default: Collection } = require("@discordjs/collection"); -// const { Guild } = require("discord.js"); -// const { PollReactions, EmbedDefaultColor } = require("../../../constants/Constants.js"); -// const { FilterUtil, SettingsMigrator, InfractionMigrator } = require("../../../utilities/index.js"); -// const MemberWrapper = require("./MemberWrapper.js"); -const configVersion = '3.slash.2'; - -import { PollReactions, EmbedDefaultColor } from '../../../constants/Constants.js'; - -import { - Guild, - Collection, - Webhook, - Channel, - ImageURLOptions, - GuildAuditLogsFetchOptions, - GuildAuditLogsResolvable, - TextChannel, - Invite, - GuildMember, - RoleResolvable, - GuildBasedChannel, -} from 'discord.js'; -import MemberWrapper from './MemberWrapper.js'; -import { FilterUtil, Util } from '../../../utilities/index.js'; -import { LoggerClient } from '@navy.gif/logger'; - -type CallbackFn = (data: CallbackData) => void; -type Callback = { - timeout: NodeJS.Timeout, - data: CallbackData -} - -class GuildWrapper -{ - [key: string]: unknown; - - #client: DiscordClient; - #guild: Guild; - #logger: LoggerClient; - - #invites?: Collection; - #webhooks: Collection; - #memberWrappers: Collection; - #callbacks: Collection; - - #data!: GuildData; - #settings!: GuildSettings; - #permissions?: GuildPermissions; - - constructor (client: DiscordClient, guild: Guild) - { - if (!guild || !(guild instanceof Guild)) - throw new Error('Invalid guild passed to GuildWrapper'); - if (guild instanceof GuildWrapper) - throw new Error('Already a wrapper'); - - this.#client = client; - this.#logger = client.createLogger({ name: `Guild: ${guild.id}` }); - this.#guild = guild; - this.#webhooks = new Collection(); - this.#memberWrappers = new Collection(); - this.#callbacks = new Collection(); - this.#debugLog('Created wrapper'); - } - - async createPoll ({ user, duration, ...opts }: PollData) - { - // Idk polls that don't have a duration should still be stored somewhere so they can be ended at an arbitrary point - const type = 'poll'; - const now = Date.now(); - const id = `${type}:${user}:${now}`; - const data = { ...opts, user, id, guild: this.id, type, time: duration * 1000, created: now }; - if (duration) - await this.createCallback(data satisfies CallbackData); - } - - async createReminder ({ time, user, channel, reminder }: ReminderData) - { - const type = 'reminder'; - const now = Date.now(); - const id = `${type}:${user}:${now}`; - const data = { user, channel, reminder, id, guild: this.id, type, time: time * 1000, created: now }; - await this.createCallback(data); - } - - async loadCallbacks () - { - const data = await this.#client.mongodb.callbacks.find({ guild: this.id }); - for (const cb of data) - await this.createCallback(cb, false); - } - - async createCallback (data: CallbackData, update = true) - { - const handler = this[`_${data.type}`] as CallbackFn;// .bind(this); - if (!handler) - throw new Error('Invalid callback type'); - - const now = Date.now(); - const time = data.created + data.time; - const diff = time - now; - if (diff < 5000) - return handler.bind(this)(data); - - const cb = { timeout: setTimeout(handler.bind(this), diff, data), data }; - this.#callbacks.set(data.id, cb); - if (update) - await this.#client.mongodb.callbacks.updateOne({ id: data.id, guild: this.id }, data); - } - - async removeCallback (id: string) - { - const cb = this.#callbacks.get(id); - if (cb) - clearTimeout(cb.timeout); - this.#callbacks.delete(id); - await this.#client.mongodb.callbacks.deleteOne({ guild: this.id, id }); - } - - async _poll ({ user, message, channel, id, questions, startedIn }: PollData & CallbackData) - { // multichoice, - const startedInChannel = await this.resolveChannel(startedIn); - const pollChannel = await this.resolveChannel(channel); - if (pollChannel) - { - const msg = await pollChannel.messages.fetch(message).catch(() => null); - if (msg) - { - const { reactions } = msg; - const reactionEmojis = questions.length ? PollReactions.Multi : PollReactions.Single; - const result: {[key: string]: number} = {}; - for (const emoji of reactionEmojis) - { - let reaction = reactions.resolve(emoji); - // eslint-disable-next-line max-depth - if (!reaction) - continue; - // eslint-disable-next-line max-depth - if (reaction.partial) - reaction = await reaction.fetch(); - result[emoji] = reaction.count - 1; - } - - const embed = msg.embeds[0].toJSON(); - const results = Object.entries(result).map(([ emoji, count ]) => `${emoji} - ${count}`).join('\n'); - embed.description = this.format('COMMAND_POLL_END', { results }); - await msg.edit({ embeds: [ embed ] }); - } - } - await this.removeCallback(id); - if (startedInChannel) - await startedInChannel.send(this.format('COMMAND_POLL_NOTIFY_STARTER', { user, channel })); - } - - async _reminder ({ reminder, user, channel, id }: ReminderData & CallbackData) - { - const reminderChannel = await this.resolveChannel(channel); - if (reminderChannel && reminderChannel.permissionsFor(this.#client.user!)?.has([ 'ViewChannel', 'SendMessages' ])) - await reminderChannel.send({ - content: `<@${user}>`, - embeds: [{ - title: this.format('GENERAL_REMINDER_TITLE'), - description: reminder, - color: EmbedDefaultColor - }] - }); - await this.removeCallback(id); - } - - async filterText (member: GuildMember, text: string) - { - const settings = await this.settings(); - const { wordfilter } = settings; - const { enabled, bypass } = wordfilter; - - if (!enabled) - return text; - if (member.roles.cache.map((r) => r.id).some((r) => bypass.includes(r))) - return text; - - return FilterUtil.filterText(text, wordfilter); - } - - async checkInvite (code: string) - { - // Is maintained by the utility hook - if (!this.#invites && this.me?.permissions.has('ManageGuild')) - this.#invites = await this.fetchInvites(); - return this.#invites?.has(code) || false; - } - - async fetchData () - { - if (this.#data) - return this.#data; - const data = await this.#client.mongodb.guilds.findOne({ guildId: this.id }); - if (!data) - { - this.#data = {}; - return this.#data; - } - if (data._version === '3.slash') - { - const oldSettings = data as GuildSettings; - const keys = Object.keys(this.defaultConfig); - const settings: PartialGuildSettings = {}; - for (const key of keys) - { - settings[key] = oldSettings[key]; - delete data[key]; - } - data.settings = settings as GuildSettings; - data._version = configVersion;// '3.slash.2'; - await this.#client.mongodb.guilds.deleteOne({ guildId: this.id }); - await this.#client.mongodb.guilds.updateOne({ guildId: this.id }, data); - } - this.#data = data; - return data; - } - - async settings (forceFetch = false) - : Promise - { - if (this.#settings && !forceFetch) - return this.#settings; - - const data = await this.fetchData(); - // eslint-disable-next-line prefer-const - const { - settings, - // _imported - } = data; - const { defaultConfig } = this; - - // V2 db server is no more, leaving this here if needed again - // if (!settings && !_imported?.settings && !_imported?.modlogs && process.env.NODE_ENV === 'staging') { - // if (this._importPromise) settings = await this._importPromise; - // else { - // this._importPromise = this._attemptDataImport(); - // settings = await this._importPromise; - // } - // } - - if (settings) - { - // Ensure new settings properties are propagated to existing configs - const keys = Object.keys(settings); - for (const key of keys) - { - if (!(key in defaultConfig)) - continue; - defaultConfig[key] = { ...defaultConfig[key], ...settings[key] }; - } - } - - this.#settings = defaultConfig; - return this.#settings; - } - - async updateData (data: GuildData) - { - try - { - await this.#client.mongodb.guilds.updateOne({ guildId: this.id }, { _version: configVersion, ...data }); - this.#data = { ...this.#data, ...data, _version: configVersion }; - this.#storageLog(`Database update: Data (guild:${this.id})`); - } - catch (error) - { - const err = error as Error; - this.#storageError(err); - } - } - - async updateSettings (settings: Partial) - { - if (!this.#settings) - await this.settings(); - await this.updateData({ settings: settings as GuildSettings }); - this.#settings = { - ...this.#settings, - ...settings - } as GuildSettings; - } - - async permissions () - { - if (this.#permissions) - return this.#permissions; - - const perms = await this.#client.mongodb.permissions.findOne({ guildId: this.id }, { projection: { guildId: -1 } }); - if (perms) - this.#permissions = perms; - else - this.#permissions = { guildId: this.id }; - - return this.#permissions!; - } - - async updatePermissions () - { - if (!this.#permissions) - throw new Error('Permissions not loaded'); - try - { - await this.#client.mongodb.permissions.updateOne({ guildId: this.id }, this.#permissions, { upsert: true }); - } - catch (error) - { - const err = error as Error; - this.#logger.error(`Failed database insertion:\n${err.stack || err}`); - return false; - } - return true; - } - - // async _attemptDataImport () - // { - // const migratorOptions = { - // // host: MONGODB_V2_HOST, - // database: 'galacticbot', - // version: '2' - // }; - - // const settingsMigrator = new SettingsMigrator(this.client, this, migratorOptions); - // const modlogsMigrator = new InfractionMigrator(this.client, this, migratorOptions); - - // await settingsMigrator.connect(); - // await modlogsMigrator.connect(); - - // let importedSettings = null; - // let importedModlogs = null; - - // try - // { - // importedSettings = await settingsMigrator.import(); - // importedModlogs = await modlogsMigrator.import(); - // importedModlogs.sort((a, b) => a.case - b.case); - // } - // catch (err) - // { - // await settingsMigrator.end(); - // await modlogsMigrator.end(); - // // Did not find old settings, marking as imported anyway - // if (err.message.includes('No old')) - // { - // await this.updateData({ _imported: { settings: true, modlogs: true } }); - // } - // else - // this.client.logger.error(err.stack); - // return null; - // } - // await settingsMigrator.end(); - // await modlogsMigrator.end(); - - // await this.client.mongodb.infractions.deleteMany({ guild: this.id }); - // await this.client.mongodb.infractions.insertMany(importedModlogs); - // this._data.caseId = importedModlogs[importedModlogs.length - 1].case; - // await this.updateData({ - // caseId: this._data.caseId, - // premium: importedSettings.premium, - // _imported: { settings: true, modlogs: true } - // }); - - // const { webhook, permissions, settings } = importedSettings; - // await this.updateSettings(settings); - // if (webhook) - // { - // const hooks = await this.fetchWebhooks().catch(() => null); - // const hook = hooks?.get(webhook); - // if (hook) - // await this.updateWebhook('messages', hook); - // } - - // if (permissions) - // await this.#client.mongodb.permissions.updateOne({ guildId: this.id }, permissions); - - - // return settings; - - // } - - /** - * Update a webhook entry in the database - * - * @param {string} feature Which feature webhook to update, e.g. messagelog - * @param {Webhook} hook The webhook object, omitting this will nullify the hook data - * @memberof ExtendedGuild - */ - async updateWebhook (feature: string, webhook?: Webhook | null) - { - if (!feature) - throw new Error('Missing feature name'); - - if (!webhook) - { - this.#logger.debug(`Removing webhook in ${this.name} (${this.id})`); - const hook = this.#webhooks.get(feature); - if (hook) - await hook.delete('Removing old webhook').catch((err) => - { - if (err.code !== 10015) - this.#logger.error(err.stack); - }); - this.#webhooks.delete(feature); - return this.#client.mongodb.webhooks.deleteOne({ feature, guild: this.id }); - } - - this.#webhooks.set(feature, webhook); - await this.#client.mongodb.webhooks.updateOne({ feature, guild: this.id }, { hookId: webhook.id, token: webhook.token }); - } - - /** - * Retrieves a cached webhook for a feature if it exists, gets it from the database if not cached - * - * @param {string} feature The name of the feature, ex. messageLogs - * @returns {Webhook} - * @memberof ExtendedGuild - */ - async getWebhook (feature: string): Promise - { - if (!feature) - return Promise.resolve(null); - if (this.#webhooks.has(feature)) - return Promise.resolve(this.#webhooks.get(feature) as Webhook); - - const result = await this.#client.mongodb.webhooks.findOne({ feature, guild: this.id }); - if (!result) - return null; - if (!this.me?.permissions.has('ManageWebhooks')) - throw new Error('Missing ManageWebhooks'); - - const hooks = await this.fetchWebhooks(); - let hook = hooks.get(result.hookId); - if (!hook) - return null; - if (!hook.token) - { // Happens when the webhook from imported settings is used, replace it. - const channel = await this.resolveChannel(hook.channelId); - if (!channel) - throw new Error('Missing channel?'); - await hook.delete('Old hook'); - hook = await channel.createWebhook({ name: 'Galactic Bot message logs' }); - } - // const hook = new WebhookClient(result.hookID, result.token, { - // disableMentions: 'everyone' - // }); - this.#webhooks.set(feature, hook); - return hook; - } - - fetchWebhooks () - { - return this.#guild.fetchWebhooks(); - } - - async fetchInvites () - { - const invites = await this.#guild.invites.fetch(); - this.#invites = invites; - return invites; - } - - get invites () - { - return this.#invites; - } - - get defaultConfig (): GuildSettings - { - return { ...JSON.parse(JSON.stringify(this.#client.defaultConfig('GUILD'))) }; - } - - get locale () - { - return this.#settings?.locale || 'en_gb'; - } - - format (index: string, parameters: FormatParams = {}, opts: FormatOpts = {}) - { - const { - code = false, - language = this.locale.language || 'en_gb' - } = opts; - return this.#client.localeLoader.format(language, index, parameters, code); - } - - async memberWrapper (user: UserResolveable) - { - const id = Util.hasId(user) ? user.id : user; - const member = user instanceof GuildMember ? user : await this.resolveMember(id); - if (!member) - // return Promise.reject(new Error('No member found')); - return null; - if (this.#memberWrappers.has(member.id)) - return this.#memberWrappers.get(member.id)!; - - const wrapper = new MemberWrapper(this.#client, member, this); - this.#memberWrappers.set(wrapper.id, wrapper); - return wrapper; - } - - resolveMembers (resolveables: MemberResolveable[], strict?: boolean) - { - return this.#client.resolver.resolveMembers(resolveables, strict, this); - } - - resolveMember (resolveable: MemberResolveable, strict?: boolean) - { - return this.#client.resolver.resolveMember(resolveable, strict, this); - } - - resolveRoles (resolveables: RoleResolvable[], strict?: boolean) - { - return this.#client.resolver.resolveRoles(resolveables, strict, this); - } - - resolveRole (resolveable: RoleResolvable, strict?: boolean) - { - return this.#client.resolver.resolveRole(resolveable, strict, this); - } - - // eslint-disable-next-line max-len - resolveChannels (resolveables: ChannelResolveable[], strict = false, filter?: (channel: Channel) => boolean) - { - return this.#client.resolver.resolveChannels(resolveables, strict, this, filter); - } - - resolveChannel (resolveable?: ChannelResolveable | null, strict = false, filter?: (channel: Channel) => boolean) - { - return this.#client.resolver.resolveChannel(resolveable, strict, this, filter); - } - - resolveUsers (resolveables: UserResolveable[], strict?: boolean) - { - return this.#client.resolver.resolveUsers(resolveables, strict); - } - - resolveUser (resolveable: UserResolveable, strict?: boolean) - { - return this.#client.resolver.resolveUser(resolveable, strict); - } - - // Logging - - #storageLog (log: string) - { - this.#logger.debug(log); - } - - #storageError (error: Error) - { - this.#logger.error(`Database error (guild:${this.id}) :\n${error.stack || error}`); - } - - #debugLog (log: string) - { - this.#logger.debug(`[${this.name}]: ${log}`); - } - - /* Wrapper Functions */ - - fetchAuditLogs (opts: GuildAuditLogsFetchOptions) - { - return this.guild.fetchAuditLogs(opts); - } - - fetch () - { - return this.guild.fetch(); - } - - iconURL (opts?: ImageURLOptions) - { - return this.guild.iconURL(opts); - } - - get callbacks () - { - return this.#callbacks; - } - - get guild () - { - return this.#guild; - } - - get data () - { - return this.#data; - } - - get prefix () - { - return this.#settings.textcommands.prefix || this.#client.prefix; - } - - get available () - { - return this.guild.available; - } - - get bans () - { - return this.guild.bans; - } - - get channels () - { - return this.guild.channels; - } - - get features () - { - return this.guild.features; - } - - get id () - { - return this.guild.id; - } - - get shardId () - { - return this.guild.shardId; - } - - get maximumMembers () - { - return this.guild.maximumMembers; - } - - get maximumPresences () - { - return this.guild.maximumPresences; - } - - get me () - { - return this.guild.members.me; - } - - get memberCount () - { - return this.guild.memberCount; - } - - get members () - { - return this.guild.members; - } - - get name () - { - return this.guild.name; - } - - get roles () - { - return this.guild.roles; - } - - get ownerId () - { - return this.guild.ownerId; - } - - // Boost tier - get premiumTier () - { - return this.guild.premiumTier; - } - - get premium () - { - return this.#data.premium ?? 0; - } - - get _settings () - { - return this.#settings; - } - - // Primarily used by the API - toJSON (): GuildJSON - { - const json = this.guild.toJSON() as { - channels: ChannelJSON[], - roles: RoleJSON[] - }; - // json.members = await Promise.all(json.members.map(async (id) => { - // const member = await this.guild.resolveMember(id); - // return { id: member.id, tag: member.user.tag }; - // })); - json.channels = this.guild.channels.cache.map((channel) => - { - return { - id: channel.id, - type: channel.type, - name: channel.name, - parent: channel.parentId - }; - }); - json.roles = this.guild.roles.cache.map((role) => - { - return { - id: role.id, - name: role.name, - position: role.position - }; - }); - return json; - } - -} - +import { ChannelResolveable, FormatOpts, FormatParams, MemberResolveable, UserResolveable } from '../../../../@types/Client.js'; +import { + CallbackData, + ChannelJSON, + GuildData, + GuildJSON, + GuildPermissions, + GuildSettings, + PartialGuildSettings, + PollData, + ReminderData, + RoleJSON +} from '../../../../@types/Guild.js'; +import DiscordClient from '../../DiscordClient.js'; + +// const { default: Collection } = require("@discordjs/collection"); +// const { Guild } = require("discord.js"); +// const { PollReactions, EmbedDefaultColor } = require("../../../constants/Constants.js"); +// const { FilterUtil, SettingsMigrator, InfractionMigrator } = require("../../../utilities/index.js"); +// const MemberWrapper = require("./MemberWrapper.js"); +const configVersion = '3.slash.2'; + +import { PollReactions, EmbedDefaultColor } from '../../../constants/Constants.js'; + +import { + Guild, + Collection, + Webhook, + Channel, + ImageURLOptions, + GuildAuditLogsFetchOptions, + GuildAuditLogsResolvable, + TextChannel, + Invite, + GuildMember, + RoleResolvable, + GuildBasedChannel, +} from 'discord.js'; +import MemberWrapper from './MemberWrapper.js'; +import { FilterUtil, Util } from '../../../utilities/index.js'; +import { LoggerClient } from '@navy.gif/logger'; + +type CallbackFn = (data: CallbackData) => void; +type Callback = { + timeout: NodeJS.Timeout, + data: CallbackData +} + +class GuildWrapper +{ + [key: string]: unknown; + + #client: DiscordClient; + #guild: Guild; + #logger: LoggerClient; + + #invites?: Collection; + #webhooks: Collection; + #memberWrappers: Collection; + #callbacks: Collection; + + #data!: GuildData; + #settings!: GuildSettings; + #permissions?: GuildPermissions; + + constructor (client: DiscordClient, guild: Guild) + { + if (!guild || !(guild instanceof Guild)) + throw new Error('Invalid guild passed to GuildWrapper'); + if (guild instanceof GuildWrapper) + throw new Error('Already a wrapper'); + + this.#client = client; + this.#logger = client.createLogger({ name: `Guild: ${guild.id}` }); + this.#guild = guild; + this.#webhooks = new Collection(); + this.#memberWrappers = new Collection(); + this.#callbacks = new Collection(); + this.#debugLog('Created wrapper'); + } + + async createPoll ({ user, duration, ...opts }: PollData) + { + // Idk polls that don't have a duration should still be stored somewhere so they can be ended at an arbitrary point + const type = 'poll'; + const now = Date.now(); + const id = `${type}:${user}:${now}`; + const data = { ...opts, user, id, guild: this.id, type, time: duration * 1000, created: now }; + if (duration) + await this.createCallback(data satisfies CallbackData); + } + + async createReminder ({ time, user, channel, reminder }: ReminderData) + { + const type = 'reminder'; + const now = Date.now(); + const id = `${type}:${user}:${now}`; + const data = { user, channel, reminder, id, guild: this.id, type, time: time * 1000, created: now }; + await this.createCallback(data); + } + + async loadCallbacks () + { + const data = await this.#client.mongodb.callbacks.find({ guild: this.id }); + for (const cb of data) + await this.createCallback(cb, false); + } + + async createCallback (data: CallbackData, update = true) + { + const handler = this[`_${data.type}`] as CallbackFn;// .bind(this); + if (!handler) + throw new Error('Invalid callback type'); + + const now = Date.now(); + const time = data.created + data.time; + const diff = time - now; + if (diff < 5000) + return handler.bind(this)(data); + + const cb = { timeout: setTimeout(handler.bind(this), diff, data), data }; + this.#callbacks.set(data.id, cb); + if (update) + await this.#client.mongodb.callbacks.updateOne({ id: data.id, guild: this.id }, data); + } + + async removeCallback (id: string) + { + const cb = this.#callbacks.get(id); + if (cb) + clearTimeout(cb.timeout); + this.#callbacks.delete(id); + await this.#client.mongodb.callbacks.deleteOne({ guild: this.id, id }); + } + + async _poll ({ user, message, channel, id, questions, startedIn }: PollData & CallbackData) + { // multichoice, + const startedInChannel = await this.resolveChannel(startedIn); + const pollChannel = await this.resolveChannel(channel); + if (pollChannel) + { + const msg = await pollChannel.messages.fetch(message).catch(() => null); + if (msg) + { + const { reactions } = msg; + const reactionEmojis = questions.length ? PollReactions.Multi : PollReactions.Single; + const result: {[key: string]: number} = {}; + for (const emoji of reactionEmojis) + { + let reaction = reactions.resolve(emoji); + // eslint-disable-next-line max-depth + if (!reaction) + continue; + // eslint-disable-next-line max-depth + if (reaction.partial) + reaction = await reaction.fetch(); + result[emoji] = reaction.count - 1; + } + + const embed = msg.embeds[0].toJSON(); + const results = Object.entries(result).map(([ emoji, count ]) => `${emoji} - ${count}`).join('\n'); + embed.description = this.format('COMMAND_POLL_END', { results }); + await msg.edit({ embeds: [ embed ] }); + } + } + await this.removeCallback(id); + if (startedInChannel) + await startedInChannel.send(this.format('COMMAND_POLL_NOTIFY_STARTER', { user, channel })); + } + + async _reminder ({ reminder, user, channel, id }: ReminderData & CallbackData) + { + const reminderChannel = await this.resolveChannel(channel); + if (reminderChannel && reminderChannel.permissionsFor(this.#client.user!)?.has([ 'ViewChannel', 'SendMessages' ])) + await reminderChannel.send({ + content: `<@${user}>`, + embeds: [{ + title: this.format('GENERAL_REMINDER_TITLE'), + description: reminder, + color: EmbedDefaultColor + }] + }); + await this.removeCallback(id); + } + + async filterText (member: GuildMember, text: string) + { + const settings = await this.settings(); + const { wordfilter } = settings; + const { enabled, bypass } = wordfilter; + + if (!enabled) + return text; + if (member.roles.cache.map((r) => r.id).some((r) => bypass.includes(r))) + return text; + + return FilterUtil.filterText(text, wordfilter); + } + + async checkInvite (code: string) + { + // Is maintained by the utility hook + if (!this.#invites && this.me?.permissions.has('ManageGuild')) + this.#invites = await this.fetchInvites(); + return this.#invites?.has(code) || false; + } + + async fetchData () + { + if (this.#data) + return this.#data; + const data = await this.#client.mongodb.guilds.findOne({ guildId: this.id }); + if (!data) + { + this.#data = {}; + return this.#data; + } + if (data._version === '3.slash') + { + const oldSettings = data as GuildSettings; + const keys = Object.keys(this.defaultConfig); + const settings: PartialGuildSettings = {}; + for (const key of keys) + { + settings[key] = oldSettings[key]; + delete data[key]; + } + data.settings = settings as GuildSettings; + data._version = configVersion;// '3.slash.2'; + await this.#client.mongodb.guilds.deleteOne({ guildId: this.id }); + await this.#client.mongodb.guilds.updateOne({ guildId: this.id }, data); + } + this.#data = data; + return data; + } + + async settings (forceFetch = false) + : Promise + { + if (this.#settings && !forceFetch) + return this.#settings; + + const data = await this.fetchData(); + // eslint-disable-next-line prefer-const + const { + settings, + // _imported + } = data; + const { defaultConfig } = this; + + // V2 db server is no more, leaving this here if needed again + // if (!settings && !_imported?.settings && !_imported?.modlogs && process.env.NODE_ENV === 'staging') { + // if (this._importPromise) settings = await this._importPromise; + // else { + // this._importPromise = this._attemptDataImport(); + // settings = await this._importPromise; + // } + // } + + if (settings) + { + // Ensure new settings properties are propagated to existing configs + const keys = Object.keys(settings); + for (const key of keys) + { + if (!(key in defaultConfig)) + continue; + defaultConfig[key] = { ...defaultConfig[key], ...settings[key] }; + } + } + + this.#settings = defaultConfig; + return this.#settings; + } + + async updateData (data: GuildData) + { + try + { + await this.#client.mongodb.guilds.updateOne({ guildId: this.id }, { _version: configVersion, ...data }); + this.#data = { ...this.#data, ...data, _version: configVersion }; + this.#storageLog(`Database update: Data (guild:${this.id})`); + } + catch (error) + { + const err = error as Error; + this.#storageError(err); + } + } + + async updateSettings (settings: Partial) + { + if (!this.#settings) + await this.settings(); + await this.updateData({ settings: settings as GuildSettings }); + this.#settings = { + ...this.#settings, + ...settings + } as GuildSettings; + } + + async permissions () + { + if (this.#permissions) + return this.#permissions; + + const perms = await this.#client.mongodb.permissions.findOne({ guildId: this.id }, { projection: { guildId: -1 } }); + if (perms) + this.#permissions = perms; + else + this.#permissions = { guildId: this.id }; + + return this.#permissions!; + } + + async updatePermissions () + { + if (!this.#permissions) + throw new Error('Permissions not loaded'); + try + { + await this.#client.mongodb.permissions.updateOne({ guildId: this.id }, this.#permissions, { upsert: true }); + } + catch (error) + { + const err = error as Error; + this.#logger.error(`Failed database insertion:\n${err.stack || err}`); + return false; + } + return true; + } + + // async _attemptDataImport () + // { + // const migratorOptions = { + // // host: MONGODB_V2_HOST, + // database: 'galacticbot', + // version: '2' + // }; + + // const settingsMigrator = new SettingsMigrator(this.client, this, migratorOptions); + // const modlogsMigrator = new InfractionMigrator(this.client, this, migratorOptions); + + // await settingsMigrator.connect(); + // await modlogsMigrator.connect(); + + // let importedSettings = null; + // let importedModlogs = null; + + // try + // { + // importedSettings = await settingsMigrator.import(); + // importedModlogs = await modlogsMigrator.import(); + // importedModlogs.sort((a, b) => a.case - b.case); + // } + // catch (err) + // { + // await settingsMigrator.end(); + // await modlogsMigrator.end(); + // // Did not find old settings, marking as imported anyway + // if (err.message.includes('No old')) + // { + // await this.updateData({ _imported: { settings: true, modlogs: true } }); + // } + // else + // this.client.logger.error(err.stack); + // return null; + // } + // await settingsMigrator.end(); + // await modlogsMigrator.end(); + + // await this.client.mongodb.infractions.deleteMany({ guild: this.id }); + // await this.client.mongodb.infractions.insertMany(importedModlogs); + // this._data.caseId = importedModlogs[importedModlogs.length - 1].case; + // await this.updateData({ + // caseId: this._data.caseId, + // premium: importedSettings.premium, + // _imported: { settings: true, modlogs: true } + // }); + + // const { webhook, permissions, settings } = importedSettings; + // await this.updateSettings(settings); + // if (webhook) + // { + // const hooks = await this.fetchWebhooks().catch(() => null); + // const hook = hooks?.get(webhook); + // if (hook) + // await this.updateWebhook('messages', hook); + // } + + // if (permissions) + // await this.#client.mongodb.permissions.updateOne({ guildId: this.id }, permissions); + + + // return settings; + + // } + + /** + * Update a webhook entry in the database + * + * @param {string} feature Which feature webhook to update, e.g. messagelog + * @param {Webhook} hook The webhook object, omitting this will nullify the hook data + * @memberof ExtendedGuild + */ + async updateWebhook (feature: string, webhook?: Webhook | null) + { + if (!feature) + throw new Error('Missing feature name'); + + if (!webhook) + { + this.#logger.debug(`Removing webhook in ${this.name} (${this.id})`); + const hook = this.#webhooks.get(feature); + if (hook) + await hook.delete('Removing old webhook').catch((err) => + { + if (err.code !== 10015) + this.#logger.error(err.stack); + }); + this.#webhooks.delete(feature); + return this.#client.mongodb.webhooks.deleteOne({ feature, guild: this.id }); + } + + this.#webhooks.set(feature, webhook); + await this.#client.mongodb.webhooks.updateOne({ feature, guild: this.id }, { hookId: webhook.id, token: webhook.token }); + } + + /** + * Retrieves a cached webhook for a feature if it exists, gets it from the database if not cached + * + * @param {string} feature The name of the feature, ex. messageLogs + * @returns {Webhook} + * @memberof ExtendedGuild + */ + async getWebhook (feature: string): Promise + { + if (!feature) + return Promise.resolve(null); + if (this.#webhooks.has(feature)) + return Promise.resolve(this.#webhooks.get(feature) as Webhook); + + const result = await this.#client.mongodb.webhooks.findOne({ feature, guild: this.id }); + if (!result) + return null; + if (!this.me?.permissions.has('ManageWebhooks')) + throw new Error('Missing ManageWebhooks'); + + const hooks = await this.fetchWebhooks(); + let hook = hooks.get(result.hookId); + if (!hook) + return null; + if (!hook.token) + { // Happens when the webhook from imported settings is used, replace it. + const channel = await this.resolveChannel(hook.channelId); + if (!channel) + throw new Error('Missing channel?'); + await hook.delete('Old hook'); + hook = await channel.createWebhook({ name: 'Galactic Bot message logs' }); + } + // const hook = new WebhookClient(result.hookID, result.token, { + // disableMentions: 'everyone' + // }); + this.#webhooks.set(feature, hook); + return hook; + } + + fetchWebhooks () + { + return this.#guild.fetchWebhooks(); + } + + async fetchInvites () + { + const invites = await this.#guild.invites.fetch(); + this.#invites = invites; + return invites; + } + + get invites () + { + return this.#invites; + } + + get defaultConfig (): GuildSettings + { + return { ...JSON.parse(JSON.stringify(this.#client.defaultConfig('GUILD'))) }; + } + + get locale () + { + return this.#settings?.locale || 'en_gb'; + } + + format (index: string, parameters: FormatParams = {}, opts: FormatOpts = {}) + { + const { + code = false, + language = this.locale.language || 'en_gb' + } = opts; + return this.#client.localeLoader.format(language, index, parameters, code); + } + + async memberWrapper (user: UserResolveable) + { + const id = Util.hasId(user) ? user.id : user; + const member = user instanceof GuildMember ? user : await this.resolveMember(id); + if (!member) + // return Promise.reject(new Error('No member found')); + return null; + if (this.#memberWrappers.has(member.id)) + return this.#memberWrappers.get(member.id)!; + + const wrapper = new MemberWrapper(this.#client, member, this); + this.#memberWrappers.set(wrapper.id, wrapper); + return wrapper; + } + + resolveMembers (resolveables: MemberResolveable[], strict?: boolean) + { + return this.#client.resolver.resolveMembers(resolveables, strict, this); + } + + resolveMember (resolveable: MemberResolveable, strict?: boolean) + { + return this.#client.resolver.resolveMember(resolveable, strict, this); + } + + resolveRoles (resolveables: RoleResolvable[], strict?: boolean) + { + return this.#client.resolver.resolveRoles(resolveables, strict, this); + } + + resolveRole (resolveable: RoleResolvable, strict?: boolean) + { + return this.#client.resolver.resolveRole(resolveable, strict, this); + } + + // eslint-disable-next-line max-len + resolveChannels (resolveables: ChannelResolveable[], strict = false, filter?: (channel: Channel) => boolean) + { + return this.#client.resolver.resolveChannels(resolveables, strict, this, filter); + } + + resolveChannel (resolveable?: ChannelResolveable | null, strict = false, filter?: (channel: Channel) => boolean) + { + return this.#client.resolver.resolveChannel(resolveable, strict, this, filter); + } + + resolveUsers (resolveables: UserResolveable[], strict?: boolean) + { + return this.#client.resolver.resolveUsers(resolveables, strict); + } + + resolveUser (resolveable: UserResolveable, strict?: boolean) + { + return this.#client.resolver.resolveUser(resolveable, strict); + } + + // Logging + + #storageLog (log: string) + { + this.#logger.debug(log); + } + + #storageError (error: Error) + { + this.#logger.error(`Database error (guild:${this.id}) :\n${error.stack || error}`); + } + + #debugLog (log: string) + { + this.#logger.debug(`[${this.name}]: ${log}`); + } + + /* Wrapper Functions */ + + fetchAuditLogs (opts: GuildAuditLogsFetchOptions) + { + return this.guild.fetchAuditLogs(opts); + } + + fetch () + { + return this.guild.fetch(); + } + + iconURL (opts?: ImageURLOptions) + { + return this.guild.iconURL(opts); + } + + get callbacks () + { + return this.#callbacks; + } + + get guild () + { + return this.#guild; + } + + get data () + { + return this.#data; + } + + get prefix () + { + return this.#settings.textcommands.prefix || this.#client.prefix; + } + + get available () + { + return this.guild.available; + } + + get bans () + { + return this.guild.bans; + } + + get channels () + { + return this.guild.channels; + } + + get features () + { + return this.guild.features; + } + + get id () + { + return this.guild.id; + } + + get shardId () + { + return this.guild.shardId; + } + + get maximumMembers () + { + return this.guild.maximumMembers; + } + + get maximumPresences () + { + return this.guild.maximumPresences; + } + + get me () + { + return this.guild.members.me; + } + + get memberCount () + { + return this.guild.memberCount; + } + + get members () + { + return this.guild.members; + } + + get name () + { + return this.guild.name; + } + + get roles () + { + return this.guild.roles; + } + + get ownerId () + { + return this.guild.ownerId; + } + + // Boost tier + get premiumTier () + { + return this.guild.premiumTier; + } + + get premium () + { + return this.#data.premium ?? 0; + } + + get _settings () + { + return this.#settings; + } + + // Primarily used by the API + toJSON (): GuildJSON + { + const json = this.guild.toJSON() as { + channels: ChannelJSON[], + roles: RoleJSON[] + }; + // json.members = await Promise.all(json.members.map(async (id) => { + // const member = await this.guild.resolveMember(id); + // return { id: member.id, tag: member.user.tag }; + // })); + json.channels = this.guild.channels.cache.map((channel) => + { + return { + id: channel.id, + type: channel.type, + name: channel.name, + parent: channel.parentId + }; + }); + json.roles = this.guild.roles.cache.map((role) => + { + return { + id: role.id, + name: role.name, + position: role.position + }; + }); + return json; + } + +} + export default GuildWrapper; \ No newline at end of file diff --git a/src/client/interfaces/commands/Command.ts b/src/client/interfaces/commands/Command.ts index e2a6c69..56e149f 100644 --- a/src/client/interfaces/commands/Command.ts +++ b/src/client/interfaces/commands/Command.ts @@ -1,402 +1,402 @@ -import { LoggerClient } from '@navy.gif/logger'; -import { EmbedBuilder, Message, PermissionsString, Snowflake } from 'discord.js'; -import Component from '../Component.js'; -import CommandOption from '../CommandOption.js'; -import { Util } from '../../../utilities/index.js'; -import DiscordClient from '../../DiscordClient.js'; -import { InvokerWrapper } from '../../components/wrappers/index.js'; -import { CommandOptionParams, CommandOptionType, CommandOptions, CommandParams } from '../../../../@types/Client.js'; -import { ReplyOptions } from '../../../../@types/Wrappers.js'; - -type CommandUsageLimits = { - usages: number, - duration: number -} -type CommandThrottle = { - usages: number, - start: number, - timeout: NodeJS.Timeout -} - -// declare interface Command extends Component -// { -// get type(): 'command' -// } - -abstract class Command extends Component -{ - #logger: LoggerClient; - - #name: string; - #description: string; - #tags: string[]; - #aliases: string[]; - - #restricted: boolean; - #showUsage: boolean; - #guildOnly: boolean; - #archivable: boolean; - #slash: boolean; - - #clientPermissions: PermissionsString[]; - #memberPermissions: PermissionsString[]; - - #invokes: { - success: number, - successTime: number, - fail: number, - failTime: number - }; - - #options: CommandOption[]; - - #throttling?: CommandUsageLimits; - #throttles: Map; - - /** - * Creates an instance of Command. - * @param {DiscordClient} client - * @param {Object} [options={}] - * @memberof Command - */ - constructor (client: DiscordClient, options: CommandOptions) - { - if (!options) - throw Util.fatal(new Error('Missing command options')); - if (!options.name) - throw Util.fatal(new Error('Missing name')); - - super(client, { - id: options.name, - type: 'command', - disabled: options.disabled, - guarded: options.guarded, - moduleName: options.moduleName - }); - - this.#name = options.name; - this.#logger = client.createLogger(this); - if (!options.moduleName) - this.logger.warn(`Command ${this.#name} is missing module information.`); - - this.#description = options.description || ''; - this.#tags = options.tags || []; - this.#aliases = options.aliases || []; - - this.#restricted = Boolean(options?.restricted); - this.#showUsage = Boolean(options.showUsage); - this.#guildOnly = Boolean(options?.guildOnly); - - this.#archivable = typeof options.archivable === 'undefined' ? true : Boolean(options.archivable); - - this.#slash = Boolean(options.slash); - // Convers permissions to PascalCase from snake case bc for some reason d.js decided it was a good change - this.#clientPermissions = [ ...new Set([ 'SendMessages', ...options.clientPermissions || [] ]) ]; // .map(Util.pascalConverter); - this.#memberPermissions = options.memberPermissions || []; // .map(Util.pascalConverter); - - this.#invokes = { - success: 0, - successTime: 0, - fail: 0, - failTime: 0 - }; - - this.#options = []; - if (options.options) - this.#parseOptions(options.options); - - this.#options.sort((a, b) => - { - if (a.required) - return -1; - if (b.required) - return 1; - return 0; - }); - - this.#throttles = new Map(); - } - - get name () - { - return this.#name; - } - - get aliases () - { - return this.#aliases; - } - - get description () - { - return this.#description; - } - - get tags () - { - return this.#tags; - } - - get restricted () - { - return this.#restricted; - } - - get showUsage () - { - return this.#showUsage; - } - - get archivable () - { - return this.#archivable; - } - - get slash () - { - return this.#slash; - } - - get memberPermissions () - { - return this.#memberPermissions; - } - - get clientPermissions () - { - return this.#clientPermissions; - } - - get options () - { - return this.#options; - } - - get guildOnly () - { - return this.#guildOnly; - } - - protected get logger () - { - return this.#logger; - } - - get throttling () - { - return this.#throttling; - } - - get throttles () - { - return this.#throttles; - } - - get invokes () - { - return this.#invokes; - } - - - abstract execute(invoker: InvokerWrapper, options: CommandParams): - Promise; - // { - // throw new Error(`${this.resolveable} is missing an execute function.`); - // } - - success (when: number) - { - const now = Date.now(); - const execTime = now - when; - // Calculate new average - if (this.#invokes.successTime) - { - this.#invokes.successTime = (this.#invokes.successTime * this.#invokes.success + execTime) / ++this.#invokes.success; - } - else - { - this.#invokes.successTime = execTime; - this.#invokes.success++; - } - } - - error (when: number) - { - const now = Date.now(); - const execTime = now - when; - // Calculate new average - if (this.#invokes.failTime) - { - this.#invokes.failTime = (this.#invokes.failTime * this.#invokes.fail + execTime) / ++this.#invokes.fail; - } - else - { - this.#invokes.failTime = execTime; - this.#invokes.fail++; - - } - } - - async usageEmbed (invoker: InvokerWrapper, verbose = false) - { - const fields = []; - const { guild, subcommand, subcommandGroup } = invoker; - - let type = null; - const format = (index: string) => guild - ? guild.format(index) - : this.client.format(index); - if (guild) - ({ permissions: { type } } = await guild.settings()); - - if (this.#options.length) - { - if (verbose) - fields.push(...this.#options.map((opt) => opt.usage(guild))); - else if (subcommand) - { - const opt = this.subcommand(subcommand.name) as CommandOption; - fields.push(opt.usage(guild)); - } - else if (subcommandGroup) - { - const opt = this.subcommandGroup(subcommandGroup.name) as CommandOption; - fields.push(opt.usage(guild)); - } - } - - if (this.memberPermissions.length) - { - let required = []; - if (type === 'discord') - required = this.memberPermissions; - else if (type === 'grant') - required = [ this.resolveable ]; - else - required = [ this.resolveable, ...this.memberPermissions ]; - fields.push({ - name: `》 ${format('GENERAL_PERMISSIONS')}`, - value: `\`${required.join('`, `')}\`` - }); - } - - return new EmbedBuilder({ - author: { - name: `${this.name} [module:${this.module?.name}]` - }, - description: format(`COMMAND_${this.name.toUpperCase()}_HELP`), - fields - }); - } - - subcommandGroup (name: string) - { - if (!name) - return null; - name = name.toLowerCase(); - return this.subcommandGroups.find((group) => group.name === name) ?? null; - } - - get subcommandGroups () - { - return this.#options.filter((opt) => opt.type === CommandOptionType.SUB_COMMAND_GROUP); - } - - subcommand (name?: string) - { - if (!name) - return null; - name = name.toLowerCase(); - return this.subcommands.find((cmd) => cmd.name === name) ?? null; - } - - get subcommands () - { - return this.#subcommands(this.#options); - } - - /** - * @private - */ - #subcommands (opts: CommandOption[]): CommandOption[] - { - const subcommands = []; - for (const opt of opts) - { - if (opt.type === CommandOptionType.SUB_COMMAND) - subcommands.push(opt); - else if (opt.type === CommandOptionType.SUB_COMMAND_GROUP) - subcommands.push(...this.#subcommands(opt.options)); - } - return subcommands; - } - - // probably not a final name -- flattenedOptions maybe? - get actualOptions () - { - return this.#actualOptions(this.#options); - } - - /** - * @private - */ - #actualOptions (opts: CommandOption[]): CommandOption[] - { - const options: CommandOption[] = []; - for (const opt of opts) - { - if ([ CommandOptionType.SUB_COMMAND_GROUP, CommandOptionType.SUB_COMMAND ].includes(opt.type)) - options.push(...this.#actualOptions(opt.options)); - else - options.push(opt); - } - return options; - } - - #parseOptions (options: CommandOptionParams[]) - { - for (const opt of options) - { - if (opt instanceof CommandOption) - { - opt.client = this.client; - this.#options.push(opt); - continue; - } - - if (!(opt.name instanceof Array)) - { - this.#options.push(new CommandOption({ ...opt, client: this.client })); - continue; - } - - // Allows easy templating of subcommands that share arguments - const { name: names, description, type, ...opts } = opt; - for (const name of names) - { - const index = names.indexOf(name); - let desc = description, - _type = type; - if (description instanceof Array) - desc = description[index] || 'Missing description'; - if (type instanceof Array) - _type = type[index]; - if (!_type) - { - _type = CommandOptionType.STRING; - this.logger.warn(`Missing option type for ${this.resolveable}.${name}, defaulting to string`); - } - // throw new Error(`Missing type for option ${name} in command ${this.name}`); - this.#options.push(new CommandOption({ - ...opts, - name, - type: _type, - description: desc, - client: this.client - })); - } - } - } -} - +import { LoggerClient } from '@navy.gif/logger'; +import { EmbedBuilder, Message, PermissionsString, Snowflake } from 'discord.js'; +import Component from '../Component.js'; +import CommandOption from '../CommandOption.js'; +import { Util } from '../../../utilities/index.js'; +import DiscordClient from '../../DiscordClient.js'; +import { InvokerWrapper } from '../../components/wrappers/index.js'; +import { CommandOptionParams, CommandOptionType, CommandOptions, CommandParams } from '../../../../@types/Client.js'; +import { ReplyOptions } from '../../../../@types/Wrappers.js'; + +type CommandUsageLimits = { + usages: number, + duration: number +} +type CommandThrottle = { + usages: number, + start: number, + timeout: NodeJS.Timeout +} + +// declare interface Command extends Component +// { +// get type(): 'command' +// } + +abstract class Command extends Component +{ + #logger: LoggerClient; + + #name: string; + #description: string; + #tags: string[]; + #aliases: string[]; + + #restricted: boolean; + #showUsage: boolean; + #guildOnly: boolean; + #archivable: boolean; + #slash: boolean; + + #clientPermissions: PermissionsString[]; + #memberPermissions: PermissionsString[]; + + #invokes: { + success: number, + successTime: number, + fail: number, + failTime: number + }; + + #options: CommandOption[]; + + #throttling?: CommandUsageLimits; + #throttles: Map; + + /** + * Creates an instance of Command. + * @param {DiscordClient} client + * @param {Object} [options={}] + * @memberof Command + */ + constructor (client: DiscordClient, options: CommandOptions) + { + if (!options) + throw Util.fatal(new Error('Missing command options')); + if (!options.name) + throw Util.fatal(new Error('Missing name')); + + super(client, { + id: options.name, + type: 'command', + disabled: options.disabled, + guarded: options.guarded, + moduleName: options.moduleName + }); + + this.#name = options.name; + this.#logger = client.createLogger(this); + if (!options.moduleName) + this.logger.warn(`Command ${this.#name} is missing module information.`); + + this.#description = options.description || ''; + this.#tags = options.tags || []; + this.#aliases = options.aliases || []; + + this.#restricted = Boolean(options?.restricted); + this.#showUsage = Boolean(options.showUsage); + this.#guildOnly = Boolean(options?.guildOnly); + + this.#archivable = typeof options.archivable === 'undefined' ? true : Boolean(options.archivable); + + this.#slash = Boolean(options.slash); + // Convers permissions to PascalCase from snake case bc for some reason d.js decided it was a good change + this.#clientPermissions = [ ...new Set([ 'SendMessages', ...options.clientPermissions || [] ]) ]; // .map(Util.pascalConverter); + this.#memberPermissions = options.memberPermissions || []; // .map(Util.pascalConverter); + + this.#invokes = { + success: 0, + successTime: 0, + fail: 0, + failTime: 0 + }; + + this.#options = []; + if (options.options) + this.#parseOptions(options.options); + + this.#options.sort((a, b) => + { + if (a.required) + return -1; + if (b.required) + return 1; + return 0; + }); + + this.#throttles = new Map(); + } + + get name () + { + return this.#name; + } + + get aliases () + { + return this.#aliases; + } + + get description () + { + return this.#description; + } + + get tags () + { + return this.#tags; + } + + get restricted () + { + return this.#restricted; + } + + get showUsage () + { + return this.#showUsage; + } + + get archivable () + { + return this.#archivable; + } + + get slash () + { + return this.#slash; + } + + get memberPermissions () + { + return this.#memberPermissions; + } + + get clientPermissions () + { + return this.#clientPermissions; + } + + get options () + { + return this.#options; + } + + get guildOnly () + { + return this.#guildOnly; + } + + protected get logger () + { + return this.#logger; + } + + get throttling () + { + return this.#throttling; + } + + get throttles () + { + return this.#throttles; + } + + get invokes () + { + return this.#invokes; + } + + + abstract execute(invoker: InvokerWrapper, options: CommandParams): + Promise; + // { + // throw new Error(`${this.resolveable} is missing an execute function.`); + // } + + success (when: number) + { + const now = Date.now(); + const execTime = now - when; + // Calculate new average + if (this.#invokes.successTime) + { + this.#invokes.successTime = (this.#invokes.successTime * this.#invokes.success + execTime) / ++this.#invokes.success; + } + else + { + this.#invokes.successTime = execTime; + this.#invokes.success++; + } + } + + error (when: number) + { + const now = Date.now(); + const execTime = now - when; + // Calculate new average + if (this.#invokes.failTime) + { + this.#invokes.failTime = (this.#invokes.failTime * this.#invokes.fail + execTime) / ++this.#invokes.fail; + } + else + { + this.#invokes.failTime = execTime; + this.#invokes.fail++; + + } + } + + async usageEmbed (invoker: InvokerWrapper, verbose = false) + { + const fields = []; + const { guild, subcommand, subcommandGroup } = invoker; + + let type = null; + const format = (index: string) => guild + ? guild.format(index) + : this.client.format(index); + if (guild) + ({ permissions: { type } } = await guild.settings()); + + if (this.#options.length) + { + if (verbose) + fields.push(...this.#options.map((opt) => opt.usage(guild))); + else if (subcommand) + { + const opt = this.subcommand(subcommand.name) as CommandOption; + fields.push(opt.usage(guild)); + } + else if (subcommandGroup) + { + const opt = this.subcommandGroup(subcommandGroup.name) as CommandOption; + fields.push(opt.usage(guild)); + } + } + + if (this.memberPermissions.length) + { + let required = []; + if (type === 'discord') + required = this.memberPermissions; + else if (type === 'grant') + required = [ this.resolveable ]; + else + required = [ this.resolveable, ...this.memberPermissions ]; + fields.push({ + name: `》 ${format('GENERAL_PERMISSIONS')}`, + value: `\`${required.join('`, `')}\`` + }); + } + + return new EmbedBuilder({ + author: { + name: `${this.name} [module:${this.module?.name}]` + }, + description: format(`COMMAND_${this.name.toUpperCase()}_HELP`), + fields + }); + } + + subcommandGroup (name: string) + { + if (!name) + return null; + name = name.toLowerCase(); + return this.subcommandGroups.find((group) => group.name === name) ?? null; + } + + get subcommandGroups () + { + return this.#options.filter((opt) => opt.type === CommandOptionType.SUB_COMMAND_GROUP); + } + + subcommand (name?: string) + { + if (!name) + return null; + name = name.toLowerCase(); + return this.subcommands.find((cmd) => cmd.name === name) ?? null; + } + + get subcommands () + { + return this.#subcommands(this.#options); + } + + /** + * @private + */ + #subcommands (opts: CommandOption[]): CommandOption[] + { + const subcommands = []; + for (const opt of opts) + { + if (opt.type === CommandOptionType.SUB_COMMAND) + subcommands.push(opt); + else if (opt.type === CommandOptionType.SUB_COMMAND_GROUP) + subcommands.push(...this.#subcommands(opt.options)); + } + return subcommands; + } + + // probably not a final name -- flattenedOptions maybe? + get actualOptions () + { + return this.#actualOptions(this.#options); + } + + /** + * @private + */ + #actualOptions (opts: CommandOption[]): CommandOption[] + { + const options: CommandOption[] = []; + for (const opt of opts) + { + if ([ CommandOptionType.SUB_COMMAND_GROUP, CommandOptionType.SUB_COMMAND ].includes(opt.type)) + options.push(...this.#actualOptions(opt.options)); + else + options.push(opt); + } + return options; + } + + #parseOptions (options: CommandOptionParams[]) + { + for (const opt of options) + { + if (opt instanceof CommandOption) + { + opt.client = this.client; + this.#options.push(opt); + continue; + } + + if (!(opt.name instanceof Array)) + { + this.#options.push(new CommandOption({ ...opt, client: this.client })); + continue; + } + + // Allows easy templating of subcommands that share arguments + const { name: names, description, type, ...opts } = opt; + for (const name of names) + { + const index = names.indexOf(name); + let desc = description, + _type = type; + if (description instanceof Array) + desc = description[index] || 'Missing description'; + if (type instanceof Array) + _type = type[index]; + if (!_type) + { + _type = CommandOptionType.STRING; + this.logger.warn(`Missing option type for ${this.resolveable}.${name}, defaulting to string`); + } + // throw new Error(`Missing type for option ${name} in command ${this.name}`); + this.#options.push(new CommandOption({ + ...opts, + name, + type: _type, + description: desc, + client: this.client + })); + } + } + } +} + export default Command; \ No newline at end of file diff --git a/src/middleware/Controller.ts b/src/middleware/Controller.ts index 621def9..7528174 100644 --- a/src/middleware/Controller.ts +++ b/src/middleware/Controller.ts @@ -1,375 +1,375 @@ -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)); + } + + 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 }; + } +} + export default Controller; \ No newline at end of file diff --git a/src/middleware/rest/SlashCommandManager.ts b/src/middleware/rest/SlashCommandManager.ts index 15070af..d6add20 100644 --- a/src/middleware/rest/SlashCommandManager.ts +++ b/src/middleware/rest/SlashCommandManager.ts @@ -1,185 +1,185 @@ -import { REST } from '@discordjs/rest'; -import { Routes } from 'discord-api-types/v9'; -import hash from 'object-hash'; -import fs from 'node:fs'; -import path from 'node:path'; -import { inspect } from 'node:util'; - -import BaseClient from '../Controller.js'; -import { Command, CommandOption, CommandsDef } from '../../../@types/Shared.js'; - -type CommandHashTable = { - global: string | null, - guilds: { - [key: string]: string - } -} - -type ClusterFuck = { - rawError: { - errors: { - [key: string]: { - options: { - [key: string]: CommandOption - } - } - } - } -} & Error - -class SlashCommandManager -{ - #client: BaseClient; - #rest: REST; - #hashPath: string; - #hash: CommandHashTable; - #developerGuilds: string[]; - - constructor (client: BaseClient) - { - this.#client = client; - this.#rest = new REST({ version: '9' }) - .setToken(process.env.DISCORD_TOKEN as string); - - this.#developerGuilds = client.developerGuilds ?? []; - this.#hashPath = path.join(process.cwd(), '/commandHash.json'); - this.#hash = fs.existsSync(this.#hashPath) - ? JSON.parse(fs.readFileSync(this.#hashPath, { encoding: 'utf-8' })) - : { global: null, guilds: {} }; - } - - async _handleMessage ({ commands, guilds, clientId, type }: CommandsDef) - { - fs.writeFileSync('./commands.json', JSON.stringify(commands, null, 4), { encoding: 'utf-8' }); - if (type === 'global') - await this.#global(commands, clientId); - else if (type === 'guild') - await this.#guild(commands, { guilds, clientId }); - } - - async #guild (commands: Command[], { guilds = [], clientId }: { guilds: string[], clientId: string }) - { - if (!guilds.length) - guilds = this.#developerGuilds; - - const cmdHash = hash(commands); - for (const guild of [ ...guilds ]) - { - // Skip guild if unavailable - const res = await this.#rest.get(Routes.guild(guild)).catch(() => - { - return null; - }); - if (!res) - { - guilds.splice(guilds.indexOf(guild), 1); - continue; - } - // Skip guild update if hash is already up to date - if (this.#hash.guilds[guild] === cmdHash) - guilds.splice(guilds.indexOf(guild), 1); - // else update hash - else - this.#hash.guilds[guild] = cmdHash; - } - - this.#client.logger.write('info', `Commands hash: ${cmdHash}, ${guilds.length} out of date`); - if (!guilds.length) - return; - const promises = []; - // fs.writeFileSync(path.join(process.cwd(), 'commands.json'), JSON.stringify(commands)); - for (const guild of guilds) - { - promises.push(this.#rest.put( - Routes.applicationGuildCommands(clientId, guild), - { body: commands } - )); - } - - let result = null; - try - { - result = await Promise.all(promises); - } - catch (err) - { - this.#parseError(err as ClusterFuck, commands); - } - - if (!result) - return null; - - this.#saveHash(); - this.#client.logger.debug(`Refreshed guild slash commands for guild${guilds.length === 1 ? '' : 's'}: ${guilds.join(' ')}`); - return result; - } - - #parseError (error: ClusterFuck, commands: Command[]) - { - // console.log(inspect(error, { depth: 25 })); - this.#client.logger.error(`An issue has occured while updating guild commands. Guild command refresh aborted.\n${error.stack || error}`); - // Figures out which command and option ran into issues - const invalid = error.rawError.errors; - const keys = Object.keys(invalid); - let str = ''; - for (const key of keys) - { - const i = parseInt(key); - const command = commands[i]; - if (!command) - { - this.#client.logger.warn(`Unable to select command for index ${i} (${key})`); - continue; - } - str += `${command.name}: `; - const options = Object.keys(invalid[key].options); - for (const optKey of options) - { - if (!command.options[optKey]) - { - this.#client.logger.warn(`Missing properties for ${command.name}: ${optKey}\nOptions: ${inspect(command.options)}`); - continue; - } - str += `${command.options[optKey].name}\t`; - } - str += '\n\n'; - } - this.#client.logger.error(`Failed commands:\n${str}`); - } - - async #global (commands: Command[], clientId: string) - { - const cmdHash = hash(commands); - const upToDate = this.#hash.global === cmdHash; - this.#client.logger.info(`Commands hash: ${cmdHash}, ${upToDate ? 'not ' : ''}updating`); - if (upToDate) - return; - - this.#hash.global = cmdHash; - - try - { - this.#client.logger.debug('Starting global refresh for slash commands.'); - await this.#rest.put( - Routes.applicationCommands(clientId), - { body: commands } - ); - this.#client.logger.debug('Finished global refresh for slash commands.'); - } - catch (err) - { - const error = err as Error; - return this.#client.logger.error(`Failed to refresh slash commands globally.\n${error.stack || error}`); - } - - this.#saveHash(); - } - - #saveHash () - { - fs.writeFileSync(this.#hashPath, JSON.stringify(this.#hash), { encoding: 'utf-8' }); - } - -} - +import { REST } from '@discordjs/rest'; +import { Routes } from 'discord-api-types/v9'; +import hash from 'object-hash'; +import fs from 'node:fs'; +import path from 'node:path'; +import { inspect } from 'node:util'; + +import BaseClient from '../Controller.js'; +import { Command, CommandOption, CommandsDef } from '../../../@types/Shared.js'; + +type CommandHashTable = { + global: string | null, + guilds: { + [key: string]: string + } +} + +type ClusterFuck = { + rawError: { + errors: { + [key: string]: { + options: { + [key: string]: CommandOption + } + } + } + } +} & Error + +class SlashCommandManager +{ + #client: BaseClient; + #rest: REST; + #hashPath: string; + #hash: CommandHashTable; + #developerGuilds: string[]; + + constructor (client: BaseClient) + { + this.#client = client; + this.#rest = new REST({ version: '9' }) + .setToken(process.env.DISCORD_TOKEN as string); + + this.#developerGuilds = client.developerGuilds ?? []; + this.#hashPath = path.join(process.cwd(), '/commandHash.json'); + this.#hash = fs.existsSync(this.#hashPath) + ? JSON.parse(fs.readFileSync(this.#hashPath, { encoding: 'utf-8' })) + : { global: null, guilds: {} }; + } + + async _handleMessage ({ commands, guilds, clientId, type }: CommandsDef) + { + fs.writeFileSync('./commands.json', JSON.stringify(commands, null, 4), { encoding: 'utf-8' }); + if (type === 'global') + await this.#global(commands, clientId); + else if (type === 'guild') + await this.#guild(commands, { guilds, clientId }); + } + + async #guild (commands: Command[], { guilds = [], clientId }: { guilds: string[], clientId: string }) + { + if (!guilds.length) + guilds = this.#developerGuilds; + + const cmdHash = hash(commands); + for (const guild of [ ...guilds ]) + { + // Skip guild if unavailable + const res = await this.#rest.get(Routes.guild(guild)).catch(() => + { + return null; + }); + if (!res) + { + guilds.splice(guilds.indexOf(guild), 1); + continue; + } + // Skip guild update if hash is already up to date + if (this.#hash.guilds[guild] === cmdHash) + guilds.splice(guilds.indexOf(guild), 1); + // else update hash + else + this.#hash.guilds[guild] = cmdHash; + } + + this.#client.logger.write('info', `Commands hash: ${cmdHash}, ${guilds.length} out of date`); + if (!guilds.length) + return; + const promises = []; + // fs.writeFileSync(path.join(process.cwd(), 'commands.json'), JSON.stringify(commands)); + for (const guild of guilds) + { + promises.push(this.#rest.put( + Routes.applicationGuildCommands(clientId, guild), + { body: commands } + )); + } + + let result = null; + try + { + result = await Promise.all(promises); + } + catch (err) + { + this.#parseError(err as ClusterFuck, commands); + } + + if (!result) + return null; + + this.#saveHash(); + this.#client.logger.debug(`Refreshed guild slash commands for guild${guilds.length === 1 ? '' : 's'}: ${guilds.join(' ')}`); + return result; + } + + #parseError (error: ClusterFuck, commands: Command[]) + { + // console.log(inspect(error, { depth: 25 })); + this.#client.logger.error(`An issue has occured while updating guild commands. Guild command refresh aborted.\n${error.stack || error}`); + // Figures out which command and option ran into issues + const invalid = error.rawError.errors; + const keys = Object.keys(invalid); + let str = ''; + for (const key of keys) + { + const i = parseInt(key); + const command = commands[i]; + if (!command) + { + this.#client.logger.warn(`Unable to select command for index ${i} (${key})`); + continue; + } + str += `${command.name}: `; + const options = Object.keys(invalid[key].options); + for (const optKey of options) + { + if (!command.options[optKey]) + { + this.#client.logger.warn(`Missing properties for ${command.name}: ${optKey}\nOptions: ${inspect(command.options)}`); + continue; + } + str += `${command.options[optKey].name}\t`; + } + str += '\n\n'; + } + this.#client.logger.error(`Failed commands:\n${str}`); + } + + async #global (commands: Command[], clientId: string) + { + const cmdHash = hash(commands); + const upToDate = this.#hash.global === cmdHash; + this.#client.logger.info(`Commands hash: ${cmdHash}, ${upToDate ? 'not ' : ''}updating`); + if (upToDate) + return; + + this.#hash.global = cmdHash; + + try + { + this.#client.logger.debug('Starting global refresh for slash commands.'); + await this.#rest.put( + Routes.applicationCommands(clientId), + { body: commands } + ); + this.#client.logger.debug('Finished global refresh for slash commands.'); + } + catch (err) + { + const error = err as Error; + return this.#client.logger.error(`Failed to refresh slash commands globally.\n${error.stack || error}`); + } + + this.#saveHash(); + } + + #saveHash () + { + fs.writeFileSync(this.#hashPath, JSON.stringify(this.#hash), { encoding: 'utf-8' }); + } + +} + export default SlashCommandManager; \ No newline at end of file