diff options
Diffstat (limited to 'bot/exts')
| -rw-r--r-- | bot/exts/christmas/advent_of_code/_cog.py | 7 | ||||
| -rw-r--r-- | bot/exts/christmas/advent_of_code/_helpers.py | 22 | ||||
| -rw-r--r-- | bot/exts/evergreen/bookmark.py | 8 | ||||
| -rw-r--r-- | bot/exts/evergreen/error_handler.py | 41 | ||||
| -rw-r--r-- | bot/exts/evergreen/source.py | 11 | ||||
| -rw-r--r-- | bot/exts/evergreen/status_codes.py | 23 | ||||
| -rw-r--r-- | bot/exts/evergreen/trivia_quiz.py | 446 | 
7 files changed, 448 insertions, 110 deletions
| diff --git a/bot/exts/christmas/advent_of_code/_cog.py b/bot/exts/christmas/advent_of_code/_cog.py index ead84544..3d61753b 100644 --- a/bot/exts/christmas/advent_of_code/_cog.py +++ b/bot/exts/christmas/advent_of_code/_cog.py @@ -3,6 +3,7 @@ import logging  from datetime import datetime, timedelta  from pathlib import Path +import arrow  import discord  from discord.ext import commands @@ -100,11 +101,11 @@ class AdventOfCode(commands.Cog):      async def aoc_countdown(self, ctx: commands.Context) -> None:          """Return time left until next day."""          if not _helpers.is_in_advent(): -            datetime_now = datetime.now(_helpers.EST) +            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 = datetime(datetime_now.year, 12, 1, tzinfo=_helpers.EST) -            next_year = datetime(datetime_now.year + 1, 12, 1, tzinfo=_helpers.EST) +            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 diff --git a/bot/exts/christmas/advent_of_code/_helpers.py b/bot/exts/christmas/advent_of_code/_helpers.py index f4a258c0..96de90c4 100644 --- a/bot/exts/christmas/advent_of_code/_helpers.py +++ b/bot/exts/christmas/advent_of_code/_helpers.py @@ -9,8 +9,8 @@ import typing  from typing import Tuple  import aiohttp +import arrow  import discord -import pytz  from bot.bot import Bot  from bot.constants import AdventOfCode, Channels, Colours @@ -48,7 +48,7 @@ AOC_EMBED_THUMBNAIL = (  )  # Create an easy constant for the EST timezone -EST = pytz.timezone("EST") +EST = "America/New_York"  # Step size for the challenge countdown status  COUNTDOWN_STEP = 60 * 5 @@ -395,13 +395,13 @@ def is_in_advent() -> bool:      something for the next Advent of Code challenge should run. As the puzzle      published on the 25th is the last puzzle, this check excludes that date.      """ -    return datetime.datetime.now(EST).day in range(1, 25) and datetime.datetime.now(EST).month == 12 +    return arrow.now(EST).day in range(1, 25) and arrow.now(EST).month == 12  def time_left_to_est_midnight() -> Tuple[datetime.datetime, datetime.timedelta]:      """Calculate the amount of time left until midnight EST/UTC-5."""      # Change all time properties back to 00:00 -    todays_midnight = datetime.datetime.now(EST).replace( +    todays_midnight = arrow.now(EST).replace(          microsecond=0,          second=0,          minute=0, @@ -412,7 +412,7 @@ def time_left_to_est_midnight() -> Tuple[datetime.datetime, datetime.timedelta]:      tomorrow = todays_midnight + datetime.timedelta(days=1)      # Calculate the timedelta between the current time and midnight -    return tomorrow, tomorrow - datetime.datetime.now(EST) +    return tomorrow, tomorrow - arrow.now(EST)  async def wait_for_advent_of_code(*, hours_before: int = 1) -> None: @@ -430,9 +430,9 @@ async def wait_for_advent_of_code(*, hours_before: int = 1) -> None:      if we're already past the Advent of Code edition the bot is currently      configured for.      """ -    start = datetime.datetime(AdventOfCode.year, 12, 1, 0, 0, 0, tzinfo=EST) +    start = arrow.get(datetime.datetime(AdventOfCode.year, 12, 1), EST)      target = start - datetime.timedelta(hours=hours_before) -    now = datetime.datetime.now(EST) +    now = arrow.now(EST)      # If we've already reached or passed to target, we      # simply return immediately. @@ -474,10 +474,10 @@ async def countdown_status(bot: Bot) -> None:      # sleeping for the entire year, it will only wait in the currently      # configured year. This means that the task will only start hibernating once      # we start preparing the next event by changing environment variables. -    last_challenge = datetime.datetime(AdventOfCode.year, 12, 25, 0, 0, 0, tzinfo=EST) +    last_challenge = arrow.get(datetime.datetime(AdventOfCode.year, 12, 25), EST)      end = last_challenge + datetime.timedelta(hours=1) -    while datetime.datetime.now(EST) < end: +    while arrow.now(EST) < end:          _, time_left = time_left_to_est_midnight()          aligned_seconds = int(math.ceil(time_left.seconds / COUNTDOWN_STEP)) * COUNTDOWN_STEP @@ -534,8 +534,8 @@ async def new_puzzle_notification(bot: Bot) -> None:      # The last event day is 25 December, so we only have to schedule      # a reminder if the current day is before 25 December. -    end = datetime.datetime(AdventOfCode.year, 12, 25, tzinfo=EST) -    while datetime.datetime.now(EST) < end: +    end = arrow.get(datetime.datetime(AdventOfCode.year, 12, 25), EST) +    while arrow.now(EST) < end:          log.trace("Started puzzle notification loop.")          tomorrow, time_left = time_left_to_est_midnight() diff --git a/bot/exts/evergreen/bookmark.py b/bot/exts/evergreen/bookmark.py index 29915627..85c9b46f 100644 --- a/bot/exts/evergreen/bookmark.py +++ b/bot/exts/evergreen/bookmark.py @@ -1,6 +1,7 @@  import asyncio  import logging  import random +import typing as t  import discord  from discord.ext import commands @@ -88,11 +89,16 @@ class Bookmark(commands.Cog):      async def bookmark(          self,          ctx: commands.Context, -        target_message: WrappedMessageConverter, +        target_message: t.Optional[WrappedMessageConverter],          *,          title: str = "Bookmark"      ) -> None:          """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.") +            target_message = ctx.message.reference.resolved +          # Prevent users from bookmarking a message in a channel they don't have access to          permissions = ctx.author.permissions_in(target_message.channel)          if not permissions.read_messages: diff --git a/bot/exts/evergreen/error_handler.py b/bot/exts/evergreen/error_handler.py index de8e53d0..5873fb83 100644 --- a/bot/exts/evergreen/error_handler.py +++ b/bot/exts/evergreen/error_handler.py @@ -1,3 +1,4 @@ +import difflib  import logging  import math  import random @@ -8,16 +9,22 @@ from discord.ext import commands  from sentry_sdk import push_scope  from bot.bot import Bot -from bot.constants import Channels, Colours, ERROR_REPLIES, NEGATIVE_REPLIES +from bot.constants import Channels, Colours, ERROR_REPLIES, NEGATIVE_REPLIES, RedirectOutput  from bot.utils.decorators import InChannelCheckFailure, InMonthCheckFailure  from bot.utils.exceptions import UserNotPlayingError  log = logging.getLogger(__name__) +QUESTION_MARK_ICON = "https://cdn.discordapp.com/emojis/512367613339369475.png" + +  class CommandErrorHandler(commands.Cog):      """A error handler for the PythonDiscord server.""" +    def __init__(self, bot: Bot) -> None: +        self.bot = bot +      @staticmethod      def revert_cooldown_counter(command: commands.Command, message: Message) -> None:          """Undoes the last cooldown counter for user-error cases.""" @@ -58,6 +65,7 @@ class CommandErrorHandler(commands.Cog):          )          if isinstance(error, commands.CommandNotFound): +            await self.send_command_suggestion(ctx, ctx.invoked_with)              return          if isinstance(error, (InChannelCheckFailure, InMonthCheckFailure)): @@ -129,7 +137,36 @@ class CommandErrorHandler(commands.Cog):              log.exception(f"Unhandled command error: {str(error)}", exc_info=error) +    async def send_command_suggestion(self, ctx: commands.Context, command_name: str) -> None: +        """Sends user similar commands if any can be found.""" +        raw_commands = [] +        for cmd in self.bot.walk_commands(): +            if not cmd.hidden: +                raw_commands += (cmd.name, *cmd.aliases) +        if similar_command_data := difflib.get_close_matches(command_name, raw_commands, 1): +            similar_command_name = similar_command_data[0] +            similar_command = self.bot.get_command(similar_command_name) + +            if not similar_command: +                return + +            log_msg = "Cancelling attempt to suggest a command due to failed checks." +            try: +                if not await similar_command.can_run(ctx): +                    log.debug(log_msg) +                    return +            except commands.errors.CommandError as cmd_error: +                log.debug(log_msg) +                await self.on_command_error(ctx, cmd_error) +                return + +            misspelled_content = ctx.message.content +            e = Embed() +            e.set_author(name="Did you mean:", icon_url=QUESTION_MARK_ICON) +            e.description = misspelled_content.replace(command_name, similar_command_name, 1) +            await ctx.send(embed=e, delete_after=RedirectOutput.delete_delay) +  def setup(bot: Bot) -> None:      """Load the ErrorHandler cog.""" -    bot.add_cog(CommandErrorHandler()) +    bot.add_cog(CommandErrorHandler(bot)) diff --git a/bot/exts/evergreen/source.py b/bot/exts/evergreen/source.py index 8fb72143..fc209bc3 100644 --- a/bot/exts/evergreen/source.py +++ b/bot/exts/evergreen/source.py @@ -33,7 +33,8 @@ class BotSource(commands.Cog):          Raise BadArgument if `source_item` is a dynamically-created object (e.g. via internal eval).          """          if isinstance(source_item, commands.Command): -            src = source_item.callback.__code__ +            callback = inspect.unwrap(source_item.callback) +            src = callback.__code__              filename = src.co_filename          else:              src = type(source_item) @@ -64,12 +65,8 @@ class BotSource(commands.Cog):          url, location, first_line = self.get_source_link(source_object)          if isinstance(source_object, commands.Command): -            if source_object.cog_name == "Help": -                title = "Help Command" -                description = source_object.__doc__.splitlines()[1] -            else: -                description = source_object.short_doc -                title = f"Command: {source_object.qualified_name}" +            description = source_object.short_doc +            title = f"Command: {source_object.qualified_name}"          else:              title = f"Cog: {source_object.qualified_name}"              description = source_object.description.splitlines()[0] diff --git a/bot/exts/evergreen/status_codes.py b/bot/exts/evergreen/status_codes.py index a866692e..181c71ce 100644 --- a/bot/exts/evergreen/status_codes.py +++ b/bot/exts/evergreen/status_codes.py @@ -1,26 +1,30 @@  from http import HTTPStatus +from random import choice  import discord  from discord.ext import commands  from bot.bot import Bot -from bot.utils.extensions import invoke_help_command  HTTP_DOG_URL = "https://httpstatusdogs.com/img/{code}.jpg"  HTTP_CAT_URL = "https://http.cat/{code}.jpg"  class HTTPStatusCodes(commands.Cog): -    """Commands that give HTTP statuses described and visualized by cats and dogs.""" +    """ +    Fetch an image depicting HTTP status codes as a dog or a cat. + +    If neither animal is selected a cat or dog is chosen randomly for the given status code. +    """      def __init__(self, bot: Bot):          self.bot = bot -    @commands.group(name="http_status", aliases=("status", "httpstatus")) -    async def http_status_group(self, ctx: commands.Context) -> None: -        """Group containing dog and cat http status code commands.""" -        if not ctx.invoked_subcommand: -            await invoke_help_command(ctx) +    @commands.group(name="http_status", aliases=("status", "httpstatus"), invoke_without_command=True) +    async def http_status_group(self, ctx: commands.Context, code: int) -> None: +        """Choose a cat or dog randomly for the given status code.""" +        subcmd = choice((self.http_cat, self.http_dog)) +        await subcmd(ctx, code)      @http_status_group.command(name="cat")      async def http_cat(self, ctx: commands.Context, code: int) -> None: @@ -48,6 +52,11 @@ class HTTPStatusCodes(commands.Cog):      @http_status_group.command(name="dog")      async def http_dog(self, ctx: commands.Context, code: int) -> None:          """Sends an embed with an image of a dog, portraying the status code.""" +        # These codes aren't server-friendly. +        if code in (304, 422): +            await self.http_cat(ctx, code) +            return +          embed = discord.Embed(title=f"**Status: {code}**")          url = HTTP_DOG_URL.format(code=code) diff --git a/bot/exts/evergreen/trivia_quiz.py b/bot/exts/evergreen/trivia_quiz.py index 352d5ae8..a8d10afd 100644 --- a/bot/exts/evergreen/trivia_quiz.py +++ b/bot/exts/evergreen/trivia_quiz.py @@ -1,55 +1,235 @@  import asyncio  import json  import logging +import operator  import random +from dataclasses import dataclass  from pathlib import Path +from typing import Callable, List, Optional  import discord  from discord.ext import commands  from fuzzywuzzy import fuzz  from bot.bot import Bot -from bot.constants import Roles - +from bot.constants import Colours, NEGATIVE_REPLIES, Roles  logger = logging.getLogger(__name__) +DEFAULT_QUESTION_LIMIT = 6 +STANDARD_VARIATION_TOLERANCE = 83 +DYNAMICALLY_GEN_VARIATION_TOLERANCE = 95  WRONG_ANS_RESPONSE = [      "No one answered correctly!", -    "Better luck next time" +    "Better luck next time...", +] + +N_PREFIX_STARTS_AT = 5 +N_PREFIXES = [ +    "penta", "hexa", "hepta", "octa", "nona", +    "deca", "hendeca", "dodeca", "trideca", "tetradeca", +] + +PLANETS = [ +    ("1st", "Mercury"), +    ("2nd", "Venus"), +    ("3rd", "Earth"), +    ("4th", "Mars"), +    ("5th", "Jupiter"), +    ("6th", "Saturn"), +    ("7th", "Uranus"), +    ("8th", "Neptune"), +] + +TAXONOMIC_HIERARCHY = [ +    "species", "genus", "family", "order", +    "class", "phylum", "kingdom", "domain",  ] +UNITS_TO_BASE_UNITS = { +    "hertz": ("(unit of frequency)", "s^-1"), +    "newton": ("(unit of force)", "m*kg*s^-2"), +    "pascal": ("(unit of pressure & stress)", "m^-1*kg*s^-2"), +    "joule": ("(unit of energy & quantity of heat)", "m^2*kg*s^-2"), +    "watt": ("(unit of power)", "m^2*kg*s^-3"), +    "coulomb": ("(unit of electric charge & quantity of electricity)", "s*A"), +    "volt": ("(unit of voltage & electromotive force)", "m^2*kg*s^-3*A^-1"), +    "farad": ("(unit of capacitance)", "m^-2*kg^-1*s^4*A^2"), +    "ohm": ("(unit of electric resistance)", "m^2*kg*s^-3*A^-2"), +    "weber": ("(unit of magnetic flux)", "m^2*kg*s^-2*A^-1"), +    "tesla": ("(unit of magnetic flux density)", "kg*s^-2*A^-1"), +} + + +@dataclass(frozen=True) +class QuizEntry: +    """Dataclass for a quiz entry (a question and a string containing answers separated by commas).""" + +    question: str +    answer: str + + +def linear_system(q_format: str, a_format: str) -> QuizEntry: +    """Generate a system of linear equations with two unknowns.""" +    x, y = random.randint(2, 5), random.randint(2, 5) +    answer = a_format.format(x, y) + +    coeffs = random.sample(range(1, 6), 4) + +    question = q_format.format( +        coeffs[0], +        coeffs[1], +        coeffs[0] * x + coeffs[1] * y, +        coeffs[2], +        coeffs[3], +        coeffs[2] * x + coeffs[3] * y, +    ) + +    return QuizEntry(question, answer) + + +def mod_arith(q_format: str, a_format: str) -> QuizEntry: +    """Generate a basic modular arithmetic question.""" +    quotient, m, b = random.randint(30, 40), random.randint(10, 20), random.randint(200, 350) +    ans = random.randint(0, 9)  # max remainder is 9, since the minimum modulus is 10 +    a = quotient * m + ans - b + +    question = q_format.format(a, b, m) +    answer = a_format.format(ans) + +    return QuizEntry(question, answer) + + +def ngonal_prism(q_format: str, a_format: str) -> QuizEntry: +    """Generate a question regarding vertices on n-gonal prisms.""" +    n = random.randint(0, len(N_PREFIXES) - 1) + +    question = q_format.format(N_PREFIXES[n]) +    answer = a_format.format((n + N_PREFIX_STARTS_AT) * 2) + +    return QuizEntry(question, answer) + + +def imag_sqrt(q_format: str, a_format: str) -> QuizEntry: +    """Generate a negative square root question.""" +    ans_coeff = random.randint(3, 10) + +    question = q_format.format(ans_coeff ** 2) +    answer = a_format.format(ans_coeff) + +    return QuizEntry(question, answer) + + +def binary_calc(q_format: str, a_format: str) -> QuizEntry: +    """Generate a binary calculation question.""" +    a = random.randint(15, 20) +    b = random.randint(10, a) +    oper = random.choice( +        ( +            ("+", operator.add), +            ("-", operator.sub), +            ("*", operator.mul), +        ) +    ) + +    # if the operator is multiplication, lower the values of the two operands to make it easier +    if oper[0] == "*": +        a -= 5 +        b -= 5 + +    question = q_format.format(a, oper[0], b) +    answer = a_format.format(oper[1](a, b)) + +    return QuizEntry(question, answer) + + +def solar_system(q_format: str, a_format: str) -> QuizEntry: +    """Generate a question on the planets of the Solar System.""" +    planet = random.choice(PLANETS) + +    question = q_format.format(planet[0]) +    answer = a_format.format(planet[1]) + +    return QuizEntry(question, answer) + + +def taxonomic_rank(q_format: str, a_format: str) -> QuizEntry: +    """Generate a question on taxonomic classification.""" +    level = random.randint(0, len(TAXONOMIC_HIERARCHY) - 2) + +    question = q_format.format(TAXONOMIC_HIERARCHY[level]) +    answer = a_format.format(TAXONOMIC_HIERARCHY[level + 1]) + +    return QuizEntry(question, answer) + + +def base_units_convert(q_format: str, a_format: str) -> QuizEntry: +    """Generate a SI base units conversion question.""" +    unit = random.choice(list(UNITS_TO_BASE_UNITS)) + +    question = q_format.format( +        unit + " " + UNITS_TO_BASE_UNITS[unit][0] +    ) +    answer = a_format.format( +        UNITS_TO_BASE_UNITS[unit][1] +    ) + +    return QuizEntry(question, answer) + + +DYNAMIC_QUESTIONS_FORMAT_FUNCS = { +    201: linear_system, +    202: mod_arith, +    203: ngonal_prism, +    204: imag_sqrt, +    205: binary_calc, +    301: solar_system, +    302: taxonomic_rank, +    303: base_units_convert, +} +  class TriviaQuiz(commands.Cog):      """A cog for all quiz commands."""      def __init__(self, bot: Bot) -> None:          self.bot = bot -        self.questions = self.load_questions() +          self.game_status = {}  # A variable to store the game status: either running or not running.          self.game_owners = {}  # A variable to store the person's ID who started the quiz game in a channel. -        self.question_limit = 4 + +        self.questions = self.load_questions() +        self.question_limit = 0 +          self.player_scores = {}  # A variable to store all player's scores for a bot session.          self.game_player_scores = {}  # A variable to store temporary game player's scores. +          self.categories = { -            "general": "Test your general knowledge" -            # "retro": "Questions related to retro gaming." +            "general": "Test your general knowledge.", +            "retro": "Questions related to retro gaming.", +            "math": "General questions about mathematics ranging from grade 8 to grade 12.", +            "science": "Put your understanding of science to the test!",          }      @staticmethod      def load_questions() -> dict:          """Load the questions from the JSON file."""          p = Path("bot", "resources", "evergreen", "trivia_quiz.json") -        return json.loads(p.read_text("utf8")) -    @commands.group(name="quiz", aliases=("trivia",), invoke_without_command=True) -    async def quiz_game(self, ctx: commands.Context, category: str = None) -> None: +        return json.loads(p.read_text(encoding="utf-8")) + +    @commands.group(name="quiz", aliases=["trivia"], invoke_without_command=True) +    async def quiz_game(self, ctx: commands.Context, category: Optional[str], questions: Optional[int]) -> None:          """          Start a quiz!          Questions for the quiz can be selected from the following categories: -        - general : Test your general knowledge. (default) +        - general: Test your general knowledge. (default) +        - retro: Questions related to retro gaming. +        - math: General questions about mathematics ranging from grade 8 to grade 12. +        - science: Put your understanding of science to the test! +          (More to come!)          """          if ctx.channel.id not in self.game_status: @@ -59,9 +239,9 @@ class TriviaQuiz(commands.Cog):              self.game_player_scores[ctx.channel.id] = {}          # Stop game if running. -        if self.game_status[ctx.channel.id] is True: +        if self.game_status[ctx.channel.id]:              await ctx.send( -                f"Game is already running..." +                "Game is already running... "                  f"do `{self.bot.command_prefix}quiz stop`"              )              return @@ -76,8 +256,35 @@ class TriviaQuiz(commands.Cog):              await ctx.send(embed=embed)              return +        topic = self.questions[category] +        topic_length = len(topic) + +        if questions is None: +            self.question_limit = DEFAULT_QUESTION_LIMIT +        else: +            if questions > topic_length: +                await ctx.send( +                    embed=self.make_error_embed( +                        f"This category only has {topic_length} questions. " +                        "Please input a lower value!" +                    ) +                ) +                return + +            elif questions < 1: +                await ctx.send( +                    embed=self.make_error_embed( +                        "You must choose to complete at least one question. " +                        f"(or enter nothing for the default value of {DEFAULT_QUESTION_LIMIT + 1} questions)" +                    ) +                ) +                return + +            else: +                self.question_limit = questions - 1 +          # Start game if not running. -        if self.game_status[ctx.channel.id] is False: +        if not self.game_status[ctx.channel.id]:              self.game_owners[ctx.channel.id] = ctx.author              self.game_status[ctx.channel.id] = True              start_embed = self.make_start_embed(category) @@ -85,11 +292,10 @@ class TriviaQuiz(commands.Cog):              await ctx.send(embed=start_embed)  # send an embed with the rules              await asyncio.sleep(1) -        topic = self.questions[category] -          done_question = []          hint_no = 0 -        answer = None +        answers = None +          while self.game_status[ctx.channel.id]:              # Exit quiz if number of questions for a round are already sent.              if len(done_question) > self.question_limit and hint_no == 0: @@ -111,34 +317,58 @@ class TriviaQuiz(commands.Cog):                          done_question.append(question_dict["id"])                          break -                q = question_dict["question"] -                answer = question_dict["answer"] +                if "dynamic_id" not in question_dict: +                    question = question_dict["question"] +                    answers = question_dict["answer"].split(", ") + +                    var_tol = STANDARD_VARIATION_TOLERANCE +                else: +                    format_func = DYNAMIC_QUESTIONS_FORMAT_FUNCS[question_dict["dynamic_id"]] + +                    quiz_entry = format_func( +                        question_dict["question"], +                        question_dict["answer"], +                    ) -                embed = discord.Embed(colour=discord.Colour.gold()) -                embed.title = f"Question #{len(done_question)}" -                embed.description = q -                await ctx.send(embed=embed)  # Send question embed. +                    question, answers = quiz_entry.question, quiz_entry.answer +                    answers = [answers] -            # A function to check whether user input is the correct answer(close to the right answer) -            def check(m: discord.Message) -> bool: -                return ( -                    m.channel == ctx.channel -                    and fuzz.ratio(answer.lower(), m.content.lower()) > 85 +                    var_tol = DYNAMICALLY_GEN_VARIATION_TOLERANCE + +                embed = discord.Embed( +                    colour=Colours.gold, +                    title=f"Question #{len(done_question)}", +                    description=question,                  ) +                if img_url := question_dict.get("img_url"): +                    embed.set_image(url=img_url) + +                await ctx.send(embed=embed) + +            def check_func(variation_tolerance: int) -> Callable[[discord.Message], bool]: +                def contains_correct_answer(m: discord.Message) -> bool: +                    return m.channel == ctx.channel and any( +                        fuzz.ratio(answer.lower(), m.content.lower()) > variation_tolerance +                        for answer in answers +                    ) + +                return contains_correct_answer +              try: -                msg = await self.bot.wait_for("message", check=check, timeout=10) +                msg = await self.bot.wait_for("message", check=check_func(var_tol), timeout=10)              except asyncio.TimeoutError:                  # In case of TimeoutError and the game has been stopped, then do nothing. -                if self.game_status[ctx.channel.id] is False: +                if not self.game_status[ctx.channel.id]:                      break -                # if number of hints sent or time alerts sent is less than 2, then send one.                  if hint_no < 2:                      hint_no += 1 +                      if "hints" in question_dict:                          hints = question_dict["hints"] -                        await ctx.send(f"**Hint #{hint_no+1}\n**{hints[hint_no]}") + +                        await ctx.send(f"**Hint #{hint_no}\n**{hints[hint_no - 1]}")                      else:                          await ctx.send(f"{30 - hint_no * 10}s left!") @@ -151,10 +381,17 @@ class TriviaQuiz(commands.Cog):                      response = random.choice(WRONG_ANS_RESPONSE)                      await ctx.send(response) -                    await self.send_answer(ctx.channel, question_dict) + +                    await self.send_answer( +                        ctx.channel, +                        answers, +                        False, +                        question_dict, +                        self.question_limit - len(done_question) + 1, +                    )                      await asyncio.sleep(1) -                    hint_no = 0  # init hint_no = 0 so that 2 hints/time alerts can be sent for the new question. +                    hint_no = 0  # Reset the hint counter so that on the next round, it's in the initial state                      await self.send_score(ctx.channel, self.game_player_scores[ctx.channel.id])                      await asyncio.sleep(2) @@ -162,8 +399,7 @@ class TriviaQuiz(commands.Cog):                  if self.game_status[ctx.channel.id] is False:                      break -                # Reduce points by 25 for every hint/time alert that has been sent. -                points = 100 - 25*hint_no +                points = 100 - 25 * hint_no                  if msg.author in self.game_player_scores[ctx.channel.id]:                      self.game_player_scores[ctx.channel.id][msg.author] += points                  else: @@ -178,23 +414,50 @@ class TriviaQuiz(commands.Cog):                  hint_no = 0                  await ctx.send(f"{msg.author.mention} got the correct answer :tada: {points} points!") -                await self.send_answer(ctx.channel, question_dict) + +                await self.send_answer( +                    ctx.channel, +                    answers, +                    True, +                    question_dict, +                    self.question_limit - len(done_question) + 1, +                )                  await self.send_score(ctx.channel, self.game_player_scores[ctx.channel.id]) +                  await asyncio.sleep(2) -    @staticmethod -    def make_start_embed(category: str) -> discord.Embed: +    def make_start_embed(self, category: str) -> discord.Embed:          """Generate a starting/introduction embed for the quiz.""" -        start_embed = discord.Embed(colour=discord.Colour.red()) -        start_embed.title = "Quiz game Starting!!" -        start_embed.description = "Each game consists of 5 questions.\n" -        start_embed.description += "**Rules :**\nNo cheating and have fun!" -        start_embed.description += f"\n **Category** : {category}" +        start_embed = discord.Embed( +            colour=Colours.blue, +            title="Quiz game starting!", +            description=( +                f"This game consists of {self.question_limit + 1} questions.\n" +                "**Rules: **No cheating and have fun!\n" +                f"**Category**: {category}" +            ), +        ) +          start_embed.set_footer( -            text="Points for each question reduces by 25 after 10s or after a hint. Total time is 30s per question" +            text=( +                "Points for each question reduces by 25 after 10s or after a hint. " +                "Total time is 30s per question" +            )          ) +          return start_embed +    @staticmethod +    def make_error_embed(desc: str) -> discord.Embed: +        """Generate an error embed with the given description.""" +        error_embed = discord.Embed( +            colour=Colours.soft_red, +            title=random.choice(NEGATIVE_REPLIES), +            description=desc, +        ) + +        return error_embed +      @quiz_game.command(name="stop")      async def stop_quiz(self, ctx: commands.Context) -> None:          """ @@ -202,21 +465,24 @@ class TriviaQuiz(commands.Cog):          Note: Only mods or the owner of the quiz can stop it.          """ -        if self.game_status[ctx.channel.id] is True: -            # Check if the author is the game starter or a moderator. -            if ( -                ctx.author == self.game_owners[ctx.channel.id] -                or any(Roles.moderator == role.id for role in ctx.author.roles) -            ): -                await ctx.send("Quiz stopped.") -                await self.declare_winner(ctx.channel, self.game_player_scores[ctx.channel.id]) +        try: +            if self.game_status[ctx.channel.id]: +                # Check if the author is the game starter or a moderator. +                if ctx.author == self.game_owners[ctx.channel.id] or any( +                    Roles.moderator == role.id for role in ctx.author.roles +                ): +                    self.game_status[ctx.channel.id] = False +                    del self.game_owners[ctx.channel.id] +                    self.game_player_scores[ctx.channel.id] = {} + +                    await ctx.send("Quiz stopped.") +                    await self.declare_winner(ctx.channel, self.game_player_scores[ctx.channel.id]) -                self.game_status[ctx.channel.id] = False -                del self.game_owners[ctx.channel.id] -                self.game_player_scores[ctx.channel.id] = {} +                else: +                    await ctx.send(f"{ctx.author.mention}, you are not authorised to stop this game :ghost:!")              else: -                await ctx.send(f"{ctx.author.mention}, you are not authorised to stop this game :ghost:!") -        else: +                await ctx.send("No quiz running.") +        except KeyError:              await ctx.send("No quiz running.")      @quiz_game.command(name="leaderboard") @@ -226,18 +492,20 @@ class TriviaQuiz(commands.Cog):      @staticmethod      async def send_score(channel: discord.TextChannel, player_data: dict) -> None: -        """A function which sends the score.""" +        """Send the current scores of players in the game channel."""          if len(player_data) == 0:              await channel.send("No one has made it onto the leaderboard yet.")              return -        embed = discord.Embed(colour=discord.Colour.blue()) -        embed.title = "Score Board" -        embed.description = "" +        embed = discord.Embed( +            colour=Colours.blue, +            title="Score Board", +            description="", +        ) -        sorted_dict = sorted(player_data.items(), key=lambda a: a[1], reverse=True) +        sorted_dict = sorted(player_data.items(), key=operator.itemgetter(1), reverse=True)          for item in sorted_dict: -            embed.description += f"{item[0]} : {item[1]}\n" +            embed.description += f"{item[0]}: {item[1]}\n"          await channel.send(embed=embed) @@ -250,7 +518,6 @@ class TriviaQuiz(commands.Cog):              # Check if more than 1 player has highest points.              if no_of_winners > 1: -                word = "You guys"                  winners = []                  points_copy = list(player_data.values()).copy() @@ -261,41 +528,62 @@ class TriviaQuiz(commands.Cog):                  winners_mention = " ".join(winner.mention for winner in winners)              else: -                word = "You"                  author_index = list(player_data.values()).index(highest_points)                  winner = list(player_data.keys())[author_index]                  winners_mention = winner.mention              await channel.send(                  f"Congratulations {winners_mention} :tada: " -                f"{word} have won this quiz game with a grand total of {highest_points} points!" +                f"You have won this quiz game with a grand total of {highest_points} points!"              )      def category_embed(self) -> discord.Embed:          """Build an embed showing all available trivia categories.""" -        embed = discord.Embed(colour=discord.Colour.blue()) -        embed.title = "The available question categories are:" +        embed = discord.Embed( +            colour=Colours.blue, +            title="The available question categories are:", +            description="", +        ) +          embed.set_footer(text="If a category is not chosen, a random one will be selected.") -        embed.description = ""          for cat, description in self.categories.items(): -            embed.description += f"**- {cat.capitalize()}**\n{description.capitalize()}\n" +            embed.description += ( +                f"**- {cat.capitalize()}**\n" +                f"{description.capitalize()}\n" +            )          return embed      @staticmethod -    async def send_answer(channel: discord.TextChannel, question_dict: dict) -> None: +    async def send_answer( +        channel: discord.TextChannel, +        answers: List[str], +        answer_is_correct: bool, +        question_dict: dict, +        q_left: int, +    ) -> None:          """Send the correct answer of a question to the game channel.""" -        answer = question_dict["answer"] -        info = question_dict["info"] -        embed = discord.Embed(color=discord.Colour.red()) -        embed.title = f"The correct answer is **{answer}**\n" -        embed.description = "" +        info = question_dict.get("info") + +        plurality = " is" if len(answers) == 1 else "s are" -        if info != "": +        embed = discord.Embed( +            color=Colours.bright_green, +            title=( +                ("You got it! " if answer_is_correct else "") +                + f"The correct answer{plurality} **`{', '.join(answers)}`**\n" +            ), +            description="", +        ) + +        if info is not None:              embed.description += f"**Information**\n{info}\n\n" -        embed.description += "Let's move to the next question.\nRemaining questions: " +        embed.description += ( +            ("Let's move to the next question." if q_left > 0 else "") +            + f"\nRemaining questions: {q_left}" +        )          await channel.send(embed=embed) | 
