33
44import pendulum
55from aiohttp import ClientSession
6+ from ansi .color import fg
67from discord import ApplicationContext , Bot , Embed
78from discord .commands import SlashCommandGroup , option
89from discord .ext .commands import Cog
1516AOC_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+
1834class 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
178369def setup (bot : Bot ) -> None :
179370 bot .add_cog (AOCLeaderboards (bot ))
0 commit comments