diff options
Diffstat (limited to 'bot')
28 files changed, 324 insertions, 68 deletions
| diff --git a/bot/__init__.py b/bot/__init__.py index db576cb2..cfaee9f8 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -18,7 +18,6 @@ from discord.ext import commands  from bot import monkey_patches  from bot.constants import Client -  # Configure the "TRACE" logging level (e.g. "log.trace(message)")  logging.TRACE = 5  logging.addLevelName(logging.TRACE, "TRACE") diff --git a/bot/constants.py b/bot/constants.py index 567daadd..0720dd20 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -88,6 +88,7 @@ class AdventOfCode:      ignored_days = environ.get("AOC_IGNORED_DAYS", "").split(",")      leaderboard_displayed_members = 10      leaderboard_cache_expiry_seconds = 1800 +    max_day_and_star_results = 15      year = int(environ.get("AOC_YEAR", datetime.utcnow().year))      role_id = int(environ.get("AOC_ROLE_ID", 518565788744024082)) diff --git a/bot/exts/avatar_modification/avatar_modify.py b/bot/exts/avatar_modification/avatar_modify.py index 87eb05e6..fbee96dc 100644 --- a/bot/exts/avatar_modification/avatar_modify.py +++ b/bot/exts/avatar_modification/avatar_modify.py @@ -239,7 +239,7 @@ class AvatarModify(commands.Cog):                  description=f"Here is your lovely avatar, surrounded by\n a beautiful {option} flag. Enjoy :D"              )              embed.set_image(url=f"attachment://{file_name}") -            embed.set_footer(text=f"Made by {ctx.author.display_name}.", icon_url=ctx.author.avatar.url) +            embed.set_footer(text=f"Made by {ctx.author.display_name}.", icon_url=ctx.author.display_avatar.url)              await ctx.send(file=file, embed=embed)      @avatar_modify.group( diff --git a/bot/exts/core/extensions.py b/bot/exts/core/extensions.py index dbb9e069..d809d2b9 100644 --- a/bot/exts/core/extensions.py +++ b/bot/exts/core/extensions.py @@ -152,7 +152,7 @@ class Extensions(commands.Cog):          Grey indicates that the extension is unloaded.          Green indicates that the extension is currently loaded.          """ -        embed = Embed(colour=Colour.blurple()) +        embed = Embed(colour=Colour.og_blurple())          embed.set_author(              name="Extensions List",              url=Client.github_bot_repo, diff --git a/bot/exts/core/help.py b/bot/exts/core/help.py index 4b766b50..db3c2aa6 100644 --- a/bot/exts/core/help.py +++ b/bot/exts/core/help.py @@ -13,10 +13,7 @@ from rapidfuzz import process  from bot import constants  from bot.bot import Bot  from bot.constants import Emojis -from bot.utils.pagination import ( -    FIRST_EMOJI, LAST_EMOJI, -    LEFT_EMOJI, LinePaginator, RIGHT_EMOJI, -) +from bot.utils.pagination import FIRST_EMOJI, LAST_EMOJI, LEFT_EMOJI, LinePaginator, RIGHT_EMOJI  DELETE_EMOJI = Emojis.trashcan diff --git a/bot/exts/core/internal_eval/_internal_eval.py b/bot/exts/core/internal_eval/_internal_eval.py index 4f6b4321..12a860fa 100644 --- a/bot/exts/core/internal_eval/_internal_eval.py +++ b/bot/exts/core/internal_eval/_internal_eval.py @@ -10,6 +10,7 @@ from bot.bot import Bot  from bot.constants import Client, Roles  from bot.utils.decorators import with_role  from bot.utils.extensions import invoke_help_command +  from ._helpers import EvalContext  __all__ = ["InternalEval"] diff --git a/bot/exts/events/advent_of_code/_cog.py b/bot/exts/events/advent_of_code/_cog.py index ca60e517..2c1f4541 100644 --- a/bot/exts/events/advent_of_code/_cog.py +++ b/bot/exts/events/advent_of_code/_cog.py @@ -2,16 +2,16 @@ import json  import logging  from datetime import datetime, timedelta  from pathlib import Path +from typing import Optional  import arrow  import discord  from discord.ext import commands  from bot.bot import Bot -from bot.constants import ( -    AdventOfCode as AocConfig, Channels, Colours, Emojis, Month, Roles, WHITELISTED_CHANNELS, -) +from bot.constants import AdventOfCode as AocConfig, Channels, Colours, Emojis, Month, Roles, WHITELISTED_CHANNELS  from bot.exts.events.advent_of_code import _helpers +from bot.exts.events.advent_of_code.views.dayandstarview import AoCDropdownView  from bot.utils.decorators import InChannelCheckFailure, in_month, whitelist_override, with_role  from bot.utils.extensions import invoke_help_command @@ -150,7 +150,7 @@ class AdventOfCode(commands.Cog):          else:              try:                  join_code = await _helpers.get_public_join_code(author) -            except _helpers.FetchingLeaderboardFailed: +            except _helpers.FetchingLeaderboardFailedError:                  await ctx.send(":x: Failed to get join code! Notified maintainers.")                  return @@ -185,14 +185,29 @@ class AdventOfCode(commands.Cog):          brief="Get a snapshot of the PyDis private AoC leaderboard",      )      @whitelist_override(channels=AOC_WHITELIST_RESTRICTED) -    async def aoc_leaderboard(self, ctx: commands.Context) -> None: -        """Get the current top scorers of the Python Discord Leaderboard.""" +    async def aoc_leaderboard( +            self, +            ctx: commands.Context, +            day_and_star: Optional[bool] = False, +            maximum_scorers: 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: +            raise commands.BadArgument( +                f"The maximum number of results you can query is {AocConfig.max_day_and_star_results}" +            )          async with ctx.typing():              try:                  leaderboard = await _helpers.fetch_leaderboard() -            except _helpers.FetchingLeaderboardFailed: +            except _helpers.FetchingLeaderboardFailedError:                  await ctx.send(":x: Unable to fetch leaderboard!")                  return +        if not day_and_star:              number_of_participants = leaderboard["number_of_participants"] @@ -203,6 +218,22 @@ class AdventOfCode(commands.Cog):              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, +            original_author=ctx.author +        ) +        message = await ctx.send( +            content="Please select a day and a star to filter by!", +            view=view +        ) +        await view.wait() +        await message.edit(view=None)      @in_month(Month.DECEMBER)      @adventofcode_group.command( @@ -231,7 +262,7 @@ class AdventOfCode(commands.Cog):          """Send an embed with daily completion statistics for the Python Discord leaderboard."""          try:              leaderboard = await _helpers.fetch_leaderboard() -        except _helpers.FetchingLeaderboardFailed: +        except _helpers.FetchingLeaderboardFailedError:              await ctx.send(":x: Can't fetch leaderboard for stats right now!")              return @@ -267,7 +298,7 @@ class AdventOfCode(commands.Cog):          async with ctx.typing():              try:                  await _helpers.fetch_leaderboard(invalidate_cache=True) -            except _helpers.FetchingLeaderboardFailed: +            except _helpers.FetchingLeaderboardFailedError:                  await ctx.send(":x: Something went wrong while trying to refresh the cache!")              else:                  await ctx.send("\N{OK Hand Sign} Refreshed leaderboard cache!") diff --git a/bot/exts/events/advent_of_code/_helpers.py b/bot/exts/events/advent_of_code/_helpers.py index 5fedb60f..af64bc81 100644 --- a/bot/exts/events/advent_of_code/_helpers.py +++ b/bot/exts/events/advent_of_code/_helpers.py @@ -105,6 +105,7 @@ def _parse_raw_leaderboard_data(raw_leaderboard_data: dict) -> dict:      # 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. +    per_day_star_stats = collections.defaultdict(list)      for member in raw_leaderboard_data.values():          name = member["name"] if member["name"] else f"Anonymous #{member['id']}"          member_id = member["id"] @@ -122,6 +123,11 @@ def _parse_raw_leaderboard_data(raw_leaderboard_data: dict) -> dict:                  star_results[(day, star)].append(                      StarResult(member_id=member_id, completion_time=completion_time)                  ) +                per_day_star_stats[f"{day}-{star}"].append( +                    {'completion_time': int(data["get_star_ts"]), 'member_name': name} +                ) +    for key in per_day_star_stats: +        per_day_star_stats[key] = sorted(per_day_star_stats[key], key=operator.itemgetter('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 @@ -151,7 +157,7 @@ def _parse_raw_leaderboard_data(raw_leaderboard_data: dict) -> dict:          # 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} +    return {"daily_stats": daily_stats, "leaderboard": sorted_leaderboard, 'per_day_and_star': per_day_star_stats}  def _format_leaderboard(leaderboard: dict[str, dict]) -> str: @@ -289,6 +295,7 @@ async def fetch_leaderboard(invalidate_cache: bool = False) -> dict:              "leaderboard_fetched_at": leaderboard_fetched_at,              "number_of_participants": number_of_participants,              "daily_stats": json.dumps(parsed_leaderboard_data["daily_stats"]), +            "leaderboard_per_day_and_star": json.dumps(parsed_leaderboard_data["per_day_and_star"])          }          # Store the new values in Redis diff --git a/bot/exts/events/advent_of_code/views/dayandstarview.py b/bot/exts/events/advent_of_code/views/dayandstarview.py new file mode 100644 index 00000000..243db32e --- /dev/null +++ b/bot/exts/events/advent_of_code/views/dayandstarview.py @@ -0,0 +1,71 @@ +from datetime import datetime + +import discord + +AOC_DAY_AND_STAR_TEMPLATE = "{rank: >4} | {name:25.25} | {completion_time: >10}" + + +class AoCDropdownView(discord.ui.View): +    """Interactive view to filter AoC stats by Day and Star.""" + +    def __init__(self, original_author: discord.Member, day_and_star_data: dict[str: dict], maximum_scorers: int): +        super().__init__() +        self.day = 0 +        self.star = 0 +        self.data = day_and_star_data +        self.maximum_scorers = maximum_scorers +        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.""" +        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]): +            time_data = datetime.fromtimestamp(scorer['completion_time']).strftime("%I:%M:%S %p") +            lines.append(AOC_DAY_AND_STAR_TEMPLATE.format( +                datastamp="", +                rank=rank + 1, +                name=scorer['member_name'], +                completion_time=time_data) +            ) +        joined_lines = "\n".join(lines) +        return f"Statistics for Day: {self.day}, Star: {self.star}.\n ```\n{joined_lines}\n```" + +    async def interaction_check(self, interaction: discord.Interaction) -> bool: +        """Global check to ensure that the interacting user is the user who invoked the command originally.""" +        return interaction.user == self.original_author + +    @discord.ui.select( +        placeholder="Day", +        options=[discord.SelectOption(label=str(i)) for i in range(1, 26)], +        custom_id="day_select" +    ) +    async def day_select(self, select: discord.ui.Select, interaction: discord.Interaction) -> None: +        """Dropdown to choose a Day of the AoC.""" +        self.day = select.values[0] + +    @discord.ui.select( +        placeholder="Star", +        options=[discord.SelectOption(label=str(i)) for i in range(1, 3)], +        custom_id="star_select" +    ) +    async def star_select(self, select: discord.ui.Select, interaction: discord.Interaction) -> None: +        """Dropdown to choose either the first or the second star.""" +        self.star = select.values[0] + +    @discord.ui.button(label="Fetch", style=discord.ButtonStyle.blurple) +    async def fetch(self, button: discord.ui.Button, interaction: discord.Interaction) -> None: +        """Button that fetches the statistics based on the dropdown values.""" +        if self.day == 0 or self.star == 0: +            await interaction.response.send_message( +                "You have to select a value from both of the dropdowns!", +                ephemeral=True +            ) +        else: +            await interaction.response.edit_message(content=self.generate_output()) +            self.day = 0 +            self.star = 0 diff --git a/bot/exts/events/hacktoberfest/hacktober-issue-finder.py b/bot/exts/events/hacktoberfest/hacktober-issue-finder.py index 088e7e43..1774564b 100644 --- a/bot/exts/events/hacktoberfest/hacktober-issue-finder.py +++ b/bot/exts/events/hacktoberfest/hacktober-issue-finder.py @@ -100,7 +100,8 @@ class HacktoberIssues(commands.Cog):          """Format the issue data into a embed."""          title = issue["title"]          issue_url = issue["url"].replace("api.", "").replace("/repos/", "/") -        body = issue["body"] +        # issues can have empty bodies, which in that case GitHub doesn't include the key in the API response +        body = issue.get("body", "")          labels = [label["name"] for label in issue["labels"]]          embed = discord.Embed(title=title) diff --git a/bot/exts/fun/tic_tac_toe.py b/bot/exts/fun/tic_tac_toe.py index 5c4f8051..946b6f7b 100644 --- a/bot/exts/fun/tic_tac_toe.py +++ b/bot/exts/fun/tic_tac_toe.py @@ -72,10 +72,12 @@ class Player:  class AI:      """Tic Tac Toe AI class for against computer gaming.""" -    def __init__(self, symbol: str): +    def __init__(self, bot_user: discord.Member, symbol: str): +        self.user = bot_user          self.symbol = symbol -    async def get_move(self, board: dict[int, str], _: discord.Message) -> tuple[bool, int]: +    @staticmethod +    async def get_move(board: dict[int, str], _: discord.Message) -> tuple[bool, int]:          """Get move from AI. AI use Minimax strategy."""          possible_moves = [i for i, emoji in board.items() if emoji in list(Emojis.number_emojis.values())] @@ -97,8 +99,8 @@ class AI:          return False, random.choice(open_edges)      def __str__(self) -> str: -        """Return `AI` as user name.""" -        return "AI" +        """Return mention of @Sir Lancebot.""" +        return self.user.mention  class Game: @@ -107,6 +109,7 @@ class Game:      def __init__(self, players: list[Union[Player, AI]], ctx: Context):          self.players = players          self.ctx = ctx +        self.channel = ctx.channel          self.board = {              1: Emojis.number_emojis[1],              2: Emojis.number_emojis[2], @@ -173,7 +176,8 @@ class Game:              self.canceled = True              return False, "User declined" -    async def add_reactions(self, msg: discord.Message) -> None: +    @staticmethod +    async def add_reactions(msg: discord.Message) -> None:          """Add number emojis to message."""          for nr in Emojis.number_emojis.values():              await msg.add_reaction(nr) @@ -265,7 +269,7 @@ class TicTacToe(Cog):              return          if opponent is None:              game = Game( -                [Player(ctx.author, ctx, Emojis.x_square), AI(Emojis.o_square)], +                [Player(ctx.author, ctx, Emojis.x_square), AI(ctx.me, Emojis.o_square)],                  ctx              )          else: diff --git a/bot/exts/holidays/easter/earth_photos.py b/bot/exts/holidays/easter/earth_photos.py index f65790af..27442f1c 100644 --- a/bot/exts/holidays/easter/earth_photos.py +++ b/bot/exts/holidays/easter/earth_photos.py @@ -4,8 +4,7 @@ import discord  from discord.ext import commands  from bot.bot import Bot -from bot.constants import Colours -from bot.constants import Tokens +from bot.constants import Colours, Tokens  log = logging.getLogger(__name__) diff --git a/bot/exts/holidays/halloween/candy_collection.py b/bot/exts/holidays/halloween/candy_collection.py index 09bd0e59..bb9c93be 100644 --- a/bot/exts/holidays/halloween/candy_collection.py +++ b/bot/exts/holidays/halloween/candy_collection.py @@ -83,6 +83,11 @@ class CandyCollection(commands.Cog):          # if its not a candy or skull, and it is one of 10 most recent messages,          # proceed to add a skull/candy with higher chance          if str(reaction.emoji) not in (EMOJIS["SKULL"], EMOJIS["CANDY"]): +            # Ensure the reaction is not for a bot's message so users can't spam +            # reaction buttons like in .help to get candies. +            if message.author.bot: +                return +              recent_message_ids = map(                  lambda m: m.id,                  await self.hacktober_channel.history(limit=10).flatten() @@ -182,7 +187,7 @@ class CandyCollection(commands.Cog):                  for index, record in enumerate(top_five)              ) if top_five else "No Candies" -        e = discord.Embed(colour=discord.Colour.blurple()) +        e = discord.Embed(colour=discord.Colour.og_blurple())          e.add_field(              name="Top Candy Records",              value=generate_leaderboard(), diff --git a/bot/exts/holidays/halloween/scarymovie.py b/bot/exts/holidays/halloween/scarymovie.py index 33659fd8..89310b97 100644 --- a/bot/exts/holidays/halloween/scarymovie.py +++ b/bot/exts/holidays/halloween/scarymovie.py @@ -6,6 +6,7 @@ from discord.ext import commands  from bot.bot import Bot  from bot.constants import Tokens +  log = logging.getLogger(__name__) diff --git a/bot/exts/utilities/conversationstarters.py b/bot/exts/utilities/conversationstarters.py index dcbfe4d5..8bf2abfd 100644 --- a/bot/exts/utilities/conversationstarters.py +++ b/bot/exts/utilities/conversationstarters.py @@ -53,7 +53,7 @@ class ConvoStarters(commands.Cog):          # No matter what, the form will be shown.          embed = discord.Embed(              description=f"Suggest more topics [here]({SUGGESTION_FORM})!", -            color=discord.Color.blurple() +            color=discord.Colour.og_blurple()          )          try: diff --git a/bot/exts/utilities/emoji.py b/bot/exts/utilities/emoji.py index 83df39cc..fa438d7f 100644 --- a/bot/exts/utilities/emoji.py +++ b/bot/exts/utilities/emoji.py @@ -111,7 +111,7 @@ class Emojis(commands.Cog):                  **Date:** {datetime.strftime(emoji.created_at.replace(tzinfo=None), "%d/%m/%Y")}                  **ID:** {emoji.id}              """), -            color=Color.blurple(), +            color=Color.og_blurple(),              url=str(emoji.url),          ).set_thumbnail(url=emoji.url) diff --git a/bot/exts/utilities/githubinfo.py b/bot/exts/utilities/githubinfo.py index d00b408d..539e388b 100644 --- a/bot/exts/utilities/githubinfo.py +++ b/bot/exts/utilities/githubinfo.py @@ -67,7 +67,7 @@ class GithubInfo(commands.Cog):              embed = discord.Embed(                  title=f"`{user_data['login']}`'s GitHub profile info",                  description=f"```\n{user_data['bio']}\n```\n" if user_data["bio"] else "", -                colour=discord.Colour.blurple(), +                colour=discord.Colour.og_blurple(),                  url=user_data["html_url"],                  timestamp=datetime.strptime(user_data["created_at"], "%Y-%m-%dT%H:%M:%SZ")              ) @@ -139,7 +139,7 @@ class GithubInfo(commands.Cog):          embed = discord.Embed(              title=repo_data["name"],              description=repo_data["description"], -            colour=discord.Colour.blurple(), +            colour=discord.Colour.og_blurple(),              url=repo_data["html_url"]          ) diff --git a/bot/exts/utilities/issues.py b/bot/exts/utilities/issues.py index 8a7ebed0..b6d5a43e 100644 --- a/bot/exts/utilities/issues.py +++ b/bot/exts/utilities/issues.py @@ -9,14 +9,7 @@ from discord.ext import commands  from bot.bot import Bot  from bot.constants import ( -    Categories, -    Channels, -    Colours, -    ERROR_REPLIES, -    Emojis, -    NEGATIVE_REPLIES, -    Tokens, -    WHITELISTED_CHANNELS +    Categories, Channels, Colours, ERROR_REPLIES, Emojis, NEGATIVE_REPLIES, Tokens, WHITELISTED_CHANNELS  )  from bot.utils.decorators import whitelist_override  from bot.utils.extensions import invoke_help_command @@ -185,7 +178,7 @@ class Issues(commands.Cog):          return resp      @whitelist_override(channels=WHITELISTED_CHANNELS, categories=WHITELISTED_CATEGORIES) -    @commands.command(aliases=("pr",)) +    @commands.command(aliases=("issues", "pr", "prs"))      async def issue(          self,          ctx: commands.Context, @@ -197,14 +190,23 @@ class Issues(commands.Cog):          # Remove duplicates          numbers = set(numbers) -        if len(numbers) > MAXIMUM_ISSUES: -            embed = discord.Embed( +        err_message = None +        if not numbers: +            err_message = "You must have at least one issue/PR!" + +        elif len(numbers) > MAXIMUM_ISSUES: +            err_message = f"Too many issues/PRs! (maximum of {MAXIMUM_ISSUES})" + +        # If there's an error with command invocation then send an error embed +        if err_message is not None: +            err_embed = discord.Embed(                  title=random.choice(ERROR_REPLIES),                  color=Colours.soft_red, -                description=f"Too many issues/PRs! (maximum of {MAXIMUM_ISSUES})" +                description=err_message              ) -            await ctx.send(embed=embed) +            await ctx.send(embed=err_embed)              await invoke_help_command(ctx) +            return          results = [await self.fetch_issues(number, repository, user) for number in numbers]          await ctx.send(embed=self.format_embed(results, user, repository)) diff --git a/bot/exts/utilities/realpython.py b/bot/exts/utilities/realpython.py index ef8b2638..bf8f1341 100644 --- a/bot/exts/utilities/realpython.py +++ b/bot/exts/utilities/realpython.py @@ -1,5 +1,6 @@  import logging  from html import unescape +from typing import Optional  from urllib.parse import quote_plus  from discord import Embed @@ -31,9 +32,18 @@ class RealPython(commands.Cog):      @commands.command(aliases=["rp"])      @commands.cooldown(1, 10, commands.cooldowns.BucketType.user) -    async def realpython(self, ctx: commands.Context, *, user_search: str) -> None: -        """Send 5 articles that match the user's search terms.""" -        params = {"q": user_search, "limit": 5, "kind": "article"} +    async def realpython(self, ctx: commands.Context, amount: Optional[int] = 5, *, user_search: str) -> None: +        """ +        Send some articles from RealPython that match the search terms. + +        By default the top 5 matches are sent, this can be overwritten to +        a number between 1 and 5 by specifying an amount before the search query. +        """ +        if not 1 <= amount <= 5: +            await ctx.send("`amount` must be between 1 and 5 (inclusive).") +            return + +        params = {"q": user_search, "limit": amount, "kind": "article"}          async with self.bot.http_session.get(url=API_ROOT, params=params) as response:              if response.status != 200:                  logger.error( diff --git a/bot/exts/utilities/reddit.py b/bot/exts/utilities/reddit.py index e6cb5337..782583d2 100644 --- a/bot/exts/utilities/reddit.py +++ b/bot/exts/utilities/reddit.py @@ -244,7 +244,7 @@ class Reddit(Cog):          # Use only starting summary page for #reddit channel posts.          embed.description = self.build_pagination_pages(posts, paginate=False) -        embed.colour = Colour.blurple() +        embed.colour = Colour.og_blurple()          return embed      @loop() @@ -312,7 +312,7 @@ class Reddit(Cog):          await ctx.send(f"Here are the top {subreddit} posts of all time!")          embed = Embed( -            color=Colour.blurple() +            color=Colour.og_blurple()          )          await ImagePaginator.paginate(pages, ctx, embed) @@ -325,7 +325,7 @@ class Reddit(Cog):          await ctx.send(f"Here are today's top {subreddit} posts!")          embed = Embed( -            color=Colour.blurple() +            color=Colour.og_blurple()          )          await ImagePaginator.paginate(pages, ctx, embed) @@ -338,7 +338,7 @@ class Reddit(Cog):          await ctx.send(f"Here are this week's top {subreddit} posts!")          embed = Embed( -            color=Colour.blurple() +            color=Colour.og_blurple()          )          await ImagePaginator.paginate(pages, ctx, embed) @@ -349,7 +349,7 @@ class Reddit(Cog):          """Send a paginated embed of all the subreddits we're relaying."""          embed = Embed()          embed.title = "Relayed subreddits." -        embed.colour = Colour.blurple() +        embed.colour = Colour.og_blurple()          await LinePaginator.paginate(              RedditConfig.subreddits, diff --git a/bot/exts/utilities/wikipedia.py b/bot/exts/utilities/wikipedia.py index eccc1f8c..e5e8e289 100644 --- a/bot/exts/utilities/wikipedia.py +++ b/bot/exts/utilities/wikipedia.py @@ -82,13 +82,11 @@ class WikipediaSearch(commands.Cog):          if contents:              embed = Embed(                  title="Wikipedia Search Results", -                colour=Color.blurple() +                colour=Color.og_blurple()              )              embed.set_thumbnail(url=WIKI_THUMBNAIL)              embed.timestamp = datetime.utcnow() -            await LinePaginator.paginate( -                contents, ctx, embed -            ) +            await LinePaginator.paginate(contents, ctx, embed, restrict_to_user=ctx.author)          else:              await ctx.send(                  "Sorry, we could not find a wikipedia article using that search term." diff --git a/bot/exts/utilities/wtf_python.py b/bot/exts/utilities/wtf_python.py new file mode 100644 index 00000000..980b3dba --- /dev/null +++ b/bot/exts/utilities/wtf_python.py @@ -0,0 +1,138 @@ +import logging +import random +import re +from typing import Optional + +import rapidfuzz +from discord import Embed, File +from discord.ext import commands, tasks + +from bot import constants +from bot.bot import Bot + +log = logging.getLogger(__name__) + +WTF_PYTHON_RAW_URL = "http://raw.githubusercontent.com/satwikkansal/wtfpython/master/" +BASE_URL = "https://github.com/satwikkansal/wtfpython" +LOGO_PATH = "./bot/resources/utilities/wtf_python_logo.jpg" + +ERROR_MESSAGE = f""" +Unknown WTF Python Query. Please try to reformulate your query. + +**Examples**: +```md +{constants.Client.prefix}wtf wild imports +{constants.Client.prefix}wtf subclass +{constants.Client.prefix}wtf del +``` +If the problem persists send a message in <#{constants.Channels.dev_contrib}> +""" + +MINIMUM_CERTAINTY = 55 + + +class WTFPython(commands.Cog): +    """Cog that allows getting WTF Python entries from the WTF Python repository.""" + +    def __init__(self, bot: Bot): +        self.bot = bot +        self.headers: dict[str, str] = {} +        self.fetch_readme.start() + +    @tasks.loop(minutes=60) +    async def fetch_readme(self) -> None: +        """Gets the content of README.md from the WTF Python Repository.""" +        async with self.bot.http_session.get(f"{WTF_PYTHON_RAW_URL}README.md") as resp: +            log.trace("Fetching the latest WTF Python README.md") +            if resp.status == 200: +                raw = await resp.text() +                self.parse_readme(raw) + +    def parse_readme(self, data: str) -> None: +        """ +        Parses the README.md into a dict. + +        It parses the readme into the `self.headers` dict, +        where the key is the heading and the value is the +        link to the heading. +        """ +        # Match the start of examples, until the end of the table of contents (toc) +        table_of_contents = re.search( +            r"\[👀 Examples\]\(#-examples\)\n([\w\W]*)<!-- tocstop -->", data +        )[0].split("\n") + +        for header in list(map(str.strip, table_of_contents)): +            match = re.search(r"\[▶ (.*)\]\((.*)\)", header) +            if match: +                hyper_link = match[0].split("(")[1].replace(")", "") +                self.headers[match[0]] = f"{BASE_URL}/{hyper_link}" + +    def fuzzy_match_header(self, query: str) -> Optional[str]: +        """ +        Returns the fuzzy match of a query if its ratio is above "MINIMUM_CERTAINTY" else returns None. + +        "MINIMUM_CERTAINTY" is the lowest score at which the fuzzy match will return a result. +        The certainty returned by rapidfuzz.process.extractOne is a score between 0 and 100, +        with 100 being a perfect match. +        """ +        match, certainty, _ = rapidfuzz.process.extractOne(query, self.headers.keys()) +        return match if certainty > MINIMUM_CERTAINTY else None + +    @commands.command(aliases=("wtf", "WTF")) +    async def wtf_python(self, ctx: commands.Context, *, query: Optional[str] = None) -> None: +        """ +        Search WTF Python repository. + +        Gets the link of the fuzzy matched query from https://github.com/satwikkansal/wtfpython. +        Usage: +            --> .wtf wild imports +        """ +        if query is None: +            no_query_embed = Embed( +                title="WTF Python?!", +                colour=constants.Colours.dark_green, +                description="A repository filled with suprising snippets that can make you say WTF?!\n\n" +                f"[Go to the Repository]({BASE_URL})" +            ) +            logo = File(LOGO_PATH, filename="wtf_logo.jpg") +            no_query_embed.set_thumbnail(url="attachment://wtf_logo.jpg") +            await ctx.send(embed=no_query_embed, file=logo) +            return + +        if len(query) > 50: +            embed = Embed( +                title=random.choice(constants.ERROR_REPLIES), +                description=ERROR_MESSAGE, +                colour=constants.Colours.soft_red, +            ) +            match = None +        else: +            match = self.fuzzy_match_header(query) + +        if not match: +            embed = Embed( +                title=random.choice(constants.ERROR_REPLIES), +                description=ERROR_MESSAGE, +                colour=constants.Colours.soft_red, +            ) +            await ctx.send(embed=embed) +            return + +        embed = Embed( +            title="WTF Python?!", +            colour=constants.Colours.dark_green, +            description=f"""Search result for '{query}': {match.split("]")[0].replace("[", "")} +            [Go to Repository Section]({self.headers[match]})""", +        ) +        logo = File(LOGO_PATH, filename="wtf_logo.jpg") +        embed.set_thumbnail(url="attachment://wtf_logo.jpg") +        await ctx.send(embed=embed, file=logo) + +    def cog_unload(self) -> None: +        """Unload the cog and cancel the task.""" +        self.fetch_readme.cancel() + + +def setup(bot: Bot) -> None: +    """Load the WTFPython Cog.""" +    bot.add_cog(WTFPython(bot)) diff --git a/bot/resources/fun/trivia_quiz.json b/bot/resources/fun/trivia_quiz.json index 0b3e6802..99aa5f42 100644 --- a/bot/resources/fun/trivia_quiz.json +++ b/bot/resources/fun/trivia_quiz.json @@ -440,7 +440,7 @@      {        "id": 229,        "question": "What is this triangle called?", -      "img_url": "https://cdn.askpython.com/wp-content/uploads/2020/07/Pascals-triangle.png", +      "img_url": "https://wikimedia.org/api/rest_v1/media/math/render/png/23050fcb53d6083d9e42043bebf2863fa9746043",        "answer": ["Pascal's triangle", "Pascal"]      },      { diff --git a/bot/resources/holidays/halloween/bat-clipart.png b/bot/resources/holidays/halloween/bat-clipart.pngBinary files differ index 7df26ba9..fc2f77b0 100644 --- a/bot/resources/holidays/halloween/bat-clipart.png +++ b/bot/resources/holidays/halloween/bat-clipart.png diff --git a/bot/resources/utilities/py_topics.yaml b/bot/resources/utilities/py_topics.yaml index a3fb2ccc..1cd2c325 100644 --- a/bot/resources/utilities/py_topics.yaml +++ b/bot/resources/utilities/py_topics.yaml @@ -33,7 +33,6 @@      - How often do you program in Python?      - How would you learn a new library if needed to do so?      - Have you ever worked with a microcontroller or anything physical with Python before? -    - How good would you say you are at Python so far? Beginner, intermediate, or advanced?      - Have you ever tried making your own programming language?      - Has a recently discovered Python module changed your general use of Python? diff --git a/bot/resources/utilities/wtf_python_logo.jpg b/bot/resources/utilities/wtf_python_logo.jpgBinary files differ new file mode 100644 index 00000000..851d7f9a --- /dev/null +++ b/bot/resources/utilities/wtf_python_logo.jpg diff --git a/bot/utils/checks.py b/bot/utils/checks.py index 612d1ed6..8c426ed7 100644 --- a/bot/utils/checks.py +++ b/bot/utils/checks.py @@ -4,14 +4,7 @@ from collections.abc import Container, Iterable  from typing import Callable, Optional  from discord.ext.commands import ( -    BucketType, -    CheckFailure, -    Cog, -    Command, -    CommandOnCooldown, -    Context, -    Cooldown, -    CooldownMapping, +    BucketType, CheckFailure, Cog, Command, CommandOnCooldown, Context, Cooldown, CooldownMapping  )  from bot import constants diff --git a/bot/utils/halloween/spookifications.py b/bot/utils/halloween/spookifications.py index 93c5ddb9..c45ef8dc 100644 --- a/bot/utils/halloween/spookifications.py +++ b/bot/utils/halloween/spookifications.py @@ -1,8 +1,7 @@  import logging  from random import choice, randint -from PIL import Image -from PIL import ImageOps +from PIL import Image, ImageOps  log = logging.getLogger() | 
