From 0e7cb4bc8d390b6cd75b8790d9df2bcae06dcd6a Mon Sep 17 00:00:00 2001 From: ToastedToast Date: Thu, 11 Jul 2024 18:04:41 +0800 Subject: [PATCH 01/37] feat: add commas to xp and levels --- api/views/leaderboard.ejs | 6 +++--- bot/commands.ts | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/api/views/leaderboard.ejs b/api/views/leaderboard.ejs index 0e7adef..2b43b6a 100644 --- a/api/views/leaderboard.ejs +++ b/api/views/leaderboard.ejs @@ -30,9 +30,9 @@

<%= user.user_nickname %>

-

XP: <%= user.xp %> +

XP: <%= user.xp.toLocaleString() %>

-

Level <%= user.user_level %> | <%= user.xp %>/<%= user.xp + user.user_xp_needed_next_level %> points to next level (<%= user.user_progress_next_level %>%) +

Level <%= user.user_level.toLocaleString() %> | <%= user.xp.toLocaleString() %>/<%= (user.xp + user.user_xp_needed_next_level).toLocaleString() %> points to next level (<%= user.user_progress_next_level %>%)

@@ -41,4 +41,4 @@ - \ No newline at end of file + diff --git a/bot/commands.ts b/bot/commands.ts index f0df0c5..d2c6c5d 100644 --- a/bot/commands.ts +++ b/bot/commands.ts @@ -151,7 +151,7 @@ const commands: Record = { { color: 'Blurple', title: 'XP', - description: `<@${user}> you have ${xp.xp} XP! (Level ${convertToLevels(xp.xp)})`, + description: `<@${user}> you have ${xp.xp.toLocaleString("en-US")} XP! (Level ${convertToLevels(xp.xp).toLocaleString("en-US")})`, }, interaction ).addFields([ @@ -162,7 +162,7 @@ const commands: Record = { }, { name: 'XP Required', - value: `${xp.user_xp_needed_next_level} XP`, + value: `${xp.user_xp_needed_next_level.toLocaleString("en-US")} XP`, inline: true, }, ]), @@ -209,7 +209,7 @@ const commands: Record = { leaderboardEmbed.addFields([ { name: `${index + 1}.`, - value: `<@${entry.user_id}>: ${entry.xp} XP`, + value: `<@${entry.user_id}>: ${entry.xp.toLocaleString("en-US")} XP`, inline: false } ]); @@ -442,4 +442,4 @@ for (const key in commands) { } } -export default commandsMap; \ No newline at end of file +export default commandsMap; From 63b03f3306e5e6332a82ceac4fb87e7da28eaaa3 Mon Sep 17 00:00:00 2001 From: ToastedToast Date: Thu, 11 Jul 2024 18:38:41 +0800 Subject: [PATCH 02/37] feat(xp): add cooldown --- bot/events/messageCreate.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/bot/events/messageCreate.ts b/bot/events/messageCreate.ts index 307df70..44a455b 100644 --- a/bot/events/messageCreate.ts +++ b/bot/events/messageCreate.ts @@ -2,17 +2,25 @@ import { Message } from 'discord.js'; import client from '../index'; import { makePOSTRequest, updateGuildInfo } from '../utils/requestAPI'; +const cooldowns = new Map(); +const cooldownTime = 5 * 1000; + // Run this event whenever a message has been sent client.on('messageCreate', async (message: Message) => { if (message.author.bot) return; + + const cooldown = cooldowns.get(message.author.id); + if (cooldown && Date.now() - cooldown < cooldownTime) return; + const xpToGive: number = message.content.length; const pfp: string = message.member?.displayAvatarURL() ?? message.author.displayAvatarURL() const name: string = message.author.username; const nickname: string = message.member?.nickname ?? message.author.globalName ?? message.author.username; await makePOSTRequest(message.guildId as string, message.author.id, xpToGive, pfp, name, nickname); + cooldowns.set(message.author.id, Date.now()); const guildName = message.guild?.name; const guildIcon = message.guild?.iconURL() ?? 'https://cdn.discordapp.com/embed/avatars/0.png'; const guildMembers = message.guild?.memberCount; await updateGuildInfo(message.guildId as string, guildName as string, guildIcon as string, guildMembers as number); -}); \ No newline at end of file +}); From 61be2ab34dd2130a6605493baabadba8e4c85d91 Mon Sep 17 00:00:00 2001 From: ToastedToast Date: Thu, 11 Jul 2024 18:41:37 +0800 Subject: [PATCH 03/37] fix(xp): actual cooldown --- bot/events/messageCreate.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/events/messageCreate.ts b/bot/events/messageCreate.ts index 44a455b..6f145e2 100644 --- a/bot/events/messageCreate.ts +++ b/bot/events/messageCreate.ts @@ -3,7 +3,7 @@ import client from '../index'; import { makePOSTRequest, updateGuildInfo } from '../utils/requestAPI'; const cooldowns = new Map(); -const cooldownTime = 5 * 1000; +const cooldownTime = 30 * 1000; // Run this event whenever a message has been sent client.on('messageCreate', async (message: Message) => { From bebedc68318b92e47c517048fc0b02d4fd81b609 Mon Sep 17 00:00:00 2001 From: ToastedToast Date: Thu, 11 Jul 2024 20:58:15 +0800 Subject: [PATCH 04/37] feat(db): refactor --- api/db/index.ts | 14 + api/db/init.ts | 63 +++++ api/db/queries/guilds.ts | 75 ++++++ api/db/queries/users.ts | 38 +++ api/index.ts | 548 ++++++++++++++------------------------ api/views/leaderboard.ejs | 14 +- bot/utils/requestAPI.ts | 6 +- 7 files changed, 398 insertions(+), 360 deletions(-) create mode 100644 api/db/index.ts create mode 100644 api/db/init.ts create mode 100644 api/db/queries/guilds.ts create mode 100644 api/db/queries/users.ts diff --git a/api/db/index.ts b/api/db/index.ts new file mode 100644 index 0000000..cd01b1d --- /dev/null +++ b/api/db/index.ts @@ -0,0 +1,14 @@ +import mysql from "mysql2"; + +// Create a MySQL connection pool +export const pool = mysql.createPool({ + host: process.env.MYSQL_ADDRESS as string, + port: parseInt(process.env.MYSQL_PORT as string), + user: process.env.MYSQL_USER as string, + password: process.env.MYSQL_PASSWORD as string, + database: process.env.MYSQL_DATABASE as string, +}); + +export * from './init'; +export * from './queries/guilds'; +export * from './queries/users'; diff --git a/api/db/init.ts b/api/db/init.ts new file mode 100644 index 0000000..a438a0f --- /dev/null +++ b/api/db/init.ts @@ -0,0 +1,63 @@ +import { pool } from "."; + +export async function initTables() { + const createGuildsTable = ` + CREATE TABLE IF NOT EXISTS guilds ( + id VARCHAR(255) NOT NULL PRIMARY KEY, + name VARCHAR(255), + icon VARCHAR(255), + members INT, + updates_enabled BOOLEAN DEFAULT TRUE, + updates_channel VARCHAR(255) + ) + `; + const createUsersTable = ` + CREATE TABLE IF NOT EXISTS users ( + id VARCHAR(255) NOT NULL, + guild_id VARCHAR(255) NOT NULL, + name VARCHAR(255), + nickname VARCHAR(255), + pfp VARCHAR(255), + xp INT DEFAULT 0, + level INT DEFAULT 0, + xp_needed_next_level INT, + progress_next_level DECIMAL(6, 2), + PRIMARY KEY (id, guild_id), + FOREIGN KEY (guild_id) REFERENCES guilds(id) + ) + `; + const createRolesTable = ` + CREATE TABLE IF NOT EXISTS roles ( + id VARCHAR(255) NOT NULL PRIMARY KEY, + guild_id VARCHAR(255) NOT NULL, + name VARCHAR(255), + level INT NOT NULL, + FOREIGN KEY (guild_id) REFERENCES guilds(id) + ) + ` + + pool.query(createGuildsTable, (err, results) => { + if (err) { + console.error("Error creating guilds table:", err); + } else { + console.log("Guilds table created:", results); + } + }); + + pool.query(createUsersTable, (err, results) => { + if (err) { + console.error("Error creating users table:", err); + } else { + console.log("Users table created:", results); + } + }); + + + pool.query(createRolesTable, (err, results) => { + if (err) { + console.error("Error creating roles table:", err); + } else { + console.log("Roles table created:", results); + } + }); +} diff --git a/api/db/queries/guilds.ts b/api/db/queries/guilds.ts new file mode 100644 index 0000000..1b6ed93 --- /dev/null +++ b/api/db/queries/guilds.ts @@ -0,0 +1,75 @@ +import type { QueryError } from "mysql2"; +import { pool } from ".."; + +export interface Guild { + id: string; + name: string; + icon: string; + members: number; + updates_enabled: boolean; + updates_channel: string; +} + +export async function getGuild(guildId: string): Promise<[QueryError | null, Guild | null]> { + return new Promise((resolve, reject) => { + pool.query("SELECT * FROM guilds WHERE id = ?", [guildId], (err, results) => { + if (err) { + reject([err, null]); + } else { + resolve([null, (results as Guild[])[0]]); + } + }); + }); +} + +export async function updateGuild(guild: Guild): Promise<[QueryError, null] | [null, Guild[]]> { + return new Promise((resolve, reject) => { + pool.query( + ` + INSERT INTO guilds (id, name, icon, members, updates_enabled, updates_channel) + VALUES (?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + name = VALUES(name), + icon = VALUES(icon), + members = VALUES(members), + updates_enabled = VALUES(updates_enabled), + updates_channel = VALUES(updates_channel) + `, + [ + guild.id, + guild.name, + guild.icon, + guild.members, + guild.updates_enabled, + guild.updates_channel, + ], + (err, results) => { + if (err) { + reject([err, null]); + } else { + resolve([null, results as Guild[]]); + } + }, + ); + }); +} + +interface BotInfo { + total_guilds: number; + total_members: number; +} + +export async function getBotInfo(): Promise<[QueryError | null, BotInfo | null]> { + return new Promise((resolve, reject) => { + pool.query("SELECT COUNT(*) AS total_guilds, SUM(members) AS total_members FROM guilds", (err, results) => { + if (err) { + reject([err, null]); + } else { + resolve([null, { + total_guilds: (results as BotInfo[])[0].total_guilds, + total_members: (results as BotInfo[])[0].total_members ?? 0, + }]); + } + }) + }) +} diff --git a/api/db/queries/users.ts b/api/db/queries/users.ts new file mode 100644 index 0000000..b3cc2e3 --- /dev/null +++ b/api/db/queries/users.ts @@ -0,0 +1,38 @@ +import type { QueryError } from "mysql2"; +import { pool } from ".."; + +export interface User { + id: string; + guild_id: string; + name: string; + nickname: string; + pfp: string; + xp: number; + level: number; + xp_needed_next_level: number; + progress_next_level: number; +} + +export async function getUsers(guildId: string): Promise<[QueryError, null] | [null, User[]]> { + return new Promise((resolve, reject) => { + pool.query("SELECT * FROM users WHERE guild_id = ? ORDER BY xp DESC", [guildId], (err, results) => { + if (err) { + reject([err, null]); + } else { + resolve([null, (results as User[])]); + } + }); + }); +} + +export async function getUser(userId: string, guildId: string): Promise<[QueryError, null] | [null, User | null]> { + return new Promise((resolve, reject) => { + pool.query("SELECT * FROM users WHERE id = ? AND guild_id = ?", [userId, guildId], (err, results) => { + if (err) { + reject([err, null]); + } else { + resolve([null, (results as User[])[0]]); + } + }); + }); +} diff --git a/api/index.ts b/api/index.ts index ef57d6e..f91013c 100644 --- a/api/index.ts +++ b/api/index.ts @@ -1,436 +1,278 @@ -import express from 'express'; -import cors from 'cors'; -import mysql from 'mysql2'; -import path from 'path'; +import express, { type NextFunction, type Request, type Response } from "express"; +import cors from "cors"; +import mysql from "mysql2"; +import path from "path"; +import { getBotInfo, getGuild, getUser, getUsers, initTables, pool, updateGuild } from "./db"; const app = express(); const PORT = 18103; app.use(cors()); app.use(express.json()); -app.use(express.static(path.join(__dirname, 'public'))); -app.set('view engine', 'ejs'); -app.set('views', path.join(__dirname, 'views')); - -// Create a MySQL connection pool -const pool = mysql.createPool({ - host: process.env.MYSQL_ADDRESS as string, - port: parseInt(process.env.MYSQL_PORT as string), - user: process.env.MYSQL_USER as string, - password: process.env.MYSQL_PASSWORD as string, - database: process.env.MYSQL_DATABASE as string, -}); - -// Create the basic information tables -async function initInfoTables() { - const createUpdatesTable = ` - CREATE TABLE IF NOT EXISTS info_updates ( - guild_id VARCHAR(255) NOT NULL, - enabled BOOLEAN DEFAULT FALSE, - channel_id VARCHAR(255), - PRIMARY KEY (guild_id) - ) - `; - const createRolesTable = ` - CREATE TABLE IF NOT EXISTS info_roles ( - guild_id VARCHAR(255) NOT NULL, - role_id VARCHAR(255) NOT NULL, - level INT NOT NULL, - PRIMARY KEY (role_id) - ) - `; - const createExcludesTable = ` - CREATE TABLE IF NOT EXISTS info_excludes ( - channel_id VARCHAR(255) NOT NULL, - guild_id VARCHAR(255) NOT NULL, - PRIMARY KEY (channel_id) - ) - `; - const createGuildsTable = ` - CREATE TABLE IF NOT EXISTS info_guilds ( - guild_id VARCHAR(255) NOT NULL, - guild_name VARCHAR(255), - guild_icon VARCHAR(255), - guild_members INT, - PRIMARY KEY (guild_id) - ) - `; - - pool.query(createUpdatesTable, (err, results) => { - if (err) { - console.error('Error creating updates table:', err); - } else { - console.log('Updates table created:', results); - } - }); - - pool.query(createRolesTable, (err, results) => { - if (err) { - console.error('Error creating roles table:', err); - } else { - console.log('Roles table created:', results); - } - }); - - pool.query(createExcludesTable, (err, results) => { - if (err) { - console.error('Error creating excludes table:', err); - } else { - console.log('Excludes table created:', results); - } - }); - - pool.query(createGuildsTable, (err, results) => { - if (err) { - console.error('Error creating guilds info table:', err); - } else { - console.log('Guilds info table created:', results); - } - }); -} -console.log('Initializing info tables...'); -await initInfoTables(); -console.log('Info tables initialized'); - -// Ensure the table for a specific guild exists -async function ensureGuildTableExists(guild, callback) { - const createTableQuery = ` - CREATE TABLE IF NOT EXISTS \`${guild}\` ( - user_id VARCHAR(255) NOT NULL, - xp INT DEFAULT 0, - user_pfp TINYTEXT, - user_name TINYTEXT, - user_nickname TINYTEXT, - user_level INT DEFAULT 0, - user_xp_needed_next_level INT, - user_progress_next_level DECIMAL(6, 2), - PRIMARY KEY (user_id) - ) - `; - pool.query(createTableQuery, (err, results) => { - if (err) { - console.error(`Error creating table for guild ${guild}:`, err); - callback(err); - } - else { - console.log(`Table for guild ${guild} ensured:`, results); - callback(null); - } - }); -} - -async function updateGuildInfo(guild, name, icon, members, callback) { - const insertOrUpdateQuery = ` - INSERT INTO info_guilds (guild_id, guild_name, guild_icon, guild_members) - VALUES (?, ?, ?, ?) - ON DUPLICATE KEY UPDATE - guild_name = VALUES(guild_name), - guild_icon = VALUES(guild_icon), - guild_members = VALUES(guild_members) - `; - pool.query(insertOrUpdateQuery, [guild, name, icon, members], (err, results) => { - if (err) { - console.error('Error updating guild info:', err); - callback(err, null); - } - else { - console.log('Guild info updated:', results); - callback(null, results); - } - }); +app.use(express.static(path.join(__dirname, "public"))); +app.set("view engine", "ejs"); +app.set("views", path.join(__dirname, "views")); + +console.log("Initializing tables..."); +await initTables(); +console.log("Tables initialized"); + +function authMiddleware(req: Request, res: Response, next: NextFunction) { + if (req.headers.authorization !== process.env.AUTH) { + return res + .status(403) + .json({ message: "Access denied" }); + } + next(); } -app.post('/post/:guild/', async (req, res) => { +app.post("/post/:guild", authMiddleware, async (req, res) => { const { guild } = req.params; - const { name, icon, members, auth } = req.body; + const { name, icon, members } = req.body; + + const [err, results] = await updateGuild({ + id: guild, + name, + icon, + members, + updates_enabled: false, + updates_channel: "", + }); - if (auth !== process.env.AUTH) { - return res.status(403).json({ message: 'Access denied. Auth token is missing' }); + if (err) { + res.status(500).json({ message: "Internal server error" }); + } else { + res.status(200).json(results); } - - updateGuildInfo(guild, name, icon, members, (err, results) => { - if (err) { - res.status(500).json({ message: 'Internal server error' }); - } else { - res.status(200).json(results); - } - }); }); -app.post('/post/:guild/:user/:auth', (req, res) => { - const { guild, user, auth } = req.params; +app.post("/post/:guild/:user", authMiddleware, async (req, res) => { + const { guild, user } = req.params; const { name, pfp, xp, nickname } = req.body; console.log(req.body); const xpValue = parseInt(xp); - if (auth !== process.env.AUTH) { - return res.status(403).json({ message: 'Access denied. Auth token is missing' }); - } + const [err, result] = await getUser(user, guild); - ensureGuildTableExists(guild, (err) => { - if (err) { - return res.status(500).json({ message: 'Internal server error' }); - } + if (err) { + console.error("Error fetching XP:", err); + return res.status(500).json({ message: "Internal server error" }); + } - const getXpQuery = `SELECT xp, user_level FROM \`${guild}\` WHERE user_id = ?`; + const currentXp = result?.xp ?? 0; + const currentLevelSaved = result?.level ?? 0; + const newXp = currentXp + xpValue; + + const currentLevel = Math.floor(Math.sqrt(newXp / 100)); + const nextLevel = currentLevel + 1; + const nextLevelXp = Math.pow(nextLevel, 2) * 100; + const xpNeededForNextLevel = nextLevelXp - newXp; + const currentLevelXp = Math.pow(currentLevel, 2) * 100; + const progressToNextLevel = + ((newXp - currentLevelXp) / (nextLevelXp - currentLevelXp)) * 100; + + const updateQuery = ` + INSERT INTO users + (id, guild_id, xp, pfp, name, nickname, level, xp_needed_next_level, progress_next_level) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + xp = VALUES(xp), + pfp = VALUES(pfp), + name = VALUES(name), + nickname = VALUES(nickname), + level = VALUES(level), + xp_needed_next_level = VALUES(xp_needed_next_level), + progress_next_level = VALUES(progress_next_level) + `; - pool.query(getXpQuery, [user], (err, results) => { + pool.query( + updateQuery, + [ + user, + guild, + newXp, + pfp, + name, + nickname, + currentLevel, + xpNeededForNextLevel, + progressToNextLevel.toFixed(2), + ], + (err) => { if (err) { - console.error('Error fetching XP:', err); - return res.status(500).json({ message: 'Internal server error' }); + console.error("Error updating XP:", err); + return res + .status(500) + .json({ success: false, message: "Internal server error" }); + } else { + res + .status(200) + .json({ + success: true, + sendUpdateEvent: currentLevelSaved !== currentLevel, + level: currentLevel, + }); } - - const currentXp = results.length ? results[0].xp : 0; - const currentLevelSaved = results.length ? results[0].user_level : 0; - const newXp = currentXp + xpValue; - - const currentLevel = Math.floor(Math.sqrt(newXp / 100)); - const nextLevel = currentLevel + 1; - const nextLevelXp = Math.pow(nextLevel, 2) * 100; - const xpNeededForNextLevel = nextLevelXp - newXp; - const currentLevelXp = Math.pow(currentLevel, 2) * 100; - const progressToNextLevel = ((newXp - currentLevelXp) / (nextLevelXp - currentLevelXp)) * 100; - - const updateQuery = ` - INSERT INTO \`${guild}\` (user_id, xp, user_pfp, user_name, user_nickname, user_level, user_xp_needed_next_level, user_progress_next_level) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ON DUPLICATE KEY UPDATE - xp = VALUES(xp), - user_pfp = VALUES(user_pfp), - user_name = VALUES(user_name), - user_nickname = VALUES(user_nickname), - user_level = VALUES(user_level), - user_xp_needed_next_level = VALUES(user_xp_needed_next_level), - user_progress_next_level = VALUES(user_progress_next_level) - `; - - pool.query(updateQuery, [user, newXp, pfp, name, nickname, currentLevel, xpNeededForNextLevel, progressToNextLevel.toFixed(2)], (err, results) => { - if (err) { - console.error('Error updating XP:', err); - return res.status(500).json({ success: false, message: 'Internal server error' }); - } else { - res.status(200).json({ success: true, sendUpdateEvent: currentLevelSaved !== currentLevel, level: currentLevel}); - } - }); - }); - }); + }, + ); }); -app.get('/get/:guild/:user', (req, res) => { +app.get("/get/:guild/:user", async (req, res) => { const { guild, user } = req.params; - const selectQuery = ` - SELECT * FROM \`${guild}\` WHERE user_id = ? - `; - pool.query(selectQuery, [user], (err, results) => { - if (err) { - console.error('Error fetching XP:', err); - res.status(500).json({ message: 'Internal server error' }); - } - else if (results.length > 0) { - res.status(200).json(results[0]); - } - else { - res.status(404).json({ message: 'User not found' }); - } - }); + const [err, result] = await getUser(user, guild); + + if (err) { + console.error("Error fetching user:", err); + res.status(500).json({ message: "Internal server error" }); + } else if (result) { + res.status(200).json(result); + } else { + res.status(404).json({ message: "User not found" }); + } }); -app.get('/get/:guild', async (req, res) => { +app.get("/get/:guild", async (req, res) => { const { guild } = req.params; - const returnData = { "guild": {}, "leaderboard": [] }; - - const selectQuery = ` - SELECT * FROM \`${guild}\` ORDER BY xp DESC; - `; - const selectQuery2 = ` - SELECT * FROM info_guilds WHERE guild_id = ${guild}; - `; - - try { - const results1 = await new Promise((resolve, reject) => { - pool.query(selectQuery, (err, results) => { - if (err) { - console.error('Error fetching XP:', err); - reject(err); - } else { - resolve(results); - } - }); - }); - const results2 = await new Promise((resolve, reject) => { - pool.query(selectQuery2, (err, results) => { - if (err) { - console.error('Error fetching XP:', err); - reject(err); - } else { - resolve(results); - } - }); + const [guildErr, guildData] = await getGuild(guild); + const [usersErr, usersData] = await getUsers(guild); + + if (guildErr) { + console.error("Error fetching guild:", guildErr); + res.status(500).json({ message: "Internal server error" }); + } else if (usersErr) { + console.error("Error fetching users:", usersErr); + res.status(500).json({ message: "Internal server error" }); + } else if (!guildData) { + res.status(404).json({ message: "Guild not found" }); + } else { + res.status(200).json({ + guild: guildData, + leaderboard: usersData, }); - - returnData.leaderboard = results1; - returnData.guild = results2[0]; - - return res.status(200).json(returnData); - } catch (error) { - console.error('Error fetching XP:', error); - return res.status(500).json({ message: 'Internal server error' }); } }); -app.post('/admin/:action/:guild/:target', async (req, res) => { +app.post("/admin/:action/:guild/:target", authMiddleware, async (req, res) => { const { guild, action, target } = req.params; - const { auth, extraData } = req.body; - - if (auth !== process.env.AUTH) { - return res.status(403).json({ message: 'Access denied. Auth token is missing' }); - } - - let apiSuccess; + const { extraData } = req.body; switch (action) { - case 'include': + case "include": // target: channel id // run function to include target to guild break; - case 'exclude': + case "exclude": // target: channel id // run function to exclude target from guild break; - case 'updates': - if (target !== 'enable' && target !== 'disable' && target !== 'get') { - return res.status(400).json({ message: 'Illegal request' }); + case "updates": + if (target !== "enable" && target !== "disable" && target !== "get") { + return res.status(400).json({ message: "Illegal request" }); } switch (target) { - case 'enable': + case "enable": if (!extraData || !extraData.channelId) { - return res.status(400).json({ message: 'Illegal request' }); + return res.status(400).json({ message: "Illegal request" }); } try { const data = await adminUpdatesAdd(guild, extraData.channelId); return res.status(200).json(data); } catch (error) { - return res.status(500).json({ message: 'Internal server error' }); + return res.status(500).json({ message: "Internal server error" }); } - case 'disable': + case "disable": try { const data = await adminUpdatesRemove(guild); return res.status(200).json(data); } catch (error) { - return res.status(500).json({ message: 'Internal server error' }); + return res.status(500).json({ message: "Internal server error" }); } default: try { const data = await adminUpdatesGet(guild); return res.status(200).json(data); } catch (error) { - return res.status(500).json({ message: 'Internal server error' }); + return res.status(500).json({ message: "Internal server error" }); } } - case 'roles': - if (target !== 'add' && target !== 'remove' && target !== 'get') { - return res.status(400).json({ message: 'Illegal request' }); + case "roles": + if (target !== "add" && target !== "remove" && target !== "get") { + return res.status(400).json({ message: "Illegal request" }); } - if ((target === 'add' || target === 'remove') && !extraData) { - return res.status(400).json({ message: 'Illegal request' }); + if ((target === "add" || target === "remove") && !extraData) { + return res.status(400).json({ message: "Illegal request" }); } switch (target) { - case 'get': + case "get": try { const data = await adminRolesGet(guild); return res.status(200).json(data); } catch (error) { - return res.status(500).json({ message: 'Internal server error' }); + return res.status(500).json({ message: "Internal server error" }); } - case 'remove': + case "remove": try { const data = await adminRolesRemove(guild, extraData.role); return res.status(200).json(data); } catch (error) { - return res.status(500).json({ message: 'Internal server error' }); + return res.status(500).json({ message: "Internal server error" }); } - case 'add': + case "add": try { - const data = await adminRolesAdd(guild, extraData.role, extraData.level); + const data = await adminRolesAdd( + guild, + extraData.role, + extraData.level, + ); return res.status(200).json(data); } catch (error) { - return res.status(500).json({ message: 'Internal server error' }); + return res.status(500).json({ message: "Internal server error" }); } default: - return res.status(500).json({ message: 'Internal server error' }); + return res.status(500).json({ message: "Internal server error" }); } default: - return res.status(400).json({ message: 'Illegal request' }); + return res.status(400).json({ message: "Illegal request" }); } }); -app.get('/leaderboard/:guild', async (req, res) => { +app.get("/leaderboard/:guild", async (req, res) => { const { guild } = req.params; - const response = await fetch(`http://localhost:18103/get/${guild}/`); - if (!response.ok) { - return res.status(404).json({ message: 'No guild was found with this ID' }); + const [guildErr, guildData] = await getGuild(guild); + const [usersErr, usersData] = await getUsers(guild); + + if (guildErr) { + console.error("Error fetching guild:", guildErr); + res.status(500).json({ message: "Internal server error" }); + } else if (usersErr) { + console.error("Error fetching users:", usersErr); + res.status(500).json({ message: "Internal server error" }); } - const data = await response.json(); - res.render('leaderboard', { guild: data.guild, leaderboard: data.leaderboard }); -}); - -async function getBotInfo() { - const selectGuildsCountQuery = ` - SELECT COUNT(*) AS total_guilds FROM info_guilds; - `; - - const selectTotalMembersQuery = ` - SELECT SUM(guild_members) AS total_members FROM info_guilds; - `; - - try { - const guildsCountResult = await new Promise((resolve, reject) => { - pool.query(selectGuildsCountQuery, (err, results) => { - if (err) { - console.error('Error fetching guilds count:', err); - reject(err); - } else { - resolve(results[0].total_guilds); - } - }); - }); - const totalMembersResult = await new Promise((resolve, reject) => { - pool.query(selectTotalMembersQuery, (err, results) => { - if (err) { - console.error('Error fetching total members:', err); - reject(err); - } else { - resolve(results[0].total_members); - } - }); - }); - - const botInfo = { - total_guilds: guildsCountResult, - total_members: totalMembersResult, - }; - - return botInfo - } catch (error) { - console.error('Error fetching bot info:', error); - return null - } -} + res.render("leaderboard", { + guild: guildData, + leaderboard: usersData, + }); +}); -app.get('/', async (req, res) => { - const botInfo = await getBotInfo(); - res.render('index', { botInfo: botInfo }); +app.get("/", async (_req, res) => { + // TODO: handle error + const [err, botInfo] = await getBotInfo(); + res.render("index", { botInfo }); }); -app.get('/invite', (req, res) => { - res.status(308).redirect('https://discord.com/oauth2/authorize?client_id=1245807579624378601&permissions=1099780115520&integration_type=0&scope=bot+applications.commands') -}) +app.get("/invite", (_req, res) => + res + .status(308) + .redirect( + "https://discord.com/oauth2/authorize?client_id=1245807579624378601&permissions=1099780115520&integration_type=0&scope=bot+applications.commands", + ) +); app.listen(PORT, () => { console.log(`Server running on http://localhost:${PORT}`); @@ -443,7 +285,7 @@ async function adminRolesGet(guild: string) { return new Promise((resolve, reject) => { pool.query(selectRolesQuery, [guild], (err, results) => { if (err) { - console.error('Error fetching roles:', err); + console.error("Error fetching roles:", err); reject(err); } else { resolve(results); @@ -461,7 +303,7 @@ async function adminRolesRemove(guild: string, role: string) { return new Promise((resolve, reject) => { pool.query(deleteRoleQuery, [guild, role], (err, results) => { if (err) { - console.error('Error removing role:', err); + console.error("Error removing role:", err); reject(err); } else { resolve(results); @@ -479,7 +321,7 @@ async function adminRolesAdd(guild: string, role: string, level: number) { return new Promise((resolve, reject) => { pool.query(insertRoleQuery, [guild, role, level], (err, results) => { if (err) { - console.error('Error adding role:', err); + console.error("Error adding role:", err); reject(err); } else { resolve(results); @@ -496,7 +338,7 @@ async function adminUpdatesGet(guildId: string) { return new Promise((resolve, reject) => { pool.query(selectUpdatesQuery, [guildId], (err, results) => { if (err) { - console.error('Error fetching updates:', err); + console.error("Error fetching updates:", err); reject(err); } else { resolve(results); @@ -515,14 +357,18 @@ async function adminUpdatesAdd(guildId: string, channelId: string) { `; return new Promise((resolve, reject) => { - pool.query(insertUpdatesQuery, [guildId, channelId, channelId], (err, results) => { - if (err) { - console.error('Error enabling updates:', err); - reject(err); - } else { - resolve(results); - } - }); + pool.query( + insertUpdatesQuery, + [guildId, channelId, channelId], + (err, results) => { + if (err) { + console.error("Error enabling updates:", err); + reject(err); + } else { + resolve(results); + } + }, + ); }); } @@ -535,7 +381,7 @@ async function adminUpdatesRemove(guildId: string) { return new Promise((resolve, reject) => { pool.query(deleteUpdatesQuery, [guildId], (err, results) => { if (err) { - console.error('Error disabling updates:', err); + console.error("Error disabling updates:", err); reject(err); } else { resolve(results); @@ -544,4 +390,4 @@ async function adminUpdatesRemove(guildId: string) { }); } -//#endregion \ No newline at end of file +//#endregion diff --git a/api/views/leaderboard.ejs b/api/views/leaderboard.ejs index 2b43b6a..c935a14 100644 --- a/api/views/leaderboard.ejs +++ b/api/views/leaderboard.ejs @@ -4,7 +4,7 @@ - Leaderboard for <%= guild.guild_name %> + <title>Leaderboard for <%= guild.name %> @@ -14,9 +14,9 @@
- Guild Icon + Guild Icon

- <%= guild.guild_name %> + <%= guild.name %>

@@ -25,14 +25,14 @@

#<%= index + 1 %>

- User image for <%= user.user_name %> + User image for <%= user.user_name %>
-

- <%= user.user_nickname %> +

+ <%= user.nickname %>

XP: <%= user.xp.toLocaleString() %>

-

Level <%= user.user_level.toLocaleString() %> | <%= user.xp.toLocaleString() %>/<%= (user.xp + user.user_xp_needed_next_level).toLocaleString() %> points to next level (<%= user.user_progress_next_level %>%) +

Level <%= user.level.toLocaleString() %> | <%= user.xp.toLocaleString() %>/<%= (user.xp + user.xp_needed_next_level).toLocaleString() %> points to next level (<%= user.progress_next_level %>%)

diff --git a/bot/utils/requestAPI.ts b/bot/utils/requestAPI.ts index 5b2fa91..6b8b1c5 100644 --- a/bot/utils/requestAPI.ts +++ b/bot/utils/requestAPI.ts @@ -1,9 +1,10 @@ import handleLevelChange from "./handleLevelChange"; export async function makePOSTRequest(guild: string, user: string, xp: number, pfp: string, name: string, nickname: string) { - await fetch(`http://localhost:18103/post/${guild}/${user}/${process.env.AUTH}`, { + await fetch(`http://localhost:18103/post/${guild}/${user}`, { headers: { 'Content-Type': 'application/json', + 'Authorization': process.env.AUTH as string, }, method: 'POST', body: JSON.stringify({ xp, pfp, name, nickname }), @@ -42,6 +43,7 @@ export async function updateGuildInfo(guild: string, name: string, icon: string, await fetch(`http://localhost:18103/post/${guild}`, { headers: { 'Content-Type': 'application/json', + 'Authorization': process.env.AUTH as string, }, method: 'POST', body: JSON.stringify({ name, icon, members, auth: process.env.AUTH }), @@ -133,4 +135,4 @@ export async function disableUpdates(guild: string) { }); return response.status === 200; } -//#endregion \ No newline at end of file +//#endregion From 2a41ac9ff7053fd2dc46eab7fafc4e34798475c8 Mon Sep 17 00:00:00 2001 From: ToastedToast Date: Thu, 11 Jul 2024 21:27:14 +0800 Subject: [PATCH 05/37] feat(db): migrate roles and updates --- api/db/init.ts | 20 ++++++++++++++++++-- api/index.ts | 31 ++++++++++++++++--------------- bot/utils/requestAPI.ts | 32 ++++++++++++++++++++------------ 3 files changed, 54 insertions(+), 29 deletions(-) diff --git a/api/db/init.ts b/api/db/init.ts index a438a0f..14e121c 100644 --- a/api/db/init.ts +++ b/api/db/init.ts @@ -7,8 +7,8 @@ export async function initTables() { name VARCHAR(255), icon VARCHAR(255), members INT, - updates_enabled BOOLEAN DEFAULT TRUE, - updates_channel VARCHAR(255) + updates_enabled BOOLEAN DEFAULT FALSE, + updates_channel JSON ) `; const createUsersTable = ` @@ -35,6 +35,14 @@ export async function initTables() { FOREIGN KEY (guild_id) REFERENCES guilds(id) ) ` + const createUpdatesTable = ` + CREATE TABLE IF NOT EXISTS updates ( + guild_id VARCHAR(255) NOT NULL PRIMARY KEY, + channel_id VARCHAR(255) NOT NULL, + enabled BOOLEAN DEFAULT FALSE, + FOREIGN KEY (guild_id) REFERENCES guilds(id) + ) + ` pool.query(createGuildsTable, (err, results) => { if (err) { @@ -60,4 +68,12 @@ export async function initTables() { console.log("Roles table created:", results); } }); + + pool.query(createUpdatesTable, (err, results) => { + if (err) { + console.error("Error creating updates table:", err); + } else { + console.log("Updates table created:", results); + } + }); } diff --git a/api/index.ts b/api/index.ts index f91013c..c9537d5 100644 --- a/api/index.ts +++ b/api/index.ts @@ -1,6 +1,5 @@ import express, { type NextFunction, type Request, type Response } from "express"; import cors from "cors"; -import mysql from "mysql2"; import path from "path"; import { getBotInfo, getGuild, getUser, getUsers, initTables, pool, updateGuild } from "./db"; @@ -18,7 +17,7 @@ await initTables(); console.log("Tables initialized"); function authMiddleware(req: Request, res: Response, next: NextFunction) { - if (req.headers.authorization !== process.env.AUTH) { + if (!req.headers.authorization || req.headers.authorization !== process.env.AUTH) { return res .status(403) .json({ message: "Access denied" }); @@ -278,9 +277,10 @@ app.listen(PORT, () => { console.log(`Server running on http://localhost:${PORT}`); }); +// TODO: actually implement this in a real way //#region Admin: Roles async function adminRolesGet(guild: string) { - const selectRolesQuery = `SELECT role_id, level FROM info_roles WHERE guild_id = ?`; + const selectRolesQuery = `SELECT id, level FROM roles WHERE guild_id = ?`; return new Promise((resolve, reject) => { pool.query(selectRolesQuery, [guild], (err, results) => { @@ -296,12 +296,12 @@ async function adminRolesGet(guild: string) { async function adminRolesRemove(guild: string, role: string) { const deleteRoleQuery = ` - DELETE FROM info_roles - WHERE guild_id = ? AND role_id = ? + DELETE FROM roles + WHERE id = ? AND guild_id = ? `; return new Promise((resolve, reject) => { - pool.query(deleteRoleQuery, [guild, role], (err, results) => { + pool.query(deleteRoleQuery, [role, guild], (err, results) => { if (err) { console.error("Error removing role:", err); reject(err); @@ -314,12 +314,12 @@ async function adminRolesRemove(guild: string, role: string) { async function adminRolesAdd(guild: string, role: string, level: number) { const insertRoleQuery = ` - INSERT INTO info_roles (guild_id, role_id, level) + INSERT INTO roles (id, guild_id, level) VALUES (?, ?, ?) `; return new Promise((resolve, reject) => { - pool.query(insertRoleQuery, [guild, role, level], (err, results) => { + pool.query(insertRoleQuery, [role, guild, level], (err, results) => { if (err) { console.error("Error adding role:", err); reject(err); @@ -331,9 +331,10 @@ async function adminRolesAdd(guild: string, role: string, level: number) { } //#endregion +// TODO: actually implement this in a real way //#region Admin: Updates async function adminUpdatesGet(guildId: string) { - const selectUpdatesQuery = `SELECT * FROM info_updates WHERE guild_id = ?`; + const selectUpdatesQuery = `SELECT * FROM updates WHERE guild_id = ?`; return new Promise((resolve, reject) => { pool.query(selectUpdatesQuery, [guildId], (err, results) => { @@ -349,17 +350,17 @@ async function adminUpdatesGet(guildId: string) { async function adminUpdatesAdd(guildId: string, channelId: string) { const insertUpdatesQuery = ` - INSERT INTO info_updates (guild_id, enabled, channel_id) - VALUES (?, TRUE, ?) + INSERT INTO updates (guild_id, channel_id, enabled) + VALUES (?, ?, TRUE) ON DUPLICATE KEY UPDATE - enabled = TRUE, - channel_id = ? + enabled = TRUE, + channel_id = VALUES(channel_id) `; return new Promise((resolve, reject) => { pool.query( insertUpdatesQuery, - [guildId, channelId, channelId], + [guildId, channelId], (err, results) => { if (err) { console.error("Error enabling updates:", err); @@ -374,7 +375,7 @@ async function adminUpdatesAdd(guildId: string, channelId: string) { async function adminUpdatesRemove(guildId: string) { const deleteUpdatesQuery = ` - DELETE FROM info_updates + DELETE FROM updates WHERE guild_id = ? `; diff --git a/bot/utils/requestAPI.ts b/bot/utils/requestAPI.ts index 6b8b1c5..d4bb543 100644 --- a/bot/utils/requestAPI.ts +++ b/bot/utils/requestAPI.ts @@ -46,7 +46,7 @@ export async function updateGuildInfo(guild: string, name: string, icon: string, 'Authorization': process.env.AUTH as string, }, method: 'POST', - body: JSON.stringify({ name, icon, members, auth: process.env.AUTH }), + body: JSON.stringify({ name, icon, members }), }).then(res => { return res.json() }).then(data => { @@ -59,11 +59,10 @@ export async function getRoles(guild: string) { const response = await fetch(`http://localhost:18103/admin/roles/${guild}/get`, { headers: { 'Content-Type': 'application/json', + 'Authorization': process.env.AUTH as string, }, + body: JSON.stringify({}), referrerPolicy: 'strict-origin-when-cross-origin', - body: JSON.stringify({ - auth: process.env.AUTH, - }), method: 'POST', }); @@ -76,9 +75,9 @@ export async function removeRole(guild: string, role: string): Promise const response = await fetch(`http://localhost:18103/admin/roles/${guild}/remove`, { "headers": { 'Content-Type': 'application/json', + 'Authorization': process.env.AUTH as string, }, "body": JSON.stringify({ - auth: process.env.AUTH, extraData: { role: role, } @@ -92,9 +91,9 @@ export async function addRole(guild: string, role: string, level: number): Promi const response = await fetch(`http://localhost:18103/admin/roles/${guild}/add`, { "headers": { 'Content-Type': 'application/json', + 'Authorization': process.env.AUTH as string, }, "body": JSON.stringify({ - auth: process.env.AUTH, extraData: { role: role, level: level @@ -113,24 +112,33 @@ export async function addRole(guild: string, role: string, level: number): Promi //#region Updates export async function checkIfGuildHasUpdatesEnabled(guild: string) { const response = await fetch(`http://localhost:18103/admin/updates/${guild}/get`, { - "headers": { 'Content-Type': 'application/json' }, - "body": JSON.stringify({ auth: process.env.AUTH }), + "headers": { + 'Content-Type': 'application/json', + 'Authorization': process.env.AUTH as string + }, + "body": JSON.stringify({}), "method": "POST" }); return response.status === 200; } export async function enableUpdates(guild: string, channelId: string) { const response = await fetch(`http://localhost:18103/admin/updates/${guild}/enable`, { - "headers": { 'Content-Type': 'application/json' }, - "body": JSON.stringify({ auth: process.env.AUTH, extraData: { channelId } }), + "headers": { + 'Content-Type': 'application/json', + 'Authorization': process.env.AUTH as string, + }, + "body": JSON.stringify({ extraData: { channelId } }), "method": "POST" }); return response.status === 200; } export async function disableUpdates(guild: string) { const response = await fetch(`http://localhost:18103/admin/updates/${guild}/disable`, { - "headers": { 'Content-Type': 'application/json' }, - "body": JSON.stringify({ auth: process.env.AUTH }), + "headers": { + 'Content-Type': 'application/json', + 'Authorization': process.env.AUTH as string, + }, + "body": JSON.stringify({}), "method": "POST" }); return response.status === 200; From b1384dfa1537cddd8ef16a8e1bc4a51c50dcd2b5 Mon Sep 17 00:00:00 2001 From: GalvinPython <77013913+GalvinPython@users.noreply.github.com> Date: Thu, 11 Jul 2024 16:04:41 +0100 Subject: [PATCH 06/37] feat: added lint workflow --- .github/workflows/eslint.yml | 28 ++++++++++++++++++++++++++++ package.json | 5 +++-- 2 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/eslint.yml diff --git a/.github/workflows/eslint.yml b/.github/workflows/eslint.yml new file mode 100644 index 0000000..77f203f --- /dev/null +++ b/.github/workflows/eslint.yml @@ -0,0 +1,28 @@ +name: ESLint Check + +on: + push: + branches: + - '*' + pull_request: + types: [opened, reopened, synchronize] + +jobs: + lint: + name: ESLint + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Setup Node.js + uses: actions/setup-node@v2 + with: + node-version: '20' + + - name: Install dependencies + run: npm install + + - name: Run ESLint + run: npm run lint \ No newline at end of file diff --git a/package.json b/package.json index b07d790..286ceea 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,11 @@ { "name": "xpbot", "type": "module", - "version": "0.0.8", + "version": "0.1.0", "scripts": { "api": "bun api/index.ts", - "bot": "bun bot/index.ts" + "bot": "bun bot/index.ts", + "lint": "eslint . --config eslint.config.mjs" }, "devDependencies": { "@eslint/js": "^9.6.0", From 96011e8c44cca320b97bef17740ac9b412a7ad43 Mon Sep 17 00:00:00 2001 From: GalvinPython <77013913+GalvinPython@users.noreply.github.com> Date: Thu, 11 Jul 2024 23:23:14 +0100 Subject: [PATCH 07/37] fix(db): updated database and added new metric removed the foreign keys from the database as they were causing conflicts and removed the json type for updates_channel for the same reason. Added total users count, however this is something that might be removed or needs to be reconsidered --- .env.example | 2 +- README.md | 3 +++ api/db/init.ts | 30 +++++++++++++++--------------- api/db/queries/guilds.ts | 38 ++++++++++++++++++++++++++++++++------ api/views/index.ejs | 8 ++++---- bot/index.ts | 5 +---- package.json | 2 ++ 7 files changed, 58 insertions(+), 30 deletions(-) diff --git a/.env.example b/.env.example index 2899998..b86308b 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ DISCORD_TOKEN='TOKEN' -DISCORD_WEBHOOK_URL='YOUR_WEBHOOK_HERE' +DISCORD_TOKEN_DEV='DEV_TOKEN' MYSQL_ADDRESS='YOUR_MYSQL_SERVER_ADDRESS' MYSQL_PORT='YOUR_MYSQL_SERVER_PORT' diff --git a/README.md b/README.md index 865105e..8a1851d 100644 --- a/README.md +++ b/README.md @@ -17,3 +17,6 @@ bun run index.ts ``` This project was created using `bun init` in bun v1.1.10. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. + +# Changelog +* Soon \ No newline at end of file diff --git a/api/db/init.ts b/api/db/init.ts index 14e121c..5ef1fce 100644 --- a/api/db/init.ts +++ b/api/db/init.ts @@ -8,7 +8,7 @@ export async function initTables() { icon VARCHAR(255), members INT, updates_enabled BOOLEAN DEFAULT FALSE, - updates_channel JSON + updates_channel VARCHAR(255) ) `; const createUsersTable = ` @@ -22,58 +22,58 @@ export async function initTables() { level INT DEFAULT 0, xp_needed_next_level INT, progress_next_level DECIMAL(6, 2), - PRIMARY KEY (id, guild_id), - FOREIGN KEY (guild_id) REFERENCES guilds(id) + PRIMARY KEY (id, guild_id) ) `; + // FOREIGN KEY (guild_id) REFERENCES guilds(id) const createRolesTable = ` CREATE TABLE IF NOT EXISTS roles ( id VARCHAR(255) NOT NULL PRIMARY KEY, guild_id VARCHAR(255) NOT NULL, name VARCHAR(255), - level INT NOT NULL, - FOREIGN KEY (guild_id) REFERENCES guilds(id) + level INT NOT NULL ) ` + // FOREIGN KEY (guild_id) REFERENCES guilds(id) const createUpdatesTable = ` CREATE TABLE IF NOT EXISTS updates ( guild_id VARCHAR(255) NOT NULL PRIMARY KEY, channel_id VARCHAR(255) NOT NULL, - enabled BOOLEAN DEFAULT FALSE, - FOREIGN KEY (guild_id) REFERENCES guilds(id) + enabled BOOLEAN DEFAULT FALSE ) ` + // FOREIGN KEY (guild_id) REFERENCES guilds(id) - pool.query(createGuildsTable, (err, results) => { + pool.query(createGuildsTable, (err) => { if (err) { console.error("Error creating guilds table:", err); } else { - console.log("Guilds table created:", results); + console.log("Guilds table created"); } }); - pool.query(createUsersTable, (err, results) => { + pool.query(createUsersTable, (err) => { if (err) { console.error("Error creating users table:", err); } else { - console.log("Users table created:", results); + console.log("Users table created"); } }); - pool.query(createRolesTable, (err, results) => { + pool.query(createRolesTable, (err) => { if (err) { console.error("Error creating roles table:", err); } else { - console.log("Roles table created:", results); + console.log("Roles table created"); } }); - pool.query(createUpdatesTable, (err, results) => { + pool.query(createUpdatesTable, (err) => { if (err) { console.error("Error creating updates table:", err); } else { - console.log("Updates table created:", results); + console.log("Updates table created"); } }); } diff --git a/api/db/queries/guilds.ts b/api/db/queries/guilds.ts index 1b6ed93..f66e55e 100644 --- a/api/db/queries/guilds.ts +++ b/api/db/queries/guilds.ts @@ -22,7 +22,7 @@ export async function getGuild(guildId: string): Promise<[QueryError | null, Gui }); } -export async function updateGuild(guild: Guild): Promise<[QueryError, null] | [null, Guild[]]> { +export async function updateGuild(guild: Guild): Promise<[QueryError | null, null] | [null, Guild[]]> { return new Promise((resolve, reject) => { pool.query( ` @@ -34,7 +34,7 @@ export async function updateGuild(guild: Guild): Promise<[QueryError, null] | [n members = VALUES(members), updates_enabled = VALUES(updates_enabled), updates_channel = VALUES(updates_channel) - `, + `, [ guild.id, guild.name, @@ -44,6 +44,7 @@ export async function updateGuild(guild: Guild): Promise<[QueryError, null] | [n guild.updates_channel, ], (err, results) => { + console.dir(results, { depth: null }); if (err) { reject([err, null]); } else { @@ -57,6 +58,7 @@ export async function updateGuild(guild: Guild): Promise<[QueryError, null] | [n interface BotInfo { total_guilds: number; total_members: number; + user_count?: number; } export async function getBotInfo(): Promise<[QueryError | null, BotInfo | null]> { @@ -65,11 +67,35 @@ export async function getBotInfo(): Promise<[QueryError | null, BotInfo | null]> if (err) { reject([err, null]); } else { - resolve([null, { + const botInfo: BotInfo = { total_guilds: (results as BotInfo[])[0].total_guilds, total_members: (results as BotInfo[])[0].total_members ?? 0, - }]); + }; + getUsersCount() + .then(([userCountError, userCount]) => { + if (userCountError) { + reject([userCountError, null]); + } else { + botInfo.user_count = userCount; + resolve([null, botInfo]); + } + }) + .catch((error) => { + reject([error, null]); + }); } - }) - }) + }); + }); +} + +export async function getUsersCount(): Promise<[QueryError | null, number]> { + return new Promise((resolve, reject) => { + pool.query("SELECT COUNT(*) AS count FROM users", (err, results) => { + if (err) { + reject([err, null]); + } else { + resolve([null, (results[0] as { count: number }).count]); + } + }); + }); } diff --git a/api/views/index.ejs b/api/views/index.ejs index 9efaee4..c1b05ad 100644 --- a/api/views/index.ejs +++ b/api/views/index.ejs @@ -34,14 +34,14 @@ - +
diff --git a/bot/index.ts b/bot/index.ts index e1ad987..69f2cc3 100644 --- a/bot/index.ts +++ b/bot/index.ts @@ -1,8 +1,5 @@ -// https://discord.com/oauth2/authorize?client_id=1245807579624378601&permissions=274878008384&scope=bot+applications.commands -//TODO: Type this - // Check if DISCORD_TOKEN has been provided as an environment variable, and is a valid regex pattern -const discordToken: string | undefined = process.env?.DISCORD_TOKEN +const discordToken: string | undefined = process.argv.includes('--dev') ? process.env?.DISCORD_TOKEN_DEV : process.env?.DISCORD_TOKEN if (!discordToken || discordToken === 'YOUR_TOKEN_HERE') throw 'You MUST provide a discord token in .env!' diff --git a/package.json b/package.json index 286ceea..396b6c2 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,8 @@ "type": "module", "version": "0.1.0", "scripts": { + "dev:api": "bun --watch api/index.ts --dev", + "dev:bot": "bun --watch bot/index.ts --dev", "api": "bun api/index.ts", "bot": "bun bot/index.ts", "lint": "eslint . --config eslint.config.mjs" From a5dbd813150fd042c72037e7feacca091f1e84b3 Mon Sep 17 00:00:00 2001 From: ToastedToast Date: Fri, 12 Jul 2024 08:27:01 +0800 Subject: [PATCH 08/37] feat: add customizable cooldowns --- api/db/init.ts | 1 + api/db/queries/guilds.ts | 3 +- api/index.ts | 53 +++++++++++++++++++++++++ bot/commands.ts | 78 ++++++++++++++++++++++++++++++++++++- bot/events/messageCreate.ts | 5 ++- bot/utils/requestAPI.ts | 26 +++++++++++++ 6 files changed, 162 insertions(+), 4 deletions(-) diff --git a/api/db/init.ts b/api/db/init.ts index 5ef1fce..895fd53 100644 --- a/api/db/init.ts +++ b/api/db/init.ts @@ -7,6 +7,7 @@ export async function initTables() { name VARCHAR(255), icon VARCHAR(255), members INT, + cooldown INT DEFAULT 30000, updates_enabled BOOLEAN DEFAULT FALSE, updates_channel VARCHAR(255) ) diff --git a/api/db/queries/guilds.ts b/api/db/queries/guilds.ts index f66e55e..d3fc581 100644 --- a/api/db/queries/guilds.ts +++ b/api/db/queries/guilds.ts @@ -6,11 +6,12 @@ export interface Guild { name: string; icon: string; members: number; + cooldown: number; updates_enabled: boolean; updates_channel: string; } -export async function getGuild(guildId: string): Promise<[QueryError | null, Guild | null]> { +export async function getGuild(guildId: string): Promise<[QueryError, null] | [null, Guild | null]> { return new Promise((resolve, reject) => { pool.query("SELECT * FROM guilds WHERE id = ?", [guildId], (err, results) => { if (err) { diff --git a/api/index.ts b/api/index.ts index c9537d5..9541556 100644 --- a/api/index.ts +++ b/api/index.ts @@ -235,6 +235,36 @@ app.post("/admin/:action/:guild/:target", authMiddleware, async (req, res) => { default: return res.status(500).json({ message: "Internal server error" }); } + case "cooldown": + if (target !== "set" && target !== "get") { + return res.status(400).json({ message: "Illegal request" }); + } + + if(target === "set" && !extraData) { + return res.status(400).json({ message: "Illegal request" }); + } + + switch (target) { + case "get": + try { + const [err, data] = await getGuild(guild); + if(err) { + return res.status(500).json({ message: "Internal server error" }); + } + return res.status(200).json({ cooldown: data?.cooldown ?? 30_000 }); + } catch (error) { + return res.status(500).json({ message: "Internal server error" }); + } + case "set": + try { + const data = await adminCooldownSet(guild, extraData.cooldown); + return res.status(200).json(data); + } catch (error) { + return res.status(500).json({ message: "Internal server error" }); + } + default: + return res.status(500).json({ message: "Internal server error" }); + } default: return res.status(400).json({ message: "Illegal request" }); } @@ -392,3 +422,26 @@ async function adminUpdatesRemove(guildId: string) { } //#endregion + +// TODO: actually implement this in a real way +//#region Admin: Cooldown +async function adminCooldownSet(guild: string, cooldown: number) { + const updateCooldownQuery = ` + INSERT INTO guilds (id, cooldown) VALUES (?, ?) + ON DUPLICATE KEY UPDATE + cooldown = VALUES(cooldown) + `; + + return new Promise((resolve, reject) => { + pool.query(updateCooldownQuery, [guild, cooldown], (err, results) => { + if (err) { + console.error("Error setting cooldown:", err); + reject(err); + } else { + resolve(results); + } + }); + }); +} + +//#endregion diff --git a/bot/commands.ts b/bot/commands.ts index d2c6c5d..18986e2 100644 --- a/bot/commands.ts +++ b/bot/commands.ts @@ -3,7 +3,7 @@ import client from '.'; import { ActionRowBuilder, ButtonBuilder, ButtonStyle, type CommandInteraction, ChannelType } from 'discord.js'; import { heapStats } from 'bun:jsc'; -import { getGuildLeaderboard, makeGETRequest, getRoles, removeRole, addRole, enableUpdates, disableUpdates } from './utils/requestAPI'; +import { getGuildLeaderboard, makeGETRequest, getRoles, removeRole, addRole, enableUpdates, disableUpdates, getCooldown, setCooldown } from './utils/requestAPI'; import convertToLevels from './utils/convertToLevels'; import quickEmbed from './utils/quickEmbed'; @@ -429,6 +429,82 @@ const commands: Record = { return; } }, + }, + cooldown: { + data: { + options: [{ + name: 'action', + id: 'action', + description: 'Select an action', + type: 3, + required: true, + choices: [ + { + name: 'Get', + value: 'get', + }, + { + name: 'Set', + value: 'set', + } + ] + },{ + name: 'cooldown', + id: 'cooldown', + description: 'Enter the cooldown in seconds. Required for set action.', + type: 4, + required: false, + choices: [] + }], + name: 'cooldown', + description: 'Manage the cooldown for XP!', + integration_types: [0], + contexts: [0, 2], + }, + execute: async (interaction) => { + if (!interaction.memberPermissions?.has('ManageChannels')) { + const errorEmbed = quickEmbed({ + color: 'Red', + title: 'Error!', + description: 'Missing permissions: `Manage Channels`' + }, interaction); + await interaction.reply({ + ephemeral: true, + embeds: [errorEmbed] + }) + .catch(console.error); + return; + } + + const action = interaction.options.get('action')?.value; + const cooldown = interaction.options.get('cooldown')?.value; + + let cooldownData; + let apiSuccess; + + switch (action) { + case 'get': + cooldownData = await getCooldown(interaction.guildId as string); + if (!cooldownData) { + await interaction.reply({ ephemeral: true, content: 'Error fetching cooldown data!' }); + return; + } + await interaction.reply({ ephemeral: true, content: `Cooldown: ${(cooldownData?.cooldown ?? 30_000) / 1000} seconds` }); + return; + case 'set': + if (!cooldown) { + await interaction.reply({ ephemeral: true, content: 'ERROR: Cooldown was not specified!' }); + return; + } + apiSuccess = await setCooldown(interaction.guildId as string, parseInt(cooldown as string) * 1000); + if (!apiSuccess) { + await interaction.reply({ ephemeral: true, content: 'Error setting cooldown!' }); + return; + } + await interaction.reply({ ephemeral: true, content: `Cooldown set to ${cooldown} seconds` }); + return; + } + } } }; diff --git a/bot/events/messageCreate.ts b/bot/events/messageCreate.ts index 6f145e2..e3dafa6 100644 --- a/bot/events/messageCreate.ts +++ b/bot/events/messageCreate.ts @@ -1,13 +1,14 @@ import { Message } from 'discord.js'; import client from '../index'; -import { makePOSTRequest, updateGuildInfo } from '../utils/requestAPI'; +import { getCooldown, makePOSTRequest, updateGuildInfo } from '../utils/requestAPI'; const cooldowns = new Map(); -const cooldownTime = 30 * 1000; // Run this event whenever a message has been sent client.on('messageCreate', async (message: Message) => { if (message.author.bot) return; + + const cooldownTime = (await getCooldown(message.guildId as string))?.cooldown ?? 30_000; const cooldown = cooldowns.get(message.author.id); if (cooldown && Date.now() - cooldown < cooldownTime) return; diff --git a/bot/utils/requestAPI.ts b/bot/utils/requestAPI.ts index d4bb543..929fde5 100644 --- a/bot/utils/requestAPI.ts +++ b/bot/utils/requestAPI.ts @@ -144,3 +144,29 @@ export async function disableUpdates(guild: string) { return response.status === 200; } //#endregion + +//#region Cooldowns +export async function getCooldown(guild: string) { + const response = await fetch(`http://localhost:18103/admin/cooldown/${guild}/get`, { + "headers": { + 'Content-Type': 'application/json', + 'Authorization': process.env.AUTH as string, + }, + "body": JSON.stringify({}), + "method": "POST" + }); + return response.json(); +} + +export async function setCooldown(guild: string, cooldown: number) { + const response = await fetch(`http://localhost:18103/admin/cooldown/${guild}/set`, { + "headers": { + 'Content-Type': 'application/json', + 'Authorization': process.env.AUTH as string, + }, + "body": JSON.stringify({ extraData: { cooldown } }), + "method": "POST" + }); + return response.status === 200; +} +//#endregion From 8b717624430f3b11398c2306ae214519c41af802 Mon Sep 17 00:00:00 2001 From: GalvinPython <77013913+GalvinPython@users.noreply.github.com> Date: Fri, 12 Jul 2024 19:27:39 +0100 Subject: [PATCH 09/37] fix(updates): api now routes to the new database --- api/db/index.ts | 1 + api/db/queries/updates.ts | 69 ++++++++++++++++++++++++++++ api/index.ts | 95 ++++++++++----------------------------- bot/commands.ts | 24 ++++++++-- bot/utils/requestAPI.ts | 12 ++--- 5 files changed, 119 insertions(+), 82 deletions(-) create mode 100644 api/db/queries/updates.ts diff --git a/api/db/index.ts b/api/db/index.ts index cd01b1d..f3786c5 100644 --- a/api/db/index.ts +++ b/api/db/index.ts @@ -12,3 +12,4 @@ export const pool = mysql.createPool({ export * from './init'; export * from './queries/guilds'; export * from './queries/users'; +export * from './queries/updates'; \ No newline at end of file diff --git a/api/db/queries/updates.ts b/api/db/queries/updates.ts new file mode 100644 index 0000000..5fc33e0 --- /dev/null +++ b/api/db/queries/updates.ts @@ -0,0 +1,69 @@ +import type { QueryError } from "mysql2"; +import { pool } from ".."; + +export interface Updates { + guild_id: string; + channel_id: string; + enabled: boolean; +} + +export async function getUpdates(guildId: string): Promise<[QueryError | null, Updates[] | null]> { + return new Promise((resolve, reject) => { + pool.query("SELECT * FROM updates WHERE guild_id = ?", [guildId], (err, results) => { + if (err) { + reject([err, null]); + } else { + resolve([null, results as Updates[]]); + } + }); + }); +} + +export async function enableUpdates(guildId: string, channelId: string): Promise<[QueryError | null, true | null]> { + return new Promise((resolve, reject) => { + pool.query( + ` + INSERT INTO updates (guild_id, channel_id, enabled) + VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE + channel_id = VALUES(channel_id), + enabled = VALUES(enabled) + `, + [ + guildId, + channelId, + true, + ], + (err) => { + if (err) { + reject([err, null]); + } else { + resolve([null, true]); + } + }, + ); + }); +} + +export async function disableUpdates(guildId: string): Promise<[QueryError | null, true | null]> { + return new Promise((resolve, reject) => { + pool.query( + ` + UPDATE updates + SET enabled = ? + WHERE guild_id = ? + `, + [ + false, + guildId, + ], + (err) => { + if (err) { + reject([err, null]); + } else { + resolve([null, true]); + } + }, + ); + }); +} diff --git a/api/index.ts b/api/index.ts index c9537d5..eb5cfbf 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 } from "./db"; +import { getBotInfo, getGuild, getUser, getUsers, initTables, pool, updateGuild, getUpdates, enableUpdates, disableUpdates } from "./db"; const app = express(); const PORT = 18103; @@ -159,10 +159,12 @@ app.post("/admin/:action/:guild/:target", authMiddleware, async (req, res) => { switch (action) { case "include": + // TODO: implement this // target: channel id // run function to include target to guild break; case "exclude": + // TODO: implement this // target: channel id // run function to exclude target from guild break; @@ -177,21 +179,32 @@ app.post("/admin/:action/:guild/:target", authMiddleware, async (req, res) => { return res.status(400).json({ message: "Illegal request" }); } try { - const data = await adminUpdatesAdd(guild, extraData.channelId); - return res.status(200).json(data); - } catch (error) { - return res.status(500).json({ message: "Internal server error" }); + const [err, success] = await enableUpdates(guild, extraData.channelId); + 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 "disable": try { - const data = await adminUpdatesRemove(guild); - return res.status(200).json(data); - } catch (error) { - return res.status(500).json({ message: "Internal server error" }); + const [err, success] = await disableUpdates(guild); + 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: try { - const data = await adminUpdatesGet(guild); + const [err, data] = await getUpdates(guild); + if (err) { + return res.status(500).json({ message: "Internal server error", err }); + } return res.status(200).json(data); } catch (error) { return res.status(500).json({ message: "Internal server error" }); @@ -330,65 +343,3 @@ async function adminRolesAdd(guild: string, role: string, level: number) { }); } //#endregion - -// TODO: actually implement this in a real way -//#region Admin: Updates -async function adminUpdatesGet(guildId: string) { - const selectUpdatesQuery = `SELECT * FROM updates WHERE guild_id = ?`; - - return new Promise((resolve, reject) => { - pool.query(selectUpdatesQuery, [guildId], (err, results) => { - if (err) { - console.error("Error fetching updates:", err); - reject(err); - } else { - resolve(results); - } - }); - }); -} - -async function adminUpdatesAdd(guildId: string, channelId: string) { - const insertUpdatesQuery = ` - INSERT INTO updates (guild_id, channel_id, enabled) - VALUES (?, ?, TRUE) - ON DUPLICATE KEY UPDATE - enabled = TRUE, - channel_id = VALUES(channel_id) - `; - - return new Promise((resolve, reject) => { - pool.query( - insertUpdatesQuery, - [guildId, channelId], - (err, results) => { - if (err) { - console.error("Error enabling updates:", err); - reject(err); - } else { - resolve(results); - } - }, - ); - }); -} - -async function adminUpdatesRemove(guildId: string) { - const deleteUpdatesQuery = ` - DELETE FROM updates - WHERE guild_id = ? - `; - - return new Promise((resolve, reject) => { - pool.query(deleteUpdatesQuery, [guildId], (err, results) => { - if (err) { - console.error("Error disabling updates:", err); - reject(err); - } else { - resolve(results); - } - }); - }); -} - -//#endregion diff --git a/bot/commands.ts b/bot/commands.ts index d2c6c5d..9f0767a 100644 --- a/bot/commands.ts +++ b/bot/commands.ts @@ -3,7 +3,7 @@ import client from '.'; import { ActionRowBuilder, ButtonBuilder, ButtonStyle, type CommandInteraction, ChannelType } from 'discord.js'; import { heapStats } from 'bun:jsc'; -import { getGuildLeaderboard, makeGETRequest, getRoles, removeRole, addRole, enableUpdates, disableUpdates } from './utils/requestAPI'; +import { getGuildLeaderboard, makeGETRequest, getRoles, removeRole, addRole, enableUpdates, disableUpdates, checkIfGuildHasUpdatesEnabled } from './utils/requestAPI'; import convertToLevels from './utils/convertToLevels'; import quickEmbed from './utils/quickEmbed'; @@ -414,18 +414,34 @@ const commands: Record = { const action = interaction.options.get('action')?.value; const channelId = interaction.channelId; + let success + let data switch (action) { case 'disable': - await disableUpdates(interaction.guildId as string); + success = await disableUpdates(interaction.guildId as string); + if (!success) { + await interaction.reply({ ephemeral: true, content: 'Error disabling updates for this server' }).catch(console.error); + return; + } await interaction.reply({ ephemeral: true, content: 'Updates are now disabled for this server' }).catch(console.error); return; case 'enable': - await enableUpdates(interaction.guildId as string, channelId as string); + success = await enableUpdates(interaction.guildId as string, channelId as string); + if (!success) { + await interaction.reply({ ephemeral: true, content: 'Error enabling updates for this server' }).catch(console.error); + return; + } await interaction.reply({ ephemeral: true, content: `Updates are now enabled for this server in <#${channelId}>` }).catch(console.error); return; default: - await interaction.reply({ ephemeral: true, content: 'Not implemented :3' }).catch(console.error); + data = await checkIfGuildHasUpdatesEnabled(interaction.guildId as string); + if (!data || Object.keys(data).length === 0) { + await interaction.reply({ ephemeral: true, content: 'No data found' }).catch(console.error); + return; + } + // TODO: Format in embed + await interaction.reply({ ephemeral: true, content: JSON.stringify(data, null, 2) }).catch(console.error); return; } }, diff --git a/bot/utils/requestAPI.ts b/bot/utils/requestAPI.ts index d4bb543..0e7ff9a 100644 --- a/bot/utils/requestAPI.ts +++ b/bot/utils/requestAPI.ts @@ -112,19 +112,19 @@ export async function addRole(guild: string, role: string, level: number): Promi //#region Updates export async function checkIfGuildHasUpdatesEnabled(guild: string) { const response = await fetch(`http://localhost:18103/admin/updates/${guild}/get`, { - "headers": { - 'Content-Type': 'application/json', - 'Authorization': process.env.AUTH as string + "headers": { + 'Content-Type': 'application/json', + 'Authorization': process.env.AUTH as string }, "body": JSON.stringify({}), "method": "POST" }); - return response.status === 200; + return response.status === 200 ? response.json() : {}; } export async function enableUpdates(guild: string, channelId: string) { const response = await fetch(`http://localhost:18103/admin/updates/${guild}/enable`, { "headers": { - 'Content-Type': 'application/json', + 'Content-Type': 'application/json', 'Authorization': process.env.AUTH as string, }, "body": JSON.stringify({ extraData: { channelId } }), @@ -134,7 +134,7 @@ export async function enableUpdates(guild: string, channelId: string) { } export async function disableUpdates(guild: string) { const response = await fetch(`http://localhost:18103/admin/updates/${guild}/disable`, { - "headers": { + "headers": { 'Content-Type': 'application/json', 'Authorization': process.env.AUTH as string, }, From b7acde91ae64afcb9b6a28b1c36422227cfd25e6 Mon Sep 17 00:00:00 2001 From: ToastedToast Date: Sat, 13 Jul 2024 20:09:41 +0800 Subject: [PATCH 10/37] chore(db): remove unused columns --- api/db/init.ts | 79 +++++++++++++++++++--------------------- api/db/queries/guilds.ts | 2 - api/index.ts | 9 ++--- 3 files changed, 42 insertions(+), 48 deletions(-) diff --git a/api/db/init.ts b/api/db/init.ts index 895fd53..7ae8380 100644 --- a/api/db/init.ts +++ b/api/db/init.ts @@ -1,18 +1,16 @@ import { pool } from "."; export async function initTables() { - const createGuildsTable = ` + const createGuildsTable = ` CREATE TABLE IF NOT EXISTS guilds ( id VARCHAR(255) NOT NULL PRIMARY KEY, name VARCHAR(255), icon VARCHAR(255), members INT, - cooldown INT DEFAULT 30000, - updates_enabled BOOLEAN DEFAULT FALSE, - updates_channel VARCHAR(255) + cooldown INT DEFAULT 30000 ) `; - const createUsersTable = ` + const createUsersTable = ` CREATE TABLE IF NOT EXISTS users ( id VARCHAR(255) NOT NULL, guild_id VARCHAR(255) NOT NULL, @@ -26,55 +24,54 @@ export async function initTables() { PRIMARY KEY (id, guild_id) ) `; - // FOREIGN KEY (guild_id) REFERENCES guilds(id) - const createRolesTable = ` + // FOREIGN KEY (guild_id) REFERENCES guilds(id) + const createRolesTable = ` CREATE TABLE IF NOT EXISTS roles ( id VARCHAR(255) NOT NULL PRIMARY KEY, guild_id VARCHAR(255) NOT NULL, name VARCHAR(255), level INT NOT NULL ) - ` - // FOREIGN KEY (guild_id) REFERENCES guilds(id) - const createUpdatesTable = ` + `; + // FOREIGN KEY (guild_id) REFERENCES guilds(id) + const createUpdatesTable = ` CREATE TABLE IF NOT EXISTS updates ( guild_id VARCHAR(255) NOT NULL PRIMARY KEY, channel_id VARCHAR(255) NOT NULL, enabled BOOLEAN DEFAULT FALSE ) - ` - // FOREIGN KEY (guild_id) REFERENCES guilds(id) - - pool.query(createGuildsTable, (err) => { - if (err) { - console.error("Error creating guilds table:", err); - } else { - console.log("Guilds table created"); - } - }); + `; + // FOREIGN KEY (guild_id) REFERENCES guilds(id) - pool.query(createUsersTable, (err) => { - if (err) { - console.error("Error creating users table:", err); - } else { - console.log("Users table created"); - } - }); + pool.query(createGuildsTable, (err) => { + if (err) { + console.error("Error creating guilds table:", err); + } else { + console.log("Guilds table created"); + } + }); + pool.query(createUsersTable, (err) => { + if (err) { + console.error("Error creating users table:", err); + } else { + console.log("Users table created"); + } + }); - pool.query(createRolesTable, (err) => { - if (err) { - console.error("Error creating roles table:", err); - } else { - console.log("Roles table created"); - } - }); + pool.query(createRolesTable, (err) => { + if (err) { + console.error("Error creating roles table:", err); + } else { + console.log("Roles table created"); + } + }); - pool.query(createUpdatesTable, (err) => { - if (err) { - console.error("Error creating updates table:", err); - } else { - console.log("Updates table created"); - } - }); + pool.query(createUpdatesTable, (err) => { + if (err) { + console.error("Error creating updates table:", err); + } else { + console.log("Updates table created"); + } + }); } diff --git a/api/db/queries/guilds.ts b/api/db/queries/guilds.ts index d3fc581..297fa00 100644 --- a/api/db/queries/guilds.ts +++ b/api/db/queries/guilds.ts @@ -7,8 +7,6 @@ export interface Guild { icon: string; members: number; cooldown: number; - updates_enabled: boolean; - updates_channel: string; } export async function getGuild(guildId: string): Promise<[QueryError, null] | [null, Guild | null]> { diff --git a/api/index.ts b/api/index.ts index 3062b16..8c7855e 100644 --- a/api/index.ts +++ b/api/index.ts @@ -34,8 +34,7 @@ app.post("/post/:guild", authMiddleware, async (req, res) => { name, icon, members, - updates_enabled: false, - updates_channel: "", + cooldown: 30_000, }); if (err) { @@ -71,10 +70,10 @@ app.post("/post/:guild/:user", authMiddleware, async (req, res) => { ((newXp - currentLevelXp) / (nextLevelXp - currentLevelXp)) * 100; const updateQuery = ` - INSERT INTO users + INSERT INTO users (id, guild_id, xp, pfp, name, nickname, level, xp_needed_next_level, progress_next_level) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - ON DUPLICATE KEY UPDATE + ON DUPLICATE KEY UPDATE xp = VALUES(xp), pfp = VALUES(pfp), name = VALUES(name), @@ -248,7 +247,7 @@ app.post("/admin/:action/:guild/:target", authMiddleware, async (req, res) => { default: return res.status(500).json({ message: "Internal server error" }); } - case "cooldown": + case "cooldown": if (target !== "set" && target !== "get") { return res.status(400).json({ message: "Illegal request" }); } From ac8636a6e76570a5e90e7e2d16ca5f6baade73d9 Mon Sep 17 00:00:00 2001 From: ToastedToast Date: Sat, 13 Jul 2024 21:54:46 +0800 Subject: [PATCH 11/37] chore: fix errors --- api/db/queries/guilds.ts | 22 ++++++++++++++++------ api/index.ts | 5 +++-- bot/commands.ts | 23 ++++++++--------------- bot/index.ts | 5 +++-- bot/utils/handleLevelChange.ts | 2 +- eslint.config.mjs | 12 ++++++------ 6 files changed, 37 insertions(+), 32 deletions(-) diff --git a/api/db/queries/guilds.ts b/api/db/queries/guilds.ts index 297fa00..c0eb8a7 100644 --- a/api/db/queries/guilds.ts +++ b/api/db/queries/guilds.ts @@ -25,22 +25,20 @@ export async function updateGuild(guild: Guild): Promise<[QueryError | null, nul return new Promise((resolve, reject) => { pool.query( ` - INSERT INTO guilds (id, name, icon, members, updates_enabled, updates_channel) + INSERT INTO guilds (id, name, icon, members, cooldown) VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE name = VALUES(name), icon = VALUES(icon), members = VALUES(members), - updates_enabled = VALUES(updates_enabled), - updates_channel = VALUES(updates_channel) + cooldown = VALUES(cooldown) `, [ guild.id, guild.name, guild.icon, guild.members, - guild.updates_enabled, - guild.updates_channel, + guild.cooldown ], (err, results) => { console.dir(results, { depth: null }); @@ -54,6 +52,18 @@ export async function updateGuild(guild: Guild): Promise<[QueryError | null, nul }); } +export async function setCooldown(guildId: string, cooldown: number): Promise<[QueryError, null] | [null, Guild]> { + return new Promise((resolve, reject) => { + pool.query("UPDATE guilds SET cooldown = ? WHERE id = ?", [cooldown, guildId], (err, results) => { + if (err) { + reject([err, null]); + } else { + resolve([null, (results as Guild[])[0]]); + } + }); + }) +} + interface BotInfo { total_guilds: number; total_members: number; @@ -93,7 +103,7 @@ export async function getUsersCount(): Promise<[QueryError | null, number]> { if (err) { reject([err, null]); } else { - resolve([null, (results[0] as { count: number }).count]); + resolve([null, (results as { count: number }[])[0].count]); } }); }); diff --git a/api/index.ts b/api/index.ts index 8c7855e..80b16ad 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, getUpdates, enableUpdates, disableUpdates } from "./db"; +import { getBotInfo, getGuild, getUser, getUsers, initTables, pool, updateGuild, getUpdates, enableUpdates, disableUpdates, setCooldown } from "./db"; const app = express(); const PORT = 18103; @@ -269,7 +269,7 @@ app.post("/admin/:action/:guild/:target", authMiddleware, async (req, res) => { } case "set": try { - const data = await adminCooldownSet(guild, extraData.cooldown); + const data = await setCooldown(guild, extraData.cooldown); return res.status(200).json(data); } catch (error) { return res.status(500).json({ message: "Internal server error" }); @@ -303,6 +303,7 @@ app.get("/leaderboard/:guild", async (req, res) => { app.get("/", async (_req, res) => { // TODO: handle error + // eslint-disable-next-line @typescript-eslint/no-unused-vars const [err, botInfo] = await getBotInfo(); res.render("index", { botInfo }); }); diff --git a/bot/commands.ts b/bot/commands.ts index 795b480..01fb3d0 100644 --- a/bot/commands.ts +++ b/bot/commands.ts @@ -1,7 +1,7 @@ // Commands taken from https://github.com/NiaAxern/discord-youtube-subscriber-count/blob/main/src/commands/utilities.ts import client from '.'; -import { ActionRowBuilder, ButtonBuilder, ButtonStyle, type CommandInteraction, ChannelType } from 'discord.js'; +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, type CommandInteraction, ChannelType, type APIApplicationCommandOption } from 'discord.js'; import { heapStats } from 'bun:jsc'; import { getGuildLeaderboard, makeGETRequest, getRoles, removeRole, addRole, enableUpdates, disableUpdates, getCooldown, setCooldown, checkIfGuildHasUpdatesEnabled } from './utils/requestAPI'; import convertToLevels from './utils/convertToLevels'; @@ -9,7 +9,7 @@ import quickEmbed from './utils/quickEmbed'; interface Command { data: { - options: any[]; + options: APIApplicationCommandOption[]; name: string; description: string; integration_types: number[]; @@ -27,7 +27,7 @@ const commands: Record = { integration_types: [0, 1], contexts: [0, 1, 2], }, - execute: async (interaction: { reply: (arg0: { ephemeral: boolean; content: string; }) => Promise; client: { ws: { ping: any; }; }; }) => { + execute: async (interaction) => { await interaction .reply({ ephemeral: false, @@ -44,7 +44,7 @@ const commands: Record = { integration_types: [0, 1], contexts: [0, 1, 2], }, - execute: async (interaction: { reply: (arg0: { ephemeral: boolean; content: string; }) => Promise; }) => { + execute: async (interaction) => { await client.application?.commands?.fetch().catch(console.error); const chat_commands = client.application?.commands.cache.map((a) => { return `: ${a.description}`; @@ -65,7 +65,7 @@ const commands: Record = { integration_types: [0, 1], contexts: [0, 1, 2], }, - execute: async (interaction: { reply: (arg0: { ephemeral: boolean; content: string; }) => Promise; }) => { + execute: async (interaction) => { await interaction .reply({ ephemeral: true, @@ -82,7 +82,7 @@ const commands: Record = { integration_types: [0, 1], contexts: [0, 1, 2], }, - execute: async (interaction: { reply: (arg0: { ephemeral: boolean; content: string; }) => Promise; }) => { + execute: async (interaction) => { await interaction .reply({ ephemeral: false, @@ -101,7 +101,7 @@ const commands: Record = { integration_types: [0, 1], contexts: [0, 1, 2], }, - execute: async (interaction: { reply: (arg0: { ephemeral: boolean; content: string; }) => Promise; }) => { + execute: async (interaction) => { const heap = heapStats(); Bun.gc(false); await interaction @@ -205,7 +205,7 @@ const commands: Record = { }, interaction); // Add a field for each user with a mention - leaderboard.leaderboard.forEach((entry: { user_id: any; xp: any; }, index: number) => { + leaderboard.leaderboard.forEach((entry: { user_id: string; xp: number; }, index: number) => { leaderboardEmbed.addFields([ { name: `${index + 1}.`, @@ -273,7 +273,6 @@ const commands: Record = { options: [ { name: 'action', - id: 'action', description: 'Select an action', type: 3, required: true, @@ -294,15 +293,12 @@ const commands: Record = { }, { name: 'role', - id: 'role', description: 'Enter the role name. Required for add and remove actions.', type: 8, required: false, - choices: [] }, { name: 'level', - id: 'level', description: 'Enter the level. Required for add action.', type: 4, required: false, @@ -373,7 +369,6 @@ const commands: Record = { data: { options: [{ name: 'action', - id: 'action', description: 'Note that enabling is in THIS channel and will override the current updates channel!', type: 3, required: true, @@ -450,7 +445,6 @@ const commands: Record = { data: { options: [{ name: 'action', - id: 'action', description: 'Select an action', type: 3, required: true, @@ -466,7 +460,6 @@ const commands: Record = { ] },{ name: 'cooldown', - id: 'cooldown', description: 'Enter the cooldown in seconds. Required for set action.', type: 4, required: false, diff --git a/bot/index.ts b/bot/index.ts index 69f2cc3..2e39eef 100644 --- a/bot/index.ts +++ b/bot/index.ts @@ -22,14 +22,15 @@ const rest = new REST().setToken(discordToken) const getAppId: {id?: string | null} = await rest.get(Routes.currentApplication()) || { id: null } if (!getAppId?.id) throw 'No application ID was able to be found with this token' -const data: any = await rest.put( +const data = await rest.put( Routes.applicationCommands(getAppId.id), { body: [...commandsMap.values()].map((a) => { return a.data; }), }, -); + // eslint-disable-next-line @typescript-eslint/no-explicit-any +) as any[]; console.log( `Successfully reloaded ${data.length} application (/) commands.`, diff --git a/bot/utils/handleLevelChange.ts b/bot/utils/handleLevelChange.ts index f8ebb29..b110ddc 100644 --- a/bot/utils/handleLevelChange.ts +++ b/bot/utils/handleLevelChange.ts @@ -2,7 +2,7 @@ import type { TextChannel } from "discord.js"; import client from ".."; -export default async function(guild, user, level) { +export default async function(guild: string, user: string, level: number) { const hasUpdates = await checkIfGuildHasUpdatesEnabled(guild); if (!hasUpdates.enabled) return; diff --git a/eslint.config.mjs b/eslint.config.mjs index dd68544..a105b08 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,10 +1,10 @@ // @ts-check -import eslint from '@eslint/js'; -import tseslint from 'typescript-eslint'; +import eslint from "@eslint/js"; +import tseslint from "typescript-eslint"; export default tseslint.config( - eslint.configs.recommended, - ...tseslint.configs.recommended, - ...tseslint.configs.stylistic, -); \ No newline at end of file + eslint.configs.recommended, + ...tseslint.configs.recommended, + ...tseslint.configs.stylistic, +); From 01c33639b43f0fa3835cca7509ed739f642ad463 Mon Sep 17 00:00:00 2001 From: GalvinPython <77013913+GalvinPython@users.noreply.github.com> Date: Sat, 13 Jul 2024 15:27:18 +0100 Subject: [PATCH 12/37] fix(errors): now show error page on site though this needs updating --- api/index.ts | 22 ++++++++++++++++------ api/views/error.ejs | 34 ++++++++++++++++++++++++++++++++++ bot/index.ts | 7 +++---- 3 files changed, 53 insertions(+), 10 deletions(-) create mode 100644 api/views/error.ejs diff --git a/api/index.ts b/api/index.ts index 80b16ad..46ee10b 100644 --- a/api/index.ts +++ b/api/index.ts @@ -252,7 +252,7 @@ app.post("/admin/:action/:guild/:target", authMiddleware, async (req, res) => { return res.status(400).json({ message: "Illegal request" }); } - if(target === "set" && !extraData) { + if (target === "set" && !extraData) { return res.status(400).json({ message: "Illegal request" }); } @@ -260,7 +260,7 @@ app.post("/admin/:action/:guild/:target", authMiddleware, async (req, res) => { case "get": try { const [err, data] = await getGuild(guild); - if(err) { + if (err) { return res.status(500).json({ message: "Internal server error" }); } return res.status(200).json({ cooldown: data?.cooldown ?? 30_000 }); @@ -287,12 +287,16 @@ app.get("/leaderboard/:guild", async (req, res) => { const [guildErr, guildData] = await getGuild(guild); const [usersErr, usersData] = await getUsers(guild); + if (!guildData) { + return res.status(404).render("error", { error: { status: 404, message: "The guild does not exist" } }); + } + if (guildErr) { console.error("Error fetching guild:", guildErr); - res.status(500).json({ message: "Internal server error" }); + res.status(500).render("error", { error: { status: 500, message: "Internal server error whilst trying to fetch guild info. Or the guild does not exist" } }); } else if (usersErr) { console.error("Error fetching users:", usersErr); - res.status(500).json({ message: "Internal server error" }); + res.status(500).render("error", { error: { status: 500, message: "Internal server error whilst trying to fetch user info" } }); } res.render("leaderboard", { @@ -302,12 +306,18 @@ app.get("/leaderboard/:guild", async (req, res) => { }); app.get("/", async (_req, res) => { - // TODO: handle error - // eslint-disable-next-line @typescript-eslint/no-unused-vars const [err, botInfo] = await getBotInfo(); + if (err) { + console.error("Error fetching bot info:", err); + res.status(500).render("error", { error: { status: 500, message: "Internal server error whilst trying to fetch bot info" } }); + } res.render("index", { botInfo }); }); +app.use((_req, res) => { + res.status(404).render("error", { error: { status: 404, message: "Page doesn't exist" } }); +}); + app.get("/invite", (_req, res) => res .status(308) diff --git a/api/views/error.ejs b/api/views/error.ejs new file mode 100644 index 0000000..1207c7b --- /dev/null +++ b/api/views/error.ejs @@ -0,0 +1,34 @@ + + + + + + + An error occurred! + + + + + + +
+
+
+

An error occurred! Status: <%= error.status %> +

+
+
+
+
+
+

Message

+

+ <%= error.message %> +

+
+
+
+
+ + + \ No newline at end of file diff --git a/bot/index.ts b/bot/index.ts index 2e39eef..bf08588 100644 --- a/bot/index.ts +++ b/bot/index.ts @@ -4,7 +4,7 @@ const discordToken: string | undefined = process.argv.includes('--dev') ? proces if (!discordToken || discordToken === 'YOUR_TOKEN_HERE') throw 'You MUST provide a discord token in .env!' // If it has, run the bot -import { Client, GatewayIntentBits, REST, Routes } from 'discord.js'; +import { Client, GatewayIntentBits, REST, Routes, type APIApplicationCommand } from 'discord.js'; import commandsMap from './commands'; import fs from 'fs/promises'; @@ -19,7 +19,7 @@ const client = new Client({ // Update the commands console.log(`Refreshing ${commandsMap.size} commands`) const rest = new REST().setToken(discordToken) -const getAppId: {id?: string | null} = await rest.get(Routes.currentApplication()) || { id: null } +const getAppId: { id?: string | null } = await rest.get(Routes.currentApplication()) || { id: null } if (!getAppId?.id) throw 'No application ID was able to be found with this token' const data = await rest.put( @@ -29,8 +29,7 @@ const data = await rest.put( return a.data; }), }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -) as any[]; +) as APIApplicationCommand[]; console.log( `Successfully reloaded ${data.length} application (/) commands.`, From d7dc9fcd36581af5c0b3e85534c0da8521c19969 Mon Sep 17 00:00:00 2001 From: ToastedToast Date: Sun, 14 Jul 2024 09:36:47 +0800 Subject: [PATCH 13/37] feat(bot): add rankcard --- bot/commands.ts | 118 +++++++++++++++++++++++++++++++++++++----------- bot/types.d.ts | 3 ++ bun.lockb | Bin 97008 -> 144956 bytes package.json | 2 + 4 files changed, 96 insertions(+), 27 deletions(-) create mode 100644 bot/types.d.ts diff --git a/bot/commands.ts b/bot/commands.ts index 01fb3d0..3e60a93 100644 --- a/bot/commands.ts +++ b/bot/commands.ts @@ -1,11 +1,15 @@ // Commands taken from https://github.com/NiaAxern/discord-youtube-subscriber-count/blob/main/src/commands/utilities.ts import client from '.'; -import { ActionRowBuilder, ButtonBuilder, ButtonStyle, type CommandInteraction, ChannelType, type APIApplicationCommandOption } from 'discord.js'; +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, checkIfGuildHasUpdatesEnabled } from './utils/requestAPI'; import convertToLevels from './utils/convertToLevels'; import quickEmbed from './utils/quickEmbed'; +import { Font, RankCardBuilder } from 'canvacord'; +import { getColor } from 'colorthief' + +Font.loadDefault(); interface Command { data: { @@ -129,29 +133,87 @@ const commands: Record = { contexts: [0, 2], }, execute: async (interaction) => { - if (interaction?.guildId) { - const guild = interaction.guild?.id - const user = interaction.user.id - const xp = await makeGETRequest(guild as string, user) - - if (!xp) { - await interaction.reply({ - ephemeral: true, - content: "No XP data available." - }); - return; - } - - const progress = xp.user_progress_next_level; - const progressBar = createProgressBar(progress); + const guild = interaction.guild?.id + const user = interaction.user.id + const xp = await makeGETRequest(guild as string, user) + if (!xp) { await interaction.reply({ - embeds: [ + ephemeral: true, + content: "No XP data available." + }); + return; + } + + const card = new RankCardBuilder() + .setDisplayName((interaction.member as GuildMember).displayName) + .setAvatar(interaction.user.displayAvatarURL()) // user avatar + .setCurrentXP(300) // current xp + .setRequiredXP(600) // required xp + .setLevel(2) // user level + .setRank(5) // user rank + .setOverlay(90) // overlay percentage. Overlay is a semi-transparent layer on top of the background + .setBackground("#23272a") + + if (interaction.user.discriminator !== "0") { + card.setUsername("#" + interaction.user.discriminator) + } else { + card.setUsername("@" + interaction.user.username) + } + + const color = await getColor(interaction.user.displayAvatarURL({ extension: "png" })); + card.setStyles({ + progressbar: { + thumb: { + style: { + backgroundColor: `rgb(${color[0]}, ${color[1]}, ${color[2]})` + } + } + } + }) + + const image = await card.build({ + format: "png" + }); + const attachment = new AttachmentBuilder(image, { name: `${user}.png` }); + + const msg = await interaction.reply({ + files: [attachment], + components: [ + new ActionRowBuilder().setComponents( + new ButtonBuilder() + .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: [ quickEmbed( { color: 'Blurple', title: 'XP', - description: `<@${user}> you have ${xp.xp.toLocaleString("en-US")} XP! (Level ${convertToLevels(xp.xp).toLocaleString("en-US")})`, + description: `<@${user}> you have ${xp.xp} XP! (Level ${convertToLevels(xp.xp)})`, }, interaction ).addFields([ @@ -162,18 +224,20 @@ const commands: Record = { }, { name: 'XP Required', - value: `${xp.user_xp_needed_next_level.toLocaleString("en-US")} XP`, + value: `${xp.xp_needed_next_level} XP`, inline: true, }, ]), ], - }); - - function createProgressBar(progress: number): string { - const filled = Math.floor(progress / 10); - const empty = 10 - filled; - return 'â–°'.repeat(filled) + 'â–±'.repeat(empty); - } + files: [], + components: [] + }) + }) + + function createProgressBar(progress: number): string { + const filled = Math.floor(progress / 10); + const empty = 10 - filled; + return 'â–°'.repeat(filled) + 'â–±'.repeat(empty); } } }, @@ -487,7 +551,7 @@ const commands: Record = { const action = interaction.options.get('action')?.value; const cooldown = interaction.options.get('cooldown')?.value; - + let cooldownData; let apiSuccess; diff --git a/bot/types.d.ts b/bot/types.d.ts new file mode 100644 index 0000000..172a3b6 --- /dev/null +++ b/bot/types.d.ts @@ -0,0 +1,3 @@ +declare module "colorthief" { + function getColor(url: string): Promise<[number, number, number]>; +} diff --git a/bun.lockb b/bun.lockb index 3b811406317c72ac757d72505b0476a2eccb085a..0fce008178e990e9d3c82b4bfaf719ae4efc29e2 100755 GIT binary patch delta 44255 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 Y?QeH1;F9Lqw$;WXBc8pn&FCEU|K-ZKx&QzG delta 15263 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 diff --git a/package.json b/package.json index 396b6c2..c9f2d8d 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ "typescript": "^5.0.0" }, "dependencies": { + "canvacord": "^6.0.2", + "colorthief": "^2.4.0", "cors": "^2.8.5", "discord.js": "^14.15.3", "ejs": "^3.1.10", From 79cd22fbffd879c36c686011c1f73ad37edfb5f4 Mon Sep 17 00:00:00 2001 From: ToastedToast Date: Sun, 14 Jul 2024 15:36:59 +0800 Subject: [PATCH 14/37] fix(commands): readd `.toLocaleString()` for text mode in `/xp` --- bot/commands.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/commands.ts b/bot/commands.ts index 3e60a93..539fd84 100644 --- a/bot/commands.ts +++ b/bot/commands.ts @@ -213,7 +213,7 @@ const commands: Record = { { color: 'Blurple', title: 'XP', - description: `<@${user}> you have ${xp.xp} XP! (Level ${convertToLevels(xp.xp)})`, + description: `<@${user}> you have ${xp.xp.toLocaleString()} XP! (Level ${convertToLevels(xp.xp)})`, }, interaction ).addFields([ From bf4e796fd36064402a30ebb50c08e5d97ef1de5d Mon Sep 17 00:00:00 2001 From: ToastedToast Date: Sun, 14 Jul 2024 15:40:45 +0800 Subject: [PATCH 15/37] fix(xp): use highest role color for progress bar --- bot/commands.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bot/commands.ts b/bot/commands.ts index 539fd84..9dc63c5 100644 --- a/bot/commands.ts +++ b/bot/commands.ts @@ -161,13 +161,12 @@ const commands: Record = { card.setUsername("@" + interaction.user.username) } - const color = await getColor(interaction.user.displayAvatarURL({ extension: "png" })); card.setStyles({ progressbar: { thumb: { style: { - backgroundColor: `rgb(${color[0]}, ${color[1]}, ${color[2]})` - } + backgroundColor: (interaction.member as GuildMember).roles.highest.hexColor ?? "#ffffff" + } } } }) From 41124d7be497a4166aafbb40a5c45d2dae229fc5 Mon Sep 17 00:00:00 2001 From: ToastedToast Date: Sun, 14 Jul 2024 15:42:53 +0800 Subject: [PATCH 16/37] chore: remove unused imports --- bot/commands.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/commands.ts b/bot/commands.ts index 9dc63c5..15d43f5 100644 --- a/bot/commands.ts +++ b/bot/commands.ts @@ -7,7 +7,6 @@ import { getGuildLeaderboard, makeGETRequest, getRoles, removeRole, addRole, ena import convertToLevels from './utils/convertToLevels'; import quickEmbed from './utils/quickEmbed'; import { Font, RankCardBuilder } from 'canvacord'; -import { getColor } from 'colorthief' Font.loadDefault(); From c059fdd7efff2044abdf6a4727ace16909e999f8 Mon Sep 17 00:00:00 2001 From: ToastedToast Date: Sun, 14 Jul 2024 17:20:12 +0800 Subject: [PATCH 17/37] fix(commands): readd `.toLocaleString()` for progress in /xp --- bot/commands.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/commands.ts b/bot/commands.ts index 15d43f5..10fedd9 100644 --- a/bot/commands.ts +++ b/bot/commands.ts @@ -222,7 +222,7 @@ const commands: Record = { }, { name: 'XP Required', - value: `${xp.xp_needed_next_level} XP`, + value: `${xp.xp_needed_next_level.toLocaleString()} XP`, inline: true, }, ]), From ff76fbf367cce35f3b2066ac730075f83fb8bec1 Mon Sep 17 00:00:00 2001 From: ToastedToast Date: Sun, 14 Jul 2024 17:30:14 +0800 Subject: [PATCH 18/37] feat(commands): allow specifying a different user for /xp --- bot/commands.ts | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/bot/commands.ts b/bot/commands.ts index 10fedd9..0ff7d97 100644 --- a/bot/commands.ts +++ b/bot/commands.ts @@ -125,15 +125,22 @@ const commands: Record = { }, xp: { data: { - options: [], + options: [{ + name: 'user', + description: 'The user you want to check the XP of.', + type: 6, + required: false, + }], name: 'xp', description: 'Get your XP and Points', integration_types: [0], contexts: [0, 2], }, execute: async (interaction) => { + const optionUser = interaction.options.get('user')?.value as string | null; + const member = (optionUser ? interaction.guild!.members.cache.get(optionUser) : interaction.member) as GuildMember; const guild = interaction.guild?.id - const user = interaction.user.id + const user = member.id; const xp = await makeGETRequest(guild as string, user) if (!xp) { @@ -145,8 +152,8 @@ const commands: Record = { } const card = new RankCardBuilder() - .setDisplayName((interaction.member as GuildMember).displayName) - .setAvatar(interaction.user.displayAvatarURL()) // user avatar + .setDisplayName(member.displayName) + .setAvatar(member.displayAvatarURL()) // user avatar .setCurrentXP(300) // current xp .setRequiredXP(600) // required xp .setLevel(2) // user level @@ -155,16 +162,16 @@ const commands: Record = { .setBackground("#23272a") if (interaction.user.discriminator !== "0") { - card.setUsername("#" + interaction.user.discriminator) + card.setUsername("#" + member.user.discriminator) } else { - card.setUsername("@" + interaction.user.username) + card.setUsername("@" + member.user.username) } card.setStyles({ progressbar: { thumb: { style: { - backgroundColor: (interaction.member as GuildMember).roles.highest.hexColor ?? "#ffffff" + backgroundColor: member.roles.highest.hexColor ?? "#ffffff" } } } From ddb560b322e0709560a6b3fe672231e7e2e09c39 Mon Sep 17 00:00:00 2001 From: ToastedToast Date: Sun, 14 Jul 2024 17:48:55 +0800 Subject: [PATCH 19/37] feat(rankcard): use banner for background if any --- bot/commands.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/bot/commands.ts b/bot/commands.ts index 0ff7d97..0595a88 100644 --- a/bot/commands.ts +++ b/bot/commands.ts @@ -137,8 +137,11 @@ const commands: Record = { contexts: [0, 2], }, execute: async (interaction) => { + await interaction.deferReply() + const optionUser = interaction.options.get('user')?.value as string | null; const member = (optionUser ? interaction.guild!.members.cache.get(optionUser) : interaction.member) as GuildMember; + await interaction.guild!.members.fetch({ user: member.id, force: true }) const guild = interaction.guild?.id const user = member.id; const xp = await makeGETRequest(guild as string, user) @@ -153,13 +156,13 @@ const commands: Record = { const card = new RankCardBuilder() .setDisplayName(member.displayName) - .setAvatar(member.displayAvatarURL()) // user avatar + .setAvatar(member.displayAvatarURL({ forceStatic: true, size: 4096 })) // user avatar .setCurrentXP(300) // current xp .setRequiredXP(600) // required xp .setLevel(2) // user level .setRank(5) // user rank - .setOverlay(90) // overlay percentage. Overlay is a semi-transparent layer on top of the background - .setBackground("#23272a") + .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) @@ -174,7 +177,7 @@ const commands: Record = { backgroundColor: member.roles.highest.hexColor ?? "#ffffff" } } - } + }, }) const image = await card.build({ @@ -182,7 +185,7 @@ const commands: Record = { }); const attachment = new AttachmentBuilder(image, { name: `${user}.png` }); - const msg = await interaction.reply({ + const msg = await interaction.followUp({ files: [attachment], components: [ new ActionRowBuilder().setComponents( From 80af83dc6aa57e927f42137675ca5f2a057ae2dd Mon Sep 17 00:00:00 2001 From: ToastedToast Date: Sun, 14 Jul 2024 17:59:36 +0800 Subject: [PATCH 20/37] fix(rankcard): use actual data --- bot/commands.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/bot/commands.ts b/bot/commands.ts index 0595a88..aff0382 100644 --- a/bot/commands.ts +++ b/bot/commands.ts @@ -144,9 +144,10 @@ const commands: Record = { await interaction.guild!.members.fetch({ user: member.id, force: true }) const guild = interaction.guild?.id const user = member.id; + const leaderboard = await getGuildLeaderboard(guild as string); const xp = await makeGETRequest(guild as string, user) - if (!xp) { + if (!xp || leaderboard.length === 0) { await interaction.reply({ ephemeral: true, content: "No XP data available." @@ -154,13 +155,15 @@ 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(300) // current xp - .setRequiredXP(600) // required xp - .setLevel(2) // user level - .setRank(5) // user rank + .setCurrentXP(xp.xp) // current xp + .setRequiredXP(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") @@ -170,11 +173,13 @@ const commands: Record = { card.setUsername("@" + member.user.username) } + const color = member.roles.highest.hexColor ?? "#ffffff" + card.setStyles({ progressbar: { thumb: { style: { - backgroundColor: member.roles.highest.hexColor ?? "#ffffff" + backgroundColor: color } } }, @@ -219,7 +224,7 @@ const commands: Record = { embeds: [ quickEmbed( { - color: 'Blurple', + color, title: 'XP', description: `<@${user}> you have ${xp.xp.toLocaleString()} XP! (Level ${convertToLevels(xp.xp)})`, }, From f028869dfcb1002fdb647e063971d1390410cd11 Mon Sep 17 00:00:00 2001 From: ToastedToast Date: Sun, 14 Jul 2024 18:01:28 +0800 Subject: [PATCH 21/37] feat(commands): add rank to text mode in `/xp` --- bot/commands.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/commands.ts b/bot/commands.ts index aff0382..475dc4c 100644 --- a/bot/commands.ts +++ b/bot/commands.ts @@ -230,6 +230,10 @@ const commands: Record = { }, interaction ).addFields([ + { + name: 'Rank', + value: `#${rank.toLocaleString()}`, + }, { name: 'Progress To Next Level', value: `${progressBar} ${progress}%`, From c7c537665f10091efe08b2cddd8c61420743e54d Mon Sep 17 00:00:00 2001 From: ToastedToast Date: Sun, 14 Jul 2024 20:33:21 +0800 Subject: [PATCH 22/37] feat(updates): refactor --- api/db/init.ts | 20 ++------ api/db/queries/guilds.ts | 11 +++-- api/db/queries/updates.ts | 46 +++++++++---------- api/index.ts | 31 +++++++++---- bot/commands.ts | 83 ++++++++++++++++++++++++++++------ bot/events/messageCreate.ts | 4 +- bot/utils/handleLevelChange.ts | 24 ++-------- bot/utils/requestAPI.ts | 21 +++++++-- 8 files changed, 145 insertions(+), 95 deletions(-) diff --git a/api/db/init.ts b/api/db/init.ts index 7ae8380..2f67f22 100644 --- a/api/db/init.ts +++ b/api/db/init.ts @@ -7,7 +7,9 @@ export async function initTables() { name VARCHAR(255), icon VARCHAR(255), members INT, - cooldown INT DEFAULT 30000 + cooldown INT DEFAULT 30000, + updates_enabled BOOLEAN DEFAULT FALSE, + updates_channel_id VARCHAR(255) DEFAULT NULL ) `; const createUsersTable = ` @@ -34,14 +36,6 @@ export async function initTables() { ) `; // FOREIGN KEY (guild_id) REFERENCES guilds(id) - const createUpdatesTable = ` - CREATE TABLE IF NOT EXISTS updates ( - guild_id VARCHAR(255) NOT NULL PRIMARY KEY, - channel_id VARCHAR(255) NOT NULL, - enabled BOOLEAN DEFAULT FALSE - ) - `; - // FOREIGN KEY (guild_id) REFERENCES guilds(id) pool.query(createGuildsTable, (err) => { if (err) { @@ -66,12 +60,4 @@ export async function initTables() { console.log("Roles table created"); } }); - - pool.query(createUpdatesTable, (err) => { - if (err) { - console.error("Error creating updates table:", err); - } else { - console.log("Updates table created"); - } - }); } diff --git a/api/db/queries/guilds.ts b/api/db/queries/guilds.ts index c0eb8a7..d1f7bcb 100644 --- a/api/db/queries/guilds.ts +++ b/api/db/queries/guilds.ts @@ -7,8 +7,11 @@ export interface Guild { icon: string; members: number; cooldown: number; + updates_enabled: 0 | 1; + updates_channel_id: string | null; } + export async function getGuild(guildId: string): Promise<[QueryError, null] | [null, Guild | null]> { return new Promise((resolve, reject) => { pool.query("SELECT * FROM guilds WHERE id = ?", [guildId], (err, results) => { @@ -21,24 +24,22 @@ export async function getGuild(guildId: string): Promise<[QueryError, null] | [n }); } -export async function updateGuild(guild: Guild): Promise<[QueryError | null, null] | [null, Guild[]]> { +export async function updateGuild(guild: Omit): Promise<[QueryError | null, null] | [null, Guild[]]> { return new Promise((resolve, reject) => { pool.query( ` - INSERT INTO guilds (id, name, icon, members, cooldown) + INSERT INTO guilds (id, name, icon, members) VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE name = VALUES(name), icon = VALUES(icon), - members = VALUES(members), - cooldown = VALUES(cooldown) + members = VALUES(members) `, [ guild.id, guild.name, guild.icon, guild.members, - guild.cooldown ], (err, results) => { console.dir(results, { depth: null }); diff --git a/api/db/queries/updates.ts b/api/db/queries/updates.ts index 5fc33e0..80bfdbc 100644 --- a/api/db/queries/updates.ts +++ b/api/db/queries/updates.ts @@ -7,36 +7,38 @@ export interface Updates { enabled: boolean; } -export async function getUpdates(guildId: string): Promise<[QueryError | null, Updates[] | null]> { +export async function enableUpdates(guildId: string): Promise<[QueryError | null, boolean]> { return new Promise((resolve, reject) => { - pool.query("SELECT * FROM updates WHERE guild_id = ?", [guildId], (err, results) => { - if (err) { - reject([err, null]); - } else { - resolve([null, results as Updates[]]); - } - }); + pool.query( + ` + UPDATE guilds SET updates_enabled = TRUE WHERE id = ? + `, + [ + guildId, + ], + (err) => { + if (err) { + reject([err, false]); + } else { + resolve([null, true]); + } + }, + ); }); } -export async function enableUpdates(guildId: string, channelId: string): Promise<[QueryError | null, true | null]> { +export async function disableUpdates(guildId: string): Promise<[QueryError | null, boolean]> { return new Promise((resolve, reject) => { pool.query( ` - INSERT INTO updates (guild_id, channel_id, enabled) - VALUES (?, ?, ?) - ON DUPLICATE KEY UPDATE - channel_id = VALUES(channel_id), - enabled = VALUES(enabled) + UPDATE guilds SET updates_enabled = FALSE WHERE id = ? `, [ guildId, - channelId, - true, ], (err) => { if (err) { - reject([err, null]); + reject([err, false]); } else { resolve([null, true]); } @@ -45,21 +47,19 @@ export async function enableUpdates(guildId: string, channelId: string): Promise }); } -export async function disableUpdates(guildId: string): Promise<[QueryError | null, true | null]> { +export async function setUpdatesChannel(guildId: string, channelId: string | null): Promise<[QueryError | null, boolean]> { return new Promise((resolve, reject) => { pool.query( ` - UPDATE updates - SET enabled = ? - WHERE guild_id = ? + UPDATE guilds SET updates_channel_id = ? WHERE id = ? `, [ - false, + channelId, guildId, ], (err) => { if (err) { - reject([err, null]); + reject([err, false]); } else { resolve([null, true]); } diff --git a/api/index.ts b/api/index.ts index 46ee10b..63925d3 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, getUpdates, enableUpdates, disableUpdates, setCooldown } from "./db"; +import { getBotInfo, getGuild, getUser, getUsers, initTables, pool, updateGuild, enableUpdates, disableUpdates, setCooldown, setUpdatesChannel } from "./db"; const app = express(); const PORT = 18103; @@ -34,7 +34,6 @@ app.post("/post/:guild", authMiddleware, async (req, res) => { name, icon, members, - cooldown: 30_000, }); if (err) { @@ -57,6 +56,7 @@ app.post("/post/:guild/:user", authMiddleware, async (req, res) => { return res.status(500).json({ message: "Internal server error" }); } + const currentXp = result?.xp ?? 0; const currentLevelSaved = result?.level ?? 0; const newXp = currentXp + xpValue; @@ -168,15 +168,12 @@ app.post("/admin/:action/:guild/:target", authMiddleware, async (req, res) => { // run function to exclude target from guild break; case "updates": - if (target !== "enable" && target !== "disable" && target !== "get") { + if (target !== "enable" && target !== "disable" && target !== "set" && target !== "get") { return res.status(400).json({ message: "Illegal request" }); } switch (target) { case "enable": - if (!extraData || !extraData.channelId) { - return res.status(400).json({ message: "Illegal request" }); - } try { const [err, success] = await enableUpdates(guild, extraData.channelId); if (err) { @@ -198,13 +195,31 @@ app.post("/admin/:action/:guild/:target", authMiddleware, async (req, res) => { } catch (err) { return res.status(500).json({ message: "Internal server error", err }); } + case 'set': + if (!extraData || typeof extraData.channelId === "undefined") { + return res.status(400).json({ message: "Illegal request" }); + } + + try { + const [err, success] = await setUpdatesChannel(guild, extraData.channelId); + 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: try { - const [err, data] = await getUpdates(guild); + const [err, data] = await getGuild(guild); if (err) { return res.status(500).json({ message: "Internal server error", err }); } - return res.status(200).json(data); + return res.status(200).json({ + enabled: ((data?.updates_enabled ?? 1) === 1), + channel: data?.updates_channel_id ?? null, + }); } catch (error) { return res.status(500).json({ message: "Internal server error" }); } diff --git a/bot/commands.ts b/bot/commands.ts index 475dc4c..94bff3b 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, checkIfGuildHasUpdatesEnabled } from './utils/requestAPI'; +import { getGuildLeaderboard, makeGETRequest, getRoles, removeRole, addRole, enableUpdates, disableUpdates, getCooldown, setCooldown, getUpdatesChannel, setUpdatesChannel } from './utils/requestAPI'; import convertToLevels from './utils/convertToLevels'; import quickEmbed from './utils/quickEmbed'; import { Font, RankCardBuilder } from 'canvacord'; @@ -161,7 +161,7 @@ const commands: Record = { .setDisplayName(member.displayName) .setAvatar(member.displayAvatarURL({ forceStatic: true, size: 4096 })) // user avatar .setCurrentXP(xp.xp) // current xp - .setRequiredXP(xp.xp_needed_next_level) // required 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 @@ -450,24 +450,37 @@ const commands: Record = { data: { options: [{ name: 'action', - description: 'Note that enabling is in THIS channel and will override the current updates channel!', + description: 'Select an action', type: 3, required: true, choices: [ { - name: 'check', + name: 'Check', value: 'check', }, { - name: 'enable', + name: 'Enable', value: 'enable', }, { - name: 'disable', + name: 'Disable', value: 'disable', - } + }, + { + name: 'Set', + value: 'set', + }, + { + name: 'Reset to Default', + value: 'reset', + }, ] - },], + },{ + name: 'channel', + description: 'Enter the channel ID. Required for set action.', + type: 7, + required: false, + }], name: 'updates', description: 'Get the latest updates on the bot!', integration_types: [0], @@ -494,6 +507,14 @@ const commands: Record = { let data switch (action) { + case 'enable': + success = await enableUpdates(interaction.guildId as string); + if (!success) { + await interaction.reply({ ephemeral: true, content: 'Error enabling updates for this server' }).catch(console.error); + return; + } + await interaction.reply({ ephemeral: true, content: `Updates are now enabled for this server` }).catch(console.error); + return; case 'disable': success = await disableUpdates(interaction.guildId as string); if (!success) { @@ -502,22 +523,54 @@ const commands: Record = { } await interaction.reply({ ephemeral: true, content: 'Updates are now disabled for this server' }).catch(console.error); return; - case 'enable': - success = await enableUpdates(interaction.guildId as string, channelId as string); + case 'set': + if(!channelId) { + await interaction.reply({ ephemeral: true, content: 'ERROR: Channel was not specified!' }); + return; + } + success = await setUpdatesChannel(interaction.guildId as string, channelId); if (!success) { - await interaction.reply({ ephemeral: true, content: 'Error enabling updates for this server' }).catch(console.error); + await interaction.reply({ ephemeral: true, content: 'Error setting updates channel for this server' }).catch(console.error); return; } - await interaction.reply({ ephemeral: true, content: `Updates are now enabled for this server in <#${channelId}>` }).catch(console.error); + await interaction.reply({ ephemeral: true, content: `Updates channel has been set to <#${channelId}>` }).catch(console.error); return; + case 'reset': + success = await setUpdatesChannel(interaction.guildId as string, null); + if (!success) { + await interaction.reply({ ephemeral: true, content: 'Error resetting updates channel for this server' }).catch(console.error); + return; + } + await interaction.reply({ ephemeral: true, content: `Updates channel has been reset to default` }).catch(console.error); + return default: - data = await checkIfGuildHasUpdatesEnabled(interaction.guildId as string); + data = await getUpdatesChannel(interaction.guildId as string); if (!data || Object.keys(data).length === 0) { await interaction.reply({ ephemeral: true, content: 'No data found' }).catch(console.error); return; } - // TODO: Format in embed - await interaction.reply({ ephemeral: true, content: JSON.stringify(data, null, 2) }).catch(console.error); + await interaction.reply({ + embeds: [ + quickEmbed({ + color: 'Blurple', + title: 'Updates', + description: 'Updates for this server', + }, interaction) + .addFields( + { + name: 'Enabled', + value: data.enabled ? 'Yes' : 'No', + inline: true, + }, + { + name: 'Channel', + value: data.channel ? `<#${data.channel}>` : 'N/A', + inline: true, + }, + ) + ], + ephemeral: true + }).catch(console.error); return; } }, diff --git a/bot/events/messageCreate.ts b/bot/events/messageCreate.ts index e3dafa6..1309d32 100644 --- a/bot/events/messageCreate.ts +++ b/bot/events/messageCreate.ts @@ -9,7 +9,7 @@ client.on('messageCreate', async (message: Message) => { if (message.author.bot) return; const cooldownTime = (await getCooldown(message.guildId as string))?.cooldown ?? 30_000; - + const cooldown = cooldowns.get(message.author.id); if (cooldown && Date.now() - cooldown < cooldownTime) return; @@ -17,7 +17,7 @@ client.on('messageCreate', async (message: Message) => { const pfp: string = message.member?.displayAvatarURL() ?? message.author.displayAvatarURL() const name: string = message.author.username; const nickname: string = message.member?.nickname ?? message.author.globalName ?? message.author.username; - await makePOSTRequest(message.guildId as string, message.author.id, xpToGive, pfp, name, nickname); + await makePOSTRequest(message.guildId as string, message.author.id, message.channel.id, xpToGive, pfp, name, nickname); cooldowns.set(message.author.id, Date.now()); const guildName = message.guild?.name; diff --git a/bot/utils/handleLevelChange.ts b/bot/utils/handleLevelChange.ts index b110ddc..6033330 100644 --- a/bot/utils/handleLevelChange.ts +++ b/bot/utils/handleLevelChange.ts @@ -1,30 +1,14 @@ // import quickEmbed from "./quickEmbed"; import type { TextChannel } from "discord.js"; import client from ".."; +import { getUpdatesChannel } from "./requestAPI"; -export default async function(guild: string, user: string, level: number) { - const hasUpdates = await checkIfGuildHasUpdatesEnabled(guild); +export default async function(guild: string, user: string, channelId: string, level: number) { + const hasUpdates = await getUpdatesChannel(guild); if (!hasUpdates.enabled) return; - const channel = await client.channels.fetch(hasUpdates.channelId) as TextChannel; + const channel = await client.channels.fetch(hasUpdates.channelId ?? channelId) as TextChannel; if (channel) { channel.send(`<@${user}> has reached level ${level}!`); } } - -export async function checkIfGuildHasUpdatesEnabled(guild: string): Promise<{ enabled: boolean, channelId: string }> { - const response = await fetch(`http://localhost:18103/admin/updates/${guild}/get`, { - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ auth: process.env.AUTH }), - method: 'POST', - }); - - const data = await response.json(); - - return { - enabled: data[0].enabled === 1, - channelId: data[0].channel_id, - }; -} \ No newline at end of file diff --git a/bot/utils/requestAPI.ts b/bot/utils/requestAPI.ts index cae942a..c497a00 100644 --- a/bot/utils/requestAPI.ts +++ b/bot/utils/requestAPI.ts @@ -1,6 +1,6 @@ import handleLevelChange from "./handleLevelChange"; -export async function makePOSTRequest(guild: string, user: string, xp: number, pfp: string, name: string, nickname: string) { +export async function makePOSTRequest(guild: string, user: string, channel: string, xp: number, pfp: string, name: string, nickname: string) { await fetch(`http://localhost:18103/post/${guild}/${user}`, { headers: { 'Content-Type': 'application/json', @@ -11,7 +11,7 @@ export async function makePOSTRequest(guild: string, user: string, xp: number, p }).then(res => { return res.json() }).then(data => { - if (data.sendUpdateEvent) handleLevelChange(guild, user, data.level) + if (data.sendUpdateEvent) handleLevelChange(guild, user, channel, data.level) }) } @@ -110,7 +110,7 @@ export async function addRole(guild: string, role: string, level: number): Promi //#endregion //#region Updates -export async function checkIfGuildHasUpdatesEnabled(guild: string) { +export async function getUpdatesChannel(guild: string) { const response = await fetch(`http://localhost:18103/admin/updates/${guild}/get`, { "headers": { 'Content-Type': 'application/json', @@ -121,8 +121,8 @@ export async function checkIfGuildHasUpdatesEnabled(guild: string) { }); return response.status === 200 ? response.json() : {}; } -export async function enableUpdates(guild: string, channelId: string) { - const response = await fetch(`http://localhost:18103/admin/updates/${guild}/enable`, { +export async function setUpdatesChannel(guild: string, channelId: string | null) { + const response = await fetch(`http://localhost:18103/admin/updates/${guild}/set`, { "headers": { 'Content-Type': 'application/json', 'Authorization': process.env.AUTH as string, @@ -132,6 +132,17 @@ export async function enableUpdates(guild: string, channelId: string) { }); return response.status === 200; } +export async function enableUpdates(guild: string) { + const response = await fetch(`http://localhost:18103/admin/updates/${guild}/enable`, { + "headers": { + 'Content-Type': 'application/json', + 'Authorization': process.env.AUTH as string, + }, + "body": JSON.stringify({}), + "method": "POST" + }); + return response.status === 200; +} export async function disableUpdates(guild: string) { const response = await fetch(`http://localhost:18103/admin/updates/${guild}/disable`, { "headers": { From ce93ddd7dfb17f6b89e0d293eecf0dd2ccb51d58 Mon Sep 17 00:00:00 2001 From: ToastedToast Date: Mon, 15 Jul 2024 16:37:07 +0800 Subject: [PATCH 23/37] 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`, { From 17e503217de9c09ba9a5149fbcf360c94b264cb8 Mon Sep 17 00:00:00 2001 From: GalvinPython <77013913+GalvinPython@users.noreply.github.com> Date: Mon, 15 Jul 2024 18:23:21 +0100 Subject: [PATCH 24/37] feat: add ready event --- bot/events/ready.ts | 25 +++++++++++++++++++++++++ bun.lockb | Bin 144956 -> 144956 bytes 2 files changed, 25 insertions(+) create mode 100644 bot/events/ready.ts diff --git a/bot/events/ready.ts b/bot/events/ready.ts new file mode 100644 index 0000000..27891c3 --- /dev/null +++ b/bot/events/ready.ts @@ -0,0 +1,25 @@ +import { ActivityType, Events, PresenceUpdateStatus } from 'discord.js'; +import client from '../index'; + +// update the bot's presence +function updatePresence() { + if (!client?.user) return; + client.user.setPresence({ + activities: [ + { + name: `${client.guilds.cache.size} servers with ${client.guilds.cache.reduce((acc, guild) => acc + guild.memberCount, 0).toLocaleString('en-US')} members.`, + type: ActivityType.Watching, + }, + ], + status: PresenceUpdateStatus.Online, + }); +} + +// Log into the bot +client.once(Events.ClientReady, async (bot) => { + console.log(`Ready! Logged in as ${bot.user?.tag}`); + updatePresence(); +}); + +// Update the server count in the status every minute +setInterval(updatePresence, 60000); \ No newline at end of file diff --git a/bun.lockb b/bun.lockb index 0fce008178e990e9d3c82b4bfaf719ae4efc29e2..e39522ac395536767b3f05a22beb072f50d66b44 100755 GIT binary patch delta 25 hcmdn Date: Mon, 15 Jul 2024 18:54:47 +0100 Subject: [PATCH 25/37] fix(api): updateGuildInfo() now works --- api/db/queries/guilds.ts | 3 +-- bot/events/guildAdd.ts | 12 ++++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) create mode 100644 bot/events/guildAdd.ts diff --git a/api/db/queries/guilds.ts b/api/db/queries/guilds.ts index d1f7bcb..9a27c42 100644 --- a/api/db/queries/guilds.ts +++ b/api/db/queries/guilds.ts @@ -29,7 +29,7 @@ export async function updateGuild(guild: Omit { - console.dir(results, { depth: null }); if (err) { reject([err, null]); } else { diff --git a/bot/events/guildAdd.ts b/bot/events/guildAdd.ts new file mode 100644 index 0000000..b37c0a7 --- /dev/null +++ b/bot/events/guildAdd.ts @@ -0,0 +1,12 @@ +import { Events } from "discord.js"; +import client from "../index"; +import { updateGuildInfo } from "../utils/requestAPI"; + +client.on(Events.GuildCreate, async (guild) => { + try { + await updateGuildInfo(guild.id, guild.name, guild.iconURL() ?? 'https://cdn.discordapp.com/embed/avatars/0.png', guild.memberCount); + console.log(`Joined guild ${guild.name} with ${guild.memberCount} members`); + } catch (e) { + console.error(e); + } +}) \ No newline at end of file From f5cc94f8d26e95e2ef38799bbad5360d298bf09b Mon Sep 17 00:00:00 2001 From: GalvinPython <77013913+GalvinPython@users.noreply.github.com> Date: Mon, 15 Jul 2024 23:05:12 +0100 Subject: [PATCH 26/37] temp delete bun.lockb --- bun.lockb | Bin 144956 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100755 bun.lockb diff --git a/bun.lockb b/bun.lockb deleted file mode 100755 index e39522ac395536767b3f05a22beb072f50d66b44..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 144956 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⊃4;v9j7f&xMCnpgn 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 From 17c340b4b725725a42df19942808833d5841f81c Mon Sep 17 00:00:00 2001 From: GalvinPython <77013913+GalvinPython@users.noreply.github.com> Date: Tue, 16 Jul 2024 10:37:53 +0100 Subject: [PATCH 27/37] readd bun.lockb --- .github/dependabot.yml | 1 + bun.lockb | Bin 0 -> 146572 bytes 2 files changed, 1 insertion(+) create mode 100644 bun.lockb diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 7dddf6e..c32f212 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,3 +6,4 @@ updates: interval: "weekly" assignees: - "GalvinPython" + target-branch: "dev" diff --git a/bun.lockb b/bun.lockb new file mode 100644 index 0000000000000000000000000000000000000000..68e429669017c55ddde4989e1fb95df5c8a2a9a4 GIT binary patch literal 146572 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 Date: Tue, 16 Jul 2024 22:17:27 +0800 Subject: [PATCH 28/37] feat: add ability to sync xp from polaris (wip) --- api/index.ts | 92 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 91 insertions(+), 1 deletion(-) diff --git a/api/index.ts b/api/index.ts index d169605..553f2cb 100644 --- a/api/index.ts +++ b/api/index.ts @@ -175,7 +175,7 @@ app.post("/admin/:action/:guild/:target", authMiddleware, async (req, res) => { switch (target) { case "enable": try { - const [err, success] = await enableUpdates(guild, extraData.channelId); + const [err, success] = await enableUpdates(guild); if (err) { return res.status(500).json({ message: "Internal server error", err }); } else { @@ -328,6 +328,28 @@ app.post("/admin/:action/:guild/:target", authMiddleware, async (req, res) => { return res.status(500).json({ message: "Internal server error" }); } } + case "sync": { + if(target !== "polaris") { + return res.status(400).json({ message: "Illegal request" }); + } + + switch(target) { + case "polaris": { + try { + const [err, success] = await syncFromPolaris(guild); + 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" }); } @@ -434,3 +456,71 @@ async function adminRolesAdd(guild: string, role: string, level: number) { }); } //#endregion + +//#region Syncing +async function syncFromPolaris(guild: string) { + const res = await fetch(`https://gdcolon.com/polaris/api/leaderboard/${guild}`); + const data = await res.json(); + const users = data.leaderboard; + for(let i = 1; i < data.pageInfo.pageCount; i++) { + const res = await fetch(`https://gdcolon.com/polaris/api/leaderboard/${guild}?page=${i + 1}`); + const data = await res.json(); + users.push(...data.leaderboard); + } + + if(users.length === 0) { + return [new Error("No users found"), false]; + } + + console.log(users.length) + + const insertQuery = ` + INSERT INTO users + (id, guild_id, xp, pfp, name, nickname, level, xp_needed_next_level, progress_next_level) + VALUES + `; + + const insertValues: string[] = []; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for(const _user of users) { + insertValues.push(`(?, ?, ?, ?, ?, ?, ?, ?, ?)`); + } + + console.log(insertValues.length) + + const formattedUsers = users.map((user: { id: string, xp: number, avatar: string, username: string, nickname: string, displayName: string }) => { + const xpValue = user.xp; + const level = Math.floor(Math.sqrt(xpValue / 100)); + const nextLevel = level + 1; + const nextLevelXp = Math.pow(nextLevel, 2) * 100; + const xpNeededForNextLevel = nextLevelXp - xpValue; + const currentLevelXp = Math.pow(level, 2) * 100; + const progressToNextLevel = + ((xpValue - currentLevelXp) / (nextLevelXp - currentLevelXp)) * 100; + return [ + user.id, + guild, + xpValue, + user.avatar, + user.username, + user.nickname ?? user.displayName, + level, + xpNeededForNextLevel, + progressToNextLevel.toFixed(2), + ]; + }) + + return new Promise((resolve, reject) => { + pool.query(insertQuery + "\n" + insertValues.join(","), formattedUsers, (err) => + { + if (err) { + console.error("Error syncing from Polaris:", err); + reject([err, false]); + } else { + resolve([null, true]); + } + }); + }); +} +//#endregion From 4bf983d8213b79777a59a00c2368aee5b0cb72cb Mon Sep 17 00:00:00 2001 From: ToastedToast Date: Tue, 16 Jul 2024 22:34:44 +0800 Subject: [PATCH 29/37] fix: syncing from polaris actually works now --- api/index.ts | 87 +++++++++++++++++++++++----------------------------- 1 file changed, 39 insertions(+), 48 deletions(-) diff --git a/api/index.ts b/api/index.ts index 553f2cb..cbcad04 100644 --- a/api/index.ts +++ b/api/index.ts @@ -472,55 +472,46 @@ async function syncFromPolaris(guild: string) { return [new Error("No users found"), false]; } - console.log(users.length) - - const insertQuery = ` - INSERT INTO users - (id, guild_id, xp, pfp, name, nickname, level, xp_needed_next_level, progress_next_level) - VALUES - `; - - const insertValues: string[] = []; - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - for(const _user of users) { - insertValues.push(`(?, ?, ?, ?, ?, ?, ?, ?, ?)`); + try { + for(const user of users) { + const xpValue = user.xp; + const level = Math.floor(Math.sqrt(xpValue / 100)); + const nextLevel = level + 1; + const nextLevelXp = Math.pow(nextLevel, 2) * 100; + const xpNeededForNextLevel = nextLevelXp - xpValue; + const currentLevelXp = Math.pow(level, 2) * 100; + const progressToNextLevel = + ((xpValue - currentLevelXp) / (nextLevelXp - currentLevelXp)) * 100; + + await new Promise((resolve, reject) => { + pool.query( + `INSERT INTO users (id, guild_id, xp, pfp, name, nickname, level, xp_needed_next_level, progress_next_level) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + user.id, + guild, + xpValue, + user.avatar, + user.username, + user.nickname ?? user.displayName, + level, + xpNeededForNextLevel, + progressToNextLevel.toFixed(2), + ], + (err) => { + if (err) { + console.error("Error syncing from Polaris:", err); + reject(err); + } else { + resolve(null); + } + }, + ); + }); + } + return [null, true] + } catch (err) { + return [err, false]; } - console.log(insertValues.length) - - const formattedUsers = users.map((user: { id: string, xp: number, avatar: string, username: string, nickname: string, displayName: string }) => { - const xpValue = user.xp; - const level = Math.floor(Math.sqrt(xpValue / 100)); - const nextLevel = level + 1; - const nextLevelXp = Math.pow(nextLevel, 2) * 100; - const xpNeededForNextLevel = nextLevelXp - xpValue; - const currentLevelXp = Math.pow(level, 2) * 100; - const progressToNextLevel = - ((xpValue - currentLevelXp) / (nextLevelXp - currentLevelXp)) * 100; - return [ - user.id, - guild, - xpValue, - user.avatar, - user.username, - user.nickname ?? user.displayName, - level, - xpNeededForNextLevel, - progressToNextLevel.toFixed(2), - ]; - }) - - return new Promise((resolve, reject) => { - pool.query(insertQuery + "\n" + insertValues.join(","), formattedUsers, (err) => - { - if (err) { - console.error("Error syncing from Polaris:", err); - reject([err, false]); - } else { - resolve([null, true]); - } - }); - }); } //#endregion From e8815e5eed353e3adf54b694225e25b82cdb8db9 Mon Sep 17 00:00:00 2001 From: ToastedToast Date: Tue, 16 Jul 2024 22:51:20 +0800 Subject: [PATCH 30/37] feat(bot): add /sync command --- api/index.ts | 6 +++++ bot/commands.ts | 51 ++++++++++++++++++++++++++++++++++++++++- bot/utils/requestAPI.ts | 14 +++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/api/index.ts b/api/index.ts index cbcad04..4d6b623 100644 --- a/api/index.ts +++ b/api/index.ts @@ -338,6 +338,9 @@ app.post("/admin/:action/:guild/:target", authMiddleware, async (req, res) => { try { const [err, success] = await syncFromPolaris(guild); if (err) { + if(err instanceof Error && err.message === "Server not found in Polaris") { + return res.status(404).json({ message: "Server not found in Polaris" }); + } return res.status(500).json({ message: "Internal server error", err }); } else { return res.status(200).json(success); @@ -461,6 +464,9 @@ async function adminRolesAdd(guild: string, role: string, level: number) { async function syncFromPolaris(guild: string) { const res = await fetch(`https://gdcolon.com/polaris/api/leaderboard/${guild}`); const data = await res.json(); + if(data.apiError && data.code === "invalidServer") { + return [new Error("Server not found in Polaris"), false]; + } const users = data.leaderboard; for(let i = 1; i < data.pageInfo.pageCount; i++) { const res = await fetch(`https://gdcolon.com/polaris/api/leaderboard/${guild}?page=${i + 1}`); diff --git a/bot/commands.ts b/bot/commands.ts index 190d32c..60dcc6c 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, setXP, setLevel } from './utils/requestAPI'; +import { getGuildLeaderboard, makeGETRequest, getRoles, removeRole, addRole, enableUpdates, disableUpdates, getCooldown, setCooldown, getUpdatesChannel, setUpdatesChannel, setXP, setLevel, syncFromPolaris } from './utils/requestAPI'; import convertToLevels from './utils/convertToLevels'; import quickEmbed from './utils/quickEmbed'; import { Font, RankCardBuilder } from 'canvacord'; @@ -721,6 +721,55 @@ const commands: Record = { return; } } + }, + sync: { + data: { + options: [{ + name: 'bot', + description: 'Select the bot to sync XP data from', + type: 3, + required: true, + choices: [ + { + name: 'Polaris', + value: 'polaris', + }, + ] + }], + name: 'sync', + description: 'Sync XP data from another bot!', + 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 bot = interaction.options.get('bot')?.value; + + let apiSuccess; + switch (bot) { + case 'polaris': + apiSuccess = await syncFromPolaris(interaction.guildId as string); + if (!apiSuccess) { + await interaction.reply({ ephemeral: true, content: 'Error syncing data! This might mean that Polaris is not set up for this server, or the leaderboard for this server is not public.' }); + return; + } + await interaction.reply({ ephemeral: true, content: 'Data synced!' }); + return; + } + } } }; diff --git a/bot/utils/requestAPI.ts b/bot/utils/requestAPI.ts index 9945cfb..01a93f5 100644 --- a/bot/utils/requestAPI.ts +++ b/bot/utils/requestAPI.ts @@ -205,3 +205,17 @@ export async function setCooldown(guild: string, cooldown: number) { return response.status === 200; } //#endregion + +//#region Sync +export async function syncFromPolaris(guild: string) { + const response = await fetch(`http://localhost:18103/admin/sync/${guild}/polaris`, { + "headers": { + 'Content-Type': 'application/json', + 'Authorization': process.env.AUTH as string, + }, + "body": JSON.stringify({}), + "method": "POST" + }); + return response.status === 200; +} +//#endregion From 3b06833e39c4093d984937e1f389bfab8a03fa18 Mon Sep 17 00:00:00 2001 From: GalvinPython <77013913+GalvinPython@users.noreply.github.com> Date: Tue, 16 Jul 2024 16:33:07 +0100 Subject: [PATCH 31/37] feat(bot): add GuildDelete handler --- api/db/init.ts | 3 ++- api/db/queries/guilds.ts | 22 ++++++++++++++++++---- api/index.ts | 15 +++++++++++++-- api/views/error.ejs | 4 ++-- bot/events/guildRemove.ts | 12 ++++++++++++ bot/index.ts | 2 +- bot/utils/requestAPI.ts | 10 ++++++++++ 7 files changed, 58 insertions(+), 10 deletions(-) create mode 100644 bot/events/guildRemove.ts diff --git a/api/db/init.ts b/api/db/init.ts index 2f67f22..c41f995 100644 --- a/api/db/init.ts +++ b/api/db/init.ts @@ -9,7 +9,8 @@ export async function initTables() { members INT, cooldown INT DEFAULT 30000, updates_enabled BOOLEAN DEFAULT FALSE, - updates_channel_id VARCHAR(255) DEFAULT NULL + updates_channel_id VARCHAR(255) DEFAULT NULL, + is_in_guild BOOLEAN DEFAULT TRUE ) `; const createUsersTable = ` diff --git a/api/db/queries/guilds.ts b/api/db/queries/guilds.ts index 9a27c42..2b631d1 100644 --- a/api/db/queries/guilds.ts +++ b/api/db/queries/guilds.ts @@ -14,7 +14,7 @@ export interface Guild { export async function getGuild(guildId: string): Promise<[QueryError, null] | [null, Guild | null]> { return new Promise((resolve, reject) => { - pool.query("SELECT * FROM guilds WHERE id = ?", [guildId], (err, results) => { + pool.query("SELECT * FROM guilds WHERE id = ? AND is_in_guild = ?", [guildId, true], (err, results) => { if (err) { reject([err, null]); } else { @@ -28,18 +28,20 @@ export async function updateGuild(guild: Omit { pool.query( ` - INSERT INTO guilds (id, name, icon, members) - VALUES (?, ?, ?, ?) + INSERT INTO guilds (id, name, icon, members, is_in_guild) + VALUES (?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE name = VALUES(name), icon = VALUES(icon), - members = VALUES(members) + members = VALUES(members), + is_in_guild = VALUES(is_in_guild) `, [ guild.id, guild.name, guild.icon, guild.members, + true, ], (err, results) => { if (err) { @@ -52,6 +54,18 @@ export async function updateGuild(guild: Omit { + return new Promise((resolve, reject) => { + pool.query("UPDATE guilds SET is_in_guild = ? WHERE id = ?", [false, guildId], (err) => { + if (err) { + reject([err, null]); + } else { + resolve([null, true]); + } + }); + }); +} + export async function setCooldown(guildId: string, cooldown: number): Promise<[QueryError, null] | [null, Guild]> { return new Promise((resolve, reject) => { pool.query("UPDATE guilds SET cooldown = ? WHERE id = ?", [cooldown, guildId], (err, results) => { diff --git a/api/index.ts b/api/index.ts index d169605..dbe2376 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, setXP, setLevel } from "./db"; +import { getBotInfo, getGuild, getUser, getUsers, initTables, pool, updateGuild, enableUpdates, disableUpdates, setCooldown, setUpdatesChannel, setXP, setLevel, removeGuild } from "./db"; const app = express(); const PORT = 18103; @@ -43,6 +43,17 @@ app.post("/post/:guild", authMiddleware, async (req, res) => { } }); +app.post('/post/:guild/remove', authMiddleware, async (req, res) => { + const { guild } = req.params; + const [err, results] = await removeGuild(guild); + + if (err) { + res.status(500).json({ message: "Internal server error" }); + } else { + res.status(200).json(results); + } +}) + app.post("/post/:guild/:user", authMiddleware, async (req, res) => { const { guild, user } = req.params; const { name, pfp, xp, nickname } = req.body; @@ -339,7 +350,7 @@ app.get("/leaderboard/:guild", async (req, res) => { const [usersErr, usersData] = await getUsers(guild); if (!guildData) { - return res.status(404).render("error", { error: { status: 404, message: "The guild does not exist" } }); + return res.status(404).render("error", { error: { status: 404, message: "The guild does not exist. If Chatr is no longer in this server, the data for this guild has been locked from public access" } }); } if (guildErr) { diff --git a/api/views/error.ejs b/api/views/error.ejs index 1207c7b..58e66e7 100644 --- a/api/views/error.ejs +++ b/api/views/error.ejs @@ -22,9 +22,9 @@

Message

-

+

<%= error.message %> -

+

diff --git a/bot/events/guildRemove.ts b/bot/events/guildRemove.ts new file mode 100644 index 0000000..7018b49 --- /dev/null +++ b/bot/events/guildRemove.ts @@ -0,0 +1,12 @@ +import { Events } from "discord.js"; +import client from "../index"; +import { removeGuild } from "../utils/requestAPI"; + +client.on(Events.GuildDelete, async (guild) => { + try { + await removeGuild(guild.id); + console.log(`Left guild ${guild.name} with ${guild.memberCount} members. The database has been locked`); + } catch (e) { + console.error(e); + } +}) \ No newline at end of file diff --git a/bot/index.ts b/bot/index.ts index bf08588..fadf00e 100644 --- a/bot/index.ts +++ b/bot/index.ts @@ -12,7 +12,7 @@ const client = new Client({ intents: [ GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, - GatewayIntentBits.MessageContent, + GatewayIntentBits.MessageContent ] }); diff --git a/bot/utils/requestAPI.ts b/bot/utils/requestAPI.ts index 9945cfb..a866d7c 100644 --- a/bot/utils/requestAPI.ts +++ b/bot/utils/requestAPI.ts @@ -54,6 +54,16 @@ export async function updateGuildInfo(guild: string, name: string, icon: string, }) } +export async function removeGuild(guild: string) { + await fetch(`http://localhost:18103/post/${guild}/remove`, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': process.env.AUTH as string, + }, + method: 'POST', + }) +} + export async function setXP(guild: string, user: string, xp: number) { const response = await fetch(`http://localhost:18103/admin/set/${guild}/xp`, { "headers": { From def1e5a59fe19d8582aa3e5717d9a00cca0f93b2 Mon Sep 17 00:00:00 2001 From: GalvinPython <77013913+GalvinPython@users.noreply.github.com> Date: Tue, 16 Jul 2024 18:26:19 +0100 Subject: [PATCH 32/37] fix(api): removed unneeded argument --- api/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/index.ts b/api/index.ts index dbe2376..171ddbe 100644 --- a/api/index.ts +++ b/api/index.ts @@ -186,7 +186,7 @@ app.post("/admin/:action/:guild/:target", authMiddleware, async (req, res) => { switch (target) { case "enable": try { - const [err, success] = await enableUpdates(guild, extraData.channelId); + const [err, success] = await enableUpdates(guild); if (err) { return res.status(500).json({ message: "Internal server error", err }); } else { From 13d8448a20b137e91177fa6f6badd4a857f63a02 Mon Sep 17 00:00:00 2001 From: Galvin <77013913+GalvinPython@users.noreply.github.com> Date: Tue, 16 Jul 2024 22:04:01 +0100 Subject: [PATCH 33/37] Create LICENSE lol how did we miss this --- LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6745e1a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Galvin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From c5bfb8083972b2b88f45554d2a43b96efadccf1c Mon Sep 17 00:00:00 2001 From: ToastedToast Date: Wed, 17 Jul 2024 19:22:03 +0800 Subject: [PATCH 34/37] feat(syncing): allow syncing from mee6 --- api/index.ts | 97 ++++++++++++++++++++++++++++++++++++----- bot/commands.ts | 29 +++++++----- bot/utils/requestAPI.ts | 4 +- 3 files changed, 106 insertions(+), 24 deletions(-) diff --git a/api/index.ts b/api/index.ts index 4d6b623..e13b28b 100644 --- a/api/index.ts +++ b/api/index.ts @@ -195,7 +195,7 @@ app.post("/admin/:action/:guild/:target", authMiddleware, async (req, res) => { } catch (err) { return res.status(500).json({ message: "Internal server error", err }); } - case 'set': + case 'set': if (!extraData || typeof extraData.channelId === "undefined") { return res.status(400).json({ message: "Illegal request" }); } @@ -297,7 +297,7 @@ app.post("/admin/:action/:guild/:target", authMiddleware, async (req, res) => { return res.status(400).json({ message: "Illegal request" }); } - if(!extraData || !extraData.user || !extraData.value) { + if (!extraData || !extraData.user || !extraData.value) { return res.status(400).json({ message: "Illegal request" }); } @@ -329,16 +329,16 @@ app.post("/admin/:action/:guild/:target", authMiddleware, async (req, res) => { } } case "sync": { - if(target !== "polaris") { + if (target !== "polaris" && target !== "mee6") { return res.status(400).json({ message: "Illegal request" }); } - - switch(target) { + + switch (target) { case "polaris": { try { const [err, success] = await syncFromPolaris(guild); if (err) { - if(err instanceof Error && err.message === "Server not found in Polaris") { + if (err instanceof Error && err.message === "Server not found in Polaris") { return res.status(404).json({ message: "Server not found in Polaris" }); } return res.status(500).json({ message: "Internal server error", err }); @@ -349,6 +349,21 @@ app.post("/admin/:action/:guild/:target", authMiddleware, async (req, res) => { return res.status(500).json({ message: "Internal server error", err }); } } + case "mee6": { + try { + const [err, success] = await syncFromMee6(guild); + if (err) { + if (err instanceof Error && err.message === "Server not found in MEE6") { + return res.status(404).json({ message: "Server not found in MEE6" }); + } + 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" }); } @@ -464,22 +479,22 @@ async function adminRolesAdd(guild: string, role: string, level: number) { async function syncFromPolaris(guild: string) { const res = await fetch(`https://gdcolon.com/polaris/api/leaderboard/${guild}`); const data = await res.json(); - if(data.apiError && data.code === "invalidServer") { + if (data.apiError && data.code === "invalidServer") { return [new Error("Server not found in Polaris"), false]; } const users = data.leaderboard; - for(let i = 1; i < data.pageInfo.pageCount; i++) { + for (let i = 1; i < data.pageInfo.pageCount; i++) { const res = await fetch(`https://gdcolon.com/polaris/api/leaderboard/${guild}?page=${i + 1}`); const data = await res.json(); users.push(...data.leaderboard); } - if(users.length === 0) { + if (users.length === 0) { return [new Error("No users found"), false]; } try { - for(const user of users) { + for (const user of users) { const xpValue = user.xp; const level = Math.floor(Math.sqrt(xpValue / 100)); const nextLevel = level + 1; @@ -520,4 +535,66 @@ async function syncFromPolaris(guild: string) { } } + +async function syncFromMee6(guild: string) { + const res = await fetch(`https://mee6.xyz/api/plugins/levels/leaderboard/${guild}?limit=1000&page=0`); + const data = await res.json(); + if (data.status_code === 404) { + return [new Error("Server not found in MEE6"), false]; + } + const users = data.players; + let pageNumber = 1; + while (true) { + const res = await fetch(`https://mee6.xyz/api/plugins/levels/leaderboard/${guild}?limit=1000&page=${pageNumber}`); + const data = await res.json(); + users.push(...data.players); + if (data.players.length < 1000) break; + pageNumber += 1; + } + + if (users.length === 0) { + return [new Error("No users found"), false]; + } + + try { + for (const user of users) { + const xpValue = user.xp; + const level = Math.floor(Math.sqrt(xpValue / 100)); + const nextLevel = level + 1; + const nextLevelXp = Math.pow(nextLevel, 2) * 100; + const xpNeededForNextLevel = nextLevelXp - xpValue; + const currentLevelXp = Math.pow(level, 2) * 100; + const progressToNextLevel = + ((xpValue - currentLevelXp) / (nextLevelXp - currentLevelXp)) * 100; + + await new Promise((resolve, reject) => { + pool.query( + `INSERT INTO users (id, guild_id, xp, pfp, name, nickname, level, xp_needed_next_level, progress_next_level) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + user.id, + guild, + xpValue, + `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.webp`, + user.username, + user.username, + level, + xpNeededForNextLevel, + progressToNextLevel.toFixed(2), + ], + (err) => { + if (err) { + console.error("Error syncing from MEE6:", err); + reject(err); + } else { + resolve(null); + } + }, + ); + }); + } + return [null, true] + } catch (err) { + return [err, false]; + } +} //#endregion diff --git a/bot/commands.ts b/bot/commands.ts index 60dcc6c..70464f4 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, setXP, setLevel, syncFromPolaris } from './utils/requestAPI'; +import { getGuildLeaderboard, makeGETRequest, getRoles, removeRole, addRole, enableUpdates, disableUpdates, getCooldown, setCooldown, getUpdatesChannel, setUpdatesChannel, setXP, setLevel, syncFromBot } from './utils/requestAPI'; import convertToLevels from './utils/convertToLevels'; import quickEmbed from './utils/quickEmbed'; import { Font, RankCardBuilder } from 'canvacord'; @@ -734,6 +734,10 @@ const commands: Record = { name: 'Polaris', value: 'polaris', }, + { + name: 'MEE6', + value: 'mee6', + } ] }], name: 'sync', @@ -757,18 +761,19 @@ const commands: Record = { } const bot = interaction.options.get('bot')?.value; - - let apiSuccess; - switch (bot) { - case 'polaris': - apiSuccess = await syncFromPolaris(interaction.guildId as string); - if (!apiSuccess) { - await interaction.reply({ ephemeral: true, content: 'Error syncing data! This might mean that Polaris is not set up for this server, or the leaderboard for this server is not public.' }); - return; - } - await interaction.reply({ ephemeral: true, content: 'Data synced!' }); - return; + const formattedBotNames = { + 'polaris': 'Polaris', + 'mee6': 'MEE6' + }; + + await interaction.reply({ ephemeral: true, content: `Syncing data from ${formattedBotNames[bot as keyof typeof formattedBotNames]}...` }); + const apiSuccess = await syncFromBot(interaction.guildId as string, bot as string); + if (!apiSuccess) { + await interaction.editReply({ content: `Error syncing data! This might mean that ${formattedBotNames[bot as keyof typeof formattedBotNames]} is not set up for this server, or the leaderboard for this server is not public.` }); + return; } + await interaction.editReply({ content: 'Data synced!' }); + return; } } }; diff --git a/bot/utils/requestAPI.ts b/bot/utils/requestAPI.ts index 01a93f5..80e5a38 100644 --- a/bot/utils/requestAPI.ts +++ b/bot/utils/requestAPI.ts @@ -207,8 +207,8 @@ export async function setCooldown(guild: string, cooldown: number) { //#endregion //#region Sync -export async function syncFromPolaris(guild: string) { - const response = await fetch(`http://localhost:18103/admin/sync/${guild}/polaris`, { +export async function syncFromBot(guild: string, bot: string) { + const response = await fetch(`http://localhost:18103/admin/sync/${guild}/${bot}`, { "headers": { 'Content-Type': 'application/json', 'Authorization': process.env.AUTH as string, From 3fa059aa30137d1889ddf1825a07d78b2bafae49 Mon Sep 17 00:00:00 2001 From: ToastedToast Date: Wed, 17 Jul 2024 19:24:43 +0800 Subject: [PATCH 35/37] fix: ignore eslint error --- api/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api/index.ts b/api/index.ts index e13b28b..4842f73 100644 --- a/api/index.ts +++ b/api/index.ts @@ -544,6 +544,8 @@ async function syncFromMee6(guild: string) { } const users = data.players; let pageNumber = 1; + // this is needed because MEE6 doesn't give us the total amount of pages + // eslint-disable-next-line no-constant-condition while (true) { const res = await fetch(`https://mee6.xyz/api/plugins/levels/leaderboard/${guild}?limit=1000&page=${pageNumber}`); const data = await res.json(); From d5ae4e1eeff34fbfcaca8353283d106a40169ccf Mon Sep 17 00:00:00 2001 From: ToastedToast Date: Fri, 19 Jul 2024 19:41:36 +0800 Subject: [PATCH 36/37] feat: allow syncing from lurkr --- api/index.ts | 82 +++++++++++++++++++++++++++++++++++++++++++++++- bot/commands.ts | 9 ++++-- bun.lockb | Bin 146572 -> 144956 bytes 3 files changed, 88 insertions(+), 3 deletions(-) mode change 100644 => 100755 bun.lockb diff --git a/api/index.ts b/api/index.ts index 37a4118..82a92a9 100644 --- a/api/index.ts +++ b/api/index.ts @@ -340,7 +340,7 @@ app.post("/admin/:action/:guild/:target", authMiddleware, async (req, res) => { } } case "sync": { - if (target !== "polaris" && target !== "mee6") { + if (target !== "polaris" && target !== "mee6" && target !== "lurkr") { return res.status(400).json({ message: "Illegal request" }); } @@ -375,6 +375,21 @@ app.post("/admin/:action/:guild/:target", authMiddleware, async (req, res) => { return res.status(500).json({ message: "Internal server error", err }); } } + case "lurkr": { + try { + const [err, success] = await syncFromLurkr(guild); + if (err) { + if (err instanceof Error && err.message === "Server not found in Lurkr") { + return res.status(404).json({ message: "Server not found in Lurkr" }); + } + 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" }); } @@ -610,4 +625,69 @@ async function syncFromMee6(guild: string) { return [err, false]; } } + +async function syncFromLurkr(guild: string) { + const res = await fetch(`https://api.lurkr.gg/v2/levels/${guild}?page=1`); + const data = await res.json(); + if (data.message === "Guild no found") { + return [new Error("Server not found in Lurkr"), false]; + } + const users = data.levels; + + if (users.length === 0) { + return [new Error("No users found"), false]; + } + + let pageNumber = 2; + // this is needed because Lurkr doesn't give us the total amount of pages + // eslint-disable-next-line no-constant-condition + while (true) { + const res = await fetch(`https://api.lurkr.gg/v2/levels/${guild}?page=${pageNumber}`); + const data = await res.json(); + users.push(...data.levels); + if (data.levels.length < 100) break; + pageNumber += 1; + } + + try { + for (const user of users) { + const xpValue = user.xp; + const level = Math.floor(Math.sqrt(user.xp / 100)); + const nextLevel = level + 1; + const nextLevelXp = Math.pow(nextLevel, 2) * 100; + const xpNeededForNextLevel = nextLevelXp - user.xp; + const currentLevelXp = Math.pow(level, 2) * 100; + const progressToNextLevel = + ((user.xp - currentLevelXp) / (nextLevelXp - currentLevelXp)) * 100; + + await new Promise((resolve, reject) => { + pool.query( + `INSERT INTO users (id, guild_id, xp, pfp, name, nickname, level, xp_needed_next_level, progress_next_level) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + user.userId, + guild, + xpValue, + `https://cdn.discordapp.com/avatars/${user.userId}/${user.user.avatar}.webp`, + user.user.username, + user.user.username, + level, + xpNeededForNextLevel, + progressToNextLevel.toFixed(2), + ], + (err) => { + if (err) { + console.error("Error syncing from Lurkr:", err); + reject(err); + } else { + resolve(null); + } + }, + ); + }); + } + return [null, true] + } catch (err) { + return [err, false]; + } +} //#endregion diff --git a/bot/commands.ts b/bot/commands.ts index 70464f4..afc717d 100644 --- a/bot/commands.ts +++ b/bot/commands.ts @@ -737,7 +737,11 @@ const commands: Record = { { name: 'MEE6', value: 'mee6', - } + }, + { + name: 'Lurkr', + value: 'lurkr', + }, ] }], name: 'sync', @@ -763,7 +767,8 @@ const commands: Record = { const bot = interaction.options.get('bot')?.value; const formattedBotNames = { 'polaris': 'Polaris', - 'mee6': 'MEE6' + 'mee6': 'MEE6', + 'lurkr': 'Lurkr' }; await interaction.reply({ ephemeral: true, content: `Syncing data from ${formattedBotNames[bot as keyof typeof formattedBotNames]}...` }); diff --git a/bun.lockb b/bun.lockb old mode 100644 new mode 100755 index 68e429669017c55ddde4989e1fb95df5c8a2a9a4..bdf942bab4ed6a4a62ccdde58436895f07620ad0 GIT binary patch delta 4536 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 delta 4667 zcmeHLd0bOh7Jm0ZAc2HE5_Y6OSxg`Zq9Ta66w9JWQE)-Fux}D5A}R>AU}-IwvDZ4S z+N!NqZN=zB6a{g`f|aSe($)oOZKa}E1qYpTLeher&cE}|ydPiAx!*bW+;f-r?!7er z!hN@&TPtyQQ_kZu;l$n5p+@A~QJMN%)OPFj3qq=lo-L>r_O3SmR{+c4^H$wF2ez-Q zerc^iSw|Sdh#024G?`(XLE5G;3?C%=8Gw|_a^92`Fw9m9hT%c^E=XgLY1w&6NhJ&; z&rX!5gKv(kAWi1OFj=x8Ka13%`tw0zy`+M~WSN{{IyAgx8p9YdOj&sbDCpoA!7zp( zQ!)y~7~Bb>BA);O)x0dJ2wa&Q2!hWbBT!I;(Yg>kezm&XD;rW{@XcW=#*`JnLloJDIepxT9zc7*)>vJxuH*DCsJsF8-WkC{0w{w+`Ta3>wyf2&4UZ*EL?yBt&q537rNujzVhd&ntiu z#?x1~wHr!7YFoXvKkput#t%gSPfu7I=m~8>Rx&7|C#zVJr4np~(i>3HA2BFTfSvIt zHZX%>=8j~Gczhs3N0K70Vn<53;QF84VZ0+@LCyY8{>pTDxGvphN4%AHimMIEIFQjD z!Z2Krzkq~4=05(sB$4XT*nb772j#~azu^+&4FHcX^}jTBl&B7BcI~t#s`W10wgsOUROp0WlYdcW4C9A+Gp&6&;^4N zNF9(aAmNXB6@UIhqQ9rcjuKTd{9wZpjr7v+QZ&LdT%w#s=8xld;?z6s^Za?gk zTt1Q8JgRN}BcJZL!jlg=ze#M3uS?l==qUOJCxT zj??bRR-U=n2f|;^Oz)_RJeeS=+q1u-GT3m*hc8Z3F`tOa9&+-&n|^3>#O$9Yp1Ihn zNV~)zCGN@heOB|2>bp@+>moMgMg*Hpe6aYqPQ)d8C+Pb?Ig#}LlCg&?^p*_xI|K|% zFKc*unN8CMB3q)*UaCoJFl#(t?N#jd35Vp5^9bKx-|Q0D7MnZg{-ds|RtJl$S{80z zSn+VmS0$5o&aUkKu)?9!sQvjo+6n2Y2Qx=Km>UEzn4gPN*93hUvTmVrc9QFah97js zF1u~ad8Nm#KW)~ru9e*jmPoP^yZYbT@soJ)?w$*YkIzpwZC@QT!*|V-^}ar%(}rc6 zH$2<-U+bCEX7R4{`<;s~6y<&<`L;IX^x7GpcWx|?;75Ovm84$rsZEdHI_p-@rr@AFf7V50xO%$n z*}<&n=K;s3RcvoR1`EBxZO>c>usX9|8IZy;n{?(8KQc z6Z-Zl5~+s=m1l%rTSctR@Q`upR-uY%YMaAf-~@cuu+j z_y({Zy7B~Mbv69Jv#<__!BzMM6mf`F0dTUG0}51oDhTCpazW2h&8#M0ld{>MX9DnJ zGzahuVxXQ5NCm_K_<*^97(g@tW6uLbfv*f?GGGC~7%(3Y2M7jC1SA2FTL_2;ECMVB zBmffe9EQPD0QfRv!yX!m2@FpR%h>=-FbCu;jXe)!6aeie089dxFeZwhF!aZcp-(;l zdE}OWTt?u~zZ8^WfE@5901np-F4rQBj%Bo$0FW;o#^bsz1K?UkKU|9_;b70y)(n3aYKub=h`A3M*22}0c zR(q$@23Lnu@D3tQ|a<%BC^$9e*+&i*Pk`9 znMU`52b+;b&teUybow01)TDt}YjY&tA2bq6eP7Ag-~0DR+Nk}h`}Y5(`y>0i?-=wK z8>0{Q5}}^d8-{Zz9LCVznzc7m?GVC+UOhj5qyaM(*+^_@f|3Yz#!93#bU(3WHG@PB z(tsnxieh6ne>E-InCf}Z)>Kzf=ckHnA#+)S;ID_)+7Dmj_t=8AoFEd~bAqh1PKOna z|H61{yk8qxYV_QsZOF4PK(+BCX$XT1o7`DT`jIIsp!ufk7*0`PMz&nF)07Rdq%*u( zbFIe~FYs_c-)rvdN-aCfo#h)A%EgKK8DgwS9WOwC%{puLyL@Mo*oSoYs4=K6Jo~g~8Ues!tWI#6q>+o8@xgt=#0tZu&Qf%Zk?k From b0335498b8cd6407efe87b740a22921f8740187f Mon Sep 17 00:00:00 2001 From: GalvinPython <77013913+GalvinPython@users.noreply.github.com> Date: Sat, 20 Jul 2024 13:07:47 +0100 Subject: [PATCH 37/37] bump: 0.1 it's done --- README.md | 53 ++++++++++++++++++++++++++++++++++++++++++++-------- api/index.ts | 12 ++++-------- 2 files changed, 49 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 8a1851d..2fc4bef 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,27 @@ # Chatr -A Discord XP bot +A free and open-sourced Discord XP Bot. +![Bot](https://img.shields.io/badge/Invite%20Chatr-5865F2?style=for-the-badge&logo=discord&logoColor=white) +![Discord](https://img.shields.io/discord/1249813817706283019?style=for-the-badge&logo=discord&logoColor=white&label=Support%20Server&color=%235865F2) -> [!CAUTION] -> **Chatr** is currently in development and is open-sourced. The bot is functional in this state, however it shouldn't be used +Please report bugs in `bug-reports` on our server or open an issue on this repo! + +# Features +- Earn XP from your messages! +- Customisable xp cooldown on messages +- Online leaderboard +- Rankcard +- Transfer your points from other bots! + - MEE6 + - Polaris + - Lurkr + - Other bots soon + +> [!WARNING] +> **Chatr** has entered Beta! (don't worry, we will deal with the headaches for you) + +# Developer Instructions + +This a project created using (Bun)[https://bun.sh] To install dependencies: @@ -10,13 +29,31 @@ To install dependencies: bun install ``` -To run: - +Run the **API** +```bash +bun run dev:api +``` +Run the **Bot** ```bash -bun run index.ts +bun run dev:bot ``` -This project was created using `bun init` in bun v1.1.10. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. # Changelog -* Soon \ No newline at end of file +## Beta 0.1 +Thanks to @ToastedDev for his contributions to the bot. Here are some changes that were made +* General formatting fixes (#8) +* Refactored the database to be more performant (#13) +* Added a message cooldown (#14) +* Added a rankcard to /xp (#17) +* User management (#19) +* Added syncing (#24) + +# Roadmap +* Rewritten site using NextJS +* Auto-updating cached user information +* Better privacy controls +* Live updates +* Track guilds and users xp + +Want to add more features? Join our server (linked above) and add a post to `feature-requests` diff --git a/api/index.ts b/api/index.ts index 82a92a9..e3ad546 100644 --- a/api/index.ts +++ b/api/index.ts @@ -431,18 +431,14 @@ app.get("/", async (_req, res) => { res.render("index", { botInfo }); }); +app.get("/invite", (_req, res) => res.status(308).redirect("https://discord.com/oauth2/authorize?client_id=1245807579624378601&permissions=1099780115520&integration_type=0&scope=bot+applications.commands")); + +app.get('/support', (_req, res) => res.status(308).redirect('https://discord.gg/fpJVTkVngm')); + app.use((_req, res) => { res.status(404).render("error", { error: { status: 404, message: "Page doesn't exist" } }); }); -app.get("/invite", (_req, res) => - res - .status(308) - .redirect( - "https://discord.com/oauth2/authorize?client_id=1245807579624378601&permissions=1099780115520&integration_type=0&scope=bot+applications.commands", - ) -); - app.listen(PORT, () => { console.log(`Server running on http://localhost:${PORT}`); });