diff --git a/@types/CallbackManager.d.ts b/@types/CallbackManager.d.ts new file mode 100644 index 0000000..3b8a27a --- /dev/null +++ b/@types/CallbackManager.d.ts @@ -0,0 +1,12 @@ +export type CallbackCreateInfo = { + payload: T, + expiresAt: number, + id?: string, + guild?: string +}; + +export type CallbackInfo = { + created: number, + client: string, + _id: string +} & CallbackCreateInfo \ No newline at end of file diff --git a/@types/Client.ts b/@types/Client.ts index 7652380..f7934ad 100644 --- a/@types/Client.ts +++ b/@types/Client.ts @@ -395,7 +395,7 @@ export type BaseInfractionData = { expiration?: number, data?: AdditionalInfractionData, hyperlink?: string | null, - _callbacked?: boolean, + // _callbacked?: boolean, fetched?: boolean } diff --git a/@types/Guild.d.ts b/@types/Guild.d.ts index 26d8d41..e815b43 100644 --- a/@types/Guild.d.ts +++ b/@types/Guild.d.ts @@ -149,7 +149,7 @@ export type ReminderData = { user: string, channel: string, reminder: string, - time: number + time: number // In seconds } export type ChannelJSON = { diff --git a/package.json b/package.json index a0a9bd9..1e44718 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@navy.gif/logger": "^2.5.4", "@navy.gif/timestring": "^6.0.6", "@types/node": "^18.15.11", + "bufferutil": "^4.0.8", "chalk": "^5.3.0", "common-tags": "^1.8.2", "discord.js": "^14.14.1", @@ -42,7 +43,9 @@ "node-fetch": "^3.3.1", "object-hash": "^3.0.0", "similarity": "^1.2.1", - "typescript": "^5.3.2" + "typescript": "^5.3.2", + "utf-8-validate": "^6.0.3", + "zlib-sync": "^0.1.9" }, "devDependencies": { "@types/common-tags": "^1.8.1", diff --git a/src/client/DiscordClient.ts b/src/client/DiscordClient.ts index 28897a6..57df97e 100644 --- a/src/client/DiscordClient.ts +++ b/src/client/DiscordClient.ts @@ -27,13 +27,16 @@ import { Resolver, ModerationManager, RateLimiter, + CallbackManager, + PollManager, } from './components/index.js'; import { Observer, Command, Setting, - Inhibitor + Inhibitor, + ReminderManager } from './interfaces/index.js'; import { @@ -41,13 +44,35 @@ import { 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 { + 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 { + ClientEvents +} from '../../@types/Events.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'; @@ -83,11 +108,15 @@ class DiscordClient extends Client #intercom: Intercom; #dispatcher: Dispatcher; #localeLoader: LocaleLoader; - #storageManager: StorageManager; #registry: Registry; #resolver: Resolver; #rateLimiter: RateLimiter; + #moderationManager: ModerationManager; + #callbackManager: CallbackManager; + #reminderManager: ReminderManager; + #pollManager: PollManager; + #storageManager: StorageManager; // #wrapperClasses: {[key: string]: }; @@ -143,17 +172,21 @@ class DiscordClient extends Client this.#userWrappers = new Collection(); this.#invites = new Collection(); + this.#callbackManager = new CallbackManager(this); + this.#reminderManager = new ReminderManager(this); + this.#moderationManager = new ModerationManager(this); + this.#storageManager = new StorageManager(this, options.storage); + this.#pollManager = new PollManager(this); + 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 @@ -230,6 +263,7 @@ class DiscordClient extends Client // Needs to load in after connecting to discord await this.#moderationManager.initialise(); + await this.#callbackManager.initialise(); if (this.shardId === 0) { @@ -348,21 +382,18 @@ class DiscordClient extends Client { if (!this.shard || !this.user) throw new Error('Missing shard or user'); + this.logger.info(`Setting status, with idx ${this.#activity}`); const activities: (() => Promise)[] = [ 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); + const guildCount = result?.reduce((p, v) => p + v, 0) ?? this.guilds.cache.size; this.user?.setActivity(`${guildCount} servers`, { type: ActivityType.Watching }); }, 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); + const userCount = result?.reduce((p, v) => p + v, 0) ?? this.users.cache.size; this.user?.setActivity(`${userCount} users`, { type: ActivityType.Listening }); }, async () => @@ -476,7 +507,6 @@ class DiscordClient extends Client { const wrapper = new GuildWrapper(this, guild); this.#guildWrappers.set(guild.id, wrapper); - wrapper.loadCallbacks(); }); this.logger.info('Created guild wrappers'); } @@ -618,6 +648,21 @@ class DiscordClient extends Client return this.#moderationManager; } + get callbacks () + { + return this.#callbackManager; + } + + get reminders () + { + return this.#reminderManager; + } + + get polls () + { + return this.#pollManager; + } + get developers () { return this.#options.developers ?? []; @@ -627,6 +672,7 @@ class DiscordClient extends Client { return this.#options.developmentMode; } + get localeLoader () { return this.#localeLoader; diff --git a/src/client/components/commands/moderation/Modtimers.ts b/src/client/components/commands/moderation/Modtimers.ts index c8f0223..76e5c54 100644 --- a/src/client/components/commands/moderation/Modtimers.ts +++ b/src/client/components/commands/moderation/Modtimers.ts @@ -5,7 +5,6 @@ import { GuildBasedChannel, User } from 'discord.js'; class ModtimersCommand extends SlashCommand { - constructor (client: DiscordClient) { super(client, { @@ -20,15 +19,14 @@ class ModtimersCommand extends SlashCommand async execute (invoker: InvokerWrapper) { - const guild = invoker.guild!; const { moderation } = this.client; - const callbacks = moderation.callbacks.filter((cb) => cb.infraction.guild === guild.id); - if (!callbacks.size) + const callbacks = await moderation.findActiveInfractions({ guild: guild.id }); + if (!callbacks.length) return { emoji: 'failure', index: 'COMMAND_MODTIMERS_NONE' }; const fields = []; - for (const { infraction } of callbacks.values()) + for (const { payload: infraction } of callbacks) { const target = (infraction.targetType === 'USER' ? await guild.resolveUser(infraction.target) : await guild.resolveChannel(infraction.target))!; const moderator = (await guild.resolveUser(infraction.executor))!; @@ -52,7 +50,7 @@ class ModtimersCommand extends SlashCommand title: guild.format('COMMAND_MODTIMERS_TITLE'), fields, footer: { - text: `• ${callbacks.size} infraction${callbacks.size > 1 ? 's' : ''}` + text: `• ${callbacks.length} infraction${callbacks.length > 1 ? 's' : ''}` } }; diff --git a/src/client/components/commands/utility/Poll.ts b/src/client/components/commands/utility/Poll.ts index ef35679..37d8553 100644 --- a/src/client/components/commands/utility/Poll.ts +++ b/src/client/components/commands/utility/Poll.ts @@ -5,7 +5,7 @@ import { CommandOptionType, CommandParams } from '../../../../../@types/Client.j import Util from '../../../../utilities/Util.js'; import { EmbedDefaultColor, PollReactions } from '../../../../constants/Constants.js'; import { GuildTextBasedChannel, TextChannel } from 'discord.js'; -import { CallbackData, PollData } from '../../../../../@types/Guild.js'; +import { PollData } from '../../../../../@types/Guild.js'; class PollCommand extends SlashCommand { @@ -62,37 +62,19 @@ class PollCommand extends SlashCommand if (subcommand!.name === 'create') { - // await invoker.deferReply(); - const questions = []; - const _channel = (channel?.asChannel || invoker.channel) as GuildTextBasedChannel; - if (!_channel?.isTextBased()) + const targetChannel = (channel?.asChannel || invoker.channel) as GuildTextBasedChannel; + if (!targetChannel?.isTextBased()) throw new CommandError(invoker, { index: 'ERR_INVALID_CHANNEL_TYPE' }); - const botMissing = _channel.permissionsFor(this.client.user!)?.missing([ 'SendMessages', 'EmbedLinks' ]); - const userMissing = _channel.permissionsFor(member).missing([ 'SendMessages' ]); + const botMissing = targetChannel.permissionsFor(this.client.user!)?.missing([ 'SendMessages', 'EmbedLinks' ]); + const userMissing = targetChannel.permissionsFor(member).missing([ 'SendMessages' ]); if (!botMissing || botMissing.length) - return invoker.editReply({ index: 'COMMAND_POLL_BOT_PERMS', params: { missing: botMissing?.join(', ') ?? 'ALL', channel: _channel.id } }); + return invoker.editReply({ index: 'COMMAND_POLL_BOT_PERMS', params: { missing: botMissing?.join(', ') ?? 'ALL', channel: targetChannel.id } }); if (userMissing.length) - return invoker.editReply({ index: 'COMMAND_POLL_USER_PERMS', params: { missing: userMissing.join(', '), channel: _channel.id } }); + return invoker.editReply({ index: 'COMMAND_POLL_USER_PERMS', params: { missing: userMissing.join(', '), channel: targetChannel.id } }); - for (let i = 0; i < choices!.asNumber; i++) - { - const response = await invoker.promptMessage({ - content: guild.format(`COMMAND_POLL_QUESTION${choices?.asNumber === 1 ? '' : 'S'}`, { number: i + 1 }) + '\n' + guild.format('COMMAND_POLL_ADDENDUM'), - time: 90, - editReply: invoker.replied - }); - if (!response || !response.content) - return invoker.editReply({ index: 'COMMAND_POLL_TIMEOUT' }); - if (_channel!.permissionsFor(this.client.user!)?.has('ManageMessages')) - await response.delete().catch(() => null); - const { content } = response; - if (content.toLowerCase() === 'stop') - break; - if (content.toLowerCase() === 'cancel') - return invoker.editReply({ index: 'GENERAL_CANCELLED' }); - const question = await guild.filterText(member, content); - questions.push({ index: i, name: `${i + 1}.`, value: question }); - } + const questions = await this.#queryQuestions(invoker, choices?.asNumber ?? 1, targetChannel); + if (!questions) + return; await invoker.editReply({ index: 'COMMAND_POLL_STARTING' }); @@ -111,53 +93,79 @@ class PollCommand extends SlashCommand if (questions.length === 1) { questions[0].name = guild.format('COMMAND_POLL_FIELD_QUESTION'); - pollMsg = await _channel.send({ embeds: [ embed ] }); + pollMsg = await targetChannel.send({ embeds: [ embed ] }); await pollMsg.react('👍'); await pollMsg.react('👎'); } else { - pollMsg = await _channel.send({ embeds: [ embed ] }); + pollMsg = await targetChannel.send({ embeds: [ embed ] }); for (const question of questions) await pollMsg.react(PollReactions.Multi[question.index + 1]); } const poll: PollData = { questions: questions.map(q => q.value), - duration: duration?.asNumber ?? 0, + duration: (duration?.asNumber ?? 0), multiChoice: multichoice?.asBool && questions.length > 1 || false, user: author.id, - channel: _channel.id, + channel: targetChannel.id, startedIn: invoker.channel!.id, message: pollMsg.id }; - await guild.createPoll(poll); - await invoker.editReply({ emoji: 'success', index: 'COMMAND_POLL_START', params: { channel: _channel.id } }); + if (duration) + await this.client.polls.create(poll, guild.id); + await invoker.editReply({ emoji: 'success', index: 'COMMAND_POLL_START', params: { channel: targetChannel.id } }); } else if (subcommand!.name === 'delete') { - const poll = guild.callbacks.find((cb) => cb.data.type === 'poll' && (cb.data as PollData & CallbackData).message === message!.asString)?.data as (PollData & CallbackData) | undefined; + const poll = await this.client.polls.delete(message!.asString); if (!poll) return { index: 'COMMAND_POLL_404', emoji: 'failure' }; - await guild.removeCallback(poll.id); - const pollChannel = await guild.resolveChannel(poll.channel); + const pollChannel = await guild.resolveChannel(poll.payload.channel); if (!pollChannel) return { index: 'COMMAND_POLL_MISSING_CHANNEL', emoji: 'failure' }; - const msg = await pollChannel.messages.fetch(poll.message).catch(() => null); + const msg = await pollChannel.messages.fetch(poll.payload.message).catch(() => null); if (msg) await msg.delete(); return { index: 'COMMAND_POLL_DELETED', emoji: 'success' }; } else if (subcommand!.name === 'end') { - const poll = guild.callbacks.find((cb) => cb.data.type === 'poll' && (cb.data as PollData & CallbackData).message === message!.asString); + const poll = await this.client.polls.find(message!.asString); if (!poll) return { index: 'COMMAND_POLL_404', emoji: 'failure' }; - await guild._poll(poll.data as PollData & CallbackData); + await this.client.polls.end(poll.payload); + // await guild._poll(poll.data as PollData & CallbackData); return { index: 'COMMAND_POLL_ENDED', emoji: 'success' }; } } + async #queryQuestions (invoker: InvokerWrapper, choices: number, targetchannel: GuildTextBasedChannel) + { + const { guild, member } = invoker; + const questions = []; + for (let i = 0; i < choices; i++) + { + const response = await invoker.promptMessage({ + content: guild.format(`COMMAND_POLL_QUESTION${choices === 1 ? '' : 'S'}`, { number: i + 1 }) + '\n' + guild.format('COMMAND_POLL_ADDENDUM'), + time: 90, + editReply: invoker.replied + }); + if (!response || !response.content) + return void invoker.editReply({ index: 'COMMAND_POLL_TIMEOUT' }); + if (targetchannel.permissionsFor(this.client.user!)?.has('ManageMessages')) + await response.delete().catch(() => null); + const { content } = response; + if (content.toLowerCase() === 'stop') + break; + if (content.toLowerCase() === 'cancel') + return void invoker.editReply({ index: 'GENERAL_CANCELLED' }); + const question = await guild.filterText(member, content); + questions.push({ index: i, name: `${i + 1}.`, value: question }); + } + return questions; + } } export default PollCommand; \ No newline at end of file diff --git a/src/client/components/commands/utility/Remind.ts b/src/client/components/commands/utility/Remind.ts index c1d3f09..52c29c5 100644 --- a/src/client/components/commands/utility/Remind.ts +++ b/src/client/components/commands/utility/Remind.ts @@ -4,8 +4,9 @@ import InvokerWrapper from '../../wrappers/InvokerWrapper.js'; import { CommandOptionType, CommandParams } from '../../../../../@types/Client.js'; import Util from '../../../../utilities/Util.js'; import { APIEmbed } from 'discord.js'; -import { CallbackData, ReminderData } from '../../../../../@types/Guild.js'; +import { ReminderData } from '../../../../../@types/Guild.js'; import GuildWrapper from '../../wrappers/GuildWrapper.js'; +import { CallbackInfo } from '../../../../../@types/CallbackManager.js'; class RemindCommand extends SlashCommand { @@ -37,11 +38,11 @@ class RemindCommand extends SlashCommand type: CommandOptionType.SUB_COMMAND, }], // showUsage: true - guildOnly: true + // guildOnly: true }); } - async execute (invoker: InvokerWrapper, { reminder, in: time }: CommandParams) + async execute (invoker: InvokerWrapper, { reminder, in: time }: CommandParams) { const { author, channel, guild, member } = invoker; const subcommand = invoker.subcommand!.name; @@ -54,17 +55,20 @@ class RemindCommand extends SlashCommand return; if (!time?.asNumber || time!.asNumber < 30) return { index: 'COMMAND_REMIND_INVALID_TIME' }; - const text = await guild.filterText(member, reminder.asString); - await guild.createReminder({ time: time.asNumber, reminder: text, user: author.id, channel: channel.id }); + const text = guild ? await guild.filterText(member!, reminder.asString) : reminder.asString; + await this.client.reminders.create({ + time: time.asNumber, + reminder: text, + user: author.id, + channel: channel.id + }, guild?.guild); return { index: 'COMMAND_REMIND_CONFIRM', params: { reminder: text, time: Util.humanise(time.asNumber) } }; } else if (subcommand === 'delete') { - const reminders = guild.callbacks - .filter((cb) => (cb.data as ReminderData & CallbackData).user === author.id) - .map((val) => val.data as ReminderData & CallbackData); + const reminders = await this.client.reminders.find(author.id); const embed = this._remindersEmbed(reminders, guild); - const msg = await invoker.promptMessage(guild.format('COMMAND_REMIND_SELECT'), { embed }); + const msg = await invoker.promptMessage((guild ? guild : this.client).format('COMMAND_REMIND_SELECT'), { embed }); if (msg) await msg.delete(); @@ -79,24 +83,22 @@ class RemindCommand extends SlashCommand return invoker.editReply({ index: 'COMMAND_REMIND_DELETE_INDEX_OUTOFBOUNDS', embeds: [] }); const _reminder = reminders[index - 1]; - await guild.removeCallback(_reminder.id); + await this.client.reminders.remove(_reminder._id); return invoker.editReply({ index: 'COMMAND_REMIND_DELETED', emoji: 'success', embeds: [] }); } else if (subcommand === 'list') { - const reminders = guild.callbacks - .filter((cb) => (cb.data as ReminderData & CallbackData).user === author.id) - .map((val) => val.data as CallbackData & ReminderData); + const reminders = await this.client.reminders.find(author.id); if (!reminders.length) - return guild.format('COMMAND_REMIND_NONE'); + return (guild ? guild : this.client).format('COMMAND_REMIND_NONE'); return { embed: this._remindersEmbed(reminders, guild) }; } } - _remindersEmbed (reminders: (ReminderData & CallbackData)[], guild: GuildWrapper) + _remindersEmbed (reminders: CallbackInfo[], guild?: GuildWrapper | null) { const embed: APIEmbed = { - title: guild.format('COMMAND_REMINDERS_TITLE'), + title: (guild ? guild : this.client).format('COMMAND_REMINDERS_TITLE'), fields: [] }; let index = 0; @@ -104,11 +106,11 @@ class RemindCommand extends SlashCommand { embed.fields!.push({ name: `${++index}`, - value: guild.format('COMMAND_REMIND_ENTRY', { - reminder: data.reminder, - channel: data.channel, + value: (guild ? guild : this.client).format('COMMAND_REMIND_ENTRY', { + reminder: data.payload.reminder, + channel: data.payload.channel, created: Math.floor(data.created/1000), - time: Math.floor((data.created + data.time)/1000) + time: Math.floor((data.created + data.payload.time)/1000) }) }); } diff --git a/src/client/components/index.ts b/src/client/components/index.ts index 30f74ab..ac0e243 100644 --- a/src/client/components/index.ts +++ b/src/client/components/index.ts @@ -2,7 +2,9 @@ import Intercom from './Intercom.js'; import LocaleLoader from './LocaleLoader.js'; import EventHooker from './EventHooker.js'; import Dispatcher from './Dispatcher.js'; -import ModerationManager from './ModerationManager.js'; +import ModerationManager from './managers/ModerationManager.js'; +import CallbackManager from './managers/CallbackManager.js'; +import PollManager from './managers/PollManager.js'; import RateLimiter from './RateLimiter.js'; import Registry from './Registry.js'; import Resolver from './Resolver.js'; @@ -13,6 +15,8 @@ export { EventHooker, Dispatcher, ModerationManager, + CallbackManager, + PollManager, RateLimiter, Registry, Resolver, diff --git a/src/client/components/managers/CallbackManager.ts b/src/client/components/managers/CallbackManager.ts new file mode 100644 index 0000000..9485067 --- /dev/null +++ b/src/client/components/managers/CallbackManager.ts @@ -0,0 +1,251 @@ +import { + Collection +} from 'discord.js'; + +import { + LoggerClient +} from '@navy.gif/logger'; + +import { + CallbackCreateInfo, + CallbackInfo +} from '../../../../@types/CallbackManager.js'; + +import { + Filter +} from 'mongodb'; + +import { + CallbackClient, + Initialisable +} from '../../interfaces/index.js'; + +import DiscordClient from '../../DiscordClient.js'; +import Util from '../../../utilities/Util.js'; + +// type Timeout = { +// timeout: NodeJS.Timeout, +// data: CallbackInfo +// } + +const HOUR = 60 * 60 * 1000; +const DAY = 24 * HOUR; + +/** + * Handles callback storage and calling, a persistent setTimeout wrapper + * @date 3/23/2024 - 10:11:38 PM + * + * @class CallbackManager + * @typedef {CallbackManager} + * @implements {Initialisable} + */ +class CallbackManager implements Initialisable +{ + #client: DiscordClient; + #timeouts: Collection; + #clients: Collection; + #logger: LoggerClient; + #ready: boolean; + #interval!: NodeJS.Timer; + + /** + * Creates an instance of CallbackManager. + * @date 3/23/2024 - 10:11:38 PM + * + * @constructor + * @param {DiscordClient} client + */ + constructor (client: DiscordClient) + { + this.#client = client; + this.#timeouts = new Collection(); + this.#clients = new Collection(); + this.#logger = client.createLogger(this); + this.#ready = false; + } + + private get storage () + { + return this.#client.mongodb.callbacks; + } + + get ready () + { + return this.#ready; + } + + /** + * Initialises the handler + * @date 3/23/2024 - 10:11:38 PM + * + * @async + * @returns {*} + */ + async initialise (): Promise + { + if (this.#ready) + return; + await this.#loadCallbacks(); + this.#interval = setInterval(this.#loadCallbacks.bind(this), HOUR); + this.#ready = true; + } + + /** + * Stops clears any active timeouts from memory in preparation for process exit + * @date 3/23/2024 - 10:22:49 PM + */ + stop () + { + clearInterval(this.#interval); + for (const [ id, timeout ] of this.#timeouts) + { + clearTimeout(timeout); + this.#timeouts.delete(id); + } + this.#ready = false; + } + + async #loadCallbacks () + { + const guildIds = [ ...this.#client.guilds.cache.keys() ]; + const query: Filter> = { + _id: { $nin: [ ...this.#timeouts.keys() ] }, + expiresAt: { $lte: Date.now() + DAY } + }; + + if (this.#client.shardId === 0) + query.$or = [{ guild: { $in: guildIds } }, { guild: { $exists: false } }]; + else + query.guild = { $in: guildIds }; + + const callbacks = await this.#client.mongodb.callbacks.find(query); + const now = Date.now(); + for (const data of callbacks) + { + const duration = data.expiresAt - now; + if (!this.#timeouts.has(data._id)) + this.#timeouts.set(data._id, setTimeout(this.#handleCallback.bind(this), duration, data)); + } + } + + /** + * Description placeholder + * @date 3/23/2024 - 10:11:38 PM + * + * @param {CallbackClient} client + */ + registerClient (client: CallbackClient) + { + const { name } = client.constructor; + if (this.#clients.has(name)) + throw new Error(`CallbackManager already has a ${name} client`); + this.#clients.set(name, client); + } + + /** + * Description placeholder + * @date 3/23/2024 - 10:11:38 PM + * + * @async + * @template T + * @param {CallbackClient} client + * @param {CallbackCreateInfo} createData + * @returns {unknown} + */ + async createCallback (client: CallbackClient, createData: CallbackCreateInfo): Promise> + { + const clientName = client.constructor.name; + if (!this.#clients.has(clientName)) + throw new Error('No such client exists'); + + const created = Date.now(); + const data: CallbackInfo = { + created, + _id: createData.id ?? Util.createUUID(), + client: clientName, + ...createData + }; + + const duration = data.expiresAt - data.created; + this.#logger.debug(`Creating callback for client ${clientName}, expiring in ${Util.humanise(duration/1000)}`); + await this.storage.insertOne(data); + + if (duration <= 2 ** 31 - 1) + this.#timeouts.set( + data._id, + setTimeout(this.#handleCallback.bind(this), duration, data) + ); + + return data; + } + + /** + * Removes an active callback + * @date 3/23/2024 - 10:11:38 PM + * + * @async + * @param {string} id + * @returns {*} + */ + async removeCallback (id: string) + { + await this.storage.deleteOne({ _id: id }); + const timeout = this.#timeouts.get(id); + if (timeout) + clearTimeout(timeout); + } + + /** + * Query for active callbacks. Query can be a partial of the stored payload. + * To find any active callback for a given client, pass an empty object but supply the client + * @date 3/24/2024 - 12:40:44 PM + * + * @async + * @template T + * @param {Partial} query + * @param {?CallbackClient} [client] + * @returns {Promise[]>} + */ + async queryCallbacks (query: Partial, client?: CallbackClient): Promise[]> + { + const entries = Object.entries(query); + let hasAnyValue = false; + const q: Filter> = { }; + for (const [ key, value ] of entries) + { + // eslint-disable-next-line no-undefined + if (value !== undefined) + hasAnyValue = true; + q[`payload.${key}`] = value; + } + if (!hasAnyValue && !client) + throw new Error('Must supply query object with at least one item or a client'); + + if (client) + { + const { name } = client.constructor; + q.client = name; + } + const result = await this.storage.find>(q); + return result; + } + + async fetchCallback (id: string) + { + return this.storage.findOne>({ id }); + } + + async #handleCallback (data: CallbackInfo) + { + const client = this.#clients.get(data.client); + if (!client) + throw new Error(`Got invalid client ${data.client}`); + + this.#logger.debug(`Firing callback for ${data.client}: ${data._id}`); + await client.handleCallback(data._id, data.payload); + await this.removeCallback(data._id); + } + +} + +export default CallbackManager; \ No newline at end of file diff --git a/src/client/components/ModerationManager.ts b/src/client/components/managers/ModerationManager.ts similarity index 74% rename from src/client/components/ModerationManager.ts rename to src/client/components/managers/ModerationManager.ts index fe68208..bb1f12a 100644 --- a/src/client/components/ModerationManager.ts +++ b/src/client/components/managers/ModerationManager.ts @@ -1,14 +1,44 @@ -import { inspect } from 'node:util'; +import { + inspect +} from 'node:util'; -import { stripIndents } from 'common-tags'; -import { Collection, GuildTextBasedChannel, Message } from 'discord.js'; -import { LoggerClient } from '@navy.gif/logger'; +import { + stripIndents +} from 'common-tags'; + +import { + GuildTextBasedChannel, + Message +} from 'discord.js'; + +import { + LoggerClient +} from '@navy.gif/logger'; + +import { + DiscordStruct, + InfractionJSON, + InfractionType +} from '../../../../@types/Client.js'; + +import { + EscalationResult, + HandleAutomodOptions, + HandleTargetData, + InfractionHandlerOptions, + ModerationTarget, + ModerationTargets +} from '../../../../@types/Moderation.js'; + +import { + Constants, + Emojis +} from '../../../constants/index.js'; + +import { + Util +} from '../../../utilities/index.js'; -import { DiscordStruct, InfractionJSON, InfractionType, ModerationCallback } from '../../../@types/Client.js'; -import { EscalationResult, HandleAutomodOptions, HandleTargetData, InfractionHandlerOptions, ModerationTarget, ModerationTargets } from '../../../@types/Moderation.js'; -import { Constants, Emojis } from '../../constants/index.js'; -import { Util } from '../../utilities/index.js'; -import DiscordClient from '../DiscordClient.js'; import { Addrole, Ban, @@ -21,10 +51,32 @@ import { Unlockdown, Unmute, Warn -} from '../infractions/index.js'; -import { CommandOption, Infraction as InfractionClass, Initialisable } from '../interfaces/index.js'; -import { GuildWrapper, InvokerWrapper, MemberWrapper, UserWrapper } from './wrappers/index.js'; -import { InfractionFail, InfractionSuccess } from '../../../@types/Infractions.js'; +} from '../../infractions/index.js'; + +import { + CallbackClient, + CommandOption, + Infraction as InfractionClass, + Initialisable +} from '../../interfaces/index.js'; + +import { + GuildWrapper, + InvokerWrapper, + MemberWrapper, + UserWrapper +} from '../wrappers/index.js'; + +import { + InfractionFail, + InfractionSuccess +} from '../../../../@types/Infractions.js'; + +import { + CallbackCreateInfo +} from '../../../../@types/CallbackManager.js'; + +import DiscordClient from '../../DiscordClient.js'; const Constant: { @@ -72,10 +124,10 @@ const Constant: { } }; -class ModerationManager implements Initialisable +class ModerationManager implements Initialisable, CallbackClient { #client: DiscordClient; - #callbacks: Collection; + // #callbacks: Collection; #logger: LoggerClient; #infractionClasses: { [key: string]: typeof InfractionClass @@ -92,23 +144,20 @@ class ModerationManager implements Initialisable UNLOCKDOWN: typeof Unlockdown; NOTE: typeof Note }; + #ready: boolean; get infractionClasses () { return this.#infractionClasses; } - get callbacks () - { - return this.#callbacks; - } - constructor (client: DiscordClient) { this.#client = client; - this.#callbacks = new Collection(); + // this.#callbacks = new Collection(); this.#logger = client.createLogger(this); // new Logger({ name: 'ModMngr' }); this.#infractionClasses = Constant.Infractions; + this.#ready = false; } actions: { @@ -135,9 +184,16 @@ class ModerationManager implements Initialisable } }; + get ready () + { + return this.#ready; + } + async initialise () { - // TODO: Load infractions for non-cached guilds... + if (this.#ready) + return; + this.#client.callbacks.registerClient(this); const filter = { duration: { $gt: 0 }, guild: { $in: this.#client.guilds.cache.map((g) => g.id) }, @@ -147,7 +203,16 @@ class ModerationManager implements Initialisable const results = await this.#client.mongodb.infractions.find(filter); this.#logger.info(`Filtering ${results.length} infractions for callback.`); - this.handleCallbacks(results); + for (const result of results) + this.handleTimedInfraction(result); + const ids = results.map(result => result._id); + await this.#client.mongodb.infractions.removeProperty({ _id: { $in: ids } }, [ '_callbacked' ]); + this.#ready = true; + } + + async stop () + { + // } // eslint-disable-next-line max-lines-per-function @@ -526,126 +591,121 @@ class ModerationManager implements Initialisable return responses; } - // eslint-disable-next-line max-lines-per-function - async handleCallbacks (infractions: InfractionJSON[] = []) + async handleCallback (id: string) { - const currentDate = Date.now(); - const resolve = async (i: InfractionJSON) => - { - this.#logger.debug(`Infraction callback: ${i.id}`); - const undoClass = Constant.Infractions[Constants.InfractionOpposites[i.type]]; - if (!undoClass) - return false; + const infraction = await this.#client.mongodb.infractions.findOne({ id }); + if (!infraction) + return; + this.#logger.debug(`Infraction callback: ${infraction.id} (${infraction.type})`); + const undoClass = Constant.Infractions[Constants.InfractionOpposites[infraction.type]]; + if (!undoClass) + return; - const guild = await this.#client.getGuildWrapper(i.guild!); - if (!guild) - throw new Error(`Missing guild for infraction: ${i.id}`); + const guild = await this.#client.getGuildWrapper(infraction.guild!); + if (!guild) + throw new Error(`Missing guild for infraction: ${infraction.id}`); // await guild.settings(); //just incase - let target = null; - if (i.targetType === 'USER') - { - target = await guild.memberWrapper(i.target!).catch(() => null); - if (!target && i.type === 'BAN') - target = await this.#client.getUserWrapper(i.target!, true).catch(() => null); - } - else if (i.targetType === 'CHANNEL') - { - target = guild.channels.resolve(i.target!); - if (!target?.isTextBased()) - throw new Error('Invalid channel'); - } - - if (target) - { - const executor = await guild.memberWrapper(i.executor!).catch(() => null) ?? await guild.memberWrapper(guild.me!); - const channel = guild.channels.resolve(i.channel!); - if (channel && !channel.isTextBased()) - throw new Error('Bad channel ' + inspect(i)); - if (!executor) - throw new Error('Missing executor'); - try - { - await new undoClass(this.#client, this.#logger, { - type: undoClass.Type, - reason: `AUTO-${Constants.InfractionOpposites[i.type]} from Case ${i.case}`, - channel, - hyperlink: i.modLogMessage && i.modLogChannel - ? `https://discord.com/channels/${i.guild}/${i.modLogChannel}/${i.modLogMessage}` : null, - data: i.data, - guild, - target, - executor - }).execute(); - } - catch (err) - { - const error = err as Error; - this.#logger.error(`Error when resolving infraction:\n${error.stack || error}`); - } - } - else - { - // Target left guild or channel was removed from the guild. What should happen in this situation? - // Maybe continue checking if the user rejoins, but the channel will always be gone. - } - - // TODO: Log this, should never error... hopefully. - // await this.client.storageManager.mongodb.infractions.updateOne( - // { id: i.id }, - // { _callbacked: true } - // ).catch((e) => { - // this.logger.error(`Error during update of infraction:\n${e.stack || e}`); - // }); - // this.callbacks.delete(i.id); - await this.removeCallback(i, true); - return true; - }; - - for (const infraction of infractions) + let target = null; + if (infraction.targetType === 'USER') { - if (!infraction) - throw new Error('Undefined infraction'); - const callBackAt = infraction.timestamp + infraction.duration; - if (callBackAt - currentDate <= 0) - { - this.#logger.debug(`Expired infraction:\n${inspect(infraction)}`); - await resolve(infraction); - continue; - } - - this.#logger.debug(`Going to resolve infraction ${infraction.id} in: ${Math.floor((callBackAt - currentDate) / 1000)} s`); - - this.#callbacks.set(infraction.id, { - timeout: setTimeout(async () => - { - await resolve(infraction); - }, callBackAt - currentDate), - infraction - }); + target = await guild.memberWrapper(infraction.target!).catch(() => null); + if (!target && infraction.type === 'BAN') + target = await this.#client.getUserWrapper(infraction.target!, true).catch(() => null); } + else if (infraction.targetType === 'CHANNEL') + { + target = guild.channels.resolve(infraction.target!); + if (!target?.isTextBased()) + throw new Error('Invalid channel'); + } + + if (target) + { + const executor = await guild.memberWrapper(infraction.executor!).catch(() => null) ?? await guild.memberWrapper(guild.me!); + const channel = guild.channels.resolve(infraction.channel!); + if (channel && !channel.isTextBased()) + throw new Error('Bad channel ' + inspect(infraction)); + if (!executor) + throw new Error('Missing executor'); + try + { + await new undoClass(this.#client, this.#logger, { + type: undoClass.Type, + reason: `AUTO-${Constants.InfractionOpposites[infraction.type]} from Case ${infraction.case}`, + channel, + hyperlink: infraction.modLogMessage && infraction.modLogChannel + ? `https://discord.com/channels/${infraction.guild}/${infraction.modLogChannel}/${infraction.modLogMessage}` : null, + data: infraction.data, + guild, + target, + executor + }).execute(); + } + catch (err) + { + const error = err as Error; + this.#logger.error(`Error when resolving infraction:\n${error.stack || error}`); + } + } + else + { + // Target left guild or channel was removed from the guild. What should happen in this situation? + // Maybe continue checking if the user rejoins, but the channel will always be gone. + } + // await this.removeCallback(infraction, true); } - async removeCallback (infraction: InfractionClass | InfractionJSON, updateCase = false) + async handleTimedInfraction (infraction: InfractionJSON): Promise { - // if(!callback) return; - this.#logger.debug(`Removing callback ${infraction.type} for ${infraction.targetType} ${infraction.target}.`); - if (updateCase) - await this.#client.storage.mongodb.infractions.updateOne( - { id: infraction.id }, - { $set: { _callbacked: true } } - ).catch((e) => - { - this.#logger.error(`Error during update of infraction ${infraction.id}:\n${e.stack || e}\n${inspect(e.errInfo, { depth: 25 })}`); - }); - const cb = this.#callbacks.get(infraction.id); - if (cb) + const currentDate = Date.now(); + + if (!infraction) + throw new Error('Undefined infraction'); + const { duration } = infraction; + const expiresAt = infraction.timestamp + duration; + if (expiresAt - currentDate <= 0) { - clearTimeout(cb.timeout); - this.#callbacks.delete(infraction.id); + this.#logger.debug(`Expired infraction:\n${inspect(infraction)}`); + return this.handleCallback(infraction.id); } + + this.#logger.debug(`Creating infraction callback for ${infraction.id} (${infraction.type}), expiring in ${Util.humanise(duration / 1000)}`); + const callbackData: CallbackCreateInfo = { + expiresAt, + id: infraction.id, + payload: infraction, + guild: infraction.guild + }; + await this.#client.callbacks.createCallback(this, callbackData); } + async removeCallback (infraction: InfractionClass | InfractionJSON) + { + await this.#client.callbacks.removeCallback(infraction.id); + } + + // Should be obsolete, at least in this state + // async removeCallback (infraction: InfractionClass | InfractionJSON, updateCase = false) + // { + // // if(!callback) return; + // this.#logger.debug(`Removing callback ${infraction.type} for ${infraction.targetType} ${infraction.target}.`); + // if (updateCase) + // await this.#client.storage.mongodb.infractions.updateOne( + // { id: infraction.id }, + // { $set: { _callbacked: true } } + // ).catch((e) => + // { + // this.#logger.error(`Error during update of infraction ${infraction.id}:\n${e.stack || e}\n${inspect(e.errInfo, { depth: 25 })}`); + // }); + // const cb = this.#callbacks.get(infraction.id); + // if (cb) + // { + // clearTimeout(cb.timeout); + // this.#callbacks.delete(infraction.id); + // } + // } + // async _fetchTarget (guild, targetId, targetType, user = false) // { @@ -675,14 +735,14 @@ class ModerationManager implements Initialisable async findLatestInfraction (type: InfractionType, target: ModerationTarget) { - const callback = this.#callbacks.filter((c) => - { - return c.infraction.type === type - && c.infraction.target === target.id; - }).first(); + // const callback = this.#callbacks.filter((c) => + // { + // return c.infraction.type === type + // && c.infraction.target === target.id; + // }).first(); - if (callback) - return callback.infraction; + // if (callback) + // return callback.infraction; const result = await this.#client.storage.mongodb.infractions.findOne( { type, target: target.id }, @@ -691,6 +751,18 @@ class ModerationManager implements Initialisable return result || null; } + async findActiveInfraction (type: InfractionType, target: string, guild: string) + { + const [ callback ] = await this.#client.callbacks.queryCallbacks({ type, target, guild }, this); + return callback ?? null; + } + + async findActiveInfractions (query: Partial) + { + const callback = await this.#client.callbacks.queryCallbacks(query, this); + return callback; + } + async calculatePoints (user: UserWrapper, guild: GuildWrapper) { const [ result ] = await this.#client.mongodb.infractions.aggregate([{ diff --git a/src/client/components/managers/PollManager.ts b/src/client/components/managers/PollManager.ts new file mode 100644 index 0000000..ccc22d4 --- /dev/null +++ b/src/client/components/managers/PollManager.ts @@ -0,0 +1,95 @@ +import { TextChannel } from 'discord.js'; +import { PollData } from '../../../../@types/Guild.js'; +import DiscordClient from '../../DiscordClient.js'; +import CallbackClient from '../../interfaces/CallbackClient.js'; +import { PollReactions } from '../../../constants/Constants.js'; + +class PollManager implements CallbackClient +{ + #client: DiscordClient; + constructor (client: DiscordClient) + { + this.#client = client; + client.callbacks.registerClient(this); + } + + private get callbacks () + { + return this.#client.callbacks; + } + + async create ({ duration, ...opts }: PollData, guild: string) + { + const now = Date.now(); + await this.callbacks.createCallback(this, { + expiresAt: now + duration * 1000, + payload: { ...opts, duration }, + guild + }); + } + + async delete (message: string) + { + const poll = await this.find(message); + if (!poll) + return null; + await this.callbacks.removeCallback(poll._id); + return poll; + } + + async find (message: string) + { + const [ poll ] = await this.callbacks.queryCallbacks({ + message + }, this); + if (!poll) + return null; + return poll; + } + + async handleCallback (_id: string, payload: PollData): Promise + { + await this.end(payload); + } + + async end ({ user, channel, startedIn, message, multiChoice }: PollData) + { + const startChannel = await this.#client.resolveChannel(startedIn); + const pollChannel = await this.#client.resolveChannel(channel); + if (pollChannel && pollChannel.isTextBased()) + { + if (!(pollChannel instanceof TextChannel)) + return; + const guild = await this.#client.getGuildWrapper(pollChannel.guildId); + if (!guild) + return; + + const msg = await pollChannel.messages.fetch(message).catch(() => null); + if (!msg) + return; + + const { reactions } = msg; + const reactionEmojis = multiChoice ? PollReactions.Multi : PollReactions.Single; + const result: { [key: string]: number } = {}; + for (const emoji of reactionEmojis) + { + let reaction = reactions.resolve(emoji); + if (!reaction) + continue; + 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 = guild.format('COMMAND_POLL_END', { results }); + await msg.edit({ embeds: [ embed ] }); + + if (startChannel && startChannel.isTextBased()) + await startChannel.send(guild.format('COMMAND_POLL_NOTIFY_STARTER', { user, channel })); + } + } +} + +export default PollManager; \ No newline at end of file diff --git a/src/client/components/managers/ReminderManager.ts b/src/client/components/managers/ReminderManager.ts new file mode 100644 index 0000000..2ea0a2d --- /dev/null +++ b/src/client/components/managers/ReminderManager.ts @@ -0,0 +1,69 @@ +import { ReminderData } from '../../../../@types/Guild.js'; +import DiscordClient from '../../DiscordClient.js'; +import CallbackClient from '../../interfaces/CallbackClient.js'; +import { EmbedDefaultColor } from '../../../constants/Constants.js'; +import { Guild } from 'discord.js'; + +class ReminderManager implements CallbackClient +{ + #client: DiscordClient; + + constructor (client: DiscordClient) + { + this.#client = client; + this.callbacks.registerClient(this); + } + + private get callbacks () + { + return this.#client.callbacks; + } + + get format () + { + return this.#client.format.bind(this.#client); + } + + async create ({ time, user, channel, reminder }: ReminderData, guild?: Guild) + { + const now = Date.now(); + await this.callbacks.createCallback(this, { + expiresAt: now + time * 1000, + guild: guild?.id, + payload: { user, channel, reminder } + }); + } + + async remove (id: string) + { + return this.callbacks.removeCallback(id); + } + + async find (user: string) + { + return this.callbacks.queryCallbacks({ user }, this); + } + + async handleCallback (_id: string, { reminder, channel: channelId, user }: ReminderData): Promise + { + let channel = await this.#client.resolveChannel(channelId); + if (channel && channel.partial) + channel = await channel.fetch().catch(() => null); + if (!channel || !channel.isTextBased()) + return; + + const payload = { + content: '', + embeds: [{ + title: this.format('GENERAL_REMINDER_TITLE'), + description: reminder, + color: EmbedDefaultColor + }] + }; + if (!channel.isDMBased()) + payload.content = `<@${user}>`; + await channel.send(payload); + } +} + +export default ReminderManager; \ No newline at end of file diff --git a/src/client/components/observers/AuditLog.ts b/src/client/components/observers/AuditLog.ts index 02be2b6..bdc0fff 100644 --- a/src/client/components/observers/AuditLog.ts +++ b/src/client/components/observers/AuditLog.ts @@ -134,9 +134,10 @@ class AuditLogObserver extends Observer if (type === 'UNMUTE') { - const callback = this.client.moderation.callbacks.filter((cb) => cb.infraction.target === newMember.id && cb.infraction.type === 'MUTE').first(); + // const callback = this.client.moderation.callbacks.filter((cb) => cb.infraction.target === newMember.id && cb.infraction.type === 'MUTE').first(); + const callback = await this.client.moderation.findActiveInfraction('MUTE', newMember.id, wrapper.id); if (callback) - this.client.moderation.removeCallback(callback.infraction, true); + this.client.moderation.removeCallback(callback.payload!); } const userWrapper = await this.client.getUserWrapper(entry.executor!); diff --git a/src/client/components/observers/Automoderation.ts b/src/client/components/observers/Automoderation.ts index ad16eab..357c41f 100644 --- a/src/client/components/observers/Automoderation.ts +++ b/src/client/components/observers/Automoderation.ts @@ -48,6 +48,7 @@ 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[] }; + #ready: boolean; constructor (client: DiscordClient) { super(client, { @@ -78,10 +79,19 @@ export default class AutoModeration extends Observer implements Initialisable mention: /<@!?(?[0-9]{18,22})>/u, mentionG: /<@!?(?[0-9]{18,22})>/gu, }; + + this.#ready = false; + } + + get ready () + { + return this.#ready; } async initialise () { + if (this.ready) + return; // Fetch a list of TLDs from iana const tldList = await this.client.managerEval(` (() => { @@ -96,6 +106,12 @@ export default class AutoModeration extends Observer implements Initialisable tldList.splice(0, 0, midEntry); this.topLevelDomains = new BinaryTree(this.client, tldList); this.topLevelDomains.add('onion'); + this.#ready = true; + } + + stop (): void | Promise + { + throw new Error('Method not implemented.'); } async _moderate ( diff --git a/src/client/components/observers/UtilityHook.ts b/src/client/components/observers/UtilityHook.ts index 113a688..cff1bec 100644 --- a/src/client/components/observers/UtilityHook.ts +++ b/src/client/components/observers/UtilityHook.ts @@ -4,7 +4,6 @@ import Util from '../../../utilities/Util.js'; import DiscordClient from '../../DiscordClient.js'; import Observer from '../../interfaces/Observer.js'; import { PollReactions } from '../../../constants/Constants.js'; -import { CallbackData, PollData } from '../../../../@types/Guild.js'; import InteractionWrapper from '../wrappers/InteractionWrapper.js'; class UtilityHook extends Observer @@ -62,14 +61,17 @@ class UtilityHook extends Observer if (!me?.permissions.has('ManageRoles') || !setting.role) return; - const infraction = await this.client.storageManager.mongodb.infractions.findOne({ - duration: { $gt: 0 }, - guild: guild.id, - target: member.id, - type: 'MUTE', - _callbacked: false, - resolved: false - }); + // const infraction = await this.client.storageManager.mongodb.infractions.findOne({ + // duration: { $gt: 0 }, + // guild: guild.id, + // target: member.id, + // type: 'MUTE', + // _callbacked: false, + // resolved: false + // }); + + const callback = await this.client.moderation.findActiveInfraction('MUTE', member.id, guild.id); + const infraction = callback.payload; if (!infraction || infraction.resolved) return; @@ -232,14 +234,14 @@ class UtilityHook extends Observer const channel = await guild.resolveChannel(message.channelId); if (!channel) return; - const poll = guild.callbacks.find((cb) => cb.data.type === 'poll' && (cb.data as PollData & CallbackData).message === message.id)?.data as (PollData & CallbackData) | undefined; - if (!poll || poll.multiChoice) + const poll = await this.client.polls.find(message.id); + if (!poll || poll.payload.multiChoice) return; if (message.partial) message = await channel.messages.fetch(message.id); const reactions = message.reactions.cache; - const emojis = poll.questions.length > 1 ? PollReactions.Multi : PollReactions.Single; + const emojis = poll.payload.multiChoice ? PollReactions.Multi : PollReactions.Single; for (const emoji of emojis) { diff --git a/src/client/components/wrappers/GuildWrapper.ts b/src/client/components/wrappers/GuildWrapper.ts index e653858..c967d8b 100644 --- a/src/client/components/wrappers/GuildWrapper.ts +++ b/src/client/components/wrappers/GuildWrapper.ts @@ -1,27 +1,17 @@ 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, @@ -40,12 +30,6 @@ 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; @@ -57,7 +41,7 @@ class GuildWrapper #invites?: Collection; #webhooks: Collection; #memberWrappers: Collection; - #callbacks: Collection; + // #callbacks: Collection; #data!: GuildData; #settings!: GuildSettings; @@ -75,113 +59,88 @@ class GuildWrapper 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 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 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'); - 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 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 }); + // } - 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 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; + // } - 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); - } + // 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 filterText (member: GuildMember, text: string) { @@ -591,11 +550,6 @@ class GuildWrapper return this.guild.iconURL(opts); } - get callbacks () - { - return this.#callbacks; - } - get guild () { return this.#guild; diff --git a/src/client/components/wrappers/MemberWrapper.ts b/src/client/components/wrappers/MemberWrapper.ts index 6d536a2..09b0a9e 100644 --- a/src/client/components/wrappers/MemberWrapper.ts +++ b/src/client/components/wrappers/MemberWrapper.ts @@ -1,7 +1,6 @@ import { GuildMember, ImageURLOptions, MessageCreateOptions, MessagePayload } from 'discord.js'; import DiscordClient from '../../DiscordClient.js'; import UserWrapper from './UserWrapper.js'; -import { InfractionJSON, InfractionType, ModerationCallback } from '../../../../@types/Client.js'; import GuildWrapper from './GuildWrapper.js'; class MemberWrapper @@ -20,32 +19,32 @@ class MemberWrapper } // Infraction callback - async getCallback (type: InfractionType, onlyActive = false): - Promise<{ infraction: InfractionJSON, timeout: number | null } | ModerationCallback | null> - { - if (!type) - return null; - const { callbacks } = this.#client.moderation; - const filtered = callbacks.filter((e) => e.infraction.type === type - && e.infraction.target === this.id); + // async getCallback (type: InfractionType, onlyActive = false): + // Promise<{ infraction: InfractionJSON, timeout: number | null } | ModerationCallback | null> + // { + // if (!type) + // return null; + // const { callbacks } = this.#client.moderation; + // const filtered = callbacks.filter((e) => e.infraction.type === type + // && e.infraction.target === this.id); - if (filtered.size > 0) - return filtered.first() ?? null; - if (onlyActive) - return null; // Only return active callbacks, nothing from the db - const result = await this.#client.mongodb.infractions.findOne( - { duration: { $gt: 0 }, type, target: this.id, resolved: false, _callbacked: false }, - { sort: { timestamp: -1 } }// Finds latest mute. - ).catch(() => - { // eslint-disable-line no-unused-vars - return null; - }); + // if (filtered.size > 0) + // return filtered.first() ?? null; + // if (onlyActive) + // return null; // Only return active callbacks, nothing from the db + // const result = await this.#client.mongodb.infractions.findOne( + // { duration: { $gt: 0 }, type, target: this.id, resolved: false, _callbacked: false }, + // { sort: { timestamp: -1 } }// Finds latest mute. + // ).catch(() => + // { // eslint-disable-line no-unused-vars + // return null; + // }); - if (!result) - return null; + // if (!result) + // return null; - return { infraction: result, timeout: null }; - } + // return { infraction: result, timeout: null }; + // } async userWrapper () { diff --git a/src/client/infractions/Ban.ts b/src/client/infractions/Ban.ts index 979d0fa..dbaada5 100644 --- a/src/client/infractions/Ban.ts +++ b/src/client/infractions/Ban.ts @@ -49,11 +49,12 @@ class BanInfraction extends Infraction if (this.arguments.days) days = this.arguments.days.asNumber; - const callbacks = this.client.moderation.callbacks.filter((c) => c.infraction.type === 'BAN' - && c.infraction.target === this.target!.id); + // const callbacks = this.client.moderation.callbacks.filter((c) => c.infraction.type === 'BAN' + // && c.infraction.target === this.target!.id); + const callbacks = await this.client.moderation.findActiveInfractions({ type: 'BAN', target: this.targetId! }); - if (callbacks.size > 0) - callbacks.map((c) => this.client.moderation.removeCallback(c.infraction, true)); + if (callbacks.length > 0) + callbacks.map((c) => this.client.moderation.removeCallback(c.payload)); try { @@ -92,9 +93,7 @@ class BanInfraction extends Infraction async resolve (staff: UserWrapper, reason: string, notify: boolean) { // const infraction = await this.client.moderationManager.findLatestInfraction(this.type, this.targetId); - const callback = this.client.moderation.callbacks.get(this.id); - if (callback) - this.client.moderation.removeCallback(callback.infraction); + await this.client.moderation.removeCallback(this); const banned = await this.guild.bans.fetch(this.targetId!).catch(() => null); if (banned) diff --git a/src/client/infractions/Mute.ts b/src/client/infractions/Mute.ts index 213df6b..7d73193 100644 --- a/src/client/infractions/Mute.ts +++ b/src/client/infractions/Mute.ts @@ -143,15 +143,16 @@ class MuteInfraction extends Infraction muteRole: role?.id || null }; // Info will be saved in database and into the callback when resolved. - const callback = this.client.moderation.callbacks.filter((c) => c.infraction.type === 'MUTE' - && c.infraction.target === this.target!.id).first(); + // const callback = this.client.moderation.callbacks.filter((c) => c.infraction.type === 'MUTE' + // && c.infraction.target === this.target!.id).first(); + const callback = await this.client.moderation.findActiveInfraction('MUTE', this.target!.id, this.guildId!); if (callback) { if (!this.data.removedRoles) this.data.removedRoles = []; - this.data.removedRoles = [ ...new Set([ ...this.data.removedRoles, ...callback.infraction.data.removedRoles||[] ]) ]; - this.client.moderation.removeCallback(callback.infraction, true); + this.data.removedRoles = [ ...new Set([ ...this.data.removedRoles, ...callback.payload!.data.removedRoles||[] ]) ]; + this.client.moderation.removeCallback(callback.payload!); } // if(callbacks.size > 0) callbacks.map((c) => this.client.moderationManager._removeExpiration(c)); @@ -210,24 +211,21 @@ class MuteInfraction extends Infraction const settings = await this.guild.settings(); const { removedRoles = [], muteType = settings.mute.type, muteRole = settings.mute.role } = this.data || {}; - // TODO: Change this to not rely on the member - const member = await this.guild.memberWrapper(this.targetId!).catch(() => null); - if (!member) - return { error: true, message: 'Failed to unmute' }; - const callback = await member.getCallback(this.type); - if (callback) - this.client.moderation.removeCallback(callback.infraction); - if (inf.id === this.id && member) + const callback = await this.client.moderation.findActiveInfraction(this.type, this.targetId!, this.guildId!); + if (callback) + this.client.moderation.removeCallback(callback.payload!); + + if (inf.id === this.id && this.member) { const reason = `Case ${this.case} resolve`; - const roles = [ ...new Set([ ...member.roles.cache.map((r) => r.id), ...removedRoles || [] ]) ]; + const roles = [ ...new Set([ ...this.member.roles.cache.map((r) => r.id), ...removedRoles || [] ]) ]; switch (muteType) { case 0: try { - await member.roles.remove(muteRole!, reason); + await this.member.roles.remove(muteRole!, reason); } catch (e) { @@ -238,13 +236,13 @@ class MuteInfraction extends Infraction } break; case 1: - // eslint-disable-next-line no-case-declarations + { const index = roles.indexOf(muteRole!); if (index >= 0) roles.splice(index, 1); try { - await member.roles.set(roles, reason); + await this.member.roles.set(roles, reason); } catch (e) { @@ -254,10 +252,11 @@ class MuteInfraction extends Infraction message = this.guild.format('INFRACTION_RESOLVE_MUTE_FAIL23'); } break; + } case 2: try { - await member.roles.set(roles, reason); + await this.member.roles.set(roles, reason); } catch (e) { @@ -268,8 +267,8 @@ class MuteInfraction extends Infraction } break; case 3: - if (member.timedOut && member.timedOut > Date.now()) - await member.timeout(null, this._reason); + if (this.member.timedOut && this.member.timedOut > Date.now()) + await this.member.timeout(null, this._reason); break; } } diff --git a/src/client/infractions/Unban.ts b/src/client/infractions/Unban.ts index 7bb95cc..867d871 100644 --- a/src/client/infractions/Unban.ts +++ b/src/client/infractions/Unban.ts @@ -52,11 +52,12 @@ class UnbanInfraction extends Infraction return this._fail('INFRACTION_ERROR'); } - const callbacks = this.client.moderation.callbacks.filter((c) => c.infraction.type === 'BAN' - && c.infraction.target === this.targetId); + // const callbacks = this.client.moderation.callbacks.filter((c) => c.infraction.type === 'BAN' + // && c.infraction.target === this.targetId); + const callbacks = await this.client.moderation.findActiveInfractions({ type: 'BAN', target: this.targetId! }); - if (callbacks.size > 0) - callbacks.map((c) => this.client.moderation.removeCallback(c.infraction, true)); + if (callbacks.length > 0) + callbacks.map((c) => this.client.moderation.removeCallback(c.payload!)); await this.handle(); return this._succeed(); diff --git a/src/client/infractions/Unlockdown.ts b/src/client/infractions/Unlockdown.ts index 3b36746..633002c 100644 --- a/src/client/infractions/Unlockdown.ts +++ b/src/client/infractions/Unlockdown.ts @@ -107,13 +107,7 @@ class UnlockdownInfraction extends Infraction return this._fail('INFRACTION_LOCKDOWN_FAILED'); if (latest) - { - const callback = this.client.moderation.callbacks.get(latest.id); - if (callback) - await this.client.moderation.removeCallback(callback.infraction, true); - else - await this.client.mongodb.infractions.updateOne({ id: latest.id }, { $set: { _callbacked: true } }); - } + await this.client.moderation.removeCallback(latest); await this.handle(); return this._succeed(); diff --git a/src/client/infractions/Unmute.ts b/src/client/infractions/Unmute.ts index 4fc5024..4208b54 100644 --- a/src/client/infractions/Unmute.ts +++ b/src/client/infractions/Unmute.ts @@ -68,14 +68,13 @@ class UnmuteInfraction extends Infraction } else { - // TODO: Make this not rely on a member wrapper - const memberWrapper = await this.guild.memberWrapper(this.member!).catch(() => null); - callback = await memberWrapper?.getCallback('MUTE'); + callback = await this.client.moderation.findActiveInfraction('MUTE', this.targetId!, this.guildId!); if (callback) { - removedRoles = callback.infraction.data.removedRoles ?? null; - ({ muteType } = callback.infraction.data); - role = callback.infraction.data.muteRole; + const infraction = callback.payload!; + removedRoles = infraction.data.removedRoles ?? null; + ({ muteType } = infraction.data); + role = infraction.data.muteRole; } else { @@ -121,58 +120,60 @@ class UnmuteInfraction extends Infraction const roles = [ ...new Set([ ...this.member!.roles.cache.map((r) => r.id), ...removedRoles ]) ]; - switch (muteType) + if (this.member) { - case 0: - if (!role) - return this._fail('C_UNMUTE_ROLEDOESNTEXIST'); - try - { - this.member!.roles.remove(role, this._reason); - } - catch (e) - { - return this._fail('C_UNMUTE_1FAIL'); - } - break; - case 1: - if (role) - { - const index = roles.indexOf((role as Role).id); - roles.splice(index, 1); - } - try - { - this.member!.roles.set(roles, this._reason); - } - catch (err) - { - const error = err as Error; - this.logger.error(`Unmute infraction failed to calculate additional roles, might want to check this out.\n${error.stack || error}`); - return this._fail('C_UNMUTE_2FAIL'); - } - break; - case 2: - try - { - this.member!.roles.set(roles, this._reason); - } - catch (err) - { - const error = err as Error; - this.logger.error(`Unmute infraction failed to calculate additional roles, might want to check this out.\n${error.stack || error}`); - return this._fail('C_UNMUTE_3FAIL'); - } - break; - case 3: - // Unironically hate this property name, why is it so cumbersome - if (this.member?.timedOut && this.member?.timedOut > now) - await this.member!.timeout(null, this._reason); - break; + switch (muteType) + { + case 0: + if (!role) + return this._fail('C_UNMUTE_ROLEDOESNTEXIST'); + try + { + this.member.roles.remove(role, this._reason); + } + catch (e) + { + return this._fail('C_UNMUTE_1FAIL'); + } + break; + case 1: + if (role) + { + const index = roles.indexOf((role as Role).id); + roles.splice(index, 1); + } + try + { + this.member.roles.set(roles, this._reason); + } + catch (err) + { + const error = err as Error; + this.logger.error(`Unmute infraction failed to calculate additional roles, might want to check this out.\n${error.stack || error}`); + return this._fail('C_UNMUTE_2FAIL'); + } + break; + case 2: + try + { + this.member.roles.set(roles, this._reason); + } + catch (err) + { + const error = err as Error; + this.logger.error(`Unmute infraction failed to calculate additional roles, might want to check this out.\n${error.stack || error}`); + return this._fail('C_UNMUTE_3FAIL'); + } + break; + case 3: + if (this.member.timedOut && this.member.timedOut > now) + await this.member!.timeout(null, this._reason); + break; + } } if (callback) - this.client.moderation.removeCallback(callback.infraction, true); + this.client.moderation.removeCallback(callback.payload!); await this.handle(); return this._succeed(); } diff --git a/src/client/interfaces/CallbackClient.ts b/src/client/interfaces/CallbackClient.ts new file mode 100644 index 0000000..f056524 --- /dev/null +++ b/src/client/interfaces/CallbackClient.ts @@ -0,0 +1,6 @@ +interface CallbackClient +{ + handleCallback(id: string, payload: unknown): Promise | void; +} + +export default CallbackClient; \ No newline at end of file diff --git a/src/client/interfaces/CommandOption.ts b/src/client/interfaces/CommandOption.ts index 0e7806e..2219c7a 100644 --- a/src/client/interfaces/CommandOption.ts +++ b/src/client/interfaces/CommandOption.ts @@ -8,7 +8,6 @@ import Command from './commands/Command.js'; import moment from 'moment'; import { GuildBasedChannel, GuildMember, Role, User } from 'discord.js'; import Module from './Module.js'; -import { Max32BitInt } from '../../constants/Constants.js'; const PointsReg = /^([-+]?[0-9]+) ?(points|point|pts|pt|p)$/iu; const ChannelType: {[key: string]: number} = { @@ -478,8 +477,8 @@ class CommandOption const value = this.client.resolver.resolveTime(this.#rawValue); if (value === null) return { error: true }; - if ((value*1000) > Max32BitInt) - return { error: true, index: 'O_COMMANDHANDLER_TYPETIME_MAX', params: { maximum: Util.humanise(Max32BitInt/1000) } }; + if ((value*1000) > Number.MAX_SAFE_INTEGER) + return { error: true, index: 'O_COMMANDHANDLER_TYPETIME_MAX', params: { maximum: Util.humanise(Number.MAX_SAFE_INTEGER/1000) } }; if (typeof this.#maximum !== 'undefined' && value > this.#maximum) return { error: true, index: 'O_COMMANDHANDLER_TYPETIME_MAX', params: { maximum: Util.humanise(this.#maximum) } }; return { value, removed: [ this.#rawValue ] }; diff --git a/src/client/interfaces/Infraction.ts b/src/client/interfaces/Infraction.ts index 0162781..98672c4 100644 --- a/src/client/interfaces/Infraction.ts +++ b/src/client/interfaces/Infraction.ts @@ -96,7 +96,7 @@ class Infraction #changes: InfractionChange[]; #timestamp: number; - #callbacked: boolean; + // #callbacked: boolean; #fetched: boolean; #mongoId: ObjectId | null; @@ -166,7 +166,7 @@ class Infraction this.#timestamp = Date.now(); - this.#callbacked = Boolean(data._callbacked); + // this.#callbacked = Boolean(data._callbacked); this.#fetched = false; // Boolean(data); this.#mongoId = null; @@ -237,7 +237,7 @@ class Infraction } if (this.#duration) - await this.#client.moderation.handleCallbacks([ this.json ]); + await this.#client.moderation.handleTimedInfraction(this.json); /* LMAOOOO PLEASE DONT JUDGE ME */ if (this.#data.roles) @@ -532,7 +532,7 @@ class Infraction dmLogMessage: this.#dmLogMessageId!, resolved: this.#resolved, changes: this.#changes, - _callbacked: this.#callbacked || false + // _callbacked: this.#callbacked || false }; } @@ -751,8 +751,8 @@ class Infraction this.#errorCheck(); if (this.#resolved) return { error: true, index: 'INFRACTION_EDIT_DURATION_RESOLVED' }; - if (this.#callbacked) - return { error: true, index: 'INFRACTION_EDIT_DURATION_CALLEDBACK' }; + // if (this.#callbacked) + // return { error: true, index: 'INFRACTION_EDIT_DURATION_CALLEDBACK' }; if (!TimedInfractions.includes(this.#type!)) return { error: true, index: 'INFRACTION_EDIT_DURATION_NOTTIMED' }; const now = Date.now(); @@ -764,7 +764,8 @@ class Infraction }; const member = this.#targetId ? await this.#guild.memberWrapper(this.#targetId).catch(() => null) : null; // const callback = await member.getCallback(this.type, true); - const callback = this.#client.moderation.callbacks.get(this.id); + // const callback = this.#client.moderation.callbacks.get(this.id); + const callback = await this.client.callbacks.fetchCallback(this.id); this.#duration = duration; if (this.#data.muteType === 3 && member) @@ -774,8 +775,8 @@ class Infraction } if (callback) - await this.#client.moderation.removeCallback(callback.infraction); - await this.#client.moderation.handleCallbacks([ this.json ]); + await this.#client.moderation.removeCallback(callback.payload); + await this.#client.moderation.handleTimedInfraction(this.json); this.#changes.push(log); } @@ -838,7 +839,7 @@ class Infraction async #patch (data: WithId) { this.#mongoId = new ObjectId(data._id); - this.#callbacked = data._callbacked ?? false; + // this.#callbacked = data._callbacked ?? false; this.#fetched = true; this.#targetType = data.targetType; diff --git a/src/client/interfaces/Initialisable.ts b/src/client/interfaces/Initialisable.ts index ba3bb43..c9f6b9e 100644 --- a/src/client/interfaces/Initialisable.ts +++ b/src/client/interfaces/Initialisable.ts @@ -1,11 +1,13 @@ // eslint-disable-next-line @typescript-eslint/no-explicit-any const isInitialisable = (obj: any): obj is Initialisable => { - return typeof obj.initialise === 'function'; + return typeof obj.initialise === 'function' && typeof obj.stop === 'function'; }; interface Initialisable { - initialise(): Promise; + ready: boolean; + initialise(): Promise | void; + stop(): Promise | void; } export default Initialisable; diff --git a/src/client/interfaces/index.ts b/src/client/interfaces/index.ts index dd21595..e92dcfd 100644 --- a/src/client/interfaces/index.ts +++ b/src/client/interfaces/index.ts @@ -11,6 +11,8 @@ import CommandError from './CommandError.js'; import SettingsCommand from './commands/SettingsCommand.js'; import ModerationCommand from './commands/ModerationCommand.js'; import SlashCommand from './commands/SlashCommand.js'; +import CallbackClient from './CallbackClient.js'; +import ReminderManager from '../components/managers/ReminderManager.js'; export { SlashCommand, @@ -26,5 +28,7 @@ export { Inhibitor, Setting, Initialisable, - isInitialisable + isInitialisable, + CallbackClient, + ReminderManager }; \ No newline at end of file diff --git a/src/client/storage/interfaces/MongodbTable.ts b/src/client/storage/interfaces/MongodbTable.ts index 5466111..25a03eb 100644 --- a/src/client/storage/interfaces/MongodbTable.ts +++ b/src/client/storage/interfaces/MongodbTable.ts @@ -28,7 +28,7 @@ class MongodbTable extends Table { if (!this.provider.initialised) return Promise.reject(new Error('MongoDB is not connected.')); - ({ query } = this._handleData(query)); + ({ query } = this.#handleData(query)); const cursor = this.collection().find(query, options); // if (opts?.sort) // cursor.sort(opts.sort); @@ -42,14 +42,14 @@ class MongodbTable extends Table findOne (query: Filter, opts?: FindOptions) { - ({ query } = this._handleData(query)); + ({ query } = this.#handleData(query)); return this.collection() .findOne(query, opts); } aggregate (pipeline: T[], options?: AggregateOptions) { - ({ query: pipeline } = this._handleData(pipeline)); + ({ query: pipeline } = this.#handleData(pipeline)); return this.collection() .aggregate(pipeline, options) .toArray(); @@ -57,7 +57,7 @@ class MongodbTable extends Table random (query: Filter, amount = 1) { - ({ query } = this._handleData(query)); + ({ query } = this.#handleData(query)); if (amount > 100) amount = 100; return this.collection() @@ -69,51 +69,53 @@ class MongodbTable extends Table insertOne (data: OptionalUnlessRequiredId, options?: InsertOneOptions) { - ({ query: data } = this._handleData(data)); + ({ query: data } = this.#handleData(data)); return this.collection() .insertOne(data, options); } insertMany (data: OptionalUnlessRequiredId[], options?: BulkWriteOptions) { - ({ query: data } = this._handleData(data)); + ({ query: data } = this.#handleData(data)); return this.collection() .insertMany(data, options); } deleteOne (query: Filter, options?: DeleteOptions) { - ({ query } = this._handleData(query)); + ({ query } = this.#handleData(query)); return this.collection() .deleteOne(query, options); } deleteMany (query: Filter, options?: DeleteOptions) { - ({ query } = this._handleData(query)); + ({ query } = this.#handleData(query)); return this.collection() .deleteMany(query, options); } updateOne (query: Filter, data: UpdateFilter, options?: UpdateOptions) { - ({ query, options } = this._handleData(query, options)); + ({ query, options } = this.#handleData(query, options)); return this.collection() .updateOne(query, data, options); } - // removeProperty (query: Filter, data: (keyof T)[]) - // { - // query = this._handleData(query); - // const obj: { [key in keyof T]: '' } = {}; - // for (const key of data) - // obj[key] = ''; - // return this.collection().updateMany(query, { $unset: obj }); - // } + removeProperty (query: Filter, data: (keyof T)[]) + { + ({ query } = this.#handleData(query)); + const obj: {[key: string]: ''} = {}; + for (const key of data) + obj[key as string] = ''; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + return this.collection().updateMany(query, { $unset: obj }); + } push (query: Filter, data: UpdateFilter, options?: UpdateOptions) { - ({ query } = this._handleData(query)); + ({ query } = this.#handleData(query)); return this.collection().updateOne(query, { $push: data }, options); // return new Promise((resolve, reject) => // { @@ -144,15 +146,15 @@ class MongodbTable extends Table count (query: Filter, options?: CountDocumentsOptions) { - ({ query } = this._handleData(query)); + ({ query } = this.#handleData(query)); return this.collection().countDocuments(query, options); } - _handleData(query: T, options: UpdateOptions = {}): { query: T, options: UpdateOptions } + #handleData(query: T, options: UpdateOptions = {}): { query: T, options: UpdateOptions } { // Convert data._id to Mongo ObjectIds if ('_id' in query && !(query._id instanceof ObjectId)) { - if (typeof query._id === 'string') + if (typeof query._id === 'string' && query._id.length === 24) query._id = new ObjectId(query._id); else if (query._id instanceof Array) query._id = { diff --git a/src/client/storage/interfaces/Provider.ts b/src/client/storage/interfaces/Provider.ts index 1658e80..8557093 100644 --- a/src/client/storage/interfaces/Provider.ts +++ b/src/client/storage/interfaces/Provider.ts @@ -33,7 +33,7 @@ abstract class Provider implements Initialisable #tables: { [key: string]: Table }; - protected _initialised: boolean; + protected _ready: boolean; #class: typeof Table; #logger: LoggerClient; @@ -51,10 +51,16 @@ abstract class Provider implements Initialisable this.#tables = {}; - this._initialised = false; + this._ready = false; this.#class = Constants.Tables[opts.name]; } + get ready () + { + return this._ready; + } + + abstract stop(): void | Promise; abstract initialise(): Promise async loadTables () @@ -124,7 +130,7 @@ abstract class Provider implements Initialisable get initialised () { - return this._initialised; + return this._ready; } protected get config () diff --git a/src/client/storage/providers/MariaDBProvider.ts b/src/client/storage/providers/MariaDBProvider.ts index f22b847..5e5a90c 100644 --- a/src/client/storage/providers/MariaDBProvider.ts +++ b/src/client/storage/providers/MariaDBProvider.ts @@ -126,6 +126,11 @@ class MariaDBProvider extends Provider } + stop () + { + return this.close(); + } + async close () { this.logger.status('Shutting down database connections'); diff --git a/src/client/storage/providers/MongoDBProvider.ts b/src/client/storage/providers/MongoDBProvider.ts index ff8470c..3816c08 100644 --- a/src/client/storage/providers/MongoDBProvider.ts +++ b/src/client/storage/providers/MongoDBProvider.ts @@ -1,5 +1,6 @@ +import { CallbackInfo } from '../../../../@types/CallbackManager.js'; import { InfractionJSON } from '../../../../@types/Client.js'; -import { AttachmentData, CallbackData, GuildData, GuildPermissions, MessageLogEntry, RoleCacheEntry, WebhookEntry, WordWatcherEntry } from '../../../../@types/Guild.js'; +import { AttachmentData, GuildData, GuildPermissions, MessageLogEntry, RoleCacheEntry, WebhookEntry, WordWatcherEntry } from '../../../../@types/Guild.js'; import { UserSettings } from '../../../../@types/Settings.js'; import { MongoDBOptions } from '../../../../@types/Storage.js'; import DiscordClient from '../../DiscordClient.js'; @@ -56,10 +57,15 @@ class MongoDBProvider extends Provider this.logger.info('Initialising connection to DB'); await this.#client!.connect(); this.#db = this.#client!.db(this.#database); - this._initialised = true; + this._ready = true; this.logger.info('DB connected'); } + stop () + { + return this.close(); + } + async close () { if (!this.initialised) @@ -67,7 +73,7 @@ class MongoDBProvider extends Provider this.logger.status('Closing DB connection'); await this.#client?.close(); this.#client?.removeAllListeners(); - this._initialised = false; + this._ready = false; this.#db = null; this.logger.status('Database closed'); } @@ -95,7 +101,7 @@ class MongoDBProvider extends Provider get callbacks () { - return this.tables.callbacks as MongodbTable; + return this.tables.callbacks as MongodbTable>; } get permissions () diff --git a/src/middleware/Controller.ts b/src/middleware/Controller.ts index 5aaac3d..20faef0 100644 --- a/src/middleware/Controller.ts +++ b/src/middleware/Controller.ts @@ -98,7 +98,6 @@ class Controller extends EventEmitter // 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; diff --git a/src/middleware/shard/Shard.ts b/src/middleware/shard/Shard.ts index 48958cf..6aa365e 100644 --- a/src/middleware/shard/Shard.ts +++ b/src/middleware/shard/Shard.ts @@ -239,7 +239,7 @@ class Shard extends EventEmitter if (expectResponse) { - message.id = Util.randomUUID(); + message.id = Util.createUUID(); const timeout = setTimeout(reject, 10_000, [ new Error('Message timeout') ]); this.#awaitingResponse.set(message.id, (msg: IPCMessage) => { diff --git a/src/utilities/Util.ts b/src/utilities/Util.ts index edc86e1..efc6ed9 100644 --- a/src/utilities/Util.ts +++ b/src/utilities/Util.ts @@ -146,7 +146,7 @@ class Util return item.split('_').map((x) => Util.capitalise(x.toLowerCase())).join(''); } - static randomUUID () + static createUUID () { return randomUUID(); } diff --git a/yarn.lock b/yarn.lock index 2503928..096ff10 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1730,6 +1730,16 @@ __metadata: languageName: node linkType: hard +"bufferutil@npm:^4.0.8": + version: 4.0.8 + resolution: "bufferutil@npm:4.0.8" + dependencies: + node-gyp: latest + node-gyp-build: ^4.3.0 + checksum: 7e9a46f1867dca72fda350966eb468eca77f4d623407b0650913fadf73d5750d883147d6e5e21c56f9d3b0bdc35d5474e80a600b9f31ec781315b4d2469ef087 + languageName: node + linkType: hard + "busboy@npm:^1.6.0": version: 1.6.0 resolution: "busboy@npm:1.6.0" @@ -4060,6 +4070,15 @@ __metadata: languageName: node linkType: hard +"nan@npm:^2.18.0": + version: 2.19.0 + resolution: "nan@npm:2.19.0" + dependencies: + node-gyp: latest + checksum: 29a894a003c1954c250d690768c30e69cd91017e2e5eb21b294380f7cace425559508f5ffe3e329a751307140b0bd02f83af040740fa4def1a3869be6af39600 + languageName: node + linkType: hard + "natural-compare-lite@npm:^1.4.0": version: 1.4.0 resolution: "natural-compare-lite@npm:1.4.0" @@ -4097,6 +4116,7 @@ __metadata: "@types/similarity": ^1.2.1 "@typescript-eslint/eslint-plugin": ^5.58.0 "@typescript-eslint/parser": ^5.58.0 + bufferutil: ^4.0.8 chalk: ^5.3.0 common-tags: ^1.8.2 discord.js: ^14.14.1 @@ -4115,6 +4135,8 @@ __metadata: object-hash: ^3.0.0 similarity: ^1.2.1 typescript: ^5.3.2 + utf-8-validate: ^6.0.3 + zlib-sync: ^0.1.9 languageName: unknown linkType: soft @@ -4150,6 +4172,17 @@ __metadata: languageName: node linkType: hard +"node-gyp-build@npm:^4.3.0": + version: 4.8.0 + resolution: "node-gyp-build@npm:4.8.0" + bin: + node-gyp-build: bin.js + node-gyp-build-optional: optional.js + node-gyp-build-test: build-test.js + checksum: b82a56f866034b559dd3ed1ad04f55b04ae381b22ec2affe74b488d1582473ca6e7f85fccf52da085812d3de2b0bf23109e752a57709ac7b9963951c710fea40 + languageName: node + linkType: hard + "node-gyp@npm:latest": version: 9.4.0 resolution: "node-gyp@npm:9.4.0" @@ -5347,6 +5380,16 @@ __metadata: languageName: node linkType: hard +"utf-8-validate@npm:^6.0.3": + version: 6.0.3 + resolution: "utf-8-validate@npm:6.0.3" + dependencies: + node-gyp: latest + node-gyp-build: ^4.3.0 + checksum: 5e21383c81ff7469c1912119ca69d07202d944c73ddd8a54b84dddcc546b939054e5101c78c294e494d206fe93bd43428adc635a0660816b3ec9c8ec89286ac4 + languageName: node + linkType: hard + "util-deprecate@npm:^1.0.1, util-deprecate@npm:~1.0.1": version: 1.0.2 resolution: "util-deprecate@npm:1.0.2" @@ -5538,3 +5581,13 @@ __metadata: checksum: f77b3d8d00310def622123df93d4ee654fc6a0096182af8bd60679ddcdfb3474c56c6c7190817c84a2785648cdee9d721c0154eb45698c62176c322fb46fc700 languageName: node linkType: hard + +"zlib-sync@npm:^0.1.9": + version: 0.1.9 + resolution: "zlib-sync@npm:0.1.9" + dependencies: + nan: ^2.18.0 + node-gyp: latest + checksum: 36605c354b8c56bd44b0035d986ef393ad85c6774854da981a107b832c32b856b45d71529aeeca3de16aa65ed39cf9129250138c487de99cc89f14d5ee65dd2f + languageName: node + linkType: hard