diff options
| author | 2021-10-14 22:34:41 +0100 | |
|---|---|---|
| committer | 2021-10-14 22:34:41 +0100 | |
| commit | 4fd0acedd4b6f994ecb3299b93ea48115f61d785 (patch) | |
| tree | 1bed49893eada22908fb93632bbfe88eaa6f00ed /bot/exts | |
| parent | Change pascal's triangle image (diff) | |
| parent | Add support to query AoC results in respect of days and stars (#857) (diff) | |
Merge branch 'main' into fix-pascal-triangle-image
Diffstat (limited to 'bot/exts')
| -rw-r--r-- | bot/exts/events/advent_of_code/_cog.py | 45 | ||||
| -rw-r--r-- | bot/exts/events/advent_of_code/_helpers.py | 9 | ||||
| -rw-r--r-- | bot/exts/events/advent_of_code/views/dayandstarview.py | 71 | ||||
| -rw-r--r-- | bot/exts/events/hacktoberfest/hacktober-issue-finder.py | 11 | ||||
| -rw-r--r-- | bot/exts/fun/anagram.py | 110 | ||||
| -rw-r--r-- | bot/exts/fun/duck_game.py | 42 | ||||
| -rw-r--r-- | bot/exts/fun/trivia_quiz.py | 4 | ||||
| -rw-r--r-- | bot/exts/holidays/halloween/candy_collection.py | 2 | ||||
| -rw-r--r-- | bot/exts/holidays/halloween/spookynamerate.py | 6 | ||||
| -rw-r--r-- | bot/exts/holidays/halloween/spookyreact.py | 8 | ||||
| -rw-r--r-- | bot/exts/holidays/valentines/lovecalculator.py | 3 | ||||
| -rw-r--r-- | bot/exts/utilities/bookmark.py | 8 | ||||
| -rw-r--r-- | bot/exts/utilities/challenges.py | 335 | ||||
| -rw-r--r-- | bot/exts/utilities/conversationstarters.py | 91 | ||||
| -rw-r--r-- | bot/exts/utilities/emoji.py | 4 | 
15 files changed, 690 insertions, 59 deletions
diff --git a/bot/exts/events/advent_of_code/_cog.py b/bot/exts/events/advent_of_code/_cog.py index ca60e517..7dd967ec 100644 --- a/bot/exts/events/advent_of_code/_cog.py +++ b/bot/exts/events/advent_of_code/_cog.py @@ -2,6 +2,7 @@ import json  import logging  from datetime import datetime, timedelta  from pathlib import Path +from typing import Optional  import arrow  import discord @@ -12,6 +13,7 @@ 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 +152,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 +187,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 +220,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 +264,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 +300,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 e3053851..1774564b 100644 --- a/bot/exts/events/hacktoberfest/hacktober-issue-finder.py +++ b/bot/exts/events/hacktoberfest/hacktober-issue-finder.py @@ -52,10 +52,10 @@ class HacktoberIssues(commands.Cog):      async def get_issues(self, ctx: commands.Context, option: str) -> Optional[dict]:          """Get a list of the python issues with the label 'hacktoberfest' from the Github api."""          if option == "beginner": -            if (ctx.message.created_at - self.cache_timer_beginner).seconds <= 60: +            if (ctx.message.created_at.replace(tzinfo=None) - self.cache_timer_beginner).seconds <= 60:                  log.debug("using cache")                  return self.cache_beginner -        elif (ctx.message.created_at - self.cache_timer_normal).seconds <= 60: +        elif (ctx.message.created_at.replace(tzinfo=None) - self.cache_timer_normal).seconds <= 60:              log.debug("using cache")              return self.cache_normal @@ -88,10 +88,10 @@ class HacktoberIssues(commands.Cog):              if option == "beginner":                  self.cache_beginner = data -                self.cache_timer_beginner = ctx.message.created_at +                self.cache_timer_beginner = ctx.message.created_at.replace(tzinfo=None)              else:                  self.cache_normal = data -                self.cache_timer_normal = ctx.message.created_at +                self.cache_timer_normal = ctx.message.created_at.replace(tzinfo=None)              return data @@ -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/anagram.py b/bot/exts/fun/anagram.py new file mode 100644 index 00000000..9aee5f18 --- /dev/null +++ b/bot/exts/fun/anagram.py @@ -0,0 +1,110 @@ +import asyncio +import json +import logging +import random +from pathlib import Path + +import discord +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Colours + +log = logging.getLogger(__name__) + +TIME_LIMIT = 60 + +# anagram.json file contains all the anagrams +with open(Path("bot/resources/fun/anagram.json"), "r") as f: +    ANAGRAMS_ALL = json.load(f) + + +class AnagramGame: +    """ +    Used for creating instances of anagram games. + +    Once multiple games can be run at the same time, this class' instances +    can be used for keeping track of each anagram game. +    """ + +    def __init__(self, scrambled: str, correct: list[str]) -> None: +        self.scrambled = scrambled +        self.correct = set(correct) + +        self.winners = set() + +    async def message_creation(self, message: discord.Message) -> None: +        """Check if the message is a correct answer and remove it from the list of answers.""" +        if message.content.lower() in self.correct: +            self.winners.add(message.author.mention) +            self.correct.remove(message.content.lower()) + + +class Anagram(commands.Cog): +    """Cog for the Anagram game command.""" + +    def __init__(self, bot: Bot): +        self.bot = bot + +        self.games: dict[int, AnagramGame] = {} + +    @commands.command(name="anagram", aliases=("anag", "gram", "ag")) +    @commands.guild_only() +    async def anagram_command(self, ctx: commands.Context) -> None: +        """ +        Given shuffled letters, rearrange them into anagrams. + +        Show an embed with scrambled letters which if rearranged can form words. +        After a specific amount of time, list the correct answers and whether someone provided a +        correct answer. +        """ +        if self.games.get(ctx.channel.id): +            await ctx.send("An anagram is already being solved in this channel!") +            return + +        scrambled_letters, correct = random.choice(list(ANAGRAMS_ALL.items())) + +        game = AnagramGame(scrambled_letters, correct) +        self.games[ctx.channel.id] = game + +        anagram_embed = discord.Embed( +            title=f"Find anagrams from these letters: '{scrambled_letters.upper()}'", +            description=f"You have {TIME_LIMIT} seconds to find correct words.", +            colour=Colours.purple, +        ) + +        await ctx.send(embed=anagram_embed) +        await asyncio.sleep(TIME_LIMIT) + +        if game.winners: +            win_list = ", ".join(game.winners) +            content = f"Well done {win_list} for getting it right!" +        else: +            content = "Nobody got it right." + +        answer_embed = discord.Embed( +            title=f"The words were:  `{'`, `'.join(ANAGRAMS_ALL[game.scrambled])}`!", +            colour=Colours.pink, +        ) + +        await ctx.send(content, embed=answer_embed) + +        # Game is finished, let's remove it from the dict +        self.games.pop(ctx.channel.id) + +    @commands.Cog.listener() +    async def on_message(self, message: discord.Message) -> None: +        """Check a message for an anagram attempt and pass to an ongoing game.""" +        if message.author.bot or not message.guild: +            return + +        game = self.games.get(message.channel.id) +        if not game: +            return + +        await game.message_creation(message) + + +def setup(bot: Bot) -> None: +    """Load the Anagram cog.""" +    bot.add_cog(Anagram(bot)) diff --git a/bot/exts/fun/duck_game.py b/bot/exts/fun/duck_game.py index 1ef7513f..10b03a49 100644 --- a/bot/exts/fun/duck_game.py +++ b/bot/exts/fun/duck_game.py @@ -11,7 +11,7 @@ from PIL import Image, ImageDraw, ImageFont  from discord.ext import commands  from bot.bot import Bot -from bot.constants import Colours, MODERATION_ROLES +from bot.constants import MODERATION_ROLES  from bot.utils.decorators import with_role  DECK = list(product(*[(0, 1, 2)]*4)) @@ -130,6 +130,9 @@ class DuckGame:          while len(self.solutions) < minimum_solutions:              self.board = random.sample(DECK, size) +        self.board_msg = None +        self.found_msg = None +      @property      def board(self) -> list[tuple[int]]:          """Accesses board property.""" @@ -181,7 +184,7 @@ class DuckGamesDirector(commands.Cog):      )      @commands.cooldown(rate=1, per=2, type=commands.BucketType.channel)      async def start_game(self, ctx: commands.Context) -> None: -        """Generate a board, send the game embed, and end the game after a time limit.""" +        """Start a new Duck Duck Duck Goose game."""          if ctx.channel.id in self.current_games:              await ctx.send("There's already a game running!")              return @@ -191,8 +194,8 @@ class DuckGamesDirector(commands.Cog):          game.running = True          self.current_games[ctx.channel.id] = game -        game.msg_content = "" -        game.embed_msg = await self.send_board_embed(ctx, game) +        game.board_msg = await self.send_board_embed(ctx, game) +        game.found_msg = await self.send_found_embed(ctx)          await asyncio.sleep(GAME_DURATION)          # Checking for the channel ID in the currently running games is not sufficient. @@ -245,13 +248,13 @@ class DuckGamesDirector(commands.Cog):          if answer in game.solutions:              game.claimed_answers[answer] = msg.author              game.scores[msg.author] += CORRECT_SOLN -            await self.display_claimed_answer(game, msg.author, answer) +            await self.append_to_found_embed(game, f"{str(answer):12s}  -  {msg.author.display_name}")          else:              await msg.add_reaction(EMOJI_WRONG)              game.scores[msg.author] += INCORRECT_SOLN      async def send_board_embed(self, ctx: commands.Context, game: DuckGame) -> discord.Message: -        """Create and send the initial game embed. This will be edited as the game goes on.""" +        """Create and send an embed to display the board."""          image = assemble_board_image(game.board, game.rows, game.columns)          with BytesIO() as image_stream:              image.save(image_stream, format="png") @@ -259,19 +262,27 @@ class DuckGamesDirector(commands.Cog):              file = discord.File(fp=image_stream, filename="board.png")          embed = discord.Embed(              title="Duck Duck Duck Goose!", -            color=Colours.bright_green, +            color=discord.Color.dark_purple(),          )          embed.set_image(url="attachment://board.png")          return await ctx.send(embed=embed, file=file) -    async def display_claimed_answer(self, game: DuckGame, author: discord.Member, answer: tuple[int]) -> None: -        """Add a claimed answer to the game embed.""" +    async def send_found_embed(self, ctx: commands.Context) -> discord.Message: +        """Create and send an embed to display claimed answers. This will be edited as the game goes on.""" +        # Can't be part of the board embed because of discord.py limitations with editing an embed with an image. +        embed = discord.Embed( +            title="Flights Found", +            color=discord.Color.dark_purple(), +        ) +        return await ctx.send(embed=embed) + +    async def append_to_found_embed(self, game: DuckGame, text: str) -> None: +        """Append text to the claimed answers embed."""          async with game.editing_embed: -            # We specifically edit the message contents instead of the embed -            # Because we load in the image from the file, editing any portion of the embed -            # Does weird things to the image and this works around that weirdness -            game.msg_content = f"{game.msg_content}\n{str(answer):12s}  -  {author.display_name}" -            await game.embed_msg.edit(content=game.msg_content) +            found_embed, = game.found_msg.embeds +            old_desc = found_embed.description or "" +            found_embed.description = f"{old_desc.rstrip()}\n{text}" +            await game.found_msg.edit(embed=found_embed)      async def end_game(self, channel: discord.TextChannel, game: DuckGame, end_message: str) -> None:          """Edit the game embed to reflect the end of the game and mark the game as not running.""" @@ -296,8 +307,7 @@ class DuckGamesDirector(commands.Cog):              missed_text = "Flights everyone missed:\n" + "\n".join(f"{ans}" for ans in missed)          else:              missed_text = "All the flights were found!" - -        await game.embed_msg.edit(content=f"{missed_text}") +        await self.append_to_found_embed(game, f"\n{missed_text}")      @start_game.command(name="help")      async def show_rules(self, ctx: commands.Context) -> None: diff --git a/bot/exts/fun/trivia_quiz.py b/bot/exts/fun/trivia_quiz.py index 236586b0..712c8a12 100644 --- a/bot/exts/fun/trivia_quiz.py +++ b/bot/exts/fun/trivia_quiz.py @@ -16,7 +16,7 @@ from discord.ext import commands, tasks  from rapidfuzz import fuzz  from bot.bot import Bot -from bot.constants import Colours, NEGATIVE_REPLIES, Roles +from bot.constants import Client, Colours, NEGATIVE_REPLIES, Roles  logger = logging.getLogger(__name__) @@ -332,7 +332,7 @@ class TriviaQuiz(commands.Cog):          if self.game_status[ctx.channel.id]:              await ctx.send(                  "Game is already running... " -                f"do `{self.bot.command_prefix}quiz stop`" +                f"do `{Client.prefix}quiz stop`"              )              return diff --git a/bot/exts/holidays/halloween/candy_collection.py b/bot/exts/holidays/halloween/candy_collection.py index 4afd5913..09bd0e59 100644 --- a/bot/exts/holidays/halloween/candy_collection.py +++ b/bot/exts/holidays/halloween/candy_collection.py @@ -134,7 +134,7 @@ class CandyCollection(commands.Cog):      @property      def hacktober_channel(self) -> discord.TextChannel:          """Get #hacktoberbot channel from its ID.""" -        return self.bot.get_channel(id=Channels.community_bot_commands) +        return self.bot.get_channel(Channels.community_bot_commands)      @staticmethod      async def send_spook_msg( diff --git a/bot/exts/holidays/halloween/spookynamerate.py b/bot/exts/holidays/halloween/spookynamerate.py index 2e59d4a8..a3aa4f13 100644 --- a/bot/exts/holidays/halloween/spookynamerate.py +++ b/bot/exts/holidays/halloween/spookynamerate.py @@ -143,7 +143,7 @@ class SpookyNameRate(Cog):              if data["author"] == ctx.author.id:                  await ctx.send(                      "But you have already added an entry! Type " -                    f"`{self.bot.command_prefix}spookynamerate " +                    f"`{Client.prefix}spookynamerate "                      "delete` to delete it, and then you can add it again"                  )                  return @@ -185,7 +185,7 @@ class SpookyNameRate(Cog):                  return          await ctx.send( -            f"But you don't have an entry... :eyes: Type `{self.bot.command_prefix}spookynamerate add your entry`" +            f"But you don't have an entry... :eyes: Type `{Client.prefix}spookynamerate add your entry`"          )      @Cog.listener() @@ -225,7 +225,7 @@ class SpookyNameRate(Cog):                  "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`" +                f"`{Client.prefix}spookynamerate add spookified name`"              )              await self.data.set("first_time", False) diff --git a/bot/exts/holidays/halloween/spookyreact.py b/bot/exts/holidays/halloween/spookyreact.py index 25e783f4..e228b91d 100644 --- a/bot/exts/holidays/halloween/spookyreact.py +++ b/bot/exts/holidays/halloween/spookyreact.py @@ -47,12 +47,12 @@ class SpookyReact(Cog):          Short-circuit helper check.          Return True if: -          * author is the bot +          * author is a bot            * prefix is not None          """ -        # Check for self reaction -        if message.author == self.bot.user: -            log.debug(f"Ignoring reactions on self message. Message ID: {message.id}") +        # Check if message author is a bot +        if message.author.bot: +            log.debug(f"Ignoring reactions on bot message. Message ID: {message.id}")              return True          # Check for command invocation diff --git a/bot/exts/holidays/valentines/lovecalculator.py b/bot/exts/holidays/valentines/lovecalculator.py index 3999db2b..a53014e5 100644 --- a/bot/exts/holidays/valentines/lovecalculator.py +++ b/bot/exts/holidays/valentines/lovecalculator.py @@ -74,7 +74,8 @@ class LoveCalculator(Cog):          # We need the -1 due to how bisect returns the point          # see the documentation for further detail          # https://docs.python.org/3/library/bisect.html#bisect.bisect -        index = bisect.bisect(LOVE_DATA, (love_percent,)) - 1 +        love_threshold = [threshold for threshold, _ in LOVE_DATA] +        index = bisect.bisect(love_threshold, love_percent) - 1          # We already have the nearest "fit" love level          # We only need the dict, so we can ditch the first element          _, data = LOVE_DATA[index] diff --git a/bot/exts/utilities/bookmark.py b/bot/exts/utilities/bookmark.py index 39d65168..a11c366b 100644 --- a/bot/exts/utilities/bookmark.py +++ b/bot/exts/utilities/bookmark.py @@ -98,7 +98,13 @@ class Bookmark(commands.Cog):          """Send the author a link to `target_message` via DMs."""          if not target_message:              if not ctx.message.reference: -                raise commands.UserInputError("You must either provide a valid message to bookmark, or reply to one.") +                raise commands.UserInputError( +                    "You must either provide a valid message to bookmark, or reply to one." +                    "\n\nThe lookup strategy for a message is as follows (in order):" +                    "\n1. Lookup by '{channel ID}-{message ID}' (retrieved by shift-clicking on 'Copy ID')" +                    "\n2. Lookup by message ID (the message **must** have been sent after the bot last started)" +                    "\n3. Lookup by message URL" +                )              target_message = ctx.message.reference.resolved          # Prevent users from bookmarking a message in a channel they don't have access to diff --git a/bot/exts/utilities/challenges.py b/bot/exts/utilities/challenges.py new file mode 100644 index 00000000..234eb0be --- /dev/null +++ b/bot/exts/utilities/challenges.py @@ -0,0 +1,335 @@ +import logging +from asyncio import to_thread +from random import choice +from typing import Union + +from bs4 import BeautifulSoup +from discord import Embed, Interaction, SelectOption, ui +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Colours, Emojis, NEGATIVE_REPLIES + +log = logging.getLogger(__name__) +API_ROOT = "https://www.codewars.com/api/v1/code-challenges/{kata_id}" + +# Map difficulty for the kata to color we want to display in the embed. +# These colors are representative of the colors that each kyu's level represents on codewars.com +MAPPING_OF_KYU = { +    8: 0xdddbda, 7: 0xdddbda, 6: 0xecb613, 5: 0xecb613, +    4: 0x3c7ebb, 3: 0x3c7ebb, 2: 0x866cc7, 1: 0x866cc7 +} + +# Supported languages for a kata on codewars.com +SUPPORTED_LANGUAGES = { +    "stable": [ +        "c", "c#", "c++", "clojure", "coffeescript", "coq", "crystal", "dart", "elixir", +        "f#", "go", "groovy", "haskell", "java", "javascript", "kotlin", "lean", "lua", "nasm", +        "php", "python", "racket", "ruby", "rust", "scala", "shell", "sql", "swift", "typescript" +    ], +    "beta": [ +        "agda", "bf", "cfml", "cobol", "commonlisp", "elm", "erlang", "factor", +        "forth", "fortran", "haxe", "idris", "julia", "nim", "objective-c", "ocaml", +        "pascal", "perl", "powershell", "prolog", "purescript", "r", "raku", "reason", "solidity", "vb.net" +    ] +} + + +class InformationDropdown(ui.Select): +    """A dropdown inheriting from ui.Select that allows finding out other information about the kata.""" + +    def __init__(self, language_embed: Embed, tags_embed: Embed, other_info_embed: Embed, main_embed: Embed): +        options = [ +            SelectOption( +                label="Main Information", +                description="See the kata's difficulty, description, etc.", +                emoji="🌎" +            ), +            SelectOption( +                label="Languages", +                description="See what languages this kata supports!", +                emoji=Emojis.reddit_post_text +            ), +            SelectOption( +                label="Tags", +                description="See what categories this kata falls under!", +                emoji=Emojis.stackoverflow_tag +            ), +            SelectOption( +                label="Other Information", +                description="See how other people performed on this kata and more!", +                emoji="ℹ" +            ) +        ] + +        # We map the option label to the embed instance so that it can be easily looked up later in O(1) +        self.mapping_of_embeds = { +            "Main Information": main_embed, +            "Languages": language_embed, +            "Tags": tags_embed, +            "Other Information": other_info_embed, +        } + +        super().__init__( +            placeholder="See more information regarding this kata", +            min_values=1, +            max_values=1, +            options=options +        ) + +    async def callback(self, interaction: Interaction) -> None: +        """Callback for when someone clicks on a dropdown.""" +        # Edit the message to the embed selected in the option +        # The `original_message` attribute is set just after the message is sent with the view. +        # The attribute is not set during initialization. +        result_embed = self.mapping_of_embeds[self.values[0]] +        await self.original_message.edit(embed=result_embed) + + +class Challenges(commands.Cog): +    """ +    Cog for the challenge command. + +    The challenge command pulls a random kata from codewars.com. +    A kata is the name for a challenge, specific to codewars.com. + +    The challenge command also has filters to customize the kata that is given. +    You can specify the language the kata should be from, difficulty and topic of the kata. +    """ + +    def __init__(self, bot: Bot): +        self.bot = bot + +    async def kata_id(self, search_link: str, params: dict) -> Union[str, Embed]: +        """ +        Uses bs4 to get the HTML code for the page of katas, where the page is the link of the formatted `search_link`. + +        This will webscrape the search page with `search_link` and then get the ID of a kata for the +        codewars.com API to use. +        """ +        async with self.bot.http_session.get(search_link, params=params) as response: +            if response.status != 200: +                error_embed = Embed( +                    title=choice(NEGATIVE_REPLIES), +                    description="We ran into an error when getting the kata from codewars.com, try again later.", +                    color=Colours.soft_red +                ) +                log.error(f"Unexpected response from codewars.com, status code: {response.status}") +                return error_embed + +            soup = BeautifulSoup(await response.text(), features="lxml") +            first_kata_div = await to_thread(soup.find_all, "div", class_="item-title px-0") + +            if not first_kata_div: +                raise commands.BadArgument("No katas could be found with the filters provided.") +            elif len(first_kata_div) >= 3: +                first_kata_div = choice(first_kata_div[:3]) +            elif "q=" not in search_link: +                first_kata_div = choice(first_kata_div) +            else: +                first_kata_div = first_kata_div[0] + +            # There are numerous divs before arriving at the id of the kata, which can be used for the link. +            first_kata_id = first_kata_div.a["href"].split("/")[-1] +            return first_kata_id + +    async def kata_information(self, kata_id: str) -> Union[dict, Embed]: +        """ +        Returns the information about the Kata. + +        Uses the codewars.com API to get information about the kata using `kata_id`. +        """ +        async with self.bot.http_session.get(API_ROOT.format(kata_id=kata_id)) as response: +            if response.status != 200: +                error_embed = Embed( +                    title=choice(NEGATIVE_REPLIES), +                    description="We ran into an error when getting the kata information, try again later.", +                    color=Colours.soft_red +                ) +                log.error(f"Unexpected response from codewars.com/api/v1, status code: {response.status}") +                return error_embed + +            return await response.json() + +    @staticmethod +    def main_embed(kata_information: dict) -> Embed: +        """Creates the main embed which displays the name, difficulty and description of the kata.""" +        kata_description = kata_information["description"] +        kata_url = f"https://codewars.com/kata/{kata_information['id']}" + +        # Ensuring it isn't over the length 1024 +        if len(kata_description) > 1024: +            kata_description = "\n".join(kata_description[:1000].split("\n")[:-1]) + "..." +            kata_description += f" [continue reading]({kata_url})" + +        kata_embed = Embed( +            title=kata_information["name"], +            description=kata_description, +            color=MAPPING_OF_KYU[int(kata_information["rank"]["name"].replace(" kyu", ""))], +            url=kata_url +        ) +        kata_embed.add_field(name="Difficulty", value=kata_information["rank"]["name"], inline=False) +        return kata_embed + +    @staticmethod +    def language_embed(kata_information: dict) -> Embed: +        """Creates the 'language embed' which displays all languages the kata supports.""" +        kata_url = f"https://codewars.com/kata/{kata_information['id']}" + +        languages = "\n".join(map(str.title, kata_information["languages"])) +        language_embed = Embed( +            title=kata_information["name"], +            description=f"```yaml\nSupported Languages:\n{languages}\n```", +            color=Colours.python_blue, +            url=kata_url +        ) +        return language_embed + +    @staticmethod +    def tags_embed(kata_information: dict) -> Embed: +        """ +        Creates the 'tags embed' which displays all the tags of the Kata. + +        Tags explain what the kata is about, this is what codewars.com calls categories. +        """ +        kata_url = f"https://codewars.com/kata/{kata_information['id']}" + +        tags = "\n".join(kata_information["tags"]) +        tags_embed = Embed( +            title=kata_information["name"], +            description=f"```yaml\nTags:\n{tags}\n```", +            color=Colours.grass_green, +            url=kata_url +        ) +        return tags_embed + +    @staticmethod +    def miscellaneous_embed(kata_information: dict) -> Embed: +        """ +        Creates the 'other information embed' which displays miscellaneous information about the kata. + +        This embed shows statistics such as the total number of people who completed the kata, +        the total number of stars of the kata, etc. +        """ +        kata_url = f"https://codewars.com/kata/{kata_information['id']}" + +        embed = Embed( +            title=kata_information["name"], +            description="```nim\nOther Information\n```", +            color=Colours.grass_green, +            url=kata_url +        ) +        embed.add_field( +            name="`Total Score`", +            value=f"```css\n{kata_information['voteScore']}\n```", +            inline=False +        ) +        embed.add_field( +            name="`Total Stars`", +            value=f"```css\n{kata_information['totalStars']}\n```", +            inline=False +        ) +        embed.add_field( +            name="`Total Completed`", +            value=f"```css\n{kata_information['totalCompleted']}\n```", +            inline=False +        ) +        embed.add_field( +            name="`Total Attempts`", +            value=f"```css\n{kata_information['totalAttempts']}\n```", +            inline=False +        ) +        return embed + +    @staticmethod +    def create_view(dropdown: InformationDropdown, link: str) -> ui.View: +        """ +        Creates the discord.py View for the Discord message components (dropdowns and buttons). + +        The discord UI is implemented onto the embed, where the user can choose what information about the kata they +        want, along with a link button to the kata itself. +        """ +        view = ui.View() +        view.add_item(dropdown) +        view.add_item(ui.Button(label="View the Kata", url=link)) +        return view + +    @commands.command(aliases=["kata"]) +    @commands.cooldown(1, 5, commands.BucketType.user) +    async def challenge(self, ctx: commands.Context, language: str = "python", *, query: str = None) -> None: +        """ +        The challenge command pulls a random kata (challenge) from codewars.com. + +        The different ways to use this command are: +        `.challenge <language>` - Pulls a random challenge within that language's scope. +        `.challenge <language> <difficulty>` - The difficulty can be from 1-8, +        1 being the hardest, 8 being the easiest. This pulls a random challenge within that difficulty & language. +        `.challenge <language> <query>` - Pulls a random challenge with the query provided under the language +        `.challenge <language> <query>, <difficulty>` - Pulls a random challenge with the query provided, +        under that difficulty within the language's scope. +        """ +        if language.lower() not in SUPPORTED_LANGUAGES["stable"] + SUPPORTED_LANGUAGES["beta"]: +            raise commands.BadArgument("This is not a recognized language on codewars.com!") + +        get_kata_link = f"https://codewars.com/kata/search/{language}" +        params = {} + +        if language and not query: +            level = f"-{choice([1, 2, 3, 4, 5, 6, 7, 8])}" +            params["r[]"] = level +        elif "," in query: +            query_splitted = query.split("," if ", " not in query else ", ") + +            if len(query_splitted) > 2: +                raise commands.BadArgument( +                    "There can only be one comma within the query, separating the difficulty and the query itself." +                ) + +            query, level = query_splitted +            params["q"] = query +            params["r[]"] = f"-{level}" +        elif query.isnumeric(): +            params["r[]"] = f"-{query}" +        else: +            params["q"] = query + +        params["beta"] = str(language in SUPPORTED_LANGUAGES["beta"]).lower() + +        first_kata_id = await self.kata_id(get_kata_link, params) +        if isinstance(first_kata_id, Embed): +            # We ran into an error when retrieving the website link +            await ctx.send(embed=first_kata_id) +            return + +        kata_information = await self.kata_information(first_kata_id) +        if isinstance(kata_information, Embed): +            # Something went wrong when trying to fetch the kata information +            await ctx.d(embed=kata_information) +            return + +        kata_embed = self.main_embed(kata_information) +        language_embed = self.language_embed(kata_information) +        tags_embed = self.tags_embed(kata_information) +        miscellaneous_embed = self.miscellaneous_embed(kata_information) + +        dropdown = InformationDropdown( +            main_embed=kata_embed, +            language_embed=language_embed, +            tags_embed=tags_embed, +            other_info_embed=miscellaneous_embed +        ) +        kata_view = self.create_view(dropdown, f"https://codewars.com/kata/{first_kata_id}") +        original_message = await ctx.send( +            embed=kata_embed, +            view=kata_view +        ) +        dropdown.original_message = original_message + +        wait_for_kata = await kata_view.wait() +        if wait_for_kata: +            await original_message.edit(embed=kata_embed, view=None) + + +def setup(bot: Bot) -> None: +    """Load the Challenges cog.""" +    bot.add_cog(Challenges(bot)) diff --git a/bot/exts/utilities/conversationstarters.py b/bot/exts/utilities/conversationstarters.py index dd537022..dcbfe4d5 100644 --- a/bot/exts/utilities/conversationstarters.py +++ b/bot/exts/utilities/conversationstarters.py @@ -1,11 +1,15 @@ +import asyncio +from contextlib import suppress +from functools import partial  from pathlib import Path +from typing import Union +import discord  import yaml -from discord import Color, Embed  from discord.ext import commands  from bot.bot import Bot -from bot.constants import WHITELISTED_CHANNELS +from bot.constants import MODERATION_ROLES, WHITELISTED_CHANNELS  from bot.utils.decorators import whitelist_override  from bot.utils.randomization import RandomCycle @@ -35,35 +39,88 @@ TOPICS = {  class ConvoStarters(commands.Cog):      """General conversation topics.""" -    @commands.command() -    @whitelist_override(channels=ALL_ALLOWED_CHANNELS) -    async def topic(self, ctx: commands.Context) -> None: +    def __init__(self, bot: Bot): +        self.bot = bot + +    @staticmethod +    def _build_topic_embed(channel_id: int) -> discord.Embed:          """ -        Responds with a random topic to start a conversation. +        Build an embed containing a conversation topic.          If in a Python channel, a python-related topic will be given. -          Otherwise, a random conversation topic will be received by the user.          """          # No matter what, the form will be shown. -        embed = Embed(description=f"Suggest more topics [here]({SUGGESTION_FORM})!", color=Color.blurple()) +        embed = discord.Embed( +            description=f"Suggest more topics [here]({SUGGESTION_FORM})!", +            color=discord.Color.blurple() +        )          try: -            # Fetching topics. -            channel_topics = TOPICS[ctx.channel.id] - -        # If the channel isn't Python-related. +            channel_topics = TOPICS[channel_id]          except KeyError: +            # Channel doesn't have any topics.              embed.title = f"**{next(TOPICS['default'])}**" - -        # If the channel ID doesn't have any topics.          else:              embed.title = f"**{next(channel_topics)}**" +        return embed + +    @staticmethod +    def _predicate( +        command_invoker: Union[discord.User, discord.Member], +        message: discord.Message, +        reaction: discord.Reaction, +        user: discord.User +    ) -> bool: +        user_is_moderator = any(role.id in MODERATION_ROLES for role in getattr(user, "roles", [])) +        user_is_invoker = user.id == command_invoker.id + +        is_right_reaction = all(( +            reaction.message.id == message.id, +            str(reaction.emoji) == "🔄", +            user_is_moderator or user_is_invoker +        )) +        return is_right_reaction + +    async def _listen_for_refresh( +        self, +        command_invoker: Union[discord.User, discord.Member], +        message: discord.Message +    ) -> None: +        await message.add_reaction("🔄") +        while True: +            try: +                reaction, user = await self.bot.wait_for( +                    "reaction_add", +                    check=partial(self._predicate, command_invoker, message), +                    timeout=60.0 +                ) +            except asyncio.TimeoutError: +                with suppress(discord.NotFound): +                    await message.clear_reaction("🔄") +                break + +            try: +                await message.edit(embed=self._build_topic_embed(message.channel.id)) +            except discord.NotFound: +                break + +            with suppress(discord.NotFound): +                await message.remove_reaction(reaction, user) -        finally: -            await ctx.send(embed=embed) +    @commands.command() +    @commands.cooldown(1, 60*2, commands.BucketType.channel) +    @whitelist_override(channels=ALL_ALLOWED_CHANNELS) +    async def topic(self, ctx: commands.Context) -> None: +        """ +        Responds with a random topic to start a conversation. + +        Allows the refresh of a topic by pressing an emoji. +        """ +        message = await ctx.send(embed=self._build_topic_embed(ctx.channel.id)) +        self.bot.loop.create_task(self._listen_for_refresh(ctx.author, message))  def setup(bot: Bot) -> None:      """Load the ConvoStarters cog.""" -    bot.add_cog(ConvoStarters()) +    bot.add_cog(ConvoStarters(bot)) diff --git a/bot/exts/utilities/emoji.py b/bot/exts/utilities/emoji.py index 55d6b8e9..83df39cc 100644 --- a/bot/exts/utilities/emoji.py +++ b/bot/exts/utilities/emoji.py @@ -107,8 +107,8 @@ class Emojis(commands.Cog):              title=f"Emoji Information: {emoji.name}",              description=textwrap.dedent(f"""                  **Name:** {emoji.name} -                **Created:** {time_since(emoji.created_at, precision="hours")} -                **Date:** {datetime.strftime(emoji.created_at, "%d/%m/%Y")} +                **Created:** {time_since(emoji.created_at.replace(tzinfo=None), precision="hours")} +                **Date:** {datetime.strftime(emoji.created_at.replace(tzinfo=None), "%d/%m/%Y")}                  **ID:** {emoji.id}              """),              color=Color.blurple(),  |