From ce93ddd7dfb17f6b89e0d293eecf0dd2ccb51d58 Mon Sep 17 00:00:00 2001 From: ToastedToast Date: Mon, 15 Jul 2024 16:37:07 +0800 Subject: [PATCH] feat: add ability to set xp and level for a user --- api/db/queries/users.ts | 40 ++++++++ api/index.ts | 38 +++++++- bot/commands.ts | 199 +++++++++++++++++++++++++++------------- bot/utils/requestAPI.ts | 24 +++++ 4 files changed, 237 insertions(+), 64 deletions(-) diff --git a/api/db/queries/users.ts b/api/db/queries/users.ts index b3cc2e3..01020db 100644 --- a/api/db/queries/users.ts +++ b/api/db/queries/users.ts @@ -36,3 +36,43 @@ export async function getUser(userId: string, guildId: string): Promise<[QueryEr }); }); } + +export async function setXP(guildId: string, userId: string, xp: number): Promise<[QueryError | null, boolean]> { + const newLevel = Math.floor(Math.sqrt(xp / 100)); + const nextLevel = newLevel + 1; + const nextLevelXp = Math.pow(nextLevel, 2) * 100; + const xpNeededForNextLevel = nextLevelXp - xp; + const currentLevelXp = Math.pow(newLevel, 2) * 100; + const progressToNextLevel = + ((xp - currentLevelXp) / (nextLevelXp - currentLevelXp)) * 100; + + return new Promise((resolve, reject) => { + pool.query("UPDATE users SET xp = ?, level = ?, xp_needed_next_level = ?, progress_next_level = ? WHERE id = ? AND guild_id = ?", [xp, newLevel, xpNeededForNextLevel.toFixed(2), progressToNextLevel.toFixed(2), userId, guildId], (err) => { + if (err) { + reject([err, false]); + } else { + resolve([null, true]); + } + }); + }); +} + +export async function setLevel(guildId: string, userId: string, level: number): Promise<[QueryError | null, boolean]> { + const newXp = Math.pow(level, 2) * 100; + const nextLevel = level + 1; + const nextLevelXp = Math.pow(nextLevel, 2) * 100; + const xpNeededForNextLevel = nextLevelXp - newXp; + const currentLevelXp = Math.pow(level, 2) * 100; + const progressToNextLevel = + ((newXp - currentLevelXp) / (nextLevelXp - currentLevelXp)) * 100; + + return new Promise((resolve, reject) => { + pool.query("UPDATE users SET xp = ?, level = ?, xp_needed_next_level = ?, progress_next_level = ? WHERE id = ? AND guild_id = ?", [newXp, level, xpNeededForNextLevel.toFixed(2), progressToNextLevel.toFixed(2), userId, guildId], (err) => { + if (err) { + reject([err, false]); + } else { + resolve([null, true]); + } + }); + }); +} diff --git a/api/index.ts b/api/index.ts index 63925d3..d169605 100644 --- a/api/index.ts +++ b/api/index.ts @@ -1,7 +1,7 @@ import express, { type NextFunction, type Request, type Response } from "express"; import cors from "cors"; import path from "path"; -import { getBotInfo, getGuild, getUser, getUsers, initTables, pool, updateGuild, enableUpdates, disableUpdates, setCooldown, setUpdatesChannel } from "./db"; +import { getBotInfo, getGuild, getUser, getUsers, initTables, pool, updateGuild, enableUpdates, disableUpdates, setCooldown, setUpdatesChannel, setXP, setLevel } from "./db"; const app = express(); const PORT = 18103; @@ -292,6 +292,42 @@ app.post("/admin/:action/:guild/:target", authMiddleware, async (req, res) => { default: return res.status(500).json({ message: "Internal server error" }); } + case "set": { + if (target !== "xp" && target !== "level") { + return res.status(400).json({ message: "Illegal request" }); + } + + if(!extraData || !extraData.user || !extraData.value) { + return res.status(400).json({ message: "Illegal request" }); + } + + switch (target) { + case "xp": + try { + const [err, success] = await setXP(guild, extraData.user, extraData.value); + if (err) { + return res.status(500).json({ message: "Internal server error", err }); + } else { + return res.status(200).json(success); + } + } catch (err) { + return res.status(500).json({ message: "Internal server error", err }); + } + case "level": + try { + const [err, success] = await setLevel(guild, extraData.user, extraData.value); + if (err) { + return res.status(500).json({ message: "Internal server error", err }); + } else { + return res.status(200).json(success); + } + } catch (err) { + return res.status(500).json({ message: "Internal server error", err }); + } + default: + return res.status(500).json({ message: "Internal server error" }); + } + } default: return res.status(400).json({ message: "Illegal request" }); } diff --git a/bot/commands.ts b/bot/commands.ts index 94bff3b..190d32c 100644 --- a/bot/commands.ts +++ b/bot/commands.ts @@ -3,7 +3,7 @@ import client from '.'; import { ActionRowBuilder, ButtonBuilder, ButtonStyle, type CommandInteraction, ChannelType, type APIApplicationCommandOption, GuildMember, AttachmentBuilder, ComponentType } from 'discord.js'; import { heapStats } from 'bun:jsc'; -import { getGuildLeaderboard, makeGETRequest, getRoles, removeRole, addRole, enableUpdates, disableUpdates, getCooldown, setCooldown, getUpdatesChannel, setUpdatesChannel } from './utils/requestAPI'; +import { getGuildLeaderboard, makeGETRequest, getRoles, removeRole, addRole, enableUpdates, disableUpdates, getCooldown, setCooldown, getUpdatesChannel, setUpdatesChannel, setXP, setLevel } from './utils/requestAPI'; import convertToLevels from './utils/convertToLevels'; import quickEmbed from './utils/quickEmbed'; import { Font, RankCardBuilder } from 'canvacord'; @@ -154,74 +154,74 @@ const commands: Record = { }); return; } - + const rank = leaderboard.leaderboard.findIndex((entry: ({ id: string; })) => entry.id === user) + 1; - + const card = new RankCardBuilder() - .setDisplayName(member.displayName) - .setAvatar(member.displayAvatarURL({ forceStatic: true, size: 4096 })) // user avatar - .setCurrentXP(xp.xp) // current xp - .setRequiredXP(xp.xp + xp.xp_needed_next_level) // required xp - .setLevel(xp.level) // user level - .setRank(rank) // user rank - .setOverlay(member.user.banner ? 95 : 90) // overlay percentage. Overlay is a semi-transparent layer on top of the background - .setBackground(member.user.bannerURL({ forceStatic: true, size: 4096 }) ?? "#23272a") - + .setDisplayName(member.displayName) + .setAvatar(member.displayAvatarURL({ forceStatic: true, size: 4096 })) // user avatar + .setCurrentXP(xp.xp) // current xp + .setRequiredXP(xp.xp + xp.xp_needed_next_level) // required xp + .setLevel(xp.level) // user level + .setRank(rank) // user rank + .setOverlay(member.user.banner ? 95 : 90) // overlay percentage. Overlay is a semi-transparent layer on top of the background + .setBackground(member.user.bannerURL({ forceStatic: true, size: 4096 }) ?? "#23272a") + if (interaction.user.discriminator !== "0") { - card.setUsername("#" + member.user.discriminator) + card.setUsername("#" + member.user.discriminator) } else { - card.setUsername("@" + member.user.username) + card.setUsername("@" + member.user.username) } - + const color = member.roles.highest.hexColor ?? "#ffffff" - card.setStyles({ - progressbar: { - thumb: { - style: { - backgroundColor: color + card.setStyles({ + progressbar: { + thumb: { + style: { + backgroundColor: color } - } - }, - }) - - const image = await card.build({ - format: "png" - }); - const attachment = new AttachmentBuilder(image, { name: `${user}.png` }); + } + }, + }) + + const image = await card.build({ + format: "png" + }); + const attachment = new AttachmentBuilder(image, { name: `${user}.png` }); const msg = await interaction.followUp({ - files: [attachment], + files: [attachment], components: [ - new ActionRowBuilder().setComponents( + new ActionRowBuilder().setComponents( new ButtonBuilder() - .setCustomId("text-mode") - .setLabel("Use text mode") - .setStyle(ButtonStyle.Secondary) + .setCustomId("text-mode") + .setLabel("Use text mode") + .setStyle(ButtonStyle.Secondary) ) ], fetchReply: true }); - - const collector = msg.createMessageComponentCollector({ - componentType: ComponentType.Button, - time: 60 * 1000 - }); - - collector.on("collect", async (i) => { - if (i.user.id !== user) - return i.reply({ - content: "You're not the one who initialized this message! Try running /xp on your own.", - ephemeral: true - }); - - if (i.customId !== "text-mode") return; - - const progress = xp.progress_next_level; - const progressBar = createProgressBar(progress); - - await i.update({ - embeds: [ + + const collector = msg.createMessageComponentCollector({ + componentType: ComponentType.Button, + time: 60 * 1000 + }); + + collector.on("collect", async (i) => { + if (i.user.id !== user) + return i.reply({ + content: "You're not the one who initialized this message! Try running /xp on your own.", + ephemeral: true + }); + + if (i.customId !== "text-mode") return; + + const progress = xp.progress_next_level; + const progressBar = createProgressBar(progress); + + await i.update({ + embeds: [ quickEmbed( { color, @@ -248,10 +248,10 @@ const commands: Record = { ], files: [], components: [] - }) - }) - - function createProgressBar(progress: number): string { + }) + }) + + function createProgressBar(progress: number): string { const filled = Math.floor(progress / 10); const empty = 10 - filled; return '▰'.repeat(filled) + '▱'.repeat(empty); @@ -286,11 +286,11 @@ const commands: Record = { }, interaction); // Add a field for each user with a mention - leaderboard.leaderboard.forEach((entry: { user_id: string; xp: number; }, index: number) => { + leaderboard.leaderboard.forEach((entry: { id: string; xp: number; }, index: number) => { leaderboardEmbed.addFields([ { name: `${index + 1}.`, - value: `<@${entry.user_id}>: ${entry.xp.toLocaleString("en-US")} XP`, + value: `<@${entry.id}>: ${entry.xp.toLocaleString("en-US")} XP`, inline: false } ]); @@ -475,7 +475,7 @@ const commands: Record = { value: 'reset', }, ] - },{ + }, { name: 'channel', description: 'Enter the channel ID. Required for set action.', type: 7, @@ -523,8 +523,8 @@ const commands: Record = { } await interaction.reply({ ephemeral: true, content: 'Updates are now disabled for this server' }).catch(console.error); return; - case 'set': - if(!channelId) { + case 'set': + if (!channelId) { await interaction.reply({ ephemeral: true, content: 'ERROR: Channel was not specified!' }); return; } @@ -592,7 +592,7 @@ const commands: Record = { value: 'set', } ] - },{ + }, { name: 'cooldown', description: 'Enter the cooldown in seconds. Required for set action.', type: 4, @@ -648,6 +648,79 @@ const commands: Record = { return; } } + }, + set: { + data: { + options: [{ + name: 'user', + description: 'The user you want to update the XP or level of.', + type: 6, + required: true, + }, { + name: 'type', + description: 'Select the data type to set', + type: 3, + required: true, + choices: [ + { + name: 'XP', + value: 'xp', + }, + { + name: 'Level', + value: 'level', + } + ] + }, { + name: 'value', + description: 'The new value to set', + type: 3, + required: true, + }], + name: 'set', + description: 'Set the XP or level of a user!', + integration_types: [0], + contexts: [0, 2], + }, + execute: async (interaction) => { + if (!interaction.memberPermissions?.has('ManageGuild')) { + const errorEmbed = quickEmbed({ + color: 'Red', + title: 'Error!', + description: 'Missing permissions: `Manage Server`' + }, interaction); + await interaction.reply({ + ephemeral: true, + embeds: [errorEmbed] + }) + .catch(console.error); + return; + } + + const user = interaction.options.get('user')?.value as string; + const type = interaction.options.get('type')?.value; + const value = interaction.options.get('value')?.value; + + let apiSuccess; + switch (type) { + case 'xp': + apiSuccess = await setXP(interaction.guildId as string, user, parseInt(value as string)); + if (!apiSuccess) { + await interaction.reply({ ephemeral: true, content: 'Error setting XP!' }); + return; + } + await interaction.reply({ ephemeral: true, content: `XP set to ${value} for <@${user}>` }); + return; + case 'level': + apiSuccess = await setLevel(interaction.guildId as string, user, parseInt(value as string)); + if (!apiSuccess) { + await interaction.reply({ ephemeral: true, content: 'Error setting level!' }); + return; + } + await interaction.reply({ ephemeral: true, content: `Level set to ${value} for <@${user}>` }); + return; + } + } } }; diff --git a/bot/utils/requestAPI.ts b/bot/utils/requestAPI.ts index c497a00..9945cfb 100644 --- a/bot/utils/requestAPI.ts +++ b/bot/utils/requestAPI.ts @@ -54,6 +54,30 @@ export async function updateGuildInfo(guild: string, name: string, icon: string, }) } +export async function setXP(guild: string, user: string, xp: number) { + const response = await fetch(`http://localhost:18103/admin/set/${guild}/xp`, { + "headers": { + 'Content-Type': 'application/json', + 'Authorization': process.env.AUTH as string, + }, + "body": JSON.stringify({ extraData: { user, value: xp } }), + "method": "POST" + }); + return response.status === 200; +} + +export async function setLevel(guild: string, user: string, level: number) { + const response = await fetch(`http://localhost:18103/admin/set/${guild}/level`, { + "headers": { + 'Content-Type': 'application/json', + 'Authorization': process.env.AUTH as string, + }, + "body": JSON.stringify({ extraData: { user, value: level } }), + "method": "POST" + }); + return response.status === 200; +} + //#region Roles export async function getRoles(guild: string) { const response = await fetch(`http://localhost:18103/admin/roles/${guild}/get`, {