diff --git a/src/client/components/EventHooker.ts b/src/client/components/EventHooker.ts index b8c69d2..4b7794c 100644 --- a/src/client/components/EventHooker.ts +++ b/src/client/components/EventHooker.ts @@ -76,7 +76,7 @@ class EventHooker const eventArgs = []; for (const arg of args) { - if (arg && typeof arg === 'object' && 'guild' in arg) + if (arg && typeof arg === 'object' && 'guild' in arg && arg.guild) // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore arg.guildWrapper = this.#target.getGuildWrapper(arg.guild!.id); diff --git a/src/client/components/Intercom.ts b/src/client/components/Intercom.ts index cb6d7a7..299226e 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/ModerationManager.ts b/src/client/components/ModerationManager.ts index 9c3cdea..ac625c3 100644 --- a/src/client/components/ModerationManager.ts +++ b/src/client/components/ModerationManager.ts @@ -626,7 +626,7 @@ class ModerationManager implements Initialisable if (updateCase) await this.#client.storage.mongodb.infractions.updateOne( { id: infraction.id }, - { _callbacked: true } + { $set: { _callbacked: true } } ).catch((e) => { this.#logger.error(`Error during update of infraction:\n${e.stack || e}`); diff --git a/src/client/components/commands/administration/Import.ts b/src/client/components/commands/administration/Import.ts index 7844693..82e3185 100644 --- a/src/client/components/commands/administration/Import.ts +++ b/src/client/components/commands/administration/Import.ts @@ -132,7 +132,7 @@ class ImportCommand extends SlashCommand for (const log of existingLogs) { log.case += highestOldId; - await this.client.mongodb.infractions.updateOne({ _id: log._id }, { case: log.case }); + await this.client.mongodb.infractions.updateOne({ _id: log._id }, { $set: { case: log.case } }); } await this.client.mongodb.infractions.insertMany(imported); if (!guild.data.caseId) @@ -193,12 +193,12 @@ class ImportCommand extends SlashCommand else if (version === '3') { delete webhook.feature; - await this.client.storageManager.mongodb.webhooks.updateOne({ feature: 'messages', guild: guild.id }, webhook); + await this.client.storageManager.mongodb.webhooks.updateOne({ feature: 'messages', guild: guild.id }, { $set: webhook }); } } if (permissions) - await this.client.storageManager.mongodb.permissions.updateOne({ guildId: guild.id }, permissions); + await this.client.storageManager.mongodb.permissions.updateOne({ guildId: guild.id }, { $set: permissions }); const { premium } = imported.settings; delete imported.settings.premium; diff --git a/src/client/components/observers/UtilityHook.ts b/src/client/components/observers/UtilityHook.ts index 67fa862..35131dd 100644 --- a/src/client/components/observers/UtilityHook.ts +++ b/src/client/components/observers/UtilityHook.ts @@ -48,9 +48,9 @@ class UtilityHook extends Observer await this.client.storageManager.mongodb.roleCache.updateOne({ member: member.id, guild: guild.id - }, { + }, { $set: { roles: storeThese, timestamp: Date.now() - }); + } }); } async automute (member: ExtendedGuildMember) diff --git a/src/client/components/settings/administration/Protection.ts b/src/client/components/settings/administration/Protection.ts index 441ee0d..1c87b95 100644 --- a/src/client/components/settings/administration/Protection.ts +++ b/src/client/components/settings/administration/Protection.ts @@ -1,4 +1,4 @@ -import { CommandParams, FormatParams } from '../../../../../@types/Client.js'; +import { CommandOptionType, CommandParams, FormatParams } from '../../../../../@types/Client.js'; import { ProtectionSettings, ProtectionType } from '../../../../../@types/Settings.js'; import Util from '../../../../utilities/Util.js'; import DiscordClient from '../../../DiscordClient.js'; @@ -49,11 +49,11 @@ class ProtectionSetting extends Setting }, { name: 'enabled', - description: 'Whether setting is active or not' + description: 'Whether setting is active or not', + type: CommandOptionType.BOOLEAN } ] }); - } async execute (invoker: InvokerWrapper, opts: CommandParams, setting: ProtectionSettings) diff --git a/src/client/components/wrappers/GuildWrapper.ts b/src/client/components/wrappers/GuildWrapper.ts index 39ec765..f89d6c0 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 }, { $set: 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 }, { $set: 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 }, { $set: { _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 }, { $set: 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 }, { $set: { 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/components/wrappers/UserWrapper.ts b/src/client/components/wrappers/UserWrapper.ts index 47e39f9..1f8b5d2 100644 --- a/src/client/components/wrappers/UserWrapper.ts +++ b/src/client/components/wrappers/UserWrapper.ts @@ -152,7 +152,7 @@ class UserWrapper { await this.#client.mongodb.users.updateOne( { guildId: this.id }, - data + { $set: data } ); this.#settings = { ...this.#settings, diff --git a/src/client/infractions/Unlockdown.ts b/src/client/infractions/Unlockdown.ts index 61ac774..6ec4fb8 100644 --- a/src/client/infractions/Unlockdown.ts +++ b/src/client/infractions/Unlockdown.ts @@ -112,7 +112,7 @@ class UnlockdownInfraction extends Infraction if (callback) await this.client.moderation.removeCallback(callback.infraction, true); else - await this.client.mongodb.infractions.updateOne({ id: latest.id }, { _callbacked: true }); + await this.client.mongodb.infractions.updateOne({ id: latest.id }, { $set: { _callbacked: true } }); } await this.handle(); diff --git a/src/client/interfaces/Infraction.ts b/src/client/interfaces/Infraction.ts index 1686ee5..01a873b 100644 --- a/src/client/interfaces/Infraction.ts +++ b/src/client/interfaces/Infraction.ts @@ -166,7 +166,6 @@ class Infraction async handle () { - // Infraction was fetched from database, i.e. was already executed previously if (this.#fetched) throw new Error('Cannot handle a fetched Infraction'); @@ -186,7 +185,7 @@ class Infraction }); /* Logging */ - if (moderation.channel) + if (moderation.channel) { if (moderation.infractions.includes(this.#type!)) { @@ -197,7 +196,6 @@ class Infraction this.#dmLogMessage = await this.#moderationLog.send({ embeds: [ await this.#embed() ] }).catch(null); this.#modLogMessageId = this.#dmLogMessage?.id || null; } - } else { @@ -205,7 +203,7 @@ class Infraction } } - if (dminfraction.enabled && !this.#silent) + if (dminfraction.enabled && !this.#silent) { if (this.#targetType === 'USER' && dminfraction.infractions.includes(this.#type!)) { @@ -218,30 +216,25 @@ class Infraction .replace(/\{infraction\}/ugim, this.dictionary.past) .replace(/\{from\|on\}/ugim, Constants.RemovedInfractions.includes(this.#type!) ? 'from' : 'on'); // add more if you want i should probably add a better system for this... - - if (Util.isSendable(this.#target)) + if (Util.isSendable(this.#target)) { const logMessage = await this.#target.send({ content: message, embeds: [ await this.#embed(true) ] - }).catch(null); - this.#dmLogMessageId = logMessage.id; + }).catch(() => null); + this.#dmLogMessageId = logMessage?.id ?? null; } } } - if (this.#duration) - { + if (this.#duration) await this.#client.moderation.handleCallbacks([ this.json ]); - } /* LMAOOOO PLEASE DONT JUDGE ME */ if (this.#data.roles) delete this.#data.roles; - return this.save(); - } execute (): Promise @@ -254,7 +247,7 @@ class Infraction const filter: {id: string, _id?: ObjectId} = { id: this.id }; if (this.#mongoId) filter._id = this.#mongoId; - return this.#client.mongodb.infractions.updateOne(filter, this.json) + return this.#client.mongodb.infractions.updateOne(filter, { $set: this.json }) .catch((error: Error) => { this.#logger.error(`There was an issue saving infraction data to the database.\n${error.stack || error}\nInfraction data:\n${inspect(this.json)}`); @@ -321,7 +314,6 @@ class Infraction // Function implemented in subclasses for additional case data if (this.description && this.description instanceof Function) description += this.description(dm); - if (this.#resolved) { @@ -345,14 +337,12 @@ class Infraction } embed.description = description; - return embed; - } description (_dm: boolean): string { - throw new Error('Description is to be implemented by a subclass'); + return ''; } protected get client () diff --git a/src/client/interfaces/commands/Command.ts b/src/client/interfaces/commands/Command.ts index 56e149f..e2a6c69 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/utilities/Util.ts b/src/utilities/Util.ts index 32e7ee2..e360b0a 100644 --- a/src/utilities/Util.ts +++ b/src/utilities/Util.ts @@ -72,7 +72,7 @@ class Util static has (o: unknown, k: string) { - return Object.prototype.hasOwnProperty.call(o, k); + return Object.prototype.hasOwnProperty.call(o, k); } static hasId (obj: unknown): obj is DiscordStruct @@ -124,7 +124,7 @@ class Util // eslint-disable-next-line @typescript-eslint/no-explicit-any static isSendable (obj: any): obj is { send: () => Promise} { - return Util.has(obj, 'send') && typeof obj.send === 'function'; + return typeof obj.send === 'function'; } // static hasProperty (obj: any, name: string): obj is { [key in typeof name]: T }