diff options
Diffstat (limited to 'bot/exts/evergreen')
| -rw-r--r-- | bot/exts/evergreen/branding.py | 6 | ||||
| -rw-r--r-- | bot/exts/evergreen/conversationstarters.py | 71 | ||||
| -rw-r--r-- | bot/exts/evergreen/fun.py | 139 | ||||
| -rw-r--r-- | bot/exts/evergreen/issues.py | 113 | ||||
| -rw-r--r-- | bot/exts/evergreen/magic_8ball.py | 2 | ||||
| -rw-r--r-- | bot/exts/evergreen/minesweeper.py | 17 | ||||
| -rw-r--r-- | bot/exts/evergreen/recommend_game.py | 2 | ||||
| -rw-r--r-- | bot/exts/evergreen/reddit.py | 6 | ||||
| -rw-r--r-- | bot/exts/evergreen/snakes/converter.py | 4 | ||||
| -rw-r--r-- | bot/exts/evergreen/snakes/snakes_cog.py | 12 | ||||
| -rw-r--r-- | bot/exts/evergreen/speedrun.py | 2 | ||||
| -rw-r--r-- | bot/exts/evergreen/status_cats.py | 33 | ||||
| -rw-r--r-- | bot/exts/evergreen/trivia_quiz.py | 2 | ||||
| -rw-r--r-- | bot/exts/evergreen/wikipedia.py | 114 | ||||
| -rw-r--r-- | bot/exts/evergreen/wolfram.py | 278 |
15 files changed, 716 insertions, 85 deletions
diff --git a/bot/exts/evergreen/branding.py b/bot/exts/evergreen/branding.py index 72f31042..7e531011 100644 --- a/bot/exts/evergreen/branding.py +++ b/bot/exts/evergreen/branding.py @@ -171,7 +171,7 @@ class BrandingManager(commands.Cog): def _read_config(self) -> t.Dict[str, bool]: """Read and return persistent config file.""" - with self.config_file.open("r") as persistent_file: + with self.config_file.open("r", encoding="utf8") as persistent_file: return json.load(persistent_file) def _write_config(self, key: str, value: bool) -> None: @@ -179,7 +179,7 @@ class BrandingManager(commands.Cog): current_config = self._read_config() current_config[key] = value - with self.config_file.open("w") as persistent_file: + with self.config_file.open("w", encoding="utf8") as persistent_file: json.dump(current_config, persistent_file) async def _daemon_func(self) -> None: @@ -198,7 +198,7 @@ class BrandingManager(commands.Cog): All method calls in the internal loop are considered safe, i.e. no errors propagate to the daemon's loop. The daemon itself does not perform any error handling on its own. """ - await self.bot.wait_until_ready() + await self.bot.wait_until_guild_available() while True: self.current_season = get_current_season() diff --git a/bot/exts/evergreen/conversationstarters.py b/bot/exts/evergreen/conversationstarters.py new file mode 100644 index 00000000..576b8d76 --- /dev/null +++ b/bot/exts/evergreen/conversationstarters.py @@ -0,0 +1,71 @@ +from pathlib import Path + +import yaml +from discord import Color, Embed +from discord.ext import commands + +from bot.constants import WHITELISTED_CHANNELS +from bot.utils.decorators import override_in_channel +from bot.utils.randomization import RandomCycle + +SUGGESTION_FORM = 'https://forms.gle/zw6kkJqv8U43Nfjg9' + +with Path("bot/resources/evergreen/starter.yaml").open("r", encoding="utf8") as f: + STARTERS = yaml.load(f, Loader=yaml.FullLoader) + +with Path("bot/resources/evergreen/py_topics.yaml").open("r", encoding="utf8") as f: + # First ID is #python-general and the rest are top to bottom categories of Topical Chat/Help. + PY_TOPICS = yaml.load(f, Loader=yaml.FullLoader) + + # Removing `None` from lists of topics, if not a list, it is changed to an empty one. + PY_TOPICS = {k: [i for i in v if i] if isinstance(v, list) else [] for k, v in PY_TOPICS.items()} + + # All the allowed channels that the ".topic" command is allowed to be executed in. + ALL_ALLOWED_CHANNELS = list(PY_TOPICS.keys()) + list(WHITELISTED_CHANNELS) + +# Putting all topics into one dictionary and shuffling lists to reduce same-topic repetitions. +ALL_TOPICS = {'default': STARTERS, **PY_TOPICS} +TOPICS = { + channel: RandomCycle(topics or ['No topics found for this channel.']) + for channel, topics in ALL_TOPICS.items() +} + + +class ConvoStarters(commands.Cog): + """Evergreen conversation topics.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + @commands.command() + @override_in_channel(ALL_ALLOWED_CHANNELS) + async def topic(self, ctx: commands.Context) -> None: + """ + Responds with a random topic to start a conversation. + + If in a Python channel, a python-related topic will be given. + + Otherwise, a random conversation topic will be received by the user. + """ + # No matter what, the form will be shown. + embed = Embed(description=f'Suggest more topics [here]({SUGGESTION_FORM})!', color=Color.blurple()) + + try: + # Fetching topics. + channel_topics = TOPICS[ctx.channel.id] + + # If the channel isn't Python-related. + except KeyError: + embed.title = f'**{next(TOPICS["default"])}**' + + # If the channel ID doesn't have any topics. + else: + embed.title = f'**{next(channel_topics)}**' + + finally: + await ctx.send(embed=embed) + + +def setup(bot: commands.Bot) -> None: + """Conversation starters Cog load.""" + bot.add_cog(ConvoStarters(bot)) diff --git a/bot/exts/evergreen/fun.py b/bot/exts/evergreen/fun.py index 67a4bae5..de6a92c6 100644 --- a/bot/exts/evergreen/fun.py +++ b/bot/exts/evergreen/fun.py @@ -1,14 +1,16 @@ import functools +import json import logging import random -from typing import Callable, Tuple, Union +from pathlib import Path +from typing import Callable, Iterable, Tuple, Union from discord import Embed, Message from discord.ext import commands -from discord.ext.commands import Bot, Cog, Context, MessageConverter +from discord.ext.commands import Bot, Cog, Context, MessageConverter, clean_content from bot import utils -from bot.constants import Emojis +from bot.constants import Colours, Emojis log = logging.getLogger(__name__) @@ -26,12 +28,35 @@ UWU_WORDS = { } +def caesar_cipher(text: str, offset: int) -> Iterable[str]: + """ + Implements a lazy Caesar Cipher algorithm. + + Encrypts a `text` given a specific integer `offset`. The sign + of the `offset` dictates the direction in which it shifts to, + with a negative value shifting to the left, and a positive + value shifting to the right. + """ + for char in text: + if not char.isascii() or not char.isalpha() or char.isspace(): + yield char + continue + + case_start = 65 if char.isupper() else 97 + true_offset = (ord(char) - case_start + offset) % 26 + + yield chr(case_start + true_offset) + + class Fun(Cog): """A collection of general commands for fun.""" def __init__(self, bot: Bot) -> None: self.bot = bot + with Path("bot/resources/evergreen/caesar_info.json").open("r", encoding="UTF-8") as f: + self._caesar_cipher_embed = json.load(f) + @commands.command() async def roll(self, ctx: Context, num_rolls: int = 1) -> None: """Outputs a number of random dice emotes (up to 6).""" @@ -41,17 +66,13 @@ class Fun(Cog): elif num_rolls < 1: output = ":no_entry: You must roll at least once." for _ in range(num_rolls): - terning = f"terning{random.randint(1, 6)}" - output += getattr(Emojis, terning, '') + dice = f"dice_{random.randint(1, 6)}" + output += getattr(Emojis, dice, '') await ctx.send(output) @commands.command(name="uwu", aliases=("uwuwize", "uwuify",)) - async def uwu_command(self, ctx: Context, *, text: str) -> None: - """ - Converts a given `text` into it's uwu equivalent. - - Also accepts a valid discord Message ID or link. - """ + async def uwu_command(self, ctx: Context, *, text: clean_content(fix_channel_mentions=True)) -> None: + """Converts a given `text` into it's uwu equivalent.""" conversion_func = functools.partial( utils.replace_many, replacements=UWU_WORDS, ignore_case=True, match_case=True ) @@ -66,12 +87,8 @@ class Fun(Cog): await ctx.send(content=converted_text, embed=embed) @commands.command(name="randomcase", aliases=("rcase", "randomcaps", "rcaps",)) - async def randomcase_command(self, ctx: Context, *, text: str) -> None: - """ - Randomly converts the casing of a given `text`. - - Also accepts a valid discord Message ID or link. - """ + async def randomcase_command(self, ctx: Context, *, text: clean_content(fix_channel_mentions=True)) -> None: + """Randomly converts the casing of a given `text`.""" def conversion_func(text: str) -> str: """Randomly converts the casing of a given string.""" return "".join( @@ -87,22 +104,100 @@ class Fun(Cog): converted_text = f">>> {converted_text.lstrip('> ')}" await ctx.send(content=converted_text, embed=embed) + @commands.group(name="caesarcipher", aliases=("caesar", "cc",)) + async def caesarcipher_group(self, ctx: Context) -> None: + """ + Translates a message using the Caesar Cipher. + + See `decrypt`, `encrypt`, and `info` subcommands. + """ + if ctx.invoked_subcommand is None: + await ctx.invoke(self.bot.get_command("help"), "caesarcipher") + + @caesarcipher_group.command(name="info") + async def caesarcipher_info(self, ctx: Context) -> None: + """Information about the Caesar Cipher.""" + embed = Embed.from_dict(self._caesar_cipher_embed) + embed.colour = Colours.dark_green + + await ctx.send(embed=embed) + + @staticmethod + async def _caesar_cipher(ctx: Context, offset: int, msg: str, left_shift: bool = False) -> None: + """ + Given a positive integer `offset`, translates and sends the given `msg`. + + Performs a right shift by default unless `left_shift` is specified as `True`. + + Also accepts a valid Discord Message ID or link. + """ + if offset < 0: + await ctx.send(":no_entry: Cannot use a negative offset.") + return + + if left_shift: + offset = -offset + + def conversion_func(text: str) -> str: + """Encrypts the given string using the Caesar Cipher.""" + return "".join(caesar_cipher(text, offset)) + + text, embed = await Fun._get_text_and_embed(ctx, msg) + + if embed is not None: + embed = Fun._convert_embed(conversion_func, embed) + + converted_text = conversion_func(text) + + if converted_text: + converted_text = f">>> {converted_text.lstrip('> ')}" + + await ctx.send(content=converted_text, embed=embed) + + @caesarcipher_group.command(name="encrypt", aliases=("rightshift", "rshift", "enc",)) + async def caesarcipher_encrypt(self, ctx: Context, offset: int, *, msg: str) -> None: + """ + Given a positive integer `offset`, encrypt the given `msg`. + + Performs a right shift of the letters in the message. + + Also accepts a valid Discord Message ID or link. + """ + await self._caesar_cipher(ctx, offset, msg, left_shift=False) + + @caesarcipher_group.command(name="decrypt", aliases=("leftshift", "lshift", "dec",)) + async def caesarcipher_decrypt(self, ctx: Context, offset: int, *, msg: str) -> None: + """ + Given a positive integer `offset`, decrypt the given `msg`. + + Performs a left shift of the letters in the message. + + Also accepts a valid Discord Message ID or link. + """ + await self._caesar_cipher(ctx, offset, msg, left_shift=True) + @staticmethod async def _get_text_and_embed(ctx: Context, text: str) -> Tuple[str, Union[Embed, None]]: """ Attempts to extract the text and embed from a possible link to a discord Message. + Does not retrieve the text and embed from the Message if it is in a channel the user does + not have read permissions in. + Returns a tuple of: str: If `text` is a valid discord Message, the contents of the message, else `text`. Union[Embed, None]: The embed if found in the valid Message, else None """ embed = None - message = await Fun._get_discord_message(ctx, text) - if isinstance(message, Message): - text = message.content + + msg = await Fun._get_discord_message(ctx, text) + # Ensure the user has read permissions for the channel the message is in + if isinstance(msg, Message) and ctx.author.permissions_in(msg.channel).read_messages: + text = msg.clean_content # Take first embed because we can't send multiple embeds - if message.embeds: - embed = message.embeds[0] + if msg.embeds: + embed = msg.embeds[0] + return (text, embed) @staticmethod diff --git a/bot/exts/evergreen/issues.py b/bot/exts/evergreen/issues.py index 4129156a..5a5c82e7 100644 --- a/bot/exts/evergreen/issues.py +++ b/bot/exts/evergreen/issues.py @@ -1,9 +1,10 @@ import logging +import random import discord from discord.ext import commands -from bot.constants import Colours, Emojis, WHITELISTED_CHANNELS +from bot.constants import Channels, Colours, ERROR_REPLIES, Emojis, Tokens, WHITELISTED_CHANNELS from bot.utils.decorators import override_in_channel log = logging.getLogger(__name__) @@ -13,6 +14,12 @@ BAD_RESPONSE = { 403: "Rate limit has been hit! Please try again later!" } +MAX_REQUESTS = 10 + +REQUEST_HEADERS = dict() +if GITHUB_TOKEN := Tokens.github: + REQUEST_HEADERS["Authorization"] = f"token {GITHUB_TOKEN}" + class Issues(commands.Cog): """Cog that allows users to retrieve issues from GitHub.""" @@ -21,53 +28,79 @@ class Issues(commands.Cog): self.bot = bot @commands.command(aliases=("pr",)) - @override_in_channel(WHITELISTED_CHANNELS) + @override_in_channel(WHITELISTED_CHANNELS + (Channels.dev_contrib, Channels.dev_branding)) async def issue( - self, ctx: commands.Context, number: int, repository: str = "seasonalbot", user: str = "python-discord" + self, + ctx: commands.Context, + numbers: commands.Greedy[int], + repository: str = "seasonalbot", + user: str = "python-discord" ) -> None: - """Command to retrieve issues from a GitHub repository.""" - url = f"https://api.github.com/repos/{user}/{repository}/issues/{number}" - merge_url = f"https://api.github.com/repos/{user}/{repository}/pulls/{number}/merge" - - log.trace(f"Querying GH issues API: {url}") - async with self.bot.http_session.get(url) as r: - json_data = await r.json() - - if r.status in BAD_RESPONSE: - log.warning(f"Received response {r.status} from: {url}") - return await ctx.send(f"[{str(r.status)}] {BAD_RESPONSE.get(r.status)}") - - # The initial API request is made to the issues API endpoint, which will return information - # if the issue or PR is present. However, the scope of information returned for PRs differs - # from issues: if the 'issues' key is present in the response then we can pull the data we - # need from the initial API call. - if "issues" in json_data.get("html_url"): - if json_data.get("state") == "open": - icon_url = Emojis.issue - else: - icon_url = Emojis.issue_closed - - # If the 'issues' key is not contained in the API response and there is no error code, then - # we know that a PR has been requested and a call to the pulls API endpoint is necessary - # to get the desired information for the PR. - else: - log.trace(f"PR provided, querying GH pulls API for additional information: {merge_url}") - async with self.bot.http_session.get(merge_url) as m: + """Command to retrieve issue(s) from a GitHub repository.""" + links = [] + numbers = set(numbers) + + if not numbers: + await ctx.invoke(self.bot.get_command('help'), 'issue') + return + + if len(numbers) > MAX_REQUESTS: + embed = discord.Embed( + title=random.choice(ERROR_REPLIES), + color=Colours.soft_red, + description=f"Too many issues/PRs! (maximum of {MAX_REQUESTS})" + ) + await ctx.send(embed=embed) + return + + for number in set(numbers): + # Convert from list to set to remove duplicates, if any. + url = f"https://api.github.com/repos/{user}/{repository}/issues/{number}" + merge_url = f"https://api.github.com/repos/{user}/{repository}/pulls/{number}/merge" + + log.trace(f"Querying GH issues API: {url}") + async with self.bot.http_session.get(url, headers=REQUEST_HEADERS) as r: + json_data = await r.json() + + if r.status in BAD_RESPONSE: + log.warning(f"Received response {r.status} from: {url}") + return await ctx.send(f"[{str(r.status)}] #{number} {BAD_RESPONSE.get(r.status)}") + + # The initial API request is made to the issues API endpoint, which will return information + # if the issue or PR is present. However, the scope of information returned for PRs differs + # from issues: if the 'issues' key is present in the response then we can pull the data we + # need from the initial API call. + if "issues" in json_data.get("html_url"): if json_data.get("state") == "open": - icon_url = Emojis.pull_request - # When the status is 204 this means that the state of the PR is merged - elif m.status == 204: - icon_url = Emojis.merge + icon_url = Emojis.issue else: - icon_url = Emojis.pull_request_closed + icon_url = Emojis.issue_closed + + # If the 'issues' key is not contained in the API response and there is no error code, then + # we know that a PR has been requested and a call to the pulls API endpoint is necessary + # to get the desired information for the PR. + else: + log.trace(f"PR provided, querying GH pulls API for additional information: {merge_url}") + async with self.bot.http_session.get(merge_url) as m: + if json_data.get("state") == "open": + icon_url = Emojis.pull_request + # When the status is 204 this means that the state of the PR is merged + elif m.status == 204: + icon_url = Emojis.merge + else: + icon_url = Emojis.pull_request_closed + + issue_url = json_data.get("html_url") + links.append([icon_url, f"[{repository}] #{number} {json_data.get('title')}", issue_url]) - issue_url = json_data.get("html_url") - description_text = f"[{repository}] #{number} {json_data.get('title')}" + # Issue/PR format: emoji to show if open/closed/merged, number and the title as a singular link. + description_list = ["{0} [{1}]({2})".format(*link) for link in links] resp = discord.Embed( colour=Colours.bright_green, - description=f"{icon_url} [{description_text}]({issue_url})" + description='\n'.join(description_list) ) - resp.set_author(name="GitHub", url=issue_url) + + resp.set_author(name="GitHub", url=f"https://github.com/{user}/{repository}") await ctx.send(embed=resp) diff --git a/bot/exts/evergreen/magic_8ball.py b/bot/exts/evergreen/magic_8ball.py index c10f1f51..f974e487 100644 --- a/bot/exts/evergreen/magic_8ball.py +++ b/bot/exts/evergreen/magic_8ball.py @@ -13,7 +13,7 @@ class Magic8ball(commands.Cog): def __init__(self, bot: commands.Bot): self.bot = bot - with open(Path("bot/resources/evergreen/magic8ball.json"), "r") as file: + with open(Path("bot/resources/evergreen/magic8ball.json"), "r", encoding="utf8") as file: self.answers = json.load(file) @commands.command(name="8ball") diff --git a/bot/exts/evergreen/minesweeper.py b/bot/exts/evergreen/minesweeper.py index ae057b30..3e40f493 100644 --- a/bot/exts/evergreen/minesweeper.py +++ b/bot/exts/evergreen/minesweeper.py @@ -141,9 +141,20 @@ class Minesweeper(commands.Cog): await ctx.message.delete(delay=2) return + try: + await ctx.author.send( + f"Play by typing: `{Client.prefix}ms reveal xy [xy]` or `{Client.prefix}ms flag xy [xy]` \n" + f"Close the game with `{Client.prefix}ms end`\n" + ) + except discord.errors.Forbidden: + log.debug(f"{ctx.author.name} ({ctx.author.id}) has disabled DMs from server members") + await ctx.send(f":x: {ctx.author.mention}, please enable DMs to play minesweeper.") + return + # Add game to list board: GameBoard = self.generate_board(bomb_chance) revealed_board: GameBoard = [["hidden"] * 10 for _ in range(10)] + dm_msg = await ctx.author.send(f"Here's your board!\n{self.format_for_discord(revealed_board)}") if ctx.guild: await ctx.send(f"{ctx.author.mention} is playing Minesweeper") @@ -151,12 +162,6 @@ class Minesweeper(commands.Cog): else: chat_msg = None - await ctx.author.send( - f"Play by typing: `{Client.prefix}ms reveal xy [xy]` or `{Client.prefix}ms flag xy [xy]` \n" - f"Close the game with `{Client.prefix}ms end`\n" - ) - dm_msg = await ctx.author.send(f"Here's your board!\n{self.format_for_discord(revealed_board)}") - self.games[ctx.author.id] = Game( board=board, revealed=revealed_board, diff --git a/bot/exts/evergreen/recommend_game.py b/bot/exts/evergreen/recommend_game.py index 7cd52c2c..5e262a5b 100644 --- a/bot/exts/evergreen/recommend_game.py +++ b/bot/exts/evergreen/recommend_game.py @@ -11,7 +11,7 @@ game_recs = [] # Populate the list `game_recs` with resource files for rec_path in Path("bot/resources/evergreen/game_recs").glob("*.json"): - with rec_path.open(encoding='utf-8') as file: + with rec_path.open(encoding='utf8') as file: data = json.load(file) game_recs.append(data) shuffle(game_recs) diff --git a/bot/exts/evergreen/reddit.py b/bot/exts/evergreen/reddit.py index fe204419..49127bea 100644 --- a/bot/exts/evergreen/reddit.py +++ b/bot/exts/evergreen/reddit.py @@ -68,9 +68,9 @@ class Reddit(commands.Cog): # ----------------------------------------------------------- # This code below is bound of change when the emojis are added. - upvote_emoji = self.bot.get_emoji(638729835245731840) - comment_emoji = self.bot.get_emoji(638729835073765387) - user_emoji = self.bot.get_emoji(638729835442602003) + upvote_emoji = self.bot.get_emoji(755845219890757644) + comment_emoji = self.bot.get_emoji(755845255001014384) + user_emoji = self.bot.get_emoji(755845303822974997) text_emoji = self.bot.get_emoji(676030265910493204) video_emoji = self.bot.get_emoji(676030265839190047) image_emoji = self.bot.get_emoji(676030265734201344) diff --git a/bot/exts/evergreen/snakes/converter.py b/bot/exts/evergreen/snakes/converter.py index d4e93b56..55609b8e 100644 --- a/bot/exts/evergreen/snakes/converter.py +++ b/bot/exts/evergreen/snakes/converter.py @@ -63,12 +63,12 @@ class Snake(Converter): """Build list of snakes from the static snake resources.""" # Get all the snakes if cls.snakes is None: - with (SNAKE_RESOURCES / "snake_names.json").open() as snakefile: + with (SNAKE_RESOURCES / "snake_names.json").open(encoding="utf8") as snakefile: cls.snakes = json.load(snakefile) # Get the special cases if cls.special_cases is None: - with (SNAKE_RESOURCES / "special_snakes.json").open() as snakefile: + with (SNAKE_RESOURCES / "special_snakes.json").open(encoding="utf8") as snakefile: special_cases = json.load(snakefile) cls.special_cases = {snake['name'].lower(): snake for snake in special_cases} diff --git a/bot/exts/evergreen/snakes/snakes_cog.py b/bot/exts/evergreen/snakes/snakes_cog.py index 36c176ce..9bbad9fe 100644 --- a/bot/exts/evergreen/snakes/snakes_cog.py +++ b/bot/exts/evergreen/snakes/snakes_cog.py @@ -567,7 +567,7 @@ class Snakes(Cog): antidote_embed = Embed(color=SNAKE_COLOR, title="Antidote") antidote_embed.set_author(name=ctx.author.name, icon_url=ctx.author.avatar_url) antidote_embed.set_image(url="https://i.makeagif.com/media/7-12-2015/Cj1pts.gif") - antidote_embed.add_field(name=f"You have created the snake antidote!", + antidote_embed.add_field(name="You have created the snake antidote!", value=f"The solution was: {' '.join(antidote_answer)}\n" f"You had {10 - antidote_tries} tries remaining.") await board_id.edit(embed=antidote_embed) @@ -945,13 +945,15 @@ class Snakes(Cog): title="About the snake cog", description=( "The features in this cog were created by members of the community " - "during our first ever [code jam event](https://gitlab.com/discord-python/code-jams/code-jam-1). \n\n" + "during our first ever " + "[code jam event](https://pythondiscord.com/pages/code-jams/code-jam-1-snakes-bot/). \n\n" "The event saw over 50 participants, who competed to write a discord bot cog with a snake theme over " "48 hours. The staff then selected the best features from all the best teams, and made modifications " "to ensure they would all work together before integrating them into the community bot.\n\n" "It was a tight race, but in the end, <@!104749643715387392> and <@!303940835005825024> " - "walked away as grand champions. Make sure you check out `!snakes sal`, `!snakes draw` " - "and `!snakes hatch` to see what they came up with." + f"walked away as grand champions. Make sure you check out `{ctx.prefix}snakes sal`," + f"`{ctx.prefix}snakes draw` and `{ctx.prefix}snakes hatch` " + "to see what they came up with." ) ) @@ -1076,7 +1078,7 @@ class Snakes(Cog): query = snake['name'] # Build the URL and make the request - url = f'https://www.googleapis.com/youtube/v3/search' + url = 'https://www.googleapis.com/youtube/v3/search' response = await self.bot.http_session.get( url, params={ diff --git a/bot/exts/evergreen/speedrun.py b/bot/exts/evergreen/speedrun.py index 4e8d7aee..21aad5aa 100644 --- a/bot/exts/evergreen/speedrun.py +++ b/bot/exts/evergreen/speedrun.py @@ -6,7 +6,7 @@ from random import choice from discord.ext import commands log = logging.getLogger(__name__) -with Path('bot/resources/evergreen/speedrun_links.json').open(encoding="utf-8") as file: +with Path('bot/resources/evergreen/speedrun_links.json').open(encoding="utf8") as file: LINKS = json.load(file) diff --git a/bot/exts/evergreen/status_cats.py b/bot/exts/evergreen/status_cats.py new file mode 100644 index 00000000..586b8378 --- /dev/null +++ b/bot/exts/evergreen/status_cats.py @@ -0,0 +1,33 @@ +from http import HTTPStatus + +import discord +from discord.ext import commands + + +class StatusCats(commands.Cog): + """Commands that give HTTP statuses described and visualized by cats.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + @commands.command(aliases=['statuscat']) + async def http_cat(self, ctx: commands.Context, code: int) -> None: + """Sends an embed with an image of a cat, potraying the status code.""" + embed = discord.Embed(title=f'**Status: {code}**') + + try: + HTTPStatus(code) + + except ValueError: + embed.set_footer(text='Inputted status code does not exist.') + + else: + embed.set_image(url=f'https://http.cat/{code}.jpg') + + finally: + await ctx.send(embed=embed) + + +def setup(bot: commands.Bot) -> None: + """Load the StatusCats cog.""" + bot.add_cog(StatusCats(bot)) diff --git a/bot/exts/evergreen/trivia_quiz.py b/bot/exts/evergreen/trivia_quiz.py index c1a271e8..8dceceac 100644 --- a/bot/exts/evergreen/trivia_quiz.py +++ b/bot/exts/evergreen/trivia_quiz.py @@ -40,7 +40,7 @@ class TriviaQuiz(commands.Cog): def load_questions() -> dict: """Load the questions from the JSON file.""" p = Path("bot", "resources", "evergreen", "trivia_quiz.json") - with p.open() as json_data: + with p.open(encoding="utf8") as json_data: questions = json.load(json_data) return questions diff --git a/bot/exts/evergreen/wikipedia.py b/bot/exts/evergreen/wikipedia.py new file mode 100644 index 00000000..be36e2c4 --- /dev/null +++ b/bot/exts/evergreen/wikipedia.py @@ -0,0 +1,114 @@ +import asyncio +import datetime +import logging +from typing import List, Optional + +from aiohttp import client_exceptions +from discord import Color, Embed, Message +from discord.ext import commands + +from bot.constants import Wikipedia + +log = logging.getLogger(__name__) + +SEARCH_API = "https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch={search_term}&format=json" +WIKIPEDIA_URL = "https://en.wikipedia.org/wiki/{title}" + + +class WikipediaSearch(commands.Cog): + """Get info from wikipedia.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + self.http_session = bot.http_session + + @staticmethod + def formatted_wiki_url(index: int, title: str) -> str: + """Formating wikipedia link with index and title.""" + return f'`{index}` [{title}]({WIKIPEDIA_URL.format(title=title.replace(" ", "_"))})' + + async def search_wikipedia(self, search_term: str) -> Optional[List[str]]: + """Search wikipedia and return the first 10 pages found.""" + pages = [] + async with self.http_session.get(SEARCH_API.format(search_term=search_term)) as response: + try: + data = await response.json() + + search_results = data["query"]["search"] + + # Ignore pages with "may refer to" + for search_result in search_results: + log.info("trying to append titles") + if "may refer to" not in search_result["snippet"]: + pages.append(search_result["title"]) + except client_exceptions.ContentTypeError: + pages = None + + log.info("Finished appending titles") + return pages + + @commands.cooldown(1, 10, commands.BucketType.user) + @commands.command(name="wikipedia", aliases=["wiki"]) + async def wikipedia_search_command(self, ctx: commands.Context, *, search: str) -> None: + """Return list of results containing your search query from wikipedia.""" + titles = await self.search_wikipedia(search) + + def check(message: Message) -> bool: + return message.author.id == ctx.author.id and message.channel == ctx.channel + + if not titles: + await ctx.send("Sorry, we could not find a wikipedia article using that search term") + return + + async with ctx.typing(): + log.info("Finished appending titles to titles_no_underscore list") + + s_desc = "\n".join(self.formatted_wiki_url(index, title) for index, title in enumerate(titles, start=1)) + embed = Embed(colour=Color.blue(), title=f"Wikipedia results for `{search}`", description=s_desc) + embed.timestamp = datetime.datetime.utcnow() + await ctx.send(embed=embed) + embed = Embed(colour=Color.green(), description="Enter number to choose") + msg = await ctx.send(embed=embed) + titles_len = len(titles) # getting length of list + + for retry_count in range(1, Wikipedia.total_chance + 1): + retries_left = Wikipedia.total_chance - retry_count + if retry_count < Wikipedia.total_chance: + error_msg = f"You have `{retries_left}/{Wikipedia.total_chance}` chances left" + else: + error_msg = 'Please try again by using `.wiki` command' + try: + message = await ctx.bot.wait_for('message', timeout=60.0, check=check) + response_from_user = await self.bot.get_context(message) + + if response_from_user.command: + return + + response = int(message.content) + if response < 0: + await ctx.send(f"Sorry, but you can't give negative index, {error_msg}") + elif response == 0: + await ctx.send(f"Sorry, please give an integer between `1` to `{titles_len}`, {error_msg}") + else: + await ctx.send(WIKIPEDIA_URL.format(title=titles[response - 1].replace(" ", "_"))) + break + + except asyncio.TimeoutError: + embed = Embed(colour=Color.red(), description=f"Time's up {ctx.author.mention}") + await msg.edit(embed=embed) + break + + except ValueError: + await ctx.send(f"Sorry, but you cannot do that, I will only accept an positive integer, {error_msg}") + + except IndexError: + await ctx.send(f"Sorry, please give an integer between `1` to `{titles_len}`, {error_msg}") + + except Exception as e: + log.info(f"Caught exception {e}, breaking out of retry loop") + break + + +def setup(bot: commands.Bot) -> None: + """Wikipedia Cog load.""" + bot.add_cog(WikipediaSearch(bot)) diff --git a/bot/exts/evergreen/wolfram.py b/bot/exts/evergreen/wolfram.py new file mode 100644 index 00000000..898e8d2a --- /dev/null +++ b/bot/exts/evergreen/wolfram.py @@ -0,0 +1,278 @@ +import logging +from io import BytesIO +from typing import Callable, List, Optional, Tuple +from urllib import parse + +import arrow +import discord +from discord import Embed +from discord.ext import commands +from discord.ext.commands import BucketType, Cog, Context, check, group + +from bot.constants import Colours, STAFF_ROLES, Wolfram +from bot.utils.pagination import ImagePaginator + +log = logging.getLogger(__name__) + +APPID = Wolfram.key +DEFAULT_OUTPUT_FORMAT = "JSON" +QUERY = "http://api.wolframalpha.com/v2/{request}?{data}" +WOLF_IMAGE = "https://www.symbols.com/gi.php?type=1&id=2886&i=1" + +MAX_PODS = 20 + +# Allows for 10 wolfram calls pr user pr day +usercd = commands.CooldownMapping.from_cooldown(Wolfram.user_limit_day, 60 * 60 * 24, BucketType.user) + +# Allows for max api requests / days in month per day for the entire guild (Temporary) +guildcd = commands.CooldownMapping.from_cooldown(Wolfram.guild_limit_day, 60 * 60 * 24, BucketType.guild) + + +async def send_embed( + ctx: Context, + message_txt: str, + colour: int = Colours.soft_red, + footer: str = None, + img_url: str = None, + f: discord.File = None +) -> None: + """Generate & send a response embed with Wolfram as the author.""" + embed = Embed(colour=colour) + embed.description = message_txt + embed.set_author(name="Wolfram Alpha", + icon_url=WOLF_IMAGE, + url="https://www.wolframalpha.com/") + if footer: + embed.set_footer(text=footer) + + if img_url: + embed.set_image(url=img_url) + + await ctx.send(embed=embed, file=f) + + +def custom_cooldown(*ignore: List[int]) -> Callable: + """ + Implement per-user and per-guild cooldowns for requests to the Wolfram API. + + A list of roles may be provided to ignore the per-user cooldown + """ + async def predicate(ctx: Context) -> bool: + if ctx.invoked_with == 'help': + # if the invoked command is help we don't want to increase the ratelimits since it's not actually + # invoking the command/making a request, so instead just check if the user/guild are on cooldown. + guild_cooldown = not guildcd.get_bucket(ctx.message).get_tokens() == 0 # if guild is on cooldown + if not any(r.id in ignore for r in ctx.author.roles): # check user bucket if user is not ignored + return guild_cooldown and not usercd.get_bucket(ctx.message).get_tokens() == 0 + return guild_cooldown + + user_bucket = usercd.get_bucket(ctx.message) + + if all(role.id not in ignore for role in ctx.author.roles): + user_rate = user_bucket.update_rate_limit() + + if user_rate: + # Can't use api; cause: member limit + cooldown = arrow.utcnow().shift(seconds=int(user_rate)).humanize(only_distance=True) + message = ( + "You've used up your limit for Wolfram|Alpha requests.\n" + f"Cooldown: {cooldown}" + ) + await send_embed(ctx, message) + return False + + guild_bucket = guildcd.get_bucket(ctx.message) + guild_rate = guild_bucket.update_rate_limit() + + # Repr has a token attribute to read requests left + log.debug(guild_bucket) + + if guild_rate: + # Can't use api; cause: guild limit + message = ( + "The max limit of requests for the server has been reached for today.\n" + f"Cooldown: {int(guild_rate)}" + ) + await send_embed(ctx, message) + return False + + return True + + return check(predicate) + + +async def get_pod_pages(ctx: Context, bot: commands.Bot, query: str) -> Optional[List[Tuple]]: + """Get the Wolfram API pod pages for the provided query.""" + async with ctx.channel.typing(): + url_str = parse.urlencode({ + "input": query, + "appid": APPID, + "output": DEFAULT_OUTPUT_FORMAT, + "format": "image,plaintext" + }) + request_url = QUERY.format(request="query", data=url_str) + + async with bot.http_session.get(request_url) as response: + json = await response.json(content_type='text/plain') + + result = json["queryresult"] + + 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}" + ) + 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}") + await send_embed(ctx, message) + return + + if not result["success"]: + message = f"I couldn't find anything for {query}." + await send_embed(ctx, message) + return + + if not result["numpods"]: + message = "Could not find any results." + await send_embed(ctx, message) + return + + pods = result["pods"] + pages = [] + for pod in pods[:MAX_PODS]: + subs = pod.get("subpods") + + for sub in subs: + title = sub.get("title") or sub.get("plaintext") or sub.get("id", "") + img = sub["img"]["src"] + pages.append((title, img)) + return pages + + +class Wolfram(Cog): + """Commands for interacting with the Wolfram|Alpha API.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + @group(name="wolfram", aliases=("wolf", "wa"), invoke_without_command=True) + @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({ + "i": query, + "appid": APPID, + }) + query = QUERY.format(request="simple", data=url_str) + + # Give feedback that the bot is working. + async with ctx.channel.typing(): + async with self.bot.http_session.get(query) as response: + status = response.status + image_bytes = await response.read() + + f = discord.File(BytesIO(image_bytes), filename="image.png") + image_url = "attachment://image.png" + + if status == 501: + message = "Failed to get response" + footer = "" + color = Colours.soft_red + elif status == 400: + message = "No input found" + footer = "" + color = Colours.soft_red + elif status == 403: + message = "Wolfram API key is invalid or missing." + footer = "" + color = Colours.soft_red + else: + message = "" + footer = "View original for a bigger picture." + color = Colours.soft_orange + + # Sends a "blank" embed if no request is received, unsure how to fix + await send_embed(ctx, message, color, footer=footer, img_url=image_url, f=f) + + @wolfram_command.command(name="page", aliases=("pa", "p")) + @custom_cooldown(*STAFF_ROLES) + async def wolfram_page_command(self, ctx: Context, *, query: str) -> None: + """ + Requests a drawn image of given query. + + Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc. + """ + pages = await get_pod_pages(ctx, self.bot, query) + + if not pages: + return + + embed = Embed() + embed.set_author(name="Wolfram Alpha", + icon_url=WOLF_IMAGE, + url="https://www.wolframalpha.com/") + embed.colour = Colours.soft_orange + + await ImagePaginator.paginate(pages, ctx, embed) + + @wolfram_command.command(name="cut", aliases=("c",)) + @custom_cooldown(*STAFF_ROLES) + async def wolfram_cut_command(self, ctx: Context, *, query: str) -> None: + """ + Requests a drawn image of given query. + + Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc. + """ + pages = await get_pod_pages(ctx, self.bot, query) + + if not pages: + return + + if len(pages) >= 2: + page = pages[1] + else: + page = pages[0] + + await send_embed(ctx, page[0], colour=Colours.soft_orange, img_url=page[1]) + + @wolfram_command.command(name="short", aliases=("sh", "s")) + @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({ + "i": query, + "appid": APPID, + }) + query = QUERY.format(request="result", data=url_str) + + # Give feedback that the bot is working. + async with ctx.channel.typing(): + async with self.bot.http_session.get(query) as response: + status = response.status + response_text = await response.text() + + if status == 501: + message = "Failed to get response" + color = Colours.soft_red + elif status == 400: + message = "No input found" + color = Colours.soft_red + elif response_text == "Error 1: Invalid appid": + message = "Wolfram API key is invalid or missing." + color = Colours.soft_red + else: + message = response_text + color = Colours.soft_orange + + await send_embed(ctx, message, color) + + +def setup(bot: commands.Bot) -> None: + """Load the Wolfram cog.""" + bot.add_cog(Wolfram(bot)) |