Added handlers and contexts

This commit is contained in:
Toastie 2024-08-13 22:51:58 +12:00
parent 716b402e91
commit 8c4017a59b
Signed by: toastie_t0ast
GPG key ID: 27F3B6855AFD40A4
16 changed files with 2014 additions and 0 deletions

45
src/contexts/avatar.js Normal file
View file

@ -0,0 +1,45 @@
const { EmbedBuilder, ApplicationCommandType } = require("discord.js");
const { EMBED_COLORS } = require("@root/config");
/**
* @type {import('@structures/BaseContext')}
*/
module.exports = {
name: "avatar",
description: "displays avatar information about the user",
type: ApplicationCommandType.User,
enabled: true,
ephemeral: true,
async run(interaction) {
const user = await interaction.client.users.fetch(interaction.targetId);
const response = getAvatar(user);
await interaction.followUp(response);
},
};
function getAvatar(user) {
const x64 = user.displayAvatarURL({ extension: "png", size: 64 });
const x128 = user.displayAvatarURL({ extension: "png", size: 128 });
const x256 = user.displayAvatarURL({ extension: "png", size: 256 });
const x512 = user.displayAvatarURL({ extension: "png", size: 512 });
const x1024 = user.displayAvatarURL({ extension: "png", size: 1024 });
const x2048 = user.displayAvatarURL({ extension: "png", size: 2048 });
const embed = new EmbedBuilder()
.setTitle(`Avatar of ${user.username}`)
.setColor(EMBED_COLORS.BOT_EMBED)
.setImage(x256)
.setDescription(
`Links: • [x64](${x64}) ` +
`• [x128](${x128}) ` +
`• [x256](${x256}) ` +
`• [x512](${x512}) ` +
`• [x1024](${x1024}) ` +
`• [x2048](${x2048}) `
);
return {
embeds: [embed],
};
}

219
src/handlers/automod.js Normal file
View file

@ -0,0 +1,219 @@
const { EmbedBuilder } = require("discord.js");
const { containsLink, containsDiscordInvite } = require("@helpers/Utils");
const { getMember } = require("@schemas/Member");
const { addModAction } = require("@helpers/ModUtils");
const { AUTOMOD } = require("@root/config");
const { addAutoModLogToDb } = require("@schemas/AutomodLogs");
const antispamCache = new Map();
const MESSAGE_SPAM_THRESHOLD = 3000;
// Cleanup the cache
setInterval(
() => {
antispamCache.forEach((value, key) => {
if (Date.now() - value.timestamp > MESSAGE_SPAM_THRESHOLD) {
antispamCache.delete(key);
}
});
},
10 * 60 * 1000
);
/**
* Check if the message needs to be moderated and has required permissions
* @param {import('discord.js').Message} message
*/
const shouldModerate = (message) => {
const { member, guild, channel } = message;
// Ignore if bot cannot delete channel messages
if (!channel.permissionsFor(guild.members.me)?.has("ManageMessages")) return false;
// Ignore Possible Guild Moderators
if (member.permissions.has(["KickMembers", "BanMembers", "ManageGuild"])) return false;
// Ignore Possible Channel Moderators
if (channel.permissionsFor(message.member).has("ManageMessages")) return false;
return true;
};
/**
* Perform moderation on the message
* @param {import('discord.js').Message} message
* @param {object} settings
*/
async function performAutomod(message, settings) {
const { automod } = settings;
if (automod.wh_channels.includes(message.channelId)) return;
if (!automod.debug && !shouldModerate(message)) return;
const { channel, member, guild, content, author, mentions } = message;
const logChannel = settings.modlog_channel ? channel.guild.channels.cache.get(settings.modlog_channel) : null;
let shouldDelete = false;
let strikesTotal = 0;
const fields = [];
// Max mentions
if (mentions.members.size > automod.max_mentions) {
fields.push({ name: "Mentions", value: `${mentions.members.size}/${automod.max_mentions}`, inline: true });
// strikesTotal += mentions.members.size - automod.max_mentions;
strikesTotal += 1;
}
// Maxrole mentions
if (mentions.roles.size > automod.max_role_mentions) {
fields.push({ name: "RoleMentions", value: `${mentions.roles.size}/${automod.max_role_mentions}`, inline: true });
// strikesTotal += mentions.roles.size - automod.max_role_mentions;
strikesTotal += 1;
}
if (automod.anti_massmention > 0) {
// check everyone mention
if (mentions.everyone) {
fields.push({ name: "Everyone Mention", value: "✓", inline: true });
strikesTotal += 1;
}
// check user/role mentions
if (mentions.users.size + mentions.roles.size > automod.anti_massmention) {
fields.push({
name: "User/Role Mentions",
value: `${mentions.users.size + mentions.roles.size}/${automod.anti_massmention}`,
inline: true,
});
// strikesTotal += mentions.users.size + mentions.roles.size - automod.anti_massmention;
strikesTotal += 1;
}
}
// Max Lines
if (automod.max_lines > 0) {
const count = content.split("\n").length;
if (count > automod.max_lines) {
fields.push({ name: "New Lines", value: `${count}/${automod.max_lines}`, inline: true });
shouldDelete = true;
// strikesTotal += Math.ceil((count - automod.max_lines) / automod.max_lines);
strikesTotal += 1;
}
}
// Anti Attachments
if (automod.anti_attachments) {
if (message.attachments.size > 0) {
fields.push({ name: "Attachments Found", value: "✓", inline: true });
shouldDelete = true;
strikesTotal += 1;
}
}
// Anti links
if (automod.anti_links) {
if (containsLink(content)) {
fields.push({ name: "Links Found", value: "✓", inline: true });
shouldDelete = true;
strikesTotal += 1;
}
}
// Anti Spam
if (!automod.anti_links && automod.anti_spam) {
if (containsLink(content)) {
const key = author.id + "|" + message.guildId;
if (antispamCache.has(key)) {
let antispamInfo = antispamCache.get(key);
if (
antispamInfo.channelId !== message.channelId &&
antispamInfo.content === content &&
Date.now() - antispamInfo.timestamp < MESSAGE_SPAM_THRESHOLD
) {
fields.push({ name: "AntiSpam Detection", value: "✓", inline: true });
shouldDelete = true;
strikesTotal += 1;
}
} else {
let antispamInfo = {
channelId: message.channelId,
content,
timestamp: Date.now(),
};
antispamCache.set(key, antispamInfo);
}
}
}
// Anti Invites
if (!automod.anti_links && automod.anti_invites) {
if (containsDiscordInvite(content)) {
fields.push({ name: "Discord Invites", value: "✓", inline: true });
shouldDelete = true;
strikesTotal += 1;
}
}
// delete message if deletable
if (shouldDelete && message.deletable) {
message
.delete()
.then(() => channel.safeSend("> Auto-Moderation! Message deleted", 5))
.catch(() => { });
}
if (strikesTotal > 0) {
// add strikes to member
const memberDb = await getMember(guild.id, author.id);
memberDb.strikes += strikesTotal;
// log to db
const reason = fields.map((field) => field.name + ": " + field.value).join("\n");
addAutoModLogToDb(member, content, reason, strikesTotal).catch(() => { });
// send automod log
if (logChannel) {
const logEmbed = new EmbedBuilder()
.setAuthor({ name: "Auto Moderation" })
.setThumbnail(author.displayAvatarURL())
.setColor(AUTOMOD.LOG_EMBED)
.addFields(fields)
.setDescription(`**Channel:** ${channel.toString()}\n**Content:**\n${content}`)
.setFooter({
text: `By ${author.username} | ${author.id}`,
iconURL: author.avatarURL(),
});
logChannel.safeSend({ embeds: [logEmbed] });
}
// DM strike details
const strikeEmbed = new EmbedBuilder()
.setColor(AUTOMOD.DM_EMBED)
.setThumbnail(guild.iconURL())
.setAuthor({ name: "Auto Moderation" })
.addFields(fields)
.setDescription(
`You have received ${strikesTotal} strikes!\n\n` +
`**Guild:** ${guild.name}\n` +
`**Total Strikes:** ${memberDb.strikes} out of ${automod.strikes}`
);
author.send({ embeds: [strikeEmbed] }).catch((ex) => { });
// check if max strikes are received
if (memberDb.strikes >= automod.strikes) {
// Reset Strikes
memberDb.strikes = 0;
// Add Moderation Action
await addModAction(guild.members.me, member, "Automod: Max strikes received", automod.action).catch(() => { });
}
await memberDb.save();
}
}
module.exports = {
performAutomod,
};

222
src/handlers/command.js Normal file
View file

@ -0,0 +1,222 @@
const { EmbedBuilder, ApplicationCommandOptionType } = require("discord.js");
const { OWNER_IDS, PREFIX_COMMANDS, EMBED_COLORS } = require("@root/config");
const { parsePermissions } = require("@helpers/Utils");
const { timeformat } = require("@helpers/Utils");
const { getSettings } = require("@schemas/Guild");
const cooldownCache = new Map();
module.exports = {
/**
* @param {import('discord.js').Message} message
* @param {import("@structures/Command")} cmd
* @param {object} settings
*/
handlePrefixCommand: async function (message, cmd, settings) {
const prefix = settings.prefix;
const args = message.content.replace(prefix, "").split(/\s+/);
const invoke = args.shift().toLowerCase();
const data = {};
data.settings = settings;
data.prefix = prefix;
data.invoke = invoke;
if (!message.channel.permissionsFor(message.guild.members.me).has("SendMessages")) return;
// callback validations
if (cmd.validations) {
for (const validation of cmd.validations) {
if (!validation.callback(message)) {
return message.safeReply(validation.message);
}
}
}
// Owner commands
if (cmd.category === "OWNER" && !OWNER_IDS.includes(message.author.id)) {
return message.safeReply("This command is only accessible to bot owners");
}
// check user permissions
if (cmd.userPermissions && cmd.userPermissions?.length > 0) {
if (!message.channel.permissionsFor(message.member).has(cmd.userPermissions)) {
return message.safeReply(`You need ${parsePermissions(cmd.userPermissions)} for this command`);
}
}
// check bot permissions
if (cmd.botPermissions && cmd.botPermissions.length > 0) {
if (!message.channel.permissionsFor(message.guild.members.me).has(cmd.botPermissions)) {
return message.safeReply(`I need ${parsePermissions(cmd.botPermissions)} for this command`);
}
}
// minArgs count
if (cmd.command.minArgsCount > args.length) {
const usageEmbed = this.getCommandUsage(cmd, prefix, invoke);
return message.safeReply({ embeds: [usageEmbed] });
}
// cooldown check
if (cmd.cooldown > 0) {
const remaining = getRemainingCooldown(message.author.id, cmd);
if (remaining > 0) {
return message.safeReply(`You are on cooldown. You can again use the command in \`${timeformat(remaining)}\``);
}
}
try {
await cmd.messageRun(message, args, data);
} catch (ex) {
message.client.logger.error("messageRun", ex);
message.safeReply("An error occurred while running this command");
} finally {
if (cmd.cooldown > 0) applyCooldown(message.author.id, cmd);
}
},
/**
* @param {import('discord.js').ChatInputCommandInteraction} interaction
*/
handleSlashCommand: async function (interaction) {
const cmd = interaction.client.slashCommands.get(interaction.commandName);
if (!cmd) return interaction.reply({ content: "An error has occurred", ephemeral: true }).catch(() => { });
// callback validations
if (cmd.validations) {
for (const validation of cmd.validations) {
if (!validation.callback(interaction)) {
return interaction.reply({
content: validation.message,
ephemeral: true,
});
}
}
}
// Owner commands
if (cmd.category === "OWNER" && !OWNER_IDS.includes(interaction.user.id)) {
return interaction.reply({
content: `This command is only accessible to bot owners`,
ephemeral: true,
});
}
// user permissions
if (interaction.member && cmd.userPermissions?.length > 0) {
if (!interaction.member.permissions.has(cmd.userPermissions)) {
return interaction.reply({
content: `You need ${parsePermissions(cmd.userPermissions)} for this command`,
ephemeral: true,
});
}
}
// bot permissions
if (cmd.botPermissions && cmd.botPermissions.length > 0) {
if (!interaction.guild.members.me.permissions.has(cmd.botPermissions)) {
return interaction.reply({
content: `I need ${parsePermissions(cmd.botPermissions)} for this command`,
ephemeral: true,
});
}
}
// cooldown check
if (cmd.cooldown > 0) {
const remaining = getRemainingCooldown(interaction.user.id, cmd);
if (remaining > 0) {
return interaction.reply({
content: `You are on cooldown. You can again use the command in \`${timeformat(remaining)}\``,
ephemeral: true,
});
}
}
try {
await interaction.deferReply({ ephemeral: cmd.slashCommand.ephemeral });
const settings = await getSettings(interaction.guild);
await cmd.interactionRun(interaction, { settings });
} catch (ex) {
await interaction.followUp("Oops! An error occurred while running the command");
interaction.client.logger.error("interactionRun", ex);
} finally {
if (cmd.cooldown > 0) applyCooldown(interaction.user.id, cmd);
}
},
/**
* Build a usage embed for this command
* @param {import('@structures/Command')} cmd - command object
* @param {string} prefix - guild bot prefix
* @param {string} invoke - alias that was used to trigger this command
* @param {string} [title] - the embed title
*/
getCommandUsage(cmd, prefix = PREFIX_COMMANDS.DEFAULT_PREFIX, invoke, title = "Usage") {
let desc = "";
if (cmd.command.subcommands && cmd.command.subcommands.length > 0) {
cmd.command.subcommands.forEach((sub) => {
desc += `\`${prefix}${invoke || cmd.name} ${sub.trigger}\`\n ${sub.description}\n\n`;
});
if (cmd.cooldown) {
desc += `**Cooldown:** ${timeformat(cmd.cooldown)}`;
}
} else {
desc += `\`\`\`css\n${prefix}${invoke || cmd.name} ${cmd.command.usage}\`\`\``;
if (cmd.description !== "") desc += `\n**Help:** ${cmd.description}`;
if (cmd.cooldown) desc += `\n**Cooldown:** ${timeformat(cmd.cooldown)}`;
}
const embed = new EmbedBuilder().setColor(EMBED_COLORS.BOT_EMBED).setDescription(desc);
if (title) embed.setAuthor({ name: title });
return embed;
},
/**
* @param {import('@structures/Command')} cmd - command object
*/
getSlashUsage(cmd) {
let desc = "";
if (cmd.slashCommand.options?.find((o) => o.type === ApplicationCommandOptionType.Subcommand)) {
const subCmds = cmd.slashCommand.options.filter((opt) => opt.type === ApplicationCommandOptionType.Subcommand);
subCmds.forEach((sub) => {
desc += `\`/${cmd.name} ${sub.name}\`\n ${sub.description}\n\n`;
});
} else {
desc += `\`/${cmd.name}\`\n\n**Help:** ${cmd.description}`;
}
if (cmd.cooldown) {
desc += `\n**Cooldown:** ${timeformat(cmd.cooldown)}`;
}
return new EmbedBuilder().setColor(EMBED_COLORS.BOT_EMBED).setDescription(desc);
},
};
/**
* @param {string} memberId
* @param {object} cmd
*/
function applyCooldown(memberId, cmd) {
const key = cmd.name + "|" + memberId;
cooldownCache.set(key, Date.now());
}
/**
* @param {string} memberId
* @param {object} cmd
*/
function getRemainingCooldown(memberId, cmd) {
const key = cmd.name + "|" + memberId;
if (cooldownCache.has(key)) {
const remaining = (Date.now() - cooldownCache.get(key)) * 0.001;
if (remaining > cmd.cooldown) {
cooldownCache.delete(key);
return 0;
}
return cmd.cooldown - remaining;
}
return 0;
}

69
src/handlers/context.js Normal file
View file

@ -0,0 +1,69 @@
const { parsePermissions } = require("@helpers/Utils");
const { timeformat } = require("@helpers/Utils");
const cooldownCache = new Map();
module.exports = {
/**
* @param {import('discord.js').ContextMenuInteraction} interaction
* @param {import("@structures/BaseContext")} context
*/
handleContext: async function (interaction, context) {
// check cooldown
if (context.cooldown) {
const remaining = getRemainingCooldown(interaction.user.id, context);
if (remaining > 0) {
return interaction.reply({
content: `You are on cooldown, You can use this command again after ${timeformat(remaining)}`,
ephemeral: true,
})
}
}
// check user permissions
if (interaction.member && context.userPermissions && context.userPermissions?.length > 0) {
if (!interaction.member.permissions.has(context.userPermissions)) {
return interaction.reply({
content: `You need ${parsePermissions(context.userPermissions)} to use this command`,
ephemeral: true,
});
}
}
try {
await interaction.deferReply({ ephemeral: context.ephemeral });
await context.run(interaction);
} catch (ex) {
interaction.followUp("Oops! An error occured while running the command");
interaction.client.logger.error("contextRun", ex);
} finally {
applyCooldown(interaction.user.id, context);
}
},
};
/**
* @param {string} memberId
* @param {object} context
*/
function applyCooldown(memberId, context) {
const key = context.name + "|" + memberId;
cooldownCache.set(key, Date.now());
}
/**
* @param {string} memberId
* @param {object} context
*/
function getRemainingCooldown(memberId, context) {
const key = context.name + "|" + memberId;
if (cooldownCache.has(key)) {
const remaining = (Date.now() - cooldownCache.get(key)) * 0.001;
if (remaining > context.cooldown) {
cooldownCache.delete(key);
return 0;
}
return context.cooldown - remaining;
}
return 0;
}

58
src/handlers/counter.js Normal file
View file

@ -0,0 +1,58 @@
const { getSettings } = require("@schemas/Guild");
/**
* Updates the counter channel for all the guildId's present in the update queue
* @param {import('@src/structures').BotClient} client
*/
async function updateCounterChannels(client) {
client.counterUpdateQueue.forEach(async (guildId) => {
const guild = client.guilds.cache.get(guildId);
if (!guild) return;
try {
const settings = await getSettings(guild);
const all = guild.memberCount;
const bots = settings.data.bots;
const members = all - bots;
for (const config of settings.counters) {
const chId = config.channel_id;
const vc = guild.channels.cache.get(chId);
if (!vc) continue;
let channelName;
if (config.counter_type.toUpperCase() === "USERS") channelName = `${config.name} : ${all}`;
if (config.counter_type.toUpperCase() === "MEMBERS") channelName = `${config.name} : ${members}`;
if (config.counter_type.toUpperCase() === "BOTS") channelName = `${config.name} : ${bots}`;
if (vc.manageable) vc.setName(channelName).catch((err) => vc.client.logger.log("Set Name error: ", err));
}
} catch (ex) {
client.logger.error(`Error updating counter channels for guildId: ${guildId}`, ex);
} finally {
// remove guildId from cache
const i = client.counterUpdateQueue.indexOf(guild.id);
if (i > -1) client.counterUpdateQueue.splice(i, 1);
}
});
}
/**
* Initialize guild counters at startup
* @param {import("discord.js").Guild} guild
* @param {Object} settings
*/
async function init(guild, settings) {
if (settings.counters.find((doc) => ["MEMBERS", "BOTS"].includes(doc.counter_type.toUpperCase()))) {
const stats = await guild.fetchMemberStats();
settings.data.bots = stats[1]; // update bot count in database
await settings.save();
}
// schedule for update
if (!guild.client.counterUpdateQueue.includes(guild.id)) guild.client.counterUpdateQueue.push(guild.id);
return true;
}
module.exports = { init, updateCounterChannels };

43
src/handlers/giveaway.js Normal file
View file

@ -0,0 +1,43 @@
const { GiveawaysManager } = require("discord-giveaways");
const Model = require("@schemas/Giveaways");
class MongooseGiveaways extends GiveawaysManager {
/**
* @param {import("@structures/BotClient")} client
*/
constructor(client) {
super(
client,
{
default: {
botsCanWin: false,
embedColor: client.config.GIVEAWAYS.START_EMBED,
embedColorEnd: client.config.GIVEAWAYS.END_EMBED,
reaction: client.config.GIVEAWAYS.REACTION,
},
},
false // do not initialize manager yet
);
}
async getAllGiveaways() {
return await Model.find().lean().exec();
}
async saveGiveaway(messageId, giveawayData) {
await Model.create(giveawayData);
return true;
}
async editGiveaway(messageId, giveawayData) {
await Model.updateOne({ messageId }, giveawayData, { omitUndefined: true }).exec();
return true;
}
async deleteGiveaway(messageId) {
await Model.deleteOne({ messageId }).exec();
return true;
}
}
module.exports = (client) => new MongooseGiveaways(client);

134
src/handlers/greeting.js Normal file
View file

@ -0,0 +1,134 @@
const { EmbedBuilder } = require("discord.js");
const { getSettings } = require("@schemas/Guild");
/**
* @param {string} content
* @param {import('discord.js').GuildMember} member
* @param {Object} inviterData
*/
const parse = async (content, member, inviterData = {}) => {
const inviteData = {};
const getEffectiveInvites = (inviteData = {}) =>
inviteData.tracked + inviteData.added - inviteData.fake - inviteData.left || 0;
if (content.includes("{inviter:")) {
const inviterId = inviterData.member_id || "NA";
if (inviterId !== "VANITY" && inviterId !== "NA") {
try {
const inviter = await member.client.users.fetch(inviterId);
inviteData.name = inviter.username;
inviteData.tag = inviter.tag;
} catch (ex) {
member.client.logger.error(`Parsing inviterId: ${inviterId}`, ex);
inviteData.name = "NA";
inviteData.tag = "NA";
}
} else if (member.user.bot) {
inviteData.name = "OAuth";
inviteData.tag = "OAuth";
} else {
inviteData.name = inviterId;
inviteData.tag = inviterId;
}
}
return content
.replaceAll(/\\n/g, "\n")
.replaceAll(/{server}/g, member.guild.name)
.replaceAll(/{count}/g, member.guild.memberCount)
.replaceAll(/{member:nick}/g, member.displayName)
.replaceAll(/{member:name}/g, member.user.username)
.replaceAll(/{member:dis}/g, member.user.discriminator)
.replaceAll(/{member:tag}/g, member.user.tag)
.replaceAll(/{member:mention}/g, member.toString())
.replaceAll(/{member:avatar}/g, member.displayAvatarURL())
.replaceAll(/{inviter:name}/g, inviteData.name)
.replaceAll(/{inviter:tag}/g, inviteData.tag)
.replaceAll(/{invites}/g, getEffectiveInvites(inviterData.invite_data));
};
/**
* @param {import('discord.js').GuildMember} member
* @param {"WELCOME"|"FAREWELL"} type
* @param {Object} config
* @param {Object} inviterData
*/
const buildGreeting = async (member, type, config, inviterData) => {
if (!config) return;
let content;
// build content
if (config.content) content = await parse(config.content, member, inviterData);
// build embed
const embed = new EmbedBuilder();
if (config.embed.description) {
const parsed = await parse(config.embed.description, member, inviterData);
embed.setDescription(parsed);
}
if (config.embed.color) embed.setColor(config.embed.color);
if (config.embed.thumbnail) embed.setThumbnail(member.user.displayAvatarURL());
if (config.embed.footer) {
const parsed = await parse(config.embed.footer, member, inviterData);
embed.setFooter({ text: parsed });
}
if (config.embed.image) {
const parsed = await parse(config.embed.image, member);
embed.setImage(parsed);
}
// set default message
if (!config.content && !config.embed.description && !config.embed.footer) {
content =
type === "WELCOME"
? `Welcome to the server, ${member.displayName} 🎉`
: `${member.user.username} has left the server 👋`;
return { content };
}
return { content, embeds: [embed] };
};
/**
* Send welcome message
* @param {import('discord.js').GuildMember} member
* @param {Object} inviterData
*/
async function sendWelcome(member, inviterData = {}) {
const config = (await getSettings(member.guild))?.welcome;
if (!config || !config.enabled) return;
// check if channel exists
const channel = member.guild.channels.cache.get(config.channel);
if (!channel) return;
// build welcome message
const response = await buildGreeting(member, "WELCOME", config, inviterData);
channel.safeSend(response);
}
/**
* Send farewell message
* @param {import('discord.js').GuildMember} member
* @param {Object} inviterData
*/
async function sendFarewell(member, inviterData = {}) {
const config = (await getSettings(member.guild))?.farewell;
if (!config || !config.enabled) return;
// check if channel exists
const channel = member.guild.channels.cache.get(config.channel);
if (!channel) return;
// build farewell message
const response = await buildGreeting(member, "FAREWELL", config, inviterData);
channel.safeSend(response);
}
module.exports = {
buildGreeting,
sendWelcome,
sendFarewell,
};

14
src/handlers/index.js Normal file
View file

@ -0,0 +1,14 @@
module.exports = {
automodHandler: require("./automod"),
commandHandler: require("./command"),
contextHandler: require("./context"),
counterHandler: require("./counter"),
greetingHandler: require("./greeting"),
inviteHandler: require("./invite"),
presenceHandler: require("./presence"),
reactionRoleHandler: require("./reactionRoles"),
statsHandler: require("./stats"),
suggestionHandler: require("./suggestion"),
ticketHandler: require("./ticket"),
translationHandler: require("./translation"),
};

155
src/handlers/invite.js Normal file
View file

@ -0,0 +1,155 @@
const { Collection } = require("discord.js");
const { getSettings } = require("@schemas/Guild");
const { getMember } = require("@schemas/Member");
const inviteCache = new Collection();
const getInviteCache = (guild) => inviteCache.get(guild.id);
const resetInviteCache = (guild) => inviteCache.delete(guild.id);
const getEffectiveInvites = (inviteData = {}) =>
inviteData.tracked + inviteData.added - inviteData.fake - inviteData.left || 0;
const cacheInvite = (invite, isVanity) => ({
code: invite.code,
uses: invite.uses,
maxUses: invite.maxUses,
inviterId: isVanity ? "VANITY" : invite.inviter?.id,
});
/**
* This function caches all invites for the provided guild
* @param {import("discord.js").Guild} guild
*/
async function cacheGuildInvites(guild) {
if (!guild.members.me.permissions.has("ManageGuild")) return new Collection();
const invites = await guild.invites.fetch();
const tempMap = new Collection();
invites.forEach((inv) => tempMap.set(inv.code, cacheInvite(inv)));
if (guild.vanityURLCode) {
tempMap.set(guild.vanityURLCode, cacheInvite(await guild.fetchVanityData(), true));
}
inviteCache.set(guild.id, tempMap);
return tempMap;
}
/**
* Add roles to inviter based on invites count
* @param {import("discord.js").Guild} guild
* @param {Object} inviterData
* @param {boolean} isAdded
*/
const checkInviteRewards = async (guild, inviterData = {}, isAdded) => {
const settings = await getSettings(guild);
if (settings.invite.ranks.length > 0 && inviterData?.member_id) {
const inviter = await guild.members.fetch(inviterData?.member_id).catch(() => { });
if (!inviter) return;
const invites = getEffectiveInvites(inviterData.invite_data);
settings.invite.ranks.forEach((reward) => {
if (isAdded) {
if (invites >= reward.invites && !inviter.roles.cache.has(reward._id)) {
inviter.roles.add(reward._id);
}
} else if (invites < reward.invites && inviter.roles.cache.has(reward._id)) {
inviter.roles.remove(reward._id);
}
});
}
};
/**
* Track inviter by comparing new invites with cached invites
* @param {import("discord.js").GuildMember} member
*/
async function trackJoinedMember(member) {
const { guild } = member;
if (member.user.bot) return {};
const cachedInvites = inviteCache.get(guild.id);
const newInvites = await cacheGuildInvites(guild);
// return if no cached data
if (!cachedInvites) return {};
let usedInvite;
// compare newInvites with cached invites
usedInvite = newInvites.find(
(inv) => inv.uses !== 0 && cachedInvites.get(inv.code) && cachedInvites.get(inv.code).uses < inv.uses
);
// Special case: Invitation was deleted after member's arrival and
// just before GUILD_MEMBER_ADD (https://github.com/Androz2091/discord-invites-tracker/blob/29202ee8e85bb1651f19a466e2c0721b2373fefb/index.ts#L46)
if (!usedInvite) {
cachedInvites
.sort((a, b) => (a.deletedTimestamp && b.deletedTimestamp ? b.deletedTimestamp - a.deletedTimestamp : 0))
.forEach((invite) => {
if (
!newInvites.get(invite.code) && // If the invitation is no longer present
invite.maxUses > 0 && // If the invitation was indeed an invitation with a limited number of uses
invite.uses === invite.maxUses - 1 // What if the invitation was about to reach the maximum number of uses
) {
usedInvite = invite;
}
});
}
let inviterData = {};
if (usedInvite) {
const inviterId = usedInvite.code === guild.vanityURLCode ? "VANITY" : usedInvite.inviterId;
// log invite data
const memberDb = await getMember(guild.id, member.id);
memberDb.invite_data.inviter = inviterId;
memberDb.invite_data.code = usedInvite.code;
await memberDb.save();
// increment inviter's invites
const inviterDb = await getMember(guild.id, inviterId);
inviterDb.invite_data.tracked += 1;
await inviterDb.save();
inviterData = inviterDb;
}
checkInviteRewards(guild, inviterData, true);
return inviterData;
}
/**
* Fetch inviter data from database
* @param {import("discord.js").Guild} guild
* @param {import("discord.js").User} user
*/
async function trackLeftMember(guild, user) {
if (user.bot) return {};
const settings = await getSettings(guild);
if (!settings.invite.tracking) return;
const inviteData = (await getMember(guild.id, user.id)).invite_data;
let inviterData = {};
if (inviteData.inviter) {
const inviterId = inviteData.inviter === "VANITY" ? "VANITY" : inviteData.inviter;
const inviterDb = await getMember(guild.id, inviterId);
inviterDb.invite_data.left += 1;
await inviterDb.save();
inviterData = inviterDb;
}
checkInviteRewards(guild, inviterData, false);
return inviterData;
}
module.exports = {
getInviteCache,
resetInviteCache,
trackJoinedMember,
trackLeftMember,
cacheGuildInvites,
checkInviteRewards,
getEffectiveInvites,
cacheInvite,
};

View file

@ -0,0 +1,83 @@
const { EmbedBuilder } = require("discord.js");
const { Cluster } = require("lavaclient");
const prettyMs = require("pretty-ms");
const { load, SpotifyItemType } = require("@lavaclient/spotify");
require("@lavaclient/queue/register");
/**
* @param {import("@structures/BotClient")} client
*/
module.exports = (client) => {
load({
client: {
id: process.env.SPOTIFY_CLIENT_ID,
secret: process.env.SPOTIFY_CLIENT_SECRET,
},
autoResolveYoutubeTracks: false,
loaders: [SpotifyItemType.Album, SpotifyItemType.Artist, SpotifyItemType.Playlist, SpotifyItemType.Track],
});
const lavaclient = new Cluster({
nodes: client.config.MUSIC.LAVALINK_NODES,
sendGatewayPayload: (id, payload) => client.guilds.cache.get(id)?.shard?.send(payload),
});
client.ws.on("VOICE_SERVER_UPDATE", (data) => lavaclient.handleVoiceUpdate(data));
client.ws.on("VOICE_STATE_UPDATE", (data) => lavaclient.handleVoiceUpdate(data));
lavaclient.on("nodeConnect", (node, event) => {
client.logger.log(`Node "${node.id}" connected`);
});
lavaclient.on("nodeDisconnect", (node, event) => {
client.logger.log(`Node "${node.id}" disconnected`);
});
lavaclient.on("nodeError", (node, error) => {
client.logger.error(`Node "${node.id}" encountered an error: ${error.message}.`, error);
});
lavaclient.on("nodeDebug", (node, message) => {
client.logger.debug(`Node "${node.id}" debug: ${message}`);
});
lavaclient.on("nodeTrackStart", (_node, queue, song) => {
const fields = [];
const embed = new EmbedBuilder()
.setAuthor({ name: "Now Playing" })
.setColor(client.config.EMBED_COLORS.BOT_EMBED)
.setDescription(`[${song.title}](${song.uri})`)
.setFooter({ text: `Requested By: ${song.requester}` });
if (song.sourceName === "youtube") {
const identifier = song.identifier;
const thumbnail = `https://img.youtube.com/vi/${identifier}/hqdefault.jpg`;
embed.setThumbnail(thumbnail);
}
fields.push({
name: "Song Duration",
value: "`" + prettyMs(song.length, { colonNotation: true }) + "`",
inline: true,
});
if (queue.tracks.length > 0) {
fields.push({
name: "Position in Queue",
value: (queue.tracks.length + 1).toString(),
inline: true,
});
}
embed.setFields(fields);
queue.data.channel.safeSend({ embeds: [embed] });
});
lavaclient.on("nodeQueueFinish", async (_node, queue) => {
queue.data.channel.safeSend("Queue has ended.");
await client.musicManager.destroyPlayer(queue.player.guildId).then(queue.player.disconnect());
});
return lavaclient;
};

48
src/handlers/presence.js Normal file
View file

@ -0,0 +1,48 @@
const { ActivityType } = require("discord.js");
/**
* @param {import('@src/structures').BotClient} client
*/
function updatePresence(client) {
let message = client.config.PRESENCE.MESSAGE;
if (message.includes("{servers}")) {
message = message.replaceAll("{servers}", client.guilds.cache.size);
}
if (message.includes("{members}")) {
const members = client.guilds.cache.map((g) => g.memberCount).reduce((partial_sum, a) => partial_sum + a, 0);
message = message.replaceAll("{members}", members);
}
const getType = (type) => {
switch (type) {
case "COMPETING":
return ActivityType.Competing;
case "LISTENING":
return ActivityType.Listening;
case "PLAYING":
return ActivityType.Playing;
case "WATCHING":
return ActivityType.Watching;
}
};
client.user.setPresence({
status: client.config.PRESENCE.STATUS,
activities: [
{
name: message,
type: getType(client.config.PRESENCE.TYPE),
},
],
});
}
module.exports = function handlePresence(client) {
updatePresence(client);
setInterval(() => updatePresence(client), 10 * 60 * 1000);
};

View file

@ -0,0 +1,46 @@
const { getReactionRoles } = require("@schemas/ReactionRoles");
module.exports = {
/**
* @param {import('discord.js').MessageReaction} reaction
* @param {import('discord.js').User} user
*/
async handleReactionAdd(reaction, user) {
const role = await getRole(reaction);
if (!role) return;
const member = await reaction.message.guild.members.fetch(user.id);
if (!member) return;
await member.roles.add(role).catch(() => { });
},
/**
* @param {import('discord.js').MessageReaction} reaction
* @param {import('discord.js').User} user
*/
async handleReactionRemove(reaction, user) {
const role = await getRole(reaction);
if (!role) return;
const member = await reaction.message.guild.members.fetch(user.id);
if (!member) return;
await member.roles.remove(role).catch(() => { });
},
};
/**
* @param {import('discord.js').MessageReaction} reaction
*/
async function getRole(reaction) {
const { message, emoji } = reaction;
if (!message || !message.channel) return;
const rr = getReactionRoles(message.guildId, message.channelId, message.id);
const emote = emoji.id ? emoji.id : emoji.toString();
const found = rr.find((doc) => doc.emote === emote);
const reactionRole = found ? await message.guild.roles.fetch(found.role_id) : null;
return reactionRole;
}

121
src/handlers/stats.js Normal file
View file

@ -0,0 +1,121 @@
const { getMemberStats } = require("@schemas/MemberStats");
const { getRandomInt } = require("@helpers/Utils");
const cooldownCache = new Map();
const voiceStates = new Map();
const xpToAdd = () => getRandomInt(19) + 1;
/**
* @param {string} content
* @param {import('discord.js').GuildMember} member
* @param {number} level
*/
const parse = (content, member, level) => {
return content
.replaceAll(/\\n/g, "\n")
.replaceAll(/{server}/g, member.guild.name)
.replaceAll(/{count}/g, member.guild.memberCount)
.replaceAll(/{member:id}/g, member.id)
.replaceAll(/{member:name}/g, member.displayName)
.replaceAll(/{member:mention}/g, member.toString())
.replaceAll(/{member:tag}/g, member.user.tag)
.replaceAll(/{level}/g, level);
};
module.exports = {
/**
* This function saves stats for a new message
* @param {import("discord.js").Message} message
* @param {boolean} isCommand
* @param {object} settings
*/
async trackMessageStats(message, isCommand, settings) {
const statsDb = await getMemberStats(message.guildId, message.member.id);
if (isCommand) statsDb.commands.prefix++;
statsDb.messages++;
// TODO: Ignore possible bot commands
// Cooldown check to prevent Message Spamming
const key = `${message.guildId}|${message.member.id}`;
if (cooldownCache.has(key)) {
const difference = (Date.now() - cooldownCache.get(key)) * 0.001;
if (difference < message.client.config.STATS.XP_COOLDOWN) {
return statsDb.save();
}
cooldownCache.delete(key);
}
// Update member's XP in DB
statsDb.xp += xpToAdd();
// Check if member has levelled up
let { xp, level } = statsDb;
const needed = level * level * 100;
if (xp > needed) {
level += 1;
xp -= needed;
statsDb.xp = xp;
statsDb.level = level;
let lvlUpMessage = settings.stats.xp.message;
lvlUpMessage = parse(lvlUpMessage, message.member, level);
const xpChannel = settings.stats.xp.channel && message.guild.channels.cache.get(settings.stats.xp.channel);
const lvlUpChannel = xpChannel || message.channel;
lvlUpChannel.safeSend(lvlUpMessage);
}
await statsDb.save();
cooldownCache.set(key, Date.now());
},
/**
* @param {import('discord.js').Interaction} interaction
*/
async trackInteractionStats(interaction) {
if (!interaction.guild) return;
const statsDb = await getMemberStats(interaction.guildId, interaction.member.id);
if (interaction.isChatInputCommand()) statsDb.commands.slash += 1;
if (interaction.isUserContextMenuCommand()) statsDb.contexts.user += 1;
if (interaction.isMessageContextMenuCommand()) statsDb.contexts.message += 1;
await statsDb.save();
},
/**
* @param {import('discord.js').VoiceState} oldState
* @param {import('discord.js').VoiceState} newState
*/
async trackVoiceStats(oldState, newState) {
const oldChannel = oldState.channel;
const newChannel = newState.channel;
const now = Date.now();
if (!oldChannel && !newChannel) return;
if (!newState.member) return;
const member = await newState.member.fetch().catch(() => { });
if (!member || member.user.bot) return;
// Member joined a voice channel
if (!oldChannel && newChannel) {
const statsDb = await getMemberStats(member.guild.id, member.id);
statsDb.voice.connections += 1;
await statsDb.save();
voiceStates.set(member.id, now);
}
// Member left a voice channel
if (oldChannel && !newChannel) {
const statsDb = await getMemberStats(member.guild.id, member.id);
if (voiceStates.has(member.id)) {
const time = now - voiceStates.get(member.id);
statsDb.voice.time += time / 1000; // add time in seconds
await statsDb.save();
voiceStates.delete(member.id);
}
}
},
};

360
src/handlers/suggestion.js Normal file
View file

@ -0,0 +1,360 @@
const { getSettings } = require("@schemas/Guild");
const { findSuggestion, deleteSuggestionDb } = require("@schemas/Suggestions");
const { SUGGESTIONS } = require("@root/config");
const {
ActionRowBuilder,
ButtonBuilder,
ModalBuilder,
TextInputBuilder,
EmbedBuilder,
ButtonStyle,
TextInputStyle,
} = require("discord.js");
const { stripIndents } = require("common-tags");
/**
* @param {import('discord.js').Message} message
*/
const getStats = (message) => {
const upVotes = (message.reactions.resolve(SUGGESTIONS.EMOJI.UP_VOTE)?.count || 1) - 1;
const downVotes = (message.reactions.resolve(SUGGESTIONS.EMOJI.DOWN_VOTE)?.count || 1) - 1;
return [upVotes, downVotes];
};
/**
* @param {number} upVotes
* @param {number} downVotes
*/
const getVotesMessage = (upVotes, downVotes) => {
const total = upVotes + downVotes;
if (total === 0) {
return stripIndents`
_Upvotes: NA_
_Downvotes: NA_
`;
} else {
return stripIndents`
_Upvotes: ${upVotes} [${Math.round((upVotes / (upVotes + downVotes)) * 100)}%]_
_Downvotes: ${downVotes} [${Math.round((downVotes / (upVotes + downVotes)) * 100)}%]_
`;
}
};
const hasPerms = (member, settings) => {
return (
member.permissions.has("ManageGuild") ||
member.roles.cache.find((r) => settings.suggestions.staff_roles.includes(r.id))
);
};
/**
* @param {import('discord.js').GuildMember} member
* @param {import('discord.js').TextBasedChannel} channel
* @param {string} messageId
* @param {string} [reason]
*/
async function approveSuggestion(member, channel, messageId, reason) {
const { guild } = member;
const settings = await getSettings(guild);
// validate permissions
if (!hasPerms(member, settings)) return "You don't have permission to approve suggestions!";
// validate if document exists
const doc = await findSuggestion(guild.id, messageId);
if (!doc) return "Suggestion not found";
if (doc.status === "APPROVED") return "Suggestion already approved";
/**
* @type {import('discord.js').Message}
*/
let message;
try {
message = await channel.messages.fetch({ message: messageId, force: true });
} catch (err) {
return "Suggestion message not found";
}
let buttonsRow = new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId("SUGGEST_APPROVE")
.setLabel("Approve")
.setStyle(ButtonStyle.Success)
.setDisabled(true),
new ButtonBuilder().setCustomId("SUGGEST_REJECT").setLabel("Reject").setStyle(ButtonStyle.Danger),
new ButtonBuilder().setCustomId("SUGGEST_DELETE").setLabel("Delete").setStyle(ButtonStyle.Secondary)
);
const approvedEmbed = new EmbedBuilder()
.setDescription(message.embeds[0].data.description)
.setColor(SUGGESTIONS.APPROVED_EMBED)
.setAuthor({ name: "Suggestion Approved" })
.setFooter({ text: `Approved By ${member.user.username}`, iconURL: member.displayAvatarURL() })
.setTimestamp();
const fields = [];
// add stats if it doesn't exist
const statsField = message.embeds[0].fields.find((field) => field.name === "Stats");
if (!statsField) {
const [upVotes, downVotes] = getStats(message);
doc.stats.upvotes = upVotes;
doc.stats.downvotes = downVotes;
fields.push({ name: "Stats", value: getVotesMessage(upVotes, downVotes) });
} else {
fields.push(statsField);
}
// update reason
if (reason) fields.push({ name: "Reason", value: "```" + reason + "```" });
approvedEmbed.addFields(fields);
try {
doc.status = "APPROVED";
doc.status_updates.push({ user_id: member.id, status: "APPROVED", reason, timestamp: new Date() });
let approveChannel;
if (settings.suggestions.approved_channel) {
approveChannel = guild.channels.cache.get(settings.suggestions.approved_channel);
}
// suggestions-approve channel is not configured
if (!approveChannel) {
await message.edit({ embeds: [approvedEmbed], components: [buttonsRow] });
await message.reactions.removeAll();
}
// suggestions-approve channel is configured
else {
const sent = await approveChannel.send({ embeds: [approvedEmbed], components: [buttonsRow] });
doc.channel_id = approveChannel.id;
doc.message_id = sent.id;
await message.delete();
}
await doc.save();
return "Suggestion approved";
} catch (ex) {
guild.client.logger.error("approveSuggestion", ex);
return "Failed to approve suggestion";
}
}
/**
* @param {import('discord.js').GuildMember} member
* @param {import('discord.js').TextBasedChannel} channel
* @param {string} messageId
* @param {string} [reason]
*/
async function rejectSuggestion(member, channel, messageId, reason) {
const { guild } = member;
const settings = await getSettings(guild);
// validate permissions
if (!hasPerms(member, settings)) return "You don't have permission to reject suggestions!";
// validate if document exists
const doc = await findSuggestion(guild.id, messageId);
if (!doc) return "Suggestion not found";
if (doc.is_rejected) return "Suggestion already rejected";
let message;
try {
message = await channel.messages.fetch({ message: messageId });
} catch (err) {
return "Suggestion message not found";
}
let buttonsRow = new ActionRowBuilder().addComponents(
new ButtonBuilder().setCustomId("SUGGEST_APPROVE").setLabel("Approve").setStyle(ButtonStyle.Success),
new ButtonBuilder().setCustomId("SUGGEST_REJECT").setLabel("Reject").setStyle(ButtonStyle.Danger).setDisabled(true),
new ButtonBuilder().setCustomId("SUGGEST_DELETE").setLabel("Delete").setStyle(ButtonStyle.Secondary)
);
const rejectedEmbed = new EmbedBuilder()
.setDescription(message.embeds[0].data.description)
.setColor(SUGGESTIONS.DENIED_EMBED)
.setAuthor({ name: "Suggestion Rejected" })
.setFooter({ text: `Rejected By ${member.user.username}`, iconURL: member.displayAvatarURL() })
.setTimestamp();
const fields = [];
// add stats if it doesn't exist
const statsField = message.embeds[0].fields.find((field) => field.name === "Stats");
if (!statsField) {
const [upVotes, downVotes] = getStats(message);
doc.stats.upvotes = upVotes;
doc.stats.downvotes = downVotes;
fields.push({ name: "Stats", value: getVotesMessage(upVotes, downVotes) });
} else {
fields.push(statsField);
}
// update reason
if (reason) fields.push({ name: "Reason", value: "```" + reason + "```" });
rejectedEmbed.addFields(fields);
try {
doc.status = "REJECTED";
doc.status_updates.push({ user_id: member.id, status: "REJECTED", reason, timestamp: new Date() });
let rejectChannel;
if (settings.suggestions.rejected_channel) {
rejectChannel = guild.channels.cache.get(settings.suggestions.rejected_channel);
}
// suggestions-reject channel is not configured
if (!rejectChannel) {
await message.edit({ embeds: [rejectedEmbed], components: [buttonsRow] });
await message.reactions.removeAll();
}
// suggestions-reject channel is configured
else {
const sent = await rejectChannel.send({ embeds: [rejectedEmbed], components: [buttonsRow] });
doc.channel_id = rejectChannel.id;
doc.message_id = sent.id;
await message.delete();
}
await doc.save();
return "Suggestion rejected";
} catch (ex) {
guild.client.logger.error("rejectSuggestion", ex);
return "Failed to reject suggestion";
}
}
/**
* @param {import('discord.js').GuildMember} member
* @param {import('discord.js').TextBasedChannel} channel
* @param {string} messageId
* @param {string} [reason]
*/
async function deleteSuggestion(member, channel, messageId, reason) {
const { guild } = member;
const settings = await getSettings(guild);
// validate permissions
if (!hasPerms(member, settings)) return "You don't have permission to delete suggestions!";
try {
await channel.messages.delete(messageId);
await deleteSuggestionDb(guild.id, messageId, member.id, reason);
return "Suggestion deleted";
} catch (ex) {
guild.client.logger.error("deleteSuggestion", ex);
return "Failed to delete suggestion! Please delete manually";
}
}
/**
* @param {import('discord.js').ButtonInteraction} interaction
*/
async function handleApproveBtn(interaction) {
await interaction.showModal(
new ModalBuilder({
title: "Approve Suggestion",
customId: "SUGGEST_APPROVE_MODAL",
components: [
new ActionRowBuilder().addComponents([
new TextInputBuilder()
.setCustomId("reason")
.setLabel("reason")
.setStyle(TextInputStyle.Paragraph)
.setMinLength(4),
]),
],
})
);
}
/**
* @param {import('discord.js').ModalSubmitInteraction} modal
*/
async function handleApproveModal(modal) {
await modal.deferReply({ ephemeral: true });
const reason = modal.fields.getTextInputValue("reason");
const response = await approveSuggestion(modal.member, modal.channel, modal.message.id, reason);
await modal.followUp(response);
}
/**
* @param {import('discord.js').ButtonInteraction} interaction
*/
async function handleRejectBtn(interaction) {
await interaction.showModal(
new ModalBuilder({
title: "Reject Suggestion",
customId: "SUGGEST_REJECT_MODAL",
components: [
new ActionRowBuilder().addComponents([
new TextInputBuilder()
.setCustomId("reason")
.setLabel("reason")
.setStyle(TextInputStyle.Paragraph)
.setMinLength(4),
]),
],
})
);
}
/**
* @param {import('discord.js').ModalSubmitInteraction} modal
*/
async function handleRejectModal(modal) {
await modal.deferReply({ ephemeral: true });
const reason = modal.fields.getTextInputValue("reason");
const response = await rejectSuggestion(modal.member, modal.channel, modal.message.id, reason);
await modal.followUp(response);
}
/**
* @param {import('discord.js').ButtonInteraction} interaction
*/
async function handleDeleteBtn(interaction) {
await interaction.showModal(
new ModalBuilder({
title: "Delete Suggestion",
customId: "SUGGEST_DELETE_MODAL",
components: [
new ActionRowBuilder().addComponents([
new TextInputBuilder()
.setCustomId("reason")
.setLabel("reason")
.setStyle(TextInputStyle.Paragraph)
.setMinLength(4),
]),
],
})
);
}
/**
* @param {import('discord.js').ModalSubmitInteraction} modal
*/
async function handleDeleteModal(modal) {
await modal.deferReply({ ephemeral: true });
const reason = modal.fields.getTextInputValue("reason");
const response = await deleteSuggestion(modal.member, modal.channel, modal.message.id, reason);
await modal.followUp({ content: response, ephemeral: true });
}
module.exports = {
handleApproveBtn,
handleApproveModal,
handleRejectBtn,
handleRejectModal,
handleDeleteBtn,
handleDeleteModal,
approveSuggestion,
rejectSuggestion,
deleteSuggestion,
};

307
src/handlers/ticket.js Normal file
View file

@ -0,0 +1,307 @@
const {
EmbedBuilder,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
ChannelType,
StringSelectMenuBuilder,
ComponentType,
} = require("discord.js");
const { TICKET } = require("@root/config.js");
// schemas
const { getSettings } = require("@schemas/Guild");
// helpers
const { postToBin } = require("@helpers/HttpUtils");
const { error } = require("@helpers/Logger");
const OPEN_PERMS = ["ManageChannels"];
const CLOSE_PERMS = ["ManageChannels", "ReadMessageHistory"];
/**
* @param {import('discord.js').Channel} channel
*/
function isTicketChannel(channel) {
return (
channel.type === ChannelType.GuildText &&
channel.name.startsWith("tіcket-") &&
channel.topic &&
channel.topic.startsWith("tіcket|")
);
}
/**
* @param {import('discord.js').Guild} guild
*/
function getTicketChannels(guild) {
return guild.channels.cache.filter((ch) => isTicketChannel(ch));
}
/**
* @param {import('discord.js').Guild} guild
* @param {string} userId
*/
function getExistingTicketChannel(guild, userId) {
const tktChannels = getTicketChannels(guild);
return tktChannels.filter((ch) => ch.topic.split("|")[1] === userId).first();
}
/**
* @param {import('discord.js').BaseGuildTextChannel} channel
*/
async function parseTicketDetails(channel) {
if (!channel.topic) return;
const split = channel.topic?.split("|");
const userId = split[1];
const catName = split[2] || "Default";
const user = await channel.client.users.fetch(userId, { cache: false }).catch(() => { });
return { user, catName };
}
/**
* @param {import('discord.js').BaseGuildTextChannel} channel
* @param {import('discord.js').User} closedBy
* @param {string} [reason]
*/
async function closeTicket(channel, closedBy, reason) {
if (!channel.deletable || !channel.permissionsFor(channel.guild.members.me).has(CLOSE_PERMS)) {
return "MISSING_PERMISSIONS";
}
try {
const config = await getSettings(channel.guild);
const messages = await channel.messages.fetch();
const reversed = Array.from(messages.values()).reverse();
let content = "";
reversed.forEach((m) => {
content += `[${new Date(m.createdAt).toLocaleString("en-US")}] - ${m.author.username}\n`;
if (m.cleanContent !== "") content += `${m.cleanContent}\n`;
if (m.attachments.size > 0) content += `${m.attachments.map((att) => att.proxyURL).join(", ")}\n`;
content += "\n";
});
const logsUrl = await postToBin(content, `Ticket Logs for ${channel.name}`);
const ticketDetails = await parseTicketDetails(channel);
const components = [];
if (logsUrl) {
components.push(
new ActionRowBuilder().addComponents(
new ButtonBuilder().setLabel("Transcript").setURL(logsUrl.short).setStyle(ButtonStyle.Link)
)
);
}
if (channel.deletable) await channel.delete();
const embed = new EmbedBuilder().setAuthor({ name: "Ticket Closed" }).setColor(TICKET.CLOSE_EMBED);
const fields = [];
if (reason) fields.push({ name: "Reason", value: reason, inline: false });
fields.push(
{
name: "Opened By",
value: ticketDetails.user ? ticketDetails.user.username : "Unknown",
inline: true,
},
{
name: "Closed By",
value: closedBy ? closedBy.username : "Unknown",
inline: true,
}
);
embed.setFields(fields);
// send embed to log channel
if (config.ticket.log_channel) {
const logChannel = channel.guild.channels.cache.get(config.ticket.log_channel);
logChannel.safeSend({ embeds: [embed], components });
}
// send embed to user
if (ticketDetails.user) {
const dmEmbed = embed
.setDescription(`**Server:** ${channel.guild.name}\n**Category:** ${ticketDetails.catName}`)
.setThumbnail(channel.guild.iconURL());
ticketDetails.user.send({ embeds: [dmEmbed], components }).catch((ex) => { });
}
return "SUCCESS";
} catch (ex) {
error("closeTicket", ex);
return "ERROR";
}
}
/**
* @param {import('discord.js').Guild} guild
* @param {import('discord.js').User} author
*/
async function closeAllTickets(guild, author) {
const channels = getTicketChannels(guild);
let success = 0;
let failed = 0;
for (const ch of channels) {
const status = await closeTicket(ch[1], author, "Force close all open tickets");
if (status === "SUCCESS") success += 1;
else failed += 1;
}
return [success, failed];
}
/**
* @param {import("discord.js").ButtonInteraction} interaction
*/
async function handleTicketOpen(interaction) {
await interaction.deferReply({ ephemeral: true });
const { guild, user } = interaction;
if (!guild.members.me.permissions.has(OPEN_PERMS))
return interaction.followUp(
"Cannot create ticket channel, missing `Manage Channel` permission. Contact server manager for help!"
);
const alreadyExists = getExistingTicketChannel(guild, user.id);
if (alreadyExists) return interaction.followUp(`You already have an open ticket`);
const settings = await getSettings(guild);
// limit check
const existing = getTicketChannels(guild).size;
if (existing > settings.ticket.limit) return interaction.followUp("There are too many open tickets. Try again later");
// check categories
let catName = null;
let catPerms = [];
const categories = settings.ticket.categories;
if (categories.length > 0) {
const options = [];
settings.ticket.categories.forEach((cat) => options.push({ label: cat.name, value: cat.name }));
const menuRow = new ActionRowBuilder().addComponents(
new StringSelectMenuBuilder()
.setCustomId("ticket-menu")
.setPlaceholder("Choose the ticket category")
.addOptions(options)
);
await interaction.followUp({ content: "Please choose a ticket category", components: [menuRow] });
const res = await interaction.channel
.awaitMessageComponent({
componentType: ComponentType.StringSelect,
time: 60 * 1000,
})
.catch((err) => {
if (err.message.includes("time")) return;
});
if (!res) return interaction.editReply({ content: "Timed out. Try again", components: [] });
await interaction.editReply({ content: "Processing", components: [] });
catName = res.values[0];
catPerms = categories.find((cat) => cat.name === catName)?.staff_roles || [];
}
try {
const ticketNumber = (existing + 1).toString();
const permissionOverwrites = [
{
id: guild.roles.everyone,
deny: ["ViewChannel"],
},
{
id: user.id,
allow: ["ViewChannel", "SendMessages", "ReadMessageHistory"],
},
{
id: guild.members.me.roles.highest.id,
allow: ["ViewChannel", "SendMessages", "ReadMessageHistory"],
},
];
if (catPerms?.length > 0) {
catPerms?.forEach((roleId) => {
const role = guild.roles.cache.get(roleId);
if (!role) return;
permissionOverwrites.push({
id: role,
allow: ["ViewChannel", "SendMessages", "ReadMessageHistory"],
});
});
}
const tktChannel = await guild.channels.create({
name: `tіcket-${ticketNumber}`,
type: ChannelType.GuildText,
topic: `tіcket|${user.id}|${catName || "Default"}`,
permissionOverwrites,
});
const embed = new EmbedBuilder()
.setAuthor({ name: `Ticket #${ticketNumber}` })
.setDescription(
`Hello ${user.toString()}
Support will be with you shortly
${catName ? `\n**Category:** ${catName}` : ""}
`
)
.setFooter({ text: "You may close your ticket anytime by clicking the button below" });
let buttonsRow = new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setLabel("Close Ticket")
.setCustomId("TICKET_CLOSE")
.setEmoji("🔒")
.setStyle(ButtonStyle.Primary)
);
const sent = await tktChannel.send({ content: user.toString(), embeds: [embed], components: [buttonsRow] });
const dmEmbed = new EmbedBuilder()
.setColor(TICKET.CREATE_EMBED)
.setAuthor({ name: "Ticket Created" })
.setThumbnail(guild.iconURL())
.setDescription(
`**Server:** ${guild.name}
${catName ? `**Category:** ${catName}` : ""}
`
);
const row = new ActionRowBuilder().addComponents(
new ButtonBuilder().setLabel("View Channel").setURL(sent.url).setStyle(ButtonStyle.Link)
);
user.send({ embeds: [dmEmbed], components: [row] }).catch((ex) => { });
await interaction.editReply(`Ticket created! 🔥`);
} catch (ex) {
error("handleTicketOpen", ex);
return interaction.editReply("Failed to create ticket channel, an error occurred!");
}
}
/**
* @param {import("discord.js").ButtonInteraction} interaction
*/
async function handleTicketClose(interaction) {
await interaction.deferReply({ ephemeral: true });
const status = await closeTicket(interaction.channel, interaction.user);
if (status === "MISSING_PERMISSIONS") {
return interaction.followUp("Cannot close the ticket, missing permissions. Contact server manager for help!");
} else if (status == "ERROR") {
return interaction.followUp("Failed to close the ticket, an error occurred!");
}
}
module.exports = {
getTicketChannels,
getExistingTicketChannel,
isTicketChannel,
closeTicket,
closeAllTickets,
handleTicketOpen,
handleTicketClose,
};

View file

@ -0,0 +1,90 @@
const { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } = require("discord.js");
const { isTranslated, logTranslation } = require("@schemas/TranslateLog");
const data = require("@src/data.json");
const { getLanguagesFromEmoji } = require("country-emoji-languages");
const { translate } = require("@helpers/HttpUtils");
const { timeformat } = require("@helpers/Utils");
const TRANSLATE_COOLDOWN = 120;
const cooldownCache = new Map();
/**
* @param {import('discord.js').User} user
*/
const getTranslationCooldown = (user) => {
if (cooldownCache.has(user.id)) {
const remaining = (Date.now() - cooldownCache.get(user.id)) * 0.001;
if (remaining > TRANSLATE_COOLDOWN) {
cooldownCache.delete(user.id);
return 0;
}
return TRANSLATE_COOLDOWN - remaining;
}
return 0;
};
/**
* @param {string} emoji
* @param {import("discord.js").Message} message
* @param {import("discord.js").User} user
*/
async function handleFlagReaction(emoji, message, user) {
// cooldown check
const remaining = getTranslationCooldown(user);
if (remaining > 0) {
return message.channel.safeSend(`${user} You must wait ${timeformat(remaining)} before translating again!`, 5);
}
if (await isTranslated(message, emoji)) return;
const languages = getLanguagesFromEmoji(emoji);
// filter languages for which google translation is available
const targetCodes = languages.filter((language) => data.GOOGLE_TRANSLATE[language] !== undefined);
if (targetCodes.length === 0) return;
// remove english if there are other language codes
if (targetCodes.length > 1 && targetCodes.includes("en")) {
targetCodes.splice(targetCodes.indexOf("en"), 1);
}
let src;
let desc = "";
let translated = 0;
for (const tc of targetCodes) {
const response = await translate(message.content, tc);
if (!response) continue;
src = response.inputLang;
desc += `**${response.outputLang}:**\n${response.output}\n\n`;
translated += 1;
}
if (translated === 0) return;
const btnRow = new ActionRowBuilder().addComponents(
new ButtonBuilder({
url: message.url,
label: "Original Message",
style: ButtonStyle.Link,
})
);
const embed = new EmbedBuilder()
.setColor(message.client.config.EMBED_COLORS.BOT_EMBED)
.setAuthor({ name: `Translation from ${src}` })
.setDescription(desc)
.setFooter({
text: `Requested by ${user.username}`,
iconURL: user.displayAvatarURL(),
});
message.channel.safeSend({ embeds: [embed], components: [btnRow] }).then(
() => cooldownCache.set(user.id, Date.now()) // set cooldown
);
logTranslation(message, emoji);
}
module.exports = {
handleFlagReaction,
};