diff --git a/src/helpers/BotUtils.js b/src/helpers/BotUtils.js new file mode 100644 index 0000000..0a3d973 --- /dev/null +++ b/src/helpers/BotUtils.js @@ -0,0 +1,80 @@ +const { getJson } = require("@helpers/HttpUtils"); +const { success, warn, error } = require("@helpers/Logger"); + +module.exports = class BotUtils { + /** + * Check if the bot is up to date + */ + static async checkForUpdates() { + const response = await getJson("https://toastielab.dev/api/v1/repos/toastie_t0ast/discord-bot/releases/latest"); + if (!response.success) return error("VersionCheck: Failed to check for bot updates"); + if (response.data) { + if ( + require("@root/package.json").version.replace(/[^0-9]/g, "") >= response.data.tag_name.replace(/[^0-9]/g, "") + ) { + success("VersionCheck: Your discord bot is up to date"); + } else { + warn(`VersionCheck: ${response.data.tag_name} update is available`); + warn("download: https://toastielab.dev/toastie_t0ast/discord-bot/releases/latest"); + } + } + } + + /** + * Get the image url from the message + * @param {import('discord.js').Message} message + * @param {string[]} args + */ + static async getImageFromMessage(message, args) { + let url; + + // check for attachments + if (message.attachments.size > 0) { + const attachment = message.attachments.first(); + const attachUrl = attachment.url; + const attachIsImage = attachUrl.endsWith(".png") || attachUrl.endsWith(".jpg") || attachUrl.endsWith(".jpeg"); + if (attachIsImage) url = attachUrl; + } + + if (!url && args.length === 0) url = message.author.displayAvatarURL({ size: 256, extension: "png" }); + + if (!url && args.length !== 0) { + try { + url = new URL(args[0]).href; + } catch (ex) { + /* Ignore */ + } + } + + if (!url && message.mentions.users.size > 0) { + url = message.mentions.users.first().displayAvatarURL({ size: 256, extension: "png" }); + } + + if (!url) { + const member = await message.guild.resolveMember(args[0]); + if (member) url = member.user.displayAvatarURL({ size: 256, extension: "png" }); + } + + if (!url) url = message.author.displayAvatarURL({ size: 256, extension: "png" }); + + return url; + } + + static get musicValidations() { + return [ + { + callback: ({ client, guildId }) => client.musicManager.getPlayer(guildId), + message: "🚫 No music is being played!", + }, + { + callback: ({ member }) => member.voice?.channelId, + message: "🚫 You need to join my voice channel.", + }, + { + callback: ({ member, client, guildId }) => + member.voice?.channelId === client.musicManager.getPlayer(guildId)?.channelId, + message: "🚫 You're not in the same voice channel.", + }, + ]; + } +}; \ No newline at end of file diff --git a/src/helpers/HttpUtils.js b/src/helpers/HttpUtils.js new file mode 100644 index 0000000..1629ef3 --- /dev/null +++ b/src/helpers/HttpUtils.js @@ -0,0 +1,107 @@ +const ISO6391 = require("iso-639-1"); +const sourcebin = require("sourcebin_js"); +const { error, debug } = require("@helpers/Logger"); +const fetch = require("node-fetch"); +const { translate: gTranslate } = require("@vitalets/google-translate-api"); + +module.exports = class HttpUtils { + /** + * Returns JSON response from url + * @param {string} url + * @param {object} options + */ + static async getJson(url, options) { + try { + // with auth + const response = options ? await fetch(url, options) : await fetch(url); + const json = await response.json(); + return { + success: response.status === 200 ? true : false, + status: response.status, + data: json, + }; + } catch (ex) { + debug(`Url: ${url}`); + error(`getJson`, ex); + return { + success: false, + }; + } + } + + /** + * Returns buffer from url + * @param {string} url + * @param {object} options + */ + static async getBuffer(url, options) { + try { + const response = options ? await fetch(url, options) : await fetch(url); + const buffer = await response.buffer(); + if (response.status !== 200) debug(response); + return { + success: response.status === 200 ? true : false, + status: response.status, + buffer, + }; + } catch (ex) { + debug(`Url: ${url}`); + error(`getBuffer`, ex); + return { + success: false, + }; + } + } + + /** + * Translates the provided content to the provided language code + * @param {string} content + * @param {string} outputCode + */ + static async translate(content, outputCode) { + try { + const { text, raw } = await gTranslate(content, { to: outputCode }); + return { + input: raw.src, + output: text, + inputCode: raw.src, + outputCode, + inputLang: ISO6391.getName(raw.src), + outputLang: ISO6391.getName(outputCode), + }; + } catch (ex) { + error("translate", ex); + debug(`Content - ${content} OutputCode: ${outputCode}`); + } + } + + /** + * Posts the provided content to the BIN + * @param {string} content + * @param {string} title + */ + static async postToBin(content, title) { + try { + const response = await sourcebin.create( + [ + { + name: " ", + content, + languageId: "text", + }, + ], + { + title, + description: " ", + } + ); + return { + url: response.url, + short: response.short, + raw: `https://cdn.sourceb.in/bins/${response.key}/0`, + }; + } catch (ex) { + error(`postToBin`, ex); + } + } +}; \ No newline at end of file diff --git a/src/helpers/Logger.js b/src/helpers/Logger.js new file mode 100644 index 0000000..4011e1d --- /dev/null +++ b/src/helpers/Logger.js @@ -0,0 +1,94 @@ +const config = require("@root/config"); +const { EmbedBuilder, WebhookClient } = require("discord.js"); +const pino = require("pino"); + +const webhookLogger = process.env.ERROR_LOGS ? new WebhookClient({ url: process.env.ERROR_LOGS }) : undefined; + +const today = new Date(); +const pinoLogger = pino.default( + { + level: "debug", + }, + pino.multistream([ + { + level: "info", + stream: pino.transport({ + target: "pino-pretty", + options: { + colorize: true, + translateTime: "yyyy-mm-dd HH:mm:ss", + ignore: "pid,hostname", + singleLine: false, + hideObject: true, + customColors: "info:blue,warn:yellow,error:red", + }, + }), + }, + { + level: "debug", + stream: pino.destination({ + dest: `${process.cwd()}/logs/combined-${today.getFullYear()}.${today.getMonth() + 1}.${today.getDate()}.log`, + sync: true, + mkdir: true, + }), + }, + ]) +); + +function sendWebhook(content, err) { + if (!content && !err) return; + const errString = err?.stack || err; + + const embed = new EmbedBuilder().setColor(config.EMBED_COLORS.ERROR).setAuthor({ name: err?.name || "Error" }); + + if (errString) + embed.setDescription( + "```js\n" + (errString.length > 4096 ? `${errString.substr(0, 4000)}...` : errString) + "\n```" + ); + + embed.addFields({ name: "Description", value: content || err?.message || "NA" }); + webhookLogger.send({ username: "Logs", embeds: [embed] }).catch((ex) => { }); +} + +module.exports = class Logger { + /** + * @param {string} content + */ + static success(content) { + pinoLogger.info(content); + } + + /** + * @param {string} content + */ + static log(content) { + pinoLogger.info(content); + } + + /** + * @param {string} content + */ + static warn(content) { + pinoLogger.warn(content); + } + + /** + * @param {string} content + * @param {object} ex + */ + static error(content, ex) { + if (ex) { + pinoLogger.error(ex, `${content}: ${ex?.message}`); + } else { + pinoLogger.error(content); + } + if (webhookLogger) sendWebhook(content, ex); + } + + /** + * @param {string} content + */ + static debug(content) { + pinoLogger.debug(content); + } +}; \ No newline at end of file diff --git a/src/helpers/ModUtil.js b/src/helpers/ModUtil.js new file mode 100644 index 0000000..869bfa0 --- /dev/null +++ b/src/helpers/ModUtil.js @@ -0,0 +1,5 @@ +const {Collection, EmbedBuilder, GuildMember} = require("discord.js"); +const { MODERATION } = require("@root/config"); + +// Utils +const \ No newline at end of file diff --git a/src/helpers/Utils.js b/src/helpers/Utils.js new file mode 100644 index 0000000..9730784 --- /dev/null +++ b/src/helpers/Utils.js @@ -0,0 +1,136 @@ +const { COLORS } = require("@src/data.json"); +const { readdirSync, lstatSync } = require("fs"); +const { join, extname } = require("path"); +const permissions = require("./permissions"); + +module.exports = class Utils { + /** + * Checks if a string contains a URL + * @param {string} text + */ + static containsLink(text) { + return /(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})/.test( + text + ); + } + + /** + * Checks if a string is a valid discord invite + * @param {string} text + */ + static containsDiscordInvite(text) { + return /(https?:\/\/)?(www.)?(discord.(gg|io|me|li|link|plus)|discorda?p?p?.com\/invite|invite.gg|dsc.gg|urlcord.cf)\/[^\s/]+?(?=\b)/.test( + text + ); + } + + /** + * Returns a random number below a max + * @param {number} max + */ + static getRandomInt(max) { + return Math.floor(Math.random() * max); + } + + /** + * Checks if a string is a valid Hex color + * @param {string} text + */ + static isHex(text) { + return /^#[0-9A-F]{6}$/i.test(text); + } + + /** + * Checks if a string is a valid Hex color + * @param {string} text + */ + static isValidColor(text) { + if (COLORS.indexOf(text) > -1) { + return true; + } else return false; + } + + /** + * Returns hour difference between two dates + * @param {Date} dt2 + * @param {Date} dt1 + */ + static diffHours(dt2, dt1) { + let diff = (dt2.getTime() - dt1.getTime()) / 1000; + diff /= 60 * 60; + return Math.abs(Math.round(diff)); + } + + /** + * Returns remaining time in days, hours, minutes and seconds + * @param {number} timeInSeconds + */ + static timeformat(timeInSeconds) { + const days = Math.floor((timeInSeconds % 31536000) / 86400); + const hours = Math.floor((timeInSeconds % 86400) / 3600); + const minutes = Math.floor((timeInSeconds % 3600) / 60); + const seconds = Math.round(timeInSeconds % 60); + return ( + (days > 0 ? `${days} days, ` : "") + + (hours > 0 ? `${hours} hours, ` : "") + + (minutes > 0 ? `${minutes} minutes, ` : "") + + (seconds > 0 ? `${seconds} seconds` : "") + ); + } + + /** + * Converts duration to milliseconds + * @param {string} duration + */ + static durationToMillis(duration) { + return ( + duration + .split(":") + .map(Number) + .reduce((acc, curr) => curr + acc * 60) * 1000 + ); + } + + /** + * Returns time remaining until provided date + * @param {Date} timeUntil + */ + static getRemainingTime(timeUntil) { + const seconds = Math.abs((timeUntil - new Date()) / 1000); + const time = Utils.timeformat(seconds); + return time; + } + + /** + * @param {import("discord.js").PermissionResolvable[]} perms + */ + static parsePermissions(perms) { + const permissionWord = `permission${perms.length > 1 ? "s" : ""}`; + return "`" + perms.map((perm) => permissions[perm]).join(", ") + "` " + permissionWord; + } + + /** + * Recursively searches for a file in a directory + * @param {string} dir + * @param {string[]} allowedExtensions + */ + static recursiveReadDirSync(dir, allowedExtensions = [".js"]) { + const filePaths = []; + const readCommands = (dir) => { + const files = readdirSync(join(process.cwd(), dir)); + files.forEach((file) => { + const stat = lstatSync(join(process.cwd(), dir, file)); + if (stat.isDirectory()) { + readCommands(join(dir, file)); + } else { + const extension = extname(file); + if (!allowedExtensions.includes(extension)) return; + const filePath = join(process.cwd(), dir, file); + filePaths.push(filePath); + } + }); + }; + readCommands(dir); + return filePaths; + } +}; \ No newline at end of file diff --git a/src/helpers/Validator.js b/src/helpers/Validator.js new file mode 100644 index 0000000..9db8524 --- /dev/null +++ b/src/helpers/Validator.js @@ -0,0 +1,227 @@ +const CommandCategory = require("@structures/CommandCategory"); +const permissions = require("./permissions"); +const config = require("@root/config"); +const { log, warn, error } = require("./Logger"); +const { ApplicationCommandType } = require("discord.js"); + +module.exports = class Validator { + static validateConfiguration() { + log("Validating config file and environment variables"); + + // Bot Token + if (!process.env.BOT_TOKEN) { + error("env: BOT_TOKEN cannot be empty"); + process.exit(1); + } + + // Validate Database Config + if (!process.env.MONGO_CONNECTION) { + error("env: MONGO_CONNECTION cannot be empty"); + process.exit(1); + } + + // Validate Dashboard Config + if (config.DASHBOARD.enabled) { + if (!process.env.BOT_SECRET) { + error("env: BOT_SECRET cannot be empty"); + process.exit(1); + } + if (!process.env.SESSION_PASSWORD) { + error("env: SESSION_PASSWORD cannot be empty"); + process.exit(1); + } + if (!config.DASHBOARD.baseURL || !config.DASHBOARD.failureURL || !config.DASHBOARD.port) { + error("config.js: DASHBOARD details cannot be empty"); + process.exit(1); + } + } + + // Cache Size + if (isNaN(config.CACHE_SIZE.GUILDS) || isNaN(config.CACHE_SIZE.USERS) || isNaN(config.CACHE_SIZE.MEMBERS)) { + error("config.js: CACHE_SIZE must be a positive integer"); + process.exit(1); + } + + // Music + if (config.MUSIC.ENABLED) { + if (!process.env.SPOTIFY_CLIENT_ID || !process.env.SPOTIFY_CLIENT_SECRET) { + warn("env: SPOTIFY_CLIENT_ID or SPOTIFY_CLIENT_SECRET are missing. Spotify music links won't work"); + } + if (config.MUSIC.LAVALINK_NODES.length == 0) { + warn("config.js: There must be at least one node for Lavalink"); + } + if (!["YT", "YTM", "SC"].includes(config.MUSIC.DEFAULT_SOURCE)) { + warn("config.js: MUSIC.DEFAULT_SOURCE must be either YT, YTM or SC"); + } + } + + // Warnings + if (config.OWNER_IDS.length === 0) warn("config.js: OWNER_IDS are empty"); + if (!config.SUPPORT_SERVER) warn("config.js: SUPPORT_SERVER is not provided"); + if (!process.env.WEATHERSTACK_KEY) warn("env: WEATHERSTACK_KEY is missing. Weather command won't work"); + if (!process.env.STRANGE_API_KEY) warn("env: STRANGE_API_KEY is missing. Image commands won't work"); + } + + /** + * @param {import('@structures/Command')} cmd + */ + static validateCommand(cmd) { + if (typeof cmd !== "object") { + throw new TypeError("Command data must be an Object."); + } + if (typeof cmd.name !== "string" || cmd.name !== cmd.name.toLowerCase()) { + throw new Error("Command name must be a lowercase string."); + } + if (typeof cmd.description !== "string") { + throw new TypeError("Command description must be a string."); + } + if (cmd.cooldown && typeof cmd.cooldown !== "number") { + throw new TypeError("Command cooldown must be a number"); + } + if (cmd.category) { + if (!Object.prototype.hasOwnProperty.call(CommandCategory, cmd.category)) { + throw new Error(`Not a valid category ${cmd.category}`); + } + } + if (cmd.userPermissions) { + if (!Array.isArray(cmd.userPermissions)) { + throw new TypeError("Command userPermissions must be an Array of permission key strings."); + } + for (const perm of cmd.userPermissions) { + if (!permissions[perm]) throw new RangeError(`Invalid command userPermission: ${perm}`); + } + } + if (cmd.botPermissions) { + if (!Array.isArray(cmd.botPermissions)) { + throw new TypeError("Command botPermissions must be an Array of permission key strings."); + } + for (const perm of cmd.botPermissions) { + if (!permissions[perm]) throw new RangeError(`Invalid command botPermission: ${perm}`); + } + } + if (cmd.validations) { + if (!Array.isArray(cmd.validations)) { + throw new TypeError("Command validations must be an Array of validation Objects."); + } + for (const validation of cmd.validations) { + if (typeof validation !== "object") { + throw new TypeError("Command validations must be an object."); + } + if (typeof validation.callback !== "function") { + throw new TypeError("Command validation callback must be a function."); + } + if (typeof validation.message !== "string") { + throw new TypeError("Command validation message must be a string."); + } + } + } + + // Validate Command Details + if (cmd.command) { + if (typeof cmd.command !== "object") { + throw new TypeError("Command.command must be an object"); + } + if (Object.prototype.hasOwnProperty.call(cmd.command, "enabled") && typeof cmd.command.enabled !== "boolean") { + throw new TypeError("Command.command enabled must be a boolean value"); + } + if ( + cmd.command.aliases && + (!Array.isArray(cmd.command.aliases) || + cmd.command.aliases.some((ali) => typeof ali !== "string" || ali !== ali.toLowerCase())) + ) { + throw new TypeError("Command.command aliases must be an Array of lowercase strings."); + } + if (cmd.command.usage && typeof cmd.command.usage !== "string") { + throw new TypeError("Command.command usage must be a string"); + } + if (cmd.command.minArgsCount && typeof cmd.command.minArgsCount !== "number") { + throw new TypeError("Command.command minArgsCount must be a number"); + } + if (cmd.command.subcommands && !Array.isArray(cmd.command.subcommands)) { + throw new TypeError("Command.command subcommands must be an array"); + } + if (cmd.command.subcommands) { + for (const sub of cmd.command.subcommands) { + if (typeof sub !== "object") { + throw new TypeError("Command.command subcommands must be an array of objects"); + } + if (typeof sub.trigger !== "string") { + throw new TypeError("Command.command subcommand trigger must be a string"); + } + if (typeof sub.description !== "string") { + throw new TypeError("Command.command subcommand description must be a string"); + } + } + } + if (cmd.command.enabled && typeof cmd.messageRun !== "function") { + throw new TypeError("Missing 'messageRun' function"); + } + } + + // Validate Slash Command Details + if (cmd.slashCommand) { + if (typeof cmd.slashCommand !== "object") { + throw new TypeError("Command.slashCommand must be an object"); + } + if ( + Object.prototype.hasOwnProperty.call(cmd.slashCommand, "enabled") && + typeof cmd.slashCommand.enabled !== "boolean" + ) { + throw new TypeError("Command.slashCommand enabled must be a boolean value"); + } + if ( + Object.prototype.hasOwnProperty.call(cmd.slashCommand, "ephemeral") && + typeof cmd.slashCommand.ephemeral !== "boolean" + ) { + throw new TypeError("Command.slashCommand ephemeral must be a boolean value"); + } + if (cmd.slashCommand.options && !Array.isArray(cmd.slashCommand.options)) { + throw new TypeError("Command.slashCommand options must be a array"); + } + if (cmd.slashCommand.enabled && typeof cmd.interactionRun !== "function") { + throw new TypeError("Missing 'interactionRun' function"); + } + } + } + + /** + * @param {import('@structures/BaseContext')} context + */ + static validateContext(context) { + if (typeof context !== "object") { + throw new TypeError("Context must be an object"); + } + if (typeof context.name !== "string" || context.name !== context.name.toLowerCase()) { + throw new Error("Context name must be a lowercase string."); + } + if (typeof context.description !== "string") { + throw new TypeError("Context description must be a string."); + } + if (context.type !== ApplicationCommandType.User && context.type !== ApplicationCommandType.Message) { + throw new TypeError("Context type must be a either User/Message."); + } + if (Object.prototype.hasOwnProperty.call(context, "enabled") && typeof context.enabled !== "boolean") { + throw new TypeError("Context enabled must be a boolean value"); + } + if (Object.prototype.hasOwnProperty.call(context, "ephemeral") && typeof context.ephemeral !== "boolean") { + throw new TypeError("Context enabled must be a boolean value"); + } + if ( + Object.prototype.hasOwnProperty.call(context, "defaultPermission") && + typeof context.defaultPermission !== "boolean" + ) { + throw new TypeError("Context defaultPermission must be a boolean value"); + } + if (Object.prototype.hasOwnProperty.call(context, "cooldown") && typeof context.cooldown !== "number") { + throw new TypeError("Context cooldown must be a number"); + } + if (context.userPermissions) { + if (!Array.isArray(context.userPermissions)) { + throw new TypeError("Context userPermissions must be an Array of permission key strings."); + } + for (const perm of context.userPermissions) { + if (!permissions[perm]) throw new RangeError(`Invalid command userPermission: ${perm}`); + } + } + } +}; \ No newline at end of file diff --git a/src/helpers/channelTypes.js b/src/helpers/channelTypes.js new file mode 100644 index 0000000..d7fc1cc --- /dev/null +++ b/src/helpers/channelTypes.js @@ -0,0 +1,31 @@ +const { ChannelType } = require("discord.js"); + +/** + * @param {number} type + */ +module.exports = (type) => { + switch (type) { + case ChannelType.GuildText: + return "Guild Text"; + case ChannelType.GuildVoice: + return "Guild Voice"; + case ChannelType.GuildCategory: + return "Guild Category"; + case ChannelType.GuildAnnouncement: + return "Guild Announcement"; + case ChannelType.AnnouncementThread: + return "Guild Announcement Thread"; + case ChannelType.PublicThread: + return "Guild Public Thread"; + case ChannelType.PrivateThread: + return "Guild Private Thread"; + case ChannelType.GuildStageVoice: + return "Guild Stage Voice"; + case ChannelType.GuildDirectory: + return "Guild Directory"; + case ChannelType.GuildForum: + return "Guild Forum"; + default: + return "Unknown"; + } +}; \ No newline at end of file diff --git a/src/helpers/extenders/Guild.js b/src/helpers/extenders/Guild.js new file mode 100644 index 0000000..7458c80 --- /dev/null +++ b/src/helpers/extenders/Guild.js @@ -0,0 +1,151 @@ +const { Guild, ChannelType } = require("discord.js"); + +const ROLE_MENTION = /?/; +const CHANNEL_MENTION = /?/; +const MEMBER_MENTION = /?/; + +/** + * Get all channels that match the query + * @param {string} query + * @param {import("discord.js").GuildChannelTypes[]} type + */ +Guild.prototype.findMatchingChannels = function (query, type = [ChannelType.GuildText, ChannelType.GuildAnnouncement]) { + if (!this || !query || typeof query !== "string") return []; + + const channelManager = this.channels.cache.filter((ch) => type.includes(ch.type)); + + const patternMatch = query.match(CHANNEL_MENTION); + if (patternMatch) { + const id = patternMatch[1]; + const channel = channelManager.find((r) => r.id === id); + if (channel) return [channel]; + } + + const exact = []; + const startsWith = []; + const includes = []; + channelManager.forEach((ch) => { + const lowerName = ch.name.toLowerCase(); + if (ch.name === query) exact.push(ch); + if (lowerName.startsWith(query.toLowerCase())) startsWith.push(ch); + if (lowerName.includes(query.toLowerCase())) includes.push(ch); + }); + + if (exact.length > 0) return exact; + if (startsWith.length > 0) return startsWith; + if (includes.length > 0) return includes; + return []; +}; + +/** + * Get all channels that match the query + * @param {string} query + * @param {import("discord.js").GuildChannelTypes[]} type + */ +Guild.prototype.findMatchingVoiceChannels = function ( + query, + type = [ChannelType.GuildVoice, ChannelType.GuildStageVoice] +) { + if (!this || !query || typeof query !== "string") return []; + + const channelManager = this.channels.cache.filter((ch) => type.includes(ch.type)); + + const patternMatch = query.match(CHANNEL_MENTION); + if (patternMatch) { + const id = patternMatch[1]; + const channel = channelManager.find((r) => r.id === id); + if (channel) return [channel]; + } + + const exact = []; + const startsWith = []; + const includes = []; + channelManager.forEach((ch) => { + const lowerName = ch.name.toLowerCase(); + if (ch.name === query) exact.push(ch); + if (lowerName.startsWith(query.toLowerCase())) startsWith.push(ch); + if (lowerName.includes(query.toLowerCase())) includes.push(ch); + }); + + if (exact.length > 0) return exact; + if (startsWith.length > 0) return startsWith; + if (includes.length > 0) return includes; + return []; +}; + +/** + * Find all roles that match the query + * @param {string} query + */ +Guild.prototype.findMatchingRoles = function (query) { + if (!this || !query || typeof query !== "string") return []; + + const patternMatch = query.match(ROLE_MENTION); + if (patternMatch) { + const id = patternMatch[1]; + const role = this.roles.cache.find((r) => r.id === id); + if (role) return [role]; + } + + const exact = []; + const startsWith = []; + const includes = []; + this.roles.cache.forEach((role) => { + const lowerName = role.name.toLowerCase(); + if (role.name === query) exact.push(role); + if (lowerName.startsWith(query.toLowerCase())) startsWith.push(role); + if (lowerName.includes(query.toLowerCase())) includes.push(role); + }); + if (exact.length > 0) return exact; + if (startsWith.length > 0) return startsWith; + if (includes.length > 0) return includes; + return []; +}; + +/** + * Resolves a guild member from search query + * @param {string} query + * @param {boolean} exact + */ +Guild.prototype.resolveMember = async function (query, exact = false) { + if (!query || typeof query !== "string") return; + + // Check if mentioned or ID is passed + const patternMatch = query.match(MEMBER_MENTION); + if (patternMatch) { + const id = patternMatch[1]; + const fetched = await this.members.fetch({ user: id }).catch(() => {}); + if (fetched) return fetched; + } + + // Fetch and cache members from API + await this.members.fetch({ query }).catch(() => {}); + + // Check if exact tag is matched + const matchingTags = this.members.cache.filter((mem) => mem.user.tag === query); + if (matchingTags.size === 1) return matchingTags.first(); + + // Check for matching username + if (!exact) { + return this.members.cache.find( + (x) => + x.user.username === query || + x.user.username.toLowerCase().includes(query.toLowerCase()) || + x.displayName.toLowerCase().includes(query.toLowerCase()) + ); + } +}; + +/** + * Fetch member stats + */ +Guild.prototype.fetchMemberStats = async function () { + const all = await this.members.fetch({ + force: false, + cache: false, + }); + const total = all.size; + const bots = all.filter((mem) => mem.user.bot).size; + const members = total - bots; + return [total, bots, members]; +}; \ No newline at end of file diff --git a/src/helpers/extenders/GuildChannel.js b/src/helpers/extenders/GuildChannel.js new file mode 100644 index 0000000..6ace2a1 --- /dev/null +++ b/src/helpers/extenders/GuildChannel.js @@ -0,0 +1,30 @@ +const { GuildChannel, ChannelType } = require("discord.js"); + +/** + * Check if bot has permission to send embeds + */ +GuildChannel.prototype.canSendEmbeds = function () { + return this.permissionsFor(this.guild.members.me).has(["ViewChannel", "SendMessages", "EmbedLinks"]); +}; + +/** + * Safely send a message to the channel + * @param {string|import('discord.js').MessagePayload|import('discord.js').MessageOptions} content + * @param {number} [seconds] + */ +GuildChannel.prototype.safeSend = async function (content, seconds) { + if (!content) return; + if (!this.type === ChannelType.GuildText && !this.type === ChannelType.DM) return; + + const perms = ["ViewChannel", "SendMessages"]; + if (content.embeds && content.embeds.length > 0) perms.push("EmbedLinks"); + if (!this.permissionsFor(this.guild.members.me).has(perms)) return; + + try { + if (!seconds) return await this.send(content); + const reply = await this.send(content); + setTimeout(() => reply.deletable && reply.delete().catch((ex) => {}), seconds * 1000); + } catch (ex) { + this.client.logger.error(`safeSend`, ex); + } +}; \ No newline at end of file diff --git a/src/helpers/extenders/Message.js b/src/helpers/extenders/Message.js new file mode 100644 index 0000000..29336ef --- /dev/null +++ b/src/helpers/extenders/Message.js @@ -0,0 +1,25 @@ +const { Message } = require("discord.js"); + +/** + * @param {string|import('discord.js').MessagePayload|import('discord.js').MessageOptions} content + * @param {number} [seconds] + */ +Message.prototype.safeReply = async function (content, seconds) { + if (!content) return; + const perms = ["ViewChannel", "SendMessages"]; + if (content.embeds && content.embeds.length > 0) perms.push("EmbedLinks"); + if (this.channel.type !== "DM" && !this.channel.permissionsFor(this.guild.members.me).has(perms)) return; + + perms.push("ReadMessageHistory"); + if (this.channel.type !== "DM" && !this.channel.permissionsFor(this.guild.members.me).has(perms)) { + return this.channel.safeSend(content, seconds); + } + + try { + if (!seconds) return await this.reply(content); + const reply = await this.reply(content); + setTimeout(() => reply.deletable && reply.delete().catch((ex) => {}), seconds * 1000); + } catch (ex) { + this.client.logger.error(`safeReply`, ex); + } +}; \ No newline at end of file diff --git a/src/helpers/permissions.js b/src/helpers/permissions.js new file mode 100644 index 0000000..f0ed591 --- /dev/null +++ b/src/helpers/permissions.js @@ -0,0 +1,43 @@ +module.exports = { + AddReactions: "Add Reactions", + Administrator: "Administrator", + AttachFiles: "Attach files", + BanMembers: "Ban members", + ChangeNickname: "Change nickname", + Connect: "Connect", + CreateInstantInvite: "Create instant invite", + CreatePrivateThreads: "Create private threads", + CreatePublicThreads: "Create public threads", + DeafenMembers: "Deafen members", + EmbedLinks: "Embed links", + KickMembers: "Kick members", + ManageChannels: "Manage channels", + ManageEmojisAndStickers: "Manage emojis and stickers", + ManageEvents: "Manage Events", + ManageGuild: "Manage server", + ManageMessages: "Manage messages", + ManageNicknames: "Manage nicknames", + ManageRoles: "Manage roles", + ManageThreads: "Manage Threads", + ManageWebhooks: "Manage webhooks", + MentionEveryone: "Mention everyone", + ModerateMembers: "Moderate Members", + MoveMembers: "Move members", + MuteMembers: "Mute members", + PrioritySpeaker: "Priority speaker", + ReadMessageHistory: "Read message history", + RequestToSpeak: "Request to Speak", + SendMessages: "Send messages", + SendMessagesInThreads: "Send Messages In Threads", + SendTTSMessages: "Send TTS messages", + Speak: "Speak", + Stream: "Video", + UseApplicationCommands: "Use Application Commands", + UseEmbeddedActivities: "Use Embedded Activities", + UseExternalEmojis: "Use External Emojis", + UseExternalStickers: "Use External Stickers", + UseVAD: "Use voice activity", + ViewAuditLog: "View audit log", + ViewChannel: "View channel", + ViewGuildInsights: "View server insights", +}; \ No newline at end of file