Files
Lacie/profiles/profiles.py
Lilac-Rose 11157adf39 fix: shorten GroupCog docstrings to satisfy Discord's 100-char limit
Truncated multi-line class docstrings on tts, whitelist, suggest,
profile, and noping GroupCog subclasses — Discord rejects slash command
sync when the group description exceeds 100 characters.
2026-06-16 15:32:35 +02:00

562 lines
24 KiB
Python

import discord
from discord.ext import commands
from discord import app_commands
from PIL import Image, ImageDraw, ImageFont, ImageColor
from math import ceil
import aiohttp
import io
import os
import asyncio
from pathlib import Path
from textwrap import wrap
from .database import get_db, setup_db
import numpy as np
import traceback
FONTS_PATH = Path(__file__).parent / "fonts"
def list_fonts():
"""Return a list of font names (without extension) from the profiles/fonts directory."""
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
def generate_gradient_image(colors, width, height):
"""Generate a horizontal multi-stop gradient PIL image.
Splits the total width evenly between each consecutive color pair, renders
each segment as a 2-D numpy gradient, and pastes them into a single image.
Parameters
----------
colors:
List of hex color strings (e.g. ["#FF0000", "#00FF00", "#0000FF"]).
width, height:
Pixel dimensions of the output image.
"""
try:
# source: https://note.nkmk.me/en/python-numpy-generate-gradation-image/
def get_gradient_2d(start, stop, width, height, is_horizontal):
if is_horizontal:
return np.tile(np.linspace(start, stop, width), (height, 1))
else:
return np.tile(np.linspace(start, stop, height), (width, 1)).T
def get_gradient_3d(width, height, start_list, stop_list, is_horizontal_list):
result = np.zeros((height, width, len(start_list)), dtype=float)
for i, (start, stop, is_horizontal) in enumerate(zip(start_list, stop_list, is_horizontal_list)):
result[:, :, i] = get_gradient_2d(start, stop, width, height, is_horizontal)
return result
# Generate all the gradients
gradient_images = []
gradient_image_width = ceil(width / (len(colors) - 1))
i = 0
while i < len(colors) - 1:
# colors is an array of stringified hex codes
gradient_images.append(Image.fromarray(np.uint8(get_gradient_3d(gradient_image_width, height, ImageColor.getcolor(colors[i], "RGB"), ImageColor.getcolor(colors[i+1], "RGB"), (True, True, True)))))
i += 1
# Paste them into one image
i = 0
final_img = Image.new('RGB', (width, height))
for i, image in enumerate(gradient_images):
final_img.paste(image, (gradient_image_width * i, 0))
return final_img
except Exception as e:
print(e)
print(traceback.format_exc())
async def font_name_autocomplete(
interaction: discord.Interaction,
current: str,
):
"""Autocomplete callback for the font_name parameter in /profile set."""
fonts = list_fonts()
return_array = [
app_commands.Choice(name=font_name, value=font_name)
for font_name in fonts if current.lower() in font_name.lower()
]
return return_array
class Profiles(commands.GroupCog, name="profile"):
"""View and customise your server profile card."""
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):
"""Render and send a preview image showing every available font with a sample sentence."""
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), can have multiple colors as a gradient",
fav_game="Your favorite game",
fav_artist="Your favorite music artist",
birthday="Your birthday (MM-DD)",
font_name="Font to use (see /profile fonts)"
)
@app_commands.autocomplete(font_name=font_name_autocomplete)
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):
"""Update profile fields for the caller.
Only provided (non-None) fields are written; existing values for
omitted fields are preserved. bg_color accepts space-separated hex
codes (up to 5) for gradient backgrounds.
"""
# Defer the response to show "thinking"
await interaction.response.defer(thinking=True)
# Validate bg_color format
if bg_color is not None:
for color in bg_color.split(' '):
if not color.startswith('#') or len(color) not in [4, 7]:
await interaction.followup.send("Background colors must be hex colors starting with # (e.g. #FF5733 or #F57). To set gradients, do multiple hex colors separated with spaces (e.g. #FF5733 #BB153A).")
return
if len(bg_color.split(' ')) > 5:
await interaction.followup.send("That's too many colors! Please use 5 colors or fewer.")
return
# Validate hex characters
try:
int(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 available. 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):
"""Render and send the member's profile card as a PNG attachment."""
# 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
for color in bg_color.split(' '):
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 Exception:
pass # Use default if invalid
img = Image.new("RGB", (img_width, img_height), base_color)
if len(bg_color.split(' ')) >= 2:
img.paste(generate_gradient_image(bg_color.split(' '), img_width, img_height))
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 Exception:
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 Exception:
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))