aboutsummaryrefslogtreecommitdiffstats
path: root/bot/exts
diff options
context:
space:
mode:
Diffstat (limited to 'bot/exts')
-rw-r--r--bot/exts/avatar_modification/avatar_modify.py4
-rw-r--r--bot/exts/core/extensions.py2
-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.py84
-rw-r--r--bot/exts/events/advent_of_code/_helpers.py69
-rw-r--r--bot/exts/events/advent_of_code/views/dayandstarview.py76
-rw-r--r--bot/exts/events/hacktoberfest/hacktober-issue-finder.py3
-rw-r--r--bot/exts/fun/tic_tac_toe.py16
-rw-r--r--bot/exts/holidays/easter/earth_photos.py3
-rw-r--r--bot/exts/holidays/halloween/candy_collection.py7
-rw-r--r--bot/exts/holidays/halloween/scarymovie.py1
-rw-r--r--bot/exts/utilities/challenges.py341
-rw-r--r--bot/exts/utilities/colour.py259
-rw-r--r--bot/exts/utilities/conversationstarters.py2
-rw-r--r--bot/exts/utilities/emoji.py2
-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
22 files changed, 1008 insertions, 69 deletions
diff --git a/bot/exts/avatar_modification/avatar_modify.py b/bot/exts/avatar_modification/avatar_modify.py
index 87eb05e6..3ee70cfd 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(
@@ -286,7 +286,7 @@ class AvatarModify(commands.Cog):
@avatar_modify.command(
aliases=("savatar", "spookify"),
root_aliases=("spookyavatar", "spookify", "savatar"),
- brief="Spookify an user's avatar."
+ brief="Spookify a user's avatar."
)
async def spookyavatar(self, ctx: commands.Context) -> None:
"""This "spookifies" the user's avatar, with a random *spooky* effect."""
diff --git a/bot/exts/core/extensions.py b/bot/exts/core/extensions.py
index dbb9e069..d809d2b9 100644
--- a/bot/exts/core/extensions.py
+++ b/bot/exts/core/extensions.py
@@ -152,7 +152,7 @@ class Extensions(commands.Cog):
Grey indicates that the extension is unloaded.
Green indicates that the extension is currently loaded.
"""
- embed = Embed(colour=Colour.blurple())
+ embed = Embed(colour=Colour.og_blurple())
embed.set_author(
name="Extensions List",
url=Client.github_bot_repo,
diff --git a/bot/exts/core/help.py b/bot/exts/core/help.py
index 4b766b50..db3c2aa6 100644
--- a/bot/exts/core/help.py
+++ b/bot/exts/core/help.py
@@ -13,10 +13,7 @@ from rapidfuzz import process
from bot import constants
from bot.bot import Bot
from bot.constants import Emojis
-from bot.utils.pagination import (
- FIRST_EMOJI, LAST_EMOJI,
- LEFT_EMOJI, LinePaginator, RIGHT_EMOJI,
-)
+from bot.utils.pagination import FIRST_EMOJI, LAST_EMOJI, LEFT_EMOJI, LinePaginator, RIGHT_EMOJI
DELETE_EMOJI = Emojis.trashcan
diff --git a/bot/exts/core/internal_eval/_internal_eval.py b/bot/exts/core/internal_eval/_internal_eval.py
index 47e564a5..5b5461f0 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 3bd4873c..c3073fd5 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, STAFF_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
@@ -153,7 +153,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
@@ -183,29 +183,79 @@ class AdventOfCode(commands.Cog):
@in_month(Month.DECEMBER)
@adventofcode_group.command(
+ name="dayandstar",
+ aliases=("daynstar", "daystar"),
+ brief="Get a view that lets you filter the leaderboard by day and star",
+ )
+ @whitelist_override(channels=AOC_WHITELIST_RESTRICTED)
+ async def aoc_day_and_star_leaderboard(
+ self,
+ ctx: commands.Context,
+ maximum_scorers_day_and_star: Optional[int] = 10
+ ) -> None:
+ """Have the bot send a View that will let you filter the leaderboard by day and star."""
+ if maximum_scorers_day_and_star > AocConfig.max_day_and_star_results or maximum_scorers_day_and_star <= 0:
+ raise commands.BadArgument(
+ f"The maximum number of results you can query is {AocConfig.max_day_and_star_results}"
+ )
+ async with ctx.typing():
+ try:
+ leaderboard = await _helpers.fetch_leaderboard()
+ except _helpers.FetchingLeaderboardFailedError:
+ await ctx.send(":x: Unable to fetch leaderboard!")
+ 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_day_and_star,
+ 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(
name="leaderboard",
aliases=("board", "lb"),
brief="Get a snapshot of the PyDis private AoC leaderboard",
)
@whitelist_override(channels=AOC_WHITELIST_RESTRICTED)
- async def aoc_leaderboard(self, ctx: commands.Context) -> None:
- """Get the current top scorers of the Python Discord Leaderboard."""
+ async def aoc_leaderboard(
+ self,
+ ctx: commands.Context,
+ self_placement_name: Optional[str] = None,
+ ) -> None:
+ """
+ Get the current top scorers of the Python Discord Leaderboard.
+
+ Additionally you can specify a `self_placement_name`
+ that will append the specified profile's personal stats to the top of the leaderboard
+ """
async with ctx.typing():
try:
- leaderboard = await _helpers.fetch_leaderboard()
- except _helpers.FetchingLeaderboardFailed:
+ leaderboard = await _helpers.fetch_leaderboard(self_placement_name=self_placement_name)
+ except _helpers.FetchingLeaderboardFailedError:
await ctx.send(":x: Unable to fetch leaderboard!")
return
- number_of_participants = leaderboard["number_of_participants"]
+ number_of_participants = leaderboard["number_of_participants"]
- top_count = min(AocConfig.leaderboard_displayed_members, number_of_participants)
- header = f"Here's our current top {top_count}! {Emojis.christmas_tree * 3}"
-
- table = f"```\n{leaderboard['top_leaderboard']}\n```"
- info_embed = _helpers.get_summary_embed(leaderboard)
+ top_count = min(AocConfig.leaderboard_displayed_members, number_of_participants)
+ self_placement_header = "(and your personal stats compared to the top 10)" if self_placement_name else ""
+ header = f"Here's our current top {top_count}{self_placement_header}! {Emojis.christmas_tree * 3}"
+ table = "```\n" \
+ f"{leaderboard['placement_leaderboard'] if self_placement_name else leaderboard['top_leaderboard']}" \
+ "\n```"
+ info_embed = _helpers.get_summary_embed(leaderboard)
- await ctx.send(content=f"{header}\n\n{table}", embed=info_embed)
+ await ctx.send(content=f"{header}\n\n{table}", embed=info_embed)
+ return
@in_month(Month.DECEMBER)
@adventofcode_group.command(
@@ -234,7 +284,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
@@ -270,7 +320,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..35258544 100644
--- a/bot/exts/events/advent_of_code/_helpers.py
+++ b/bot/exts/events/advent_of_code/_helpers.py
@@ -10,6 +10,7 @@ from typing import Any, Optional
import aiohttp
import arrow
import discord
+from discord.ext import commands
from bot.bot import Bot
from bot.constants import AdventOfCode, Channels, Colours
@@ -70,6 +71,33 @@ class FetchingLeaderboardFailedError(Exception):
"""Raised when one or more leaderboards could not be fetched at all."""
+def _format_leaderboard_line(rank: int, data: dict[str, Any], *, is_author: bool) -> str:
+ """
+ Build a string representing a line of the leaderboard.
+
+ Parameters:
+ rank:
+ Rank in the leaderboard of this entry.
+
+ data:
+ Mapping with entry information.
+
+ Keyword arguments:
+ is_author:
+ Whether to address the name displayed in the returned line
+ personally.
+
+ Returns:
+ A formatted line for the leaderboard.
+ """
+ return AOC_TABLE_TEMPLATE.format(
+ rank=rank,
+ name=data['name'] if not is_author else f"(You) {data['name']}",
+ score=str(data['score']),
+ stars=f"({data['star_1']}, {data['star_2']})"
+ )
+
+
def leaderboard_sorting_function(entry: tuple[str, dict]) -> tuple[int, int]:
"""
Provide a sorting value for our leaderboard.
@@ -105,6 +133,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 +151,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,13 +185,26 @@ 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:
+def _format_leaderboard(leaderboard: dict[str, dict], self_placement_name: str = None) -> str:
"""Format the leaderboard using the AOC_TABLE_TEMPLATE."""
leaderboard_lines = [HEADER]
+ self_placement_exists = False
for rank, data in enumerate(leaderboard.values(), start=1):
+ if self_placement_name and data["name"].lower() == self_placement_name.lower():
+ leaderboard_lines.insert(
+ 1,
+ AOC_TABLE_TEMPLATE.format(
+ rank=rank,
+ name=f"(You) {data['name']}",
+ score=str(data["score"]),
+ stars=f"({data['star_1']}, {data['star_2']})"
+ )
+ )
+ self_placement_exists = True
+ continue
leaderboard_lines.append(
AOC_TABLE_TEMPLATE.format(
rank=rank,
@@ -166,7 +213,10 @@ def _format_leaderboard(leaderboard: dict[str, dict]) -> str:
stars=f"({data['star_1']}, {data['star_2']})"
)
)
-
+ if self_placement_name and not self_placement_exists:
+ raise commands.BadArgument(
+ "Sorry, your profile does not exist in this leaderboard."
+ )
return "\n".join(leaderboard_lines)
@@ -254,7 +304,7 @@ def _get_top_leaderboard(full_leaderboard: str) -> str:
@_caches.leaderboard_cache.atomic_transaction
-async def fetch_leaderboard(invalidate_cache: bool = False) -> dict:
+async def fetch_leaderboard(invalidate_cache: bool = False, self_placement_name: str = None) -> dict:
"""
Get the current Python Discord combined leaderboard.
@@ -264,7 +314,6 @@ async def fetch_leaderboard(invalidate_cache: bool = False) -> dict:
miss, this function is locked to one call at a time using a decorator.
"""
cached_leaderboard = await _caches.leaderboard_cache.to_dict()
-
# Check if the cached leaderboard contains everything we expect it to. If it
# does not, this probably means the cache has not been created yet or has
# expired in Redis. This check also accounts for a malformed cache.
@@ -283,12 +332,14 @@ async def fetch_leaderboard(invalidate_cache: bool = False) -> dict:
leaderboard_fetched_at = datetime.datetime.utcnow().isoformat()
cached_leaderboard = {
+ "placement_leaderboard": json.dumps(raw_leaderboard_data),
"full_leaderboard": formatted_leaderboard,
"top_leaderboard": _get_top_leaderboard(formatted_leaderboard),
"full_leaderboard_url": full_leaderboard_url,
"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
@@ -300,7 +351,13 @@ async def fetch_leaderboard(invalidate_cache: bool = False) -> dict:
_caches.leaderboard_cache.namespace,
AdventOfCode.leaderboard_cache_expiry_seconds
)
-
+ if self_placement_name:
+ formatted_placement_leaderboard = _parse_raw_leaderboard_data(
+ json.loads(cached_leaderboard["placement_leaderboard"])
+ )["leaderboard"]
+ cached_leaderboard["placement_leaderboard"] = _get_top_leaderboard(
+ _format_leaderboard(formatted_placement_leaderboard, self_placement_name=self_placement_name)
+ )
return cached_leaderboard
diff --git a/bot/exts/events/advent_of_code/views/dayandstarview.py b/bot/exts/events/advent_of_code/views/dayandstarview.py
new file mode 100644
index 00000000..a0bfa316
--- /dev/null
+++ b/bot/exts/events/advent_of_code/views/dayandstarview.py
@@ -0,0 +1,76 @@
+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.
+
+ Optionally, when the requested day and star data does not exist yet it returns an error message.
+ """
+ header = AOC_DAY_AND_STAR_TEMPLATE.format(
+ rank="Rank",
+ name="Name", completion_time="Completion time (UTC)"
+ )
+ lines = [f"{header}\n{'-' * (len(header) + 2)}"]
+ if not (day_and_star_data := self.data.get(f"{self.day}-{self.star}")):
+ return ":x: The requested data for the specified day and star does not exist yet."
+ for rank, scorer in enumerate(day_and_star_data[:self.maximum_scorers]):
+ time_data = datetime.fromtimestamp(scorer['completion_time']).strftime("%I:%M:%S %p")
+ lines.append(AOC_DAY_AND_STAR_TEMPLATE.format(
+ datastamp="",
+ rank=rank + 1,
+ name=scorer['member_name'],
+ completion_time=time_data)
+ )
+ joined_lines = "\n".join(lines)
+ return f"Statistics for Day: {self.day}, Star: {self.star}.\n ```\n{joined_lines}\n```"
+
+ async def interaction_check(self, interaction: discord.Interaction) -> bool:
+ """Global check to ensure that the interacting user is the user who invoked the command originally."""
+ return interaction.user == self.original_author
+
+ @discord.ui.select(
+ placeholder="Day",
+ options=[discord.SelectOption(label=str(i)) for i in range(1, 26)],
+ custom_id="day_select"
+ )
+ async def day_select(self, select: discord.ui.Select, interaction: discord.Interaction) -> None:
+ """Dropdown to choose a Day of the AoC."""
+ self.day = select.values[0]
+
+ @discord.ui.select(
+ placeholder="Star",
+ options=[discord.SelectOption(label=str(i)) for i in range(1, 3)],
+ custom_id="star_select"
+ )
+ async def star_select(self, select: discord.ui.Select, interaction: discord.Interaction) -> None:
+ """Dropdown to choose either the first or the second star."""
+ self.star = select.values[0]
+
+ @discord.ui.button(label="Fetch", style=discord.ButtonStyle.blurple)
+ async def fetch(self, button: discord.ui.Button, interaction: discord.Interaction) -> None:
+ """Button that fetches the statistics based on the dropdown values."""
+ if self.day == 0 or self.star == 0:
+ await interaction.response.send_message(
+ "You have to select a value from both of the dropdowns!",
+ ephemeral=True
+ )
+ else:
+ await interaction.response.edit_message(content=self.generate_output())
+ self.day = 0
+ self.star = 0
diff --git a/bot/exts/events/hacktoberfest/hacktober-issue-finder.py b/bot/exts/events/hacktoberfest/hacktober-issue-finder.py
index 088e7e43..1774564b 100644
--- a/bot/exts/events/hacktoberfest/hacktober-issue-finder.py
+++ b/bot/exts/events/hacktoberfest/hacktober-issue-finder.py
@@ -100,7 +100,8 @@ class HacktoberIssues(commands.Cog):
"""Format the issue data into a embed."""
title = issue["title"]
issue_url = issue["url"].replace("api.", "").replace("/repos/", "/")
- body = issue["body"]
+ # issues can have empty bodies, which in that case GitHub doesn't include the key in the API response
+ body = issue.get("body", "")
labels = [label["name"] for label in issue["labels"]]
embed = discord.Embed(title=title)
diff --git a/bot/exts/fun/tic_tac_toe.py b/bot/exts/fun/tic_tac_toe.py
index 5c4f8051..946b6f7b 100644
--- a/bot/exts/fun/tic_tac_toe.py
+++ b/bot/exts/fun/tic_tac_toe.py
@@ -72,10 +72,12 @@ class Player:
class AI:
"""Tic Tac Toe AI class for against computer gaming."""
- def __init__(self, symbol: str):
+ def __init__(self, bot_user: discord.Member, symbol: str):
+ self.user = bot_user
self.symbol = symbol
- async def get_move(self, board: dict[int, str], _: discord.Message) -> tuple[bool, int]:
+ @staticmethod
+ async def get_move(board: dict[int, str], _: discord.Message) -> tuple[bool, int]:
"""Get move from AI. AI use Minimax strategy."""
possible_moves = [i for i, emoji in board.items() if emoji in list(Emojis.number_emojis.values())]
@@ -97,8 +99,8 @@ class AI:
return False, random.choice(open_edges)
def __str__(self) -> str:
- """Return `AI` as user name."""
- return "AI"
+ """Return mention of @Sir Lancebot."""
+ return self.user.mention
class Game:
@@ -107,6 +109,7 @@ class Game:
def __init__(self, players: list[Union[Player, AI]], ctx: Context):
self.players = players
self.ctx = ctx
+ self.channel = ctx.channel
self.board = {
1: Emojis.number_emojis[1],
2: Emojis.number_emojis[2],
@@ -173,7 +176,8 @@ class Game:
self.canceled = True
return False, "User declined"
- async def add_reactions(self, msg: discord.Message) -> None:
+ @staticmethod
+ async def add_reactions(msg: discord.Message) -> None:
"""Add number emojis to message."""
for nr in Emojis.number_emojis.values():
await msg.add_reaction(nr)
@@ -265,7 +269,7 @@ class TicTacToe(Cog):
return
if opponent is None:
game = Game(
- [Player(ctx.author, ctx, Emojis.x_square), AI(Emojis.o_square)],
+ [Player(ctx.author, ctx, Emojis.x_square), AI(ctx.me, Emojis.o_square)],
ctx
)
else:
diff --git a/bot/exts/holidays/easter/earth_photos.py b/bot/exts/holidays/easter/earth_photos.py
index f65790af..27442f1c 100644
--- a/bot/exts/holidays/easter/earth_photos.py
+++ b/bot/exts/holidays/easter/earth_photos.py
@@ -4,8 +4,7 @@ import discord
from discord.ext import commands
from bot.bot import Bot
-from bot.constants import Colours
-from bot.constants import Tokens
+from bot.constants import Colours, Tokens
log = logging.getLogger(__name__)
diff --git a/bot/exts/holidays/halloween/candy_collection.py b/bot/exts/holidays/halloween/candy_collection.py
index 09bd0e59..bb9c93be 100644
--- a/bot/exts/holidays/halloween/candy_collection.py
+++ b/bot/exts/holidays/halloween/candy_collection.py
@@ -83,6 +83,11 @@ class CandyCollection(commands.Cog):
# if its not a candy or skull, and it is one of 10 most recent messages,
# proceed to add a skull/candy with higher chance
if str(reaction.emoji) not in (EMOJIS["SKULL"], EMOJIS["CANDY"]):
+ # Ensure the reaction is not for a bot's message so users can't spam
+ # reaction buttons like in .help to get candies.
+ if message.author.bot:
+ return
+
recent_message_ids = map(
lambda m: m.id,
await self.hacktober_channel.history(limit=10).flatten()
@@ -182,7 +187,7 @@ class CandyCollection(commands.Cog):
for index, record in enumerate(top_five)
) if top_five else "No Candies"
- e = discord.Embed(colour=discord.Colour.blurple())
+ e = discord.Embed(colour=discord.Colour.og_blurple())
e.add_field(
name="Top Candy Records",
value=generate_leaderboard(),
diff --git a/bot/exts/holidays/halloween/scarymovie.py b/bot/exts/holidays/halloween/scarymovie.py
index 33659fd8..89310b97 100644
--- a/bot/exts/holidays/halloween/scarymovie.py
+++ b/bot/exts/holidays/halloween/scarymovie.py
@@ -6,6 +6,7 @@ from discord.ext import commands
from bot.bot import Bot
from bot.constants import Tokens
+
log = logging.getLogger(__name__)
diff --git a/bot/exts/utilities/challenges.py b/bot/exts/utilities/challenges.py
new file mode 100644
index 00000000..ab7ae442
--- /dev/null
+++ b/bot/exts/utilities/challenges.py
@@ -0,0 +1,341 @@
+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})"
+
+ if kata_information["rank"]["name"] is None:
+ embed_color = 8
+ kata_difficulty = "Unable to retrieve difficulty for beta languages."
+ else:
+ embed_color = int(kata_information["rank"]["name"].replace(" kyu", ""))
+ kata_difficulty = kata_information["rank"]["name"]
+
+ kata_embed = Embed(
+ title=kata_information["name"],
+ description=kata_description,
+ color=MAPPING_OF_KYU[embed_color],
+ url=kata_url
+ )
+ kata_embed.add_field(name="Difficulty", value=kata_difficulty, 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.
+ """
+ language = language.lower()
+ if language 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 query is not None:
+ if "," 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/colour.py b/bot/exts/utilities/colour.py
new file mode 100644
index 00000000..7c83fc66
--- /dev/null
+++ b/bot/exts/utilities/colour.py
@@ -0,0 +1,259 @@
+import colorsys
+import json
+import pathlib
+import random
+import string
+from io import BytesIO
+from typing import Optional
+
+import discord
+import rapidfuzz
+from PIL import Image, ImageColor
+from discord.ext import commands
+
+from bot.bot import Bot
+from bot.exts.core.extensions import invoke_help_command
+
+THUMBNAIL_SIZE = (80, 80)
+
+
+class Colour(commands.Cog):
+ """Cog for the Colour command."""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+ with open(pathlib.Path("bot/resources/utilities/ryanzec_colours.json")) as f:
+ self.colour_mapping = json.load(f)
+ del self.colour_mapping['_'] # Delete source credit entry
+
+ async def send_colour_response(self, ctx: commands.Context, rgb: tuple[int, int, int]) -> None:
+ """Create and send embed from user given colour information."""
+ name = self._rgb_to_name(rgb)
+ try:
+ colour_or_color = ctx.invoked_parents[0]
+ except IndexError:
+ colour_or_color = "colour"
+
+ colour_mode = ctx.invoked_with
+ if colour_mode == "random":
+ colour_mode = colour_or_color
+ input_colour = name
+ elif colour_mode in ("colour", "color"):
+ input_colour = ctx.kwargs["colour_input"]
+ elif colour_mode == "name":
+ input_colour = ctx.kwargs["user_colour_name"]
+ elif colour_mode == "hex":
+ input_colour = ctx.args[2:][0]
+ if len(input_colour) > 7:
+ input_colour = input_colour[0:-2]
+ else:
+ input_colour = tuple(ctx.args[2:])
+
+ if colour_mode not in ("name", "hex", "random", "color", "colour"):
+ colour_mode = colour_mode.upper()
+ else:
+ colour_mode = colour_mode.title()
+
+ colour_embed = discord.Embed(
+ title=f"{name or input_colour}",
+ description=f"{colour_or_color.title()} information for {colour_mode} `{input_colour or name}`.",
+ colour=discord.Color.from_rgb(*rgb)
+ )
+ colour_conversions = self.get_colour_conversions(rgb)
+ for colour_space, value in colour_conversions.items():
+ colour_embed.add_field(
+ name=colour_space,
+ value=f"`{value}`",
+ inline=True
+ )
+
+ thumbnail = Image.new("RGB", THUMBNAIL_SIZE, color=rgb)
+ buffer = BytesIO()
+ thumbnail.save(buffer, "PNG")
+ buffer.seek(0)
+ thumbnail_file = discord.File(buffer, filename="colour.png")
+
+ colour_embed.set_thumbnail(url="attachment://colour.png")
+
+ await ctx.send(file=thumbnail_file, embed=colour_embed)
+
+ @commands.group(aliases=("color",), invoke_without_command=True)
+ async def colour(self, ctx: commands.Context, *, colour_input: Optional[str] = None) -> None:
+ """
+ Create an embed that displays colour information.
+
+ If no subcommand is called, a randomly selected colour will be shown.
+ """
+ if colour_input is None:
+ await self.random(ctx)
+ return
+
+ try:
+ extra_colour = ImageColor.getrgb(colour_input)
+ await self.send_colour_response(ctx, extra_colour)
+ except ValueError:
+ await invoke_help_command(ctx)
+
+ @colour.command()
+ async def rgb(self, ctx: commands.Context, red: int, green: int, blue: int) -> None:
+ """Create an embed from an RGB input."""
+ if any(c not in range(256) for c in (red, green, blue)):
+ raise commands.BadArgument(
+ message=f"RGB values can only be from 0 to 255. User input was: `{red, green, blue}`."
+ )
+ rgb_tuple = (red, green, blue)
+ await self.send_colour_response(ctx, rgb_tuple)
+
+ @colour.command()
+ async def hsv(self, ctx: commands.Context, hue: int, saturation: int, value: int) -> None:
+ """Create an embed from an HSV input."""
+ if (hue not in range(361)) or any(c not in range(101) for c in (saturation, value)):
+ raise commands.BadArgument(
+ message="Hue can only be from 0 to 360. Saturation and Value can only be from 0 to 100. "
+ f"User input was: `{hue, saturation, value}`."
+ )
+ hsv_tuple = ImageColor.getrgb(f"hsv({hue}, {saturation}%, {value}%)")
+ await self.send_colour_response(ctx, hsv_tuple)
+
+ @colour.command()
+ async def hsl(self, ctx: commands.Context, hue: int, saturation: int, lightness: int) -> None:
+ """Create an embed from an HSL input."""
+ if (hue not in range(361)) or any(c not in range(101) for c in (saturation, lightness)):
+ raise commands.BadArgument(
+ message="Hue can only be from 0 to 360. Saturation and Lightness can only be from 0 to 100. "
+ f"User input was: `{hue, saturation, lightness}`."
+ )
+ hsl_tuple = ImageColor.getrgb(f"hsl({hue}, {saturation}%, {lightness}%)")
+ await self.send_colour_response(ctx, hsl_tuple)
+
+ @colour.command()
+ async def cmyk(self, ctx: commands.Context, cyan: int, magenta: int, yellow: int, key: int) -> None:
+ """Create an embed from a CMYK input."""
+ if any(c not in range(101) for c in (cyan, magenta, yellow, key)):
+ raise commands.BadArgument(
+ message=f"CMYK values can only be from 0 to 100. User input was: `{cyan, magenta, yellow, key}`."
+ )
+ r = round(255 * (1 - (cyan / 100)) * (1 - (key / 100)))
+ g = round(255 * (1 - (magenta / 100)) * (1 - (key / 100)))
+ b = round(255 * (1 - (yellow / 100)) * (1 - (key / 100)))
+ await self.send_colour_response(ctx, (r, g, b))
+
+ @colour.command()
+ async def hex(self, ctx: commands.Context, hex_code: str) -> None:
+ """Create an embed from a HEX input."""
+ if hex_code[0] != "#":
+ hex_code = f"#{hex_code}"
+
+ if len(hex_code) not in (4, 5, 7, 9) or any(digit not in string.hexdigits for digit in hex_code[1:]):
+ raise commands.BadArgument(
+ message=f"Cannot convert `{hex_code}` to a recognizable Hex format. "
+ "Hex values must be hexadecimal and take the form *#RRGGBB* or *#RGB*."
+ )
+
+ hex_tuple = ImageColor.getrgb(hex_code)
+ if len(hex_tuple) == 4:
+ hex_tuple = hex_tuple[:-1] # Colour must be RGB. If RGBA, we remove the alpha value
+ await self.send_colour_response(ctx, hex_tuple)
+
+ @colour.command()
+ async def name(self, ctx: commands.Context, *, user_colour_name: str) -> None:
+ """Create an embed from a name input."""
+ hex_colour = self.match_colour_name(ctx, user_colour_name)
+ if hex_colour is None:
+ name_error_embed = discord.Embed(
+ title="No colour match found.",
+ description=f"No colour found for: `{user_colour_name}`",
+ colour=discord.Color.dark_red()
+ )
+ await ctx.send(embed=name_error_embed)
+ return
+ hex_tuple = ImageColor.getrgb(hex_colour)
+ await self.send_colour_response(ctx, hex_tuple)
+
+ @colour.command()
+ async def random(self, ctx: commands.Context) -> None:
+ """Create an embed from a randomly chosen colour."""
+ hex_colour = random.choice(list(self.colour_mapping.values()))
+ hex_tuple = ImageColor.getrgb(f"#{hex_colour}")
+ await self.send_colour_response(ctx, hex_tuple)
+
+ def get_colour_conversions(self, rgb: tuple[int, int, int]) -> dict[str, str]:
+ """Create a dictionary mapping of colour types and their values."""
+ colour_name = self._rgb_to_name(rgb)
+ if colour_name is None:
+ colour_name = "No match found"
+ return {
+ "RGB": rgb,
+ "HSV": self._rgb_to_hsv(rgb),
+ "HSL": self._rgb_to_hsl(rgb),
+ "CMYK": self._rgb_to_cmyk(rgb),
+ "Hex": self._rgb_to_hex(rgb),
+ "Name": colour_name
+ }
+
+ @staticmethod
+ def _rgb_to_hsv(rgb: tuple[int, int, int]) -> tuple[int, int, int]:
+ """Convert RGB values to HSV values."""
+ rgb_list = [val / 255 for val in rgb]
+ h, s, v = colorsys.rgb_to_hsv(*rgb_list)
+ hsv = (round(h * 360), round(s * 100), round(v * 100))
+ return hsv
+
+ @staticmethod
+ def _rgb_to_hsl(rgb: tuple[int, int, int]) -> tuple[int, int, int]:
+ """Convert RGB values to HSL values."""
+ rgb_list = [val / 255.0 for val in rgb]
+ h, l, s = colorsys.rgb_to_hls(*rgb_list)
+ hsl = (round(h * 360), round(s * 100), round(l * 100))
+ return hsl
+
+ @staticmethod
+ def _rgb_to_cmyk(rgb: tuple[int, int, int]) -> tuple[int, int, int, int]:
+ """Convert RGB values to CMYK values."""
+ rgb_list = [val / 255.0 for val in rgb]
+ if not any(rgb_list):
+ return 0, 0, 0, 100
+ k = 1 - max(rgb_list)
+ c = round((1 - rgb_list[0] - k) * 100 / (1 - k))
+ m = round((1 - rgb_list[1] - k) * 100 / (1 - k))
+ y = round((1 - rgb_list[2] - k) * 100 / (1 - k))
+ cmyk = (c, m, y, round(k * 100))
+ return cmyk
+
+ @staticmethod
+ def _rgb_to_hex(rgb: tuple[int, int, int]) -> str:
+ """Convert RGB values to HEX code."""
+ hex_ = "".join([hex(val)[2:].zfill(2) for val in rgb])
+ hex_code = f"#{hex_}".upper()
+ return hex_code
+
+ def _rgb_to_name(self, rgb: tuple[int, int, int]) -> Optional[str]:
+ """Convert RGB values to a fuzzy matched name."""
+ input_hex_colour = self._rgb_to_hex(rgb)
+ try:
+ match, certainty, _ = rapidfuzz.process.extractOne(
+ query=input_hex_colour,
+ choices=self.colour_mapping.values(),
+ score_cutoff=80
+ )
+ colour_name = [name for name, hex_code in self.colour_mapping.items() if hex_code == match][0]
+ except TypeError:
+ colour_name = None
+ return colour_name
+
+ def match_colour_name(self, ctx: commands.Context, input_colour_name: str) -> Optional[str]:
+ """Convert a colour name to HEX code."""
+ try:
+ match, certainty, _ = rapidfuzz.process.extractOne(
+ query=input_colour_name,
+ choices=self.colour_mapping.keys(),
+ score_cutoff=80
+ )
+ except (ValueError, TypeError):
+ return
+ return f"#{self.colour_mapping[match]}"
+
+
+def setup(bot: Bot) -> None:
+ """Load the Colour cog."""
+ bot.add_cog(Colour(bot))
diff --git a/bot/exts/utilities/conversationstarters.py b/bot/exts/utilities/conversationstarters.py
index dcbfe4d5..8bf2abfd 100644
--- a/bot/exts/utilities/conversationstarters.py
+++ b/bot/exts/utilities/conversationstarters.py
@@ -53,7 +53,7 @@ class ConvoStarters(commands.Cog):
# No matter what, the form will be shown.
embed = discord.Embed(
description=f"Suggest more topics [here]({SUGGESTION_FORM})!",
- color=discord.Color.blurple()
+ color=discord.Colour.og_blurple()
)
try:
diff --git a/bot/exts/utilities/emoji.py b/bot/exts/utilities/emoji.py
index 83df39cc..fa438d7f 100644
--- a/bot/exts/utilities/emoji.py
+++ b/bot/exts/utilities/emoji.py
@@ -111,7 +111,7 @@ class Emojis(commands.Cog):
**Date:** {datetime.strftime(emoji.created_at.replace(tzinfo=None), "%d/%m/%Y")}
**ID:** {emoji.id}
"""),
- color=Color.blurple(),
+ color=Color.og_blurple(),
url=str(emoji.url),
).set_thumbnail(url=emoji.url)
diff --git a/bot/exts/utilities/githubinfo.py b/bot/exts/utilities/githubinfo.py
index d00b408d..539e388b 100644
--- a/bot/exts/utilities/githubinfo.py
+++ b/bot/exts/utilities/githubinfo.py
@@ -67,7 +67,7 @@ class GithubInfo(commands.Cog):
embed = discord.Embed(
title=f"`{user_data['login']}`'s GitHub profile info",
description=f"```\n{user_data['bio']}\n```\n" if user_data["bio"] else "",
- colour=discord.Colour.blurple(),
+ colour=discord.Colour.og_blurple(),
url=user_data["html_url"],
timestamp=datetime.strptime(user_data["created_at"], "%Y-%m-%dT%H:%M:%SZ")
)
@@ -139,7 +139,7 @@ class GithubInfo(commands.Cog):
embed = discord.Embed(
title=repo_data["name"],
description=repo_data["description"],
- colour=discord.Colour.blurple(),
+ colour=discord.Colour.og_blurple(),
url=repo_data["html_url"]
)
diff --git a/bot/exts/utilities/issues.py b/bot/exts/utilities/issues.py
index 8a7ebed0..b6d5a43e 100644
--- a/bot/exts/utilities/issues.py
+++ b/bot/exts/utilities/issues.py
@@ -9,14 +9,7 @@ from discord.ext import commands
from bot.bot import Bot
from bot.constants import (
- Categories,
- Channels,
- Colours,
- ERROR_REPLIES,
- Emojis,
- NEGATIVE_REPLIES,
- Tokens,
- WHITELISTED_CHANNELS
+ Categories, Channels, Colours, ERROR_REPLIES, Emojis, NEGATIVE_REPLIES, Tokens, WHITELISTED_CHANNELS
)
from bot.utils.decorators import whitelist_override
from bot.utils.extensions import invoke_help_command
@@ -185,7 +178,7 @@ class Issues(commands.Cog):
return resp
@whitelist_override(channels=WHITELISTED_CHANNELS, categories=WHITELISTED_CATEGORIES)
- @commands.command(aliases=("pr",))
+ @commands.command(aliases=("issues", "pr", "prs"))
async def issue(
self,
ctx: commands.Context,
@@ -197,14 +190,23 @@ class Issues(commands.Cog):
# Remove duplicates
numbers = set(numbers)
- if len(numbers) > MAXIMUM_ISSUES:
- embed = discord.Embed(
+ err_message = None
+ if not numbers:
+ err_message = "You must have at least one issue/PR!"
+
+ elif len(numbers) > MAXIMUM_ISSUES:
+ err_message = f"Too many issues/PRs! (maximum of {MAXIMUM_ISSUES})"
+
+ # If there's an error with command invocation then send an error embed
+ if err_message is not None:
+ err_embed = discord.Embed(
title=random.choice(ERROR_REPLIES),
color=Colours.soft_red,
- description=f"Too many issues/PRs! (maximum of {MAXIMUM_ISSUES})"
+ description=err_message
)
- await ctx.send(embed=embed)
+ await ctx.send(embed=err_embed)
await invoke_help_command(ctx)
+ return
results = [await self.fetch_issues(number, repository, user) for number in numbers]
await ctx.send(embed=self.format_embed(results, user, repository))
diff --git a/bot/exts/utilities/realpython.py b/bot/exts/utilities/realpython.py
index ef8b2638..bf8f1341 100644
--- a/bot/exts/utilities/realpython.py
+++ b/bot/exts/utilities/realpython.py
@@ -1,5 +1,6 @@
import logging
from html import unescape
+from typing import Optional
from urllib.parse import quote_plus
from discord import Embed
@@ -31,9 +32,18 @@ class RealPython(commands.Cog):
@commands.command(aliases=["rp"])
@commands.cooldown(1, 10, commands.cooldowns.BucketType.user)
- async def realpython(self, ctx: commands.Context, *, user_search: str) -> None:
- """Send 5 articles that match the user's search terms."""
- params = {"q": user_search, "limit": 5, "kind": "article"}
+ async def realpython(self, ctx: commands.Context, amount: Optional[int] = 5, *, user_search: str) -> None:
+ """
+ Send some articles from RealPython that match the search terms.
+
+ By default the top 5 matches are sent, this can be overwritten to
+ a number between 1 and 5 by specifying an amount before the search query.
+ """
+ if not 1 <= amount <= 5:
+ await ctx.send("`amount` must be between 1 and 5 (inclusive).")
+ return
+
+ params = {"q": user_search, "limit": amount, "kind": "article"}
async with self.bot.http_session.get(url=API_ROOT, params=params) as response:
if response.status != 200:
logger.error(
diff --git a/bot/exts/utilities/reddit.py b/bot/exts/utilities/reddit.py
index e6cb5337..782583d2 100644
--- a/bot/exts/utilities/reddit.py
+++ b/bot/exts/utilities/reddit.py
@@ -244,7 +244,7 @@ class Reddit(Cog):
# Use only starting summary page for #reddit channel posts.
embed.description = self.build_pagination_pages(posts, paginate=False)
- embed.colour = Colour.blurple()
+ embed.colour = Colour.og_blurple()
return embed
@loop()
@@ -312,7 +312,7 @@ class Reddit(Cog):
await ctx.send(f"Here are the top {subreddit} posts of all time!")
embed = Embed(
- color=Colour.blurple()
+ color=Colour.og_blurple()
)
await ImagePaginator.paginate(pages, ctx, embed)
@@ -325,7 +325,7 @@ class Reddit(Cog):
await ctx.send(f"Here are today's top {subreddit} posts!")
embed = Embed(
- color=Colour.blurple()
+ color=Colour.og_blurple()
)
await ImagePaginator.paginate(pages, ctx, embed)
@@ -338,7 +338,7 @@ class Reddit(Cog):
await ctx.send(f"Here are this week's top {subreddit} posts!")
embed = Embed(
- color=Colour.blurple()
+ color=Colour.og_blurple()
)
await ImagePaginator.paginate(pages, ctx, embed)
@@ -349,7 +349,7 @@ class Reddit(Cog):
"""Send a paginated embed of all the subreddits we're relaying."""
embed = Embed()
embed.title = "Relayed subreddits."
- embed.colour = Colour.blurple()
+ embed.colour = Colour.og_blurple()
await LinePaginator.paginate(
RedditConfig.subreddits,
diff --git a/bot/exts/utilities/wikipedia.py b/bot/exts/utilities/wikipedia.py
index eccc1f8c..e5e8e289 100644
--- a/bot/exts/utilities/wikipedia.py
+++ b/bot/exts/utilities/wikipedia.py
@@ -82,13 +82,11 @@ class WikipediaSearch(commands.Cog):
if contents:
embed = Embed(
title="Wikipedia Search Results",
- colour=Color.blurple()
+ colour=Color.og_blurple()
)
embed.set_thumbnail(url=WIKI_THUMBNAIL)
embed.timestamp = datetime.utcnow()
- await LinePaginator.paginate(
- contents, ctx, embed
- )
+ await LinePaginator.paginate(contents, ctx, embed, restrict_to_user=ctx.author)
else:
await ctx.send(
"Sorry, we could not find a wikipedia article using that search term."
diff --git a/bot/exts/utilities/wtf_python.py b/bot/exts/utilities/wtf_python.py
new file mode 100644
index 00000000..980b3dba
--- /dev/null
+++ b/bot/exts/utilities/wtf_python.py
@@ -0,0 +1,138 @@
+import logging
+import random
+import re
+from typing import Optional
+
+import rapidfuzz
+from discord import Embed, File
+from discord.ext import commands, tasks
+
+from bot import constants
+from bot.bot import Bot
+
+log = logging.getLogger(__name__)
+
+WTF_PYTHON_RAW_URL = "http://raw.githubusercontent.com/satwikkansal/wtfpython/master/"
+BASE_URL = "https://github.com/satwikkansal/wtfpython"
+LOGO_PATH = "./bot/resources/utilities/wtf_python_logo.jpg"
+
+ERROR_MESSAGE = f"""
+Unknown WTF Python Query. Please try to reformulate your query.
+
+**Examples**:
+```md
+{constants.Client.prefix}wtf wild imports
+{constants.Client.prefix}wtf subclass
+{constants.Client.prefix}wtf del
+```
+If the problem persists send a message in <#{constants.Channels.dev_contrib}>
+"""
+
+MINIMUM_CERTAINTY = 55
+
+
+class WTFPython(commands.Cog):
+ """Cog that allows getting WTF Python entries from the WTF Python repository."""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+ self.headers: dict[str, str] = {}
+ self.fetch_readme.start()
+
+ @tasks.loop(minutes=60)
+ async def fetch_readme(self) -> None:
+ """Gets the content of README.md from the WTF Python Repository."""
+ async with self.bot.http_session.get(f"{WTF_PYTHON_RAW_URL}README.md") as resp:
+ log.trace("Fetching the latest WTF Python README.md")
+ if resp.status == 200:
+ raw = await resp.text()
+ self.parse_readme(raw)
+
+ def parse_readme(self, data: str) -> None:
+ """
+ Parses the README.md into a dict.
+
+ It parses the readme into the `self.headers` dict,
+ where the key is the heading and the value is the
+ link to the heading.
+ """
+ # Match the start of examples, until the end of the table of contents (toc)
+ table_of_contents = re.search(
+ r"\[👀 Examples\]\(#-examples\)\n([\w\W]*)<!-- tocstop -->", data
+ )[0].split("\n")
+
+ for header in list(map(str.strip, table_of_contents)):
+ match = re.search(r"\[â–¶ (.*)\]\((.*)\)", header)
+ if match:
+ hyper_link = match[0].split("(")[1].replace(")", "")
+ self.headers[match[0]] = f"{BASE_URL}/{hyper_link}"
+
+ def fuzzy_match_header(self, query: str) -> Optional[str]:
+ """
+ Returns the fuzzy match of a query if its ratio is above "MINIMUM_CERTAINTY" else returns None.
+
+ "MINIMUM_CERTAINTY" is the lowest score at which the fuzzy match will return a result.
+ The certainty returned by rapidfuzz.process.extractOne is a score between 0 and 100,
+ with 100 being a perfect match.
+ """
+ match, certainty, _ = rapidfuzz.process.extractOne(query, self.headers.keys())
+ return match if certainty > MINIMUM_CERTAINTY else None
+
+ @commands.command(aliases=("wtf", "WTF"))
+ async def wtf_python(self, ctx: commands.Context, *, query: Optional[str] = None) -> None:
+ """
+ Search WTF Python repository.
+
+ Gets the link of the fuzzy matched query from https://github.com/satwikkansal/wtfpython.
+ Usage:
+ --> .wtf wild imports
+ """
+ if query is None:
+ no_query_embed = Embed(
+ title="WTF Python?!",
+ colour=constants.Colours.dark_green,
+ description="A repository filled with suprising snippets that can make you say WTF?!\n\n"
+ f"[Go to the Repository]({BASE_URL})"
+ )
+ logo = File(LOGO_PATH, filename="wtf_logo.jpg")
+ no_query_embed.set_thumbnail(url="attachment://wtf_logo.jpg")
+ await ctx.send(embed=no_query_embed, file=logo)
+ return
+
+ if len(query) > 50:
+ embed = Embed(
+ title=random.choice(constants.ERROR_REPLIES),
+ description=ERROR_MESSAGE,
+ colour=constants.Colours.soft_red,
+ )
+ match = None
+ else:
+ match = self.fuzzy_match_header(query)
+
+ if not match:
+ embed = Embed(
+ title=random.choice(constants.ERROR_REPLIES),
+ description=ERROR_MESSAGE,
+ colour=constants.Colours.soft_red,
+ )
+ await ctx.send(embed=embed)
+ return
+
+ embed = Embed(
+ title="WTF Python?!",
+ colour=constants.Colours.dark_green,
+ description=f"""Search result for '{query}': {match.split("]")[0].replace("[", "")}
+ [Go to Repository Section]({self.headers[match]})""",
+ )
+ logo = File(LOGO_PATH, filename="wtf_logo.jpg")
+ embed.set_thumbnail(url="attachment://wtf_logo.jpg")
+ await ctx.send(embed=embed, file=logo)
+
+ def cog_unload(self) -> None:
+ """Unload the cog and cancel the task."""
+ self.fetch_readme.cancel()
+
+
+def setup(bot: Bot) -> None:
+ """Load the WTFPython Cog."""
+ bot.add_cog(WTFPython(bot))