Skip to content

Commit 91c6903

Browse files
authored
Merge pull request #101 from fiskenslakt/aoc-stats
Add new Advent of Code statistics commands
2 parents 3f48a46 + 7008747 commit 91c6903

File tree

4 files changed

+252
-49
lines changed

4 files changed

+252
-49
lines changed

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
[project]
22
name = "pzsd-bot"
3-
version = "1.23.0"
3+
version = "1.24.0"
44
description = "Discord bot for the PZSD server."
55
readme = "README.md"
66
requires-python = ">=3.12"
77
dependencies = [
88
"aiohttp>=3.13",
99
"alembic>=1.14.0",
10+
"ansi>=0.3.7",
1011
"asyncpg>=0.30.0",
1112
"emoji>=2.14.1",
1213
"greenlet>=3.1.1",

pzsd_bot/cogs/advent_of_code/admin.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ class AOCAdmin(Cog):
1313
def __init__(self, bot: Bot):
1414
self.bot = bot
1515

16-
@subcommand(group="aoc")
16+
@subcommand(group="aoc", independent=True)
1717
@slash_command(name="subscribe", description="Subscribe to aoc puzzle notifications.")
1818
async def subscribe(self, ctx: ApplicationContext) -> None:
1919
logger.info("/subscribe invoked by %s", ctx.author.name)
@@ -33,7 +33,7 @@ async def subscribe(self, ctx: ApplicationContext) -> None:
3333
logger.info("Added aoc role to %s", ctx.author.name)
3434
await ctx.respond("🎄 You've been subscribed to Advent of Code puzzle notifications! 🎄")
3535

36-
@subcommand(group="aoc")
36+
@subcommand(group="aoc", independent=True)
3737
@slash_command(name="unsubscribe", description="Unsubscribe from aoc puzzle notifications.")
3838
async def unsubscribe(self, ctx: ApplicationContext) -> None:
3939
logger.info("/unsubscribe invoked by %s", ctx.author.name)

pzsd_bot/cogs/advent_of_code/leaderboard.py

Lines changed: 236 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import pendulum
55
from aiohttp import ClientSession
6+
from ansi.color import fg
67
from discord import ApplicationContext, Bot, Embed
78
from discord.commands import SlashCommandGroup, option
89
from discord.ext.commands import Cog
@@ -15,6 +16,21 @@
1516
AOC_GENESIS = 2015
1617

1718

19+
class AoCAPIError(Exception):
20+
"""Raised when Advent of Code API responds with an error."""
21+
22+
23+
class AoCInvalidEventError(Exception):
24+
"""Raised when user provides an Advent of Code event that doesn't exist."""
25+
26+
def __init__(self, current_year: int):
27+
super().__init__(f"Invalid year, please choose a year between {AOC_GENESIS} and {current_year}.")
28+
29+
30+
class MissingLeaderboardDataError(Exception):
31+
"""Raised when there's no leaderboard data available for the requested year."""
32+
33+
1834
class CompletionDay(TypedDict):
1935
get_star_ts: int
2036
star_index: int
@@ -49,38 +65,46 @@ def __init__(self, bot: Bot):
4965
self.bot = bot
5066
self.cached_leaderboards: dict[int, CachedLeaderboard] = {}
5167

68+
async def fetch_leaderboard(self, year: int) -> None:
69+
leaderboard_url = f"{AOCSettings.base_url}/{year}/{AOCSettings.private_leaderboard_path}.json"
70+
params = {"view_key": AOCSettings.private_leaderboard_key}
71+
72+
client: ClientSession = self.bot.client.session
73+
async with client.get(url=leaderboard_url, params=params, middlewares=(retry_middleware,)) as resp:
74+
if resp.ok:
75+
leaderboard_response = await resp.json()
76+
self.cached_leaderboards[year] = {
77+
"leaderboard": leaderboard_response,
78+
"last_fetched": pendulum.now(),
79+
}
80+
else:
81+
logger.warning("Failed to fetch leaderboard")
82+
raise AoCAPIError
83+
5284
def make_aoc_leaderboard_embed(
5385
self,
5486
member_scores: list[tuple[int, int, str]],
5587
year: int,
5688
last_fetched: pendulum.DateTime,
5789
) -> Embed:
58-
ESC = "\x1b"
59-
RESET = f"{ESC}[0m"
60-
GOLD_BOLD = f"{ESC}[1;33m"
61-
RED_BOLD = f"{ESC}[1;31m"
62-
GREEN_BOLD = f"{ESC}[1;32m"
63-
RED = f"{ESC}[31m"
64-
GREEN = f"{ESC}[32m"
65-
66-
header = f"{GREEN_BOLD}Rank | Stars | Score | Name {RESET}"
67-
divider = f"{RED_BOLD}-----+---------+-------+-------- {RESET}"
90+
header = fg.boldgreen("Rank | Stars | Score | Name ")
91+
divider = fg.boldred("-----+---------+-------+-------- ")
6892

6993
lines = [header, divider]
7094

7195
for rank, (score, stars, name) in enumerate(sorted(member_scores, reverse=True), 1):
7296
if rank == 1:
73-
color = GOLD_BOLD
97+
color = fg.boldyellow
7498
elif rank <= 3:
75-
color = RED_BOLD if rank % 2 == 0 else GREEN_BOLD
99+
color = fg.boldred if rank % 2 == 0 else fg.boldgreen
76100
else:
77-
color = RED if rank % 2 == 0 else GREEN
101+
color = fg.red if rank % 2 == 0 else fg.green
78102

79103
stars_str = f"⭐ ({stars})"
80104
if stars < 10:
81105
stars_str += " "
82106

83-
line = f"{color}{rank:>3} | {stars_str:<7}| {score:>5} | {name}{RESET}"
107+
line = color(f"{rank:>3} | {stars_str:<7}| {score:>5} | {name}")
84108
lines.append(line)
85109

86110
embed = Embed(
@@ -94,23 +118,103 @@ def make_aoc_leaderboard_embed(
94118

95119
return embed
96120

97-
@aoc.command(description="View aoc leaderboard.")
98-
@option("year", description="What year to view the leaderboard for.", default=None)
99-
async def leaderboard(self, ctx: ApplicationContext, year: int) -> None:
121+
def make_aoc_star_times_embed(
122+
self,
123+
member_star_times: list[tuple[int | None, int | None, str]],
124+
day: int,
125+
year: int,
126+
last_fetched: pendulum.DateTime,
127+
) -> Embed:
128+
longest_name = max((len(name) for *_, name in member_star_times), default=4)
129+
130+
header = fg.green(f"Rank | {'Name'.ljust(longest_name)} | ⭐ | ⭐⭐")
131+
divider = fg.red(f"-----+{'-' * (longest_name + 2)}+----------+----------")
132+
133+
lines = [header, divider]
134+
135+
# Sort by first star time
136+
member_star_times.sort(key=lambda x: x[0] or float("inf"))
137+
138+
def get_completion_time(timestamp: int | None) -> str:
139+
"""Format a timestamp into HH:MM:SS, or >24h if not same day."""
140+
if timestamp is None:
141+
return "--:--:--"
142+
143+
dt = pendulum.from_timestamp(timestamp, tz="America/New_York")
144+
145+
if dt.day != day:
146+
return ">24h"
147+
148+
return dt.to_time_string()
149+
150+
for rank, (ts1, ts2, name) in enumerate(member_star_times, 1):
151+
color = fg.green if rank % 2 == 1 else fg.red
152+
153+
star1_time = get_completion_time(ts1)
154+
star2_time = get_completion_time(ts2)
155+
156+
line = color(f"{rank:>3} | {name.ljust(longest_name)} | {star1_time:>8} | {star2_time:>8}")
157+
lines.append(line)
158+
159+
embed = Embed(
160+
colour=Colors.dark_green.value,
161+
title=f"🎄 Advent of Code {year} ✨ Day {day} Star Times 🎄",
162+
description="```ansi\n" + "\n".join(lines) + "\n```",
163+
url=f"{AOCSettings.base_url}/{year}/day/{day}",
164+
timestamp=last_fetched,
165+
)
166+
embed.set_footer(text="Last updated")
167+
168+
return embed
169+
170+
def make_aoc_stats_embed(
171+
self,
172+
daily_stats: list[dict[str, int]],
173+
total_members: int,
174+
year: int,
175+
last_fetched: pendulum.DateTime,
176+
) -> Embed:
177+
header = fg.green("Day | ⭐ | ⭐⭐ | ⭐% | ⭐⭐%")
178+
divider = fg.red("----+-----+-----+---------+--------")
179+
lines = [header, divider]
180+
181+
for day, stats in enumerate(daily_stats, 1):
182+
star1_count = stats["star1_count"]
183+
star2_count = stats["star2_count"]
184+
185+
if total_members > 0:
186+
star1_pct = (star1_count / total_members) * 100
187+
star2_pct = (star2_count / total_members) * 100
188+
else:
189+
star1_pct = 0.0
190+
star2_pct = 0.0
191+
192+
color = fg.green if day % 2 == 1 else fg.red
193+
194+
line = color(f"{day:>3} | {star1_count:>3} | {star2_count:>3} | {star1_pct:>6.2f}% | {star2_pct:>6.2f}%")
195+
lines.append(line)
196+
197+
embed = Embed(
198+
colour=Colors.dark_green.value,
199+
title=f"🎄 Advent of Code ✨ {year} Star Stats 🎄",
200+
description="```ansi\n" + "\n".join(lines) + "\n```",
201+
url=f"{AOCSettings.base_url}/{year}",
202+
timestamp=last_fetched,
203+
)
204+
embed.set_footer(text="Last updated")
205+
206+
return embed
207+
208+
async def update_leaderboard_data(self, ctx: ApplicationContext, year: int | None) -> tuple[bool, int]:
100209
current_year = pendulum.today().year
101210

102211
deferred = False
103212

104-
logger.info("`/aoc leaderboard` invoked by %s with year=%s", ctx.author.name, year)
105213
if year is None:
106214
year = current_year
107215
elif year < AOC_GENESIS or year > current_year:
108216
logger.info("Invalid year, doing nothing")
109-
await ctx.respond(
110-
f"Invalid year, please choose a year between {AOC_GENESIS} and {current_year}.",
111-
ephemeral=True,
112-
)
113-
return
217+
raise AoCInvalidEventError(current_year)
114218

115219
last_fetched = None
116220
if year in self.cached_leaderboards:
@@ -126,36 +230,38 @@ async def leaderboard(self, ctx: ApplicationContext, year: int) -> None:
126230
await ctx.defer()
127231
deferred = True
128232

129-
last_fetched = pendulum.now()
130-
131-
leaderboard_url = (
132-
f"{AOCSettings.base_url}/{year}/{AOCSettings.private_leaderboard_path}.json"
133-
f"?view_key={AOCSettings.private_leaderboard_key}"
134-
)
135-
136-
client: ClientSession = self.bot.client.session
137-
async with client.get(url=leaderboard_url, middlewares=(retry_middleware,)) as resp:
138-
if resp.ok:
139-
leaderboard_response = await resp.json()
140-
self.cached_leaderboards[year] = {
141-
"leaderboard": leaderboard_response,
142-
"last_fetched": last_fetched,
143-
}
233+
try:
234+
await self.fetch_leaderboard(year)
235+
except AoCAPIError:
236+
if year not in self.cached_leaderboards:
237+
logger.info("No %s leaderboard in cache, doing nothing", year)
238+
await ctx.followup.send("Unable to fetch leaderboard, please try again later.")
239+
raise MissingLeaderboardDataError
144240
else:
145-
logger.warning("Failed to fetch leaderboard")
146-
if year not in self.cached_leaderboards:
147-
logger.info("No %s leaderboard in cache, doing nothing", year)
148-
await ctx.followup.send("Unable to fetch leaderboard, please try again later.")
149-
return
150-
else:
151-
logger.info("Last fetch failed, falling back to leaderboard from cache")
241+
logger.info("Last fetch failed, falling back to leaderboard from cache")
152242
else:
153243
logger.info(
154244
"Last fetch <%smin ago. Returning leaderboard from cache",
155245
AOCSettings.leaderboard_cache_ttl_minutes,
156246
)
157247

248+
return deferred, year
249+
250+
@aoc.command(description="View aoc leaderboard.")
251+
@option("year", description="What year to view the leaderboard for.", default=None)
252+
async def leaderboard(self, ctx: ApplicationContext, year: int) -> None:
253+
logger.info("/aoc leaderboard invoked by %s with year=%s", ctx.author.name, year)
254+
255+
try:
256+
deferred, year = await self.update_leaderboard_data(ctx, year)
257+
except AoCInvalidEventError as e:
258+
await ctx.respond(e, ephemeral=True)
259+
return
260+
except MissingLeaderboardDataError:
261+
return
262+
158263
leaderboard = self.cached_leaderboards[year]["leaderboard"]
264+
last_fetched = self.cached_leaderboards[year]["last_fetched"]
159265

160266
member_scores = []
161267
for member in leaderboard["members"].values():
@@ -174,6 +280,91 @@ async def leaderboard(self, ctx: ApplicationContext, year: int) -> None:
174280
else:
175281
await ctx.respond(embed=embed)
176282

283+
@aoc.command(description="View aoc star times.")
284+
@option("day", description="What day to view the star times for.")
285+
@option("year", description="What year to view the star times for.", default=None)
286+
async def star_times(self, ctx: ApplicationContext, day: int, year: int) -> None:
287+
logger.info("/aoc star_times invoked by %s with day=%s, year=%s", ctx.author.name, day, year)
288+
289+
try:
290+
deferred, year = await self.update_leaderboard_data(ctx, year)
291+
except AoCInvalidEventError as e:
292+
await ctx.respond(e, ephemeral=True)
293+
return
294+
except MissingLeaderboardDataError:
295+
return
296+
297+
leaderboard = self.cached_leaderboards[year]["leaderboard"]
298+
last_fetched = self.cached_leaderboards[year]["last_fetched"]
299+
300+
if day < 1 or day > leaderboard["num_days"]:
301+
logger.info("Invalid day '%s' given for year %s, doing nothing", day, year)
302+
msg = f"Invalid day for {year}, try again"
303+
if deferred:
304+
await ctx.followup.send(msg)
305+
else:
306+
await ctx.respond(msg)
307+
return
308+
309+
member_star_times = []
310+
for member in leaderboard["members"].values():
311+
star1_timestamp = None
312+
star2_timestamp = None
313+
314+
if str(day) in member["completion_day_level"]:
315+
if "1" in member["completion_day_level"][str(day)]:
316+
star1_timestamp = member["completion_day_level"][str(day)]["1"]["get_star_ts"]
317+
if "2" in member["completion_day_level"][str(day)]:
318+
star2_timestamp = member["completion_day_level"][str(day)]["2"]["get_star_ts"]
319+
320+
member_star_times.append(
321+
(
322+
star1_timestamp,
323+
star2_timestamp,
324+
member["name"] or str(member["id"]),
325+
)
326+
)
327+
328+
embed = self.make_aoc_star_times_embed(member_star_times, day, year, last_fetched)
329+
330+
if deferred:
331+
await ctx.followup.send(embed=embed)
332+
else:
333+
await ctx.respond(embed=embed)
334+
335+
@aoc.command(description="View aoc stats.")
336+
@option("year", description="What year to view the stats for.", default=None)
337+
async def stats(self, ctx: ApplicationContext, year: int) -> None:
338+
logger.info("/aoc stats invoked by %s with year=%s", ctx.author.name, year)
339+
340+
try:
341+
deferred, year = await self.update_leaderboard_data(ctx, year)
342+
except AoCInvalidEventError as e:
343+
await ctx.respond(e, ephemeral=True)
344+
return
345+
except MissingLeaderboardDataError:
346+
return
347+
348+
leaderboard = self.cached_leaderboards[year]["leaderboard"]
349+
last_fetched = self.cached_leaderboards[year]["last_fetched"]
350+
351+
total_members = len(leaderboard["members"])
352+
daily_stats = [{"star1_count": 0, "star2_count": 0} for _ in range(leaderboard["num_days"])]
353+
354+
for member in leaderboard["members"].values():
355+
for day in member["completion_day_level"]:
356+
if "1" in member["completion_day_level"][day]:
357+
daily_stats[int(day) - 1]["star1_count"] += 1
358+
if "2" in member["completion_day_level"][day]:
359+
daily_stats[int(day) - 1]["star2_count"] += 1
360+
361+
embed = self.make_aoc_stats_embed(daily_stats, total_members, year, last_fetched)
362+
363+
if deferred:
364+
await ctx.followup.send(embed=embed)
365+
else:
366+
await ctx.respond(embed=embed)
367+
177368

178369
def setup(bot: Bot) -> None:
179370
bot.add_cog(AOCLeaderboards(bot))

0 commit comments

Comments
 (0)