mirror of
https://github.com/ahmadk953/poixpixel-discord-bot.git
synced 2025-05-10 10:43:06 +00:00
281 lines
7.4 KiB
TypeScript
281 lines
7.4 KiB
TypeScript
import path from 'path';
|
|
import Canvas, { GlobalFonts } from '@napi-rs/canvas';
|
|
import { GuildMember, Message, AttachmentBuilder, Guild } from 'discord.js';
|
|
|
|
import {
|
|
addXpToUser,
|
|
db,
|
|
getUserLevel,
|
|
getUserRank,
|
|
handleDbError,
|
|
} from '@/db/db.js';
|
|
import * as schema from '@/db/schema.js';
|
|
import { loadConfig } from './configLoader.js';
|
|
import { roundRect } from './helpers.js';
|
|
|
|
const config = loadConfig();
|
|
|
|
const XP_COOLDOWN = config.leveling.xpCooldown * 1000;
|
|
const MIN_XP = config.leveling.minXpAwarded;
|
|
const MAX_XP = config.leveling.maxXpAwarded;
|
|
|
|
const __dirname = path.resolve();
|
|
|
|
/**
|
|
* Calculates the amount of XP required to reach the given level
|
|
* @param level - The level to calculate the XP for
|
|
* @returns - The amount of XP required to reach the given level
|
|
*/
|
|
export const calculateXpForLevel = (level: number): number => {
|
|
if (level === 0) return 0;
|
|
return (5 / 6) * level * (2 * level * level + 27 * level + 91);
|
|
};
|
|
|
|
/**
|
|
* Calculates the level that corresponds to the given amount of XP
|
|
* @param xp - The amount of XP to calculate the level for
|
|
* @returns - The level that corresponds to the given amount of XP
|
|
*/
|
|
export const calculateLevelFromXp = (xp: number): number => {
|
|
if (xp < calculateXpForLevel(1)) return 0;
|
|
|
|
let level = 0;
|
|
while (calculateXpForLevel(level + 1) <= xp) {
|
|
level++;
|
|
}
|
|
|
|
return level;
|
|
};
|
|
|
|
/**
|
|
* Gets the amount of XP required to reach the next level
|
|
* @param level - The level to calculate the XP for
|
|
* @param currentXp - The current amount of XP
|
|
* @returns - The amount of XP required to reach the next level
|
|
*/
|
|
export const getXpToNextLevel = (level: number, currentXp: number): number => {
|
|
if (level === 0) return calculateXpForLevel(1) - currentXp;
|
|
|
|
const nextLevelXp = calculateXpForLevel(level + 1);
|
|
return nextLevelXp - currentXp;
|
|
};
|
|
|
|
/**
|
|
* Recalculates the levels for all users in the database
|
|
*/
|
|
export async function recalculateUserLevels() {
|
|
try {
|
|
const users = await db.select().from(schema.levelTable);
|
|
|
|
for (const user of users) {
|
|
await addXpToUser(user.discordId, 0);
|
|
}
|
|
} catch (error) {
|
|
handleDbError('Failed to recalculate user levels', error as Error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Processes a message for XP
|
|
* @param message - The message to process for XP
|
|
* @returns - The result of processing the message
|
|
*/
|
|
export async function processMessage(message: Message) {
|
|
if (message.author.bot || !message.guild) return;
|
|
|
|
try {
|
|
const userId = message.author.id;
|
|
const userData = await getUserLevel(userId);
|
|
|
|
if (userData.lastMessageTimestamp) {
|
|
const lastMessageTime = new Date(userData.lastMessageTimestamp).getTime();
|
|
const currentTime = Date.now();
|
|
|
|
if (currentTime - lastMessageTime < XP_COOLDOWN) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const xpToAdd = Math.floor(Math.random() * (MAX_XP - MIN_XP + 1)) + MIN_XP;
|
|
const result = await addXpToUser(userId, xpToAdd);
|
|
|
|
return result;
|
|
} catch (error) {
|
|
console.error('Error processing message for XP:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generates a rank card for the given member
|
|
* @param member - The member to generate a rank card for
|
|
* @param userData - The user's level data
|
|
* @returns - The rank card as an attachment
|
|
*/
|
|
export async function generateRankCard(
|
|
member: GuildMember,
|
|
userData: schema.levelTableTypes,
|
|
) {
|
|
GlobalFonts.registerFromPath(
|
|
path.join(__dirname, 'assets', 'fonts', 'Manrope-Bold.ttf'),
|
|
'Manrope Bold',
|
|
);
|
|
GlobalFonts.registerFromPath(
|
|
path.join(__dirname, 'assets', 'fonts', 'Manrope-Regular.ttf'),
|
|
'Manrope',
|
|
);
|
|
|
|
const userRank = await getUserRank(userData.discordId);
|
|
|
|
const canvas = Canvas.createCanvas(934, 282);
|
|
const context = canvas.getContext('2d');
|
|
|
|
context.fillStyle = '#23272A';
|
|
context.fillRect(0, 0, canvas.width, canvas.height);
|
|
|
|
context.fillStyle = '#2C2F33';
|
|
roundRect({
|
|
ctx: context,
|
|
x: 22,
|
|
y: 22,
|
|
width: 890,
|
|
height: 238,
|
|
radius: 20,
|
|
fill: true,
|
|
});
|
|
|
|
try {
|
|
const avatar = await Canvas.loadImage(
|
|
member.user.displayAvatarURL({ extension: 'png', size: 256 }),
|
|
);
|
|
context.save();
|
|
context.beginPath();
|
|
context.arc(120, 141, 80, 0, Math.PI * 2);
|
|
context.closePath();
|
|
context.clip();
|
|
context.drawImage(avatar, 40, 61, 160, 160);
|
|
context.restore();
|
|
} catch (error) {
|
|
console.error('Error loading avatar image:', error);
|
|
context.fillStyle = '#5865F2';
|
|
context.beginPath();
|
|
context.arc(120, 141, 80, 0, Math.PI * 2);
|
|
context.fill();
|
|
}
|
|
|
|
context.font = '38px "Manrope Bold"';
|
|
context.fillStyle = '#FFFFFF';
|
|
context.fillText(member.user.username, 242, 142);
|
|
|
|
context.font = '24px "Manrope Bold"';
|
|
context.fillStyle = '#FFFFFF';
|
|
context.textAlign = 'end';
|
|
context.fillText(`LEVEL ${userData.level}`, 890, 82);
|
|
|
|
context.font = '24px "Manrope Bold"';
|
|
context.fillStyle = '#FFFFFF';
|
|
context.fillText(`RANK #${userRank}`, 890, 122);
|
|
|
|
const barWidth = 615;
|
|
const barHeight = 38;
|
|
const barX = 242;
|
|
const barY = 182;
|
|
|
|
const currentLevel = userData.level;
|
|
const currentLevelXp = calculateXpForLevel(currentLevel);
|
|
const nextLevelXp = calculateXpForLevel(currentLevel + 1);
|
|
|
|
const xpNeededForNextLevel = nextLevelXp - currentLevelXp;
|
|
|
|
let xpIntoCurrentLevel;
|
|
if (currentLevel === 0) {
|
|
xpIntoCurrentLevel = userData.xp;
|
|
} else {
|
|
xpIntoCurrentLevel = userData.xp - currentLevelXp;
|
|
}
|
|
|
|
const progress = Math.max(
|
|
0,
|
|
Math.min(xpIntoCurrentLevel / xpNeededForNextLevel, 1),
|
|
);
|
|
|
|
context.fillStyle = '#484b4E';
|
|
roundRect({
|
|
ctx: context,
|
|
x: barX,
|
|
y: barY,
|
|
width: barWidth,
|
|
height: barHeight,
|
|
radius: barHeight / 2,
|
|
fill: true,
|
|
});
|
|
|
|
if (progress > 0) {
|
|
context.fillStyle = '#5865F2';
|
|
roundRect({
|
|
ctx: context,
|
|
x: barX,
|
|
y: barY,
|
|
width: barWidth * progress,
|
|
height: barHeight,
|
|
radius: barHeight / 2,
|
|
fill: true,
|
|
});
|
|
}
|
|
|
|
context.textAlign = 'center';
|
|
context.font = '20px "Manrope"';
|
|
context.fillStyle = '#A0A0A0';
|
|
context.fillText(
|
|
`${xpIntoCurrentLevel.toLocaleString()} / ${xpNeededForNextLevel.toLocaleString()} XP`,
|
|
barX + barWidth / 2,
|
|
barY + barHeight / 2 + 7,
|
|
);
|
|
|
|
return new AttachmentBuilder(canvas.toBuffer('image/png'), {
|
|
name: 'rank-card.png',
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Assigns level roles to a user based on their new level
|
|
* @param guild - The guild to assign roles in
|
|
* @param userId - The userId of the user to assign roles to
|
|
* @param newLevel - The new level of the user
|
|
* @returns - The highest role that was assigned
|
|
*/
|
|
export async function checkAndAssignLevelRoles(
|
|
guild: Guild,
|
|
userId: string,
|
|
newLevel: number,
|
|
) {
|
|
try {
|
|
if (!config.roles.levelRoles || config.roles.levelRoles.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const member = await guild.members.fetch(userId);
|
|
if (!member) return;
|
|
|
|
const rolesToAdd = config.roles.levelRoles
|
|
.filter((role) => role.level <= newLevel)
|
|
.map((role) => role.roleId);
|
|
|
|
if (rolesToAdd.length === 0) return;
|
|
|
|
const existingLevelRoles = config.roles.levelRoles.map((r) => r.roleId);
|
|
const rolesToRemove = member.roles.cache.filter((role) =>
|
|
existingLevelRoles.includes(role.id),
|
|
);
|
|
if (rolesToRemove.size > 0) {
|
|
await member.roles.remove(rolesToRemove);
|
|
}
|
|
|
|
const highestRole = rolesToAdd[rolesToAdd.length - 1];
|
|
await member.roles.add(highestRole);
|
|
|
|
return highestRole;
|
|
} catch (error) {
|
|
console.error('Error assigning level roles:', error);
|
|
}
|
|
}
|