From cfc9ea5ea9c15351c384e6fe2eea47bda3a0c005 Mon Sep 17 00:00:00 2001 From: Conatum Date: Mon, 9 Oct 2023 18:23:10 +0300 Subject: [PATCH] feat(stats): New /profile command. --- src/modules/statistics/cog.py | 49 ++++++++++++++- src/modules/statistics/graphics/profile.py | 4 +- .../statistics/graphics/profilestats.py | 62 +++++++++++++++++++ 3 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 src/modules/statistics/graphics/profilestats.py diff --git a/src/modules/statistics/cog.py b/src/modules/statistics/cog.py index 899ed657..fd640004 100644 --- a/src/modules/statistics/cog.py +++ b/src/modules/statistics/cog.py @@ -8,14 +8,17 @@ from discord import app_commands as appcmds from discord.ui.button import ButtonStyle from meta import LionBot, LionCog, LionContext +from core.lion_guild import VoiceMode from utils.lib import error_embed from utils.ui import LeoUI, AButton, utc_now +from gui.base import CardMode from wards import low_management_ward from . import babel from .data import StatsData from .ui import ProfileUI, WeeklyMonthlyUI, LeaderboardUI from .settings import StatisticsSettings, StatisticsConfigUI +from .graphics.profilestats import get_full_profile _p = babel._p @@ -43,7 +46,7 @@ class StatsCog(LionCog): name=_p('cmd:me', "me"), description=_p( 'cmd:me|desc', - "Display your personal profile and summary statistics." + "Edit your personal profile and see your statistics." ) ) @appcmds.guild_only @@ -53,6 +56,50 @@ class StatsCog(LionCog): await ui.run(ctx.interaction) await ui.wait() + @cmds.hybrid_command( + name=_p('cmd:profile', 'profile'), + description=_p( + 'cmd:profile|desc', + "Display the target's profile and statistics summary." + ) + ) + @appcmds.rename( + member=_p('cmd:profile|param:member', "member") + ) + @appcmds.describe( + member=_p( + 'cmd:profile|param:member|desc', "Member to display profile for." + ) + ) + @appcmds.guild_only + async def profile_cmd(self, ctx: LionContext, member: Optional[discord.Member] = None): + if not ctx.guild: + return + if not ctx.interaction: + return + + member = member if member is not None else ctx.author + if member.bot: + # TODO: Localise + await ctx.reply( + "Bots cannot have profiles!", + ephemeral=True + ) + return + await ctx.interaction.response.defer(thinking=True) + # Ensure the lion exists + await self.bot.core.lions.fetch_member(member.guild.id, member.id, member=member) + + if ctx.lguild.guild_mode.voice: + mode = CardMode.VOICE + else: + mode = CardMode.TEXT + + profile_data = await get_full_profile(self.bot, member.id, member.guild.id, mode) + with profile_data: + file = discord.File(profile_data, 'profile.png') + await ctx.reply(file=file) + @cmds.hybrid_command( name=_p('cmd:stats', "stats"), description=_p( diff --git a/src/modules/statistics/graphics/profile.py b/src/modules/statistics/graphics/profile.py index 759a974b..42798cf7 100644 --- a/src/modules/statistics/graphics/profile.py +++ b/src/modules/statistics/graphics/profile.py @@ -17,11 +17,11 @@ async def get_profile_card(bot: LionBot, userid: int, guildid: int): ranks: Optional[RankCog] = bot.get_cog('RankCog') stats: Optional[StatsCog] = bot.get_cog('StatsCog') if ranks is None or stats is None: - return + raise ValueError("Cannot get profile card without ranks and stats cog loaded.") guild = bot.get_guild(guildid) if guild is None: - return + raise ValueError(f"Cannot get profile card without guild {guildid}") lion = await bot.core.lions.fetch_member(guildid, userid) luser = lion.luser diff --git a/src/modules/statistics/graphics/profilestats.py b/src/modules/statistics/graphics/profilestats.py new file mode 100644 index 00000000..3296cbea --- /dev/null +++ b/src/modules/statistics/graphics/profilestats.py @@ -0,0 +1,62 @@ +import asyncio +from io import BytesIO + +from PIL import Image + +from meta import LionBot +from gui.base import CardMode + +from .stats import get_stats_card +from .profile import get_profile_card + + +card_gap = 10 + + +async def get_full_profile(bot: LionBot, userid: int, guildid: int, mode: CardMode) -> BytesIO: + """ + Render both profile and stats for the target member in the given mode. + + Combines the resulting cards into a single image and returns the image data. + """ + # Prepare cards for rendering + get_tasks = ( + asyncio.create_task(get_stats_card(bot, userid, guildid, mode), name='get-stats-for-combined'), + asyncio.create_task(get_profile_card(bot, userid, guildid), name='get-profile-for-combined'), + ) + stats_card, profile_card = await asyncio.gather(*get_tasks) + + # Render cards + render_tasks = ( + asyncio.create_task(stats_card.render(), name='render-stats-for-combined'), + asyncio.create_task(profile_card.render(), name='render=profile-for-combined'), + ) + + # Load the card data into images + stats_data, profile_data = await asyncio.gather(*render_tasks) + with BytesIO(stats_data) as stats_stream, BytesIO(profile_data) as profile_stream: + with Image.open(stats_stream) as stats_image, Image.open(profile_stream) as profile_image: + # Create a new blank image of the correct dimenstions + stats_bbox = stats_image.getbbox(alpha_only=False) + profile_bbox = profile_image.getbbox(alpha_only=False) + + if stats_bbox is None or profile_bbox is None: + # Should be impossible, image is already checked by GUI client + raise ValueError("Could not combine, empty stats or profile image.") + + combined = Image.new( + 'RGBA', + ( + max(stats_bbox[2], profile_bbox[2]), + stats_bbox[3] + card_gap + profile_bbox[3] + ), + color=None + ) + with combined: + combined.alpha_composite(profile_image) + combined.alpha_composite(stats_image, (0, profile_bbox[3] + card_gap)) + + results = BytesIO() + combined.save(results, format='PNG', compress_type=3, compress_level=1) + results.seek(0) + return results