rewrite: Profile, Stats, Leaderboard.

This commit is contained in:
2023-05-14 12:31:43 +03:00
parent 16bc05d39b
commit 7f79009ac7
13 changed files with 1447 additions and 51 deletions

View File

@@ -9,11 +9,12 @@ from discord.ui.button import ButtonStyle
from meta import LionBot, LionCog, LionContext
from utils.lib import error_embed
from utils.ui import LeoUI, AButton
from wards import low_management
from . import babel
from .data import StatsData
from .ui import ProfileUI, WeeklyMonthlyUI
from .settings import StatsSettings
from .ui import ProfileUI, WeeklyMonthlyUI, LeaderboardUI
from .settings import StatisticsSettings, StatisticsConfigUI
_p = babel._p
@@ -25,12 +26,17 @@ class StatsCog(LionCog):
def __init__(self, bot: LionBot):
self.bot = bot
self.data = bot.db.load_registry(StatsData())
self.settings = StatsSettings
self.settings = StatisticsSettings()
async def cog_load(self):
await self.data.init()
self.bot.core.user_config.register_model_setting(self.settings.UserGlobalStats)
self.bot.core.guild_config.register_model_setting(self.settings.SeasonStart)
self.bot.core.guild_config.register_setting(self.settings.UnrankedRoles)
configcog = self.bot.get_cog('ConfigCog')
self.crossload_group(self.configure_group, configcog.configure_group)
@cmds.hybrid_command(
name=_p('cmd:me', "me"),
@@ -68,4 +74,67 @@ class StatsCog(LionCog):
)
@appcmds.guild_only
async def leaderboard_cmd(self, ctx: LionContext):
await ctx.interaction.response.defer(thinking=True)
ui = LeaderboardUI(self.bot, ctx.author, ctx.guild)
await ui.run(ctx.interaction)
# Setting commands
@LionCog.placeholder_group
@cmds.hybrid_group('configure', with_app_command=False)
async def configure_group(self, ctx: LionContext):
...
@configure_group.command(
name=_p('cmd:configure_statistics', "statistics"),
description=_p('cmd:configure_statistics|desc', "Statistics configuration panel")
)
@appcmds.rename(
season_start=_p('cmd:configure_statistics|param:season_start', "season_start")
)
@appcmds.describe(
season_start=_p(
'cmd:configure_statistics|param:season_start|desc',
"Time from which to start counting activity for rank badges and season leadeboards."
)
)
@cmds.check(low_management)
async def configure_statistics_cmd(self, ctx: LionContext,
season_start: Optional[str] = None):
t = self.bot.translator.t
# Type checking guards
if not ctx.guild:
return
if not ctx.interaction:
return
# Retrieve settings, using cache where possible
setting_season_start = await self.settings.SeasonStart.get(ctx.guild.id)
modified = []
if season_start is not None:
data = await setting_season_start._parse_string(ctx.guild.id, season_start)
setting_season_start.data = data
await setting_season_start.write()
modified.append(setting_season_start)
# Send update ack
if modified:
# TODO
description = t(_p(
'cmd:configure_statistics|resp:success|desc',
"Activity ranks and season leaderboard will now be measured from {season_start}."
)).format(
season_start=setting_season_start.formatted
)
embed = discord.Embed(
colour=discord.Colour.brand_green(),
description=description
)
await ctx.reply(embed=embed)
if ctx.channel.id not in StatisticsConfigUI._listening or not modified:
# Launch setting group UI
configui = StatisticsConfigUI(self.bot, ctx.guild.id, ctx.channel.id)
await configui.run(ctx.interaction)
await configui.wait()

View File

View File

@@ -1,14 +1,52 @@
from typing import Optional, Iterable
from enum import Enum
from itertools import chain
from psycopg import sql
from data import RowModel, Registry, Table
from data.columns import Integer, String, Timestamp, Bool
from data import RowModel, Registry, Table, RegisterEnum
from data.columns import Integer, String, Timestamp, Bool, Column
from utils.lib import utc_now
class StatisticType(Enum):
"""
Schema
------
CREATE TYPE StatisticType AS ENUM(
'VOICE',
'TEXT',
'ANKI'
)
"""
VOICE = ('VOICE',)
TEXT = ('TEXT',)
ANKI = ('ANKI',)
class ExpType(Enum):
"""
Schema
------
CREATE TYPE ExperienceType AS ENUM(
'VOICE_XP',
'TEXT_XP',
'QUEST_XP', -- Custom guild quests
'ACHIEVEMENT_XP', -- Individual tracked achievements
'BONUS_XP' -- Manually adjusted XP
);
"""
VOICE_XP = 'VOICE_XP',
TEXT_XP = 'TEXT_XP',
QUEST_XP = 'QUEST_XP',
ACHIEVEMENT_XP = 'ACHIEVEMENT_XP'
BONUS_XP = 'BONUS_XP'
class StatsData(Registry):
StatisticType = RegisterEnum(StatisticType, name='StatisticType')
ExpType = RegisterEnum(ExpType, name='ExperienceType')
class VoiceSessionStats(RowModel):
"""
View containing voice session statistics.
@@ -21,7 +59,7 @@ class StatsData(Registry):
guildid,
start_time,
duration,
(start_time + duration * interval '1 second') AS end_time
(timezone('UTC', start_time) + duration * interval '1 second') AS end_time
FROM session_history
UNION ALL
SELECT
@@ -115,6 +153,321 @@ class StatsData(Registry):
)
return [r['stime'] or 0 for r in await cursor.fetchall()]
@classmethod
async def leaderboard_since(cls, guildid: int, since):
"""
Return the voice totals since the given time for each member in the guild.
"""
# Retrieve sum of all sessions (incl. ongoing) ending after given time
first_query = sql.SQL(
"""
SELECT
userid,
sum(duration) as total_duration
FROM voice_sessions_combined
WHERE
guildid = %s
AND
end_time > %s
GROUP BY userid
ORDER BY total_duration DESC
"""
)
first_query_args = (guildid, since)
# Retrieve how much we "overshoot", from sessions which intersect the given time
second_query = sql.SQL(
"""
SELECT
SUM(EXTRACT(EPOCH FROM (%s - start_time))) AS diff,
userid
FROM voice_sessions_combined
WHERE
guildid = %s
AND
start_time < %s
AND
end_time > %s
GROUP BY userid
"""
)
second_query_args = (since, guildid, since, since)
conn = await cls._connector.get_connection()
async with conn.transaction():
async with conn.cursor() as cursor:
await cursor.execute(second_query, second_query_args)
overshoot_rows = await cursor.fetchall()
overshoot = {row['userid']: int(row['diff']) for row in overshoot_rows}
async with conn.cursor() as cursor:
await cursor.execute(first_query, first_query_args)
leaderboard = [
(row['userid'], int(row['total_duration'] - overshoot.get(row['userid'], 0)))
for row in await cursor.fetchall()
]
leaderboard.sort(key=lambda t: t[1], reverse=True)
return leaderboard
@classmethod
async def leaderboard_all(cls, guildid: int):
"""
Return the all-time voice totals for the given guild.
"""
query = sql.SQL(
"""
SELECT userid, sum(duration) as total_duration
FROM voice_sessions_combined
WHERE guildid = %s
GROUP BY userid
ORDER BY total_duration DESC
"""
)
conn = await cls._connector.get_connection()
async with conn.cursor() as cursor:
await cursor.execute(query, (guildid, ))
leaderboard = [
(row['userid'], int(row['total_duration']))
for row in await cursor.fetchall()
]
return leaderboard
class MemberExp(RowModel):
"""
Model representing a member experience update.
Schema
------
CREATE TABLE member_experience(
member_expid BIGSERIAL PRIMARY KEY,
guildid BIGINT NOT NULL,
userid BIGINT NOT NULL,
earned_at TIMESTAMPTZ NOT NULL DEFAULT (now() at time zone 'UTC'),
amount INTEGER NOT NULL,
exp_type ExperienceType NOT NULL,
transactionid INTEGER REFERENCES coin_transactions ON DELETE SET NULL,
FOREIGN KEY (guildid, userid) REFERENCES members ON DELETE CASCADE
);
CREATE INDEX member_experience_members ON member_experience (guildid, userid, earned_at);
CREATE INDEX member_experience_guilds ON member_experience (guildid, earned_at);
"""
_tablename_ = 'member_experience'
member_expid = Integer(primary=True)
guildid = Integer()
userid = Integer()
earned_at = Timestamp()
amount = Integer()
exp_type: Column[ExpType] = Column()
transactionid = Integer()
@classmethod
async def xp_since(cls, guildid: int, userid: int, *starts):
query = sql.SQL(
"""
SELECT
(
SELECT
SUM(amount)
FROM member_experience s
WHERE
s.guildid = %s
AND s.userid = %s
AND s.earned_at >= t._start
) AS exp
FROM
(VALUES ({}))
AS
t (_start)
ORDER BY t._start
"""
).format(
sql.SQL('), (').join(
sql.Placeholder() for _ in starts
)
)
conn = await cls._connector.get_connection()
async with conn.cursor() as cursor:
await cursor.execute(
query,
tuple(chain((guildid, userid), starts))
)
return [r['exp'] or 0 for r in await cursor.fetchall()]
@classmethod
async def xp_between(cls, guildid: int, userid: int, *points):
blocks = zip(points, points[1:])
query = sql.SQL(
"""
SELECT
(
SELECT
SUM(amount)
FROM member_experience s
WHERE
s.guildid = %s
AND s.userid = %s
AND s.earned_at >= periods._start
AND s.earned_at < periods._end
) AS period_xp
FROM
(VALUES {})
AS
periods (_start, _end)
ORDER BY periods._start
"""
).format(
sql.SQL(', ').join(
sql.SQL("({}, {})").format(sql.Placeholder(), sql.Placeholder()) for _ in points[1:]
)
)
conn = await cls._connector.get_connection()
async with conn.cursor() as cursor:
await cursor.execute(
query,
tuple(chain((guildid, userid), *blocks))
)
return [r['period_xp'] or 0 for r in await cursor.fetchall()]
@classmethod
async def leaderboard_since(cls, guildid: int, since):
"""
Return the XP totals for the given guild since the given time.
"""
query = sql.SQL(
"""
SELECT userid, sum(amount) AS total_xp
FROM member_experience
WHERE guildid = %s AND earned_at >= %s
GROUP BY userid
ORDER BY total_xp DESC
"""
)
conn = await cls._connector.get_connection()
async with conn.cursor() as cursor:
await cursor.execute(query, (guildid, since))
leaderboard = [
(row['userid'], int(row['total_xp']))
for row in await cursor.fetchall()
]
return leaderboard
@classmethod
async def leaderboard_all(cls, guildid: int):
"""
Return the all-time XP totals for the given guild.
"""
query = sql.SQL(
"""
SELECT userid, sum(amount) AS total_xp
FROM member_experience
WHERE guildid = %s
GROUP BY userid
ORDER BY total_xp DESC
"""
)
conn = await cls._connector.get_connection()
async with conn.cursor() as cursor:
await cursor.execute(query, (guildid, ))
leaderboard = [
(row['userid'], int(row['total_xp']))
for row in await cursor.fetchall()
]
return leaderboard
class UserExp(RowModel):
"""
Model representing a user experience update.
Schema
------
CREATE TABLE user_experience(
user_expid BIGSERIAL PRIMARY KEY,
userid BIGINT NOT NULL,
earned_at TIMESTAMPTZ NOT NULL DEFAULT (now() at time zone 'UTC'),
amount INTEGER NOT NULL,
exp_type ExperienceType NOT NULL,
FOREIGN KEY (userid) REFERENCES user_config ON DELETE CASCADE
);
CREATE INDEX user_experience_users ON user_experience (userid, earned_at);
"""
_tablename_ = 'user_experience'
user_expid = Integer(primary=True)
userid = Integer()
earned_at = Timestamp()
amount = Integer()
exp_type: Column[ExpType] = Column()
@classmethod
async def xp_since(cls, userid: int, *starts):
query = sql.SQL(
"""
SELECT
(
SELECT
SUM(amount)
FROM user_experience s
WHERE
s.userid = %s
AND s.start_time >= t._start
) AS exp
FROM
(VALUES ({}))
AS
t (_start)
ORDER BY t._start
"""
).format(
sql.SQL('), (').join(
sql.Placeholder() for _ in starts
)
)
conn = await cls._connector.get_connection()
async with conn.cursor() as cursor:
await cursor.execute(
query,
tuple(chain((userid,), starts))
)
return [r['exp'] or 0 for r in await cursor.fetchall()]
@classmethod
async def xp_between(cls, userid: int, *points):
blocks = zip(points, points[1:])
query = sql.SQL(
"""
SELECT
(
SELECT
SUM(global_xp)
FROM user_experience s
WHERE
s.userid = %s
AND s.start_time >= periods._start
AND s.start_time < periods._end
) AS period_xp
FROM
(VALUES {})
AS
periods (_start, _end)
ORDER BY periods._start
"""
).format(
sql.SQL(', ').join(
sql.SQL("({}, {})").format(sql.Placeholder(), sql.Placeholder()) for _ in points[1:]
)
)
conn = await cls._connector.get_connection()
async with conn.cursor() as cursor:
await cursor.execute(
query,
tuple(chain((userid,), *blocks))
)
return [r['period_xp'] or 0 for r in await cursor.fetchall()]
class ProfileTag(RowModel):
"""
Schema
@@ -256,3 +609,6 @@ class StatsData(Registry):
content = String()
completed = Bool()
_timestamp = Timestamp()
unranked_roles = Table('unranked_roles')
visible_statistics = Table('visible_statistics')

View File

@@ -5,6 +5,7 @@ from data import NULL
from meta import LionBot
from gui.cards import WeeklyGoalCard, MonthlyGoalCard
from gui.base import CardMode
from tracking.text.data import TextTrackerData
from ..data import StatsData
from ..lib import extract_weekid, extract_monthid, apply_week_offset, apply_month_offset
@@ -41,7 +42,15 @@ async def get_goals_card(
key = {'guildid': guildid or 0, 'userid': userid, 'monthid': periodid}
# Extract goals and tasks
# TODO: Major data model fixy fixy here
if guildid:
goals = await goal_model.fetch_or_create(*key.values())
else:
goals = await goal_model.fetch(*key.values())
if not goals:
from collections import defaultdict
goals = defaultdict(lambda: -1)
task_rows = await tasks_model.fetch_where(**key)
tasks = [(i, row.content, bool(row.completed)) for i, row in enumerate(task_rows)]
@@ -58,11 +67,17 @@ async def get_goals_card(
tasks_completed = results[0]['total'] if results else 0
# Set and compute correct middle goal column
# if mode in (CardMode.VOICE, CardMode.STUDY):
if True:
if mode in (CardMode.VOICE, CardMode.STUDY):
model = data.VoiceSessionStats
middle_completed = (await model.study_times_between(guildid or None, userid, start, end))[0]
middle_goal = goals['study_goal']
elif mode is CardMode.TEXT:
model = TextTrackerData.TextSessions
middle_goal = goals['message_goal']
if guildid:
middle_completed = (await model.member_messages_between(guildid, userid, start, end))[0]
else:
middle_completed = (await model.user_messages_between(userid, start, end))[0]
# Compute schedule session progress
# TODO

View File

@@ -0,0 +1,74 @@
from meta import LionBot
from gui.cards import LeaderboardCard
from gui.base import CardMode
async def get_leaderboard_card(
bot: LionBot, highlightid: int, guildid: int,
mode: CardMode,
entry_data: list[tuple[int, int, int]], # userid, position, time
):
"""
Render a leaderboard card with given parameters.
"""
guild = bot.get_guild(guildid)
if guild is None:
raise ValueError("Attempting to build leaderboard for non-existent guild!")
# Need to do two passes here in case we need to do a db request for the avatars or names
avatars = {}
names = {}
missing = []
for userid, _, _ in entry_data:
hash = name = None
if guild and (member := guild.get_member(userid)):
hash = member.avatar.key
name = member.display_name
elif (user := bot.get_user(userid)):
hash = user.avatar.key
name = user.name
elif (user_data := bot.core.data.User._cache_.get((userid,))):
hash = user_data.avatar_hash
name = user_data.name
if hash:
avatars[userid] = hash
names[userid] = name or 'Unknown'
else:
missing.append(userid)
if missing:
# We were unable to retrieve information for some userids
# Bulk-fetch missing users from data
data = await bot.core.data.User.fetch_where(userid=missing)
for user_data in data:
avatars[user_data.userid] = user_data.avatar_hash
names[user_data.userid] = user_data.name or 'Unknown'
missing.remove(user_data.userid)
if missing:
# Some of the users were missing from data
# This should be impossible (by FKEY constraints on sessions)
# But just in case...
for userid in missing:
avatars[userid] = None
names[userid] = str(userid)
highlight = None
entries = []
for userid, position, duration in entry_data:
entries.append(
(userid, position, duration, names[userid], (userid, avatars[userid]))
)
if userid == highlightid:
highlight = position
# Request Card
card = LeaderboardCard(
skin={'mode': mode},
server_name=guild.name,
entries=entries,
highlight=highlight
)
return card

View File

@@ -6,6 +6,7 @@ from data import ORDER
from meta import LionBot
from gui.cards import MonthlyStatsCard
from gui.base import CardMode
from tracking.text.data import TextTrackerData
from ..data import StatsData
from ..lib import apply_month_offset
@@ -34,8 +35,23 @@ async def get_monthly_card(bot: LionBot, userid: int, guildid: int, offset: int,
[0]*(calendar.monthrange(month.year, month.month)[1]) for month in months
]
# TODO: Select model based on card mode
if mode is CardMode.VOICE:
model = data.VoiceSessionStats
req = model.study_times_between
reqkey = (guildid or None, userid,)
elif mode is CardMode.TEXT:
model = TextTrackerData.TextSessions
if guildid:
req = model.member_messages_between
reqkey = (guildid, userid,)
else:
req = model.user_messages_between
reqkey = (userid,)
else:
# TODO: ANKI
model = data.VoiceSessionStats
req = model.study_times_between
reqkey = (guildid or None, userid,)
# Get first session
query = model.table.select_where().order_by('start_time', ORDER.ASC).limit(1)
@@ -50,10 +66,10 @@ async def get_monthly_card(bot: LionBot, userid: int, guildid: int, offset: int,
longest_streak = 0
else:
first_day = first_session.replace(hour=0, minute=0, second=0, microsecond=0)
first_month = first_day.replace(day=1)
# first_month = first_day.replace(day=1)
# Build list of day starts up to now, or end of requested month
requests = []
requests = [first_day]
end_of_req = target_end if offset else today
day = first_day
while day <= end_of_req:
@@ -61,7 +77,7 @@ async def get_monthly_card(bot: LionBot, userid: int, guildid: int, offset: int,
requests.append(day)
# Request times between requested days
day_stats = await model.study_times_between(guildid or None, userid, *requests)
day_stats = await req(*reqkey, *requests)
# Compute current streak and longest streak
current_streak = 0
@@ -79,7 +95,10 @@ async def get_monthly_card(bot: LionBot, userid: int, guildid: int, offset: int,
if day < months[0]:
break
i = offsets[(day.year, day.month)]
if mode in (CardMode.VOICE, CardMode.STUDY):
monthly[i][day.day - 1] = stat / 3600
else:
monthly[i][day.day - 1] = stat
# Get member profile
if user:

View File

@@ -0,0 +1,91 @@
from typing import Optional, TYPE_CHECKING
from datetime import datetime, timedelta
import discord
from meta import LionBot
from gui.cards import ProfileCard
from modules.ranks.cog import RankCog
from modules.ranks.utils import format_stat_range
if TYPE_CHECKING:
from ..cog import StatsCog
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
guild = bot.get_guild(guildid)
if guild is None:
return
lion = await bot.core.lions.fetch_member(guildid, userid)
luser = lion.luser
member = await lion.fetch_member()
if member:
username = (member.display_name, '#' + str(member.discriminator))
avatar = member.avatar.key
else:
username = (lion.data.display_name, "#????")
avatar = luser.data.avatar_hash
profile_badges = await stats.data.ProfileTag.fetch_tags(guildid, userid)
# Fetch current and next guild rank
season_rank = await ranks.get_member_rank(guildid, userid)
rank_type = lion.lguild.config.get('rank_type').value
crank = season_rank.current_rank
nrank = season_rank.next_rank
if crank:
roleid = crank.roleid
role = guild.get_role(roleid)
name = role.name if role else str(role.id)
minimum = crank.required
maximum = nrank.required if nrank else None
rangestr = format_stat_range(rank_type, minimum, maximum)
if maximum is None:
rangestr = f"{rangestr}"
current_rank = (name, rangestr)
if maximum:
rank_progress = (season_rank.stat - minimum) / (maximum - minimum)
else:
rank_progress = 1
else:
current_rank = None
rank_progress = 0
if nrank:
roleid = nrank.roleid
role = guild.get_role(roleid)
name = role.name if role else str(role.id)
minimum = nrank.required
guild_ranks = await ranks.get_guild_ranks(guildid)
nnrank = next((rank for rank in guild_ranks if rank.required > nrank.required), None)
maximum = nnrank.required if nnrank else None
rangestr = format_stat_range(rank_type, minimum, maximum)
if maximum is None:
rangestr = f"{rangestr}"
next_rank = (name, rangestr)
else:
next_rank = None
achievements = (0, 1)
card = ProfileCard(
user=username,
avatar=(userid, avatar),
coins=lion.data.coins, gems=luser.data.gems, gifts=0,
profile_badges=profile_badges,
achievements=achievements,
current_rank=current_rank,
rank_progress=rank_progress,
next_rank=next_rank
)
return card

View File

@@ -5,15 +5,16 @@ import discord
from meta import LionBot
from gui.cards import StatsCard
from gui.base import CardMode
from ..data import StatsData
async def get_stats_card(bot: LionBot, userid: int, guildid: int):
async def get_stats_card(bot: LionBot, userid: int, guildid: int, mode: CardMode):
data: StatsData = bot.get_cog('StatsCog').data
# TODO: Workouts
# TODO: Leaderboard rankings
# TODO: Leaderboard rankings for this season or all time
guildid = guildid or 0
lion = await bot.core.lions.fetch_member(guildid, userid)
@@ -31,20 +32,40 @@ async def get_stats_card(bot: LionBot, userid: int, guildid: int):
)
# Extract the study times for each period
study_times = await data.VoiceSessionStats.study_times_since(guildid, userid, *period_timestamps)
print("Study times", study_times)
if mode in (CardMode.STUDY, CardMode.VOICE):
model = data.VoiceSessionStats
refkey = (guildid, userid)
ref_since = model.study_times_since
ref_between = model.study_times_between
elif mode is CardMode.TEXT:
if guildid:
model = data.MemberExp
refkey = (guildid, userid)
else:
model = data.UserExp
refkey = (userid,)
ref_since = model.xp_since
ref_between = model.xp_between
else:
# TODO ANKI
model = data.VoiceSessionStats
refkey = (guildid, userid)
ref_since = model.study_times_since
ref_between = model.study_times_between
study_times = await ref_since(*refkey, *period_timestamps)
print("Period study times: ", study_times)
# Calculate streak data by requesting times per day
# First calculate starting timestamps for each day
days = list(range(0, today.day + 2))
day_timestamps = [month_start + timedelta(days=day - 1) for day in days]
study_times = await data.VoiceSessionStats.study_times_between(guildid, userid, *day_timestamps)
print("Study times", study_times)
study_times_month = await ref_between(*refkey, *day_timestamps)
# Then extract streak tuples
streaks = []
streak_start = None
for day, stime in zip(days, study_times):
for day, stime in zip(days, study_times_month):
stime = stime or 0
if stime > 0 and streak_start is None:
streak_start = day
@@ -59,5 +80,6 @@ async def get_stats_card(bot: LionBot, userid: int, guildid: int):
list(reversed(study_times)),
100,
streaks,
skin={'mode': mode}
)
return card

View File

@@ -5,6 +5,7 @@ from data import ORDER
from meta import LionBot
from gui.cards import WeeklyStatsCard
from gui.base import CardMode
from tracking.text.data import TextTrackerData
from ..data import StatsData
@@ -20,13 +21,27 @@ async def get_weekly_card(bot: LionBot, userid: int, guildid: int, offset: int,
user = await bot.fetch_user(userid)
today = lion.today
week_start = today - timedelta(days=today.weekday()) - timedelta(weeks=offset)
days = [week_start + timedelta(i) for i in range(-7, 7 if offset else (today.weekday() + 1))]
days = [week_start + timedelta(i) for i in range(-7, 8 if offset else (today.weekday() + 2))]
# TODO: Select statistics model based on mode
if mode is CardMode.VOICE:
model = data.VoiceSessionStats
day_stats = await model.study_times_between(guildid or None, userid, *days)
day_stats = list(map(lambda n: n // 3600, day_stats))
elif mode is CardMode.TEXT:
model = TextTrackerData.TextSessions
if guildid:
day_stats = await model.member_messages_between(guildid, userid, *days)
else:
day_stats = await model.user_messages_between(userid, *days)
else:
# TODO: ANKI
model = data.VoiceSessionStats
day_stats = await model.study_times_between(guildid or None, userid, *days)
day_stats = list(map(lambda n: n // 3600, day_stats))
# Get user session rows
query = model.table.select_where()
query = model.table.select_where(model.start_time >= days[0])
if guildid:
query = query.where(userid=userid, guildid=guildid).order_by('start_time', ORDER.ASC)
else:
@@ -34,7 +49,6 @@ async def get_weekly_card(bot: LionBot, userid: int, guildid: int, offset: int,
sessions = await query
# Extract quantities per-day
day_stats = await model.study_times_between(guildid or None, userid, *days)
for i in range(14 - len(day_stats)):
day_stats.append(0)
@@ -49,9 +63,9 @@ async def get_weekly_card(bot: LionBot, userid: int, guildid: int, offset: int,
timezone=str(lion.timezone),
now=lion.now.timestamp(),
week=week_start.timestamp(),
daily=tuple(map(lambda n: n/3600, day_stats)),
daily=tuple(map(int, day_stats)),
sessions=[
(int(session['start_time'].timestamp()), int(session['end_time'].timestamp()))
(int(session['start_time'].timestamp()), int(session['start_time'].timestamp() + int(session['duration'])))
for session in sessions
],
skin={'mode': mode}

View File

@@ -1,15 +1,51 @@
from settings import ModelData
from settings.setting_types import BoolSetting
"""
Configuration settings associated to the statistics module
"""
from typing import Optional
import asyncio
import discord
from discord.ui.select import select, Select, SelectOption, RoleSelect
from discord.ui.button import button, Button, ButtonStyle
from discord.ui.text_input import TextInput, TextStyle
from settings import ListData, ModelData, InteractiveSetting
from settings.setting_types import RoleListSetting, EnumSetting, ListSetting, BoolSetting, TimestampSetting
from settings.groups import SettingGroup
from meta import conf, LionBot
from meta.context import ctx_bot
from utils.lib import tabulate
from utils.ui import ConfigUI, FastModal, error_handler_for, ModalRetryUI
from utils.lib import MessageArgs
from core.data import CoreData
from core.lion_guild import VoiceMode
from babel.translator import ctx_translator
from . import babel
from .data import StatsData, StatisticType
_p = babel._p
class StatsSettings(SettingGroup):
class StatTypeSetting(EnumSetting):
"""
ABC setting type mixin describing an available stat type.
"""
_enum = StatisticType
_outputs = {
StatisticType.VOICE: '`Voice`',
StatisticType.TEXT: '`Text`',
StatisticType.ANKI: '`Anki`'
}
_inputs = {
'voice': StatisticType.VOICE,
'study': StatisticType.VOICE,
'text': StatisticType.TEXT,
'anki': StatisticType.ANKI
}
class StatisticsSettings(SettingGroup):
class UserGlobalStats(ModelData, BoolSetting):
"""
User setting, describing whether to display global statistics or not in servers.
@@ -20,9 +56,240 @@ class StatsSettings(SettingGroup):
_display_name = _p('userset:show_global_stats', "global_stats")
_desc = _p(
'userset:show_global_stats',
'userset:show_global_stats|desc',
"Whether displayed statistics include all your servers."
)
_long_desc = _p(
'userset:show_global_stats|long_desc',
"Whether statistics commands display combined stats for all servers or just your current server."
)
_model = CoreData.User
_column = CoreData.User.show_global_stats.name
class SeasonStart(ModelData, TimestampSetting):
"""
Start of the statistics season,
displayed on the leaderboard and used to determine activity ranks
Time is assumed to be in set guild timezone (although supports +00 syntax)
"""
setting_id = 'season_start'
_display_name = _p('guildset:season_start', "season_start")
_desc = _p(
'guildset:season_start|desc',
"Start of the current statistics season."
)
_long_desc = _p(
'guildset:season_start|long_desc',
"Activity ranks will be determined based on tracked activity since this time, "
"and the leaderboard will display activity since this time by default. "
"Unset to disable seasons and use all-time statistics instead."
)
_model = CoreData.Guild
_column = CoreData.Guild.season_start.name
# TODO: Offer to update badge ranks when this changes?
# TODO: Don't allow future times?
@classmethod
async def _timezone_from_id(cls, guildid, **kwargs):
bot = ctx_bot.get()
lguild = await bot.core.lions.fetch_guild(guildid)
return lguild.timezone
class UnrankedRoles(ListData, RoleListSetting):
"""
List of roles not displayed on the leaderboard
"""
setting_id = 'unranked_roles'
_display_name = _p('guildset:unranked_roles', "unranked_roles")
_desc = _p(
'guildset:unranked_roles|desc',
"Roles to exclude from the leaderboards."
)
_long_desc = _p(
'guildset:unranked_roles|long_desc',
"When set, members with *any* of these roles will not appear on the /leaderboard ranking list."
)
_default = None
_table_interface = StatsData.unranked_roles
_id_column = 'guildid'
_data_column = 'roleid'
_order_column = 'roleid'
_cache = {}
@property
def set_str(self):
return "Role selector below"
class VisibleStats(ListData, ListSetting, InteractiveSetting):
"""
Which of the three stats (text, voice/study, anki) to enable in statistics views
Default is determined by current guild mode
"""
setting_id = 'visible_stats'
_setting = StatTypeSetting
_display_name = _p('guildset:visible_stats', "visible_stats")
_desc = _p(
'guildset:visible_stats|desc',
"Which statistics will be visible in the statistics commands."
)
_long_desc = _p(
'guildset:visible_stats|desc',
"Choose which statistics types to display in the leaderboard and statistics commands."
)
# TODO: Format VOICE as STUDY when possible?
_default = [
StatisticType.VOICE,
StatisticType.TEXT,
]
_table_interface = StatsData.visible_statistics
_id_column = 'guildid'
_data_column = 'stat_type'
_order_column = 'stat_type'
_cache = {}
class DefaultStat(ModelData, StatTypeSetting):
"""
Which of the three stats to display by default
"""
setting_id = 'default_stat'
_display_name = _p('guildset:default_stat', "default_stat")
_desc = _p(
'guildset:default_stat|desc',
"Statistic type to display by default in setting dialogues."
)
_long_desc = _p(
'guildset:default_stat|long_desc',
"Which statistic type to display by default in setting dialogues."
)
class StatisticsConfigUI(ConfigUI):
setting_classes = (
StatisticsSettings.SeasonStart,
StatisticsSettings.UnrankedRoles,
StatisticsSettings.VisibleStats
)
def __init__(self, bot: LionBot,
guildid: int, channelid: int, **kwargs):
super().__init__(bot, guildid, channelid, **kwargs)
self.settings = self.bot.get_cog('StatsCog').settings
@select(cls=RoleSelect, placeholder='UNRANKED_ROLE_MENU', min_values=0, max_values=25)
async def unranked_roles_menu(self, selection: discord.Interaction, selected):
"""
Selection menu for the "unranked_roles" setting.
"""
await selection.response.defer(thinking=True)
setting = self.instances[1]
setting.value = selected.values
await setting.write()
# Don't need to refresh due to instance hooks
# await self.refresh(thinking=selection)
await selection.delete_original_response()
async def unranked_roles_menu_refresh(self):
t = self.bot.translator.t
self.unranked_roles_menu.placeholder = t(_p(
'ui:statistics_config|menu:unranked_roles|placeholder',
"Select Unranked Roles"
))
@select(placeholder="STAT_TYPE_MENU", min_values=1, max_values=3)
async def stat_type_menu(self, selection: discord.Interaction, selected):
"""
Selection menu for the "visible_stats" setting.
"""
await selection.response.defer(thinking=True)
setting = self.instances[2]
data = [StatisticType((value,)) for value in selected.values]
setting.data = data
await setting.write()
await selection.delete_original_response()
async def stat_type_menu_refresh(self):
t = self.bot.translator.t
setting = self.instances[2]
value = setting.value
lguild = await self.bot.core.lions.fetch_guild(self.guildid)
if lguild.guild_mode.voice is VoiceMode.VOICE:
voice_label = t(_p(
'ui:statistics_config|menu:visible_stats|item:voice|mode:voice',
"Voice Activity"
))
else:
voice_label = t(_p(
'ui:statistics_config|menu:visible_stats|item:voice|mode:study',
"Study Statistics"
))
voice_option = SelectOption(
label=voice_label,
value=StatisticType.VOICE.value[0],
default=(StatisticType.VOICE in value)
)
text_option = SelectOption(
label=t(_p(
'ui:statistics_config|menu:visible_stats|item:text',
"Message Activity"
)),
value=StatisticType.TEXT.value[0],
default=(StatisticType.TEXT in value)
)
anki_option = SelectOption(
label=t(_p(
'ui:statistics_config|menu:visible_stats|item:anki',
"Anki Reviews"
)),
value=StatisticType.ANKI.value[0],
default=(StatisticType.ANKI in value)
)
self.stat_type_menu.options = [
voice_option, text_option, anki_option
]
self.stat_type_menu.placeholder = t(_p(
'ui:statistics_config|menu:visible_stats|placeholder',
"Select Visible Statistics"
))
async def refresh_components(self):
await asyncio.gather(
self.edit_button_refresh(),
self.close_button_refresh(),
self.reset_button_refresh(),
self.unranked_roles_menu_refresh(),
self.stat_type_menu_refresh(),
)
self._layout = [
(self.unranked_roles_menu,),
(self.stat_type_menu,),
(self.edit_button, self.reset_button, self.close_button)
]
async def make_message(self):
t = self.bot.translator.t
title = t(_p(
'ui:statistics_config|embed|title',
"Statistics Configuration Panel"
))
embed = discord.Embed(
colour=discord.Colour.orange(),
title=title
)
for setting in self.instances:
embed.add_field(**setting.embed_field, inline=False)
return MessageArgs(embed=embed)

View File

@@ -1,2 +1,3 @@
from .profile import ProfileUI
from .weeklymonthly import WeeklyMonthlyUI
from .leaderboard import LeaderboardUI

View File

@@ -0,0 +1,421 @@
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
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
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 = {}
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
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
)
# TODO: Handle removing members in invisible roles
return data
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
userids, times = zip(*data[page_starts_at:page_starts_at + self.page_size])
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
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
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
menu = self.stat_menu
options = []
lguild = await self.bot.core.lions.fetch_guild(self.guildid)
if lguild.guild_mode.voice is VoiceMode.VOICE:
options.append(
SelectOption(
label="Voice Activity",
value=str(StatType.VOICE.value)
)
)
else:
options.append(
SelectOption(
label="Study Statistics",
value=str(StatType.VOICE.value)
)
)
options.append(
SelectOption(
label="Message Activity",
value=str(StatType.TEXT.value)
)
)
options.append(
SelectOption(
label="Anki Cards Reviewed",
value=str(StatType.ANKI.value)
)
)
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)
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)
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)
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)
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)
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)
self.pagen -= 1
self.focused = False
await self.refresh(thinking=press)
@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.
"""
try:
interaction, value = await input(
press,
title="Jump to page",
question="Page number to jump to"
)
value = value.strip()
except asyncio.TimeoutError:
return
if not value.lstrip('- ').isdigit():
error_embed = discord.Embed(
title="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
"""
if self.card is not None:
args = MessageArgs(
embed=None,
file=self.card.as_file('leaderboard.png')
)
else:
# TOLOCALISE:
embed = discord.Embed(
colour=discord.Colour.orange(),
title="Empty Leaderboard!",
description=(
"There has been no activity of this type in this period!"
)
)
args = MessageArgs(embed=embed, files=[])
return args
async def refresh_components(self):
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
]
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
)

View File

@@ -13,8 +13,10 @@ from utils.lib import MessageArgs
from utils.ui import LeoUI, ModalRetryUI, FastModal, error_handler_for
from babel.translator import ctx_translator
from gui.cards import ProfileCard, StatsCard
from gui.base import CardMode
from ..graphics.stats import get_stats_card
from ..graphics.profile import get_profile_card
from ..data import StatsData
from .. import babel
@@ -101,6 +103,16 @@ class StatType(IntEnum):
)
return name
@property
def card_mode(self):
# TODO: Need to support VOICE separately from STUDY
if self is self.VOICE:
return CardMode.VOICE
elif self is self.TEXT:
return CardMode.TEXT
elif self is self.ANKI:
return CardMode.ANKI
class ProfileUI(StatsUI):
def __init__(self, bot, user, guild, **kwargs):
@@ -109,6 +121,7 @@ class ProfileUI(StatsUI):
# State
self._stat_type = StatType.VOICE
self._showing_stats = False
self._stat_message = None
# Card data for rendering
self._profile_card: Optional[ProfileCard] = None
@@ -181,7 +194,11 @@ class ProfileUI(StatsUI):
await press.response.send_modal(modal)
async def edit_button_refresh(self):
...
t = self.bot.translator.t
self.edit_button.label = t(_p(
'ui:profile_card|button:edit|label',
"Edit Profile Badges"
))
@button(label="Show Statistics", style=ButtonStyle.blurple)
async def stats_button(self, press: discord.Interaction, pressed: Button):
@@ -189,10 +206,6 @@ class ProfileUI(StatsUI):
Press to show or hide the statistics panel.
"""
self._showing_stats = not self._showing_stats
if self._stats_card or not self._showing_stats:
await press.response.defer()
await self.refresh()
else:
await press.response.defer(thinking=True, ephemeral=True)
await self.refresh(thinking=press)
@@ -243,6 +256,19 @@ class ProfileUI(StatsUI):
else:
button.label = "Global Statistics"
@button(emoji=conf.emojis.cancel, style=ButtonStyle.red)
async def close_button(self, press: discord.Interaction, pressed: Button):
"""
Delete the output message and close the UI.
"""
await press.response.defer()
await self._original.delete_original_response()
if self._stat_message is not None:
await self._stat_message.delete()
self._stat_message = None
self._original = None
await self.close()
async def refresh_components(self):
"""
Refresh each UI component, and the overall layout.
@@ -268,7 +294,7 @@ class ProfileUI(StatsUI):
"""
Create and render the profile card.
"""
card = await get_stats_card(self.bot, self.userid, self.guildid)
card = await get_stats_card(self.bot, self.userid, self.guildid, self._stat_type.card_mode)
await card.render()
self._stats_card = card
return card
@@ -277,10 +303,8 @@ class ProfileUI(StatsUI):
"""
Create and render the XP and stats cards.
"""
args = await ProfileCard.sample_args(None)
data: StatsData = self.bot.get_cog('StatsCog').data
args |= {'badges': await data.ProfileTag.fetch_tags(self.guildid, self.userid)}
card = ProfileCard(**args)
card = await get_profile_card(self.bot, self.userid, self.guildid)
if card:
await card.render()
self._profile_card = card
return card
@@ -302,16 +326,39 @@ class ProfileUI(StatsUI):
if tasks:
await asyncio.gather(*tasks)
async def redraw(self, thinking: Optional[discord.Interaction] = None):
"""
Redraw the UI.
If a thinking interaction is provided,
deletes the response while redrawing.
"""
profile_args, stat_args = await self.make_message()
if thinking is not None and not thinking.is_expired() and thinking.response.is_done():
asyncio.create_task(thinking.delete_original_response())
if stat_args is not None:
send_task = asyncio.create_task(self._original.edit_original_response(**profile_args.edit_args, view=None))
if self._stat_message is None:
self._stat_message = await self._original.followup.send(**stat_args.send_args, view=self)
else:
await self._stat_message.edit(**stat_args.edit_args, view=self)
else:
send_task = asyncio.create_task(self._original.edit_original_response(**profile_args.edit_args, view=self))
if self._stat_message is not None:
await self._stat_message.delete()
self._stat_message = None
await send_task
async def make_message(self) -> MessageArgs:
"""
Make the message arguments. Apply cache where possible.
"""
# Build the final message arguments
files = []
files.append(self._profile_card.as_file('profile.png'))
profile_args = MessageArgs(file=self._profile_card.as_file('profile.png'))
if self._showing_stats:
files.append(self._stats_card.as_file('stats.png'))
return MessageArgs(files=files)
stats_args = MessageArgs(file=self._stats_card.as_file('stats.png'))
else:
stats_args = None
return (profile_args, stats_args)
async def run(self, interaction: discord.Interaction):
"""