diff --git a/src/structure/components/commands/moderation/Resolve.js b/src/structure/components/commands/moderation/Resolve.js new file mode 100644 index 0000000..21505fe --- /dev/null +++ b/src/structure/components/commands/moderation/Resolve.js @@ -0,0 +1,44 @@ +const { SlashCommand, Infraction } = require("../../../interfaces"); + +class ResolveCommand extends SlashCommand { + + constructor(client) { + super(client, { + name: 'resolve', + description: 'Resolve infraction(s)', + module: 'moderation', + memberPermissions: ['MANAGE_GUILD'], + options: [{ + name: 'case', + type: 'INTEGER', + description: 'Case ID (Integer)', + required: true, + minimum: 0 + }, { + name: 'reason', + description: 'Reason for resolving case', + type: 'STRING' + }, { + name: 'notify', + description: 'Attempt to notify the user about the resolve, may not always be possible', + type: 'BOOLEAN' + }] // Potentially add another option to enable a range of cases + }); + } + + async execute(invoker, { case: caseId, reason, notify }) { + + const { guild, member } = invoker; + const infraction = await new Infraction(this.client, { guild, case: caseId.value }).fetch(true);//.catch(() => null); + if (!infraction) return { emoji: 'failure', index: 'INFRACTION_NOT_FOUND' }; + if (infraction.resolved) return { emoji: 'failure', index: 'INFRACTION_ALREADY_RESOLVED' }; + + const response = await infraction.resolve(member, reason?.value || null, notify?.value || false); + if (response?.error) return { emoji: 'warning', index: 'COMMAND_RESOLVE_WARNING', params: { message: response.message } }; + return { emoji: 'success', index: 'COMMAND_RESOLVE_SUCCESS' }; + + } + +} + +module.exports = ResolveCommand; \ No newline at end of file diff --git a/src/structure/components/infractions/Ban.js b/src/structure/components/infractions/Ban.js index a3071e7..f750d4c 100644 --- a/src/structure/components/infractions/Ban.js +++ b/src/structure/components/infractions/Ban.js @@ -8,7 +8,8 @@ class BanInfraction extends Infraction { constructor(client, opts = {}) { - super(client, { + if (opts.fetched) super(client, opts); + else super(client, { targetType: 'USER', type: opts.type, guild: opts.guild, @@ -61,7 +62,7 @@ class BanInfraction extends Infraction { let alreadyBanned = null; try { - alreadyBanned = await this.guild.fetchBan(this.member.id); + alreadyBanned = await this.guild.bans.fetch(this.member.id); } catch (e) { } //eslint-disable-line no-empty if (alreadyBanned) return super._fail('C_BAN_ALREADYBANNED'); @@ -70,6 +71,25 @@ class BanInfraction extends Infraction { } + async resolve(...args) { + const member = await this.guild.memberWrapper(this.targetId); + const callback = await member.getCallback(this.type); + if (callback) this.client.moderationManager.removeCallback(callback); + + const banned = await this.guild.bans.fetch(this.targetId).catch(() => null); + if (banned) { + const inf = await this.client.mongodb.infractions.findOne({ + type: this.type, guild: this.guild.id, target: this.targetId + }, { + sort: { timestamp: -1 }, + projection: { id: 1 } + }); + // Don't unban unless the resolved case is the same one that banned them + if(inf.id === this.id) await this.guild.bans.remove(this.targetId, `Case ${this.case} resolve`); + } + return super.resolve(...args); + } + } module.exports = BanInfraction; \ No newline at end of file diff --git a/src/structure/components/infractions/Mute.js b/src/structure/components/infractions/Mute.js index a8b6f94..804d209 100644 --- a/src/structure/components/infractions/Mute.js +++ b/src/structure/components/infractions/Mute.js @@ -8,26 +8,29 @@ class MuteInfraction extends Infraction { constructor(client, opts = {}) { - super(client, { - targetType: 'USER', - type: opts.type, - invoker: opts.invoker, - executor: opts.executor.user, - target: opts.target.user, - reason: opts.reason, - guild: opts.guild, - channel: opts.channel, - arguments: opts.arguments, - silent: opts.silent, - duration: opts.duration, - points: opts.points, - expiration: opts.expiration, - data: opts.data, - hyperlink: opts.hyperlink - }); + if (opts.fetched) super(client, opts); + else { + super(client, { + targetType: 'USER', + type: opts.type, + invoker: opts.invoker, + executor: opts.executor.user, + target: opts.target.user, + reason: opts.reason, + guild: opts.guild, + channel: opts.channel, + arguments: opts.arguments, + silent: opts.silent, + duration: opts.duration, + points: opts.points, + expiration: opts.expiration, + data: opts.data, + hyperlink: opts.hyperlink + }); - if (!(opts.target instanceof GuildMember)) throw new Error('Guild member required'); - this.member = opts.target; + if (!(opts.target instanceof GuildMember)) throw new Error('Guild member required'); + this.member = opts.target; + } } @@ -47,7 +50,7 @@ class MuteInfraction extends Infraction { this.member.roles.add(role, this._reason); } catch (e) { this.client.logger.debug(`Mute fail, type 1:\n${e.stack}`); - return this._fail('C_MUTE_1FAIL'); + return this._fail('COMMAND_MUTE_1FAIL'); } break; case 1: @@ -63,7 +66,7 @@ class MuteInfraction extends Infraction { ], this._reason); } catch (error) { this.client.logger.error(`Mute infraction failed to calculate removeable roles, might want to check this out.\n${error.stack || error}`); - return this._fail('C_MUTE_2FAIL'); + return this._fail('COMMAND_MUTE_2FAIL'); } break; case 2: @@ -76,7 +79,7 @@ class MuteInfraction extends Infraction { r.id === this.guild.id), this._reason); } catch (error) { this.client.logger.error(`Mute infraction failed to calculate removeable roles, might want to check this out.\n${error.stack || error}`); - return this._fail('C_MUTE_3FAIL'); + return this._fail('COMMAND_MUTE_3FAIL'); } break; } @@ -89,7 +92,6 @@ class MuteInfraction extends Infraction { const callback = this.client.moderationManager.callbacks.filter((c) => c.infraction.type === 'MUTE' && c.infraction.target === this.target.id).first(); - // console.log(callback); if (callback) { this.data.removedRoles = [...new Set([...this.data.removedRoles, ...callback.infraction.data.removedRoles])]; @@ -124,6 +126,60 @@ class MuteInfraction extends Infraction { } + async resolve(...args) { + const inf = await this.client.mongodb.infractions.findOne({ + type: this.type, guild: this.guild.id, target: this.targetId + }, { + sort: { timestamp: -1 }, + projection: { id: 1 } + }); + let message = null, + error = false; + + const { removedRoles, muteType, muteRole } = this.data; + const member = await this.guild.memberWrapper(this.targetId); + const callback = await member.getCallback(this.type); + if (callback) this.client.moderationManager.removeCallback(callback); + + if (inf.id === this.id && member) { + const reason = `Case ${this.case} resolve`; + const roles = [...new Set([...member.roles.cache.map((r) => r.id), ...removedRoles])]; + switch (muteType) { + case 0: + try { + await member.roles.remove(muteRole, reason); + } catch (e) { + this.client.logger.debug(`Mute fail, type 1:\n${e.stack}`); + error = true; + message = this.guild.format('INFRACTION_RESOLVE_MUTE_FAIL1'); + } + 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); + } catch (e) { + this.client.logger.debug(`Mute fail, type 2:\n${e.stack}`); + error = true; + message = this.guild.format('INFRACTION_RESOLVE_MUTE_FAIL23'); + } + break; + case 2: + try { + await member.roles.set(roles, reason); + } catch (e) { + this.client.logger.debug(`Mute fail, type 3:\n${e.stack}`); + error = true; + message = this.guild.format('INFRACTION_RESOLVE_MUTE_FAIL23'); + } + break; + } + } + return { result: await super.resolve(...args), message, error }; + } + } module.exports = MuteInfraction; \ No newline at end of file diff --git a/src/structure/interfaces/Infraction.js b/src/structure/interfaces/Infraction.js index 03d891e..1aa8443 100644 --- a/src/structure/interfaces/Infraction.js +++ b/src/structure/interfaces/Infraction.js @@ -56,7 +56,7 @@ class Infraction { 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._hyperlink = data.hyperlink || null; // To overwrite hyperlink (if it's from a callback) this.modLogMessageId = null; this.dmLogMessageId = null; @@ -106,7 +106,7 @@ class Infraction { } } - if (dminfraction.enabled) { + if (dminfraction.enabled && !this.silent) { if (this.targetType === 'USER') { let message = dminfraction.messages[this.type] || dminfraction.messages.default; if (!message) message = ''; @@ -147,20 +147,35 @@ class Infraction { }); } - hyperlink(bool = false) { - if (bool) return `https://discord.com/channels/${this.guildId}/${this.modlogId}/${this.modLogMessageId}`; + hyperlink(modLogMessage = false) { + if(this._hyperlink) return this._hyperlink; + if (modLogMessage) return `https://discord.com/channels/${this.guildId}/${this.modlogId}/${this.modLogMessageId}`; return `https://discord.com/channels/${this.guildId}/${this.channelId}/${this.messageId}`; } _embed(dm) { + + const embed = { + author: { + name: `${this.target.displayName || this.target.username || this.target.name} (${this.targetId})`, + icon_url: this.targetIcon //eslint-disable-line camelcase + }, + timestamp: this.timestamp, + color: this.color, + footer: { + text: `》 Case ${this.case}` + }, + fields: [] + }; let description = ""; description += `${this.guild.format('INFRACTION_DESCRIPTION', { - type: this.dictionary.past.toUpperCase(), + type: this.dictionary.past.toUpperCase(), moderator: `${Util.escapeMarkdown(this.executor.tag)}`, reason: Util.escapeMarkdown(this.reason.length > Constants.MaxCharacters ? `${this.reason.substring(0, Constants.MaxCharacters - 3)}...` : - this.reason, { italic: false, underline: false, strikethrough: false }) + this.reason, { italic: false, underline: false, strikethrough: false }), + // resolved: this.resolved ? this.guild.format('INFRACTION_RESOLVED') : '' })}`; if (this.duration) { @@ -175,29 +190,30 @@ class Infraction { })}`; } + // Function implemented in subclasses for additional case data if (this.description && this.description instanceof Function) { description += this.description(dm); } - if (!this.silent && (this.message || this.hyperlink)) { - description += `\n${this.guild.format('INFRACTION_DESCRIPTIONJUMPTO', { - name: this.hyperlink ? 'Case' : 'Message', - link: this.hyperlink ? this.hyperlink : `https://discord.com/channels/${this.guildId}/${this.channelId}/${this.messageId}` - })}`; + if (this.resolved) { + description += '\n' + this.guild.format('INFRACTION_RESOLVED'); + if (!dm) { + const resolveReason = this.changes.sort((a, b) => b.timestamp - a.timestamp).find((change) => change.type === 'RESOLVE'); + embed.fields.push({ + name: this.guild.format('INFRACTION_RESOLVE_REASON'), + value: resolveReason.reason || this.guild.format(`INFRACTION_RESOLVE_NO_REASON`) + }); + } } - const embed = { - author: { - name: `${this.target.displayName || this.target.username || this.target.name} (${this.targetId})`, - icon_url: this.targetIcon //eslint-disable-line camelcase - }, - timestamp: this.timestamp, - color: this.color, - footer: { - text: `》 Case ${this.case}` - }, - description - }; + if (!dm && (this.message || this._hyperlink)) { + description += `\n${this.guild.format('INFRACTION_DESCRIPTIONJUMPTO', { + name: this._hyperlink ? 'Case' : 'Message', + link: this._hyperlink || this.hyperlink() //`https://discord.com/channels/${this.guildId}/${this.channelId}/${this.messageId}` + })}`; + } + + embed.description = description; return embed; @@ -398,12 +414,44 @@ class Infraction { this.changes.push(log); } - async fetch() { //Data from Mongodb (id-based data) + async resolve(staff, reason, notify) { + this.changes.push({ + type: 'RESOLVE', + staff: staff.id, + timestamp: Date.now(), + reason + }); + this.resolved = true; + await this.updateMessages(); + await this.save(); + if(notify && this.target) await this.target.send(`Your infraction **#${this.case}** on **${this.guild.name}** was resolved.`); + } + + /** + * @param {boolean} [resolveToRightClass=false] Whether the function should instead return + * @return {*} + * @memberof Infraction + */ + async fetch(resolveToRightClass = false) { //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'); + if (!data) throw new Error('No such case'); + if (resolveToRightClass) { + const InfClass = this.client.moderationManager.infractionClasses[data.type]; + const infraction = new InfClass(this.client, { fetched: true, guild: this.guild, case: this.case }); + await infraction._patch(data); + return infraction; + } + + this._patch(data); + return this; + + } + + async _patch(data) { this._mongoId = ObjectId(data._id); this._callbacked = data._callbacked; + this._fetched = true; this.targetType = data.targetType; this.targetId = data.target; @@ -416,7 +464,6 @@ class Infraction { this.timestamp = data.timestamp; this.duration = data.duration; - // this.callback = data.callback; this.expiration = data.expiration; this.points = data.points; @@ -439,10 +486,7 @@ class Infraction { 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) {