Skip to content
2 changes: 0 additions & 2 deletions .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ commands/manualVerify.js
commands/manualVetVerify.js
commands/memes.js
commands/modmailBlacklist.js
commands/mute.js
commands/parsemembers.js
commands/permaSuspend.js
commands/poll.js
Expand All @@ -44,7 +43,6 @@ commands/suspendremove.js
commands/sysinfo.js
commands/test.js
commands/unlock.js
commands/unmute.js
commands/unverify.js
commands/unvetverify.js
commands/updateIP.js
Expand Down
334 changes: 253 additions & 81 deletions commands/mute.js

Large diffs are not rendered by default.

45 changes: 23 additions & 22 deletions commands/punishments.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
/* eslint-disable no-bitwise */
/* eslint-disable no-await-in-loop */
const { EmbedBuilder, Colors, ActionRowBuilder, ButtonBuilder, ButtonStyle, StringSelectMenuBuilder,
StringSelectMenuOptionBuilder, InteractionCollector, ComponentType } = require('discord.js');
const SlashArgType = require('discord-api-types/v10').ApplicationCommandOptionType;
const { slashArg, slashCommandJSON } = require('../utils.js');
const moment = require('moment');

/** @typedef {{ id: string }} HasID */
/**
Expand Down Expand Up @@ -35,11 +37,13 @@ const { slashArg, slashCommandJSON } = require('../utils.js');
* @typedef MuteRow
* @property {string} id
* @property {string} guildid
* @property {boolean} muted
* @property {string} reason
* @property {string} modid
* @property {string} uTime
* @property {boolean} perma
* @property {string} reason
* @property {number} appliedOn
* @property {number} duration
* @property {number?} removedOn
* @property {string?} removedBy
* @property {string?} removeReason
*/
/**
* @param {WarnRow[]} rows
Expand All @@ -53,16 +57,7 @@ function flattenOnId(rows) {
}, {});
}

/**
* @param {boolean} permanent
* @param {number} ending unix time
* @param {number?} ended unix time
* @returns {string}
*/
function timeString(permanent, ending, ended) {
if (!ended && permanent) return 'Permanently';
return `<t:${ended || ending}:R> at <t:${ended || ending}:f>`;
}
const timestamp = (time) => `<t:${time}:R> at <t:${time}:f>`;

class PunishmentsUI {
/** @type {string[]} */
Expand Down Expand Up @@ -142,8 +137,11 @@ class PunishmentsUI {
this.#suspensions = flattenOnId(suspensions);
}
if (position >= cache.get(roles[rolePermissions.punishmentsMutes]).position && punishmentsMutes) {
const [mutes] = await db.promise().query('SELECT * FROM mutes WHERE id in (?) AND guildid = ?', [ids, this.#guild.id]);
this.#mutes = flattenOnId(mutes);
const [mutes] = await db.promise().query('SELECT * FROM mutes WHERE id in (?) AND guildid = ? ORDER BY appliedOn + duration DESC', [ids, this.#guild.id]);
const permas = mutes.filter(mute => !mute.removedOn && !mute.duration);
const actives = mutes.filter(mute => !permas.includes(mute) && !mute.removedOn);
const remaining = mutes.filter(mute => !permas.includes(mute) && !actives.includes(mute));
this.#mutes = flattenOnId([...permas, ...actives, ...remaining]); // sort by perma > active > inactive, flattenOnId respects local order
}
const joined = [...Object.values(this.#warns), ...Object.values(this.#mutes), ...Object.values(this.#suspensions)].flat();
if (full !== false && (full || joined.length < 20)) {
Expand Down Expand Up @@ -304,8 +302,7 @@ class PunishmentsUI {
let i = 1;
for (; i <= rows.length; i++) {
const row = rows[i - 1];
const time = timeString(false, (row.time / 1000).toFixed(0));
const text = `\`${(i).toString().padStart(3, ' ')}\`${row.silent ? ' *Silently*' : ''} By <@!${row.modid}> ${time}\`\`\`${row.reason}\`\`\`\n`;
const text = `\`${(i).toString().padStart(3, ' ')}\`${row.silent ? ' *Silently*' : ''} By <@!${row.modid}> ${timestamp((row.time / 1000) ^ 0)}\`\`\`${row.reason}\`\`\`\n`;
if (embed.length + fields.map(f => f.length).reduce((a, c) => a + c, 0) + text.length >= 5600) break;
if (fields[fields.length - 1].length + text.length >= 800) fields.push('');
fields[fields.length - 1] += text;
Expand All @@ -330,8 +327,7 @@ class PunishmentsUI {
let i = 1;
for (; i <= rows.length; i++) {
const row = rows[i - 1];
const time = timeString(row.perma, (parseInt(row.uTime) / 1000).toFixed(0));
const text = `\`${(i).toString().padStart(3, ' ')}\`${row.suspended ? ' **Active**' : ''} By <@!${row.modid}> ${time}\`\`\`${row.reason}\`\`\`\n`;
const text = `\`${(i).toString().padStart(3, ' ')}\`${row.suspended ? ' **Active**' : ''} By <@!${row.modid}> ${row.perma ? 'Permanently' : timestamp((parseInt(row.uTime) / 1000) ^ 0)}\`\`\`${row.reason}\`\`\`\n`;
if (embed.length + fields.map(f => f.length).reduce((a, c) => a + c, 0) + text.length >= 5600) break;
if (fields[fields.length - 1].length + text.length >= 800) fields.push('');
fields[fields.length - 1] += text;
Expand All @@ -356,8 +352,13 @@ class PunishmentsUI {
let i = 1;
for (; i <= rows.length; i++) {
const row = rows[i - 1];
const time = timeString(row.perma, (row.uTime / 1000).toFixed(0));
const text = `\`${(i).toString().padStart(3, ' ')}\`${row.muted ? ' **Active**' : ''} By <@!${row.modid}> ${time}\`\`\`${row.reason}\`\`\`\n`;
let timestr = '';
if (!row.duration && !row.removedOn) timestr = 'Permanently';
else if (!row.duration) timestr = 'was Permanent';
else if (row.duration == -1) timestr = `Unknown duration\nEnded before <t:${row.appliedOn}:f>`;
else timestr = `for ${moment.duration(row.duration * 1000).humanize()}\nApplied on <t:${row.appliedOn}:f>`;
const reason = row.duration != -1 && row.removedOn ? `\`\`\`\n${row.reason}\`\`\`${row.overwritten ? 'Overwritten' : 'Removed'} <t:${row.removedOn}:R> by <@!${row.removedBy}> \`\`\`\n${row.removeReason}\`\`\`` : `\`\`\`${row.reason}\`\`\``;
const text = `\`${(i).toString().padStart(3, ' ')}\`${!row.removedOn ? ' **Active**' : ''} By <@!${row.modid}> ${timestr} ${reason}\n`;
if (embed.length + fields.map(f => f.length).reduce((a, c) => a + c, 0) + text.length >= 5600) break;
if (fields[fields.length - 1].length + text.length >= 800) fields.push('');
fields[fields.length - 1] += text;
Expand Down
3 changes: 1 addition & 2 deletions commands/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ module.exports = {
guildspecific: true,
role: 'developer',
async execute(message, args, bot, db) {
const repliedMessage = "Toast!!!!!"
await message.reply(repliedMessage)
message.reply('This is a test 1 2 3 one two three I II III')
}
}
160 changes: 102 additions & 58 deletions commands/unmute.js
Original file line number Diff line number Diff line change
@@ -1,71 +1,115 @@
const Discord = require('discord.js')
const ErrorLogger = require('../lib/logError')
const fs = require('fs')
const SlashArgType = require('discord-api-types/v10').ApplicationCommandOptionType;
const { slashArg, slashChoices, slashCommandJSON } = require('../utils.js')
const { EmbedBuilder, Colors } = require('discord.js');
const ErrorLogger = require('../lib/logError');
const { ApplicationCommandOptionType } = require('discord-api-types/v10');
const { slashArg, slashCommandJSON } = require('../utils.js');
const moment = require('moment');

/**
*
* @param {number} duration
* @returns {string}
*/
function durationString(duration, when = Date.unix()) {
if (!duration) return 'Permanent';
duration = parseInt(duration);
when = parseInt(when);
return `${moment.duration(duration * 1000).humanize()} ending <t:${when + duration}:R> at <t:${when + duration}:f>`;
}

module.exports = {
name: 'unmute',
description: 'Removes muted role from user',
args: '<ign/mention/id>',
requiredArgs: 1,
role: 'security',
varargs: true,
args: [
slashArg(SlashArgType.User, 'member', {
description: "Member in the Server"
slashArg(ApplicationCommandOptionType.User, 'member', {
description: 'Member in the Server'
}),
slashArg(ApplicationCommandOptionType.String, 'reason', {
description: 'Reason for the unmute',
required: false
})
],
getSlashCommandData(guild) {
return slashCommandJSON(this, guild)
return slashCommandJSON(this, guild);
},
async execute(message, args, bot, db) {
let settings = bot.settings[message.guild.id]
var member = message.mentions.members.first()
if (!member) member = message.guild.members.cache.get(args[0]);
if (!member) member = message.guild.members.cache.filter(user => user.nickname != null).find(nick => nick.nickname.replace(/[^a-z|]/gi, '').toLowerCase().split('|').includes(args[0].toLowerCase()));
if (!member) { message.reply('User not found. Please try again'); return; }
if (member.roles.highest.position >= message.member.roles.highest.position) return message.reply(`${member} has a role greater than or equal to you and cannot be unmuted by you`);
let muted = settings.roles.muted
if (!member.roles.cache.has(muted)) {
message.reply(`${member} is not muted`)
return;

/**
*
* @param {import('../utils.js').BotCommandInteraction} interaction
* @param {string[]} args unused
* @param {import('discord.js').Client} bot
* @param {import('mysql2').Pool} db
*/
async execute(interaction, args, bot, db) {
const settings = bot.settings[interaction.guild.id];
const member = interaction.options.getMember('member');
const reason = [interaction.options.getString('reason'), ...interaction.options.getVarargs()].join(' ') || 'No reason provided';

const [[row]] = await db.promise().query('SELECT * FROM mutes WHERE id = ? AND guildid = ? AND removedOn IS NULL', [member.id, member.guild.id]);
const embed = new EmbedBuilder()
.setFooter({ text: `${interaction.member.displayName}`, iconURL: interaction.member.displayAvatarURL() })
.setAuthor({ name: member.displayName, iconURL: member.displayAvatarURL() })
.setTitle('Unmute')
.setTimestamp()
.setColor(Colors.Red);
if (!row && !member.roles.cache.has(settings.roles.muted)) {
embed.setDescription(`${member} does not have the muted role and doesn't have any active mutes with ${interaction.client.user}.`);
return await interaction.reply({ embeds: [embed] });
}
db.query(`SELECT * FROM mutes WHERE id = '${member.id}' AND muted = true`, async (err, rows) => {
if (err) ErrorLogger.log(err, bot, message.guild)
if (!rows || rows.length == 0) {
let embed = new Discord.EmbedBuilder()
.setTitle('Confirm Action')
.setColor('#ff0000')
.setDescription(`I don't have any log of ${member} being muted. Are you sure you want to unmute them?`)
let confirmMessage = await message.channel.send({ embeds: [embed] })
let reactionCollector = new Discord.ReactionCollector(confirmMessage, { filter: (r, u) => !u.bot && u.id == message.author.id && (r.emoji.name === '✅' || r.emoji.name === '❌') })
reactionCollector.on('collect', async (r, u) => {
confirmMessage.delete()
if (r.emoji.name !== '✅') return;
member.roles.remove(muted).catch(er => ErrorLogger.log(er, bot, message.guild))
message.channel.send(`${member} has been unmuted`)
})
await confirmMessage.react('✅')
await confirmMessage.react('❌')
} else {
const reason = rows[0].reason
const unmuteUTime = parseInt(rows[0].uTime)
let embed = new Discord.EmbedBuilder()
.setTitle('Confirm Action')
.setColor('#ff0000')
.setDescription(`Are you sure you want to unmute ${member}\nReason: ${reason}\nMuted by <@!${rows[0].modid}>\nUnmute: <t:${(unmuteUTime/1000).toFixed(0)}:R> at <t:${(unmuteUTime/1000).toFixed(0)}:f>`)
let confirmMessage = await message.channel.send({ embeds: [embed] })
let reactionCollector = new Discord.ReactionCollector(confirmMessage, { filter: (r, u) => !u.bot && u.id == message.author.id && (r.emoji.name === '✅' || r.emoji.name === '❌') })
await confirmMessage.react('✅')
await confirmMessage.react('❌')
reactionCollector.on('collect', async (r, u) => {
confirmMessage.delete()
if (r.emoji.name !== '✅') return;
await member.roles.remove(muted).catch(er => ErrorLogger.log(er, bot, message.guild))
message.reply(`${member} has been unmuted`)
db.query(`UPDATE mutes SET muted = false WHERE id = '${member.id}'`)
})
const rpmrole = member.guild.roles.cache.get(settings.roles[settings.rolePermissions.removePermanentMute]);
if (rpmrole && member.roles.highest.position < rpmrole.position) {
embed.setDescription(`You must have ${rpmrole} or higher to remove a permanent mute.`);
return await interaction.reply({ embeds: [embed] });
}
await this.unmute(interaction, interaction.guild, interaction.member, member, settings, db, row, reason);
},

/**
*
* @param {import('../utils.js').BotCommandInteraction?} interaction
* @param {import('discord.js').GuildMember} moderator
* @param {import('discord.js').GuildMember?} member
* @param {*} settings
* @param {import('mysql2').Pool} db
* @param {import('./mute.js').MuteRow} row
* @param {string} reason
*/
async unmute(interaction, guild, moderator, member, settings, db, row, reason) {
const embed = new EmbedBuilder()
.setColor(Colors.Green)
.setAuthor({ name: member.displayName, iconURL: member.displayAvatarURL() })
.setTitle('Unmute')
.setTimestamp()
.setFooter({ text: `${moderator.displayName}`, iconURL: moderator.displayAvatarURL() })
.addFields({ name: 'Member', value: member ? `${member}\n\`${member.displayName}\`` : `<@!${row.id}> (not in server)`, inline: true },
{ name: 'Moderator', value: `${moderator}\n\`${moderator.displayName}\``, inline: true },
{ name: 'Unmute Time', value: `<t:${Date.unix()}:f>`, inline: true },
{ name: 'Unmute Reason', value: reason });

if (row) {
await db.promise().query('UPDATE mutes SET removedOn = unix_timestamp(), removedBy = ?, removeReason = ? WHERE id = ? AND guildid = ? AND removedOn IS NULL',
[moderator.id, reason, row.id, guild.id]);

const mod = guild.members.cache.get(row.modid);
embed.addFields({ name: 'Muted By', value: mod ? `${mod}\n\`${mod.displayName}\`` : `<@!${row.modid}>`, inline: true },
{ name: 'Muted On', value: `<t:${row.appliedOn}:R>\n<t:${row.appliedOn}:f>`, inline: true },
{ name: 'Mute Duration', value: durationString(row.duration, row.appliedOn), inline: true },
{ name: 'Mute Reason', value: row.reason });
} else {
embed.addFields({ name: 'Unmanaged', value: `<@!${member.id}> had the <@&${settings.roles.muted}> role but it was not managed by ${guild.client.user}.` });
}

try {
if (member) {
await member.roles.remove(settings.roles.muted);
const dm = await member.createDM().catch(() => {});
await dm?.send({ embeds: [embed] }).catch(() => {});
}
})
if (interaction) await interaction.reply({ embeds: [embed] });
await guild.channels.cache.get(settings.channels.modlogs).send({ embeds: [embed] });
} catch (e) {
ErrorLogger.log(e, guild.client, guild);
}
}
}
};
2 changes: 1 addition & 1 deletion dbSetup.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ class DbWrap {
let error = null;
let rv = null;
try {
rv = await dbPromise.query(query, params, cb).catch(() => {});
rv = await dbPromise.query(query, params, cb);
} catch (e) {
error = e;
}
Expand Down
35 changes: 17 additions & 18 deletions jobs/mute.js
Original file line number Diff line number Diff line change
@@ -1,26 +1,25 @@
const { RepeatedJob } = require('./RepeatedJob.js');
const ErrorLogger = require('../lib/logError');
const { iterServersWithQuery } = require('./util.js');
const { iterServers } = require('./util.js');
const { getDB } = require('../dbSetup.js');

const { unmute } = require('../commands/unmute.js');
class Mute extends RepeatedJob {
async run(bot) {
await iterServersWithQuery(bot, 'SELECT * FROM mutes WHERE muted = true', async (bot, row, g) => {
if (Date.now() > parseInt(row.uTime)) {
const guildId = row.guildid;
const settings = bot.settings[guildId];
const guild = bot.guilds.cache.get(guildId);
if (guild) {
const db = getDB(g.id);
const member = guild.members.cache.get(row.id);
if (!member) return await db.promise().query('UPDATE mutes SET muted = false WHERE id = ?', [row.id]);
try {
await member.roles.remove(settings.roles.muted);
await db.promise().query('UPDATE mutes SET muted = false WHERE id = ?', [row.id]);
} catch (er) {
ErrorLogger.log(er, bot, g);
}
await iterServers(bot, async (bot, guild) => {
const ids = [];
const settings = bot.settings[guild.id];
const db = getDB(guild.id);
const botMember = guild.members.cache.get(bot.user.id);
const [rows] = await db.promise().query('SELECT * FROM mutes WHERE guildid = ? AND removedOn IS NULL', [guild.id]);
for (const row of rows) {
const member = guild.members.cache.get(row.id);
if (ids.includes(row.id)) continue;
if (parseInt(row.appliedOn) + parseInt(row.duration) > Date.unix() || !row.duration) {
if (!member?.roles.cache.has(settings.roles.muted)) member?.roles.add(settings.roles.muted);
continue;
}
ids.push(row.id); // In case db ends up with multiple rows with removedOn = null
// eslint-disable-next-line no-await-in-loop
await unmute(null, guild, botMember, member, settings, db, row, 'Mute expired');
}
});
}
Expand Down
2 changes: 2 additions & 0 deletions lib/extensions.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ module.exports = {
createEmbed
};

Date.unix = function() { return (Date.now() / 1000)^0 };

/* Promise.wait = Promise.wait || function(time) {
let waiter, rej;
return { finished: new Promise((resolve, reject) => { rej = reject; waiter = setTimeout(resolve, time) }), cancel: () => { clearTimeout(waiter); rej(new Error('Wait cancelled.')); } };
Expand Down
Loading