diff options
| -rw-r--r-- | bot/constants.py | 1 | ||||
| -rw-r--r-- | bot/exts/avatar_modification/avatar_modify.py | 2 | ||||
| -rw-r--r-- | bot/exts/events/advent_of_code/_cog.py | 109 | ||||
| -rw-r--r-- | bot/exts/events/advent_of_code/_helpers.py | 60 | ||||
| -rw-r--r-- | bot/exts/events/advent_of_code/views/dayandstarview.py | 11 | ||||
| -rw-r--r-- | bot/exts/holidays/hanukkah/hanukkah_embed.py | 84 | ||||
| -rw-r--r-- | bot/log.py | 5 | 
7 files changed, 163 insertions, 109 deletions
| diff --git a/bot/constants.py b/bot/constants.py index 2b41b8a4..33bc8b61 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -135,6 +135,7 @@ class Client(NamedTuple):      prefix = environ.get("PREFIX", ".")      token = environ.get("BOT_TOKEN")      debug = environ.get("BOT_DEBUG", "true").lower() == "true" +    file_logs = environ.get("FILE_LOGS", "false").lower() == "true"      github_bot_repo = "https://github.com/python-discord/sir-lancebot"      # Override seasonal locks: 1 (January) to 12 (December)      month_override = int(environ["MONTH_OVERRIDE"]) if "MONTH_OVERRIDE" in environ else None diff --git a/bot/exts/avatar_modification/avatar_modify.py b/bot/exts/avatar_modification/avatar_modify.py index fbee96dc..3ee70cfd 100644 --- a/bot/exts/avatar_modification/avatar_modify.py +++ b/bot/exts/avatar_modification/avatar_modify.py @@ -286,7 +286,7 @@ class AvatarModify(commands.Cog):      @avatar_modify.command(          aliases=("savatar", "spookify"),          root_aliases=("spookyavatar", "spookify", "savatar"), -        brief="Spookify an user's avatar." +        brief="Spookify a user's avatar."      )      async def spookyavatar(self, ctx: commands.Context) -> None:          """This "spookifies" the user's avatar, with a random *spooky* effect.""" diff --git a/bot/exts/events/advent_of_code/_cog.py b/bot/exts/events/advent_of_code/_cog.py index 2c1f4541..af1cbcf5 100644 --- a/bot/exts/events/advent_of_code/_cog.py +++ b/bot/exts/events/advent_of_code/_cog.py @@ -100,32 +100,26 @@ class AdventOfCode(commands.Cog):      @whitelist_override(channels=AOC_WHITELIST)      async def aoc_countdown(self, ctx: commands.Context) -> None:          """Return time left until next day.""" -        if not _helpers.is_in_advent(): -            datetime_now = arrow.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 = arrow.get(datetime(datetime_now.year, 12, 1), _helpers.EST) -            next_year = arrow.get(datetime(datetime_now.year + 1, 12, 1), _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 - -            # 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" +        if _helpers.is_in_advent(): +            tomorrow, _ = _helpers.time_left_to_est_midnight() +            next_day_timestamp = int(tomorrow.timestamp()) -            await ctx.send( -                "The Advent of Code event is not currently running. " -                f"The next event will start in {delta_str}." -            ) +            await ctx.send(f"Day {tomorrow.day} starts <t:{next_day_timestamp}:R>.")              return -        tomorrow, time_left = _helpers.time_left_to_est_midnight() +        datetime_now = arrow.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 = arrow.get(datetime(datetime_now.year, 12, 1), _helpers.EST) +        next_year = arrow.get(datetime(datetime_now.year + 1, 12, 1), _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 -        hours, minutes = time_left.seconds // 3600, time_left.seconds // 60 % 60 +        next_aoc_timestamp = int((datetime_now + delta).timestamp()) -        await ctx.send(f"There are {hours} hours and {minutes} minutes left until day {tomorrow.day}.") +        await ctx.send( +            "The Advent of Code event is not currently running. " +            f"The next event will start <t:{next_aoc_timestamp}:R>." +        )      @adventofcode_group.command(name="about", aliases=("ab", "info"), brief="Learn about Advent of Code")      @whitelist_override(channels=AOC_WHITELIST) @@ -180,24 +174,18 @@ class AdventOfCode(commands.Cog):      @in_month(Month.DECEMBER)      @adventofcode_group.command( -        name="leaderboard", -        aliases=("board", "lb"), -        brief="Get a snapshot of the PyDis private AoC leaderboard", +        name="dayandstar", +        aliases=("daynstar", "daystar"), +        brief="Get a view that lets you filter the leaderboard by day and star",      )      @whitelist_override(channels=AOC_WHITELIST_RESTRICTED) -    async def aoc_leaderboard( +    async def aoc_day_and_star_leaderboard(              self,              ctx: commands.Context, -            day_and_star: Optional[bool] = False, -            maximum_scorers: Optional[int] = 10 +            maximum_scorers_day_and_star: Optional[int] = 10      ) -> None: -        """ -        Get the current top scorers of the Python Discord Leaderboard. - -        Additionally, you can provide an argument `day_and_star` (Boolean) to have the bot send a View -        that will let you filter by day and star. -        """ -        if maximum_scorers > AocConfig.max_day_and_star_results or maximum_scorers <= 0: +        """Have the bot send a View that will let you filter the leaderboard by day and star.""" +        if maximum_scorers_day_and_star > AocConfig.max_day_and_star_results or maximum_scorers_day_and_star <= 0:              raise commands.BadArgument(                  f"The maximum number of results you can query is {AocConfig.max_day_and_star_results}"              ) @@ -207,25 +195,12 @@ class AdventOfCode(commands.Cog):              except _helpers.FetchingLeaderboardFailedError:                  await ctx.send(":x: Unable to fetch leaderboard!")                  return -        if not day_and_star: - -            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) -            return -          # This is a dictionary that contains solvers in respect of day, and star.          # e.g. 1-1 means the solvers of the first star of the first day and their completion time          per_day_and_star = json.loads(leaderboard['leaderboard_per_day_and_star'])          view = AoCDropdownView(              day_and_star_data=per_day_and_star, -            maximum_scorers=maximum_scorers, +            maximum_scorers=maximum_scorers_day_and_star,              original_author=ctx.author          )          message = await ctx.send( @@ -237,6 +212,44 @@ class AdventOfCode(commands.Cog):      @in_month(Month.DECEMBER)      @adventofcode_group.command( +        name="leaderboard", +        aliases=("board", "lb"), +        brief="Get a snapshot of the PyDis private AoC leaderboard", +    ) +    @whitelist_override(channels=AOC_WHITELIST_RESTRICTED) +    async def aoc_leaderboard( +            self, +            ctx: commands.Context, +            self_placement_name: Optional[str] = None, +    ) -> None: +        """ +        Get the current top scorers of the Python Discord Leaderboard. + +        Additionally you can specify a `self_placement_name` +        that will append the specified profile's personal stats to the top of the leaderboard +        """ +        async with ctx.typing(): +            try: +                leaderboard = await _helpers.fetch_leaderboard(self_placement_name=self_placement_name) +            except _helpers.FetchingLeaderboardFailedError: +                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) +        self_placement_header = "(and your personal stats compared to the top 10)" if self_placement_name else "" +        header = f"Here's our current top {top_count}{self_placement_header}! {Emojis.christmas_tree * 3}" +        table = "```\n" \ +                f"{leaderboard['placement_leaderboard'] if self_placement_name else leaderboard['top_leaderboard']}" \ +                "\n```" +        info_embed = _helpers.get_summary_embed(leaderboard) + +        await ctx.send(content=f"{header}\n\n{table}", embed=info_embed) +        return + +    @in_month(Month.DECEMBER) +    @adventofcode_group.command(          name="global",          aliases=("globalboard", "gb"),          brief="Get a link to the global leaderboard", diff --git a/bot/exts/events/advent_of_code/_helpers.py b/bot/exts/events/advent_of_code/_helpers.py index af64bc81..35258544 100644 --- a/bot/exts/events/advent_of_code/_helpers.py +++ b/bot/exts/events/advent_of_code/_helpers.py @@ -10,6 +10,7 @@ from typing import Any, Optional  import aiohttp  import arrow  import discord +from discord.ext import commands  from bot.bot import Bot  from bot.constants import AdventOfCode, Channels, Colours @@ -70,6 +71,33 @@ class FetchingLeaderboardFailedError(Exception):      """Raised when one or more leaderboards could not be fetched at all.""" +def _format_leaderboard_line(rank: int, data: dict[str, Any], *, is_author: bool) -> str: +    """ +    Build a string representing a line of the leaderboard. + +    Parameters: +        rank: +            Rank in the leaderboard of this entry. + +        data: +            Mapping with entry information. + +    Keyword arguments: +        is_author: +            Whether to address the name displayed in the returned line +            personally. + +    Returns: +        A formatted line for the leaderboard. +    """ +    return AOC_TABLE_TEMPLATE.format( +        rank=rank, +        name=data['name'] if not is_author else f"(You) {data['name']}", +        score=str(data['score']), +        stars=f"({data['star_1']}, {data['star_2']})" +    ) + +  def leaderboard_sorting_function(entry: tuple[str, dict]) -> tuple[int, int]:      """      Provide a sorting value for our leaderboard. @@ -160,10 +188,23 @@ def _parse_raw_leaderboard_data(raw_leaderboard_data: dict) -> dict:      return {"daily_stats": daily_stats, "leaderboard": sorted_leaderboard, 'per_day_and_star': per_day_star_stats} -def _format_leaderboard(leaderboard: dict[str, dict]) -> str: +def _format_leaderboard(leaderboard: dict[str, dict], self_placement_name: str = None) -> str:      """Format the leaderboard using the AOC_TABLE_TEMPLATE."""      leaderboard_lines = [HEADER] +    self_placement_exists = False      for rank, data in enumerate(leaderboard.values(), start=1): +        if self_placement_name and data["name"].lower() == self_placement_name.lower(): +            leaderboard_lines.insert( +                1, +                AOC_TABLE_TEMPLATE.format( +                    rank=rank, +                    name=f"(You) {data['name']}", +                    score=str(data["score"]), +                    stars=f"({data['star_1']}, {data['star_2']})" +                ) +            ) +            self_placement_exists = True +            continue          leaderboard_lines.append(              AOC_TABLE_TEMPLATE.format(                  rank=rank, @@ -172,7 +213,10 @@ def _format_leaderboard(leaderboard: dict[str, dict]) -> str:                  stars=f"({data['star_1']}, {data['star_2']})"              )          ) - +    if self_placement_name and not self_placement_exists: +        raise commands.BadArgument( +            "Sorry, your profile does not exist in this leaderboard." +        )      return "\n".join(leaderboard_lines) @@ -260,7 +304,7 @@ def _get_top_leaderboard(full_leaderboard: str) -> str:  @_caches.leaderboard_cache.atomic_transaction -async def fetch_leaderboard(invalidate_cache: bool = False) -> dict: +async def fetch_leaderboard(invalidate_cache: bool = False, self_placement_name: str = None) -> dict:      """      Get the current Python Discord combined leaderboard. @@ -270,7 +314,6 @@ async def fetch_leaderboard(invalidate_cache: bool = False) -> dict:      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. @@ -289,6 +332,7 @@ async def fetch_leaderboard(invalidate_cache: bool = False) -> dict:          leaderboard_fetched_at = datetime.datetime.utcnow().isoformat()          cached_leaderboard = { +            "placement_leaderboard": json.dumps(raw_leaderboard_data),              "full_leaderboard": formatted_leaderboard,              "top_leaderboard": _get_top_leaderboard(formatted_leaderboard),              "full_leaderboard_url": full_leaderboard_url, @@ -307,7 +351,13 @@ async def fetch_leaderboard(invalidate_cache: bool = False) -> dict:                  _caches.leaderboard_cache.namespace,                  AdventOfCode.leaderboard_cache_expiry_seconds              ) - +    if self_placement_name: +        formatted_placement_leaderboard = _parse_raw_leaderboard_data( +            json.loads(cached_leaderboard["placement_leaderboard"]) +        )["leaderboard"] +        cached_leaderboard["placement_leaderboard"] = _get_top_leaderboard( +            _format_leaderboard(formatted_placement_leaderboard, self_placement_name=self_placement_name) +        )      return cached_leaderboard diff --git a/bot/exts/events/advent_of_code/views/dayandstarview.py b/bot/exts/events/advent_of_code/views/dayandstarview.py index 243db32e..a0bfa316 100644 --- a/bot/exts/events/advent_of_code/views/dayandstarview.py +++ b/bot/exts/events/advent_of_code/views/dayandstarview.py @@ -17,14 +17,19 @@ class AoCDropdownView(discord.ui.View):          self.original_author = original_author      def generate_output(self) -> str: -        """Generates a formatted codeblock with AoC statistics based on the currently selected day and star.""" +        """ +        Generates a formatted codeblock with AoC statistics based on the currently selected day and star. + +        Optionally, when the requested day and star data does not exist yet it returns an error message. +        """          header = AOC_DAY_AND_STAR_TEMPLATE.format(              rank="Rank",              name="Name", completion_time="Completion time (UTC)"          )          lines = [f"{header}\n{'-' * (len(header) + 2)}"] - -        for rank, scorer in enumerate(self.data[f"{self.day}-{self.star}"][:self.maximum_scorers]): +        if not (day_and_star_data := self.data.get(f"{self.day}-{self.star}")): +            return ":x: The requested data for the specified day and star does not exist yet." +        for rank, scorer in enumerate(day_and_star_data[:self.maximum_scorers]):              time_data = datetime.fromtimestamp(scorer['completion_time']).strftime("%I:%M:%S %p")              lines.append(AOC_DAY_AND_STAR_TEMPLATE.format(                  datastamp="", diff --git a/bot/exts/holidays/hanukkah/hanukkah_embed.py b/bot/exts/holidays/hanukkah/hanukkah_embed.py index ac3eab7b..5767f91e 100644 --- a/bot/exts/holidays/hanukkah/hanukkah_embed.py +++ b/bot/exts/holidays/hanukkah/hanukkah_embed.py @@ -21,45 +21,41 @@ class HanukkahEmbed(commands.Cog):      def __init__(self, bot: Bot):          self.bot = bot -        self.hanukkah_days = [] -        self.hanukkah_months = [] -        self.hanukkah_years = [] +        self.hanukkah_dates: list[datetime.date] = [] -    async def get_hanukkah_dates(self) -> list[str]: +    def _parse_time_to_datetime(self, date: list[str]) -> datetime.datetime: +        """Format the times provided by the api to datetime forms.""" +        try: +            return datetime.datetime.strptime(date, "%Y-%m-%dT%H:%M:%S%z") +        except ValueError: +            # there is a possibility of an event not having a time, just a day +            # to catch this, we try again without time information +            return datetime.datetime.strptime(date, "%Y-%m-%d") + +    async def fetch_hanukkah_dates(self) -> list[datetime.date]:          """Gets the dates for hanukkah festival.""" -        hanukkah_dates = [] +        # clear the datetime objects to prevent a memory link +        self.hanukkah_dates = []          async with self.bot.http_session.get(HEBCAL_URL) as response:              json_data = await response.json()          festivals = json_data["items"]          for festival in festivals:              if festival["title"].startswith("Chanukah"):                  date = festival["date"] -                hanukkah_dates.append(date) -        return hanukkah_dates +                self.hanukkah_dates.append(self._parse_time_to_datetime(date).date()) +        return self.hanukkah_dates      @in_month(Month.NOVEMBER, Month.DECEMBER)      @commands.command(name="hanukkah", aliases=("chanukah",))      async def hanukkah_festival(self, ctx: commands.Context) -> None:          """Tells you about the Hanukkah Festivaltime of festival, festival day, etc).""" -        hanukkah_dates = await self.get_hanukkah_dates() -        self.hanukkah_dates_split(hanukkah_dates) -        hanukkah_start_day = int(self.hanukkah_days[0]) -        hanukkah_start_month = int(self.hanukkah_months[0]) -        hanukkah_start_year = int(self.hanukkah_years[0]) -        hanukkah_end_day = int(self.hanukkah_days[8]) -        hanukkah_end_month = int(self.hanukkah_months[8]) -        hanukkah_end_year = int(self.hanukkah_years[8]) - -        hanukkah_start = datetime.date(hanukkah_start_year, hanukkah_start_month, hanukkah_start_day) -        hanukkah_end = datetime.date(hanukkah_end_year, hanukkah_end_month, hanukkah_end_day) +        hanukkah_dates = await self.fetch_hanukkah_dates() +        start_day = hanukkah_dates[0] +        end_day = hanukkah_dates[-1]          today = datetime.date.today() -        # today = datetime.date(2019, 12, 24) (for testing) -        day = str(today.day) -        month = str(today.month) -        year = str(today.year)          embed = Embed(title="Hanukkah", colour=Colours.blue) -        if day in self.hanukkah_days and month in self.hanukkah_months and year in self.hanukkah_years: -            if int(day) == hanukkah_start_day: +        if start_day <= today <= end_day: +            if start_day == today:                  now = datetime.datetime.utcnow()                  hours = now.hour + 4  # using only hours                  hanukkah_start_hour = 18 @@ -77,35 +73,27 @@ class HanukkahEmbed(commands.Cog):                      )                      await ctx.send(embed=embed)                      return -            festival_day = self.hanukkah_days.index(day) +            festival_day = hanukkah_dates.index(today)              number_suffixes = ["st", "nd", "rd", "th"]              suffix = number_suffixes[festival_day - 1 if festival_day <= 3 else 3]              message = ":menorah:" * festival_day -            embed.description = f"It is the {festival_day}{suffix} day of Hanukkah!\n{message}" -            await ctx.send(embed=embed) +            embed.description = ( +                f"It is the {festival_day}{suffix} day of Hanukkah!\n{message}" +            ) +        elif today < start_day: +            format_start = start_day.strftime("%d of %B") +            embed.description = ( +                "Hanukkah has not started yet. " +                f"Hanukkah will start at sundown on {format_start}." +            )          else: -            if today < hanukkah_start: -                festival_starting_month = hanukkah_start.strftime("%B") -                embed.description = ( -                    f"Hanukkah has not started yet. " -                    f"Hanukkah will start at sundown on {hanukkah_start_day}th " -                    f"of {festival_starting_month}." -                ) -            else: -                festival_end_month = hanukkah_end.strftime("%B") -                embed.description = ( -                    f"Looks like you missed Hanukkah!" -                    f"Hanukkah ended on {hanukkah_end_day}th of {festival_end_month}." -                ) - -            await ctx.send(embed=embed) +            format_end = end_day.strftime("%d of %B") +            embed.description = ( +                "Looks like you missed Hanukkah! " +                f"Hanukkah ended on {format_end}." +            ) -    def hanukkah_dates_split(self, hanukkah_dates: list[str]) -> None: -        """We are splitting the dates for hanukkah into days, months and years.""" -        for date in hanukkah_dates: -            self.hanukkah_days.append(date[8:10]) -            self.hanukkah_months.append(date[5:7]) -            self.hanukkah_years.append(date[0:4]) +        await ctx.send(embed=embed)  def setup(bot: Bot) -> None: @@ -20,10 +20,7 @@ def setup() -> None:      log_format = logging.Formatter(format_string)      root_logger = logging.getLogger() -    # Copied from constants file, which we can't import yet since loggers aren't instantiated -    debug = os.environ.get("BOT_DEBUG", "true").lower() == "true" - -    if debug: +    if Client.file_logs:          # Set up file logging          log_file = Path("logs/sir-lancebot.log")          log_file.parent.mkdir(exist_ok=True) | 
