diff --git a/src/contexts/avatar.js b/src/contexts/avatar.js new file mode 100644 index 0000000..094941e --- /dev/null +++ b/src/contexts/avatar.js @@ -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], + }; +} \ No newline at end of file diff --git a/src/handlers/automod.js b/src/handlers/automod.js new file mode 100644 index 0000000..4478111 --- /dev/null +++ b/src/handlers/automod.js @@ -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, +}; \ No newline at end of file diff --git a/src/handlers/command.js b/src/handlers/command.js new file mode 100644 index 0000000..7c31288 --- /dev/null +++ b/src/handlers/command.js @@ -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; +} \ No newline at end of file diff --git a/src/handlers/context.js b/src/handlers/context.js new file mode 100644 index 0000000..a9a5ff6 --- /dev/null +++ b/src/handlers/context.js @@ -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; +} \ No newline at end of file diff --git a/src/handlers/counter.js b/src/handlers/counter.js new file mode 100644 index 0000000..b623284 --- /dev/null +++ b/src/handlers/counter.js @@ -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 }; \ No newline at end of file diff --git a/src/handlers/giveaway.js b/src/handlers/giveaway.js new file mode 100644 index 0000000..a4c5b29 --- /dev/null +++ b/src/handlers/giveaway.js @@ -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); \ No newline at end of file diff --git a/src/handlers/greeting.js b/src/handlers/greeting.js new file mode 100644 index 0000000..fa2106f --- /dev/null +++ b/src/handlers/greeting.js @@ -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, +}; \ No newline at end of file diff --git a/src/handlers/index.js b/src/handlers/index.js new file mode 100644 index 0000000..41ca9a3 --- /dev/null +++ b/src/handlers/index.js @@ -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"), +}; \ No newline at end of file diff --git a/src/handlers/invite.js b/src/handlers/invite.js new file mode 100644 index 0000000..86ec7ed --- /dev/null +++ b/src/handlers/invite.js @@ -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, +}; \ No newline at end of file diff --git a/src/handlers/lavaclient.js b/src/handlers/lavaclient.js new file mode 100644 index 0000000..77a374f --- /dev/null +++ b/src/handlers/lavaclient.js @@ -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; +}; \ No newline at end of file diff --git a/src/handlers/presence.js b/src/handlers/presence.js new file mode 100644 index 0000000..b31d654 --- /dev/null +++ b/src/handlers/presence.js @@ -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); +}; \ No newline at end of file diff --git a/src/handlers/reactionRoles.js b/src/handlers/reactionRoles.js new file mode 100644 index 0000000..ab9f7fe --- /dev/null +++ b/src/handlers/reactionRoles.js @@ -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; +} \ No newline at end of file diff --git a/src/handlers/stats.js b/src/handlers/stats.js new file mode 100644 index 0000000..6a34fed --- /dev/null +++ b/src/handlers/stats.js @@ -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); + } + } + }, +}; \ No newline at end of file diff --git a/src/handlers/suggestion.js b/src/handlers/suggestion.js new file mode 100644 index 0000000..d56cdbf --- /dev/null +++ b/src/handlers/suggestion.js @@ -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, +}; \ No newline at end of file diff --git a/src/handlers/ticket.js b/src/handlers/ticket.js new file mode 100644 index 0000000..bb3d3cd --- /dev/null +++ b/src/handlers/ticket.js @@ -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, +}; \ No newline at end of file diff --git a/src/handlers/translation.js b/src/handlers/translation.js new file mode 100644 index 0000000..5dc9a89 --- /dev/null +++ b/src/handlers/translation.js @@ -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, +}; \ No newline at end of file