Files
croccybot/src/modules/statistics/ui/leaderboard.py

557 lines
19 KiB
Python

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
)