aboutsummaryrefslogtreecommitdiffstats
path: root/bot/exts
diff options
context:
space:
mode:
Diffstat (limited to 'bot/exts')
-rw-r--r--bot/exts/avatar_modification/avatar_modify.py2
-rw-r--r--bot/exts/core/extensions.py4
-rw-r--r--bot/exts/core/help.py5
-rw-r--r--bot/exts/core/internal_eval/_internal_eval.py1
-rw-r--r--bot/exts/events/advent_of_code/_cog.py49
-rw-r--r--bot/exts/events/advent_of_code/_helpers.py9
-rw-r--r--bot/exts/events/advent_of_code/views/dayandstarview.py71
-rw-r--r--bot/exts/events/hacktoberfest/hacktober-issue-finder.py11
-rw-r--r--bot/exts/fun/anagram.py110
-rw-r--r--bot/exts/fun/duck_game.py42
-rw-r--r--bot/exts/fun/hangman.py182
-rw-r--r--bot/exts/fun/quack.py75
-rw-r--r--bot/exts/fun/tic_tac_toe.py16
-rw-r--r--bot/exts/fun/trivia_quiz.py426
-rw-r--r--bot/exts/holidays/easter/earth_photos.py3
-rw-r--r--bot/exts/holidays/halloween/candy_collection.py9
-rw-r--r--bot/exts/holidays/halloween/scarymovie.py1
-rw-r--r--bot/exts/holidays/halloween/spookynamerate.py6
-rw-r--r--bot/exts/holidays/halloween/spookyreact.py8
-rw-r--r--bot/exts/holidays/hanukkah/hanukkah_embed.py2
-rw-r--r--bot/exts/holidays/valentines/lovecalculator.py3
-rw-r--r--bot/exts/utilities/bookmark.py13
-rw-r--r--bot/exts/utilities/challenges.py335
-rw-r--r--bot/exts/utilities/conversationstarters.py91
-rw-r--r--bot/exts/utilities/emoji.py6
-rw-r--r--bot/exts/utilities/githubinfo.py4
-rw-r--r--bot/exts/utilities/issues.py28
-rw-r--r--bot/exts/utilities/realpython.py16
-rw-r--r--bot/exts/utilities/reddit.py10
-rw-r--r--bot/exts/utilities/wikipedia.py6
-rw-r--r--bot/exts/utilities/wtf_python.py138
31 files changed, 1402 insertions, 280 deletions
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 424bacac..d809d2b9 100644
--- a/bot/exts/core/extensions.py
+++ b/bot/exts/core/extensions.py
@@ -18,7 +18,7 @@ from bot.utils.pagination import LinePaginator
log = logging.getLogger(__name__)
-UNLOAD_BLACKLIST = {f"{exts.__name__}.utils.extensions"}
+UNLOAD_BLACKLIST = {f"{exts.__name__}.core.extensions"}
BASE_PATH_LEN = len(exts.__name__.split("."))
@@ -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 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/hangman.py b/bot/exts/fun/hangman.py
new file mode 100644
index 00000000..a2c8c735
--- /dev/null
+++ b/bot/exts/fun/hangman.py
@@ -0,0 +1,182 @@
+from asyncio import TimeoutError
+from pathlib import Path
+from random import choice
+
+from discord import Embed, Message
+from discord.ext import commands
+
+from bot.bot import Bot
+from bot.constants import Colours, NEGATIVE_REPLIES
+
+# Defining all words in the list of words as a global variable
+ALL_WORDS = Path("bot/resources/fun/hangman_words.txt").read_text().splitlines()
+
+# Defining a dictionary of images that will be used for the game to represent the hangman person
+IMAGES = {
+ 6: "https://cdn.discordapp.com/attachments/859123972884922418/888133201497837598/hangman0.png",
+ 5: "https://cdn.discordapp.com/attachments/859123972884922418/888133595259084800/hangman1.png",
+ 4: "https://cdn.discordapp.com/attachments/859123972884922418/888134194474139688/hangman2.png",
+ 3: "https://cdn.discordapp.com/attachments/859123972884922418/888133758069395466/hangman3.png",
+ 2: "https://cdn.discordapp.com/attachments/859123972884922418/888133786724859924/hangman4.png",
+ 1: "https://cdn.discordapp.com/attachments/859123972884922418/888133828831477791/hangman5.png",
+ 0: "https://cdn.discordapp.com/attachments/859123972884922418/888133845449338910/hangman6.png",
+}
+
+
+class Hangman(commands.Cog):
+ """
+ Cog for the Hangman game.
+
+ Hangman is a classic game where the user tries to guess a word, with a limited amount of tries.
+ """
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+
+ @staticmethod
+ def create_embed(tries: int, user_guess: str) -> Embed:
+ """
+ Helper method that creates the embed where the game information is shown.
+
+ This includes how many letters the user has guessed so far, and the hangman photo itself.
+ """
+ hangman_embed = Embed(
+ title="Hangman",
+ color=Colours.python_blue,
+ )
+ hangman_embed.set_image(url=IMAGES[tries])
+ hangman_embed.add_field(
+ name=f"You've guessed `{user_guess}` so far.",
+ value="Guess the word by sending a message with a letter!"
+ )
+ hangman_embed.set_footer(text=f"Tries remaining: {tries}")
+ return hangman_embed
+
+ @commands.command()
+ async def hangman(
+ self,
+ ctx: commands.Context,
+ min_length: int = 0,
+ max_length: int = 25,
+ min_unique_letters: int = 0,
+ max_unique_letters: int = 25,
+ ) -> None:
+ """
+ Play hangman against the bot, where you have to guess the word it has provided!
+
+ The arguments for this command mean:
+ - min_length: the minimum length you want the word to be (i.e. 2)
+ - max_length: the maximum length you want the word to be (i.e. 5)
+ - min_unique_letters: the minimum unique letters you want the word to have (i.e. 4)
+ - max_unique_letters: the maximum unique letters you want the word to have (i.e. 7)
+ """
+ # Filtering the list of all words depending on the configuration
+ filtered_words = [
+ word for word in ALL_WORDS
+ if min_length < len(word) < max_length
+ and min_unique_letters < len(set(word)) < max_unique_letters
+ ]
+
+ if not filtered_words:
+ filter_not_found_embed = Embed(
+ title=choice(NEGATIVE_REPLIES),
+ description="No words could be found that fit all filters specified.",
+ color=Colours.soft_red,
+ )
+ await ctx.send(embed=filter_not_found_embed)
+ return
+
+ word = choice(filtered_words)
+ # `pretty_word` is used for comparing the indices where the guess of the user is similar to the word
+ # The `user_guess` variable is prettified by adding spaces between every dash, and so is the `pretty_word`
+ pretty_word = ''.join([f"{letter} " for letter in word])[:-1]
+ user_guess = ("_ " * len(word))[:-1]
+ tries = 6
+ guessed_letters = set()
+
+ def check(msg: Message) -> bool:
+ return msg.author == ctx.author and msg.channel == ctx.channel
+
+ original_message = await ctx.send(embed=Embed(
+ title="Hangman",
+ description="Loading game...",
+ color=Colours.soft_green
+ ))
+
+ # Game loop
+ while user_guess.replace(' ', '') != word:
+ # Edit the message to the current state of the game
+ await original_message.edit(embed=self.create_embed(tries, user_guess))
+
+ try:
+ message = await self.bot.wait_for(
+ event="message",
+ timeout=60.0,
+ check=check
+ )
+ except TimeoutError:
+ timeout_embed = Embed(
+ title=choice(NEGATIVE_REPLIES),
+ description="Looks like the bot timed out! You must send a letter within 60 seconds.",
+ color=Colours.soft_red,
+ )
+ await original_message.edit(embed=timeout_embed)
+ return
+
+ # If the user enters a capital letter as their guess, it is automatically converted to a lowercase letter
+ normalized_content = message.content.lower()
+ # The user should only guess one letter per message
+ if len(normalized_content) > 1:
+ letter_embed = Embed(
+ title=choice(NEGATIVE_REPLIES),
+ description="You can only send one letter at a time, try again!",
+ color=Colours.dark_green,
+ )
+ await ctx.send(embed=letter_embed, delete_after=4)
+ continue
+
+ # Checks for repeated guesses
+ elif normalized_content in guessed_letters:
+ already_guessed_embed = Embed(
+ title=choice(NEGATIVE_REPLIES),
+ description=f"You have already guessed `{normalized_content}`, try again!",
+ color=Colours.dark_green,
+ )
+ await ctx.send(embed=already_guessed_embed, delete_after=4)
+ continue
+
+ # Checks for correct guesses from the user
+ elif normalized_content in word:
+ positions = {idx for idx, letter in enumerate(pretty_word) if letter == normalized_content}
+ user_guess = "".join(
+ [normalized_content if index in positions else dash for index, dash in enumerate(user_guess)]
+ )
+
+ else:
+ tries -= 1
+
+ if tries <= 0:
+ losing_embed = Embed(
+ title="You lost.",
+ description=f"The word was `{word}`.",
+ color=Colours.soft_red,
+ )
+ await original_message.edit(embed=self.create_embed(tries, user_guess))
+ await ctx.send(embed=losing_embed)
+ return
+
+ guessed_letters.add(normalized_content)
+
+ # The loop exited meaning that the user has guessed the word
+ await original_message.edit(embed=self.create_embed(tries, user_guess))
+ win_embed = Embed(
+ title="You won!",
+ description=f"The word was `{word}`.",
+ color=Colours.grass_green
+ )
+ await ctx.send(embed=win_embed)
+
+
+def setup(bot: Bot) -> None:
+ """Load the Hangman cog."""
+ bot.add_cog(Hangman(bot))
diff --git a/bot/exts/fun/quack.py b/bot/exts/fun/quack.py
new file mode 100644
index 00000000..0c228aed
--- /dev/null
+++ b/bot/exts/fun/quack.py
@@ -0,0 +1,75 @@
+import logging
+import random
+from typing import Literal, Optional
+
+import discord
+from discord.ext import commands
+
+from bot.bot import Bot
+from bot.constants import Colours, NEGATIVE_REPLIES
+
+API_URL = 'https://quackstack.pythondiscord.com'
+
+log = logging.getLogger(__name__)
+
+
+class Quackstack(commands.Cog):
+ """Cog used for wrapping Quackstack."""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+
+ @commands.command()
+ async def quack(
+ self,
+ ctx: commands.Context,
+ ducktype: Literal["duck", "manduck"] = "duck",
+ *,
+ seed: Optional[str] = None
+ ) -> None:
+ """
+ Use the Quackstack API to generate a random duck.
+
+ If a seed is provided, a duck is generated based on the given seed.
+ Either "duck" or "manduck" can be provided to change the duck type generated.
+ """
+ ducktype = ducktype.lower()
+ quackstack_url = f"{API_URL}/{ducktype}"
+ params = {}
+ if seed is not None:
+ try:
+ seed = int(seed)
+ except ValueError:
+ # We just need to turn the string into an integer any way possible
+ seed = int.from_bytes(seed.encode(), "big")
+ params["seed"] = seed
+
+ async with self.bot.http_session.get(quackstack_url, params=params) as response:
+ error_embed = discord.Embed(
+ title=random.choice(NEGATIVE_REPLIES),
+ description="The request failed. Please try again later.",
+ color=Colours.soft_red,
+ )
+ if response.status != 200:
+ log.error(f"Response to Quackstack returned code {response.status}")
+ await ctx.send(embed=error_embed)
+ return
+
+ data = await response.json()
+ file = data["file"]
+
+ embed = discord.Embed(
+ title=f"Quack! Here's a {ducktype} for you.",
+ description=f"A {ducktype} from Quackstack.",
+ color=Colours.grass_green,
+ url=f"{API_URL}/docs"
+ )
+
+ embed.set_image(url=API_URL + file)
+
+ await ctx.send(embed=embed)
+
+
+def setup(bot: Bot) -> None:
+ """Loads the Quack cog."""
+ bot.add_cog(Quackstack(bot))
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/fun/trivia_quiz.py b/bot/exts/fun/trivia_quiz.py
index cf9e6cd3..712c8a12 100644
--- a/bot/exts/fun/trivia_quiz.py
+++ b/bot/exts/fun/trivia_quiz.py
@@ -3,190 +3,211 @@ import json
import logging
import operator
import random
+import re
+import string
+from collections import defaultdict
from dataclasses import dataclass
+from datetime import datetime, timedelta
from pathlib import Path
from typing import Callable, Optional
import discord
-from discord.ext import commands
+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__)
-DEFAULT_QUESTION_LIMIT = 6
+DEFAULT_QUESTION_LIMIT = 7
STANDARD_VARIATION_TOLERANCE = 88
DYNAMICALLY_GEN_VARIATION_TOLERANCE = 97
+MAX_ERROR_FETCH_TRIES = 3
+
WRONG_ANS_RESPONSE = [
"No one answered correctly!",
"Better luck next time...",
]
-N_PREFIX_STARTS_AT = 5
-N_PREFIXES = [
- "penta", "hexa", "hepta", "octa", "nona",
- "deca", "hendeca", "dodeca", "trideca", "tetradeca",
-]
+RULES = (
+ "No cheating and have fun!",
+ "Points for each question reduces by 25 after 10s or after a hint. Total time is 30s per question"
+)
-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"),
-}
+WIKI_FEED_API_URL = "https://en.wikipedia.org/api/rest_v1/feed/featured/{date}"
+TRIVIA_QUIZ_ICON = (
+ "https://raw.githubusercontent.com/python-discord/branding/main/icons/trivia_quiz/trivia-quiz-dist.png"
+)
@dataclass(frozen=True)
class QuizEntry:
- """Dataclass for a quiz entry (a question and a string containing answers separated by commas)."""
+ """Stores quiz entry (a question and a list of answers)."""
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)
+ answers: list[str]
+ var_tol: int
+
+
+class DynamicQuestionGen:
+ """Class that contains functions to generate math/science questions for TriviaQuiz Cog."""
+
+ 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"),
+ }
+
+ @classmethod
+ def linear_system(cls, 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)
+ return QuizEntry(question, [answer], DYNAMICALLY_GEN_VARIATION_TOLERANCE)
+ @classmethod
+ def mod_arith(cls, 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
-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(a, b, m)
+ answer = a_format.format(ans)
- question = q_format.format(N_PREFIXES[n])
- answer = a_format.format((n + N_PREFIX_STARTS_AT) * 2)
+ return QuizEntry(question, [answer], DYNAMICALLY_GEN_VARIATION_TOLERANCE)
- return QuizEntry(question, answer)
+ @classmethod
+ def ngonal_prism(cls, q_format: str, a_format: str) -> QuizEntry:
+ """Generate a question regarding vertices on n-gonal prisms."""
+ n = random.randint(0, len(cls.N_PREFIXES) - 1)
+ question = q_format.format(cls.N_PREFIXES[n])
+ answer = a_format.format((n + cls.N_PREFIX_STARTS_AT) * 2)
-def imag_sqrt(q_format: str, a_format: str) -> QuizEntry:
- """Generate a negative square root question."""
- ans_coeff = random.randint(3, 10)
+ return QuizEntry(question, [answer], DYNAMICALLY_GEN_VARIATION_TOLERANCE)
- question = q_format.format(ans_coeff ** 2)
- answer = a_format.format(ans_coeff)
+ @classmethod
+ def imag_sqrt(cls, q_format: str, a_format: str) -> QuizEntry:
+ """Generate a negative square root question."""
+ ans_coeff = random.randint(3, 10)
- return QuizEntry(question, answer)
+ question = q_format.format(ans_coeff ** 2)
+ answer = a_format.format(ans_coeff)
+ return QuizEntry(question, [answer], DYNAMICALLY_GEN_VARIATION_TOLERANCE)
-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),
+ @classmethod
+ def binary_calc(cls, 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
+ # 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))
+ question = q_format.format(a, oper[0], b)
+ answer = a_format.format(oper[1](a, b))
- return QuizEntry(question, answer)
+ return QuizEntry(question, [answer], DYNAMICALLY_GEN_VARIATION_TOLERANCE)
+ @classmethod
+ def solar_system(cls, q_format: str, a_format: str) -> QuizEntry:
+ """Generate a question on the planets of the Solar System."""
+ planet = random.choice(cls.PLANETS)
-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])
- question = q_format.format(planet[0])
- answer = a_format.format(planet[1])
+ return QuizEntry(question, [answer], DYNAMICALLY_GEN_VARIATION_TOLERANCE)
- return QuizEntry(question, answer)
+ @classmethod
+ def taxonomic_rank(cls, q_format: str, a_format: str) -> QuizEntry:
+ """Generate a question on taxonomic classification."""
+ level = random.randint(0, len(cls.TAXONOMIC_HIERARCHY) - 2)
+ question = q_format.format(cls.TAXONOMIC_HIERARCHY[level])
+ answer = a_format.format(cls.TAXONOMIC_HIERARCHY[level + 1])
-def taxonomic_rank(q_format: str, a_format: str) -> QuizEntry:
- """Generate a question on taxonomic classification."""
- level = random.randint(0, len(TAXONOMIC_HIERARCHY) - 2)
+ return QuizEntry(question, [answer], DYNAMICALLY_GEN_VARIATION_TOLERANCE)
- question = q_format.format(TAXONOMIC_HIERARCHY[level])
- answer = a_format.format(TAXONOMIC_HIERARCHY[level + 1])
+ @classmethod
+ def base_units_convert(cls, q_format: str, a_format: str) -> QuizEntry:
+ """Generate a SI base units conversion question."""
+ unit = random.choice(list(cls.UNITS_TO_BASE_UNITS))
- 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]
- )
+ question = q_format.format(
+ unit + " " + cls.UNITS_TO_BASE_UNITS[unit][0]
+ )
+ answer = a_format.format(
+ cls.UNITS_TO_BASE_UNITS[unit][1]
+ )
- return QuizEntry(question, answer)
+ return QuizEntry(question, [answer], DYNAMICALLY_GEN_VARIATION_TOLERANCE)
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,
+ 201: DynamicQuestionGen.linear_system,
+ 202: DynamicQuestionGen.mod_arith,
+ 203: DynamicQuestionGen.ngonal_prism,
+ 204: DynamicQuestionGen.imag_sqrt,
+ 205: DynamicQuestionGen.binary_calc,
+ 301: DynamicQuestionGen.solar_system,
+ 302: DynamicQuestionGen.taxonomic_rank,
+ 303: DynamicQuestionGen.base_units_convert,
}
@@ -202,7 +223,7 @@ class TriviaQuiz(commands.Cog):
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.player_scores = defaultdict(int) # 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 = {
@@ -212,8 +233,72 @@ class TriviaQuiz(commands.Cog):
"science": "Put your understanding of science to the test!",
"cs": "A large variety of computer science questions.",
"python": "Trivia on our amazing language, Python!",
+ "wikipedia": "Guess the title of random wikipedia passages.",
}
+ self.get_wiki_questions.start()
+
+ def cog_unload(self) -> None:
+ """Cancel `get_wiki_questions` task when Cog will unload."""
+ self.get_wiki_questions.cancel()
+
+ @tasks.loop(hours=24.0)
+ async def get_wiki_questions(self) -> None:
+ """Get yesterday's most read articles from wikipedia and format them like trivia questions."""
+ error_fetches = 0
+ wiki_questions = []
+ # trivia_quiz.json follows a pattern, every new category starts with the next century.
+ start_id = 501
+ yesterday = datetime.strftime(datetime.now() - timedelta(1), '%Y/%m/%d')
+
+ while error_fetches < MAX_ERROR_FETCH_TRIES:
+ async with self.bot.http_session.get(url=WIKI_FEED_API_URL.format(date=yesterday)) as r:
+ if r.status != 200:
+ error_fetches += 1
+ continue
+ raw_json = await r.json()
+ articles_raw = raw_json["mostread"]["articles"]
+
+ for article in articles_raw:
+ question = article.get("extract")
+ if not question:
+ continue
+
+ # Normalize the wikipedia article title to remove all punctuations from it
+ for word in re.split(r"[\s-]", title := article["normalizedtitle"]):
+ cleaned_title = re.sub(
+ rf'\b{word.strip(string.punctuation)}\b', word, title, flags=re.IGNORECASE
+ )
+
+ # Since the extract contains the article name sometimes this would replace all the matching words
+ # in that article with *** of that length.
+ # NOTE: This removes the "answer" for 99% of the cases, but sometimes the wikipedia article is
+ # very different from the words in the extract, for example the title would be the nickname of a
+ # person (Bob Ross) whereas in the extract it would the full name (Robert Norman Ross) so it comes
+ # out as (Robert Norman ****) and (Robert Norman Ross) won't be a right answer :(
+ for word in re.split(r"[\s-]", cleaned_title):
+ word = word.strip(string.punctuation)
+ secret_word = r"\*" * len(word)
+ question = re.sub(rf'\b{word}\b', f"**{secret_word}**", question, flags=re.IGNORECASE)
+
+ formatted_article_question = {
+ "id": start_id,
+ "question": f"Guess the title of the Wikipedia article.\n\n{question}",
+ "answer": cleaned_title,
+ "info": article["extract"]
+ }
+ start_id += 1
+ wiki_questions.append(formatted_article_question)
+
+ # If everything has gone smoothly until now, we can break out of the while loop
+ break
+
+ if error_fetches < MAX_ERROR_FETCH_TRIES:
+ self.questions["wikipedia"] = wiki_questions.copy()
+ else:
+ del self.categories["wikipedia"]
+ logger.warning(f"Not loading wikipedia guess questions, hit max error fetches: {MAX_ERROR_FETCH_TRIES}.")
+
@staticmethod
def load_questions() -> dict:
"""Load the questions from the JSON file."""
@@ -221,7 +306,7 @@ class TriviaQuiz(commands.Cog):
return json.loads(p.read_text(encoding="utf-8"))
- @commands.group(name="quiz", aliases=["trivia"], invoke_without_command=True)
+ @commands.group(name="quiz", aliases=("trivia", "triviaquiz"), invoke_without_command=True)
async def quiz_game(self, ctx: commands.Context, category: Optional[str], questions: Optional[int]) -> None:
"""
Start a quiz!
@@ -233,6 +318,7 @@ class TriviaQuiz(commands.Cog):
- science: Put your understanding of science to the test!
- cs: A large variety of computer science questions.
- python: Trivia on our amazing language, Python!
+ - wikipedia: Guess the title of random wikipedia passages.
(More to come!)
"""
@@ -246,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
@@ -264,7 +350,7 @@ class TriviaQuiz(commands.Cog):
topic_length = len(topic)
if questions is None:
- self.question_limit = DEFAULT_QUESTION_LIMIT
+ self.question_limit = min(DEFAULT_QUESTION_LIMIT, topic_length)
else:
if questions > topic_length:
await ctx.send(
@@ -279,13 +365,13 @@ class TriviaQuiz(commands.Cog):
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)"
+ f"(or enter nothing for the default value of {DEFAULT_QUESTION_LIMIT} questions)"
)
)
return
else:
- self.question_limit = questions - 1
+ self.question_limit = questions
# Start game if not running.
if not self.game_status[ctx.channel.id]:
@@ -296,13 +382,13 @@ class TriviaQuiz(commands.Cog):
await ctx.send(embed=start_embed) # send an embed with the rules
await asyncio.sleep(5)
- done_question = []
+ done_questions = []
hint_no = 0
- answers = None
+ quiz_entry = 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:
+ if len(done_questions) == self.question_limit and hint_no == 0:
await ctx.send("The round has ended.")
await self.declare_winner(ctx.channel, self.game_player_scores[ctx.channel.id])
@@ -317,32 +403,27 @@ class TriviaQuiz(commands.Cog):
# Select a random question which has not been used yet.
while True:
question_dict = random.choice(topic)
- if question_dict["id"] not in done_question:
- done_question.append(question_dict["id"])
+ if question_dict["id"] not in done_questions:
+ done_questions.append(question_dict["id"])
break
if "dynamic_id" not in question_dict:
- question = question_dict["question"]
- answers = question_dict["answer"].split(", ")
-
- var_tol = STANDARD_VARIATION_TOLERANCE
+ quiz_entry = QuizEntry(
+ question_dict["question"],
+ quiz_answers if isinstance(quiz_answers := question_dict["answer"], list) else [quiz_answers],
+ STANDARD_VARIATION_TOLERANCE
+ )
else:
format_func = DYNAMIC_QUESTIONS_FORMAT_FUNCS[question_dict["dynamic_id"]]
-
quiz_entry = format_func(
question_dict["question"],
question_dict["answer"],
)
- question, answers = quiz_entry.question, quiz_entry.answer
- answers = [answers]
-
- var_tol = DYNAMICALLY_GEN_VARIATION_TOLERANCE
-
embed = discord.Embed(
colour=Colours.gold,
- title=f"Question #{len(done_question)}",
- description=question,
+ title=f"Question #{len(done_questions)}",
+ description=quiz_entry.question,
)
if img_url := question_dict.get("img_url"):
@@ -354,13 +435,13 @@ class TriviaQuiz(commands.Cog):
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
+ for answer in quiz_entry.answers
)
return contains_correct_answer
try:
- msg = await self.bot.wait_for("message", check=check_func(var_tol), timeout=10)
+ msg = await self.bot.wait_for("message", check=check_func(quiz_entry.var_tol), timeout=10)
except asyncio.TimeoutError:
# In case of TimeoutError and the game has been stopped, then do nothing.
if not self.game_status[ctx.channel.id]:
@@ -388,10 +469,10 @@ class TriviaQuiz(commands.Cog):
await self.send_answer(
ctx.channel,
- answers,
+ quiz_entry.answers,
False,
question_dict,
- self.question_limit - len(done_question) + 1,
+ self.question_limit - len(done_questions),
)
await asyncio.sleep(1)
@@ -421,10 +502,10 @@ class TriviaQuiz(commands.Cog):
await self.send_answer(
ctx.channel,
- answers,
+ quiz_entry.answers,
True,
question_dict,
- self.question_limit - len(done_question) + 1,
+ self.question_limit - len(done_questions),
)
await self.send_score(ctx.channel, self.game_player_scores[ctx.channel.id])
@@ -432,19 +513,18 @@ class TriviaQuiz(commands.Cog):
def make_start_embed(self, category: str) -> discord.Embed:
"""Generate a starting/introduction embed for the quiz."""
+ rules = "\n".join([f"{index}: {rule}" for index, rule in enumerate(RULES, start=1)])
+
start_embed = discord.Embed(
- colour=Colours.blue,
- title="A quiz game is starting!",
+ title="Quiz game Starting!!",
description=(
- f"This game consists of {self.question_limit + 1} questions.\n\n"
- "**Rules: **\n"
- "1. Only enclose your answer in backticks when the question tells you to.\n"
- "2. If the question specifies an answer format, follow it or else it won't be accepted.\n"
- "3. You have 30s per question. Points for each question reduces by 25 after 10s or after a hint.\n"
- "4. No cheating and have fun!\n\n"
- f"**Category**: {category}"
+ f"Each game consists of {self.question_limit} questions.\n"
+ f"**Rules :**\n{rules}"
+ f"\n **Category** : {category}"
),
+ colour=Colours.blue
)
+ start_embed.set_thumbnail(url=TRIVIA_QUIZ_ICON)
return start_embed
@@ -503,6 +583,7 @@ class TriviaQuiz(commands.Cog):
title="Score Board",
description="",
)
+ embed.set_thumbnail(url=TRIVIA_QUIZ_ICON)
sorted_dict = sorted(player_data.items(), key=operator.itemgetter(1), reverse=True)
for item in sorted_dict:
@@ -534,8 +615,8 @@ class TriviaQuiz(commands.Cog):
winners_mention = winner.mention
await channel.send(
- f"Congratulations {winners_mention} :tada: "
- f"You have won this quiz game with a grand total of {highest_points} points!"
+ f"{winners_mention} Congratulations "
+ f"on winning this quiz game with a grand total of {highest_points} points :tada:"
)
def category_embed(self) -> discord.Embed:
@@ -578,7 +659,8 @@ class TriviaQuiz(commands.Cog):
description="",
)
- if info is not None:
+ # Don't check for info is not None, as we want to filter out empty strings.
+ if info:
embed.description += f"**Information**\n{info}\n\n"
embed.description += (
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 4afd5913..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()
@@ -134,7 +139,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(
@@ -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/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/hanukkah/hanukkah_embed.py b/bot/exts/holidays/hanukkah/hanukkah_embed.py
index 00125be3..ac3eab7b 100644
--- a/bot/exts/holidays/hanukkah/hanukkah_embed.py
+++ b/bot/exts/holidays/hanukkah/hanukkah_embed.py
@@ -37,7 +37,7 @@ class HanukkahEmbed(commands.Cog):
hanukkah_dates.append(date)
return hanukkah_dates
- @in_month(Month.DECEMBER)
+ @in_month(Month.NOVEMBER, Month.DECEMBER)
@commands.command(name="hanukkah", aliases=("chanukah",))
async def hanukkah_festival(self, ctx: commands.Context) -> None:
"""Tells you about the Hanukkah Festivaltime of festival, festival day, etc)."""
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 a91ef1c0..a11c366b 100644
--- a/bot/exts/utilities/bookmark.py
+++ b/bot/exts/utilities/bookmark.py
@@ -7,7 +7,7 @@ import discord
from discord.ext import commands
from bot.bot import Bot
-from bot.constants import Categories, Colours, ERROR_REPLIES, Icons, WHITELISTED_CHANNELS
+from bot.constants import Colours, ERROR_REPLIES, Icons, Roles
from bot.utils.converters import WrappedMessageConverter
from bot.utils.decorators import whitelist_override
@@ -16,7 +16,6 @@ log = logging.getLogger(__name__)
# Number of seconds to wait for other users to bookmark the same message
TIMEOUT = 120
BOOKMARK_EMOJI = "📌"
-WHITELISTED_CATEGORIES = (Categories.help_in_use,)
class Bookmark(commands.Cog):
@@ -87,8 +86,8 @@ class Bookmark(commands.Cog):
await message.add_reaction(BOOKMARK_EMOJI)
return message
- @whitelist_override(channels=WHITELISTED_CHANNELS, categories=WHITELISTED_CATEGORIES)
@commands.command(name="bookmark", aliases=("bm", "pin"))
+ @whitelist_override(roles=(Roles.everyone,))
async def bookmark(
self,
ctx: commands.Context,
@@ -99,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..8bf2abfd 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.Colour.og_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..fa438d7f 100644
--- a/bot/exts/utilities/emoji.py
+++ b/bot/exts/utilities/emoji.py
@@ -107,11 +107,11 @@ 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(),
+ 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))