aboutsummaryrefslogtreecommitdiffstats
path: root/bot
diff options
context:
space:
mode:
Diffstat (limited to 'bot')
-rw-r--r--bot/constants.py22
-rw-r--r--bot/exts/christmas/advent_of_code/_cog.py27
-rw-r--r--bot/exts/christmas/advent_of_code/_helpers.py65
3 files changed, 95 insertions, 19 deletions
diff --git a/bot/constants.py b/bot/constants.py
index e313e086..9e6db7a6 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -1,3 +1,4 @@
+import dataclasses
import enum
import logging
from datetime import datetime
@@ -29,11 +30,27 @@ __all__ = (
log = logging.getLogger(__name__)
-class AdventOfCodeLeaderboard(NamedTuple):
+class AdventOfCodeLeaderboard:
id: str
- session: str
+ _session: str
join_code: str
+ # If we notice that the session for this board expired, we set
+ # this attribute to `True`. We will emit a Sentry error so we
+ # can handle it, but, in the meantime, we'll try using the
+ # fallback session to make sure the commands still work.
+ use_fallback_session: bool = False
+
+ @property
+ def session(self) -> str:
+ """Return either the actual `session` cookie or the fallback cookie."""
+ if self.use_fallback_session:
+ log.info(f"Returning fallback cookie for board `{self.id}`.")
+ return AdventOfCode.fallback_session
+
+ return self._session
+
def _parse_aoc_leaderboard_env() -> Dict[str, AdventOfCodeLeaderboard]:
"""
@@ -61,6 +78,7 @@ class AdventOfCode:
# Information for the several leaderboards we have
leaderboards = _parse_aoc_leaderboard_env()
staff_leaderboard_id = environ.get("AOC_STAFF_LEADERBOARD_ID", "")
+ fallback_session = environ.get("AOC_FALLBACK_SESSION", "")
# Other Advent of Code constants
ignored_days = [day for day in environ.get("AOC_IGNORED_DAYS", "").split(",")]
diff --git a/bot/exts/christmas/advent_of_code/_cog.py b/bot/exts/christmas/advent_of_code/_cog.py
index 2a1a776b..0bcd9f42 100644
--- a/bot/exts/christmas/advent_of_code/_cog.py
+++ b/bot/exts/christmas/advent_of_code/_cog.py
@@ -221,7 +221,11 @@ class AdventOfCode(commands.Cog):
if AocConfig.staff_leaderboard_id and any(r.id == Roles.helpers for r in author.roles):
join_code = AocConfig.leaderboards[AocConfig.staff_leaderboard_id].join_code
else:
- join_code = await _helpers.get_public_join_code(author)
+ try:
+ join_code = await _helpers.get_public_join_code(author)
+ except _helpers.FetchingLeaderboardFailed:
+ await ctx.send(":x: Failed to get join code! Notified maintainers.")
+ return
if not join_code:
log.error(f"Failed to get a join code for user {author} ({author.id})")
@@ -256,7 +260,12 @@ class AdventOfCode(commands.Cog):
async def aoc_leaderboard(self, ctx: commands.Context) -> None:
"""Get the current top scorers of the Python Discord Leaderboard."""
async with ctx.typing():
- leaderboard = await _helpers.fetch_leaderboard()
+ try:
+ leaderboard = await _helpers.fetch_leaderboard()
+ except _helpers.FetchingLeaderboardFailed:
+ await ctx.send(":x: Unable to fetch leaderboard!")
+ return
+
number_of_participants = leaderboard["number_of_participants"]
top_count = min(AocConfig.leaderboard_displayed_members, number_of_participants)
@@ -291,7 +300,11 @@ class AdventOfCode(commands.Cog):
@override_in_channel(AOC_WHITELIST)
async def private_leaderboard_daily_stats(self, ctx: commands.Context) -> None:
"""Send an embed with daily completion statistics for the Python Discord leaderboard."""
- leaderboard = await _helpers.fetch_leaderboard()
+ try:
+ leaderboard = await _helpers.fetch_leaderboard()
+ except _helpers.FetchingLeaderboardFailed:
+ await ctx.send(":x: Can't fetch leaderboard for stats right now!")
+ return
# The daily stats are serialized as JSON as they have to be cached in Redis
daily_stats = json.loads(leaderboard["daily_stats"])
@@ -323,8 +336,12 @@ class AdventOfCode(commands.Cog):
many requests to the Advent of Code server.
"""
async with ctx.typing():
- await _helpers.fetch_leaderboard(invalidate_cache=True)
- await ctx.send("\N{OK Hand Sign} Refreshed leaderboard cache!")
+ try:
+ await _helpers.fetch_leaderboard(invalidate_cache=True)
+ except _helpers.FetchingLeaderboardFailed:
+ await ctx.send(":x: Something went wrong while trying to refresh the cache!")
+ else:
+ await ctx.send("\N{OK Hand Sign} Refreshed leaderboard cache!")
def cog_unload(self) -> None:
"""Cancel season-related tasks on cog unload."""
diff --git a/bot/exts/christmas/advent_of_code/_helpers.py b/bot/exts/christmas/advent_of_code/_helpers.py
index e7eeedb2..d883c09f 100644
--- a/bot/exts/christmas/advent_of_code/_helpers.py
+++ b/bot/exts/christmas/advent_of_code/_helpers.py
@@ -53,6 +53,18 @@ EST = pytz.timezone("EST")
StarResult = collections.namedtuple("StarResult", "member_id completion_time")
+class UnexpectedRedirect(aiohttp.ClientError):
+ """Raised when an unexpected redirect was detected."""
+
+
+class UnexpectedResponseStatus(aiohttp.ClientError):
+ """Raised when an unexpected redirect was detected."""
+
+
+class FetchingLeaderboardFailed(Exception):
+ """Raised when one or more leaderboards could not be fetched at all."""
+
+
def leaderboard_sorting_function(entry: typing.Tuple[str, dict]) -> typing.Tuple[int, int]:
"""
Provide a sorting value for our leaderboard.
@@ -153,6 +165,23 @@ def _format_leaderboard(leaderboard: typing.Dict[str, dict]) -> str:
return "\n".join(leaderboard_lines)
+async def _leaderboard_request(url: str, board: int, cookies: dict) -> typing.Optional[dict]:
+ """Make a leaderboard request using the specified session cookie."""
+ async with aiohttp.request("GET", url, headers=AOC_REQUEST_HEADER, cookies=cookies) as resp:
+ # The Advent of Code website redirects silently with a 200 response if a
+ # session cookie has expired, is invalid, or was not provided.
+ if str(resp.url) != url:
+ log.error(f"Fetching leaderboard `{board}` failed! Check the session cookie.")
+ raise UnexpectedRedirect(f"redirected unexpectedly to {resp.url} for board `{board}`")
+
+ # Every status other than `200` is unexpected, not only 400+
+ if not resp.status == 200:
+ log.error(f"Unexpected response `{resp.status}` while fetching leaderboard `{board}`")
+ raise UnexpectedResponseStatus(f"status `{resp.status}`")
+
+ return await resp.json()
+
+
async def _fetch_leaderboard_data() -> typing.Dict[str, typing.Any]:
"""Fetch data for all leaderboards and return a pooled result."""
year = AdventOfCode.year
@@ -165,22 +194,34 @@ async def _fetch_leaderboard_data() -> typing.Dict[str, typing.Any]:
participants = {}
for leaderboard in AdventOfCode.leaderboards.values():
leaderboard_url = AOC_API_URL.format(year=year, leaderboard_id=leaderboard.id)
- cookies = {"session": leaderboard.session}
- # We don't need to create a session if we're going to throw it away after each request
- async with aiohttp.request(
- "GET", leaderboard_url, headers=AOC_REQUEST_HEADER, cookies=cookies
- ) as resp:
- if resp.status == 200:
- raw_data = await resp.json()
-
- # Get the participants and store their current count
+ # Two attempts, one with the original session cookie and one with the fallback session
+ for attempt in range(1, 3):
+ log.info(f"Attempting to fetch leaderboard `{leaderboard.id}` ({attempt}/2)")
+ cookies = {"session": leaderboard.session}
+ try:
+ raw_data = await _leaderboard_request(leaderboard_url, leaderboard.id, cookies)
+ except UnexpectedRedirect:
+ if cookies["session"] == AdventOfCode.fallback_session:
+ log.error("It seems like the fallback cookie has expired!")
+ raise FetchingLeaderboardFailed from None
+
+ # If we're here, it means that the original session did not
+ # work. Let's fall back to the fallback session.
+ leaderboard.use_fallback_session = True
+ continue
+ except aiohttp.ClientError:
+ # Don't retry, something unexpected is wrong and it may not be the session.
+ raise FetchingLeaderboardFailed from None
+ else:
+ # Get the participants and store their current count.
board_participants = raw_data["members"]
await _caches.leaderboard_counts.set(leaderboard.id, len(board_participants))
participants.update(board_participants)
- else:
- log.warning(f"Fetching data failed for leaderboard `{leaderboard.id}`")
- resp.raise_for_status()
+ break
+ else:
+ log.error(f"reached 'unreachable' state while fetching board `{leaderboard.id}`.")
+ raise FetchingLeaderboardFailed
log.info(f"Fetched leaderboard information for {len(participants)} participants")
return participants