367 lines
16 KiB
Python
367 lines
16 KiB
Python
import asyncio
|
|
import base64
|
|
import logging
|
|
import random
|
|
import typing as t
|
|
from io import BytesIO
|
|
from time import perf_counter
|
|
|
|
import aiohttp
|
|
import discord
|
|
from redbot.core import bank
|
|
from redbot.core.i18n import Translator
|
|
from redbot.core.utils.chat_formatting import box, humanize_number
|
|
|
|
from ..abc import MixinMeta
|
|
from ..common import formatter, utils
|
|
from ..common.models import Profile
|
|
from ..generator.styles import default, runescape
|
|
|
|
log = logging.getLogger("red.vrt.levelup.shared.profile")
|
|
_ = Translator("LevelUp", __file__)
|
|
|
|
|
|
class ProfileFormatting(MixinMeta):
|
|
async def add_xp(self, member: discord.Member, xp: int) -> int:
|
|
"""Add XP to a user and check for level ups"""
|
|
if not isinstance(member, discord.Member):
|
|
raise TypeError("member must be a discord.Member")
|
|
conf = self.db.get_conf(member.guild)
|
|
profile = conf.get_profile(member)
|
|
profile.xp += xp
|
|
self.save()
|
|
return int(profile.xp)
|
|
|
|
async def set_xp(self, member: discord.Member, xp: int) -> int:
|
|
"""Set a user's XP and check for level ups"""
|
|
if not isinstance(member, discord.Member):
|
|
raise TypeError("member must be a discord.Member")
|
|
conf = self.db.get_conf(member.guild)
|
|
profile = conf.get_profile(member)
|
|
profile.xp = xp
|
|
self.save()
|
|
return int(profile.xp)
|
|
|
|
async def remove_xp(self, member: discord.Member, xp: int) -> int:
|
|
"""Remove XP from a user and check for level ups"""
|
|
if not isinstance(member, discord.Member):
|
|
raise TypeError("member must be a discord.Member")
|
|
conf = self.db.get_conf(member.guild)
|
|
profile = conf.get_profile(member)
|
|
profile.xp -= xp
|
|
self.save()
|
|
return int(profile.xp)
|
|
|
|
async def get_profile_background(
|
|
self, user_id: int, profile: Profile, try_return_url: bool = False
|
|
) -> t.Union[bytes, str]:
|
|
"""
|
|
Get a background for a user's profile in the following priority:
|
|
- Custom background selected by user
|
|
- Banner of user's Discord profile
|
|
- Random background
|
|
"""
|
|
if profile.background == "default":
|
|
if banner_url := await self.get_banner(user_id):
|
|
if try_return_url:
|
|
return banner_url
|
|
if banner_bytes := await utils.get_content_from_url(banner_url):
|
|
return banner_bytes
|
|
|
|
if profile.background.lower().startswith("http"):
|
|
if try_return_url:
|
|
return profile.background
|
|
if content := await utils.get_content_from_url(profile.background):
|
|
return content
|
|
|
|
valid = list(self.backgrounds.glob("*.webp")) + list(self.custom_backgrounds.iterdir())
|
|
if profile.background == "random":
|
|
return random.choice(valid).read_bytes()
|
|
|
|
# See if filename is specified
|
|
for path in valid:
|
|
if profile.background == path.stem or profile.background == path.name:
|
|
return path.read_bytes()
|
|
|
|
# If we're here then the profile's preference failed
|
|
# Try banner first if not default
|
|
if profile.background != "default":
|
|
if banner_url := await self.get_banner(user_id):
|
|
if try_return_url:
|
|
return banner_url
|
|
if banner_bytes := await utils.get_content_from_url(banner_url):
|
|
return banner_bytes
|
|
|
|
return random.choice(valid).read_bytes()
|
|
|
|
async def get_banner(self, user_id: int) -> t.Optional[str]:
|
|
"""Fetch a user's banner from Discord's API
|
|
|
|
Args:
|
|
user_id (int): The ID of the user
|
|
|
|
Returns:
|
|
t.Optional[str]: The URL of the user's banner image, or None if no banner is found
|
|
"""
|
|
req = await self.bot.http.request(discord.http.Route("GET", "/users/{uid}", uid=user_id))
|
|
if banner_id := req.get("banner"):
|
|
return f"https://cdn.discordapp.com/banners/{user_id}/{banner_id}?size=1024"
|
|
|
|
async def get_user_profile(
|
|
self, member: discord.Member, reraise: bool = False
|
|
) -> t.Union[discord.Embed, discord.File]:
|
|
"""
|
|
Get a user's profile as an embed or file
|
|
If embed profiles are disabled, a file will be returned, otherwise an embed will be returned
|
|
|
|
Args:
|
|
member (discord.Member): The member to get the profile for
|
|
reraise (bool, optional): Fetching profiles will normally catch almost all exceptions and try to
|
|
handle them silently, this will make them throw an exception. Defaults to False.
|
|
|
|
Returns:
|
|
t.Union[discord.Embed, discord.File]: An embed or file containing the user's profile
|
|
"""
|
|
if not isinstance(member, discord.Member):
|
|
raise TypeError("member must be a discord.Member")
|
|
guild = member.guild
|
|
conf = self.db.get_conf(guild)
|
|
profile = conf.get_profile(member)
|
|
|
|
last_level_xp = conf.algorithm.get_xp(profile.level)
|
|
current_xp = int(profile.xp)
|
|
next_level_xp = conf.algorithm.get_xp(profile.level + 1)
|
|
log.debug(f"last_level_xp: {last_level_xp}, current_xp: {current_xp}, next_level_xp: {next_level_xp}")
|
|
if current_xp >= next_level_xp:
|
|
# Rare but possible
|
|
log.warning(f"User {member} has more XP than needed for next level")
|
|
await self.check_levelups(guild, member, profile, conf)
|
|
return await self.get_user_profile(member)
|
|
|
|
current_diff = next_level_xp - last_level_xp
|
|
progress = current_diff - (next_level_xp - current_xp)
|
|
|
|
stat = await asyncio.to_thread(
|
|
formatter.get_user_position,
|
|
guild=guild,
|
|
conf=conf,
|
|
lbtype="xp",
|
|
target_user=member.id,
|
|
key="xp",
|
|
)
|
|
bar = utils.get_bar(progress, current_diff)
|
|
|
|
level = conf.emojis.get("level", self.bot)
|
|
trophy = conf.emojis.get("trophy", self.bot)
|
|
star = conf.emojis.get("star", self.bot)
|
|
chat = conf.emojis.get("chat", self.bot)
|
|
mic = conf.emojis.get("mic", self.bot)
|
|
bulb = conf.emojis.get("bulb", self.bot)
|
|
money = conf.emojis.get("money", self.bot)
|
|
|
|
# Prestige data
|
|
pdata = None
|
|
if profile.prestige and profile.prestige in conf.prestigedata:
|
|
pdata = conf.prestigedata[profile.prestige]
|
|
|
|
if conf.use_embeds or self.db.force_embeds:
|
|
# Use member's color if available, otherwise use ruby red
|
|
color = member.color
|
|
if color == discord.Color.default():
|
|
color = discord.Color.from_rgb(155, 17, 30) # Ruby red
|
|
|
|
# Create a clean, modern layout
|
|
description = []
|
|
|
|
# Add server score/rank info
|
|
if stat is not None:
|
|
description.append(f"Server Score: **{humanize_number(current_xp)}** XP")
|
|
if isinstance(stat, dict) and "position" in stat:
|
|
description.append(f"Rank: **#{humanize_number(stat['position'])}**")
|
|
|
|
# Add level progress bar
|
|
progress_perc = (progress / current_diff) * 100
|
|
filled_blocks = int(progress_perc / 10)
|
|
progress_bar = "█" * filled_blocks + "░" * (10 - filled_blocks)
|
|
description.append(f"\n{progress_bar} {progress_perc:.1f}%")
|
|
description.append(f"**{humanize_number(progress)}**/**{humanize_number(current_diff)}** XP until level {profile.level + 1}")
|
|
|
|
# Add stats in a clean format
|
|
stats = []
|
|
if profile.prestige and profile.prestige in conf.prestigedata:
|
|
pdata = conf.prestigedata[profile.prestige]
|
|
stats.append(f"{trophy} Prestige {humanize_number(profile.prestige)} {pdata.emoji_string}")
|
|
stats.append(f"{level} Level {humanize_number(profile.level)}")
|
|
stats.append(f"{star} {humanize_number(profile.stars)} stars")
|
|
stats.append(f"{chat} {humanize_number(profile.messages)} messages")
|
|
stats.append(f"{mic} {utils.humanize_delta(profile.voice)} voice time")
|
|
|
|
if conf.showbal:
|
|
balance = await bank.get_balance(member)
|
|
creditname = await bank.get_currency_name(guild)
|
|
stats.append(f"{money} {humanize_number(balance)} {creditname}")
|
|
|
|
description.append("\n" + "\n".join(stats))
|
|
|
|
embed = discord.Embed(
|
|
description="\n".join(description),
|
|
color=color
|
|
)
|
|
|
|
# Set author with member info
|
|
display_name = member.display_name if profile.show_displayname else member.name
|
|
embed.set_author(
|
|
name=f"{display_name}'s Profile",
|
|
icon_url=member.display_avatar.url
|
|
)
|
|
|
|
# Add member ID as footer
|
|
embed.set_footer(text=f"ID: {member.id}")
|
|
|
|
return embed
|
|
|
|
kwargs = {
|
|
"username": member.display_name if profile.show_displayname else member.name,
|
|
"status": str(member.status).strip(),
|
|
"level": profile.level,
|
|
"messages": profile.messages,
|
|
"voicetime": profile.voice,
|
|
"stars": profile.stars,
|
|
"prestige": profile.prestige,
|
|
"previous_xp": last_level_xp,
|
|
"current_xp": current_xp,
|
|
"next_xp": next_level_xp,
|
|
"position": stat["position"],
|
|
"blur": profile.blur,
|
|
"base_color": member.color.to_rgb() if member.color.to_rgb() != (0, 0, 0) else None,
|
|
"user_color": utils.string_to_rgb(profile.namecolor) if profile.namecolor else None,
|
|
"stat_color": utils.string_to_rgb(profile.statcolor) if profile.statcolor else None,
|
|
"level_bar_color": utils.string_to_rgb(profile.barcolor) if profile.barcolor else None,
|
|
"render_gif": self.db.render_gifs,
|
|
"reraise": reraise,
|
|
}
|
|
|
|
profile_style = conf.style_override or profile.style
|
|
if self.db.external_api_url or (self.db.internal_api_port and self.api_proc):
|
|
# We'll use the external/internal API, try to get URLs instead for faster http requests
|
|
kwargs["avatar_bytes"] = member.display_avatar.url
|
|
if profile_style != "runescape":
|
|
kwargs["background_bytes"] = await self.get_profile_background(member.id, profile, try_return_url=True)
|
|
if pdata and pdata.emoji_url:
|
|
kwargs["prestige_emoji"] = pdata.emoji_url
|
|
if member.top_role.icon:
|
|
kwargs["role_icon"] = member.top_role.icon.url
|
|
else:
|
|
kwargs["avatar_bytes"] = await member.display_avatar.read()
|
|
if profile_style != "runescape":
|
|
kwargs["background_bytes"] = await self.get_profile_background(member.id, profile)
|
|
if pdata and pdata.emoji_url:
|
|
emoji_bytes = await utils.get_content_from_url(pdata.emoji_url)
|
|
kwargs["prestige_emoji"] = emoji_bytes
|
|
if member.top_role.icon:
|
|
kwargs["role_icon"] = await member.top_role.icon.read()
|
|
|
|
if profile.font:
|
|
if (self.fonts / profile.font).exists():
|
|
kwargs["font_path"] = str(self.fonts / profile.font)
|
|
elif (self.custom_fonts / profile.font).exists():
|
|
kwargs["font_path"] = str(self.custom_fonts / profile.font)
|
|
|
|
if conf.showbal:
|
|
kwargs["balance"] = await bank.get_balance(member)
|
|
kwargs["currency_name"] = await bank.get_currency_name(guild)
|
|
|
|
if background_bytes := kwargs.get("background_bytes"):
|
|
# Sometimes discord's CDN returns b'This content is no longer available.'
|
|
# If this occurs we'll reset the background to default
|
|
if "This content is no longer available." in str(background_bytes):
|
|
profile.background = "default"
|
|
self.save()
|
|
log.warning(
|
|
f"User {member.name} ({member.id}) has a background that no longer exists! Resetting to default"
|
|
)
|
|
kwargs["background_bytes"] = await self.get_profile_background(member.id, profile)
|
|
|
|
endpoints = {
|
|
"default": "fullprofile",
|
|
"runescape": "runescape",
|
|
}
|
|
payload = aiohttp.FormData()
|
|
if self.db.external_api_url or (self.db.internal_api_port and self.api_proc):
|
|
for key, value in kwargs.items():
|
|
if value is None:
|
|
continue
|
|
if isinstance(value, bytes):
|
|
payload.add_field(key, value, filename="data")
|
|
else:
|
|
payload.add_field(key, str(value))
|
|
|
|
if external_url := self.db.external_api_url:
|
|
try:
|
|
url = f"{external_url}/{endpoints[profile_style]}"
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.post(url, data=payload) as response:
|
|
if response.status == 200:
|
|
data = await response.json()
|
|
img_b64, animated = data["b64"], data["animated"]
|
|
img_bytes = base64.b64decode(img_b64)
|
|
ext = "gif" if animated else "webp"
|
|
return discord.File(BytesIO(img_bytes), filename=f"profile.{ext}")
|
|
log.error(f"Failed to fetch profile from external API: {response.status}")
|
|
except Exception as e:
|
|
log.error("Failed to fetch profile from external API", exc_info=e)
|
|
elif self.db.internal_api_port and self.api_proc:
|
|
try:
|
|
url = f"http://127.0.0.1:{self.db.internal_api_port}/{endpoints[profile_style]}"
|
|
async with aiohttp.ClientSession(trust_env=True) as session:
|
|
async with session.post(url, data=payload, ssl=False) as response:
|
|
if response.status == 200:
|
|
data = await response.json()
|
|
img_b64, animated = data["b64"], data["animated"]
|
|
img_bytes = base64.b64decode(img_b64)
|
|
ext = "gif" if animated else "webp"
|
|
return discord.File(BytesIO(img_bytes), filename=f"profile.{ext}")
|
|
log.error(f"Failed to fetch profile from internal API: {response.status}")
|
|
except Exception as e:
|
|
log.error("Failed to fetch profile from internal API", exc_info=e)
|
|
|
|
# Use the default generator
|
|
funcs = {
|
|
"default": default.generate_default_profile, # Modern default style
|
|
"runescape": runescape.generate_runescape_profile,
|
|
}
|
|
|
|
profile_style = profile.style if profile.style in funcs else "default" # Default to default style
|
|
|
|
def _run() -> discord.File:
|
|
img_bytes, animated = funcs[profile_style](**kwargs)
|
|
ext = "gif" if animated else "webp"
|
|
return discord.File(BytesIO(img_bytes), filename=f"profile.{ext}")
|
|
|
|
file = await asyncio.to_thread(_run)
|
|
return file
|
|
|
|
async def get_user_profile_cached(self, member: discord.Member) -> t.Union[discord.File, discord.Embed]:
|
|
"""Cached version of get_user_profile"""
|
|
if not self.db.cache_seconds:
|
|
return await self.get_user_profile(member)
|
|
now = perf_counter()
|
|
cachedata = self.profile_cache.setdefault(member.guild.id, {}).get(member.id)
|
|
if cachedata is None:
|
|
file = await self.get_user_profile(member)
|
|
if not isinstance(file, discord.File):
|
|
return file
|
|
filebytes = file.fp.read()
|
|
self.profile_cache[member.guild.id][member.id] = (now, filebytes)
|
|
return discord.File(BytesIO(filebytes), filename="profile.webp")
|
|
|
|
last_used, imgbytes = cachedata
|
|
if last_used and now - last_used < self.db.cache_seconds:
|
|
return discord.File(BytesIO(imgbytes), filename="profile.webp")
|
|
|
|
file = await self.get_user_profile(member)
|
|
if not isinstance(file, discord.File):
|
|
return file
|
|
filebytes = file.fp.read()
|
|
self.profile_cache[member.guild.id][member.id] = (now, filebytes)
|
|
return discord.File(BytesIO(filebytes), filename="profile.webp")
|