aboutsummaryrefslogtreecommitdiffstats
path: root/bot
diff options
context:
space:
mode:
Diffstat (limited to 'bot')
-rw-r--r--bot/constants.py6
-rw-r--r--bot/exts/christmas/advent_of_code/_helpers.py8
-rw-r--r--bot/exts/evergreen/avatar_modification/_effects.py31
-rw-r--r--bot/exts/evergreen/avatar_modification/avatar_modify.py86
-rw-r--r--bot/exts/evergreen/bookmark.py5
-rw-r--r--bot/exts/evergreen/error_handler.py11
-rw-r--r--bot/exts/evergreen/githubinfo.py9
-rw-r--r--bot/exts/evergreen/help.py4
-rw-r--r--bot/exts/evergreen/movie.py11
-rw-r--r--bot/exts/evergreen/realpython.py76
-rw-r--r--bot/exts/evergreen/snakes/_converter.py2
-rw-r--r--bot/exts/evergreen/snakes/_utils.py3
-rw-r--r--bot/exts/evergreen/stackoverflow.py88
-rw-r--r--bot/exts/evergreen/trivia_quiz.py2
-rw-r--r--bot/exts/evergreen/wikipedia.py83
-rw-r--r--bot/exts/evergreen/wolfram.py34
-rw-r--r--bot/exts/halloween/hacktoberstats.py17
-rw-r--r--bot/exts/halloween/scarymovie.py16
-rw-r--r--bot/exts/internal_eval/_internal_eval.py5
-rw-r--r--bot/exts/pride/pride_leader.py2
-rw-r--r--bot/exts/valentines/lovecalculator.py49
-rw-r--r--bot/exts/valentines/movie_generator.py5
-rw-r--r--bot/resources/evergreen/py_topics.yaml15
-rw-r--r--bot/utils/exceptions.py13
-rw-r--r--bot/utils/pagination.py6
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)", ""))