diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bb3a051 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,37 @@ +# Dependencies +node_modules +npm-debug.log +yarn-debug.log +yarn-error.log + +# Environment variables +.env +.env.local +.env.*.local + +# Version control +.git +.gitignore + +# IDE and editor files +.idea +.vscode +*.swp +*.swo + +# OS generated files +.DS_Store +Thumbs.db + +# Build output +dist +build +coverage + +# Docker files +Dockerfile +docker-compose.yml +.dockerignore + +# Development config +botconfig.dev.json \ No newline at end of file diff --git a/.gitignore b/.gitignore index 9f544ec..cd8d06e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /node_modules /.babelrc /yarn.lock -/botconfig.json \ No newline at end of file +/botconfig.json +/botconfig.dev.json \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cbc5023 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +# Use Debian Bookworm as the base image +FROM debian:bookworm-slim + +# Install Node.js and npm +RUN apt-get update && apt-get install -y \ + curl \ + gnupg \ + && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ + && apt-get install -y nodejs \ + fonts-liberation fonts-dejavu fontconfig \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Create app directory +WORKDIR /usr/src/app + +# Copy package.json and package-lock.json (if available) +COPY package*.json ./ + +# Install dependencies +RUN npm install + +# Copy application code +COPY . . + +# Start the application +CMD ["npm", "start"] diff --git a/assets/level_clean.png b/assets/level_clean.png new file mode 100644 index 0000000..97f75c0 Binary files /dev/null and b/assets/level_clean.png differ diff --git a/assets/progress_fill.png b/assets/progress_fill.png new file mode 100644 index 0000000..a98cabb Binary files /dev/null and b/assets/progress_fill.png differ diff --git a/commands/admin/adjust_xp.js b/commands/admin/adjust_xp.js new file mode 100644 index 0000000..90e6f86 --- /dev/null +++ b/commands/admin/adjust_xp.js @@ -0,0 +1,92 @@ +const Embed = require("../../functions/embed") +const { updateUserXP, getUserXP } = require("../../models/user"); +const { xpSystem } = require("../../functions/level/xpSystem"); + +module.exports = { + config: { + name: "adjustxp", + cooldown: 5000, + available: true, + usage: true, + permissions: [], + aliases: [], + roles: ['staff'], + dm: false, + }, + run: async (client, message, args, db) => { + if (!args[0]) return message.reply("You must mention a user to adjust their XP", false); + if (message.mentionIds && message.mentionIds.length > 0) { + let targetUser; + try { + const server = await client.servers.fetch(message.server.id); + const member = await server.fetchMember(message.mentionIds[0]); + + if (!member || !member.id || !member.id.user) { + throw new Error("Invalid member structure"); + } + + // Fetch the user using the user ID from the member object + targetUser = await client.users.fetch(member.id.user); + + if (!targetUser || !targetUser.username) { + throw new Error("Could not fetch user information"); + } + + } catch (fetchError) { + console.error("Fetch error details:", fetchError.message); + return message.reply({ + embeds: [new Embed() + .setDescription("Could not find the mentioned user.") + .setColor(`#FF0000`)] + }, false); + } + + if (!args[1]) return message.reply("You must specify an amount to adjust the XP by. `!adjust_xp @USER +/-`", false); + + // Validate the amount is a valid number + const amountStr = args[1].replace("+", "").replace("-", ""); + const amount = parseInt(amountStr); + + if (isNaN(amount)) { + return message.reply({ + embeds: [new Embed() + .setDescription("Invalid amount specified. Please provide a valid number.") + .setColor(`#FF0000`)] + }, false); + } + + try { + const userDoc = await getUserXP(targetUser.id); + const currentXP = userDoc.xp || 0; + const newXP = args[1].includes("+") ? currentXP + amount : currentXP - amount; + + // Ensure the new XP value is not negative + if (newXP < 0) { + return message.reply({ + embeds: [new Embed() + .setDescription("Cannot set XP below 0.") + .setColor(`#FF0000`)] + }, false); + } + + await updateUserXP(targetUser, newXP); + const adjusted_amount = args[1].includes("+") ? `+${amount}` : `-${amount}`; + await xpSystem(client, message); + return message.reply({ + embeds: [new Embed() + .setDescription(`XP adjusted for ${targetUser.username} by ${adjusted_amount}`) + .setColor(`#00FF00`)] + }, false); + } catch (error) { + console.error("Error updating XP:", error); + return message.reply({ + embeds: [new Embed() + .setDescription("An error occurred while updating the XP.") + .setColor(`#FF0000`)] + }, false); + } + } else { + return message.reply("You must mention a user to adjust their XP", false); + } + }, +}; diff --git a/commands/prefix.js b/commands/admin/prefix.js similarity index 95% rename from commands/prefix.js rename to commands/admin/prefix.js index 753359f..84e7d54 100644 --- a/commands/prefix.js +++ b/commands/admin/prefix.js @@ -1,4 +1,4 @@ -const Embed = require("../functions/embed") +const Embed = require("../../functions/embed") module.exports = { config: { diff --git a/commands/register.js b/commands/admin/register.js similarity index 65% rename from commands/register.js rename to commands/admin/register.js index 30f8af5..b6de00b 100644 --- a/commands/register.js +++ b/commands/admin/register.js @@ -1,10 +1,25 @@ const axios = require(`axios`); const path = require('path'); -const botConfig = require(path.join(__dirname, '../botconfig.json')); -const { generateUniqueId } = require(path.join(__dirname, '../functions/randomStr')); +const botConfig = require(path.join(__dirname, '../../botconfig.json')); +const { generateUniqueId } = require(path.join(__dirname, '../../functions/randomStr')); const https = require('https'); -const Invites = require(path.join(__dirname, '../models/registerInvite')); -const Embed = require(path.join(__dirname, '../functions/embed')); +const Invites = require(path.join(__dirname, '../../models/registerInvite')); +const Embed = require(path.join(__dirname, '../../functions/embed')); +const DELAY = '5m'; + +// Helper function to parse time string to milliseconds +const parseTimeToMs = (timeStr) => { + const unit = timeStr.slice(-1).toLowerCase(); + const value = parseInt(timeStr.slice(0, -1)); + + switch(unit) { + case 's': return value * 1000; // seconds + case 'm': return value * 60 * 1000; // minutes + case 'h': return value * 60 * 60 * 1000; // hours + case 'd': return value * 24 * 60 * 60 * 1000; // days + default: return 300000; // default 5 minutes if invalid format + } +}; // Create axios instance outside of module.exports const makeRequest = axios.create({ @@ -17,6 +32,16 @@ const makeRequest = axios.create({ }) }); +// Helper function to delete messages after delay +const deleteAfterDelay = async (message, delay = parseTimeToMs(DELAY)) => { + try { + await new Promise(resolve => setTimeout(resolve, delay)); + await message.delete().catch(() => {}); + } catch (error) { + console.error('Error deleting message:', error); + } +}; + module.exports = { config: { name: `register`, @@ -29,24 +54,41 @@ module.exports = { dm: true }, run: async (client, message, args, db) => { + console.log('[Register Command] Command started with args:', { + args: args, + channelType: message.channel.type, + author: message.author.username, + guild: message.server?.name || 'DM' + }); try { // If not in DM, check for permissions and roles if (message.channel.type !== 'DirectMessage') { + console.log('[Register Command] Not in DM, checking permissions'); // Get allowed role IDs from config const allowedRoleIds = module.exports.config.roles .map(roleName => botConfig.roles[0][roleName]) .filter(Boolean); + console.log('[Register Command] Allowed role IDs:', allowedRoleIds); + console.log('[Register Command] User roles:', message.member.roles); + console.log('[Register Command] Bot config roles:', botConfig.roles[0]); const hasRequiredRole = message.member.roles.some(roleId => allowedRoleIds.includes(roleId)); + console.log('[Register Command] Has required role:', hasRequiredRole); + console.log('[Register Command] Is owner:', client.config.owners.includes(message.authorId)); if (!hasRequiredRole && !client.config.owners.includes(message.authorId)) { - return message.reply({ + console.log('[Register Command] User lacks required permissions'); + const reply = await message.reply({ embeds: [ new Embed() .setColor("#FF0000") .setDescription(`You don't have the required roles to use this command.`) ] }); + // Delete both messages after 5 minutes + deleteAfterDelay(message); + deleteAfterDelay(reply); + return; } } @@ -80,41 +122,46 @@ module.exports = { await registerInvite.save(); - return message.reply({ + const reply = await message.reply({ content: `Run this command in a DM to the bot and use this invite code: \`${inviteCode}\`\n **Usage:** \`!register ${inviteCode} \`\n **Note: This invite code will expire in 10 minutes.**` }); + return; } const [invite, username, email, profileUrl] = args; if (!invite || !username || !email || !profileUrl) { - return message.reply({ + const reply = await message.reply({ content: `[REG3] Please provide a invite code, username, email, and f95zone profile url. \n\n**Usage:** \`${client.prefix}register \`` }); + return; } // DM flow - Use client.database instead of direct model const registerInvite = await Invites.findOne({ invite: invite }); if (!registerInvite) { - return message.reply({ + const reply = await message.reply({ content: `[REG1] You don't have an invite code. Please run the command outside of DMs.` }); + return; } if (registerInvite.createdAt + 10 * 60 * 1000 < Date.now()) { await registerInvite.deleteOne({ invite: invite }); - return message.reply({ + const reply = await message.reply({ content: `[REG2] Your invite code has expired. Please run the command outside of DMs.` }); + return; } if (invite !== registerInvite.invite) { - return message.reply({ + const reply = await message.reply({ content: `[REG4] Invalid invite code. Please run the command outside of DMs.` }); + return; } let f95zoneId = profileUrl.split('/')[4]; @@ -134,40 +181,49 @@ module.exports = { if (data.status === 'success') { await registerInvite.deleteOne({ invite: invite }); - return message.reply({ - content: `[REG5] You have been registered. Please check your email for verification.` + const reply = await message.reply({ + content: `[REG5] You have been registered. Please check your email for verification.\n + **If you do not receive email in 5 minutes. Please check your spam or trash for email.**` }); + return; } else if (data.status === 'invalid_email') { - return message.reply({ + const reply = await message.reply({ content: `[REG5.1] An error occurred during registration. ${data.msg}` }); + return; } else if (data.status === 'username_exists') { - return message.reply({ + const reply = await message.reply({ content: `[REG5.2] An error occurred during registration. ${data.msg}` }); + return; } else if (data.status === 'invalid_f95_id') { - return message.reply({ + const reply = await message.reply({ content: `[REG5.3] An error occurred during registration. ${data.msg}` }); + return; } else if (data.status === 'email_exists') { - return message.reply({ + const reply = await message.reply({ content: `[REG5.4] An error occurred during registration. ${data.msg}` }); + return; } else if (data.status === 'error') { - return message.reply({ + const reply = await message.reply({ content: `[REG5.5] An error occurred during registration. ${data.msg}` }); + return; } } catch (apiError) { - return message.reply({ + const reply = await message.reply({ content: `[REG6] An error occurred during registration. Please try again later.\n\`${apiError}\`` }); + return; } } catch (error) { console.error('Registration Error:', error); - return message.reply({ + const reply = await message.reply({ content: `[REG7] An unexpected error occurred. Please try again later.\n\`${error}\`` }); + return; } }, }; diff --git a/commands/reload.js b/commands/admin/reload.js similarity index 96% rename from commands/reload.js rename to commands/admin/reload.js index 7586021..3ac434b 100644 --- a/commands/reload.js +++ b/commands/admin/reload.js @@ -1,4 +1,4 @@ -const Reload = require("../functions/reload") +const Reload = require("../../functions/reload") module.exports = { config: { name: "reload", diff --git a/commands/gaydar.js b/commands/fun/gaydar.js similarity index 98% rename from commands/gaydar.js rename to commands/fun/gaydar.js index ce7ab1a..eb263f1 100644 --- a/commands/gaydar.js +++ b/commands/fun/gaydar.js @@ -1,6 +1,6 @@ const fs = require("fs"); const path = require("path"); -const Embed = require("../functions/embed") +const Embed = require("../../functions/embed") const Uploader = require("revolt-uploader"); module.exports = { diff --git a/commands/gif.js b/commands/fun/gif.js similarity index 89% rename from commands/gif.js rename to commands/fun/gif.js index 5b0bd3d..0564472 100644 --- a/commands/gif.js +++ b/commands/fun/gif.js @@ -1,12 +1,12 @@ -const Embed = require("../functions/embed") +const Embed = require("../../functions/embed") const fetch = require('node-fetch-commonjs') -const config = require('../config') +const config = require('../../config') module.exports = { config: { name: "gif", usage: true, - cooldown: 5000, + cooldown: 10000, available: true, permissions: [], roles: [], diff --git a/commands/urban.js b/commands/fun/urban.js similarity index 96% rename from commands/urban.js rename to commands/fun/urban.js index b1e7bab..c4c71ac 100644 --- a/commands/urban.js +++ b/commands/fun/urban.js @@ -1,4 +1,4 @@ -const Embed = require("../functions/embed"); +const Embed = require("../../functions/embed"); const axios = require('axios'); module.exports = { diff --git a/commands/command.js b/commands/moderation/command.js similarity index 96% rename from commands/command.js rename to commands/moderation/command.js index bdfed1e..37c72d8 100644 --- a/commands/command.js +++ b/commands/moderation/command.js @@ -1,6 +1,6 @@ -const Embed = require("../functions/embed") -const CommandsDB = require('../models/commands'); -const { random } = require('../functions/randomStr'); +const Embed = require("../../functions/embed") +const CommandsDB = require('../../models/commands'); +const { random } = require('../../functions/randomStr'); const moment = require('moment'); module.exports = { diff --git a/commands/polls.js b/commands/moderation/polls.js similarity index 95% rename from commands/polls.js rename to commands/moderation/polls.js index 004a38f..d00bc9d 100644 --- a/commands/polls.js +++ b/commands/moderation/polls.js @@ -1,8 +1,8 @@ -const Embed = require(`../functions/embed`) -const Polls = require(`../functions/poll`) -const dhms = require(`../functions/dhms`); -const PollDB = require("../models/polls"); -const SavedPolls = require(`../models/savedPolls`) +const Embed = require(`../../functions/embed`) +const Polls = require(`../../functions/poll`) +const dhms = require(`../../functions/dhms`); +const PollDB = require("../../models/polls"); +const SavedPolls = require(`../../models/savedPolls`) module.exports = { config: { diff --git a/commands/rule7.js b/commands/moderation/rule7.js similarity index 95% rename from commands/rule7.js rename to commands/moderation/rule7.js index 589d17d..87f84cf 100644 --- a/commands/rule7.js +++ b/commands/moderation/rule7.js @@ -1,8 +1,8 @@ -const Embed = require(`../functions/embed`) -const Polls = require(`../functions/poll`) -const dhms = require(`../functions/dhms`); -const PollDB = require("../models/rule7"); -const SavedPolls = require(`../models/savedPolls`) +const Embed = require(`../../functions/embed`) +const Polls = require(`../../functions/poll`) +const dhms = require(`../../functions/dhms`); +const PollDB = require("../../models/rule7"); +const SavedPolls = require(`../../models/savedPolls`) module.exports = { config: { diff --git a/commands/avatar.js b/commands/utility/avatar.js similarity index 97% rename from commands/avatar.js rename to commands/utility/avatar.js index 8732b9d..e94c903 100644 --- a/commands/avatar.js +++ b/commands/utility/avatar.js @@ -1,4 +1,4 @@ -const Embed = require("../functions/embed") +const Embed = require("../../functions/embed") module.exports = { config: { diff --git a/commands/help.js b/commands/utility/help.js similarity index 96% rename from commands/help.js rename to commands/utility/help.js index 2a11d96..934cedd 100644 --- a/commands/help.js +++ b/commands/utility/help.js @@ -1,4 +1,4 @@ -const Embed = require("../functions/embed") +const Embed = require("../../functions/embed") module.exports = { config: { diff --git a/commands/info.js b/commands/utility/info.js similarity index 86% rename from commands/info.js rename to commands/utility/info.js index ca4b43c..b4ef22b 100644 --- a/commands/info.js +++ b/commands/utility/info.js @@ -1,7 +1,7 @@ -const Embed = require("../functions/embed"); -const Giveaway = require("../models/giveaways"); -const SavedPolls = require("../models/savedPolls"); -const { dependencies } = require("../package.json"); +const Embed = require("../../functions/embed"); +const Giveaway = require("../../models/giveaways"); +const SavedPolls = require("../../models/savedPolls"); +const { dependencies } = require("../../package.json"); module.exports = { config: { name: "info", diff --git a/commands/utility/level.js b/commands/utility/level.js new file mode 100644 index 0000000..b5ac885 --- /dev/null +++ b/commands/utility/level.js @@ -0,0 +1,46 @@ +const { getUserXP } = require("../../models/user"); +const generateLevelCard = require("../../functions/level/generate_level_card"); +const fs = require('fs').promises; +const path = require('path'); +const os = require('os'); + +module.exports = { + config: { + name: "level", + usage: true, + cooldown: 5000, + available: true, + permissions: [], + roles: [], + dm: false, + aliases: ['lvl'] + }, + run: async (client, message, args, db) => { + try { + const user = await getUserXP(message.author.id); + const levelCardBuffer = await generateLevelCard(user); + + // Create a temporary file path + const tempFilePath = path.join(os.tmpdir(), `level_card_${message.author.id}.png`); + + // Save the buffer to the temporary file + await fs.writeFile(tempFilePath, levelCardBuffer); + + // Upload the file + const attachment = await client.Uploader.uploadFile(tempFilePath, "level_card.png"); + + // Send the message with the attachment + await message.channel.sendMessage({ + attachments: [attachment] + }); + + // Clean up the temporary file + await fs.unlink(tempFilePath).catch(console.error); + } catch (error) { + console.error('Error in level command:', error); + await message.channel.sendMessage({ + content: "❌ An error occurred while generating your level card. Please try again later." + }); + } + }, +}; \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f429345 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,42 @@ +version: '3.8' + +services: + app: + build: . + container_name: zonies-bot + restart: unless-stopped + environment: + - MONGODB_URI=mongodb://mongodb:27017/zonies + volumes: + - ./assets:/usr/src/app/assets + - ./botconfig.json:/usr/src/app/botconfig.json + depends_on: + - mongodb + networks: + - app-network + deploy: + resources: + limits: + cpus: '2' + memory: 4G + reservations: + cpus: '1' + memory: 2G + + mongodb: + image: mongo:6.0 + container_name: zonies-mongodb + restart: unless-stopped + ports: + - "27017:27017" + volumes: + - mongodb_data:/data/db + networks: + - app-network + +networks: + app-network: + driver: bridge + +volumes: + mongodb_data: diff --git a/events/messageCreate.js b/events/messageCreate.js index 8be7bab..0530834 100644 --- a/events/messageCreate.js +++ b/events/messageCreate.js @@ -5,6 +5,7 @@ const EditCollector = require(path.join(__dirname, "../functions/messageEdit")); const CommandDB = require(path.join(__dirname, "../models/commands")); const { isJson } = require(path.join(__dirname, "../functions/randomStr")); const botConfig = require(path.join(__dirname, "../botconfig.json")); +const {xpSystem} = require(path.join(__dirname, "../functions/level/xpSystem")); module.exports = async (client, message) => { // Early return checks @@ -13,12 +14,18 @@ module.exports = async (client, message) => { const isDM = message.channel.type === "DirectMessage"; // Get guild settings (if not DM) - const db = isDM ? { prefix: client.botConfig.prefix, language: 'en' } : await client.database.getGuild(message.server.id, true); + const db = isDM ? { prefix: client.config.prefix, language: 'en' } : await client.database.getGuild(message.server.id, true); + + if (!isDM) { + await xpSystem(client, message); + } // Check if message starts with prefix if (!message.content.startsWith(db.prefix)) { + // Handle bot mention if (message.content && (new RegExp(`^(<@!?${client.user.id}>)`)).test(message.content)) { + console.log('[MessageCreate] Bot was mentioned'); const mention = new Embed() .setColor("#A52F05") .setTitle(client.user.username) @@ -41,6 +48,7 @@ module.exports = async (client, message) => { // Handle custom commands let check = await CommandDB.findOne({ name: cmd }).select("name").lean(); if (check) { + console.log('[MessageCreate] Custom command found:', cmd); CommandDB.findOne({ name: cmd }).then((data) => { if (isJson(data.content)) { let items = JSON.parse(data.content); @@ -53,6 +61,7 @@ module.exports = async (client, message) => { // Command handling let commandfile = client.commands.get(cmd) || client.commands.get(client.aliases.get(cmd)); + if (commandfile) { // DM Check - if command doesn't allow DMs and we're in a DM, return if (isDM && !commandfile.config.dm) { diff --git a/functions/level/generate_level_card.js b/functions/level/generate_level_card.js new file mode 100644 index 0000000..00ce4f0 --- /dev/null +++ b/functions/level/generate_level_card.js @@ -0,0 +1,88 @@ +const { createCanvas, loadImage } = require('canvas'); +const path = require('path'); + +/** + * Calculates the XP required for the next level + * @param {number} currentLevel - Current user level + * @returns {number} XP required for next level + */ +function calculateNextLevelXP(currentLevel) { + // Using the inverse of the level calculation formula from xpSystem.js + // level = 0.47 * sqrt(xp) + // Therefore, xp = (level/0.47)^2 + return Math.ceil(Math.pow((currentLevel + 1) / 0.47, 2)); +} + +/** + * Generates a level card image for a user + * @param {Object} user - User object containing id, username, xp, and level + * @returns {Promise} - Returns a buffer containing the generated image + */ +async function generateLevelCard(user) { + // Create canvas with dimensions matching the background image + const canvas = createCanvas(1516, 662); + const ctx = canvas.getContext('2d'); + + try { + // Load background image + const background = await loadImage(path.join(__dirname, '../../assets/level_clean.png')); + ctx.drawImage(background, 0, 0, canvas.width, canvas.height); + + // Load progress bar fill image + const progressFill = await loadImage(path.join(__dirname, '../../assets/progress_fill.png')); + + // Calculate XP progress using the same formula as xpSystem.js + const currentLevelXP = Math.floor(Math.pow(user.level / 0.47, 2)); + const nextLevelXP = Math.floor(calculateNextLevelXP(user.level)); + const xpInLevel = user.xp - currentLevelXP; + const xpForLevel = nextLevelXP - currentLevelXP; + // Clamp progress between 0 and 1 + const progress = Math.max(0, Math.min(1, xpInLevel / xpForLevel)); + // Clamp displayed XP to not exceed xpForLevel + const displayXP = Math.min(Math.max(0, xpInLevel), xpForLevel); + + // Draw username + ctx.font = 'bold 60px Arial'; + ctx.fillStyle = '#FFFFFF'; + ctx.textAlign = 'left'; + ctx.fillText(user.username, 420, 250); + + // Draw level + ctx.font = 'bold 48px Arial'; + ctx.fillStyle = '#FFD700'; // Gold color for level + ctx.fillText(`Level ${user.level}`, 420, 330); + + // Progress bar dimensions and position + const progressBarX = 472; + const progressBarY = 388; + const progressBarWidth = 800; + const progressBarHeight = 60; + + // Draw progress bar fill + ctx.drawImage( + progressFill, + progressBarX, + progressBarY, + progressBarWidth * progress, + progressBarHeight + ); + + // Draw XP progress text inside the bar, right-aligned + ctx.font = 'bold 36px Arial'; + ctx.fillStyle = '#FFFFFF'; + ctx.textAlign = 'center'; + ctx.fillText( + `${displayXP}/${xpForLevel} XP`, + progressBarX + progressBarWidth / 2, + progressBarY + progressBarHeight / 2 + 12 // adjust as needed + ); + + // Convert canvas to buffer + return canvas.toBuffer('image/png'); + } catch (error) { + console.error('Error generating level card:', error); + throw error; + } +} + +module.exports = generateLevelCard; diff --git a/functions/level/xpSystem.js b/functions/level/xpSystem.js new file mode 100644 index 0000000..92d0614 --- /dev/null +++ b/functions/level/xpSystem.js @@ -0,0 +1,84 @@ +const Embed = require("../embed"); +const { User, getUserXP, checkUser, updateUserMessageCount, updateUserXPAndLevel } = require("../../models/user"); +const { XPSetting, getXPSettings } = require("../../models/xp_setting"); +const logger = require("../logger"); + +const self = module.exports = { + xpSystem: async (client, message) => { + try { + // Check if message is from a server + if (!message.server) { + return; // Skip XP for DMs + } + + await checkUser(message.author); + const user = await getUserXP(message.author.id); + const settings = await getXPSettings(message.server.id); + + // If no settings exist for this server, create default settings + if (!settings) { + const defaultSettings = { + serverId: message.server.id, + messages_per_xp: 3, + min_xp_per_gain: 2, + max_xp_per_gain: 12, + weekend_multiplier: 2, + weekend_days: "sat,sun", + double_xp_enabled: false, + level_up_enabled: true, + level_up_channel: '01HF7B18Z864E10XSF22F9RFZQ' + }; + await XPSetting.create(defaultSettings); + return; // Skip this message, will work from next message + } + + user.message_count++; + + if (user.message_count >= settings.messages_per_xp) { + user.message_count = 0; + let xpGain = Math.floor(Math.random() * (settings.max_xp_per_gain - settings.min_xp_per_gain + 1)) + settings.min_xp_per_gain; + + if (settings.double_xp_enabled || self.isWeekend(settings.weekend_days)) { + xpGain *= settings.weekend_multiplier; + } + + user.xp += xpGain; + + const newLevel = self.calculateLevel(user.xp); + if (newLevel > user.level) { + user.level = newLevel; + if (settings.level_up_enabled) { + const channel = client.channels.get(settings.level_up_channel); + if (channel) { + await self.sendLevelUpMessage(channel, message.author, newLevel); + } + } + } + + await updateUserXPAndLevel(message.author.id, user.xp, user.level, user.message_count); + logger.info('XP SYSTEM', `${message.author.username} gained ${xpGain} XP and is now level ${user.level}`); + } else { + await updateUserMessageCount(message.author.id, user.message_count); + } + } catch (error) { + logger.error('XP SYSTEM', error); + } + }, + + isWeekend: (weekendDays) => { + const today = new Date().toLocaleDateString('en-US', { weekday: 'short' }).toLowerCase(); + return weekendDays.split(',').includes(today); + }, + + calculateLevel: (xp) => Math.floor(0.47 * Math.sqrt(xp)), + + sendLevelUpMessage: async (channel, user, newLevel) => { + const embed = { + title: 'Level Up!', + description: `Congratulations ${user.username}! You've reached level ${newLevel}!`, + colour: '#00FF00' + }; + + await channel.sendMessage({ embeds: [embed] }); + } +}; diff --git a/handlers/command.js b/handlers/command.js index 9e42424..c5bbfc4 100644 --- a/handlers/command.js +++ b/handlers/command.js @@ -1,7 +1,30 @@ -const { readdirSync } = require("fs") +const { readdirSync, statSync } = require("fs") const { join } = require("path") const color = require("../functions/colorCodes") +/** + * Recursively gets all command files from a directory + * @param {string} dir - Directory to scan + * @returns {string[]} Array of command file paths + */ +function getCommandFiles(dir) { + const files = [] + const items = readdirSync(dir) + + for (const item of items) { + const path = join(dir, item) + const stat = statSync(path) + + if (stat.isDirectory()) { + files.push(...getCommandFiles(path)) + } else if (item.endsWith('.js')) { + files.push(path) + } + } + + return files +} + /** * Loads and registers all command files from the commands directory * @param {Object} client - The Discord client instance @@ -10,14 +33,14 @@ const color = require("../functions/colorCodes") module.exports = async (client) => { try { const commandsPath = join(__dirname, "..", "commands") - const commandFiles = readdirSync(commandsPath).filter(file => file.endsWith(".js")) - + const commandFiles = getCommandFiles(commandsPath) + let loadedCommands = 0 let failedCommands = 0 for (const file of commandFiles) { try { - const command = require(join(commandsPath, file)) + const command = require(file) // Validate command structure if (!command.config?.name) { @@ -26,6 +49,8 @@ module.exports = async (client) => { continue } + console.log(color("%", `%b[Command_Handler]%7 :: Loading %e${command.config.name}%7 command`)) + // Register command and aliases client.commands.set(command.config.name, command) if (command.config.aliases?.length) { diff --git a/index.js b/index.js index 6930b7c..aebd731 100644 --- a/index.js +++ b/index.js @@ -1,12 +1,27 @@ const { Client } = require("revolt.js"); const { Collection } = require('@discordjs/collection'); -const { token, mongoDB, api } = require("./botconfig.json"); const logger = require('./functions/logger'); const checkPolls = require('./functions/checkPolls'); const color = require("./functions/colorCodes"); const Uploader = require("revolt-uploader"); const TranslationHandler = require('./handlers/translation'); const DatabaseHandler = require('./handlers/database'); +// let config; + +// if (process.env.NODE_ENV === "development") { +// config = require("./botconfig.dev.json"); +// } else { +// config = require("./botconfig.json"); +// } +const config = require("./botconfig.json"); + +// Validate required configuration +if (!config || !config.token || !config.mongoDB || !config.api) { + throw new Error('Missing required configuration. Please check your botconfig.json file.'); +} + +const { token, mongoDB, api } = config; +console.log(mongoDB) class Bot { constructor(config) { diff --git a/level_card.png b/level_card.png new file mode 100644 index 0000000..211a414 Binary files /dev/null and b/level_card.png differ diff --git a/models/guilds.js b/models/guilds.js index 3d6704a..be25028 100644 --- a/models/guilds.js +++ b/models/guilds.js @@ -2,7 +2,7 @@ const { Schema, model } = require("mongoose"); const guilds = new Schema({ id: { type: String }, - prefix: { type: String, default: "f!" }, + prefix: { type: String, default: "!" }, language: { type: String, default: "en_EN" }, joined: { type: String, default: Date.now() / 1000 | 0 }, dm: { type: Boolean, default: true }, diff --git a/models/user.js b/models/user.js new file mode 100644 index 0000000..44be668 --- /dev/null +++ b/models/user.js @@ -0,0 +1,118 @@ +const { Schema, model } = require("mongoose"); + +const user = new Schema({ + id: { type: String }, + username: { type: String }, + xp: { type: Number, default: 0 }, + level: { type: Number, default: 1 }, + message_count: { type: Number, default: 0 } +}); + +async function checkUser(user) { + const userId = user.id; + const username = user.username; + + // Find user by id + const existingUser = await User.findOne({ id: userId }); + + // If user doesn't exist, create new user + if (!existingUser) { + await User.create({ + id: userId, + username: username, + xp: 0, + level: 1, + message_count: 0 + }); + } +} + +async function getUserXP(userId) { + let userDoc = await User.findOne({ id: userId }); + + // If user doesn't exist, create new user + if (!userDoc) { + userDoc = await User.create({ + id: userId, + xp: 0, + level: 1, + message_count: 0 + }); + } + + return userDoc; +} + +async function updateUserXP(user, xp) { + const userId = user.id; + const userDoc = await User.findOne({ id: userId }); + userDoc.xp = xp; + await userDoc.save(); +} + +async function updateUserLevel(user, level) { + const userId = user.id; + const userDoc = await User.findOne({ id: userId }); + userDoc.level = level; + await userDoc.save(); +} + +async function updateUserXPAndLevel(userId, xp, level, message_count) { + const userDoc = await User.findOne({ id: userId }); + if (!userDoc) { + return await User.create({ + id: userId, + xp: xp, + level: level, + message_count: message_count + }); + } + userDoc.xp = xp; + userDoc.level = level; + userDoc.message_count = message_count; + await userDoc.save(); +} + +async function updateUserMessageCount(userId, message_count) { + const userDoc = await User.findOne({ id: userId }); + if (!userDoc) { + return await User.create({ + id: userId, + xp: 0, + level: 1, + message_count: message_count + }); + } + userDoc.message_count = message_count; + await userDoc.save(); +} +/** + * Retrieves the top users sorted by XP + * @param {number} limit - Maximum number of users to return (default: 10) + * @returns {Promise} Array of user objects containing id, username, xp, and level + */ +async function getLeaderboard(limit = 10) { + const users = await User.find() + .sort({ xp: -1 }) + .limit(limit) + .select('id username xp level'); + + return users.map(user => ({ + user_id: user.id, + username: user.username, + xp: user.xp, + level: user.level + })); +} + +const User = model("user", user); +module.exports = { + checkUser, + getUserXP, + updateUserXP, + updateUserLevel, + updateUserXPAndLevel, + getLeaderboard, + updateUserMessageCount, + User +}; \ No newline at end of file diff --git a/models/xp_setting.js b/models/xp_setting.js new file mode 100644 index 0000000..0c5ad6a --- /dev/null +++ b/models/xp_setting.js @@ -0,0 +1,30 @@ +const { Schema, model } = require("mongoose"); + +const xpSetting = new Schema({ + messages_per_xp: { type: Number }, + min_xp_per_gain: { type: Number }, + max_xp_per_gain: { type: Number }, + weekend_multiplier: { type: Number }, + weekend_days: { type: String }, + double_xp_enabled: { type: Boolean }, + serverId: { type: String }, + level_up_channel: { type: String }, + level_up_enabled: { type: Boolean } +}); + +async function getXPSettings(serverId) { + const settings = await XPSetting.findOne({ serverId: serverId }); + return settings; +} + +async function updateXPSettings(serverId, settings) { + await XPSetting.updateOne({ serverId: serverId }, { $set: settings }); +} + +const XPSetting = model("xpSetting", xpSetting); + +module.exports = { + getXPSettings, + updateXPSettings, + XPSetting +}; diff --git a/package-lock.json b/package-lock.json index 071d13f..8d2619a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,9 @@ "revolt.js": "npm:revolt.js-update@^7.0.0-beta.9", "screen": "^0.2.10", "wumpfetch": "^0.3.1" + }, + "devDependencies": { + "cross-env": "^7.0.3" } }, "node_modules/@discordjs/collection": { @@ -385,6 +388,40 @@ "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", "license": "ISC" }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/csstype": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", @@ -674,6 +711,13 @@ "node": ">=8" } }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, "node_modules/isomorphic-ws": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz", @@ -1171,6 +1215,16 @@ "node": ">=0.10.0" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/prettier": { "version": "2.8.8", "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", @@ -1359,6 +1413,29 @@ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", "license": "ISC" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/shiki": { "version": "0.14.4", "resolved": "https://registry.npmjs.org/shiki/-/shiki-0.14.4.tgz", @@ -1655,6 +1732,22 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/wide-align": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", diff --git a/package.json b/package.json index 05a1328..9f80c6b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,8 @@ { "main": "index.js", "scripts": { - "start": "node index.js" + "start": "node index.js", + "dev": "cross-env NODE_ENV=development node index.js" }, "license": "MIT", "dependencies": { @@ -26,5 +27,8 @@ "Discord": "ainzooalgown", "Revolt": "Ryahn#1337", "GitHub": "https://github.com/Ryahn/zonies" + }, + "devDependencies": { + "cross-env": "^7.0.3" } } diff --git a/test.js b/test.js index ef8dda1..450bdcc 100644 --- a/test.js +++ b/test.js @@ -15,13 +15,11 @@ // }) (async () => { - const axios = require("axios"); - const query = 'yeet' - const url = ('https://api.urbandictionary.com/v0/define?term=' + query) - const response = await axios.get(url); - const data = response.data; - const def = data.list[0]; + const xp = Math.floor(0.47 * Math.sqrt(12345632346)); + const level = Math.floor(xp / 100); + console.log(xp); + console.log(level); })(); \ No newline at end of file