diff --git a/modules/levels/leaderboard.py b/modules/levels/leaderboard.py new file mode 100644 index 0000000..015cefd --- /dev/null +++ b/modules/levels/leaderboard.py @@ -0,0 +1,40 @@ +from typing import Optional +from discord.ext import commands +from discord import Embed, Guild +from lib.const import CONST +from ui.embeds import builder +from ui.views.leaderboard import LeaderboardCommandOptions, LeaderboardCommandView + + +class Leaderboard(commands.Cog): + def __init__(self, bot: commands.Bot) -> None: + self.bot: commands.Bot = bot + + @commands.hybrid_command( + name="leaderboard", + aliases=["lb"], + usage="leaderboard", + ) + async def leaderboard(self, ctx: commands.Context[commands.Bot]) -> None: + guild: Optional[Guild] = ctx.guild + if not guild: + return + + options: LeaderboardCommandOptions = LeaderboardCommandOptions() + view: LeaderboardCommandView = LeaderboardCommandView(ctx, options) + + embed: Embed = builder.create_embed( + theme="info", + user_name=ctx.author.name, + thumbnail_url=ctx.author.display_avatar.url, + hide_name_in_description=True, + ) + + icon: str = guild.icon.url if guild.icon else CONST.FLOWERS_ART + await view.populate_leaderboard("xp", embed, icon) + + await ctx.send(embed=embed, view=view) + + +async def setup(bot: commands.Bot) -> None: + await bot.add_cog(Leaderboard(bot)) diff --git a/poetry.lock b/poetry.lock index dc96b1a..142432f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -838,6 +838,17 @@ nodeenv = ">=1.6.0" all = ["twine (>=3.4.1)"] dev = ["twine (>=3.4.1)"] +[[package]] +name = "pytz" +version = "2024.1" +description = "World timezone definitions, modern and historical" +optional = false +python-versions = "*" +files = [ + {file = "pytz-2024.1-py2.py3-none-any.whl", hash = "sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319"}, + {file = "pytz-2024.1.tar.gz", hash = "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812"}, +] + [[package]] name = "pyyaml" version = "6.0.2" @@ -1154,4 +1165,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "189f79c9e4eaaae2cfdde9b29509d1dd15030bdf8c82f972f5883d87c74365ae" +content-hash = "1bec4428d16328dd4054cda20654e446c54aa0463b79ef32ae4cfa10de7c0dfd" diff --git a/pyproject.toml b/pyproject.toml index 92ebed9..c86138b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ aiocache = "^0.12.2" aioconsole = "^0.7.1" psutil = "^6.0.0" dropbox = "^12.0.2" +pytz = "^2024.1" [build-system] build-backend = "poetry.core.masonry.api" diff --git a/settings.yaml b/settings.yaml index e94fbd2..bff4566 100644 --- a/settings.yaml +++ b/settings.yaml @@ -14,7 +14,7 @@ art: money_coins: lumi_money_coins.png question: lumi_question.png streak: lumi_streak.png - streak_bronze: lumi_streak_bronze.png + streak_bronze: lumi_streak_bronze.png\ streak_gold: lumi_streak_gold.png streak_silver: lumi_streak_silver.png warning: lumi_warning.png diff --git a/ui/views/leaderboard.py b/ui/views/leaderboard.py new file mode 100644 index 0000000..2e9b261 --- /dev/null +++ b/ui/views/leaderboard.py @@ -0,0 +1,185 @@ +from datetime import datetime + +import discord +from discord.ext import commands + +from lib.const import CONST +from ui.embeds import builder +from services.currency_service import Currency +from services.daily_service import Dailies +from services.xp_service import XpService + + +class LeaderboardCommandOptions(discord.ui.Select): + """ + This class specifies the options for the leaderboard command: + - XP + - Currency + - Daily streak + """ + + def __init__(self) -> None: + super().__init__( + placeholder="Select a leaderboard", + min_values=1, + max_values=1, + options=[ + discord.SelectOption( + label="Levels", + description="See the top chatters of this server!", + emoji="🆙", + value="xp", + ), + discord.SelectOption( + label="Currency", + description="Who is the richest Lumi user?", + value="currency", + emoji="💸", + ), + discord.SelectOption( + label="Dailies", + description="See who has the biggest streak!", + value="dailies", + emoji="📅", + ), + ], + ) + + async def callback(self, interaction: discord.Interaction) -> None: + if self.view: + await self.view.on_select(self.values[0], interaction) + + +class LeaderboardCommandView(discord.ui.View): + """ + This view represents a dropdown menu to choose + what kind of leaderboard to show. + """ + + def __init__( + self, + ctx: commands.Context[commands.Bot], + options: LeaderboardCommandOptions, + ) -> None: + self.ctx = ctx + self.options = options + + super().__init__(timeout=180) + self.add_item(self.options) + + async def on_timeout(self) -> None: + self.stop() + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + if interaction.user and interaction.user != self.ctx.author: + embed = builder.create_embed( + theme="error", + user_name=interaction.user.name, + description=CONST.STRINGS["xp_lb_cant_use_dropdown"], + hide_name_in_description=True, + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + return False + return True + + async def on_select(self, item: str, interaction: discord.Interaction) -> None: + if not self.ctx.guild: + return + + embed = builder.create_embed( + theme="success", + user_name=interaction.user.name, + thumbnail_url=CONST.FLOWERS_ART, + hide_name_in_description=True, + ) + + icon = self.ctx.guild.icon.url if self.ctx.guild.icon else CONST.FLOWERS_ART + + await self.populate_leaderboard(item, embed, icon) + + await interaction.response.edit_message(embed=embed) + + async def populate_leaderboard(self, item: str, embed, icon): + leaderboard_methods = { + "xp": self._populate_xp_leaderboard, + "currency": self._populate_currency_leaderboard, + "dailies": self._populate_dailies_leaderboard, + } + await leaderboard_methods[item](embed, icon) + + async def _populate_xp_leaderboard(self, embed, icon): + if not self.ctx.guild: + return + + xp_lb = XpService.load_leaderboard(self.ctx.guild.id) + embed.set_author(name=CONST.STRINGS["xp_lb_author"], icon_url=icon) + + for rank, (user_id, xp, level, xp_needed_for_next_level) in enumerate( + xp_lb[:5], + start=1, + ): + try: + member = await self.ctx.guild.fetch_member(user_id) + except discord.HTTPException: + continue # skip user if not in guild + + embed.add_field( + name=CONST.STRINGS["xp_lb_field_name"].format(rank, member.name), + value=CONST.STRINGS["xp_lb_field_value"].format( + level, + xp, + xp_needed_for_next_level, + ), + inline=False, + ) + + async def _populate_currency_leaderboard(self, embed, icon): + if not self.ctx.guild: + return + + cash_lb = Currency.load_leaderboard() + embed.set_author(name=CONST.STRINGS["xp_lb_currency_author"], icon_url=icon) + embed.set_thumbnail(url=CONST.TEAPOT_ART) + + for user_id, balance, rank in cash_lb[:5]: + try: + member = await self.ctx.guild.fetch_member(user_id) + except discord.HTTPException: + member = None + + name = member.name if member else str(user_id) + + embed.add_field( + name=f"#{rank} - {name}", + value=CONST.STRINGS["xp_lb_currency_field_value"].format( + Currency.format(balance), + ), + inline=False, + ) + + async def _populate_dailies_leaderboard(self, embed, icon): + if not self.ctx.guild: + return + + daily_lb = Dailies.load_leaderboard() + embed.set_author(name=CONST.STRINGS["xp_lb_dailies_author"], icon_url=icon) + embed.set_thumbnail(url=CONST.MUFFIN_ART) + + for user_id, streak, claimed_at, rank in daily_lb[:5]: + try: + member = await self.ctx.guild.fetch_member(user_id) + except discord.HTTPException: + member = None + + name = member.name if member else user_id + + claimed_at = datetime.fromisoformat(claimed_at).date() + + embed.add_field( + name=f"#{rank} - {name}", + value=CONST.STRINGS["xp_lb_dailies_field_value"].format( + streak, + claimed_at, + ), + inline=False, + )