Files
DiscordBot/cogs/xp.py
T
2025-09-16 15:00:16 +02:00

246 lines
9.7 KiB
Python
Executable File

import discord
from discord.ext import commands
from utils.sql_commands import DatabaseManager
from utils.bank_functions import *
from random import randint
from math import log2
from datetime import datetime
import io
from PIL import Image, ImageDraw, ImageFont
import logging
import os
def calculate_xp_needed_for_next_level(level: int) -> int:
"""Calculates the XP needed for the next level."""
return int(35 * (level**2) + 35 * level)
class XP(commands.Cog):
def __init__(self, client):
self.client = client
self.db = DatabaseManager("")
@commands.Cog.listener()
async def on_message(self, ctx: discord.Message) -> None:
if ctx.author.bot:
return
try:
data = self.db.fetch_one(
"SELECT XP, LEVEL FROM users WHERE ID = %s", (ctx.author.id,)
)
if data is None:
# Insert a new row for this user
current_level = 0
current_xp = 0
self.db.execute_query(
"INSERT INTO users (ID, XP, LEVEL) VALUES (%s, %s, %s)",
(ctx.author.id, current_xp, current_level),
)
else:
current_level = data.get("LEVEL", 0)
current_xp = data.get("XP", 0)
logging.info(f"XP: {current_xp}, Level: {current_level}")
# Balanced XP calculation
base = randint(5, 10)
length_bonus = (
min(len(ctx.content), 100) // 10
) # +1 XP per 10 chars, max +10
extra_xp = base + length_bonus
extra_xp = min(extra_xp, 20) # cap at 20 XP per message
current_xp += extra_xp
if current_xp > calculate_xp_needed_for_next_level(current_level):
current_level += 1
player_balance = self.db.fetch_one(
"SELECT BANK FROM economy WHERE ID = %s", (ctx.author.id,)
)
player_balance = player_balance.get("BANK", 0) if player_balance else 0
levelup_bonus = randint(500, 2000)
new_balance = player_balance + levelup_bonus
await update_money(ctx.author, bank=new_balance)
await ctx.channel.send(
f"Congrats!! You achieved a new level.\nYou are now level {current_level}. You received {levelup_bonus}<:flooney:1194943899765051473>"
)
current_xp = 0
# Use INSERT ... ON DUPLICATE KEY UPDATE for XP/level
self.db.execute_query(
"INSERT INTO users (ID, XP, LEVEL) VALUES (%s, %s, %s) "
"ON DUPLICATE KEY UPDATE XP = %s, LEVEL = %s",
(ctx.author.id, current_xp, current_level, current_xp, current_level),
)
except Exception as e:
logging.error(f"Error in XP on_message: {e}")
@commands.command()
async def top(self, ctx: commands.Context) -> None:
embed = discord.Embed(
colour=discord.Colour.purple(),
timestamp=ctx.message.created_at,
title="Top Messengers",
)
users = self.db.fetch_all("SELECT ID, XP, LEVEL FROM users")
if not users:
return
sorted_users = sorted(users, key=lambda x: int(x["XP"]), reverse=True)[:10]
data = []
for index, member in enumerate(sorted_users, start=1):
try:
member_obj = await self.client.fetch_user(int(member["ID"]))
member_name = (
member_obj.display_name
if hasattr(member_obj, "display_name")
else str(member_obj)
)
except Exception:
member_name = f"User {member['ID']}"
member_xp, member_lvl = int(member["XP"]), int(member["LEVEL"])
medals = ["🥇", "🥈", "🥉"]
msg = f"**{medals[index - 1] if index <= 3 else index} `{member_name}` -- {member_xp:,}xp, {member_lvl:,}level**"
data.append(msg)
msg = "\n".join(data)
embed.description = f"It's Based on XP of Global Users\n\n{msg}"
guild_name = ctx.guild.name if ctx.guild is not None else "Direct Message"
embed.set_footer(text=f"GLOBAL - {guild_name}")
embed.color = discord.Color(0x00FF00)
await ctx.reply(embed=embed, mention_author=False)
@commands.command()
async def stats(self, ctx: commands.Context) -> None:
"""Shows the user's current XP and level."""
try:
# Use a fallback background if the random one doesn't exist
bg_path = f"images/image_{randint(1,100)}.jpg"
if not os.path.exists(bg_path):
bg_path = "images/default.jpg"
profile_image = await self.create_profile_card(ctx.author, bg_path)
await ctx.send(file=discord.File(profile_image, "profile.png"))
if profile_image is not None:
profile_image.close()
except ZeroDivisionError:
await ctx.send(f"{ctx.author.mention}, you haven't earned any XP yet.")
except Exception as e:
await ctx.send(f"Error creating profile card: {e}")
logging.error(f"Error creating profile card: {e}")
async def create_profile_card(
self, user: discord.Member | discord.User, background_image_path: str
) -> io.BytesIO:
"""Create a profile card image for a user with a custom background and text overlay."""
card_width, card_height = 400, 200
text_color = (255, 255, 255)
progress_bar_color = (0, 255, 0)
outline_color = (255, 255, 255)
tint_color = (50, 50, 50)
transparency = 25
opacity = int(255 * transparency / 100)
# Load and resize the background image
try:
background_image = Image.open(background_image_path).resize(
(card_width, card_height)
)
except Exception:
# Fallback to a plain background if image fails
# type: ignore[reportArgumentType] - Pillow expects a tuple for RGBA color, but Pylance incorrectly warns here
background_image = Image.new(
"RGBA", (card_width, card_height), (30, 30, 30, 255) # type: ignore[reportArgumentType] - Pillow expects a tuple for RGBA color, but Pylance incorrectly warns here
)
card_image = Image.new("RGBA", (card_width, card_height))
card_image.paste(background_image, (0, 0))
overlay = Image.new(
"RGBA",
card_image.size,
tint_color + (opacity,), # type: ignore[reportArgumentType] - Pillow expects a tuple for RGBA color, but Pylance incorrectly warns here
)
card_image = Image.alpha_composite(card_image, overlay)
draw = ImageDraw.Draw(card_image)
# Load fonts with fallback
font_path = "arial.ttf"
try:
font = ImageFont.truetype(font_path, 20)
except Exception:
font = ImageFont.load_default()
draw.text((10, 10), f"User: {user.display_name}", fill=text_color, font=font)
user_info = self.db.fetch_one(
"SELECT XP, LEVEL FROM users WHERE ID = %s", (user.id,)
)
draw.text(
(10, 40), f"Level: {user_info.get('LEVEL', 0)}", fill=text_color, font=font
)
draw.text(
(10, 70),
f"XP: {user_info.get('XP', 0)}/{calculate_xp_needed_for_next_level(user_info.get('LEVEL', 0))}",
fill=text_color,
font=font,
)
draw.text(
(10, 100),
f"Next level in: {calculate_xp_needed_for_next_level(user_info.get('LEVEL', 0))-user_info.get('XP', 0)}XP",
fill=text_color,
font=font,
)
max_xp = calculate_xp_needed_for_next_level(user_info.get("LEVEL", 0))
progress_ratio = user_info.get("XP", 0) / max_xp if max_xp > 0 else 0
progress_width = progress_ratio * 200
draw.rectangle([(10, 130), (210, 150)], outline=outline_color, width=2)
draw.rectangle([(10, 130), (10 + progress_width, 150)], fill=progress_bar_color)
try:
profile_size = 96
margin = 30
if user.avatar is not None:
profile_picture_data = await user.avatar.read()
profile_image = Image.open(io.BytesIO(profile_picture_data)).resize(
(profile_size, profile_size)
)
else:
# Use a default avatar image if user has no avatar
default_avatar_path = "images/default_avatar.png"
if os.path.exists(default_avatar_path):
profile_image = Image.open(default_avatar_path).resize(
(profile_size, profile_size)
)
else:
# Create a blank image if default not found
# type: ignore[reportArgumentType] - Pillow expects a tuple for RGBA color, but Pylance incorrectly warns here
profile_image = Image.new(
"RGBA", (profile_size, profile_size), (100, 100, 100, 255) # type: ignore[reportArgumentType] - Pillow expects a tuple for RGBA color, but Pylance incorrectly warns here
)
card_image.paste(
profile_image,
(card_width - profile_size - margin, margin),
profile_image.convert("RGBA"),
)
except Exception as error:
logging.error(f"Error fetching profile picture: {error}")
image_bytes = io.BytesIO()
card_image.save(image_bytes, format="PNG")
image_bytes.seek(0)
return image_bytes
async def setup(client):
await client.add_cog(XP(client))