aboutsummaryrefslogtreecommitdiffstats
path: root/bot/exts/utilities
diff options
context:
space:
mode:
authorGravatar brad90four <[email protected]>2021-10-26 07:38:22 -0400
committerGravatar brad90four <[email protected]>2021-10-26 07:38:22 -0400
commit93fa57ae4ad6892f660d841eb1f28b9f1bf9b2c2 (patch)
tree48c1590bb846514be2c1897e547bd3ad5156fc3f /bot/exts/utilities
parentMerge branch 'color-677' of https://github.com/brad90four/sir-lancebot into c... (diff)
parentchore: code cleanup (diff)
Merge branch 'color-677' of https://github.com/brad90four/sir-lancebot into color-677
Diffstat (limited to 'bot/exts/utilities')
-rw-r--r--bot/exts/utilities/challenges.py335
-rw-r--r--bot/exts/utilities/color.py431
-rw-r--r--bot/exts/utilities/conversationstarters.py91
-rw-r--r--bot/exts/utilities/issues.py28
-rw-r--r--bot/exts/utilities/wtf_python.py126
5 files changed, 696 insertions, 315 deletions
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/color.py b/bot/exts/utilities/color.py
index 6aa0c3cd..618970df 100644
--- a/bot/exts/utilities/color.py
+++ b/bot/exts/utilities/color.py
@@ -1,7 +1,7 @@
import colorsys
import json
-import logging
-import re
+import pathlib
+import random
from io import BytesIO
from PIL import Image, ImageColor
@@ -10,256 +10,171 @@ from discord.ext import commands
from rapidfuzz import process
from bot.bot import Bot
-from bot.constants import Colours
+from bot.exts.core.extensions import invoke_help_command
-
-logger = logging.getLogger(__name__)
-
-
-ERROR_MSG = """The color code {user_color} is not a possible color combination.
-The range of possible values are:
-RGB & HSV: 0-255
-CMYK: 0-100%
-HSL: 0-360 degrees
-Hex: #000000-#FFFFFF
-"""
-
-with open("bot/resources/utilities/ryanzec_colours.json") as f:
+with open(pathlib.Path("bot/resources/utilities/ryanzec_colours.json")) as f:
COLOR_MAPPING = json.load(f)
+THUMBNAIL_SIZE = 80
-class Color(commands.Cog):
- """User initiated commands to receive color information."""
- def __init__(self, bot: Bot):
- self.bot = bot
+class Colour(commands.Cog):
+ """Cog for the Colour command."""
- @commands.command(aliases=["colour"])
- async def color(self, ctx: commands.Context, mode: str, *, user_color: str) -> None:
+ @commands.group(aliases=("color",))
+ async def colour(self, ctx: commands.Context) -> None:
"""
- Send information on input color code or color name.
+ User initiated command to create an embed that displays color information.
- Possible modes are: "hex", "rgb", "hsv", "hsl", "cmyk" or "name".
+ For the commands `hsl`, `hsv` and `rgb`: input is in the form `.color <mode> <int> <int> <int>`
+ For the command `cmyk`: input is in the form `.color cmyk <int> <int> <int> <int>`
+ For the command `hex`: input is in the form `.color hex #<hex code>`
+ For the command `name`: input is in the form `.color name <color name>`
+ For the command `random`: input is in the form `.color random`
"""
- logger.debug(f"{mode = }")
- logger.debug(f"{user_color = }")
- if mode.lower() == "hex":
- await self.hex_to_rgb(ctx, user_color)
- elif mode.lower() == "rgb":
- rgb_color = self.tuple_create(user_color)
- await self.color_embed(ctx, rgb_color)
- elif mode.lower() == "hsv":
- await self.hsv_to_rgb(ctx, user_color)
- elif mode.lower() == "hsl":
- await self.hsl_to_rgb(ctx, user_color)
- elif mode.lower() == "cmyk":
- await self.cmyk_to_rgb(ctx, user_color)
- elif mode.lower() == "name":
- color_name, hex_color = self.match_color_name(user_color)
- if "#" in hex_color:
- rgb_color = ImageColor.getcolor(hex_color, "RGB")
- else:
- rgb_color = ImageColor.getcolor("#" + hex_color, "RGB")
- await self.color_embed(ctx, rgb_color, color_name)
- else:
- # mode is either None or an invalid code
- if mode is None:
- no_mode_embed = Embed(
- title="No mode was passed, please define a color code.",
- description="Possible modes are: Name, Hex, RGB, HSV, HSL and CMYK.",
- color=Colours.soft_red,
- )
- await ctx.send(embed=no_mode_embed)
- return
- wrong_mode_embed = Embed(
- title=f"The color code {mode} is not a valid option",
- description="Possible modes are: Name, Hex, RGB, HSV, HSL and CMYK.",
- color=Colours.soft_red,
- )
- await ctx.send(embed=wrong_mode_embed)
- return
+ if ctx.invoked_subcommand is None:
+ await invoke_help_command(ctx)
+
+ @colour.command()
+ async def rgb(self, ctx: commands.Context, red: int, green: int, blue: int) -> None:
+ """Function to create an embed from an RGB input."""
+ rgb_tuple = ImageColor.getrgb(f"rgb({red}, {green}, {blue})")
+ await Colour.send_colour_response(ctx, list(rgb_tuple))
+
+ @colour.command()
+ async def hsv(self, ctx: commands.Context, hue: int, saturation: int, value: int) -> None:
+ """Function to create an embed from an HSV input."""
+ hsv_tuple = ImageColor.getrgb(f"hsv({hue}, {saturation}%, {value}%)")
+ await Colour.send_colour_response(ctx, list(hsv_tuple))
+
+ @colour.command()
+ async def hsl(self, ctx: commands.Context, hue: int, saturation: int, lightness: int) -> None:
+ """Function to create an embed from an HSL input."""
+ hsl_tuple = ImageColor.getrgb(f"hsl({hue}, {saturation}%, {lightness}%)")
+ await Colour.send_colour_response(ctx, list(hsl_tuple))
+
+ @colour.command()
+ async def cmyk(self, ctx: commands.Context, cyan: int, yellow: int, magenta: int, key: int) -> None:
+ """Function to create an embed from a CMYK input."""
+ r = int(255 * (1.0 - cyan / float(100)) * (1.0 - key / float(100)))
+ g = int(255 * (1.0 - magenta / float(100)) * (1.0 - key / float(100)))
+ b = int(255 * (1.0 - yellow / float(100)) * (1.0 - key / float(100)))
+ await Colour.send_colour_response(ctx, list((r, g, b)))
+
+ @colour.command()
+ async def hex(self, ctx: commands.Context, hex_code: str) -> None:
+ """Function to create an embed from a HEX input. (Requires # as a prefix)."""
+ hex_tuple = ImageColor.getrgb(hex_code)
+ await Colour.send_colour_response(ctx, list(hex_tuple))
+
+ @colour.command()
+ async def name(self, ctx: commands.Context, user_color: str) -> None:
+ """Function to create an embed from a name input."""
+ _, hex_color = self.match_color_name(user_color)
+ hex_tuple = ImageColor.getrgb(hex_color)
+ await Colour.send_colour_response(ctx, list(hex_tuple))
+
+ @colour.command()
+ async def random(self, ctx: commands.Context) -> None:
+ """Function to create an embed from a randomly chosen color from the ryanzec.json file."""
+ color_choices = list(COLOR_MAPPING.values())
+ hex_color = random.choice(color_choices)
+ hex_tuple = ImageColor.getrgb(f"#{hex_color}")
+ await Colour.send_colour_response(ctx, list(hex_tuple))
@staticmethod
- def tuple_create(input_color: str) -> tuple[int, int, int]:
- """
- Create a tuple of integers based on user's input.
-
- Can handle inputs of the types:
- (100, 100, 100)
- 100, 100, 100
- 100 100 100
- """
- if "(" in input_color:
- remove = "[() ]"
- color_tuple = re.sub(remove, "", input_color)
- color_tuple = tuple(map(int, color_tuple.split(",")))
- elif "," in input_color:
- color_tuple = tuple(map(int, input_color.split(",")))
- else:
- color_tuple = tuple(map(int, input_color.split(" ")))
- return color_tuple
-
- async def hex_to_rgb(self, ctx: commands.Context, hex_string: str) -> None:
- """Function to convert hex color to rgb color and send main embed."""
- hex_match = re.fullmatch(r"(#?[0x]?)((?:[0-9a-fA-F]{3}){1,2})", hex_string)
- if hex_match:
- if "#" in hex_string:
- rgb_color = ImageColor.getcolor(hex_string, "RGB")
- elif "0x" in hex_string:
- hex_ = hex_string.replace("0x", "#")
- rgb_color = ImageColor.getcolor(hex_, "RGB")
- else:
- hex_ = "#" + hex_string
- rgb_color = ImageColor.getcolor(hex_, "RGB")
- await self.color_embed(ctx, rgb_color)
+ async def send_colour_response(ctx: commands.Context, rgb: list[int]) -> None:
+ """Function to create and send embed from color information."""
+ r, g, b = rgb[0], rgb[1], rgb[2]
+ name = Colour._rgb_to_name(rgb)
+ if name is None:
+ desc = "Color information for the input color."
else:
- await ctx.send(
- embed=Embed(
- title="There was an issue converting the hex color code.",
- description=ERROR_MSG.format(user_color=hex_string),
- )
+ desc = f"Color information for {name}."
+ colour_embed = Embed(
+ title="Colour",
+ description=desc,
+ colour=int(f"{r:02x}{g:02x}{b:02x}", 16)
+ )
+ colour_conversions = Colour.get_colour_conversions(rgb)
+ for colour_space, value in colour_conversions.items():
+ colour_embed.add_field(
+ name=colour_space.upper(),
+ value=f"`{value}`",
+ inline=True
)
- async def hsv_to_rgb(self, ctx: commands.Context, input_color: tuple[int, int, int]) -> tuple[int, int, int]:
- """Function to convert hsv color to rgb color and send main embed."""
- input_color = self.tuple_create(input_color)
- (h, v, s) = input_color # the function hsv_to_rgb expects v and s to be swapped
- h = h / 360
- s = s / 100
- v = v / 100
- rgb_color = colorsys.hsv_to_rgb(h, s, v)
- (r, g, b) = rgb_color
- r = int(r * 255)
- g = int(g * 255)
- b = int(b * 255)
- await self.color_embed(ctx, (r, g, b))
+ thumbnail = Image.new("RGB", (THUMBNAIL_SIZE, THUMBNAIL_SIZE), color=tuple(rgb))
+ buffer = BytesIO()
+ thumbnail.save(buffer, "PNG")
+ buffer.seek(0)
+ thumbnail_file = File(buffer, filename="colour.png")
- async def hsl_to_rgb(self, ctx: commands.Context, input_color: tuple[int, int, int]) -> tuple[int, int, int]:
- """Function to convert hsl color to rgb color and send main embed."""
- input_color = self.tuple_create(input_color)
- (h, s, l) = input_color
- h = h / 360
- s = s / 100
- l = l / 100 # noqa: E741 It's little `L`, Reason: To maintain consistency.
- rgb_color = colorsys.hls_to_rgb(h, l, s)
- (r, g, b) = rgb_color
- r = int(r * 255)
- g = int(g * 255)
- b = int(b * 255)
- await self.color_embed(ctx, (r, g, b))
+ colour_embed.set_thumbnail(url="attachment://colour.png")
- async def cmyk_to_rgb(
- self,
- ctx: commands.Context,
- input_color: tuple[int, int, int, int]
- ) -> tuple[int, int, int]:
- """Function to convert cmyk color to rgb color and send main embed."""
- input_color = self.tuple_create(input_color)
- c = input_color[0]
- m = input_color[1]
- y = input_color[2]
- k = input_color[3]
- r = int(255 * (1.0 - c / float(100)) * (1.0 - k / float(100)))
- g = int(255 * (1.0 - m / float(100)) * (1.0 - k / float(100)))
- b = int(255 * (1.0 - y / float(100)) * (1.0 - k / float(100)))
- await self.color_embed(ctx, (r, g, b))
+ await ctx.send(file=thumbnail_file, embed=colour_embed)
@staticmethod
- async def create_thumbnail_attachment(color: tuple[int, int, int]) -> File:
- """
- Generate a thumbnail from `color`.
-
- Assumes that color is an rgb tuple.
- """
- thumbnail = Image.new("RGB", (80, 80), color=color)
- bufferedio = BytesIO()
- thumbnail.save(bufferedio, format="PNG")
- bufferedio.seek(0)
-
- file = File(bufferedio, filename="color.png")
-
- return file
+ def get_colour_conversions(rgb: list[int]) -> dict[str, str]:
+ """Create a dictionary mapping of color types and their values."""
+ return {
+ "rgb": tuple(rgb),
+ "hsv": Colour._rgb_to_hsv(rgb),
+ "hsl": Colour._rgb_to_hsl(rgb),
+ "cmyk": Colour._rgb_to_cmyk(rgb),
+ "hex": Colour._rgb_to_hex(rgb),
+ "name": Colour._rgb_to_name(rgb)
+ }
@staticmethod
- def get_color_fields(rgb_color: tuple[int, int, int]) -> list[dict]:
- """Converts from `RGB` to `CMYK`, `HSV`, `HSL` and returns a list of fields."""
-
- def _rgb_to_hex(rgb_color: tuple[int, int, int]) -> str:
- """To convert from `RGB` to `Hex` notation."""
- return '#' + ''.join(hex(int(color))[2:].zfill(2) for color in rgb_color).upper()
-
- def _rgb_to_cmyk(rgb_color: tuple[int, int, int]) -> tuple[int, int, int, int]:
- """To convert from `RGB` to `CMYK` color space."""
- r, g, b = rgb_color
-
- # RGB_SCALE -> 255
- # CMYK_SCALE -> 100
-
- if (r == g == b == 0):
- return 0, 0, 0, 100 # Representing Black
-
- # rgb [0,RGB_SCALE] -> cmy [0,1]
- c = 1 - r / 255
- m = 1 - g / 255
- y = 1 - b / 255
-
- # extract out k [0, 1]
- min_cmy = min(c, m, y)
- c = (c - min_cmy) / (1 - min_cmy)
- m = (m - min_cmy) / (1 - min_cmy)
- y = (y - min_cmy) / (1 - min_cmy)
- k = min_cmy
+ def _rgb_to_hsv(rgb: list[int]) -> tuple[int, int, int]:
+ """Function to convert an RGB list to a HSV list."""
+ rgb = [val / 255.0 for val in rgb]
+ h, v, s = colorsys.rgb_to_hsv(*rgb)
+ hsv = (round(h * 360), round(s * 100), round(v * 100))
+ return hsv
- # rescale to the range [0,CMYK_SCALE] and round off
- c = round(c * 100)
- m = round(m * 100)
- y = round(y * 100)
- k = round(k * 100)
-
- return c, m, y, k
-
- def _rgb_to_hsv(rgb_color: tuple[int, int, int]) -> tuple[int, int, int]:
- """To convert from `RGB` to `HSV` color space."""
- r, g, b = rgb_color
- h, v, s = colorsys.rgb_to_hsv(r / float(255), g / float(255), b / float(255))
- h = round(h * 360)
- s = round(s * 100)
- v = round(v * 100)
- return h, s, v
+ @staticmethod
+ def _rgb_to_hsl(rgb: list[int]) -> tuple[int, int, int]:
+ """Function to convert an RGB list to a HSL list."""
+ rgb = [val / 255.0 for val in rgb]
+ h, l, s = colorsys.rgb_to_hls(*rgb)
+ hsl = (round(h * 360), round(s * 100), round(l * 100))
+ return hsl
- def _rgb_to_hsl(rgb_color: tuple[int, int, int]) -> tuple[int, int, int]:
- """To convert from `RGB` to `HSL` color space."""
- r, g, b = rgb_color
- h, l, s = colorsys.rgb_to_hls(r / float(255), g / float(255), b / float(255))
- h = round(h * 360)
- s = round(s * 100)
- l = round(l * 100) # noqa: E741 It's little `L`, Reason: To maintain consistency.
- return h, s, l
+ @staticmethod
+ def _rgb_to_cmyk(rgb: list[int]) -> tuple[int, int, int, int]:
+ """Function to convert an RGB list to a CMYK list."""
+ rgb = [val / 255.0 for val in rgb]
+ if all(val == 0 for val in rgb):
+ return 0, 0, 0, 100
+ cmy = [1 - val / 255 for val in rgb]
+ min_cmy = min(cmy)
+ cmyk = [(val - min_cmy) / (1 - min_cmy) for val in cmy] + [min_cmy]
+ cmyk = [round(val * 100) for val in cmyk]
+ return tuple(cmyk)
- all_fields = [
- {
- "name": "RGB",
- "value": f"ยป rgb {rgb_color}"
- },
- {
- "name": "HEX",
- "value": f"ยป hex {_rgb_to_hex(rgb_color)}"
- },
- {
- "name": "CMYK",
- "value": f"ยป cmyk {_rgb_to_cmyk(rgb_color)}"
- },
- {
- "name": "HSV",
- "value": f"ยป hsv {_rgb_to_hsv(rgb_color)}"
- },
- {
- "name": "HSL",
- "value": f"ยป hsl {_rgb_to_hsl(rgb_color)}"
- },
- ]
+ @staticmethod
+ def _rgb_to_hex(rgb: list[int]) -> str:
+ """Function to convert an RGB list to a HEX string."""
+ hex_ = ''.join([hex(val)[2:].zfill(2) for val in rgb])
+ hex_code = f"#{hex_}".upper()
+ return hex_code
- return all_fields
+ @staticmethod
+ def _rgb_to_name(rgb: list[int]) -> str:
+ """Function to convert from an RGB list to a fuzzy matched color name."""
+ input_hex_color = Colour._rgb_to_hex(rgb)
+ try:
+ match, certainty, _ = process.extractOne(
+ query=input_hex_color,
+ choices=COLOR_MAPPING.values(),
+ score_cutoff=80
+ )
+ color_name = [name for name, _ in COLOR_MAPPING.items() if _ == match][0]
+ except TypeError:
+ color_name = None
+ return color_name
@staticmethod
def match_color_name(input_color_name: str) -> str:
@@ -270,67 +185,13 @@ class Color(commands.Cog):
choices=COLOR_MAPPING.keys(),
score_cutoff=50
)
- logger.debug(f"{match = }, {certainty = }")
- hex_match = COLOR_MAPPING[match]
- logger.debug(f"{hex_match = }")
+ hex_match = f"#{COLOR_MAPPING[match]}"
except TypeError:
match = "No color name match found."
hex_match = input_color_name
-
return match, hex_match
- @staticmethod
- def match_color_hex(input_hex_color: str) -> str:
- """Use fuzzy matching to return a hex color code based on the user's input."""
- try:
- match, certainty, _ = process.extractOne(
- query=input_hex_color,
- choices=COLOR_MAPPING.values(),
- score_cutoff=80
- )
- logger.debug(f"{match = }, {certainty = }")
- color_name = [name for name, _ in COLOR_MAPPING.items() if _ == match][0]
- logger.debug(f"{color_name = }")
- except TypeError:
- color_name = "No color name match found."
-
- return color_name
-
- async def color_embed(
- self,
- ctx: commands.Context,
- rgb_color: tuple[int, int, int],
- color_name: str = None
- ) -> None:
- """Take a RGB color tuple, create embed, and send."""
- (r, g, b) = rgb_color
- discord_rgb_int = int(f"{r:02x}{g:02x}{b:02x}", 16)
- all_colors = self.get_color_fields(rgb_color)
- hex_color = all_colors[1]["value"].replace("ยป hex ", "")
- if color_name is None:
- logger.debug(f"Find color name from hex color: {hex_color}")
- color_name = self.match_color_hex(hex_color)
-
- async with ctx.typing():
- main_embed = Embed(
- title=color_name,
- description='(Approx..)',
- color=discord_rgb_int,
- )
-
- file = await self.create_thumbnail_attachment(rgb_color)
- main_embed.set_thumbnail(url="attachment://color.png")
-
- for field in all_colors:
- main_embed.add_field(
- name=field['name'],
- value=field['value'],
- inline=False,
- )
-
- await ctx.send(file=file, embed=main_embed)
-
def setup(bot: Bot) -> None:
- """Load the Color Cog."""
- bot.add_cog(Color(bot))
+ """Load the Colour cog."""
+ bot.add_cog(Colour(bot))
diff --git a/bot/exts/utilities/conversationstarters.py b/bot/exts/utilities/conversationstarters.py
index dd537022..dcbfe4d5 100644
--- a/bot/exts/utilities/conversationstarters.py
+++ b/bot/exts/utilities/conversationstarters.py
@@ -1,11 +1,15 @@
+import asyncio
+from contextlib import suppress
+from functools import partial
from pathlib import Path
+from typing import Union
+import discord
import yaml
-from discord import Color, Embed
from discord.ext import commands
from bot.bot import Bot
-from bot.constants import WHITELISTED_CHANNELS
+from bot.constants import MODERATION_ROLES, WHITELISTED_CHANNELS
from bot.utils.decorators import whitelist_override
from bot.utils.randomization import RandomCycle
@@ -35,35 +39,88 @@ TOPICS = {
class ConvoStarters(commands.Cog):
"""General conversation topics."""
- @commands.command()
- @whitelist_override(channels=ALL_ALLOWED_CHANNELS)
- async def topic(self, ctx: commands.Context) -> None:
+ def __init__(self, bot: Bot):
+ self.bot = bot
+
+ @staticmethod
+ def _build_topic_embed(channel_id: int) -> discord.Embed:
"""
- Responds with a random topic to start a conversation.
+ Build an embed containing a conversation topic.
If in a Python channel, a python-related topic will be given.
-
Otherwise, a random conversation topic will be received by the user.
"""
# No matter what, the form will be shown.
- embed = Embed(description=f"Suggest more topics [here]({SUGGESTION_FORM})!", color=Color.blurple())
+ embed = discord.Embed(
+ description=f"Suggest more topics [here]({SUGGESTION_FORM})!",
+ color=discord.Color.blurple()
+ )
try:
- # Fetching topics.
- channel_topics = TOPICS[ctx.channel.id]
-
- # If the channel isn't Python-related.
+ channel_topics = TOPICS[channel_id]
except KeyError:
+ # Channel doesn't have any topics.
embed.title = f"**{next(TOPICS['default'])}**"
-
- # If the channel ID doesn't have any topics.
else:
embed.title = f"**{next(channel_topics)}**"
+ return embed
+
+ @staticmethod
+ def _predicate(
+ command_invoker: Union[discord.User, discord.Member],
+ message: discord.Message,
+ reaction: discord.Reaction,
+ user: discord.User
+ ) -> bool:
+ user_is_moderator = any(role.id in MODERATION_ROLES for role in getattr(user, "roles", []))
+ user_is_invoker = user.id == command_invoker.id
+
+ is_right_reaction = all((
+ reaction.message.id == message.id,
+ str(reaction.emoji) == "๐Ÿ”„",
+ user_is_moderator or user_is_invoker
+ ))
+ return is_right_reaction
+
+ async def _listen_for_refresh(
+ self,
+ command_invoker: Union[discord.User, discord.Member],
+ message: discord.Message
+ ) -> None:
+ await message.add_reaction("๐Ÿ”„")
+ while True:
+ try:
+ reaction, user = await self.bot.wait_for(
+ "reaction_add",
+ check=partial(self._predicate, command_invoker, message),
+ timeout=60.0
+ )
+ except asyncio.TimeoutError:
+ with suppress(discord.NotFound):
+ await message.clear_reaction("๐Ÿ”„")
+ break
+
+ try:
+ await message.edit(embed=self._build_topic_embed(message.channel.id))
+ except discord.NotFound:
+ break
+
+ with suppress(discord.NotFound):
+ await message.remove_reaction(reaction, user)
- finally:
- await ctx.send(embed=embed)
+ @commands.command()
+ @commands.cooldown(1, 60*2, commands.BucketType.channel)
+ @whitelist_override(channels=ALL_ALLOWED_CHANNELS)
+ async def topic(self, ctx: commands.Context) -> None:
+ """
+ Responds with a random topic to start a conversation.
+
+ Allows the refresh of a topic by pressing an emoji.
+ """
+ message = await ctx.send(embed=self._build_topic_embed(ctx.channel.id))
+ self.bot.loop.create_task(self._listen_for_refresh(ctx.author, message))
def setup(bot: Bot) -> None:
"""Load the ConvoStarters cog."""
- bot.add_cog(ConvoStarters())
+ bot.add_cog(ConvoStarters(bot))
diff --git a/bot/exts/utilities/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/wtf_python.py b/bot/exts/utilities/wtf_python.py
new file mode 100644
index 00000000..66a022d7
--- /dev/null
+++ b/bot/exts/utilities/wtf_python.py
@@ -0,0 +1,126 @@
+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: str) -> None:
+ """
+ Search WTF Python repository.
+
+ Gets the link of the fuzzy matched query from https://github.com/satwikkansal/wtfpython.
+ Usage:
+ --> .wtf wild imports
+ """
+ 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))