diff options
| author | 2021-08-30 10:02:39 +0100 | |
|---|---|---|
| committer | 2021-08-30 10:02:39 +0100 | |
| commit | 7ae7117cd411d9d943b448076a38f6261fc2e286 (patch) | |
| tree | e7c4b9712899ef7a48a1fdf6207d7d9ab6323746 /bot | |
| parent | Make some return type annotations accurate (diff) | |
| parent | Merge pull request #816 from Objectivitix/patch-1 (diff) | |
Merge branch 'main' into duckduckduckgoose
Diffstat (limited to 'bot')
25 files changed, 409 insertions, 178 deletions
| diff --git a/bot/constants.py b/bot/constants.py index ff901c8e..6323af80 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -134,7 +134,7 @@ class Client(NamedTuple):      prefix = environ.get("PREFIX", ".")      token = environ.get("BOT_TOKEN")      sentry_dsn = environ.get("BOT_SENTRY_DSN") -    debug = environ.get("BOT_DEBUG", "").lower() == "true" +    debug = environ.get("BOT_DEBUG", "true").lower() == "true"      github_bot_repo = "https://github.com/python-discord/sir-lancebot"      # Override seasonal locks: 1 (January) to 12 (December)      month_override = int(environ["MONTH_OVERRIDE"]) if "MONTH_OVERRIDE" in environ else None @@ -226,6 +226,10 @@ class Emojis:      status_dnd = "<:status_dnd:470326272082313216>"      status_offline = "<:status_offline:470326266537705472>" + +    stackoverflow_tag = "<:stack_tag:870926975307501570>" +    stackoverflow_views = "<:stack_eye:870926992692879371>" +      # Reddit emojis      reddit = "<:reddit:676030265734332427>"      reddit_post_text = "<:reddit_post_text:676030265910493204>" diff --git a/bot/exts/christmas/advent_of_code/_helpers.py b/bot/exts/christmas/advent_of_code/_helpers.py index 96de90c4..e26a17ca 100644 --- a/bot/exts/christmas/advent_of_code/_helpers.py +++ b/bot/exts/christmas/advent_of_code/_helpers.py @@ -67,7 +67,7 @@ class UnexpectedResponseStatus(aiohttp.ClientError):      """Raised when an unexpected redirect was detected.""" -class FetchingLeaderboardFailed(Exception): +class FetchingLeaderboardFailedError(Exception):      """Raised when one or more leaderboards could not be fetched at all.""" @@ -210,7 +210,7 @@ async def _fetch_leaderboard_data() -> typing.Dict[str, typing.Any]:              except UnexpectedRedirect:                  if cookies["session"] == AdventOfCode.fallback_session:                      log.error("It seems like the fallback cookie has expired!") -                    raise FetchingLeaderboardFailed from None +                    raise FetchingLeaderboardFailedError from None                  # If we're here, it means that the original session did not                  # work. Let's fall back to the fallback session. @@ -218,7 +218,7 @@ async def _fetch_leaderboard_data() -> typing.Dict[str, typing.Any]:                  continue              except aiohttp.ClientError:                  # Don't retry, something unexpected is wrong and it may not be the session. -                raise FetchingLeaderboardFailed from None +                raise FetchingLeaderboardFailedError from None              else:                  # Get the participants and store their current count.                  board_participants = raw_data["members"] @@ -227,7 +227,7 @@ async def _fetch_leaderboard_data() -> typing.Dict[str, typing.Any]:                  break          else:              log.error(f"reached 'unreachable' state while fetching board `{leaderboard.id}`.") -            raise FetchingLeaderboardFailed +            raise FetchingLeaderboardFailedError      log.info(f"Fetched leaderboard information for {len(participants)} participants")      return participants diff --git a/bot/exts/evergreen/avatar_modification/_effects.py b/bot/exts/evergreen/avatar_modification/_effects.py index b53b26f3..92244207 100644 --- a/bot/exts/evergreen/avatar_modification/_effects.py +++ b/bot/exts/evergreen/avatar_modification/_effects.py @@ -22,6 +22,7 @@ class PfpEffects:          """Applies the given effect to the image passed to it."""          im = Image.open(BytesIO(image_bytes))          im = im.convert("RGBA") +        im = im.resize((1024, 1024))          im = effect(im, *args)          bufferedio = BytesIO() @@ -74,7 +75,6 @@ class PfpEffects:      @staticmethod      def pridify_effect(image: Image.Image, pixels: int, flag: str) -> Image.Image:          """Applies the given pride effect to the given image.""" -        image = image.resize((1024, 1024))          image = PfpEffects.crop_avatar_circle(image)          ring = Image.open(Path(f"bot/resources/pride/flags/{flag}.png")).resize((1024, 1024)) @@ -97,6 +97,17 @@ class PfpEffects:          return image.quantize()      @staticmethod +    def flip_effect(image: Image.Image) -> Image.Image: +        """ +        Flips the image horizontally. + +        This is done by just using ImageOps.mirror(). +        """ +        image = ImageOps.mirror(image) + +        return image + +    @staticmethod      def easterify_effect(image: Image.Image, overlay_image: t.Optional[Image.Image] = None) -> Image.Image:          """          Applies the easter effect to the given image. @@ -272,16 +283,14 @@ class PfpEffects:          return new_image      @staticmethod -    def mosaic_effect(img_bytes: bytes, squares: int, file_name: str) -> discord.File: -        """Separate function run from an executor which turns an image into a mosaic.""" -        avatar = Image.open(BytesIO(img_bytes)) -        avatar = avatar.convert("RGBA").resize((1024, 1024)) +    def mosaic_effect(image: Image.Image, squares: int) -> Image.Image: +        """ +        Applies a mosaic effect to the given image. -        img_squares = PfpEffects.split_image(avatar, squares) +        The "squares" argument specifies the number of squares to split +        the image into. This should be a square number. +        """ +        img_squares = PfpEffects.split_image(image, squares)          new_img = PfpEffects.join_images(img_squares) -        bufferedio = BytesIO() -        new_img.save(bufferedio, format="PNG") -        bufferedio.seek(0) - -        return discord.File(bufferedio, filename=file_name) +        return new_img diff --git a/bot/exts/evergreen/avatar_modification/avatar_modify.py b/bot/exts/evergreen/avatar_modification/avatar_modify.py index 17f34ed4..7b4ae9c7 100644 --- a/bot/exts/evergreen/avatar_modification/avatar_modify.py +++ b/bot/exts/evergreen/avatar_modification/avatar_modify.py @@ -9,7 +9,6 @@ from concurrent.futures import ThreadPoolExecutor  from pathlib import Path  import discord -from aiohttp import client_exceptions  from discord.ext import commands  from bot.bot import Bot @@ -121,6 +120,43 @@ class AvatarModify(commands.Cog):          await ctx.send(embed=embed, file=file) +    @avatar_modify.command(name="reverse", root_aliases=("reverse",)) +    async def reverse(self, ctx: commands.Context, *, text: t.Optional[str]) -> None: +        """ +        Reverses the sent text. + +        If no text is provided, the user's profile picture will be reversed. +        """ +        if text: +            await ctx.send(f"> {text[::-1]}", allowed_mentions=discord.AllowedMentions.none()) +            return + +        async with ctx.typing(): +            user = await self._fetch_user(ctx.author.id) +            if not user: +                await ctx.send(f"{Emojis.cross_mark} Could not get user info.") +                return + +            image_bytes = await user.avatar_url_as(size=1024).read() +            filename = file_safe_name("reverse_avatar", ctx.author.display_name) + +            file = await in_executor( +                PfpEffects.apply_effect, +                image_bytes, +                PfpEffects.flip_effect, +                filename +            ) + +            embed = discord.Embed( +                title="Your reversed avatar.", +                description="Here is your reversed avatar. I think it is a spitting image of you." +            ) + +            embed.set_image(url=f"attachment://{filename}") +            embed.set_footer(text=f"Made by {ctx.author.display_name}.", icon_url=user.avatar_url) + +            await ctx.send(embed=embed, file=file) +      @avatar_modify.command(aliases=("easterify",), root_aliases=("easterify", "avatareasterify"))      async def avatareasterify(self, ctx: commands.Context, *colours: t.Union[discord.Colour, str]) -> None:          """ @@ -236,37 +272,6 @@ class AvatarModify(commands.Cog):              await self.send_pride_image(ctx, image_bytes, pixels, flag, option)      @prideavatar.command() -    async def image(self, ctx: commands.Context, url: str, option: str = "lgbt", pixels: int = 64) -> None: -        """ -        This surrounds the image specified by the URL with a border of a specified LGBT flag. - -        This defaults to the LGBT rainbow flag if none is given. -        The amount of pixels can be given which determines the thickness of the flag border. -        This has a maximum of 512px and defaults to a 64px border. -        The full image is 1024x1024. -        """ -        option = option.lower() -        pixels = max(0, min(512, pixels)) -        flag = GENDER_OPTIONS.get(option) -        if flag is None: -            await ctx.send("I don't have that flag!") -            return - -        async with ctx.typing(): -            try: -                async with self.bot.http_session.get(url) as response: -                    if response.status != 200: -                        await ctx.send("Bad response from provided URL!") -                        return -                    image_bytes = await response.read() -            except client_exceptions.ClientConnectorError: -                raise commands.BadArgument("Cannot connect to provided URL!") -            except client_exceptions.InvalidURL: -                raise commands.BadArgument("Invalid URL!") - -            await self.send_pride_image(ctx, image_bytes, pixels, flag, option) - -    @prideavatar.command()      async def flags(self, ctx: commands.Context) -> None:          """This lists the flags that can be used with the prideavatar command."""          choices = sorted(set(GENDER_OPTIONS.values())) @@ -283,12 +288,9 @@ class AvatarModify(commands.Cog):          root_aliases=("spookyavatar", "spookify", "savatar"),          brief="Spookify an user's avatar."      ) -    async def spookyavatar(self, ctx: commands.Context, member: discord.Member = None) -> None: -        """This "spookifies" the given user's avatar, with a random *spooky* effect.""" -        if member is None: -            member = ctx.author - -        user = await self._fetch_user(member.id) +    async def spookyavatar(self, ctx: commands.Context) -> None: +        """This "spookifies" the user's avatar, with a random *spooky* effect.""" +        user = await self._fetch_user(ctx.author.id)          if not user:              await ctx.send(f"{Emojis.cross_mark} Could not get user info.")              return @@ -296,7 +298,7 @@ class AvatarModify(commands.Cog):          async with ctx.typing():              image_bytes = await user.avatar_url_as(size=1024).read() -            file_name = file_safe_name("spooky_avatar", member.display_name) +            file_name = file_safe_name("spooky_avatar", ctx.author.display_name)              file = await in_executor(                  PfpEffects.apply_effect, @@ -309,7 +311,6 @@ class AvatarModify(commands.Cog):                  title="Is this you or am I just really paranoid?",                  colour=Colours.soft_red              ) -            embed.set_author(name=member.name, icon_url=member.avatar_url)              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) @@ -337,10 +338,11 @@ class AvatarModify(commands.Cog):              img_bytes = await user.avatar_url_as(size=1024).read()              file = await in_executor( -                PfpEffects.mosaic_effect, +                PfpEffects.apply_effect,                  img_bytes, +                PfpEffects.mosaic_effect, +                file_name,                  squares, -                file_name              )              if squares == 1: diff --git a/bot/exts/evergreen/bookmark.py b/bot/exts/evergreen/bookmark.py index 85c9b46f..f93371a6 100644 --- a/bot/exts/evergreen/bookmark.py +++ b/bot/exts/evergreen/bookmark.py @@ -7,14 +7,16 @@ import discord  from discord.ext import commands  from bot.bot import Bot -from bot.constants import Colours, ERROR_REPLIES, Icons +from bot.constants import Categories, Colours, ERROR_REPLIES, Icons, WHITELISTED_CHANNELS  from bot.utils.converters import WrappedMessageConverter +from bot.utils.decorators import whitelist_override  log = logging.getLogger(__name__)  # Number of seconds to wait for other users to bookmark the same message  TIMEOUT = 120  BOOKMARK_EMOJI = "📌" +WHITELISTED_CATEGORIES = (Categories.help_in_use,)  class Bookmark(commands.Cog): @@ -85,6 +87,7 @@ class Bookmark(commands.Cog):          await message.add_reaction(BOOKMARK_EMOJI)          return message +    @whitelist_override(channels=WHITELISTED_CHANNELS, categories=WHITELISTED_CATEGORIES)      @commands.command(name="bookmark", aliases=("bm", "pin"))      async def bookmark(          self, diff --git a/bot/exts/evergreen/error_handler.py b/bot/exts/evergreen/error_handler.py index 5873fb83..a280c725 100644 --- a/bot/exts/evergreen/error_handler.py +++ b/bot/exts/evergreen/error_handler.py @@ -11,7 +11,7 @@ from sentry_sdk import push_scope  from bot.bot import Bot  from bot.constants import Channels, Colours, ERROR_REPLIES, NEGATIVE_REPLIES, RedirectOutput  from bot.utils.decorators import InChannelCheckFailure, InMonthCheckFailure -from bot.utils.exceptions import UserNotPlayingError +from bot.utils.exceptions import APIError, UserNotPlayingError  log = logging.getLogger(__name__) @@ -120,6 +120,15 @@ class CommandErrorHandler(commands.Cog):              await ctx.send("Game not found.")              return +        if isinstance(error, APIError): +            await ctx.send( +                embed=self.error_embed( +                    f"There was an error when communicating with the {error.api}", +                    NEGATIVE_REPLIES +                ) +            ) +            return +          with push_scope() as scope:              scope.user = {                  "id": ctx.author.id, diff --git a/bot/exts/evergreen/githubinfo.py b/bot/exts/evergreen/githubinfo.py index 27e607e5..d29f3aa9 100644 --- a/bot/exts/evergreen/githubinfo.py +++ b/bot/exts/evergreen/githubinfo.py @@ -1,7 +1,7 @@  import logging  import random  from datetime import datetime -from urllib.parse import quote +from urllib.parse import quote, quote_plus  import discord  from discord.ext import commands @@ -37,7 +37,7 @@ class GithubInfo(commands.Cog):      async def github_user_info(self, ctx: commands.Context, username: str) -> None:          """Fetches a user's GitHub information."""          async with ctx.typing(): -            user_data = await self.fetch_data(f"{GITHUB_API_URL}/users/{username}") +            user_data = await self.fetch_data(f"{GITHUB_API_URL}/users/{quote_plus(username)}")              # User_data will not have a message key if the user exists              if "message" in user_data: @@ -91,7 +91,10 @@ class GithubInfo(commands.Cog):              )              if user_data["type"] == "User": -                embed.add_field(name="Gists", value=f"[{gists}](https://gist.github.com/{quote(username, safe='')})") +                embed.add_field( +                    name="Gists", +                    value=f"[{gists}](https://gist.github.com/{quote_plus(username, safe='')})" +                )                  embed.add_field(                      name=f"Organization{'s' if len(orgs)!=1 else ''}", diff --git a/bot/exts/evergreen/help.py b/bot/exts/evergreen/help.py index 3c9ba4d2..bfb5db17 100644 --- a/bot/exts/evergreen/help.py +++ b/bot/exts/evergreen/help.py @@ -8,7 +8,7 @@ from typing import List, NamedTuple, Union  from discord import Colour, Embed, HTTPException, Message, Reaction, User  from discord.ext import commands  from discord.ext.commands import CheckFailure, Cog as DiscordCog, Command, Context -from fuzzywuzzy import fuzz, process +from rapidfuzz import process  from bot import constants  from bot.bot import Bot @@ -159,7 +159,7 @@ class HelpSession:          # Combine command and cog names          choices = list(self._bot.all_commands) + list(self._bot.cogs) -        result = process.extractBests(query, choices, scorer=fuzz.ratio, score_cutoff=90) +        result = process.extract(query, choices, score_cutoff=90)          raise HelpQueryNotFound(f'Query "{query}" not found.', dict(result)) diff --git a/bot/exts/evergreen/movie.py b/bot/exts/evergreen/movie.py index 10638aea..c6af4bcd 100644 --- a/bot/exts/evergreen/movie.py +++ b/bot/exts/evergreen/movie.py @@ -2,7 +2,6 @@ import logging  import random  from enum import Enum  from typing import Any, Dict, List, Tuple -from urllib.parse import urlencode  from aiohttp import ClientSession  from discord import Embed @@ -121,10 +120,10 @@ class Movie(Cog):              "with_genres": genre_id          } -        url = BASE_URL + "discover/movie?" + urlencode(params) +        url = BASE_URL + "discover/movie"          # Make discover request to TMDB, return result -        async with client.get(url) as resp: +        async with client.get(url, params=params) as resp:              return await resp.json()      async def get_pages(self, client: ClientSession, movies: Dict[str, Any], amount: int) -> List[Tuple[str, str]]: @@ -142,9 +141,11 @@ class Movie(Cog):      async def get_movie(self, client: ClientSession, movie: int) -> Dict:          """Get Movie by movie ID from TMDB. Return result dictionary.""" -        url = BASE_URL + f"movie/{movie}?" + urlencode(MOVIE_PARAMS) +        if not isinstance(movie, int): +            raise ValueError("Error while fetching movie from TMDB, movie argument must be integer. ") +        url = BASE_URL + f"movie/{movie}" -        async with client.get(url) as resp: +        async with client.get(url, params=MOVIE_PARAMS) as resp:              return await resp.json()      async def create_page(self, movie: Dict[str, Any]) -> Tuple[str, str]: diff --git a/bot/exts/evergreen/realpython.py b/bot/exts/evergreen/realpython.py new file mode 100644 index 00000000..e722dd4b --- /dev/null +++ b/bot/exts/evergreen/realpython.py @@ -0,0 +1,76 @@ +import logging +from html import unescape +from urllib.parse import quote_plus + +from discord import Embed +from discord.ext import commands + +from bot import bot +from bot.constants import Colours + +logger = logging.getLogger(__name__) + + +API_ROOT = "https://realpython.com/search/api/v1/" +ARTICLE_URL = "https://realpython.com{article_url}" +SEARCH_URL = "https://realpython.com/search?q={user_search}" + + +ERROR_EMBED = Embed( +    title="Error while searching Real Python", +    description="There was an error while trying to reach Real Python. Please try again shortly.", +    color=Colours.soft_red, +) + + +class RealPython(commands.Cog): +    """User initiated command to search for a Real Python article.""" + +    def __init__(self, bot: bot.Bot): +        self.bot = bot + +    @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} +        async with self.bot.http_session.get(url=API_ROOT, params=params) as response: +            if response.status != 200: +                logger.error( +                    f"Unexpected status code {response.status} from Real Python" +                ) +                await ctx.send(embed=ERROR_EMBED) +                return + +            data = await response.json() + +        articles = data["results"] + +        if len(articles) == 0: +            no_articles = Embed( +                title=f"No articles found for '{user_search}'", color=Colours.soft_red +            ) +            await ctx.send(embed=no_articles) +            return + +        article_embed = Embed( +            title="Search results - Real Python", +            url=SEARCH_URL.format(user_search=quote_plus(user_search)), +            description="Here are the top 5 results:", +            color=Colours.orange, +        ) + +        for article in articles: +            article_embed.add_field( +                name=unescape(article["title"]), +                value=ARTICLE_URL.format(article_url=article["url"]), +                inline=False, +            ) +        article_embed.set_footer(text="Click the links to go to the articles.") + +        await ctx.send(embed=article_embed) + + +def setup(bot: bot.Bot) -> None: +    """Load the Real Python Cog.""" +    bot.add_cog(RealPython(bot)) diff --git a/bot/exts/evergreen/snakes/_converter.py b/bot/exts/evergreen/snakes/_converter.py index 26bde611..c8d1909b 100644 --- a/bot/exts/evergreen/snakes/_converter.py +++ b/bot/exts/evergreen/snakes/_converter.py @@ -5,7 +5,7 @@ from typing import Iterable, List  import discord  from discord.ext.commands import Context, Converter -from fuzzywuzzy import fuzz +from rapidfuzz import fuzz  from bot.exts.evergreen.snakes._utils import SNAKE_RESOURCES  from bot.utils import disambiguate diff --git a/bot/exts/evergreen/snakes/_utils.py b/bot/exts/evergreen/snakes/_utils.py index 0a5894b7..f996d7f8 100644 --- a/bot/exts/evergreen/snakes/_utils.py +++ b/bot/exts/evergreen/snakes/_utils.py @@ -55,7 +55,8 @@ snakes = {      "Baby Rattle Snake": "https://i.imgur.com/i5jYA8f.png",      "Baby Dragon Snake": "https://i.imgur.com/SuMKM4m.png",      "Baby Garden Snake": "https://i.imgur.com/5vYx3ah.png", -    "Baby Cobra": "https://i.imgur.com/jk14ryt.png" +    "Baby Cobra": "https://i.imgur.com/jk14ryt.png", +    "Baby Anaconda": "https://i.imgur.com/EpdrnNr.png",  }  BOARD_TILE_SIZE = 56         # the size of each board tile diff --git a/bot/exts/evergreen/stackoverflow.py b/bot/exts/evergreen/stackoverflow.py new file mode 100644 index 00000000..40f149c9 --- /dev/null +++ b/bot/exts/evergreen/stackoverflow.py @@ -0,0 +1,88 @@ +import logging +from html import unescape +from urllib.parse import quote_plus + +from discord import Embed, HTTPException +from discord.ext import commands + +from bot import bot +from bot.constants import Colours, Emojis + +logger = logging.getLogger(__name__) + +BASE_URL = "https://api.stackexchange.com/2.2/search/advanced" +SO_PARAMS = { +    "order": "desc", +    "sort": "activity", +    "site": "stackoverflow" +} +SEARCH_URL = "https://stackoverflow.com/search?q={query}" +ERR_EMBED = Embed( +    title="Error in fetching results from Stackoverflow", +    description=( +        "Sorry, there was en error while trying to fetch data from the Stackoverflow website. Please try again in some " +        "time. If this issue persists, please contact the staff or send a message in #dev-contrib." +    ), +    color=Colours.soft_red +) + + +class Stackoverflow(commands.Cog): +    """Contains command to interact with stackoverflow from discord.""" + +    def __init__(self, bot: bot.Bot): +        self.bot = bot + +    @commands.command(aliases=["so"]) +    @commands.cooldown(1, 15, commands.cooldowns.BucketType.user) +    async def stackoverflow(self, ctx: commands.Context, *, search_query: str) -> None: +        """Sends the top 5 results of a search query from stackoverflow.""" +        params = SO_PARAMS | {"q": search_query} +        async with self.bot.http_session.get(url=BASE_URL, params=params) as response: +            if response.status == 200: +                data = await response.json() +            else: +                logger.error(f'Status code is not 200, it is {response.status}') +                await ctx.send(embed=ERR_EMBED) +                return +        if not data['items']: +            no_search_result = Embed( +                title=f"No search results found for {search_query}", +                color=Colours.soft_red +            ) +            await ctx.send(embed=no_search_result) +            return + +        top5 = data["items"][:5] +        encoded_search_query = quote_plus(search_query) +        embed = Embed( +            title="Search results - Stackoverflow", +            url=SEARCH_URL.format(query=encoded_search_query), +            description=f"Here are the top {len(top5)} results:", +            color=Colours.orange +        ) +        for item in top5: +            embed.add_field( +                name=unescape(item['title']), +                value=( +                    f"[{Emojis.reddit_upvote} {item['score']}    " +                    f"{Emojis.stackoverflow_views} {item['view_count']}     " +                    f"{Emojis.reddit_comments} {item['answer_count']}   " +                    f"{Emojis.stackoverflow_tag} {', '.join(item['tags'][:3])}]" +                    f"({item['link']})" +                ), +                inline=False) +        embed.set_footer(text="View the original link for more results.") +        try: +            await ctx.send(embed=embed) +        except HTTPException: +            search_query_too_long = Embed( +                title="Your search query is too long, please try shortening your search query", +                color=Colours.soft_red +            ) +            await ctx.send(embed=search_query_too_long) + + +def setup(bot: bot.Bot) -> None: +    """Load the Stackoverflow Cog.""" +    bot.add_cog(Stackoverflow(bot)) diff --git a/bot/exts/evergreen/trivia_quiz.py b/bot/exts/evergreen/trivia_quiz.py index 28924aed..bc25cbf7 100644 --- a/bot/exts/evergreen/trivia_quiz.py +++ b/bot/exts/evergreen/trivia_quiz.py @@ -9,7 +9,7 @@ from typing import Callable, List, Optional  import discord  from discord.ext import commands -from fuzzywuzzy import fuzz +from rapidfuzz import fuzz  from bot.bot import Bot  from bot.constants import Colours, NEGATIVE_REPLIES, Roles diff --git a/bot/exts/evergreen/wikipedia.py b/bot/exts/evergreen/wikipedia.py index 83937438..27e68397 100644 --- a/bot/exts/evergreen/wikipedia.py +++ b/bot/exts/evergreen/wikipedia.py @@ -2,20 +2,30 @@ import logging  import re  from datetime import datetime  from html import unescape -from typing import List, Optional +from typing import List  from discord import Color, Embed, TextChannel  from discord.ext import commands  from bot.bot import Bot  from bot.utils import LinePaginator +from bot.utils.exceptions import APIError  log = logging.getLogger(__name__)  SEARCH_API = ( -    "https://en.wikipedia.org/w/api.php?action=query&list=search&prop=info&inprop=url&utf8=&" -    "format=json&origin=*&srlimit={number_of_results}&srsearch={string}" +    "https://en.wikipedia.org/w/api.php"  ) +WIKI_PARAMS = { +    "action": "query", +    "list": "search", +    "prop": "info", +    "inprop": "url", +    "utf8": "", +    "format": "json", +    "origin": "*", + +}  WIKI_THUMBNAIL = (      "https://upload.wikimedia.org/wikipedia/en/thumb/8/80/Wikipedia-logo-v2.svg"      "/330px-Wikipedia-logo-v2.svg.png" @@ -33,43 +43,36 @@ class WikipediaSearch(commands.Cog):      def __init__(self, bot: Bot):          self.bot = bot -    async def wiki_request(self, channel: TextChannel, search: str) -> Optional[List[str]]: +    async def wiki_request(self, channel: TextChannel, search: str) -> List[str]:          """Search wikipedia search string and return formatted first 10 pages found.""" -        url = SEARCH_API.format(number_of_results=10, string=search) -        async with self.bot.http_session.get(url=url) as resp: -            if resp.status == 200: -                raw_data = await resp.json() -                number_of_results = raw_data["query"]["searchinfo"]["totalhits"] - -                if number_of_results: -                    results = raw_data["query"]["search"] -                    lines = [] - -                    for article in results: -                        line = WIKI_SEARCH_RESULT.format( -                            name=article["title"], -                            description=unescape( -                                re.sub( -                                    WIKI_SNIPPET_REGEX, "", article["snippet"] -                                ) -                            ), -                            url=f"https://en.wikipedia.org/?curid={article['pageid']}" -                        ) -                        lines.append(line) - -                    return lines - -                else: -                    await channel.send( -                        "Sorry, we could not find a wikipedia article using that search term." -                    ) -                    return -            else: +        params = WIKI_PARAMS | {"srlimit": 10, "srsearch": search} +        async with self.bot.http_session.get(url=SEARCH_API, params=params) as resp: +            if resp.status != 200:                  log.info(f"Unexpected response `{resp.status}` while searching wikipedia for `{search}`") -                await channel.send( -                    "Whoops, the Wikipedia API is having some issues right now. Try again later." -                ) -                return +                raise APIError("Wikipedia API", resp.status) + +            raw_data = await resp.json() + +            if not raw_data.get("query"): +                if error := raw_data.get("errors"): +                    log.error(f"There was an error while communicating with the Wikipedia API: {error}") +                raise APIError("Wikipedia API", resp.status, error) + +            lines = [] +            if raw_data["query"]["searchinfo"]["totalhits"]: +                for article in raw_data["query"]["search"]: +                    line = WIKI_SEARCH_RESULT.format( +                        name=article["title"], +                        description=unescape( +                            re.sub( +                                WIKI_SNIPPET_REGEX, "", article["snippet"] +                            ) +                        ), +                        url=f"https://en.wikipedia.org/?curid={article['pageid']}" +                    ) +                    lines.append(line) + +            return lines      @commands.cooldown(1, 10, commands.BucketType.user)      @commands.command(name="wikipedia", aliases=("wiki",)) @@ -87,6 +90,10 @@ class WikipediaSearch(commands.Cog):              await LinePaginator.paginate(                  contents, ctx, embed              ) +        else: +            await ctx.send( +                "Sorry, we could not find a wikipedia article using that search term." +            )  def setup(bot: Bot) -> None: diff --git a/bot/exts/evergreen/wolfram.py b/bot/exts/evergreen/wolfram.py index d23afd6f..26674d37 100644 --- a/bot/exts/evergreen/wolfram.py +++ b/bot/exts/evergreen/wolfram.py @@ -1,7 +1,7 @@  import logging  from io import BytesIO  from typing import Callable, List, Optional, Tuple -from urllib import parse +from urllib.parse import urlencode  import arrow  import discord @@ -17,7 +17,7 @@ log = logging.getLogger(__name__)  APPID = Wolfram.key  DEFAULT_OUTPUT_FORMAT = "JSON" -QUERY = "http://api.wolframalpha.com/v2/{request}?{data}" +QUERY = "http://api.wolframalpha.com/v2/{request}"  WOLF_IMAGE = "https://www.symbols.com/gi.php?type=1&id=2886&i=1"  MAX_PODS = 20 @@ -108,7 +108,7 @@ def custom_cooldown(*ignore: List[int]) -> Callable:  async def get_pod_pages(ctx: Context, bot: Bot, query: str) -> Optional[List[Tuple]]:      """Get the Wolfram API pod pages for the provided query."""      async with ctx.typing(): -        url_str = parse.urlencode({ +        params = {              "input": query,              "appid": APPID,              "output": DEFAULT_OUTPUT_FORMAT, @@ -116,27 +116,27 @@ async def get_pod_pages(ctx: Context, bot: Bot, query: str) -> Optional[List[Tup              "location": "the moon",              "latlong": "0.0,0.0",              "ip": "1.1.1.1" -        }) -        request_url = QUERY.format(request="query", data=url_str) +        } +        request_url = QUERY.format(request="query") -        async with bot.http_session.get(request_url) as response: +        async with bot.http_session.get(url=request_url, params=params) as response:              json = await response.json(content_type="text/plain")          result = json["queryresult"] - +        log_full_url = f"{request_url}?{urlencode(params)}"          if result["error"]:              # API key not set up correctly              if result["error"]["msg"] == "Invalid appid":                  message = "Wolfram API key is invalid or missing."                  log.warning(                      "API key seems to be missing, or invalid when " -                    f"processing a wolfram request: {url_str}, Response: {json}" +                    f"processing a wolfram request: {log_full_url}, Response: {json}"                  )                  await send_embed(ctx, message)                  return              message = "Something went wrong internally with your request, please notify staff!" -            log.warning(f"Something went wrong getting a response from wolfram: {url_str}, Response: {json}") +            log.warning(f"Something went wrong getting a response from wolfram: {log_full_url}, Response: {json}")              await send_embed(ctx, message)              return @@ -172,18 +172,18 @@ class Wolfram(Cog):      @custom_cooldown(*STAFF_ROLES)      async def wolfram_command(self, ctx: Context, *, query: str) -> None:          """Requests all answers on a single image, sends an image of all related pods.""" -        url_str = parse.urlencode({ +        params = {              "i": query,              "appid": APPID,              "location": "the moon",              "latlong": "0.0,0.0",              "ip": "1.1.1.1" -        }) -        query = QUERY.format(request="simple", data=url_str) +        } +        request_url = QUERY.format(request="simple")          # Give feedback that the bot is working.          async with ctx.typing(): -            async with self.bot.http_session.get(query) as response: +            async with self.bot.http_session.get(url=request_url, params=params) as response:                  status = response.status                  image_bytes = await response.read() @@ -257,18 +257,18 @@ class Wolfram(Cog):      @custom_cooldown(*STAFF_ROLES)      async def wolfram_short_command(self, ctx: Context, *, query: str) -> None:          """Requests an answer to a simple question.""" -        url_str = parse.urlencode({ +        params = {              "i": query,              "appid": APPID,              "location": "the moon",              "latlong": "0.0,0.0",              "ip": "1.1.1.1" -        }) -        query = QUERY.format(request="result", data=url_str) +        } +        request_url = QUERY.format(request="result")          # Give feedback that the bot is working.          async with ctx.typing(): -            async with self.bot.http_session.get(query) as response: +            async with self.bot.http_session.get(url=request_url, params=params) as response:                  status = response.status                  response_text = await response.text() diff --git a/bot/exts/halloween/hacktoberstats.py b/bot/exts/halloween/hacktoberstats.py index 50d3aaf6..24106a5e 100644 --- a/bot/exts/halloween/hacktoberstats.py +++ b/bot/exts/halloween/hacktoberstats.py @@ -4,6 +4,7 @@ import re  from collections import Counter  from datetime import datetime, timedelta  from typing import List, Optional, Tuple, Union +from urllib.parse import quote_plus  import discord  from async_rediscache import RedisCache @@ -208,24 +209,24 @@ class HacktoberStats(commands.Cog):          None will be returned when the GitHub user was not found.          """          log.info(f"Fetching Hacktoberfest Stats for GitHub user: '{github_username}'") -        base_url = "https://api.github.com/search/issues?q=" +        base_url = "https://api.github.com/search/issues"          action_type = "pr"          is_query = "public"          not_query = "draft"          date_range = f"{CURRENT_YEAR}-09-30T10:00Z..{CURRENT_YEAR}-11-01T12:00Z"          per_page = "300" -        query_url = ( -            f"{base_url}" +        query_params = (              f"+type:{action_type}"              f"+is:{is_query}" -            f"+author:{github_username}" +            f"+author:{quote_plus(github_username)}"              f"+-is:{not_query}"              f"+created:{date_range}"              f"&per_page={per_page}"          ) -        log.debug(f"GitHub query URL generated: {query_url}") -        jsonresp = await self._fetch_url(query_url, REQUEST_HEADERS) +        log.debug(f"GitHub query parameters generated: {query_params}") + +        jsonresp = await self._fetch_url(base_url, REQUEST_HEADERS, {"q": query_params})          if "message" in jsonresp:              # One of the parameters is invalid, short circuit for now              api_message = jsonresp["errors"][0]["message"] @@ -295,9 +296,9 @@ class HacktoberStats(commands.Cog):                  outlist.append(itemdict)          return outlist -    async def _fetch_url(self, url: str, headers: dict) -> dict: +    async def _fetch_url(self, url: str, headers: dict, params: dict) -> dict:          """Retrieve API response from URL.""" -        async with self.bot.http_session.get(url, headers=headers) as resp: +        async with self.bot.http_session.get(url, headers=headers, params=params) as resp:              return await resp.json()      @staticmethod diff --git a/bot/exts/halloween/scarymovie.py b/bot/exts/halloween/scarymovie.py index f4cf41db..33659fd8 100644 --- a/bot/exts/halloween/scarymovie.py +++ b/bot/exts/halloween/scarymovie.py @@ -1,19 +1,14 @@  import logging  import random -from os import environ  from discord import Embed  from discord.ext import commands  from bot.bot import Bot - +from bot.constants import Tokens  log = logging.getLogger(__name__) -TMDB_API_KEY = environ.get("TMDB_API_KEY") -TMDB_TOKEN = environ.get("TMDB_TOKEN") - -  class ScaryMovie(commands.Cog):      """Selects a random scary movie and embeds info into Discord chat.""" @@ -31,13 +26,14 @@ class ScaryMovie(commands.Cog):      async def select_movie(self) -> dict:          """Selects a random movie and returns a JSON of movie details from TMDb.""" -        url = "https://api.themoviedb.org/4/discover/movie" +        url = "https://api.themoviedb.org/3/discover/movie"          params = { +            "api_key": Tokens.tmdb,              "with_genres": "27", -            "vote_count.gte": "5" +            "vote_count.gte": "5", +            "include_adult": "false"          }          headers = { -            "Authorization": "Bearer " + TMDB_TOKEN,              "Content-Type": "application/json;charset=utf-8"          } @@ -55,7 +51,7 @@ class ScaryMovie(commands.Cog):          # Get full details and credits          async with self.bot.http_session.get(              url=f"https://api.themoviedb.org/3/movie/{selection_id}", -            params={"api_key": TMDB_API_KEY, "append_to_response": "credits"} +            params={"api_key": Tokens.tmdb, "append_to_response": "credits"}          ) as selection:              return await selection.json() diff --git a/bot/exts/internal_eval/_internal_eval.py b/bot/exts/internal_eval/_internal_eval.py index 56bf5add..b7749144 100644 --- a/bot/exts/internal_eval/_internal_eval.py +++ b/bot/exts/internal_eval/_internal_eval.py @@ -7,7 +7,7 @@ import discord  from discord.ext import commands  from bot.bot import Bot -from bot.constants import Roles +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 @@ -41,6 +41,9 @@ class InternalEval(commands.Cog):          self.bot = bot          self.locals = {} +        if Client.debug: +            self.internal_group.add_check(commands.is_owner().predicate) +      @staticmethod      def shorten_output(              output: str, diff --git a/bot/exts/pride/pride_leader.py b/bot/exts/pride/pride_leader.py index c3426ad1..8e88183b 100644 --- a/bot/exts/pride/pride_leader.py +++ b/bot/exts/pride/pride_leader.py @@ -6,7 +6,7 @@ from typing import Optional  import discord  from discord.ext import commands -from fuzzywuzzy import fuzz +from rapidfuzz import fuzz  from bot import bot  from bot import constants diff --git a/bot/exts/valentines/lovecalculator.py b/bot/exts/valentines/lovecalculator.py index b10b7bca..1cb10e64 100644 --- a/bot/exts/valentines/lovecalculator.py +++ b/bot/exts/valentines/lovecalculator.py @@ -4,7 +4,7 @@ import json  import logging  import random  from pathlib import Path -from typing import Coroutine, Union +from typing import Coroutine, Optional  import discord  from discord import Member @@ -12,6 +12,8 @@ from discord.ext import commands  from discord.ext.commands import BadArgument, Cog, clean_content  from bot.bot import Bot +from bot.constants import Channels, Client, Lovefest, Month +from bot.utils.decorators import in_month  log = logging.getLogger(__name__) @@ -22,45 +24,45 @@ LOVE_DATA = sorted((int(key), value) for key, value in LOVE_DATA.items())  class LoveCalculator(Cog):      """A cog for calculating the love between two people.""" +    @in_month(Month.FEBRUARY)      @commands.command(aliases=("love_calculator", "love_calc"))      @commands.cooldown(rate=1, per=5, type=commands.BucketType.user) -    async def love(self, ctx: commands.Context, who: Union[Member, str], whom: Union[Member, str] = None) -> None: +    async def love(self, ctx: commands.Context, who: Member, whom: Optional[Member] = None) -> None:          """          Tells you how much the two love each other. -        This command accepts users or arbitrary strings as arguments. -        Users are converted from: +        This command requires at least one member as input, if two are given love will be calculated between +        those two users, if only one is given, the second member is asusmed to be the invoker. +        Members are converted from:            - User ID            - Mention            - name#discrim            - name            - nickname -        Any two arguments will always yield the same result, though the order of arguments matters: -          Running .love joseph erlang will always yield the same result. -          Running .love erlang joseph won't yield the same result as .love joseph erlang - -        If you want to use multiple words for one argument, you must include quotes. -          .love "Zes Vappa" "morning coffee" +        Any two arguments will always yield the same result, regardless of the order of arguments: +          Running .love @joe#6000 @chrisjl#2655 will always yield the same result. +          Running .love @chrisjl#2655 @joe#6000 will yield the same result as before.          """ +        if ( +            Lovefest.role_id not in [role.id for role in who.roles] +            or (whom is not None and Lovefest.role_id not in [role.id for role in whom.roles]) +        ): +            raise BadArgument( +                "This command can only be ran against members with the lovefest role! " +                "This role be can assigned by running " +                f"`{Client.prefix}lovefest sub` in <#{Channels.community_bot_commands}>." +            ) +          if whom is None:              whom = ctx.author -        def normalize(arg: Union[Member, str]) -> Coroutine: -            if isinstance(arg, Member): -                # If we are given a member, return name#discrim without any extra changes -                arg = str(arg) -            else: -                # Otherwise normalise case and remove any leading/trailing whitespace -                arg = arg.strip().title() +        def normalize(arg: Member) -> Coroutine:              # This has to be done manually to be applied to usernames -            return clean_content(escape_markdown=True).convert(ctx, arg) - -        who, whom = [await normalize(arg) for arg in (who, whom)] +            return clean_content(escape_markdown=True).convert(ctx, str(arg)) -        # Make sure user didn't provide something silly such as 10 spaces -        if not (who and whom): -            raise BadArgument("Arguments must be non-empty strings.") +        # Sort to ensure same result for same input, regardless of order +        who, whom = sorted([await normalize(arg) for arg in (who, whom)])          # Hash inputs to guarantee consistent results (hashing algorithm choice arbitrary)          # @@ -87,6 +89,7 @@ class LoveCalculator(Cog):              name="A letter from Dr. Love:",              value=data["text"]          ) +        embed.set_footer(text=f"You can unsubscribe from lovefest by using {Client.prefix}lovefest unsub")          await ctx.send(embed=embed) diff --git a/bot/exts/valentines/movie_generator.py b/bot/exts/valentines/movie_generator.py index 0fc5edb4..d2dc8213 100644 --- a/bot/exts/valentines/movie_generator.py +++ b/bot/exts/valentines/movie_generator.py @@ -1,7 +1,6 @@  import logging  import random  from os import environ -from urllib import parse  import discord  from discord.ext import commands @@ -35,8 +34,8 @@ class RomanceMovieFinder(commands.Cog):              "with_genres": "10749"          }          # The api request url -        request_url = "https://api.themoviedb.org/3/discover/movie?" + parse.urlencode(params) -        async with self.bot.http_session.get(request_url) as resp: +        request_url = "https://api.themoviedb.org/3/discover/movie" +        async with self.bot.http_session.get(request_url, params=params) as resp:              # Trying to load the json file returned from the api              try:                  data = await resp.json() diff --git a/bot/resources/evergreen/py_topics.yaml b/bot/resources/evergreen/py_topics.yaml index 6b7e0206..a3fb2ccc 100644 --- a/bot/resources/evergreen/py_topics.yaml +++ b/bot/resources/evergreen/py_topics.yaml @@ -23,6 +23,19 @@      - When you were first learning, what is a resource you wish you had?      - What is something you know now, that you wish you knew when starting out?      - What is something simple that you still error on today? +    - What do you plan on eventually achieving with Python? +    - Is Python your first programming language? If not, what is it? +    - What's your favourite aspect of Python development? (Backend, frontend, game dev, machine learning, ai, etc.) +    - In what ways has Python Discord helped you with Python? +    - Are you currently using Python professionally, for education, or as a hobby? +    - What is your process when you decide to start a project in Python? +    - Have you ever been unable to finish a Python project? What is it and why? +    - How often do you program in Python? +    - How would you learn a new library if needed to do so? +    - Have you ever worked with a microcontroller or anything physical with Python before? +    - How good would you say you are at Python so far? Beginner, intermediate, or advanced? +    - Have you ever tried making your own programming language? +    - Has a recently discovered Python module changed your general use of Python?  # algos-and-data-structs  650401909852864553: @@ -52,7 +65,7 @@      - What unique features does your bot contain, if any?      - What commands/features are you proud of making?      - What feature would you be the most interested in making? -    - What feature would you like to see added to the library? what feature in the library do you think is redundant? +    - What feature would you like to see added to the library? What feature in the library do you think is redundant?      - Do you think there's a way in which Discord could handle bots better?      - What's one feature you wish more developers had in their bots? diff --git a/bot/utils/exceptions.py b/bot/utils/exceptions.py index 9e080759..bf0e5813 100644 --- a/bot/utils/exceptions.py +++ b/bot/utils/exceptions.py @@ -1,4 +1,17 @@ +from typing import Optional + +  class UserNotPlayingError(Exception):      """Raised when users try to use game commands when they are not playing."""      pass + + +class APIError(Exception): +    """Raised when an external API (eg. Wikipedia) returns an error response.""" + +    def __init__(self, api: str, status_code: int, error_msg: Optional[str] = None): +        super().__init__() +        self.api = api +        self.status_code = status_code +        self.error_msg = error_msg diff --git a/bot/utils/pagination.py b/bot/utils/pagination.py index d9c0862a..b1062c09 100644 --- a/bot/utils/pagination.py +++ b/bot/utils/pagination.py @@ -20,7 +20,7 @@ PAGINATION_EMOJI = (FIRST_EMOJI, LEFT_EMOJI, RIGHT_EMOJI, LAST_EMOJI, DELETE_EMO  log = logging.getLogger(__name__) -class EmptyPaginatorEmbed(Exception): +class EmptyPaginatorEmbedError(Exception):      """Base Exception class for an empty paginator embed.""" @@ -141,7 +141,7 @@ class LinePaginator(Paginator):          if not lines:              if exception_on_empty_embed:                  log.exception("Pagination asked for empty lines iterable") -                raise EmptyPaginatorEmbed("No lines to paginate") +                raise EmptyPaginatorEmbedError("No lines to paginate")              log.debug("No lines to add to paginator, adding '(nothing to display)' message")              lines.append("(nothing to display)") @@ -349,7 +349,7 @@ class ImagePaginator(Paginator):          if not pages:              if exception_on_empty_embed:                  log.exception("Pagination asked for empty image list") -                raise EmptyPaginatorEmbed("No images to paginate") +                raise EmptyPaginatorEmbedError("No images to paginate")              log.debug("No images to add to paginator, adding '(no images to display)' message")              pages.append(("(no images to display)", "")) | 
