From 6139fb76dbb6c016bd949f759560af0838b805e2 Mon Sep 17 00:00:00 2001 From: Will Da Silva Date: Mon, 5 Oct 2020 10:17:18 -0400 Subject: Make prideavatar support specifying the image by URL Closes #224 --- bot/exts/pride/pride_avatar.py | 107 +++++++++++++++++++++++++++-------------- 1 file changed, 70 insertions(+), 37 deletions(-) diff --git a/bot/exts/pride/pride_avatar.py b/bot/exts/pride/pride_avatar.py index 3f9878e3..2eade796 100644 --- a/bot/exts/pride/pride_avatar.py +++ b/bot/exts/pride/pride_avatar.py @@ -1,10 +1,12 @@ import logging from io import BytesIO from pathlib import Path +from typing import Tuple +import aiohttp import discord -from PIL import Image, ImageDraw -from discord.ext import commands +from PIL import Image, ImageDraw, UnidentifiedImageError +from discord.ext.commands import Bot, Cog, Context, group from bot.constants import Colours @@ -53,10 +55,10 @@ OPTIONS = { } -class PrideAvatar(commands.Cog): +class PrideAvatar(Cog): """Put an LGBT spin on your avatar!""" - def __init__(self, bot: commands.Bot): + def __init__(self, bot: Bot): self.bot = bot @staticmethod @@ -78,8 +80,41 @@ class PrideAvatar(commands.Cog): ring.putalpha(mask) return ring - @commands.group(aliases=["avatarpride", "pridepfp", "prideprofile"], invoke_without_command=True) - async def prideavatar(self, ctx: commands.Context, option: str = "lgbt", pixels: int = 64) -> None: + @staticmethod + def process_options(option: str, pixels: int) -> Tuple[str, int, str]: + """Does some shared preprocessing for the prideavatar commands.""" + return option.lower(), max(0, min(512, pixels)), OPTIONS.get(option) + + async def process_image(self, ctx: Context, image_bytes: bytes, pixels: int, flag: str, option: str) -> None: + """Constructs the final image, embeds it, and sends it.""" + try: + avatar = Image.open(BytesIO(image_bytes)) + except UnidentifiedImageError: + return await ctx.send("Cannot identify image from provided URL") + avatar = avatar.convert("RGBA").resize((1024, 1024)) + + avatar = self.crop_avatar(avatar) + + ring = Image.open(Path(f"bot/resources/pride/flags/{flag}.png")).resize((1024, 1024)) + ring = ring.convert("RGBA") + ring = self.crop_ring(ring, pixels) + + avatar.alpha_composite(ring, (0, 0)) + bufferedio = BytesIO() + avatar.save(bufferedio, format="PNG") + bufferedio.seek(0) + + file = discord.File(bufferedio, filename="pride_avatar.png") # Creates file to be used in embed + embed = discord.Embed( + name="Your Lovely Pride Avatar", + description=f"Here is your lovely avatar, surrounded by\n a beautiful {option} flag. Enjoy :D" + ) + embed.set_image(url="attachment://pride_avatar.png") + embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url) + await ctx.send(file=file, embed=embed) + + @group(aliases=["avatarpride", "pridepfp", "prideprofile"], invoke_without_command=True) + async def prideavatar(self, ctx: Context, option: str = "lgbt", pixels: int = 64) -> None: """ This surrounds an avatar with a border of a specified LGBT flag. @@ -88,45 +123,43 @@ class PrideAvatar(commands.Cog): This has a maximum of 512px and defaults to a 64px border. The full image is 1024x1024. """ - pixels = 0 if pixels < 0 else 512 if pixels > 512 else pixels - - option = option.lower() - - if option not in OPTIONS.keys(): + option, pixels, flag = self.process_options(option, pixels) + if flag is None: return await ctx.send("I don't have that flag!") - flag = OPTIONS[option] - async with ctx.typing(): - - # Get avatar bytes image_bytes = await ctx.author.avatar_url.read() - avatar = Image.open(BytesIO(image_bytes)) - avatar = avatar.convert("RGBA").resize((1024, 1024)) - - avatar = self.crop_avatar(avatar) - - ring = Image.open(Path(f"bot/resources/pride/flags/{flag}.png")).resize((1024, 1024)) - ring = ring.convert("RGBA") - ring = self.crop_ring(ring, pixels) + await self.process_image(ctx, image_bytes, pixels, flag, option) - avatar.alpha_composite(ring, (0, 0)) - bufferedio = BytesIO() - avatar.save(bufferedio, format="PNG") - bufferedio.seek(0) + @prideavatar.command() + async def image(self, ctx: Context, url: str, option: str = "lgbt", pixels: int = 64) -> None: + """ + This surrounds the image specified by the URL with a border of a specified LGBT flag. - file = discord.File(bufferedio, filename="pride_avatar.png") # Creates file to be used in embed - embed = discord.Embed( - name="Your Lovely Pride Avatar", - description=f"Here is your lovely avatar, surrounded by\n a beautiful {option} flag. Enjoy :D" - ) - embed.set_image(url="attachment://pride_avatar.png") - embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url) + This defaults to the LGBT rainbow flag if none is given. + The amount of pixels can be given which determines the thickness of the flag border. + This has a maximum of 512px and defaults to a 64px border. + The full image is 1024x1024. + """ + option, pixels, flag = self.process_options(option, pixels) + if flag is None: + return await ctx.send("I don't have that flag!") - await ctx.send(file=file, embed=embed) + async with ctx.typing(): + async with aiohttp.ClientSession() as session: + try: + response = await session.get(url) + except aiohttp.client_exceptions.ClientConnectorError: + return await ctx.send("Cannot connect to provided URL!") + except aiohttp.client_exceptions.InvalidURL: + return await ctx.send("Invalid URL!") + if response.status != 200: + return await ctx.send("Bad response from provided URL!") + image_bytes = await response.read() + await self.process_image(ctx, image_bytes, pixels, flag, option) @prideavatar.command() - async def flags(self, ctx: commands.Context) -> None: + async def flags(self, ctx: Context) -> None: """This lists the flags that can be used with the prideavatar command.""" choices = sorted(set(OPTIONS.values())) options = "• " + "\n• ".join(choices) @@ -139,6 +172,6 @@ class PrideAvatar(commands.Cog): await ctx.send(embed=embed) -def setup(bot: commands.Bot) -> None: +def setup(bot: Bot) -> None: """Cog load.""" bot.add_cog(PrideAvatar(bot)) -- cgit v1.2.3 From 17b4703748d6e0b8f92e8679b7b6799c20097d11 Mon Sep 17 00:00:00 2001 From: Will Da Silva Date: Mon, 5 Oct 2020 14:14:01 -0400 Subject: Replace OMDB with TMDB Closes: #136 --- bot/constants.py | 1 - bot/exts/evergreen/snakes/_snakes_cog.py | 94 +++++++++++++++----------------- 2 files changed, 43 insertions(+), 52 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index e113428e..d2e10ae1 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -184,7 +184,6 @@ class Roles(NamedTuple): class Tokens(NamedTuple): giphy = environ.get("GIPHY_TOKEN") aoc_session_cookie = environ.get("AOC_SESSION_COOKIE") - omdb = environ.get("OMDB_API_KEY") youtube = environ.get("YOUTUBE_API_KEY") tmdb = environ.get("TMDB_API_KEY") nasa = environ.get("NASA_API_KEY") diff --git a/bot/exts/evergreen/snakes/_snakes_cog.py b/bot/exts/evergreen/snakes/_snakes_cog.py index a846274b..9216c054 100644 --- a/bot/exts/evergreen/snakes/_snakes_cog.py +++ b/bot/exts/evergreen/snakes/_snakes_cog.py @@ -15,6 +15,7 @@ import aiohttp import async_timeout from PIL import Image, ImageDraw, ImageFont from discord import Colour, Embed, File, Member, Message, Reaction +from discord.errors import HTTPException from discord.ext.commands import BadArgument, Bot, Cog, CommandError, Context, bot_has_permissions, group from bot.constants import ERROR_REPLIES, Tokens @@ -739,71 +740,62 @@ class Snakes(Cog): @snakes_group.command(name='movie') async def movie_command(self, ctx: Context) -> None: """ - Gets a random snake-related movie from OMDB. + Gets a random snake-related movie from TMDB. Written by Samuel. Modified by gdude. + Modified by Will Da Silva. """ - url = "http://www.omdbapi.com/" - page = random.randint(1, 27) + page = random.randint(1, 16) - response = await self.bot.http_session.get( - url, - params={ - "s": "snake", - "page": page, - "type": "movie", - "apikey": Tokens.omdb - } - ) - data = await response.json() - movie = random.choice(data["Search"])["imdbID"] - - response = await self.bot.http_session.get( - url, - params={ - "i": movie, - "apikey": Tokens.omdb - } - ) - data = await response.json() - - embed = Embed( - title=data["Title"], - color=SNAKE_COLOR - ) - - del data["Response"], data["imdbID"], data["Title"] - - for key, value in data.items(): - if not value or value == "N/A" or key in ("Response", "imdbID", "Title", "Type"): - continue + async with ctx.typing(): + response = await self.bot.http_session.get( + "https://api.themoviedb.org/3/search/movie", + params={ + "query": "snake", + "page": page, + "language": "en-US", + "api_key": Tokens.tmdb, + } + ) + data = await response.json() + movie = random.choice(data["results"])["id"] + + response = await self.bot.http_session.get( + f"https://api.themoviedb.org/3/movie/{movie}", + params={ + "language": "en-US", + "api_key": Tokens.tmdb, + } + ) + data = await response.json() - if key == "Ratings": # [{'Source': 'Internet Movie Database', 'Value': '7.6/10'}] - rating = random.choice(value) + embed = Embed(title=data["title"], color=SNAKE_COLOR) - if rating["Source"] != "Internet Movie Database": - embed.add_field(name=f"Rating: {rating['Source']}", value=rating["Value"]) + if data["poster_path"] is not None: + embed.set_image(url=f"https://images.tmdb.org/t/p/original{data['poster_path']}?api_key={Tokens.tmdb}") - continue + embed.add_field(name="Overview", value=data["overview"]) - if key == "Poster": - embed.set_image(url=value) - continue + embed.add_field(name="Release Date", value=data["release_date"]) - elif key == "imdbRating": - key = "IMDB Rating" + if data["genres"]: + embed.add_field(name="Genres", value=", ".join([x["name"] for x in data["genres"]])) - elif key == "imdbVotes": - key = "IMDB Votes" + if data["vote_count"]: + embed.add_field(name="Rating", value=f"{data['vote_average']}/10 ({data['vote_count']} votes)", inline=True) - embed.add_field(name=key, value=value, inline=True) + if data["budget"] and data["revenue"]: + embed.add_field(name="Budget", value=data["budget"], inline=True) + embed.add_field(name="Revenue", value=data["revenue"], inline=True) - embed.set_footer(text="Data provided by the OMDB API") + embed.set_footer(text="Data provided by the TMDB") - await ctx.channel.send( - embed=embed - ) + try: + await ctx.channel.send(embed=embed) + except HTTPException as err: + await ctx.channel.send("An error occurred while fetching a snake-related movie!") + raise err from None @snakes_group.command(name='quiz') @locked() -- cgit v1.2.3 From 86e4b815e07c058b9af200540c5a6032a9d4f98a Mon Sep 17 00:00:00 2001 From: Will Da Silva Date: Thu, 8 Oct 2020 10:37:37 -0400 Subject: Fix TMDB leak --- bot/exts/evergreen/snakes/_snakes_cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/evergreen/snakes/_snakes_cog.py b/bot/exts/evergreen/snakes/_snakes_cog.py index 9216c054..5e7a1169 100644 --- a/bot/exts/evergreen/snakes/_snakes_cog.py +++ b/bot/exts/evergreen/snakes/_snakes_cog.py @@ -773,7 +773,7 @@ class Snakes(Cog): embed = Embed(title=data["title"], color=SNAKE_COLOR) if data["poster_path"] is not None: - embed.set_image(url=f"https://images.tmdb.org/t/p/original{data['poster_path']}?api_key={Tokens.tmdb}") + embed.set_image(url=f"https://images.tmdb.org/t/p/original{data['poster_path']}") embed.add_field(name="Overview", value=data["overview"]) -- cgit v1.2.3 From 1a1ebf1a53542259ac202d4f4a4ba25d949a2f25 Mon Sep 17 00:00:00 2001 From: Will Da Silva Date: Tue, 13 Oct 2020 15:06:40 -0400 Subject: Set the number of movie pages fetched on first request --- bot/exts/evergreen/snakes/_snakes_cog.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/exts/evergreen/snakes/_snakes_cog.py b/bot/exts/evergreen/snakes/_snakes_cog.py index 5e7a1169..a7740b94 100644 --- a/bot/exts/evergreen/snakes/_snakes_cog.py +++ b/bot/exts/evergreen/snakes/_snakes_cog.py @@ -152,6 +152,7 @@ class Snakes(Cog): self.snake_idioms = utils.get_resource("snake_idioms") self.snake_quizzes = utils.get_resource("snake_quiz") self.snake_facts = utils.get_resource("snake_facts") + self.num_movie_pages = None # region: Helper methods @staticmethod @@ -746,7 +747,8 @@ class Snakes(Cog): Modified by gdude. Modified by Will Da Silva. """ - page = random.randint(1, 16) + # Initially 8 pages are fetched. The actual number of pages is set after the first request. + page = random.randint(1, self.num_movie_pages or 8) async with ctx.typing(): response = await self.bot.http_session.get( @@ -759,6 +761,8 @@ class Snakes(Cog): } ) data = await response.json() + if self.num_movie_pages is None: + self.num_movie_pages = data["total_pages"] movie = random.choice(data["results"])["id"] response = await self.bot.http_session.get( -- cgit v1.2.3 From 60277653cdfd66f9721667544b379b793f87b1a6 Mon Sep 17 00:00:00 2001 From: Will Da Silva Date: Fri, 16 Oct 2020 11:05:22 -0400 Subject: Comply with TMDB ToS --- bot/exts/evergreen/snakes/_snakes_cog.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/exts/evergreen/snakes/_snakes_cog.py b/bot/exts/evergreen/snakes/_snakes_cog.py index a7740b94..75234e7d 100644 --- a/bot/exts/evergreen/snakes/_snakes_cog.py +++ b/bot/exts/evergreen/snakes/_snakes_cog.py @@ -793,7 +793,8 @@ class Snakes(Cog): embed.add_field(name="Budget", value=data["budget"], inline=True) embed.add_field(name="Revenue", value=data["revenue"], inline=True) - embed.set_footer(text="Data provided by the TMDB") + embed.set_footer(text="This product uses the TMDb API but is not endorsed or certified by TMDb.") + embed.set_thumbnail(url="https://i.imgur.com/LtFtC8H.png") try: await ctx.channel.send(embed=embed) -- cgit v1.2.3 From 6bf4974f1975600c2943d8eae46fe24bf1f6a69e Mon Sep 17 00:00:00 2001 From: Will Da Silva Date: Fri, 16 Oct 2020 11:06:18 -0400 Subject: Fix empty field in snake movie embed --- bot/exts/evergreen/snakes/_snakes_cog.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/exts/evergreen/snakes/_snakes_cog.py b/bot/exts/evergreen/snakes/_snakes_cog.py index 75234e7d..0b3a8fe5 100644 --- a/bot/exts/evergreen/snakes/_snakes_cog.py +++ b/bot/exts/evergreen/snakes/_snakes_cog.py @@ -779,9 +779,11 @@ class Snakes(Cog): if data["poster_path"] is not None: embed.set_image(url=f"https://images.tmdb.org/t/p/original{data['poster_path']}") - embed.add_field(name="Overview", value=data["overview"]) + if data["overview"]: + embed.add_field(name="Overview", value=data["overview"]) - embed.add_field(name="Release Date", value=data["release_date"]) + if data["release_date"]: + embed.add_field(name="Release Date", value=data["release_date"]) if data["genres"]: embed.add_field(name="Genres", value=", ".join([x["name"] for x in data["genres"]])) -- cgit v1.2.3 From fcad0c8421a185ea696d02bf92d78fb7e21fc63e Mon Sep 17 00:00:00 2001 From: Hedy Li Date: Thu, 22 Oct 2020 07:09:17 +0000 Subject: improve message when user not found --- bot/exts/halloween/hacktoberstats.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/bot/exts/halloween/hacktoberstats.py b/bot/exts/halloween/hacktoberstats.py index 94bfe138..e8ed1d1b 100644 --- a/bot/exts/halloween/hacktoberstats.py +++ b/bot/exts/halloween/hacktoberstats.py @@ -4,7 +4,7 @@ import re from collections import Counter from datetime import datetime, timedelta from pathlib import Path -from typing import List, Tuple, Union +from typing import List, Optional, Tuple, Union import aiohttp import discord @@ -179,11 +179,15 @@ class HacktoberStats(commands.Cog): async with ctx.typing(): prs = await self.get_october_prs(github_username) + if isinstance(prs, str): # it will be a string if user not found or no october prs found + await ctx.send(prs) + return + if prs: stats_embed = await self.build_embed(github_username, prs) await ctx.send('Here are some stats!', embed=stats_embed) else: - await ctx.send(f"No valid October GitHub contributions found for '{github_username}'") + await ctx.send(f"No valid hacktoberfest contributions found for '{github_username}'") async def build_embed(self, github_username: str, prs: List[dict]) -> discord.Embed: """Return a stats embed built from github_username's PRs.""" @@ -232,7 +236,7 @@ class HacktoberStats(commands.Cog): return stats_embed @staticmethod - async def get_october_prs(github_username: str) -> Union[List[dict], None]: + async def get_october_prs(github_username: str) -> Optional[Union[List[dict], str]]: """ Query GitHub's API for PRs created during the month of October by github_username. @@ -279,15 +283,18 @@ class HacktoberStats(commands.Cog): # Ignore logging non-existent users or users we do not have permission to see if api_message == GITHUB_NONEXISTENT_USER_MESSAGE: - logging.debug(f"No GitHub user found named '{github_username}'") + message = f"No GitHub user found named '{github_username}'" + logging.debug(message) else: logging.error(f"GitHub API request for '{github_username}' failed with message: {api_message}") - return + message = None + return message if jsonresp["total_count"] == 0: # Short circuit if there aren't any PRs - logging.info(f"No Hacktoberfest PRs found for GitHub user: '{github_username}'") - return + message = f"No october PRs found for GitHub user: '{github_username}'" + logging.info(message) + return message logging.info(f"Found {len(jsonresp['items'])} Hacktoberfest PRs for GitHub user: '{github_username}'") outlist = [] # list of pr information dicts that will get returned -- cgit v1.2.3 From df9d809c223d01b4e7bb056468fed7eb5e5989d1 Mon Sep 17 00:00:00 2001 From: Hedy Li Date: Thu, 22 Oct 2020 07:49:11 +0000 Subject: improve implementation with return value instead of string/None now its empty list/None --- bot/exts/halloween/hacktoberstats.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/bot/exts/halloween/hacktoberstats.py b/bot/exts/halloween/hacktoberstats.py index 9ae9c227..9fb71651 100644 --- a/bot/exts/halloween/hacktoberstats.py +++ b/bot/exts/halloween/hacktoberstats.py @@ -179,8 +179,8 @@ class HacktoberStats(commands.Cog): async with ctx.typing(): prs = await self.get_october_prs(github_username) - if isinstance(prs, str): # it will be a string if user not found or no october prs found - await ctx.send(prs) + if prs is None: # it will be a None if user not found + await ctx.send("GitHub user not found: " + github_username) return if prs: @@ -236,7 +236,7 @@ class HacktoberStats(commands.Cog): return stats_embed @staticmethod - async def get_october_prs(github_username: str) -> Optional[Union[List[dict], str]]: + async def get_october_prs(github_username: str) -> Optional[List[dict]]: """ Query GitHub's API for PRs created during the month of October by github_username. @@ -256,7 +256,8 @@ class HacktoberStats(commands.Cog): "number": int } - Otherwise, return None + Otherwise, return empty list + None will be returned when github user not found """ logging.info(f"Fetching Hacktoberfest Stats for GitHub user: '{github_username}'") base_url = "https://api.github.com/search/issues?q=" @@ -283,18 +284,16 @@ class HacktoberStats(commands.Cog): # Ignore logging non-existent users or users we do not have permission to see if api_message == GITHUB_NONEXISTENT_USER_MESSAGE: - message = f"No GitHub user found named '{github_username}'" - logging.debug(message) + logging.debug(f"No GitHub user found named '{github_username}'") + return else: logging.error(f"GitHub API request for '{github_username}' failed with message: {api_message}") - message = None - return message + return [] # not returning None here, because that should be for when user not found if jsonresp["total_count"] == 0: # Short circuit if there aren't any PRs - message = f"No october PRs found for GitHub user: '{github_username}'" - logging.info(message) - return message + logging.info(f"No october PRs found for GitHub user: '{github_username}'") + return [] logging.info(f"Found {len(jsonresp['items'])} Hacktoberfest PRs for GitHub user: '{github_username}'") outlist = [] # list of pr information dicts that will get returned @@ -340,7 +339,7 @@ class HacktoberStats(commands.Cog): jsonresp2 = await HacktoberStats._fetch_url(topics_query_url, GITHUB_TOPICS_ACCEPT_HEADER) if jsonresp2.get("names") is None: logging.error(f"Error fetching topics for {shortname}: {jsonresp2['message']}") - return + return [] # PRs after oct 3 that doesn't have 'hacktoberfest-accepted' label # must be in repo with 'hacktoberfest' topic -- cgit v1.2.3 From 664c64ffb9b163e663740fd9fe56fc977fb1c36d Mon Sep 17 00:00:00 2001 From: Hedy Li Date: Thu, 22 Oct 2020 07:56:51 +0000 Subject: better no-prs message --- bot/exts/halloween/hacktoberstats.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/halloween/hacktoberstats.py b/bot/exts/halloween/hacktoberstats.py index 9fb71651..1640e6cf 100644 --- a/bot/exts/halloween/hacktoberstats.py +++ b/bot/exts/halloween/hacktoberstats.py @@ -187,7 +187,7 @@ class HacktoberStats(commands.Cog): stats_embed = await self.build_embed(github_username, prs) await ctx.send('Here are some stats!', embed=stats_embed) else: - await ctx.send(f"No valid hacktoberfest contributions found for '{github_username}'") + await ctx.send(f"No valid hacktoberfest PRs found for '{github_username}'") async def build_embed(self, github_username: str, prs: List[dict]) -> discord.Embed: """Return a stats embed built from github_username's PRs.""" -- cgit v1.2.3 From 2b379c2f8f352caf694913c3cb298599463c87c0 Mon Sep 17 00:00:00 2001 From: Hedy Li Date: Sat, 31 Oct 2020 05:18:21 +0000 Subject: Fix capitalization and grammar For Hacktoberstats --- bot/exts/halloween/hacktoberstats.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/bot/exts/halloween/hacktoberstats.py b/bot/exts/halloween/hacktoberstats.py index 1640e6cf..0f6b9924 100644 --- a/bot/exts/halloween/hacktoberstats.py +++ b/bot/exts/halloween/hacktoberstats.py @@ -179,7 +179,7 @@ class HacktoberStats(commands.Cog): async with ctx.typing(): prs = await self.get_october_prs(github_username) - if prs is None: # it will be a None if user not found + if prs is None: # Will be None if the user was not found await ctx.send("GitHub user not found: " + github_username) return @@ -187,14 +187,14 @@ class HacktoberStats(commands.Cog): stats_embed = await self.build_embed(github_username, prs) await ctx.send('Here are some stats!', embed=stats_embed) else: - await ctx.send(f"No valid hacktoberfest PRs found for '{github_username}'") + await ctx.send(f"No valid Hacktoberfest PRs found for '{github_username}'") async def build_embed(self, github_username: str, prs: List[dict]) -> discord.Embed: """Return a stats embed built from github_username's PRs.""" logging.info(f"Building Hacktoberfest embed for GitHub user: '{github_username}'") in_review, accepted = await self._categorize_prs(prs) - n = len(accepted) + len(in_review) # total number of PRs + n = len(accepted) + len(in_review) # Total number of PRs if n >= PRS_FOR_SHIRT: shirtstr = f"**{github_username} is eligible for a T-shirt or a tree!**" elif n == PRS_FOR_SHIRT - 1: @@ -220,7 +220,7 @@ class HacktoberStats(commands.Cog): icon_url="https://avatars1.githubusercontent.com/u/35706162?s=200&v=4" ) - # this will handle when no PRs in_review or accepted + # This will handle when no PRs in_review or accepted review_str = self._build_prs_string(in_review, github_username) or "None" accepted_str = self._build_prs_string(accepted, github_username) or "None" stats_embed.add_field( @@ -257,7 +257,7 @@ class HacktoberStats(commands.Cog): } Otherwise, return empty list - None will be returned when github user not found + None will be returned when the GitHub user was not found """ logging.info(f"Fetching Hacktoberfest Stats for GitHub user: '{github_username}'") base_url = "https://api.github.com/search/issues?q=" @@ -288,11 +288,11 @@ class HacktoberStats(commands.Cog): return else: logging.error(f"GitHub API request for '{github_username}' failed with message: {api_message}") - return [] # not returning None here, because that should be for when user not found + return [] # No October PRs were found due to error if jsonresp["total_count"] == 0: # Short circuit if there aren't any PRs - logging.info(f"No october PRs found for GitHub user: '{github_username}'") + logging.info(f"No October PRs found for GitHub user: '{github_username}'") return [] logging.info(f"Found {len(jsonresp['items'])} Hacktoberfest PRs for GitHub user: '{github_username}'") @@ -310,7 +310,7 @@ class HacktoberStats(commands.Cog): "number": item["number"] } - # if the PR has 'invalid' or 'spam' labels, the PR must be + # If the PR has 'invalid' or 'spam' labels, the PR must be # either merged or approved for it to be included if HacktoberStats._has_label(item, ["invalid", "spam"]): if not await HacktoberStats._is_accepted(itemdict): @@ -323,17 +323,17 @@ class HacktoberStats(commands.Cog): outlist.append(itemdict) continue - # checking PR's labels for "hacktoberfest-accepted" + # Checking PR's labels for "hacktoberfest-accepted" if HacktoberStats._has_label(item, "hacktoberfest-accepted"): outlist.append(itemdict) continue - # no need to query github if repo topics are fetched before already + # No need to query GitHub if repo topics are fetched before already if shortname in hackto_topics.keys(): if hackto_topics[shortname]: outlist.append(itemdict) continue - # fetch topics for the pr repo + # Fetch topics for the pr repo topics_query_url = f"https://api.github.com/repos/{shortname}/topics" logging.debug(f"Fetching repo topics for {shortname} with url: {topics_query_url}") jsonresp2 = await HacktoberStats._fetch_url(topics_query_url, GITHUB_TOPICS_ACCEPT_HEADER) @@ -344,7 +344,7 @@ class HacktoberStats(commands.Cog): # PRs after oct 3 that doesn't have 'hacktoberfest-accepted' label # must be in repo with 'hacktoberfest' topic if "hacktoberfest" in jsonresp2["names"]: - hackto_topics[shortname] = True # cache result in the dict for later use if needed + hackto_topics[shortname] = True # Cache result in the dict for later use if needed outlist.append(itemdict) return outlist -- cgit v1.2.3 From 147e37a53073b6e4291e1d386f8d2a6b0114f535 Mon Sep 17 00:00:00 2001 From: Hedy Li Date: Sat, 31 Oct 2020 07:28:56 +0000 Subject: Put GitHub user-not-found message in embed With random `NEGATIVE_REPLIES` + color=red --- bot/exts/halloween/hacktoberstats.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bot/exts/halloween/hacktoberstats.py b/bot/exts/halloween/hacktoberstats.py index 0f6b9924..bb5a6325 100644 --- a/bot/exts/halloween/hacktoberstats.py +++ b/bot/exts/halloween/hacktoberstats.py @@ -1,5 +1,6 @@ import json import logging +import random import re from collections import Counter from datetime import datetime, timedelta @@ -10,7 +11,7 @@ import aiohttp import discord from discord.ext import commands -from bot.constants import Channels, Month, Tokens, WHITELISTED_CHANNELS +from bot.constants import Channels, Month, NEGATIVE_REPLIES, Tokens, WHITELISTED_CHANNELS from bot.utils.decorators import in_month, override_in_channel from bot.utils.persist import make_persistent @@ -180,7 +181,9 @@ class HacktoberStats(commands.Cog): prs = await self.get_october_prs(github_username) if prs is None: # Will be None if the user was not found - await ctx.send("GitHub user not found: " + github_username) + await ctx.send(embed=discord.Embed(title=random.choice(NEGATIVE_REPLIES), + description=f"GitHub user `{github_username}` was not found.", + colour=discord.Colour.red())) return if prs: -- cgit v1.2.3 From 12a0832d9259b20eb622bcc4a0b5e2c2d766628a Mon Sep 17 00:00:00 2001 From: Hedy Li Date: Sat, 31 Oct 2020 15:38:28 +0800 Subject: Fix capitalization of 'PR' in hacktoberstats.py --- bot/exts/halloween/hacktoberstats.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/halloween/hacktoberstats.py b/bot/exts/halloween/hacktoberstats.py index bb5a6325..a5ec4dc4 100644 --- a/bot/exts/halloween/hacktoberstats.py +++ b/bot/exts/halloween/hacktoberstats.py @@ -336,7 +336,7 @@ class HacktoberStats(commands.Cog): if hackto_topics[shortname]: outlist.append(itemdict) continue - # Fetch topics for the pr repo + # Fetch topics for the PR's repo topics_query_url = f"https://api.github.com/repos/{shortname}/topics" logging.debug(f"Fetching repo topics for {shortname} with url: {topics_query_url}") jsonresp2 = await HacktoberStats._fetch_url(topics_query_url, GITHUB_TOPICS_ACCEPT_HEADER) -- cgit v1.2.3 From 57800121c067d22396055e30e3e3a91899c9b4fa Mon Sep 17 00:00:00 2001 From: Hedy Li Date: Sat, 31 Oct 2020 15:42:59 +0800 Subject: Continue loop if repo topics API request errored --- bot/exts/halloween/hacktoberstats.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/halloween/hacktoberstats.py b/bot/exts/halloween/hacktoberstats.py index a5ec4dc4..701ef0b3 100644 --- a/bot/exts/halloween/hacktoberstats.py +++ b/bot/exts/halloween/hacktoberstats.py @@ -342,7 +342,7 @@ class HacktoberStats(commands.Cog): jsonresp2 = await HacktoberStats._fetch_url(topics_query_url, GITHUB_TOPICS_ACCEPT_HEADER) if jsonresp2.get("names") is None: logging.error(f"Error fetching topics for {shortname}: {jsonresp2['message']}") - return [] + continue # Assume the repo doesn't have the `hacktoberfest` topic if API request errored # PRs after oct 3 that doesn't have 'hacktoberfest-accepted' label # must be in repo with 'hacktoberfest' topic -- cgit v1.2.3 From 415f550969555f6929cc2d0273818bf6db57cab0 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Tue, 24 Nov 2020 17:38:43 +0200 Subject: Update constants to match with new format of AoC that will run in 2020 --- bot/constants.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 6999f321..841f2303 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -31,8 +31,10 @@ log = logging.getLogger(__name__) class AdventOfCode: leaderboard_cache_age_threshold_seconds = 3600 - leaderboard_id = 631135 - leaderboard_join_code = str(environ.get("AOC_JOIN_CODE", None)) + leaderboard_public_ids = [645282] + leaderboard_staff_id = 957532 + leaderboard_public_join_codes = environ.get("AOC_PUBLIC_JOIN_CODES", "").split(",") + leaderboard_staff_join_code = environ.get("AOC_STAFF_JOIN_CODE", "") leaderboard_max_displayed_members = 10 year = int(environ.get("AOC_YEAR", datetime.utcnow().year)) role_id = int(environ.get("AOC_ROLE_ID", 518565788744024082)) @@ -44,7 +46,8 @@ class Branding: class Channels(NamedTuple): admins = 365960823622991872 - advent_of_code = int(environ.get("AOC_CHANNEL_ID", 517745814039166986)) + advent_of_code = int(environ.get("AOC_CHANNEL_ID", 780818162836439041)) + advent_of_code_staff = int(environ.get("AOC_STAFF_CHANNEL_ID", 778646502641500181)) announcements = int(environ.get("CHANNEL_ANNOUNCEMENTS", 354619224620138496)) big_brother_logs = 468507907357409333 bot = 267659945086812160 -- cgit v1.2.3 From e956170ab1be2ff2af803ce6ea46c6bcb2f91837 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Tue, 24 Nov 2020 17:39:42 +0200 Subject: Add staff AoC channel to whitelist --- bot/exts/christmas/adventofcode.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/christmas/adventofcode.py b/bot/exts/christmas/adventofcode.py index b3fe0623..fdd84d5d 100644 --- a/bot/exts/christmas/adventofcode.py +++ b/bot/exts/christmas/adventofcode.py @@ -25,7 +25,7 @@ AOC_SESSION_COOKIE = {"session": Tokens.aoc_session_cookie} EST = timezone("EST") COUNTDOWN_STEP = 60 * 5 -AOC_WHITELIST = WHITELISTED_CHANNELS + (Channels.advent_of_code,) +AOC_WHITELIST = WHITELISTED_CHANNELS + (Channels.advent_of_code, Channels.advent_of_code_staff) def is_in_advent() -> bool: -- cgit v1.2.3 From 2f6236671f89eb1088e985284f87292695e376cf Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Tue, 24 Nov 2020 17:44:08 +0200 Subject: Add comments about AoC env config order and change cookies way --- bot/constants.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/constants.py b/bot/constants.py index 841f2303..bbfe7c3f 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -33,6 +33,7 @@ class AdventOfCode: leaderboard_cache_age_threshold_seconds = 3600 leaderboard_public_ids = [645282] leaderboard_staff_id = 957532 + # Public join codes in environment must be in same order than in AdventOfCode.leaderboard_public_ids leaderboard_public_join_codes = environ.get("AOC_PUBLIC_JOIN_CODES", "").split(",") leaderboard_staff_join_code = environ.get("AOC_STAFF_JOIN_CODE", "") leaderboard_max_displayed_members = 10 @@ -203,7 +204,9 @@ class Roles(NamedTuple): class Tokens(NamedTuple): giphy = environ.get("GIPHY_TOKEN") - aoc_session_cookie = environ.get("AOC_SESSION_COOKIE") + # Public AoC cookies in environment must be in same order than in AdventOfCode.leaderboard_public_ids + aoc_public_session_cookies = environ.get("AOC_PUBLIC_SESSION_COOKIES", "").split(",") + aoc_staff_session_cookie = environ.get("AOC_STAFF_SESSION_COOKIE") omdb = environ.get("OMDB_API_KEY") youtube = environ.get("YOUTUBE_API_KEY") tmdb = environ.get("TMDB_API_KEY") -- cgit v1.2.3 From 85ddb6d8c06722f09842339a43e91d0a88fd9946 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 25 Nov 2020 18:21:30 +0200 Subject: Implement different invitation codes for staff and public leaderboards --- bot/exts/christmas/adventofcode.py | 77 ++++++++++++++++++++++++++++++++------ 1 file changed, 66 insertions(+), 11 deletions(-) diff --git a/bot/exts/christmas/adventofcode.py b/bot/exts/christmas/adventofcode.py index fdd84d5d..be1c733a 100644 --- a/bot/exts/christmas/adventofcode.py +++ b/bot/exts/christmas/adventofcode.py @@ -9,18 +9,19 @@ from typing import List, Tuple import aiohttp import discord +from async_rediscache import RedisCache from bs4 import BeautifulSoup from discord.ext import commands from pytz import timezone +from bot.bot import Bot from bot.constants import AdventOfCode as AocConfig, Channels, Colours, Emojis, Month, Tokens, WHITELISTED_CHANNELS from bot.utils import unlocked_role -from bot.utils.decorators import in_month, override_in_channel +from bot.utils.decorators import in_month, override_in_channel, seasonal_task log = logging.getLogger(__name__) AOC_REQUEST_HEADER = {"user-agent": "PythonDiscord AoC Event Bot"} -AOC_SESSION_COOKIE = {"session": Tokens.aoc_session_cookie} EST = timezone("EST") COUNTDOWN_STEP = 60 * 5 @@ -131,12 +132,19 @@ async def day_countdown(bot: commands.Bot) -> None: class AdventOfCode(commands.Cog): """Advent of Code festivities! Ho Ho Ho!""" - def __init__(self, bot: commands.Bot): + # Mapping for AoC PyDis community leaderboard IDs -> cached amount of members in leaderboard. + public_leaderboard_members = RedisCache() + + # We don't want that users join to multiple leaderboards, so return only 1 code to user. + # User ID -> AoC leaderboard ID + user_join_codes = RedisCache() + + def __init__(self, bot: Bot): self.bot = bot self._base_url = f"https://adventofcode.com/{AocConfig.year}" self.global_leaderboard_url = f"https://adventofcode.com/{AocConfig.year}/leaderboard" - self.private_leaderboard_url = f"{self._base_url}/leaderboard/private/view/{AocConfig.leaderboard_id}" + self.private_leaderboard_url = f"{self._base_url}/leaderboard/private/view/{AocConfig.leaderboard_staff_id}" self.about_aoc_filepath = Path("./bot/resources/advent_of_code/about.json") self.cached_about_aoc = self._build_about_embed() @@ -146,6 +154,7 @@ class AdventOfCode(commands.Cog): self.countdown_task = None self.status_task = None + self.leaderboard_member_update_task = self.bot.loop.create_task(self.leaderboard_members_updater()) countdown_coro = day_countdown(self.bot) self.countdown_task = self.bot.loop.create_task(countdown_coro) @@ -153,6 +162,32 @@ class AdventOfCode(commands.Cog): status_coro = countdown_status(self.bot) self.status_task = self.bot.loop.create_task(status_coro) + self.leaderboard_join_codes = { + aoc_id: join_code for aoc_id, join_code in zip( + AocConfig.leaderboard_public_ids, AocConfig.leaderboard_public_join_codes + ) + } + self.leaderboard_cookies = { + aoc_id: cookie for aoc_id, cookie in zip( + AocConfig.leaderboard_public_ids, Tokens.aoc_public_session_cookies + ) + } + + @seasonal_task(Month.DECEMBER, sleep_time=60 * 30) + async def leaderboard_members_updater(self) -> None: + """Updates public leaderboards cached member amounts in every 30 minutes.""" + # Whole December isn't advent + if not is_in_advent(): + return + + # Update every leaderboard for what we have session cookie + for aoc_id, cookie in self.leaderboard_cookies.items(): + leaderboard = await AocPrivateLeaderboard.from_url(aoc_id, cookie) + log.info(leaderboard.members) + # Update only when API return any members + if len(leaderboard.members) > 0: + await self.public_leaderboard_members.set(aoc_id, len(leaderboard.members)) + @in_month(Month.DECEMBER) @commands.group(name="adventofcode", aliases=("aoc",)) @override_in_channel(AOC_WHITELIST) @@ -234,9 +269,29 @@ class AdventOfCode(commands.Cog): author = ctx.message.author log.info(f"{author.name} ({author.id}) has requested the PyDis AoC leaderboard code") + if ctx.channel.id == Channels.advent_of_code_staff: + join_code = AocConfig.leaderboard_staff_join_code + log.info(f"{author.name} ({author.id}) ran command in staff AoC channel. Returning staff code.") + else: + # We want that user get only 1 code + if await self.user_join_codes.contains(ctx.author.id): + join_code = await self.user_join_codes.get(ctx.author.id) + log.info(f"{author.name} ({author.id}) have already cached AoC join code. Returning it.") + else: + least_id, least = 0, 200 + for aoc_id, amount in await self.public_leaderboard_members.items(): + log.info(amount, least) + if amount < least: + least, least_id = amount, aoc_id + + join_code = self.leaderboard_join_codes[least_id] + # Persist this code to Redis, so we can get it later again. + await self.user_join_codes.set(ctx.author.id, join_code) + log.info(f"{author.name} ({author.id}) got new join code. Persisted it to cache.") + info_str = ( "Head over to https://adventofcode.com/leaderboard/private " - f"with code `{AocConfig.leaderboard_join_code}` to join the PyDis private leaderboard!" + f"with code `{join_code}` to join the PyDis private leaderboard!" ) try: await author.send(info_str) @@ -580,8 +635,8 @@ class AocPrivateLeaderboard: @staticmethod async def json_from_url( - leaderboard_id: int = AocConfig.leaderboard_id, year: int = AocConfig.year - ) -> "AocPrivateLeaderboard": + leaderboard_id: int, cookie: str, year: int = AocConfig.year + ) -> dict: """ Request the API JSON from Advent of Code for leaderboard_id for the specified year's event. @@ -590,7 +645,7 @@ class AocPrivateLeaderboard: api_url = f"https://adventofcode.com/{year}/leaderboard/private/view/{leaderboard_id}.json" log.debug("Querying Advent of Code Private Leaderboard API") - async with aiohttp.ClientSession(cookies=AOC_SESSION_COOKIE, headers=AOC_REQUEST_HEADER) as session: + async with aiohttp.ClientSession(headers=AOC_REQUEST_HEADER, cookies={"session": cookie}) as session: async with session.get(api_url) as resp: if resp.status == 200: raw_dict = await resp.json() @@ -608,9 +663,9 @@ class AocPrivateLeaderboard: ) @classmethod - async def from_url(cls) -> "AocPrivateLeaderboard": + async def from_url(cls, leaderboard_id: int, cookie: str) -> "AocPrivateLeaderboard": """Helper wrapping of AocPrivateLeaderboard.json_from_url and AocPrivateLeaderboard.from_json.""" - api_json = await cls.json_from_url() + api_json = await cls.json_from_url(leaderboard_id, cookie) return cls.from_json(api_json) @staticmethod @@ -738,6 +793,6 @@ def _error_embed_helper(title: str, description: str) -> discord.Embed: return discord.Embed(title=title, description=description, colour=discord.Colour.red()) -def setup(bot: commands.Bot) -> None: +def setup(bot: Bot) -> None: """Advent of Code Cog load.""" bot.add_cog(AdventOfCode(bot)) -- cgit v1.2.3 From c3bbbbd114489d12b24a93ab3ab835fa85eb15e6 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 26 Nov 2020 20:01:59 +0200 Subject: Implement staff and public leaderboards --- bot/exts/christmas/adventofcode.py | 255 ++++++++++++++++++++++++++++--------- 1 file changed, 197 insertions(+), 58 deletions(-) diff --git a/bot/exts/christmas/adventofcode.py b/bot/exts/christmas/adventofcode.py index be1c733a..9b7780ae 100644 --- a/bot/exts/christmas/adventofcode.py +++ b/bot/exts/christmas/adventofcode.py @@ -136,21 +136,24 @@ class AdventOfCode(commands.Cog): public_leaderboard_members = RedisCache() # We don't want that users join to multiple leaderboards, so return only 1 code to user. - # User ID -> AoC leaderboard ID + # User ID -> Join code user_join_codes = RedisCache() + # We must keep track when user got (and what) stars, because we have multiple leaderboards. + # Format: User ID -> AoCCachedMember (pickle) + public_user_data = RedisCache() + def __init__(self, bot: Bot): self.bot = bot self._base_url = f"https://adventofcode.com/{AocConfig.year}" self.global_leaderboard_url = f"https://adventofcode.com/{AocConfig.year}/leaderboard" - self.private_leaderboard_url = f"{self._base_url}/leaderboard/private/view/{AocConfig.leaderboard_staff_id}" self.about_aoc_filepath = Path("./bot/resources/advent_of_code/about.json") self.cached_about_aoc = self._build_about_embed() self.cached_global_leaderboard = None - self.cached_private_leaderboard = None + self.cached_staff_leaderboard = None self.countdown_task = None self.status_task = None @@ -173,6 +176,11 @@ class AdventOfCode(commands.Cog): ) } + self.last_updated = None + self.staff_last_updated = None + self.refresh_lock = asyncio.Lock() + self.staff_refresh_lock = asyncio.Lock() + @seasonal_task(Month.DECEMBER, sleep_time=60 * 30) async def leaderboard_members_updater(self) -> None: """Updates public leaderboards cached member amounts in every 30 minutes.""" @@ -183,11 +191,129 @@ class AdventOfCode(commands.Cog): # Update every leaderboard for what we have session cookie for aoc_id, cookie in self.leaderboard_cookies.items(): leaderboard = await AocPrivateLeaderboard.from_url(aoc_id, cookie) - log.info(leaderboard.members) # Update only when API return any members if len(leaderboard.members) > 0: await self.public_leaderboard_members.set(aoc_id, len(leaderboard.members)) + async def refresh_leaderboard(self) -> None: + """Updates public PyDis leaderboard scores based on dates.""" + self.last_updated = datetime.utcnow() + leaderboard_users = {} + leaderboards = [ + await AocPrivateLeaderboard.json_from_url(aoc_id, cookie) + for aoc_id, cookie in self.leaderboard_cookies.items() + ] + + for leaderboard in leaderboards: + for member_id, data in leaderboard["members"].items(): + leaderboard_users[int(member_id)] = { + "name": data.get("name", "Anonymous User"), + "aoc_id": int(member_id), + "days": { + day: { + "star_one": "1" in stars, + "star_two": "2" in stars, + "star_one_earned": int(stars["1"]["get_star_ts"]) if "1" in stars else None, + "star_two_earned": int(stars["2"]["get_star_ts"]) if "2" in stars else None, + } for day, stars in data.get("completion_day_level", {}).items() + } + } + + # Iterate over every advent day + for day in range(1, 26): + day = str(day) + star_one_users = [] + star_two_users = [] + + for user, user_data in leaderboard_users.items(): + if day in user_data["days"]: + if user_data["days"][day]["star_one"]: + star_one_users.append({ + "id": user, + "earned": datetime.fromtimestamp(user_data["days"][day]["star_one_earned"]), + }) + + if user_data["days"][day]["star_two"]: + star_two_users.append({ + "id": user, + "earned": datetime.fromtimestamp(user_data["days"][day]["star_two_earned"]), + }) + + # Sort these lists based on user star earning time + star_one_users = sorted(star_one_users, key=lambda k: k["earned"])[:100] + star_two_users = sorted(star_two_users, key=lambda k: k["earned"])[:100] + + points = 100 + for star_user_one in star_one_users: + if "score" in leaderboard_users[star_user_one["id"]]: + leaderboard_users[star_user_one["id"]]["score"] += points + else: + leaderboard_users[star_user_one["id"]]["score"] = points + points -= 1 + + points = 100 + for star_user_two in star_two_users: + if "score" in leaderboard_users[star_user_two["id"]]: + leaderboard_users[star_user_two["id"]]["score"] += points + else: + leaderboard_users[star_user_two["id"]]["score"] = points + points -= 1 + + # Put completions also in to make building easier later. + for user, user_data in leaderboard_users.items(): + completions_star_one = sum([1 for day in user_data["days"].values() if day["star_one"]]) + completions_star_two = sum([1 for day in user_data["days"].values() if day["star_two"]]) + + leaderboard_users[user]["star_one_completions"] = completions_star_one + leaderboard_users[user]["star_two_completions"] = completions_star_two + + # Finally clear old cache and persist everything to Redis + await self.public_user_data.clear() + [await self.public_user_data.set(user, json.dumps(user_data)) for user, user_data in leaderboard_users.items()] + + async def check_leaderboard(self) -> None: + """Checks should be public leaderboard refreshed and refresh when required.""" + async with self.refresh_lock: + secs = AocConfig.leaderboard_cache_age_threshold_seconds + if self.last_updated is None or self.last_updated < datetime.utcnow() - timedelta(seconds=secs): + await self.refresh_leaderboard() + + async def check_staff_leaderboard(self) -> None: + """Checks should be staff leaderboard refreshed and refresh when required.""" + async with self.staff_refresh_lock: + secs = AocConfig.leaderboard_cache_age_threshold_seconds + if self.staff_last_updated is None or self.staff_last_updated < datetime.utcnow() - timedelta(seconds=secs): + self.staff_last_updated = datetime.utcnow() + self.cached_staff_leaderboard = await AocPrivateLeaderboard.from_url( + AocConfig.leaderboard_staff_id, + Tokens.aoc_staff_session_cookie + ) + + async def get_leaderboard(self, members_amount: int) -> str: + """Generates leaderboard based on Redis data.""" + await self.check_leaderboard() + leaderboard_members = sorted( + [json.loads(data) for user, data in await self.public_user_data.items()], key=lambda k: k["score"] + )[:members_amount] + + stargroup = f"{Emojis.star}, {Emojis.star * 2}" + header = f"{' ' * 3}{'Score'} {'Name':^25} {stargroup:^7}\n{'-' * 44}" + table = "" + for i, member in enumerate(leaderboard_members): + if member["name"] == "Anonymous User": + name = f"{member['name']} #{member['aoc_id']}" + else: + name = member["name"] + + table += ( + f"{i + 1:2}) {member['score']:4} {name:25.25} " + f"({member['star_one_completions']:2}, {member['star_two_completions']:2})\n" + ) + else: + table = f"```{header}\n{table}```" + + return table + @in_month(Month.DECEMBER) @commands.group(name="adventofcode", aliases=("aoc",)) @override_in_channel(AOC_WHITELIST) @@ -316,26 +442,32 @@ class AdventOfCode(commands.Cog): limit will default to this maximum and provide feedback to the user. """ async with ctx.typing(): - await self._check_leaderboard_cache(ctx) - - if not self.cached_private_leaderboard: - # Feedback on issues with leaderboard caching are sent by _check_leaderboard_cache() - # Short circuit here if there's an issue - return - + staff = ctx.channel.id == Channels.advent_of_code_staff number_of_people_to_display = await self._check_n_entries(ctx, number_of_people_to_display) - # Generate leaderboard table for embed - members_to_print = self.cached_private_leaderboard.top_n(number_of_people_to_display) - table = AocPrivateLeaderboard.build_leaderboard_embed(members_to_print) + if staff: + await self.check_staff_leaderboard() + members_to_print = self.cached_staff_leaderboard.top_n(number_of_people_to_display) + table = AocPrivateLeaderboard.build_leaderboard_embed(members_to_print) + else: + table = await self.get_leaderboard(number_of_people_to_display) # Build embed aoc_embed = discord.Embed( - description=f"Total members: {len(self.cached_private_leaderboard.members)}", + description=( + "Total members: " + f"{len(self.cached_staff_leaderboard.members) if staff else await self.public_user_data.length()}" + ), colour=Colours.soft_green, - timestamp=self.cached_private_leaderboard.last_updated + timestamp=self.staff_last_updated if staff else self.last_updated ) - aoc_embed.set_author(name="Advent of Code", url=self.private_leaderboard_url) + if ctx.channel.id == Channels.advent_of_code_staff: + aoc_embed.set_author( + name="Advent of Code", + url=f"{self._base_url}/leaderboard/private/view/{AocConfig.leaderboard_staff_id}" + ) + else: + aoc_embed.set_author(name="Advent of Code") aoc_embed.set_footer(text="Last Updated") await ctx.send( @@ -356,29 +488,54 @@ class AdventOfCode(commands.Cog): Embed will display the total members and the number of users who have completed each day's puzzle """ async with ctx.typing(): - await self._check_leaderboard_cache(ctx) - - if not self.cached_private_leaderboard: - # Feedback on issues with leaderboard caching are sent by _check_leaderboard_cache() - # Short circuit here if there's an issue - return + is_staff = ctx.channel.id == Channels.advent_of_code_staff + if is_staff: + await self.check_staff_leaderboard() + else: + await self.check_leaderboard() # Build ASCII table - total_members = len(self.cached_private_leaderboard.members) + if is_staff: + total_members = len(self.cached_staff_leaderboard.members) + else: + total_members = await self.public_user_data.length() + _star = Emojis.star header = f"{'Day':4}{_star:^8}{_star*2:^4}{'% ' + _star:^8}{'% ' + _star*2:^4}\n{'='*35}" table = "" - for day, completions in enumerate(self.cached_private_leaderboard.daily_completion_summary): - per_one_star = f"{(completions[0]/total_members)*100:.2f}" - per_two_star = f"{(completions[1]/total_members)*100:.2f}" + if is_staff: + for day, completions in enumerate(self.cached_staff_leaderboard.daily_completion_summary): + per_one_star = f"{(completions[0]/total_members)*100:.2f}" + per_two_star = f"{(completions[1]/total_members)*100:.2f}" - table += f"{day+1:3}){completions[0]:^8}{completions[1]:^6}{per_one_star:^10}{per_two_star:^6}\n" + table += f"{day+1:3}){completions[0]:^8}{completions[1]:^6}{per_one_star:^10}{per_two_star:^6}\n" + else: + completions = {} + # Build data for completion rates + for _, user_data in await self.public_user_data.items(): + user_data = json.loads(user_data) + for day, stars in user_data["days"].items(): + day = int(day) + if day not in completions: + completions[day] = [0, 0] + + if stars["star_one"]: + completions[day][0] += 1 + if stars["star_two"]: + completions[day][1] += 1 + + for day, completion in completions.items(): + per_one_star = f"{(completion[0]/total_members)*100:.2f}" + per_two_star = f"{(completion[1] / total_members) * 100:.2f}" + + table += f"{day:3}){completion[0]:^8}{completion[1]:^6}{per_one_star:^10}{per_two_star:^6}\n" table = f"```\n{header}\n{table}```" # Build embed daily_stats_embed = discord.Embed( - colour=Colours.soft_green, timestamp=self.cached_private_leaderboard.last_updated + colour=Colours.soft_green, + timestamp=self.staff_last_updated if is_staff else self.last_updated ) daily_stats_embed.set_author(name="Advent of Code", url=self._base_url) daily_stats_embed.set_footer(text="Last Updated") @@ -402,7 +559,7 @@ class AdventOfCode(commands.Cog): limit will default to this maximum and provide feedback to the user. """ async with ctx.typing(): - await self._check_leaderboard_cache(ctx, global_board=True) + await self._check_leaderboard_cache(ctx) if not self.cached_global_leaderboard: # Feedback on issues with leaderboard caching are sent by _check_leaderboard_cache() @@ -425,41 +582,31 @@ class AdventOfCode(commands.Cog): embed=aoc_embed, ) - async def _check_leaderboard_cache(self, ctx: commands.Context, global_board: bool = False) -> None: + async def _check_leaderboard_cache(self, ctx: commands.Context) -> None: """ Check age of current leaderboard & pull a new one if the board is too old. global_board is a boolean to toggle between the global board and the Pydis private board """ - # Toggle between global & private leaderboards - if global_board: - log.debug("Checking global leaderboard cache") - leaderboard_str = "cached_global_leaderboard" - _shortstr = "global" - else: - log.debug("Checking private leaderboard cache") - leaderboard_str = "cached_private_leaderboard" - _shortstr = "private" - - leaderboard = getattr(self, leaderboard_str) + leaderboard = self.cached_global_leaderboard if not leaderboard: - log.debug(f"No cached {_shortstr} leaderboard found") - await self._boardgetter(global_board) + log.debug("No cached global leaderboard found") + self.cached_global_leaderboard = await AocGlobalLeaderboard.from_url() else: leaderboard_age = datetime.utcnow() - leaderboard.last_updated age_seconds = leaderboard_age.total_seconds() if age_seconds < AocConfig.leaderboard_cache_age_threshold_seconds: - log.debug(f"Cached {_shortstr} leaderboard age less than threshold ({age_seconds} seconds old)") + log.debug(f"Cached global leaderboard age less than threshold ({age_seconds} seconds old)") else: - log.debug(f"Cached {_shortstr} leaderboard age greater than threshold ({age_seconds} seconds old)") - await self._boardgetter(global_board) + log.debug(f"Cached global leaderboard age greater than threshold ({age_seconds} seconds old)") + self.cached_global_leaderboard = await AocGlobalLeaderboard.from_url() - leaderboard = getattr(self, leaderboard_str) + leaderboard = self.cached_global_leaderboard if not leaderboard: await ctx.send( "", embed=_error_embed_helper( - title=f"Something's gone wrong and there's no cached {_shortstr} leaderboard!", + title="Something's gone wrong and there's no cached global leaderboard!", description="Please check in with a staff member.", ), ) @@ -475,8 +622,7 @@ class AdventOfCode(commands.Cog): ) await ctx.send( f":x: {author.mention}, number of entries to display must be a positive " - f"integer less than or equal to {max_entries}\n\n" - f"Head to {self.private_leaderboard_url} to view the entire leaderboard" + f"integer less than or equal to {max_entries}" ) number_of_people_to_display = max_entries @@ -496,13 +642,6 @@ class AdventOfCode(commands.Cog): return about_embed - async def _boardgetter(self, global_board: bool) -> None: - """Invoke the proper leaderboard getter based on the global_board boolean.""" - if global_board: - self.cached_global_leaderboard = await AocGlobalLeaderboard.from_url() - else: - self.cached_private_leaderboard = await AocPrivateLeaderboard.from_url() - def cog_unload(self) -> None: """Cancel season-related tasks on cog unload.""" log.debug("Unloading the cog and canceling the background task.") -- cgit v1.2.3 From eddb38e9c091e8e0f3cb4529ec501091f71d901b Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 26 Nov 2020 20:05:29 +0200 Subject: Store AoC leaderboard IDs instead join codes for users mapping --- bot/exts/christmas/adventofcode.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/bot/exts/christmas/adventofcode.py b/bot/exts/christmas/adventofcode.py index 9b7780ae..70bdc50a 100644 --- a/bot/exts/christmas/adventofcode.py +++ b/bot/exts/christmas/adventofcode.py @@ -136,8 +136,8 @@ class AdventOfCode(commands.Cog): public_leaderboard_members = RedisCache() # We don't want that users join to multiple leaderboards, so return only 1 code to user. - # User ID -> Join code - user_join_codes = RedisCache() + # User ID -> AoC Leaderboard ID + user_leaderboards = RedisCache() # We must keep track when user got (and what) stars, because we have multiple leaderboards. # Format: User ID -> AoCCachedMember (pickle) @@ -400,8 +400,8 @@ class AdventOfCode(commands.Cog): log.info(f"{author.name} ({author.id}) ran command in staff AoC channel. Returning staff code.") else: # We want that user get only 1 code - if await self.user_join_codes.contains(ctx.author.id): - join_code = await self.user_join_codes.get(ctx.author.id) + if await self.user_leaderboards.contains(ctx.author.id): + join_code = self.leaderboard_join_codes[await self.user_leaderboards.get(ctx.author.id)] log.info(f"{author.name} ({author.id}) have already cached AoC join code. Returning it.") else: least_id, least = 0, 200 @@ -412,7 +412,7 @@ class AdventOfCode(commands.Cog): join_code = self.leaderboard_join_codes[least_id] # Persist this code to Redis, so we can get it later again. - await self.user_join_codes.set(ctx.author.id, join_code) + await self.user_leaderboards.set(ctx.author.id, least_id) log.info(f"{author.name} ({author.id}) got new join code. Persisted it to cache.") info_str = ( @@ -466,6 +466,11 @@ class AdventOfCode(commands.Cog): name="Advent of Code", url=f"{self._base_url}/leaderboard/private/view/{AocConfig.leaderboard_staff_id}" ) + elif await self.user_leaderboards.contains(ctx.author.id): + aoc_embed.set_author( + name="Advent of Code", + url=f"{self._base_url}/leaderboard/private/view/{await self.user_leaderboards.get(ctx.author.id)}" + ) else: aoc_embed.set_author(name="Advent of Code") aoc_embed.set_footer(text="Last Updated") -- cgit v1.2.3 From 40461802eca56b9d92e627e275b402bdcd3e6824 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 27 Nov 2020 17:31:05 +0200 Subject: Add comment about choosing leaderboard for user --- bot/exts/christmas/adventofcode.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/exts/christmas/adventofcode.py b/bot/exts/christmas/adventofcode.py index 70bdc50a..2b8ac7bf 100644 --- a/bot/exts/christmas/adventofcode.py +++ b/bot/exts/christmas/adventofcode.py @@ -404,6 +404,7 @@ class AdventOfCode(commands.Cog): join_code = self.leaderboard_join_codes[await self.user_leaderboards.get(ctx.author.id)] log.info(f"{author.name} ({author.id}) have already cached AoC join code. Returning it.") else: + # Find leaderboard that have least members inside (based on cache) least_id, least = 0, 200 for aoc_id, amount in await self.public_leaderboard_members.items(): log.info(amount, least) -- cgit v1.2.3 From ab28341bfdcf91d0ebf6ce1e4bc492b748067330 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 27 Nov 2020 17:32:03 +0200 Subject: Remove unnecessary check for members in leaderboard updater task --- bot/exts/christmas/adventofcode.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bot/exts/christmas/adventofcode.py b/bot/exts/christmas/adventofcode.py index 2b8ac7bf..0d73eb3f 100644 --- a/bot/exts/christmas/adventofcode.py +++ b/bot/exts/christmas/adventofcode.py @@ -191,9 +191,7 @@ class AdventOfCode(commands.Cog): # Update every leaderboard for what we have session cookie for aoc_id, cookie in self.leaderboard_cookies.items(): leaderboard = await AocPrivateLeaderboard.from_url(aoc_id, cookie) - # Update only when API return any members - if len(leaderboard.members) > 0: - await self.public_leaderboard_members.set(aoc_id, len(leaderboard.members)) + await self.public_leaderboard_members.set(aoc_id, len(leaderboard.members)) async def refresh_leaderboard(self) -> None: """Updates public PyDis leaderboard scores based on dates.""" -- cgit v1.2.3 From bede07a6983a1d15461cd65914f41df624244dc3 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 27 Nov 2020 18:00:35 +0200 Subject: Handle leaderboard cache create/update fail --- bot/exts/christmas/adventofcode.py | 64 +++++++++++++++++++++++++++++++------- 1 file changed, 52 insertions(+), 12 deletions(-) diff --git a/bot/exts/christmas/adventofcode.py b/bot/exts/christmas/adventofcode.py index 0d73eb3f..9d7289c2 100644 --- a/bot/exts/christmas/adventofcode.py +++ b/bot/exts/christmas/adventofcode.py @@ -3,6 +3,7 @@ import json import logging import math import re +import typing from datetime import datetime, timedelta from pathlib import Path from typing import List, Tuple @@ -202,6 +203,11 @@ class AdventOfCode(commands.Cog): for aoc_id, cookie in self.leaderboard_cookies.items() ] + # Check does this have any failed requests + if False in leaderboards: + log.warning("Unable to get one or more of the public leaderboards. Not updating cache.") + return + for leaderboard in leaderboards: for member_id, data in leaderboard["members"].items(): leaderboard_users[int(member_id)] = { @@ -281,15 +287,24 @@ class AdventOfCode(commands.Cog): async with self.staff_refresh_lock: secs = AocConfig.leaderboard_cache_age_threshold_seconds if self.staff_last_updated is None or self.staff_last_updated < datetime.utcnow() - timedelta(seconds=secs): - self.staff_last_updated = datetime.utcnow() - self.cached_staff_leaderboard = await AocPrivateLeaderboard.from_url( + leaderboard = await AocPrivateLeaderboard.from_url( AocConfig.leaderboard_staff_id, Tokens.aoc_staff_session_cookie ) - async def get_leaderboard(self, members_amount: int) -> str: + if leaderboard is not None: + self.staff_last_updated = datetime.utcnow() + self.cached_staff_leaderboard = leaderboard + else: + log.warning("Can't update staff leaderboard. Got unexpected response.") + + async def get_leaderboard(self, members_amount: int, context: commands.Context) -> typing.Union[str, bool]: """Generates leaderboard based on Redis data.""" - await self.check_leaderboard() + # When we don't have users in cache, log warning and return False. + if await self.public_user_data.length() == 0: + log.warning("Don't have cache for displaying AoC public leaderboard.") + return False + leaderboard_members = sorted( [json.loads(data) for user, data in await self.public_user_data.items()], key=lambda k: k["score"] )[:members_amount] @@ -446,10 +461,22 @@ class AdventOfCode(commands.Cog): if staff: await self.check_staff_leaderboard() - members_to_print = self.cached_staff_leaderboard.top_n(number_of_people_to_display) - table = AocPrivateLeaderboard.build_leaderboard_embed(members_to_print) + if self.cached_staff_leaderboard is not None: + members_to_print = self.cached_staff_leaderboard.top_n(number_of_people_to_display) + table = AocPrivateLeaderboard.build_leaderboard_embed(members_to_print) + else: + log.warning("Missing AoC staff leaderboard cache.") + table = False else: - table = await self.get_leaderboard(number_of_people_to_display) + await self.check_leaderboard() + table = await self.get_leaderboard(number_of_people_to_display, ctx) + + # When we don't have cache, show it to user. + if table is False: + await ctx.send( + ":x: Sorry, we can't get our leaderboard cache. Please let staff know about it." + ) + return # Build embed aoc_embed = discord.Embed( @@ -495,6 +522,9 @@ class AdventOfCode(commands.Cog): is_staff = ctx.channel.id == Channels.advent_of_code_staff if is_staff: await self.check_staff_leaderboard() + if self.cached_staff_leaderboard is None: + await ctx.send(":x: Missing leaderboard cache.") + return else: await self.check_leaderboard() @@ -503,6 +533,9 @@ class AdventOfCode(commands.Cog): total_members = len(self.cached_staff_leaderboard.members) else: total_members = await self.public_user_data.length() + if total_members == 0: + await ctx.send(":x: Missing leaderboard cache. Please notify staff about it.") + return _star = Emojis.star header = f"{'Day':4}{_star:^8}{_star*2:^4}{'% ' + _star:^8}{'% ' + _star*2:^4}\n{'='*35}" @@ -779,7 +812,7 @@ class AocPrivateLeaderboard: @staticmethod async def json_from_url( leaderboard_id: int, cookie: str, year: int = AocConfig.year - ) -> dict: + ) -> typing.Union[dict, bool]: """ Request the API JSON from Advent of Code for leaderboard_id for the specified year's event. @@ -791,10 +824,14 @@ class AocPrivateLeaderboard: async with aiohttp.ClientSession(headers=AOC_REQUEST_HEADER, cookies={"session": cookie}) as session: async with session.get(api_url) as resp: if resp.status == 200: - raw_dict = await resp.json() + try: + raw_dict = await resp.json() + except aiohttp.ContentTypeError: + log.warning(f"Got invalid response type from AoC API. Leaderboard ID {leaderboard_id}") + return False else: log.warning(f"Bad response received from AoC ({resp.status}), check session cookie") - resp.raise_for_status() + return False return raw_dict @@ -806,10 +843,13 @@ class AocPrivateLeaderboard: ) @classmethod - async def from_url(cls, leaderboard_id: int, cookie: str) -> "AocPrivateLeaderboard": + async def from_url(cls, leaderboard_id: int, cookie: str) -> typing.Union["AocPrivateLeaderboard", None]: """Helper wrapping of AocPrivateLeaderboard.json_from_url and AocPrivateLeaderboard.from_json.""" api_json = await cls.json_from_url(leaderboard_id, cookie) - return cls.from_json(api_json) + if api_json is not False: + return cls.from_json(api_json) + else: + return None @staticmethod def _sorted_members(injson: dict) -> list: -- cgit v1.2.3 From 6be2a47e32e5e07ab23d2796b5e551be1ec7b0b6 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 27 Nov 2020 18:02:09 +0200 Subject: Use default 0 for score and reverse leaderboard members --- bot/exts/christmas/adventofcode.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/exts/christmas/adventofcode.py b/bot/exts/christmas/adventofcode.py index 9d7289c2..feff09aa 100644 --- a/bot/exts/christmas/adventofcode.py +++ b/bot/exts/christmas/adventofcode.py @@ -306,7 +306,9 @@ class AdventOfCode(commands.Cog): return False leaderboard_members = sorted( - [json.loads(data) for user, data in await self.public_user_data.items()], key=lambda k: k["score"] + [json.loads(data) for user, data in await self.public_user_data.items()], + key=lambda k: k.get("score", 0), + reverse=True )[:members_amount] stargroup = f"{Emojis.star}, {Emojis.star * 2}" -- cgit v1.2.3 From ffa3619a19515c147013669339f51d36f83a5f29 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 27 Nov 2020 19:14:27 +0200 Subject: Fix adventofcode extension (and constants) grammar Co-authored-by: Joe Banks --- bot/constants.py | 4 ++-- bot/exts/christmas/adventofcode.py | 20 ++++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index bbfe7c3f..d24551f6 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -33,7 +33,7 @@ class AdventOfCode: leaderboard_cache_age_threshold_seconds = 3600 leaderboard_public_ids = [645282] leaderboard_staff_id = 957532 - # Public join codes in environment must be in same order than in AdventOfCode.leaderboard_public_ids + # Public join codes in environment must be in the same order as AdventOfCode.leaderboard_public_ids leaderboard_public_join_codes = environ.get("AOC_PUBLIC_JOIN_CODES", "").split(",") leaderboard_staff_join_code = environ.get("AOC_STAFF_JOIN_CODE", "") leaderboard_max_displayed_members = 10 @@ -204,7 +204,7 @@ class Roles(NamedTuple): class Tokens(NamedTuple): giphy = environ.get("GIPHY_TOKEN") - # Public AoC cookies in environment must be in same order than in AdventOfCode.leaderboard_public_ids + # Public AoC cookies in environment must be in the same order as AdventOfCode.leaderboard_public_ids aoc_public_session_cookies = environ.get("AOC_PUBLIC_SESSION_COOKIES", "").split(",") aoc_staff_session_cookie = environ.get("AOC_STAFF_SESSION_COOKIE") omdb = environ.get("OMDB_API_KEY") diff --git a/bot/exts/christmas/adventofcode.py b/bot/exts/christmas/adventofcode.py index feff09aa..b188059e 100644 --- a/bot/exts/christmas/adventofcode.py +++ b/bot/exts/christmas/adventofcode.py @@ -185,11 +185,11 @@ class AdventOfCode(commands.Cog): @seasonal_task(Month.DECEMBER, sleep_time=60 * 30) async def leaderboard_members_updater(self) -> None: """Updates public leaderboards cached member amounts in every 30 minutes.""" - # Whole December isn't advent + # Check whether we are in the 25 days of advent if not is_in_advent(): return - # Update every leaderboard for what we have session cookie + # Update every leaderboard with our session cookies for aoc_id, cookie in self.leaderboard_cookies.items(): leaderboard = await AocPrivateLeaderboard.from_url(aoc_id, cookie) await self.public_leaderboard_members.set(aoc_id, len(leaderboard.members)) @@ -203,7 +203,7 @@ class AdventOfCode(commands.Cog): for aoc_id, cookie in self.leaderboard_cookies.items() ] - # Check does this have any failed requests + # Check if any requests failed if False in leaderboards: log.warning("Unable to get one or more of the public leaderboards. Not updating cache.") return @@ -243,7 +243,7 @@ class AdventOfCode(commands.Cog): "earned": datetime.fromtimestamp(user_data["days"][day]["star_two_earned"]), }) - # Sort these lists based on user star earning time + # Sort these lists based on the time a user earnt a star star_one_users = sorted(star_one_users, key=lambda k: k["earned"])[:100] star_two_users = sorted(star_two_users, key=lambda k: k["earned"])[:100] @@ -263,7 +263,7 @@ class AdventOfCode(commands.Cog): leaderboard_users[star_user_two["id"]]["score"] = points points -= 1 - # Put completions also in to make building easier later. + # Attach star completions for building the response later for user, user_data in leaderboard_users.items(): completions_star_one = sum([1 for day in user_data["days"].values() if day["star_one"]]) completions_star_two = sum([1 for day in user_data["days"].values() if day["star_two"]]) @@ -271,7 +271,7 @@ class AdventOfCode(commands.Cog): leaderboard_users[user]["star_one_completions"] = completions_star_one leaderboard_users[user]["star_two_completions"] = completions_star_two - # Finally clear old cache and persist everything to Redis + # Finally, clear old cache and persist everything to Redis await self.public_user_data.clear() [await self.public_user_data.set(user, json.dumps(user_data)) for user, user_data in leaderboard_users.items()] @@ -300,7 +300,7 @@ class AdventOfCode(commands.Cog): async def get_leaderboard(self, members_amount: int, context: commands.Context) -> typing.Union[str, bool]: """Generates leaderboard based on Redis data.""" - # When we don't have users in cache, log warning and return False. + # When we don't have users in cache, warn and return False. if await self.public_user_data.length() == 0: log.warning("Don't have cache for displaying AoC public leaderboard.") return False @@ -414,12 +414,12 @@ class AdventOfCode(commands.Cog): join_code = AocConfig.leaderboard_staff_join_code log.info(f"{author.name} ({author.id}) ran command in staff AoC channel. Returning staff code.") else: - # We want that user get only 1 code + # Ensure we use the same leaderboard code for the same user if await self.user_leaderboards.contains(ctx.author.id): join_code = self.leaderboard_join_codes[await self.user_leaderboards.get(ctx.author.id)] - log.info(f"{author.name} ({author.id}) have already cached AoC join code. Returning it.") + log.info(f"{author.name} ({author.id}) has a cached AoC join code, returning it.") else: - # Find leaderboard that have least members inside (based on cache) + # Find the leaderboard that has the least members inside from cache least_id, least = 0, 200 for aoc_id, amount in await self.public_leaderboard_members.items(): log.info(amount, least) -- cgit v1.2.3 From 73dff91f530d7125a7ff130e9a56b759b7fdbaab Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Mon, 30 Nov 2020 00:35:57 +0100 Subject: Add constants parsing for multiple leaderboards I've added a unified approach to setting data for multiple leaderboards using environmental variables. Instead of setting separate variables for the three pieces of data we have, hoping that the position of each board matches up in the three fields, I now set one environmental variable, AOC_LEADERBOARDS, that holds delimited data. The data is in the format: board_id1,session1,join_code1::board_id2,session2,join_code2[::...] The staff leaderboards should be included as usual in this environmental variable. Another environment variable, AOC_STAFF_LEADERBOARD_ID, can be used to designate which leaderboard should be used as the staff board. I've also made some other constants configurable in this commit and added the role ID of the Events Lead role to allow the Events Lead to force a reload of the leaderboard cache. --- bot/constants.py | 47 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index d24551f6..fc9929ec 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -2,7 +2,7 @@ import enum import logging from datetime import datetime from os import environ -from typing import NamedTuple +from typing import Dict, NamedTuple __all__ = ( "AdventOfCode", @@ -29,14 +29,42 @@ __all__ = ( log = logging.getLogger(__name__) +class AdventOfCodeLeaderboard(NamedTuple): + id: str + session: str + join_code: str + + +def _parse_aoc_leaderboard_env() -> Dict[str, AdventOfCodeLeaderboard]: + """ + Parse the environment variable containing leaderboard information. + + A leaderboard should be specified in the format `id,session,join_code`, + without the backticks. If more than leaderboard needs to be added to the + constants, separate the individual leaderboards with `::`. + + Example ENV: `id1,session1,join_code1::id2,session2,join_code2` + """ + raw_leaderboards = environ.get("AOC_LEADERBOARDS", "") + if not raw_leaderboards: + return {} + + leaderboards = {} + for leaderboard in raw_leaderboards.split("::"): + leaderboard_id, session, join_code = leaderboard.split(",") + leaderboards[leaderboard_id] = AdventOfCodeLeaderboard(leaderboard_id, session, join_code) + + return leaderboards + + class AdventOfCode: - leaderboard_cache_age_threshold_seconds = 3600 - leaderboard_public_ids = [645282] - leaderboard_staff_id = 957532 - # Public join codes in environment must be in the same order as AdventOfCode.leaderboard_public_ids - leaderboard_public_join_codes = environ.get("AOC_PUBLIC_JOIN_CODES", "").split(",") - leaderboard_staff_join_code = environ.get("AOC_STAFF_JOIN_CODE", "") - leaderboard_max_displayed_members = 10 + # Information for the several leaderboards we have + leaderboards = _parse_aoc_leaderboard_env() + staff_leaderboard_id = environ.get("AOC_STAFF_LEADERBOARD_ID", "") + + # Other Advent of Code constants + leaderboard_displayed_members = 10 + leaderboard_cache_expiry_seconds = 1800 year = int(environ.get("AOC_YEAR", datetime.utcnow().year)) role_id = int(environ.get("AOC_ROLE_ID", 518565788744024082)) @@ -197,9 +225,10 @@ class Roles(NamedTuple): muted = 277914926603829249 owner = 267627879762755584 verified = 352427296948486144 - helpers = 267630620367257601 + helpers = int(environ.get("ROLE_HELPERS", 267630620367257601)) rockstars = 458226413825294336 core_developers = 587606783669829632 + events_lead = 778361735739998228 class Tokens(NamedTuple): -- cgit v1.2.3 From 0c79e90c8f9637fd6136047fecdec5813d2ccca3 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Mon, 30 Nov 2020 00:42:55 +0100 Subject: Remove adventofcode.py in favour of subpackage As the Advent of Code file was getting massive, I've removed the old single-file based extension as I'm going to replace it with a partially rewritten subpackage-based extension. --- bot/exts/christmas/adventofcode.py | 983 ------------------------------------- 1 file changed, 983 deletions(-) delete mode 100644 bot/exts/christmas/adventofcode.py diff --git a/bot/exts/christmas/adventofcode.py b/bot/exts/christmas/adventofcode.py deleted file mode 100644 index b188059e..00000000 --- a/bot/exts/christmas/adventofcode.py +++ /dev/null @@ -1,983 +0,0 @@ -import asyncio -import json -import logging -import math -import re -import typing -from datetime import datetime, timedelta -from pathlib import Path -from typing import List, Tuple - -import aiohttp -import discord -from async_rediscache import RedisCache -from bs4 import BeautifulSoup -from discord.ext import commands -from pytz import timezone - -from bot.bot import Bot -from bot.constants import AdventOfCode as AocConfig, Channels, Colours, Emojis, Month, Tokens, WHITELISTED_CHANNELS -from bot.utils import unlocked_role -from bot.utils.decorators import in_month, override_in_channel, seasonal_task - -log = logging.getLogger(__name__) - -AOC_REQUEST_HEADER = {"user-agent": "PythonDiscord AoC Event Bot"} - -EST = timezone("EST") -COUNTDOWN_STEP = 60 * 5 - -AOC_WHITELIST = WHITELISTED_CHANNELS + (Channels.advent_of_code, Channels.advent_of_code_staff) - - -def is_in_advent() -> bool: - """Utility function to check if we are between December 1st and December 25th.""" - # Run the code from the 1st to the 24th - return datetime.now(EST).day in range(1, 25) and datetime.now(EST).month == 12 - - -def time_left_to_aoc_midnight() -> Tuple[datetime, timedelta]: - """Calculates the amount of time left until midnight in UTC-5 (Advent of Code maintainer timezone).""" - # Change all time properties back to 00:00 - todays_midnight = datetime.now(EST).replace(microsecond=0, - second=0, - minute=0, - hour=0) - - # We want tomorrow so add a day on - tomorrow = todays_midnight + timedelta(days=1) - - # Calculate the timedelta between the current time and midnight - return tomorrow, tomorrow - datetime.now(EST) - - -async def countdown_status(bot: commands.Bot) -> None: - """Set the playing status of the bot to the minutes & hours left until the next day's challenge.""" - while is_in_advent(): - _, time_left = time_left_to_aoc_midnight() - - aligned_seconds = int(math.ceil(time_left.seconds / COUNTDOWN_STEP)) * COUNTDOWN_STEP - hours, minutes = aligned_seconds // 3600, aligned_seconds // 60 % 60 - - if aligned_seconds == 0: - playing = "right now!" - elif aligned_seconds == COUNTDOWN_STEP: - playing = f"in less than {minutes} minutes" - elif hours == 0: - playing = f"in {minutes} minutes" - elif hours == 23: - playing = f"since {60 - minutes} minutes ago" - else: - playing = f"in {hours} hours and {minutes} minutes" - - # Status will look like "Playing in 5 hours and 30 minutes" - await bot.change_presence(activity=discord.Game(playing)) - - # Sleep until next aligned time or a full step if already aligned - delay = time_left.seconds % COUNTDOWN_STEP or COUNTDOWN_STEP - await asyncio.sleep(delay) - - -async def day_countdown(bot: commands.Bot) -> None: - """ - Calculate the number of seconds left until the next day of Advent. - - Once we have calculated this we should then sleep that number and when the time is reached, ping - the Advent of Code role notifying them that the new challenge is ready. - """ - while is_in_advent(): - tomorrow, time_left = time_left_to_aoc_midnight() - - # Correct `time_left.seconds` for the sleep we have after unlocking the role (-5) and adding - # a second (+1) as the bot is consistently ~0.5 seconds early in announcing the puzzles. - await asyncio.sleep(time_left.seconds - 4) - - channel = bot.get_channel(Channels.advent_of_code) - - if not channel: - log.error("Could not find the AoC channel to send notification in") - break - - aoc_role = channel.guild.get_role(AocConfig.role_id) - if not aoc_role: - log.error("Could not find the AoC role to announce the daily puzzle") - break - - async with unlocked_role(aoc_role, delay=5): - puzzle_url = f"https://adventofcode.com/{AocConfig.year}/day/{tomorrow.day}" - - # Check if the puzzle is already available to prevent our members from spamming - # the puzzle page before it's available by making a small HEAD request. - for retry in range(1, 5): - log.debug(f"Checking if the puzzle is already available (attempt {retry}/4)") - async with bot.http_session.head(puzzle_url, raise_for_status=False) as resp: - if resp.status == 200: - log.debug("Puzzle is available; let's send an announcement message.") - break - log.debug(f"The puzzle is not yet available (status={resp.status})") - await asyncio.sleep(10) - else: - log.error("The puzzle does does not appear to be available at this time, canceling announcement") - break - - await channel.send( - f"{aoc_role.mention} Good morning! Day {tomorrow.day} is ready to be attempted. " - f"View it online now at {puzzle_url}. Good luck!" - ) - - # Wait a couple minutes so that if our sleep didn't sleep enough - # time we don't end up announcing twice. - await asyncio.sleep(120) - - -class AdventOfCode(commands.Cog): - """Advent of Code festivities! Ho Ho Ho!""" - - # Mapping for AoC PyDis community leaderboard IDs -> cached amount of members in leaderboard. - public_leaderboard_members = RedisCache() - - # We don't want that users join to multiple leaderboards, so return only 1 code to user. - # User ID -> AoC Leaderboard ID - user_leaderboards = RedisCache() - - # We must keep track when user got (and what) stars, because we have multiple leaderboards. - # Format: User ID -> AoCCachedMember (pickle) - public_user_data = RedisCache() - - def __init__(self, bot: Bot): - self.bot = bot - - self._base_url = f"https://adventofcode.com/{AocConfig.year}" - self.global_leaderboard_url = f"https://adventofcode.com/{AocConfig.year}/leaderboard" - - self.about_aoc_filepath = Path("./bot/resources/advent_of_code/about.json") - self.cached_about_aoc = self._build_about_embed() - - self.cached_global_leaderboard = None - self.cached_staff_leaderboard = None - - self.countdown_task = None - self.status_task = None - self.leaderboard_member_update_task = self.bot.loop.create_task(self.leaderboard_members_updater()) - - countdown_coro = day_countdown(self.bot) - self.countdown_task = self.bot.loop.create_task(countdown_coro) - - status_coro = countdown_status(self.bot) - self.status_task = self.bot.loop.create_task(status_coro) - - self.leaderboard_join_codes = { - aoc_id: join_code for aoc_id, join_code in zip( - AocConfig.leaderboard_public_ids, AocConfig.leaderboard_public_join_codes - ) - } - self.leaderboard_cookies = { - aoc_id: cookie for aoc_id, cookie in zip( - AocConfig.leaderboard_public_ids, Tokens.aoc_public_session_cookies - ) - } - - self.last_updated = None - self.staff_last_updated = None - self.refresh_lock = asyncio.Lock() - self.staff_refresh_lock = asyncio.Lock() - - @seasonal_task(Month.DECEMBER, sleep_time=60 * 30) - async def leaderboard_members_updater(self) -> None: - """Updates public leaderboards cached member amounts in every 30 minutes.""" - # Check whether we are in the 25 days of advent - if not is_in_advent(): - return - - # Update every leaderboard with our session cookies - for aoc_id, cookie in self.leaderboard_cookies.items(): - leaderboard = await AocPrivateLeaderboard.from_url(aoc_id, cookie) - await self.public_leaderboard_members.set(aoc_id, len(leaderboard.members)) - - async def refresh_leaderboard(self) -> None: - """Updates public PyDis leaderboard scores based on dates.""" - self.last_updated = datetime.utcnow() - leaderboard_users = {} - leaderboards = [ - await AocPrivateLeaderboard.json_from_url(aoc_id, cookie) - for aoc_id, cookie in self.leaderboard_cookies.items() - ] - - # Check if any requests failed - if False in leaderboards: - log.warning("Unable to get one or more of the public leaderboards. Not updating cache.") - return - - for leaderboard in leaderboards: - for member_id, data in leaderboard["members"].items(): - leaderboard_users[int(member_id)] = { - "name": data.get("name", "Anonymous User"), - "aoc_id": int(member_id), - "days": { - day: { - "star_one": "1" in stars, - "star_two": "2" in stars, - "star_one_earned": int(stars["1"]["get_star_ts"]) if "1" in stars else None, - "star_two_earned": int(stars["2"]["get_star_ts"]) if "2" in stars else None, - } for day, stars in data.get("completion_day_level", {}).items() - } - } - - # Iterate over every advent day - for day in range(1, 26): - day = str(day) - star_one_users = [] - star_two_users = [] - - for user, user_data in leaderboard_users.items(): - if day in user_data["days"]: - if user_data["days"][day]["star_one"]: - star_one_users.append({ - "id": user, - "earned": datetime.fromtimestamp(user_data["days"][day]["star_one_earned"]), - }) - - if user_data["days"][day]["star_two"]: - star_two_users.append({ - "id": user, - "earned": datetime.fromtimestamp(user_data["days"][day]["star_two_earned"]), - }) - - # Sort these lists based on the time a user earnt a star - star_one_users = sorted(star_one_users, key=lambda k: k["earned"])[:100] - star_two_users = sorted(star_two_users, key=lambda k: k["earned"])[:100] - - points = 100 - for star_user_one in star_one_users: - if "score" in leaderboard_users[star_user_one["id"]]: - leaderboard_users[star_user_one["id"]]["score"] += points - else: - leaderboard_users[star_user_one["id"]]["score"] = points - points -= 1 - - points = 100 - for star_user_two in star_two_users: - if "score" in leaderboard_users[star_user_two["id"]]: - leaderboard_users[star_user_two["id"]]["score"] += points - else: - leaderboard_users[star_user_two["id"]]["score"] = points - points -= 1 - - # Attach star completions for building the response later - for user, user_data in leaderboard_users.items(): - completions_star_one = sum([1 for day in user_data["days"].values() if day["star_one"]]) - completions_star_two = sum([1 for day in user_data["days"].values() if day["star_two"]]) - - leaderboard_users[user]["star_one_completions"] = completions_star_one - leaderboard_users[user]["star_two_completions"] = completions_star_two - - # Finally, clear old cache and persist everything to Redis - await self.public_user_data.clear() - [await self.public_user_data.set(user, json.dumps(user_data)) for user, user_data in leaderboard_users.items()] - - async def check_leaderboard(self) -> None: - """Checks should be public leaderboard refreshed and refresh when required.""" - async with self.refresh_lock: - secs = AocConfig.leaderboard_cache_age_threshold_seconds - if self.last_updated is None or self.last_updated < datetime.utcnow() - timedelta(seconds=secs): - await self.refresh_leaderboard() - - async def check_staff_leaderboard(self) -> None: - """Checks should be staff leaderboard refreshed and refresh when required.""" - async with self.staff_refresh_lock: - secs = AocConfig.leaderboard_cache_age_threshold_seconds - if self.staff_last_updated is None or self.staff_last_updated < datetime.utcnow() - timedelta(seconds=secs): - leaderboard = await AocPrivateLeaderboard.from_url( - AocConfig.leaderboard_staff_id, - Tokens.aoc_staff_session_cookie - ) - - if leaderboard is not None: - self.staff_last_updated = datetime.utcnow() - self.cached_staff_leaderboard = leaderboard - else: - log.warning("Can't update staff leaderboard. Got unexpected response.") - - async def get_leaderboard(self, members_amount: int, context: commands.Context) -> typing.Union[str, bool]: - """Generates leaderboard based on Redis data.""" - # When we don't have users in cache, warn and return False. - if await self.public_user_data.length() == 0: - log.warning("Don't have cache for displaying AoC public leaderboard.") - return False - - leaderboard_members = sorted( - [json.loads(data) for user, data in await self.public_user_data.items()], - key=lambda k: k.get("score", 0), - reverse=True - )[:members_amount] - - stargroup = f"{Emojis.star}, {Emojis.star * 2}" - header = f"{' ' * 3}{'Score'} {'Name':^25} {stargroup:^7}\n{'-' * 44}" - table = "" - for i, member in enumerate(leaderboard_members): - if member["name"] == "Anonymous User": - name = f"{member['name']} #{member['aoc_id']}" - else: - name = member["name"] - - table += ( - f"{i + 1:2}) {member['score']:4} {name:25.25} " - f"({member['star_one_completions']:2}, {member['star_two_completions']:2})\n" - ) - else: - table = f"```{header}\n{table}```" - - return table - - @in_month(Month.DECEMBER) - @commands.group(name="adventofcode", aliases=("aoc",)) - @override_in_channel(AOC_WHITELIST) - async def adventofcode_group(self, ctx: commands.Context) -> None: - """All of the Advent of Code commands.""" - if not ctx.invoked_subcommand: - await ctx.send_help(ctx.command) - - @adventofcode_group.command( - name="subscribe", - aliases=("sub", "notifications", "notify", "notifs"), - brief="Notifications for new days" - ) - @override_in_channel(AOC_WHITELIST) - async def aoc_subscribe(self, ctx: commands.Context) -> None: - """Assign the role for notifications about new days being ready.""" - role = ctx.guild.get_role(AocConfig.role_id) - unsubscribe_command = f"{ctx.prefix}{ctx.command.root_parent} unsubscribe" - - if role not in ctx.author.roles: - await ctx.author.add_roles(role) - await ctx.send("Okay! You have been __subscribed__ to notifications about new Advent of Code tasks. " - f"You can run `{unsubscribe_command}` to disable them again for you.") - else: - await ctx.send("Hey, you already are receiving notifications about new Advent of Code tasks. " - f"If you don't want them any more, run `{unsubscribe_command}` instead.") - - @adventofcode_group.command(name="unsubscribe", aliases=("unsub",), brief="Notifications for new days") - @override_in_channel(AOC_WHITELIST) - async def aoc_unsubscribe(self, ctx: commands.Context) -> None: - """Remove the role for notifications about new days being ready.""" - role = ctx.guild.get_role(AocConfig.role_id) - - if role in ctx.author.roles: - await ctx.author.remove_roles(role) - await ctx.send("Okay! You have been __unsubscribed__ from notifications about new Advent of Code tasks.") - else: - await ctx.send("Hey, you don't even get any notifications about new Advent of Code tasks currently anyway.") - - @adventofcode_group.command(name="countdown", aliases=("count", "c"), brief="Return time left until next day") - @override_in_channel(AOC_WHITELIST) - async def aoc_countdown(self, ctx: commands.Context) -> None: - """Return time left until next day.""" - if not is_in_advent(): - datetime_now = datetime.now(EST) - - # Calculate the delta to this & next year's December 1st to see which one is closest and not in the past - this_year = datetime(datetime_now.year, 12, 1, tzinfo=EST) - next_year = datetime(datetime_now.year + 1, 12, 1, tzinfo=EST) - deltas = (dec_first - datetime_now for dec_first in (this_year, next_year)) - delta = min(delta for delta in deltas if delta >= timedelta()) # timedelta() gives 0 duration delta - - # Add a finer timedelta if there's less than a day left - if delta.days == 0: - delta_str = f"approximately {delta.seconds // 3600} hours" - else: - delta_str = f"{delta.days} days" - - await ctx.send(f"The Advent of Code event is not currently running. " - f"The next event will start in {delta_str}.") - return - - tomorrow, time_left = time_left_to_aoc_midnight() - - hours, minutes = time_left.seconds // 3600, time_left.seconds // 60 % 60 - - await ctx.send(f"There are {hours} hours and {minutes} minutes left until day {tomorrow.day}.") - - @adventofcode_group.command(name="about", aliases=("ab", "info"), brief="Learn about Advent of Code") - @override_in_channel(AOC_WHITELIST) - async def about_aoc(self, ctx: commands.Context) -> None: - """Respond with an explanation of all things Advent of Code.""" - await ctx.send("", embed=self.cached_about_aoc) - - @adventofcode_group.command(name="join", aliases=("j",), brief="Learn how to join the leaderboard (via DM)") - @override_in_channel(AOC_WHITELIST) - async def join_leaderboard(self, ctx: commands.Context) -> None: - """DM the user the information for joining the PyDis AoC private leaderboard.""" - author = ctx.message.author - log.info(f"{author.name} ({author.id}) has requested the PyDis AoC leaderboard code") - - if ctx.channel.id == Channels.advent_of_code_staff: - join_code = AocConfig.leaderboard_staff_join_code - log.info(f"{author.name} ({author.id}) ran command in staff AoC channel. Returning staff code.") - else: - # Ensure we use the same leaderboard code for the same user - if await self.user_leaderboards.contains(ctx.author.id): - join_code = self.leaderboard_join_codes[await self.user_leaderboards.get(ctx.author.id)] - log.info(f"{author.name} ({author.id}) has a cached AoC join code, returning it.") - else: - # Find the leaderboard that has the least members inside from cache - least_id, least = 0, 200 - for aoc_id, amount in await self.public_leaderboard_members.items(): - log.info(amount, least) - if amount < least: - least, least_id = amount, aoc_id - - join_code = self.leaderboard_join_codes[least_id] - # Persist this code to Redis, so we can get it later again. - await self.user_leaderboards.set(ctx.author.id, least_id) - log.info(f"{author.name} ({author.id}) got new join code. Persisted it to cache.") - - info_str = ( - "Head over to https://adventofcode.com/leaderboard/private " - f"with code `{join_code}` to join the PyDis private leaderboard!" - ) - try: - await author.send(info_str) - except discord.errors.Forbidden: - log.debug(f"{author.name} ({author.id}) has disabled DMs from server members") - await ctx.send(f":x: {author.mention}, please (temporarily) enable DMs to receive the join code") - else: - await ctx.message.add_reaction(Emojis.envelope) - - @adventofcode_group.command( - name="leaderboard", - aliases=("board", "lb"), - brief="Get a snapshot of the PyDis private AoC leaderboard", - ) - @override_in_channel(AOC_WHITELIST) - async def aoc_leaderboard(self, ctx: commands.Context, number_of_people_to_display: int = 10) -> None: - """ - Pull the top number_of_people_to_display members from the PyDis leaderboard and post an embed. - - For readability, number_of_people_to_display defaults to 10. A maximum value is configured in the - Advent of Code section of the bot constants. number_of_people_to_display values greater than this - limit will default to this maximum and provide feedback to the user. - """ - async with ctx.typing(): - staff = ctx.channel.id == Channels.advent_of_code_staff - number_of_people_to_display = await self._check_n_entries(ctx, number_of_people_to_display) - - if staff: - await self.check_staff_leaderboard() - if self.cached_staff_leaderboard is not None: - members_to_print = self.cached_staff_leaderboard.top_n(number_of_people_to_display) - table = AocPrivateLeaderboard.build_leaderboard_embed(members_to_print) - else: - log.warning("Missing AoC staff leaderboard cache.") - table = False - else: - await self.check_leaderboard() - table = await self.get_leaderboard(number_of_people_to_display, ctx) - - # When we don't have cache, show it to user. - if table is False: - await ctx.send( - ":x: Sorry, we can't get our leaderboard cache. Please let staff know about it." - ) - return - - # Build embed - aoc_embed = discord.Embed( - description=( - "Total members: " - f"{len(self.cached_staff_leaderboard.members) if staff else await self.public_user_data.length()}" - ), - colour=Colours.soft_green, - timestamp=self.staff_last_updated if staff else self.last_updated - ) - if ctx.channel.id == Channels.advent_of_code_staff: - aoc_embed.set_author( - name="Advent of Code", - url=f"{self._base_url}/leaderboard/private/view/{AocConfig.leaderboard_staff_id}" - ) - elif await self.user_leaderboards.contains(ctx.author.id): - aoc_embed.set_author( - name="Advent of Code", - url=f"{self._base_url}/leaderboard/private/view/{await self.user_leaderboards.get(ctx.author.id)}" - ) - else: - aoc_embed.set_author(name="Advent of Code") - aoc_embed.set_footer(text="Last Updated") - - await ctx.send( - content=f"Here's the current Top {number_of_people_to_display}! {Emojis.christmas_tree*3}\n\n{table}", - embed=aoc_embed, - ) - - @adventofcode_group.command( - name="stats", - aliases=("dailystats", "ds"), - brief="Get daily statistics for the PyDis private leaderboard" - ) - @override_in_channel(AOC_WHITELIST) - async def private_leaderboard_daily_stats(self, ctx: commands.Context) -> None: - """ - Respond with a table of the daily completion statistics for the PyDis private leaderboard. - - Embed will display the total members and the number of users who have completed each day's puzzle - """ - async with ctx.typing(): - is_staff = ctx.channel.id == Channels.advent_of_code_staff - if is_staff: - await self.check_staff_leaderboard() - if self.cached_staff_leaderboard is None: - await ctx.send(":x: Missing leaderboard cache.") - return - else: - await self.check_leaderboard() - - # Build ASCII table - if is_staff: - total_members = len(self.cached_staff_leaderboard.members) - else: - total_members = await self.public_user_data.length() - if total_members == 0: - await ctx.send(":x: Missing leaderboard cache. Please notify staff about it.") - return - - _star = Emojis.star - header = f"{'Day':4}{_star:^8}{_star*2:^4}{'% ' + _star:^8}{'% ' + _star*2:^4}\n{'='*35}" - table = "" - if is_staff: - for day, completions in enumerate(self.cached_staff_leaderboard.daily_completion_summary): - per_one_star = f"{(completions[0]/total_members)*100:.2f}" - per_two_star = f"{(completions[1]/total_members)*100:.2f}" - - table += f"{day+1:3}){completions[0]:^8}{completions[1]:^6}{per_one_star:^10}{per_two_star:^6}\n" - else: - completions = {} - # Build data for completion rates - for _, user_data in await self.public_user_data.items(): - user_data = json.loads(user_data) - for day, stars in user_data["days"].items(): - day = int(day) - if day not in completions: - completions[day] = [0, 0] - - if stars["star_one"]: - completions[day][0] += 1 - if stars["star_two"]: - completions[day][1] += 1 - - for day, completion in completions.items(): - per_one_star = f"{(completion[0]/total_members)*100:.2f}" - per_two_star = f"{(completion[1] / total_members) * 100:.2f}" - - table += f"{day:3}){completion[0]:^8}{completion[1]:^6}{per_one_star:^10}{per_two_star:^6}\n" - - table = f"```\n{header}\n{table}```" - - # Build embed - daily_stats_embed = discord.Embed( - colour=Colours.soft_green, - timestamp=self.staff_last_updated if is_staff else self.last_updated - ) - daily_stats_embed.set_author(name="Advent of Code", url=self._base_url) - daily_stats_embed.set_footer(text="Last Updated") - - await ctx.send( - content=f"Here's the current daily statistics!\n\n{table}", embed=daily_stats_embed - ) - - @adventofcode_group.command( - name="global", - aliases=("globalboard", "gb"), - brief="Get a snapshot of the global AoC leaderboard", - ) - @override_in_channel(AOC_WHITELIST) - async def global_leaderboard(self, ctx: commands.Context, number_of_people_to_display: int = 10) -> None: - """ - Pull the top number_of_people_to_display members from the global AoC leaderboard and post an embed. - - For readability, number_of_people_to_display defaults to 10. A maximum value is configured in the - Advent of Code section of the bot constants. number_of_people_to_display values greater than this - limit will default to this maximum and provide feedback to the user. - """ - async with ctx.typing(): - await self._check_leaderboard_cache(ctx) - - if not self.cached_global_leaderboard: - # Feedback on issues with leaderboard caching are sent by _check_leaderboard_cache() - # Short circuit here if there's an issue - return - - number_of_people_to_display = await self._check_n_entries(ctx, number_of_people_to_display) - - # Generate leaderboard table for embed - members_to_print = self.cached_global_leaderboard.top_n(number_of_people_to_display) - table = AocGlobalLeaderboard.build_leaderboard_embed(members_to_print) - - # Build embed - aoc_embed = discord.Embed(colour=Colours.soft_green, timestamp=self.cached_global_leaderboard.last_updated) - aoc_embed.set_author(name="Advent of Code", url=self._base_url) - aoc_embed.set_footer(text="Last Updated") - - await ctx.send( - f"Here's the current global Top {number_of_people_to_display}! {Emojis.christmas_tree*3}\n\n{table}", - embed=aoc_embed, - ) - - async def _check_leaderboard_cache(self, ctx: commands.Context) -> None: - """ - Check age of current leaderboard & pull a new one if the board is too old. - - global_board is a boolean to toggle between the global board and the Pydis private board - """ - leaderboard = self.cached_global_leaderboard - if not leaderboard: - log.debug("No cached global leaderboard found") - self.cached_global_leaderboard = await AocGlobalLeaderboard.from_url() - else: - leaderboard_age = datetime.utcnow() - leaderboard.last_updated - age_seconds = leaderboard_age.total_seconds() - if age_seconds < AocConfig.leaderboard_cache_age_threshold_seconds: - log.debug(f"Cached global leaderboard age less than threshold ({age_seconds} seconds old)") - else: - log.debug(f"Cached global leaderboard age greater than threshold ({age_seconds} seconds old)") - self.cached_global_leaderboard = await AocGlobalLeaderboard.from_url() - - leaderboard = self.cached_global_leaderboard - if not leaderboard: - await ctx.send( - "", - embed=_error_embed_helper( - title="Something's gone wrong and there's no cached global leaderboard!", - description="Please check in with a staff member.", - ), - ) - - async def _check_n_entries(self, ctx: commands.Context, number_of_people_to_display: int) -> int: - """Check for n > max_entries and n <= 0.""" - max_entries = AocConfig.leaderboard_max_displayed_members - author = ctx.message.author - if not 0 <= number_of_people_to_display <= max_entries: - log.debug( - f"{author.name} ({author.id}) attempted to fetch an invalid number " - f" of entries from the AoC leaderboard ({number_of_people_to_display})" - ) - await ctx.send( - f":x: {author.mention}, number of entries to display must be a positive " - f"integer less than or equal to {max_entries}" - ) - number_of_people_to_display = max_entries - - return number_of_people_to_display - - def _build_about_embed(self) -> discord.Embed: - """Build and return the informational "About AoC" embed from the resources file.""" - with self.about_aoc_filepath.open("r", encoding="utf8") as f: - embed_fields = json.load(f) - - about_embed = discord.Embed(title=self._base_url, colour=Colours.soft_green, url=self._base_url) - about_embed.set_author(name="Advent of Code", url=self._base_url) - for field in embed_fields: - about_embed.add_field(**field) - - about_embed.set_footer(text=f"Last Updated (UTC): {datetime.utcnow()}") - - return about_embed - - def cog_unload(self) -> None: - """Cancel season-related tasks on cog unload.""" - log.debug("Unloading the cog and canceling the background task.") - self.countdown_task.cancel() - self.status_task.cancel() - - -class AocMember: - """Object representing the Advent of Code user.""" - - def __init__(self, name: str, aoc_id: int, stars: int, starboard: list, local_score: int, global_score: int): - self.name = name - self.aoc_id = aoc_id - self.stars = stars - self.starboard = starboard - self.local_score = local_score - self.global_score = global_score - self.completions = self._completions_from_starboard(self.starboard) - - def __repr__(self): - """Generate a user-friendly representation of the AocMember & their score.""" - return f"<{self.name} ({self.aoc_id}): {self.local_score}>" - - @classmethod - def member_from_json(cls, injson: dict) -> "AocMember": - """ - Generate an AocMember from AoC's private leaderboard API JSON. - - injson is expected to be the dict contained in: - - AoC_APIjson['members'][:str] - - Returns an AocMember object - """ - return cls( - name=injson["name"] if injson["name"] else "Anonymous User", - aoc_id=int(injson["id"]), - stars=injson["stars"], - starboard=cls._starboard_from_json(injson["completion_day_level"]), - local_score=injson["local_score"], - global_score=injson["global_score"], - ) - - @staticmethod - def _starboard_from_json(injson: dict) -> list: - """ - Generate starboard from AoC's private leaderboard API JSON. - - injson is expected to be the dict contained in: - - AoC_APIjson['members'][:str]['completion_day_level'] - - Returns a list of 25 lists, where each nested list contains a pair of booleans representing - the code challenge completion status for that day - """ - # Basic input validation - if not isinstance(injson, dict): - raise ValueError - - # Initialize starboard - starboard = [] - for _i in range(25): - starboard.append([False, False]) - - # Iterate over days, which are the keys of injson (as str) - for day in injson: - idx = int(day) - 1 - # If there is a second star, the first star must be completed - if "2" in injson[day].keys(): - starboard[idx] = [True, True] - # If the day exists in injson, then at least the first star is completed - else: - starboard[idx] = [True, False] - - return starboard - - @staticmethod - def _completions_from_starboard(starboard: list) -> tuple: - """Return days completed, as a (1 star, 2 star) tuple, from starboard.""" - completions = [0, 0] - for day in starboard: - if day[0]: - completions[0] += 1 - if day[1]: - completions[1] += 1 - - return tuple(completions) - - -class AocPrivateLeaderboard: - """Object representing the Advent of Code private leaderboard.""" - - def __init__(self, members: list, owner_id: int, event_year: int): - self.members = members - self._owner_id = owner_id - self._event_year = event_year - self.last_updated = datetime.utcnow() - - self.daily_completion_summary = self.calculate_daily_completion() - - def top_n(self, n: int = 10) -> dict: - """ - Return the top n participants on the leaderboard. - - If n is not specified, default to the top 10 - """ - return self.members[:n] - - def calculate_daily_completion(self) -> List[tuple]: - """ - Calculate member completion rates by day. - - Return a list of tuples for each day containing the number of users who completed each part - of the challenge - """ - daily_member_completions = [] - for day in range(25): - one_star_count = 0 - two_star_count = 0 - for member in self.members: - if member.starboard[day][1]: - one_star_count += 1 - two_star_count += 1 - elif member.starboard[day][0]: - one_star_count += 1 - else: - daily_member_completions.append((one_star_count, two_star_count)) - - return(daily_member_completions) - - @staticmethod - async def json_from_url( - leaderboard_id: int, cookie: str, year: int = AocConfig.year - ) -> typing.Union[dict, bool]: - """ - Request the API JSON from Advent of Code for leaderboard_id for the specified year's event. - - If no year is input, year defaults to the current year - """ - api_url = f"https://adventofcode.com/{year}/leaderboard/private/view/{leaderboard_id}.json" - - log.debug("Querying Advent of Code Private Leaderboard API") - async with aiohttp.ClientSession(headers=AOC_REQUEST_HEADER, cookies={"session": cookie}) as session: - async with session.get(api_url) as resp: - if resp.status == 200: - try: - raw_dict = await resp.json() - except aiohttp.ContentTypeError: - log.warning(f"Got invalid response type from AoC API. Leaderboard ID {leaderboard_id}") - return False - else: - log.warning(f"Bad response received from AoC ({resp.status}), check session cookie") - return False - - return raw_dict - - @classmethod - def from_json(cls, injson: dict) -> "AocPrivateLeaderboard": - """Generate an AocPrivateLeaderboard object from AoC's private leaderboard API JSON.""" - return cls( - members=cls._sorted_members(injson["members"]), owner_id=injson["owner_id"], event_year=injson["event"] - ) - - @classmethod - async def from_url(cls, leaderboard_id: int, cookie: str) -> typing.Union["AocPrivateLeaderboard", None]: - """Helper wrapping of AocPrivateLeaderboard.json_from_url and AocPrivateLeaderboard.from_json.""" - api_json = await cls.json_from_url(leaderboard_id, cookie) - if api_json is not False: - return cls.from_json(api_json) - else: - return None - - @staticmethod - def _sorted_members(injson: dict) -> list: - """ - Generate a sorted list of AocMember objects from AoC's private leaderboard API JSON. - - Output list is sorted based on the AocMember.local_score - """ - members = [AocMember.member_from_json(injson[member]) for member in injson] - members.sort(key=lambda x: x.local_score, reverse=True) - - return members - - @staticmethod - def build_leaderboard_embed(members_to_print: List[AocMember]) -> str: - """ - Build a text table from members_to_print, a list of AocMember objects. - - Returns a string to be used as the content of the bot's leaderboard response - """ - stargroup = f"{Emojis.star}, {Emojis.star*2}" - header = f"{' '*3}{'Score'} {'Name':^25} {stargroup:^7}\n{'-'*44}" - table = "" - for i, member in enumerate(members_to_print): - if member.name == "Anonymous User": - name = f"{member.name} #{member.aoc_id}" - else: - name = member.name - - table += ( - f"{i+1:2}) {member.local_score:4} {name:25.25} " - f"({member.completions[0]:2}, {member.completions[1]:2})\n" - ) - else: - table = f"```{header}\n{table}```" - - return table - - -class AocGlobalLeaderboard: - """Object representing the Advent of Code global leaderboard.""" - - def __init__(self, members: List[tuple]): - self.members = members - self.last_updated = datetime.utcnow() - - def top_n(self, n: int = 10) -> dict: - """ - Return the top n participants on the leaderboard. - - If n is not specified, default to the top 10 - """ - return self.members[:n] - - @classmethod - async def from_url(cls) -> "AocGlobalLeaderboard": - """ - Generate an list of tuples for the entries on AoC's global leaderboard. - - Because there is no API for this, web scraping needs to be used - """ - aoc_url = f"https://adventofcode.com/{AocConfig.year}/leaderboard" - - async with aiohttp.ClientSession(headers=AOC_REQUEST_HEADER) as session: - async with session.get(aoc_url) as resp: - if resp.status == 200: - raw_html = await resp.text() - else: - log.warning(f"Bad response received from AoC ({resp.status}), check session cookie") - resp.raise_for_status() - - soup = BeautifulSoup(raw_html, "html.parser") - ele = soup.find_all("div", class_="leaderboard-entry") - - exp = r"(?:[ ]{,2}(\d+)\))?[ ]+(\d+)\s+([\w\(\)\#\@\-\d ]+)" - - lb_list = [] - for entry in ele: - # Strip off the AoC++ decorator - raw_str = entry.text.replace("(AoC++)", "").rstrip() - - # Use a regex to extract the info from the string to unify formatting - # Group 1: Rank - # Group 2: Global Score - # Group 3: Member string - r = re.match(exp, raw_str) - - rank = int(r.group(1)) if r.group(1) else None - global_score = int(r.group(2)) - - member = r.group(3) - if member.lower().startswith("(anonymous"): - # Normalize anonymous user string by stripping () and title casing - member = re.sub(r"[\(\)]", "", member).title() - - lb_list.append((rank, global_score, member)) - - return cls(lb_list) - - @staticmethod - def build_leaderboard_embed(members_to_print: List[tuple]) -> str: - """ - Build a text table from members_to_print, a list of tuples. - - Returns a string to be used as the content of the bot's leaderboard response - """ - header = f"{' '*4}{'Score'} {'Name':^25}\n{'-'*36}" - table = "" - for member in members_to_print: - # In the event of a tie, rank is None - if member[0]: - rank = f"{member[0]:3})" - else: - rank = f"{' ':4}" - table += f"{rank} {member[1]:4} {member[2]:25.25}\n" - else: - table = f"```{header}\n{table}```" - - return table - - -def _error_embed_helper(title: str, description: str) -> discord.Embed: - """Return a red-colored Embed with the given title and description.""" - return discord.Embed(title=title, description=description, colour=discord.Colour.red()) - - -def setup(bot: Bot) -> None: - """Advent of Code Cog load.""" - bot.add_cog(AdventOfCode(bot)) -- cgit v1.2.3 From 1444c81f41703a224a527eaa45381a1cf073c549 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Mon, 30 Nov 2020 01:06:18 +0100 Subject: Rewrite Advent of Code leaderboard logic I've rewritten the Advent of Code leaderboard logic. Unfortunately, nearly all of the changes made are interrelated, meaning that they've ended up in the same commit. To add a bit of structure to the extension, I've chosen for a subpackage structure instead of a single file structure. The biggest changes: - Whether or not you get a join code for the staff leaderboard will now be determined by looking for the Helpers-role. - The Python Discord Leaderboard now includes all boards, including the staff leaderboard. This is one event. - Redis is now used to set a cache expiry period. This means that our code does not have to check for cache staleness; Redis will do that for us. - The period "fetching" task has been removed. We now fetch solely when the data is needed to prevent putting unnecessary stress on the Advent of Code website. - The option to display the Global Leaderboard within Discord has been removed. Rather, we now link to the website. This simplified the code for now, although we could add it back later. - An additional command, `.aoc refresh`, has been added to allow Admins and the Events Lead to force the cache to be invalidated. This should be done sparingly to not overburden the AoC website. - I've also made sure that the daily notification task actually pings the notification role by setting the `allowed_mentions` kwarg. --- bot/exts/christmas/advent_of_code/__init__.py | 10 + bot/exts/christmas/advent_of_code/_caches.py | 5 + bot/exts/christmas/advent_of_code/_cog.py | 353 ++++++++++++++++++++++++++ bot/exts/christmas/advent_of_code/_helpers.py | 312 +++++++++++++++++++++++ 4 files changed, 680 insertions(+) create mode 100644 bot/exts/christmas/advent_of_code/__init__.py create mode 100644 bot/exts/christmas/advent_of_code/_caches.py create mode 100644 bot/exts/christmas/advent_of_code/_cog.py create mode 100644 bot/exts/christmas/advent_of_code/_helpers.py diff --git a/bot/exts/christmas/advent_of_code/__init__.py b/bot/exts/christmas/advent_of_code/__init__.py new file mode 100644 index 00000000..20ac5ab9 --- /dev/null +++ b/bot/exts/christmas/advent_of_code/__init__.py @@ -0,0 +1,10 @@ +from bot.bot import Bot + + +def setup(bot: Bot) -> None: + """Advent of Code Cog load.""" + # Import the Cog at runtime to prevent side effects like defining + # RedisCache instances too early. + from ._cog import AdventOfCode + + bot.add_cog(AdventOfCode(bot)) diff --git a/bot/exts/christmas/advent_of_code/_caches.py b/bot/exts/christmas/advent_of_code/_caches.py new file mode 100644 index 00000000..32d5394f --- /dev/null +++ b/bot/exts/christmas/advent_of_code/_caches.py @@ -0,0 +1,5 @@ +import async_rediscache + +leaderboard_counts = async_rediscache.RedisCache(namespace="AOC_leaderboard_counts") +leaderboard_cache = async_rediscache.RedisCache(namespace="AOC_leaderboard_cache") +assigned_leaderboard = async_rediscache.RedisCache(namespace="AOC_assigned_leaderboard") diff --git a/bot/exts/christmas/advent_of_code/_cog.py b/bot/exts/christmas/advent_of_code/_cog.py new file mode 100644 index 00000000..0df645bd --- /dev/null +++ b/bot/exts/christmas/advent_of_code/_cog.py @@ -0,0 +1,353 @@ +import asyncio +import json +import logging +import math +from datetime import datetime, timedelta +from pathlib import Path +from typing import Tuple + +import discord +from discord.ext import commands +from pytz import timezone + +from bot.bot import Bot +from bot.constants import ( + AdventOfCode as AocConfig, Channels, Colours, Emojis, Month, Roles, WHITELISTED_CHANNELS, +) +from bot.exts.christmas.advent_of_code import _helpers +from bot.utils.decorators import in_month, override_in_channel, with_role + +log = logging.getLogger(__name__) + +AOC_REQUEST_HEADER = {"user-agent": "PythonDiscord AoC Event Bot"} + +EST = timezone("EST") +COUNTDOWN_STEP = 60 * 5 + +AOC_WHITELIST = WHITELISTED_CHANNELS + (Channels.advent_of_code, Channels.advent_of_code_staff) + + +def is_in_advent() -> bool: + """Utility function to check if we are between December 1st and December 25th.""" + # Run the code from the 1st to the 24th + return datetime.now(EST).day in range(1, 25) and datetime.now(EST).month == 12 + + +def time_left_to_aoc_midnight() -> Tuple[datetime, timedelta]: + """Calculates the amount of time left until midnight in UTC-5 (Advent of Code maintainer timezone).""" + # Change all time properties back to 00:00 + todays_midnight = datetime.now(EST).replace( + microsecond=0, + second=0, + minute=0, + hour=0 + ) + + # We want tomorrow so add a day on + tomorrow = todays_midnight + timedelta(days=1) + + # Calculate the timedelta between the current time and midnight + return tomorrow, tomorrow - datetime.now(EST) + + +async def countdown_status(bot: commands.Bot) -> None: + """Set the playing status of the bot to the minutes & hours left until the next day's challenge.""" + while is_in_advent(): + _, time_left = time_left_to_aoc_midnight() + + aligned_seconds = int(math.ceil(time_left.seconds / COUNTDOWN_STEP)) * COUNTDOWN_STEP + hours, minutes = aligned_seconds // 3600, aligned_seconds // 60 % 60 + + if aligned_seconds == 0: + playing = "right now!" + elif aligned_seconds == COUNTDOWN_STEP: + playing = f"in less than {minutes} minutes" + elif hours == 0: + playing = f"in {minutes} minutes" + elif hours == 23: + playing = f"since {60 - minutes} minutes ago" + else: + playing = f"in {hours} hours and {minutes} minutes" + + # Status will look like "Playing in 5 hours and 30 minutes" + await bot.change_presence(activity=discord.Game(playing)) + + # Sleep until next aligned time or a full step if already aligned + delay = time_left.seconds % COUNTDOWN_STEP or COUNTDOWN_STEP + await asyncio.sleep(delay) + + +async def day_countdown(bot: commands.Bot) -> None: + """ + Calculate the number of seconds left until the next day of Advent. + + Once we have calculated this we should then sleep that number and when the time is reached, ping + the Advent of Code role notifying them that the new challenge is ready. + """ + while is_in_advent(): + tomorrow, time_left = time_left_to_aoc_midnight() + + # Prevent bot from being slightly too early in trying to announce today's puzzle + await asyncio.sleep(time_left.seconds + 1) + + channel = bot.get_channel(Channels.advent_of_code) + + if not channel: + log.error("Could not find the AoC channel to send notification in") + break + + aoc_role = channel.guild.get_role(AocConfig.role_id) + if not aoc_role: + log.error("Could not find the AoC role to announce the daily puzzle") + break + + puzzle_url = f"https://adventofcode.com/{AocConfig.year}/day/{tomorrow.day}" + + # Check if the puzzle is already available to prevent our members from spamming + # the puzzle page before it's available by making a small HEAD request. + for retry in range(1, 5): + log.debug(f"Checking if the puzzle is already available (attempt {retry}/4)") + async with bot.http_session.head(puzzle_url, raise_for_status=False) as resp: + if resp.status == 200: + log.debug("Puzzle is available; let's send an announcement message.") + break + log.debug(f"The puzzle is not yet available (status={resp.status})") + await asyncio.sleep(10) + else: + log.error("The puzzle does does not appear to be available at this time, canceling announcement") + break + + await channel.send( + f"{aoc_role.mention} Good morning! Day {tomorrow.day} is ready to be attempted. " + f"View it online now at {puzzle_url}. Good luck!", + allowed_mentions=discord.AllowedMentions( + everyone=False, + users=False, + roles=[discord.Object(AocConfig.role_id)], + ) + ) + + # Wait a couple minutes so that if our sleep didn't sleep enough + # time we don't end up announcing twice. + await asyncio.sleep(120) + + +class AdventOfCode(commands.Cog): + """Advent of Code festivities! Ho Ho Ho!""" + + def __init__(self, bot: Bot) -> None: + self.bot = bot + + self._base_url = f"https://adventofcode.com/{AocConfig.year}" + self.global_leaderboard_url = f"https://adventofcode.com/{AocConfig.year}/leaderboard" + + self.about_aoc_filepath = Path("./bot/resources/advent_of_code/about.json") + self.cached_about_aoc = self._build_about_embed() + + self.countdown_task = None + self.status_task = None + + countdown_coro = day_countdown(self.bot) + self.countdown_task = self.bot.loop.create_task(countdown_coro) + + status_coro = countdown_status(self.bot) + self.status_task = self.bot.loop.create_task(status_coro) + + @in_month(Month.DECEMBER) + @commands.group(name="adventofcode", aliases=("aoc",)) + @override_in_channel(AOC_WHITELIST) + async def adventofcode_group(self, ctx: commands.Context) -> None: + """All of the Advent of Code commands.""" + if not ctx.invoked_subcommand: + await ctx.send_help(ctx.command) + + @adventofcode_group.command( + name="subscribe", + aliases=("sub", "notifications", "notify", "notifs"), + brief="Notifications for new days" + ) + @override_in_channel(AOC_WHITELIST) + async def aoc_subscribe(self, ctx: commands.Context) -> None: + """Assign the role for notifications about new days being ready.""" + role = ctx.guild.get_role(AocConfig.role_id) + unsubscribe_command = f"{ctx.prefix}{ctx.command.root_parent} unsubscribe" + + if role not in ctx.author.roles: + await ctx.author.add_roles(role) + await ctx.send("Okay! You have been __subscribed__ to notifications about new Advent of Code tasks. " + f"You can run `{unsubscribe_command}` to disable them again for you.") + else: + await ctx.send("Hey, you already are receiving notifications about new Advent of Code tasks. " + f"If you don't want them any more, run `{unsubscribe_command}` instead.") + + @adventofcode_group.command(name="unsubscribe", aliases=("unsub",), brief="Notifications for new days") + @override_in_channel(AOC_WHITELIST) + async def aoc_unsubscribe(self, ctx: commands.Context) -> None: + """Remove the role for notifications about new days being ready.""" + role = ctx.guild.get_role(AocConfig.role_id) + + if role in ctx.author.roles: + await ctx.author.remove_roles(role) + await ctx.send("Okay! You have been __unsubscribed__ from notifications about new Advent of Code tasks.") + else: + await ctx.send("Hey, you don't even get any notifications about new Advent of Code tasks currently anyway.") + + @adventofcode_group.command(name="countdown", aliases=("count", "c"), brief="Return time left until next day") + @override_in_channel(AOC_WHITELIST) + async def aoc_countdown(self, ctx: commands.Context) -> None: + """Return time left until next day.""" + if not is_in_advent(): + datetime_now = datetime.now(EST) + + # Calculate the delta to this & next year's December 1st to see which one is closest and not in the past + this_year = datetime(datetime_now.year, 12, 1, tzinfo=EST) + next_year = datetime(datetime_now.year + 1, 12, 1, tzinfo=EST) + deltas = (dec_first - datetime_now for dec_first in (this_year, next_year)) + delta = min(delta for delta in deltas if delta >= timedelta()) # timedelta() gives 0 duration delta + + # Add a finer timedelta if there's less than a day left + if delta.days == 0: + delta_str = f"approximately {delta.seconds // 3600} hours" + else: + delta_str = f"{delta.days} days" + + await ctx.send(f"The Advent of Code event is not currently running. " + f"The next event will start in {delta_str}.") + return + + tomorrow, time_left = time_left_to_aoc_midnight() + + hours, minutes = time_left.seconds // 3600, time_left.seconds // 60 % 60 + + await ctx.send(f"There are {hours} hours and {minutes} minutes left until day {tomorrow.day}.") + + @adventofcode_group.command(name="about", aliases=("ab", "info"), brief="Learn about Advent of Code") + @override_in_channel(AOC_WHITELIST) + async def about_aoc(self, ctx: commands.Context) -> None: + """Respond with an explanation of all things Advent of Code.""" + await ctx.send("", embed=self.cached_about_aoc) + + @adventofcode_group.command(name="join", aliases=("j",), brief="Learn how to join the leaderboard (via DM)") + @override_in_channel(AOC_WHITELIST) + async def join_leaderboard(self, ctx: commands.Context) -> None: + """DM the user the information for joining the PyDis AoC private leaderboard.""" + author = ctx.message.author + log.info(f"{author.name} ({author.id}) has requested a PyDis AoC leaderboard code") + + 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) + + if not join_code: + log.error(f"Failed to get a join code for user {author} ({author.id})") + error_embed = _error_embed_helper( + title="Unable to get join code", + description="Failed to get a join code to one of our boards. Please notify staff." + ) + await ctx.send(embed=error_embed) + return + + info_str = ( + "Head over to https://adventofcode.com/leaderboard/private " + f"with code `{join_code}` to join the Python Discord leaderboard!" + ) + try: + await author.send(info_str) + except discord.errors.Forbidden: + log.debug(f"{author.name} ({author.id}) has disabled DMs from server members") + await ctx.send(f":x: {author.mention}, please (temporarily) enable DMs to receive the join code") + else: + await ctx.message.add_reaction(Emojis.envelope) + + @adventofcode_group.command( + name="leaderboard", + aliases=("board", "lb"), + brief="Get a snapshot of the PyDis private AoC leaderboard", + ) + @override_in_channel(AOC_WHITELIST) + 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() + number_of_participants = leaderboard["number_of_participants"] + + top_count = min(AocConfig.leaderboard_displayed_members, number_of_participants) + header = f"Here's our current top {top_count}! {Emojis.christmas_tree * 3}" + + table = f"```\n{leaderboard['top_leaderboard']}\n```" + info_embed = _helpers.get_summary_embed(leaderboard) + + await ctx.send(content=f"{header}\n\n{table}", embed=info_embed) + + @adventofcode_group.command( + name="stats", + aliases=("dailystats", "ds"), + brief="Get daily statistics for the PyDis private leaderboard" + ) + @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() + + # The daily stats are serialized as JSON as they have to be cached in Redis + daily_stats = json.loads(leaderboard["daily_stats"]) + async with ctx.typing(): + lines = ["Day ⭐ ⭐⭐ | %⭐ %⭐⭐\n================================"] + for day, stars in daily_stats.items(): + star_one = stars["star_one"] + star_two = stars["star_two"] + p_star_one = star_one / leaderboard["number_of_participants"] + p_star_two = star_two / leaderboard["number_of_participants"] + lines.append( + f"{day:>2}) {star_one:>4} {star_two:>4} | {p_star_one:>7.2%} {p_star_two:>7.2%}" + ) + table = "\n".join(lines) + info_embed = _helpers.get_summary_embed(leaderboard) + await ctx.send(f"```\n{table}\n```", embed=info_embed) + + @with_role(Roles.admin, Roles.events_lead) + @adventofcode_group.command( + name="refresh", + aliases=("fetch",), + brief="Force a refresh of the leaderboard cache.", + ) + async def refresh_leaderboard(self, ctx: commands.Context) -> None: + """ + Force a refresh of the leaderboard cache. + + Note: This should be used sparingly, as we want to prevent sending too + 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!") + + def cog_unload(self) -> None: + """Cancel season-related tasks on cog unload.""" + log.debug("Unloading the cog and canceling the background task.") + self.countdown_task.cancel() + self.status_task.cancel() + + def _build_about_embed(self) -> discord.Embed: + """Build and return the informational "About AoC" embed from the resources file.""" + with self.about_aoc_filepath.open("r", encoding="utf8") as f: + embed_fields = json.load(f) + + about_embed = discord.Embed( + title=self._base_url, + colour=Colours.soft_green, + url=self._base_url, + timestamp=datetime.utcnow() + ) + about_embed.set_author(name="Advent of Code", url=self._base_url) + for field in embed_fields: + about_embed.add_field(**field) + + about_embed.set_footer(text="Last Updated") + return about_embed + + +def _error_embed_helper(title: str, description: str) -> discord.Embed: + """Return a red-colored Embed with the given title and description.""" + return discord.Embed(title=title, description=description, colour=discord.Colour.red()) diff --git a/bot/exts/christmas/advent_of_code/_helpers.py b/bot/exts/christmas/advent_of_code/_helpers.py new file mode 100644 index 00000000..8b85bf5d --- /dev/null +++ b/bot/exts/christmas/advent_of_code/_helpers.py @@ -0,0 +1,312 @@ +import collections +import datetime +import json +import logging +import operator +import typing + +import aiohttp +import discord + +from bot.constants import AdventOfCode, Colours +from bot.exts.christmas.advent_of_code import _caches + +log = logging.getLogger(__name__) + +PASTE_URL = "https://paste.pythondiscord.com/documents" +RAW_PASTE_URL_TEMPLATE = "https://paste.pythondiscord.com/raw/{key}" + +# Base API URL for Advent of Code Private Leaderboards +AOC_API_URL = "https://adventofcode.com/{year}/leaderboard/private/view/{leaderboard_id}.json" +AOC_REQUEST_HEADER = {"user-agent": "PythonDiscord AoC Event Bot"} + +# Leaderboard Line Template +AOC_TABLE_TEMPLATE = "{rank: >4} | {name:25.25} | {score: >5} | {stars}" +HEADER = AOC_TABLE_TEMPLATE.format(rank="", name="Name", score="Score", stars="⭐, ⭐⭐") +HEADER = f"{HEADER}\n{'-' * (len(HEADER) + 2)}" +HEADER_LINES = len(HEADER.splitlines()) +TOP_LEADERBOARD_LINES = HEADER_LINES + AdventOfCode.leaderboard_displayed_members + +# Keys that need to be set for a cached leaderboard +REQUIRED_CACHE_KEYS = ( + "full_leaderboard", + "top_leaderboard", + "full_leaderboard_url", + "leaderboard_fetched_at", + "number_of_participants", + "daily_stats", +) + +AOC_EMBED_THUMBNAIL = ( + "https://raw.githubusercontent.com/python-discord" + "/branding/master/seasonal/christmas/server_icons/festive_256.gif" +) + +# Create namedtuple that combines a participant's name and their completion +# time for a specific star. We're going to use this later to order the results +# for each star to compute the rank score. +_StarResult = collections.namedtuple("StarResult", "name completion_time") + + +def _parse_raw_leaderboard_data(raw_leaderboard_data: dict) -> dict: + """ + Parse the leaderboard data received from the AoC website. + + The data we receive from AoC is structured by member, not by day/star. This + means that we need to "transpose" the data to a per star structure in order + to calculate the rank scores each individual should get. + + As we need our data both "per participant" as well as "per day", we return + the parsed and analyzed data in both formats. + """ + # We need to get an aggregate of completion times for each star of each day, + # instead of per participant to compute the rank scores. This dictionary will + # provide such a transposed dataset. + star_results = collections.defaultdict(list) + + # As we're already iterating over the participants, we can record the number of + # first stars and second stars they've achieved right here and now. This means + # we won't have to iterate over the participants again later. + leaderboard = {} + + # The data we get from the AoC website is structured by member, not by day/star, + # which means we need to iterate over the members to transpose the data to a per + # star view. We need that per star view to compute rank scores per star. + for member in raw_leaderboard_data.values(): + name = member["name"] if member["name"] else f"Anonymous #{member['id']}" + leaderboard[name] = {"score": 0, "star_1_count": 0, "star_2_count": 0} + + # Iterate over all days for this participant + for day, stars in member["completion_day_level"].items(): + # Iterate over the complete stars for this day for this participant + for star, data in stars.items(): + # Record completion of this star for this individual + leaderboard[name][f"star_{star}_count"] += 1 + + # Record completion datetime for this participant for this day/star + completion_time = datetime.datetime.fromtimestamp(int(data['get_star_ts'])) + star_results[(day, star)].append( + _StarResult(name=name, completion_time=completion_time) + ) + + # Now that we have a transposed dataset that holds the completion time of all + # participants per star, we can compute the rank-based scores each participant + # should get for that star. + max_score = len(leaderboard) + for star in star_results.values(): + for rank, star_result in enumerate(sorted(star, key=operator.itemgetter(1))): + leaderboard[star_result.name]["score"] += max_score - rank + + # Since dictionaries now retain insertion order, let's use that + sorted_leaderboard = dict( + sorted(leaderboard.items(), key=lambda t: t[1]["score"], reverse=True) + ) + + daily_stats = {} + for day in range(1, 26): + star_one = len(star_results.get((day, 1), [])) + star_two = len(star_results.get((day, 1), [])) + daily_stats[day] = {"star_one": star_one, "star_two": star_two} + + return {"daily_stats": daily_stats, "leaderboard": sorted_leaderboard} + + +def _format_leaderboard(leaderboard: typing.Dict[str, int]) -> str: + """Format the leaderboard using the AOC_TABLE_TEMPLATE.""" + leaderboard_lines = [HEADER] + for rank, (name, results) in enumerate(leaderboard.items(), start=1): + leaderboard_lines.append( + AOC_TABLE_TEMPLATE.format( + rank=rank, + name=name, + score=str(results["score"]), + stars=f"({results['star_1_count']}, {results['star_2_count']})" + ) + ) + + return "\n".join(leaderboard_lines) + + +async def _fetch_leaderboard_data() -> typing.Dict[str, typing.Any]: + """Fetch data for all leaderboards and return a pooled result.""" + year = AdventOfCode.year + + # We'll make our requests one at a time to not flood the AoC website with + # up to six simultaneous requests. This may take a little longer, but it + # does avoid putting unnecessary stress on the Advent of Code website. + + # Container to store the raw data of each leaderboard + 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 + 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() + + log.info(f"Fetched leaderboard information for {len(participants)} participants") + return participants + + +async def _upload_leaderboard(leaderboard: str) -> str: + """Upload the full leaderboard to our paste service and return the URL.""" + async with aiohttp.request("POST", PASTE_URL, data=leaderboard) as resp: + try: + resp_json = await resp.json() + except Exception: + log.exception("Failed to upload full leaderboard to paste service") + return "" + + if "key" in resp_json: + return RAW_PASTE_URL_TEMPLATE.format(key=resp_json["key"]) + + log.error(f"Unexpected response from paste service while uploading leaderboard {resp_json}") + return "" + + +def _get_top_leaderboard(full_leaderboard: str) -> str: + """Get the leaderboard up to the maximum specified entries.""" + return "\n".join(full_leaderboard.splitlines()[:TOP_LEADERBOARD_LINES]) + + +@_caches.leaderboard_cache.atomic_transaction +async def fetch_leaderboard(invalidate_cache: bool = False) -> dict: + """ + Get the current Python Discord combined leaderboard. + + The leaderboard is cached and only fetched from the API if the current data + is older than the lifetime set in the constants. To prevent multiple calls + to this function fetching new leaderboard information in case of a cache + miss, this function is locked to one call at a time using a decorator. + """ + cached_leaderboard = await _caches.leaderboard_cache.to_dict() + + # Check if the cached leaderboard contains everything we expect it to. If it + # does not, this probably means the cache has not been created yet or has + # expired in Redis. This check also accounts for a malformed cache. + if invalidate_cache or any(key not in cached_leaderboard for key in REQUIRED_CACHE_KEYS): + log.info("No leaderboard cache available, fetching leaderboards...") + # Fetch the raw data + raw_leaderboard_data = await _fetch_leaderboard_data() + + # Parse it to extract "per star, per day" data and participant scores + parsed_leaderboard_data = _parse_raw_leaderboard_data(raw_leaderboard_data) + + leaderboard = parsed_leaderboard_data["leaderboard"] + number_of_participants = len(leaderboard) + formatted_leaderboard = _format_leaderboard(leaderboard) + full_leaderboard_url = await _upload_leaderboard(formatted_leaderboard) + leaderboard_fetched_at = datetime.datetime.utcnow().isoformat() + + cached_leaderboard = { + "full_leaderboard": formatted_leaderboard, + "top_leaderboard": _get_top_leaderboard(formatted_leaderboard), + "full_leaderboard_url": full_leaderboard_url, + "leaderboard_fetched_at": leaderboard_fetched_at, + "number_of_participants": number_of_participants, + "daily_stats": json.dumps(parsed_leaderboard_data["daily_stats"]), + } + + # Store the new values in Redis + await _caches.leaderboard_cache.update(cached_leaderboard) + + # Set an expiry on the leaderboard RedisCache + with await _caches.leaderboard_cache._get_pool_connection() as connection: + await connection.expire( + _caches.leaderboard_cache.namespace, + AdventOfCode.leaderboard_cache_expiry_seconds + ) + + return cached_leaderboard + + +def get_summary_embed(leaderboard: dict) -> discord.Embed: + """Get an embed with the current summary stats of the leaderboard.""" + leaderboard_url = leaderboard['full_leaderboard_url'] + + aoc_embed = discord.Embed( + colour=Colours.soft_green, + timestamp=datetime.datetime.fromisoformat(leaderboard["leaderboard_fetched_at"]), + ) + aoc_embed.add_field( + name="Number of Participants", + value=leaderboard["number_of_participants"], + inline=True, + ) + if leaderboard_url: + aoc_embed.add_field( + name="Full Leaderboard", + value=f"[Python Discord Leaderboard]({leaderboard_url})", + inline=True, + ) + aoc_embed.set_author(name="Advent of Code", url=leaderboard_url) + aoc_embed.set_footer(text="Last Updated") + aoc_embed.set_thumbnail(url=AOC_EMBED_THUMBNAIL) + + return aoc_embed + + +async def get_public_join_code(author: discord.Member) -> typing.Optional[str]: + """ + Get the join code for one of the non-staff leaderboards. + + If a user has previously requested a join code and their assigned board + hasn't filled up yet, we'll return the same join code to prevent them from + getting join codes for multiple boards. + """ + # Make sure to fetch new leaderboard information if the cache is older than + # 30 minutes. While this still means that there could be a discrepancy + # between the current leaderboard state and the numbers we have here, this + # should work fairly well given the buffer of slots that we have. + await fetch_leaderboard() + previously_assigned_board = await _caches.assigned_leaderboard.get(author.id) + current_board_counts = await _caches.leaderboard_counts.to_dict() + + # Remove the staff board from the current board counts as it should be ignored. + current_board_counts.pop(AdventOfCode.staff_leaderboard_id, None) + + # If this user has already received a join code, we'll give them the + # exact same one to prevent them from joining multiple boards and taking + # up multiple slots. + if previously_assigned_board: + # Check if their previously assigned board still has room for them + if current_board_counts.get(previously_assigned_board, 0) < 200: + log.info(f"{author} ({author.id}) was already assigned to a board with open slots.") + return AdventOfCode.leaderboards[previously_assigned_board].join_code + + log.info( + f"User {author} ({author.id}) previously received the join code for " + f"board `{previously_assigned_board}`, but that board's now full. " + "Assigning another board to this user." + ) + + # If we don't have the current board counts cached, let's force fetching a new cache + if not current_board_counts: + log.warning("Leaderboard counts were missing from the cache unexpectedly!") + await fetch_leaderboard(invalidate_cache=True) + current_board_counts = await _caches.leaderboard_counts.to_dict() + + # Find the board with the current lowest participant count. As we can't + best_board, _count = min(current_board_counts.items(), key=operator.itemgetter(1)) + + if current_board_counts.get(best_board, 0) >= 200: + log.warning(f"User {author} `{author.id}` requested a join code, but all boards are full!") + return + + log.info(f"Assigning user {author} ({author.id}) to board `{best_board}`") + await _caches.assigned_leaderboard.set(author.id, best_board) + + # Return the join code for this board + return AdventOfCode.leaderboards[best_board].join_code -- cgit v1.2.3 From 159c2ebdbbdb2c2ebdffe9e857c9abad5f36511f Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Mon, 30 Nov 2020 01:09:37 +0100 Subject: Remove redundant _error_embed_helper function This helper function was only being used in one spot and did not factor out any logic. I've removed the helper function to just create the embed where it's needed. --- bot/exts/christmas/advent_of_code/_cog.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/bot/exts/christmas/advent_of_code/_cog.py b/bot/exts/christmas/advent_of_code/_cog.py index 0df645bd..1a6715bb 100644 --- a/bot/exts/christmas/advent_of_code/_cog.py +++ b/bot/exts/christmas/advent_of_code/_cog.py @@ -241,9 +241,10 @@ class AdventOfCode(commands.Cog): if not join_code: log.error(f"Failed to get a join code for user {author} ({author.id})") - error_embed = _error_embed_helper( + error_embed = discord.Embed( title="Unable to get join code", - description="Failed to get a join code to one of our boards. Please notify staff." + description="Failed to get a join code to one of our boards. Please notify staff.", + colour=discord.Colour.red(), ) await ctx.send(embed=error_embed) return @@ -346,8 +347,3 @@ class AdventOfCode(commands.Cog): about_embed.set_footer(text="Last Updated") return about_embed - - -def _error_embed_helper(title: str, description: str) -> discord.Embed: - """Return a red-colored Embed with the given title and description.""" - return discord.Embed(title=title, description=description, colour=discord.Colour.red()) -- cgit v1.2.3 From b13667cfc261ec43daad6fd592d5b952f73997fe Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Mon, 30 Nov 2020 01:18:40 +0100 Subject: Remove now redundant advent_of_code_staff constant We're no longer going to use a two-channel setup for this event, as we don't want to split the event community into two, staff and non-staff. --- bot/constants.py | 1 - bot/exts/christmas/advent_of_code/_cog.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index fc9929ec..e459ed21 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -76,7 +76,6 @@ class Branding: class Channels(NamedTuple): admins = 365960823622991872 advent_of_code = int(environ.get("AOC_CHANNEL_ID", 780818162836439041)) - advent_of_code_staff = int(environ.get("AOC_STAFF_CHANNEL_ID", 778646502641500181)) announcements = int(environ.get("CHANNEL_ANNOUNCEMENTS", 354619224620138496)) big_brother_logs = 468507907357409333 bot = 267659945086812160 diff --git a/bot/exts/christmas/advent_of_code/_cog.py b/bot/exts/christmas/advent_of_code/_cog.py index 1a6715bb..388d0592 100644 --- a/bot/exts/christmas/advent_of_code/_cog.py +++ b/bot/exts/christmas/advent_of_code/_cog.py @@ -24,7 +24,7 @@ AOC_REQUEST_HEADER = {"user-agent": "PythonDiscord AoC Event Bot"} EST = timezone("EST") COUNTDOWN_STEP = 60 * 5 -AOC_WHITELIST = WHITELISTED_CHANNELS + (Channels.advent_of_code, Channels.advent_of_code_staff) +AOC_WHITELIST = WHITELISTED_CHANNELS + (Channels.advent_of_code,) def is_in_advent() -> bool: -- cgit v1.2.3 From 5ce12af9d9f48042b0ebb7ef215e3253bcc418bd Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Mon, 30 Nov 2020 01:20:19 +0100 Subject: Set correct channel ID as default for AoC channel --- bot/constants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/constants.py b/bot/constants.py index e459ed21..00d75a3f 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -75,7 +75,7 @@ class Branding: class Channels(NamedTuple): admins = 365960823622991872 - advent_of_code = int(environ.get("AOC_CHANNEL_ID", 780818162836439041)) + advent_of_code = int(environ.get("AOC_CHANNEL_ID", 782715290437943306)) announcements = int(environ.get("CHANNEL_ANNOUNCEMENTS", 354619224620138496)) big_brother_logs = 468507907357409333 bot = 267659945086812160 -- cgit v1.2.3 From c352c80cf97620e715ee55184b54d1609312c76b Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Mon, 30 Nov 2020 01:38:11 +0100 Subject: Fix docstrings and add a few explanatory comments --- bot/constants.py | 4 ++-- bot/exts/christmas/advent_of_code/__init__.py | 2 +- bot/exts/christmas/advent_of_code/_cog.py | 4 ++-- bot/exts/christmas/advent_of_code/_helpers.py | 3 +++ 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 00d75a3f..cb5a91cc 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -40,8 +40,8 @@ def _parse_aoc_leaderboard_env() -> Dict[str, AdventOfCodeLeaderboard]: Parse the environment variable containing leaderboard information. A leaderboard should be specified in the format `id,session,join_code`, - without the backticks. If more than leaderboard needs to be added to the - constants, separate the individual leaderboards with `::`. + without the backticks. If more than one leaderboard needs to be added to + the constant, separate the individual leaderboards with `::`. Example ENV: `id1,session1,join_code1::id2,session2,join_code2` """ diff --git a/bot/exts/christmas/advent_of_code/__init__.py b/bot/exts/christmas/advent_of_code/__init__.py index 20ac5ab9..3c521168 100644 --- a/bot/exts/christmas/advent_of_code/__init__.py +++ b/bot/exts/christmas/advent_of_code/__init__.py @@ -2,7 +2,7 @@ from bot.bot import Bot def setup(bot: Bot) -> None: - """Advent of Code Cog load.""" + """Set up the Advent of Code extension.""" # Import the Cog at runtime to prevent side effects like defining # RedisCache instances too early. from ._cog import AdventOfCode diff --git a/bot/exts/christmas/advent_of_code/_cog.py b/bot/exts/christmas/advent_of_code/_cog.py index 388d0592..19baca93 100644 --- a/bot/exts/christmas/advent_of_code/_cog.py +++ b/bot/exts/christmas/advent_of_code/_cog.py @@ -230,7 +230,7 @@ class AdventOfCode(commands.Cog): @adventofcode_group.command(name="join", aliases=("j",), brief="Learn how to join the leaderboard (via DM)") @override_in_channel(AOC_WHITELIST) async def join_leaderboard(self, ctx: commands.Context) -> None: - """DM the user the information for joining the PyDis AoC private leaderboard.""" + """DM the user the information for joining the Python Discord leaderboard.""" author = ctx.message.author log.info(f"{author.name} ({author.id}) has requested a PyDis AoC leaderboard code") @@ -284,7 +284,7 @@ class AdventOfCode(commands.Cog): @adventofcode_group.command( name="stats", aliases=("dailystats", "ds"), - brief="Get daily statistics for the PyDis private leaderboard" + brief="Get daily statistics for the Python Discord leaderboard" ) @override_in_channel(AOC_WHITELIST) async def private_leaderboard_daily_stats(self, ctx: commands.Context) -> None: diff --git a/bot/exts/christmas/advent_of_code/_helpers.py b/bot/exts/christmas/advent_of_code/_helpers.py index 8b85bf5d..57aad54d 100644 --- a/bot/exts/christmas/advent_of_code/_helpers.py +++ b/bot/exts/christmas/advent_of_code/_helpers.py @@ -102,10 +102,13 @@ def _parse_raw_leaderboard_data(raw_leaderboard_data: dict) -> dict: sorted(leaderboard.items(), key=lambda t: t[1]["score"], reverse=True) ) + # Create summary stats for the stars completed for each day of the event. daily_stats = {} for day in range(1, 26): star_one = len(star_results.get((day, 1), [])) star_two = len(star_results.get((day, 1), [])) + # By using a dictionary instead of namedtuple here, we can serialize + # this data to JSON in order to cache it in Redis. daily_stats[day] = {"star_one": star_one, "star_two": star_two} return {"daily_stats": daily_stats, "leaderboard": sorted_leaderboard} -- cgit v1.2.3 From c6b89c21dc7d73e4286cf67df421772c1e6df77d Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Mon, 30 Nov 2020 01:52:08 +0100 Subject: Add global leaderboard command back I accidentally removed the global leaderboard command. I've added it back! --- bot/exts/christmas/advent_of_code/_cog.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/bot/exts/christmas/advent_of_code/_cog.py b/bot/exts/christmas/advent_of_code/_cog.py index 19baca93..3d1d268f 100644 --- a/bot/exts/christmas/advent_of_code/_cog.py +++ b/bot/exts/christmas/advent_of_code/_cog.py @@ -281,6 +281,22 @@ class AdventOfCode(commands.Cog): await ctx.send(content=f"{header}\n\n{table}", embed=info_embed) + @adventofcode_group.command( + name="global", + aliases=("globalboard", "gb"), + brief="Get a link to the global leaderboard", + ) + @override_in_channel(AOC_WHITELIST) + async def aoc_global_leaderboard(self, ctx: commands.Context) -> None: + """Get a link to the global Advent of Code leaderboard.""" + url = self.global_leaderboard_url + global_leaderboard = discord.Embed( + title="Advent of Code — Global Leaderboard", + description=f"You can find the global leaderboard [here]({url})." + ) + global_leaderboard.set_thumbnail(url=_helpers.AOC_EMBED_THUMBNAIL) + await ctx.send(embed=global_leaderboard) + @adventofcode_group.command( name="stats", aliases=("dailystats", "ds"), -- cgit v1.2.3 From 60ace6a69804041bb47bd4fa1e70699aa2bd6633 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Mon, 30 Nov 2020 02:19:35 +0100 Subject: Update information for the .aoc about embed I've updated the information for the about embed: - Added information on the scoring of private leaderboards - Changed the text of "join our private leaderboard", as we no longer have to introduce private leaderboards there. - I've also streamlined the section on Auth providers. --- bot/resources/advent_of_code/about.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/resources/advent_of_code/about.json b/bot/resources/advent_of_code/about.json index 91ae6813..dd0fe59a 100644 --- a/bot/resources/advent_of_code/about.json +++ b/bot/resources/advent_of_code/about.json @@ -6,22 +6,22 @@ }, { "name": "How do I sign up?", - "value": "AoC utilizes the following services' OAuth:", + "value": "Sign up with one of these services:", "inline": true }, { - "name": "Service", + "name": "Auth Services", "value": "GitHub\nGoogle\nTwitter\nReddit", "inline": true }, { "name": "How does scoring work?", - "value": "Getting a star first is worth 100 points, second is 99, and so on down to 1 point at 100th place.\n\nCheck out AoC's [global leaderboard](https://adventofcode.com/leaderboard) to see who's leading this year's event!", + "value": "For the [global leaderboard](https://adventofcode.com/leaderboard), the first person to get a star first gets 100 points, the second person gets 99 points, and so on down to 1 point at 100th place.\n\nFor private leaderboards, the first person to get a star gets N points, where N is the number of people on the leaderboard. The second person to get the star gets N-1 points and so on and so forth.", "inline": false }, { "name": "Join our private leaderboard!", - "value": "In addition to the global leaderboard, AoC also offers private leaderboards, where you can compete against a smaller group of friends!\n\nGet the join code using `.aoc join` and head over to AoC's [private leaderboard page](https://adventofcode.com/leaderboard/private) to join the PyDis private leaderboard!", + "value": "Come join the Python Discord private leaderboard and compete against other people in the community! Get the join code using `.aoc join` and visit the [private leaderboard page](https://adventofcode.com/leaderboard/private) to join our leaderboard.", "inline": false } ] -- cgit v1.2.3 From b0e30c21f0638a0b45096a01a00327acee51b46d Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Mon, 30 Nov 2020 13:50:34 +0100 Subject: Remove constants replaced by AOC_LEADERBOARDS There were still two constants left over that were no longer used after the rewrite. I've removed them. --- bot/constants.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index cb5a91cc..292a242a 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -232,9 +232,6 @@ class Roles(NamedTuple): class Tokens(NamedTuple): giphy = environ.get("GIPHY_TOKEN") - # Public AoC cookies in environment must be in the same order as AdventOfCode.leaderboard_public_ids - aoc_public_session_cookies = environ.get("AOC_PUBLIC_SESSION_COOKIES", "").split(",") - aoc_staff_session_cookie = environ.get("AOC_STAFF_SESSION_COOKIE") omdb = environ.get("OMDB_API_KEY") youtube = environ.get("YOUTUBE_API_KEY") tmdb = environ.get("TMDB_API_KEY") -- cgit v1.2.3 From c0685c73ebbffb185bddc8e0b1e6a8e09b9f289d Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Mon, 30 Nov 2020 14:01:32 +0100 Subject: Move helper functions to the ._helpers module I've moved the helper functions to the _helpers.py module and clarified the docstring of the `is_in_advent` helper function. --- bot/exts/christmas/advent_of_code/_cog.py | 44 ++++++--------------------- bot/exts/christmas/advent_of_code/_helpers.py | 33 ++++++++++++++++++++ 2 files changed, 42 insertions(+), 35 deletions(-) diff --git a/bot/exts/christmas/advent_of_code/_cog.py b/bot/exts/christmas/advent_of_code/_cog.py index 3d1d268f..bc2a4724 100644 --- a/bot/exts/christmas/advent_of_code/_cog.py +++ b/bot/exts/christmas/advent_of_code/_cog.py @@ -4,11 +4,9 @@ import logging import math from datetime import datetime, timedelta from pathlib import Path -from typing import Tuple import discord from discord.ext import commands -from pytz import timezone from bot.bot import Bot from bot.constants import ( @@ -21,39 +19,15 @@ log = logging.getLogger(__name__) AOC_REQUEST_HEADER = {"user-agent": "PythonDiscord AoC Event Bot"} -EST = timezone("EST") COUNTDOWN_STEP = 60 * 5 AOC_WHITELIST = WHITELISTED_CHANNELS + (Channels.advent_of_code,) -def is_in_advent() -> bool: - """Utility function to check if we are between December 1st and December 25th.""" - # Run the code from the 1st to the 24th - return datetime.now(EST).day in range(1, 25) and datetime.now(EST).month == 12 - - -def time_left_to_aoc_midnight() -> Tuple[datetime, timedelta]: - """Calculates the amount of time left until midnight in UTC-5 (Advent of Code maintainer timezone).""" - # Change all time properties back to 00:00 - todays_midnight = datetime.now(EST).replace( - microsecond=0, - second=0, - minute=0, - hour=0 - ) - - # We want tomorrow so add a day on - tomorrow = todays_midnight + timedelta(days=1) - - # Calculate the timedelta between the current time and midnight - return tomorrow, tomorrow - datetime.now(EST) - - async def countdown_status(bot: commands.Bot) -> None: """Set the playing status of the bot to the minutes & hours left until the next day's challenge.""" - while is_in_advent(): - _, time_left = time_left_to_aoc_midnight() + while _helpers.is_in_advent(): + _, time_left = _helpers.time_left_to_aoc_midnight() aligned_seconds = int(math.ceil(time_left.seconds / COUNTDOWN_STEP)) * COUNTDOWN_STEP hours, minutes = aligned_seconds // 3600, aligned_seconds // 60 % 60 @@ -84,8 +58,8 @@ async def day_countdown(bot: commands.Bot) -> None: Once we have calculated this we should then sleep that number and when the time is reached, ping the Advent of Code role notifying them that the new challenge is ready. """ - while is_in_advent(): - tomorrow, time_left = time_left_to_aoc_midnight() + while _helpers.is_in_advent(): + tomorrow, time_left = _helpers.time_left_to_aoc_midnight() # Prevent bot from being slightly too early in trying to announce today's puzzle await asyncio.sleep(time_left.seconds + 1) @@ -196,12 +170,12 @@ class AdventOfCode(commands.Cog): @override_in_channel(AOC_WHITELIST) async def aoc_countdown(self, ctx: commands.Context) -> None: """Return time left until next day.""" - if not is_in_advent(): - datetime_now = datetime.now(EST) + if not _helpers.is_in_advent(): + datetime_now = datetime.now(_helpers.EST) # Calculate the delta to this & next year's December 1st to see which one is closest and not in the past - this_year = datetime(datetime_now.year, 12, 1, tzinfo=EST) - next_year = datetime(datetime_now.year + 1, 12, 1, tzinfo=EST) + this_year = datetime(datetime_now.year, 12, 1, tzinfo=_helpers.EST) + next_year = datetime(datetime_now.year + 1, 12, 1, tzinfo=_helpers.EST) deltas = (dec_first - datetime_now for dec_first in (this_year, next_year)) delta = min(delta for delta in deltas if delta >= timedelta()) # timedelta() gives 0 duration delta @@ -215,7 +189,7 @@ class AdventOfCode(commands.Cog): f"The next event will start in {delta_str}.") return - tomorrow, time_left = time_left_to_aoc_midnight() + tomorrow, time_left = _helpers.time_left_to_aoc_midnight() hours, minutes = time_left.seconds // 3600, time_left.seconds // 60 % 60 diff --git a/bot/exts/christmas/advent_of_code/_helpers.py b/bot/exts/christmas/advent_of_code/_helpers.py index 57aad54d..7ac54322 100644 --- a/bot/exts/christmas/advent_of_code/_helpers.py +++ b/bot/exts/christmas/advent_of_code/_helpers.py @@ -4,9 +4,11 @@ import json import logging import operator import typing +from typing import Tuple import aiohttp import discord +import pytz from bot.constants import AdventOfCode, Colours from bot.exts.christmas.advent_of_code import _caches @@ -42,6 +44,9 @@ AOC_EMBED_THUMBNAIL = ( "/branding/master/seasonal/christmas/server_icons/festive_256.gif" ) +# Create an easy constant for the EST timezone +EST = pytz.timezone("EST") + # Create namedtuple that combines a participant's name and their completion # time for a specific star. We're going to use this later to order the results # for each star to compute the rank score. @@ -313,3 +318,31 @@ async def get_public_join_code(author: discord.Member) -> typing.Optional[str]: # Return the join code for this board return AdventOfCode.leaderboards[best_board].join_code + + +def is_in_advent() -> bool: + """ + Check if we're currently on an Advent of Code day, excluding 25 December. + + This helper function is used to check whether or not a feature that prepares + something for the next Advent of Code challenge should run. As the puzzle + published on the 25th is the last puzzle, this check excludes that date. + """ + return datetime.datetime.now(EST).day in range(1, 25) and datetime.datetime.now(EST).month == 12 + + +def time_left_to_aoc_midnight() -> Tuple[datetime.datetime, datetime.timedelta]: + """Calculates the amount of time left until midnight in UTC-5 (Advent of Code maintainer timezone).""" + # Change all time properties back to 00:00 + todays_midnight = datetime.datetime.now(EST).replace( + microsecond=0, + second=0, + minute=0, + hour=0 + ) + + # We want tomorrow so add a day on + tomorrow = todays_midnight + datetime.timedelta(days=1) + + # Calculate the timedelta between the current time and midnight + return tomorrow, tomorrow - datetime.datetime.now(EST) -- cgit v1.2.3 From 809da330f5ec60c6186dfefaf8bb4bb0128f8442 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Mon, 30 Nov 2020 14:20:21 +0100 Subject: Set character encoding for logging to utf-8 --- bot/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/__init__.py b/bot/__init__.py index a9a0865e..bdb18666 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -37,7 +37,8 @@ os.makedirs(log_dir, exist_ok=True) # File handler rotates logs every 5 MB file_handler = logging.handlers.RotatingFileHandler( - log_file, maxBytes=5 * (2**20), backupCount=10) + log_file, maxBytes=5 * (2**20), backupCount=10, encoding="utf-8", +) file_handler.setLevel(logging.TRACE if Client.debug else logging.DEBUG) # Console handler prints to terminal @@ -61,7 +62,7 @@ logging.basicConfig( format='%(asctime)s - %(name)s %(levelname)s: %(message)s', datefmt="%D %H:%M:%S", level=logging.TRACE if Client.debug else logging.DEBUG, - handlers=[console_handler, file_handler] + handlers=[console_handler, file_handler], ) logging.getLogger().info('Logging initialization complete') -- cgit v1.2.3 From d3a708e8921add6a70302ddc69d4e4f00edee32a Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Mon, 30 Nov 2020 16:12:30 +0100 Subject: Enable AOC commands before December Note: This won't start the countdown functions yet, they still rely on the cog being loaded in december. --- bot/exts/christmas/advent_of_code/_cog.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/bot/exts/christmas/advent_of_code/_cog.py b/bot/exts/christmas/advent_of_code/_cog.py index bc2a4724..646671c7 100644 --- a/bot/exts/christmas/advent_of_code/_cog.py +++ b/bot/exts/christmas/advent_of_code/_cog.py @@ -127,7 +127,6 @@ class AdventOfCode(commands.Cog): status_coro = countdown_status(self.bot) self.status_task = self.bot.loop.create_task(status_coro) - @in_month(Month.DECEMBER) @commands.group(name="adventofcode", aliases=("aoc",)) @override_in_channel(AOC_WHITELIST) async def adventofcode_group(self, ctx: commands.Context) -> None: @@ -143,6 +142,11 @@ class AdventOfCode(commands.Cog): @override_in_channel(AOC_WHITELIST) async def aoc_subscribe(self, ctx: commands.Context) -> None: """Assign the role for notifications about new days being ready.""" + current_year = datetime.now().year + if current_year != AocConfig.year: + await ctx.send(f"You can't subscribe to {current_year}'s Advent of Code announcements yet!") + return + role = ctx.guild.get_role(AocConfig.role_id) unsubscribe_command = f"{ctx.prefix}{ctx.command.root_parent} unsubscribe" @@ -154,6 +158,7 @@ class AdventOfCode(commands.Cog): await ctx.send("Hey, you already are receiving notifications about new Advent of Code tasks. " f"If you don't want them any more, run `{unsubscribe_command}` instead.") + @in_month(Month.DECEMBER) @adventofcode_group.command(name="unsubscribe", aliases=("unsub",), brief="Notifications for new days") @override_in_channel(AOC_WHITELIST) async def aoc_unsubscribe(self, ctx: commands.Context) -> None: @@ -205,6 +210,11 @@ class AdventOfCode(commands.Cog): @override_in_channel(AOC_WHITELIST) async def join_leaderboard(self, ctx: commands.Context) -> None: """DM the user the information for joining the Python Discord leaderboard.""" + current_year = datetime.now().year + if current_year != AocConfig.year: + await ctx.send(f"The Python Discord leaderboard for {current_year} is not yet available!") + return + author = ctx.message.author log.info(f"{author.name} ({author.id}) has requested a PyDis AoC leaderboard code") -- cgit v1.2.3 From 9173abcfefd1e6bf9de3ae1d5f6fcd392b3e8788 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Mon, 30 Nov 2020 17:42:13 +0100 Subject: Add note about refresh time to info embed --- bot/exts/christmas/advent_of_code/_helpers.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/exts/christmas/advent_of_code/_helpers.py b/bot/exts/christmas/advent_of_code/_helpers.py index 7ac54322..72c1ce20 100644 --- a/bot/exts/christmas/advent_of_code/_helpers.py +++ b/bot/exts/christmas/advent_of_code/_helpers.py @@ -243,10 +243,12 @@ async def fetch_leaderboard(invalidate_cache: bool = False) -> dict: def get_summary_embed(leaderboard: dict) -> discord.Embed: """Get an embed with the current summary stats of the leaderboard.""" leaderboard_url = leaderboard['full_leaderboard_url'] + refresh_minutes = AdventOfCode.leaderboard_cache_expiry_seconds // 60 aoc_embed = discord.Embed( colour=Colours.soft_green, timestamp=datetime.datetime.fromisoformat(leaderboard["leaderboard_fetched_at"]), + description=f"*The leaderboard is refreshed every {refresh_minutes} minutes.*" ) aoc_embed.add_field( name="Number of Participants", -- cgit v1.2.3 From d664c68ecd81b2e25fd09c6de69ecafdc13a0b95 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Mon, 30 Nov 2020 18:42:41 +0100 Subject: Clarify text of DM with Advent of Code join code --- bot/exts/christmas/advent_of_code/_cog.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/bot/exts/christmas/advent_of_code/_cog.py b/bot/exts/christmas/advent_of_code/_cog.py index 646671c7..2a1a776b 100644 --- a/bot/exts/christmas/advent_of_code/_cog.py +++ b/bot/exts/christmas/advent_of_code/_cog.py @@ -233,12 +233,14 @@ class AdventOfCode(commands.Cog): await ctx.send(embed=error_embed) return - info_str = ( - "Head over to https://adventofcode.com/leaderboard/private " - f"with code `{join_code}` to join the Python Discord leaderboard!" - ) + info_str = [ + "To join our leaderboard, follow these steps:", + "• Log in on https://adventofcode.com", + "• Head over to https://adventofcode.com/leaderboard/private", + f"• Use this code `{join_code}` to join the Python Discord leaderboard!", + ] try: - await author.send(info_str) + await author.send("\n".join(info_str)) except discord.errors.Forbidden: log.debug(f"{author.name} ({author.id}) has disabled DMs from server members") await ctx.send(f":x: {author.mention}, please (temporarily) enable DMs to receive the join code") -- cgit v1.2.3 From 210c564e1b6823617cb4f64ff5e33031dc61d487 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Tue, 1 Dec 2020 18:30:12 +0100 Subject: Add support for ignoring scores from specific days I've added support for ignoring scores from specific days. A list of days to ignore can be provided using the environment variable `AOC_IGNORED_DAYS` as a comma-separated list. This example would ignore day 1 and day 23: AOC_IGNORED_DAYS=1,23 I've also added a helper function to sort the leaderboard not only on the achieved score, but also on the number of stars an individual has completed. --- bot/constants.py | 1 + bot/exts/christmas/advent_of_code/_helpers.py | 19 ++++++++++++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 292a242a..e313e086 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -63,6 +63,7 @@ class AdventOfCode: staff_leaderboard_id = environ.get("AOC_STAFF_LEADERBOARD_ID", "") # Other Advent of Code constants + ignored_days = [day for day in environ.get("AOC_IGNORED_DAYS", "").split(",")] leaderboard_displayed_members = 10 leaderboard_cache_expiry_seconds = 1800 year = int(environ.get("AOC_YEAR", datetime.utcnow().year)) diff --git a/bot/exts/christmas/advent_of_code/_helpers.py b/bot/exts/christmas/advent_of_code/_helpers.py index 72c1ce20..e84348cb 100644 --- a/bot/exts/christmas/advent_of_code/_helpers.py +++ b/bot/exts/christmas/advent_of_code/_helpers.py @@ -53,6 +53,17 @@ EST = pytz.timezone("EST") _StarResult = collections.namedtuple("StarResult", "name completion_time") +def leaderboard_sorting_function(entry: typing.Tuple[str, dict]) -> typing.Tuple[int, int]: + """ + Provide a sorting value for our leaderboard. + + The leaderboard is sorted primarily on the score someone has received and + secondary on the number of stars someone has completed. + """ + result = entry[1] + return result["score"], result["star_2_count"] + result["star_1_count"] + + def _parse_raw_leaderboard_data(raw_leaderboard_data: dict) -> dict: """ Parse the leaderboard data received from the AoC website. @@ -98,13 +109,15 @@ def _parse_raw_leaderboard_data(raw_leaderboard_data: dict) -> dict: # participants per star, we can compute the rank-based scores each participant # should get for that star. max_score = len(leaderboard) - for star in star_results.values(): - for rank, star_result in enumerate(sorted(star, key=operator.itemgetter(1))): + for(day, _star), results in star_results.items(): + if day in AdventOfCode.ignored_days: + continue + for rank, star_result in enumerate(sorted(results, key=operator.itemgetter(1))): leaderboard[star_result.name]["score"] += max_score - rank # Since dictionaries now retain insertion order, let's use that sorted_leaderboard = dict( - sorted(leaderboard.items(), key=lambda t: t[1]["score"], reverse=True) + sorted(leaderboard.items(), key=leaderboard_sorting_function, reverse=True) ) # Create summary stats for the stars completed for each day of the event. -- cgit v1.2.3 From ddb3dcf00573dd46e260ac2e39bfc09e68e68e44 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Tue, 1 Dec 2020 18:33:11 +0100 Subject: Fix daily stats by converting day, star to str The daily stats function contained a bug that prevented it from working correctly. The reason was that I was looking for `int` keys where the actual keys were strings. I now make sure to create a `str` from the `int` I get back from `range`. --- bot/exts/christmas/advent_of_code/_helpers.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/exts/christmas/advent_of_code/_helpers.py b/bot/exts/christmas/advent_of_code/_helpers.py index e84348cb..f4a20955 100644 --- a/bot/exts/christmas/advent_of_code/_helpers.py +++ b/bot/exts/christmas/advent_of_code/_helpers.py @@ -123,8 +123,9 @@ def _parse_raw_leaderboard_data(raw_leaderboard_data: dict) -> dict: # Create summary stats for the stars completed for each day of the event. daily_stats = {} for day in range(1, 26): - star_one = len(star_results.get((day, 1), [])) - star_two = len(star_results.get((day, 1), [])) + day = str(day) + star_one = len(star_results.get((day, "1"), [])) + star_two = len(star_results.get((day, "2"), [])) # By using a dictionary instead of namedtuple here, we can serialize # this data to JSON in order to cache it in Redis. daily_stats[day] = {"star_one": star_one, "star_two": star_two} -- cgit v1.2.3 From 82a600747e4142f1c7073c2be57c520be6455a87 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Tue, 1 Dec 2020 22:09:57 +0100 Subject: Add function that allows tasks to sleep until AoC Our Advent of Code background tasks were written in such a way that they relied on the extension being loaded in December and only in December. However, since the deseasonification, the extension is loaded prior to December, with some commands being locked to that month with a check instead. This meant that our background tasks immediately cancelled themselves, as they observed themselves to be running outside of the boundaries of the event. As there was no mechanism for starting them back up again, these tasks would only start running again after redeployment of Sir Lancebot. To solve this issue, I've added a helper function that allows tasks to wait until a x hours before the event starts. This allows them to wake up in time to prepare for the release of the first puzzle. --- bot/exts/christmas/advent_of_code/_helpers.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/bot/exts/christmas/advent_of_code/_helpers.py b/bot/exts/christmas/advent_of_code/_helpers.py index f4a20955..145fa30a 100644 --- a/bot/exts/christmas/advent_of_code/_helpers.py +++ b/bot/exts/christmas/advent_of_code/_helpers.py @@ -1,3 +1,4 @@ +import asyncio import collections import datetime import json @@ -362,3 +363,24 @@ def time_left_to_aoc_midnight() -> Tuple[datetime.datetime, datetime.timedelta]: # Calculate the timedelta between the current time and midnight return tomorrow, tomorrow - datetime.datetime.now(EST) + + +async def wait_for_advent_of_code(*, hours_before: int = 1) -> None: + """ + Wait for the Advent of Code event to start. + + This function returns `hours_before` (default: 1) before the Advent of Code + actually starts. This allows functions to schedule and execute code that + needs to run before the event starts. + """ + start = datetime.datetime(AdventOfCode.year, 12, 1, 0, 0, 0, tzinfo=EST) + target = start - datetime.timedelta(hours=hours_before) + now = datetime.datetime.now(EST) + + # If we've already reached or passed to target, we + # simply return immediately. + if now >= target: + return + + delta = target - now + await asyncio.sleep(delta.total_seconds()) -- cgit v1.2.3 From cb418139b19aae38d384746ee0d7e149094c05b1 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Tue, 1 Dec 2020 22:22:29 +0100 Subject: Let status update task sleep until the AoC starts I've refactored the rich presence countdown task by making it hibernate until 2 hours before the next Advent of Code starts if the task starts up before the event has started. This ensures that the task will run when the event starts and allows it to countdown to the first challenge. After the event for the configured Advent of Code year has finished, the task will terminate. This also means that the task will terminate immediately in the year following the currently configured Advent of Code; it will only start hibernating again once we configure the bot for the next event. No unnecessary, year-long hibernation. --- bot/exts/christmas/advent_of_code/_cog.py | 32 +-------------- bot/exts/christmas/advent_of_code/_helpers.py | 59 +++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 31 deletions(-) diff --git a/bot/exts/christmas/advent_of_code/_cog.py b/bot/exts/christmas/advent_of_code/_cog.py index 2a1a776b..57043454 100644 --- a/bot/exts/christmas/advent_of_code/_cog.py +++ b/bot/exts/christmas/advent_of_code/_cog.py @@ -1,7 +1,6 @@ import asyncio import json import logging -import math from datetime import datetime, timedelta from pathlib import Path @@ -19,38 +18,9 @@ log = logging.getLogger(__name__) AOC_REQUEST_HEADER = {"user-agent": "PythonDiscord AoC Event Bot"} -COUNTDOWN_STEP = 60 * 5 - AOC_WHITELIST = WHITELISTED_CHANNELS + (Channels.advent_of_code,) -async def countdown_status(bot: commands.Bot) -> None: - """Set the playing status of the bot to the minutes & hours left until the next day's challenge.""" - while _helpers.is_in_advent(): - _, time_left = _helpers.time_left_to_aoc_midnight() - - aligned_seconds = int(math.ceil(time_left.seconds / COUNTDOWN_STEP)) * COUNTDOWN_STEP - hours, minutes = aligned_seconds // 3600, aligned_seconds // 60 % 60 - - if aligned_seconds == 0: - playing = "right now!" - elif aligned_seconds == COUNTDOWN_STEP: - playing = f"in less than {minutes} minutes" - elif hours == 0: - playing = f"in {minutes} minutes" - elif hours == 23: - playing = f"since {60 - minutes} minutes ago" - else: - playing = f"in {hours} hours and {minutes} minutes" - - # Status will look like "Playing in 5 hours and 30 minutes" - await bot.change_presence(activity=discord.Game(playing)) - - # Sleep until next aligned time or a full step if already aligned - delay = time_left.seconds % COUNTDOWN_STEP or COUNTDOWN_STEP - await asyncio.sleep(delay) - - async def day_countdown(bot: commands.Bot) -> None: """ Calculate the number of seconds left until the next day of Advent. @@ -124,7 +94,7 @@ class AdventOfCode(commands.Cog): countdown_coro = day_countdown(self.bot) self.countdown_task = self.bot.loop.create_task(countdown_coro) - status_coro = countdown_status(self.bot) + status_coro = _helpers.countdown_status(self.bot) self.status_task = self.bot.loop.create_task(status_coro) @commands.group(name="adventofcode", aliases=("aoc",)) diff --git a/bot/exts/christmas/advent_of_code/_helpers.py b/bot/exts/christmas/advent_of_code/_helpers.py index 145fa30a..57ad001a 100644 --- a/bot/exts/christmas/advent_of_code/_helpers.py +++ b/bot/exts/christmas/advent_of_code/_helpers.py @@ -3,6 +3,7 @@ import collections import datetime import json import logging +import math import operator import typing from typing import Tuple @@ -11,6 +12,7 @@ import aiohttp import discord import pytz +from bot.bot import Bot from bot.constants import AdventOfCode, Colours from bot.exts.christmas.advent_of_code import _caches @@ -48,6 +50,9 @@ AOC_EMBED_THUMBNAIL = ( # Create an easy constant for the EST timezone EST = pytz.timezone("EST") +# Step size for the challenge countdown status +COUNTDOWN_STEP = 60 * 5 + # Create namedtuple that combines a participant's name and their completion # time for a specific star. We're going to use this later to order the results # for each star to compute the rank score. @@ -384,3 +389,57 @@ async def wait_for_advent_of_code(*, hours_before: int = 1) -> None: delta = target - now await asyncio.sleep(delta.total_seconds()) + + +async def countdown_status(bot: Bot) -> None: + """ + Add the time until the next challenge is published to the bot's status. + + This function sleeps until 2 hours before the event and exists one hour + after the last challenge has been published. It will not start up again + automatically for next year's event, as it will wait for the environment + variable AOC_YEAR to be updated. + + This ensures that the task will only start sleeping again once the next + event approaches and we're making preparations for that event. + """ + log.debug("Initializing status countdown task.") + # We wait until 2 hours before the event starts. Then we + # set our first countdown status. + await wait_for_advent_of_code(hours_before=2) + + # Log that we're going to start with the countdown status. + log.info("The Advent of Code has started or will start soon, starting countdown status.") + + # Calculate when the task needs to stop running. To prevent the task from + # sleeping for the entire year, it will only wait in the currently + # configured year. This means that the task will only start hibernating once + # we start preparing the next event by changing environment variables. + last_challenge = datetime.datetime(AdventOfCode.year, 12, 25, 0, 0, 0, tzinfo=EST) + end = last_challenge + datetime.timedelta(hours=1) + + while datetime.datetime.now(EST) < end: + _, time_left = time_left_to_aoc_midnight() + + aligned_seconds = int(math.ceil(time_left.seconds / COUNTDOWN_STEP)) * COUNTDOWN_STEP + hours, minutes = aligned_seconds // 3600, aligned_seconds // 60 % 60 + + if aligned_seconds == 0: + playing = "right now!" + elif aligned_seconds == COUNTDOWN_STEP: + playing = f"in less than {minutes} minutes" + elif hours == 0: + playing = f"in {minutes} minutes" + elif hours == 23: + playing = f"since {60 - minutes} minutes ago" + else: + playing = f"in {hours} hours and {minutes} minutes" + + log.trace(f"Changing presence to {playing!r}") + # Status will look like "Playing in 5 hours and 30 minutes" + await bot.change_presence(activity=discord.Game(playing)) + + # Sleep until next aligned time or a full step if already aligned + delay = time_left.seconds % COUNTDOWN_STEP or COUNTDOWN_STEP + log.trace(f"The countdown status task will sleep for {delay} seconds.") + await asyncio.sleep(delay) -- cgit v1.2.3 From 575fedc0b4b22c712c541f7b49311ce2d02e9880 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Tue, 1 Dec 2020 22:28:16 +0100 Subject: Ensure status countdown waits for bot start-up Trying to change the rich presence status of the bot too early in the bot's start-up sequence will cause the task to fail. To make it wait, I've added a `bot.wait_until_guild_available` point before the main loop of the task starts. This seems to solve the issue reliably, despite the `guild_available` event not being directly related to when the bot's connection with the API is ready to accept presence updates. However, the `ready` event is know to fire too early, according to the discord.py community. --- bot/exts/christmas/advent_of_code/_helpers.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/exts/christmas/advent_of_code/_helpers.py b/bot/exts/christmas/advent_of_code/_helpers.py index 57ad001a..9ba4d9be 100644 --- a/bot/exts/christmas/advent_of_code/_helpers.py +++ b/bot/exts/christmas/advent_of_code/_helpers.py @@ -411,6 +411,10 @@ async def countdown_status(bot: Bot) -> None: # Log that we're going to start with the countdown status. log.info("The Advent of Code has started or will start soon, starting countdown status.") + # Trying to change status too early in the bot's startup sequence will fail + # the task. Waiting until this event seems to work well. + await bot.wait_until_guild_available() + # Calculate when the task needs to stop running. To prevent the task from # sleeping for the entire year, it will only wait in the currently # configured year. This means that the task will only start hibernating once -- cgit v1.2.3 From 3e5cff49f40158acb6417d948c2155daed2e4c29 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Tue, 1 Dec 2020 23:04:30 +0100 Subject: Let puzzle notification sleep until AoC starts Instead of cancelling the task when it starts up outside of the boundaries of the Advent of Code, the task will now hibernate until just before the event starts if starts up before December. This allows it to actually announce the first puzzle. After the announcement for the last day of the current event is made, it will terminate itself. It will only start hibernating again when we've updated the environment variables for next year's event, ensuring that it does not run unnecessarily. To prevent issues with the guild cache not being available, I've added our new `wait_until_guild_available` waiting function. I've also moved the calls that get teh role/channel to before the loop, as there's no need to get them each time the loop goes around. I've also changed the way we calculate the time we need to sleep, as the old way used truncated seconds, meaning that we would always wake up relatively early. Instead, I'm now using fractional seconds, which means we can reduce the safety padding to a fraction of second. More accurate announcement timing! --- bot/exts/christmas/advent_of_code/_cog.py | 58 +------------------- bot/exts/christmas/advent_of_code/_helpers.py | 77 ++++++++++++++++++++++++++- 2 files changed, 77 insertions(+), 58 deletions(-) diff --git a/bot/exts/christmas/advent_of_code/_cog.py b/bot/exts/christmas/advent_of_code/_cog.py index 57043454..902391b5 100644 --- a/bot/exts/christmas/advent_of_code/_cog.py +++ b/bot/exts/christmas/advent_of_code/_cog.py @@ -1,4 +1,3 @@ -import asyncio import json import logging from datetime import datetime, timedelta @@ -21,61 +20,6 @@ AOC_REQUEST_HEADER = {"user-agent": "PythonDiscord AoC Event Bot"} AOC_WHITELIST = WHITELISTED_CHANNELS + (Channels.advent_of_code,) -async def day_countdown(bot: commands.Bot) -> None: - """ - Calculate the number of seconds left until the next day of Advent. - - Once we have calculated this we should then sleep that number and when the time is reached, ping - the Advent of Code role notifying them that the new challenge is ready. - """ - while _helpers.is_in_advent(): - tomorrow, time_left = _helpers.time_left_to_aoc_midnight() - - # Prevent bot from being slightly too early in trying to announce today's puzzle - await asyncio.sleep(time_left.seconds + 1) - - channel = bot.get_channel(Channels.advent_of_code) - - if not channel: - log.error("Could not find the AoC channel to send notification in") - break - - aoc_role = channel.guild.get_role(AocConfig.role_id) - if not aoc_role: - log.error("Could not find the AoC role to announce the daily puzzle") - break - - puzzle_url = f"https://adventofcode.com/{AocConfig.year}/day/{tomorrow.day}" - - # Check if the puzzle is already available to prevent our members from spamming - # the puzzle page before it's available by making a small HEAD request. - for retry in range(1, 5): - log.debug(f"Checking if the puzzle is already available (attempt {retry}/4)") - async with bot.http_session.head(puzzle_url, raise_for_status=False) as resp: - if resp.status == 200: - log.debug("Puzzle is available; let's send an announcement message.") - break - log.debug(f"The puzzle is not yet available (status={resp.status})") - await asyncio.sleep(10) - else: - log.error("The puzzle does does not appear to be available at this time, canceling announcement") - break - - await channel.send( - f"{aoc_role.mention} Good morning! Day {tomorrow.day} is ready to be attempted. " - f"View it online now at {puzzle_url}. Good luck!", - allowed_mentions=discord.AllowedMentions( - everyone=False, - users=False, - roles=[discord.Object(AocConfig.role_id)], - ) - ) - - # Wait a couple minutes so that if our sleep didn't sleep enough - # time we don't end up announcing twice. - await asyncio.sleep(120) - - class AdventOfCode(commands.Cog): """Advent of Code festivities! Ho Ho Ho!""" @@ -91,7 +35,7 @@ class AdventOfCode(commands.Cog): self.countdown_task = None self.status_task = None - countdown_coro = day_countdown(self.bot) + countdown_coro = _helpers.day_countdown(self.bot) self.countdown_task = self.bot.loop.create_task(countdown_coro) status_coro = _helpers.countdown_status(self.bot) diff --git a/bot/exts/christmas/advent_of_code/_helpers.py b/bot/exts/christmas/advent_of_code/_helpers.py index 9ba4d9be..1c4a01ed 100644 --- a/bot/exts/christmas/advent_of_code/_helpers.py +++ b/bot/exts/christmas/advent_of_code/_helpers.py @@ -13,7 +13,7 @@ import discord import pytz from bot.bot import Bot -from bot.constants import AdventOfCode, Colours +from bot.constants import AdventOfCode, Channels, Colours from bot.exts.christmas.advent_of_code import _caches log = logging.getLogger(__name__) @@ -447,3 +447,78 @@ async def countdown_status(bot: Bot) -> None: delay = time_left.seconds % COUNTDOWN_STEP or COUNTDOWN_STEP log.trace(f"The countdown status task will sleep for {delay} seconds.") await asyncio.sleep(delay) + + +async def day_countdown(bot: Bot) -> None: + """ + Calculate the number of seconds left until the next day of Advent. + + Once we have calculated this we should then sleep that number and when the time is reached, ping + the Advent of Code role notifying them that the new challenge is ready. + """ + # We wake up one hour before the event starts to prepare the announcement + # of the release of the first puzzle. + await wait_for_advent_of_code(hours_before=1) + + log.info("The Advent of Code has started or will start soon, waking up notification task.") + + # Ensure that the guild cache is loaded so we can get the Advent of Code + # channel and role. + await bot.wait_until_guild_available() + aoc_channel = bot.get_channel(Channels.advent_of_code) + aoc_role = aoc_channel.guild.get_role(AdventOfCode.role_id) + + if not aoc_channel: + log.error("Could not find the AoC channel to send notification in") + return + + if not aoc_role: + log.error("Could not find the AoC role to announce the daily puzzle") + return + + # The last event day is 25 December, so we only have to schedule + # a reminder if the current day is before 25 December. + end = datetime.datetime(AdventOfCode.year, 12, 25, tzinfo=EST) + while datetime.datetime.now(EST) < end: + log.trace("Started puzzle notification loop.") + tomorrow, time_left = time_left_to_aoc_midnight() + + # Use fractional `total_seconds` to wake up very close to our target, with + # padding of 0.1 seconds to ensure that we actually pass midnight. + sleep_seconds = time_left.total_seconds() + 0.1 + log.trace(f"The puzzle notification task will sleep for {sleep_seconds} seconds") + await asyncio.sleep(sleep_seconds) + + puzzle_url = f"https://adventofcode.com/{AdventOfCode.year}/day/{tomorrow.day}" + + # Check if the puzzle is already available to prevent our members from spamming + # the puzzle page before it's available by making a small HEAD request. + for retry in range(1, 5): + log.debug(f"Checking if the puzzle is already available (attempt {retry}/4)") + async with bot.http_session.head(puzzle_url, raise_for_status=False) as resp: + if resp.status == 200: + log.debug("Puzzle is available; let's send an announcement message.") + break + log.debug(f"The puzzle is not yet available (status={resp.status})") + await asyncio.sleep(10) + else: + log.error( + "The puzzle does does not appear to be available " + "at this time, canceling announcement" + ) + break + + await aoc_channel.send( + f"{aoc_role.mention} Good morning! Day {tomorrow.day} is ready to be attempted. " + f"View it online now at {puzzle_url}. Good luck!", + allowed_mentions=discord.AllowedMentions( + everyone=False, + users=False, + roles=[aoc_role], + ) + ) + + # Ensure that we don't send duplicate announcements by sleeping to well + # over midnight. This means we're certain to calculate the time to the + # next midnight at the top of the loop. + await asyncio.sleep(120) -- cgit v1.2.3 From d9e98c32fed67e1c01cd496341ef196bba88ca32 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Tue, 1 Dec 2020 23:12:26 +0100 Subject: Clarify time_left_until_est_midnight function The helper function calculates the time left until the next midnight in the EST timezone, not necessarily the next midnight during and Advent of Code event. To prevent confusion, I've clarified its function by changing the name of the function and its docstring. --- bot/exts/christmas/advent_of_code/_cog.py | 2 +- bot/exts/christmas/advent_of_code/_helpers.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/bot/exts/christmas/advent_of_code/_cog.py b/bot/exts/christmas/advent_of_code/_cog.py index 902391b5..2f60e512 100644 --- a/bot/exts/christmas/advent_of_code/_cog.py +++ b/bot/exts/christmas/advent_of_code/_cog.py @@ -108,7 +108,7 @@ class AdventOfCode(commands.Cog): f"The next event will start in {delta_str}.") return - tomorrow, time_left = _helpers.time_left_to_aoc_midnight() + tomorrow, time_left = _helpers.time_left_to_est_midnight() hours, minutes = time_left.seconds // 3600, time_left.seconds // 60 % 60 diff --git a/bot/exts/christmas/advent_of_code/_helpers.py b/bot/exts/christmas/advent_of_code/_helpers.py index 1c4a01ed..c75f47fa 100644 --- a/bot/exts/christmas/advent_of_code/_helpers.py +++ b/bot/exts/christmas/advent_of_code/_helpers.py @@ -353,8 +353,8 @@ def is_in_advent() -> bool: return datetime.datetime.now(EST).day in range(1, 25) and datetime.datetime.now(EST).month == 12 -def time_left_to_aoc_midnight() -> Tuple[datetime.datetime, datetime.timedelta]: - """Calculates the amount of time left until midnight in UTC-5 (Advent of Code maintainer timezone).""" +def time_left_to_est_midnight() -> Tuple[datetime.datetime, datetime.timedelta]: + """Calculate the amount of time left until midnight EST/UTC-5.""" # Change all time properties back to 00:00 todays_midnight = datetime.datetime.now(EST).replace( microsecond=0, @@ -423,7 +423,7 @@ async def countdown_status(bot: Bot) -> None: end = last_challenge + datetime.timedelta(hours=1) while datetime.datetime.now(EST) < end: - _, time_left = time_left_to_aoc_midnight() + _, time_left = time_left_to_est_midnight() aligned_seconds = int(math.ceil(time_left.seconds / COUNTDOWN_STEP)) * COUNTDOWN_STEP hours, minutes = aligned_seconds // 3600, aligned_seconds // 60 % 60 @@ -481,7 +481,7 @@ async def day_countdown(bot: Bot) -> None: end = datetime.datetime(AdventOfCode.year, 12, 25, tzinfo=EST) while datetime.datetime.now(EST) < end: log.trace("Started puzzle notification loop.") - tomorrow, time_left = time_left_to_aoc_midnight() + tomorrow, time_left = time_left_to_est_midnight() # Use fractional `total_seconds` to wake up very close to our target, with # padding of 0.1 seconds to ensure that we actually pass midnight. -- cgit v1.2.3 From d4c8c0f7184e5d494136cc2b7fc670e8ab7a8f93 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Wed, 2 Dec 2020 00:22:55 +0100 Subject: Update docstrings and fix grammar in comments I've updated some docstrings to include more information about the inner workings of some of the functions. In addition, I've also slightly reformulated some block comments to improve their grammar. Kaizen change: There was a redundant list comprehension in the Advent of Code section of the constants. I've removed it. --- bot/constants.py | 2 +- bot/exts/christmas/advent_of_code/_cog.py | 4 ++-- bot/exts/christmas/advent_of_code/_helpers.py | 24 +++++++++++++++++------- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index e313e086..c696b202 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -63,7 +63,7 @@ class AdventOfCode: staff_leaderboard_id = environ.get("AOC_STAFF_LEADERBOARD_ID", "") # Other Advent of Code constants - ignored_days = [day for day in environ.get("AOC_IGNORED_DAYS", "").split(",")] + ignored_days = environ.get("AOC_IGNORED_DAYS", "").split(",") leaderboard_displayed_members = 10 leaderboard_cache_expiry_seconds = 1800 year = int(environ.get("AOC_YEAR", datetime.utcnow().year)) diff --git a/bot/exts/christmas/advent_of_code/_cog.py b/bot/exts/christmas/advent_of_code/_cog.py index 2f60e512..29dcc3cf 100644 --- a/bot/exts/christmas/advent_of_code/_cog.py +++ b/bot/exts/christmas/advent_of_code/_cog.py @@ -35,8 +35,8 @@ class AdventOfCode(commands.Cog): self.countdown_task = None self.status_task = None - countdown_coro = _helpers.day_countdown(self.bot) - self.countdown_task = self.bot.loop.create_task(countdown_coro) + announcement_coro = _helpers.new_puzzle_announcement(self.bot) + self.new_puzzle_announcement_task = self.bot.loop.create_task(announcement_coro) status_coro = _helpers.countdown_status(self.bot) self.status_task = self.bot.loop.create_task(status_coro) diff --git a/bot/exts/christmas/advent_of_code/_helpers.py b/bot/exts/christmas/advent_of_code/_helpers.py index c75f47fa..7a6d873e 100644 --- a/bot/exts/christmas/advent_of_code/_helpers.py +++ b/bot/exts/christmas/advent_of_code/_helpers.py @@ -374,9 +374,16 @@ async def wait_for_advent_of_code(*, hours_before: int = 1) -> None: """ Wait for the Advent of Code event to start. - This function returns `hours_before` (default: 1) before the Advent of Code + This function returns `hours_before` (default: 1) the Advent of Code actually starts. This allows functions to schedule and execute code that needs to run before the event starts. + + If the event has already started, this function returns immediately. + + Note: The "next Advent of Code" is determined based on the current value + of the `AOC_YEAR` environment variable. This allows callers to exit early + if we're already past the Advent of Code edition the bot is currently + configured for. """ start = datetime.datetime(AdventOfCode.year, 12, 1, 0, 0, 0, tzinfo=EST) target = start - datetime.timedelta(hours=hours_before) @@ -449,12 +456,13 @@ async def countdown_status(bot: Bot) -> None: await asyncio.sleep(delay) -async def day_countdown(bot: Bot) -> None: +async def new_puzzle_announcement(bot: Bot) -> None: """ - Calculate the number of seconds left until the next day of Advent. + Announce the release of a new Advent of Code puzzle. - Once we have calculated this we should then sleep that number and when the time is reached, ping - the Advent of Code role notifying them that the new challenge is ready. + This background task hibernates until just before the Advent of Code starts + and will then start announcing puzzles as they are published. After the + event has finished, this task will terminate. """ # We wake up one hour before the event starts to prepare the announcement # of the release of the first puzzle. @@ -483,8 +491,10 @@ async def day_countdown(bot: Bot) -> None: log.trace("Started puzzle notification loop.") tomorrow, time_left = time_left_to_est_midnight() - # Use fractional `total_seconds` to wake up very close to our target, with - # padding of 0.1 seconds to ensure that we actually pass midnight. + # Use `total_seconds` to get the time left in fractional seconds This + # should wake us up very close to the target. As a safe guard, the sleep + # duration is padded with 0.1 second to make sure we wake up after + # midnight. sleep_seconds = time_left.total_seconds() + 0.1 log.trace(f"The puzzle notification task will sleep for {sleep_seconds} seconds") await asyncio.sleep(sleep_seconds) -- cgit v1.2.3 From 02c5c7e2b120c055b7db07d2ca9568d1ae25c399 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Wed, 2 Dec 2020 08:46:26 +0100 Subject: Fix leaderboard glitch caused by duplicate names We noticed that some entries on our leaderboard had an incorrect star count attached to their name. After a bit of digging, @HassanAbouelela discovered that this was caused by the use of the member's name as the key for the leaderboard dictionary: If different accounts used the same display name for the leaderboard, they'd be combined into one glitched score dict. The fix @HassanAbouelela wrote is to use the member id instead of the name as the key for the leaderboard. I've changed a few names here and there, but nothing major. This commit closes #536 --- bot/exts/christmas/advent_of_code/_helpers.py | 30 +++++++++++++++------------ 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/bot/exts/christmas/advent_of_code/_helpers.py b/bot/exts/christmas/advent_of_code/_helpers.py index f4a20955..e7eeedb2 100644 --- a/bot/exts/christmas/advent_of_code/_helpers.py +++ b/bot/exts/christmas/advent_of_code/_helpers.py @@ -50,7 +50,7 @@ EST = pytz.timezone("EST") # Create namedtuple that combines a participant's name and their completion # time for a specific star. We're going to use this later to order the results # for each star to compute the rank score. -_StarResult = collections.namedtuple("StarResult", "name completion_time") +StarResult = collections.namedtuple("StarResult", "member_id completion_time") def leaderboard_sorting_function(entry: typing.Tuple[str, dict]) -> typing.Tuple[int, int]: @@ -61,7 +61,7 @@ def leaderboard_sorting_function(entry: typing.Tuple[str, dict]) -> typing.Tuple secondary on the number of stars someone has completed. """ result = entry[1] - return result["score"], result["star_2_count"] + result["star_1_count"] + return result["score"], result["star_2"] + result["star_1"] def _parse_raw_leaderboard_data(raw_leaderboard_data: dict) -> dict: @@ -90,30 +90,34 @@ def _parse_raw_leaderboard_data(raw_leaderboard_data: dict) -> dict: # star view. We need that per star view to compute rank scores per star. for member in raw_leaderboard_data.values(): name = member["name"] if member["name"] else f"Anonymous #{member['id']}" - leaderboard[name] = {"score": 0, "star_1_count": 0, "star_2_count": 0} + member_id = member['id'] + leaderboard[member_id] = {"name": name, "score": 0, "star_1": 0, "star_2": 0} # Iterate over all days for this participant for day, stars in member["completion_day_level"].items(): # Iterate over the complete stars for this day for this participant for star, data in stars.items(): # Record completion of this star for this individual - leaderboard[name][f"star_{star}_count"] += 1 + leaderboard[member_id][f"star_{star}"] += 1 # Record completion datetime for this participant for this day/star completion_time = datetime.datetime.fromtimestamp(int(data['get_star_ts'])) star_results[(day, star)].append( - _StarResult(name=name, completion_time=completion_time) + StarResult(member_id=member_id, completion_time=completion_time) ) # Now that we have a transposed dataset that holds the completion time of all # participants per star, we can compute the rank-based scores each participant # should get for that star. max_score = len(leaderboard) - for(day, _star), results in star_results.items(): + for (day, _star), results in star_results.items(): + # If this day should not count in the ranking, skip it. if day in AdventOfCode.ignored_days: continue - for rank, star_result in enumerate(sorted(results, key=operator.itemgetter(1))): - leaderboard[star_result.name]["score"] += max_score - rank + + sorted_result = sorted(results, key=operator.attrgetter('completion_time')) + for rank, star_result in enumerate(sorted_result): + leaderboard[star_result.member_id]["score"] += max_score - rank # Since dictionaries now retain insertion order, let's use that sorted_leaderboard = dict( @@ -133,16 +137,16 @@ def _parse_raw_leaderboard_data(raw_leaderboard_data: dict) -> dict: return {"daily_stats": daily_stats, "leaderboard": sorted_leaderboard} -def _format_leaderboard(leaderboard: typing.Dict[str, int]) -> str: +def _format_leaderboard(leaderboard: typing.Dict[str, dict]) -> str: """Format the leaderboard using the AOC_TABLE_TEMPLATE.""" leaderboard_lines = [HEADER] - for rank, (name, results) in enumerate(leaderboard.items(), start=1): + for rank, data in enumerate(leaderboard.values(), start=1): leaderboard_lines.append( AOC_TABLE_TEMPLATE.format( rank=rank, - name=name, - score=str(results["score"]), - stars=f"({results['star_1_count']}, {results['star_2_count']})" + name=data["name"], + score=str(data["score"]), + stars=f"({data['star_1']}, {data['star_2']})" ) ) -- cgit v1.2.3 From 4d2f89991432639431968d352bde2cd747b9432c Mon Sep 17 00:00:00 2001 From: Rohan Date: Wed, 2 Dec 2020 21:58:09 +0530 Subject: Modify error handler check for locally handled errors. Error handler now checks if the error has the attribute "handled" for locally handled errors. --- bot/exts/evergreen/error_handler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/evergreen/error_handler.py b/bot/exts/evergreen/error_handler.py index 6e518435..b502dd4e 100644 --- a/bot/exts/evergreen/error_handler.py +++ b/bot/exts/evergreen/error_handler.py @@ -42,8 +42,8 @@ class CommandErrorHandler(commands.Cog): @commands.Cog.listener() async def on_command_error(self, ctx: commands.Context, error: commands.CommandError) -> None: """Activates when a command opens an error.""" - if hasattr(ctx.command, 'on_error'): - logging.debug("A command error occured but the command had it's own error handler.") + if hasattr(error, 'handled'): + logging.debug(f"Command {ctx.command} had its error already handled locally; ignoring.") return error = getattr(error, 'original', error) -- cgit v1.2.3 From 4550d27308b17c6107f123469cf529c35318654a Mon Sep 17 00:00:00 2001 From: Hedy Li Date: Thu, 3 Dec 2020 09:45:43 +0800 Subject: Remove unused pathlib.Path import --- bot/exts/halloween/hacktoberstats.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/exts/halloween/hacktoberstats.py b/bot/exts/halloween/hacktoberstats.py index ae949f53..a599af73 100644 --- a/bot/exts/halloween/hacktoberstats.py +++ b/bot/exts/halloween/hacktoberstats.py @@ -3,7 +3,6 @@ import random import re from collections import Counter from datetime import datetime, timedelta -from pathlib import Path from typing import List, Optional, Tuple, Union import aiohttp -- cgit v1.2.3 From cd24f9b4e70ebe654fa771238fca7e207e8cb0de Mon Sep 17 00:00:00 2001 From: Rohan Date: Fri, 4 Dec 2020 20:33:03 +0530 Subject: Check value of handled attribute on error in global error handler. --- bot/exts/evergreen/error_handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/evergreen/error_handler.py b/bot/exts/evergreen/error_handler.py index b502dd4e..99af1519 100644 --- a/bot/exts/evergreen/error_handler.py +++ b/bot/exts/evergreen/error_handler.py @@ -42,7 +42,7 @@ class CommandErrorHandler(commands.Cog): @commands.Cog.listener() async def on_command_error(self, ctx: commands.Context, error: commands.CommandError) -> None: """Activates when a command opens an error.""" - if hasattr(error, 'handled'): + if getattr(error, 'handled', False): logging.debug(f"Command {ctx.command} had its error already handled locally; ignoring.") return -- cgit v1.2.3 From 1aad543a8d4b3c7317fe98937144591d85ad3330 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Sat, 5 Dec 2020 00:21:04 +0100 Subject: Support a fallback session cookie in constants To mitigate problems due to expiring session cookies, I'm currently in the process of adding support for a fallback cookie. Basically, my Advent of Code account is a member of *all* leaderboards, which means that my cookie can be used to fetch all leaderboards as well. As my session cookie should not expire until after the event, it should not give us any issues. However, to avoid issues with issuing too many requests from one session, we should still make sure to set individual session values regardless of the mitigation. --- bot/constants.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 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): +@dataclasses.dataclass +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(",")] -- cgit v1.2.3 From c21014abc7c74a72363daeac3714b9fa976247ce Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Sat, 5 Dec 2020 00:39:09 +0100 Subject: Mitigate session expiry by using fallback session Unfortunately, an expired session cookie wreaked havoc to our Advent of Code commands: All commands that relied on leaderboard data failed because we couldn't refresh our data and the cache had expired. To mitigate an expired session, I've added a fallback session feature that enables us to try again with a different session. While it will issue an error message to inform us to refresh the expired session cookie, it does mean that the functionality should continue to work in the mean time. The fallback session cookie is currently set to my session cookie, using an environment variable, `AOC_FALLBACK_SESSION`. It is important that the user connected to the session is a member of all boards and that it's a fresh session: We don't want our fallback to expire! At the same time, while a single fallback session works, the AoC website also does not like too many requests from a single user. That's why we'll still use a multi-session model under normal circumstances. To check for expired sessions, I've added a URL check: The Advent of Code website will silently redirect people with an expired session, issuing an 200: OK status as usual. The only way to really check for it is by comparing the final URL in the response object to the URL we set out to GET. I've added a custom exception to signal such an unexpected redirect. Finally, instead of having the commands just break, I've added an Exception signal that propagates back to the caller. The solution, with try-except, is a bit hacky and could benefit from an actual error handler, but I wanted to get things fixed first; polish can be added later. --- bot/exts/christmas/advent_of_code/_cog.py | 27 ++++++++--- bot/exts/christmas/advent_of_code/_helpers.py | 65 ++++++++++++++++++++++----- 2 files changed, 75 insertions(+), 17 deletions(-) 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 -- cgit v1.2.3 From a2d85eb6b5fc68b687741f23bb5061802f2f88cb Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Sat, 5 Dec 2020 13:01:55 +0100 Subject: Use custom status embeds for workflow runs This commit introduces enhanced status embeds for workflow runs that give more information about run that just ended. An added advantage is that we can disable the default "give me everything"-setting of GitHub and fine tune when we want to send embeds. This allows us to only send an embed for the `lint->build` sequence when the sequence ends (e.g. in the end when done or after an intermediate step due to failure/cancellation). --- .github/workflows/build.yaml | 31 +++++++++++++++++++++++++++++++ .github/workflows/lint.yaml | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index b0c03139..64c272cf 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -75,3 +75,34 @@ jobs: kubernetes/sir-lancebot/deployment.yaml images: 'ghcr.io/python-discord/sir-lancebot:${{ steps.sha_tag.outputs.tag }}' kubectl-version: 'latest' + + # Send an informational status embed to Discord instead of the + # standard embeds that Discord sends. This embed will contain + # more information and we can fine tune when we actually want + # to send an embed. + - name: GitHub Actions Status Embed for Discord + # This is the last step in the lint-build sequence, so always send + # an embed, regardless of success, failure or cancelled status. + if: always() + uses: SebastiaanZ/github-status-embed-for-discord@v0.1.1 + with: + # Our GitHub Actions webhook + webhook_id: '784184528997842985' + webhook_token: ${{ secrets.GHA_WEBHOOK_TOKEN }} + + # Workflow information + workflow_name: ${{ github.workflow }} + run_id: ${{ github.run_id }} + run_number: ${{ github.run_number }} + status: ${{ job.status }} + actor: ${{ github.actor }} + repository: ${{ github.repository }} + ref: ${{ github.ref }} + sha: ${{ github.sha }} + + # Optional PR-information. These values will be "null" if + # the event trigger was not PR-related. + pr_author_login: ${{ github.event.pull_request.user.login }} + pr_number: ${{ github.event.pull_request.number }} + pr_title: ${{ github.event.pull_request.title }} + pr_source: ${{ github.event.pull_request.head.label }} diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 063f406c..8dd93773 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -91,3 +91,35 @@ jobs: - name: Run flake8 run: "flake8 \ --format='::error file=%(path)s,line=%(row)d,col=%(col)d::[flake8] %(code)s: %(text)s'" + + # Send an informational status embed to Discord instead of the + # standard embeds that Discord sends. This embed will contain + # more information and we can fine tune when we actually want + # to send an embed. + - name: GitHub Actions Status Embed for Discord + # For a `pull_request` we always want to send a status embed + # here. For a push event, only when this workflow is the last + # in lint->build sequence because it failed or was cancelled. + if: github.event_name == 'pull_request' || cancelled() || failure() + uses: SebastiaanZ/github-status-embed-for-discord@v0.1.1 + with: + # Our GitHub Actions webhook + webhook_id: '784184528997842985' + webhook_token: ${{ secrets.GHA_WEBHOOK_TOKEN }} + + # Workflow information + workflow_name: ${{ github.workflow }} + run_id: ${{ github.run_id }} + run_number: ${{ github.run_number }} + status: ${{ job.status }} + actor: ${{ github.actor }} + repository: ${{ github.repository }} + ref: ${{ github.ref }} + sha: ${{ github.sha }} + + # Optional PR-information. These values will be "null" if + # the event trigger was not PR-related. + pr_author_login: ${{ github.event.pull_request.user.login }} + pr_number: ${{ github.event.pull_request.number }} + pr_title: ${{ github.event.pull_request.title }} + pr_source: ${{ github.event.pull_request.head.label }} -- cgit v1.2.3 From 8d3f326c7971a2b3547ea220272d9465b49986db Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sun, 6 Dec 2020 18:42:16 +0000 Subject: Delete review check GitHub Action --- .github/workflows/review-check.yaml | 166 ------------------------------------ 1 file changed, 166 deletions(-) delete mode 100644 .github/workflows/review-check.yaml diff --git a/.github/workflows/review-check.yaml b/.github/workflows/review-check.yaml deleted file mode 100644 index 3e45a4b5..00000000 --- a/.github/workflows/review-check.yaml +++ /dev/null @@ -1,166 +0,0 @@ -name: Review Check - -# This workflow needs to trigger in two situations: -# -# 1. When a pull request is opened, reopened, or synchronized (new commit) -# This is accomplished using the `pull_request_target` event that triggers in -# precisely those situations by default. I've opted for `pull_request_target` -# as we don't need to have access to the PR's code and it's safer to make the -# secrets we need available to the workflow compared to `pull_request`. -# -# The reason we need to run the workflow for this event is because we need to -# make sure that our check is part of the check suite for the current commit. -# -# 2. When a review is added or dismissed. -# Whenever reviews are submitted or dismissed, the number of Core Developer -# approvals may obviously change. -# -# --- -# -# Unfortunately, having two different event triggers means that can't let -# this workflow fail on its own, as GitHub actions registers a separate check -# run result per event trigger. As both triggers need to share the success/fail -# state, we get around that by registering a custom "status". -on: - pull_request_review: - types: - - submitted - - dismissed - pull_request_target: - - -jobs: - review-check: - name: Check Core Dev Reviews - runs-on: ubuntu-latest - - steps: - # Fetch the latest Opinionated reviews from users with write - # access. We can't narrow it down using a specific team here - # yet, so we'll do that later. - - uses: octokit/graphql-action@v2.x - id: reviews - with: - query: | - query ($repository: String!, $pr: Int!) { - repository(owner: "python-discord", name: $repository) { - pullRequest(number: $pr) { - latestOpinionatedReviews(last: 100, writersOnly: true) { - nodes{ - author{ - login - } - state - } - } - } - } - } - repository: ${{ github.event.repository.name }} - pr: ${{ github.event.pull_request.number }} - env: - GITHUB_TOKEN: ${{ secrets.REPO_TOKEN }} - - # Fetch the members of the Core Developers team so we can - # check if any of them actually approved this PR. - - uses: octokit/graphql-action@v2.x - id: core_developers - with: - query: | - query { - organization(login: "python-discord") { - team(slug: "core-developers") { - members(first: 100) { - nodes { - login - } - } - } - } - } - env: - GITHUB_TOKEN: ${{ secrets.TEAM_TOKEN }} - - # I've opted for a Python script, as that's what most of us - # are familiar with. We do need to setup Python for that. - - name: Setup python - id: python - uses: actions/setup-python@v2 - with: - python-version: '3.9' - - # This is a small, inline Python script that looks for the - # intersection between approving reviewers and the core dev - # team. If that intersection exists, we have at least one - # approving Core Developer. - # - # I've opted to keep this inline as it's relatively small - # and this workflow will be added to multiple repositories. - - name: Check for Accepting Core Developers - id: core_dev_reviews - run: | - python -c 'import json - reviews = json.loads("""${{ steps.reviews.outputs.data }}""") - reviewers = { - review["author"]["login"] - for review in reviews["repository"]["pullRequest"]["latestOpinionatedReviews"]["nodes"] - if review["state"] == "APPROVED" - } - core_devs = json.loads("""${{ steps.core_developers.outputs.data }}""") - core_devs = { - member["login"] for member in core_devs["organization"]["team"]["members"]["nodes"] - } - approving_core_devs = reviewers & core_devs - approval_check = "success" if approving_core_devs else "failure" - print(f"::set-output name=approval_check::{approval_check}") - ' - - # This step registers a a new status for the head commit of the pull - # request. If a status with the same context and description already - # exists, it will be overwritten. The reason we have to do this is - # because workflows run for the separate `pull_request_target` and - #`pull_request_review` events need to share a single result state. - - name: Add Core Dev Approval status check - uses: octokit/request-action@v2.x - with: - route: POST /repos/:repository/statuses/:sha - repository: ${{ github.repository }} - sha: ${{ github.event.pull_request.head.sha }} - state: ${{ steps.core_dev_reviews.outputs.approval_check }} - description: At least one core developer needs to approve this PR - context: Core Dev Approval - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - # If we have at least one Core Developer approval, this step - # removes the 'waiting for core dev approval' label if it's - # still present for the PR. - - name: Remove "waiting for core dev approval" if a core dev approved this PR - if: >- - steps.core_dev_reviews.outputs.approval_check == 'success' && - contains(github.event.pull_request.labels.*.name, 'waiting for core dev approval') - uses: octokit/request-action@v2.x - with: - route: DELETE /repos/:repository/issues/:number/labels/:label - repository: ${{ github.repository }} - number: ${{ github.event.pull_request.number }} - label: needs core dev approval - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - # If we have do not have one Core Developer approval, this step - # adds the 'waiting for core dev approval' label if it's not - # already present for the PR. - - name: Add "waiting for core dev approval" if no core dev has approved yet - if: >- - steps.core_dev_reviews.outputs.approval_check == 'failure' && - !contains(github.event.pull_request.labels.*.name, 'waiting for core dev approval') - uses: octokit/request-action@v2.x - with: - route: POST /repos/:repository/issues/:number/labels - repository: ${{ github.repository }} - number: ${{ github.event.pull_request.number }} - labels: | - - needs core dev approval - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -- cgit v1.2.3 From 186159d1af5cd87043586d4595b7647fbf1d6056 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sun, 6 Dec 2020 18:44:06 +0000 Subject: Create review-policy.yml --- .github/review-policy.yml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .github/review-policy.yml diff --git a/.github/review-policy.yml b/.github/review-policy.yml new file mode 100644 index 00000000..421b30f8 --- /dev/null +++ b/.github/review-policy.yml @@ -0,0 +1,3 @@ +remote: python-discord/.github +path: review-policies/core-developers.yml +ref: main -- cgit v1.2.3 From cd20acfee1e36351e561ba7c410dc8fac7725572 Mon Sep 17 00:00:00 2001 From: Rohan Date: Mon, 7 Dec 2020 01:16:29 +0530 Subject: Modify snakes_cog error handler. local error handler no longer checks for BadArgument error and the attribute handled will be set to True on the error if an OSError occurs. --- bot/exts/evergreen/snakes/_snakes_cog.py | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/bot/exts/evergreen/snakes/_snakes_cog.py b/bot/exts/evergreen/snakes/_snakes_cog.py index 70bb0e73..2e88c146 100644 --- a/bot/exts/evergreen/snakes/_snakes_cog.py +++ b/bot/exts/evergreen/snakes/_snakes_cog.py @@ -15,7 +15,7 @@ import aiohttp import async_timeout from PIL import Image, ImageDraw, ImageFont from discord import Colour, Embed, File, Member, Message, Reaction -from discord.ext.commands import BadArgument, Bot, Cog, CommandError, Context, bot_has_permissions, group +from discord.ext.commands import Bot, Cog, CommandError, Context, bot_has_permissions, group from bot.constants import ERROR_REPLIES, Tokens from bot.exts.evergreen.snakes import _utils as utils @@ -1131,21 +1131,11 @@ class Snakes(Cog): @video_command.error async def command_error(self, ctx: Context, error: CommandError) -> None: """Local error handler for the Snake Cog.""" - embed = Embed() - embed.colour = Colour.red() - - if isinstance(error, BadArgument): - embed.description = str(error) - embed.title = random.choice(ERROR_REPLIES) - - elif isinstance(error, OSError): + if isinstance(error, OSError): + error.handled = True + embed = Embed() + embed.colour = Colour.red() log.error(f"snake_card encountered an OSError: {error} ({error.original})") embed.description = "Could not generate the snake card! Please try again." embed.title = random.choice(ERROR_REPLIES) - - else: - log.error(f"Unhandled tag command error: {error} ({error.original})") - return - - await ctx.send(embed=embed) - # endregion + await ctx.send(embed=embed) -- cgit v1.2.3 From 7cd3e74d46927fb667cb1e8c336be960e86647a1 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Wed, 9 Dec 2020 23:06:30 +0100 Subject: Use workflow_run to send status embed to Discord I've changed the way we send status embeds to make it work for PRs made from forks without potentially exposing secrets. Instead of using the initial workflows to send the embed, I've created a `workflow_run` workflow that always runs in the context of the base repository. And added benefit is that we don't have to add the status embed step to two separate workflows. --- .github/workflows/build.yaml | 31 ---------------- .github/workflows/lint.yaml | 32 ----------------- .github/workflows/status_embed.yaml | 71 +++++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 63 deletions(-) create mode 100644 .github/workflows/status_embed.yaml diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 64c272cf..b0c03139 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -75,34 +75,3 @@ jobs: kubernetes/sir-lancebot/deployment.yaml images: 'ghcr.io/python-discord/sir-lancebot:${{ steps.sha_tag.outputs.tag }}' kubectl-version: 'latest' - - # Send an informational status embed to Discord instead of the - # standard embeds that Discord sends. This embed will contain - # more information and we can fine tune when we actually want - # to send an embed. - - name: GitHub Actions Status Embed for Discord - # This is the last step in the lint-build sequence, so always send - # an embed, regardless of success, failure or cancelled status. - if: always() - uses: SebastiaanZ/github-status-embed-for-discord@v0.1.1 - with: - # Our GitHub Actions webhook - webhook_id: '784184528997842985' - webhook_token: ${{ secrets.GHA_WEBHOOK_TOKEN }} - - # Workflow information - workflow_name: ${{ github.workflow }} - run_id: ${{ github.run_id }} - run_number: ${{ github.run_number }} - status: ${{ job.status }} - actor: ${{ github.actor }} - repository: ${{ github.repository }} - ref: ${{ github.ref }} - sha: ${{ github.sha }} - - # Optional PR-information. These values will be "null" if - # the event trigger was not PR-related. - pr_author_login: ${{ github.event.pull_request.user.login }} - pr_number: ${{ github.event.pull_request.number }} - pr_title: ${{ github.event.pull_request.title }} - pr_source: ${{ github.event.pull_request.head.label }} diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 8dd93773..063f406c 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -91,35 +91,3 @@ jobs: - name: Run flake8 run: "flake8 \ --format='::error file=%(path)s,line=%(row)d,col=%(col)d::[flake8] %(code)s: %(text)s'" - - # Send an informational status embed to Discord instead of the - # standard embeds that Discord sends. This embed will contain - # more information and we can fine tune when we actually want - # to send an embed. - - name: GitHub Actions Status Embed for Discord - # For a `pull_request` we always want to send a status embed - # here. For a push event, only when this workflow is the last - # in lint->build sequence because it failed or was cancelled. - if: github.event_name == 'pull_request' || cancelled() || failure() - uses: SebastiaanZ/github-status-embed-for-discord@v0.1.1 - with: - # Our GitHub Actions webhook - webhook_id: '784184528997842985' - webhook_token: ${{ secrets.GHA_WEBHOOK_TOKEN }} - - # Workflow information - workflow_name: ${{ github.workflow }} - run_id: ${{ github.run_id }} - run_number: ${{ github.run_number }} - status: ${{ job.status }} - actor: ${{ github.actor }} - repository: ${{ github.repository }} - ref: ${{ github.ref }} - sha: ${{ github.sha }} - - # Optional PR-information. These values will be "null" if - # the event trigger was not PR-related. - pr_author_login: ${{ github.event.pull_request.user.login }} - pr_number: ${{ github.event.pull_request.number }} - pr_title: ${{ github.event.pull_request.title }} - pr_source: ${{ github.event.pull_request.head.label }} diff --git a/.github/workflows/status_embed.yaml b/.github/workflows/status_embed.yaml new file mode 100644 index 00000000..1d175fb9 --- /dev/null +++ b/.github/workflows/status_embed.yaml @@ -0,0 +1,71 @@ +name: Status Embed + +on: + workflow_run: + workflows: + - Lint + - Build + types: + - completed + +jobs: + status_embed: + # We send the embed in the following situations: + # - Always after the `Build` workflow, as it runs at the + # end of our workflow sequence regardless of status. + # - Always for the `pull_request` event, as it only + # runs one workflow. + # - Always run for non-success workflows, as they + # terminate the workflow sequence. + if: >- + github.event.workflow_run.name == 'Build' || + github.event.workflow_run.event == 'pull_request' || + github.event.workflow_run.conclusion != 'success' + name: Send Status Embed to Discord + runs-on: ubuntu-latest + + steps: + # Unfortunately, not all the pull request information we + # need is available in the workflow_run payload. We need + # to fetch it from the API. + - name: Get Pull Request Information + if: github.event.workflow_run.event == 'pull_request' + uses: octokit/request-action@v2.0.2 + id: pull_request + with: + route: GET /repos/{owner}/{repo}/pulls + owner: ${{ github.event.repository.owner.login }} + repo: ${{ github.event.repository.name }} + state: open + head: ${{format( + '{0}:{1}', + github.event.workflow_run.head_repository.owner.login, + github.event.workflow_run.head_branch + )}} + sort: updated + direction: desc + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Send an informational status embed to Discord instead of the + # standard embeds that Discord sends. This embed will contain + # more information and we can fine tune when we actually want + # to send an embed. + - name: GitHub Actions Status Embed for Discord + uses: SebastiaanZ/github-status-embed-for-discord@v0.2.1 + with: + # Our GitHub Actions webhook + webhook_id: '784184528997842985' + webhook_token: ${{ secrets.GHA_WEBHOOK_TOKEN }} + + # Workflow information + workflow_name: ${{ github.event.workflow_run.name }} + run_id: ${{ github.event.workflow_run.id }} + run_number: ${{ github.event.workflow_run.run_number }} + status: ${{ github.event.workflow_run.conclusion }} + actor: ${{ github.actor }} + repository: ${{ github.repository }} + ref: ${{ github.ref }} + sha: ${{ github.event.workflow_run.head_sha }} + + pull_request_payload: ${{ steps.pull_request.outputs.data }} -- cgit v1.2.3 From eacaf581ea5e0953c48f14c7f9801a0d661e6c89 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Thu, 10 Dec 2020 13:39:17 +0100 Subject: Use Build Artifact to communicate PR information A workflow run with a `workflow_run` payload does not contain the necessary information to build a PR embed. As the old method of using the API to fetch the relevant information turned out to be fragile (read note 1 below) and the original Lint workflow already contains the `pull_request` payload, we now store it as a build artifact. The embed workflow then fetches the artifact and parses it to get the relevant information out of it. --- Note 1: Unfortunately, filtering Pull Requests using the "head" parameter of the ``/repos/{owner}/{repo}/pulls` endpoint does not work if the PR belongs to a fork with a different name than the base repository: the API request will just return an empty array. I've contacted GH to ask if this was intended or if it's a glitch, but, for now, it's not a route that's easily available. --- .github/workflows/lint.yaml | 22 ++++++++++++++++++++++ .github/workflows/status_embed.yaml | 37 +++++++++++++++++++------------------ 2 files changed, 41 insertions(+), 18 deletions(-) diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 063f406c..c0822e7f 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -91,3 +91,25 @@ jobs: - name: Run flake8 run: "flake8 \ --format='::error file=%(path)s,line=%(row)d,col=%(col)d::[flake8] %(code)s: %(text)s'" + + # Prepare the Pull Request Payload artifact. If this fails, we + # we fail silently using the `continue-on-error` option. It's + # nice if this succeeds, but if it fails for any reason, it + # does not mean that our lint checks failed. + - name: Prepare Pull Request Payload artifact + id: prepare-artifact + if: always() && github.event_name == 'pull_request' + continue-on-error: true + run: cat $GITHUB_EVENT_PATH | jq '.pull_request' > pull_request_payload.json + + # This only makes sense if the previous step succeeded. To + # get the original outcome of the previous step before the + # `continue-on-error` conclusion is applied, we use the + # `.outcome` value. This step also fails silently. + - name: Upload a Build Artifact + if: steps.prepare-artifact.outcome == 'success' + continue-on-error: true + uses: actions/upload-artifact@v2 + with: + name: pull-request-payload + path: pull_request_payload.json diff --git a/.github/workflows/status_embed.yaml b/.github/workflows/status_embed.yaml index 1d175fb9..c8502a19 100644 --- a/.github/workflows/status_embed.yaml +++ b/.github/workflows/status_embed.yaml @@ -25,25 +25,23 @@ jobs: runs-on: ubuntu-latest steps: - # Unfortunately, not all the pull request information we - # need is available in the workflow_run payload. We need - # to fetch it from the API. + # A workflow_run event does not contain all the information + # we need for a PR embed. That's why we upload an artifact + # with that information in the Lint workflow. - name: Get Pull Request Information + id: pr_info if: github.event.workflow_run.event == 'pull_request' - uses: octokit/request-action@v2.0.2 - id: pull_request - with: - route: GET /repos/{owner}/{repo}/pulls - owner: ${{ github.event.repository.owner.login }} - repo: ${{ github.event.repository.name }} - state: open - head: ${{format( - '{0}:{1}', - github.event.workflow_run.head_repository.owner.login, - github.event.workflow_run.head_branch - )}} - sort: updated - direction: desc + run: | + curl -s -H "Authorization: token $GITHUB_TOKEN" ${{ github.event.workflow_run.artifacts_url }} > artifacts.json + DOWNLOAD_URL=$(cat artifacts.json | jq -r '.artifacts[] | select(.name == "pull-request-payload") | .archive_download_url') + [ -z "$DOWNLOAD_URL" ] && exit 1 + wget --quiet --header="Authorization: token $GITHUB_TOKEN" -O pull_request_payload.zip $DOWNLOAD_URL || exit 2 + unzip -p pull_request_payload.zip > pull_request_payload.json + [ -s pull_request_payload.json ] || exit 3 + echo "::set-output name=pr_author_login::$(jq -r '.user.login // empty' pull_request_payload.json)" + echo "::set-output name=pr_number::$(jq -r '.number // empty' pull_request_payload.json)" + echo "::set-output name=pr_title::$(jq -r '.title // empty' pull_request_payload.json)" + echo "::set-output name=pr_source::$(jq -r '.head.label // empty' pull_request_payload.json)" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -68,4 +66,7 @@ jobs: ref: ${{ github.ref }} sha: ${{ github.event.workflow_run.head_sha }} - pull_request_payload: ${{ steps.pull_request.outputs.data }} + pr_author_login: ${{ steps.pr_info.outputs.pr_author_login }} + pr_number: ${{ steps.pr_info.outputs.pr_number }} + pr_title: ${{ steps.pr_info.outputs.pr_title }} + pr_source: ${{ steps.pr_info.outputs.pr_source }} -- cgit v1.2.3 From b92e802d2c1768ecfb3d8c9dab49036d9a9fe528 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Thu, 10 Dec 2020 14:59:10 +0100 Subject: Skip status embed for skipped Build workflow With the new `workflow_run` setup, we need to make sure that we don't try to send an embed for a skipped workflow. This would be noisy and does not add a lot of useful information. --- .github/workflows/status_embed.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/status_embed.yaml b/.github/workflows/status_embed.yaml index c8502a19..28caa8c2 100644 --- a/.github/workflows/status_embed.yaml +++ b/.github/workflows/status_embed.yaml @@ -18,9 +18,10 @@ jobs: # - Always run for non-success workflows, as they # terminate the workflow sequence. if: >- - github.event.workflow_run.name == 'Build' || + (github.event.workflow_run.name == 'Build' && github.event.workflow_run.conclusion != 'skipped') || github.event.workflow_run.event == 'pull_request' || - github.event.workflow_run.conclusion != 'success' + github.event.workflow_run.conclusion == 'failure' || + github.event.workflow_run.conclusion == 'cancelled' name: Send Status Embed to Discord runs-on: ubuntu-latest -- cgit v1.2.3 From 40fce53d6c01f0e753d70cb4b265d88f1107e104 Mon Sep 17 00:00:00 2001 From: Rohan Date: Wed, 9 Dec 2020 23:13:39 +0530 Subject: Check if error.original is an instance of OSError. Also, remove error handler for get_command and video_command. --- bot/exts/evergreen/snakes/_snakes_cog.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/bot/exts/evergreen/snakes/_snakes_cog.py b/bot/exts/evergreen/snakes/_snakes_cog.py index 2e88c146..4fa4dcd1 100644 --- a/bot/exts/evergreen/snakes/_snakes_cog.py +++ b/bot/exts/evergreen/snakes/_snakes_cog.py @@ -1126,16 +1126,15 @@ class Snakes(Cog): # endregion # region: Error handlers - @get_command.error @card_command.error - @video_command.error async def command_error(self, ctx: Context, error: CommandError) -> None: """Local error handler for the Snake Cog.""" - if isinstance(error, OSError): + original_error = getattr(error, "original", None) + if isinstance(original_error, OSError): error.handled = True embed = Embed() embed.colour = Colour.red() - log.error(f"snake_card encountered an OSError: {error} ({error.original})") + log.error(f"snake_card encountered an OSError: {error} ({original_error})") embed.description = "Could not generate the snake card! Please try again." embed.title = random.choice(ERROR_REPLIES) await ctx.send(embed=embed) -- cgit v1.2.3 From b691aedb81a7cc35067f4ddfda0127d7ca86af6a Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Fri, 11 Dec 2020 10:21:44 +0100 Subject: Make sure PR artifacts are always uploaded We need to upload PR artifacts regardless of our lint job failing. As GitHub Actions uses an implicit "success" status function if you don't specify one of your own, we need to include the "always" function in our condition. --- .github/workflows/lint.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index c0822e7f..a5f45255 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -107,7 +107,7 @@ jobs: # `continue-on-error` conclusion is applied, we use the # `.outcome` value. This step also fails silently. - name: Upload a Build Artifact - if: steps.prepare-artifact.outcome == 'success' + if: always() && steps.prepare-artifact.outcome == 'success' continue-on-error: true uses: actions/upload-artifact@v2 with: -- cgit v1.2.3 From d884c21e8ae2bd82370b11621230d7172cb098a3 Mon Sep 17 00:00:00 2001 From: janine9vn Date: Wed, 2 Dec 2020 09:28:32 -0500 Subject: Disallow .aoc commands in primary aoc channel Commands like `.aoc leaderboard` and `.aoc stats` proved to be spammy in the main advent of code channel. An aoc_commands channel has been added for aoc commands and this update prohibits aoc commands from being used in the primary aoc channel and adds the comands channel to the whitelist. This also specifically allows the less spammier commands: join, subscribe, unsubscribe, and countdown in the primary channel to foster discussion though. --- bot/constants.py | 1 + bot/exts/christmas/advent_of_code/_cog.py | 10 +++++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 9e6db7a6..5dc42462 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -95,6 +95,7 @@ class Branding: class Channels(NamedTuple): admins = 365960823622991872 advent_of_code = int(environ.get("AOC_CHANNEL_ID", 782715290437943306)) + advent_of_code_commands = int(environ.get("AOC_COMMANDS_CHANNEL_ID", 783503267849437205)) announcements = int(environ.get("CHANNEL_ANNOUNCEMENTS", 354619224620138496)) big_brother_logs = 468507907357409333 bot = 267659945086812160 diff --git a/bot/exts/christmas/advent_of_code/_cog.py b/bot/exts/christmas/advent_of_code/_cog.py index 0bcd9f42..fad13f23 100644 --- a/bot/exts/christmas/advent_of_code/_cog.py +++ b/bot/exts/christmas/advent_of_code/_cog.py @@ -21,7 +21,7 @@ AOC_REQUEST_HEADER = {"user-agent": "PythonDiscord AoC Event Bot"} COUNTDOWN_STEP = 60 * 5 -AOC_WHITELIST = WHITELISTED_CHANNELS + (Channels.advent_of_code,) +AOC_WHITELIST = WHITELISTED_CHANNELS + (Channels.advent_of_code_commands,) async def countdown_status(bot: commands.Bot) -> None: @@ -139,7 +139,7 @@ class AdventOfCode(commands.Cog): aliases=("sub", "notifications", "notify", "notifs"), brief="Notifications for new days" ) - @override_in_channel(AOC_WHITELIST) + @override_in_channel(AOC_WHITELIST + (Channels.advent_of_code)) async def aoc_subscribe(self, ctx: commands.Context) -> None: """Assign the role for notifications about new days being ready.""" current_year = datetime.now().year @@ -160,7 +160,7 @@ class AdventOfCode(commands.Cog): @in_month(Month.DECEMBER) @adventofcode_group.command(name="unsubscribe", aliases=("unsub",), brief="Notifications for new days") - @override_in_channel(AOC_WHITELIST) + @override_in_channel(AOC_WHITELIST + Channels.advent_of_code) async def aoc_unsubscribe(self, ctx: commands.Context) -> None: """Remove the role for notifications about new days being ready.""" role = ctx.guild.get_role(AocConfig.role_id) @@ -172,7 +172,7 @@ class AdventOfCode(commands.Cog): await ctx.send("Hey, you don't even get any notifications about new Advent of Code tasks currently anyway.") @adventofcode_group.command(name="countdown", aliases=("count", "c"), brief="Return time left until next day") - @override_in_channel(AOC_WHITELIST) + @override_in_channel(AOC_WHITELIST + (Channels.advent_of_code)) async def aoc_countdown(self, ctx: commands.Context) -> None: """Return time left until next day.""" if not _helpers.is_in_advent(): @@ -207,7 +207,7 @@ class AdventOfCode(commands.Cog): await ctx.send("", embed=self.cached_about_aoc) @adventofcode_group.command(name="join", aliases=("j",), brief="Learn how to join the leaderboard (via DM)") - @override_in_channel(AOC_WHITELIST) + @override_in_channel(AOC_WHITELIST + (Channels.advent_of_code)) async def join_leaderboard(self, ctx: commands.Context) -> None: """DM the user the information for joining the Python Discord leaderboard.""" current_year = datetime.now().year -- cgit v1.2.3 From d222ee24ec4d90c4eacbe50348fb1e9b6f1a0fed Mon Sep 17 00:00:00 2001 From: janine9vn Date: Wed, 2 Dec 2020 11:15:03 -0500 Subject: Add cog-level error handler for Incorrect Channel If any of the "spammier" commands (stats, leaderboard) are used within the primary advent of code channel, rather than a non-specific embed we instead reply with the channel they should be using. This also adds a "AOC_WHITELIST_PLUS" constant that makes it easier to adjust what channels the non-spammier aoc commands can be used in. --- bot/exts/christmas/advent_of_code/_cog.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/bot/exts/christmas/advent_of_code/_cog.py b/bot/exts/christmas/advent_of_code/_cog.py index fad13f23..30030e70 100644 --- a/bot/exts/christmas/advent_of_code/_cog.py +++ b/bot/exts/christmas/advent_of_code/_cog.py @@ -13,7 +13,7 @@ from bot.constants import ( AdventOfCode as AocConfig, Channels, Colours, Emojis, Month, Roles, WHITELISTED_CHANNELS, ) from bot.exts.christmas.advent_of_code import _helpers -from bot.utils.decorators import in_month, override_in_channel, with_role +from bot.utils.decorators import InChannelCheckFailure, in_month, override_in_channel, with_role log = logging.getLogger(__name__) @@ -23,6 +23,10 @@ COUNTDOWN_STEP = 60 * 5 AOC_WHITELIST = WHITELISTED_CHANNELS + (Channels.advent_of_code_commands,) +# Some commands can be run in the regular advent of code channel +# They aren't spammy and foster discussion +AOC_WHITELIST_PLUS = AOC_WHITELIST + (Channels.advent_of_code,) + async def countdown_status(bot: commands.Bot) -> None: """Set the playing status of the bot to the minutes & hours left until the next day's challenge.""" @@ -128,7 +132,7 @@ class AdventOfCode(commands.Cog): self.status_task = self.bot.loop.create_task(status_coro) @commands.group(name="adventofcode", aliases=("aoc",)) - @override_in_channel(AOC_WHITELIST) + @override_in_channel(AOC_WHITELIST_PLUS) async def adventofcode_group(self, ctx: commands.Context) -> None: """All of the Advent of Code commands.""" if not ctx.invoked_subcommand: @@ -139,7 +143,7 @@ class AdventOfCode(commands.Cog): aliases=("sub", "notifications", "notify", "notifs"), brief="Notifications for new days" ) - @override_in_channel(AOC_WHITELIST + (Channels.advent_of_code)) + @override_in_channel(AOC_WHITELIST_PLUS) async def aoc_subscribe(self, ctx: commands.Context) -> None: """Assign the role for notifications about new days being ready.""" current_year = datetime.now().year @@ -160,7 +164,7 @@ class AdventOfCode(commands.Cog): @in_month(Month.DECEMBER) @adventofcode_group.command(name="unsubscribe", aliases=("unsub",), brief="Notifications for new days") - @override_in_channel(AOC_WHITELIST + Channels.advent_of_code) + @override_in_channel(AOC_WHITELIST_PLUS) async def aoc_unsubscribe(self, ctx: commands.Context) -> None: """Remove the role for notifications about new days being ready.""" role = ctx.guild.get_role(AocConfig.role_id) @@ -172,7 +176,7 @@ class AdventOfCode(commands.Cog): await ctx.send("Hey, you don't even get any notifications about new Advent of Code tasks currently anyway.") @adventofcode_group.command(name="countdown", aliases=("count", "c"), brief="Return time left until next day") - @override_in_channel(AOC_WHITELIST + (Channels.advent_of_code)) + @override_in_channel(AOC_WHITELIST_PLUS) async def aoc_countdown(self, ctx: commands.Context) -> None: """Return time left until next day.""" if not _helpers.is_in_advent(): @@ -207,7 +211,7 @@ class AdventOfCode(commands.Cog): await ctx.send("", embed=self.cached_about_aoc) @adventofcode_group.command(name="join", aliases=("j",), brief="Learn how to join the leaderboard (via DM)") - @override_in_channel(AOC_WHITELIST + (Channels.advent_of_code)) + @override_in_channel(AOC_WHITELIST_PLUS) async def join_leaderboard(self, ctx: commands.Context) -> None: """DM the user the information for joining the Python Discord leaderboard.""" current_year = datetime.now().year @@ -366,3 +370,11 @@ class AdventOfCode(commands.Cog): about_embed.set_footer(text="Last Updated") return about_embed + + async def cog_command_error(self, ctx: commands.Context, error: Exception) -> None: + """Custom error handler if an advent of code command was posted in the wrong channel.""" + if isinstance(error, InChannelCheckFailure): + await ctx.send(f":x: Please use <#{Channels.advent_of_code_commands}> for aoc commands instead, please.") + ctx.command.on_error = True + else: + raise error -- cgit v1.2.3 From 15a511d5895c5c1f9ec68b968fa4e0ff55a416a3 Mon Sep 17 00:00:00 2001 From: janine9vn Date: Wed, 2 Dec 2020 11:21:27 -0500 Subject: Change custom error handler to match new style I'm a bit ahead of the game and changing the error handler to match the new style that Iceman will PR shortly. --- bot/exts/christmas/advent_of_code/_cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/christmas/advent_of_code/_cog.py b/bot/exts/christmas/advent_of_code/_cog.py index 30030e70..0ad718b9 100644 --- a/bot/exts/christmas/advent_of_code/_cog.py +++ b/bot/exts/christmas/advent_of_code/_cog.py @@ -375,6 +375,6 @@ class AdventOfCode(commands.Cog): """Custom error handler if an advent of code command was posted in the wrong channel.""" if isinstance(error, InChannelCheckFailure): await ctx.send(f":x: Please use <#{Channels.advent_of_code_commands}> for aoc commands instead, please.") - ctx.command.on_error = True + error.handled = True else: raise error -- cgit v1.2.3 From b6fa6385c82e216d9ef5d2cefe1dd1efc470008f Mon Sep 17 00:00:00 2001 From: janine9vn Date: Fri, 11 Dec 2020 15:03:57 -0500 Subject: Remove re-raising the error Per Mark's comment, re-raising the error isn't necessary. --- bot/exts/christmas/advent_of_code/_cog.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/bot/exts/christmas/advent_of_code/_cog.py b/bot/exts/christmas/advent_of_code/_cog.py index 0ad718b9..b6462ab2 100644 --- a/bot/exts/christmas/advent_of_code/_cog.py +++ b/bot/exts/christmas/advent_of_code/_cog.py @@ -376,5 +376,3 @@ class AdventOfCode(commands.Cog): if isinstance(error, InChannelCheckFailure): await ctx.send(f":x: Please use <#{Channels.advent_of_code_commands}> for aoc commands instead, please.") error.handled = True - else: - raise error -- cgit v1.2.3 From f493699a4a076bebcd69404be57c3a32da5a22a1 Mon Sep 17 00:00:00 2001 From: janine9vn Date: Fri, 11 Dec 2020 17:11:41 -0500 Subject: Remove extra please Please -= 1 --- bot/exts/christmas/advent_of_code/_cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/christmas/advent_of_code/_cog.py b/bot/exts/christmas/advent_of_code/_cog.py index b6462ab2..90d92fc1 100644 --- a/bot/exts/christmas/advent_of_code/_cog.py +++ b/bot/exts/christmas/advent_of_code/_cog.py @@ -374,5 +374,5 @@ class AdventOfCode(commands.Cog): async def cog_command_error(self, ctx: commands.Context, error: Exception) -> None: """Custom error handler if an advent of code command was posted in the wrong channel.""" if isinstance(error, InChannelCheckFailure): - await ctx.send(f":x: Please use <#{Channels.advent_of_code_commands}> for aoc commands instead, please.") + await ctx.send(f":x: Please use <#{Channels.advent_of_code_commands}> for aoc commands instead.") error.handled = True -- cgit v1.2.3 From 0ca1c0b55ea5d07694dc53d4f4856cbcddb35a8f Mon Sep 17 00:00:00 2001 From: janine9vn Date: Fri, 11 Dec 2020 19:30:23 -0500 Subject: Change Default AoC Commands Channel Changes the default value of the advent_of_code_commands constant to be the same channel ID as sir-lancebot-commands. If no AoC commands channel is set in the .env file, it'll re-direct people to sir-lancebot-commands instead. --- bot/constants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/constants.py b/bot/constants.py index 5dc42462..a58801f7 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -95,7 +95,7 @@ class Branding: class Channels(NamedTuple): admins = 365960823622991872 advent_of_code = int(environ.get("AOC_CHANNEL_ID", 782715290437943306)) - advent_of_code_commands = int(environ.get("AOC_COMMANDS_CHANNEL_ID", 783503267849437205)) + advent_of_code_commands = int(environ.get("AOC_COMMANDS_CHANNEL_ID", 607247579608121354)) announcements = int(environ.get("CHANNEL_ANNOUNCEMENTS", 354619224620138496)) big_brother_logs = 468507907357409333 bot = 267659945086812160 -- cgit v1.2.3 From 8f119f66ec52b1ad55f2078289d35632d0c8127a Mon Sep 17 00:00:00 2001 From: janine9vn Date: Fri, 11 Dec 2020 19:43:43 -0500 Subject: Change AOC_WHITELIST names for clarity AOC_WHITELIST was changed to AOC_WHITELIST_RESTRICTED because it is clearer that commands with this parameter in the `@override_in_channel()` decorator will be restricted to the aoc commands channel and not be allowed in the main aoc channel. In the same vein, AOC_WHITELIST_PLUS was changed to AOC_WHITELIST. --- bot/exts/christmas/advent_of_code/_cog.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/bot/exts/christmas/advent_of_code/_cog.py b/bot/exts/christmas/advent_of_code/_cog.py index 90d92fc1..0968dd26 100644 --- a/bot/exts/christmas/advent_of_code/_cog.py +++ b/bot/exts/christmas/advent_of_code/_cog.py @@ -21,11 +21,11 @@ AOC_REQUEST_HEADER = {"user-agent": "PythonDiscord AoC Event Bot"} COUNTDOWN_STEP = 60 * 5 -AOC_WHITELIST = WHITELISTED_CHANNELS + (Channels.advent_of_code_commands,) +AOC_WHITELIST_RESTRICTED = WHITELISTED_CHANNELS + (Channels.advent_of_code_commands,) # Some commands can be run in the regular advent of code channel # They aren't spammy and foster discussion -AOC_WHITELIST_PLUS = AOC_WHITELIST + (Channels.advent_of_code,) +AOC_WHITELIST = AOC_WHITELIST_RESTRICTED + (Channels.advent_of_code,) async def countdown_status(bot: commands.Bot) -> None: @@ -132,7 +132,7 @@ class AdventOfCode(commands.Cog): self.status_task = self.bot.loop.create_task(status_coro) @commands.group(name="adventofcode", aliases=("aoc",)) - @override_in_channel(AOC_WHITELIST_PLUS) + @override_in_channel(AOC_WHITELIST) async def adventofcode_group(self, ctx: commands.Context) -> None: """All of the Advent of Code commands.""" if not ctx.invoked_subcommand: @@ -143,7 +143,7 @@ class AdventOfCode(commands.Cog): aliases=("sub", "notifications", "notify", "notifs"), brief="Notifications for new days" ) - @override_in_channel(AOC_WHITELIST_PLUS) + @override_in_channel(AOC_WHITELIST) async def aoc_subscribe(self, ctx: commands.Context) -> None: """Assign the role for notifications about new days being ready.""" current_year = datetime.now().year @@ -164,7 +164,7 @@ class AdventOfCode(commands.Cog): @in_month(Month.DECEMBER) @adventofcode_group.command(name="unsubscribe", aliases=("unsub",), brief="Notifications for new days") - @override_in_channel(AOC_WHITELIST_PLUS) + @override_in_channel(AOC_WHITELIST) async def aoc_unsubscribe(self, ctx: commands.Context) -> None: """Remove the role for notifications about new days being ready.""" role = ctx.guild.get_role(AocConfig.role_id) @@ -176,7 +176,7 @@ class AdventOfCode(commands.Cog): await ctx.send("Hey, you don't even get any notifications about new Advent of Code tasks currently anyway.") @adventofcode_group.command(name="countdown", aliases=("count", "c"), brief="Return time left until next day") - @override_in_channel(AOC_WHITELIST_PLUS) + @override_in_channel(AOC_WHITELIST) async def aoc_countdown(self, ctx: commands.Context) -> None: """Return time left until next day.""" if not _helpers.is_in_advent(): @@ -211,7 +211,7 @@ class AdventOfCode(commands.Cog): await ctx.send("", embed=self.cached_about_aoc) @adventofcode_group.command(name="join", aliases=("j",), brief="Learn how to join the leaderboard (via DM)") - @override_in_channel(AOC_WHITELIST_PLUS) + @override_in_channel(AOC_WHITELIST) async def join_leaderboard(self, ctx: commands.Context) -> None: """DM the user the information for joining the Python Discord leaderboard.""" current_year = datetime.now().year @@ -260,7 +260,7 @@ class AdventOfCode(commands.Cog): aliases=("board", "lb"), brief="Get a snapshot of the PyDis private AoC leaderboard", ) - @override_in_channel(AOC_WHITELIST) + @override_in_channel(AOC_WHITELIST_RESTRICTED) async def aoc_leaderboard(self, ctx: commands.Context) -> None: """Get the current top scorers of the Python Discord Leaderboard.""" async with ctx.typing(): @@ -285,7 +285,7 @@ class AdventOfCode(commands.Cog): aliases=("globalboard", "gb"), brief="Get a link to the global leaderboard", ) - @override_in_channel(AOC_WHITELIST) + @override_in_channel(AOC_WHITELIST_RESTRICTED) async def aoc_global_leaderboard(self, ctx: commands.Context) -> None: """Get a link to the global Advent of Code leaderboard.""" url = self.global_leaderboard_url @@ -301,7 +301,7 @@ class AdventOfCode(commands.Cog): aliases=("dailystats", "ds"), brief="Get daily statistics for the Python Discord leaderboard" ) - @override_in_channel(AOC_WHITELIST) + @override_in_channel(AOC_WHITELIST_RESTRICTED) async def private_leaderboard_daily_stats(self, ctx: commands.Context) -> None: """Send an embed with daily completion statistics for the Python Discord leaderboard.""" try: -- cgit v1.2.3 From 0a91d49969600fa2d5f5b9e429ff693f3b94da72 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Sun, 13 Dec 2020 10:40:56 +0100 Subject: Add callback to log errors in AoC background tasks Currently, our Advent of Code background tasks fail without logging errors or printing error messages. This makes it difficult to debug the errors and means that they may fail silently. While we should ideally find the root cause that hides such errors, I've added a done_callback function in the meantime to help us debug the current issues with the Advent of Code Notification Task. --- bot/exts/christmas/advent_of_code/_cog.py | 6 ++++++ bot/exts/christmas/advent_of_code/_helpers.py | 11 +++++++++++ 2 files changed, 17 insertions(+) diff --git a/bot/exts/christmas/advent_of_code/_cog.py b/bot/exts/christmas/advent_of_code/_cog.py index 0968dd26..0671203e 100644 --- a/bot/exts/christmas/advent_of_code/_cog.py +++ b/bot/exts/christmas/advent_of_code/_cog.py @@ -30,6 +30,7 @@ AOC_WHITELIST = AOC_WHITELIST_RESTRICTED + (Channels.advent_of_code,) async def countdown_status(bot: commands.Bot) -> None: """Set the playing status of the bot to the minutes & hours left until the next day's challenge.""" + log.info("Started `AoC Status Countdown` task") while _helpers.is_in_advent(): _, time_left = _helpers.time_left_to_aoc_midnight() @@ -62,6 +63,7 @@ async def day_countdown(bot: commands.Bot) -> None: Once we have calculated this we should then sleep that number and when the time is reached, ping the Advent of Code role notifying them that the new challenge is ready. """ + log.info("Started `Daily AoC Notification` task") while _helpers.is_in_advent(): tomorrow, time_left = _helpers.time_left_to_aoc_midnight() @@ -127,9 +129,13 @@ class AdventOfCode(commands.Cog): countdown_coro = day_countdown(self.bot) self.countdown_task = self.bot.loop.create_task(countdown_coro) + self.countdown_task.set_name("Daily AoC Notification") + self.countdown_task.add_done_callback(_helpers.background_task_callback) status_coro = countdown_status(self.bot) self.status_task = self.bot.loop.create_task(status_coro) + self.status_task.set_name("AoC Status Countdown") + self.status_task.add_done_callback(_helpers.background_task_callback) @commands.group(name="adventofcode", aliases=("aoc",)) @override_in_channel(AOC_WHITELIST) diff --git a/bot/exts/christmas/advent_of_code/_helpers.py b/bot/exts/christmas/advent_of_code/_helpers.py index d883c09f..da139e40 100644 --- a/bot/exts/christmas/advent_of_code/_helpers.py +++ b/bot/exts/christmas/advent_of_code/_helpers.py @@ -1,3 +1,4 @@ +import asyncio import collections import datetime import json @@ -407,3 +408,13 @@ def time_left_to_aoc_midnight() -> Tuple[datetime.datetime, datetime.timedelta]: # Calculate the timedelta between the current time and midnight return tomorrow, tomorrow - datetime.datetime.now(EST) + + +def background_task_callback(task: asyncio.Task) -> None: + """Check if the finished background task failed to make sure we log errors.""" + if task.cancelled(): + log.info(f"Background task `{task.get_name()}` was cancelled.") + elif exception := task.exception(): + log.error(f"Background task `{task.get_name()}` failed:", exc_info=exception) + else: + log.info(f"Background task `{task.get_name()}` exited normally.") -- cgit v1.2.3 From fb7838c6165b6b32f6561a871428330f9d8f0c7c Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Sun, 13 Dec 2020 11:20:13 +0100 Subject: Clarify comment on AoC Status Task startup delay The Advent of Code Status Countdown task needs to wait for two things to happen to prevent it from failing during the startup sequence: 1. The Websocket instance discord.py creates needs to be available as an attribute of the bot, otherwise discord.py fails internally: Traceback (most recent call last): File "discord/client.py", line 1049, in change_presence await self.ws.change_presence( activity=activity, status=status, afk=afk ) File "advent_of_code/_cog.py", line 52, in countdown_status await bot.change_presence(activity=discord.Game(playing)) AttributeError: 'NoneType' object has no attribute 'change_presence' 2. Allegedly, according to the discord.py community, trying to change the status too early in the sequence to establish a connection with Discord may result ub the Discord API aborting the connection. To solve this, I've added a `wait_until_guild_available` waiter, as it guarantees that the websocket is available and the connections is mature. Kaizen: I've changed the name `new_puzzle_announcement` to `new_puzzle_notification` to better reflect its function. --- bot/exts/christmas/advent_of_code/_cog.py | 2 +- bot/exts/christmas/advent_of_code/_helpers.py | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/bot/exts/christmas/advent_of_code/_cog.py b/bot/exts/christmas/advent_of_code/_cog.py index 8c07cdb4..c3b87f96 100644 --- a/bot/exts/christmas/advent_of_code/_cog.py +++ b/bot/exts/christmas/advent_of_code/_cog.py @@ -39,7 +39,7 @@ class AdventOfCode(commands.Cog): self.countdown_task = None self.status_task = None - notification_coro = _helpers.new_puzzle_announcement(self.bot) + notification_coro = _helpers.new_puzzle_notification(self.bot) self.notification_task = self.bot.loop.create_task(notification_coro) self.notification_task.set_name("Daily AoC Notification") self.notification_task.add_done_callback(_helpers.background_task_callback) diff --git a/bot/exts/christmas/advent_of_code/_helpers.py b/bot/exts/christmas/advent_of_code/_helpers.py index f8c0dc22..b7adc895 100644 --- a/bot/exts/christmas/advent_of_code/_helpers.py +++ b/bot/exts/christmas/advent_of_code/_helpers.py @@ -464,7 +464,10 @@ async def countdown_status(bot: Bot) -> None: log.info("The Advent of Code has started or will start soon, starting countdown status.") # Trying to change status too early in the bot's startup sequence will fail - # the task. Waiting until this event seems to work well. + # the task because the websocket instance has not yet been created. Waiting + # for this event means that both the websocket instance has been initialized + # and that the connection to Discord is mature enough to change the presence + # of the bot. await bot.wait_until_guild_available() # Calculate when the task needs to stop running. To prevent the task from @@ -501,7 +504,7 @@ async def countdown_status(bot: Bot) -> None: await asyncio.sleep(delay) -async def new_puzzle_announcement(bot: Bot) -> None: +async def new_puzzle_notification(bot: Bot) -> None: """ Announce the release of a new Advent of Code puzzle. -- cgit v1.2.3 From c64132452168a5d7e316f9b592aabb6d632e394e Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 16 Dec 2020 17:50:09 +0200 Subject: Add codeowner entries for ks129 --- .github/CODEOWNERS | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 6afbfb31..16e89359 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,3 +1,7 @@ +# Extensions groups +bot/exts/christmas/** @ks129 +bot/exts/halloween/** @ks129 + # CI & Docker .github/workflows/** @Akarys42 @SebastiaanZ @Den4200 Dockerfile @Akarys42 @Den4200 -- cgit v1.2.3 From 7735b439b0f4726f1c15052b78e157fa36d84cbf Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 19 Dec 2020 15:55:40 +0200 Subject: Bump Sentry SDK from 0.14 to 0.19 --- Pipfile | 2 +- Pipfile.lock | 136 ++++++++++++++++++++++++++++++----------------------------- 2 files changed, 71 insertions(+), 67 deletions(-) diff --git a/Pipfile b/Pipfile index 100d51a1..c382902f 100644 --- a/Pipfile +++ b/Pipfile @@ -10,7 +10,7 @@ beautifulsoup4 = "~=4.8" fuzzywuzzy = "~=0.17" pillow = "~=7.2" pytz = "~=2019.2" -sentry-sdk = "~=0.14.2" +sentry-sdk = "~=0.19" PyYAML = "~=5.3.1" "discord.py" = {extras = ["voice"], version = "~=1.5.1"} async-rediscache = {extras = ["fakeredis"], version = "~=0.1.4"} diff --git a/Pipfile.lock b/Pipfile.lock index 779d986c..be6f9574 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "c358b14c467cb5ac9f3827e7835ce338ec6750f708bc5a11735163cf4f095f2d" + "sha256": "9be419062bd9db364ac9dddfcd50aef9c932384b45850363e482591fe7d12403" }, "pipfile-spec": 6, "requires": { @@ -96,51 +96,51 @@ }, "certifi": { "hashes": [ - "sha256:1f422849db327d534e3d0c5f02a263458c3955ec0aae4ff09b95f195c59f4edd", - "sha256:f05def092c44fbf25834a51509ef6e631dc19765ab8a57b4e7ab85531f0a9cf4" + "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c", + "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830" ], - "version": "==2020.11.8" + "version": "==2020.12.5" }, "cffi": { "hashes": [ - "sha256:005f2bfe11b6745d726dbb07ace4d53f057de66e336ff92d61b8c7e9c8f4777d", - "sha256:09e96138280241bd355cd585148dec04dbbedb4f46128f340d696eaafc82dd7b", - "sha256:0b1ad452cc824665ddc682400b62c9e4f5b64736a2ba99110712fdee5f2505c4", - "sha256:0ef488305fdce2580c8b2708f22d7785ae222d9825d3094ab073e22e93dfe51f", - "sha256:15f351bed09897fbda218e4db5a3d5c06328862f6198d4fb385f3e14e19decb3", - "sha256:22399ff4870fb4c7ef19fff6eeb20a8bbf15571913c181c78cb361024d574579", - "sha256:23e5d2040367322824605bc29ae8ee9175200b92cb5483ac7d466927a9b3d537", - "sha256:2791f68edc5749024b4722500e86303a10d342527e1e3bcac47f35fbd25b764e", - "sha256:2f9674623ca39c9ebe38afa3da402e9326c245f0f5ceff0623dccdac15023e05", - "sha256:3363e77a6176afb8823b6e06db78c46dbc4c7813b00a41300a4873b6ba63b171", - "sha256:33c6cdc071ba5cd6d96769c8969a0531be2d08c2628a0143a10a7dcffa9719ca", - "sha256:3b8eaf915ddc0709779889c472e553f0d3e8b7bdf62dab764c8921b09bf94522", - "sha256:3cb3e1b9ec43256c4e0f8d2837267a70b0e1ca8c4f456685508ae6106b1f504c", - "sha256:3eeeb0405fd145e714f7633a5173318bd88d8bbfc3dd0a5751f8c4f70ae629bc", - "sha256:44f60519595eaca110f248e5017363d751b12782a6f2bd6a7041cba275215f5d", - "sha256:4d7c26bfc1ea9f92084a1d75e11999e97b62d63128bcc90c3624d07813c52808", - "sha256:529c4ed2e10437c205f38f3691a68be66c39197d01062618c55f74294a4a4828", - "sha256:6642f15ad963b5092d65aed022d033c77763515fdc07095208f15d3563003869", - "sha256:85ba797e1de5b48aa5a8427b6ba62cf69607c18c5d4eb747604b7302f1ec382d", - "sha256:8f0f1e499e4000c4c347a124fa6a27d37608ced4fe9f7d45070563b7c4c370c9", - "sha256:a624fae282e81ad2e4871bdb767e2c914d0539708c0f078b5b355258293c98b0", - "sha256:b0358e6fefc74a16f745afa366acc89f979040e0cbc4eec55ab26ad1f6a9bfbc", - "sha256:bbd2f4dfee1079f76943767fce837ade3087b578aeb9f69aec7857d5bf25db15", - "sha256:bf39a9e19ce7298f1bd6a9758fa99707e9e5b1ebe5e90f2c3913a47bc548747c", - "sha256:c11579638288e53fc94ad60022ff1b67865363e730ee41ad5e6f0a17188b327a", - "sha256:c150eaa3dadbb2b5339675b88d4573c1be3cb6f2c33a6c83387e10cc0bf05bd3", - "sha256:c53af463f4a40de78c58b8b2710ade243c81cbca641e34debf3396a9640d6ec1", - "sha256:cb763ceceae04803adcc4e2d80d611ef201c73da32d8f2722e9d0ab0c7f10768", - "sha256:cc75f58cdaf043fe6a7a6c04b3b5a0e694c6a9e24050967747251fb80d7bce0d", - "sha256:d80998ed59176e8cba74028762fbd9b9153b9afc71ea118e63bbf5d4d0f9552b", - "sha256:de31b5164d44ef4943db155b3e8e17929707cac1e5bd2f363e67a56e3af4af6e", - "sha256:e66399cf0fc07de4dce4f588fc25bfe84a6d1285cc544e67987d22663393926d", - "sha256:f0620511387790860b249b9241c2f13c3a80e21a73e0b861a2df24e9d6f56730", - "sha256:f4eae045e6ab2bb54ca279733fe4eb85f1effda392666308250714e01907f394", - "sha256:f92cdecb618e5fa4658aeb97d5eb3d2f47aa94ac6477c6daf0f306c5a3b9e6b1", - "sha256:f92f789e4f9241cd262ad7a555ca2c648a98178a953af117ef7fad46aa1d5591" - ], - "version": "==1.14.3" + "sha256:00a1ba5e2e95684448de9b89888ccd02c98d512064b4cb987d48f4b40aa0421e", + "sha256:00e28066507bfc3fe865a31f325c8391a1ac2916219340f87dfad602c3e48e5d", + "sha256:045d792900a75e8b1e1b0ab6787dd733a8190ffcf80e8c8ceb2fb10a29ff238a", + "sha256:0638c3ae1a0edfb77c6765d487fee624d2b1ee1bdfeffc1f0b58c64d149e7eec", + "sha256:105abaf8a6075dc96c1fe5ae7aae073f4696f2905fde6aeada4c9d2926752362", + "sha256:155136b51fd733fa94e1c2ea5211dcd4c8879869008fc811648f16541bf99668", + "sha256:1a465cbe98a7fd391d47dce4b8f7e5b921e6cd805ef421d04f5f66ba8f06086c", + "sha256:1d2c4994f515e5b485fd6d3a73d05526aa0fcf248eb135996b088d25dfa1865b", + "sha256:2c24d61263f511551f740d1a065eb0212db1dbbbbd241db758f5244281590c06", + "sha256:51a8b381b16ddd370178a65360ebe15fbc1c71cf6f584613a7ea08bfad946698", + "sha256:594234691ac0e9b770aee9fcdb8fa02c22e43e5c619456efd0d6c2bf276f3eb2", + "sha256:5cf4be6c304ad0b6602f5c4e90e2f59b47653ac1ed9c662ed379fe48a8f26b0c", + "sha256:64081b3f8f6f3c3de6191ec89d7dc6c86a8a43911f7ecb422c60e90c70be41c7", + "sha256:6bc25fc545a6b3d57b5f8618e59fc13d3a3a68431e8ca5fd4c13241cd70d0009", + "sha256:798caa2a2384b1cbe8a2a139d80734c9db54f9cc155c99d7cc92441a23871c03", + "sha256:7c6b1dece89874d9541fc974917b631406233ea0440d0bdfbb8e03bf39a49b3b", + "sha256:840793c68105fe031f34d6a086eaea153a0cd5c491cde82a74b420edd0a2b909", + "sha256:8d6603078baf4e11edc4168a514c5ce5b3ba6e3e9c374298cb88437957960a53", + "sha256:9cc46bc107224ff5b6d04369e7c595acb700c3613ad7bcf2e2012f62ece80c35", + "sha256:9f7a31251289b2ab6d4012f6e83e58bc3b96bd151f5b5262467f4bb6b34a7c26", + "sha256:9ffb888f19d54a4d4dfd4b3f29bc2c16aa4972f1c2ab9c4ab09b8ab8685b9c2b", + "sha256:a5ed8c05548b54b998b9498753fb9cadbfd92ee88e884641377d8a8b291bcc01", + "sha256:a7711edca4dcef1a75257b50a2fbfe92a65187c47dab5a0f1b9b332c5919a3fb", + "sha256:af5c59122a011049aad5dd87424b8e65a80e4a6477419c0c1015f73fb5ea0293", + "sha256:b18e0a9ef57d2b41f5c68beefa32317d286c3d6ac0484efd10d6e07491bb95dd", + "sha256:b4e248d1087abf9f4c10f3c398896c87ce82a9856494a7155823eb45a892395d", + "sha256:ba4e9e0ae13fc41c6b23299545e5ef73055213e466bd107953e4a013a5ddd7e3", + "sha256:c6332685306b6417a91b1ff9fae889b3ba65c2292d64bd9245c093b1b284809d", + "sha256:d5ff0621c88ce83a28a10d2ce719b2ee85635e85c515f12bac99a95306da4b2e", + "sha256:d9efd8b7a3ef378dd61a1e77367f1924375befc2eba06168b6ebfa903a5e59ca", + "sha256:df5169c4396adc04f9b0a05f13c074df878b6052430e03f50e68adf3a57aa28d", + "sha256:ebb253464a5d0482b191274f1c8bf00e33f7e0b9c66405fbffc61ed2c839c775", + "sha256:ec80dc47f54e6e9a78181ce05feb71a0353854cc26999db963695f950b5fb375", + "sha256:f032b34669220030f905152045dfa27741ce1a6db3324a5bc0b96b6c7420c87b", + "sha256:f60567825f791c6f8a592f3c6e3bd93dd2934e3f9dac189308426bd76b00ef3b", + "sha256:f803eaa94c2fcda012c047e62bc7a51b0bdabda1cad7a92a522694ea2d76e49f" + ], + "version": "==1.14.4" }, "chardet": { "hashes": [ @@ -162,10 +162,10 @@ }, "fakeredis": { "hashes": [ - "sha256:8070b7fce16f828beaef2c757a4354af91698685d5232404f1aeeb233529c7a5", - "sha256:f8c8ea764d7b6fd801e7f5486e3edd32ca991d506186f1923a01fc072e33c271" + "sha256:01cb47d2286825a171fb49c0e445b1fa9307087e07cbb3d027ea10dbff108b6a", + "sha256:2c6041cf0225889bc403f3949838b2c53470a95a9e2d4272422937786f5f8f73" ], - "version": "==1.4.4" + "version": "==1.4.5" }, "fuzzywuzzy": { "hashes": [ @@ -381,11 +381,13 @@ "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97", "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76", "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2", + "sha256:6034f55dab5fea9e53f436aa68fa3ace2634918e8b5994d82f3621c04ff5ed2e", "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648", "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf", "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f", "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2", "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee", + "sha256:ad9c67312c84def58f3c04504727ca879cb0013b2517c85a9a253f0cb6380c0a", "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d", "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c", "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a" @@ -403,11 +405,11 @@ }, "sentry-sdk": { "hashes": [ - "sha256:0e5e947d0f7a969314aa23669a94a9712be5a688ff069ff7b9fc36c66adc160c", - "sha256:799a8bf76b012e3030a881be00e97bc0b922ce35dde699c6537122b751d80e2c" + "sha256:0a711ec952441c2ec89b8f5d226c33bc697914f46e876b44a4edd3e7864cf4d0", + "sha256:737a094e49a529dd0fdcaafa9e97cf7c3d5eb964bd229821d640bc77f3502b3f" ], "index": "pypi", - "version": "==0.14.4" + "version": "==0.19.5" }, "six": { "hashes": [ @@ -426,11 +428,11 @@ }, "soupsieve": { "hashes": [ - "sha256:1634eea42ab371d3d346309b93df7870a88610f0725d47528be902a0d95ecc55", - "sha256:a59dc181727e95d25f781f0eb4fd1825ff45590ec8ff49eadfd7f1a537cc0232" + "sha256:4bb21a6ee4707bf43b61230e80740e71bfe56e55d1f1f50924b087bb2975c851", + "sha256:6dc52924dc0bc710a5d16794e6b3480b2c7c08b07729505feab2b2c16661ff6e" ], "markers": "python_version >= '3.0'", - "version": "==2.0.1" + "version": "==2.1" }, "urllib3": { "hashes": [ @@ -520,11 +522,11 @@ }, "flake8-bugbear": { "hashes": [ - "sha256:a3ddc03ec28ba2296fc6f89444d1c946a6b76460f859795b35b77d4920a51b63", - "sha256:bd02e4b009fb153fe6072c31c52aeab5b133d508095befb2ffcf3b41c4823162" + "sha256:528020129fea2dea33a466b9d64ab650aa3e5f9ffc788b70ea4bc6cf18283538", + "sha256:f35b8135ece7a014bc0aee5b5d485334ac30a6da48494998cc1fabf7ec70d703" ], "index": "pypi", - "version": "==20.1.4" + "version": "==20.11.1" }, "flake8-docstrings": { "hashes": [ @@ -559,11 +561,11 @@ }, "flake8-tidy-imports": { "hashes": [ - "sha256:62059ca07d8a4926b561d392cbab7f09ee042350214a25cf12823384a45d27dd", - "sha256:c30b40337a2e6802ba3bb611c26611154a27e94c53fc45639e3e282169574fd3" + "sha256:52e5f2f987d3d5597538d5941153409ebcab571635835b78f522c7bf03ca23bc", + "sha256:76e36fbbfdc8e3c5017f9a216c2855a298be85bc0631e66777f4e6a07a859dc4" ], "index": "pypi", - "version": "==4.1.0" + "version": "==4.2.1" }, "flake8-todo": { "hashes": [ @@ -574,11 +576,11 @@ }, "identify": { "hashes": [ - "sha256:5dd84ac64a9a115b8e0b27d1756b244b882ad264c3c423f42af8235a6e71ca12", - "sha256:c9504ba6a043ee2db0a9d69e43246bc138034895f6338d5aed1b41e4a73b1513" + "sha256:943cd299ac7f5715fcb3f684e2fc1594c1e0f22a90d15398e5888143bd4144b5", + "sha256:cc86e6a9a390879dcc2976cef169dd9cc48843ed70b7380f321d1b118163c60e" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.5.9" + "version": "==1.5.10" }, "mccabe": { "hashes": [ @@ -604,11 +606,11 @@ }, "pre-commit": { "hashes": [ - "sha256:22e6aa3bd571debb01eb7d34483f11c01b65237be4eebbf30c3d4fb65762d315", - "sha256:905ebc9b534b991baec87e934431f2d0606ba27f2b90f7f652985f5a5b8b6ae6" + "sha256:6c86d977d00ddc8a60d68eec19f51ef212d9462937acf3ea37c7adec32284ac0", + "sha256:ee784c11953e6d8badb97d19bc46b997a3a9eded849881ec587accd8608d74a4" ], "index": "pypi", - "version": "==2.8.2" + "version": "==2.9.3" }, "pycodestyle": { "hashes": [ @@ -639,11 +641,13 @@ "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97", "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76", "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2", + "sha256:6034f55dab5fea9e53f436aa68fa3ace2634918e8b5994d82f3621c04ff5ed2e", "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648", "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf", "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f", "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2", "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee", + "sha256:ad9c67312c84def58f3c04504727ca879cb0013b2517c85a9a253f0cb6380c0a", "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d", "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c", "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a" @@ -676,11 +680,11 @@ }, "virtualenv": { "hashes": [ - "sha256:b0011228208944ce71052987437d3843e05690b2f23d1c7da4263fde104c97a2", - "sha256:b8d6110f493af256a40d65e29846c69340a947669eec8ce784fcf3dd3af28380" + "sha256:54b05fc737ea9c9ee9f8340f579e5da5b09fb64fd010ab5757eb90268616907c", + "sha256:b7a8ec323ee02fb2312f098b6b4c9de99559b462775bc8fe3627a73706603c1b" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==20.1.0" + "version": "==20.2.2" } } } -- cgit v1.2.3 From 760e89c310d9132688d182bd3b523e79207280b4 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 19 Dec 2020 16:06:02 +0200 Subject: Add Redis and aiohttp integrations to Sentry --- bot/__main__.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/bot/__main__.py b/bot/__main__.py index cd2d43a9..df0e30eb 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -2,6 +2,8 @@ import logging import sentry_sdk from sentry_sdk.integrations.logging import LoggingIntegration +from sentry_sdk.integrations.redis import RedisIntegration +from sentry_sdk.integrations.aiohttp import AioHttpIntegration from bot.bot import bot from bot.constants import Client, STAFF_ROLES, WHITELISTED_CHANNELS @@ -16,7 +18,11 @@ sentry_logging = LoggingIntegration( sentry_sdk.init( dsn=Client.sentry_dsn, - integrations=[sentry_logging] + integrations=[ + sentry_logging, + RedisIntegration(), + AioHttpIntegration() + ] ) log = logging.getLogger(__name__) -- cgit v1.2.3 From 1a6effd84461000029fe83375bbc429af29935dc Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 19 Dec 2020 16:07:46 +0200 Subject: Create Sentry release workflow --- .github/workflows/sentry_release.yaml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .github/workflows/sentry_release.yaml diff --git a/.github/workflows/sentry_release.yaml b/.github/workflows/sentry_release.yaml new file mode 100644 index 00000000..67447f32 --- /dev/null +++ b/.github/workflows/sentry_release.yaml @@ -0,0 +1,24 @@ +name: Create Sentry release + +on: + push: + branches: + - master + +jobs: + create_sentry_release: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@master + + - name: Create a Sentry.io release + uses: tclindner/sentry-releases-action@v1.2.0 + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: python-discord + SENTRY_PROJECT: sir-lancebot + with: + tagName: ${{ github.sha }} + environment: production + releaseNamePrefix: pydis-sir-lancebot@ -- cgit v1.2.3 From 45b40587d37655eeaac9723de44fee18f0080285 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 19 Dec 2020 19:16:11 +0200 Subject: Add Git SHA build argument to Dockerfile --- Dockerfile | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 40a77414..2f124139 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,14 @@ FROM python:3.8-slim +# Set SHA build argument +ARG git_sha + # Set pip to have cleaner logs and no saved cache ENV PIP_NO_CACHE_DIR=false \ PIPENV_HIDE_EMOJIS=1 \ PIPENV_IGNORE_VIRTUALENVS=1 \ - PIPENV_NOSPIN=1 + PIPENV_NOSPIN=1 \ + GIT_SHA=$git_sha # Install git to be able to dowload git dependencies in the Pipfile RUN apt-get -y update \ -- cgit v1.2.3 From 3f821a46134bc17f4adf21ec186b31fc441beec6 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 19 Dec 2020 19:17:40 +0200 Subject: Add default development value to Git SHA --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 2f124139..328984ad 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ FROM python:3.8-slim # Set SHA build argument -ARG git_sha +ARG git_sha="development" # Set pip to have cleaner logs and no saved cache ENV PIP_NO_CACHE_DIR=false \ -- cgit v1.2.3 From 1f48dc42999d67ab084cdcd28bd26636ef04954c Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 19 Dec 2020 19:18:20 +0200 Subject: Put Git SHA to container on container build --- .github/workflows/build.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index b0c03139..da2419e4 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -61,6 +61,8 @@ jobs: tags: | ghcr.io/python-discord/sir-lancebot:latest ghcr.io/python-discord/sir-lancebot:${{ steps.sha_tag.outputs.tag }} + build-args: | + git_sha=${{ GITHUB_SHA }} - name: Authenticate with Kubernetes uses: azure/k8s-set-context@v1 -- cgit v1.2.3 From 7d3180c91d65f959a8374167edd22486c1175ca7 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 19 Dec 2020 19:18:45 +0200 Subject: Add Git SHA as constant --- bot/constants.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/constants.py b/bot/constants.py index 5e97fa2d..0325066a 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -313,6 +313,8 @@ WHITELISTED_CHANNELS = ( Channels.sprint_documentation, ) +GIT_SHA = environ.get("GIT_SHA", "foobar") + # Bot replies ERROR_REPLIES = [ "Please don't do that.", -- cgit v1.2.3 From fe3ecaefc1b7491916bc87a6e608f143afbed2f3 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 19 Dec 2020 19:19:05 +0200 Subject: Add release tag to Sentry SDK initialization --- bot/__main__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bot/__main__.py b/bot/__main__.py index df0e30eb..ee7710e7 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -1,12 +1,12 @@ import logging import sentry_sdk +from sentry_sdk.integrations.aiohttp import AioHttpIntegration from sentry_sdk.integrations.logging import LoggingIntegration from sentry_sdk.integrations.redis import RedisIntegration -from sentry_sdk.integrations.aiohttp import AioHttpIntegration from bot.bot import bot -from bot.constants import Client, STAFF_ROLES, WHITELISTED_CHANNELS +from bot.constants import Client, GIT_SHA, STAFF_ROLES, WHITELISTED_CHANNELS from bot.utils.decorators import in_channel_check from bot.utils.extensions import walk_extensions @@ -22,7 +22,8 @@ sentry_sdk.init( sentry_logging, RedisIntegration(), AioHttpIntegration() - ] + ], + release=f"pydis-sir-lancebot@{GIT_SHA}" ) log = logging.getLogger(__name__) -- cgit v1.2.3 From 826ff04368756fcf0b6d5c41bef40e8f193281bf Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 19 Dec 2020 19:26:24 +0200 Subject: Remove aiohttp integration from Sentry --- bot/__main__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bot/__main__.py b/bot/__main__.py index ee7710e7..da0ff439 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -1,7 +1,6 @@ import logging import sentry_sdk -from sentry_sdk.integrations.aiohttp import AioHttpIntegration from sentry_sdk.integrations.logging import LoggingIntegration from sentry_sdk.integrations.redis import RedisIntegration @@ -20,8 +19,7 @@ sentry_sdk.init( dsn=Client.sentry_dsn, integrations=[ sentry_logging, - RedisIntegration(), - AioHttpIntegration() + RedisIntegration() ], release=f"pydis-sir-lancebot@{GIT_SHA}" ) -- cgit v1.2.3 From 9392a2af65021db56bafa462da0b78121ca428d2 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 19 Dec 2020 19:26:59 +0200 Subject: Use sir-lancebot instead pydis-sir-lancebot for release name Co-authored-by: Joe Banks --- bot/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/__main__.py b/bot/__main__.py index da0ff439..e9b14a53 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -21,7 +21,7 @@ sentry_sdk.init( sentry_logging, RedisIntegration() ], - release=f"pydis-sir-lancebot@{GIT_SHA}" + release=f"sir-lancebot@{GIT_SHA}" ) log = logging.getLogger(__name__) -- cgit v1.2.3 From 9ec74820535f43017f952c68ce80de3aeba52dc9 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 19 Dec 2020 19:27:29 +0200 Subject: Use sir-lancebot release prefix in workflow --- .github/workflows/sentry_release.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sentry_release.yaml b/.github/workflows/sentry_release.yaml index 67447f32..0e02dd0c 100644 --- a/.github/workflows/sentry_release.yaml +++ b/.github/workflows/sentry_release.yaml @@ -21,4 +21,4 @@ jobs: with: tagName: ${{ github.sha }} environment: production - releaseNamePrefix: pydis-sir-lancebot@ + releaseNamePrefix: sir-lancebot@ -- cgit v1.2.3 From 3e1ddb2635768c2877684aef5ba88b0fc96584c0 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 20 Dec 2020 17:20:23 +0200 Subject: Fix wrong way for getting Git SHA --- .github/workflows/build.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index da2419e4..9d12cd10 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -62,7 +62,7 @@ jobs: ghcr.io/python-discord/sir-lancebot:latest ghcr.io/python-discord/sir-lancebot:${{ steps.sha_tag.outputs.tag }} build-args: | - git_sha=${{ GITHUB_SHA }} + git_sha=${{ github.sha }} - name: Authenticate with Kubernetes uses: azure/k8s-set-context@v1 -- cgit v1.2.3 From 2e4e50216508dd89314b093872f2d5477f94aa10 Mon Sep 17 00:00:00 2001 From: William Da Silva Date: Tue, 29 Dec 2020 00:24:10 -0500 Subject: Remove unused import --- bot/exts/evergreen/snakes/_snakes_cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/evergreen/snakes/_snakes_cog.py b/bot/exts/evergreen/snakes/_snakes_cog.py index f18014a8..d5e4f206 100644 --- a/bot/exts/evergreen/snakes/_snakes_cog.py +++ b/bot/exts/evergreen/snakes/_snakes_cog.py @@ -16,7 +16,7 @@ import async_timeout from PIL import Image, ImageDraw, ImageFont from discord import Colour, Embed, File, Member, Message, Reaction from discord.errors import HTTPException -from discord.ext.commands import BadArgument, Bot, Cog, CommandError, Context, bot_has_permissions, group +from discord.ext.commands import Bot, Cog, CommandError, Context, bot_has_permissions, group from bot.constants import ERROR_REPLIES, Tokens from bot.exts.evergreen.snakes import _utils as utils -- cgit v1.2.3 From cf04425c67c1389d7739f8352b4a9a1089817bd9 Mon Sep 17 00:00:00 2001 From: xithrius Date: Sat, 2 Jan 2021 02:37:05 -0800 Subject: Changed Python language hook to system. --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index be57904e..a66bf97c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,6 +20,6 @@ repos: name: Flake8 description: This hook runs flake8 within our project's pipenv environment. entry: pipenv run flake8 - language: python + language: system types: [python] require_serial: true -- cgit v1.2.3 From 030a9996664cb6a52e027406a76d35c62e30a72a Mon Sep 17 00:00:00 2001 From: Hedy Li Date: Wed, 6 Jan 2021 17:42:37 +0800 Subject: Formatting and add full stop to docstring - bot/exts/halloween/hacktoberstats.py line 130, better readability - same file line 208-209 add full stop --- bot/exts/halloween/hacktoberstats.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/bot/exts/halloween/hacktoberstats.py b/bot/exts/halloween/hacktoberstats.py index a599af73..a1c55922 100644 --- a/bot/exts/halloween/hacktoberstats.py +++ b/bot/exts/halloween/hacktoberstats.py @@ -127,9 +127,13 @@ class HacktoberStats(commands.Cog): prs = await self.get_october_prs(github_username) if prs is None: # Will be None if the user was not found - await ctx.send(embed=discord.Embed(title=random.choice(NEGATIVE_REPLIES), - description=f"GitHub user `{github_username}` was not found.", - colour=discord.Colour.red())) + await ctx.send( + embed=discord.Embed( + title=random.choice(NEGATIVE_REPLIES), + description=f"GitHub user `{github_username}` was not found.", + colour=discord.Colour.red() + ) + ) return if prs: @@ -205,8 +209,8 @@ class HacktoberStats(commands.Cog): "number": int } - Otherwise, return empty list - None will be returned when the GitHub user was not found + Otherwise, return empty list. + None will be returned when the GitHub user was not found. """ logging.info(f"Fetching Hacktoberfest Stats for GitHub user: '{github_username}'") base_url = "https://api.github.com/search/issues?q=" -- cgit v1.2.3 From 8e54fab377c7f798259bbf217afc8c3f68c9fb0f Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 8 Jan 2021 00:04:44 +0000 Subject: Get and renew V4 OAuth token --- bot/constants.py | 5 +++-- bot/exts/evergreen/game.py | 53 ++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index f6da272e..e638dfa1 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -174,7 +174,7 @@ class Emojis: christmas_tree = "\U0001F384" check = "\u2611" envelope = "\U0001F4E8" - trashcan = "<:trashcan:637136429717389331>" + trashcan = "<:trashcan:796854840293589003>" ok_hand = ":ok_hand:" dice_1 = "<:dice_1:755891608859443290>" @@ -257,7 +257,8 @@ class Tokens(NamedTuple): youtube = environ.get("YOUTUBE_API_KEY") tmdb = environ.get("TMDB_API_KEY") nasa = environ.get("NASA_API_KEY") - igdb = environ.get("IGDB_API_KEY") + igdb_client_id = environ.get("IGDB_CLIENT_ID") + igdb_client_secret = environ.get("IGDB_CLIENT_SECRET") github = environ.get("GITHUB_TOKEN") diff --git a/bot/exts/evergreen/game.py b/bot/exts/evergreen/game.py index d0fd7a40..f5707a22 100644 --- a/bot/exts/evergreen/game.py +++ b/bot/exts/evergreen/game.py @@ -2,7 +2,8 @@ import difflib import logging import random import re -from datetime import datetime as dt +from asyncio import sleep +from datetime import datetime as dt, timedelta from enum import IntEnum from typing import Any, Dict, List, Optional, Tuple @@ -17,10 +18,22 @@ from bot.utils.decorators import with_role from bot.utils.pagination import ImagePaginator, LinePaginator # Base URL of IGDB API -BASE_URL = "https://api-v3.igdb.com" +BASE_URL = "https://api.igdb.com/v4" + +CLIENT_ID = Tokens.igdb_client_id +CLIENT_SECRET = Tokens.igdb_client_secret + +# URL to request API access token +OAUTH_URL = "https://id.twitch.tv/oauth2/token" + +OAUTH_PARAMS = { + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + "grant_type": "client_credentials" +} HEADERS = { - "user-key": Tokens.igdb, + "Client-ID": CLIENT_ID, "Accept": "application/json" } @@ -136,7 +149,32 @@ class Games(Cog): self.genres: Dict[str, int] = {} - self.refresh_genres_task.start() + self.bot.loop.create_task(self.renew_access_token()) + + # self.refresh_genres_task.start() + + async def renew_access_token(self) -> None: + """Refeshes V4 access token 2 days before expiry.""" + while True: + async with self.http_session.post(OAUTH_URL, params=OAUTH_PARAMS) as resp: + result = await resp.json() + if resp.status != 200: + logger.error( + "Failed to renew IGDB access token, unloading Games cog." + f"OAuth response message: {result['message']}" + ) + self.bot.remove_cog('Games') + return + + self.access_token = result["access_token"] + + # Set next renewal to 2 days before expire time + next_renewal = result["expires_in"] - 60*60*24*2 + + time_delta = timedelta(seconds=next_renewal) + logger.info(f"Successfully renewed access token. Refreshing again in {time_delta}") + + await sleep(next_renewal) @tasks.loop(hours=24.0) async def refresh_genres_task(self) -> None: @@ -418,7 +456,10 @@ class Games(Cog): def setup(bot: Bot) -> None: """Add/Load Games cog.""" # Check does IGDB API key exist, if not, log warning and don't load cog - if not Tokens.igdb: - logger.warning("No IGDB API key. Not loading Games cog.") + if not Tokens.igdb_client_id: + logger.warning("No IGDB client ID. Not loading Games cog.") + return + if not Tokens.igdb_client_secret: + logger.warning("No IGDB client secret. Not loading Games cog.") return bot.add_cog(Games(bot)) -- cgit v1.2.3 From 4505ba3b07c75f531bb6aea0f16737f92330f37e Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 8 Jan 2021 00:07:31 +0000 Subject: Revert change to trashcan emoji --- bot/constants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/constants.py b/bot/constants.py index e638dfa1..cc342f3d 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -174,7 +174,7 @@ class Emojis: christmas_tree = "\U0001F384" check = "\u2611" envelope = "\U0001F4E8" - trashcan = "<:trashcan:796854840293589003>" + trashcan = "<:trashcan:637136429717389331>" ok_hand = ":ok_hand:" dice_1 = "<:dice_1:755891608859443290>" -- cgit v1.2.3 From 0fcfb1459c258bae4bf2895a16559210e44c675c Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 8 Jan 2021 00:09:16 +0000 Subject: Un-comment out task start --- bot/exts/evergreen/game.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/evergreen/game.py b/bot/exts/evergreen/game.py index f5707a22..fe5fc612 100644 --- a/bot/exts/evergreen/game.py +++ b/bot/exts/evergreen/game.py @@ -151,7 +151,7 @@ class Games(Cog): self.bot.loop.create_task(self.renew_access_token()) - # self.refresh_genres_task.start() + self.refresh_genres_task.start() async def renew_access_token(self) -> None: """Refeshes V4 access token 2 days before expiry.""" -- cgit v1.2.3 From 542cdc023629513c6d280e2bf96d5d554baa78d9 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 8 Jan 2021 16:43:39 +0000 Subject: Move renewal window to a const --- bot/exts/evergreen/game.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bot/exts/evergreen/game.py b/bot/exts/evergreen/game.py index fe5fc612..be4fcbc9 100644 --- a/bot/exts/evergreen/game.py +++ b/bot/exts/evergreen/game.py @@ -23,6 +23,9 @@ BASE_URL = "https://api.igdb.com/v4" CLIENT_ID = Tokens.igdb_client_id CLIENT_SECRET = Tokens.igdb_client_secret +# The number of seconds before expiry that we attempt to re-fetch a new access token +ACCESS_TOKEN_RENEWAL_WINDOW = 60*60*24*2 + # URL to request API access token OAUTH_URL = "https://id.twitch.tv/oauth2/token" @@ -168,8 +171,8 @@ class Games(Cog): self.access_token = result["access_token"] - # Set next renewal to 2 days before expire time - next_renewal = result["expires_in"] - 60*60*24*2 + # Attempt to renew before the token expires + next_renewal = result["expires_in"] - ACCESS_TOKEN_RENEWAL_WINDOW time_delta = timedelta(seconds=next_renewal) logger.info(f"Successfully renewed access token. Refreshing again in {time_delta}") -- cgit v1.2.3 From 4d68fb6e1be9fd7d1287e2f1650f88b1e447f69d Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 8 Jan 2021 18:40:50 +0000 Subject: Use current token if we can't get a new one --- bot/exts/evergreen/game.py | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/bot/exts/evergreen/game.py b/bot/exts/evergreen/game.py index be4fcbc9..2abd7555 100644 --- a/bot/exts/evergreen/game.py +++ b/bot/exts/evergreen/game.py @@ -35,7 +35,7 @@ OAUTH_PARAMS = { "grant_type": "client_credentials" } -HEADERS = { +BASE_HEADERS = { "Client-ID": CLIENT_ID, "Accept": "application/json" } @@ -151,25 +151,34 @@ class Games(Cog): self.http_session: ClientSession = bot.http_session self.genres: Dict[str, int] = {} + self.headers = BASE_HEADERS self.bot.loop.create_task(self.renew_access_token()) self.refresh_genres_task.start() async def renew_access_token(self) -> None: - """Refeshes V4 access token 2 days before expiry.""" + """Refeshes V4 access token a number of seconds before expiry. See `ACCESS_TOKEN_RENEWAL_WINDOW`.""" while True: async with self.http_session.post(OAUTH_URL, params=OAUTH_PARAMS) as resp: result = await resp.json() if resp.status != 200: - logger.error( - "Failed to renew IGDB access token, unloading Games cog." - f"OAuth response message: {result['message']}" - ) - self.bot.remove_cog('Games') + # If there is a valid access token continue to use that, + # otherwise unload cog. + if "access_token" in self.headers: + time_delta = timedelta(seconds=ACCESS_TOKEN_RENEWAL_WINDOW) + logger.error( + "Failed to renew IGDB access token. " + f"Current token will last for {time_delta} " + f"OAuth response message: {result['message']}" + ) + else: + logger.warning("Invalid OAuth credentials. Unloading Games cog.") + self.bot.remove_cog('Games') + return - self.access_token = result["access_token"] + self.headers["access_token"] = result["access_token"] # Attempt to renew before the token expires next_renewal = result["expires_in"] - ACCESS_TOKEN_RENEWAL_WINDOW @@ -197,7 +206,7 @@ class Games(Cog): async def _get_genres(self) -> None: """Create genres variable for games command.""" body = "fields name; limit 100;" - async with self.http_session.get(f"{BASE_URL}/genres", data=body, headers=HEADERS) as resp: + async with self.http_session.get(f"{BASE_URL}/genres", data=body, headers=self.headers) as resp: result = await resp.json() genres = {genre["name"].capitalize(): genre["id"] for genre in result} @@ -347,7 +356,7 @@ class Games(Cog): body = GAMES_LIST_BODY.format(**params) # Do request to IGDB API, create headers, URL, define body, return result - async with self.http_session.get(url=f"{BASE_URL}/games", data=body, headers=HEADERS) as resp: + async with self.http_session.get(url=f"{BASE_URL}/games", data=body, headers=self.headers) as resp: return await resp.json() async def create_page(self, data: Dict[str, Any]) -> Tuple[str, str]: @@ -389,7 +398,7 @@ class Games(Cog): # Define request body of IGDB API request and do request body = SEARCH_BODY.format(**{"term": search_term}) - async with self.http_session.get(url=f"{BASE_URL}/games", data=body, headers=HEADERS) as resp: + async with self.http_session.get(url=f"{BASE_URL}/games", data=body, headers=self.headers) as resp: data = await resp.json() # Loop over games, format them to good format, make line and append this to total lines @@ -418,7 +427,7 @@ class Games(Cog): "offset": offset }) - async with self.http_session.get(url=f"{BASE_URL}/companies", data=body, headers=HEADERS) as resp: + async with self.http_session.get(url=f"{BASE_URL}/companies", data=body, headers=self.headers) as resp: return await resp.json() async def create_company_page(self, data: Dict[str, Any]) -> Tuple[str, str]: -- cgit v1.2.3 From 3ba700e7b7c211d69193d3ba3d3e02927b448441 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 8 Jan 2021 20:44:43 +0000 Subject: Switch to post requests and start task at right time. --- bot/exts/evergreen/game.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/bot/exts/evergreen/game.py b/bot/exts/evergreen/game.py index 2abd7555..680724c2 100644 --- a/bot/exts/evergreen/game.py +++ b/bot/exts/evergreen/game.py @@ -155,8 +155,6 @@ class Games(Cog): self.bot.loop.create_task(self.renew_access_token()) - self.refresh_genres_task.start() - async def renew_access_token(self) -> None: """Refeshes V4 access token a number of seconds before expiry. See `ACCESS_TOKEN_RENEWAL_WINDOW`.""" while True: @@ -165,7 +163,7 @@ class Games(Cog): if resp.status != 200: # If there is a valid access token continue to use that, # otherwise unload cog. - if "access_token" in self.headers: + if "Authorization" in self.headers: time_delta = timedelta(seconds=ACCESS_TOKEN_RENEWAL_WINDOW) logger.error( "Failed to renew IGDB access token. " @@ -178,7 +176,7 @@ class Games(Cog): return - self.headers["access_token"] = result["access_token"] + self.headers["Authorization"] = f"Bearer {result['access_token']}" # Attempt to renew before the token expires next_renewal = result["expires_in"] - ACCESS_TOKEN_RENEWAL_WINDOW @@ -186,6 +184,10 @@ class Games(Cog): time_delta = timedelta(seconds=next_renewal) logger.info(f"Successfully renewed access token. Refreshing again in {time_delta}") + # This will be true the first time this loop runs. + # Since we now have an access token, its safe to start this task. + if self.genres == {}: + self.refresh_genres_task.start() await sleep(next_renewal) @tasks.loop(hours=24.0) @@ -206,9 +208,8 @@ class Games(Cog): async def _get_genres(self) -> None: """Create genres variable for games command.""" body = "fields name; limit 100;" - async with self.http_session.get(f"{BASE_URL}/genres", data=body, headers=self.headers) as resp: + async with self.http_session.post(f"{BASE_URL}/genres", data=body, headers=self.headers) as resp: result = await resp.json() - genres = {genre["name"].capitalize(): genre["id"] for genre in result} # Replace complex names with names from ALIASES @@ -356,7 +357,7 @@ class Games(Cog): body = GAMES_LIST_BODY.format(**params) # Do request to IGDB API, create headers, URL, define body, return result - async with self.http_session.get(url=f"{BASE_URL}/games", data=body, headers=self.headers) as resp: + async with self.http_session.post(url=f"{BASE_URL}/games", data=body, headers=self.headers) as resp: return await resp.json() async def create_page(self, data: Dict[str, Any]) -> Tuple[str, str]: @@ -398,7 +399,7 @@ class Games(Cog): # Define request body of IGDB API request and do request body = SEARCH_BODY.format(**{"term": search_term}) - async with self.http_session.get(url=f"{BASE_URL}/games", data=body, headers=self.headers) as resp: + async with self.http_session.post(url=f"{BASE_URL}/games", data=body, headers=self.headers) as resp: data = await resp.json() # Loop over games, format them to good format, make line and append this to total lines @@ -427,7 +428,7 @@ class Games(Cog): "offset": offset }) - async with self.http_session.get(url=f"{BASE_URL}/companies", data=body, headers=self.headers) as resp: + async with self.http_session.post(url=f"{BASE_URL}/companies", data=body, headers=self.headers) as resp: return await resp.json() async def create_company_page(self, data: Dict[str, Any]) -> Tuple[str, str]: -- cgit v1.2.3 From 92f1ee13d71fd4f72edc8e24488115a2294421f8 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 8 Jan 2021 20:57:57 +0000 Subject: Add OAuth response to warning. --- bot/exts/evergreen/game.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/exts/evergreen/game.py b/bot/exts/evergreen/game.py index 680724c2..d37be0e2 100644 --- a/bot/exts/evergreen/game.py +++ b/bot/exts/evergreen/game.py @@ -171,7 +171,10 @@ class Games(Cog): f"OAuth response message: {result['message']}" ) else: - logger.warning("Invalid OAuth credentials. Unloading Games cog.") + logger.warning( + "Invalid OAuth credentials. Unloading Games cog. " + f"OAuth response message: {result['message']}" + ) self.bot.remove_cog('Games') return -- cgit v1.2.3 From 03cb65d7b08210f43fe991196546fb1d61c7dca4 Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 9 Jan 2021 20:05:58 +0000 Subject: Actually use 256 colours --- bot/exts/evergreen/8bitify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/evergreen/8bitify.py b/bot/exts/evergreen/8bitify.py index c048d9bf..54e68f80 100644 --- a/bot/exts/evergreen/8bitify.py +++ b/bot/exts/evergreen/8bitify.py @@ -19,7 +19,7 @@ class EightBitify(commands.Cog): @staticmethod def quantize(image: Image) -> Image: """Reduces colour palette to 256 colours.""" - return image.quantize(colors=32) + return image.quantize() @commands.command(name="8bitify") async def eightbit_command(self, ctx: commands.Context) -> None: -- cgit v1.2.3 From a6013bd678ed0b79684a707560ce052b18799052 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Fri, 15 Jan 2021 12:48:12 +0100 Subject: Remove sprint channels from the configuration. Now that the core dev sprint has ended, we can safely remove those. It caused the wrong channel message to be huge because of all the deleted channels. --- bot/constants.py | 34 ---------------------------------- 1 file changed, 34 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index f6da272e..90e302d0 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -126,23 +126,6 @@ class Channels(NamedTuple): hacktoberfest_2020 = 760857070781071431 voice_chat = 412357430186344448 - # Core Dev Sprint channels - sprint_announcements = 755958119963557958 - sprint_information = 753338352136224798 - sprint_organisers = 753340132639375420 - sprint_general = 753340631538991305 - sprint_social1_cheese_shop = 758779754789863514 - sprint_social2_pet_shop = 758780951978573824 - sprint_escape_room = 761031075942105109 - sprint_stdlib = 758553316732698634 - sprint_asyncio = 762904152438472714 - sprint_typing = 762904690341838888 - sprint_discussion_capi = 758553358587527218 - sprint_discussion_triage = 758553458365300746 - sprint_discussion_design = 758553492662255616 - sprint_discussion_mentor = 758553536623280159 - sprint_documentation = 761038271127093278 - class Client(NamedTuple): name = "Sir Lancebot" @@ -295,23 +278,6 @@ WHITELISTED_CHANNELS = ( Channels.off_topic_1, Channels.off_topic_2, Channels.voice_chat, - - # Core Dev Sprint Channels - Channels.sprint_announcements, - Channels.sprint_information, - Channels.sprint_organisers, - Channels.sprint_general, - Channels.sprint_social1_cheese_shop, - Channels.sprint_social2_pet_shop, - Channels.sprint_escape_room, - Channels.sprint_stdlib, - Channels.sprint_asyncio, - Channels.sprint_typing, - Channels.sprint_discussion_capi, - Channels.sprint_discussion_triage, - Channels.sprint_discussion_design, - Channels.sprint_discussion_mentor, - Channels.sprint_documentation, ) GIT_SHA = environ.get("GIT_SHA", "foobar") -- cgit v1.2.3 From a17445129a66907e210844d3edaf2871a001b4a8 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Fri, 15 Jan 2021 18:19:40 +0300 Subject: Updates Constants Updates the constants file with the new channel ID, and renames both channels to match the new names. --- bot/constants.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 90e302d0..24765c2a 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -124,7 +124,8 @@ class Channels(NamedTuple): verification = 352442727016693763 python_discussion = 267624335836053506 hacktoberfest_2020 = 760857070781071431 - voice_chat = 412357430186344448 + voice_chat_0 = 412357430186344448 + voice_chat_1 = 799647045886541885 class Client(NamedTuple): @@ -277,7 +278,8 @@ WHITELISTED_CHANNELS = ( Channels.off_topic_0, Channels.off_topic_1, Channels.off_topic_2, - Channels.voice_chat, + Channels.voice_chat_0, + Channels.voice_chat_1, ) GIT_SHA = environ.get("GIT_SHA", "foobar") -- cgit v1.2.3 From d259741371af9d4a09ed97b3f0ddb3df25ce2ded Mon Sep 17 00:00:00 2001 From: Vivaan Parashar Date: Mon, 19 Oct 2020 13:48:48 +0530 Subject: Implement Spooky Name Rate game removed commented code added the delete command Changed the name from ratethespook to spooknamerate renamed file too rename ratethespook.json to spooknamerate.json Added more names from mockaroo added one user one reaction removed print statements fixed flake8 comments and prevented the user from deleting the word while polling fixed typo, added random messages, made each entry unique, fixed one user one reaction, made it for one day. I think I am done commented some code autopep8ed edited few lines of code added comments added some comments edited code edited code edited fixed syntax error fixed flake8 complaints fixed flake8 complaint fixed small error added a `word` command and informed user if they don't have a registered word fixed a small error where the first and last names weren't separated removed unecessary code changed word to name remove slash in multiline strings and remove unecessary comments. Also, lock the background loop to `OCTOBER` and make emojis into discord emoij form (:emoij_name:) fixed an accidental tab removed another unecessary comment remove more unecessary comments remove `.keys()` for dictionaries removed `len() > 0` for lists and dicts and changed emojis to '\N{}' form Fixed code so that return value is that specified and added fail-safes instead of `if` and `else`s f-stringify fixed borderline api abuse and missing space in defining word lint code multiple imports on one line and fix typo remove unecessary return, shorten var typehint remove 'Just' in suggestion and add extra line in json file. - unecessary comment and replace on with for in help embed description shorten emoij_message remove check in delete command use defaultdict sort imports group imports, add typehinting, remove unnecessary comments and docstring, remove redundant elses and returns sort imports refactor var wrds, use generator instead of list use typing.Dict[str, str] instead of dict and use .items() instead of get() add a comment remove reduntant comment Renamed variable to avoid conflicts add asyncio locking to prevent Runtime error add some comments lock all commands to OCTOBER enhance looping in checking messages add `cog_check` instead of limiting each command remove unused import remove test code and comments use fail fast rename function Make storage persistent and make sure announce_word does not go off everytime the bot restarts fixed typo make data persist, rename everything that has word in it to 'name' and make sure announce_name doesn't start off everytime the bot restarts. remove testing code which would cause a real mess if commited. Which I did commit. use a separate file for the name and first_time becuase re-dumping such a long file might take time make var for repetitive paths and change .day to .hour change scheduling logic lint code add cog_unload fix error in spooknamerate_names.json and fix the before_loop in spooknamerate.py revert accidental changes and remove commented code remove unused code refactor vars to caps and make emojis_val global edit docstring and make seasonalbot_commands to community_bot_commands make annotation correct and add check when channel is None for get_channel Add fullstop Loop directly over data Add a proper dash and fix linting Fix linting reverted to making EMOJIS_VAL global and fixed capitalization fix small error verify it is working and simplify import remove data files Use redis caching instead of JSON and rename remove empty title and description in embed and use discord's red color remove var typehint add Client.month_override for dev move ping function rename seasonalbot to sir-lancebot remove unnecessary newline fix line formatting move added_messages to global Add more info on the caches remove + alias improve formatting use str.format instead of func fix error directly used Channels.community_bot_commands get user from cache instead of actively fetching the user move help messages to constant add more info in err msg Apply suggestions from review Co-authored-by: ks129 <45097959+ks129@users.noreply.github.com> remove unnecessary comments remove another redundant comment improve formatting and use better var names hard-code a var Use get or fetch format Remove redundant commit Fix in_allowed_month for debugging remove extra space make channel name link to channel simplify uteration use msg.reactions directly rename r to reaction and directly use variable reformat code use from_dict instead of manually creating the Embed Remove commented code fix channel linking add some debugging support add some more info to the debug mode Directly use getLogger sort imports Remove (name) in function doctype Use SpookNameRate.debug everywhere Shrink function call to one line and remove extra info in comment Use fail fast in on_reaction_add use environment var for debug mode Set debug val to False by default Fix some line breaks that formatting with black had made and use fail fast Use custom environment variable instead of the global bot env var make bot reply and store info from the bot's reply instead of the user's message remove an accidental swp file fix the reaction not getting removed remove extra brackets use generators instead of lists fix logging statement simplify loop rename spooknamerate to spookynamerate Correct docstring Improve the name announcing code Ignore reaction of all bots rearrange or send "Name deleted" instead of "Message deleted" Add client prefix --- bot/exts/halloween/spookynamerate.py | 401 ++++ bot/resources/halloween/spookynamerate_names.json | 2206 +++++++++++++++++++++ 2 files changed, 2607 insertions(+) create mode 100644 bot/exts/halloween/spookynamerate.py create mode 100644 bot/resources/halloween/spookynamerate_names.json diff --git a/bot/exts/halloween/spookynamerate.py b/bot/exts/halloween/spookynamerate.py new file mode 100644 index 00000000..e2950343 --- /dev/null +++ b/bot/exts/halloween/spookynamerate.py @@ -0,0 +1,401 @@ +import asyncio +import json +import random +from collections import defaultdict +from datetime import datetime, timedelta +from logging import getLogger +from os import getenv +from pathlib import Path +from typing import Dict, Union + +from async_rediscache import RedisCache +from discord import Embed, Reaction, TextChannel, User +from discord.colour import Colour +from discord.ext import tasks +from discord.ext.commands import Bot, Cog, Context, group + +from bot.constants import Channels, Client, Colours, Month +from bot.utils.decorators import InMonthCheckFailure + +logger = getLogger(__name__) + +EMOJIS_VAL = { + "\N{Jack-O-Lantern}": 1, + "\N{Ghost}": 2, + "\N{Skull and Crossbones}": 3, + "\N{Zombie}": 4, + "\N{Face Screaming In Fear}": 5, +} +ADDED_MESSAGES = [ + "Let's see if you win?", + ":jack_o_lantern: SPOOKY :jack_o_lantern:", + "If you got it, haunt it.", + "TIME TO GET YOUR SPOOKY ON! :skull:", +] +PING = "<@{id}>" + +EMOJI_MESSAGE = "\n".join([f"- {emoji} {val}" for emoji, val in EMOJIS_VAL.items()]) +HELP_MESSAGE_DICT = { + "title": "Spooky Name Rate", + "description": f"Help for the `{Client.prefix}spookynamerate` command", + "color": Colours.soft_orange, + "fields": [ + { + "name": "How to play", + "value": ( + "Everyday, the bot will post a random name, which you will need to spookify using your creativity.\n" + "You can rate each message according to how scary it is.\n" + "At the end of the day, the author of the message with most reactions will be the winner of the day.\n" + f"On a scale of 1 to {len(EMOJIS_VAL)}, the reactions order:\n" + f"{EMOJI_MESSAGE}" + ), + "inline": False, + }, + { + "name": "How do I add my spookified name?", + "value": f"Simply type `{Client.prefix}spookynamerate add my name`", + "inline": False, + }, + { + "name": "How do I *delete* my spookified name?", + "value": f"Simply type `{Client.prefix}spookynamerate delete`", + "inline": False, + }, + ], +} + + +class SpookyNameRate(Cog): + """ + A game that asks the user to spookify or halloweenify a name that is given everyday. + + It sends a random name everyday. The user needs to try and spookify it to his best ability and + send that name back using the `spookynamerate add entry` command + """ + + # This cache stores the message id of each added word along with a dictionary which contains the name the author + # added, the author's id, and the author's score (which is 0 by default) + messages = RedisCache() + + # The data cache stores small information such as the current name that is going on and whether it is the first time + # the bot is running + data = RedisCache() + debug = getenv('SPOOKYNAMERATE_DEBUG', False) # Enable if you do not want to limit the commands to October or if + # you do not want to wait till 12 UTC. Note: if debug is enabled and you run `.cogs reload spookynamerate`, it + # will automatically start the scoring and announcing the result (without waiting for 12, so do not expect it to.). + # Also, it won't wait for the two hours (when the poll closes). + + def __init__(self, bot: Bot) -> None: + self.bot = bot + + names_data = self.load_json( + Path("bot", "resources", "halloween", "spookynamerate_names.json") + ) + self.first_names = names_data["first_names"] + self.last_names = names_data["last_names"] + # the names are from https://www.mockaroo.com/ + + self.name = None + + self.bot.loop.create_task(self.load_vars()) + + self.first_time = None + self.poll = False + self.announce_name.start() + self.checking_messages = asyncio.Lock() + # Define an asyncio.Lock() to make sure the dictionary isn't changed + # when checking the messages for duplicate emojis' + + async def load_vars(self) -> None: + """Loads the variables that couldn't be loaded in __init__.""" + self.first_time = await self.data.get("first_time", True) + self.name = await self.data.get("name") + + @group(name="spookynamerate", invoke_without_command=True) + async def spooky_name_rate(self, ctx: Context) -> None: + """Get help on the Spooky Name Rate game.""" + await ctx.send(embed=Embed.from_dict(HELP_MESSAGE_DICT)) + + @spooky_name_rate.command(name="list", aliases=["all", "entries"]) + async def list_entries(self, ctx: Context) -> None: + """Send all the entries up till now in a single embed.""" + await ctx.send(embed=await self.get_responses_list(final=False)) + + @spooky_name_rate.command(name="name") + async def tell_name(self, ctx: Context) -> None: + """Tell the current random name.""" + if not self.poll: + await ctx.send(f"The name is **{self.name}**") + return + + await ctx.send( + f"The name ~~is~~ was **{self.name}**. The poll has already started, so you cannot " + "add an entry." + ) + + @spooky_name_rate.command(name="add", aliases=["register"]) + async def add_name(self, ctx: Context, *, name: str) -> None: + """Use this command to add/register your spookified name.""" + if self.poll: + logger.info(f"{ctx.message.author} tried to add a name, but the poll had already started.") + await ctx.send("Sorry, the poll has started! You can try and participate in the next round though!") + return + + message = ctx.message + + for data in (json.loads(user_data) for _, user_data in await self.messages.items()): + if data["author"] == message.author.id: + await ctx.send( + "But you have already added an entry! Type " + f"`{self.bot.command_prefix}spookynamerate " + "delete` to delete it, and then you can add it again" + ) + return + + elif data["name"] == name: + await ctx.send("TOO LATE. Someone has already added this name.") + return + + msg = await (await self.get_channel()).send(f"{message.author.mention} added the name {name!r}!") + + await self.messages.set( + msg.id, + json.dumps( + { + "name": name, + "author": message.author.id, + "score": 0, + } + ), + ) + + for emoji in EMOJIS_VAL: + await msg.add_reaction(emoji) + + logger.info(f"{message.author} added the name {name!r}") + + @spooky_name_rate.command(name="delete") + async def delete_name(self, ctx: Context) -> None: + """Delete the user's name.""" + if self.poll: + await ctx.send("You can't delete your name since the poll has already started!") + return + for message_id, data in await self.messages.items(): + data = json.loads(data) + + if ctx.author.id == data["author"]: + await self.messages.delete(message_id) + await ctx.send(f'Name deleted successfully ({data["name"]!r})!') + return + + await ctx.send( + f"But you don't have an entry... :eyes: Type `{self.bot.command_prefix}spookynamerate add your entry`" + ) + + @Cog.listener() + async def on_reaction_add(self, reaction: Reaction, user: User) -> None: + """Ensures that each user adds maximum one reaction.""" + if user.bot or not await self.messages.contains(reaction.message.id): + return + + async with self.checking_messages: # Acquire the lock so that the dictionary isn't reset while iterating. + if reaction.emoji in EMOJIS_VAL: + # create a custom counter + reaction_counter = defaultdict(int) + for msg_reaction in reaction.message.reactions: + async for reaction_user in msg_reaction.users(): + if reaction_user == self.bot.user: + continue + reaction_counter[reaction_user] += 1 + + if reaction_counter[user] > 1: + await user.send( + "Sorry, you have already added a reaction, " + "please remove your reaction and try again." + ) + await reaction.remove(user) + return + + @tasks.loop(hours=24.0) + async def announce_name(self) -> None: + """Announces the name needed to spookify every 24 hours and the winner of the previous game.""" + if not self.in_allowed_month(): + return + + channel = await self.get_channel() + + if self.first_time: + await channel.send( + "Okkey... Welcome to the **Spooky Name Rate Game**! It's a relatively simple game.\n" + f"Everyday, a random name will be sent in <#{Channels.community_bot_commands}> " + "and you need to try and spookify it!\nRegister your name using " + f"`{self.bot.command_prefix}spookynamerate add spookified name`" + ) + + await self.data.set("first_time", False) + self.first_time = False + + else: + if await self.messages.items(): + await channel.send(embed=await self.get_responses_list(final=True)) + self.poll = True + if not SpookyNameRate.debug: + await asyncio.sleep(2 * 60 * 60) # sleep for two hours + + logger.info("Calculating score") + for message_id, data in await self.messages.items(): + data = json.loads(data) + + msg = await channel.fetch_message(message_id) + score = 0 + for reaction in msg.reactions: + reaction_value = EMOJIS_VAL.get(reaction.emoji, 0) # get the value of the emoji else 0 + score += reaction_value * (reaction.count - 1) # multiply by the num of reactions + # subtract one, since one reaction was done by the bot + + logger.debug(f"{self.bot.get_user(data['author'])} got a score of {score}") + data["score"] = score + await self.messages.set(message_id, json.dumps(data)) + + # Sort the winner messages + winner_messages = sorted( + ((msg_id, json.loads(usr_data)) for msg_id, usr_data in await self.messages.items()), + key=lambda x: x[1]["score"], + reverse=True, + ) + + winners = [] + for i, winner in enumerate(winner_messages): + winners.append(winner) + if len(winner_messages) > i + 1: + if winner_messages[i + 1][1]["score"] != winner[1]["score"]: + break + elif len(winner_messages) == (i + 1) + 1: # The next element is the last element + if winner_messages[i + 1][1]["score"] != winner[1]["score"]: + break + + # one iteration is complete + await channel.send("Today's Spooky Name Rate Game ends now, and the winner(s) is(are)...") + + async with channel.typing(): + await asyncio.sleep(1) # give the drum roll feel + + if not winners: # There are no winners (no participants) + await channel.send("Hmm... Looks like no one participated! :cry:") + return + + score = winners[0][1]["score"] + congratulations = "to all" if len(winners) > 1 else PING.format(id=winners[0][1]["author"]) + names = ", ".join(f'{win[1]["name"]} ({PING.format(id=win[1]["author"])})' for win in winners) + + # display winners, their names and scores + await channel.send( + f"Congratulations {congratulations}!\n" + f"You have a score of {score}!\n" + f"Your name{ 's were' if len(winners) > 1 else 'was'}:\n{names}" + ) + + # Send random party emojis + party = (random.choice([":partying_face:", ":tada:"]) for _ in range(random.randint(1, 10))) + await channel.send(" ".join(party)) + + async with self.checking_messages: # Acquire the lock to delete the messages + await self.messages.clear() # reset the messages + + # send the next name + self.name = f"{random.choice(self.first_names)} {random.choice(self.last_names)}" + await self.data.set("name", self.name) + + await channel.send( + "Let's move on to the next name!\nAnd the next name is...\n" + f"**{self.name}**!\nTry to spookify that... :smirk:" + ) + + self.poll = False # accepting responses + + @announce_name.before_loop + async def wait_till_scheduled_time(self) -> None: + """Waits till the next day's 12PM if crossed it, otherwise waits till the same day's 12PM.""" + if SpookyNameRate.debug: + return + + now = datetime.utcnow() + if now.hour < 12: + twelve_pm = now.replace(hour=12, minute=0, second=0, microsecond=0) + time_left = twelve_pm - now + await asyncio.sleep(time_left.seconds) + return + + tomorrow_12pm = now + timedelta(days=1) + tomorrow_12pm = tomorrow_12pm.replace(hour=12, minute=0, second=0, microsecond=0) + await asyncio.sleep((tomorrow_12pm - now).seconds) + + async def get_responses_list(self, final: bool = False) -> Embed: + """Returns an embed containing the responses of the people.""" + channel = await self.get_channel() + + embed = Embed(color=Colour.red()) + + if await self.messages.items(): + if final: + embed.title = "Spooky Name Rate is about to end!" + embed.description = ( + "This Spooky Name Rate round is about to end in 2 hours! You can review " + "the entries below! Have you rated other's names?" + ) + else: + embed.title = "All the spookified names!" + embed.description = "See a list of all the entries entered by everyone!" + else: + embed.title = "No one has added an entry yet..." + + for message_id, data in await self.messages.items(): + data = json.loads(data) + + embed.add_field( + name=(self.bot.get_user(data["author"]) or await self.bot.fetch_user(data["author"])).name, + value=f"[{(data)['name']}](https://discord.com/channels/{Client.guild}/{channel.id}/{message_id})", + ) + + return embed + + async def get_channel(self) -> Union[TextChannel, None]: + """Gets the sir-lancebot-channel after waiting until ready.""" + await self.bot.wait_until_ready() + channel = self.bot.get_channel( + Channels.community_bot_commands + ) or await self.bot.fetch_channel(Channels.community_bot_commands) + if not channel: + logger.warning("Bot is unable to get the #seasonalbot-commands channel. Please check the channel ID.") + return channel + + @staticmethod + def load_json(file: Path) -> Dict[str, str]: + """Loads a JSON file and returns its contents.""" + with file.open("r", encoding="utf-8") as f: + return json.load(f) + + @staticmethod + def in_allowed_month() -> bool: + """Returns whether running in the limited month.""" + if SpookyNameRate.debug: + return True + + if not Client.month_override: + return datetime.utcnow().month == Month.OCTOBER + return Client.month_override == Month.OCTOBER + + def cog_check(self, ctx: Context) -> bool: + """A command to check whether the command is being called in October.""" + if not self.in_allowed_month(): + raise InMonthCheckFailure("You can only use these commands in October!") + return True + + def cog_unload(self) -> None: + """Stops the announce_name task.""" + self.announce_name.cancel() + + +def setup(bot: Bot) -> None: + """Loads the SpookyNameRate Cog.""" + bot.add_cog(SpookyNameRate(bot)) diff --git a/bot/resources/halloween/spookynamerate_names.json b/bot/resources/halloween/spookynamerate_names.json new file mode 100644 index 00000000..7657880b --- /dev/null +++ b/bot/resources/halloween/spookynamerate_names.json @@ -0,0 +1,2206 @@ +{ + "first_names": [ + "Eberhard", + "Gladys", + "Joshua", + "Misty", + "Bondy", + "Constantine", + "Juliette", + "Dalis", + "Nap", + "Sandy", + "Inglebert", + "Sasha", + "Julietta", + "Christoforo", + "Del", + "Zelma", + "Vladimir", + "Wayland", + "Enos", + "Siobhan", + "Farrand", + "Ailee", + "Horatia", + "Gloriana", + "Britney", + "Shel", + "Lindsey", + "Francis", + "Elsa", + "Fred", + "Upton", + "Lothaire", + "Cara", + "Margarete", + "Wolfgang", + "Charin", + "Loydie", + "Aurelea", + "Sibel", + "Glenden", + "Julian", + "Roby", + "Gerri", + "Sandie", + "Twila", + "Shaylyn", + "Clyde", + "Dina", + "Chase", + "Caron", + "Carlin", + "Aida", + "Rhonda", + "Rebekkah", + "Charmian", + "Lindy", + "Obadiah", + "Willy", + "Matti", + "Melodie", + "Ira", + "Wilfrid", + "Berton", + "Denver", + "Clarette", + "Nicolas", + "Tawnya", + "Cynthy", + "Arman", + "Sherwood", + "Flemming", + "Berri", + "Beret", + "Aili", + "Hannie", + "Eadie", + "Tannie", + "Gilda", + "Walton", + "Nolly", + "Tonya", + "Meaghan", + "Timmi", + "Faina", + "Sarge", + "Britteny", + "Farlay", + "Carola", + "Skippy", + "Corrina", + "Hans", + "Courtnay", + "Taffy", + "Averill", + "Martie", + "Tobye", + "Broderic", + "Gardner", + "Lucky", + "Beverie", + "Ignaz", + "Siana", + "Marybelle", + "Leif", + "Baily", + "Pyotr", + "Myrtle", + "Darb", + "Gar", + "Vinni", + "Samson", + "Kinny", + "Briant", + "Verney", + "Del", + "Marion", + "Beniamino", + "Nona", + "Fay", + "Noreen", + "Maurizio", + "Nial", + "Mirabella", + "Melisa", + "Anatol", + "Halette", + "Johnathon", + "Antonietta", + "Germana", + "Towny", + "Shayne", + "Court", + "Merrile", + "Staffard", + "Odele", + "Gustav", + "Moyna", + "Warden", + "Craggie", + "Hurleigh", + "Hartley", + "Rustie", + "Raven", + "Farra", + "Leonidas", + "Jorrie", + "Maximilian", + "Augustin", + "Cordelia", + "Christoffer", + "Lana", + "Vittorio", + "Janith", + "Margaret", + "Bethanne", + "Brooke", + "Payton", + "Poul", + "Diahann", + "Andy", + "Garek", + "Isa", + "Dunn", + "Anny", + "Hillary", + "Andres", + "Winn", + "Gare", + "Ameline", + "Audre", + "Rodrigo", + "Anabal", + "Reuben", + "Cecil", + "Alexandro", + "Corny", + "Erek", + "William", + "Rudyard", + "Muffin", + "Allin", + "Emmit", + "Heindrick", + "Myrna", + "Kriste", + "Perry", + "Annmarie", + "Jasun", + "Esdras", + "Jobyna", + "Marian", + "Theodore", + "Dionisio", + "Efren", + "Clarita", + "Leilah", + "Modestia", + "Clem", + "Jemmy", + "Karol", + "Minni", + "Damien", + "Tessy", + "Roanne", + "Daniele", + "Camel", + "Charlot", + "Daron", + "Cherey", + "Ashil", + "Joel", + "Michell", + "Sukey", + "Micheil", + "Chev", + "Winny", + "Natale", + "Kendra", + "Bell", + "Darice", + "Beilul", + "Leonore", + "Abba", + "Warden", + "Bryna", + "Sammy", + "Brantley", + "Goldi", + "Meridith", + "Eleanor", + "Brear", + "Kristina", + "Muriel", + "Serge", + "Iver", + "Jonis", + "Ada", + "Marleen", + "Pavlov", + "Kellia", + "Abdel", + "Waylin", + "Ignazio", + "Tana", + "Kiley", + "Lynna", + "Peyton", + "Linoel", + "Patrice", + "Loria", + "Linda", + "Edna", + "Viki", + "Kelcy", + "Chelsae", + "Olga", + "Trace", + "Ethel", + "Giorgio", + "Geralda", + "Rosaline", + "Caralie", + "Duke", + "Sig", + "Seana", + "Boris", + "Jeanie", + "Stacee", + "Giffie", + "Myrta", + "Prescott", + "Roger", + "Ame", + "Lelia", + "Marthena", + "Mord", + "Tommi", + "Artemus", + "Wynn", + "Rodi", + "Denna", + "Joleen", + "Iris", + "Pascale", + "Cody", + "Kienan", + "Darline", + "Lanna", + "Chandra", + "Michel", + "Nanete", + "Rosana", + "Ondrea", + "Linette", + "Syd", + "Rhianon", + "Christiano", + "Moyna", + "Darbee", + "Chadd", + "Roselia", + "Niki", + "Flint", + "Natala", + "Merrie", + "Noelyn", + "Arvin", + "Vin", + "Khalil", + "Nance", + "Seward", + "Dagmar", + "Shanta", + "Noland", + "Vance", + "Kyla", + "Locke", + "Abagail", + "Guthrey", + "Thalia", + "Devlen", + "Parrnell", + "Leonard", + "Amber", + "Dell", + "Lolita", + "Revkah", + "Ronna", + "Ninnetta", + "Jobey", + "Larisa", + "Wendel", + "Sonnnie", + "Saul", + "Lem", + "Wang", + "Borg", + "Korie", + "Rosanna", + "Barnaby", + "Channa", + "Gordan", + "Wang", + "Dasi", + "Laurianne", + "Jo ann", + "Bond", + "Kean", + "Harwell", + "Abbey", + "Carlo", + "Hamil", + "Ameline", + "Tristam", + "Donn", + "Earle", + "Lanie", + "Maximilianus", + "Frieda", + "Noella", + "Orsa", + "Timmi", + "Linea", + "Claudina", + "Langsdon", + "Murdock", + "Cello", + "Lek", + "Viviyan", + "Candra", + "Erena", + "Shirline", + "Mariann", + "Keelby", + "Jacquelin", + "Clerissa", + "Davis", + "Ara", + "My", + "Andris", + "Drugi", + "Lynn", + "Andonis", + "Jamie", + "Cherise", + "Lonni", + "Reamonn", + "Cathee", + "Clarence", + "Joletta", + "Tanny", + "Gasparo", + "Heddie", + "Cullin", + "Sander", + "Emmalee", + "Gwendolin", + "Hayley", + "Mandie", + "Cassondra", + "Celestyna", + "Fanny", + "Alica", + "Vivyan", + "Kippy", + "Leandra", + "Jerry", + "Elspeth", + "Lexine", + "Tobie", + "Allin", + "Ambros", + "Ash", + "Conroy", + "Melonie", + "Aylmer", + "Maximo", + "Connie", + "Torre", + "Tammie", + "Corabella", + "Beau", + "Nancee", + "Ailbert", + "Florrie", + "Trevar", + "Tiffani", + "Dre", + "Eward", + "Hallie", + "Stesha", + "Ralina", + "Vinni", + "Bastien", + "Galvan", + "Romain", + "Yasmin", + "Theodoric", + "Maxy", + "Lesly", + "Gerald", + "Erskine", + "Joice", + "Theadora", + "Sheeree", + "Danit", + "Burr", + "Morten", + "Godfree", + "Lacey", + "Sandye", + "Louisa", + "Annora", + "Rochester", + "Saundra", + "Deeann", + "Aloisia", + "Oralle", + "Ree", + "Kaile", + "Rogerio", + "Graeme", + "Garald", + "Hulda", + "Deny", + "Bessy", + "Zarah", + "Melisande", + "Taffy", + "Jed", + "Bar", + "Jacki", + "Avictor", + "Damiano", + "Yasmeen", + "Geralda", + "Kermie", + "Verge", + "Cyril", + "Klara", + "Anna", + "Abey", + "Mariellen", + "Mirabel", + "Charmain", + "Carleton", + "Biddie", + "Junina", + "Cass", + "Jdavie", + "Laird", + "Olenka", + "Dion", + "Hedy", + "Haley", + "Stacy", + "Alis", + "Morena", + "Damita", + "Wynn", + "Kellia", + "Midge", + "Gerri", + "Symon", + "Markus", + "Brenn", + "Rancell", + "Marlon", + "Dulciana", + "Lemmy", + "Neale", + "Vladamir", + "Alasteir", + "Gilberta", + "Seumas", + "Ronda", + "Myrvyn", + "Gabey", + "Goldia", + "Lothaire", + "Averil", + "Marlo", + "Nanice", + "Bernadette", + "Nehemiah", + "Ivar", + "Natala", + "Dorthy", + "Melva", + "Alisha", + "Ruthann", + "Ray", + "Ariel", + "Gib", + "Pippo", + "Miner", + "Ardith", + "Letisha", + "Granger", + "Sue", + "Toby", + "Tallou", + "Stephi", + "Hunter", + "Terrell", + "Pail", + "Moise", + "Rosetta", + "Ira", + "Denyse", + "Jackie", + "Fons", + "Goldy", + "Rani", + "Bendick", + "Valentijn", + "Annabell", + "Ardith", + "Lesly", + "Almire", + "Emmalyn", + "Mechelle", + "Anna", + "Duff", + "Louise", + "Vivian", + "Farand", + "Sophi", + "Thedric", + "Vivien", + "Jere", + "Kassie", + "Andy", + "Helli", + "Ros", + "Babara", + "Othella", + "Shelton", + "Hector", + "Charmian", + "Rosamond", + "Maison", + "Magda", + "Gustave", + "Latisha", + "Erik", + "Gavin", + "Bobette", + "Masha", + "Collie", + "Kippie", + "Jillayne", + "Fairfax", + "Ulrika", + "Juliann", + "Joly", + "Aldus", + "Clarie", + "Aluin", + "Claudetta", + "Noella", + "Nichols", + "Rutger", + "Niall", + "Hunter", + "Hyacinthia", + "Eva", + "Humphrey", + "Randi", + "Leontyne", + "Bordy", + "Orin", + "Tobey", + "Aldis", + "Vernon", + "Griz", + "Dynah", + "Ann-marie", + "Inglebert", + "Gifford", + "Emeline", + "Shem", + "Sigvard", + "Mayne", + "Rhodia", + "Seward", + "Valencia", + "Babara", + "Cirstoforo", + "Nye", + "Merissa", + "Lucinda", + "Wynn", + "Vassili", + "Cletus", + "Felisha", + "Laural", + "William", + "Emmalynne", + "Angy", + "Charles", + "Jemmy", + "Edward", + "Millicent", + "Homer", + "Allie", + "Brandyn", + "Dannye", + "Hector", + "Fawne", + "Frayda", + "Issiah", + "Deana", + "Bearnard", + "Ken", + "Sinclare", + "Mallorie", + "Noby", + "Deonne", + "Brig", + "Ruy", + "Vivia", + "Nyssa", + "Ame", + "Carmen", + "Solly", + "Carolee", + "Felice", + "Claiborne", + "Layney", + "Raina", + "Tami", + "Dosi", + "Barth", + "Julita", + "Gardiner", + "Stesha", + "Geneva", + "Saudra", + "Ella", + "Welbie", + "Marya", + "Happy", + "Brandise", + "Jewell", + "Joana", + "Eddy", + "Buck", + "Leslie", + "Yolanda", + "Murdoch", + "Muffin", + "Myrna", + "Susi", + "Berthe", + "Debra", + "Kyla", + "Bron", + "Thurston", + "Case", + "Shelli", + "Danika", + "Charissa", + "Wylie", + "Corine", + "Caitrin", + "Atalanta", + "Vevay", + "Thekla", + "Inez", + "Pris", + "Zsazsa", + "Ardenia", + "Ole", + "Kelcy", + "Earl", + "Pierson", + "Opalina", + "Leta", + "Keefer", + "Conrado", + "Chen", + "Alys", + "Floyd", + "Kai", + "Warden", + "Peyton", + "Debora", + "Walton", + "Fionna", + "Kendra", + "Michail", + "Christa", + "Theodor", + "Avivah", + "Patric", + "Quinton", + "Fey", + "Lewiss", + "Loren", + "Nedi", + "Fergus", + "Jeanie", + "Liuka", + "Ashley", + "Ellsworth", + "Winslow", + "Land", + "Rooney", + "Kati", + "Joelie", + "Garner", + "Clarice", + "Clair", + "Heddi", + "Ivan", + "Enrichetta", + "Umberto", + "Alys", + "Marcellina", + "Elnore", + "Wilburt", + "Ami", + "Meridith", + "Devlin", + "Cicely", + "Nathanael", + "Rafi", + "Arluene", + "Erasmus", + "Tasia", + "Seumas", + "George", + "Fredrika", + "Jayne", + "Linus", + "Mathilde", + "Klarrisa", + "Willy", + "Rad", + "Rae", + "Wilfred", + "Amberly", + "Paulo", + "Robbi", + "Gladys", + "Mirilla", + "Danica", + "Montgomery", + "Bellina", + "Neill", + "Roddie", + "Sebastiano", + "Adrianne", + "Gilli", + "Rhodia", + "Orbadiah", + "Levy", + "Griswold", + "Millicent", + "Carry", + "Alexander", + "Carole", + "Othilie", + "Enrica", + "Corissa", + "Meaghan", + "Margret", + "Sheff", + "Walton", + "Tremain", + "Bear", + "Maximilian", + "Theodora", + "Fredric", + "Baudoin", + "Rees", + "Roldan", + "Mayor", + "Angelica", + "Clemente", + "Florencia", + "Lancelot", + "Valencia", + "Caddric", + "Frieda", + "Jarvis", + "Shamus", + "Kalindi", + "Allen", + "Maureen", + "Ax", + "Barbra", + "Craggy", + "Howie", + "Orson", + "Cammy", + "Sullivan", + "Marleen", + "Jarrad", + "Lucy", + "Catha", + "Guillemette", + "Birdie", + "Forrest", + "Luce", + "Myriam", + "Serge", + "Kali", + "Ruperto", + "Trisha", + "Shaylynn", + "Janella", + "Franciskus", + "Melinde", + "Effie", + "Letti", + "Roderic", + "Jandy", + "Michaelina", + "Mohammed", + "Dolorita", + "Elbertine", + "Esma", + "Emmett", + "Lucila", + "Joyann", + "Mufi", + "Karlotta", + "Vannie", + "Daphna", + "Blondie", + "Madelene", + "Tomkin", + "Kassie", + "Flynn", + "Zebadiah", + "Lauritz", + "Brian", + "Leah", + "Amalita", + "Corissa", + "Onfre", + "Shantee", + "Deena", + "Marena", + "Alejoa", + "Fania", + "Catha", + "Cherlyn", + "Gerrilee", + "Brook", + "Yardley", + "Karry", + "Dennis", + "Ingra", + "Damian", + "Alexandros", + "Romola", + "Grantley", + "Antons", + "Randal", + "Lorilee", + "Brier", + "Tyrone", + "Jennica", + "Deidre", + "Arlin", + "Marline", + "Lyell", + "Lorelei", + "Marius", + "Willy", + "Teddy", + "Grantham", + "Yelena", + "Jaimie", + "Brewer", + "Tess", + "Othelia", + "Bondy", + "Rebecka", + "Laurice", + "Jasen", + "Betty", + "Alverta", + "Pepita", + "Kandace", + "Loni", + "Doreen", + "Ketty", + "Ree", + "Danni", + "Zorah", + "Shayla", + "Ivy", + "Darin", + "Karie", + "Brittaney", + "Viole", + "Harlene", + "Jasun", + "Aime", + "Rickie", + "Heath", + "Andris", + "Vaughn", + "Giorgi", + "Maddalena", + "Shirley", + "Cherie", + "Zacharia", + "Darcey", + "Barbee", + "Ernest", + "Sher", + "Faustina", + "Nari", + "Gusella", + "Reginald", + "Zack", + "Michele", + "Gene", + "Lindy", + "Mirilla", + "Tudor", + "Tyler", + "Bernadina", + "Magdalen", + "Nollie", + "Coreen", + "Hoebart", + "Virginie", + "Waylin", + "Hank", + "Valenka", + "Sabine", + "Jesus", + "Annabell", + "Jesselyn", + "Marysa", + "Corbett", + "Carena", + "Bert", + "Tanhya", + "Alphonse", + "Johnette", + "Vince", + "Cordell", + "Ramonda", + "Trev", + "Glenna", + "Loy", + "Arni", + "Tedd", + "Tristam", + "Zelma", + "Emmeline", + "Ellswerth", + "Janeta", + "Hughie", + "Tarun", + "Enid", + "Rafe", + "Hal", + "Melissa", + "Layan", + "Sia", + "Horace", + "Derry", + "Kelsi", + "Zacharia", + "Tillie", + "Dillon", + "Maxwell", + "Shanai", + "Charlize", + "Usama", + "Nabeela", + "Emily-Jane", + "Martyn", + "Tre", + "Ioan", + "Elysia", + "Mikaeel", + "Danny", + "Ciaron", + "Ace", + "Amy-Louise", + "Gabrielle", + "Robbie", + "Thea", + "Gloria", + "Jana", + "Cole", + "Eamon", + "Samiyah", + "Ellie-Mai", + "Lawson", + "Gia", + "Merryn", + "Andre", + "Ansh", + "Kavita", + "Alasdair", + "Aamina", + "Donna", + "Dario", + "Sahra", + "Brittany", + "Shakeel", + "Taylor", + "Ellenor", + "Kacy", + "Gene", + "Hetty", + "Fletcher", + "Donte", + "Krisha", + "Everett", + "Leila", + "Aairah", + "Zander", + "Sakina", + "Sanaya", + "Nelly", + "Manon", + "Antonio", + "Aimie", + "Kyran", + "Daria", + "Tilly-Mae", + "Lisa", + "Ammaarah", + "Adina", + "Kaan", + "Torin", + "Sadie", + "Mia-Rose", + "Aadam", + "Phyllis", + "Jace", + "Fraser", + "Tamanna", + "Dahlia", + "Cristian", + "Maira", + "Lana", + "Lily-Mai", + "Barney", + "Beatrice", + "Tabitha", + "Anis", + "Heidi", + "Ahyan", + "Usaamah", + "Jolene", + "Melisa", + "Magdalena", + "Hina" + ], + "last_names": [ + "Silveston", + "Manson", + "Hoodlass", + "Auden", + "Speakman", + "Seavers", + "Sodeau", + "Gouth", + "Pickersail", + "Ferschke", + "Buzzing", + "Kinnar", + "Pemberton", + "Firebrace", + "Kornilyev", + "Linsley", + "Petyanin", + "McCobb", + "Disdel", + "Eskrick", + "Pringuer", + "Clavering", + "Sims", + "Lippitt", + "Springall", + "Spiteri", + "Dwyr", + "Tomas", + "Cleminson", + "Crowder", + "Juster", + "Leven", + "Doucette", + "Schimoni", + "Readwing", + "Karet", + "Reef", + "Welden", + "Bemand", + "Schulze", + "Bartul", + "Collihole", + "Thain", + "Bernhardt", + "Tolputt", + "Hedges", + "Lowne", + "Kobu", + "Cabrera", + "Gavozzi", + "Ghilardini", + "Leamon", + "Gadsden", + "Gregg", + "Tew", + "Bangle", + "Youster", + "Vince", + "Cristea", + "Ablott", + "Lightowlers", + "Kittredge", + "Armour", + "Bukowski", + "Knowlton", + "Juett", + "Santorini", + "Ends", + "Hawkings", + "Janowicz", + "Harry", + "Bougourd", + "Gillow", + "Whalebelly", + "Conneau", + "Mellows", + "Stolting", + "Stickells", + "Maryet", + "Echallie", + "Edgecombe", + "Orchart", + "Mowles", + "McGibbon", + "Titchen", + "Madgewick", + "Fairburne", + "Colgan", + "Chaudhry", + "Taks", + "Lorinez", + "Eixenberger", + "Burel", + "Chapleo", + "Margram", + "Purse", + "MacKay", + "Oxlade", + "Prahm", + "Wellbank", + "Blackborow", + "Woodbridge", + "Sodory", + "Vedmore", + "Beeckx", + "Newcomb", + "Ridel", + "Desporte", + "Jobling", + "Winear", + "Korneichuk", + "Aucott", + "Wawer", + "Aicheson", + "Hawkslee", + "Wynes", + "St. Quentin", + "McQuorkel", + "Hendrick", + "Rudsdale", + "Winsor", + "Thunders", + "Stonbridge", + "Perrie", + "D'Alessandro", + "Banasevich", + "Mc Elory", + "Cobbledick", + "Wreakes", + "Carnie", + "Pallister", + "Yeates", + "Hoovart", + "Doogood", + "Churn", + "Gillon", + "Nibley", + "Dusting", + "Melledy", + "O'Noland", + "Crosfeld", + "Pairpoint", + "Longson", + "Rodden", + "Foyston", + "Le Teve", + "Brumen", + "Pudsey", + "Klimentov", + "Agent", + "Seabert", + "Cramp", + "Bitcheno", + "Embery", + "Etheredge", + "Sheardown", + "McKune", + "Vearncomb", + "Lavington", + "Rylands", + "Derges", + "Olivetti", + "Matasov", + "Thrower", + "Jobin", + "Ramsell", + "Rude", + "Tregale", + "Bradforth", + "McQuarter", + "Walburn", + "Poad", + "Filtness", + "Carneck", + "Pavis", + "Pinchen", + "Polye", + "Abry", + "Radloff", + "McDugal", + "Loughton", + "Revitt", + "Baniard", + "Kovalski", + "Mapother", + "Hendrikse", + "Rickardsson", + "Featherbie", + "Harlow", + "Kruschov", + "McCrillis", + "Barabich", + "Peaker", + "Skamell", + "Gorges", + "Chance", + "Bresner", + "Profit", + "Swinfon", + "Goldson", + "Nunson", + "Tarling", + "Ruperti", + "Grimsell", + "Davey", + "Deetlof", + "Gave", + "Fawltey", + "Tyre", + "Whaymand", + "Trudgian", + "McAndrew", + "Aleksankov", + "Dimbleby", + "Beseke", + "Cleverley", + "Aberhart", + "Courtin", + "MacKellen", + "Johannesson", + "Churm", + "Laverock", + "Astbury", + "Canto", + "Nelles", + "Dormand", + "Blucher", + "Youngs", + "Dalrymple", + "M'Chirrie", + "Jansens", + "Golthorpp", + "Ibberson", + "Andriveau", + "Paulton", + "Parrington", + "Shergill", + "Bickerton", + "Hugonneau", + "Cornelissen", + "Spincks", + "Malkinson", + "Kettow", + "Wasiel", + "Skeat", + "Maynard", + "Goutcher", + "Cratchley", + "Loving", + "Averies", + "Cahillane", + "Alvarado", + "Truggian", + "Bravington", + "McGonigle", + "Crocombe", + "Slorance", + "Dukes", + "Nairns", + "Condict", + "Got", + "Flowerdew", + "Deboy", + "Death", + "Patroni", + "Colgrave", + "Polley", + "Spraging", + "Orteaux", + "Daskiewicz", + "Dunsmore", + "Forrington", + "De Gogay", + "Swires", + "Grimmert", + "Castells", + "Scraggs", + "Chase", + "Dixsee", + "Brennans", + "Gookes", + "MacQueen", + "Galbreth", + "Buttwell", + "Annear", + "Sutherley", + "Portis", + "Pashen", + "Blackbourn", + "Sedgemond", + "Huegett", + "Emms", + "Leifer", + "Paschek", + "Bynold", + "Mahony", + "Izacenko", + "Hadland", + "Sallows", + "Hamper", + "Godlee", + "Rablin", + "Emms", + "Zealy", + "Russi", + "Crassweller", + "Shotbolt", + "Van Der Weedenburg", + "MacGille", + "Carillo", + "Guerin", + "Cuolahan", + "Metzel", + "Martinovsky", + "Stoggles", + "Brameld", + "Coupland", + "Kaaskooper", + "Sallows", + "Rizzotto", + "Dike", + "O'Lochan", + "Spragg", + "Lavarack", + "MacNess", + "Swetenham", + "Dillet", + "Coffey", + "Meikle", + "Loynes", + "Josum", + "Adkin", + "Tompsett", + "Maclaine", + "Fippe", + "Bispo", + "Whittek", + "Rylett", + "Iveagh", + "Elgar", + "Casswell", + "Tilt", + "Macklin", + "Lillee", + "Hamshere", + "Coite", + "Dollard", + "Tiesman", + "Coltart", + "Stothert", + "Crosswaite", + "Padgett", + "Gleadle", + "Meedendorpe", + "Alexsandrovich", + "Williamson", + "Futty", + "Antwis", + "Romanski", + "Dionisetti", + "Dimitriev", + "Swalowe", + "Dewing", + "O'Driscoll", + "Jeandel", + "Summerly", + "Shoute", + "Trelevan", + "Matkin", + "Headey", + "Rosson", + "Dunn", + "Gunner", + "Stapells", + "Fratczak", + "McGillivray", + "Edis", + "Treuge", + "Haskayne", + "Perell", + "O'Fairy", + "Slisby", + "Axcell", + "Mattingley", + "Tumilty", + "Kibble", + "Lambert", + "Hassall", + "Simpkin", + "Nitti", + "Stiegar", + "Pavitt", + "Kerby", + "Ruzic", + "Westwick", + "Tonbye", + "Bocken", + "Kinforth", + "Wren", + "Attow", + "McComish", + "McNickle", + "Wildman", + "O'Corhane", + "Jewar", + "Caveau", + "Woodrooffe", + "Batson", + "Stayt", + "A'field", + "Domesday", + "Taberer", + "Gigg", + "Stanmore", + "Hanton", + "Roskell", + "Brasener", + "Stanbro", + "Cordy", + "O'Bradane", + "Hansberry", + "Erdes", + "Wagon", + "Jimmes", + "Ruffles", + "Wigginton", + "Haste", + "Rymill", + "Tomsett", + "Ambrosoli", + "Reidshaw", + "Nurcombe", + "Costigan", + "Berwick", + "Hinchon", + "Blissitt", + "Golston", + "Goullee", + "Hudspeth", + "Traher", + "Salandino", + "Fatscher", + "Davidov", + "Baukham", + "Mallan", + "Kilmurray", + "Dmych", + "Mair", + "Felmingham", + "Kedward", + "Leechman", + "Frank", + "Tremoulet", + "Manley", + "Newcom", + "Brandone", + "Cliffe", + "Shorte", + "Baalham", + "Fairhead", + "Sheal", + "Effnert", + "MacCaughey", + "Rizzolo", + "Linthead", + "Greenhouse", + "Clayson", + "Franca", + "Lambell", + "Egdal", + "Pringell", + "Penni", + "Train", + "Langfitt", + "Dady", + "Rannigan", + "Ledwidge", + "Summerton", + "D'Hooghe", + "Ary", + "Gooderick", + "Scarsbrooke", + "Janouch", + "Pond", + "Menichini", + "Crinidge", + "Sneesbie", + "Harflete", + "Ubsdell", + "Littleover", + "Vanne", + "Fassbender", + "Zellner", + "Gorce", + "McKeighan", + "Claffey", + "MacGarvey", + "Norwich", + "Antosch", + "Loughton", + "McCuthais", + "Arnaudi", + "Broz", + "Stert", + "McMechan", + "Texton", + "Bees", + "Couser", + "Easseby", + "McCorry", + "Fetterplace", + "Crankshaw", + "Spancock", + "Neasam", + "Bruckental", + "Badgers", + "Rodda", + "Bossingham", + "Crump", + "Jurgensen", + "Noyes", + "Scarman", + "Bakey", + "Swindin", + "Tolworthie", + "Vynehall", + "Shallcrass", + "Bazoge", + "Jonczyk", + "Eatherton", + "Finlason", + "Hembery", + "Lassetter", + "Soule", + "Baldocci", + "Thurman", + "Poppy", + "Eveque", + "Summerlad", + "Eberle", + "Pettecrew", + "Hitzmann", + "Allonby", + "Bodimeade", + "Catteroll", + "Wooldridge", + "Baines", + "Halloway", + "Doghartie", + "Bracher", + "Kynnd", + "Metherell", + "Routham", + "Fielder", + "Ashleigh", + "Aked", + "Kolakowski", + "Picardo", + "Murdy", + "Feacham", + "Lewin", + "Braben", + "Salaman", + "Letterick", + "Bovaird", + "Moriarty", + "Bertot", + "Cowan", + "Dionisi", + "Maybey", + "Joskowicz", + "Shoutt", + "Bernli", + "Dikles", + "Corringham", + "Shaw", + "Donovin", + "Merigeau", + "Pinckney", + "Queripel", + "Sampson", + "Benfell", + "Cansdell", + "Tasseler", + "Amthor", + "Nancekivell", + "Stock", + "Boltwood", + "Goreisr", + "Le Grand", + "Terrans", + "Knapp", + "Roseman", + "Gunstone", + "Hissie", + "Orto", + "Bell", + "Colam", + "Drust", + "Roseblade", + "Sulman", + "Jennaway", + "Joust", + "Curthoys", + "Cajkler", + "MacIllrick", + "Print", + "Coulthard", + "Lemmon", + "Bush", + "McMurrugh", + "Toping", + "Brute", + "Fryman", + "Bosomworth", + "Lawson", + "Lauder", + "Heinssen", + "Bittlestone", + "Brinson", + "Hambling", + "Vassman", + "Brookbank", + "Bolstridge", + "Leslie", + "Berndsen", + "Aindrais", + "Mogra", + "Wilson", + "Josefs", + "Norgan", + "Wong", + "le Keux", + "Hastwall", + "Bunson", + "Van", + "Waghorne", + "Ojeda", + "Boole", + "Winters", + "Gurge", + "Gallemore", + "Perulli", + "Dight", + "Di Filippo", + "Winsley", + "Chalcraft", + "Human", + "Laetham", + "Lennie", + "McSorley", + "Toolan", + "Brammar", + "Cadogan", + "Molloy", + "Shoveller", + "Vignaux", + "Hannaway", + "Sykora", + "Brealey", + "Harness", + "Profit", + "Goldsbury", + "Brands", + "Godmar", + "Binden", + "Kondratenya", + "Warsap", + "Rumble", + "Maudson", + "Demer", + "Laxtonne", + "Kmietsch", + "Colten", + "Raysdale", + "Gadd", + "Blanche", + "Viant", + "Daskiewicz", + "Macura", + "Crouch", + "Janicijevic", + "Oade", + "Fancourt", + "Dimitriev", + "Earnshaw", + "Wing", + "Fountain", + "Fearey", + "Nottram", + "Bescoby", + "Jeandeau", + "Mapowder", + "Iacobo", + "Rabjohns", + "Dean", + "Whiterod", + "Mathiasen", + "Josephson", + "Boc", + "Olivet", + "Yeardley", + "Labuschagne", + "Curmi", + "Rogger", + "Tesoe", + "Mellhuish", + "Malan", + "McArt", + "Ing", + "Renowden", + "Mellsop", + "Critchlow", + "Seedhouse", + "Tiffin", + "Chirm", + "Oldknow", + "Wolffers", + "Dainter", + "Bundy", + "Copplestone", + "Moses", + "Weedon", + "Borzone", + "Craigg", + "Pyrah", + "Shoorbrooke", + "Jeandeau", + "Halgarth", + "Bamlett", + "Greally", + "Abrahamovitz", + "Oger", + "Mandrake", + "Craigg", + "Stenning", + "Tommei", + "Mapother", + "Cree", + "Clandillon", + "Thorlby", + "Careswell", + "Woolnough", + "McMeekin", + "Woodman", + "Mougin", + "Burchill", + "Pegg", + "Morin", + "Eskriett", + "Gelderd", + "Latham", + "Siney", + "Freen", + "Walrond", + "Bell", + "Twigley", + "D'Souza", + "Anton", + "Doyle", + "Pieters", + "Rosenvasser", + "Mackneis", + "Brisse", + "Boffin", + "Rushe", + "Cozens", + "Bensusan", + "Plampin", + "Gauford", + "Lecky", + "Belton", + "Fleming", + "Gent", + "Bunclark", + "Climar", + "Milner", + "Karolovsky", + "Claesens", + "Oleksiak", + "Barkway", + "Glenister", + "Steynor", + "Hecks", + "Rollo", + "Elcoux", + "Altham", + "Veschambes", + "Livingstone", + "Miroy", + "Edy", + "Bendle", + "Widdall", + "Onions", + "Devita", + "McOwan", + "Ahearne", + "Wisniowski", + "Pask", + "Ciccottini", + "Parlatt", + "Gindghill", + "Marquess", + "Claworth", + "Veel", + "Fairbairn", + "Galletley", + "Glew", + "Gillice", + "Liddyard", + "Babin", + "Ryson", + "Kyteley", + "Toms", + "Downton", + "Mougel", + "Inglefield", + "Gaskins", + "Bradie", + "Stanbury", + "McMenamy", + "Cranstone", + "Thody", + "Iacovozzo", + "Theobalds", + "Perrins", + "Dyott", + "Hupe", + "Gelling", + "Eadington", + "Crumbie", + "Stainsby", + "Kolakowski", + "Norwich", + "Ehrat", + "Basnett", + "Marden", + "Godby", + "Kubacki", + "Wiles", + "Littrick", + "Chuck", + "Negus", + "Aisthorpe", + "Danelut", + "Helversen", + "McCombe", + "Dallender", + "Offner", + "Leser", + "Savin", + "Belcham", + "Pockett", + "Selway", + "Santostefano.", + "Telford", + "Presser", + "Haken", + "Wybourne", + "Reolfo", + "Mineghelli", + "Beverage", + "Grimsdike", + "Drogan", + "Bynert", + "Boothman", + "Postle", + "Baskwell", + "Branno", + "Hechlin", + "Geake", + "Morstatt", + "Towne", + "Phillott", + "Doumerc", + "Ladewig", + "Sexty", + "Sleigh", + "Simonaitis", + "Han", + "Crommett", + "Blowes", + "Floyde", + "Delgardo", + "Brounsell", + "Klimowski", + "Jaffray", + "Kingzeth", + "Pithie", + "Eriksson", + "Gudgin", + "Hamal", + "Hooks", + "Rosle", + "Braysher", + "O'Curneen", + "Millett", + "Woofinden", + "Lillistone", + "Broxis", + "Mochar", + "Drewell", + "Hedgeman", + "Wharf", + "Lambden", + "Lambol", + "Slowcock", + "Cicchillo", + "Trineman", + "Sinyard", + "Brandone", + "Masding", + "Britnell", + "Quinlan", + "Arnopp", + "Jeratt", + "Bantick", + "Craigs", + "Pantling", + "Klais", + "Pickvance", + "Goodwill", + "McGavin", + "Esslemont", + "Bakewell", + "Downer", + "Scallan", + "Ronchka", + "Scholcroft", + "Van Der Walt", + "Armfield", + "Chalker", + "Chinge", + "Yakubov", + "Folkerd", + "Manon", + "Gookey", + "Connold", + "Dusey", + "Muselli", + "Skala", + "Dibbin", + "Kreber", + "De Blasi", + "Drei", + "Argo", + "Maudson", + "Stanlick", + "Steinham", + "Dallewater", + "Litchmore", + "Mathie", + "Gook", + "Forrestor", + "Ferreira", + "Budd", + "Joskowitz", + "Whetnall", + "Beany", + "Keymar", + "Merrin", + "Waldera", + "O'Gleasane", + "Duiged", + "Cumo", + "Giddings", + "Craker", + "Olenov", + "Whayman", + "Raoux", + "Delete", + "McDell", + "Gauntlett", + "Gomby", + "Rottgers", + "Spraggon", + "Orth", + "Shortan", + "Lineen", + "Monkhouse", + "Di Domenico", + "Brinsden", + "MacCallister", + "Sieghard", + "Pheasant", + "Cloney", + "Igglesden", + "Checklin", + "Grosier", + "Garnett", + "Vasnetsov", + "Chsteney", + "Manifield", + "Coutts", + "Bagshawe", + "Pryn", + "Dunstall", + "Rowlings", + "Whines", + "Bish", + "Solomon", + "Mackay", + "Daugherty", + "Gutierrez", + "Goff", + "Villanueva", + "Heath", + "Serrano", + "Munro", + "Levine", + "Barrett", + "Bateman", + "Colon", + "Alford", + "Whitehouse", + "Mendoza", + "Keith", + "Orr", + "Shepherd", + "North", + "Steele", + "Morales", + "Shea", + "Olsen", + "Wormald", + "Torres", + "Haines", + "Kerr", + "Reeves", + "Bates", + "Potts", + "Foreman", + "Herrera", + "Mccoy", + "Fulton", + "Charles", + "Clay", + "Estes", + "Mata", + "Childs", + "Kendall", + "Wallace", + "Thorpe", + "Oconnell", + "Waters", + "Roth", + "Barker", + "Fritz", + "Singleton", + "Sharpe", + "Little", + "Oliver", + "Ayala", + "Khan", + "Braun", + "Dean", + "Stout", + "Adamson", + "Tate", + "Juarez", + "Pickett", + "Burke", + "Gordon", + "Mackenzie", + "Bloggs", + "Read", + "Britton", + "Jefferson", + "Lutz", + "Chen", + "Wagstaff", + "Coates", + "Gilliam", + "Mullins", + "Ryan", + "Moon", + "Thompson", + "Abbott", + "Cotton", + "Barajas", + "Chan", + "Bostock", + "Spencer", + "Sparrow", + "Robinson", + "Morrison", + "Aguirre", + "Clayton", + "Hope", + "Swanson", + "Ochoa", + "Ruiz", + "Truong", + "Gibbons", + "Daniel", + "Zimmerman", + "Flynn", + "Keeling", + "Greenaway", + "Edwards" + ] +} -- cgit v1.2.3 From c654f693eda0db6b12e8bafa6ef0354ca4f43245 Mon Sep 17 00:00:00 2001 From: aruna2019 Date: Thu, 21 Jan 2021 01:13:52 +0530 Subject: Fixed battleship reading uppercases incorrectly. --- bot/exts/evergreen/battleship.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/evergreen/battleship.py b/bot/exts/evergreen/battleship.py index 9bc374e6..fa3fb35c 100644 --- a/bot/exts/evergreen/battleship.py +++ b/bot/exts/evergreen/battleship.py @@ -140,7 +140,7 @@ class Game: @staticmethod def get_square(grid: Grid, square: str) -> Square: """Grabs a square from a grid with an inputted key.""" - index = ord(square[0]) - ord("A") + index = ord(square[0].upper()) - ord("A") number = int(square[1:]) return grid[number-1][index] # -1 since lists are indexed from 0 -- cgit v1.2.3 From f085c3ae55f4ecba3cd3b68b2b2ce28680533c3d Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Fri, 22 Jan 2021 11:21:44 +0100 Subject: Update the README badges --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3301ccda..6b453879 100755 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ # Sir Lancebot +[![Discord][5]][6] [![Lint Badge][1]][2] [![Build Badge][3]][4] -[![Discord](https://img.shields.io/static/v1?label=Python%20Discord&logo=discord&message=%3E100k%20members&color=%237289DA&logoColor=white)](https://discord.gg/2B963hn) +[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE) ![Header](sir-lancebot-logo.png) @@ -25,3 +26,5 @@ See [Sir Lancebot's Wiki](https://pythondiscord.com/pages/contributing/sir-lance [2]:https://github.com/python-discord/sir-lancebot/actions?query=workflow%3ALint+branch%3Amaster [3]:https://github.com/python-discord/sir-lancebot/workflows/Build/badge.svg?branch=master [4]:https://github.com/python-discord/sir-lancebot/actions?query=workflow%3ABuild+branch%3Amaster +[5]: https://raw.githubusercontent.com/python-discord/branding/master/logos/badge/badge_github.svg +[6]: https://discord.gg/python -- cgit v1.2.3