from enum import IntEnum import asyncio import discord from discord.ui.button import ButtonStyle, button, Button from discord.ui.select import select, Select, SelectOption from gui.base import CardMode from meta import LionBot, conf from utils.lib import MessageArgs from utils.ui import input from core.lion_guild import VoiceMode from babel.translator import ctx_translator, LazyStr from ..data import StatsData from ..graphics.leaderboard import get_leaderboard_card from .. import babel from .base import StatsUI _p = babel._p ANKI_AVAILABLE = False class LBPeriod(IntEnum): SEASON = 0 DAY = 1 WEEK = 2 MONTH = 3 ALLTIME = 4 class StatType(IntEnum): VOICE = 0 TEXT = 1 ANKI = 2 class LeaderboardUI(StatsUI): page_size = 10 guildid: int def __init__(self, bot, user, guild, **kwargs): super().__init__(bot, user, guild, **kwargs) self.data: StatsData = bot.get_cog('StatsCog').data # ----- Constants initialised on run ----- self.show_season = None self.period_starts = None # ----- UI state ----- # Whether the leaderboard is focused on the calling member self.focused = True # Current visible page number self.pagen = 0 # Current stat type self.stat_type = StatType.VOICE # Start of the current period self.current_period = LBPeriod.SEASON # Current rendered leaderboard card, if it exists self.card = None # ----- Cached and on-demand data ----- # Cache of the full leaderboards for each type and period, populated on demand # (type, period) -> List[(userid, duration)] self.lb_data = {} # Cache of the cards already displayed # (type, period) -> (pagen -> Optional[Future[Card]]) self.cache = {} self.was_chunked: bool = guild.chunked async def run(self, interaction: discord.Interaction): self._original = interaction # Fetch guild data and populate period starts lguild = await self.bot.core.lions.fetch_guild(self.guildid) periods = {} self.show_season = bool(lguild.data.season_start) if self.show_season: periods[LBPeriod.SEASON] = lguild.data.season_start self.current_period = LBPeriod.SEASON else: self.current_period = LBPeriod.ALLTIME periods[LBPeriod.DAY] = lguild.today periods[LBPeriod.WEEK] = lguild.week_start periods[LBPeriod.MONTH] = lguild.month_start alltime = (lguild.data.first_joined_at or interaction.guild.created_at).astimezone(lguild.timezone) periods[LBPeriod.ALLTIME] = alltime self.period_starts = periods self.focused = True await self.refresh() async def focus_caller(self): """ Focus the calling user, if possible. """ self.focused = True data = await self.current_data() if data: caller_index = next((i for i, (uid, _) in enumerate(data) if uid == self.userid), None) if caller_index is not None: self.pagen = caller_index // self.page_size async def _fetch_lb_data(self, stat_type, period) -> list[tuple[int, int]]: """ Worker for `fetch_lb_data`. """ if stat_type is StatType.VOICE: if period is LBPeriod.ALLTIME: data = await self.data.VoiceSessionStats.leaderboard_all(self.guildid) elif (period_start := self.period_starts.get(period, None)) is None: raise ValueError("Uninitialised period requested!") else: data = await self.data.VoiceSessionStats.leaderboard_since( self.guildid, period_start ) elif stat_type is StatType.TEXT: if period is LBPeriod.ALLTIME: data = await self.data.MemberExp.leaderboard_all(self.guildid) elif (period_start := self.period_starts.get(period, None)) is None: raise ValueError("Uninitialised period requested!") else: data = await self.data.MemberExp.leaderboard_since( self.guildid, period_start ) else: # TODO: Anki data data = [] # Filter out members which are not in the server and unranked roles and bots # Usually hits cache self.was_chunked = self.guild.chunked unranked_setting = await self.bot.get_cog('StatsCog').settings.UnrankedRoles.get(self.guild.id) unranked_roleids = set(unranked_setting.data) true_leaderboard = [] guild = self.guild for userid, stat_total in data: if member := guild.get_member(userid): if member.bot: continue if any(role.id in unranked_roleids for role in member.roles): continue true_leaderboard.append((userid, stat_total)) return true_leaderboard async def fetch_lb_data(self, stat_type, period): """ Fetch the leaderboard data for the given type and period. Uses cached futures so that requests are not repeated. """ key = (stat_type, period) future = self.lb_data.get(key, None) if future is not None and not future.cancelled(): result = await future else: future = asyncio.create_task(self._fetch_lb_data(*key)) self.lb_data[key] = future result = await future return result async def current_data(self): """ Helper method to retrieve the leaderboard data for the current mode. """ return await self.fetch_lb_data(self.stat_type, self.current_period) async def _render_card(self, stat_type, period, pagen, data): """ Render worker for the given leaderboard page. """ if data: # Calculate page data page_starts_at = pagen * self.page_size page_data = data[page_starts_at:page_starts_at + self.page_size] if not page_data: return None userids, times = zip(*page_data) positions = range(page_starts_at + 1, page_starts_at + self.page_size + 1) page_data = zip(userids, positions, times) if self.stat_type is StatType.VOICE: lguild = await self.bot.core.lions.fetch_guild(self.guildid) if lguild.guild_mode.voice is VoiceMode.VOICE: mode = CardMode.VOICE else: mode = CardMode.STUDY elif self.stat_type is StatType.TEXT: mode = CardMode.TEXT elif self.stat_type is StatType.ANKI: mode = CardMode.ANKI else: raise ValueError card = await get_leaderboard_card( self.bot, self.userid, self.guildid, mode, list(page_data) ) await card.render() return card else: # Leaderboard is empty return None async def fetch_page(self, stat_type, period, pagen): """ Fetch the requested leaderboard page as a rendered LeaderboardCard. Applies cache where possible. """ lb_data = await self.fetch_lb_data(stat_type, period) if lb_data: pagen %= (len(lb_data) // self.page_size) + (1 if len(lb_data) % self.page_size else 0) else: pagen = 0 key = (stat_type, period, pagen) if (future := self.cache.get(key, None)) is not None and not future.cancelled(): card = await future else: future = asyncio.create_task(self._render_card( stat_type, period, pagen, lb_data )) self.cache[key] = future card = await future return card # UI interface @select(placeholder="Select Activity Type") async def stat_menu(self, selection: discord.Interaction, selected): if selected.values: await selection.response.defer(thinking=True) self.stat_type = StatType(int(selected.values[0])) self.focused = True await self.refresh(thinking=selection) async def stat_menu_refresh(self): # TODO: Customise based on configuration t = self.bot.translator.t menu = self.stat_menu menu.placeholder = t(_p( 'ui:leaderboard|menu:stats|placeholder', "Select Activity Type" )) options = [] lguild = await self.bot.core.lions.fetch_guild(self.guildid) if lguild.guild_mode.voice is VoiceMode.VOICE: options.append( SelectOption( label=t(_p( 'ui:leaderboard|menu:stats|item:voice', "Voice Activity" )), value=str(StatType.VOICE.value), default=(self.stat_type == StatType.VOICE), ) ) else: options.append( SelectOption( label=t(_p( 'ui:leaderboard|menu:stats|item:study', "Study Statistics" )), value=str(StatType.VOICE.value), default=(self.stat_type == StatType.VOICE), ) ) options.append( SelectOption( label=t(_p( 'ui:leaderboard|menu:stats|item:message', "Message Activity" )), value=str(StatType.TEXT.value), default=(self.stat_type == StatType.TEXT), ) ) if ANKI_AVAILABLE: options.append( SelectOption( label=t(_p( 'ui:leaderboard|menu;stats|item:anki', "Anki Cards Reviewed" )), value=str(StatType.ANKI.value), default=(self.stat_type == StatType.ANKI), ) ) menu.options = options @button(label="This Season", style=ButtonStyle.grey) async def season_button(self, press: discord.Interaction, pressed: Button): await press.response.defer(thinking=True, ephemeral=True) self.current_period = LBPeriod.SEASON self.focused = True await self.refresh(thinking=press) @button(label="Today", style=ButtonStyle.grey) async def day_button(self, press: discord.Interaction, pressed: Button): await press.response.defer(thinking=True, ephemeral=True) self.current_period = LBPeriod.DAY self.focused = True await self.refresh(thinking=press) @button(label="This Week", style=ButtonStyle.grey) async def week_button(self, press: discord.Interaction, pressed: Button): await press.response.defer(thinking=True, ephemeral=True) self.current_period = LBPeriod.WEEK self.focused = True await self.refresh(thinking=press) @button(label="This Month", style=ButtonStyle.grey) async def month_button(self, press: discord.Interaction, pressed: Button): await press.response.defer(thinking=True, ephemeral=True) self.current_period = LBPeriod.MONTH self.focused = True await self.refresh(thinking=press) @button(label="All Time", style=ButtonStyle.grey) async def alltime_button(self, press: discord.Interaction, pressed: Button): await press.response.defer(thinking=True, ephemeral=True) self.current_period = LBPeriod.ALLTIME self.focused = True await self.refresh(thinking=press) @button(emoji=conf.emojis.backward, style=ButtonStyle.grey) async def prev_button(self, press: discord.Interaction, pressed: Button): await press.response.defer(thinking=True, ephemeral=True) self.pagen -= 1 self.focused = False await self.refresh(thinking=press) async def _prepare(self): t = self.bot.translator.t self.season_button.label = t(_p( 'ui:leaderboard|button:season|label', "This Season" )) self.day_button.label = t(_p( 'ui:leaderboard|button:day|label', "Today" )) self.week_button.label = t(_p( 'ui:leaderboard|button:week|label', "This Week" )) self.month_button.label = t(_p( 'ui:leaderboard|button:month|label', "This Month" )) self.alltime_button.label = t(_p( 'ui:leaderboard|button:alltime|label', "All Time" )) self.jump_button.label = t(_p( 'ui:leaderboard|button:jump|label', "Jump" )) @button(label="Jump", style=ButtonStyle.blurple) async def jump_button(self, press: discord.Interaction, pressed: Button): """ Jump-to-page button. Loads a page-switch dialogue. """ t = self.bot.translator.t try: interaction, value = await input( press, title=t(_p( 'ui:leaderboard|button:jump|input:title', "Jump to page" )), question=t(_p( 'ui:leaderboard|button:jump|input:question', "Page number to jump to" )) ) value = value.strip() except asyncio.TimeoutError: return if not value.lstrip('- ').isdigit(): error_embed = discord.Embed( title=t(_p( 'ui:leaderboard|button:jump|error:invalid_page', "Invalid page number, please try again!" )), colour=discord.Colour.brand_red() ) await interaction.response.send_message(embed=error_embed, ephemeral=True) else: await interaction.response.defer(thinking=True) pagen = int(value.lstrip('- ')) if value.startswith('-'): pagen = -1 * pagen elif pagen > 0: pagen = pagen - 1 self.pagen = pagen self.focused = False await self.refresh(thinking=interaction) async def jump_button_refresh(self): component = self.jump_button data = await self.current_data() if not data: # Component should be hidden component.label = "-/-" component.disabled = True else: page_count = (len(data) // self.page_size) + 1 pagen = self.pagen % page_count component.label = "{}/{}".format(pagen + 1, page_count) component.disabled = (page_count <= 1) @button(emoji=conf.emojis.forward, style=ButtonStyle.grey) async def next_button(self, press: discord.Interaction, pressed: Button): await press.response.defer(thinking=True) self.pagen += 1 self.focused = False await self.refresh(thinking=press) async def make_message(self) -> MessageArgs: """ Generate UI message arguments from stored data """ t = self.bot.translator.t chunk_warning = t(_p( 'ui:leaderboard|chunk_warning', "**Note:** Could not retrieve member list from Discord, so some members may be missing. " "Try again in a minute!" )) if self.card is not None: period_start = self.period_starts[self.current_period] header = t(_p( 'ui:leaderboard|since', "Counting statistics since {timestamp}" )).format(timestamp=discord.utils.format_dt(period_start)) if not self.was_chunked: header = '\n'.join((header, chunk_warning)) args = MessageArgs( embed=None, content=header, file=self.card.as_file('leaderboard.png') ) else: if self.stat_type is StatType.VOICE: empty_description = t(_p( 'ui:leaderboard|mode:voice|message:empty|desc', "There has been no voice activity since {timestamp}" )) elif self.stat_type is StatType.TEXT: empty_description = t(_p( 'ui:leaderboard|mode:text|message:empty|desc', "There has been no message activity since {timestamp}" )) elif self.stat_type is StatType.ANKI: empty_description = t(_p( 'ui:leaderboard|mode:anki|message:empty|desc', "There have been no Anki cards reviewed since {timestamp}" )) empty_description = empty_description.format( timestamp=discord.utils.format_dt(self.period_starts[self.current_period]) ) embed = discord.Embed( colour=discord.Colour.orange(), title=t(_p( 'ui:leaderboard|message:empty|title', "Leaderboard Empty!" )), description=empty_description ) args = MessageArgs( content=chunk_warning if not self.was_chunked else None, embed=embed, files=[] ) return args async def refresh_components(self): await self._prepare() await asyncio.gather( self.jump_button_refresh(), self.close_button_refresh(), self.stat_menu_refresh() ) # Compute period row period_buttons = { LBPeriod.DAY: self.day_button, LBPeriod.WEEK: self.week_button, LBPeriod.MONTH: self.month_button } if self.show_season: period_buttons[LBPeriod.SEASON] = self.season_button else: period_buttons[LBPeriod.ALLTIME] = self.alltime_button for period, component in period_buttons.items(): if period is self.current_period: component.style = ButtonStyle.blurple else: component.style = ButtonStyle.grey period_row = tuple(period_buttons.values()) # Compute page row data = await self.current_data() multipage = len(data) > self.page_size if multipage: page_row = ( self.prev_button, self.jump_button, self.close_button, self.next_button ) else: period_row = (*period_row, self.close_button) page_row = () self._layout = [ (self.stat_menu,), period_row, page_row ] voting = self.bot.get_cog('TopggCog') if voting and not await voting.check_voted_recently(self.userid): premiumcog = self.bot.get_cog('PremiumCog') if not (premiumcog and await premiumcog.is_premium_guild(self.guild.id)): self._layout.append((voting.vote_button(),)) async def reload(self): """ Reload UI data, applying cache where possible. """ if self.focused: await self.focus_caller() self.card = await self.fetch_page( self.stat_type, self.current_period, self.pagen )