Lacie/profiles/profiles.py
2026-02-03 18:17:11 +01:00

473 lines
21 KiB
Python

import discord
from discord.ext import commands
from discord import app_commands
from PIL import Image, ImageDraw, ImageFont
import aiohttp
import io
import os
import asyncio
from textwrap import wrap
from .database import get_db, setup_db
FONTS_PATH = os.path.join(os.path.dirname(__file__), "fonts")
def list_fonts():
fonts = []
if os.path.exists(FONTS_PATH):
for f in os.listdir(FONTS_PATH):
if f.lower().endswith((".ttf", ".otf")):
fonts.append(os.path.splitext(f)[0])
return fonts
class Profiles(commands.GroupCog, name="profile"):
"""Profile commands"""
def __init__(self, bot):
self.bot = bot
setup_db()
@app_commands.command(name="fonts", description="List all available fonts with a visual preview.")
async def list_fonts_cmd(self, interaction: discord.Interaction):
await interaction.response.defer(thinking=True)
fonts = list_fonts()
if not fonts:
await interaction.followup.send("No fonts found in the fonts folder.")
return
async def generate_font_preview():
def _generate():
font_sample_text = "The quick brown fox jumps over the lazy dog"
margin = 40
spacing = 90
img_width = 1600
img_height = max(400, len(fonts) * spacing + 60)
img = Image.new("RGB", (img_width, img_height), "#1e1e1e")
draw = ImageDraw.Draw(img)
y = margin
for name in fonts:
font_path_ttf = os.path.join(FONTS_PATH, f"{name}.ttf")
font_path_otf = os.path.join(FONTS_PATH, f"{name}.otf")
try:
if os.path.exists(font_path_ttf):
preview_font = ImageFont.truetype(font_path_ttf, 36)
elif os.path.exists(font_path_otf):
preview_font = ImageFont.truetype(font_path_otf, 36)
else:
preview_font = ImageFont.load_default()
except Exception:
preview_font = ImageFont.load_default()
# Slightly smaller font name for balance
try:
if os.path.exists(font_path_ttf):
name_font = ImageFont.truetype(font_path_ttf, 32)
elif os.path.exists(font_path_otf):
name_font = ImageFont.truetype(font_path_otf, 32)
else:
name_font = ImageFont.load_default()
except Exception:
name_font = ImageFont.load_default()
# Draw font name in lilac-blue
draw.text((margin, y), f"{name}:", font=name_font, fill="#7289DA")
# Draw preview text with more horizontal space
draw.text((350, y), font_sample_text, font=preview_font, fill="#FFFFFF")
# Optional divider line for clarity
draw.line([(margin, y + spacing - 15), (img_width - margin, y + spacing - 15)], fill="#2f3136", width=2)
y += spacing
buffer = io.BytesIO()
img.save(buffer, format="PNG")
buffer.seek(0)
return buffer
return await asyncio.to_thread(_generate)
buffer = await generate_font_preview()
await interaction.followup.send(
content="Here are all available fonts:",
file=discord.File(buffer, "font_preview.png")
)
@app_commands.command(name="set", description="Set your profile information.")
@app_commands.describe(
pronouns="Your pronouns",
about_me="A short description about you",
fav_color="Your favorite color (name or hex)",
bg_color="Background color (hex with #, e.g. #FF5733)",
fav_game="Your favorite game",
fav_artist="Your favorite music artist",
birthday="Your birthday (MM-DD)",
font_name="Font to use (see /profile fonts)"
)
async def setprofile(self, interaction: discord.Interaction, pronouns: str = None, about_me: str = None, fav_color: str = None, bg_color: str = None, fav_game: str = None, fav_artist: str = None, birthday: str = None, font_name: str = None):
# Defer the response to show "thinking"
await interaction.response.defer(thinking=True)
# Validate bg_color format
if bg_color is not None:
if not bg_color.startswith('#') or len(bg_color) not in [4, 7]:
await interaction.followup.send("Background color must be a hex color starting with # (e.g. #FF5733 or #F57)")
return
# Validate hex characters
try:
int(bg_color[1:], 16)
except ValueError:
await interaction.followup.send("Invalid hex color format. Use # followed by hex digits (e.g. #FF5733)")
return
if font_name and font_name not in list_fonts():
await interaction.followup.send("That font is not avaliable. use /profile fonts to see the options.")
return
db = get_db()
cursor = db.cursor()
# Build update query dynamically to only update provided fields
update_fields = []
values = []
if pronouns is not None:
update_fields.append("pronouns = ?")
values.append(pronouns)
if about_me is not None:
update_fields.append("about_me = ?")
values.append(about_me)
if fav_color is not None:
update_fields.append("fav_color = ?")
values.append(fav_color)
if bg_color is not None:
update_fields.append("bg_color = ?")
values.append(bg_color)
if fav_game is not None:
update_fields.append("fav_game = ?")
values.append(fav_game)
if fav_artist is not None:
update_fields.append("fav_artist = ?")
values.append(fav_artist)
if birthday is not None:
update_fields.append("birthday = ?")
values.append(birthday)
if font_name is not None:
update_fields.append("font_name = ?")
values.append(font_name)
if not update_fields:
await interaction.followup.send("Please provide at least one field to update!")
db.close()
return
# Check if user exists
cursor.execute("SELECT user_id FROM profiles WHERE user_id = ?", (interaction.user.id,))
exists = cursor.fetchone()
if exists:
# Update existing profile
query = f"UPDATE profiles SET {', '.join(update_fields)} WHERE user_id = ?"
values.append(interaction.user.id)
cursor.execute(query, values)
else:
# Insert new profile with only provided fields
fields = ["user_id"] + [field.split(" = ")[0] for field in update_fields]
placeholders = ["?"] * len(fields)
query = f"INSERT INTO profiles ({', '.join(fields)}) VALUES ({', '.join(placeholders)})"
cursor.execute(query, [interaction.user.id] + values)
db.commit()
db.close()
await interaction.followup.send("Your profile has been saved!")
@app_commands.command(name="view", description="View your or another user's profile.")
async def profile(self, interaction: discord.Interaction, member: discord.Member = None):
# Defer immediately to show "thinking"
await interaction.response.defer(thinking=True)
member = member or interaction.user
db = get_db()
cursor = db.cursor()
cursor.execute("SELECT * FROM profiles WHERE user_id=?", (member.id,))
row = cursor.fetchone()
db.close()
if not row:
await interaction.followup.send("This user hasn't set up a profile yet. Use /profile set to add information to your profile")
return
_, pronouns, about_me, fav_color, bg_color, fav_game, fav_artist, birthday, font_name = row
avatar_asset = member.display_avatar.with_size(256)
avatar_bytes = await avatar_asset.read()
def generate_image():
try:
if font_name:
# Try .ttf first, then .otf
font_path_ttf = os.path.join(FONTS_PATH, f"{font_name}.ttf")
font_path_otf = os.path.join(FONTS_PATH, f"{font_name}.otf")
if os.path.exists(font_path_ttf):
font = ImageFont.truetype(font_path_ttf, 20)
font_bold = ImageFont.truetype(font_path_ttf, 32)
font_label = ImageFont.truetype(font_path_ttf, 20)
elif os.path.exists(font_path_otf):
font = ImageFont.truetype(font_path_otf, 20)
font_bold = ImageFont.truetype(font_path_otf, 32)
font_label = ImageFont.truetype(font_path_otf, 20)
else:
font = ImageFont.load_default()
font_bold = ImageFont.load_default()
font_label = ImageFont.load_default()
else:
font = ImageFont.load_default()
font_bold = ImageFont.load_default()
font_label = ImageFont.load_default()
except Exception:
font = ImageFont.load_default()
font_bold = ImageFont.load_default()
font_label = ImageFont.load_default()
img_width, img_height = 1000, 550
# Parse background color - only accept hex with #
base_color = "#0f1419" # default
if bg_color and bg_color.startswith('#'):
try:
# Validate it's a proper hex color
int(bg_color[1:], 16)
base_color = bg_color
except:
pass # Use default if invalid
img = Image.new("RGB", (img_width, img_height), base_color)
draw = ImageDraw.Draw(img)
# Draw semi-transparent panel for avatar section
panel_left_x = 30
panel_left_width = 290
draw.rounded_rectangle(
[(panel_left_x, 30), (panel_left_x + panel_left_width, 470)],
radius=20,
fill=(40, 45, 52, 200)
)
# Avatar setup - centered in left panel
avatar_size = 200
panel_center = panel_left_x + panel_left_width // 2
avatar_x = panel_center - avatar_size // 2
avatar_y = 60
# Draw glowing border around avatar
for offset in range(3):
draw.rounded_rectangle(
[(avatar_x - 6 - offset, avatar_y - 6 - offset),
(avatar_x + avatar_size + 6 + offset, avatar_y + avatar_size + 6 + offset)],
radius=15,
outline=(88, 101, 242, 100 - offset * 30),
width=2
)
# Paste avatar with rounded corners
avatar = Image.open(io.BytesIO(avatar_bytes)).convert("RGBA")
avatar = avatar.resize((avatar_size, avatar_size))
mask = Image.new('L', (avatar_size, avatar_size), 0)
mask_draw = ImageDraw.Draw(mask)
mask_draw.rounded_rectangle([(0, 0), (avatar_size, avatar_size)], radius=12, fill=255)
img.paste(avatar, (avatar_x, avatar_y), mask)
# Draw username with shadow effect - centered in panel with dynamic sizing
username_y = avatar_y + avatar_size + 25
# Dynamically scale font size to fit username in panel
max_username_width = panel_left_width - 20 # Leave some padding
username_font = font_bold
username_font_size = 32
# Keep reducing font size until it fits
while username_font_size > 16:
try:
if font_name:
font_path_ttf = os.path.join(FONTS_PATH, f"{font_name}.ttf")
font_path_otf = os.path.join(FONTS_PATH, f"{font_name}.otf")
if os.path.exists(font_path_ttf):
username_font = ImageFont.truetype(font_path_ttf, username_font_size)
elif os.path.exists(font_path_otf):
username_font = ImageFont.truetype(font_path_otf, username_font_size)
else:
username_font = ImageFont.load_default()
break
else:
username_font = ImageFont.load_default()
break
except:
username_font = ImageFont.load_default()
break
# Check if username fits
bbox = draw.textbbox((0, 0), member.display_name, font=username_font)
text_width = bbox[2] - bbox[0]
if text_width <= max_username_width:
break
username_font_size -= 2
# Shadow
draw.text((panel_center + 2, username_y + 2), member.display_name, fill=(0, 0, 0, 100), font=username_font, anchor="mt")
# Main text
draw.text((panel_center, username_y), member.display_name, fill="#FFFFFF", font=username_font, anchor="mt")
# Draw pronouns below username if available - centered in panel with wrapping
current_y = username_y + 40
if pronouns:
# Wrap pronouns text to fit panel width
max_pronouns_width = panel_left_width - 20 # Leave some padding
words = pronouns.split()
line = ""
for word in words:
test_line = f"{line} {word}".strip()
bbox = draw.textbbox((0, 0), test_line, font=font)
line_width = bbox[2] - bbox[0]
if line_width <= max_pronouns_width:
line = test_line
else:
if line:
draw.text((panel_center, current_y), line, fill="#B9BBBE", font=font, anchor="mt")
current_y += 25 # Move to next line
line = word
# Draw any remaining text
if line:
draw.text((panel_center, current_y), line, fill="#B9BBBE", font=font, anchor="mt")
current_y += 35 # Space before roles
# Draw roles below pronouns
roles = [role for role in member.roles if role.name != "@everyone"]
if roles:
# Sort roles by position (highest first, like Discord does)
roles.sort(key=lambda r: r.position, reverse=True)
# Limit to top 5 roles to avoid overflow
display_roles = roles[:5]
role_y = current_y
role_font_size = 14
try:
if font_name:
font_path_ttf = os.path.join(FONTS_PATH, f"{font_name}.ttf")
font_path_otf = os.path.join(FONTS_PATH, f"{font_name}.otf")
if os.path.exists(font_path_ttf):
role_font = ImageFont.truetype(font_path_ttf, role_font_size)
elif os.path.exists(font_path_otf):
role_font = ImageFont.truetype(font_path_otf, role_font_size)
else:
role_font = ImageFont.load_default()
else:
role_font = ImageFont.load_default()
except:
role_font = ImageFont.load_default()
for role in display_roles:
# Get role color (use white if no color)
role_color = role.color.to_rgb() if role.color.value != 0 else (185, 187, 190)
# Draw role name centered
role_text = f"{role.name}"
draw.text((panel_center, role_y), role_text, fill=role_color, font=role_font, anchor="mt")
role_y += 22
# Right side panel
panel_x = 360
panel_y = 50
panel_width = img_width - panel_x - 40
# Draw semi-transparent panel for info
draw.rounded_rectangle(
[(panel_x - 20, 30), (img_width - 30, 470)],
radius=20,
fill=(40, 45, 52, 150)
)
line_spacing = 50
max_text_width = panel_width - 60
fields = [
("Favorite Color", fav_color),
("Favorite Game", fav_game),
("Favorite Artist", fav_artist),
("Birthday", birthday),
("About Me", about_me)
]
for label, value in fields:
if value:
if label == "About Me":
# Draw label and value on same line
draw.text((panel_x, panel_y), f"{label}: ", fill="#7289DA", font=font_label)
# Calculate label width to position value text
label_bbox = draw.textbbox((0, 0), f"{label}: ", font=font_label)
label_width = label_bbox[2] - label_bbox[0]
value_x = panel_x + label_width
# Wrap About Me text
words = value.split()
line = ""
first_line = True
for word in words:
test_line = f"{line} {word}".strip()
bbox = draw.textbbox((0, 0), test_line, font=font)
line_width = bbox[2] - bbox[0]
available_width = max_text_width - (label_width if first_line else 0)
if line_width <= available_width:
line = test_line
else:
if line:
draw.text((value_x if first_line else panel_x, panel_y), line, fill="#FFFFFF", font=font)
panel_y += 30
first_line = False
value_x = panel_x
line = word
if line:
draw.text((value_x if first_line else panel_x, panel_y), line, fill="#FFFFFF", font=font)
panel_y += line_spacing
else:
# Draw label and value on same line
draw.text((panel_x, panel_y), f"{label}: ", fill="#7289DA", font=font_label)
# Calculate label width to position value text
label_bbox = draw.textbbox((0, 0), f"{label}: ", font=font_label)
label_width = label_bbox[2] - label_bbox[0]
# Draw value right after label
draw.text((panel_x + label_width, panel_y), str(value), fill="#FFFFFF", font=font)
panel_y += line_spacing
buffer = io.BytesIO()
img.save(buffer, format="PNG")
buffer.seek(0)
return buffer
buffer = await asyncio.to_thread(generate_image)
await interaction.followup.send(file=discord.File(buffer, "profile.png"))
async def setup(bot):
await bot.add_cog(Profiles(bot))