5 Commits

9 changed files with 459 additions and 22 deletions

Submodule src/gui updated: 40bc140355...62d2484914

View File

@@ -15,9 +15,10 @@ from data.queries import ORDER
from meta import LionCog, LionBot, CrocBot, LionContext from meta import LionCog, LionBot, CrocBot, LionContext
from modules.profiles.community import Community from modules.profiles.community import Community
from modules.profiles.profile import UserProfile from modules.profiles.profile import UserProfile
from utils.lib import utc_now from utils.lib import utc_now, paginate_list, pager
from . import logger from . import logger
from .data import CounterData from .data import CounterData
from .graphics.weekly import counter_weekly_card, counter_monthly_card
class PERIOD(Enum): class PERIOD(Enum):
@@ -92,7 +93,7 @@ def counter_cmd_factory(
community: Community, community: Community,
args: Optional[str] args: Optional[str]
): ):
await ctx.reply(await cog.formatted_lb(counter, args, community, origin)) await cog.show_lb(ctx, counter, args, author, community, origin)
async def undo_cmd( async def undo_cmd(
cog, cog,
@@ -134,9 +135,10 @@ class CounterCog(LionCog):
async def cog_load(self): async def cog_load(self):
self._load_twitch_methods(self.crocbot) self._load_twitch_methods(self.crocbot)
await self.load_counter_commands()
await self.data.init() await self.data.init()
await self.load_counter_commands()
await self.load_counters() await self.load_counters()
self.loaded.set() self.loaded.set()
@@ -401,8 +403,7 @@ class CounterCog(LionCog):
total = await self.totals(name) total = await self.totals(name)
await ctx.reply(f"'{name}' counter is now: {total}") await ctx.reply(f"'{name}' counter is now: {total}")
elif subcmd == 'lb': elif subcmd == 'lb':
lbstr = await self.formatted_lb(name, args or '', community) await self.show_lb(ctx, name, args or '', author, community, origin=ORIGIN.TWITCH)
await ctx.reply(lbstr)
elif subcmd == 'clear': elif subcmd == 'clear':
await self.reset_counter(name) await self.reset_counter(name)
await ctx.reply(f"'{name}' counter reset.") await ctx.reply(f"'{name}' counter reset.")
@@ -483,24 +484,71 @@ class CounterCog(LionCog):
return (period, start_time) return (period, start_time)
async def formatted_lb( @cmds.hybrid_command(
name='counterlb',
description="Show the leaderboard for the given counter."
)
async def counterlb_dcmd(self, ctx: LionContext, counter: str, period: Optional[str] = None):
profiles = self.bot.get_cog('ProfileCog')
author = await profiles.fetch_profile_discord(ctx.author)
community = await profiles.fetch_community_discord(ctx.guild)
await self.show_lb(ctx, counter, period, author, community, ORIGIN.DISCORD)
@cmds.hybrid_command(
name='counterstats',
description="Show your stats for the given counter."
)
async def counterstats_dcmd(self, ctx: LionContext, counter: str, period: Optional[str]=None):
profiles = self.bot.get_cog('ProfileCog')
author = await profiles.fetch_profile_discord(ctx.author)
community = await profiles.fetch_community_discord(ctx.guild)
if period and period.lower() in ('monthly', 'month'):
card = await counter_monthly_card(
self.bot,
userid=ctx.author.id,
profile=author,
counter=await self.fetch_counter(counter),
guildid=ctx.guild.id,
offset=0,
)
await card.render()
await ctx.reply(file=card.as_file('stats.png'))
else:
card = await counter_weekly_card(
self.bot,
userid=ctx.author.id,
profile=author,
counter=await self.fetch_counter(counter),
guildid=ctx.guild.id,
offset=0,
)
await card.render()
await ctx.reply(file=card.as_file('stats.png'))
async def show_lb(
self, self,
ctx: commands.Context | LionContext,
counter: str, counter: str,
periodstr: str, periodstr: str,
caller: UserProfile,
community: Community, community: Community,
origin: ORIGIN = ORIGIN.TWITCH origin: ORIGIN = ORIGIN.TWITCH
): ):
period, start_time = await self.parse_period(community, periodstr) period, start_time = await self.parse_period(community, periodstr)
lb = await self.leaderboard(counter, start_time=start_time) lb = await self.leaderboard(counter, start_time=start_time)
if lb:
name_map = {} name_map = {}
for userid in lb.keys(): for userid in lb.keys():
profile = await UserProfile.fetch(self.bot, userid) profile = await UserProfile.fetch(self.bot, userid)
name = await profile.get_name() name = await profile.get_name()
name_map[userid] = name name_map[userid] = name
if not lb:
await ctx.reply(
f"{counter} {period.value[-1]} leaderboard is empty!"
)
elif origin is ORIGIN.TWITCH:
parts = [] parts = []
items = list(lb.items()) items = list(lb.items())
prefix = 'top 10 ' if len(items) > 10 else '' prefix = 'top 10 ' if len(items) > 10 else ''
@@ -510,6 +558,30 @@ class CounterCog(LionCog):
part = f"{name}: {total}" part = f"{name}: {total}"
parts.append(part) parts.append(part)
lbstr = '; '.join(parts) lbstr = '; '.join(parts)
return f"{counter} {period.value[-1]} {prefix}leaderboard --- {lbstr}" await ctx.reply(f"{counter} {period.value[-1]} {prefix}leaderboard --- {lbstr}")
else: elif origin is ORIGIN.DISCORD:
return f"{counter} {period.value[-1]} leaderboard is empty!" title = f"'{counter}' {period.value[-1]} leaderboard"
lb_strings = []
author_index = None
max_name_len = min((30, max(len(name) for name in name_map.values())))
for i, (uid, total) in enumerate(lb.items()):
if author_index is None and uid == caller.profileid:
author_index = i
lb_strings.append(
"{:<{}}\t{:<9}".format(
name_map[uid],
max_name_len,
total,
)
)
page_len = 20
pages = paginate_list(lb_strings, block_length=page_len, title=title)
start_page = author_index // page_len if author_index is not None else 0
await pager(
ctx,
pages,
start_at=start_page
)

View File

View File

@@ -0,0 +1,222 @@
import itertools
from typing import Optional
from datetime import timedelta, datetime
import calendar
from meta import LionBot
from gui.cards import WeeklyStatsCard, MonthlyStatsCard
from gui.base import CardMode
from modules.profiles.profile import UserProfile
from babel import LocalBabel
from modules.statistics.lib import apply_month_offset
from ..data import CounterData
babel = LocalBabel('counters')
_ = babel._
async def counter_monthly_card(
bot: LionBot,
userid: int,
profile: UserProfile,
counter: CounterData.Counter,
guildid: int,
offset: int,
):
cog = bot.get_cog('CounterCog')
data: CounterData = cog.data
if guildid:
lion = await bot.core.lions.fetch_member(guildid, userid)
user = await lion.fetch_member()
else:
lion = await bot.core.lions.fetch_user(userid)
user = await bot.fetch_user(userid)
today = lion.today
month_start = today.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
target = apply_month_offset(month_start, offset)
target_end = (target + timedelta(days=40)).replace(day=1, hour=0, minute=0) - timedelta(days=1)
months = [target]
for i in range(0, 3):
months.append((months[-1] - timedelta(days=1)).replace(day=1))
months.reverse()
rows = await data.CounterEntry.fetch_where(
data.CounterEntry.counterid == counter.counterid,
data.CounterEntry.userid == profile.profileid,
data.CounterEntry.created_at <= target_end,
data.CounterEntry.created_at >= months[0],
)
events = [(row.created_at, row.value) for row in rows]
month_lengths = [
(calendar.monthrange(month.year, month.month)[1]) for month in months
]
month_dates = []
for month, length in zip(months, month_lengths):
for day in range(1, length + 1):
month_dates.append(datetime(month.year, month.month, day, tzinfo=month.tzinfo))
monthly_flat = events_to_dayfreq(events, month_dates)
print(monthly_flat)
monthly = []
i = 0
for length in month_lengths:
this_month = monthly_flat[i : i+length]
i += length
monthly.append(this_month)
skin = await bot.get_cog('CustomSkinCog').get_skinargs_for(
guildid, userid, MonthlyStatsCard.card_id
)
skin |= {
'title_text': f"{counter.name.upper()}",
'this_month_text': f"THIS MONTH: {{amount}} {counter.name.upper()}",
'last_month_text': f"LAST MONTH: {{amount}} {counter.name.upper()}"
}
if user:
username = (user.display_name, '')
else:
username = (await profile.get_name(), '')
card = MonthlyStatsCard(
user=username,
timezone=str(lion.timezone),
now=lion.now.timestamp(),
month=int(target.timestamp()),
monthly=monthly,
current_streak=-1,
longest_streak=-1,
skin=skin | {'mode': CardMode.TEXT}
)
return card
async def counter_weekly_card(
bot: LionBot,
userid: int,
profile: UserProfile,
counter: CounterData.Counter,
guildid: int,
offset: int,
):
cog = bot.get_cog('CounterCog')
data: CounterData = cog.data
if guildid:
lion = await bot.core.lions.fetch_member(guildid, userid)
user = await lion.fetch_member()
else:
lion = await bot.core.lions.fetch_user(userid)
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, 8 if offset else (today.weekday() + 2))]
rows = await data.CounterEntry.fetch_where(
data.CounterEntry.counterid == counter.counterid,
data.CounterEntry.userid == profile.profileid,
data.CounterEntry.created_at <= days[-1],
data.CounterEntry.created_at >= days[0],
)
events = [(row.created_at, row.value) for row in rows]
daily = events_to_dayfreq(events, days)
sessions = events_to_sessions(next(zip(*events), []))
skin = await bot.get_cog('CustomSkinCog').get_skinargs_for(
guildid, userid, WeeklyStatsCard.card_id
)
skin |= {
'title_text': f"{counter.name.upper()}",
'this_week_text': f"THIS WEEK: {{amount}} {counter.name.upper()}",
'last_week_text': f"LAST WEEK: {{amount}} {counter.name.upper()}"
}
if user:
username = (user.display_name, '')
else:
username = (await profile.get_name(), '')
card = WeeklyStatsCard(
user=username,
timezone=str(lion.timezone),
now=lion.now.timestamp(),
week=week_start.timestamp(),
daily=tuple(map(int, daily)),
sessions=sessions,
skin=skin | {'mode': CardMode.TEXT}
)
return card
def events_to_dayfreq(events: list[tuple[datetime, int]], days: list[datetime]) -> list[int]:
if not days:
return []
last_day = 0
dayts = 0
daymap = {}
for day in sorted(days, reverse=True):
dayts = day.timestamp()
last_day = last_day or (day + timedelta(days=1)).timestamp()
daymap[dayts] = 0
first_day = dayts
for tim, count in events:
timts = tim.timestamp()
if not first_day < timts < last_day:
continue
for day_start in daymap:
if timts > day_start:
daymap[day_start] += count
break
return list(reversed(daymap.values()))
def events_to_sessions(event_times: list[datetime]) -> list[tuple[int, int]]:
"""
Convert a provided list of event times to a session list.
"""
sessions = []
session_start = None
session_end = None
SESSION_GAP = 60 * 30
SESSION_RADIUS = 60 * 30
for time in sorted(event_times):
if session_start and session_end and (time - session_end).total_seconds() - SESSION_RADIUS > SESSION_GAP:
session = (int(session_start.timestamp()), int(session_end.timestamp()))
sessions.append(session)
session_start = None
session_end = None
if session_start is None:
session_start = time - timedelta(seconds=SESSION_RADIUS)
session_end = time + timedelta(seconds=SESSION_RADIUS)
if session_start and session_end:
session = (int(session_start.timestamp()), int(session_end.timestamp()))
sessions.append(session)
return sessions

View File

View File

View File

@@ -38,7 +38,7 @@ class UserProfile:
twitches = await self.twitch_accounts() twitches = await self.twitch_accounts()
if twitches: if twitches:
users = await self.bot.crocbot.fetch_users( users = await self.bot.crocbot.fetch_users(
ids=[int(twitch.userid) for twitch in twitches] ids=[int(twitches[0].userid)]
) )
if users: if users:
user = users[0] user = users[0]
@@ -86,13 +86,21 @@ class UserProfile:
""" """
Fetch the Discord accounts associated to this profile. Fetch the Discord accounts associated to this profile.
""" """
return await self.data.DiscordProfileRow.fetch_where(profileid=self.profileid) return await self.data.DiscordProfileRow.fetch_where(
profileid=self.profileid
).order_by(
'created_at'
)
async def twitch_accounts(self) -> list[ProfileData.TwitchProfileRow]: async def twitch_accounts(self) -> list[ProfileData.TwitchProfileRow]:
""" """
Fetch the Twitch accounts associated to this profile. Fetch the Twitch accounts associated to this profile.
""" """
return await self.data.TwitchProfileRow.fetch_where(profileid=self.profileid) return await self.data.TwitchProfileRow.fetch_where(
profileid=self.profileid
).order_by(
'created_at'
)
@classmethod @classmethod
async def fetch(cls, bot: LionBot, profile_id: int) -> Self: async def fetch(cls, bot: LionBot, profile_id: int) -> Self:

View File

@@ -8,9 +8,16 @@ async def get_leaderboard_card(
bot: LionBot, highlightid: int, guildid: int, bot: LionBot, highlightid: int, guildid: int,
mode: CardMode, mode: CardMode,
entry_data: list[tuple[int, int, int]], # userid, position, time entry_data: list[tuple[int, int, int]], # userid, position, time
name_map: dict[int, str] = {},
extra_skin_args = {},
): ):
""" """
Render a leaderboard card with given parameters. Render a leaderboard card with given parameters.
Parameters
----------
name_map: dict[int, str]
Map of userid -> name, used first before cache or fetch.
""" """
guild = bot.get_guild(guildid) guild = bot.get_guild(guildid)
if guild is None: if guild is None:
@@ -20,8 +27,12 @@ async def get_leaderboard_card(
avatars = {} avatars = {}
names = {} names = {}
missing = [] missing = []
for userid, _, _ in entry_data: for userid, _, _ in entry_data:
if guild and (member := guild.get_member(userid)): if (name := name_map.get(userid, None)):
avatars[userid] = None
names[userid] = name
elif guild and (member := guild.get_member(userid)):
avatars[userid] = member.avatar.key if member.avatar else None avatars[userid] = member.avatar.key if member.avatar else None
names[userid] = member.display_name names[userid] = member.display_name
elif (user := bot.get_user(userid)): elif (user := bot.get_user(userid)):
@@ -65,7 +76,7 @@ async def get_leaderboard_card(
guildid, None, LeaderboardCard.card_id guildid, None, LeaderboardCard.card_id
) )
card = LeaderboardCard( card = LeaderboardCard(
skin=skin | {'mode': mode}, skin=skin | {'mode': mode} | extra_skin_args,
server_name=guild.name, server_name=guild.name,
entries=entries, entries=entries,
highlight=highlight highlight=highlight

View File

@@ -7,6 +7,7 @@ import iso8601 # type: ignore
import pytz import pytz
import re import re
import json import json
import asyncio
from contextvars import Context from contextvars import Context
import discord import discord
@@ -918,3 +919,126 @@ def write_records(records: list[dict[str, Any]], stream: StringIO):
for record in records: for record in records:
stream.write(','.join(map(str, record.values()))) stream.write(','.join(map(str, record.values())))
stream.write('\n') stream.write('\n')
async def pager(ctx, pages, locked=True, start_at=0, add_cancel=False, **kwargs):
"""
Shows the user each page from the provided list `pages` one at a time,
providing reactions to page back and forth between pages.
This is done asynchronously, and returns after displaying the first page.
Parameters
----------
pages: List(Union(str, discord.Embed))
A list of either strings or embeds to display as the pages.
locked: bool
Whether only the `ctx.author` should be able to use the paging reactions.
kwargs: ...
Remaining keyword arguments are transparently passed to the reply context method.
Returns: discord.Message
This is the output message, returned for easy deletion.
"""
cancel_emoji = cross
# Handle broken input
if len(pages) == 0:
raise ValueError("Pager cannot page with no pages!")
# Post first page. Method depends on whether the page is an embed or not.
if isinstance(pages[start_at], discord.Embed):
out_msg = await ctx.reply(embed=pages[start_at], **kwargs)
else:
out_msg = await ctx.reply(pages[start_at], **kwargs)
# Run the paging loop if required
if len(pages) > 1:
task = asyncio.create_task(_pager(ctx, out_msg, pages, locked, start_at, add_cancel, **kwargs))
# ctx.tasks.append(task)
elif add_cancel:
await out_msg.add_reaction(cancel_emoji)
# Return the output message
return out_msg
async def _pager(ctx, out_msg, pages, locked, start_at, add_cancel, **kwargs):
"""
Asynchronous initialiser and loop for the `pager` utility above.
"""
# Page number
page = start_at
# Add reactions to the output message
next_emoji = ""
prev_emoji = ""
cancel_emoji = cross
try:
await out_msg.add_reaction(prev_emoji)
if add_cancel:
await out_msg.add_reaction(cancel_emoji)
await out_msg.add_reaction(next_emoji)
except discord.Forbidden:
# We don't have permission to add paging emojis
# Die as gracefully as we can
if ctx.guild:
perms = ctx.channel.permissions_for(ctx.guild.me)
if not perms.add_reactions:
await ctx.error_reply(
"Cannot page results because I do not have the `add_reactions` permission!"
)
elif not perms.read_message_history:
await ctx.error_reply(
"Cannot page results because I do not have the `read_message_history` permission!"
)
else:
await ctx.error_reply(
"Cannot page results due to insufficient permissions!"
)
else:
await ctx.error_reply(
"Cannot page results!"
)
return
# Check function to determine whether a reaction is valid
def check(reaction, user):
result = reaction.message.id == out_msg.id
result = result and str(reaction.emoji) in [next_emoji, prev_emoji]
result = result and not (user.id == ctx.bot.user.id)
result = result and not (locked and user != ctx.author)
return result
# Begin loop
while True:
# Wait for a valid reaction, break if we time out
try:
reaction, user = await ctx.bot.wait_for('reaction_add', check=check, timeout=300)
except asyncio.TimeoutError:
break
# Attempt to remove the user's reaction, silently ignore errors
asyncio.ensure_future(out_msg.remove_reaction(reaction.emoji, user))
# Change the page number
page += 1 if reaction.emoji == next_emoji else -1
page %= len(pages)
# Edit the message with the new page
active_page = pages[page]
if isinstance(active_page, discord.Embed):
await out_msg.edit(embed=active_page, **kwargs)
else:
await out_msg.edit(content=active_page, **kwargs)
# Clean up by removing the reactions
try:
await out_msg.clear_reactions()
except discord.Forbidden:
try:
await out_msg.remove_reaction(next_emoji, ctx.client.user)
await out_msg.remove_reaction(prev_emoji, ctx.client.user)
except discord.NotFound:
pass
except discord.NotFound:
pass