From 70da77675792d7d9f8212eb44f191e42d80965ae Mon Sep 17 00:00:00 2001 From: Epsilon10 Date: Fri, 23 Mar 2018 17:45:12 +0530 Subject: [PATCH 1/4] deps --- .DS_Store | Bin 0 -> 6148 bytes .gitignore | 104 +++++++++++ .travis.yml | 26 +++ LICENSE | 21 +++ Pipfile | 25 +++ Pipfile.lock | 435 +++++++++++++++++++++++++++++++++++++++++++ README.md | 69 +++++++ bot/__init__.py | 164 ++++++++++++++++ bot/cogs/__init__.py | 1 + bot/cogs/logging.py | 23 +++ bot/cogs/security.py | 24 +++ bot/cogs/snakes.py | 49 +++++ bot/constants.py | 27 +++ bot/decorators.py | 49 +++++ bot/formatter.py | 128 +++++++++++++ bot/pagination.py | 270 +++++++++++++++++++++++++++ bot/utils.py | 47 +++++ doc/README.md | 13 ++ doc/bot-setup.md | 94 ++++++++++ doc/linting.md | 54 ++++++ doc/pipenv.md | 56 ++++++ run.py | 40 ++++ tox.ini | 4 + 23 files changed, 1723 insertions(+) create mode 100644 .DS_Store create mode 100755 .gitignore create mode 100755 .travis.yml create mode 100755 LICENSE create mode 100755 Pipfile create mode 100644 Pipfile.lock create mode 100755 README.md create mode 100755 bot/__init__.py create mode 100755 bot/cogs/__init__.py create mode 100755 bot/cogs/logging.py create mode 100755 bot/cogs/security.py create mode 100755 bot/cogs/snakes.py create mode 100755 bot/constants.py create mode 100755 bot/decorators.py create mode 100755 bot/formatter.py create mode 100755 bot/pagination.py create mode 100755 bot/utils.py create mode 100755 doc/README.md create mode 100755 doc/bot-setup.md create mode 100755 doc/linting.md create mode 100755 doc/pipenv.md create mode 100755 run.py create mode 100755 tox.ini diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..36e361aa6658223d1ff6a66e89a61446c8f32836 GIT binary patch literal 6148 zcmeHKO>Wab7=6=(#(^TTKx&uXAhD>bgjRHckW88^A_1x}f(4*9t^>8|+EL;VN(l0X zI{-J}D4c+UZ~%Do1BzYPR#7#dH1ochdEa>EjmP5wAlj3#1vCISOcJYCnf=3LyG-Y7 z#nh}3h3q3e3gR?iNp@$=Dqs~@b_L|#tsp>(tQQyd?>)BM{-sP!!mxtrcxZO{_h1-D z-U3a(O`PexVe`m zSwBh!qXqiQ=TQ=-({?&Z!m;XSzn>j5Rz2vTu`!$7*==sRt?ixpraRk}{?7K@`P^~V zZ?*3C4o}bC&pymQe$lHS_>od}!{8tIO22$MDfDN+i}x64kJe2t&MP9%E2qe=4!pifn8#hRcAHQryo{Tpdq1 zo(JgQ5pfHr2#XPrZ}sPew|W&LOpuT}#94_emwAy}L&j0OBR671RAj3eFR~fD6J1$B^bF=2Swj;#l&C|6 zxnc-&cMv+9?VjQ18g)2?dNTVlPZs8eBFxo8xQY(pX|%ajz$#EwprVTndH#3*fB!EA z*^*VjDsZV35Y>*~X>&^EY&|zQdDgm2hfLBWFW0C-n3>~PIr1ppU{c05S2{$`V6Krh QH2Wi bool: + """ + Our version of the skip_string method from + discord.ext.commands.view; used to find + the prefix in a message, but allowing prefix + to ignore case sensitivity + """ + + strlen = len(string) + if self.buffer.lower()[self.index:self.index + strlen] == string: + self.previous = self.index + self.index += strlen + return True + return False + + +def _get_word(self) -> str: + """ + Invokes the get_word method from + discord.ext.commands.view used to find + the bot command part of a message, but + allows the command to ignore case sensitivity, + and allows commands to have Python syntax. + + Example of valid Python syntax calls: + ------------------------------ + bot.tags.set("test", 'a dark, dark night') + bot.help(tags.delete) + bot.hELP(tags.delete) + """ + + pos = 0 + while not self.eof: + try: + current = self.buffer[self.index + pos] + if current.isspace() or current == "(": + break + pos += 1 + except IndexError: + break + + self.previous = self.index + result = self.buffer[self.index:self.index + pos] + self.index += pos + next = None + + # Check what's after the '(' + if len(self.buffer) != self.index: + next = self.buffer[self.index + 1] + + # Is it possible to parse this without syntax error? + syntax_valid = True + try: + ast.literal_eval(self.buffer[self.index:]) + except SyntaxError: + log.warning("The command cannot be parsed by ast.literal_eval because it raises a SyntaxError.") + # TODO: It would be nice if this actually made the bot return a SyntaxError. ClickUp #1b12z # noqa: T000 + syntax_valid = False + + # Conditions for a valid, parsable command. + python_parse_conditions = ( + current == "(" + and next + and next != ")" + and syntax_valid + ) + + if python_parse_conditions: + log.debug(f"A python-style command was used. Attempting to parse. Buffer is {self.buffer}. " + "A step-by-step can be found in the trace log.") + + # Parse the args + log.trace("Parsing command with ast.literal_eval.") + args = self.buffer[self.index:] + args = ast.literal_eval(args) + + # Force args into container + if isinstance(args, str): + args = (args,) + + # Type validate and format + new_args = [] + for arg in args: + + # Other types get converted to strings + if not isinstance(arg, str): + log.trace(f"{arg} is not a str, casting to str.") + arg = str(arg) + + # Adding double quotes to every argument + log.trace(f"Wrapping all args in double quotes.") + new_args.append(f'"{arg}"') + + # Add the result to the buffer + new_args = " ".join(new_args) + self.buffer = f"{self.buffer[:self.index]} {new_args}" + log.trace(f"Modified the buffer. New buffer is now {self.buffer}") + + # Recalibrate the end since we've removed commas + self.end = len(self.buffer) + + elif current == "(" and next == ")": + # Move the cursor to capture the ()'s + log.debug("User called command without providing arguments.") + pos += 2 + result = self.buffer[self.previous:self.index + (pos+2)] + self.index += 2 + + if isinstance(result, str): + return result.lower() # Case insensitivity, baby + return result + + +# Monkey patch the methods +discord.ext.commands.view.StringView.skip_string = _skip_string +discord.ext.commands.view.StringView.get_word = _get_word diff --git a/bot/cogs/__init__.py b/bot/cogs/__init__.py new file mode 100755 index 00000000..9bad5790 --- /dev/null +++ b/bot/cogs/__init__.py @@ -0,0 +1 @@ +# coding=utf-8 diff --git a/bot/cogs/logging.py b/bot/cogs/logging.py new file mode 100755 index 00000000..5f5cd85c --- /dev/null +++ b/bot/cogs/logging.py @@ -0,0 +1,23 @@ +# coding=utf-8 +import logging + +from discord.ext.commands import AutoShardedBot + +log = logging.getLogger(__name__) + + +class Logging: + """ + Debug logging module + """ + + def __init__(self, bot: AutoShardedBot): + self.bot = bot + + async def on_ready(self): + log.info("Bot connected!") + + +def setup(bot): + bot.add_cog(Logging(bot)) + log.info("Cog loaded: Logging") diff --git a/bot/cogs/security.py b/bot/cogs/security.py new file mode 100755 index 00000000..7b4cf319 --- /dev/null +++ b/bot/cogs/security.py @@ -0,0 +1,24 @@ +# coding=utf-8 +import logging + +from discord.ext.commands import AutoShardedBot, Context + +log = logging.getLogger(__name__) + + +class Security: + """ + Security-related helpers + """ + + def __init__(self, bot: AutoShardedBot): + self.bot = bot + self.bot.check(self.check_not_bot) # Global commands check - no bots can run any commands at all + + def check_not_bot(self, ctx: Context): + return not ctx.author.bot + + +def setup(bot): + bot.add_cog(Security(bot)) + log.info("Cog loaded: Security") diff --git a/bot/cogs/snakes.py b/bot/cogs/snakes.py new file mode 100755 index 00000000..c9ed8042 --- /dev/null +++ b/bot/cogs/snakes.py @@ -0,0 +1,49 @@ +# coding=utf-8 +import logging +from typing import Any, Dict + +from discord.ext.commands import AutoShardedBot, Context, command + +log = logging.getLogger(__name__) + + +class Snakes: + """ + Snake-related commands + """ + + def __init__(self, bot: AutoShardedBot): + self.bot = bot + + async def get_snek(self, name: str = None) -> Dict[str, Any]: + """ + Go online and fetch information about a snake + + The information includes the name of the snake, a picture of the snake, and various other pieces of info. + What information you get for the snake is up to you. Be creative! + + If "python" is given as the snake name, you should return information about the programming language, but with + all the information you'd provide for a real snake. Try to have some fun with this! + + :param name: Optional, the name of the snake to get information for - omit for a random snake + :return: A dict containing information on a snake + """ + + @command() + async def get(self, ctx: Context, name: str = None): + """ + Go online and fetch information about a snake + + This should make use of your `get_snek` method, using it to get information about a snake. This information + should be sent back to Discord in an embed. + + :param ctx: Context object passed from discord.py + :param name: Optional, the name of the snake to get information for - omit for a random snake + """ + + # Any additional commands can be placed here. Be creative, but keep it to a reasonable amount! + + +def setup(bot): + bot.add_cog(Snakes(bot)) + log.info("Cog loaded: Snakes") diff --git a/bot/constants.py b/bot/constants.py new file mode 100755 index 00000000..e272ff8f --- /dev/null +++ b/bot/constants.py @@ -0,0 +1,27 @@ +# coding=utf-8 + +# Channels, servers and roles +PYTHON_GUILD = 267624335836053506 + +BOT_CHANNEL = 267659945086812160 +HELP1_CHANNEL = 303906576991780866 +HELP2_CHANNEL = 303906556754395136 +HELP3_CHANNEL = 303906514266226689 +PYTHON_CHANNEL = 267624335836053506 +DEVLOG_CHANNEL = 409308876241108992 +DEVTEST_CHANNEL = 414574275865870337 +VERIFICATION_CHANNEL = 352442727016693763 +CHECKPOINT_TEST_CHANNEL = 422077681434099723 + +ADMIN_ROLE = 267628507062992896 +MODERATOR_ROLE = 267629731250176001 +VERIFIED_ROLE = 352427296948486144 +OWNER_ROLE = 267627879762755584 +DEVOPS_ROLE = 409416496733880320 + +# URLs +GITHUB_URL_BOT = "https://github.com/discord-python/bot" +BOT_AVATAR_URL = "https://raw.githubusercontent.com/discord-python/branding/master/logos/logo_circle.png" + +# Bot internals +HELP_PREFIX = "bot." diff --git a/bot/decorators.py b/bot/decorators.py new file mode 100755 index 00000000..7009e259 --- /dev/null +++ b/bot/decorators.py @@ -0,0 +1,49 @@ +# coding=utf-8 +import logging + +from discord.ext import commands +from discord.ext.commands import Context + +log = logging.getLogger(__name__) + + +def with_role(*role_ids: int): + async def predicate(ctx: Context): + if not ctx.guild: # Return False in a DM + log.debug(f"{ctx.author} tried to use the '{ctx.command.name}'command from a DM. " + "This command is restricted by the with_role decorator. Rejecting request.") + return False + + for role in ctx.author.roles: + if role.id in role_ids: + log.debug(f"{ctx.author} has the '{role.name}' role, and passes the check.") + return True + + log.debug(f"{ctx.author} does not have the required role to use " + f"the '{ctx.command.name}' command, so the request is rejected.") + return False + return commands.check(predicate) + + +def without_role(*role_ids: int): + async def predicate(ctx: Context): + if not ctx.guild: # Return False in a DM + log.debug(f"{ctx.author} tried to use the '{ctx.command.name}' command from a DM. " + "This command is restricted by the without_role decorator. Rejecting request.") + return False + + author_roles = [role.id for role in ctx.author.roles] + check = all(role not in author_roles for role in role_ids) + log.debug(f"{ctx.author} tried to call the '{ctx.command.name}' command. " + f"The result of the without_role check was {check}.") + return check + return commands.check(predicate) + + +def in_channel(channel_id): + async def predicate(ctx: Context): + check = ctx.channel.id == channel_id + log.debug(f"{ctx.author} tried to call the '{ctx.command.name}' command. " + f"The result of the in_channel check was {check}.") + return check + return commands.check(predicate) diff --git a/bot/formatter.py b/bot/formatter.py new file mode 100755 index 00000000..5b75d6a0 --- /dev/null +++ b/bot/formatter.py @@ -0,0 +1,128 @@ +# coding=utf-8 + +""" +Credit to Rapptz's script used as an example: +https://github.com/Rapptz/discord.py/blob/rewrite/discord/ext/commands/formatter.py +Which falls under The MIT License. +""" + +import itertools +import logging +from inspect import formatargspec, getfullargspec + +from discord.ext.commands import Command, HelpFormatter, Paginator + +from bot.constants import HELP_PREFIX + +log = logging.getLogger(__name__) + + +class Formatter(HelpFormatter): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def _add_subcommands_to_page(self, max_width: int, commands: list): + """ + basically the same function from d.py but changed: + - to make the helptext appear as a comment + - to change the indentation to the PEP8 standard: 4 spaces + """ + + for name, command in commands: + if name in command.aliases: + # skip aliases + continue + + entry = " {0}{1:<{width}} # {2}".format(HELP_PREFIX, name, command.short_doc, width=max_width) + shortened = self.shorten(entry) + self._paginator.add_line(shortened) + + async def format(self): + """ + rewritten help command to make it more python-y + + example of specific command: + async def (ctx, ): + \""" + + \""" + await do_(ctx, ) + + example of standard help page: + class : + bot.() # + class : + bot.() # + + # + """ + + self._paginator = Paginator(prefix="```py") + + if isinstance(self.command, Command): + # strip the command off bot. and () + stripped_command = self.command.name.replace(HELP_PREFIX, "").replace("()", "") + + # get the args using the handy inspect module + argspec = getfullargspec(self.command.callback) + arguments = formatargspec(*argspec) + for arg, annotation in argspec.annotations.items(): + # remove module name to only show class name + # discord.ext.commands.context.Context -> Context + arguments = arguments.replace(f"{annotation.__module__}.", "") + + # manipulate the argspec to make it valid python when 'calling' the do_ + args_no_type_hints = argspec.args + for kwarg in argspec.kwonlyargs: + args_no_type_hints.append("{0}={0}".format(kwarg)) + args_no_type_hints = "({0})".format(", ".join(args_no_type_hints)) + + # remove self from the args + arguments = arguments.replace("self, ", "") + args_no_type_hints = args_no_type_hints.replace("self, ", "") + + # indent every line in the help message + helptext = "\n ".join(self.command.help.split("\n")) + + # prepare the different sections of the help output, and add them to the paginator + definition = f"async def {stripped_command}{arguments}:" + doc_elems = [ + '"""', + helptext, + '"""' + ] + + docstring = "" + for elem in doc_elems: + docstring += f' {elem}\n' + + invocation = f" await do_{stripped_command}{args_no_type_hints}" + self._paginator.add_line(definition) + self._paginator.add_line(docstring) + self._paginator.add_line(invocation) + + return self._paginator.pages + + max_width = self.max_name_size + + def category_check(tup): + cog = tup[1].cog_name + # zero width character to make it appear last when put in alphabetical order + return cog if cog is not None else "\u200bNoCategory" + + command_list = await self.filter_command_list() + data = sorted(command_list, key=category_check) + + for category, commands in itertools.groupby(data, key=category_check): + commands = sorted(commands) + if len(commands) > 0: + self._paginator.add_line(f"class {category}:") + self._add_subcommands_to_page(max_width, commands) + + self._paginator.add_line() + ending_note = self.get_ending_note() + # make the ending note appear as comments + ending_note = "# "+ending_note.replace("\n", "\n# ") + self._paginator.add_line(ending_note) + + return self._paginator.pages diff --git a/bot/pagination.py b/bot/pagination.py new file mode 100755 index 00000000..268f3474 --- /dev/null +++ b/bot/pagination.py @@ -0,0 +1,270 @@ +# coding=utf-8 +import asyncio +import logging +from typing import Iterable, Optional + +from discord import Embed, Member, Reaction +from discord.abc import User +from discord.ext.commands import Context, Paginator + +LEFT_EMOJI = "\u2B05" +RIGHT_EMOJI = "\u27A1" +DELETE_EMOJI = "\u274c" +FIRST_EMOJI = "\u23EE" +LAST_EMOJI = "\u23ED" + +PAGINATION_EMOJI = [FIRST_EMOJI, LEFT_EMOJI, RIGHT_EMOJI, LAST_EMOJI, DELETE_EMOJI] + +log = logging.getLogger(__name__) + + +class LinePaginator(Paginator): + """ + A class that aids in paginating code blocks for Discord messages. + + Attributes + ----------- + prefix: :class:`str` + The prefix inserted to every page. e.g. three backticks. + suffix: :class:`str` + The suffix appended at the end of every page. e.g. three backticks. + max_size: :class:`int` + The maximum amount of codepoints allowed in a page. + max_lines: :class:`int` + The maximum amount of lines allowed in a page. + """ + + def __init__(self, prefix='```', suffix='```', + max_size=2000, max_lines=None): + """ + This function overrides the Paginator.__init__ + from inside discord.ext.commands. + It overrides in order to allow us to configure + the maximum number of lines per page. + """ + self.prefix = prefix + self.suffix = suffix + self.max_size = max_size - len(suffix) + self.max_lines = max_lines + self._current_page = [prefix] + self._linecount = 0 + self._count = len(prefix) + 1 # prefix + newline + self._pages = [] + + def add_line(self, line='', *, empty=False): + """Adds a line to the current page. + + If the line exceeds the :attr:`max_size` then an exception + is raised. + + This function overrides the Paginator.add_line + from inside discord.ext.commands. + It overrides in order to allow us to configure + the maximum number of lines per page. + + Parameters + ----------- + line: str + The line to add. + empty: bool + Indicates if another empty line should be added. + + Raises + ------ + RuntimeError + The line was too big for the current :attr:`max_size`. + """ + if len(line) > self.max_size - len(self.prefix) - 2: + raise RuntimeError('Line exceeds maximum page size %s' % (self.max_size - len(self.prefix) - 2)) + + if self.max_lines is not None: + if self._linecount >= self.max_lines: + self._linecount = 0 + self.close_page() + + self._linecount += 1 + if self._count + len(line) + 1 > self.max_size: + self.close_page() + + self._count += len(line) + 1 + self._current_page.append(line) + + if empty: + self._current_page.append('') + self._count += 1 + + @classmethod + async def paginate(cls, lines: Iterable[str], ctx: Context, embed: Embed, + prefix: str = "", suffix: str = "", max_lines: Optional[int] = None, max_size: int = 500, + empty: bool = True, restrict_to_user: User = None, timeout: int=300, + footer_text: str = None): + """ + Use a paginator and set of reactions to provide pagination over a set of lines. The reactions are used to + switch page, or to finish with pagination. + When used, this will send a message using `ctx.send()` and apply a set of reactions to it. These reactions may + be used to change page, or to remove pagination from the message. Pagination will also be removed automatically + if no reaction is added for five minutes (300 seconds). + >>> embed = Embed() + >>> embed.set_author(name="Some Operation", url=url, icon_url=icon) + >>> await LinePaginator.paginate( + ... (line for line in lines), + ... ctx, embed + ... ) + :param lines: The lines to be paginated + :param ctx: Current context object + :param embed: A pre-configured embed to be used as a template for each page + :param prefix: Text to place before each page + :param suffix: Text to place after each page + :param max_lines: The maximum number of lines on each page + :param max_size: The maximum number of characters on each page + :param empty: Whether to place an empty line between each given line + :param restrict_to_user: A user to lock pagination operations to for this message, if supplied + :param timeout: The amount of time in seconds to disable pagination of no reaction is added + :param footer_text: Text to prefix the page number in the footer with + """ + + def event_check(reaction_: Reaction, user_: Member): + """ + Make sure that this reaction is what we want to operate on + """ + + no_restrictions = ( + # Pagination is not restricted + not restrict_to_user or + # The reaction was by a whitelisted user + user_.id == restrict_to_user.id + ) + + return ( + # Conditions for a successful pagination: + all(( + # Reaction is on this message + reaction_.message.id == message.id, + # Reaction is one of the pagination emotes + reaction_.emoji in PAGINATION_EMOJI, + # Reaction was not made by the Bot + user_.id != ctx.bot.user.id, + # There were no restrictions + no_restrictions + )) + ) + + paginator = cls(prefix=prefix, suffix=suffix, max_size=max_size, max_lines=max_lines) + current_page = 0 + + for line in lines: + try: + paginator.add_line(line, empty=empty) + except Exception: + log.exception(f"Failed to add line to paginator: '{line}'") + raise # Should propagate + else: + log.trace(f"Added line to paginator: '{line}'") + + log.debug(f"Paginator created with {len(paginator.pages)} pages") + + embed.description = paginator.pages[current_page] + + if len(paginator.pages) <= 1: + if footer_text: + embed.set_footer(text=footer_text) + log.trace(f"Setting embed footer to '{footer_text}'") + + log.debug("There's less than two pages, so we won't paginate - sending single page on its own") + return await ctx.send(embed=embed) + else: + if footer_text: + embed.set_footer(text=f"{footer_text} (Page {current_page + 1}/{len(paginator.pages)})") + else: + embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}") + + log.trace(f"Setting embed footer to '{embed.footer.text}'") + + log.debug("Sending first page to channel...") + message = await ctx.send(embed=embed) + + log.debug("Adding emoji reactions to message...") + + for emoji in PAGINATION_EMOJI: + # Add all the applicable emoji to the message + log.trace(f"Adding reaction: {repr(emoji)}") + await message.add_reaction(emoji) + + while True: + try: + reaction, user = await ctx.bot.wait_for("reaction_add", timeout=timeout, check=event_check) + log.trace(f"Got reaction: {reaction}") + except asyncio.TimeoutError: + log.debug("Timed out waiting for a reaction") + break # We're done, no reactions for the last 5 minutes + + if reaction.emoji == DELETE_EMOJI: + log.debug("Got delete reaction") + break + + if reaction.emoji == FIRST_EMOJI: + await message.remove_reaction(reaction.emoji, user) + current_page = 0 + + log.debug(f"Got first page reaction - changing to page 1/{len(paginator.pages)}") + + embed.description = paginator.pages[current_page] + if footer_text: + embed.set_footer(text=f"{footer_text} (Page {current_page + 1}/{len(paginator.pages)})") + else: + embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}") + await message.edit(embed=embed) + + if reaction.emoji == LAST_EMOJI: + await message.remove_reaction(reaction.emoji, user) + current_page = len(paginator.pages) - 1 + + log.debug(f"Got last page reaction - changing to page {current_page + 1}/{len(paginator.pages)}") + + embed.description = paginator.pages[current_page] + if footer_text: + embed.set_footer(text=f"{footer_text} (Page {current_page + 1}/{len(paginator.pages)})") + else: + embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}") + await message.edit(embed=embed) + + if reaction.emoji == LEFT_EMOJI: + await message.remove_reaction(reaction.emoji, user) + + if current_page <= 0: + log.debug("Got previous page reaction, but we're on the first page - ignoring") + continue + + current_page -= 1 + log.debug(f"Got previous page reaction - changing to page {current_page + 1}/{len(paginator.pages)}") + + embed.description = paginator.pages[current_page] + + if footer_text: + embed.set_footer(text=f"{footer_text} (Page {current_page + 1}/{len(paginator.pages)})") + else: + embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}") + + await message.edit(embed=embed) + + if reaction.emoji == RIGHT_EMOJI: + await message.remove_reaction(reaction.emoji, user) + + if current_page >= len(paginator.pages) - 1: + log.debug("Got next page reaction, but we're on the last page - ignoring") + continue + + current_page += 1 + log.debug(f"Got next page reaction - changing to page {current_page + 1}/{len(paginator.pages)}") + + embed.description = paginator.pages[current_page] + + if footer_text: + embed.set_footer(text=f"{footer_text} (Page {current_page + 1}/{len(paginator.pages)})") + else: + embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}") + + await message.edit(embed=embed) + + log.debug("Ending pagination and removing all reactions...") + await message.clear_reactions() diff --git a/bot/utils.py b/bot/utils.py new file mode 100755 index 00000000..eac37a4b --- /dev/null +++ b/bot/utils.py @@ -0,0 +1,47 @@ +# coding=utf-8 + + +class CaseInsensitiveDict(dict): + """ + We found this class on StackOverflow. Thanks to m000 for writing it! + + https://stackoverflow.com/a/32888599/4022104 + """ + + @classmethod + def _k(cls, key): + return key.lower() if isinstance(key, str) else key + + def __init__(self, *args, **kwargs): + super(CaseInsensitiveDict, self).__init__(*args, **kwargs) + self._convert_keys() + + def __getitem__(self, key): + return super(CaseInsensitiveDict, self).__getitem__(self.__class__._k(key)) + + def __setitem__(self, key, value): + super(CaseInsensitiveDict, self).__setitem__(self.__class__._k(key), value) + + def __delitem__(self, key): + return super(CaseInsensitiveDict, self).__delitem__(self.__class__._k(key)) + + def __contains__(self, key): + return super(CaseInsensitiveDict, self).__contains__(self.__class__._k(key)) + + def pop(self, key, *args, **kwargs): + return super(CaseInsensitiveDict, self).pop(self.__class__._k(key), *args, **kwargs) + + def get(self, key, *args, **kwargs): + return super(CaseInsensitiveDict, self).get(self.__class__._k(key), *args, **kwargs) + + def setdefault(self, key, *args, **kwargs): + return super(CaseInsensitiveDict, self).setdefault(self.__class__._k(key), *args, **kwargs) + + def update(self, E=None, **F): + super(CaseInsensitiveDict, self).update(self.__class__(E)) + super(CaseInsensitiveDict, self).update(self.__class__(**F)) + + def _convert_keys(self): + for k in list(self.keys()): + v = super(CaseInsensitiveDict, self).pop(k) + self.__setitem__(k, v) diff --git a/doc/README.md b/doc/README.md new file mode 100755 index 00000000..007aa51d --- /dev/null +++ b/doc/README.md @@ -0,0 +1,13 @@ +Documentation +============= + +This folder contains documentation intended to help you with the task at hand. Before you start your task, +it's generally a good idea to have a look here - if you've got a problem, it's quite likely that we've explained +it for you here. + +Contents +-------- + +* [How to set up with Pipenv](./pipenv.md) +* [How to set up your Discord Bot](./bot-setup.md) +* [Linting your code](./linting.md) diff --git a/doc/bot-setup.md b/doc/bot-setup.md new file mode 100755 index 00000000..71c13d45 --- /dev/null +++ b/doc/bot-setup.md @@ -0,0 +1,94 @@ +# Setting up your bot + +In order to test your project, you're going to need to set it up. There's a few steps to this. In short, they are: + +1. Create a testing server for you and your teammates +1. Create an OAuth application under your Discord account +1. Add a bot to your OAuth application +1. Add the bot to your testing server +1. Save the bot token and test your bot + +**Please note:** Only one of you needs to do all of these steps. The rest of the team should wait until the token is +given to them, and save that so that they can test as well. + +## Create a testing server + +First of all, you need somewhere to test your bot. Open Discord, and scroll to the bottom of your server list, to find +the following button. Click on it, and create your testing server. + +![Create Server button](https://dl.dropboxusercontent.com/s/bbfmxkyaodo30ng/DiscordCanary_2018-03-22_10-40-21.png) + +![Create Server dialog](https://dl.dropboxusercontent.com/s/m1nbq3tr871k6ws/DiscordCanary_2018-03-22_10-46-00.png) + +Once your server is created, invite your teammates. + +![Invite dialog](https://dl.dropboxusercontent.com/s/d1u5f3nlootix85/DiscordCanary_2018-03-22_10-47-32.png) + +## Create an OAuth application + +Head over to [https://discordapp.com/developers/applications/me](https://discordapp.com/developers/applications/me). +If you need to login, it'll end up taking you to the actual client, so navigate back to that page. Create a new +application. + +![New App button](https://dl.dropboxusercontent.com/s/t2blc2yt47wl4ax/vivaldi_2018-03-22_10-56-19.png) + +![New App dialog](https://dl.dropboxusercontent.com/s/o1veymtov0s6i4v/vivaldi_2018-03-22_10-57-12.png) + +## Add a bot to your OAuth application + +Scroll down, and click on the "Create a Bot User" button. Confirm, and your application will now have a bot. +Make sure you **do not** enable the OAuth2 Code Grant option. + +![Bot creation section](https://dl.dropboxusercontent.com/s/cn9zp8yg9m5uwx7/vivaldi_2018-03-22_10-58-26.png) + +![Bot info section](https://dl.dropboxusercontent.com/s/kzwjlq73v9biof4/vivaldi_2018-03-22_10-59-25.png) + +## Add the bot to your testing server + +Scroll up, and find the "Generate OAuth2 URL" option. Click on it, and copy the link in the box that it generates for +you. Open it in a new browser tab, and add the bot to your server. + +![App operations section](https://dl.dropboxusercontent.com/s/kjwgorjweimc8kh/vivaldi_2018-03-22_11-01-00.png) + +![OAuth URL generator](https://dl.dropboxusercontent.com/s/k5oyvw1gowk00l1/vivaldi_2018-03-22_11-01-44.png) + +![Bot addition page](https://dl.dropboxusercontent.com/s/33e7vit8kj0un58/vivaldi_2018-03-22_11-02-19.png) + +![Server with bot joined](https://dl.dropboxusercontent.com/s/tm9fxgefi60rql8/DiscordCanary_2018-03-22_11-03-08.png) + +## Save the bot token + +In your copy of the repository, create a `.env` file. Do not try to push this file to GitHub - keep it on your machine. + +Back on your application page, scroll down to the bot section. Reveal your bot token, and copy it. + +Open your `.env` file, and add the following text to it, where `YOUR_TOKEN` is the bot token you got from the page. + +```dotenv +BOT_TOKEN=YOUR_TOKEN +``` + +Share this token with the rest of your team, so that they can also fill out their `.env` file. + +![Revealing the token](https://dl.dropboxusercontent.com/s/wjepk3okf8dvqmu/vivaldi_2018-03-22_11-06-22.png) + +![Copying the token](https://dl.dropboxusercontent.com/s/859ut0jlm2xybly/vivaldi_2018-03-22_11-06-59.png) + +![Filling out the .env file](https://dl.dropboxusercontent.com/s/28zqnrht21toawl/pycharm64_2018-03-22_11-07-35.png) + +## Test your bot + +Now that everything is set up, test your bot. Open a terminal in your copy of the repository, and run +`pipenv sync --dev`, and then `pipenv run run.py`. + +![Running the bot](https://dl.dropboxusercontent.com/s/mp4o5wptliczp7c/Hyper_2018-03-22_11-17-23.png) + +![Bot is online](https://dl.dropboxusercontent.com/s/fanw68x5meoat2a/DiscordCanary_2018-03-22_11-11-54.png) + +Press `CTRL+C` at any time to stop the bot, and use `pipenv run run.py` to start it again. You should do this whenever +you've edited the code and want to test it. + +--- + +That's all there is to it! If you get stuck, don't be afraid to ask for help on the server - but please do take a stab +at this yourself first! diff --git a/doc/linting.md b/doc/linting.md new file mode 100755 index 00000000..5ba4e0e8 --- /dev/null +++ b/doc/linting.md @@ -0,0 +1,54 @@ +Linting +======= + +Linting is a very important part of maintaining your code. Linting is a process that checks over the +code you've written, looking for common issues and style guide violations. + +We require that all of your code be linted. Unlinted code will result in a hefty penalty during our +internal judging process, and we encourage that everyone lints their code before pushing it. + +Automatic Linting +----------------- + +We use Travis CI to automatically lint code pushed to a pull request. This means that your code will be +checked every time you push it - so we do recommend that you create a pull request as soon as you possibly +can. You can check the status of your commits by going to the page for your pull request - commits with code +that fails to lint will be marked with a red `X`. You can click on that to read the build log, which will +contain all of the errors from the linter - but you should run the linter yourself before pushing to avoid this. + +Linting Tooling +--------------- + +We try to keep the linting process simple, so that users can easily lint their own code. All of our linting is done +by a tool called `flake8`, which you can easily run on your own machine to check your code. + +Since our code jams all use Pipenv, you can simply run `pipenv run flake8` to test your code, once you've installed +the project dependencies using `pipenv sync --dev`. If you get no output from this command, congrats - your code +is compliant! + +If your code is not compliant, you'll get a stack of error messages which indicate the line number the problem is on, +as well as a description of the problem. Go to the files, fix the issues, and run `flake8` again to ensure that the +problems have been solved. + +The validation will seem quite strict at first, but stick with it! + +Integration +----------- + +Many Python-supporting editors already support basic linting and problem alerting, usually based on the guidelines +provided by the infamous [PEP8](https://www.python.org/dev/peps/pep-0008/). In some cases, however, this will not be +enough. + +It's always worth checking whether your editor has a plugin that directly supports `flake8`. Visual Studio Code +[supports Flake8 directly](https://code.visualstudio.com/docs/python/linting), but it needs to be set up using the +`Python: Select Linter` command. + +Additionally, you can install a Git hook for `flake8`. This is optional, but it will allow you to have `flake8` run +every time you make a commit. You can do this by running `pipenv run flake8 --install-hook git`. + +If you're unsure about editor support, it's always worth googling around and seeing what other users have done to +integrate `flake8`! + +--- + +If you get stuck, don't be afraid to ask for help on the server - but please do take a stab at this yourself first! \ No newline at end of file diff --git a/doc/pipenv.md b/doc/pipenv.md new file mode 100755 index 00000000..c9f347e4 --- /dev/null +++ b/doc/pipenv.md @@ -0,0 +1,56 @@ +Pipenv +====== + +Pipenv is a tool that makes it easy to distribute the dependencies for a project. It's recommended by the Python Software +Foundation as the best way to solve this problem. We'll be using it in all of our code jams, so it would be a good idea +to try to get used to it. + +Requirements +------------ + +Pipenv itself requires the following to be installed: + +* Python 3, installed with the "Add to PATH" option enabled if you're on Windows +* Pip, which should be installed alongside Python on Windows, but may need to be installed separately on other platforms +* Optionally, Pyenv - this allows Pipenv to install the needed version of Python for you as well, in the event that you don't have it + +Once you have the necessary requirements, you can open a terminal or command prompt and run `pip install pipenv` to get Pipenv installed. + +Using Pipenv +------------ + +Pipenv is usually used from a terminal or command prompt. Open a terminal in the project directory, or use `cd path/to/project` to move there +in an already-opened terminal. From there, the following commands are available: + +* `pipenv sync --dev` will install all of the needed dependencies for you, into a virtual environment +* `pipenv install ` will install a `package` to your virtual environment and update the `Pipfile` and `Pipfile.lock` accordingly +* `pipenv uninstall ` will remove an installed `package` from your virtual environment and update the `Pipfile` and `Pipfile.lock` accordingly +* `pipenv run ` will run a `command` using your virtual environment - for example, `pipenv run ` will run `filename` using the Python in your virtual environment +* `pipenv shell` will open up a Python shell using the Python in your virtual environment + +As a first step, you should run `pipenv sync --dev` to set up your environment. You will also need to run this again if a teammate updates the +`Pipfile` or `Pipfile.lock` - for example, if they add a new package to it, or remove an old one. + +Pipenv And You +-------------- + +Pipenv is best-used from a terminal or command prompt. However, the virtual environments it creates act like any others, and it is possible to use them +in your editor or IDE to run or debug your project. Visual Studio Code [supports PyEnv natively](https://code.visualstudio.com/docs/python/environments), +but editors that don't provide an integration will have to be set up manually. Fortunately, doing that is usually quite simple. + +When you first run `pipenv sync`, it tells you where the virtual environment it creates is located. For example, it may be located in +`/home/username/.virtualenvs/code-jam-1-FgMl5Zkj`. The Python interpreter for the virtual environment is located within it, in `Scripts/python`, +or `Scripts/python.exe` if you're on Windows. + +All you need to do is add this interpreter to your editor, using its configuration tools. In our example, the interpreter is located at +`/home/username/.virtualenvs/code-jam-1-FgMl5Zkj/Scripts/python`, so that is what you'd add to your editor. + +The Pipenv documentation maintains [a list of community-supported integrations](https://docs.pipenv.org/advanced/#community-integrations). +If your editor is not listed there, you'll need to set up manually. Please refer to the documentation for your editor for more information on +how to do this. + +--- + +**Make sure you use Pipenv** - if you do not, your dependencies won't be available to your project. + +If you get stuck, don't be afraid to ask for help on the server - but please do take a stab at this yourself first! \ No newline at end of file diff --git a/run.py b/run.py new file mode 100755 index 00000000..26be066f --- /dev/null +++ b/run.py @@ -0,0 +1,40 @@ +# coding=utf-8 +import os + +from aiohttp import AsyncResolver, ClientSession, TCPConnector + +from discord import Game +from discord.ext.commands import AutoShardedBot, when_mentioned_or + +from bot.formatter import Formatter +from bot.utils import CaseInsensitiveDict + +bot = AutoShardedBot( + command_prefix=when_mentioned_or( + ">>> self.", ">> self.", "> self.", "self.", + ">>> bot.", ">> bot.", "> bot.", "bot.", + ">>> ", ">> ", "> ", + ">>>", ">>", ">" + ), # Order matters (and so do commas) + activity=Game(name="Help: bot.help()"), + help_attrs={"aliases": ["help()"]}, + formatter=Formatter() +) + +# Make cog names case-insensitive +bot.cogs = CaseInsensitiveDict() + +# Global aiohttp session for all cogs - uses asyncio for DNS resolution instead of threads, so we don't *spam threads* +bot.http_session = ClientSession(connector=TCPConnector(resolver=AsyncResolver())) + +# Internal/debug +bot.load_extension("bot.cogs.logging") +bot.load_extension("bot.cogs.security") + + +# Commands, etc +bot.load_extension("bot.cogs.snakes") + +bot.run("NDI2NzA2Njc1NDQ2MTg1OTg0.DZZ5GA.eItXcWuW2QGvaFHJaMXeZm8L--8") + +bot.http_session.close() # Close the aiohttp session when the bot finishes running diff --git a/tox.ini b/tox.ini new file mode 100755 index 00000000..028645b4 --- /dev/null +++ b/tox.ini @@ -0,0 +1,4 @@ +[flake8] +max-line-length=120 +application_import_names=bot +exclude=.venv From 413d669d3f1f2b5c8f9707ed623d1688e3d3ccef Mon Sep 17 00:00:00 2001 From: Epsilon10 Date: Sat, 24 Mar 2018 09:02:33 +0530 Subject: [PATCH 2/4] Snake info thing --- Pipfile | 1 + Pipfile.lock | 17 ++++++++++++++++- bot/cogs/snakes.py | 24 +++++++++++++++--------- run.py | 7 ++----- 4 files changed, 34 insertions(+), 15 deletions(-) diff --git a/Pipfile b/Pipfile index 77793604..17ad07af 100755 --- a/Pipfile +++ b/Pipfile @@ -9,6 +9,7 @@ aiodns = "*" aiohttp = "<2.3.0,>=2.0.0" websockets = ">=4.0,<5.0" wikipedia-api = "*" +wikipedia = "*" [dev-packages] "flake8" = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 668a4964..56220104 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "56b4e76557ad5d33b4789d9d4e336502c1adb7793a72d066fcf1d06a16b4bf1f" + "sha256": "3c8714c6e632d8dbbd07fc37481722229f629188c12e196c373354015e11962e" }, "pipfile-spec": 6, "requires": { @@ -53,6 +53,14 @@ ], "version": "==2.0.1" }, + "beautifulsoup4": { + "hashes": [ + "sha256:11a9a27b7d3bddc6d86f59fb76afb70e921a25ac2d6cc55b40d072bd68435a76", + "sha256:7015e76bf32f1f574636c4288399a6de66ce08fb7b2457f628a8d70c0fbabb11", + "sha256:808b6ac932dccb0a4126558f7dfdcf41710dd44a4ef497a0bb59a77f9f078e89" + ], + "version": "==4.6.0" + }, "certifi": { "hashes": [ "sha256:14131608ad2fd56836d33a71ee60fa1c82bc9d2c8d98b7bdbc631fe1b3cd1296", @@ -163,6 +171,13 @@ "index": "pypi", "version": "==4.0.1" }, + "wikipedia": { + "hashes": [ + "sha256:db0fad1829fdd441b1852306e9856398204dc0786d2996dd2e0c8bb8e26133b2" + ], + "index": "pypi", + "version": "==1.4.0" + }, "wikipedia-api": { "hashes": [ "sha256:9144f82c7a9adbee9e643be54cdf54fb86f21032f0f2aa3bffb0dc078eccaafd" diff --git a/bot/cogs/snakes.py b/bot/cogs/snakes.py index c9ed8042..fc4665e7 100755 --- a/bot/cogs/snakes.py +++ b/bot/cogs/snakes.py @@ -1,11 +1,11 @@ -# coding=utf-8 import logging from typing import Any, Dict from discord.ext.commands import AutoShardedBot, Context, command log = logging.getLogger(__name__) - +import wikipedia +import discord class Snakes: """ @@ -18,32 +18,38 @@ def __init__(self, bot: AutoShardedBot): async def get_snek(self, name: str = None) -> Dict[str, Any]: """ Go online and fetch information about a snake - The information includes the name of the snake, a picture of the snake, and various other pieces of info. What information you get for the snake is up to you. Be creative! - If "python" is given as the snake name, you should return information about the programming language, but with all the information you'd provide for a real snake. Try to have some fun with this! - :param name: Optional, the name of the snake to get information for - omit for a random snake :return: A dict containing information on a snake + """ + @command() - async def get(self, ctx: Context, name: str = None): + async def get(self, ctx: Context, *, query: str = None): """ Go online and fetch information about a snake - This should make use of your `get_snek` method, using it to get information about a snake. This information should be sent back to Discord in an embed. - :param ctx: Context object passed from discord.py :param name: Optional, the name of the snake to get information for - omit for a random snake """ + em = discord.Embed(title=str(query)) + em.set_footer(text='Powered by wikipedia.org') + async with self.bot.http_session.get(f"https://en.wikipedia.org/w/api.php?format=json&action=query&prop=extracts&exintro=&explaintext=&titles={query}") as resp: + data = await resp.json() + data = data['query'] + em.description = (data["pages"][list(data["pages"].keys())[0]]["extract"])[:2000] + await ctx.send(embed=em) + + # Any additional commands can be placed here. Be creative, but keep it to a reasonable amount! def setup(bot): bot.add_cog(Snakes(bot)) - log.info("Cog loaded: Snakes") + log.info("Cog loaded: Snakes") \ No newline at end of file diff --git a/run.py b/run.py index 86966ece..685adb66 100755 --- a/run.py +++ b/run.py @@ -35,10 +35,7 @@ # Commands, etc bot.load_extension("bot.cogs.snakes") -<<<<<<< HEAD -bot.run("NDI2NzA2Njc1NDQ2MTg1OTg0.DZZ5GA.eItXcWuW2QGvaFHJaMXeZm8L--8") -======= -bot.run(os.environ.get("BOT_TOKEN")) ->>>>>>> 9fd7c9d66605a5f9d86c13ad1f2fd98b9b54d123 +bot.run("NDI2NzA2Njc1NDQ2MTg1OTg0.DZaHmA.nHCe5z0HzLUCDYefGBnJr_JDB4M") + bot.http_session.close() # Close the aiohttp session when the bot finishes running From 3766da4d9d24b8fffa95d7e8861e3309ab83a3ec Mon Sep 17 00:00:00 2001 From: Epsilon10 Date: Sat, 24 Mar 2018 09:35:55 +0530 Subject: [PATCH 3/4] remove --- run.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/run.py b/run.py index 685adb66..5d552bcd 100755 --- a/run.py +++ b/run.py @@ -35,7 +35,7 @@ # Commands, etc bot.load_extension("bot.cogs.snakes") -bot.run("NDI2NzA2Njc1NDQ2MTg1OTg0.DZaHmA.nHCe5z0HzLUCDYefGBnJr_JDB4M") +bot.run(os.environ.get('BOT_TOKEN')) bot.http_session.close() # Close the aiohttp session when the bot finishes running From 2743cf73aeca98d60a6d5c270392dc56d7f3c26a Mon Sep 17 00:00:00 2001 From: Kaidan Date: Sat, 24 Mar 2018 15:31:19 +1100 Subject: [PATCH 4/4] Remove unused deps --- Pipfile | 2 -- Pipfile.lock | 45 +-------------------------------------------- bot/cogs/snakes.py | 3 +-- 3 files changed, 2 insertions(+), 48 deletions(-) diff --git a/Pipfile b/Pipfile index 17ad07af..096fb9b3 100755 --- a/Pipfile +++ b/Pipfile @@ -8,8 +8,6 @@ name = "pypi" aiodns = "*" aiohttp = "<2.3.0,>=2.0.0" websockets = ">=4.0,<5.0" -wikipedia-api = "*" -wikipedia = "*" [dev-packages] "flake8" = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 56220104..4e5214bb 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "3c8714c6e632d8dbbd07fc37481722229f629188c12e196c373354015e11962e" + "sha256": "d797e580ddcddc99bf058109ab0306ad584c2902752a3d4076ba713fdc580fb7" }, "pipfile-spec": 6, "requires": { @@ -53,21 +53,6 @@ ], "version": "==2.0.1" }, - "beautifulsoup4": { - "hashes": [ - "sha256:11a9a27b7d3bddc6d86f59fb76afb70e921a25ac2d6cc55b40d072bd68435a76", - "sha256:7015e76bf32f1f574636c4288399a6de66ce08fb7b2457f628a8d70c0fbabb11", - "sha256:808b6ac932dccb0a4126558f7dfdcf41710dd44a4ef497a0bb59a77f9f078e89" - ], - "version": "==4.6.0" - }, - "certifi": { - "hashes": [ - "sha256:14131608ad2fd56836d33a71ee60fa1c82bc9d2c8d98b7bdbc631fe1b3cd1296", - "sha256:edbc3f203427eef571f79a7692bb160a2b0f7ccaa31953e99bd17e307cf63f7d" - ], - "version": "==2018.1.18" - }, "chardet": { "hashes": [ "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", @@ -135,20 +120,6 @@ ], "version": "==2.3.0" }, - "requests": { - "hashes": [ - "sha256:6a1b267aa90cac58ac3a765d067950e7dbbf75b1da07e895d1f594193a40a38b", - "sha256:9c443e7324ba5b85070c4a818ade28bfabedf16ea10206da1132edaa6dda237e" - ], - "version": "==2.18.4" - }, - "urllib3": { - "hashes": [ - "sha256:06330f386d6e4b195fbfc736b297f58c5a892e4440e54d294d7004e3a9bbea1b", - "sha256:cc44da8e1145637334317feebd728bd869a35285b93cbb4cca2577da7e62db4f" - ], - "version": "==1.22" - }, "websockets": { "hashes": [ "sha256:0c31bc832d529dc7583d324eb6c836a4f362032a1902723c112cf57883488d8c", @@ -171,20 +142,6 @@ "index": "pypi", "version": "==4.0.1" }, - "wikipedia": { - "hashes": [ - "sha256:db0fad1829fdd441b1852306e9856398204dc0786d2996dd2e0c8bb8e26133b2" - ], - "index": "pypi", - "version": "==1.4.0" - }, - "wikipedia-api": { - "hashes": [ - "sha256:9144f82c7a9adbee9e643be54cdf54fb86f21032f0f2aa3bffb0dc078eccaafd" - ], - "index": "pypi", - "version": "==0.3.5" - }, "yarl": { "hashes": [ "sha256:045dbba18c9142278113d5dc62622978a6f718ba662392d406141c59b540c514", diff --git a/bot/cogs/snakes.py b/bot/cogs/snakes.py index fc4665e7..ec15e9f0 100755 --- a/bot/cogs/snakes.py +++ b/bot/cogs/snakes.py @@ -4,7 +4,6 @@ from discord.ext.commands import AutoShardedBot, Context, command log = logging.getLogger(__name__) -import wikipedia import discord class Snakes: @@ -52,4 +51,4 @@ async def get(self, ctx: Context, *, query: str = None): def setup(bot): bot.add_cog(Snakes(bot)) - log.info("Cog loaded: Snakes") \ No newline at end of file + log.info("Cog loaded: Snakes")