diff --git a/src/constants/Constants.js b/src/constants/Constants.js index c7036f3..072af5d 100644 --- a/src/constants/Constants.js +++ b/src/constants/Constants.js @@ -171,6 +171,8 @@ exports.InfractionOpposites = { REMOVEROLE: 'ADDROLE' }; +exports.TimedInfractions = Object.keys(exports.InfractionOpposites); + exports.InfractionColors = { NOTE: 0xEBEBEB, WARN: 0xffe15c, diff --git a/src/localization/en_gb/commands/en_gb_moderation.lang b/src/localization/en_gb/commands/en_gb_moderation.lang index 7be3cf7..b82b0b6 100644 --- a/src/localization/en_gb/commands/en_gb_moderation.lang +++ b/src/localization/en_gb/commands/en_gb_moderation.lang @@ -433,5 +433,22 @@ No changes have been made to the case. Please respond with the new reason. Timeout in {time} seconds +[COMMAND_EDIT_NO_ARGS] +Must specify at least one property to edit. + [COMMAND_EDIT_NOT_FOUND] -Could not find the infraction. \ No newline at end of file +Could not find the infraction. + +[COMMAND_EDIT_ISSUES] +The command ran into the following errors: +{issues} + +*Note that some of the edits were successful* + +[COMMAND_EDIT_FAILURE] +Failed to edit the infraction. +The following errors occurred: +- {issues} + +[COMMAND_EDIT_SUCCESS] +Successfully edited the infraction. \ No newline at end of file diff --git a/src/localization/en_gb/general/en_gb_moderation.lang b/src/localization/en_gb/general/en_gb_moderation.lang index 814bf73..d5385d6 100644 --- a/src/localization/en_gb/general/en_gb_moderation.lang +++ b/src/localization/en_gb/general/en_gb_moderation.lang @@ -39,6 +39,24 @@ Failed to {infraction} {targetType} {target} because {reason}. [INFRACTION_DESCRIPTIONJUMPTO] **[Jump To {name}]({link})** +[INFRACTION_EDIT_DURATION_RESOLVED] +Cannot edit duration of a resolved infraction. + +[INFRACTION_EDIT_DURATION_CALLEDBACK] +Cannot edit duration of an infraction whose timer expired. + +[INFRACTION_EDIT_INVALID_TARGETTYPE] +Cannot edit expiration of an infraction with non-user target. + +[INFRACTION_EDIT_MODPOINTS_NOT_ON] +Cannot edit modpoints as they are not enabled. + +[INFRACTION_EDIT_EXPIRATION_NOT_ON] +Cannot edit expiration as modpoints are not enabled. + +[INFRACTION_EDIT_DURATION_NOTTIMED] +Cannot edit duration of a non-timed infraction. + //Prune Command [INFRACTION_DESCRIPTIONAMOUNT] **Amount:** {amount} diff --git a/src/structure/components/commands/moderation/Edit.js b/src/structure/components/commands/moderation/Edit.js new file mode 100644 index 0000000..9b53ee9 --- /dev/null +++ b/src/structure/components/commands/moderation/Edit.js @@ -0,0 +1,77 @@ +const { SlashCommand, Infraction } = require("../../../interfaces"); + +class EditCommand extends SlashCommand { + + constructor(client) { + super(client, { + name: 'edit', + description: 'Edit case data', + module: 'moderation', + showUsage: true, + guildOnly: true, + memberPermissions: ['MANAGE_MESSAGES'], + options: [{ + name: 'case', + type: 'INTEGER', + description: 'Case ID (Integer)', + required: true, + minimum: 0 + }, { + name: 'reason', + description: 'Give "long" as a reason to instead enter through a prompt to bypass length limit', + type: 'STRING' + }, { + name: 'points', + type: 'INTEGER', + description: 'New point value for case', + minimum: 0, maximum: 100 + }, { + name: ['expiration', 'duration'], + type: 'TIME', + description: [ + 'New expiration for points, starts from the time the infraction was issued', + 'Duration if the infraction is timed' + ] + }] + }); + } + + async execute(invoker, { case: caseId, reason, points, expiration, duration }) { + + if (!(reason || points || expiration || duration)) return { emoji: 'failure', index: 'COMMAND_EDIT_NO_ARGS' }; + const { guild, member } = invoker; + // const data = await this.client.storageManager.mongodb.infractions.findOne({ id: `${guild.id}:${caseId.value}` }); + const infraction = await new Infraction(this.client, { guild, case: caseId.value }).fetch().catch(() => null); + if(!infraction) return { emoji: 'failure', index: 'COMMAND_EDIT_NOT_FOUND' }; + + const time = 120; + if (reason?.value === 'long') reason = await invoker.promptMessage(invoker.format('COMMAND_EDIT_LONG', { time }), { time }); + else if (reason) reason = reason.value; + + const results = []; + if (reason) results.push(await infraction.editReason(member, reason)); + if (points) results.push(await infraction.editPoints(member, points.value)); + if (expiration) results.push(await infraction.editExpiration(member, expiration.value * 1000)); + if (duration) results.push(await infraction.editDuration(member, duration.value * 1000)); + + const successful = results.filter((result) => !result).length; + const set = new Set(results.filter((result) => Boolean(result)).map((result) => result.index)); + const messages = []; + for (const index of set) { + messages.push(invoker.format(index)); + } + + if (successful) { + await infraction.updateMessages(); + await infraction.save(); + } + + if (messages.length && successful) return { emoji: 'warning', index: 'COMMAND_EDIT_ISSUES', params: { issues: messages.join('\n') } }; + else if (!successful) return { emoji: 'failure', index: 'COMMAND_EDIT_FAILURE', params: { issues: messages.join('\n- ') } }; + return { emoji: 'success', index: 'COMMAND_EDIT_SUCCESS' }; + + } + +} + +module.exports = EditCommand; \ No newline at end of file diff --git a/src/structure/interfaces/Infraction.js b/src/structure/interfaces/Infraction.js index 6d2abe1..03d891e 100644 --- a/src/structure/interfaces/Infraction.js +++ b/src/structure/interfaces/Infraction.js @@ -1,8 +1,10 @@ +const { ObjectId } = require('mongodb'); const { Constants: { InfractionTargetTypes, InfractionDictionary, - InfractionColors + InfractionColors, + TimedInfractions } } = require('../../constants'); @@ -19,8 +21,8 @@ class Infraction { this.client = client; this.type = data.type || null; - this.id = null; - this.case = null; + // this.id = null; + this.case = data.case || null; this.arguments = data.arguments || {}; @@ -44,20 +46,20 @@ class Infraction { this.resolved = false; this.duration = isNaN(data.duration) ? null : data.duration; //How long the action will last. Must be in milliseconds. - this.callback = isNaN(data.duration) ? null : Date.now() + data.duration * 1000; //At what epoch(?) time it will callback. + // this.callback = isNaN(data.duration) ? null : Date.now() + data.duration; //At what time it will callback. this.reason = data.reason || 'N/A'; this.points = data.points || 0; - this.expiration = isNaN(data.expiration) ? null : Date.now() + data.expiration * 1000; + this.expiration = data.expiration > 0 ? Date.now() + data.expiration : null; // Time when the points expire in milliseconds this.totalPoints = 0; this.data = data.data || {}; //Miscellaneous data that may need to be saved for future use. this.flags = data.arguments ? Object.keys(data.arguments) : []; this.hyperlink = data.hyperlink || null; // To overwrite hyperlink (if it's from a callback) - this.modlogMessageId = null; - this.dmlogMessageId = null; + this.modLogMessageId = null; + this.dmLogMessageId = null; this.modlogId = null; this.changes = []; @@ -65,12 +67,15 @@ class Infraction { this.timestamp = Date.now(); this._callbacked = Boolean(data._callbacked); - this._fetched = Boolean(data); + this._fetched = false; //Boolean(data); + this._mongoId = null; } async handle() { + // Infraction was fetched from database, i.e. was already executed previously + if (this._fetched) throw new Error(`Cannot execute a fetched Infraction`); //NOTE: Temporary logging, making sure there isn't any other issues. if (typeof this.reason !== 'string') this.client.logger.error(`Infraction type ${this.type} was passed an invalid type to the reason.`); @@ -94,7 +99,7 @@ class Infraction { this.modlogId = this._moderationLog.id; try { this._logMessage = await this._moderationLog.send({ embeds: [this._embed()] }); - this.modlogMessageId = this._logMessage.id; + this.modLogMessageId = this._logMessage.id; } catch (e) { } //eslint-disable-line no-empty } else { this.client.logger.debug(`Did not log infraction ${this.type} because it is not in the infractions.`); @@ -111,16 +116,17 @@ 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... try { - const logMessage = await this.target.send(message, { + const logMessage = await this.target.send({ + content: message, embeds: [this._embed(true)] }); - this.dmlogMessageId = logMessage.id; + this.dmLogMessageId = logMessage.id; } catch (e) { } //eslint-disable-line no-empty } } if (this.duration) { - await this.client.moderationManager._handleExpirations([this.json]); + await this.client.moderationManager.handleCallbacks([this.json]); } /* LMAOOOO PLEASE DONT JUDGE ME */ @@ -133,14 +139,16 @@ class Infraction { } async save() { - return this.client.storageManager.mongodb.infractions.insertOne(this.json) + const filter = { id: this.id }; + if(this._mongoId) filter._id = this._mongoId; + return this.client.storageManager.mongodb.infractions.updateOne(filter, this.json) .catch((error) => { this.client.logger.error(`There was an issue saving infraction data to the database.\n${error.stack || error}`); }); } hyperlink(bool = false) { - if (bool) return `https://discord.com/channels/${this.guildId}/${this.modlogId}/${this.modlogMessageId}`; + if (bool) return `https://discord.com/channels/${this.guildId}/${this.modlogId}/${this.modLogMessageId}`; return `https://discord.com/channels/${this.guildId}/${this.channelId}/${this.messageId}`; } @@ -156,7 +164,7 @@ class Infraction { })}`; if (this.duration) { - description += `\n${this.guild.format('INFRACTION_DESCRIPTIONDURATION', { duration: Util.duration(this.duration) })}`; + description += `\n${this.guild.format('INFRACTION_DESCRIPTIONDURATION', { duration: Util.humanise(Math.floor(this.duration / 1000)) })}`; } if (this.points && this.points > 0) { //TODO: Add expiration to INFRACTION_DESCRIPTIONPOINTS @@ -195,17 +203,22 @@ class Infraction { } + get callback() { + if (isNaN(this.duration)) return null; + return this.duration + this.timestamp; + } + get json() { return { - id: `${this.guildId}:${this.case}`, + id: this.id, guild: this.guildId, channel: this.channelId, - channelName: this.channel?.display, + channelName: this.channel?.name, message: this.messageId, executor: this.executorId, - executorTag: this.executor.display, + executorTag: this.executor.tag || this.executor.user?.tag, target: this.targetId, - targetTag: this.target.display || this.target.username || this.target.name, + targetTag: this.target.tag || this.target.user?.tag || this.target.name, targetType: this.targetType, type: this.type, case: this.case, @@ -217,8 +230,8 @@ class Infraction { flags: this.flags, points: this.points, expiration: this.expiration, - modLogMessage: this.modlogMessageId, - dmLogMessage: this.dmlogMessageId, + modLogMessage: this.modLogMessageId, + dmLogMessage: this.dmLogMessageId, modLogChannel: this.modlogId, resolved: this.resolved, changes: this.changes, @@ -226,6 +239,10 @@ class Infraction { }; } + get id() { + return `${this.guildId}:${this.case}`; + } + get targetIcon() { return this.targetType === 'USER' ? this.target.displayAvatarURL() @@ -299,68 +316,148 @@ class Infraction { return this._succeed(); } - // async fetch() { //Data from Mongodb (id-based data) - // const data = await this.client.storageManager.mongodb.infractions.findOne({ id: this.id }); - // if(!data) { - // this.client.logger.error(`Case ${this.id} is missing infraction data in database.`); - // return null; - // } + _error(reason) { + if (!this._fetched) throw new Error(reason || `Cannot edit an unfetched infraction`); + } - // if(data.guild) { - // let guild = null; - // try { - // guild = await this.client.guilds.fetch(data.guild); - // } catch(error) { - // this.client.logger.error(`Unable to fetch guild: ${data.guild}\n${error.stack || error}`); - // guild = null; - // } - // if(!guild) return null; - // if(data.targets) { - // this.targetIds = data.targets; - // for(const target of data.targets) { - // const fetchedTarget = await this._fetchTarget(target); - // if(fetchedTarget) this.targets.push(fetchedTarget); - // } - // } - // if(data.executor) { - // this.executorId = data.executor; - // const fetchedExecutor = await this._fetchTarget(data.executor, 'USER'); - // if(fetchedExecutor) this.executor = fetchedExecutor; - // } - // } + async updateMessages() { + this._error(`Cannot update messages for unfetched infractions`); + if (this._dmLogMessage) await this._dmLogMessage.edit({ embeds: [this._embed(true)] }); + if (this._modLogMessage) await this._modLogMessage.edit({ embeds: [this._embed()] }); + } - // this.type = data.type; - // this.timestamp = data.timestamp; - // this.duration = data.duration; - // this.reason = data.reason; - // this.channelId = data.channel; - // this.resolved = data.resolved; - // this._callbacked = data._callbacked; + async editReason(staff, reason) { + this._error(); + const log = { + reason: this.reason, + staff: staff.id, + timestamp: Date.now(), + type: 'REASON' + }; + this.reason = reason; + this.changes.push(log); + } - // this.dictionary = InfractionDictionary[this.type]; - // this.color = InfractionColors[this.type]; + async editPoints(staff, points) { + this._error(); + if (this.targetType !== 'USER') return { error: true, index: 'INFRACTION_EDIT_INVALID_TARGETTYPE' }; + if (!this.guild._settings.modpoints.enabled) return { error: true, index: 'INFRACTION_EDIT_MODPOINTS_NOT_ON' }; + const log = { + points: this.points, + staff: staff.id, + timestamp: Date.now(), + type: 'POINTS' + }; + const diff = points - this.points; + this.points = points; + const userWrapper = await this.client.getUserWrapper(this.target.id); + this.totalPoints = await userWrapper.editPoints(this.guild, { diff, id: this.id, expiration: this.expiration }); + this.changes.push(log); + } - // this.modlogMessageId = data.modlogMessageId; - // this.dmlogMessageId = data.dmlogMessageId; + async editExpiration(staff, expiration) { + this._error(); + if (this.targetType !== 'USER') return { error: true, index: 'INFRACTION_EDIT_INVALID_TARGETTYPE' }; + if (!this.guild._settings.modpoints.enabled) return { error: true, index: 'INFRACTION_EDIT_EXPIRATION_NOT_ON' }; + // const now = Date.now(); + const log = { + type: 'EXPIRATION', + staff: staff.id, + timestamp: Date.now(), + expiration: this.expiration ? this.expiration - this.timestamp : null + }; + this.expiration = expiration + this.timestamp; + const userWrapper = await this.client.getUserWrapper(this.target.id); + this.totalPoints = await userWrapper.editExpiration(this.guild, { + id: this.id, + expiration: this.expiration, + points: this.points + }); + this.changes.push(log); + } - // this._fetched = Boolean(data._fetched); - // return this; + async editDuration(staff, duration) { + this._error(); + if (this.resolved) return { error: true, index: 'INFRACTION_EDIT_DURATION_RESOLVED' }; + 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(); + const log = { + type: 'DURATION', + staff: staff.id, + timestamp: now, + duration: this.duration + }; + const member = await this.guild.memberWrapper(this.target); + const callback = await member.getCallback(this.type, true); - // } + this.duration = duration; + if(callback) await this.client.moderationManager.removeCallback(callback); + await this.client.moderationManager.handleCallbacks([this.json]); - // async _fetchTarget(target, type = null) { - // type = type || this.targetType; - // let fetched = null; - // if(type === 'CHANNEL') { - // fetched = await this.client.resolver.resolveChannel(target, true, this.guild); - // } else if (type) { - // fetched = await this.client.resolver.resolveMember(target, true, this.guild); - // if(!fetched) { - // fetched = await this.client.resolver.resolveUser(target, true); - // } - // } - // return fetched || null; - // } + this.changes.push(log); + } + + async fetch() { //Data from Mongodb (id-based data) + const data = await this.client.storageManager.mongodb.infractions.findOne({ id: this.id }); + if(!data) throw new Error('No such case'); + + this._mongoId = ObjectId(data._id); + this._callbacked = data._callbacked; + + this.targetType = data.targetType; + this.targetId = data.target; + this.target = await this._fetchTarget(data.target); + + this.executorId = data.executor; + this.executor = await this.client.users.fetch(data.executor).catch(() => null); + + this.type = data.type; + + this.timestamp = data.timestamp; + this.duration = data.duration; + // this.callback = data.callback; + + this.expiration = data.expiration; + this.points = data.points; + + this.reason = data.reason; + this.resolved = data.resolved; + this.changes = data.changes; + + this.channelId = data.channel; + this.channel = { name: data.channelName }; + + this.data = data.data; + this.flags = data.flags; + + this.modLogMessageId = data.modLogMessage; + this.dmLogMessageId = data.dmLogMessage; + this.messageId = data.message; + this.modlogId = data.modLogChannel; + const logChannel = await this.guild.resolveChannel(data.modLogChannel).catch(() => null); + if (logChannel) this._modLogMessage = await logChannel.messages.fetch(data.modLogMessage).catch(() => null); + const dm = await this.target.createDM().catch(() => null); + if (dm) this._dmLogMessage = await dm.messages.fetch(data.dmLogMessage).catch(() => null); + + this._fetched = true; + return this; + + } + + async _fetchTarget(target, type = null) { + type = type || this.targetType; + let fetched = null; + if(type === 'CHANNEL') { + fetched = await this.client.resolver.resolveChannel(target, true, this.guild); + } else if (type) { + fetched = await this.client.resolver.resolveMember(target, true, this.guild); + if(!fetched) { + fetched = await this.client.resolver.resolveUser(target, true); + } + } + return fetched || null; + } }