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/.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/.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/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. diff --git a/README.md b/README.md index 865105e..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,10 +29,31 @@ To install dependencies: bun install ``` -To run: - +Run the **API** ```bash -bun run index.ts +bun run dev:api +``` +Run the **Bot** +```bash +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 +## 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/db/index.ts b/api/db/index.ts new file mode 100644 index 0000000..f3786c5 --- /dev/null +++ b/api/db/index.ts @@ -0,0 +1,15 @@ +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'; +export * from './queries/updates'; \ No newline at end of file diff --git a/api/db/init.ts b/api/db/init.ts new file mode 100644 index 0000000..c41f995 --- /dev/null +++ b/api/db/init.ts @@ -0,0 +1,64 @@ +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, + cooldown INT DEFAULT 30000, + updates_enabled BOOLEAN DEFAULT FALSE, + updates_channel_id VARCHAR(255) DEFAULT NULL, + is_in_guild BOOLEAN DEFAULT TRUE + ) + `; + 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) => { + 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"); + } + }); +} diff --git a/api/db/queries/guilds.ts b/api/db/queries/guilds.ts new file mode 100644 index 0000000..2b631d1 --- /dev/null +++ b/api/db/queries/guilds.ts @@ -0,0 +1,124 @@ +import type { QueryError } from "mysql2"; +import { pool } from ".."; + +export interface Guild { + id: string; + name: string; + 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 = ? AND is_in_guild = ?", [guildId, true], (err, results) => { + if (err) { + reject([err, null]); + } else { + resolve([null, (results as Guild[])[0]]); + } + }); + }); +} + +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, is_in_guild) + VALUES (?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + name = VALUES(name), + icon = VALUES(icon), + members = VALUES(members), + is_in_guild = VALUES(is_in_guild) + `, + [ + guild.id, + guild.name, + guild.icon, + guild.members, + true, + ], + (err, results) => { + if (err) { + reject([err, null]); + } else { + resolve([null, results as Guild[]]); + } + }, + ); + }); +} + +export async function removeGuild(guildId: string): Promise<[QueryError, null] | [null, true]> { + 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) => { + if (err) { + reject([err, null]); + } else { + resolve([null, (results as Guild[])[0]]); + } + }); + }) +} + +interface BotInfo { + total_guilds: number; + total_members: number; + user_count?: 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 { + 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 as { count: number }[])[0].count]); + } + }); + }); +} diff --git a/api/db/queries/updates.ts b/api/db/queries/updates.ts new file mode 100644 index 0000000..80bfdbc --- /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 enableUpdates(guildId: string): Promise<[QueryError | null, boolean]> { + return new Promise((resolve, reject) => { + pool.query( + ` + UPDATE guilds SET updates_enabled = TRUE WHERE id = ? + `, + [ + guildId, + ], + (err) => { + if (err) { + reject([err, false]); + } else { + resolve([null, true]); + } + }, + ); + }); +} + +export async function disableUpdates(guildId: string): Promise<[QueryError | null, boolean]> { + return new Promise((resolve, reject) => { + pool.query( + ` + UPDATE guilds SET updates_enabled = FALSE WHERE id = ? + `, + [ + guildId, + ], + (err) => { + if (err) { + reject([err, false]); + } else { + resolve([null, true]); + } + }, + ); + }); +} + +export async function setUpdatesChannel(guildId: string, channelId: string | null): Promise<[QueryError | null, boolean]> { + return new Promise((resolve, reject) => { + pool.query( + ` + UPDATE guilds SET updates_channel_id = ? WHERE id = ? + `, + [ + channelId, + guildId, + ], + (err) => { + if (err) { + reject([err, false]); + } else { + resolve([null, true]); + } + }, + ); + }); +} diff --git a/api/db/queries/users.ts b/api/db/queries/users.ts new file mode 100644 index 0000000..01020db --- /dev/null +++ b/api/db/queries/users.ts @@ -0,0 +1,78 @@ +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]]); + } + }); + }); +} + +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 ef57d6e..e3ad546 100644 --- a/api/index.ts +++ b/api/index.ts @@ -1,449 +1,457 @@ -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 path from "path"; +import { getBotInfo, getGuild, getUser, getUsers, initTables, pool, updateGuild, enableUpdates, disableUpdates, setCooldown, setUpdatesChannel, setXP, setLevel, removeGuild } 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); - } - }); +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 || req.headers.authorization !== process.env.AUTH) { + return res + .status(403) + .json({ message: "Access denied" }); + } + next(); +} - pool.query(createExcludesTable, (err, results) => { - if (err) { - console.error('Error creating excludes table:', err); - } else { - console.log('Excludes table created:', results); - } - }); +app.post("/post/:guild", authMiddleware, async (req, res) => { + const { guild } = req.params; + const { name, icon, members } = req.body; - 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); - } + const [err, results] = await updateGuild({ + id: guild, + name, + icon, + members, }); -} -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); - } - }); -} + if (err) { + res.status(500).json({ message: "Internal server error" }); + } else { + res.status(200).json(results); + } +}); -app.post('/post/:guild/', async (req, res) => { +app.post('/post/:guild/remove', authMiddleware, async (req, res) => { const { guild } = req.params; - const { name, icon, members, auth } = req.body; + const [err, results] = await removeGuild(guild); - 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); + + if (err) { + console.error("Error fetching XP:", err); + return res.status(500).json({ message: "Internal server error" }); } - ensureGuildTableExists(guild, (err) => { - if (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 [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, }); - - 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); - } - }); - }); - - 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": + // TODO: implement this // target: channel id // run function to include target to guild break; - case 'exclude': + case "exclude": + // TODO: implement this // 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 !== "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' }); + case "enable": + try { + const [err, success] = await enableUpdates(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 }); } + case "disable": 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 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 }); } - case 'disable': + case 'set': + if (!extraData || typeof extraData.channelId === "undefined") { + return res.status(400).json({ message: "Illegal request" }); + } + 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 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 data = await adminUpdatesGet(guild); - return res.status(200).json(data); + const [err, data] = await getGuild(guild); + if (err) { + return res.status(500).json({ message: "Internal server error", err }); + } + 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' }); + 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": + try { + 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" }); + } + 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 'add': + case "set": try { - const data = await adminRolesAdd(guild, extraData.role, extraData.level); + const data = await setCooldown(guild, extraData.cooldown); 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" }); + } + 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" }); + } + } + case "sync": { + if (target !== "polaris" && target !== "mee6" && target !== "lurkr") { + return res.status(400).json({ message: "Illegal request" }); + } + + switch (target) { + case "polaris": { + 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); + } + } catch (err) { + 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 }); + } + } + 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" }); + } + } 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 data = await response.json(); - res.render('leaderboard', { guild: data.guild, leaderboard: data.leaderboard }); -}); + const [guildErr, guildData] = await getGuild(guild); + const [usersErr, usersData] = await getUsers(guild); -async function getBotInfo() { - const selectGuildsCountQuery = ` - SELECT COUNT(*) AS total_guilds FROM info_guilds; - `; + if (!guildData) { + 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" } }); + } - const selectTotalMembersQuery = ` - SELECT SUM(guild_members) AS total_members FROM info_guilds; - `; + if (guildErr) { + console.error("Error fetching guild:", guildErr); + 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).render("error", { error: { status: 500, message: "Internal server error whilst trying to fetch user info" } }); + } - 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); - } - }); - }); + res.render("leaderboard", { + guild: guildData, + leaderboard: usersData, + }); +}); - 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); - } - }); - }); +app.get("/", async (_req, res) => { + 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 }); +}); - const botInfo = { - total_guilds: guildsCountResult, - total_members: totalMembersResult, - }; +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")); - return botInfo - } catch (error) { - console.error('Error fetching bot info:', error); - return null - } -} +app.get('/support', (_req, res) => res.status(308).redirect('https://discord.gg/fpJVTkVngm')); -app.get('/', async (req, res) => { - const botInfo = await getBotInfo(); - res.render('index', { botInfo: 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).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}`); }); +// 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) => { if (err) { - console.error('Error fetching roles:', err); + console.error("Error fetching roles:", err); reject(err); } else { resolve(results); @@ -454,14 +462,14 @@ 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); + console.error("Error removing role:", err); reject(err); } else { resolve(results); @@ -472,14 +480,14 @@ 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); + console.error("Error adding role:", err); reject(err); } else { resolve(results); @@ -489,59 +497,193 @@ async function adminRolesAdd(guild: string, role: string, level: number) { } //#endregion -//#region Admin: Updates -async function adminUpdatesGet(guildId: string) { - const selectUpdatesQuery = `SELECT * FROM info_updates WHERE guild_id = ?`; +//#region Syncing +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}`); + const data = await res.json(); + users.push(...data.leaderboard); + } - return new Promise((resolve, reject) => { - pool.query(selectUpdatesQuery, [guildId], (err, results) => { - if (err) { - console.error('Error fetching updates:', err); - reject(err); - } else { - resolve(results); - } - }); - }); -} + if (users.length === 0) { + return [new Error("No users found"), false]; + } -async function adminUpdatesAdd(guildId: string, channelId: string) { - const insertUpdatesQuery = ` - INSERT INTO info_updates (guild_id, enabled, channel_id) - VALUES (?, TRUE, ?) - ON DUPLICATE KEY UPDATE - enabled = TRUE, - channel_id = ? - `; + 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]; + } - 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); - } - }); - }); } -async function adminUpdatesRemove(guildId: string) { - const deleteUpdatesQuery = ` - DELETE FROM info_updates - WHERE guild_id = ? - `; +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; + // 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(); + users.push(...data.players); + if (data.players.length < 1000) break; + pageNumber += 1; + } - return new Promise((resolve, reject) => { - pool.query(deleteUpdatesQuery, [guildId], (err, results) => { - if (err) { - console.error('Error disabling updates:', err); - reject(err); - } else { - resolve(results); - } - }); - }); + 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 \ No newline at end of file +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/api/views/error.ejs b/api/views/error.ejs new file mode 100644 index 0000000..58e66e7 --- /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/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/api/views/leaderboard.ejs b/api/views/leaderboard.ejs index 0e7adef..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 %> +

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.level.toLocaleString() %> | <%= user.xp.toLocaleString() %>/<%= (user.xp + user.xp_needed_next_level).toLocaleString() %> points to next level (<%= 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..afc717d 100644 --- a/bot/commands.ts +++ b/bot/commands.ts @@ -1,15 +1,18 @@ // 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, GuildMember, AttachmentBuilder, ComponentType } 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, getUpdatesChannel, setUpdatesChannel, setXP, setLevel, syncFromBot } from './utils/requestAPI'; import convertToLevels from './utils/convertToLevels'; import quickEmbed from './utils/quickEmbed'; +import { Font, RankCardBuilder } from 'canvacord'; + +Font.loadDefault(); interface Command { data: { - options: any[]; + options: APIApplicationCommandOption[]; name: string; description: string; integration_types: number[]; @@ -27,7 +30,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 +47,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 +68,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 +85,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 +104,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 @@ -122,39 +125,115 @@ 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) => { - if (interaction?.guildId) { - const guild = interaction.guild?.id - const user = interaction.user.id - const xp = await makeGETRequest(guild as string, user) + await interaction.deferReply() - if (!xp) { - await interaction.reply({ - ephemeral: true, - content: "No XP data available." + 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 leaderboard = await getGuildLeaderboard(guild as string); + const xp = await makeGETRequest(guild as string, user) + + if (!xp || leaderboard.length === 0) { + await interaction.reply({ + ephemeral: true, + content: "No XP data available." + }); + 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") + + if (interaction.user.discriminator !== "0") { + card.setUsername("#" + member.user.discriminator) + } else { + card.setUsername("@" + member.user.username) + } + + const color = member.roles.highest.hexColor ?? "#ffffff" + + card.setStyles({ + progressbar: { + thumb: { + style: { + backgroundColor: color + } + } + }, + }) + + const image = await card.build({ + format: "png" + }); + const attachment = new AttachmentBuilder(image, { name: `${user}.png` }); + + const msg = await interaction.followUp({ + 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 }); - return; - } - const progress = xp.user_progress_next_level; + if (i.customId !== "text-mode") return; + + const progress = xp.progress_next_level; const progressBar = createProgressBar(progress); - await interaction.reply({ + await i.update({ embeds: [ quickEmbed( { - color: 'Blurple', + color, 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([ + { + name: 'Rank', + value: `#${rank.toLocaleString()}`, + }, { name: 'Progress To Next Level', value: `${progressBar} ${progress}%`, @@ -162,18 +241,20 @@ const commands: Record = { }, { name: 'XP Required', - value: `${xp.user_xp_needed_next_level} XP`, + value: `${xp.xp_needed_next_level.toLocaleString()} XP`, inline: true, }, ]), ], - }); + files: [], + components: [] + }) + }) - function createProgressBar(progress: number): string { - const filled = Math.floor(progress / 10); - const empty = 10 - filled; - return '▰'.repeat(filled) + '▱'.repeat(empty); - } + function createProgressBar(progress: number): string { + const filled = Math.floor(progress / 10); + const empty = 10 - filled; + return '▰'.repeat(filled) + '▱'.repeat(empty); } } }, @@ -205,11 +286,11 @@ 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: { id: string; xp: number; }, index: number) => { leaderboardEmbed.addFields([ { name: `${index + 1}.`, - value: `<@${entry.user_id}>: ${entry.xp} XP`, + value: `<@${entry.id}>: ${entry.xp.toLocaleString("en-US")} XP`, inline: false } ]); @@ -273,7 +354,6 @@ const commands: Record = { options: [ { name: 'action', - id: 'action', description: 'Select an action', type: 3, required: true, @@ -294,15 +374,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,25 +450,37 @@ const commands: Record = { data: { options: [{ name: 'action', - id: '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], @@ -414,21 +503,283 @@ const commands: Record = { const action = interaction.options.get('action')?.value; const channelId = interaction.channelId; + let success + 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': - 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); - await interaction.reply({ ephemeral: true, content: `Updates are now enabled for this server in <#${channelId}>` }).catch(console.error); + 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 setting updates channel for this server' }).catch(console.error); + return; + } + 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: - await interaction.reply({ ephemeral: true, content: 'Not implemented :3' }).catch(console.error); + 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; + } + 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; + } + }, + }, + cooldown: { + data: { + options: [{ + name: 'action', + description: 'Select an action', + type: 3, + required: true, + choices: [ + { + name: 'Get', + value: 'get', + }, + { + name: 'Set', + value: 'set', + } + ] + }, { + name: '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; } + } + }, + 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; + } + } + }, + sync: { + data: { + options: [{ + name: 'bot', + description: 'Select the bot to sync XP data from', + type: 3, + required: true, + choices: [ + { + name: 'Polaris', + value: 'polaris', + }, + { + name: 'MEE6', + value: 'mee6', + }, + { + name: 'Lurkr', + value: 'lurkr', + }, + ] + }], + 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; + const formattedBotNames = { + 'polaris': 'Polaris', + 'mee6': 'MEE6', + 'lurkr': 'Lurkr' + }; + + 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; + } } }; @@ -442,4 +793,4 @@ for (const key in commands) { } } -export default commandsMap; \ No newline at end of file +export default commandsMap; 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 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/events/messageCreate.ts b/bot/events/messageCreate.ts index 307df70..1309d32 100644 --- a/bot/events/messageCreate.ts +++ b/bot/events/messageCreate.ts @@ -1,18 +1,27 @@ 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(); // 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; + 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); + 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; 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 +}); 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/bot/index.ts b/bot/index.ts index e1ad987..fadf00e 100644 --- a/bot/index.ts +++ b/bot/index.ts @@ -1,13 +1,10 @@ -// 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!' // 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'; @@ -15,24 +12,24 @@ const client = new Client({ intents: [ GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, - GatewayIntentBits.MessageContent, + GatewayIntentBits.MessageContent ] }); // 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: any = await rest.put( +const data = await rest.put( Routes.applicationCommands(getAppId.id), { body: [...commandsMap.values()].map((a) => { return a.data; }), }, -); +) as APIApplicationCommand[]; console.log( `Successfully reloaded ${data.length} application (/) commands.`, 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/bot/utils/handleLevelChange.ts b/bot/utils/handleLevelChange.ts index f8ebb29..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, user, level) { - 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 5b2fa91..14d8891 100644 --- a/bot/utils/requestAPI.ts +++ b/bot/utils/requestAPI.ts @@ -1,16 +1,17 @@ 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}`, { +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', + 'Authorization': process.env.AUTH as string, }, method: 'POST', body: JSON.stringify({ xp, pfp, name, nickname }), }).then(res => { return res.json() }).then(data => { - if (data.sendUpdateEvent) handleLevelChange(guild, user, data.level) + if (data.sendUpdateEvent) handleLevelChange(guild, user, channel, data.level) }) } @@ -42,9 +43,10 @@ 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 }), + body: JSON.stringify({ name, icon, members }), }).then(res => { return res.json() }).then(data => { @@ -52,16 +54,49 @@ 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": { + '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`, { 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', }); @@ -74,9 +109,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, } @@ -90,9 +125,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 @@ -109,28 +144,88 @@ 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' }, - "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 ? response.json() : {}; +} +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, + }, + "body": JSON.stringify({ extraData: { channelId } }), "method": "POST" }); return response.status === 200; } -export async function enableUpdates(guild: string, channelId: string) { +export async function enableUpdates(guild: 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({}), "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; +} +//#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 + +//#region Sync +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, + }, + "body": JSON.stringify({}), "method": "POST" }); return response.status === 200; } -//#endregion \ No newline at end of file +//#endregion diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..bdf942b Binary files /dev/null and b/bun.lockb differ 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, +); diff --git a/package.json b/package.json index c8fc64c..134c9ef 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,13 @@ { "name": "xpbot", "type": "module", - "version": "0.0.8", + "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" + "bot": "bun bot/index.ts", + "lint": "eslint . --config eslint.config.mjs" }, "devDependencies": { "@eslint/js": "^9.7.0", @@ -18,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",