From 83e49c25d0cdd473ddf2d5feb4aa85d041ed596b Mon Sep 17 00:00:00 2001 From: Jeremiah Boby Date: Sun, 13 Oct 2019 18:03:07 +0100 Subject: Check partially hidden words against the wordlist --- bot/cogs/filtering.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 265ae5160..875276d8a 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -26,6 +26,7 @@ INVITE_RE = re.compile( flags=re.IGNORECASE ) +SPOILER_RE = re.compile(r"(\|\|.+?\|\|)") URL_RE = re.compile(r"(https?://[^\s]+)", flags=re.IGNORECASE) ZALGO_RE = re.compile(r"[\u0300-\u036F\u0489]") @@ -237,7 +238,7 @@ class Filtering(Cog): Only matches words with boundaries before and after the expression. """ for regex_pattern in WORD_WATCHLIST_PATTERNS: - if regex_pattern.search(text): + if regex_pattern.search(text + SPOILER_RE.sub('', text)): return True return False -- cgit v1.2.3 From e731db98569d55051b944278221449a206992850 Mon Sep 17 00:00:00 2001 From: Jeremiah Boby Date: Mon, 21 Oct 2019 12:58:26 +0100 Subject: Update spoiler regex to support multi-line spoilers --- bot/cogs/filtering.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 875276d8a..fd90ff836 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -26,7 +26,7 @@ INVITE_RE = re.compile( flags=re.IGNORECASE ) -SPOILER_RE = re.compile(r"(\|\|.+?\|\|)") +SPOILER_RE = re.compile(r"(\|\|.+?\|\|)", re.DOTALL) URL_RE = re.compile(r"(https?://[^\s]+)", flags=re.IGNORECASE) ZALGO_RE = re.compile(r"[\u0300-\u036F\u0489]") -- cgit v1.2.3 From def24f55c81865e1a7a8d93e0de7562a7abb556a Mon Sep 17 00:00:00 2001 From: Jeremiah Boby Date: Thu, 28 Nov 2019 10:28:52 +0000 Subject: Expand spoilers to match multiple interpretations --- bot/cogs/filtering.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index fd90ff836..f1651b4d0 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -38,6 +38,14 @@ TOKEN_WATCHLIST_PATTERNS = [ ] +def expand_spoilers(text: str) -> str: + """Return a string containing all interpretations of a spoilered message.""" + split_text = SPOILER_RE.split(text) + return ''.join( + split_text[0::2] + split_text[1::2] + split_text + ) + + class Filtering(Cog): """Filtering out invites, blacklisting domains, and warning us of certain regular expressions.""" @@ -237,8 +245,10 @@ class Filtering(Cog): Only matches words with boundaries before and after the expression. """ + if SPOILER_RE.search(text): + text = expand_spoilers(text) for regex_pattern in WORD_WATCHLIST_PATTERNS: - if regex_pattern.search(text + SPOILER_RE.sub('', text)): + if regex_pattern.search(text): return True return False -- cgit v1.2.3 From 9fd7b0829162bb589b371215e5772b24d2bd7d38 Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Sat, 29 Feb 2020 14:29:42 +0530 Subject: Added all the tag files in resources and modified cogs/tags.py file to access the static tag files rather than sending an API get request. Removed all methods calling the API so the tags cannot be edited, added nor deleted. --- bot/cogs/tags.py | 103 +++++----------------------- bot/resources/tags/args-kwargs.md | 17 +++++ bot/resources/tags/ask.md | 9 +++ bot/resources/tags/class.md | 25 +++++++ bot/resources/tags/classmethod.md | 20 ++++++ bot/resources/tags/codeblock.md | 17 +++++ bot/resources/tags/decorators.md | 31 +++++++++ bot/resources/tags/dictcomps.md | 20 ++++++ bot/resources/tags/enumerate.md | 13 ++++ bot/resources/tags/except.md | 17 +++++ bot/resources/tags/exit().md | 8 +++ bot/resources/tags/f-strings.md | 17 +++++ bot/resources/tags/foo.md | 10 +++ bot/resources/tags/functions-are-objects.md | 39 +++++++++++ bot/resources/tags/global.md | 16 +++++ bot/resources/tags/if-name-main.md | 26 +++++++ bot/resources/tags/indent.md | 24 +++++++ bot/resources/tags/inline.md | 16 +++++ bot/resources/tags/iterate-dict.md | 10 +++ bot/resources/tags/listcomps.md | 14 ++++ bot/resources/tags/mutable-default-args.md | 48 +++++++++++++ bot/resources/tags/names.md | 37 ++++++++++ bot/resources/tags/off-topic.md | 8 +++ bot/resources/tags/open.md | 26 +++++++ bot/resources/tags/or-gotcha.md | 17 +++++ bot/resources/tags/param-arg.md | 12 ++++ bot/resources/tags/paste.md | 6 ++ bot/resources/tags/pathlib.md | 21 ++++++ bot/resources/tags/pep8.md | 3 + bot/resources/tags/positional-keyword.md | 38 ++++++++++ bot/resources/tags/precedence.md | 13 ++++ bot/resources/tags/quotes.md | 20 ++++++ bot/resources/tags/relative-path.md | 7 ++ bot/resources/tags/repl.md | 13 ++++ bot/resources/tags/return.md | 35 ++++++++++ bot/resources/tags/round.md | 24 +++++++ bot/resources/tags/scope.md | 24 +++++++ bot/resources/tags/seek.md | 22 ++++++ bot/resources/tags/self.md | 25 +++++++ bot/resources/tags/star-imports.md | 48 +++++++++++++ bot/resources/tags/traceback.md | 18 +++++ bot/resources/tags/windows-path.md | 30 ++++++++ bot/resources/tags/with.md | 8 +++ bot/resources/tags/xy-problem.md | 7 ++ bot/resources/tags/ytdl.md | 9 +++ bot/resources/tags/zen.md | 20 ++++++ bot/resources/tags/zip.md | 12 ++++ 47 files changed, 919 insertions(+), 84 deletions(-) create mode 100644 bot/resources/tags/args-kwargs.md create mode 100644 bot/resources/tags/ask.md create mode 100644 bot/resources/tags/class.md create mode 100644 bot/resources/tags/classmethod.md create mode 100644 bot/resources/tags/codeblock.md create mode 100644 bot/resources/tags/decorators.md create mode 100644 bot/resources/tags/dictcomps.md create mode 100644 bot/resources/tags/enumerate.md create mode 100644 bot/resources/tags/except.md create mode 100644 bot/resources/tags/exit().md create mode 100644 bot/resources/tags/f-strings.md create mode 100644 bot/resources/tags/foo.md create mode 100644 bot/resources/tags/functions-are-objects.md create mode 100644 bot/resources/tags/global.md create mode 100644 bot/resources/tags/if-name-main.md create mode 100644 bot/resources/tags/indent.md create mode 100644 bot/resources/tags/inline.md create mode 100644 bot/resources/tags/iterate-dict.md create mode 100644 bot/resources/tags/listcomps.md create mode 100644 bot/resources/tags/mutable-default-args.md create mode 100644 bot/resources/tags/names.md create mode 100644 bot/resources/tags/off-topic.md create mode 100644 bot/resources/tags/open.md create mode 100644 bot/resources/tags/or-gotcha.md create mode 100644 bot/resources/tags/param-arg.md create mode 100644 bot/resources/tags/paste.md create mode 100644 bot/resources/tags/pathlib.md create mode 100644 bot/resources/tags/pep8.md create mode 100644 bot/resources/tags/positional-keyword.md create mode 100644 bot/resources/tags/precedence.md create mode 100644 bot/resources/tags/quotes.md create mode 100644 bot/resources/tags/relative-path.md create mode 100644 bot/resources/tags/repl.md create mode 100644 bot/resources/tags/return.md create mode 100644 bot/resources/tags/round.md create mode 100644 bot/resources/tags/scope.md create mode 100644 bot/resources/tags/seek.md create mode 100644 bot/resources/tags/self.md create mode 100644 bot/resources/tags/star-imports.md create mode 100644 bot/resources/tags/traceback.md create mode 100644 bot/resources/tags/windows-path.md create mode 100644 bot/resources/tags/with.md create mode 100644 bot/resources/tags/xy-problem.md create mode 100644 bot/resources/tags/ytdl.md create mode 100644 bot/resources/tags/zen.md create mode 100644 bot/resources/tags/zip.md diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index b6360dfae..0e959b45f 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -1,22 +1,22 @@ import logging +import os import re import time +from pathlib import Path from typing import Dict, List, Optional from discord import Colour, Embed from discord.ext.commands import Cog, Context, group from bot.bot import Bot -from bot.constants import Channels, Cooldowns, MODERATION_ROLES, Roles -from bot.converters import TagContentConverter, TagNameConverter -from bot.decorators import with_role +from bot.constants import Channels, Cooldowns +from bot.converters import TagNameConverter from bot.pagination import LinePaginator log = logging.getLogger(__name__) TEST_CHANNELS = ( - Channels.devtest, - Channels.bot, + Channels.bot_commands, Channels.helpers ) @@ -29,7 +29,6 @@ class Tags(Cog): def __init__(self, bot: Bot): self.bot = bot self.tag_cooldowns = {} - self._cache = {} self._last_fetch: float = 0.0 @@ -38,12 +37,23 @@ class Tags(Cog): # refresh only when there's a more than 5m gap from last call. time_now: float = time.time() if is_forced or not self._last_fetch or time_now - self._last_fetch > 5 * 60: - tags = await self.bot.api_client.get('bot/tags') - self._cache = {tag['title'].lower(): tag for tag in tags} + tag_files = os.listdir("bot/resources/tags") + for file in tag_files: + p = Path("bot", "resources", "tags", file) + tag_title = os.path.splitext(file)[0].lower() + with p.open() as f: + tag = { + "title": tag_title, + "embed": { + "description": f.read() + } + } + self._cache[tag_title] = tag + self._last_fetch = time_now @staticmethod - def _fuzzy_search(search: str, target: str) -> int: + def _fuzzy_search(search: str, target: str) -> float: """A simple scoring algorithm based on how many letters are found / total, with order in mind.""" current, index = 0, 0 _search = REGEX_NON_ALPHABET.sub('', search.lower()) @@ -159,81 +169,6 @@ class Tags(Cog): max_lines=15 ) - @tags_group.command(name='set', aliases=('add', 's')) - @with_role(*MODERATION_ROLES) - async def set_command( - self, - ctx: Context, - tag_name: TagNameConverter, - *, - tag_content: TagContentConverter, - ) -> None: - """Create a new tag.""" - body = { - 'title': tag_name.lower().strip(), - 'embed': { - 'title': tag_name, - 'description': tag_content - } - } - - await self.bot.api_client.post('bot/tags', json=body) - self._cache[tag_name.lower()] = await self.bot.api_client.get(f'bot/tags/{tag_name}') - - log.debug(f"{ctx.author} successfully added the following tag to our database: \n" - f"tag_name: {tag_name}\n" - f"tag_content: '{tag_content}'\n") - - await ctx.send(embed=Embed( - title="Tag successfully added", - description=f"**{tag_name}** added to tag database.", - colour=Colour.blurple() - )) - - @tags_group.command(name='edit', aliases=('e', )) - @with_role(*MODERATION_ROLES) - async def edit_command( - self, - ctx: Context, - tag_name: TagNameConverter, - *, - tag_content: TagContentConverter, - ) -> None: - """Edit an existing tag.""" - body = { - 'embed': { - 'title': tag_name, - 'description': tag_content - } - } - - await self.bot.api_client.patch(f'bot/tags/{tag_name}', json=body) - self._cache[tag_name.lower()] = await self.bot.api_client.get(f'bot/tags/{tag_name}') - - log.debug(f"{ctx.author} successfully edited the following tag in our database: \n" - f"tag_name: {tag_name}\n" - f"tag_content: '{tag_content}'\n") - - await ctx.send(embed=Embed( - title="Tag successfully edited", - description=f"**{tag_name}** edited in the database.", - colour=Colour.blurple() - )) - - @tags_group.command(name='delete', aliases=('remove', 'rm', 'd')) - @with_role(Roles.admin, Roles.owner) - async def delete_command(self, ctx: Context, *, tag_name: TagNameConverter) -> None: - """Remove a tag from the database.""" - await self.bot.api_client.delete(f'bot/tags/{tag_name}') - self._cache.pop(tag_name.lower(), None) - - log.debug(f"{ctx.author} successfully deleted the tag called '{tag_name}'") - await ctx.send(embed=Embed( - title=tag_name, - description=f"Tag successfully removed: {tag_name}.", - colour=Colour.blurple() - )) - def setup(bot: Bot) -> None: """Load the Tags cog.""" diff --git a/bot/resources/tags/args-kwargs.md b/bot/resources/tags/args-kwargs.md new file mode 100644 index 000000000..fb19d39fd --- /dev/null +++ b/bot/resources/tags/args-kwargs.md @@ -0,0 +1,17 @@ +`*args` and `**kwargs` + +These special parameters allow functions to take arbitrary amounts of positional and keyword arguments. The names `args` and `kwargs` are purely convention, and could be named any other valid variable name. The special functionality comes from the single and double asterisks (`*`). If both are used in a function signature, `*args` **must** appear before `**kwargs`. + +**Single asterisk** +`*args` will ingest an arbitrary amount of **positional arguments**, and store it in a tuple. If there are parameters after `*args` in the parameter list with no default value, they will become **required** keyword arguments by default. + +**Double asterisk** +`**kwargs` will ingest an arbitrary amount of **keyword arguments**, and store it in a dictionary. There can be **no** additional parameters **after** `**kwargs` in the parameter list. + +**Use cases** +• **Decorators** (see `!tags decorators`) +• **Inheritance** (overriding methods) +• **Future proofing** (in the case of the first two bullet points, if the parameters change, your code won't break) +• **Flexibility** (writing functions that behave like `dict()` or `print()`) + +*See* `!tags positional-keyword` *for information about positional and keyword arguments* \ No newline at end of file diff --git a/bot/resources/tags/ask.md b/bot/resources/tags/ask.md new file mode 100644 index 000000000..07f9bd84d --- /dev/null +++ b/bot/resources/tags/ask.md @@ -0,0 +1,9 @@ +Asking good questions will yield a much higher chance of a quick response: + +• Don't ask to ask your question, just go ahead and tell us your problem. +• Don't ask if anyone is knowledgeable in some area, filtering serves no purpose. +• Try to solve the problem on your own first, we're not going to write code for you. +• Show us the code you've tried and any errors or unexpected results it's giving. +• Be patient while we're helping you. + +You can find a much more detailed explanation [on our website](https://pythondiscord.com/pages/asking-good-questions/). \ No newline at end of file diff --git a/bot/resources/tags/class.md b/bot/resources/tags/class.md new file mode 100644 index 000000000..74c36b9fa --- /dev/null +++ b/bot/resources/tags/class.md @@ -0,0 +1,25 @@ +**Classes** + +Classes are used to create objects that have specific behavior. + +Every object in python has a class, including `list`s, `dict`ionaries and even numbers. Using a class to group code and data like this is the foundation of Object Oriented Programming. Classes allow you to expose a simple, consistent interface while hiding the more complicated details. This simplifies the rest of your program and makes it easier to separately maintain and debug each component. + +Here is an example class: + +```python +class Foo: + def __init__(self, somedata): + self.my_attrib = somedata + + def show(self): + print(self.my_attrib) +``` + +To use a class, you need to instantiate it. The following creates a new object named `bar`, with `Foo` as its class. + +```python +bar = Foo('data') +bar.show() +``` + +We can access any of `Foo`'s methods via `bar.my_method()`, and access any of `bar`s data via `bar.my_attribute`. \ No newline at end of file diff --git a/bot/resources/tags/classmethod.md b/bot/resources/tags/classmethod.md new file mode 100644 index 000000000..43c6d9909 --- /dev/null +++ b/bot/resources/tags/classmethod.md @@ -0,0 +1,20 @@ +Although most methods are tied to an _object instance_, it can sometimes be useful to create a method that does something with _the class itself_. To achieve this in Python, you can use the `@classmethod` decorator. This is often used to provide alternative constructors for a class. + +For example, you may be writing a class that takes some magic token (like an API key) as a constructor argument, but you sometimes read this token from a configuration file. You could make use of a `@classmethod` to create an alternate constructor for when you want to read from the configuration file. +```py +class Bot: + def __init__(self, token: str): + self._token = token + + @classmethod + def from_config(cls, config: dict) -> Bot: + token = config['token'] + return cls(token) + +# now we can create the bot instance like this +alternative_bot = Bot.from_config(default_config) + +# but this still works, too +regular_bot = Bot("tokenstring") +``` +This is just one of the many use cases of `@classmethod`. A more in-depth explanation can be found [here](https://stackoverflow.com/questions/12179271/meaning-of-classmethod-and-staticmethod-for-beginner#12179752). \ No newline at end of file diff --git a/bot/resources/tags/codeblock.md b/bot/resources/tags/codeblock.md new file mode 100644 index 000000000..816bb8232 --- /dev/null +++ b/bot/resources/tags/codeblock.md @@ -0,0 +1,17 @@ +Discord has support for Markdown, which allows you to post code with full syntax highlighting. Please use these whenever you paste code, as this helps improve the legibility and makes it easier for us to help you. + +To do this, use the following method: + +\```python +print('Hello world!') +\``` + +Note: +• **These are backticks, not quotes.** Backticks can usually be found on the tilde key. +• You can also use py as the language instead of python +• The language must be on the first line next to the backticks with **no** space between them + +This will result in the following: +```py +print('Hello world!') +``` \ No newline at end of file diff --git a/bot/resources/tags/decorators.md b/bot/resources/tags/decorators.md new file mode 100644 index 000000000..3ff1db16c --- /dev/null +++ b/bot/resources/tags/decorators.md @@ -0,0 +1,31 @@ +**Decorators** + +A decorator is a function that modifies another function. + +Consider the following example of a timer decorator: +```py +>>> import time +>>> def timer(f): +... def inner(*args, **kwargs): +... start = time.time() +... result = f(*args, **kwargs) +... print('Time elapsed:', time.time() - start) +... return result +... return inner +... +>>> @timer +... def slow(delay=1): +... time.sleep(delay) +... return 'Finished!' +... +>>> print(slow()) +Time elapsed: 1.0011568069458008 +Finished! +>>> print(slow(3)) +Time elapsed: 3.000307321548462 +Finished! +``` + +More information: +• [Corey Schafer's video on decorators](https://youtu.be/FsAPt_9Bf3U) +• [Real python article](https://realpython.com/primer-on-python-decorators/) \ No newline at end of file diff --git a/bot/resources/tags/dictcomps.md b/bot/resources/tags/dictcomps.md new file mode 100644 index 000000000..ddefa1299 --- /dev/null +++ b/bot/resources/tags/dictcomps.md @@ -0,0 +1,20 @@ +**Dictionary Comprehensions** + +Like lists, there is a convenient way of creating dictionaries: +```py +>>> ftoc = {f: round((5/9)*(f-32)) for f in range(-40,101,20)} +>>> print(ftoc) +{-40: -40, -20: -29, 0: -18, 20: -7, 40: 4, 60: 16, 80: 27, 100: 38} +``` +In the example above, I created a dictionary of temperatures in Fahrenheit, that are mapped to (*roughly*) their Celsius counterpart within a small range. These comprehensions are useful for succinctly creating dictionaries from some other sequence. + +They are also very useful for inverting the key value pairs of a dictionary that already exists, such that the value in the old dictionary is now the key, and the corresponding key is now its value: +```py +>>> ctof = {v:k for k, v in ftoc.items()} +>>> print(ctof) +{-40: -40, -29: -20, -18: 0, -7: 20, 4: 40, 16: 60, 27: 80, 38: 100} +``` + +Also like list comprehensions, you can add a conditional to it in order to filter out items you don't want. + +For more information and examples, check [PEP 274](https://www.python.org/dev/peps/pep-0274/) \ No newline at end of file diff --git a/bot/resources/tags/enumerate.md b/bot/resources/tags/enumerate.md new file mode 100644 index 000000000..610843cf4 --- /dev/null +++ b/bot/resources/tags/enumerate.md @@ -0,0 +1,13 @@ +Ever find yourself in need of the current iteration number of your `for` loop? You should use **enumerate**! Using `enumerate`, you can turn code that looks like this: +```py +index = 0 +for item in my_list: + print(f"{index}: {item}") + index += 1 +``` +into beautiful, _pythonic_ code: +```py +for index, item in enumerate(my_list): + print(f"{index}: {item}") +``` +For more information, check out [the official docs](https://docs.python.org/3/library/functions.html#enumerate), or [PEP 279](https://www.python.org/dev/peps/pep-0279/). \ No newline at end of file diff --git a/bot/resources/tags/except.md b/bot/resources/tags/except.md new file mode 100644 index 000000000..66dce13ab --- /dev/null +++ b/bot/resources/tags/except.md @@ -0,0 +1,17 @@ +A key part of the Python philosophy is to ask for forgiveness, not permission. This means that it's okay to write code that may produce an error, as long as you specify how that error should be handled. Code written this way is readable and resilient. +```py +try: + number = int(user_input) +except ValueError: + print("failed to convert user_input to a number. setting number to 0.") + number = 0 +``` +You should always specify the exception type if it is possible to do so, and your `try` block should be as short as possible. Attempting to handle broad categories of unexpected exceptions can silently hide serious problems. +```py +try: + number = int(user_input) + item = some_list[number] +except: + print("An exception was raised, but we have no idea if it was a ValueError or an IndexError.") +``` +For more information about exception handling, see [the official Python docs](https://docs.python.org/3/tutorial/errors.html), or watch [Corey Schafer's video on exception handling](https://www.youtube.com/watch?v=NIWwJbo-9_8). \ No newline at end of file diff --git a/bot/resources/tags/exit().md b/bot/resources/tags/exit().md new file mode 100644 index 000000000..89f83f7e0 --- /dev/null +++ b/bot/resources/tags/exit().md @@ -0,0 +1,8 @@ +**Exiting Programmatically** + +If you want to exit your code programmatically, you might think to use the functions `exit()` or `quit()`, however this is bad practice. These functions are constants added by the [`site`](https://docs.python.org/3/library/site.html#module-site) module as a convenient method for exiting the interactive interpreter shell, and should not be used in programs. + +You should use either [`SystemExit`](https://docs.python.org/3/library/exceptions.html#SystemExit) or [`sys.exit()`](https://docs.python.org/3/library/sys.html#sys.exit) instead. +There's not much practical difference between these two other than having to `import sys` for the latter. Both take an optional argument to provide an exit status. + +[Official documentation](https://docs.python.org/3/library/constants.html#exit) with the warning not to use `exit()` or `quit()` in source code. \ No newline at end of file diff --git a/bot/resources/tags/f-strings.md b/bot/resources/tags/f-strings.md new file mode 100644 index 000000000..966fe6080 --- /dev/null +++ b/bot/resources/tags/f-strings.md @@ -0,0 +1,17 @@ +In Python, there are several ways to do string interpolation, including using `%s`\'s and by using the `+` operator to concatenate strings together. However, because some of these methods offer poor readability and require typecasting to prevent errors, you should for the most part be using a feature called format strings. + +**In Python 3.6 or later, we can use f-strings like this:** +```py +snake = "Pythons" +print(f"{snake} are some of the largest snakes in the world") +``` +**In earlier versions of Python or in projects where backwards compatibility is very important, use str.format() like this:** +```py +snake = "Pythons" + +# With str.format() you can either use indexes +print("{0} are some of the largest snakes in the world".format(snake)) + +# Or keyword arguments +print("{family} are some of the largest snakes in the world".format(family=snake)) +``` \ No newline at end of file diff --git a/bot/resources/tags/foo.md b/bot/resources/tags/foo.md new file mode 100644 index 000000000..58bc4b78f --- /dev/null +++ b/bot/resources/tags/foo.md @@ -0,0 +1,10 @@ +**Metasyntactic variables** + +A specific word or set of words identified as a placeholder used in programming. They are used to name entities such as variables, functions, etc, whose exact identity is unimportant and serve only to demonstrate a concept, which is useful for teaching programming. + +Common examples include `foobar`, `foo`, `bar`, `baz`, and `qux`. +Python has its own metasyntactic variables, namely `spam`, `eggs`, and `bacon`. This is a reference to a [Monty Python](https://en.wikipedia.org/wiki/Monty_Python) sketch (the eponym of the language). + +More information: +• [History of foobar](https://en.wikipedia.org/wiki/Foobar) +• [Monty Python sketch](https://en.wikipedia.org/wiki/Spam_%28Monty_Python%29) \ No newline at end of file diff --git a/bot/resources/tags/functions-are-objects.md b/bot/resources/tags/functions-are-objects.md new file mode 100644 index 000000000..d10e6b73e --- /dev/null +++ b/bot/resources/tags/functions-are-objects.md @@ -0,0 +1,39 @@ +**Calling vs. Referencing functions** + +When assigning a new name to a function, storing it in a container, or passing it as an argument, a common mistake made is to call the function. Instead of getting the actual function, you'll get its return value. + +In Python you can treat function names just like any other variable. Assume there was a function called `now` that returns the current time. If you did `x = now()`, the current time would be assigned to `x`, but if you did `x = now`, the function `now` itself would be assigned to `x`. `x` and `now` would both equally reference the function. + +**Examples** +```py +# assigning new name + +def foo(): + return 'bar' + +def spam(): + return 'eggs' + +baz = foo +baz() # returns 'bar' + +ham = spam +ham() # returns 'eggs' +``` +```py +# storing in container + +import math +functions = [math.sqrt, math.factorial, math.log] +functions[0](25) # returns 5.0 +# the above equivalent to math.sqrt(25) +``` +```py +# passing as argument + +class C: + builtin_open = staticmethod(open) + +# open function is passed +# to the staticmethod class +``` \ No newline at end of file diff --git a/bot/resources/tags/global.md b/bot/resources/tags/global.md new file mode 100644 index 000000000..fc60f9177 --- /dev/null +++ b/bot/resources/tags/global.md @@ -0,0 +1,16 @@ +When adding functions or classes to a program, it can be tempting to reference inaccessible variables by declaring them as global. Doing this can result in code that is harder to read, debug and test. Instead of using globals, pass variables or objects as parameters and receive return values. + +Instead of writing +```py +def update_score(): + global score, roll + score = score + roll +update_score() +``` +do this instead +```py +def update_score(score, roll): + return score + roll +score = update_score(score, roll) +``` +For in-depth explanations on why global variables are bad news in a variety of situations, see [this Stack Overflow answer](https://stackoverflow.com/questions/19158339/why-are-global-variables-evil/19158418#19158418). \ No newline at end of file diff --git a/bot/resources/tags/if-name-main.md b/bot/resources/tags/if-name-main.md new file mode 100644 index 000000000..d44f0086d --- /dev/null +++ b/bot/resources/tags/if-name-main.md @@ -0,0 +1,26 @@ +`if __name__ == '__main__'` + +This is a statement that is only true if the module (your source code) it appears in is being run directly, as opposed to being imported into another module. When you run your module, the `__name__` special variable is automatically set to the string `'__main__'`. Conversely, when you import that same module into a different one, and run that, `__name__` is instead set to the filename of your module minus the `.py` extension. + +**Example** +```py +# foo.py + +print('spam') + +if __name__ == '__main__': + print('eggs') +``` +If you run the above module `foo.py` directly, both `'spam'`and `'eggs'` will be printed. Now consider this next example: +```py +# bar.py + +import foo +``` +If you run this module named `bar.py`, it will execute the code in `foo.py`. First it will print `'spam'`, and then the `if` statement will fail, because `__name__` will now be the string `'foo'`. + +**Why would I do this?** + +• Your module is a library, but also has a special case where it can be run directly +• Your module is a library and you want to safeguard it against people running it directly (like what `pip` does) +• Your module is the main program, but has unit tests and the testing framework works by importing your module, and you want to avoid having your main code run during the test \ No newline at end of file diff --git a/bot/resources/tags/indent.md b/bot/resources/tags/indent.md new file mode 100644 index 000000000..5b36a4818 --- /dev/null +++ b/bot/resources/tags/indent.md @@ -0,0 +1,24 @@ +**Indentation** + +Indentation is leading whitespace (spaces and tabs) at the beginning of a line of code. In the case of Python, they are used to determine the grouping of statements. + +Spaces should be preferred over tabs. To be clear, this is in reference to the character itself, not the keys on a keyboard. Your editor/IDE should be configured to insert spaces when the TAB key is pressed. The amount of spaces should be a multiple of 4, except optionally in the case of continuation lines. + +**Example** +```py +def foo(): + bar = 'baz' # indented one level + if bar == 'baz': + print('ham') # indented two levels + return bar # indented one level +``` +The first line is not indented. The next two lines are indented to be inside of the function definition. They will only run when the function is called. The fourth line is indented to be inside the `if` statement, and will only run if the `if` statement evaluates to `True`. The fifth and last line is like the 2nd and 3rd and will always run when the function is called. It effectively closes the `if` statement above as no more lines can be inside the `if` statement below that line. + +**Indentation is used after:** +**1.** [Compound statements](https://docs.python.org/3/reference/compound_stmts.html) (eg. `if`, `while`, `for`, `try`, `with`, `def`, `class`, and their counterparts) +**2.** [Continuation lines](https://www.python.org/dev/peps/pep-0008/#indentation) + +**More Info** +**1.** [Indentation style guide](https://www.python.org/dev/peps/pep-0008/#indentation) +**2.** [Tabs or Spaces?](https://www.python.org/dev/peps/pep-0008/#tabs-or-spaces) +**3.** [Official docs on indentation](https://docs.python.org/3/reference/lexical_analysis.html#indentation) \ No newline at end of file diff --git a/bot/resources/tags/inline.md b/bot/resources/tags/inline.md new file mode 100644 index 000000000..4670256bc --- /dev/null +++ b/bot/resources/tags/inline.md @@ -0,0 +1,16 @@ +**Inline codeblocks** + +In addition to multi-line codeblocks, discord has support for inline codeblocks as well. These are small codeblocks that are usually a single line, that can fit between non-codeblocks on the same line. + +The following is an example of how it's done: + +The \`\_\_init\_\_\` method customizes the newly created instance. + +And results in the following: + +The `__init__` method customizes the newly created instance. + +**Note:** +• These are **backticks** not quotes +• Avoid using them for multiple lines +• Useful for negating formatting you don't want \ No newline at end of file diff --git a/bot/resources/tags/iterate-dict.md b/bot/resources/tags/iterate-dict.md new file mode 100644 index 000000000..b23475506 --- /dev/null +++ b/bot/resources/tags/iterate-dict.md @@ -0,0 +1,10 @@ +There are two common ways to iterate over a dictionary in Python. To iterate over the keys: +```py +for key in my_dict: + print(key) +``` +To iterate over both the keys and values: +```py +for key, val in my_dict.items(): + print(key, val) +``` \ No newline at end of file diff --git a/bot/resources/tags/listcomps.md b/bot/resources/tags/listcomps.md new file mode 100644 index 000000000..5ef0ce2bc --- /dev/null +++ b/bot/resources/tags/listcomps.md @@ -0,0 +1,14 @@ +Do you ever find yourself writing something like: +```py +even_numbers = [] +for n in range(20): + if n % 2 == 0: + even_numbers.append(n) +``` +Using list comprehensions can simplify this significantly, and greatly improve code readability. If we rewrite the example above to use list comprehensions, it would look like this: +```py +even_numbers = [n for n in range(20) if n % 2 == 0] +``` +This also works for generators, dicts and sets by using `()` or `{}` instead of `[]`. + +For more info, see [this pythonforbeginners.com post](http://www.pythonforbeginners.com/basics/list-comprehensions-in-python) or [PEP 202](https://www.python.org/dev/peps/pep-0202/). \ No newline at end of file diff --git a/bot/resources/tags/mutable-default-args.md b/bot/resources/tags/mutable-default-args.md new file mode 100644 index 000000000..49f536b78 --- /dev/null +++ b/bot/resources/tags/mutable-default-args.md @@ -0,0 +1,48 @@ +**Mutable Default Arguments** + +Default arguments in python are evaluated *once* when the function is +**defined**, *not* each time the function is **called**. This means that if +you have a mutable default argument and mutate it, you will have +mutated that object for all future calls to the function as well. + +For example, the following `append_one` function appends `1` to a list +and returns it. `foo` is set to an empty list by default. +```python +>>> def append_one(foo=[]): +... foo.append(1) +... return foo +... +``` +See what happens when we call it a few times: +```python +>>> append_one() +[1] +>>> append_one() +[1, 1] +>>> append_one() +[1, 1, 1] +``` +Each call appends an additional `1` to our list `foo`. It does not +receive a new empty list on each call, it is the same list everytime. + +To avoid this problem, you have to create a new object every time the +function is **called**: +```python +>>> def append_one(foo=None): +... if foo is None: +... foo = [] +... foo.append(1) +... return foo +... +>>> append_one() +[1] +>>> append_one() +[1] +``` + +**Note**: + +• This behavior can be used intentionally to maintain state between +calls of a function (eg. when writing a caching function). +• This behavior is not unique to mutable objects, all default +arguments are evaulated only once when the function is defined. \ No newline at end of file diff --git a/bot/resources/tags/names.md b/bot/resources/tags/names.md new file mode 100644 index 000000000..b7b914d53 --- /dev/null +++ b/bot/resources/tags/names.md @@ -0,0 +1,37 @@ +**Naming and Binding** + +A name is a piece of text that is bound to an object. They are a **reference** to an object. Examples are function names, class names, module names, variables, etc. + +**Note:** Names **cannot** reference other names, and assignment **never** creates a copy. +```py +x = 1 # x is bound to 1 +y = x # y is bound to VALUE of x +x = 2 # x is bound to 2 +print(x, y) # 2 1 +``` +When doing `y = x`, the name `y` is being bound to the *value* of `x` which is `1`. Neither `x` nor `y` are the 'real' name. The object `1` simply has *multiple* names. They are the exact same object. +``` +>>> x = 1 +x ━━ 1 + +>>> y = x +x ━━ 1 +y ━━━┛ + +>>> x = 2 +x ━━ 2 +y ━━ 1 +``` +**Names are created in multiple ways** +You might think that the only way to bind a name to an object is by using assignment, but that isn't the case. All of the following work exactly the same as assignment: +• `import` statements +• `class` and `def` +• `for` loop headers +• `as` keyword when used with `except`, `import`, and `with` +• formal parameters in function headers + +There is also `del` which has the purpose of *unbinding* a name. + +**More info** +• Please watch [Ned Batchelder's talk](https://youtu.be/_AEJHKGk9ns) on names in python for a detailed explanation with examples +• [Official documentation](https://docs.python.org/3/reference/executionmodel.html#naming-and-binding) \ No newline at end of file diff --git a/bot/resources/tags/off-topic.md b/bot/resources/tags/off-topic.md new file mode 100644 index 000000000..8fa70bf6e --- /dev/null +++ b/bot/resources/tags/off-topic.md @@ -0,0 +1,8 @@ +**Off-topic channels** + +There are three off-topic channels: +• <#291284109232308226> +• <#463035241142026251> +• <#463035268514185226> + +Their names change randomly every 24 hours, but you can always find them under the `OFF-TOPIC/GENERAL` category in the channel list. \ No newline at end of file diff --git a/bot/resources/tags/open.md b/bot/resources/tags/open.md new file mode 100644 index 000000000..74150dbc7 --- /dev/null +++ b/bot/resources/tags/open.md @@ -0,0 +1,26 @@ +**Opening files** + +The built-in function `open()` is one of several ways to open files on your computer. It accepts many different parameters, so this tag will only go over two of them (`file` and `mode`). For more extensive documentation on all these parameters, consult the [official documentation](https://docs.python.org/3/library/functions.html#open). The object returned from this function is a [file object or stream](https://docs.python.org/3/glossary.html#term-file-object), for which the full documentation can be found [here](https://docs.python.org/3/library/io.html#io.TextIOBase). + +See also: +• `!tags with` for information on context managers +• `!tags pathlib` for an alternative way of opening files +• `!tags seek` for information on changing your position in a file + +**The `file` parameter** + +This should be a [path-like object](https://docs.python.org/3/glossary.html#term-path-like-object) denoting the name or path (absolute or relative) to the file you want to open. + +An absolute path is the full path from your root directory to the file you want to open. Generally this is the option you should choose so it doesn't matter what directory you're in when you execute your module. + +See `!tags relative-path` for more information on relative paths. + +**The `mode` parameter** + +This is an optional string that specifies the mode in which the file should be opened. There's not enough room to discuss them all, but listed below are some of the more confusing modes. + +`'r+'` Opens for reading and writing (file must already exist) +`'w+'` Opens for reading and writing and truncates (can create files) +`'x'` Creates file and opens for writing (file must **not** already exist) +`'x+'` Creates file and opens for reading and writing (file must **not** already exist) +`'a+'` Opens file for reading and writing at **end of file** (can create files) \ No newline at end of file diff --git a/bot/resources/tags/or-gotcha.md b/bot/resources/tags/or-gotcha.md new file mode 100644 index 000000000..da82e3fdd --- /dev/null +++ b/bot/resources/tags/or-gotcha.md @@ -0,0 +1,17 @@ +When checking if something is equal to one thing or another, you might think that this is possible: +```py +if favorite_fruit == 'grapefruit' or 'lemon': + print("That's a weird favorite fruit to have.") +``` +After all, that's how you would normally phrase it in plain English. In Python, however, you have to have _complete instructions on both sides of the logical operator_. + +So, if you want to check if something is equal to one thing or another, there are two common ways: +```py +# Like this... +if favorite_fruit == 'grapefruit' or favorite_fruit == 'lemon': + print("That's a weird favorite fruit to have.") + +# ...or like this. +if favorite_fruit in ('grapefruit', 'lemon'): + print("That's a weird favorite fruit to have.") +``` \ No newline at end of file diff --git a/bot/resources/tags/param-arg.md b/bot/resources/tags/param-arg.md new file mode 100644 index 000000000..9e946812b --- /dev/null +++ b/bot/resources/tags/param-arg.md @@ -0,0 +1,12 @@ +**Parameters vs. Arguments** + +A parameter is a variable defined in a function signature (the line with `def` in it), while arguments are objects passed to a function call. + +```py +def square(n): # n is the parameter + return n*n + +print(square(5)) # 5 is the argument +``` + +Note that `5` is the argument passed to `square`, but `square(5)` in its entirety is the argument passed to `print` \ No newline at end of file diff --git a/bot/resources/tags/paste.md b/bot/resources/tags/paste.md new file mode 100644 index 000000000..d8e6e6c61 --- /dev/null +++ b/bot/resources/tags/paste.md @@ -0,0 +1,6 @@ +**Pasting large amounts of code** + +If your code is too long to fit in a codeblock in discord, you can paste your code here: +https://paste.pydis.com/ + +After pasting your code, **save** it by clicking the floppy disk icon in the top right, or by typing `ctrl + S`. After doing that, the URL should **change**. Copy the URL and post it here so others can see it. \ No newline at end of file diff --git a/bot/resources/tags/pathlib.md b/bot/resources/tags/pathlib.md new file mode 100644 index 000000000..37913951d --- /dev/null +++ b/bot/resources/tags/pathlib.md @@ -0,0 +1,21 @@ +**Pathlib** + +Python 3 comes with a new module named `Pathlib`. Since Python 3.6, `pathlib.Path` objects work nearly everywhere that `os.path` can be used, meaning you can integrate your new code directly into legacy code without having to rewrite anything. Pathlib makes working with paths way simpler than `os.path` does. + +**Feature spotlight**: + +• Normalizes file paths for all platforms automatically +• Has glob-like utilites (eg. `Path.glob`, `Path.rglob`) for searching files +• Can read and write files, and close them automatically +• Convenient syntax, utilising the `/` operator (e.g. `Path('~') / 'Documents'`) +• Can easily pick out components of a path (eg. name, parent, stem, suffix, anchor) +• Supports method chaining +• Move and delete files +• And much more + +**More Info**: + +• [**Why you should use pathlib** - Trey Hunner](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/) +• [**Answering concerns about pathlib** - Trey Hunner](https://treyhunner.com/2019/01/no-really-pathlib-is-great/) +• [**Official Documentation**](https://docs.python.org/3/library/pathlib.html) +• [**PEP 519** - Adding a file system path protocol](https://www.python.org/dev/peps/pep-0519/) \ No newline at end of file diff --git a/bot/resources/tags/pep8.md b/bot/resources/tags/pep8.md new file mode 100644 index 000000000..ec999bedc --- /dev/null +++ b/bot/resources/tags/pep8.md @@ -0,0 +1,3 @@ +**PEP 8** is the official style guide for Python. It includes comprehensive guidelines for code formatting, variable naming, and making your code easy to read. Professional Python developers are usually required to follow the guidelines, and will often use code-linters like `flake8` to verify that the code they\'re writing complies with the style guide. + +You can find the PEP 8 document [here](https://www.python.org/dev/peps/pep-0008). \ No newline at end of file diff --git a/bot/resources/tags/positional-keyword.md b/bot/resources/tags/positional-keyword.md new file mode 100644 index 000000000..3faec32ca --- /dev/null +++ b/bot/resources/tags/positional-keyword.md @@ -0,0 +1,38 @@ +**Positional vs. Keyword arguments** + +Functions can take two different kinds of arguments. A positional argument is just the object itself. A keyword argument is a name assigned to an object. + +**Example** +```py +>>> print('Hello', 'world!', sep=', ') +Hello, world! +``` +The first two strings `'Hello'` and `world!'` are positional arguments. +The `sep=', '` is a keyword argument. + +**Note** +A keyword argument can be passed positionally in some cases. +```py +def sum(a, b=1): + return a + b + +sum(1, b=5) +sum(1, 5) # same as above +``` +[Somtimes this is forced](https://www.python.org/dev/peps/pep-0570/#history-of-positional-only-parameter-semantics-in-python), in the case of the `pow()` function. + +The reverse is also true: +```py +>>> def foo(a, b): +... print(a, b) +... +>>> foo(a=1, b=2) +1 2 +>>> foo(b=1, a=2) +2 1 +``` + +**More info** +• [Keyword only arguments](https://www.python.org/dev/peps/pep-3102/) +• [Positional only arguments](https://www.python.org/dev/peps/pep-0570/) +• `!tags param-arg` (Parameters vs. Arguments) \ No newline at end of file diff --git a/bot/resources/tags/precedence.md b/bot/resources/tags/precedence.md new file mode 100644 index 000000000..8a4c66c4e --- /dev/null +++ b/bot/resources/tags/precedence.md @@ -0,0 +1,13 @@ +**Operator Precedence** + +Operator precedence is essentially like an order of operations for python's operators. + +**Example 1** (arithmetic) +`2 * 3 + 1` is `7` because multiplication is first +`2 * (3 + 1)` is `8` because the parenthesis change the precedence allowing the sum to be first + +**Example 2** (logic) +`not True or True` is `True` because the `not` is first +`not (True or True)` is `False` because the `or` is first + +The full table of precedence from lowest to highest is [here](https://docs.python.org/3/reference/expressions.html#operator-precedence) \ No newline at end of file diff --git a/bot/resources/tags/quotes.md b/bot/resources/tags/quotes.md new file mode 100644 index 000000000..609b6d2d2 --- /dev/null +++ b/bot/resources/tags/quotes.md @@ -0,0 +1,20 @@ +**String Quotes** + +Single and Double quoted strings are the **same** in Python. The choice of which one to use is up to you, just make sure that you **stick to that choice**. + +With that said, there are exceptions to this that are more important than consistency. If a single or double quote is needed *inside* the string, using the opposite quotation is better than using escape characters. + +Example: +```py +'My name is "Guido"' # good +"My name is \"Guido\"" # bad + +"Don't go in there" # good +'Don\'t go in there' # bad +``` +**Note:** +If you need both single and double quotes inside your string, use the version that would result in the least amount of escapes. In the case of a tie, use the quotation you use the most. + +**References:** +• [pep-8 on quotes](https://www.python.org/dev/peps/pep-0008/#string-quotes) +• [convention for triple quoted strings](https://www.python.org/dev/peps/pep-0257/) \ No newline at end of file diff --git a/bot/resources/tags/relative-path.md b/bot/resources/tags/relative-path.md new file mode 100644 index 000000000..269276e81 --- /dev/null +++ b/bot/resources/tags/relative-path.md @@ -0,0 +1,7 @@ +**Relative Path** + +A relative path is a partial path that is relative to your current working directory. A common misconception is that your current working directory is the location of the module you're executing, **but this is not the case**. Your current working directory is actually the **directory you were in when you ran the python interpreter**. The reason for this misconception is because a common way to run your code is to navigate to the directory your module is stored, and run `python .py`. Thus, in this case your current working directory will be the same as the location of the module. However, if we instead did `python path/to/.py`, our current working directory would no longer be the same as the location of the module we're executing. + +**Why is this important?** + +When opening files in python, relative paths won't always work since it's dependent on what directory you were in when you ran your code. A common issue people face is running their code in an IDE thinking they can open files that are in the same directory as their module, but the current working directory will be different than what they expect and so they won't find the file. The way to avoid this problem is by using absolute paths, which is the full path from your root directory to the file you want to open. \ No newline at end of file diff --git a/bot/resources/tags/repl.md b/bot/resources/tags/repl.md new file mode 100644 index 000000000..a68fe9397 --- /dev/null +++ b/bot/resources/tags/repl.md @@ -0,0 +1,13 @@ +**Read-Eval-Print Loop** + +A REPL is an interactive language shell environment. It first **reads** one or more expressions entered by the user, **evaluates** it, yields the result, and **prints** it out to the user. It will then **loop** back to the **read** step. + +To use python's REPL, execute the interpreter with no arguments. This will drop you into the interactive interpreter shell, print out some relevant information, and then prompt you with the primary prompt `>>>`. At this point it is waiting for your input. + +Firstly you can start typing in some valid python expressions, pressing to either bring you to the **eval** step, or prompting you with the secondary prompt `...` (or no prompt at all depending on your environment), meaning your expression isn't yet terminated and it's waiting for more input. This is useful for code that requires multiple lines like loops, functions, and classes. If you reach the secondary prompt in a clause that can have an arbitrary amount of expressions, you can terminate it by pressing on a blank line. In other words, for the last expression you write in the clause, must be pressed twice in a row. + +Alternatively, you can make use of the builtin `help()` function. `help(thing)` to get help on some `thing` object, or `help()` to start an interactive help session. This mode is extremely powerful, read the instructions when first entering the session to learn how to use it. + +Lastly you can run your code with the `-i` flag to execute your code normally, but be dropped into the REPL once execution is finished, giving you access to all your global variables/functions in the REPL. + +To **exit** either a help session, or normal REPL prompt, you must send an EOF signal to the prompt. In *nix systems, this is done with `ctrl + D`, and in windows systems it is `ctrl + Z`. You can also exit the normal REPL prompt with the dedicated functions `exit()` or `quit()`. \ No newline at end of file diff --git a/bot/resources/tags/return.md b/bot/resources/tags/return.md new file mode 100644 index 000000000..7e0cdaa98 --- /dev/null +++ b/bot/resources/tags/return.md @@ -0,0 +1,35 @@ +**Return Statement** + +When calling a function, you'll often want it to give you a value back. In order to do that, you must `return` it. The reason for this is because functions have their own scope. Any values defined within the function body are inaccessible outside of that function. + +*For more information about scope, see `!tags scope`* + +Consider the following function: +```py +def square(n): + return n*n +``` +If we wanted to store 5 squared in a variable called `x`, we could do that like so: +`x = square(5)`. `x` would now equal `25`. + +**Common Mistakes** +```py +>>> def square(n): +... n*n # calculates then throws away, returns None +... +>>> x = square(5) +>>> print(x) +None +>>> def square(n): +... print(n*n) # calculates and prints, then throws away and returns None +... +>>> x = square(5) +25 +>>> print(x) +None +``` +**Things to note** +• `print()` and `return` do **not** accomplish the same thing. `print()` will only print the value, it will not be accessible outside of the function afterwards. +• A function will return `None` if it ends without reaching an explicit `return` statement. +• When you want to print a value calculated in a function, instead of printing inside the function, it is often better to return the value and print the *function call* instead. +• [Official documentation for `return`](https://docs.python.org/3/reference/simple_stmts.html#the-return-statement) \ No newline at end of file diff --git a/bot/resources/tags/round.md b/bot/resources/tags/round.md new file mode 100644 index 000000000..3e33c8ff7 --- /dev/null +++ b/bot/resources/tags/round.md @@ -0,0 +1,24 @@ +**Round half to even** + +Python 3 uses bankers' rounding (also known by other names), where if the fractional part of a number is `.5`, it's rounded to the nearest **even** result instead of away from zero. + +Example: +```py +>>> round(2.5) +2 +>>> round(1.5) +2 +``` +In the first example, there is a tie between 2 and 3, and since 3 is odd and 2 is even, the result is 2. +In the second example, the tie is between 1 and 2, and so 2 is also the result. + +**Why this is done:** +The round half up technique creates a slight bias towards the larger number. With a large amount of calculations, this can be significant. The round half to even technique eliminates this bias. + +It should be noted that round half to even distorts the distribution by increasing the probability of evens relative to odds, however this is considered less important than the bias explained above. + +**References:** +• [Wikipedia article about rounding](https://en.wikipedia.org/wiki/Rounding#Round_half_to_even) +• [Documentation on `round` function](https://docs.python.org/3/library/functions.html#round) +• [`round` in what's new in python 3](https://docs.python.org/3/whatsnew/3.0.html#builtins) (4th bullet down) +• [How to force rounding technique](https://stackoverflow.com/a/10826537/4607272) \ No newline at end of file diff --git a/bot/resources/tags/scope.md b/bot/resources/tags/scope.md new file mode 100644 index 000000000..ff9d96637 --- /dev/null +++ b/bot/resources/tags/scope.md @@ -0,0 +1,24 @@ +**Scoping Rules** + +A *scope* defines the visibility of a name within a block, where a block is a piece of python code executed as a unit. For simplicity, this would be a module, a function body, and a class definition. A name refers to text bound to an object. + +*For more information about names, see `!tags names`* + +A module is the source code file itself, and encompasses all blocks defined within it. Therefore if a variable is defined at the module level (top-level code block), it is a global variable and can be accessed anywhere in the module as long as the block in which it's referenced is executed after it was defined. + +Alternatively if a variable is defined within a function block for example, it is a local variable. It is not accessible at the module level, as that would be *outside* its scope. This is the purpose of the `return` statement, as it hands an object back to the scope of its caller. Conversely if a function was defined *inside* the previously mentioned block, it *would* have access to that variable, because it is within the first function's scope. +```py +>>> def outer(): +... foo = 'bar' # local variable to outer +... def inner(): +... print(foo) # has access to foo from scope of outer +... return inner # brings inner to scope of caller +... +>>> inner = outer() # get inner function +>>> inner() # prints variable foo without issue +bar +``` +**Official Documentation** +**1.** [Program structure, name binding and resolution](https://docs.python.org/3/reference/executionmodel.html#execution-model) +**2.** [`global` statement](https://docs.python.org/3/reference/simple_stmts.html#the-global-statement) +**3.** [`nonlocal` statement](https://docs.python.org/3/reference/simple_stmts.html#the-nonlocal-statement) \ No newline at end of file diff --git a/bot/resources/tags/seek.md b/bot/resources/tags/seek.md new file mode 100644 index 000000000..ada23fd00 --- /dev/null +++ b/bot/resources/tags/seek.md @@ -0,0 +1,22 @@ +**Seek** + +In the context of a [file object](https://docs.python.org/3/glossary.html#term-file-object), the `seek` function changes the stream position to a given byte offset, with an optional argument of where to offset from. While you can find the official documentation [here](https://docs.python.org/3/library/io.html#io.IOBase.seek), it can be unclear how to actually use this feature, so keep reading to see examples on how to use it. + +File named `example`: +``` +foobar +spam eggs +``` +Open file for reading in byte mode: +```py +f = open('example', 'rb') +``` +Note that stream positions start from 0 in much the same way that the index for a list does. If we do `f.seek(3, 0)`, our stream position will move 3 bytes forward relative to the **beginning** of the stream. Now if we then did `f.read(1)` to read a single byte from where we are in the stream, it would return the string `'b'` from the 'b' in 'foobar'. Notice that the 'b' is the 4th character. Also note that after we did `f.read(1)`, we moved the stream position again 1 byte forward relative to the **current** position in the stream. So the stream position is now currently at position 4. + +Now lets do `f.seek(4, 1)`. This will move our stream position 4 bytes forward relative to our **current** position in the stream. Now if we did `f.read(1)`, it would return the string `'p'` from the 'p' in 'spam' on the next line. Note this time that the character at position 6 is the newline character `'\n'`. + +Finally, lets do `f.seek(-4, 2)`, moving our stream position *backwards* 4 bytes relative to the **end** of the stream. Now if we did `f.read()` to read everything after our position in the file, it would return the string `'eggs'` and also move our stream position to the end of the file. + +**Note** +• For the second argument in `seek()`, use `os.SEEK_SET`, `os.SEEK_CUR`, and `os.SEEK_END` in place of 0, 1, and 2 respectively. +• `os.SEEK_CUR` is only usable when the file is in byte mode. \ No newline at end of file diff --git a/bot/resources/tags/self.md b/bot/resources/tags/self.md new file mode 100644 index 000000000..a9cd5e9df --- /dev/null +++ b/bot/resources/tags/self.md @@ -0,0 +1,25 @@ +**Class instance** + +When calling a method from a class instance (ie. `instance.method()`), the instance itself will automatically be passed as the first argument implicitly. By convention, we call this `self`, but it could technically be called any valid variable name. + +```py +class Foo: + def bar(self): + print('bar') + + def spam(self, eggs): + print(eggs) + +foo = Foo() +``` + +If we call `foo.bar()`, it is equivalent to doing `Foo.bar(foo)`. Our instance `foo` is passed for us to the `bar` function, so while we initially gave zero arguments, it is actually called with one. + +Similarly if we call `foo.spam('ham')`, it is equivalent to +doing `Foo.spam(foo, 'ham')`. + +**Why is this useful?** + +Methods do not inherently have access to attributes defined in the class. In order for any one method to be able to access other methods or variables defined in the class, it must have access to the instance. + +Consider if outside the class, we tried to do this: `spam(foo, 'ham')`. This would give an error, because we don't have access to the `spam` method directly, we have to call it by doing `foo.spam('ham')`. This is also the case inside of the class. If we wanted to call the `bar` method inside the `spam` method, we'd have to do `self.bar()`, just doing `bar()` would give an error. \ No newline at end of file diff --git a/bot/resources/tags/star-imports.md b/bot/resources/tags/star-imports.md new file mode 100644 index 000000000..4c7e0199c --- /dev/null +++ b/bot/resources/tags/star-imports.md @@ -0,0 +1,48 @@ +**Star / Wildcard imports** + +Wildcard imports are import statements in the form `from import *`. What imports like these do is that they import everything **[1]** from the module into the current module's namespace **[2]**. This allows you to use names defined in the imported module without prefixing the module's name. + +Example: +```python +>>> from math import * +>>> sin(pi / 2) +1.0 +``` +**This is discouraged, for various reasons:** + +Example: +```python +>>> from custom_sin import sin +>>> from math import * +>>> sin(pi / 2) # uses sin from math rather than your custom sin +``` + +• Potential namespace collision. Names defined from a previous import might get shadowed by a wildcard import. + +• Causes ambiguity. From the example, it is unclear which `sin` function is actually being used. From the Zen of Python **[3]**: `Explicit is better than implicit.` + +• Makes import order significant, which they shouldn't. Certain IDE's `sort import` functionality may end up breaking code due to namespace collision. + +**How should you import?** + +• Import the module under the module's namespace (Only import the name of the module, and names defined in the module can be used by prefixing the module's name) + +```python +>>> import math +>>> math.sin(math.pi / 2) +``` + +• Explicitly import certain names from the module + +```python +>>> from math import sin, pi +>>> sin(pi / 2) +``` + +Conclusion: Namespaces are one honking great idea -- let's do more of those! *[3]* + +**[1]** If the module defines the variable `__all__`, the names defined in `__all__` will get imported by the wildcard import, otherwise all the names in the module get imported (except for names with a leading underscore) + +**[2]** [Namespaces and scopes](https://www.programiz.com/python-programming/namespace) + +**[3]** [Zen of Python](https://www.python.org/dev/peps/pep-0020/) \ No newline at end of file diff --git a/bot/resources/tags/traceback.md b/bot/resources/tags/traceback.md new file mode 100644 index 000000000..74401abf0 --- /dev/null +++ b/bot/resources/tags/traceback.md @@ -0,0 +1,18 @@ +Please provide a full traceback to your exception in order for us to identify your issue. + +A full traceback could look like: +```py +Traceback (most recent call last): + File "tiny", line 3, in + do_something() + File "tiny", line 2, in do_something + a = 6 / 0 +ZeroDivisionError: integer division or modulo by zero +``` +The best way to read your traceback is bottom to top. + +• Identify the exception raised (e.g. ZeroDivisonError) +• Make note of the line number, and navigate there in your program. +• Try to understand why the error occurred. + +To read more about exceptions and errors, please refer to the [PyDis Wiki](https://pythondiscord.com/pages/asking-good-questions/#examining-tracebacks) or the [official Python tutorial.](https://docs.python.org/3.7/tutorial/errors.html) \ No newline at end of file diff --git a/bot/resources/tags/windows-path.md b/bot/resources/tags/windows-path.md new file mode 100644 index 000000000..d8723f06f --- /dev/null +++ b/bot/resources/tags/windows-path.md @@ -0,0 +1,30 @@ +**PATH on Windows** + +If you have installed Python but you forgot to check the *Add Python to PATH* option during the installation you may still be able to access your installation with ease. + +If you did not uncheck the option to install the Python launcher then you will find a `py` command on your system. If you want to be able to open your Python installation by running `python` then your best option is to re-install Python. + +Otherwise, you can access your install using the `py` command in Command Prompt. Where you may type something with the `python` command like: +``` +C:\Users\Username> python3 my_application_file.py +``` + +You can achieve the same result using the `py` command like this: +``` +C:\Users\Username> py -3 my_application_file.py +``` + +You can pass any options to the Python interpreter after you specify a version, for example, to install a Python module using `pip` you can run: +``` +C:\Users\Username> py -3 -m pip install numpy +``` + +You can also access different versions of Python using the version flag, like so: +``` +C:\Users\Username> py -3.7 +... Python 3.7 starts ... +C:\Users\Username> py -3.6 +... Python 3.6 stars ... +C:\Users\Username> py -2 +... Python 2 (any version installed) starts ... +``` \ No newline at end of file diff --git a/bot/resources/tags/with.md b/bot/resources/tags/with.md new file mode 100644 index 000000000..a79eb7dbb --- /dev/null +++ b/bot/resources/tags/with.md @@ -0,0 +1,8 @@ +The `with` keyword triggers a context manager. Context managers automatically set up and take down data connections, or any other kind of object that implements the magic methods `__enter__` and `__exit__`. +```py +with open("test.txt", "r") as file: + do_things(file) +``` +The above code automatically closes `file` when the `with` block exits, so you never have to manually do a `file.close()`. Most connection types, including file readers and database connections, support this. + +For more information, read [the official docs](https://docs.python.org/3/reference/compound_stmts.html#with), watch [Corey Schafer\'s context manager video](https://www.youtube.com/watch?v=-aKFBoZpiqA), or see [PEP 343](https://www.python.org/dev/peps/pep-0343/). \ No newline at end of file diff --git a/bot/resources/tags/xy-problem.md b/bot/resources/tags/xy-problem.md new file mode 100644 index 000000000..77700e7a0 --- /dev/null +++ b/bot/resources/tags/xy-problem.md @@ -0,0 +1,7 @@ +**xy-problem** + +Asking about your attempted solution rather than your actual problem. + +Often programmers will get distracted with a potential solution they've come up with, and will try asking for help getting it to work. However, it's possible this solution either wouldn't work as they expect, or there's a much better solution instead. + +For more information and examples: http://xyproblem.info/ \ No newline at end of file diff --git a/bot/resources/tags/ytdl.md b/bot/resources/tags/ytdl.md new file mode 100644 index 000000000..e1085d1af --- /dev/null +++ b/bot/resources/tags/ytdl.md @@ -0,0 +1,9 @@ +Per [PyDis' Rule 5](https://pythondiscord.com/pages/rules), we are unable to assist with questions related to youtube-dl, commonly used by Discord bots to stream audio, as its use violates YouTube's Terms of Service. + +For reference, this usage is covered by the following clauses in [YouTube's TOS](https://www.youtube.com/static?template=terms), as of 2018-05-25: +``` +4A: You agree not to distribute in any medium any part of the Service or the Content without YouTube's prior written authorization, unless YouTube makes available the means for such distribution through functionality offered by the Service (such as the Embeddable Player). +``` +``` +4C: You agree not to access Content through any technology or means other than the video playback pages of the Service itself, the Embeddable Player, or other explicitly authorized means YouTube may designate. +``` \ No newline at end of file diff --git a/bot/resources/tags/zen.md b/bot/resources/tags/zen.md new file mode 100644 index 000000000..3e132eed8 --- /dev/null +++ b/bot/resources/tags/zen.md @@ -0,0 +1,20 @@ + +Beautiful is better than ugly. +Explicit is better than implicit. +Simple is better than complex. +Complex is better than complicated. +Flat is better than nested. +Sparse is better than dense. +Readability counts. +Special cases aren't special enough to break the rules. +Although practicality beats purity. +Errors should never pass silently. +Unless explicitly silenced. +In the face of ambiguity, refuse the temptation to guess. +There should be one-- and preferably only one --obvious way to do it. +Although that way may not be obvious at first unless you're Dutch. +Now is better than never. +Although never is often better than *right* now. +If the implementation is hard to explain, it's a bad idea. +If the implementation is easy to explain, it may be a good idea. +Namespaces are one honking great idea -- let's do more of those! diff --git a/bot/resources/tags/zip.md b/bot/resources/tags/zip.md new file mode 100644 index 000000000..9d2fe5ee3 --- /dev/null +++ b/bot/resources/tags/zip.md @@ -0,0 +1,12 @@ +The zip function allows you to iterate through multiple iterables simultaneously. It joins the iterables together, almost like a zipper, so that each new element is a tuple with one element from each iterable. + +```py +letters = 'abc' +numbers = [1, 2, 3] +# zip(letters, numbers) --> [('a', 1), ('b', 2), ('c', 3)] +for letter, number in zip(letters, numbers): + print(letter, number) +``` +The `zip()` iterator is exhausted after the length of the shortest iterable is exceeded. If you would like to retain the other values, consider using [itertools.zip_longest](https://docs.python.org/3/library/itertools.html#itertools.zip_longest). + +For more information on zip, please refer to the [official documentation](https://docs.python.org/3/library/functions.html#zip). \ No newline at end of file -- cgit v1.2.3 From f2563465396ab381d85f98b55b7a91b2ad00ed04 Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Sat, 29 Feb 2020 14:50:45 +0530 Subject: added white spaces on statements before bullet points for proper rendering of points on github --- bot/resources/tags/args-kwargs.md | 10 +++++----- bot/resources/tags/ask.md | 8 ++++---- bot/resources/tags/codeblock.md | 8 ++++---- bot/resources/tags/decorators.md | 6 +++--- bot/resources/tags/foo.md | 4 ++-- bot/resources/tags/inline.md | 6 +++--- bot/resources/tags/mutable-default-args.md | 2 +- bot/resources/tags/names.md | 18 +++++++++--------- bot/resources/tags/off-topic.md | 8 ++++---- bot/resources/tags/open.md | 8 ++++---- bot/resources/tags/pathlib.md | 24 ++++++++++++------------ bot/resources/tags/positional-keyword.md | 8 ++++---- bot/resources/tags/quotes.md | 4 ++-- bot/resources/tags/return.md | 10 +++++----- bot/resources/tags/round.md | 10 +++++----- bot/resources/tags/scope.md | 6 +++--- bot/resources/tags/seek.md | 4 ++-- bot/resources/tags/traceback.md | 6 +++--- 18 files changed, 75 insertions(+), 75 deletions(-) diff --git a/bot/resources/tags/args-kwargs.md b/bot/resources/tags/args-kwargs.md index fb19d39fd..de883dea8 100644 --- a/bot/resources/tags/args-kwargs.md +++ b/bot/resources/tags/args-kwargs.md @@ -8,10 +8,10 @@ These special parameters allow functions to take arbitrary amounts of positional **Double asterisk** `**kwargs` will ingest an arbitrary amount of **keyword arguments**, and store it in a dictionary. There can be **no** additional parameters **after** `**kwargs` in the parameter list. -**Use cases** -• **Decorators** (see `!tags decorators`) -• **Inheritance** (overriding methods) -• **Future proofing** (in the case of the first two bullet points, if the parameters change, your code won't break) -• **Flexibility** (writing functions that behave like `dict()` or `print()`) +**Use cases** +• **Decorators** (see `!tags decorators`) +• **Inheritance** (overriding methods) +• **Future proofing** (in the case of the first two bullet points, if the parameters change, your code won't break) +• **Flexibility** (writing functions that behave like `dict()` or `print()`) *See* `!tags positional-keyword` *for information about positional and keyword arguments* \ No newline at end of file diff --git a/bot/resources/tags/ask.md b/bot/resources/tags/ask.md index 07f9bd84d..ed651e8c5 100644 --- a/bot/resources/tags/ask.md +++ b/bot/resources/tags/ask.md @@ -1,9 +1,9 @@ Asking good questions will yield a much higher chance of a quick response: -• Don't ask to ask your question, just go ahead and tell us your problem. -• Don't ask if anyone is knowledgeable in some area, filtering serves no purpose. -• Try to solve the problem on your own first, we're not going to write code for you. -• Show us the code you've tried and any errors or unexpected results it's giving. +• Don't ask to ask your question, just go ahead and tell us your problem. +• Don't ask if anyone is knowledgeable in some area, filtering serves no purpose. +• Try to solve the problem on your own first, we're not going to write code for you. +• Show us the code you've tried and any errors or unexpected results it's giving. • Be patient while we're helping you. You can find a much more detailed explanation [on our website](https://pythondiscord.com/pages/asking-good-questions/). \ No newline at end of file diff --git a/bot/resources/tags/codeblock.md b/bot/resources/tags/codeblock.md index 816bb8232..34db060ef 100644 --- a/bot/resources/tags/codeblock.md +++ b/bot/resources/tags/codeblock.md @@ -6,10 +6,10 @@ To do this, use the following method: print('Hello world!') \``` -Note: -• **These are backticks, not quotes.** Backticks can usually be found on the tilde key. -• You can also use py as the language instead of python -• The language must be on the first line next to the backticks with **no** space between them +Note: +• **These are backticks, not quotes.** Backticks can usually be found on the tilde key. +• You can also use py as the language instead of python +• The language must be on the first line next to the backticks with **no** space between them This will result in the following: ```py diff --git a/bot/resources/tags/decorators.md b/bot/resources/tags/decorators.md index 3ff1db16c..9b53af064 100644 --- a/bot/resources/tags/decorators.md +++ b/bot/resources/tags/decorators.md @@ -26,6 +26,6 @@ Time elapsed: 3.000307321548462 Finished! ``` -More information: -• [Corey Schafer's video on decorators](https://youtu.be/FsAPt_9Bf3U) -• [Real python article](https://realpython.com/primer-on-python-decorators/) \ No newline at end of file +More information: +• [Corey Schafer's video on decorators](https://youtu.be/FsAPt_9Bf3U) +• [Real python article](https://realpython.com/primer-on-python-decorators/) \ No newline at end of file diff --git a/bot/resources/tags/foo.md b/bot/resources/tags/foo.md index 58bc4b78f..2b5b659fd 100644 --- a/bot/resources/tags/foo.md +++ b/bot/resources/tags/foo.md @@ -5,6 +5,6 @@ A specific word or set of words identified as a placeholder used in programming. Common examples include `foobar`, `foo`, `bar`, `baz`, and `qux`. Python has its own metasyntactic variables, namely `spam`, `eggs`, and `bacon`. This is a reference to a [Monty Python](https://en.wikipedia.org/wiki/Monty_Python) sketch (the eponym of the language). -More information: -• [History of foobar](https://en.wikipedia.org/wiki/Foobar) +More information: +• [History of foobar](https://en.wikipedia.org/wiki/Foobar) • [Monty Python sketch](https://en.wikipedia.org/wiki/Spam_%28Monty_Python%29) \ No newline at end of file diff --git a/bot/resources/tags/inline.md b/bot/resources/tags/inline.md index 4670256bc..d0c9d1b5e 100644 --- a/bot/resources/tags/inline.md +++ b/bot/resources/tags/inline.md @@ -10,7 +10,7 @@ And results in the following: The `__init__` method customizes the newly created instance. -**Note:** -• These are **backticks** not quotes -• Avoid using them for multiple lines +**Note:** +• These are **backticks** not quotes +• Avoid using them for multiple lines • Useful for negating formatting you don't want \ No newline at end of file diff --git a/bot/resources/tags/mutable-default-args.md b/bot/resources/tags/mutable-default-args.md index 49f536b78..7b16e6b82 100644 --- a/bot/resources/tags/mutable-default-args.md +++ b/bot/resources/tags/mutable-default-args.md @@ -43,6 +43,6 @@ function is **called**: **Note**: • This behavior can be used intentionally to maintain state between -calls of a function (eg. when writing a caching function). +calls of a function (eg. when writing a caching function). • This behavior is not unique to mutable objects, all default arguments are evaulated only once when the function is defined. \ No newline at end of file diff --git a/bot/resources/tags/names.md b/bot/resources/tags/names.md index b7b914d53..462c550bc 100644 --- a/bot/resources/tags/names.md +++ b/bot/resources/tags/names.md @@ -22,16 +22,16 @@ y ━━━┛ x ━━ 2 y ━━ 1 ``` -**Names are created in multiple ways** -You might think that the only way to bind a name to an object is by using assignment, but that isn't the case. All of the following work exactly the same as assignment: -• `import` statements -• `class` and `def` -• `for` loop headers -• `as` keyword when used with `except`, `import`, and `with` -• formal parameters in function headers +**Names are created in multiple ways** +You might think that the only way to bind a name to an object is by using assignment, but that isn't the case. All of the following work exactly the same as assignment: +• `import` statements +• `class` and `def` +• `for` loop headers +• `as` keyword when used with `except`, `import`, and `with` +• formal parameters in function headers There is also `del` which has the purpose of *unbinding* a name. -**More info** -• Please watch [Ned Batchelder's talk](https://youtu.be/_AEJHKGk9ns) on names in python for a detailed explanation with examples +**More info** +• Please watch [Ned Batchelder's talk](https://youtu.be/_AEJHKGk9ns) on names in python for a detailed explanation with examples • [Official documentation](https://docs.python.org/3/reference/executionmodel.html#naming-and-binding) \ No newline at end of file diff --git a/bot/resources/tags/off-topic.md b/bot/resources/tags/off-topic.md index 8fa70bf6e..004adfa17 100644 --- a/bot/resources/tags/off-topic.md +++ b/bot/resources/tags/off-topic.md @@ -1,8 +1,8 @@ **Off-topic channels** -There are three off-topic channels: -• <#291284109232308226> -• <#463035241142026251> -• <#463035268514185226> +There are three off-topic channels: +• <#291284109232308226> +• <#463035241142026251> +• <#463035268514185226> Their names change randomly every 24 hours, but you can always find them under the `OFF-TOPIC/GENERAL` category in the channel list. \ No newline at end of file diff --git a/bot/resources/tags/open.md b/bot/resources/tags/open.md index 74150dbc7..1ba19dedd 100644 --- a/bot/resources/tags/open.md +++ b/bot/resources/tags/open.md @@ -2,10 +2,10 @@ The built-in function `open()` is one of several ways to open files on your computer. It accepts many different parameters, so this tag will only go over two of them (`file` and `mode`). For more extensive documentation on all these parameters, consult the [official documentation](https://docs.python.org/3/library/functions.html#open). The object returned from this function is a [file object or stream](https://docs.python.org/3/glossary.html#term-file-object), for which the full documentation can be found [here](https://docs.python.org/3/library/io.html#io.TextIOBase). -See also: -• `!tags with` for information on context managers -• `!tags pathlib` for an alternative way of opening files -• `!tags seek` for information on changing your position in a file +See also: +• `!tags with` for information on context managers +• `!tags pathlib` for an alternative way of opening files +• `!tags seek` for information on changing your position in a file **The `file` parameter** diff --git a/bot/resources/tags/pathlib.md b/bot/resources/tags/pathlib.md index 37913951d..468945cc5 100644 --- a/bot/resources/tags/pathlib.md +++ b/bot/resources/tags/pathlib.md @@ -4,18 +4,18 @@ Python 3 comes with a new module named `Pathlib`. Since Python 3.6, `pathlib.Pat **Feature spotlight**: -• Normalizes file paths for all platforms automatically -• Has glob-like utilites (eg. `Path.glob`, `Path.rglob`) for searching files -• Can read and write files, and close them automatically -• Convenient syntax, utilising the `/` operator (e.g. `Path('~') / 'Documents'`) -• Can easily pick out components of a path (eg. name, parent, stem, suffix, anchor) -• Supports method chaining -• Move and delete files -• And much more +• Normalizes file paths for all platforms automatically +• Has glob-like utilites (eg. `Path.glob`, `Path.rglob`) for searching files +• Can read and write files, and close them automatically +• Convenient syntax, utilising the `/` operator (e.g. `Path('~') / 'Documents'`) +• Can easily pick out components of a path (eg. name, parent, stem, suffix, anchor) +• Supports method chaining +• Move and delete files +• And much more **More Info**: -• [**Why you should use pathlib** - Trey Hunner](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/) -• [**Answering concerns about pathlib** - Trey Hunner](https://treyhunner.com/2019/01/no-really-pathlib-is-great/) -• [**Official Documentation**](https://docs.python.org/3/library/pathlib.html) -• [**PEP 519** - Adding a file system path protocol](https://www.python.org/dev/peps/pep-0519/) \ No newline at end of file +• [**Why you should use pathlib** - Trey Hunner](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/) +• [**Answering concerns about pathlib** - Trey Hunner](https://treyhunner.com/2019/01/no-really-pathlib-is-great/) +• [**Official Documentation**](https://docs.python.org/3/library/pathlib.html) +• [**PEP 519** - Adding a file system path protocol](https://www.python.org/dev/peps/pep-0519/) \ No newline at end of file diff --git a/bot/resources/tags/positional-keyword.md b/bot/resources/tags/positional-keyword.md index 3faec32ca..bc7f68ee0 100644 --- a/bot/resources/tags/positional-keyword.md +++ b/bot/resources/tags/positional-keyword.md @@ -32,7 +32,7 @@ The reverse is also true: 2 1 ``` -**More info** -• [Keyword only arguments](https://www.python.org/dev/peps/pep-3102/) -• [Positional only arguments](https://www.python.org/dev/peps/pep-0570/) -• `!tags param-arg` (Parameters vs. Arguments) \ No newline at end of file +**More info** +• [Keyword only arguments](https://www.python.org/dev/peps/pep-3102/) +• [Positional only arguments](https://www.python.org/dev/peps/pep-0570/) +• `!tags param-arg` (Parameters vs. Arguments) \ No newline at end of file diff --git a/bot/resources/tags/quotes.md b/bot/resources/tags/quotes.md index 609b6d2d2..bb6e2a009 100644 --- a/bot/resources/tags/quotes.md +++ b/bot/resources/tags/quotes.md @@ -15,6 +15,6 @@ Example: **Note:** If you need both single and double quotes inside your string, use the version that would result in the least amount of escapes. In the case of a tie, use the quotation you use the most. -**References:** -• [pep-8 on quotes](https://www.python.org/dev/peps/pep-0008/#string-quotes) +**References:** +• [pep-8 on quotes](https://www.python.org/dev/peps/pep-0008/#string-quotes) • [convention for triple quoted strings](https://www.python.org/dev/peps/pep-0257/) \ No newline at end of file diff --git a/bot/resources/tags/return.md b/bot/resources/tags/return.md index 7e0cdaa98..c944dddf2 100644 --- a/bot/resources/tags/return.md +++ b/bot/resources/tags/return.md @@ -28,8 +28,8 @@ None >>> print(x) None ``` -**Things to note** -• `print()` and `return` do **not** accomplish the same thing. `print()` will only print the value, it will not be accessible outside of the function afterwards. -• A function will return `None` if it ends without reaching an explicit `return` statement. -• When you want to print a value calculated in a function, instead of printing inside the function, it is often better to return the value and print the *function call* instead. -• [Official documentation for `return`](https://docs.python.org/3/reference/simple_stmts.html#the-return-statement) \ No newline at end of file +**Things to note** +• `print()` and `return` do **not** accomplish the same thing. `print()` will only print the value, it will not be accessible outside of the function afterwards. +• A function will return `None` if it ends without reaching an explicit `return` statement. +• When you want to print a value calculated in a function, instead of printing inside the function, it is often better to return the value and print the *function call* instead. +• [Official documentation for `return`](https://docs.python.org/3/reference/simple_stmts.html#the-return-statement) \ No newline at end of file diff --git a/bot/resources/tags/round.md b/bot/resources/tags/round.md index 3e33c8ff7..28a12469a 100644 --- a/bot/resources/tags/round.md +++ b/bot/resources/tags/round.md @@ -17,8 +17,8 @@ The round half up technique creates a slight bias towards the larger number. Wit It should be noted that round half to even distorts the distribution by increasing the probability of evens relative to odds, however this is considered less important than the bias explained above. -**References:** -• [Wikipedia article about rounding](https://en.wikipedia.org/wiki/Rounding#Round_half_to_even) -• [Documentation on `round` function](https://docs.python.org/3/library/functions.html#round) -• [`round` in what's new in python 3](https://docs.python.org/3/whatsnew/3.0.html#builtins) (4th bullet down) -• [How to force rounding technique](https://stackoverflow.com/a/10826537/4607272) \ No newline at end of file +**References:** +• [Wikipedia article about rounding](https://en.wikipedia.org/wiki/Rounding#Round_half_to_even) +• [Documentation on `round` function](https://docs.python.org/3/library/functions.html#round) +• [`round` in what's new in python 3](https://docs.python.org/3/whatsnew/3.0.html#builtins) (4th bullet down) +• [How to force rounding technique](https://stackoverflow.com/a/10826537/4607272) \ No newline at end of file diff --git a/bot/resources/tags/scope.md b/bot/resources/tags/scope.md index ff9d96637..c1eeb3b84 100644 --- a/bot/resources/tags/scope.md +++ b/bot/resources/tags/scope.md @@ -18,7 +18,7 @@ Alternatively if a variable is defined within a function block for example, it i >>> inner() # prints variable foo without issue bar ``` -**Official Documentation** -**1.** [Program structure, name binding and resolution](https://docs.python.org/3/reference/executionmodel.html#execution-model) -**2.** [`global` statement](https://docs.python.org/3/reference/simple_stmts.html#the-global-statement) +**Official Documentation** +**1.** [Program structure, name binding and resolution](https://docs.python.org/3/reference/executionmodel.html#execution-model) +**2.** [`global` statement](https://docs.python.org/3/reference/simple_stmts.html#the-global-statement) **3.** [`nonlocal` statement](https://docs.python.org/3/reference/simple_stmts.html#the-nonlocal-statement) \ No newline at end of file diff --git a/bot/resources/tags/seek.md b/bot/resources/tags/seek.md index ada23fd00..ff6569a0c 100644 --- a/bot/resources/tags/seek.md +++ b/bot/resources/tags/seek.md @@ -17,6 +17,6 @@ Now lets do `f.seek(4, 1)`. This will move our stream position 4 bytes forward r Finally, lets do `f.seek(-4, 2)`, moving our stream position *backwards* 4 bytes relative to the **end** of the stream. Now if we did `f.read()` to read everything after our position in the file, it would return the string `'eggs'` and also move our stream position to the end of the file. -**Note** -• For the second argument in `seek()`, use `os.SEEK_SET`, `os.SEEK_CUR`, and `os.SEEK_END` in place of 0, 1, and 2 respectively. +**Note** +• For the second argument in `seek()`, use `os.SEEK_SET`, `os.SEEK_CUR`, and `os.SEEK_END` in place of 0, 1, and 2 respectively. • `os.SEEK_CUR` is only usable when the file is in byte mode. \ No newline at end of file diff --git a/bot/resources/tags/traceback.md b/bot/resources/tags/traceback.md index 74401abf0..678ba1991 100644 --- a/bot/resources/tags/traceback.md +++ b/bot/resources/tags/traceback.md @@ -11,8 +11,8 @@ ZeroDivisionError: integer division or modulo by zero ``` The best way to read your traceback is bottom to top. -• Identify the exception raised (e.g. ZeroDivisonError) -• Make note of the line number, and navigate there in your program. -• Try to understand why the error occurred. +• Identify the exception raised (e.g. ZeroDivisonError) +• Make note of the line number, and navigate there in your program. +• Try to understand why the error occurred. To read more about exceptions and errors, please refer to the [PyDis Wiki](https://pythondiscord.com/pages/asking-good-questions/#examining-tracebacks) or the [official Python tutorial.](https://docs.python.org/3.7/tutorial/errors.html) \ No newline at end of file -- cgit v1.2.3 From fe31808089aa01c9e495d16d5c0cdbc4640a5ded Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Sat, 29 Feb 2020 15:19:37 +0530 Subject: Re-corrected the lines which I had changed by mistake --- bot/cogs/tags.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 0e959b45f..b62289e38 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -16,7 +16,8 @@ from bot.pagination import LinePaginator log = logging.getLogger(__name__) TEST_CHANNELS = ( - Channels.bot_commands, + Channels.devtest, + Channels.bot, Channels.helpers ) -- cgit v1.2.3 From 1b568681575d70d5b8dc5e8449e71a928896076c Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Sat, 29 Feb 2020 21:48:50 +0530 Subject: Caching all the tags when the bot has loaded(caching only once) insted of caching it after the tags command is used. --- bot/cogs/tags.py | 39 ++++++++++++++++++--------------------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index b62289e38..3cab8c11f 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -31,27 +31,27 @@ class Tags(Cog): self.bot = bot self.tag_cooldowns = {} self._cache = {} - self._last_fetch: float = 0.0 - async def _get_tags(self, is_forced: bool = False) -> None: + @Cog.listener() + async def on_ready(self) -> None: + """Runs the code before the bot has connected.""" + await self.get_tags() + + async def get_tags(self) -> None: """Get all tags.""" - # refresh only when there's a more than 5m gap from last call. - time_now: float = time.time() - if is_forced or not self._last_fetch or time_now - self._last_fetch > 5 * 60: - tag_files = os.listdir("bot/resources/tags") - for file in tag_files: - p = Path("bot", "resources", "tags", file) - tag_title = os.path.splitext(file)[0].lower() - with p.open() as f: - tag = { - "title": tag_title, - "embed": { - "description": f.read() - } + # Save all tags in memory. + tag_files = os.listdir("bot/resources/tags") + for file in tag_files: + p = Path("bot", "resources", "tags", file) + tag_title = os.path.splitext(file)[0].lower() + with p.open() as f: + tag = { + "title": tag_title, + "embed": { + "description": f.read() } - self._cache[tag_title] = tag - - self._last_fetch = time_now + } + self._cache[tag_title] = tag @staticmethod def _fuzzy_search(search: str, target: str) -> float: @@ -92,7 +92,6 @@ class Tags(Cog): async def _get_tag(self, tag_name: str) -> list: """Get a specific tag.""" - await self._get_tags() found = [self._cache.get(tag_name.lower(), None)] if not found[0]: return self._get_suggestions(tag_name) @@ -133,8 +132,6 @@ class Tags(Cog): ) return - await self._get_tags() - if tag_name is not None: founds = await self._get_tag(tag_name) -- cgit v1.2.3 From 1231f384ef9cf3aac6a4318b2ca4c465f2552aa8 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 2 Mar 2020 13:57:33 +0100 Subject: Add HushDurationConverter. --- bot/converters.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/bot/converters.py b/bot/converters.py index 1945e1da3..976376fce 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -262,6 +262,34 @@ class ISODateTime(Converter): return dt +class HushDurationConverter(Converter): + """Convert passed duration to `int` minutes or `None`.""" + + MINUTES_RE = re.compile(r"(\d+)(?:M|m|$)") + + async def convert(self, ctx: Context, argument: str) -> t.Optional[int]: + """ + Convert `argument` to a duration that's max 15 minutes or None. + + If `"forever"` is passed, None is returned; otherwise an int of the extracted time. + Accepted formats are: + , + m, + M, + forever. + """ + if argument == "forever": + return None + match = self.MINUTES_RE.match(argument) + if not match: + raise BadArgument(f"{argument} is not a valid minutes duration.") + + duration = int(match.group(1)) + if duration > 15: + raise BadArgument("Duration must be below 15 minutes.") + return duration + + def proxy_user(user_id: str) -> discord.Object: """ Create a proxy user object from the given id. -- cgit v1.2.3 From be6738983e5150ca24c20cbd7b482002ab9d69e6 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 2 Mar 2020 23:24:05 +0100 Subject: Add Silence cog. FirstHash is used for handling channels in `loop_alert_channels` set as tuples without considering other elements. --- bot/cogs/moderation/__init__.py | 2 + bot/cogs/moderation/silence.py | 141 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 bot/cogs/moderation/silence.py diff --git a/bot/cogs/moderation/__init__.py b/bot/cogs/moderation/__init__.py index 5243cb92d..0349fe4b1 100644 --- a/bot/cogs/moderation/__init__.py +++ b/bot/cogs/moderation/__init__.py @@ -2,6 +2,7 @@ from bot.bot import Bot from .infractions import Infractions from .management import ModManagement from .modlog import ModLog +from .silence import Silence from .superstarify import Superstarify @@ -10,4 +11,5 @@ def setup(bot: Bot) -> None: bot.add_cog(Infractions(bot)) bot.add_cog(ModLog(bot)) bot.add_cog(ModManagement(bot)) + bot.add_cog(Silence(bot)) bot.add_cog(Superstarify(bot)) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py new file mode 100644 index 000000000..f37196744 --- /dev/null +++ b/bot/cogs/moderation/silence.py @@ -0,0 +1,141 @@ +import asyncio +import logging +from contextlib import suppress +from typing import Optional + +from discord import PermissionOverwrite, TextChannel +from discord.ext import commands, tasks +from discord.ext.commands import Context, TextChannelConverter + +from bot.bot import Bot +from bot.constants import Channels, Emojis, Guild, Roles +from bot.converters import HushDurationConverter + +log = logging.getLogger(__name__) + + +class FirstHash(tuple): + """Tuple with only first item used for hash and eq.""" + + def __new__(cls, *args): + """Construct tuple from `args`.""" + return super().__new__(cls, args) + + def __hash__(self): + return hash((self[0],)) + + def __eq__(self, other: "FirstHash"): + return self[0] == other[0] + + +class Silence(commands.Cog): + """Commands for stopping channel messages for `verified` role in a channel.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.loop_alert_channels = set() + self.bot.loop.create_task(self._get_server_values()) + + async def _get_server_values(self) -> None: + """Fetch required internal values after they're available.""" + await self.bot.wait_until_guild_available() + guild = self.bot.get_guild(Guild.id) + self._verified_role = guild.get_role(Roles.verified) + self._mod_alerts_channel = self.bot.get_channel(Channels.mod_alerts) + self._mod_log_channel = self.bot.get_channel(Channels.mod_log) + + @commands.command(aliases=("hush",)) + async def silence( + self, + ctx: Context, + duration: HushDurationConverter = 10, + channel: TextChannelConverter = None + ) -> None: + """ + Silence `channel` for `duration` minutes or `"forever"`. + + If duration is forever, start a notifier loop that triggers every 15 minutes. + """ + channel = channel or ctx.channel + + if not await self._silence(channel, persistent=(duration is None), duration=duration): + await ctx.send(f"{Emojis.cross_mark} {channel.mention} is already silenced.") + return + if duration is None: + await ctx.send(f"{Emojis.check_mark} Channel {channel.mention} silenced indefinitely.") + return + + await ctx.send(f"{Emojis.check_mark} {channel.mention} silenced for {duration} minute(s).") + await asyncio.sleep(duration*60) + await self.unsilence(ctx, channel) + + @commands.command(aliases=("unhush",)) + async def unsilence(self, ctx: Context, channel: TextChannelConverter = None) -> None: + """ + Unsilence `channel`. + + Unsilence a previously silenced `channel` and remove it from indefinitely muted channels notice if applicable. + """ + channel = channel or ctx.channel + alert_channel = self._mod_log_channel if ctx.invoked_with == "hush" else ctx.channel + + if await self._unsilence(channel): + await alert_channel.send(f"{Emojis.check_mark} Unsilenced {channel.mention}.") + + async def _silence(self, channel: TextChannel, persistent: bool, duration: Optional[int]) -> bool: + """ + Silence `channel` for `self._verified_role`. + + If `persistent` is `True` add `channel` with current iteration of `self._notifier` + to `self.self.loop_alert_channels` and attempt to start notifier. + `duration` is only used for logging; if None is passed `persistent` should be True to not log None. + """ + if channel.overwrites_for(self._verified_role).send_messages is False: + log.debug(f"Tried to silence channel #{channel} ({channel.id}) but the channel was already silenced.") + return False + await channel.set_permissions(self._verified_role, overwrite=PermissionOverwrite(send_messages=False)) + if persistent: + log.debug(f"Silenced #{channel} ({channel.id}) indefinitely.") + self.loop_alert_channels.add(FirstHash(channel, self._notifier.current_loop)) + with suppress(RuntimeError): + self._notifier.start() + return True + + log.debug(f"Silenced #{channel} ({channel.id}) for {duration} minute(s).") + return True + + async def _unsilence(self, channel: TextChannel) -> bool: + """ + Unsilence `channel`. + + Check if `channel` is silenced through a `PermissionOverwrite`, + if it is unsilence it, attempt to remove it from `self.loop_alert_channels` + and if `self.loop_alert_channels` are left empty, stop the `self._notifier` + """ + if channel.overwrites_for(self._verified_role).send_messages is False: + await channel.set_permissions(self._verified_role, overwrite=None) + log.debug(f"Unsilenced channel #{channel} ({channel.id}).") + + with suppress(KeyError): + self.loop_alert_channels.remove(FirstHash(channel)) + if not self.loop_alert_channels: + self._notifier.cancel() + return True + log.debug(f"Tried to unsilence channel #{channel} ({channel.id}) but the channel was not silenced.") + return False + + @tasks.loop() + async def _notifier(self) -> None: + """Post notice of permanently silenced channels to `mod_alerts` periodically.""" + # Wait for 15 minutes between notices with pause at start of loop. + await asyncio.sleep(15*60) + current_iter = self._notifier.current_loop+1 + channels_text = ', '.join( + f"{channel.mention} for {current_iter-start} min" + for channel, start in self.loop_alert_channels + ) + channels_log_text = ', '.join( + f'#{channel} ({channel.id})' for channel, _ in self.loop_alert_channels + ) + log.debug(f"Sending notice with channels: {channels_log_text}") + await self._mod_alerts_channel.send(f"<@&{Roles.moderators}> currently silenced channels: {channels_text}") -- cgit v1.2.3 From 1f8933f5551185aa60dc11807778c0bf7f78b0c3 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 2 Mar 2020 23:25:36 +0100 Subject: Add logging to loop start and loop end. --- bot/cogs/moderation/silence.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index f37196744..560a0a15c 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -139,3 +139,11 @@ class Silence(commands.Cog): ) log.debug(f"Sending notice with channels: {channels_log_text}") await self._mod_alerts_channel.send(f"<@&{Roles.moderators}> currently silenced channels: {channels_text}") + + @_notifier.before_loop + async def _log_notifier_start(self) -> None: + log.trace("Starting notifier loop.") + + @_notifier.after_loop + async def _log_notifier_end(self) -> None: + log.trace("Stopping notifier loop.") -- cgit v1.2.3 From 1c7b2d8a212ee837adf5dedb617310fea3b45080 Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Wed, 4 Mar 2020 23:30:37 +0530 Subject: Use "pathlib" instead of "os" module and context manager The pathlib module simplifies opening and reading files, hence the os module and the context manager are no longer used. --- bot/cogs/tags.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 1c6b6aa21..7b5e3ed3a 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -1,5 +1,4 @@ import logging -import os import re import time from pathlib import Path @@ -39,18 +38,17 @@ class Tags(Cog): async def get_tags(self) -> None: """Get all tags.""" # Save all tags in memory. - tag_files = os.listdir("bot/resources/tags") + tag_files = Path("bot", "resources", "tags").iterdir() for file in tag_files: - p = Path("bot", "resources", "tags", file) - tag_title = os.path.splitext(file)[0].lower() - with p.open() as f: - tag = { - "title": tag_title, - "embed": { - "description": f.read() - } + file_path = Path(file) + tag_title = file_path.stem + tag = { + "title": tag_title, + "embed": { + "description": file_path.read_text() } - self._cache[tag_title] = tag + } + self._cache[tag_title] = tag @staticmethod def _fuzzy_search(search: str, target: str) -> float: -- cgit v1.2.3 From 073fdf1c69a9e4a2f9967d43a3e9960e9388aa23 Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Wed, 4 Mar 2020 23:43:45 +0530 Subject: Convert "get_tags()" and "_get_tag()" to sync functions "get_tags()" and "_get_tag()" functions need not be async as we are no longer doing any API call but instead reading from local files. --- bot/cogs/tags.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 7b5e3ed3a..9665aa04e 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -28,16 +28,12 @@ class Tags(Cog): def __init__(self, bot: Bot): self.bot = bot self.tag_cooldowns = {} - self._cache = {} + self._cache = self.get_tags() - @Cog.listener() - async def on_ready(self) -> None: - """Runs the code before the bot has connected.""" - await self.get_tags() - - async def get_tags(self) -> None: + def get_tags(self) -> dict: """Get all tags.""" # Save all tags in memory. + cache = {} tag_files = Path("bot", "resources", "tags").iterdir() for file in tag_files: file_path = Path(file) @@ -48,7 +44,8 @@ class Tags(Cog): "description": file_path.read_text() } } - self._cache[tag_title] = tag + cache[tag_title] = tag + return cache @staticmethod def _fuzzy_search(search: str, target: str) -> float: @@ -87,7 +84,7 @@ class Tags(Cog): return [] - async def _get_tag(self, tag_name: str) -> list: + def _get_tag(self, tag_name: str) -> list: """Get a specific tag.""" found = [self._cache.get(tag_name.lower(), None)] if not found[0]: @@ -130,7 +127,7 @@ class Tags(Cog): return if tag_name is not None: - founds = await self._get_tag(tag_name) + founds = self._get_tag(tag_name) if len(founds) == 1: tag = founds[0] -- cgit v1.2.3 From 564690f79a61944c25d62530caaae671f6afcb31 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Wed, 4 Mar 2020 17:31:11 -0500 Subject: Update tag files for new linting hooks --- bot/resources/tags/args-kwargs.md | 2 +- bot/resources/tags/ask.md | 2 +- bot/resources/tags/class.md | 4 ++-- bot/resources/tags/classmethod.md | 2 +- bot/resources/tags/codeblock.md | 2 +- bot/resources/tags/decorators.md | 6 +++--- bot/resources/tags/dictcomps.md | 2 +- bot/resources/tags/enumerate.md | 4 ++-- bot/resources/tags/except.md | 2 +- bot/resources/tags/exit().md | 2 +- bot/resources/tags/f-strings.md | 2 +- bot/resources/tags/foo.md | 2 +- bot/resources/tags/functions-are-objects.md | 2 +- bot/resources/tags/global.md | 2 +- bot/resources/tags/if-name-main.md | 2 +- bot/resources/tags/indent.md | 4 ++-- bot/resources/tags/inline.md | 2 +- bot/resources/tags/iterate-dict.md | 2 +- bot/resources/tags/listcomps.md | 2 +- bot/resources/tags/mutable-default-args.md | 6 +++--- bot/resources/tags/names.md | 2 +- bot/resources/tags/off-topic.md | 6 +++--- bot/resources/tags/open.md | 2 +- bot/resources/tags/or-gotcha.md | 2 +- bot/resources/tags/param-arg.md | 2 +- bot/resources/tags/paste.md | 2 +- bot/resources/tags/pathlib.md | 2 +- bot/resources/tags/pep8.md | 2 +- bot/resources/tags/positional-keyword.md | 4 ++-- bot/resources/tags/precedence.md | 2 +- bot/resources/tags/quotes.md | 2 +- bot/resources/tags/relative-path.md | 2 +- bot/resources/tags/repl.md | 2 +- bot/resources/tags/return.md | 6 +++--- bot/resources/tags/round.md | 2 +- bot/resources/tags/scope.md | 4 ++-- bot/resources/tags/seek.md | 2 +- bot/resources/tags/self.md | 2 +- bot/resources/tags/star-imports.md | 2 +- bot/resources/tags/traceback.md | 2 +- bot/resources/tags/windows-path.md | 4 ++-- bot/resources/tags/with.md | 2 +- bot/resources/tags/xy-problem.md | 2 +- bot/resources/tags/ytdl.md | 2 +- bot/resources/tags/zip.md | 2 +- 45 files changed, 59 insertions(+), 59 deletions(-) diff --git a/bot/resources/tags/args-kwargs.md b/bot/resources/tags/args-kwargs.md index de883dea8..b440a2346 100644 --- a/bot/resources/tags/args-kwargs.md +++ b/bot/resources/tags/args-kwargs.md @@ -14,4 +14,4 @@ These special parameters allow functions to take arbitrary amounts of positional • **Future proofing** (in the case of the first two bullet points, if the parameters change, your code won't break) • **Flexibility** (writing functions that behave like `dict()` or `print()`) -*See* `!tags positional-keyword` *for information about positional and keyword arguments* \ No newline at end of file +*See* `!tags positional-keyword` *for information about positional and keyword arguments* diff --git a/bot/resources/tags/ask.md b/bot/resources/tags/ask.md index ed651e8c5..e2c2a88f6 100644 --- a/bot/resources/tags/ask.md +++ b/bot/resources/tags/ask.md @@ -6,4 +6,4 @@ Asking good questions will yield a much higher chance of a quick response: • Show us the code you've tried and any errors or unexpected results it's giving. • Be patient while we're helping you. -You can find a much more detailed explanation [on our website](https://pythondiscord.com/pages/asking-good-questions/). \ No newline at end of file +You can find a much more detailed explanation [on our website](https://pythondiscord.com/pages/asking-good-questions/). diff --git a/bot/resources/tags/class.md b/bot/resources/tags/class.md index 74c36b9fa..4f73fc974 100644 --- a/bot/resources/tags/class.md +++ b/bot/resources/tags/class.md @@ -10,7 +10,7 @@ Here is an example class: class Foo: def __init__(self, somedata): self.my_attrib = somedata - + def show(self): print(self.my_attrib) ``` @@ -22,4 +22,4 @@ bar = Foo('data') bar.show() ``` -We can access any of `Foo`'s methods via `bar.my_method()`, and access any of `bar`s data via `bar.my_attribute`. \ No newline at end of file +We can access any of `Foo`'s methods via `bar.my_method()`, and access any of `bar`s data via `bar.my_attribute`. diff --git a/bot/resources/tags/classmethod.md b/bot/resources/tags/classmethod.md index 43c6d9909..a4e803093 100644 --- a/bot/resources/tags/classmethod.md +++ b/bot/resources/tags/classmethod.md @@ -17,4 +17,4 @@ alternative_bot = Bot.from_config(default_config) # but this still works, too regular_bot = Bot("tokenstring") ``` -This is just one of the many use cases of `@classmethod`. A more in-depth explanation can be found [here](https://stackoverflow.com/questions/12179271/meaning-of-classmethod-and-staticmethod-for-beginner#12179752). \ No newline at end of file +This is just one of the many use cases of `@classmethod`. A more in-depth explanation can be found [here](https://stackoverflow.com/questions/12179271/meaning-of-classmethod-and-staticmethod-for-beginner#12179752). diff --git a/bot/resources/tags/codeblock.md b/bot/resources/tags/codeblock.md index 34db060ef..a28ae397b 100644 --- a/bot/resources/tags/codeblock.md +++ b/bot/resources/tags/codeblock.md @@ -14,4 +14,4 @@ Note: This will result in the following: ```py print('Hello world!') -``` \ No newline at end of file +``` diff --git a/bot/resources/tags/decorators.md b/bot/resources/tags/decorators.md index 9b53af064..39c943f0a 100644 --- a/bot/resources/tags/decorators.md +++ b/bot/resources/tags/decorators.md @@ -12,12 +12,12 @@ Consider the following example of a timer decorator: ... print('Time elapsed:', time.time() - start) ... return result ... return inner -... +... >>> @timer ... def slow(delay=1): ... time.sleep(delay) ... return 'Finished!' -... +... >>> print(slow()) Time elapsed: 1.0011568069458008 Finished! @@ -28,4 +28,4 @@ Finished! More information: • [Corey Schafer's video on decorators](https://youtu.be/FsAPt_9Bf3U) -• [Real python article](https://realpython.com/primer-on-python-decorators/) \ No newline at end of file +• [Real python article](https://realpython.com/primer-on-python-decorators/) diff --git a/bot/resources/tags/dictcomps.md b/bot/resources/tags/dictcomps.md index ddefa1299..11867d77b 100644 --- a/bot/resources/tags/dictcomps.md +++ b/bot/resources/tags/dictcomps.md @@ -17,4 +17,4 @@ They are also very useful for inverting the key value pairs of a dictionary that Also like list comprehensions, you can add a conditional to it in order to filter out items you don't want. -For more information and examples, check [PEP 274](https://www.python.org/dev/peps/pep-0274/) \ No newline at end of file +For more information and examples, check [PEP 274](https://www.python.org/dev/peps/pep-0274/) diff --git a/bot/resources/tags/enumerate.md b/bot/resources/tags/enumerate.md index 610843cf4..dd984af52 100644 --- a/bot/resources/tags/enumerate.md +++ b/bot/resources/tags/enumerate.md @@ -4,10 +4,10 @@ index = 0 for item in my_list: print(f"{index}: {item}") index += 1 -``` +``` into beautiful, _pythonic_ code: ```py for index, item in enumerate(my_list): print(f"{index}: {item}") ``` -For more information, check out [the official docs](https://docs.python.org/3/library/functions.html#enumerate), or [PEP 279](https://www.python.org/dev/peps/pep-0279/). \ No newline at end of file +For more information, check out [the official docs](https://docs.python.org/3/library/functions.html#enumerate), or [PEP 279](https://www.python.org/dev/peps/pep-0279/). diff --git a/bot/resources/tags/except.md b/bot/resources/tags/except.md index 66dce13ab..8f0abf156 100644 --- a/bot/resources/tags/except.md +++ b/bot/resources/tags/except.md @@ -14,4 +14,4 @@ try: except: print("An exception was raised, but we have no idea if it was a ValueError or an IndexError.") ``` -For more information about exception handling, see [the official Python docs](https://docs.python.org/3/tutorial/errors.html), or watch [Corey Schafer's video on exception handling](https://www.youtube.com/watch?v=NIWwJbo-9_8). \ No newline at end of file +For more information about exception handling, see [the official Python docs](https://docs.python.org/3/tutorial/errors.html), or watch [Corey Schafer's video on exception handling](https://www.youtube.com/watch?v=NIWwJbo-9_8). diff --git a/bot/resources/tags/exit().md b/bot/resources/tags/exit().md index 89f83f7e0..27da9f866 100644 --- a/bot/resources/tags/exit().md +++ b/bot/resources/tags/exit().md @@ -5,4 +5,4 @@ If you want to exit your code programmatically, you might think to use the funct You should use either [`SystemExit`](https://docs.python.org/3/library/exceptions.html#SystemExit) or [`sys.exit()`](https://docs.python.org/3/library/sys.html#sys.exit) instead. There's not much practical difference between these two other than having to `import sys` for the latter. Both take an optional argument to provide an exit status. -[Official documentation](https://docs.python.org/3/library/constants.html#exit) with the warning not to use `exit()` or `quit()` in source code. \ No newline at end of file +[Official documentation](https://docs.python.org/3/library/constants.html#exit) with the warning not to use `exit()` or `quit()` in source code. diff --git a/bot/resources/tags/f-strings.md b/bot/resources/tags/f-strings.md index 966fe6080..69bc82487 100644 --- a/bot/resources/tags/f-strings.md +++ b/bot/resources/tags/f-strings.md @@ -14,4 +14,4 @@ print("{0} are some of the largest snakes in the world".format(snake)) # Or keyword arguments print("{family} are some of the largest snakes in the world".format(family=snake)) -``` \ No newline at end of file +``` diff --git a/bot/resources/tags/foo.md b/bot/resources/tags/foo.md index 2b5b659fd..98529bfc0 100644 --- a/bot/resources/tags/foo.md +++ b/bot/resources/tags/foo.md @@ -7,4 +7,4 @@ Python has its own metasyntactic variables, namely `spam`, `eggs`, and `bacon`. More information: • [History of foobar](https://en.wikipedia.org/wiki/Foobar) -• [Monty Python sketch](https://en.wikipedia.org/wiki/Spam_%28Monty_Python%29) \ No newline at end of file +• [Monty Python sketch](https://en.wikipedia.org/wiki/Spam_%28Monty_Python%29) diff --git a/bot/resources/tags/functions-are-objects.md b/bot/resources/tags/functions-are-objects.md index d10e6b73e..01af7a721 100644 --- a/bot/resources/tags/functions-are-objects.md +++ b/bot/resources/tags/functions-are-objects.md @@ -36,4 +36,4 @@ class C: # open function is passed # to the staticmethod class -``` \ No newline at end of file +``` diff --git a/bot/resources/tags/global.md b/bot/resources/tags/global.md index fc60f9177..64c316b62 100644 --- a/bot/resources/tags/global.md +++ b/bot/resources/tags/global.md @@ -13,4 +13,4 @@ def update_score(score, roll): return score + roll score = update_score(score, roll) ``` -For in-depth explanations on why global variables are bad news in a variety of situations, see [this Stack Overflow answer](https://stackoverflow.com/questions/19158339/why-are-global-variables-evil/19158418#19158418). \ No newline at end of file +For in-depth explanations on why global variables are bad news in a variety of situations, see [this Stack Overflow answer](https://stackoverflow.com/questions/19158339/why-are-global-variables-evil/19158418#19158418). diff --git a/bot/resources/tags/if-name-main.md b/bot/resources/tags/if-name-main.md index d44f0086d..9d88bb897 100644 --- a/bot/resources/tags/if-name-main.md +++ b/bot/resources/tags/if-name-main.md @@ -23,4 +23,4 @@ If you run this module named `bar.py`, it will execute the code in `foo.py`. Fir • Your module is a library, but also has a special case where it can be run directly • Your module is a library and you want to safeguard it against people running it directly (like what `pip` does) -• Your module is the main program, but has unit tests and the testing framework works by importing your module, and you want to avoid having your main code run during the test \ No newline at end of file +• Your module is the main program, but has unit tests and the testing framework works by importing your module, and you want to avoid having your main code run during the test diff --git a/bot/resources/tags/indent.md b/bot/resources/tags/indent.md index 5b36a4818..dec8407b0 100644 --- a/bot/resources/tags/indent.md +++ b/bot/resources/tags/indent.md @@ -12,7 +12,7 @@ def foo(): print('ham') # indented two levels return bar # indented one level ``` -The first line is not indented. The next two lines are indented to be inside of the function definition. They will only run when the function is called. The fourth line is indented to be inside the `if` statement, and will only run if the `if` statement evaluates to `True`. The fifth and last line is like the 2nd and 3rd and will always run when the function is called. It effectively closes the `if` statement above as no more lines can be inside the `if` statement below that line. +The first line is not indented. The next two lines are indented to be inside of the function definition. They will only run when the function is called. The fourth line is indented to be inside the `if` statement, and will only run if the `if` statement evaluates to `True`. The fifth and last line is like the 2nd and 3rd and will always run when the function is called. It effectively closes the `if` statement above as no more lines can be inside the `if` statement below that line. **Indentation is used after:** **1.** [Compound statements](https://docs.python.org/3/reference/compound_stmts.html) (eg. `if`, `while`, `for`, `try`, `with`, `def`, `class`, and their counterparts) @@ -21,4 +21,4 @@ The first line is not indented. The next two lines are indented to be inside of **More Info** **1.** [Indentation style guide](https://www.python.org/dev/peps/pep-0008/#indentation) **2.** [Tabs or Spaces?](https://www.python.org/dev/peps/pep-0008/#tabs-or-spaces) -**3.** [Official docs on indentation](https://docs.python.org/3/reference/lexical_analysis.html#indentation) \ No newline at end of file +**3.** [Official docs on indentation](https://docs.python.org/3/reference/lexical_analysis.html#indentation) diff --git a/bot/resources/tags/inline.md b/bot/resources/tags/inline.md index d0c9d1b5e..a6a7c35d6 100644 --- a/bot/resources/tags/inline.md +++ b/bot/resources/tags/inline.md @@ -13,4 +13,4 @@ The `__init__` method customizes the newly created instance. **Note:** • These are **backticks** not quotes • Avoid using them for multiple lines -• Useful for negating formatting you don't want \ No newline at end of file +• Useful for negating formatting you don't want diff --git a/bot/resources/tags/iterate-dict.md b/bot/resources/tags/iterate-dict.md index b23475506..78c067b20 100644 --- a/bot/resources/tags/iterate-dict.md +++ b/bot/resources/tags/iterate-dict.md @@ -7,4 +7,4 @@ To iterate over both the keys and values: ```py for key, val in my_dict.items(): print(key, val) -``` \ No newline at end of file +``` diff --git a/bot/resources/tags/listcomps.md b/bot/resources/tags/listcomps.md index 5ef0ce2bc..0003b9bb8 100644 --- a/bot/resources/tags/listcomps.md +++ b/bot/resources/tags/listcomps.md @@ -11,4 +11,4 @@ even_numbers = [n for n in range(20) if n % 2 == 0] ``` This also works for generators, dicts and sets by using `()` or `{}` instead of `[]`. -For more info, see [this pythonforbeginners.com post](http://www.pythonforbeginners.com/basics/list-comprehensions-in-python) or [PEP 202](https://www.python.org/dev/peps/pep-0202/). \ No newline at end of file +For more info, see [this pythonforbeginners.com post](http://www.pythonforbeginners.com/basics/list-comprehensions-in-python) or [PEP 202](https://www.python.org/dev/peps/pep-0202/). diff --git a/bot/resources/tags/mutable-default-args.md b/bot/resources/tags/mutable-default-args.md index 7b16e6b82..a8f0c38b3 100644 --- a/bot/resources/tags/mutable-default-args.md +++ b/bot/resources/tags/mutable-default-args.md @@ -11,7 +11,7 @@ and returns it. `foo` is set to an empty list by default. >>> def append_one(foo=[]): ... foo.append(1) ... return foo -... +... ``` See what happens when we call it a few times: ```python @@ -33,7 +33,7 @@ function is **called**: ... foo = [] ... foo.append(1) ... return foo -... +... >>> append_one() [1] >>> append_one() @@ -45,4 +45,4 @@ function is **called**: • This behavior can be used intentionally to maintain state between calls of a function (eg. when writing a caching function). • This behavior is not unique to mutable objects, all default -arguments are evaulated only once when the function is defined. \ No newline at end of file +arguments are evaulated only once when the function is defined. diff --git a/bot/resources/tags/names.md b/bot/resources/tags/names.md index 462c550bc..3e76269f7 100644 --- a/bot/resources/tags/names.md +++ b/bot/resources/tags/names.md @@ -34,4 +34,4 @@ There is also `del` which has the purpose of *unbinding* a name. **More info** • Please watch [Ned Batchelder's talk](https://youtu.be/_AEJHKGk9ns) on names in python for a detailed explanation with examples -• [Official documentation](https://docs.python.org/3/reference/executionmodel.html#naming-and-binding) \ No newline at end of file +• [Official documentation](https://docs.python.org/3/reference/executionmodel.html#naming-and-binding) diff --git a/bot/resources/tags/off-topic.md b/bot/resources/tags/off-topic.md index 004adfa17..c7f98a813 100644 --- a/bot/resources/tags/off-topic.md +++ b/bot/resources/tags/off-topic.md @@ -1,8 +1,8 @@ **Off-topic channels** -There are three off-topic channels: -• <#291284109232308226> +There are three off-topic channels: +• <#291284109232308226> • <#463035241142026251> • <#463035268514185226> -Their names change randomly every 24 hours, but you can always find them under the `OFF-TOPIC/GENERAL` category in the channel list. \ No newline at end of file +Their names change randomly every 24 hours, but you can always find them under the `OFF-TOPIC/GENERAL` category in the channel list. diff --git a/bot/resources/tags/open.md b/bot/resources/tags/open.md index 1ba19dedd..13b4555b9 100644 --- a/bot/resources/tags/open.md +++ b/bot/resources/tags/open.md @@ -23,4 +23,4 @@ This is an optional string that specifies the mode in which the file should be o `'w+'` Opens for reading and writing and truncates (can create files) `'x'` Creates file and opens for writing (file must **not** already exist) `'x+'` Creates file and opens for reading and writing (file must **not** already exist) -`'a+'` Opens file for reading and writing at **end of file** (can create files) \ No newline at end of file +`'a+'` Opens file for reading and writing at **end of file** (can create files) diff --git a/bot/resources/tags/or-gotcha.md b/bot/resources/tags/or-gotcha.md index da82e3fdd..00c2db1f8 100644 --- a/bot/resources/tags/or-gotcha.md +++ b/bot/resources/tags/or-gotcha.md @@ -14,4 +14,4 @@ if favorite_fruit == 'grapefruit' or favorite_fruit == 'lemon': # ...or like this. if favorite_fruit in ('grapefruit', 'lemon'): print("That's a weird favorite fruit to have.") -``` \ No newline at end of file +``` diff --git a/bot/resources/tags/param-arg.md b/bot/resources/tags/param-arg.md index 9e946812b..88069d8bd 100644 --- a/bot/resources/tags/param-arg.md +++ b/bot/resources/tags/param-arg.md @@ -9,4 +9,4 @@ def square(n): # n is the parameter print(square(5)) # 5 is the argument ``` -Note that `5` is the argument passed to `square`, but `square(5)` in its entirety is the argument passed to `print` \ No newline at end of file +Note that `5` is the argument passed to `square`, but `square(5)` in its entirety is the argument passed to `print` diff --git a/bot/resources/tags/paste.md b/bot/resources/tags/paste.md index d8e6e6c61..2ed51def7 100644 --- a/bot/resources/tags/paste.md +++ b/bot/resources/tags/paste.md @@ -3,4 +3,4 @@ If your code is too long to fit in a codeblock in discord, you can paste your code here: https://paste.pydis.com/ -After pasting your code, **save** it by clicking the floppy disk icon in the top right, or by typing `ctrl + S`. After doing that, the URL should **change**. Copy the URL and post it here so others can see it. \ No newline at end of file +After pasting your code, **save** it by clicking the floppy disk icon in the top right, or by typing `ctrl + S`. After doing that, the URL should **change**. Copy the URL and post it here so others can see it. diff --git a/bot/resources/tags/pathlib.md b/bot/resources/tags/pathlib.md index 468945cc5..dfeb7ecac 100644 --- a/bot/resources/tags/pathlib.md +++ b/bot/resources/tags/pathlib.md @@ -18,4 +18,4 @@ Python 3 comes with a new module named `Pathlib`. Since Python 3.6, `pathlib.Pat • [**Why you should use pathlib** - Trey Hunner](https://treyhunner.com/2018/12/why-you-should-be-using-pathlib/) • [**Answering concerns about pathlib** - Trey Hunner](https://treyhunner.com/2019/01/no-really-pathlib-is-great/) • [**Official Documentation**](https://docs.python.org/3/library/pathlib.html) -• [**PEP 519** - Adding a file system path protocol](https://www.python.org/dev/peps/pep-0519/) \ No newline at end of file +• [**PEP 519** - Adding a file system path protocol](https://www.python.org/dev/peps/pep-0519/) diff --git a/bot/resources/tags/pep8.md b/bot/resources/tags/pep8.md index ec999bedc..cab4c4db8 100644 --- a/bot/resources/tags/pep8.md +++ b/bot/resources/tags/pep8.md @@ -1,3 +1,3 @@ **PEP 8** is the official style guide for Python. It includes comprehensive guidelines for code formatting, variable naming, and making your code easy to read. Professional Python developers are usually required to follow the guidelines, and will often use code-linters like `flake8` to verify that the code they\'re writing complies with the style guide. -You can find the PEP 8 document [here](https://www.python.org/dev/peps/pep-0008). \ No newline at end of file +You can find the PEP 8 document [here](https://www.python.org/dev/peps/pep-0008). diff --git a/bot/resources/tags/positional-keyword.md b/bot/resources/tags/positional-keyword.md index bc7f68ee0..dd6ddfc4b 100644 --- a/bot/resources/tags/positional-keyword.md +++ b/bot/resources/tags/positional-keyword.md @@ -25,7 +25,7 @@ The reverse is also true: ```py >>> def foo(a, b): ... print(a, b) -... +... >>> foo(a=1, b=2) 1 2 >>> foo(b=1, a=2) @@ -35,4 +35,4 @@ The reverse is also true: **More info** • [Keyword only arguments](https://www.python.org/dev/peps/pep-3102/) • [Positional only arguments](https://www.python.org/dev/peps/pep-0570/) -• `!tags param-arg` (Parameters vs. Arguments) \ No newline at end of file +• `!tags param-arg` (Parameters vs. Arguments) diff --git a/bot/resources/tags/precedence.md b/bot/resources/tags/precedence.md index 8a4c66c4e..ed399143c 100644 --- a/bot/resources/tags/precedence.md +++ b/bot/resources/tags/precedence.md @@ -10,4 +10,4 @@ Operator precedence is essentially like an order of operations for python's oper `not True or True` is `True` because the `not` is first `not (True or True)` is `False` because the `or` is first -The full table of precedence from lowest to highest is [here](https://docs.python.org/3/reference/expressions.html#operator-precedence) \ No newline at end of file +The full table of precedence from lowest to highest is [here](https://docs.python.org/3/reference/expressions.html#operator-precedence) diff --git a/bot/resources/tags/quotes.md b/bot/resources/tags/quotes.md index bb6e2a009..8421748a1 100644 --- a/bot/resources/tags/quotes.md +++ b/bot/resources/tags/quotes.md @@ -17,4 +17,4 @@ If you need both single and double quotes inside your string, use the version th **References:** • [pep-8 on quotes](https://www.python.org/dev/peps/pep-0008/#string-quotes) -• [convention for triple quoted strings](https://www.python.org/dev/peps/pep-0257/) \ No newline at end of file +• [convention for triple quoted strings](https://www.python.org/dev/peps/pep-0257/) diff --git a/bot/resources/tags/relative-path.md b/bot/resources/tags/relative-path.md index 269276e81..6e97b78af 100644 --- a/bot/resources/tags/relative-path.md +++ b/bot/resources/tags/relative-path.md @@ -4,4 +4,4 @@ A relative path is a partial path that is relative to your current working direc **Why is this important?** -When opening files in python, relative paths won't always work since it's dependent on what directory you were in when you ran your code. A common issue people face is running their code in an IDE thinking they can open files that are in the same directory as their module, but the current working directory will be different than what they expect and so they won't find the file. The way to avoid this problem is by using absolute paths, which is the full path from your root directory to the file you want to open. \ No newline at end of file +When opening files in python, relative paths won't always work since it's dependent on what directory you were in when you ran your code. A common issue people face is running their code in an IDE thinking they can open files that are in the same directory as their module, but the current working directory will be different than what they expect and so they won't find the file. The way to avoid this problem is by using absolute paths, which is the full path from your root directory to the file you want to open. diff --git a/bot/resources/tags/repl.md b/bot/resources/tags/repl.md index a68fe9397..875b4ec47 100644 --- a/bot/resources/tags/repl.md +++ b/bot/resources/tags/repl.md @@ -10,4 +10,4 @@ Alternatively, you can make use of the builtin `help()` function. `help(thing)` Lastly you can run your code with the `-i` flag to execute your code normally, but be dropped into the REPL once execution is finished, giving you access to all your global variables/functions in the REPL. -To **exit** either a help session, or normal REPL prompt, you must send an EOF signal to the prompt. In *nix systems, this is done with `ctrl + D`, and in windows systems it is `ctrl + Z`. You can also exit the normal REPL prompt with the dedicated functions `exit()` or `quit()`. \ No newline at end of file +To **exit** either a help session, or normal REPL prompt, you must send an EOF signal to the prompt. In *nix systems, this is done with `ctrl + D`, and in windows systems it is `ctrl + Z`. You can also exit the normal REPL prompt with the dedicated functions `exit()` or `quit()`. diff --git a/bot/resources/tags/return.md b/bot/resources/tags/return.md index c944dddf2..e37f0eebc 100644 --- a/bot/resources/tags/return.md +++ b/bot/resources/tags/return.md @@ -16,13 +16,13 @@ If we wanted to store 5 squared in a variable called `x`, we could do that like ```py >>> def square(n): ... n*n # calculates then throws away, returns None -... +... >>> x = square(5) >>> print(x) None >>> def square(n): ... print(n*n) # calculates and prints, then throws away and returns None -... +... >>> x = square(5) 25 >>> print(x) @@ -32,4 +32,4 @@ None • `print()` and `return` do **not** accomplish the same thing. `print()` will only print the value, it will not be accessible outside of the function afterwards. • A function will return `None` if it ends without reaching an explicit `return` statement. • When you want to print a value calculated in a function, instead of printing inside the function, it is often better to return the value and print the *function call* instead. -• [Official documentation for `return`](https://docs.python.org/3/reference/simple_stmts.html#the-return-statement) \ No newline at end of file +• [Official documentation for `return`](https://docs.python.org/3/reference/simple_stmts.html#the-return-statement) diff --git a/bot/resources/tags/round.md b/bot/resources/tags/round.md index 28a12469a..0392bb41b 100644 --- a/bot/resources/tags/round.md +++ b/bot/resources/tags/round.md @@ -21,4 +21,4 @@ It should be noted that round half to even distorts the distribution by increasi • [Wikipedia article about rounding](https://en.wikipedia.org/wiki/Rounding#Round_half_to_even) • [Documentation on `round` function](https://docs.python.org/3/library/functions.html#round) • [`round` in what's new in python 3](https://docs.python.org/3/whatsnew/3.0.html#builtins) (4th bullet down) -• [How to force rounding technique](https://stackoverflow.com/a/10826537/4607272) \ No newline at end of file +• [How to force rounding technique](https://stackoverflow.com/a/10826537/4607272) diff --git a/bot/resources/tags/scope.md b/bot/resources/tags/scope.md index c1eeb3b84..5c1e64e1c 100644 --- a/bot/resources/tags/scope.md +++ b/bot/resources/tags/scope.md @@ -13,7 +13,7 @@ Alternatively if a variable is defined within a function block for example, it i ... def inner(): ... print(foo) # has access to foo from scope of outer ... return inner # brings inner to scope of caller -... +... >>> inner = outer() # get inner function >>> inner() # prints variable foo without issue bar @@ -21,4 +21,4 @@ bar **Official Documentation** **1.** [Program structure, name binding and resolution](https://docs.python.org/3/reference/executionmodel.html#execution-model) **2.** [`global` statement](https://docs.python.org/3/reference/simple_stmts.html#the-global-statement) -**3.** [`nonlocal` statement](https://docs.python.org/3/reference/simple_stmts.html#the-nonlocal-statement) \ No newline at end of file +**3.** [`nonlocal` statement](https://docs.python.org/3/reference/simple_stmts.html#the-nonlocal-statement) diff --git a/bot/resources/tags/seek.md b/bot/resources/tags/seek.md index ff6569a0c..bc013fe03 100644 --- a/bot/resources/tags/seek.md +++ b/bot/resources/tags/seek.md @@ -19,4 +19,4 @@ Finally, lets do `f.seek(-4, 2)`, moving our stream position *backwards* 4 bytes **Note** • For the second argument in `seek()`, use `os.SEEK_SET`, `os.SEEK_CUR`, and `os.SEEK_END` in place of 0, 1, and 2 respectively. -• `os.SEEK_CUR` is only usable when the file is in byte mode. \ No newline at end of file +• `os.SEEK_CUR` is only usable when the file is in byte mode. diff --git a/bot/resources/tags/self.md b/bot/resources/tags/self.md index a9cd5e9df..d20154fd5 100644 --- a/bot/resources/tags/self.md +++ b/bot/resources/tags/self.md @@ -22,4 +22,4 @@ doing `Foo.spam(foo, 'ham')`. Methods do not inherently have access to attributes defined in the class. In order for any one method to be able to access other methods or variables defined in the class, it must have access to the instance. -Consider if outside the class, we tried to do this: `spam(foo, 'ham')`. This would give an error, because we don't have access to the `spam` method directly, we have to call it by doing `foo.spam('ham')`. This is also the case inside of the class. If we wanted to call the `bar` method inside the `spam` method, we'd have to do `self.bar()`, just doing `bar()` would give an error. \ No newline at end of file +Consider if outside the class, we tried to do this: `spam(foo, 'ham')`. This would give an error, because we don't have access to the `spam` method directly, we have to call it by doing `foo.spam('ham')`. This is also the case inside of the class. If we wanted to call the `bar` method inside the `spam` method, we'd have to do `self.bar()`, just doing `bar()` would give an error. diff --git a/bot/resources/tags/star-imports.md b/bot/resources/tags/star-imports.md index 4c7e0199c..2be6aab6e 100644 --- a/bot/resources/tags/star-imports.md +++ b/bot/resources/tags/star-imports.md @@ -45,4 +45,4 @@ Conclusion: Namespaces are one honking great idea -- let's do more of those! *[3 **[2]** [Namespaces and scopes](https://www.programiz.com/python-programming/namespace) -**[3]** [Zen of Python](https://www.python.org/dev/peps/pep-0020/) \ No newline at end of file +**[3]** [Zen of Python](https://www.python.org/dev/peps/pep-0020/) diff --git a/bot/resources/tags/traceback.md b/bot/resources/tags/traceback.md index 678ba1991..46ef40aa1 100644 --- a/bot/resources/tags/traceback.md +++ b/bot/resources/tags/traceback.md @@ -15,4 +15,4 @@ The best way to read your traceback is bottom to top. • Make note of the line number, and navigate there in your program. • Try to understand why the error occurred. -To read more about exceptions and errors, please refer to the [PyDis Wiki](https://pythondiscord.com/pages/asking-good-questions/#examining-tracebacks) or the [official Python tutorial.](https://docs.python.org/3.7/tutorial/errors.html) \ No newline at end of file +To read more about exceptions and errors, please refer to the [PyDis Wiki](https://pythondiscord.com/pages/asking-good-questions/#examining-tracebacks) or the [official Python tutorial.](https://docs.python.org/3.7/tutorial/errors.html) diff --git a/bot/resources/tags/windows-path.md b/bot/resources/tags/windows-path.md index d8723f06f..da8edf685 100644 --- a/bot/resources/tags/windows-path.md +++ b/bot/resources/tags/windows-path.md @@ -1,6 +1,6 @@ **PATH on Windows** -If you have installed Python but you forgot to check the *Add Python to PATH* option during the installation you may still be able to access your installation with ease. +If you have installed Python but you forgot to check the *Add Python to PATH* option during the installation you may still be able to access your installation with ease. If you did not uncheck the option to install the Python launcher then you will find a `py` command on your system. If you want to be able to open your Python installation by running `python` then your best option is to re-install Python. @@ -27,4 +27,4 @@ C:\Users\Username> py -3.6 ... Python 3.6 stars ... C:\Users\Username> py -2 ... Python 2 (any version installed) starts ... -``` \ No newline at end of file +``` diff --git a/bot/resources/tags/with.md b/bot/resources/tags/with.md index a79eb7dbb..62d5612f2 100644 --- a/bot/resources/tags/with.md +++ b/bot/resources/tags/with.md @@ -5,4 +5,4 @@ with open("test.txt", "r") as file: ``` The above code automatically closes `file` when the `with` block exits, so you never have to manually do a `file.close()`. Most connection types, including file readers and database connections, support this. -For more information, read [the official docs](https://docs.python.org/3/reference/compound_stmts.html#with), watch [Corey Schafer\'s context manager video](https://www.youtube.com/watch?v=-aKFBoZpiqA), or see [PEP 343](https://www.python.org/dev/peps/pep-0343/). \ No newline at end of file +For more information, read [the official docs](https://docs.python.org/3/reference/compound_stmts.html#with), watch [Corey Schafer\'s context manager video](https://www.youtube.com/watch?v=-aKFBoZpiqA), or see [PEP 343](https://www.python.org/dev/peps/pep-0343/). diff --git a/bot/resources/tags/xy-problem.md b/bot/resources/tags/xy-problem.md index 77700e7a0..b77bd27e8 100644 --- a/bot/resources/tags/xy-problem.md +++ b/bot/resources/tags/xy-problem.md @@ -4,4 +4,4 @@ Asking about your attempted solution rather than your actual problem. Often programmers will get distracted with a potential solution they've come up with, and will try asking for help getting it to work. However, it's possible this solution either wouldn't work as they expect, or there's a much better solution instead. -For more information and examples: http://xyproblem.info/ \ No newline at end of file +For more information and examples: http://xyproblem.info/ diff --git a/bot/resources/tags/ytdl.md b/bot/resources/tags/ytdl.md index e1085d1af..09664af26 100644 --- a/bot/resources/tags/ytdl.md +++ b/bot/resources/tags/ytdl.md @@ -6,4 +6,4 @@ For reference, this usage is covered by the following clauses in [YouTube's TOS] ``` ``` 4C: You agree not to access Content through any technology or means other than the video playback pages of the Service itself, the Embeddable Player, or other explicitly authorized means YouTube may designate. -``` \ No newline at end of file +``` diff --git a/bot/resources/tags/zip.md b/bot/resources/tags/zip.md index 9d2fe5ee3..6b05f0282 100644 --- a/bot/resources/tags/zip.md +++ b/bot/resources/tags/zip.md @@ -9,4 +9,4 @@ for letter, number in zip(letters, numbers): ``` The `zip()` iterator is exhausted after the length of the shortest iterable is exceeded. If you would like to retain the other values, consider using [itertools.zip_longest](https://docs.python.org/3/library/itertools.html#itertools.zip_longest). -For more information on zip, please refer to the [official documentation](https://docs.python.org/3/library/functions.html#zip). \ No newline at end of file +For more information on zip, please refer to the [official documentation](https://docs.python.org/3/library/functions.html#zip). -- cgit v1.2.3 From 76a9a03a7b4334fd9606f0940c78111f3cfec9ea Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Fri, 6 Mar 2020 14:35:17 -0600 Subject: Added BigBrother Helper Methods - Added apply_unwatch() and migrated the code from the unwatch command to it. This will give us more control regarding testing and also determining when unwatches trigger. - Added apply_watch() and migrated the code from the watch command to it. Again, this will assist with testing and could make it easier to automate adding to the watch list if need be. - Added unwatch call to apply_ban. User will only be removed from the watch list if they were permanently banned. They will not be removed if it was only temporary. Signed-off-by: Daniel Brown --- bot/cogs/moderation/infractions.py | 13 ++++++++++++- bot/cogs/watchchannels/bigbrother.py | 22 ++++++++++++++++------ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 9ea17b2b3..9bab38e23 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -67,7 +67,7 @@ class Infractions(InfractionScheduler, commands.Cog): @command() async def ban(self, ctx: Context, user: FetchedMember, *, reason: str = None) -> None: - """Permanently ban a user for the given reason.""" + """Permanently ban a user for the given reason. Also removes them from the BigBrother watch list.""" await self.apply_ban(ctx, user, reason) # endregion @@ -243,6 +243,17 @@ class Infractions(InfractionScheduler, commands.Cog): action = ctx.guild.ban(user, reason=reason, delete_message_days=0) await self.apply_infraction(ctx, infraction, user, action) + # Remove perma banned users from the watch list + if 'expires_at' not in kwargs: + bb_cog = self.bot.get_cog("BigBrother") + if bb_cog: + await bb_cog.apply_unwatch( + ctx, + user, + "User has been permanently banned from the server. Automatically removed.", + banned=True + ) + # endregion # region: Base pardon functions diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index c601e0d4d..75b66839e 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -52,6 +52,16 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): A `reason` for adding the user to Big Brother is required and will be displayed in the header when relaying messages of this user to the watchchannel. """ + await self.apply_watch(ctx, user, reason) + + @bigbrother_group.command(name='unwatch', aliases=('uw',)) + @with_role(*MODERATION_ROLES) + async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: + """Stop relaying messages by the given `user`.""" + await self.apply_unwatch(ctx, user, reason) + + async def apply_watch(self, ctx: Context, user: FetchedMember, reason: str) -> None: + """Handles adding a user to the watch list.""" if user.bot: await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.") return @@ -90,10 +100,8 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): await ctx.send(msg) - @bigbrother_group.command(name='unwatch', aliases=('uw',)) - @with_role(*MODERATION_ROLES) - async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: - """Stop relaying messages by the given `user`.""" + async def apply_unwatch(self, ctx: Context, user: FetchedMember, reason: str, banned: bool = False) -> None: + """Handles the actual user removal from the watch list.""" active_watches = await self.bot.api_client.get( self.api_endpoint, params=ChainMap( @@ -111,8 +119,10 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): await post_infraction(ctx, user, 'watch', f"Unwatched: {reason}", hidden=True, active=False) - await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed.") + if not banned: # Prevents a message being sent to the channel if part of a permanent ban + await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed.") self._remove_user(user.id) else: - await ctx.send(":x: The specified user is currently not being watched.") + if not banned: # Prevents a message being sent to the channel if part of a permanent ban + await ctx.send(":x: The specified user is currently not being watched.") -- cgit v1.2.3 From 4254053af6980c75a845ddcc7f1701f7b86a42a9 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 8 Mar 2020 19:15:17 +0100 Subject: Restrict cog to moderators. --- bot/cogs/moderation/silence.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 560a0a15c..0081a420e 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -8,8 +8,9 @@ from discord.ext import commands, tasks from discord.ext.commands import Context, TextChannelConverter from bot.bot import Bot -from bot.constants import Channels, Emojis, Guild, Roles +from bot.constants import Channels, Emojis, Guild, MODERATION_ROLES, Roles from bot.converters import HushDurationConverter +from bot.utils.checks import with_role_check log = logging.getLogger(__name__) @@ -147,3 +148,8 @@ class Silence(commands.Cog): @_notifier.after_loop async def _log_notifier_end(self) -> None: log.trace("Stopping notifier loop.") + + # This cannot be static (must have a __func__ attribute). + def cog_check(self, ctx: Context) -> bool: + """Only allow moderators to invoke the commands in this cog.""" + return with_role_check(ctx, *MODERATION_ROLES) -- cgit v1.2.3 From 8be3c7d1a65bf5d0e0bc07267e7c45f143fae2e6 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 8 Mar 2020 19:39:53 +0100 Subject: Add handling for shh/unshh for `CommandNotFound`. --- bot/cogs/error_handler.py | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 261769efc..45ab1f326 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -31,7 +31,9 @@ class ErrorHandler(Cog): Error handling emits a single error message in the invoking context `ctx` and a log message, prioritised as follows: - 1. If the name fails to match a command but matches a tag, the tag is invoked + 1. If the name fails to match a command: + If it matches shh+ or unshh+, the channel is silenced or unsilenced respectively. + otherwise if it matches a tag, the tag is invoked * If CommandNotFound is raised when invoking the tag (determined by the presence of the `invoked_from_error_handler` attribute), this error is treated as being unexpected and therefore sends an error message @@ -49,10 +51,14 @@ class ErrorHandler(Cog): return # Try to look for a tag with the command's name if the command isn't found. - if isinstance(e, errors.CommandNotFound) and not hasattr(ctx, "invoked_from_error_handler"): - if ctx.channel.id != Channels.verification: + if isinstance(e, errors.CommandNotFound): + if ( + not await self.try_silence(ctx) + and not hasattr(ctx, "invoked_from_error_handler") + and ctx.channel.id != Channels.verification + ): await self.try_get_tag(ctx) - return # Exit early to avoid logging. + return # Exit early to avoid logging. elif isinstance(e, errors.UserInputError): await self.handle_user_input_error(ctx, e) elif isinstance(e, errors.CheckFailure): @@ -89,6 +95,28 @@ class ErrorHandler(Cog): else: return self.bot.get_command("help") + async def try_silence(self, ctx: Context) -> bool: + """ + Attempt to invoke the silence or unsilence command if invoke with matches a pattern. + + Respecting the checks if: + invoked with `shh+` silence channel for amount of h's*2 with max of 15. + invoked with `unshh+` unsilence channel + Return bool depending on success of command. + """ + command = ctx.invoked_with.lower() + silence_command = self.bot.get_command("silence") + if not await silence_command.can_run(ctx): + log.debug("Cancelling attempt to invoke silence/unsilence due to failed checks.") + return False + if command.startswith("shh"): + await ctx.invoke(silence_command, duration=min(command.count("h")*2, 15)) + return True + elif command.startswith("unshh"): + await ctx.invoke(self.bot.get_command("unsilence")) + return True + return False + async def try_get_tag(self, ctx: Context) -> None: """ Attempt to display a tag by interpreting the command name as a tag name. -- cgit v1.2.3 From d4253e106771f90a983717a994349d52337b2de9 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 8 Mar 2020 19:40:34 +0100 Subject: Add tests for FirstHash class. --- tests/bot/cogs/moderation/__init__.py | 0 tests/bot/cogs/moderation/test_silence.py | 25 +++++++++++++++++++++++++ 2 files changed, 25 insertions(+) create mode 100644 tests/bot/cogs/moderation/__init__.py create mode 100644 tests/bot/cogs/moderation/test_silence.py diff --git a/tests/bot/cogs/moderation/__init__.py b/tests/bot/cogs/moderation/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py new file mode 100644 index 000000000..2a06f5944 --- /dev/null +++ b/tests/bot/cogs/moderation/test_silence.py @@ -0,0 +1,25 @@ +import unittest + +from bot.cogs.moderation.silence import FirstHash + + +class FirstHashTests(unittest.TestCase): + def setUp(self) -> None: + self.test_cases = ( + (FirstHash(0, 4), FirstHash(0, 5)), + (FirstHash("string", None), FirstHash("string", True)) + ) + + def test_hashes_equal(self): + """Check hashes equal with same first item.""" + + for tuple1, tuple2 in self.test_cases: + with self.subTest(tuple1=tuple1, tuple2=tuple2): + self.assertEqual(hash(tuple1), hash(tuple2)) + + def test_eq(self): + """Check objects are equal with same first item.""" + + for tuple1, tuple2 in self.test_cases: + with self.subTest(tuple1=tuple1, tuple2=tuple2): + self.assertTrue(tuple1 == tuple2) -- cgit v1.2.3 From e872176b452ceca1b639ef42d640e18656c7c0c9 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 8 Mar 2020 19:42:18 +0100 Subject: Add test case for Silence cog. --- tests/bot/cogs/moderation/test_silence.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 2a06f5944..1db2b6eec 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -1,6 +1,7 @@ import unittest -from bot.cogs.moderation.silence import FirstHash +from bot.cogs.moderation.silence import FirstHash, Silence +from tests.helpers import MockBot, MockContext class FirstHashTests(unittest.TestCase): @@ -23,3 +24,11 @@ class FirstHashTests(unittest.TestCase): for tuple1, tuple2 in self.test_cases: with self.subTest(tuple1=tuple1, tuple2=tuple2): self.assertTrue(tuple1 == tuple2) + + +class SilenceTests(unittest.TestCase): + def setUp(self) -> None: + + self.bot = MockBot() + self.cog = Silence(self.bot) + self.ctx = MockContext() -- cgit v1.2.3 From 1d83a5752aae483224129ee798e529f3d7d8e132 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 8 Mar 2020 19:42:51 +0100 Subject: Add test for `silence` discord output. --- tests/bot/cogs/moderation/test_silence.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 1db2b6eec..088410bee 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -1,6 +1,10 @@ +import asyncio import unittest +from functools import partial +from unittest import mock from bot.cogs.moderation.silence import FirstHash, Silence +from bot.constants import Emojis from tests.helpers import MockBot, MockContext @@ -32,3 +36,23 @@ class SilenceTests(unittest.TestCase): self.bot = MockBot() self.cog = Silence(self.bot) self.ctx = MockContext() + + def test_silence_sent_correct_discord_message(self): + """Check if proper message was sent when called with duration in channel with previous state.""" + test_cases = ( + ((self.cog, self.ctx, 0.0001), f"{Emojis.check_mark} #channel silenced for 0.0001 minute(s).", True,), + ((self.cog, self.ctx, None), f"{Emojis.check_mark} #channel silenced indefinitely.", True,), + ((self.cog, self.ctx, 5), f"{Emojis.cross_mark} #channel is already silenced.", False,), + ) + for silence_call_args, result_message, _silence_patch_return in test_cases: + with self.subTest( + silence_duration=silence_call_args[-1], + result_message=result_message, + starting_unsilenced_state=_silence_patch_return + ): + with mock.patch( + "bot.cogs.moderation.silence.Silence._silence", + new_callable=partial(mock.AsyncMock, return_value=_silence_patch_return) + ): + asyncio.run(self.cog.silence.callback(*silence_call_args)) + self.ctx.send.call_args.assert_called_once_with(result_message) -- cgit v1.2.3 From cfbe3b9742b5531bdced1d5b099739f01033a6bb Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 8 Mar 2020 22:20:00 +0100 Subject: Add test for `unsilence` discord output. --- tests/bot/cogs/moderation/test_silence.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 088410bee..17420ce7d 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -56,3 +56,12 @@ class SilenceTests(unittest.TestCase): ): asyncio.run(self.cog.silence.callback(*silence_call_args)) self.ctx.send.call_args.assert_called_once_with(result_message) + + def test_unsilence_sent_correct_discord_message(self): + """Check if proper message was sent to `alert_chanel`.""" + with mock.patch( + "bot.cogs.moderation.silence.Silence._unsilence", + new_callable=partial(mock.AsyncMock, return_value=True) + ): + asyncio.run(self.cog.unsilence.callback(self.cog, self.ctx)) + self.ctx.channel.send.call_args.assert_called_once_with(f"{Emojis.check_mark} Unsilenced #channel.") -- cgit v1.2.3 From 7acaa717aab47f470353dcb49ee0202e86339d7c Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 8 Mar 2020 22:20:43 +0100 Subject: Use `Context.invoke` instead of calling `unsilence` directly. Calling the command coro directly did unnecessary checks and made tests for the method harder to realize. --- bot/cogs/moderation/silence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 0081a420e..266d6dedd 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -68,7 +68,7 @@ class Silence(commands.Cog): await ctx.send(f"{Emojis.check_mark} {channel.mention} silenced for {duration} minute(s).") await asyncio.sleep(duration*60) - await self.unsilence(ctx, channel) + await ctx.invoke(self.unsilence, channel=channel) @commands.command(aliases=("unhush",)) async def unsilence(self, ctx: Context, channel: TextChannelConverter = None) -> None: -- cgit v1.2.3 From 01c7f193806e494408792eb3907280dccad3eacf Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 8 Mar 2020 22:20:57 +0100 Subject: Remove "Channel" from output string for consistency. --- bot/cogs/moderation/silence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 266d6dedd..10185761c 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -63,7 +63,7 @@ class Silence(commands.Cog): await ctx.send(f"{Emojis.cross_mark} {channel.mention} is already silenced.") return if duration is None: - await ctx.send(f"{Emojis.check_mark} Channel {channel.mention} silenced indefinitely.") + await ctx.send(f"{Emojis.check_mark} {channel.mention} silenced indefinitely.") return await ctx.send(f"{Emojis.check_mark} {channel.mention} silenced for {duration} minute(s).") -- cgit v1.2.3 From 57e7fc0b02704dd65b3307e92be87237f806cb68 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 8 Mar 2020 22:21:43 +0100 Subject: Move notifier to separate class. Separating the notifier allows us to keep the Silence class and its methods to be more focused on the class' purpose, handling the logic of adding/removing channels and the loop itself behind `SilenceNotifier`'s interface. --- bot/cogs/moderation/silence.py | 86 ++++++++++++++++++++++-------------------- 1 file changed, 46 insertions(+), 40 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 10185761c..e12b6c606 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -29,21 +29,59 @@ class FirstHash(tuple): return self[0] == other[0] +class SilenceNotifier(tasks.Loop): + """Loop notifier for posting notices to `alert_channel` containing added channels.""" + + def __init__(self, alert_channel: TextChannel): + super().__init__(self._notifier, seconds=1, minutes=0, hours=0, count=None, reconnect=True, loop=None) + self._silenced_channels = set() + self._alert_channel = alert_channel + + def add_channel(self, channel: TextChannel) -> None: + """Add channel to `_silenced_channels` and start loop if not launched.""" + if not self._silenced_channels: + self.start() + log.trace("Starting notifier loop.") + self._silenced_channels.add(FirstHash(channel, self._current_loop)) + + def remove_channel(self, channel: TextChannel) -> None: + """Remove channel from `_silenced_channels` and stop loop if no channels remain.""" + with suppress(KeyError): + self._silenced_channels.remove(FirstHash(channel)) + if not self._silenced_channels: + self.stop() + log.trace("Stopping notifier loop.") + + async def _notifier(self) -> None: + """Post notice of `_silenced_channels` with their silenced duration to `_alert_channel` periodically.""" + # Wait for 15 minutes between notices with pause at start of loop. + if self._current_loop and not self._current_loop/60 % 15: + log.debug( + f"Sending notice with channels: " + f"{', '.join(f'#{channel} ({channel.id})' for channel, _ in self._silenced_channels)}." + ) + channels_text = ', '.join( + f"{channel.mention} for {(self._current_loop-start)//60} min" + for channel, start in self._silenced_channels + ) + await self._alert_channel.send(f"<@&{Roles.moderators}> currently silenced channels: {channels_text}") + + class Silence(commands.Cog): """Commands for stopping channel messages for `verified` role in a channel.""" def __init__(self, bot: Bot): self.bot = bot - self.loop_alert_channels = set() - self.bot.loop.create_task(self._get_server_values()) + self.bot.loop.create_task(self._get_instance_vars()) - async def _get_server_values(self) -> None: - """Fetch required internal values after they're available.""" + async def _get_instance_vars(self) -> None: + """Get instance variables after they're available to get from the guild.""" await self.bot.wait_until_guild_available() guild = self.bot.get_guild(Guild.id) self._verified_role = guild.get_role(Roles.verified) self._mod_alerts_channel = self.bot.get_channel(Channels.mod_alerts) self._mod_log_channel = self.bot.get_channel(Channels.mod_log) + self.notifier = SilenceNotifier(self._mod_log_channel) @commands.command(aliases=("hush",)) async def silence( @@ -87,8 +125,7 @@ class Silence(commands.Cog): """ Silence `channel` for `self._verified_role`. - If `persistent` is `True` add `channel` with current iteration of `self._notifier` - to `self.self.loop_alert_channels` and attempt to start notifier. + If `persistent` is `True` add `channel` to notifier. `duration` is only used for logging; if None is passed `persistent` should be True to not log None. """ if channel.overwrites_for(self._verified_role).send_messages is False: @@ -97,9 +134,7 @@ class Silence(commands.Cog): await channel.set_permissions(self._verified_role, overwrite=PermissionOverwrite(send_messages=False)) if persistent: log.debug(f"Silenced #{channel} ({channel.id}) indefinitely.") - self.loop_alert_channels.add(FirstHash(channel, self._notifier.current_loop)) - with suppress(RuntimeError): - self._notifier.start() + self.notifier.add_channel(channel) return True log.debug(f"Silenced #{channel} ({channel.id}) for {duration} minute(s).") @@ -110,45 +145,16 @@ class Silence(commands.Cog): Unsilence `channel`. Check if `channel` is silenced through a `PermissionOverwrite`, - if it is unsilence it, attempt to remove it from `self.loop_alert_channels` - and if `self.loop_alert_channels` are left empty, stop the `self._notifier` + if it is unsilence it and remove it from the notifier. """ if channel.overwrites_for(self._verified_role).send_messages is False: await channel.set_permissions(self._verified_role, overwrite=None) log.debug(f"Unsilenced channel #{channel} ({channel.id}).") - - with suppress(KeyError): - self.loop_alert_channels.remove(FirstHash(channel)) - if not self.loop_alert_channels: - self._notifier.cancel() + self.notifier.remove_channel(channel) return True log.debug(f"Tried to unsilence channel #{channel} ({channel.id}) but the channel was not silenced.") return False - @tasks.loop() - async def _notifier(self) -> None: - """Post notice of permanently silenced channels to `mod_alerts` periodically.""" - # Wait for 15 minutes between notices with pause at start of loop. - await asyncio.sleep(15*60) - current_iter = self._notifier.current_loop+1 - channels_text = ', '.join( - f"{channel.mention} for {current_iter-start} min" - for channel, start in self.loop_alert_channels - ) - channels_log_text = ', '.join( - f'#{channel} ({channel.id})' for channel, _ in self.loop_alert_channels - ) - log.debug(f"Sending notice with channels: {channels_log_text}") - await self._mod_alerts_channel.send(f"<@&{Roles.moderators}> currently silenced channels: {channels_text}") - - @_notifier.before_loop - async def _log_notifier_start(self) -> None: - log.trace("Starting notifier loop.") - - @_notifier.after_loop - async def _log_notifier_end(self) -> None: - log.trace("Stopping notifier loop.") - # This cannot be static (must have a __func__ attribute). def cog_check(self, ctx: Context) -> bool: """Only allow moderators to invoke the commands in this cog.""" -- cgit v1.2.3 From f1eb927fb1aa1fffc9f3e03a2987e03361d9f0b9 Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Fri, 6 Mar 2020 14:35:17 -0600 Subject: Added BigBrother Helper Methods - Added apply_unwatch() and migrated the code from the unwatch command to it. This will give us more control regarding testing and also determining when unwatches trigger. - Added apply_watch() and migrated the code from the watch command to it. Again, this will assist with testing and could make it easier to automate adding to the watch list if need be. - Added unwatch call to apply_ban. User will only be removed from the watch list if they were permanently banned. They will not be removed if it was only temporary. Signed-off-by: Daniel Brown --- bot/cogs/moderation/infractions.py | 13 ++++++++++++- bot/cogs/watchchannels/bigbrother.py | 22 ++++++++++++++++------ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 9ea17b2b3..9bab38e23 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -67,7 +67,7 @@ class Infractions(InfractionScheduler, commands.Cog): @command() async def ban(self, ctx: Context, user: FetchedMember, *, reason: str = None) -> None: - """Permanently ban a user for the given reason.""" + """Permanently ban a user for the given reason. Also removes them from the BigBrother watch list.""" await self.apply_ban(ctx, user, reason) # endregion @@ -243,6 +243,17 @@ class Infractions(InfractionScheduler, commands.Cog): action = ctx.guild.ban(user, reason=reason, delete_message_days=0) await self.apply_infraction(ctx, infraction, user, action) + # Remove perma banned users from the watch list + if 'expires_at' not in kwargs: + bb_cog = self.bot.get_cog("BigBrother") + if bb_cog: + await bb_cog.apply_unwatch( + ctx, + user, + "User has been permanently banned from the server. Automatically removed.", + banned=True + ) + # endregion # region: Base pardon functions diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index c601e0d4d..75b66839e 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -52,6 +52,16 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): A `reason` for adding the user to Big Brother is required and will be displayed in the header when relaying messages of this user to the watchchannel. """ + await self.apply_watch(ctx, user, reason) + + @bigbrother_group.command(name='unwatch', aliases=('uw',)) + @with_role(*MODERATION_ROLES) + async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: + """Stop relaying messages by the given `user`.""" + await self.apply_unwatch(ctx, user, reason) + + async def apply_watch(self, ctx: Context, user: FetchedMember, reason: str) -> None: + """Handles adding a user to the watch list.""" if user.bot: await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.") return @@ -90,10 +100,8 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): await ctx.send(msg) - @bigbrother_group.command(name='unwatch', aliases=('uw',)) - @with_role(*MODERATION_ROLES) - async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: - """Stop relaying messages by the given `user`.""" + async def apply_unwatch(self, ctx: Context, user: FetchedMember, reason: str, banned: bool = False) -> None: + """Handles the actual user removal from the watch list.""" active_watches = await self.bot.api_client.get( self.api_endpoint, params=ChainMap( @@ -111,8 +119,10 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): await post_infraction(ctx, user, 'watch', f"Unwatched: {reason}", hidden=True, active=False) - await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed.") + if not banned: # Prevents a message being sent to the channel if part of a permanent ban + await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed.") self._remove_user(user.id) else: - await ctx.send(":x: The specified user is currently not being watched.") + if not banned: # Prevents a message being sent to the channel if part of a permanent ban + await ctx.send(":x: The specified user is currently not being watched.") -- cgit v1.2.3 From ee94c38063981ee6770c1d263eab9c0d2e178380 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 9 Mar 2020 20:41:55 +0100 Subject: Use `patch.object` instead of patch with direct `return_value`. --- tests/bot/cogs/moderation/test_silence.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 17420ce7d..53b3fd388 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -1,6 +1,5 @@ import asyncio import unittest -from functools import partial from unittest import mock from bot.cogs.moderation.silence import FirstHash, Silence @@ -50,18 +49,12 @@ class SilenceTests(unittest.TestCase): result_message=result_message, starting_unsilenced_state=_silence_patch_return ): - with mock.patch( - "bot.cogs.moderation.silence.Silence._silence", - new_callable=partial(mock.AsyncMock, return_value=_silence_patch_return) - ): + with mock.patch.object(self.cog, "_silence", return_value=_silence_patch_return): asyncio.run(self.cog.silence.callback(*silence_call_args)) self.ctx.send.call_args.assert_called_once_with(result_message) def test_unsilence_sent_correct_discord_message(self): """Check if proper message was sent to `alert_chanel`.""" - with mock.patch( - "bot.cogs.moderation.silence.Silence._unsilence", - new_callable=partial(mock.AsyncMock, return_value=True) - ): + with mock.patch.object(self.cog, "_unsilence", return_value=True): asyncio.run(self.cog.unsilence.callback(self.cog, self.ctx)) self.ctx.channel.send.call_args.assert_called_once_with(f"{Emojis.check_mark} Unsilenced #channel.") -- cgit v1.2.3 From 60814ee9270d4c550047478bf8d4a179d7351696 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 9 Mar 2020 13:09:08 -0700 Subject: Cog tests: create boilerplate for command name tests --- tests/bot/cogs/test_cogs.py | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 tests/bot/cogs/test_cogs.py diff --git a/tests/bot/cogs/test_cogs.py b/tests/bot/cogs/test_cogs.py new file mode 100644 index 000000000..6f5d07030 --- /dev/null +++ b/tests/bot/cogs/test_cogs.py @@ -0,0 +1,7 @@ +"""Test suite for general tests which apply to all cogs.""" + +import unittest + + +class CommandNameTests(unittest.TestCase): + """Tests for shadowing command names and aliases.""" -- cgit v1.2.3 From d31f7e3f4a4876d51119d5875afa9221b14b285e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 9 Mar 2020 13:10:21 -0700 Subject: Cog tests: add a function to get all commands For tests, ideally creating instances of cogs should be avoided to avoid extra code execution. This function was copied over from discord.py because their function is not a static method, though it still works as one. It was probably just a design decision on their part to not make it static. --- tests/bot/cogs/test_cogs.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/bot/cogs/test_cogs.py b/tests/bot/cogs/test_cogs.py index 6f5d07030..b128ca123 100644 --- a/tests/bot/cogs/test_cogs.py +++ b/tests/bot/cogs/test_cogs.py @@ -1,7 +1,19 @@ """Test suite for general tests which apply to all cogs.""" +import typing as t import unittest +from discord.ext import commands + class CommandNameTests(unittest.TestCase): """Tests for shadowing command names and aliases.""" + + @staticmethod + def walk_commands(cog: commands.Cog) -> t.Iterator[commands.Command]: + """An iterator that recursively walks through `cog`'s commands and subcommands.""" + for command in cog.__cog_commands__: + if command.parent is None: + yield command + if isinstance(command, commands.GroupMixin): + yield from command.walk_commands() -- cgit v1.2.3 From d9bf06e7b916a7214f00b43cb08b582485f86781 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Tue, 10 Mar 2020 00:38:43 +0100 Subject: Retain previous channel overwrites. Previously silencing a channel reset all overwrites excluding `send_messages` and unsilencing them removed all overwrites. This is prevented by getting the current overwrite and applying it with only send_messages changed. --- bot/cogs/moderation/silence.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index e12b6c606..626c1ecfb 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -128,10 +128,14 @@ class Silence(commands.Cog): If `persistent` is `True` add `channel` to notifier. `duration` is only used for logging; if None is passed `persistent` should be True to not log None. """ - if channel.overwrites_for(self._verified_role).send_messages is False: + current_overwrite = channel.overwrites_for(self._verified_role) + if current_overwrite.send_messages is False: log.debug(f"Tried to silence channel #{channel} ({channel.id}) but the channel was already silenced.") return False - await channel.set_permissions(self._verified_role, overwrite=PermissionOverwrite(send_messages=False)) + await channel.set_permissions( + self._verified_role, + overwrite=PermissionOverwrite(**dict(current_overwrite, send_messages=False)) + ) if persistent: log.debug(f"Silenced #{channel} ({channel.id}) indefinitely.") self.notifier.add_channel(channel) @@ -147,8 +151,12 @@ class Silence(commands.Cog): Check if `channel` is silenced through a `PermissionOverwrite`, if it is unsilence it and remove it from the notifier. """ - if channel.overwrites_for(self._verified_role).send_messages is False: - await channel.set_permissions(self._verified_role, overwrite=None) + current_overwrite = channel.overwrites_for(self._verified_role) + if current_overwrite.send_messages is False: + await channel.set_permissions( + self._verified_role, + overwrite=PermissionOverwrite(**dict(current_overwrite, send_messages=True)) + ) log.debug(f"Unsilenced channel #{channel} ({channel.id}).") self.notifier.remove_channel(channel) return True -- cgit v1.2.3 From 90a2e14abb898f39000cf11cfd26f0d89abb4800 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Tue, 10 Mar 2020 19:43:57 +0100 Subject: Remove `channel` arg from commands. --- bot/cogs/moderation/silence.py | 30 ++++++++++-------------------- 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 626c1ecfb..0fe720882 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -5,7 +5,7 @@ from typing import Optional from discord import PermissionOverwrite, TextChannel from discord.ext import commands, tasks -from discord.ext.commands import Context, TextChannelConverter +from discord.ext.commands import Context from bot.bot import Bot from bot.constants import Channels, Emojis, Guild, MODERATION_ROLES, Roles @@ -84,42 +84,32 @@ class Silence(commands.Cog): self.notifier = SilenceNotifier(self._mod_log_channel) @commands.command(aliases=("hush",)) - async def silence( - self, - ctx: Context, - duration: HushDurationConverter = 10, - channel: TextChannelConverter = None - ) -> None: + async def silence(self, ctx: Context, duration: HushDurationConverter = 10) -> None: """ Silence `channel` for `duration` minutes or `"forever"`. If duration is forever, start a notifier loop that triggers every 15 minutes. """ - channel = channel or ctx.channel - - if not await self._silence(channel, persistent=(duration is None), duration=duration): - await ctx.send(f"{Emojis.cross_mark} {channel.mention} is already silenced.") + if not await self._silence(ctx.channel, persistent=(duration is None), duration=duration): + await ctx.send(f"{Emojis.cross_mark} {ctx.channel.mention} is already silenced.") return if duration is None: - await ctx.send(f"{Emojis.check_mark} {channel.mention} silenced indefinitely.") + await ctx.send(f"{Emojis.check_mark} {ctx.channel.mention} silenced indefinitely.") return - await ctx.send(f"{Emojis.check_mark} {channel.mention} silenced for {duration} minute(s).") + await ctx.send(f"{Emojis.check_mark} {ctx.channel.mention} silenced for {duration} minute(s).") await asyncio.sleep(duration*60) - await ctx.invoke(self.unsilence, channel=channel) + await ctx.invoke(self.unsilence) @commands.command(aliases=("unhush",)) - async def unsilence(self, ctx: Context, channel: TextChannelConverter = None) -> None: + async def unsilence(self, ctx: Context) -> None: """ Unsilence `channel`. Unsilence a previously silenced `channel` and remove it from indefinitely muted channels notice if applicable. """ - channel = channel or ctx.channel - alert_channel = self._mod_log_channel if ctx.invoked_with == "hush" else ctx.channel - - if await self._unsilence(channel): - await alert_channel.send(f"{Emojis.check_mark} Unsilenced {channel.mention}.") + if await self._unsilence(ctx.channel): + await ctx.send(f"{Emojis.check_mark} Unsilenced {ctx.channel.mention}.") async def _silence(self, channel: TextChannel, persistent: bool, duration: Optional[int]) -> bool: """ -- cgit v1.2.3 From 39b2319bff31a7b3e2cb17154bcbdad0a0e71fc7 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Tue, 10 Mar 2020 23:38:53 +0100 Subject: Add alert with silenced channels on `cog_unload`. --- bot/cogs/moderation/silence.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 0fe720882..76c5a171d 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -72,6 +72,7 @@ class Silence(commands.Cog): def __init__(self, bot: Bot): self.bot = bot + self.muted_channels = set() self.bot.loop.create_task(self._get_instance_vars()) async def _get_instance_vars(self) -> None: @@ -131,6 +132,7 @@ class Silence(commands.Cog): self.notifier.add_channel(channel) return True + self.muted_channels.add(channel) log.debug(f"Silenced #{channel} ({channel.id}) for {duration} minute(s).") return True @@ -149,10 +151,19 @@ class Silence(commands.Cog): ) log.debug(f"Unsilenced channel #{channel} ({channel.id}).") self.notifier.remove_channel(channel) + with suppress(KeyError): + self.muted_channels.remove(channel) return True log.debug(f"Tried to unsilence channel #{channel} ({channel.id}) but the channel was not silenced.") return False + def cog_unload(self) -> None: + """Send alert with silenced channels on unload.""" + if self.muted_channels: + channels_string = ''.join(channel.mention for channel in self.muted_channels) + message = f"<@&{Roles.moderators}> channels left silenced on cog unload: {channels_string}" + asyncio.create_task(self._mod_alerts_channel.send(message)) + # This cannot be static (must have a __func__ attribute). def cog_check(self, ctx: Context) -> bool: """Only allow moderators to invoke the commands in this cog.""" -- cgit v1.2.3 From 85c439fbf78f59ff314f4f9daef1467d486709c3 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 11 Mar 2020 00:01:58 +0100 Subject: Remove unnecessary args from test cases. Needless call args which were constant were kept in the test cases, resulting in redundant code, the args were moved directly into the function call. --- tests/bot/cogs/moderation/test_silence.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 53b3fd388..1341911d5 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -39,18 +39,18 @@ class SilenceTests(unittest.TestCase): def test_silence_sent_correct_discord_message(self): """Check if proper message was sent when called with duration in channel with previous state.""" test_cases = ( - ((self.cog, self.ctx, 0.0001), f"{Emojis.check_mark} #channel silenced for 0.0001 minute(s).", True,), - ((self.cog, self.ctx, None), f"{Emojis.check_mark} #channel silenced indefinitely.", True,), - ((self.cog, self.ctx, 5), f"{Emojis.cross_mark} #channel is already silenced.", False,), + (0.0001, f"{Emojis.check_mark} #channel silenced for 0.0001 minute(s).", True,), + (None, f"{Emojis.check_mark} #channel silenced indefinitely.", True,), + (5, f"{Emojis.cross_mark} #channel is already silenced.", False,), ) - for silence_call_args, result_message, _silence_patch_return in test_cases: + for duration, result_message, _silence_patch_return in test_cases: with self.subTest( - silence_duration=silence_call_args[-1], + silence_duration=duration, result_message=result_message, starting_unsilenced_state=_silence_patch_return ): with mock.patch.object(self.cog, "_silence", return_value=_silence_patch_return): - asyncio.run(self.cog.silence.callback(*silence_call_args)) + asyncio.run(self.cog.silence.callback(self.cog, self.ctx, duration)) self.ctx.send.call_args.assert_called_once_with(result_message) def test_unsilence_sent_correct_discord_message(self): -- cgit v1.2.3 From adaf456607ba2f2724c6fd34308cd170c81aa651 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 11 Mar 2020 00:56:31 +0100 Subject: Remove channel mentions from output discord messages. With the removal of the channel args, it's no longer necessary to mention the channel in the command output. Tests adjusted accordingly --- bot/cogs/moderation/silence.py | 8 ++++---- tests/bot/cogs/moderation/test_silence.py | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 76c5a171d..68cad4062 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -92,13 +92,13 @@ class Silence(commands.Cog): If duration is forever, start a notifier loop that triggers every 15 minutes. """ if not await self._silence(ctx.channel, persistent=(duration is None), duration=duration): - await ctx.send(f"{Emojis.cross_mark} {ctx.channel.mention} is already silenced.") + await ctx.send(f"{Emojis.cross_mark} current channel is already silenced.") return if duration is None: - await ctx.send(f"{Emojis.check_mark} {ctx.channel.mention} silenced indefinitely.") + await ctx.send(f"{Emojis.check_mark} silenced current channel indefinitely.") return - await ctx.send(f"{Emojis.check_mark} {ctx.channel.mention} silenced for {duration} minute(s).") + await ctx.send(f"{Emojis.check_mark} silenced current channel for {duration} minute(s).") await asyncio.sleep(duration*60) await ctx.invoke(self.unsilence) @@ -110,7 +110,7 @@ class Silence(commands.Cog): Unsilence a previously silenced `channel` and remove it from indefinitely muted channels notice if applicable. """ if await self._unsilence(ctx.channel): - await ctx.send(f"{Emojis.check_mark} Unsilenced {ctx.channel.mention}.") + await ctx.send(f"{Emojis.check_mark} unsilenced current channel.") async def _silence(self, channel: TextChannel, persistent: bool, duration: Optional[int]) -> bool: """ diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 1341911d5..6da374a8f 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -39,9 +39,9 @@ class SilenceTests(unittest.TestCase): def test_silence_sent_correct_discord_message(self): """Check if proper message was sent when called with duration in channel with previous state.""" test_cases = ( - (0.0001, f"{Emojis.check_mark} #channel silenced for 0.0001 minute(s).", True,), - (None, f"{Emojis.check_mark} #channel silenced indefinitely.", True,), - (5, f"{Emojis.cross_mark} #channel is already silenced.", False,), + (0.0001, f"{Emojis.check_mark} silenced current channel for 0.0001 minute(s).", True,), + (None, f"{Emojis.check_mark} silenced current channel indefinitely.", True,), + (5, f"{Emojis.cross_mark} current channel is already silenced.", False,), ) for duration, result_message, _silence_patch_return in test_cases: with self.subTest( @@ -57,4 +57,4 @@ class SilenceTests(unittest.TestCase): """Check if proper message was sent to `alert_chanel`.""" with mock.patch.object(self.cog, "_unsilence", return_value=True): asyncio.run(self.cog.unsilence.callback(self.cog, self.ctx)) - self.ctx.channel.send.call_args.assert_called_once_with(f"{Emojis.check_mark} Unsilenced #channel.") + self.ctx.send.call_args.assert_called_once_with(f"{Emojis.check_mark} unsilenced current channel.") -- cgit v1.2.3 From a24ffb3b53ca2043afe4d428e1456b6979c1f888 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 11 Mar 2020 01:21:54 +0100 Subject: Move adding of channel to `muted_channels` up. Before the channel was not added if `persistent` was `True`. --- bot/cogs/moderation/silence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 68cad4062..4153b3439 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -127,12 +127,12 @@ class Silence(commands.Cog): self._verified_role, overwrite=PermissionOverwrite(**dict(current_overwrite, send_messages=False)) ) + self.muted_channels.add(channel) if persistent: log.debug(f"Silenced #{channel} ({channel.id}) indefinitely.") self.notifier.add_channel(channel) return True - self.muted_channels.add(channel) log.debug(f"Silenced #{channel} ({channel.id}) for {duration} minute(s).") return True -- cgit v1.2.3 From 68d43946d1dc6393a4f7b8b4812b5c4787842c12 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 11 Mar 2020 01:28:26 +0100 Subject: Add test for `_silence` method. --- tests/bot/cogs/moderation/test_silence.py | 35 ++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 6da374a8f..6a75db2a0 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -1,10 +1,11 @@ import asyncio import unittest from unittest import mock +from unittest.mock import Mock from bot.cogs.moderation.silence import FirstHash, Silence from bot.constants import Emojis -from tests.helpers import MockBot, MockContext +from tests.helpers import MockBot, MockContext, MockTextChannel class FirstHashTests(unittest.TestCase): @@ -35,6 +36,7 @@ class SilenceTests(unittest.TestCase): self.bot = MockBot() self.cog = Silence(self.bot) self.ctx = MockContext() + self.cog._verified_role = None def test_silence_sent_correct_discord_message(self): """Check if proper message was sent when called with duration in channel with previous state.""" @@ -58,3 +60,34 @@ class SilenceTests(unittest.TestCase): with mock.patch.object(self.cog, "_unsilence", return_value=True): asyncio.run(self.cog.unsilence.callback(self.cog, self.ctx)) self.ctx.send.call_args.assert_called_once_with(f"{Emojis.check_mark} unsilenced current channel.") + + def test_silence_private_for_false(self): + """Permissions are not set and `False` is returned in an already silenced channel.""" + perm_overwrite = Mock(send_messages=False) + channel = Mock(overwrites_for=Mock(return_value=perm_overwrite)) + + self.assertFalse(asyncio.run(self.cog._silence(channel, True, None))) + channel.set_permissions.assert_not_called() + + def test_silence_private_silenced_channel(self): + """Channel had `send_message` permissions revoked and was added to `muted_channels`.""" + channel = MockTextChannel() + muted_channels = Mock() + with mock.patch.object(self.cog, "muted_channels", new=muted_channels, create=True): + self.assertTrue(asyncio.run(self.cog._silence(channel, False, None))) + channel.set_permissions.assert_called_once() + self.assertFalse(channel.set_permissions.call_args.kwargs['overwrite'].send_messages) + muted_channels.add.call_args.assert_called_once_with(channel) + + def test_silence_private_notifier(self): + """Channel should be added to notifier with `persistent` set to `True`, and the other way around.""" + channel = MockTextChannel() + with mock.patch.object(self.cog, "notifier", create=True): + with self.subTest(persistent=True): + asyncio.run(self.cog._silence(channel, True, None)) + self.cog.notifier.add_channel.assert_called_once() + + with mock.patch.object(self.cog, "notifier", create=True): + with self.subTest(persistent=False): + asyncio.run(self.cog._silence(channel, False, None)) + self.cog.notifier.add_channel.assert_not_called() -- cgit v1.2.3 From fef8c8e8504d8431ae7cad23128733d0b9039c7a Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 11 Mar 2020 02:31:36 +0100 Subject: Use async test case. This allows us to use coroutines with await directly instead of asyncio.run --- tests/bot/cogs/moderation/test_silence.py | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 6a75db2a0..33ff78ca6 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -1,4 +1,3 @@ -import asyncio import unittest from unittest import mock from unittest.mock import Mock @@ -30,15 +29,14 @@ class FirstHashTests(unittest.TestCase): self.assertTrue(tuple1 == tuple2) -class SilenceTests(unittest.TestCase): +class SilenceTests(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: - self.bot = MockBot() self.cog = Silence(self.bot) self.ctx = MockContext() self.cog._verified_role = None - def test_silence_sent_correct_discord_message(self): + async def test_silence_sent_correct_discord_message(self): """Check if proper message was sent when called with duration in channel with previous state.""" test_cases = ( (0.0001, f"{Emojis.check_mark} silenced current channel for 0.0001 minute(s).", True,), @@ -52,42 +50,42 @@ class SilenceTests(unittest.TestCase): starting_unsilenced_state=_silence_patch_return ): with mock.patch.object(self.cog, "_silence", return_value=_silence_patch_return): - asyncio.run(self.cog.silence.callback(self.cog, self.ctx, duration)) + await self.cog.silence.callback(self.cog, self.ctx, duration) self.ctx.send.call_args.assert_called_once_with(result_message) - def test_unsilence_sent_correct_discord_message(self): + async def test_unsilence_sent_correct_discord_message(self): """Check if proper message was sent to `alert_chanel`.""" with mock.patch.object(self.cog, "_unsilence", return_value=True): - asyncio.run(self.cog.unsilence.callback(self.cog, self.ctx)) + await self.cog.unsilence.callback(self.cog, self.ctx) self.ctx.send.call_args.assert_called_once_with(f"{Emojis.check_mark} unsilenced current channel.") - def test_silence_private_for_false(self): + async def test_silence_private_for_false(self): """Permissions are not set and `False` is returned in an already silenced channel.""" perm_overwrite = Mock(send_messages=False) channel = Mock(overwrites_for=Mock(return_value=perm_overwrite)) - self.assertFalse(asyncio.run(self.cog._silence(channel, True, None))) + self.assertFalse(await self.cog._silence(channel, True, None)) channel.set_permissions.assert_not_called() - def test_silence_private_silenced_channel(self): + async def test_silence_private_silenced_channel(self): """Channel had `send_message` permissions revoked and was added to `muted_channels`.""" channel = MockTextChannel() muted_channels = Mock() with mock.patch.object(self.cog, "muted_channels", new=muted_channels, create=True): - self.assertTrue(asyncio.run(self.cog._silence(channel, False, None))) + self.assertTrue(await self.cog._silence(channel, False, None)) channel.set_permissions.assert_called_once() self.assertFalse(channel.set_permissions.call_args.kwargs['overwrite'].send_messages) muted_channels.add.call_args.assert_called_once_with(channel) - def test_silence_private_notifier(self): + async def test_silence_private_notifier(self): """Channel should be added to notifier with `persistent` set to `True`, and the other way around.""" channel = MockTextChannel() with mock.patch.object(self.cog, "notifier", create=True): with self.subTest(persistent=True): - asyncio.run(self.cog._silence(channel, True, None)) + await self.cog._silence(channel, True, None) self.cog.notifier.add_channel.assert_called_once() with mock.patch.object(self.cog, "notifier", create=True): with self.subTest(persistent=False): - asyncio.run(self.cog._silence(channel, False, None)) + await self.cog._silence(channel, False, None) self.cog.notifier.add_channel.assert_not_called() -- cgit v1.2.3 From c575beccdbe5e4e715a4b11b378dd969a0327191 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 11 Mar 2020 02:47:07 +0100 Subject: Add tests for `_unsilence` --- tests/bot/cogs/moderation/test_silence.py | 34 ++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 33ff78ca6..acfa3ffb8 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -1,6 +1,6 @@ import unittest from unittest import mock -from unittest.mock import Mock +from unittest.mock import MagicMock, Mock from bot.cogs.moderation.silence import FirstHash, Silence from bot.constants import Emojis @@ -89,3 +89,35 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): with self.subTest(persistent=False): await self.cog._silence(channel, False, None) self.cog.notifier.add_channel.assert_not_called() + + async def test_unsilence_private_for_false(self): + """Permissions are not set and `False` is returned in an unsilenced channel.""" + channel = Mock() + self.assertFalse(await self.cog._unsilence(channel)) + channel.set_permissions.assert_not_called() + + async def test_unsilence_private_unsilenced_channel(self): + """Channel had `send_message` permissions restored""" + perm_overwrite = MagicMock(send_messages=False) + channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite)) + with mock.patch.object(self.cog, "notifier", create=True): + self.assertTrue(await self.cog._unsilence(channel)) + channel.set_permissions.assert_called_once() + self.assertTrue(channel.set_permissions.call_args.kwargs['overwrite'].send_messages) + + async def test_unsilence_private_removed_notifier(self): + """Channel was removed from `notifier` on unsilence.""" + perm_overwrite = MagicMock(send_messages=False) + channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite)) + with mock.patch.object(self.cog, "notifier", create=True): + await self.cog._unsilence(channel) + self.cog.notifier.remove_channel.call_args.assert_called_once_with(channel) + + async def test_unsilence_private_removed_muted_channel(self): + """Channel was removed from `muted_channels` on unsilence.""" + perm_overwrite = MagicMock(send_messages=False) + channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite)) + with mock.patch.object(self.cog, "muted_channels", create=True),\ + mock.patch.object(self.cog, "notifier", create=True): # noqa E127 + await self.cog._unsilence(channel) + self.cog.muted_channels.remove.call_args.assert_called_once_with(channel) -- cgit v1.2.3 From a3f07589b215317d6a0fc16d982c3b645fe96151 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 11 Mar 2020 02:54:35 +0100 Subject: Separate tests for permissions and `muted_channels.add` on `_silence`. --- tests/bot/cogs/moderation/test_silence.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index acfa3ffb8..3a513f3a7 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -68,14 +68,11 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): channel.set_permissions.assert_not_called() async def test_silence_private_silenced_channel(self): - """Channel had `send_message` permissions revoked and was added to `muted_channels`.""" + """Channel had `send_message` permissions revoked.""" channel = MockTextChannel() - muted_channels = Mock() - with mock.patch.object(self.cog, "muted_channels", new=muted_channels, create=True): - self.assertTrue(await self.cog._silence(channel, False, None)) + self.assertTrue(await self.cog._silence(channel, False, None)) channel.set_permissions.assert_called_once() self.assertFalse(channel.set_permissions.call_args.kwargs['overwrite'].send_messages) - muted_channels.add.call_args.assert_called_once_with(channel) async def test_silence_private_notifier(self): """Channel should be added to notifier with `persistent` set to `True`, and the other way around.""" @@ -90,6 +87,12 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): await self.cog._silence(channel, False, None) self.cog.notifier.add_channel.assert_not_called() + async def test_silence_private_removed_muted_channel(self): + channel = MockTextChannel() + with mock.patch.object(self.cog, "muted_channels") as muted_channels: + await self.cog._silence(MockTextChannel(), False, None) + muted_channels.add.call_args.assert_called_once_with(channel) + async def test_unsilence_private_for_false(self): """Permissions are not set and `False` is returned in an unsilenced channel.""" channel = Mock() -- cgit v1.2.3 From c72d31f717ac5e755fe3848c99ebf426fcdf6d8b Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 11 Mar 2020 02:58:09 +0100 Subject: Use patch decorators and assign names from `with` patches. --- tests/bot/cogs/moderation/test_silence.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 3a513f3a7..027508661 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -99,28 +99,28 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.assertFalse(await self.cog._unsilence(channel)) channel.set_permissions.assert_not_called() - async def test_unsilence_private_unsilenced_channel(self): + @mock.patch.object(Silence, "notifier", create=True) + async def test_unsilence_private_unsilenced_channel(self, _): """Channel had `send_message` permissions restored""" perm_overwrite = MagicMock(send_messages=False) channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite)) - with mock.patch.object(self.cog, "notifier", create=True): - self.assertTrue(await self.cog._unsilence(channel)) + self.assertTrue(await self.cog._unsilence(channel)) channel.set_permissions.assert_called_once() self.assertTrue(channel.set_permissions.call_args.kwargs['overwrite'].send_messages) - async def test_unsilence_private_removed_notifier(self): + @mock.patch.object(Silence, "notifier", create=True) + async def test_unsilence_private_removed_notifier(self, notifier): """Channel was removed from `notifier` on unsilence.""" perm_overwrite = MagicMock(send_messages=False) channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite)) - with mock.patch.object(self.cog, "notifier", create=True): - await self.cog._unsilence(channel) - self.cog.notifier.remove_channel.call_args.assert_called_once_with(channel) + await self.cog._unsilence(channel) + notifier.remove_channel.call_args.assert_called_once_with(channel) - async def test_unsilence_private_removed_muted_channel(self): + @mock.patch.object(Silence, "notifier", create=True) + async def test_unsilence_private_removed_muted_channel(self, _): """Channel was removed from `muted_channels` on unsilence.""" perm_overwrite = MagicMock(send_messages=False) channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite)) - with mock.patch.object(self.cog, "muted_channels", create=True),\ - mock.patch.object(self.cog, "notifier", create=True): # noqa E127 + with mock.patch.object(self.cog, "muted_channels") as muted_channels: await self.cog._unsilence(channel) - self.cog.muted_channels.remove.call_args.assert_called_once_with(channel) + muted_channels.remove.call_args.assert_called_once_with(channel) -- cgit v1.2.3 From 44967038f39f4ecd1375fb9edff2b972becb5661 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 11 Mar 2020 14:51:15 +0100 Subject: Add test for `cog_unload`. --- tests/bot/cogs/moderation/test_silence.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 027508661..fc2600f5c 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -3,7 +3,7 @@ from unittest import mock from unittest.mock import MagicMock, Mock from bot.cogs.moderation.silence import FirstHash, Silence -from bot.constants import Emojis +from bot.constants import Emojis, Roles from tests.helpers import MockBot, MockContext, MockTextChannel @@ -124,3 +124,19 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): with mock.patch.object(self.cog, "muted_channels") as muted_channels: await self.cog._unsilence(channel) muted_channels.remove.call_args.assert_called_once_with(channel) + + @mock.patch("bot.cogs.moderation.silence.asyncio") + @mock.patch.object(Silence, "_mod_alerts_channel", create=True) + def test_cog_unload(self, alert_channel, asyncio_mock): + """Task for sending an alert was created with present `muted_channels`.""" + with mock.patch.object(self.cog, "muted_channels"): + self.cog.cog_unload() + asyncio_mock.create_task.call_args.assert_called_once_with( + alert_channel.send(f"<@&{Roles.moderators}> channels left silenced on cog unload: ") + ) + + @mock.patch("bot.cogs.moderation.silence.asyncio") + def test_cog_unload1(self, asyncio_mock): + """No task created with no channels.""" + self.cog.cog_unload() + asyncio_mock.create_task.assert_not_called() -- cgit v1.2.3 From cb9397ba9ef311917629c8904087c1b3c38cc2d3 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 11 Mar 2020 15:26:03 +0100 Subject: Add test for `cog_check`. --- tests/bot/cogs/moderation/test_silence.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index fc2600f5c..eaf897d1d 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -140,3 +140,10 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): """No task created with no channels.""" self.cog.cog_unload() asyncio_mock.create_task.assert_not_called() + + @mock.patch("bot.cogs.moderation.silence.with_role_check") + @mock.patch("bot.cogs.moderation.silence.MODERATION_ROLES", new=(1, 2, 3)) + def test_cog_check(self, role_check): + """Role check is called with `MODERATION_ROLES`""" + self.cog.cog_check(self.ctx) + role_check.assert_called_once_with(self.ctx, *(1, 2, 3)) -- cgit v1.2.3 From fbee48ee04dc6b44f97f229549c62cbfd5cef615 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 11 Mar 2020 15:32:06 +0100 Subject: Fix erroneous `assert_called_once_with` calls. `assert_called_once_with` was being tested on call_args which always reported success.st. --- tests/bot/cogs/moderation/test_silence.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index eaf897d1d..4163a9af7 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -51,13 +51,13 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): ): with mock.patch.object(self.cog, "_silence", return_value=_silence_patch_return): await self.cog.silence.callback(self.cog, self.ctx, duration) - self.ctx.send.call_args.assert_called_once_with(result_message) + self.ctx.send.assert_called_once_with(result_message) async def test_unsilence_sent_correct_discord_message(self): """Check if proper message was sent to `alert_chanel`.""" with mock.patch.object(self.cog, "_unsilence", return_value=True): await self.cog.unsilence.callback(self.cog, self.ctx) - self.ctx.send.call_args.assert_called_once_with(f"{Emojis.check_mark} unsilenced current channel.") + self.ctx.send.assert_called_once_with(f"{Emojis.check_mark} unsilenced current channel.") async def test_silence_private_for_false(self): """Permissions are not set and `False` is returned in an already silenced channel.""" @@ -91,7 +91,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): channel = MockTextChannel() with mock.patch.object(self.cog, "muted_channels") as muted_channels: await self.cog._silence(MockTextChannel(), False, None) - muted_channels.add.call_args.assert_called_once_with(channel) + muted_channels.add.assert_called_once_with(channel) async def test_unsilence_private_for_false(self): """Permissions are not set and `False` is returned in an unsilenced channel.""" @@ -114,7 +114,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): perm_overwrite = MagicMock(send_messages=False) channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite)) await self.cog._unsilence(channel) - notifier.remove_channel.call_args.assert_called_once_with(channel) + notifier.remove_channel.assert_called_once_with(channel) @mock.patch.object(Silence, "notifier", create=True) async def test_unsilence_private_removed_muted_channel(self, _): @@ -123,7 +123,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite)) with mock.patch.object(self.cog, "muted_channels") as muted_channels: await self.cog._unsilence(channel) - muted_channels.remove.call_args.assert_called_once_with(channel) + muted_channels.remove.assert_called_once_with(channel) @mock.patch("bot.cogs.moderation.silence.asyncio") @mock.patch.object(Silence, "_mod_alerts_channel", create=True) @@ -131,9 +131,8 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): """Task for sending an alert was created with present `muted_channels`.""" with mock.patch.object(self.cog, "muted_channels"): self.cog.cog_unload() - asyncio_mock.create_task.call_args.assert_called_once_with( - alert_channel.send(f"<@&{Roles.moderators}> channels left silenced on cog unload: ") - ) + asyncio_mock.create_task.assert_called_once_with(alert_channel.send()) + alert_channel.send.called_once_with(f"<@&{Roles.moderators}> chandnels left silenced on cog unload: ") @mock.patch("bot.cogs.moderation.silence.asyncio") def test_cog_unload1(self, asyncio_mock): -- cgit v1.2.3 From 64b27e557acf268a19246b2eb80ad6a743df95f4 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 11 Mar 2020 15:32:24 +0100 Subject: Reset `self.ctx` call history after every subtest. --- tests/bot/cogs/moderation/test_silence.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 4163a9af7..ab2f091ec 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -52,6 +52,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): with mock.patch.object(self.cog, "_silence", return_value=_silence_patch_return): await self.cog.silence.callback(self.cog, self.ctx, duration) self.ctx.send.assert_called_once_with(result_message) + self.ctx.reset_mock() async def test_unsilence_sent_correct_discord_message(self): """Check if proper message was sent to `alert_chanel`.""" -- cgit v1.2.3 From 10428d9a456c7bce533cda53100e4c35930211d6 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 11 Mar 2020 15:33:05 +0100 Subject: Pass created channel instead of new object. Creating a new object caused the assert to fail because different objects were used. --- tests/bot/cogs/moderation/test_silence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index ab2f091ec..23f8a84ab 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -91,7 +91,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_silence_private_removed_muted_channel(self): channel = MockTextChannel() with mock.patch.object(self.cog, "muted_channels") as muted_channels: - await self.cog._silence(MockTextChannel(), False, None) + await self.cog._silence(channel, False, None) muted_channels.add.assert_called_once_with(channel) async def test_unsilence_private_for_false(self): -- cgit v1.2.3 From b2aa9af7f9f1485aa3ae8ed4d029fd2d72ea17ad Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 11 Mar 2020 16:02:35 +0100 Subject: Add tests for `_get_instance_vars`. --- tests/bot/cogs/moderation/test_silence.py | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 23f8a84ab..c9aa7d84f 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -3,7 +3,7 @@ from unittest import mock from unittest.mock import MagicMock, Mock from bot.cogs.moderation.silence import FirstHash, Silence -from bot.constants import Emojis, Roles +from bot.constants import Channels, Emojis, Guild, Roles from tests.helpers import MockBot, MockContext, MockTextChannel @@ -36,6 +36,33 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.ctx = MockContext() self.cog._verified_role = None + async def test_instance_vars_got_guild(self): + """Bot got guild after it became available.""" + await self.cog._get_instance_vars() + self.bot.wait_until_guild_available.assert_called_once() + self.bot.get_guild.assert_called_once_with(Guild.id) + + async def test_instance_vars_got_role(self): + """Got `Roles.verified` role from guild.""" + await self.cog._get_instance_vars() + guild = self.bot.get_guild() + guild.get_role.assert_called_once_with(Roles.verified) + + async def test_instance_vars_got_channels(self): + """Got channels from bot.""" + await self.cog._get_instance_vars() + self.bot.get_channel.called_once_with(Channels.mod_alerts) + self.bot.get_channel.called_once_with(Channels.mod_log) + + @mock.patch("bot.cogs.moderation.silence.SilenceNotifier") + async def test_instance_vars_got_notifier(self, notifier): + """Notifier was started with channel.""" + mod_log = MockTextChannel() + self.bot.get_channel.side_effect = (None, mod_log) + await self.cog._get_instance_vars() + notifier.assert_called_once_with(mod_log) + self.bot.get_channel.side_effect = None + async def test_silence_sent_correct_discord_message(self): """Check if proper message was sent when called with duration in channel with previous state.""" test_cases = ( -- cgit v1.2.3 From 8ee70ffe645621a6b97172176afe1ac63261df31 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 11 Mar 2020 16:10:38 +0100 Subject: Create test case for `SilenceNotifier` --- tests/bot/cogs/moderation/test_silence.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index c9aa7d84f..fc7734d45 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -2,7 +2,7 @@ import unittest from unittest import mock from unittest.mock import MagicMock, Mock -from bot.cogs.moderation.silence import FirstHash, Silence +from bot.cogs.moderation.silence import FirstHash, Silence, SilenceNotifier from bot.constants import Channels, Emojis, Guild, Roles from tests.helpers import MockBot, MockContext, MockTextChannel @@ -29,6 +29,12 @@ class FirstHashTests(unittest.TestCase): self.assertTrue(tuple1 == tuple2) +class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + self.alert_channel = MockTextChannel() + self.notifier = SilenceNotifier(self.alert_channel) + + class SilenceTests(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: self.bot = MockBot() -- cgit v1.2.3 From fd75f10f3c8a588bd1763873baad08b8f90d58a3 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 11 Mar 2020 17:09:40 +0100 Subject: Add tests for `add_channel`. --- tests/bot/cogs/moderation/test_silence.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index fc7734d45..be5b8e550 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -33,6 +33,27 @@ class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: self.alert_channel = MockTextChannel() self.notifier = SilenceNotifier(self.alert_channel) + self.notifier.stop = self.notifier_stop_mock = Mock() + self.notifier.start = self.notifier_start_mock = Mock() + self.notifier._current_loop = self.current_loop_mock = Mock() + + def test_add_channel_adds_channel(self): + """Channel in FirstHash with current loop is added to internal set.""" + channel = Mock() + with mock.patch.object(self.notifier, "_silenced_channels") as silenced_channels: + self.notifier.add_channel(channel) + silenced_channels.add.assert_called_with(FirstHash(channel, self.current_loop_mock)) + + def test_add_channel_starts_loop(self): + """Loop is started if `_silenced_channels` was empty.""" + self.notifier.add_channel(Mock()) + self.notifier_start_mock.assert_called_once() + + def test_add_channel_skips_start_with_channels(self): + """Loop start is not called when `_silenced_channels` is not empty.""" + with mock.patch.object(self.notifier, "_silenced_channels"): + self.notifier.add_channel(Mock()) + self.notifier_start_mock.assert_not_called() class SilenceTests(unittest.IsolatedAsyncioTestCase): -- cgit v1.2.3 From d9c904164a9e54750ce8ee36535bceacfc4800f5 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 11 Mar 2020 18:06:53 +0100 Subject: Remove `_current_loop` from setup. --- tests/bot/cogs/moderation/test_silence.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index be5b8e550..2e04dc407 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -35,14 +35,13 @@ class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): self.notifier = SilenceNotifier(self.alert_channel) self.notifier.stop = self.notifier_stop_mock = Mock() self.notifier.start = self.notifier_start_mock = Mock() - self.notifier._current_loop = self.current_loop_mock = Mock() def test_add_channel_adds_channel(self): """Channel in FirstHash with current loop is added to internal set.""" channel = Mock() with mock.patch.object(self.notifier, "_silenced_channels") as silenced_channels: self.notifier.add_channel(channel) - silenced_channels.add.assert_called_with(FirstHash(channel, self.current_loop_mock)) + silenced_channels.add.assert_called_with(FirstHash(channel, self.notifier._current_loop)) def test_add_channel_starts_loop(self): """Loop is started if `_silenced_channels` was empty.""" -- cgit v1.2.3 From 4740c0fcdc6da6f164963fb34715e78c5d586cec Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 11 Mar 2020 18:07:14 +0100 Subject: Add tests for `remove_channel`. --- tests/bot/cogs/moderation/test_silence.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 2e04dc407..c52ca2a2a 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -54,6 +54,24 @@ class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): self.notifier.add_channel(Mock()) self.notifier_start_mock.assert_not_called() + def test_remove_channel_removes_channel(self): + """Channel in FirstHash is removed from `_silenced_channels`.""" + channel = Mock() + with mock.patch.object(self.notifier, "_silenced_channels") as silenced_channels: + self.notifier.remove_channel(channel) + silenced_channels.remove.assert_called_with(FirstHash(channel)) + + def test_remove_channel_stops_loop(self): + """Notifier loop is stopped if `_silenced_channels` is empty after remove.""" + with mock.patch.object(self.notifier, "_silenced_channels", __bool__=lambda _: False): + self.notifier.remove_channel(Mock()) + self.notifier_stop_mock.assert_called_once() + + def test_remove_channel_skips_stop_with_channels(self): + """Notifier loop is not stopped if `_silenced_channels` is not empty after remove.""" + self.notifier.remove_channel(Mock()) + self.notifier_stop_mock.assert_not_called() + class SilenceTests(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: -- cgit v1.2.3 From 2bcadd209e14e3a119806e069b8ae7150a73c1ba Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 11 Mar 2020 19:12:34 +0100 Subject: Change various logging levels. --- bot/cogs/moderation/silence.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 4153b3439..1c751a4b1 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -41,7 +41,7 @@ class SilenceNotifier(tasks.Loop): """Add channel to `_silenced_channels` and start loop if not launched.""" if not self._silenced_channels: self.start() - log.trace("Starting notifier loop.") + log.info("Starting notifier loop.") self._silenced_channels.add(FirstHash(channel, self._current_loop)) def remove_channel(self, channel: TextChannel) -> None: @@ -50,7 +50,7 @@ class SilenceNotifier(tasks.Loop): self._silenced_channels.remove(FirstHash(channel)) if not self._silenced_channels: self.stop() - log.trace("Stopping notifier loop.") + log.info("Stopping notifier loop.") async def _notifier(self) -> None: """Post notice of `_silenced_channels` with their silenced duration to `_alert_channel` periodically.""" @@ -121,7 +121,7 @@ class Silence(commands.Cog): """ current_overwrite = channel.overwrites_for(self._verified_role) if current_overwrite.send_messages is False: - log.debug(f"Tried to silence channel #{channel} ({channel.id}) but the channel was already silenced.") + log.info(f"Tried to silence channel #{channel} ({channel.id}) but the channel was already silenced.") return False await channel.set_permissions( self._verified_role, @@ -129,11 +129,11 @@ class Silence(commands.Cog): ) self.muted_channels.add(channel) if persistent: - log.debug(f"Silenced #{channel} ({channel.id}) indefinitely.") + log.info(f"Silenced #{channel} ({channel.id}) indefinitely.") self.notifier.add_channel(channel) return True - log.debug(f"Silenced #{channel} ({channel.id}) for {duration} minute(s).") + log.info(f"Silenced #{channel} ({channel.id}) for {duration} minute(s).") return True async def _unsilence(self, channel: TextChannel) -> bool: @@ -149,12 +149,12 @@ class Silence(commands.Cog): self._verified_role, overwrite=PermissionOverwrite(**dict(current_overwrite, send_messages=True)) ) - log.debug(f"Unsilenced channel #{channel} ({channel.id}).") + log.info(f"Unsilenced channel #{channel} ({channel.id}).") self.notifier.remove_channel(channel) with suppress(KeyError): self.muted_channels.remove(channel) return True - log.debug(f"Tried to unsilence channel #{channel} ({channel.id}) but the channel was not silenced.") + log.info(f"Tried to unsilence channel #{channel} ({channel.id}) but the channel was not silenced.") return False def cog_unload(self) -> None: -- cgit v1.2.3 From d40a8688bb1e9cf5aa33d7b9fbc5272417eb1c81 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 11 Mar 2020 21:13:43 +0100 Subject: Add logging to commands. --- bot/cogs/moderation/silence.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 1c751a4b1..e1b0b703f 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -91,6 +91,7 @@ class Silence(commands.Cog): If duration is forever, start a notifier loop that triggers every 15 minutes. """ + log.debug(f"{ctx.author} is silencing channel #{ctx.channel}.") if not await self._silence(ctx.channel, persistent=(duration is None), duration=duration): await ctx.send(f"{Emojis.cross_mark} current channel is already silenced.") return @@ -100,6 +101,7 @@ class Silence(commands.Cog): await ctx.send(f"{Emojis.check_mark} silenced current channel for {duration} minute(s).") await asyncio.sleep(duration*60) + log.info(f"Unsilencing channel after set delay.") await ctx.invoke(self.unsilence) @commands.command(aliases=("unhush",)) @@ -109,6 +111,7 @@ class Silence(commands.Cog): Unsilence a previously silenced `channel` and remove it from indefinitely muted channels notice if applicable. """ + log.debug(f"Unsilencing channel #{ctx.channel} from {ctx.author}'s command.") if await self._unsilence(ctx.channel): await ctx.send(f"{Emojis.check_mark} unsilenced current channel.") -- cgit v1.2.3 From 9bb6f4ae560df90bc45ebe2af449fbb100c5970b Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 11 Mar 2020 21:49:14 +0100 Subject: Improve commands help. --- bot/cogs/moderation/silence.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index e1b0b703f..8ed1cb28b 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -87,9 +87,10 @@ class Silence(commands.Cog): @commands.command(aliases=("hush",)) async def silence(self, ctx: Context, duration: HushDurationConverter = 10) -> None: """ - Silence `channel` for `duration` minutes or `"forever"`. + Silence `channel` for `duration` minutes or `forever`. - If duration is forever, start a notifier loop that triggers every 15 minutes. + Duration is capped at 15 minutes, passing forever makes the silence indefinite. + Indefinitely silenced channels get added to a notifier which posts notices every 15 minutes from the start. """ log.debug(f"{ctx.author} is silencing channel #{ctx.channel}.") if not await self._silence(ctx.channel, persistent=(duration is None), duration=duration): @@ -109,7 +110,8 @@ class Silence(commands.Cog): """ Unsilence `channel`. - Unsilence a previously silenced `channel` and remove it from indefinitely muted channels notice if applicable. + Unsilence a previously silenced `channel`, + remove it from notifier of indefinitely silenced channels and cancel the notifier if empty. """ log.debug(f"Unsilencing channel #{ctx.channel} from {ctx.author}'s command.") if await self._unsilence(ctx.channel): -- cgit v1.2.3 From 28cf22bcd98d94fa27e80dde4c86c9054b33c538 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 11 Mar 2020 21:55:09 +0100 Subject: Add tests for `_notifier`. --- tests/bot/cogs/moderation/test_silence.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index c52ca2a2a..d4719159e 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -72,6 +72,25 @@ class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): self.notifier.remove_channel(Mock()) self.notifier_stop_mock.assert_not_called() + async def test_notifier_private_sends_alert(self): + """Alert is sent on 15 min intervals.""" + test_cases = (900, 1800, 2700) + for current_loop in test_cases: + with self.subTest(current_loop=current_loop): + with mock.patch.object(self.notifier, "_current_loop", new=current_loop): + await self.notifier._notifier() + self.alert_channel.send.assert_called_once_with(f"<@&{Roles.moderators}> currently silenced channels: ") + self.alert_channel.send.reset_mock() + + async def test_notifier_skips_alert(self): + """Alert is skipped on first loop or not an increment of 900.""" + test_cases = (0, 15, 5000) + for current_loop in test_cases: + with self.subTest(current_loop=current_loop): + with mock.patch.object(self.notifier, "_current_loop", new=current_loop): + await self.notifier._notifier() + self.alert_channel.send.assert_not_called() + class SilenceTests(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: -- cgit v1.2.3 From 4d333497da0622e0e242b5eee4922932499d2183 Mon Sep 17 00:00:00 2001 From: Jeremiah Boby Date: Wed, 11 Mar 2020 23:36:13 +0000 Subject: Escape markdown in watchlist triggers --- bot/cogs/filtering.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 38c28dd00..6651d38e4 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -6,6 +6,7 @@ import discord.errors from dateutil.relativedelta import relativedelta from discord import Colour, DMChannel, Member, Message, TextChannel from discord.ext.commands import Cog +from discord.utils import escape_markdown from bot.bot import Bot from bot.cogs.moderation import ModLog @@ -195,8 +196,8 @@ class Filtering(Cog): surroundings = match.string[max(match.start() - 10, 0): match.end() + 10] message_content = ( f"**Match:** '{match[0]}'\n" - f"**Location:** '...{surroundings}...'\n" - f"\n**Original Message:**\n{msg.content}" + f"**Location:** '...{escape_markdown(surroundings)}...'\n" + f"\n**Original Message:**\n{escape_markdown(msg.content)}" ) else: # Use content of discord Message message_content = msg.content -- cgit v1.2.3 From 956e76b72efc33b7563a13e43b94ed27d5e263ce Mon Sep 17 00:00:00 2001 From: Jeremiah Boby Date: Wed, 11 Mar 2020 23:41:16 +0000 Subject: Escape markdown in member updates --- bot/cogs/moderation/modlog.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 81d95298d..f9dd10e75 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -12,6 +12,7 @@ from deepdiff import DeepDiff from discord import Colour from discord.abc import GuildChannel from discord.ext.commands import Cog, Context +from discord.utils import escape_markdown from bot.bot import Bot from bot.constants import Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, URLs @@ -523,7 +524,8 @@ class ModLog(Cog, name="ModLog"): for item in sorted(changes): message += f"{Emojis.bullet} {item}\n" - message = f"**{after}** (`{after.id}`)\n{message}" + member_str = escape_markdown(str(after)) + message = f"**{member_str}** (`{after.id}`)\n{message}" await self.send_log_message( Icons.user_update, Colour.blurple(), -- cgit v1.2.3 From debbc619e47239b268171d7599b363dc8b18c727 Mon Sep 17 00:00:00 2001 From: Jeremiah Boby Date: Wed, 11 Mar 2020 23:57:09 +0000 Subject: Escape markdown in voice updates --- bot/cogs/moderation/modlog.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index f9dd10e75..d42a1ae66 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -387,7 +387,8 @@ class ModLog(Cog, name="ModLog"): if member.guild.id != GuildConstant.id: return - message = f"{member} (`{member.id}`)" + member_str = escape_markdown(str(member)) + message = f"{member_str} (`{member.id}`)" now = datetime.utcnow() difference = abs(relativedelta(now, member.created_at)) @@ -413,9 +414,10 @@ class ModLog(Cog, name="ModLog"): self._ignored[Event.member_remove].remove(member.id) return + member_str = escape_markdown(str(member)) await self.send_log_message( Icons.sign_out, Colours.soft_red, - "User left", f"{member} (`{member.id}`)", + "User left", f"{member_str} (`{member.id}`)", thumbnail=member.avatar_url_as(static_format="png"), channel_id=Channels.user_log ) @@ -430,9 +432,10 @@ class ModLog(Cog, name="ModLog"): self._ignored[Event.member_unban].remove(member.id) return + member_str = escape_markdown(str(member)) await self.send_log_message( Icons.user_unban, Colour.blurple(), - "User unbanned", f"{member} (`{member.id}`)", + "User unbanned", f"{member_str} (`{member.id}`)", thumbnail=member.avatar_url_as(static_format="png"), channel_id=Channels.mod_log ) @@ -552,16 +555,17 @@ class ModLog(Cog, name="ModLog"): if author.bot: return + author_str = escape_markdown(str(author)) if channel.category: response = ( - f"**Author:** {author} (`{author.id}`)\n" + f"**Author:** {author_str} (`{author.id}`)\n" f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" f"**Message ID:** `{message.id}`\n" "\n" ) else: response = ( - f"**Author:** {author} (`{author.id}`)\n" + f"**Author:** {author_str} (`{author.id}`)\n" f"**Channel:** #{channel.name} (`{channel.id}`)\n" f"**Message ID:** `{message.id}`\n" "\n" @@ -648,6 +652,8 @@ class ModLog(Cog, name="ModLog"): return author = msg_before.author + author_str = escape_markdown(str(author)) + channel = msg_before.channel channel_name = f"{channel.category}/#{channel.name}" if channel.category else f"#{channel.name}" @@ -679,7 +685,7 @@ class ModLog(Cog, name="ModLog"): content_after.append(sub) response = ( - f"**Author:** {author} (`{author.id}`)\n" + f"**Author:** {author_str} (`{author.id}`)\n" f"**Channel:** {channel_name} (`{channel.id}`)\n" f"**Message ID:** `{msg_before.id}`\n" "\n" @@ -822,8 +828,9 @@ class ModLog(Cog, name="ModLog"): if not changes: return + member_str = escape_markdown(str(member)) message = "\n".join(f"{Emojis.bullet} {item}" for item in sorted(changes)) - message = f"**{member}** (`{member.id}`)\n{message}" + message = f"**{member_str}** (`{member.id}`)\n{message}" await self.send_log_message( icon_url=icon, -- cgit v1.2.3 From 4662cfd29cd9c5ac0081621648a87d102b825852 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Thu, 12 Mar 2020 15:05:41 +0100 Subject: Update ytdl tag to the new YouTube ToS --- bot/resources/tags/ytdl.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/bot/resources/tags/ytdl.md b/bot/resources/tags/ytdl.md index 09664af26..4c47b0595 100644 --- a/bot/resources/tags/ytdl.md +++ b/bot/resources/tags/ytdl.md @@ -1,9 +1,8 @@ Per [PyDis' Rule 5](https://pythondiscord.com/pages/rules), we are unable to assist with questions related to youtube-dl, commonly used by Discord bots to stream audio, as its use violates YouTube's Terms of Service. -For reference, this usage is covered by the following clauses in [YouTube's TOS](https://www.youtube.com/static?template=terms), as of 2018-05-25: +For reference, this usage is covered by the following clauses in [YouTube's TOS](https://www.youtube.com/static?template=terms), as of 2019-07-22: ``` -4A: You agree not to distribute in any medium any part of the Service or the Content without YouTube's prior written authorization, unless YouTube makes available the means for such distribution through functionality offered by the Service (such as the Embeddable Player). -``` -``` -4C: You agree not to access Content through any technology or means other than the video playback pages of the Service itself, the Embeddable Player, or other explicitly authorized means YouTube may designate. +The following restrictions apply to your use of the Service. You are not allowed to: + +3. access the Service using any automated means (such as robots, botnets or scrapers) except: (a) in the case of public search engines, in accordance with YouTube’s robots.txt file; (b) with YouTube’s prior written permission; or (c) as permitted by applicable law; ``` -- cgit v1.2.3 From f2d10e46e44b4d4cdd3dc343a2462ba00d654409 Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Thu, 12 Mar 2020 22:18:24 +0530 Subject: remove repetitive file search --- bot/cogs/tags.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 9665aa04e..692cff0d8 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -36,12 +36,11 @@ class Tags(Cog): cache = {} tag_files = Path("bot", "resources", "tags").iterdir() for file in tag_files: - file_path = Path(file) - tag_title = file_path.stem + tag_title = file.stem tag = { "title": tag_title, "embed": { - "description": file_path.read_text() + "description": file.read_text() } } cache[tag_title] = tag -- cgit v1.2.3 From 55effb33627fe21a4bcd230a26cc3cf2dfb0b512 Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Thu, 12 Mar 2020 22:32:24 +0530 Subject: convert get_tags() method to staticmethod --- bot/cogs/tags.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 692cff0d8..48f000143 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -30,7 +30,8 @@ class Tags(Cog): self.tag_cooldowns = {} self._cache = self.get_tags() - def get_tags(self) -> dict: + @staticmethod + def get_tags() -> dict: """Get all tags.""" # Save all tags in memory. cache = {} -- cgit v1.2.3 From 96639bca024f5b22b7e44b59de0d75aebe9c7f20 Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Thu, 12 Mar 2020 13:30:41 -0500 Subject: Corrected expiration check logic and cog loading Bugs fixed: - Previously, the code would check to see if `'expires_at'` was in the kwargs, which after testing I came to find out that it is regardless of the duration of the ban. It has sense been changed to use a `.get()` in order to do a proper comparison. - Code previously attempted to load from the `"BigBrother"` cog which is the incorrect spelling. Changed it to `"Big Brother"` to correct this. Logging Added: - Additional trace logs added to both the `infractions.py` file as well as `bigbrother.py` to assist with future debugging or testing. Signed-off-by: Daniel Brown --- bot/cogs/moderation/infractions.py | 7 +++++-- bot/cogs/watchchannels/bigbrother.py | 4 ++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 9bab38e23..3ea185d29 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -244,15 +244,18 @@ class Infractions(InfractionScheduler, commands.Cog): await self.apply_infraction(ctx, infraction, user, action) # Remove perma banned users from the watch list - if 'expires_at' not in kwargs: - bb_cog = self.bot.get_cog("BigBrother") + if infraction.get('expires_at') is None: + log.trace("Ban was a permanent one. Attempt to remove from watched list.") + bb_cog = self.bot.get_cog("Big Brother") if bb_cog: + log.trace("Cog loaded. Attempting to remove from list.") await bb_cog.apply_unwatch( ctx, user, "User has been permanently banned from the server. Automatically removed.", banned=True ) + log.debug("Perma banned user removed from watch list.") # endregion # region: Base pardon functions diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index 75b66839e..caae793bb 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -110,6 +110,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): ) ) if active_watches: + log.trace("Active watches for user found. Attempting to remove.") [infraction] = active_watches await self.bot.api_client.patch( @@ -120,9 +121,12 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): await post_infraction(ctx, user, 'watch', f"Unwatched: {reason}", hidden=True, active=False) if not banned: # Prevents a message being sent to the channel if part of a permanent ban + log.trace("User is not banned. Sending message to channel") await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed.") self._remove_user(user.id) else: + log.trace("No active watches found for user.") if not banned: # Prevents a message being sent to the channel if part of a permanent ban + log.trace("User is not perma banned. Send the error message.") await ctx.send(":x: The specified user is currently not being watched.") -- cgit v1.2.3 From 9b18912d2d4a6c575e7f45a55f34d6dab41f6b57 Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Fri, 13 Mar 2020 14:52:15 -0500 Subject: Verification Cog Kaizen Changes Kaizen: - Cut down on the size of the import line by changing the imports from bot.constants to instead just importing the constants. This will help clarify where certain constants are coming from. - The periodic checkpoint message will no longer ping `@everyone` or `@Admins` when the bot detects that it is being ran in a debug environment. Message is now a simple confirmation that the periodic ping method successfully ran. Signed-off-by: Daniel Brown --- bot/cogs/verification.py | 71 ++++++++++++++++++++++++------------------------ 1 file changed, 36 insertions(+), 35 deletions(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 57b50c34f..107bc1058 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -6,13 +6,9 @@ from discord import Colour, Forbidden, Message, NotFound, Object from discord.ext import tasks from discord.ext.commands import Cog, Context, command +from bot import constants from bot.bot import Bot from bot.cogs.moderation import ModLog -from bot.constants import ( - Bot as BotConfig, - Channels, Colours, Event, - Filter, Icons, MODERATION_ROLES, Roles -) from bot.decorators import InChannelCheckFailure, in_channel, without_role from bot.utils.checks import without_role_check @@ -29,18 +25,23 @@ your information removed here as well. Feel free to review them at any point! -Additionally, if you'd like to receive notifications for the announcements we post in <#{Channels.announcements}> \ -from time to time, you can send `!subscribe` to <#{Channels.bot_commands}> at any time to assign yourself the \ -**Announcements** role. We'll mention this role every time we make an announcement. +Additionally, if you'd like to receive notifications for the announcements \ +we post in <#{constants.Channels.announcements}> +from time to time, you can send `!subscribe` to <#{constants.Channels.bot_commands}> at any time \ +to assign yourself the **Announcements** role. We'll mention this role every time we make an announcement. If you'd like to unsubscribe from the announcement notifications, simply send `!unsubscribe` to \ -<#{Channels.bot_commands}>. +<#{constants.Channels.bot_commands}>. """ -PERIODIC_PING = ( - f"@everyone To verify that you have read our rules, please type `{BotConfig.prefix}accept`." - f" If you encounter any problems during the verification process, ping the <@&{Roles.admins}> role in this channel." -) +if constants.DEBUG_MODE: + PERIODIC_PING = "Periodic checkpoint message successfully sent." +else: + PERIODIC_PING = ( + f"@everyone To verify that you have read our rules, please type `{constants.Bot.prefix}accept`." + " If you encounter any problems during the verification process, " + f"ping the <@&{constants.Roles.admins}> role in this channel." + ) BOT_MESSAGE_DELETE_DELAY = 10 @@ -59,7 +60,7 @@ class Verification(Cog): @Cog.listener() async def on_message(self, message: Message) -> None: """Check new message event for messages to the checkpoint channel & process.""" - if message.channel.id != Channels.verification: + if message.channel.id != constants.Channels.verification: return # Only listen for #checkpoint messages if message.author.bot: @@ -85,20 +86,20 @@ class Verification(Cog): # Send pretty mod log embed to mod-alerts await self.mod_log.send_log_message( - icon_url=Icons.filtering, - colour=Colour(Colours.soft_red), + icon_url=constants.Icons.filtering, + colour=Colour(constants.Colours.soft_red), title=f"User/Role mentioned in {message.channel.name}", text=embed_text, thumbnail=message.author.avatar_url_as(static_format="png"), - channel_id=Channels.mod_alerts, - ping_everyone=Filter.ping_everyone, + channel_id=constants.Channels.mod_alerts, + ping_everyone=constants.Filter.ping_everyone, ) - ctx: Context = await self.bot.get_context(message) + ctx: Context = await self.get_context(message) if ctx.command is not None and ctx.command.name == "accept": return - if any(r.id == Roles.verified for r in ctx.author.roles): + if any(r.id == constants.Roles.verified for r in ctx.author.roles): log.info( f"{ctx.author} posted '{ctx.message.content}' " "in the verification channel, but is already verified." @@ -120,12 +121,12 @@ class Verification(Cog): await ctx.message.delete() @command(name='accept', aliases=('verify', 'verified', 'accepted'), hidden=True) - @without_role(Roles.verified) - @in_channel(Channels.verification) + @without_role(constants.Roles.verified) + @in_channel(constants.Channels.verification) async def accept_command(self, ctx: Context, *_) -> None: # We don't actually care about the args """Accept our rules and gain access to the rest of the server.""" log.debug(f"{ctx.author} called !accept. Assigning the 'Developer' role.") - await ctx.author.add_roles(Object(Roles.verified), reason="Accepted the rules") + await ctx.author.add_roles(Object(constants.Roles.verified), reason="Accepted the rules") try: await ctx.author.send(WELCOME_MESSAGE) except Forbidden: @@ -133,17 +134,17 @@ class Verification(Cog): finally: log.trace(f"Deleting accept message by {ctx.author}.") with suppress(NotFound): - self.mod_log.ignore(Event.message_delete, ctx.message.id) + self.mod_log.ignore(constants.Event.message_delete, ctx.message.id) await ctx.message.delete() @command(name='subscribe') - @in_channel(Channels.bot_commands) + @in_channel(constants.Channels.bot_commands) async def subscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args """Subscribe to announcement notifications by assigning yourself the role.""" has_role = False for role in ctx.author.roles: - if role.id == Roles.announcements: + if role.id == constants.Roles.announcements: has_role = True break @@ -152,22 +153,22 @@ class Verification(Cog): return log.debug(f"{ctx.author} called !subscribe. Assigning the 'Announcements' role.") - await ctx.author.add_roles(Object(Roles.announcements), reason="Subscribed to announcements") + await ctx.author.add_roles(Object(constants.Roles.announcements), reason="Subscribed to announcements") log.trace(f"Deleting the message posted by {ctx.author}.") await ctx.send( - f"{ctx.author.mention} Subscribed to <#{Channels.announcements}> notifications.", + f"{ctx.author.mention} Subscribed to <#{constants.Channels.announcements}> notifications.", ) @command(name='unsubscribe') - @in_channel(Channels.bot_commands) + @in_channel(constants.Channels.bot_commands) async def unsubscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args """Unsubscribe from announcement notifications by removing the role from yourself.""" has_role = False for role in ctx.author.roles: - if role.id == Roles.announcements: + if role.id == constants.Roles.announcements: has_role = True break @@ -176,12 +177,12 @@ class Verification(Cog): return log.debug(f"{ctx.author} called !unsubscribe. Removing the 'Announcements' role.") - await ctx.author.remove_roles(Object(Roles.announcements), reason="Unsubscribed from announcements") + await ctx.author.remove_roles(Object(constants.Roles.announcements), reason="Unsubscribed from announcements") log.trace(f"Deleting the message posted by {ctx.author}.") await ctx.send( - f"{ctx.author.mention} Unsubscribed from <#{Channels.announcements}> notifications." + f"{ctx.author.mention} Unsubscribed from <#{constants.Channels.announcements}> notifications." ) # This cannot be static (must have a __func__ attribute). @@ -193,7 +194,7 @@ class Verification(Cog): @staticmethod def bot_check(ctx: Context) -> bool: """Block any command within the verification channel that is not !accept.""" - if ctx.channel.id == Channels.verification and without_role_check(ctx, *MODERATION_ROLES): + if ctx.channel.id == constants.Channels.verification and without_role_check(ctx, *constants.MODERATION_ROLES): return ctx.command.name == "accept" else: return True @@ -201,7 +202,7 @@ class Verification(Cog): @tasks.loop(hours=12) async def periodic_ping(self) -> None: """Every week, mention @everyone to remind them to verify.""" - messages = self.bot.get_channel(Channels.verification).history(limit=10) + messages = self.bot.get_channel(constants.Channels.verification).history(limit=10) need_to_post = True # True if a new message needs to be sent. async for message in messages: @@ -215,7 +216,7 @@ class Verification(Cog): break if need_to_post: - await self.bot.get_channel(Channels.verification).send(PERIODIC_PING) + await self.bot.get_channel(constants.Channels.verification).send(PERIODIC_PING) @periodic_ping.before_loop async def before_ping(self) -> None: -- cgit v1.2.3 From d9ed24922f6daa17d625b345cb195e7fae7758cc Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 9 Mar 2020 13:31:44 -0700 Subject: Cog tests: add a function to get all extensions --- tests/bot/cogs/test_cogs.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/bot/cogs/test_cogs.py b/tests/bot/cogs/test_cogs.py index b128ca123..386299fb1 100644 --- a/tests/bot/cogs/test_cogs.py +++ b/tests/bot/cogs/test_cogs.py @@ -1,10 +1,15 @@ """Test suite for general tests which apply to all cogs.""" +import importlib +import pkgutil import typing as t import unittest +from types import ModuleType from discord.ext import commands +from bot import cogs + class CommandNameTests(unittest.TestCase): """Tests for shadowing command names and aliases.""" @@ -17,3 +22,9 @@ class CommandNameTests(unittest.TestCase): yield command if isinstance(command, commands.GroupMixin): yield from command.walk_commands() + + @staticmethod + def walk_extensions() -> t.Iterator[ModuleType]: + """Yield imported extensions (modules) from the bot.cogs subpackage.""" + for module in pkgutil.iter_modules(cogs.__path__, "bot.cogs."): + yield importlib.import_module(module.name) -- cgit v1.2.3 From b923c0f844f65275d90e3807aa8e3eadf3920252 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 9 Mar 2020 13:37:56 -0700 Subject: Cog tests: add a function to get all cogs --- tests/bot/cogs/test_cogs.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/bot/cogs/test_cogs.py b/tests/bot/cogs/test_cogs.py index 386299fb1..4290c279c 100644 --- a/tests/bot/cogs/test_cogs.py +++ b/tests/bot/cogs/test_cogs.py @@ -28,3 +28,10 @@ class CommandNameTests(unittest.TestCase): """Yield imported extensions (modules) from the bot.cogs subpackage.""" for module in pkgutil.iter_modules(cogs.__path__, "bot.cogs."): yield importlib.import_module(module.name) + + @staticmethod + def walk_cogs(extension: ModuleType) -> t.Iterator[commands.Cog]: + """Yield all cogs defined in an extension.""" + for name, cls in extension.__dict__.items(): + if isinstance(cls, commands.Cog): + yield getattr(extension, name) -- cgit v1.2.3 From 0358121687988159cb6754e249eed1ee2d40a783 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 9 Mar 2020 13:56:58 -0700 Subject: Cog tests: add a function to get all qualified names for a cmd --- tests/bot/cogs/test_cogs.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/bot/cogs/test_cogs.py b/tests/bot/cogs/test_cogs.py index 4290c279c..e28717756 100644 --- a/tests/bot/cogs/test_cogs.py +++ b/tests/bot/cogs/test_cogs.py @@ -35,3 +35,11 @@ class CommandNameTests(unittest.TestCase): for name, cls in extension.__dict__.items(): if isinstance(cls, commands.Cog): yield getattr(extension, name) + + @staticmethod + def get_qualified_names(command: commands.Command) -> t.List[str]: + """Return a list of all qualified names, including aliases, for the `command`.""" + names = [f"{command.full_parent_name} {alias}" for alias in command.aliases] + names.append(command.qualified_name) + + return names -- cgit v1.2.3 From 5419b3e9599e8bb2f519949aa268eb3a4b3adbcc Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 9 Mar 2020 14:17:23 -0700 Subject: Cog tests: add a function to yield all commands This will help reduce nesting in the actual test. --- tests/bot/cogs/test_cogs.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/bot/cogs/test_cogs.py b/tests/bot/cogs/test_cogs.py index e28717756..d260b46a7 100644 --- a/tests/bot/cogs/test_cogs.py +++ b/tests/bot/cogs/test_cogs.py @@ -43,3 +43,10 @@ class CommandNameTests(unittest.TestCase): names.append(command.qualified_name) return names + + def get_all_commands(self) -> t.Iterator[commands.Command]: + """Yield all commands for all cogs in all extensions.""" + for extension in self.walk_extensions(): + for cog in self.walk_cogs(extension): + for cmd in self.walk_commands(cog): + yield cmd -- cgit v1.2.3 From 1b4def2c8c0d82fc9738c1e969404e305c91cac9 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 9 Mar 2020 14:31:56 -0700 Subject: Cog tests: fix Cog type check in `walk_cogs` --- tests/bot/cogs/test_cogs.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/bot/cogs/test_cogs.py b/tests/bot/cogs/test_cogs.py index d260b46a7..75aa1dbf6 100644 --- a/tests/bot/cogs/test_cogs.py +++ b/tests/bot/cogs/test_cogs.py @@ -32,9 +32,9 @@ class CommandNameTests(unittest.TestCase): @staticmethod def walk_cogs(extension: ModuleType) -> t.Iterator[commands.Cog]: """Yield all cogs defined in an extension.""" - for name, cls in extension.__dict__.items(): - if isinstance(cls, commands.Cog): - yield getattr(extension, name) + for obj in extension.__dict__.values(): + if isinstance(obj, type) and issubclass(obj, commands.Cog): + yield obj @staticmethod def get_qualified_names(command: commands.Command) -> t.List[str]: -- cgit v1.2.3 From 78327b9fa7c64a04d527fce582b93210356451fe Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 9 Mar 2020 14:49:08 -0700 Subject: Cog tests: fix duplicate cogs being yielded Have to check the modules are equal to prevent yielding imported cogs. --- tests/bot/cogs/test_cogs.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/test_cogs.py b/tests/bot/cogs/test_cogs.py index 75aa1dbf6..de0982c93 100644 --- a/tests/bot/cogs/test_cogs.py +++ b/tests/bot/cogs/test_cogs.py @@ -33,7 +33,8 @@ class CommandNameTests(unittest.TestCase): def walk_cogs(extension: ModuleType) -> t.Iterator[commands.Cog]: """Yield all cogs defined in an extension.""" for obj in extension.__dict__.values(): - if isinstance(obj, type) and issubclass(obj, commands.Cog): + is_cog = isinstance(obj, type) and issubclass(obj, commands.Cog) + if is_cog and obj.__module__ == extension.__name__: yield obj @staticmethod -- cgit v1.2.3 From bbcdf24a4b5d4f84834bbc8a8da7db2da627541f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 9 Mar 2020 15:02:22 -0700 Subject: Cog tests: fix nested modules not being found * Rename `walk_extensions` to `walk_modules` because some extensions don't consist of a single module --- tests/bot/cogs/test_cogs.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/tests/bot/cogs/test_cogs.py b/tests/bot/cogs/test_cogs.py index de0982c93..3a9f07db6 100644 --- a/tests/bot/cogs/test_cogs.py +++ b/tests/bot/cogs/test_cogs.py @@ -24,17 +24,21 @@ class CommandNameTests(unittest.TestCase): yield from command.walk_commands() @staticmethod - def walk_extensions() -> t.Iterator[ModuleType]: - """Yield imported extensions (modules) from the bot.cogs subpackage.""" - for module in pkgutil.iter_modules(cogs.__path__, "bot.cogs."): - yield importlib.import_module(module.name) + def walk_modules() -> t.Iterator[ModuleType]: + """Yield imported modules from the bot.cogs subpackage.""" + def on_error(name: str) -> t.NoReturn: + raise ImportError(name=name) + + for module in pkgutil.walk_packages(cogs.__path__, "bot.cogs.", onerror=on_error): + if not module.ispkg: + yield importlib.import_module(module.name) @staticmethod - def walk_cogs(extension: ModuleType) -> t.Iterator[commands.Cog]: + def walk_cogs(module: ModuleType) -> t.Iterator[commands.Cog]: """Yield all cogs defined in an extension.""" - for obj in extension.__dict__.values(): + for obj in module.__dict__.values(): is_cog = isinstance(obj, type) and issubclass(obj, commands.Cog) - if is_cog and obj.__module__ == extension.__name__: + if is_cog and obj.__module__ == module.__name__: yield obj @staticmethod @@ -47,7 +51,7 @@ class CommandNameTests(unittest.TestCase): def get_all_commands(self) -> t.Iterator[commands.Command]: """Yield all commands for all cogs in all extensions.""" - for extension in self.walk_extensions(): - for cog in self.walk_cogs(extension): + for module in self.walk_modules(): + for cog in self.walk_cogs(module): for cmd in self.walk_commands(cog): yield cmd -- cgit v1.2.3 From 02fe32879be51b3f202501ea8cdc5314ca3b77b2 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 9 Mar 2020 15:29:19 -0700 Subject: Cog tests: fix duplicate commands being yielded discord.py yields duplicate Command objects for each alias a command has, so the duplicates need to be removed on our end. --- tests/bot/cogs/test_cogs.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/test_cogs.py b/tests/bot/cogs/test_cogs.py index 3a9f07db6..9d1d4ebea 100644 --- a/tests/bot/cogs/test_cogs.py +++ b/tests/bot/cogs/test_cogs.py @@ -21,7 +21,8 @@ class CommandNameTests(unittest.TestCase): if command.parent is None: yield command if isinstance(command, commands.GroupMixin): - yield from command.walk_commands() + # Annoyingly it returns duplicates for each alias so use a set to fix that + yield from set(command.walk_commands()) @staticmethod def walk_modules() -> t.Iterator[ModuleType]: -- cgit v1.2.3 From 7e7c538435c899f45ff277e05fb59d139f401954 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 9 Mar 2020 15:34:12 -0700 Subject: Cog tests: add a test for duplicate command names & aliases --- tests/bot/cogs/test_cogs.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/bot/cogs/test_cogs.py b/tests/bot/cogs/test_cogs.py index 9d1d4ebea..616f5f44a 100644 --- a/tests/bot/cogs/test_cogs.py +++ b/tests/bot/cogs/test_cogs.py @@ -4,6 +4,7 @@ import importlib import pkgutil import typing as t import unittest +from collections import defaultdict from types import ModuleType from discord.ext import commands @@ -56,3 +57,19 @@ class CommandNameTests(unittest.TestCase): for cog in self.walk_cogs(module): for cmd in self.walk_commands(cog): yield cmd + + def test_names_dont_shadow(self): + """Names and aliases of commands should be unique.""" + all_names = defaultdict(list) + for cmd in self.get_all_commands(): + func_name = f"{cmd.module}.{cmd.callback.__qualname__}" + + for name in self.get_qualified_names(cmd): + with self.subTest(cmd=func_name, name=name): + if name in all_names: + conflicts = ", ".join(all_names.get(name, "")) + self.fail( + f"Name '{name}' of the command {func_name} conflicts with {conflicts}." + ) + + all_names[name].append(func_name) -- cgit v1.2.3 From f105ae75a98ae3e0295352d7debbc4fe04c73afd Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 13 Mar 2020 17:32:16 -0700 Subject: Cog tests: fix leading space in aliases without parents --- tests/bot/cogs/test_cogs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/cogs/test_cogs.py b/tests/bot/cogs/test_cogs.py index 616f5f44a..cbd203786 100644 --- a/tests/bot/cogs/test_cogs.py +++ b/tests/bot/cogs/test_cogs.py @@ -46,7 +46,7 @@ class CommandNameTests(unittest.TestCase): @staticmethod def get_qualified_names(command: commands.Command) -> t.List[str]: """Return a list of all qualified names, including aliases, for the `command`.""" - names = [f"{command.full_parent_name} {alias}" for alias in command.aliases] + names = [f"{command.full_parent_name} {alias}".strip() for alias in command.aliases] names.append(command.qualified_name) return names -- cgit v1.2.3 From 4d4975544ffec249aa6cd43d14987c00794caf99 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 13 Mar 2020 17:42:24 -0700 Subject: Cog tests: fix error on import due to discord.ext.tasks.loop The tasks extensions loop requires an event loop to exist. To work around this, it's been mocked. --- tests/bot/cogs/test_cogs.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/bot/cogs/test_cogs.py b/tests/bot/cogs/test_cogs.py index cbd203786..db559ded6 100644 --- a/tests/bot/cogs/test_cogs.py +++ b/tests/bot/cogs/test_cogs.py @@ -6,6 +6,7 @@ import typing as t import unittest from collections import defaultdict from types import ModuleType +from unittest import mock from discord.ext import commands @@ -31,9 +32,10 @@ class CommandNameTests(unittest.TestCase): def on_error(name: str) -> t.NoReturn: raise ImportError(name=name) - for module in pkgutil.walk_packages(cogs.__path__, "bot.cogs.", onerror=on_error): - if not module.ispkg: - yield importlib.import_module(module.name) + with mock.patch("discord.ext.tasks.loop"): + for module in pkgutil.walk_packages(cogs.__path__, "bot.cogs.", onerror=on_error): + if not module.ispkg: + yield importlib.import_module(module.name) @staticmethod def walk_cogs(module: ModuleType) -> t.Iterator[commands.Cog]: -- cgit v1.2.3 From e9ae8a89ec97c3bdb53dd354e292c53ad72fb42e Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Sat, 14 Mar 2020 19:43:31 +0530 Subject: Remove line that calls get_tags() method The tags have now been shifted from the database to being static files and hence the get_tags() method has undergone changes. It now dosen't fetch from the database but looks at the local files and we need not call it more than once. --- bot/cogs/tags.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index fecaf926d..fee24b2e7 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -97,8 +97,6 @@ class Tags(Cog): `predicate` will be the built-in any, all, or a custom callable. Must return a bool. """ - await self._get_tags() - keywords_processed: List[str] = [] for keyword in keywords.split(','): keyword_sanitized = keyword.strip().casefold() -- cgit v1.2.3 From 52ed9aa590a4c190f70778448ec926df4f2d0119 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 14 Mar 2020 12:35:24 -0700 Subject: Tags: use constant for command prefix in embed footer * Add a constant for the footer text * Import constants module rather than its classes --- bot/cogs/tags.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index fee24b2e7..09ce5a413 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -7,19 +7,20 @@ from typing import Callable, Dict, Iterable, List, Optional from discord import Colour, Embed from discord.ext.commands import Cog, Context, group +from bot import constants from bot.bot import Bot -from bot.constants import Channels, Cooldowns from bot.converters import TagNameConverter from bot.pagination import LinePaginator log = logging.getLogger(__name__) TEST_CHANNELS = ( - Channels.bot_commands, - Channels.helpers + constants.Channels.bot_commands, + constants.Channels.helpers ) REGEX_NON_ALPHABET = re.compile(r"[^a-z]", re.MULTILINE & re.IGNORECASE) +FOOTER_TEXT = f"To show a tag, type {constants.Bot.prefix}tags ." class Tags(Cog): @@ -133,7 +134,7 @@ class Tags(Cog): sorted(f"**»** {tag['title']}" for tag in matching_tags), ctx, embed, - footer_text="To show a tag, type !tags .", + footer_text=FOOTER_TEXT, empty=False, max_lines=15 ) @@ -177,7 +178,7 @@ class Tags(Cog): cooldown_conditions = ( tag_name and tag_name in self.tag_cooldowns - and (now - self.tag_cooldowns[tag_name]["time"]) < Cooldowns.tags + and (now - self.tag_cooldowns[tag_name]["time"]) < constants.Cooldowns.tags and self.tag_cooldowns[tag_name]["channel"] == ctx.channel.id ) @@ -186,7 +187,8 @@ class Tags(Cog): return False if _command_on_cooldown(tag_name): - time_left = Cooldowns.tags - (time.time() - self.tag_cooldowns[tag_name]["time"]) + time_elapsed = time.time() - self.tag_cooldowns[tag_name]["time"] + time_left = constants.Cooldowns.tags - time_elapsed log.info( f"{ctx.author} tried to get the '{tag_name}' tag, but the tag is on cooldown. " f"Cooldown ends in {time_left:.1f} seconds." @@ -223,7 +225,7 @@ class Tags(Cog): sorted(f"**»** {tag['title']}" for tag in tags), ctx, embed, - footer_text="To show a tag, type !tags .", + footer_text=FOOTER_TEXT, empty=False, max_lines=15 ) -- cgit v1.2.3 From eeacceae01b95e39eaeecab2fd14f6edfb19b94b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 14 Mar 2020 12:43:49 -0700 Subject: Tags: add restrictions 1 & 9 from YouTube ToS to ytdl tag --- bot/resources/tags/ytdl.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/bot/resources/tags/ytdl.md b/bot/resources/tags/ytdl.md index 4c47b0595..e34ecff44 100644 --- a/bot/resources/tags/ytdl.md +++ b/bot/resources/tags/ytdl.md @@ -2,7 +2,11 @@ Per [PyDis' Rule 5](https://pythondiscord.com/pages/rules), we are unable to ass For reference, this usage is covered by the following clauses in [YouTube's TOS](https://www.youtube.com/static?template=terms), as of 2019-07-22: ``` -The following restrictions apply to your use of the Service. You are not allowed to: +The following restrictions apply to your use of the Service. You are not allowed to: -3. access the Service using any automated means (such as robots, botnets or scrapers) except: (a) in the case of public search engines, in accordance with YouTube’s robots.txt file; (b) with YouTube’s prior written permission; or (c) as permitted by applicable law; +1. access, reproduce, download, distribute, transmit, broadcast, display, sell, license, alter, modify or otherwise use any part of the Service or any Content except: (a) as specifically permitted by the Service; (b) with prior written permission from YouTube and, if applicable, the respective rights holders; or (c) as permitted by applicable law; + +3. access the Service using any automated means (such as robots, botnets or scrapers) except: (a) in the case of public search engines, in accordance with YouTube’s robots.txt file; (b) with YouTube’s prior written permission; or (c) as permitted by applicable law; + +9. use the Service to view or listen to Content other than for personal, non-commercial use (for example, you may not publicly screen videos or stream music from the Service) ``` -- cgit v1.2.3 From a43315e3c4ec2725aec307de765898a85be02cc5 Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Sat, 14 Mar 2020 16:42:24 -0500 Subject: Update bot/cogs/moderation/infractions.py Co-Authored-By: Mark --- bot/cogs/moderation/infractions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 3ea185d29..f68f8ba9a 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -67,7 +67,7 @@ class Infractions(InfractionScheduler, commands.Cog): @command() async def ban(self, ctx: Context, user: FetchedMember, *, reason: str = None) -> None: - """Permanently ban a user for the given reason. Also removes them from the BigBrother watch list.""" + """Permanently ban a user for the given reason and stop watching them with Big Brother.""" await self.apply_ban(ctx, user, reason) # endregion -- cgit v1.2.3 From 5865126b87aad1a9fd425f9b131d8520c82a96a5 Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Sun, 15 Mar 2020 17:05:55 +0530 Subject: convert _get_tags_via_content() method to non-async --- bot/cogs/tags.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index fee24b2e7..ff3be7f4a 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -91,7 +91,7 @@ class Tags(Cog): return self._get_suggestions(tag_name) return found - async def _get_tags_via_content(self, check: Callable[[Iterable], bool], keywords: str) -> list: + def _get_tags_via_content(self, check: Callable[[Iterable], bool], keywords: str) -> list: """ Search for tags via contents. -- cgit v1.2.3 From 520c73093af8337c34fb7147ba7d7d15bf46a2af Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Sun, 15 Mar 2020 18:37:37 +0530 Subject: not awaiting _get_tags_via_content() method as it is non-async --- bot/cogs/tags.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 4895bd807..5b820978d 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -151,7 +151,7 @@ class Tags(Cog): Only search for tags that has ALL the keywords. """ - matching_tags = await self._get_tags_via_content(all, keywords) + matching_tags = self._get_tags_via_content(all, keywords) await self._send_matching_tags(ctx, keywords, matching_tags) @search_tag_content.command(name='any') @@ -161,7 +161,7 @@ class Tags(Cog): Search for tags that has ANY of the keywords. """ - matching_tags = await self._get_tags_via_content(any, keywords or 'any') + matching_tags = self._get_tags_via_content(any, keywords or 'any') await self._send_matching_tags(ctx, keywords, matching_tags) @tags_group.command(name='get', aliases=('show', 'g')) -- cgit v1.2.3 From 252b385e46ef542203e69f4f6d147dadbcec8f0f Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 15 Mar 2020 16:11:35 +0100 Subject: Use dict instead of a set and custom class. The FirstHash class is no longer necessary with only channels and the current loop in tuples. FirstHash was removed, along with its tests and tests were adjusted for new dict behaviour. --- bot/cogs/moderation/silence.py | 24 +++++------------------- tests/bot/cogs/moderation/test_silence.py | 28 +++------------------------- 2 files changed, 8 insertions(+), 44 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 8ed1cb28b..5df1fbbc0 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -15,26 +15,12 @@ from bot.utils.checks import with_role_check log = logging.getLogger(__name__) -class FirstHash(tuple): - """Tuple with only first item used for hash and eq.""" - - def __new__(cls, *args): - """Construct tuple from `args`.""" - return super().__new__(cls, args) - - def __hash__(self): - return hash((self[0],)) - - def __eq__(self, other: "FirstHash"): - return self[0] == other[0] - - class SilenceNotifier(tasks.Loop): """Loop notifier for posting notices to `alert_channel` containing added channels.""" def __init__(self, alert_channel: TextChannel): super().__init__(self._notifier, seconds=1, minutes=0, hours=0, count=None, reconnect=True, loop=None) - self._silenced_channels = set() + self._silenced_channels = {} self._alert_channel = alert_channel def add_channel(self, channel: TextChannel) -> None: @@ -42,12 +28,12 @@ class SilenceNotifier(tasks.Loop): if not self._silenced_channels: self.start() log.info("Starting notifier loop.") - self._silenced_channels.add(FirstHash(channel, self._current_loop)) + self._silenced_channels[channel] = self._current_loop def remove_channel(self, channel: TextChannel) -> None: """Remove channel from `_silenced_channels` and stop loop if no channels remain.""" with suppress(KeyError): - self._silenced_channels.remove(FirstHash(channel)) + del self._silenced_channels[channel] if not self._silenced_channels: self.stop() log.info("Stopping notifier loop.") @@ -58,11 +44,11 @@ class SilenceNotifier(tasks.Loop): if self._current_loop and not self._current_loop/60 % 15: log.debug( f"Sending notice with channels: " - f"{', '.join(f'#{channel} ({channel.id})' for channel, _ in self._silenced_channels)}." + f"{', '.join(f'#{channel} ({channel.id})' for channel in self._silenced_channels)}." ) channels_text = ', '.join( f"{channel.mention} for {(self._current_loop-start)//60} min" - for channel, start in self._silenced_channels + for channel, start in self._silenced_channels.items() ) await self._alert_channel.send(f"<@&{Roles.moderators}> currently silenced channels: {channels_text}") diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index d4719159e..6114fee21 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -2,33 +2,11 @@ import unittest from unittest import mock from unittest.mock import MagicMock, Mock -from bot.cogs.moderation.silence import FirstHash, Silence, SilenceNotifier +from bot.cogs.moderation.silence import Silence, SilenceNotifier from bot.constants import Channels, Emojis, Guild, Roles from tests.helpers import MockBot, MockContext, MockTextChannel -class FirstHashTests(unittest.TestCase): - def setUp(self) -> None: - self.test_cases = ( - (FirstHash(0, 4), FirstHash(0, 5)), - (FirstHash("string", None), FirstHash("string", True)) - ) - - def test_hashes_equal(self): - """Check hashes equal with same first item.""" - - for tuple1, tuple2 in self.test_cases: - with self.subTest(tuple1=tuple1, tuple2=tuple2): - self.assertEqual(hash(tuple1), hash(tuple2)) - - def test_eq(self): - """Check objects are equal with same first item.""" - - for tuple1, tuple2 in self.test_cases: - with self.subTest(tuple1=tuple1, tuple2=tuple2): - self.assertTrue(tuple1 == tuple2) - - class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: self.alert_channel = MockTextChannel() @@ -41,7 +19,7 @@ class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): channel = Mock() with mock.patch.object(self.notifier, "_silenced_channels") as silenced_channels: self.notifier.add_channel(channel) - silenced_channels.add.assert_called_with(FirstHash(channel, self.notifier._current_loop)) + silenced_channels.__setitem__.assert_called_with(channel, self.notifier._current_loop) def test_add_channel_starts_loop(self): """Loop is started if `_silenced_channels` was empty.""" @@ -59,7 +37,7 @@ class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): channel = Mock() with mock.patch.object(self.notifier, "_silenced_channels") as silenced_channels: self.notifier.remove_channel(channel) - silenced_channels.remove.assert_called_with(FirstHash(channel)) + silenced_channels.__delitem__.assert_called_with(channel) def test_remove_channel_stops_loop(self): """Notifier loop is stopped if `_silenced_channels` is empty after remove.""" -- cgit v1.2.3 From 39e9fc2dced2a4ce5e210f671bb03036ee91c2c6 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 15 Mar 2020 16:27:53 +0100 Subject: Adjust docstring styling. Co-authored-by: MarkKoz --- bot/cogs/error_handler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 45ab1f326..7989acde7 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -32,8 +32,8 @@ class ErrorHandler(Cog): prioritised as follows: 1. If the name fails to match a command: - If it matches shh+ or unshh+, the channel is silenced or unsilenced respectively. - otherwise if it matches a tag, the tag is invoked + * If it matches shh+ or unshh+, the channel is silenced or unsilenced respectively. + Otherwise if it matches a tag, the tag is invoked * If CommandNotFound is raised when invoking the tag (determined by the presence of the `invoked_from_error_handler` attribute), this error is treated as being unexpected and therefore sends an error message -- cgit v1.2.3 From dc534b72fcb561c057b1584311ca9e27244f08ae Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 15 Mar 2020 16:47:03 +0100 Subject: Move coro execution outside of if condition. This gives us a clearer look at the general flow control and what's getting executed. Comment was also moved to its relevant line. Co-authored-by: MarkKoz --- bot/cogs/error_handler.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 7989acde7..73757b7b7 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -50,15 +50,13 @@ class ErrorHandler(Cog): log.trace(f"Command {command} had its error already handled locally; ignoring.") return - # Try to look for a tag with the command's name if the command isn't found. - if isinstance(e, errors.CommandNotFound): - if ( - not await self.try_silence(ctx) - and not hasattr(ctx, "invoked_from_error_handler") - and ctx.channel.id != Channels.verification - ): + if isinstance(e, errors.CommandNotFound) and not hasattr(ctx, "invoked_from_error_handler"): + if await self.try_silence(ctx): + return + if ctx.channel.id != Channels.verification: + # Try to look for a tag with the command's name await self.try_get_tag(ctx) - return # Exit early to avoid logging. + return # Exit early to avoid logging. elif isinstance(e, errors.UserInputError): await self.handle_user_input_error(ctx, e) elif isinstance(e, errors.CheckFailure): -- cgit v1.2.3 From d2fad8a0e21a1cb074244bc371c88cf488229774 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 15 Mar 2020 16:47:45 +0100 Subject: Add Silence cog load to docstring. --- bot/cogs/moderation/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/__init__.py b/bot/cogs/moderation/__init__.py index 0349fe4b1..6880ca1bd 100644 --- a/bot/cogs/moderation/__init__.py +++ b/bot/cogs/moderation/__init__.py @@ -7,7 +7,7 @@ from .superstarify import Superstarify def setup(bot: Bot) -> None: - """Load the Infractions, ModManagement, ModLog, and Superstarify cogs.""" + """Load the Infractions, ModManagement, ModLog, Silence, and Superstarify cogs.""" bot.add_cog(Infractions(bot)) bot.add_cog(ModLog(bot)) bot.add_cog(ModManagement(bot)) -- cgit v1.2.3 From 4c2b8b715abd32b6818ef0cf4cdd96369eec192e Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 15 Mar 2020 16:49:37 +0100 Subject: Change BadArgument error wording. Co-authored-by: MarkKoz --- bot/converters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/converters.py b/bot/converters.py index 976376fce..635fef1c7 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -286,7 +286,7 @@ class HushDurationConverter(Converter): duration = int(match.group(1)) if duration > 15: - raise BadArgument("Duration must be below 15 minutes.") + raise BadArgument("Duration must be at most 15 minutes.") return duration -- cgit v1.2.3 From 678f8552fddcaca65289f6da0bcaa0c7b5ac14d1 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 15 Mar 2020 17:56:24 +0100 Subject: Pass kwargs directly instead of a PermissionOverwrite. The `set_permissions` method creates a `PermissionOverwrite` from kwargs internally, so we can skip creating it ourselves and unpack the dict directly into kwargs. --- bot/cogs/moderation/silence.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 5df1fbbc0..3d6ca8867 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -3,7 +3,7 @@ import logging from contextlib import suppress from typing import Optional -from discord import PermissionOverwrite, TextChannel +from discord import TextChannel from discord.ext import commands, tasks from discord.ext.commands import Context @@ -114,10 +114,7 @@ class Silence(commands.Cog): if current_overwrite.send_messages is False: log.info(f"Tried to silence channel #{channel} ({channel.id}) but the channel was already silenced.") return False - await channel.set_permissions( - self._verified_role, - overwrite=PermissionOverwrite(**dict(current_overwrite, send_messages=False)) - ) + await channel.set_permissions(self._verified_role, **dict(current_overwrite, send_messages=False)) self.muted_channels.add(channel) if persistent: log.info(f"Silenced #{channel} ({channel.id}) indefinitely.") @@ -136,10 +133,7 @@ class Silence(commands.Cog): """ current_overwrite = channel.overwrites_for(self._verified_role) if current_overwrite.send_messages is False: - await channel.set_permissions( - self._verified_role, - overwrite=PermissionOverwrite(**dict(current_overwrite, send_messages=True)) - ) + await channel.set_permissions(self._verified_role, **dict(current_overwrite, send_messages=True)) log.info(f"Unsilenced channel #{channel} ({channel.id}).") self.notifier.remove_channel(channel) with suppress(KeyError): -- cgit v1.2.3 From a52bff17234788f40e8a349cf78e72579621e8db Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 15 Mar 2020 18:02:54 +0100 Subject: Assign created task to a var. --- bot/cogs/moderation/silence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 3d6ca8867..3a3acf216 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -59,7 +59,7 @@ class Silence(commands.Cog): def __init__(self, bot: Bot): self.bot = bot self.muted_channels = set() - self.bot.loop.create_task(self._get_instance_vars()) + self._get_instance_vars_task = self.bot.loop.create_task(self._get_instance_vars()) async def _get_instance_vars(self) -> None: """Get instance variables after they're available to get from the guild.""" -- cgit v1.2.3 From a17ccdb3d22d10070dfdc79077555fa840f93e96 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 15 Mar 2020 18:06:20 +0100 Subject: Block commands until all instance vars are loaded. --- bot/cogs/moderation/silence.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 3a3acf216..42047d0f7 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -60,6 +60,7 @@ class Silence(commands.Cog): self.bot = bot self.muted_channels = set() self._get_instance_vars_task = self.bot.loop.create_task(self._get_instance_vars()) + self._get_instance_vars_event = asyncio.Event() async def _get_instance_vars(self) -> None: """Get instance variables after they're available to get from the guild.""" @@ -69,6 +70,7 @@ class Silence(commands.Cog): self._mod_alerts_channel = self.bot.get_channel(Channels.mod_alerts) self._mod_log_channel = self.bot.get_channel(Channels.mod_log) self.notifier = SilenceNotifier(self._mod_log_channel) + self._get_instance_vars_event.set() @commands.command(aliases=("hush",)) async def silence(self, ctx: Context, duration: HushDurationConverter = 10) -> None: @@ -78,6 +80,7 @@ class Silence(commands.Cog): Duration is capped at 15 minutes, passing forever makes the silence indefinite. Indefinitely silenced channels get added to a notifier which posts notices every 15 minutes from the start. """ + await self._get_instance_vars_event.wait() log.debug(f"{ctx.author} is silencing channel #{ctx.channel}.") if not await self._silence(ctx.channel, persistent=(duration is None), duration=duration): await ctx.send(f"{Emojis.cross_mark} current channel is already silenced.") @@ -99,6 +102,7 @@ class Silence(commands.Cog): Unsilence a previously silenced `channel`, remove it from notifier of indefinitely silenced channels and cancel the notifier if empty. """ + await self._get_instance_vars_event.wait() log.debug(f"Unsilencing channel #{ctx.channel} from {ctx.author}'s command.") if await self._unsilence(ctx.channel): await ctx.send(f"{Emojis.check_mark} unsilenced current channel.") -- cgit v1.2.3 From a6c9b4991dbb4dcdbc1e2abf97d1a167e8cd983c Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 15 Mar 2020 18:10:06 +0100 Subject: Document returns values of private methods. --- bot/cogs/moderation/silence.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 42047d0f7..f532260ca 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -113,6 +113,7 @@ class Silence(commands.Cog): If `persistent` is `True` add `channel` to notifier. `duration` is only used for logging; if None is passed `persistent` should be True to not log None. + Return `True` if channel permissions were changed, `False` otherwise. """ current_overwrite = channel.overwrites_for(self._verified_role) if current_overwrite.send_messages is False: @@ -134,6 +135,7 @@ class Silence(commands.Cog): Check if `channel` is silenced through a `PermissionOverwrite`, if it is unsilence it and remove it from the notifier. + Return `True` if channel permissions were changed, `False` otherwise. """ current_overwrite = channel.overwrites_for(self._verified_role) if current_overwrite.send_messages is False: -- cgit v1.2.3 From 36c57c6f89a070fbb77a641182e37c788b6de7a0 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 15 Mar 2020 18:21:49 +0100 Subject: Adjust tests for new calling behaviour. `.set_permissions` calls were changed to use kwargs directly instead of an overwrite, this reflects the changes in tests. --- tests/bot/cogs/moderation/test_silence.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 6114fee21..b09426fde 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -141,7 +141,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): channel = MockTextChannel() self.assertTrue(await self.cog._silence(channel, False, None)) channel.set_permissions.assert_called_once() - self.assertFalse(channel.set_permissions.call_args.kwargs['overwrite'].send_messages) + self.assertFalse(channel.set_permissions.call_args.kwargs['send_messages']) async def test_silence_private_notifier(self): """Channel should be added to notifier with `persistent` set to `True`, and the other way around.""" @@ -175,7 +175,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite)) self.assertTrue(await self.cog._unsilence(channel)) channel.set_permissions.assert_called_once() - self.assertTrue(channel.set_permissions.call_args.kwargs['overwrite'].send_messages) + self.assertTrue(channel.set_permissions.call_args.kwargs['send_messages']) @mock.patch.object(Silence, "notifier", create=True) async def test_unsilence_private_removed_notifier(self, notifier): -- cgit v1.2.3 From 0a2774fadddd18a86822a47599ebc4b76f1e5a7e Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 15 Mar 2020 18:23:11 +0100 Subject: Set `_get_instance_vars_event` in test's `setUp`. --- tests/bot/cogs/moderation/test_silence.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index b09426fde..c6f1fc1da 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -76,6 +76,8 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.cog = Silence(self.bot) self.ctx = MockContext() self.cog._verified_role = None + # Set event so command callbacks can continue. + self.cog._get_instance_vars_event.set() async def test_instance_vars_got_guild(self): """Bot got guild after it became available.""" -- cgit v1.2.3 From 8eea9c39261d77f86181600164920a635edd3570 Mon Sep 17 00:00:00 2001 From: Shirayuki Nekomata Date: Mon, 16 Mar 2020 00:27:18 +0700 Subject: Fixed tag search via contents, any keywords. Fixed `!tag search any` raises `AttributeError`. Changed default value of `keywords` from `None` to `'any'`. This will make it search for keyword `'any'` when there is no keyword. --- bot/cogs/tags.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 5b820978d..539105017 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -155,7 +155,7 @@ class Tags(Cog): await self._send_matching_tags(ctx, keywords, matching_tags) @search_tag_content.command(name='any') - async def search_tag_content_any_keyword(self, ctx: Context, *, keywords: Optional[str] = None) -> None: + async def search_tag_content_any_keyword(self, ctx: Context, *, keywords: Optional[str] = 'any') -> None: """ Search inside tags' contents for tags. Allow searching for multiple keywords separated by comma. -- cgit v1.2.3 From 166a4368a786c7aa1446a0348cec8c19307f1e55 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 15 Mar 2020 23:20:03 +0100 Subject: Remove long indentation from docstrings. --- bot/cogs/error_handler.py | 4 ++-- bot/converters.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 73757b7b7..bad6e51a3 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -98,8 +98,8 @@ class ErrorHandler(Cog): Attempt to invoke the silence or unsilence command if invoke with matches a pattern. Respecting the checks if: - invoked with `shh+` silence channel for amount of h's*2 with max of 15. - invoked with `unshh+` unsilence channel + * invoked with `shh+` silence channel for amount of h's*2 with max of 15. + * invoked with `unshh+` unsilence channel Return bool depending on success of command. """ command = ctx.invoked_with.lower() diff --git a/bot/converters.py b/bot/converters.py index 635fef1c7..2b413f039 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -273,10 +273,10 @@ class HushDurationConverter(Converter): If `"forever"` is passed, None is returned; otherwise an int of the extracted time. Accepted formats are: - , - m, - M, - forever. + * , + * m, + * M, + * forever. """ if argument == "forever": return None -- cgit v1.2.3 From 590c26355eb9b490a738afe936820c0e12c34873 Mon Sep 17 00:00:00 2001 From: ks123 Date: Mon, 16 Mar 2020 13:35:31 +0200 Subject: (Mod Log): Fixed case when `on_guild_channel_update` old or new value is empty and with this message formatting go wrong. --- bot/cogs/moderation/modlog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 81d95298d..5d7c91ac4 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -215,7 +215,7 @@ class ModLog(Cog, name="ModLog"): new = value["new_value"] old = value["old_value"] - changes.append(f"**{key.title()}:** `{old}` **→** `{new}`") + changes.append(f"**{key.title()}:** `{old or 'None'}` **→** `{new or 'None'}`") done.append(key) -- cgit v1.2.3 From 65057491b31f798aa82cb1e907fda6685d42eb1d Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 16 Mar 2020 17:11:08 +0100 Subject: Handle and log `CommandErrors` on `.can_run`. --- bot/cogs/error_handler.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index bad6e51a3..6a622d2ce 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -104,7 +104,12 @@ class ErrorHandler(Cog): """ command = ctx.invoked_with.lower() silence_command = self.bot.get_command("silence") - if not await silence_command.can_run(ctx): + ctx.invoked_from_error_handler = True + try: + if not await silence_command.can_run(ctx): + log.debug("Cancelling attempt to invoke silence/unsilence due to failed checks.") + return False + except errors.CommandError: log.debug("Cancelling attempt to invoke silence/unsilence due to failed checks.") return False if command.startswith("shh"): -- cgit v1.2.3 From 8e60f04048cf9272daf2a2e08eab76a69af97bf4 Mon Sep 17 00:00:00 2001 From: Karlis S <45097959+ks129@users.noreply.github.com> Date: Mon, 16 Mar 2020 18:32:55 +0200 Subject: (Mod Log): Added comment about channel update formatting change. --- bot/cogs/moderation/modlog.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 5d7c91ac4..21eded6e6 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -215,6 +215,8 @@ class ModLog(Cog, name="ModLog"): new = value["new_value"] old = value["old_value"] + # `or` is required here on `old` and `new` due otherwise, when one of them is empty, + # formatting in Discord will break. changes.append(f"**{key.title()}:** `{old or 'None'}` **→** `{new or 'None'}`") done.append(key) -- cgit v1.2.3 From 88d2d85ec114eac2b9e3be9b18e075302f73509e Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Mon, 16 Mar 2020 13:23:29 -0400 Subject: Update explanation comment so it explains what happens --- bot/cogs/moderation/modlog.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 21eded6e6..5f9bc0c6c 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -215,8 +215,9 @@ class ModLog(Cog, name="ModLog"): new = value["new_value"] old = value["old_value"] - # `or` is required here on `old` and `new` due otherwise, when one of them is empty, - # formatting in Discord will break. + # Discord does not treat consecutive backticks ("``") as an empty inline code block, so the markdown + # formatting is broken when `new` and/or `old` are empty values. "None" is used for these cases so + # formatting is preserved. changes.append(f"**{key.title()}:** `{old or 'None'}` **→** `{new or 'None'}`") done.append(key) -- cgit v1.2.3 From b8559cc12fa75dd4b4a52697cf5aa313d3c397d0 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 16 Mar 2020 10:27:21 -0700 Subject: Cog tests: comment some code for clarification --- tests/bot/cogs/test_cogs.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/bot/cogs/test_cogs.py b/tests/bot/cogs/test_cogs.py index db559ded6..39f6492cb 100644 --- a/tests/bot/cogs/test_cogs.py +++ b/tests/bot/cogs/test_cogs.py @@ -19,6 +19,7 @@ class CommandNameTests(unittest.TestCase): @staticmethod def walk_commands(cog: commands.Cog) -> t.Iterator[commands.Command]: """An iterator that recursively walks through `cog`'s commands and subcommands.""" + # Can't use Bot.walk_commands() or Cog.get_commands() cause those are instance methods. for command in cog.__cog_commands__: if command.parent is None: yield command @@ -32,6 +33,7 @@ class CommandNameTests(unittest.TestCase): def on_error(name: str) -> t.NoReturn: raise ImportError(name=name) + # The mock prevents asyncio.get_event_loop() from being called. with mock.patch("discord.ext.tasks.loop"): for module in pkgutil.walk_packages(cogs.__path__, "bot.cogs.", onerror=on_error): if not module.ispkg: @@ -41,6 +43,7 @@ class CommandNameTests(unittest.TestCase): def walk_cogs(module: ModuleType) -> t.Iterator[commands.Cog]: """Yield all cogs defined in an extension.""" for obj in module.__dict__.values(): + # Check if it's a class type cause otherwise issubclass() may raise a TypeError. is_cog = isinstance(obj, type) and issubclass(obj, commands.Cog) if is_cog and obj.__module__ == module.__name__: yield obj -- cgit v1.2.3 From e32d89ebd1df005046ca2a2a10e413d0a57cd453 Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Mon, 16 Mar 2020 13:23:04 -0500 Subject: Nesting reduced, logging cleaned up and made clearer Co-Authored-By: Mark --- bot/cogs/moderation/infractions.py | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index f68f8ba9a..0545f43bc 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -244,18 +244,21 @@ class Infractions(InfractionScheduler, commands.Cog): await self.apply_infraction(ctx, infraction, user, action) # Remove perma banned users from the watch list - if infraction.get('expires_at') is None: - log.trace("Ban was a permanent one. Attempt to remove from watched list.") - bb_cog = self.bot.get_cog("Big Brother") - if bb_cog: - log.trace("Cog loaded. Attempting to remove from list.") - await bb_cog.apply_unwatch( - ctx, - user, - "User has been permanently banned from the server. Automatically removed.", - banned=True - ) - log.debug("Perma banned user removed from watch list.") + if infraction.get('expires_at') is not None: + log.trace(f"Ban isn't permanent; user {user} won't be unwatched by Big Brother.") + return + + bb_cog = self.bot.get_cog("Big Brother") + if not bb_cog: + log.trace(f"Big Brother cog not loaded; perma-banned user {user} won't be unwatched.") + return + + log.trace(f"Big Brother cog loaded; attempting to unwatch perma-banned user {user}.") + + bb_reason = "User has been permanently banned from the server. Automatically removed." + await bb_cog.apply_unwatch(ctx, user, bb_reason banned=True) + + log.debug(f"Perma-banned user {user} was unwatched.") # endregion # region: Base pardon functions -- cgit v1.2.3 From a184b304a136d6f2e3373e475433caf7665fde6d Mon Sep 17 00:00:00 2001 From: ks123 Date: Tue, 17 Mar 2020 09:09:20 +0200 Subject: (!zen Command): Added exact word check before `difflib`'s matching, due matching may not count exact word as best choice. --- bot/cogs/utils.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 024141d62..2ca2c028e 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -230,7 +230,16 @@ class Utils(Cog): await ctx.send(embed=embed) return - # handle if it's a search string + # Try to handle first exact word due difflib.SequenceMatched may use some other similar word instead + # exact word. + for i, line in enumerate(zen_lines): + if search_value.lower() in line.lower(): + embed.title += f" (line {i}):" + embed.description = line + await ctx.send(embed=embed) + return + + # handle if it's a search string and not exact word matcher = difflib.SequenceMatcher(None, search_value.lower()) best_match = "" -- cgit v1.2.3 From 039a04462be58e9d345e32efcae13c8c999776db Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Tue, 17 Mar 2020 13:23:44 +0100 Subject: Fix `test_cog_unload` passing tests with invalid values. The first assert - `asyncio_mock.create_task.assert_called_once_with` called `alert_channel`'s send resulting in an extra call. `send` on `alert_channel` was not tested properly because of a typo and a missing assert in the method call. --- tests/bot/cogs/moderation/test_silence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index c6f1fc1da..febfd584b 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -202,8 +202,8 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): """Task for sending an alert was created with present `muted_channels`.""" with mock.patch.object(self.cog, "muted_channels"): self.cog.cog_unload() + alert_channel.send.assert_called_once_with(f"<@&{Roles.moderators}> channels left silenced on cog unload: ") asyncio_mock.create_task.assert_called_once_with(alert_channel.send()) - alert_channel.send.called_once_with(f"<@&{Roles.moderators}> chandnels left silenced on cog unload: ") @mock.patch("bot.cogs.moderation.silence.asyncio") def test_cog_unload1(self, asyncio_mock): -- cgit v1.2.3 From 2803c13c477634ceefe3501ad9cb7c76cfecf450 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Tue, 17 Mar 2020 17:10:16 +0100 Subject: Rename `cog_unload` tests. Previous names were undescriptive from testing phases. --- tests/bot/cogs/moderation/test_silence.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index febfd584b..07a70e7dc 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -198,7 +198,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): @mock.patch("bot.cogs.moderation.silence.asyncio") @mock.patch.object(Silence, "_mod_alerts_channel", create=True) - def test_cog_unload(self, alert_channel, asyncio_mock): + def test_cog_unload_starts_task(self, alert_channel, asyncio_mock): """Task for sending an alert was created with present `muted_channels`.""" with mock.patch.object(self.cog, "muted_channels"): self.cog.cog_unload() @@ -206,7 +206,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): asyncio_mock.create_task.assert_called_once_with(alert_channel.send()) @mock.patch("bot.cogs.moderation.silence.asyncio") - def test_cog_unload1(self, asyncio_mock): + def test_cog_unload_skips_task_start(self, asyncio_mock): """No task created with no channels.""" self.cog.cog_unload() asyncio_mock.create_task.assert_not_called() -- cgit v1.2.3 From 331cd64c4d874937fad052ff83388e73db3441ee Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Tue, 17 Mar 2020 17:13:17 +0100 Subject: Remove `channel` mentions from command docstrings. With the new behaviour of not accepting channels and muting the current one, it's no longer neccessary to keep the channel param in the docstring. Co-authored-by: MarkKoz --- bot/cogs/moderation/silence.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index f532260ca..552914ae8 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -75,7 +75,7 @@ class Silence(commands.Cog): @commands.command(aliases=("hush",)) async def silence(self, ctx: Context, duration: HushDurationConverter = 10) -> None: """ - Silence `channel` for `duration` minutes or `forever`. + Silence the current channel for `duration` minutes or `forever`. Duration is capped at 15 minutes, passing forever makes the silence indefinite. Indefinitely silenced channels get added to a notifier which posts notices every 15 minutes from the start. @@ -97,7 +97,7 @@ class Silence(commands.Cog): @commands.command(aliases=("unhush",)) async def unsilence(self, ctx: Context) -> None: """ - Unsilence `channel`. + Unsilence the current channel. Unsilence a previously silenced `channel`, remove it from notifier of indefinitely silenced channels and cancel the notifier if empty. -- cgit v1.2.3 From 55de2566581fbc2696c932ed57c5600e9b19f1c9 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Tue, 17 Mar 2020 17:14:00 +0100 Subject: Reword `unsilence` docstring. Co-authored-by: MarkKoz --- bot/cogs/moderation/silence.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 552914ae8..1523baf11 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -99,8 +99,7 @@ class Silence(commands.Cog): """ Unsilence the current channel. - Unsilence a previously silenced `channel`, - remove it from notifier of indefinitely silenced channels and cancel the notifier if empty. + If the channel was silenced indefinitely, notifications for the channel will stop. """ await self._get_instance_vars_event.wait() log.debug(f"Unsilencing channel #{ctx.channel} from {ctx.author}'s command.") -- cgit v1.2.3 From 20c41f2c5af6fd716c3e7f15de412f7f16f5ff1e Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Tue, 17 Mar 2020 17:15:10 +0100 Subject: Remove one indentation level. Co-authored-by: MarkKoz --- tests/bot/cogs/moderation/test_silence.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 07a70e7dc..8b9e30cfe 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -115,9 +115,9 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): ) for duration, result_message, _silence_patch_return in test_cases: with self.subTest( - silence_duration=duration, - result_message=result_message, - starting_unsilenced_state=_silence_patch_return + silence_duration=duration, + result_message=result_message, + starting_unsilenced_state=_silence_patch_return ): with mock.patch.object(self.cog, "_silence", return_value=_silence_patch_return): await self.cog.silence.callback(self.cog, self.ctx, duration) -- cgit v1.2.3 From d456e40ac97a38ee99561546bcafb6aa94117cb7 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Tue, 17 Mar 2020 17:16:31 +0100 Subject: Remove `alert_channel` mention from docstring. After removing the optional channel arg and changing output message channels we're only testing `ctx`'s `send`. --- tests/bot/cogs/moderation/test_silence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 8b9e30cfe..b4a34bbc7 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -125,7 +125,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.ctx.reset_mock() async def test_unsilence_sent_correct_discord_message(self): - """Check if proper message was sent to `alert_chanel`.""" + """Proper reply after a successful unsilence.""" with mock.patch.object(self.cog, "_unsilence", return_value=True): await self.cog.unsilence.callback(self.cog, self.ctx) self.ctx.send.assert_called_once_with(f"{Emojis.check_mark} unsilenced current channel.") -- cgit v1.2.3 From 95dae9bc7a7519c723539382848c02b9748d067f Mon Sep 17 00:00:00 2001 From: ks123 Date: Tue, 17 Mar 2020 19:32:48 +0200 Subject: (!zen Command): Under exact word match, change matching way from substring to sentence split iterate and equality check. --- bot/cogs/utils.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 2ca2c028e..0619296ad 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -233,11 +233,12 @@ class Utils(Cog): # Try to handle first exact word due difflib.SequenceMatched may use some other similar word instead # exact word. for i, line in enumerate(zen_lines): - if search_value.lower() in line.lower(): - embed.title += f" (line {i}):" - embed.description = line - await ctx.send(embed=embed) - return + for word in line.split(): + if word.lower() == search_value.lower(): + embed.title += f" (line {i}):" + embed.description = line + await ctx.send(embed=embed) + return # handle if it's a search string and not exact word matcher = difflib.SequenceMatcher(None, search_value.lower()) -- cgit v1.2.3 From 386c93a6f18adbf84691f17b13f5113800a353ae Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Tue, 17 Mar 2020 19:40:27 +0100 Subject: Fix test name. `removed` was describing the opposite behaviour. --- tests/bot/cogs/moderation/test_silence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index b4a34bbc7..55193e2f8 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -158,7 +158,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): await self.cog._silence(channel, False, None) self.cog.notifier.add_channel.assert_not_called() - async def test_silence_private_removed_muted_channel(self): + async def test_silence_private_added_muted_channel(self): channel = MockTextChannel() with mock.patch.object(self.cog, "muted_channels") as muted_channels: await self.cog._silence(channel, False, None) -- cgit v1.2.3 From dced6fdf5f571b82bc975dd3159af57c6f9a12b3 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Tue, 17 Mar 2020 19:41:13 +0100 Subject: Add docstring to test. --- tests/bot/cogs/moderation/test_silence.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 55193e2f8..71541086d 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -159,6 +159,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.cog.notifier.add_channel.assert_not_called() async def test_silence_private_added_muted_channel(self): + """Channel was added to `muted_channels` on silence.""" channel = MockTextChannel() with mock.patch.object(self.cog, "muted_channels") as muted_channels: await self.cog._silence(channel, False, None) -- cgit v1.2.3 From c68b943708eaca110ddfa6121872513a422bbef4 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Tue, 17 Mar 2020 20:10:50 +0100 Subject: Use set `discard` instead of `remove`. Discard ignores non present values, allowing us to skip the KeyError suppress. --- bot/cogs/moderation/silence.py | 3 +-- tests/bot/cogs/moderation/test_silence.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 1523baf11..a1446089e 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -141,8 +141,7 @@ class Silence(commands.Cog): await channel.set_permissions(self._verified_role, **dict(current_overwrite, send_messages=True)) log.info(f"Unsilenced channel #{channel} ({channel.id}).") self.notifier.remove_channel(channel) - with suppress(KeyError): - self.muted_channels.remove(channel) + self.muted_channels.discard(channel) return True log.info(f"Tried to unsilence channel #{channel} ({channel.id}) but the channel was not silenced.") return False diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 71541086d..eee020455 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -195,7 +195,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite)) with mock.patch.object(self.cog, "muted_channels") as muted_channels: await self.cog._unsilence(channel) - muted_channels.remove.assert_called_once_with(channel) + muted_channels.discard.assert_called_once_with(channel) @mock.patch("bot.cogs.moderation.silence.asyncio") @mock.patch.object(Silence, "_mod_alerts_channel", create=True) -- cgit v1.2.3 From cd429230fcb18c7101afd931317d37ad142bfe4b Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Tue, 17 Mar 2020 20:11:44 +0100 Subject: Add tests ensuring permissions get preserved. --- tests/bot/cogs/moderation/test_silence.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index eee020455..44682a1bd 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -2,6 +2,8 @@ import unittest from unittest import mock from unittest.mock import MagicMock, Mock +from discord import PermissionOverwrite + from bot.cogs.moderation.silence import Silence, SilenceNotifier from bot.constants import Channels, Emojis, Guild, Roles from tests.helpers import MockBot, MockContext, MockTextChannel @@ -145,6 +147,20 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): channel.set_permissions.assert_called_once() self.assertFalse(channel.set_permissions.call_args.kwargs['send_messages']) + async def test_silence_private_preserves_permissions(self): + """Previous permissions were preserved when channel was silenced.""" + channel = MockTextChannel() + # Set up mock channel permission state. + mock_permissions = PermissionOverwrite() + mock_permissions_dict = dict(mock_permissions) + channel.overwrites_for.return_value = mock_permissions + await self.cog._silence(channel, False, None) + new_permissions = channel.set_permissions.call_args.kwargs + # Remove 'send_messages' key because it got changed in the method. + del new_permissions['send_messages'] + del mock_permissions_dict['send_messages'] + self.assertDictEqual(mock_permissions_dict, new_permissions) + async def test_silence_private_notifier(self): """Channel should be added to notifier with `persistent` set to `True`, and the other way around.""" channel = MockTextChannel() @@ -197,6 +213,21 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): await self.cog._unsilence(channel) muted_channels.discard.assert_called_once_with(channel) + @mock.patch.object(Silence, "notifier", create=True) + async def test_unsilence_private_preserves_permissions(self, _): + """Previous permissions were preserved when channel was unsilenced.""" + channel = MockTextChannel() + # Set up mock channel permission state. + mock_permissions = PermissionOverwrite(send_messages=False) + mock_permissions_dict = dict(mock_permissions) + channel.overwrites_for.return_value = mock_permissions + await self.cog._unsilence(channel) + new_permissions = channel.set_permissions.call_args.kwargs + # Remove 'send_messages' key because it got changed in the method. + del new_permissions['send_messages'] + del mock_permissions_dict['send_messages'] + self.assertDictEqual(mock_permissions_dict, new_permissions) + @mock.patch("bot.cogs.moderation.silence.asyncio") @mock.patch.object(Silence, "_mod_alerts_channel", create=True) def test_cog_unload_starts_task(self, alert_channel, asyncio_mock): -- cgit v1.2.3 From cefcc575b6faa94fb18f1985f039125d023b2580 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Tue, 17 Mar 2020 22:18:58 +0100 Subject: Add tests for `HushDurationConverter`. --- tests/bot/test_converters.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/bot/test_converters.py b/tests/bot/test_converters.py index 1e5ca62ae..ca8cb6825 100644 --- a/tests/bot/test_converters.py +++ b/tests/bot/test_converters.py @@ -8,6 +8,7 @@ from discord.ext.commands import BadArgument from bot.converters import ( Duration, + HushDurationConverter, ISODateTime, TagContentConverter, TagNameConverter, @@ -271,3 +272,32 @@ class ConverterTests(unittest.TestCase): exception_message = f"`{datetime_string}` is not a valid ISO-8601 datetime string" with self.assertRaises(BadArgument, msg=exception_message): asyncio.run(converter.convert(self.context, datetime_string)) + + def test_hush_duration_converter_for_valid(self): + """HushDurationConverter returns correct value for minutes duration or `"forever"` strings.""" + test_values = ( + ("0", 0), + ("15", 15), + ("10", 10), + ("5m", 5), + ("5M", 5), + ("forever", None), + ) + converter = HushDurationConverter() + for minutes_string, expected_minutes in test_values: + with self.subTest(minutes_string=minutes_string, expected_minutes=expected_minutes): + converted = asyncio.run(converter.convert(self.context, minutes_string)) + self.assertEqual(expected_minutes, converted) + + def test_hush_duration_converter_for_invalid(self): + """HushDurationConverter raises correct exception for invalid minutes duration strings.""" + test_values = ( + ("16", "Duration must be at most 15 minutes."), + ("10d", "10d is not a valid minutes duration."), + ("-1", "-1 is not a valid minutes duration."), + ) + converter = HushDurationConverter() + for invalid_minutes_string, exception_message in test_values: + with self.subTest(invalid_minutes_string=invalid_minutes_string, exception_message=exception_message): + with self.assertRaisesRegex(BadArgument, exception_message): + asyncio.run(converter.convert(self.context, invalid_minutes_string)) -- cgit v1.2.3 From f39b2ebbb09d31a4dad0f5436c7bf450685f8d59 Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Fri, 20 Mar 2020 12:04:41 -0500 Subject: Updated doc strings to be more descriptive Co-Authored-By: Mark --- bot/cogs/watchchannels/bigbrother.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index caae793bb..fbc779bcc 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -61,7 +61,12 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): await self.apply_unwatch(ctx, user, reason) async def apply_watch(self, ctx: Context, user: FetchedMember, reason: str) -> None: - """Handles adding a user to the watch list.""" + """ + Add `user` to watched users and apply a watch infraction with `reason`. + + A message indicating the result of the operation is sent to `ctx`. + The message will include `user`'s previous watch infraction history, if it exists. + """ if user.bot: await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.") return @@ -101,7 +106,12 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): await ctx.send(msg) async def apply_unwatch(self, ctx: Context, user: FetchedMember, reason: str, banned: bool = False) -> None: - """Handles the actual user removal from the watch list.""" + """ + Remove `user` from watched users and mark their infraction as inactive with `reason`. + + If `send_message` is True, a message indicating the result of the operation is sent to + `ctx`. + """ active_watches = await self.bot.api_client.get( self.api_endpoint, params=ChainMap( -- cgit v1.2.3 From 8a983d20c705ad07902ac4f3af54b952575b25ba Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Fri, 20 Mar 2020 13:20:35 -0500 Subject: Updated Docstrings, parameters, and log messages - Docstrings for `apply_ban()` have been edited to mention that the method also removes a banned user from the watch list. - Parameter `banned` in `apply_unwatch()` was changed to `send_message` in order to be more general. Boolean logic was swapped to coincide with that change. - `apply_unwatch()`'s sent message moved to the bottom of the method for clarity. Added `return`s to the method to exit early if no message needs to be sent. Signed-off-by: Daniel Brown --- bot/cogs/moderation/infractions.py | 11 ++++++----- bot/cogs/watchchannels/bigbrother.py | 23 +++++++++++++++-------- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 0545f43bc..c242a3000 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -230,7 +230,11 @@ class Infractions(InfractionScheduler, commands.Cog): @respect_role_hierarchy() async def apply_ban(self, ctx: Context, user: UserSnowflake, reason: str, **kwargs) -> None: - """Apply a ban infraction with kwargs passed to `post_infraction`.""" + """ + Apply a ban infraction with kwargs passed to `post_infraction`. + + Will also remove the banned user from the Big Brother watch list if applicable. + """ if await utils.has_active_infraction(ctx, user, "ban"): return @@ -243,7 +247,6 @@ class Infractions(InfractionScheduler, commands.Cog): action = ctx.guild.ban(user, reason=reason, delete_message_days=0) await self.apply_infraction(ctx, infraction, user, action) - # Remove perma banned users from the watch list if infraction.get('expires_at') is not None: log.trace(f"Ban isn't permanent; user {user} won't be unwatched by Big Brother.") return @@ -256,9 +259,7 @@ class Infractions(InfractionScheduler, commands.Cog): log.trace(f"Big Brother cog loaded; attempting to unwatch perma-banned user {user}.") bb_reason = "User has been permanently banned from the server. Automatically removed." - await bb_cog.apply_unwatch(ctx, user, bb_reason banned=True) - - log.debug(f"Perma-banned user {user} was unwatched.") + await bb_cog.apply_unwatch(ctx, user, bb_reason, send_message=False) # endregion # region: Base pardon functions diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index fbc779bcc..903c87f85 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -105,7 +105,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): await ctx.send(msg) - async def apply_unwatch(self, ctx: Context, user: FetchedMember, reason: str, banned: bool = False) -> None: + async def apply_unwatch(self, ctx: Context, user: FetchedMember, reason: str, send_message: bool = True) -> None: """ Remove `user` from watched users and mark their infraction as inactive with `reason`. @@ -130,13 +130,20 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): await post_infraction(ctx, user, 'watch', f"Unwatched: {reason}", hidden=True, active=False) - if not banned: # Prevents a message being sent to the channel if part of a permanent ban - log.trace("User is not banned. Sending message to channel") - await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed.") - self._remove_user(user.id) + + if not send_message: # Prevents a message being sent to the channel if part of a permanent ban + log.debug(f"Perma-banned user {user} was unwatched.") + return + log.trace("User is not banned. Sending message to channel") + message = f":white_check_mark: Messages sent by {user} will no longer be relayed." + else: log.trace("No active watches found for user.") - if not banned: # Prevents a message being sent to the channel if part of a permanent ban - log.trace("User is not perma banned. Send the error message.") - await ctx.send(":x: The specified user is currently not being watched.") + if not send_message: # Prevents a message being sent to the channel if part of a permanent ban + log.debug(f"{user} was not on the watch list; no removal necessary.") + return + log.trace("User is not perma banned. Send the error message.") + message = ":x: The specified user is currently not being watched." + + await ctx.send(message) -- cgit v1.2.3 From abfaef92a90ff71f8b8f2176327904fda88e3d80 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Mon, 16 Mar 2020 17:41:16 -0400 Subject: Update token filter logging to match expanded detection Log message still used the first regex result (re.search) rather than the expanded approach (re.findall) recently added. --- bot/cogs/token_remover.py | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index 547ba8da0..ad6d99e84 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -3,6 +3,7 @@ import binascii import logging import re import struct +import typing as t from datetime import datetime from discord import Colour, Message @@ -53,8 +54,9 @@ class TokenRemover(Cog): See: https://discordapp.com/developers/docs/reference#snowflakes """ - if self.is_token_in_message(msg): - await self.take_action(msg) + found_token = self.find_token_in_message(msg) + if found_token: + await self.take_action(msg, found_token) @Cog.listener() async def on_message_edit(self, before: Message, after: Message) -> None: @@ -63,12 +65,13 @@ class TokenRemover(Cog): See: https://discordapp.com/developers/docs/reference#snowflakes """ - if self.is_token_in_message(after): - await self.take_action(after) + found_token = self.find_token_in_message(after) + if found_token: + await self.take_action(after, found_token) - async def take_action(self, msg: Message) -> None: + async def take_action(self, msg: Message, found_token: str) -> None: """Remove the `msg` containing a token an send a mod_log message.""" - user_id, creation_timestamp, hmac = TOKEN_RE.search(msg.content).group(0).split('.') + user_id, creation_timestamp, hmac = found_token.split('.') self.mod_log.ignore(Event.message_delete, msg.id) await msg.delete() await msg.channel.send(DELETION_MESSAGE_TEMPLATE.format(mention=msg.author.mention)) @@ -91,18 +94,21 @@ class TokenRemover(Cog): ) @classmethod - def is_token_in_message(cls, msg: Message) -> bool: - """Check if `msg` contains a seemly valid token.""" + def find_token_in_message(cls, msg: Message) -> t.Optional[str]: + """Check for a seemingly valid token in the provided `Message` instance.""" if msg.author.bot: - return False + return # Use findall rather than search to guard against method calls prematurely returning the # token check (e.g. `message.channel.send` also matches our token pattern) maybe_matches = TOKEN_RE.findall(msg.content) - if not maybe_matches: - return False + for substr in maybe_matches: + if cls.is_maybe_token(substr): + # Short-circuit on first match + return substr - return any(cls.is_maybe_token(substr) for substr in maybe_matches) + # No matching substring + return @classmethod def is_maybe_token(cls, test_str: str) -> bool: -- cgit v1.2.3 From c432bf965a7bd5f660730aa3497ef1f8d8800a31 Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Fri, 20 Mar 2020 15:11:15 -0500 Subject: Changed a logging level - Changed the log for when the big brother cog doesn't load in the `apply_ban()` method doesn't properly load from a trace to an error. --- bot/cogs/moderation/infractions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index c242a3000..efa19f59e 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -253,7 +253,7 @@ class Infractions(InfractionScheduler, commands.Cog): bb_cog = self.bot.get_cog("Big Brother") if not bb_cog: - log.trace(f"Big Brother cog not loaded; perma-banned user {user} won't be unwatched.") + log.error(f"Big Brother cog not loaded; perma-banned user {user} won't be unwatched.") return log.trace(f"Big Brother cog loaded; attempting to unwatch perma-banned user {user}.") -- cgit v1.2.3 From 387d0aa17721460d912e4d05348521d278de72c0 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Fri, 20 Mar 2020 18:45:29 -0400 Subject: Update contributor doc --- CONTRIBUTING.md | 59 ++++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 37 insertions(+), 22 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 61d11f844..be591d17e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,4 +1,4 @@ -# Contributing to one of our projects +# Contributing to one of Our Projects Our projects are open-source and are automatically deployed whenever commits are pushed to the `master` branch on each repository, so we've created a set of guidelines in order to keep everything clean and in working order. @@ -10,12 +10,12 @@ Note that contributions may be rejected on the basis of a contributor failing to 2. If you have direct access to the repository, **create a branch for your changes** and create a pull request for that branch. If not, create a branch on a fork of the repository and create a pull request from there. * It's common practice for a repository to reject direct pushes to `master`, so make branching a habit! * If PRing from your own fork, **ensure that "Allow edits from maintainers" is checked**. This gives permission for maintainers to commit changes directly to your fork, speeding up the review process. -3. **Adhere to the prevailing code style**, which we enforce using [flake8](http://flake8.pycqa.org/en/latest/index.html). - * Run `flake8` against your code **before** you push it. Your commit will be rejected by the build server if it fails to lint. - * [Git Hooks](https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks) are a powerful tool that can be a daunting to set up. Fortunately, [`pre-commit`](https://github.com/pre-commit/pre-commit) abstracts this process away from you and is provided as a dev dependency for this project. Run `pipenv run precommit` when setting up the project and you'll never have to worry about breaking the build for linting errors. +3. **Adhere to the prevailing code style**, which we enforce using [`flake8`](http://flake8.pycqa.org/en/latest/index.html) and [`pre-commit`](https://pre-commit.com/). + * Run `flake8` and `pre-commit` against your code [**before** you push it](https://soundcloud.com/lemonsaurusrex/lint-before-you-push). Your commit will be rejected by the build server if it fails to lint. + * [Git Hooks](https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks) are a powerful git feature for executing custom scripts when certain important git actions occur. The pre-commit hook is the first hook executed during the commit process and can be used to check the code being committed & abort the commit if issues, such as linting failures, are detected. While git hooks can seem daunting to configure, the `pre-commit` framework abstracts this process away from you and is provided as a dev dependency for this project. Run `pipenv run precommit` when setting up the project and you'll never have to worry about committing code that fails linting. 4. **Make great commits**. A well structured git log is key to a project's maintainability; it efficiently provides insight into when and *why* things were done for future maintainers of the project. * Commits should be as narrow in scope as possible. Commits that span hundreds of lines across multiple unrelated functions and/or files are very hard for maintainers to follow. After about a week they'll probably be hard for you to follow too. - * Try to avoid making minor commits for fixing typos or linting errors. Since you've already set up a pre-commit hook to run `flake8` before a commit, you shouldn't be committing linting issues anyway. + * Avoid making minor commits for fixing typos or linting errors. Since you've already set up a `pre-commit` hook to run the linting pipeline before a commit, you shouldn't be committing linting issues anyway. * A more in-depth guide to writing great commit messages can be found in Chris Beam's [*How to Write a Git Commit Message*](https://chris.beams.io/posts/git-commit/) 5. **Avoid frequent pushes to the main repository**. This goes for PRs opened against your fork as well. Our test build pipelines are triggered every time a push to the repository (or PR) is made. Try to batch your commits until you've finished working for that session, or you've reached a point where collaborators need your commits to continue their own work. This also provides you the opportunity to amend commits for minor changes rather than having to commit them on their own because you've already pushed. * This includes merging master into your branch. Try to leave merging from master for after your PR passes review; a maintainer will bring your PR up to date before merging. Exceptions to this include: resolving merge conflicts, needing something that was pushed to master for your branch, or something was pushed to master that could potentionally affect the functionality of what you're writing. @@ -24,13 +24,12 @@ Note that contributions may be rejected on the basis of a contributor failing to * One option is to fork the other contributor's repository and submit your changes to their branch with your own pull request. We suggest following these guidelines when interacting with their repository as well. * The author(s) of inactive PRs and claimed issues will be be pinged after a week of inactivity for an update. Continued inactivity may result in the issue being released back to the community and/or PR closure. 8. **Work as a team** and collaborate wherever possible. Keep things friendly and help each other out - these are shared projects and nobody likes to have their feet trodden on. -9. **Internal projects are internal**. As a contributor, you have access to information that the rest of the server does not. With this trust comes responsibility - do not release any information you have learned as a result of your contributor position. We are very strict about announcing things at specific times, and many staff members will not appreciate a disruption of the announcement schedule. -10. All static content, such as images or audio, **must be licensed for open public use**. +9. All static content, such as images or audio, **must be licensed for open public use**. * Static content must be hosted by a service designed to do so. Failing to do so is known as "leeching" and is frowned upon, as it generates extra bandwidth costs to the host without providing benefit. It would be best if appropriately licensed content is added to the repository itself so it can be served by PyDis' infrastructure. -Above all, the needs of our community should come before the wants of an individual. Work together, build solutions to problems and try to do so in a way that people can learn from easily. Abuse of our trust may result in the loss of your Contributor role, especially in relation to Rule 7. +Above all, the needs of our community should come before the wants of an individual. Work together, build solutions to problems and try to do so in a way that people can learn from easily. Abuse of our trust may result in the loss of your Contributor role. -## Changes to this arrangement +## Changes to this Arrangement All projects evolve over time, and this contribution guide is no different. This document is open to pull requests or changes by contributors. If you believe you have something valuable to add or change, please don't hesitate to do so in a PR. @@ -48,10 +47,14 @@ When pulling down changes from GitHub, remember to sync your environment using ` For example: ```py -def foo(input_1: int, input_2: dict) -> bool: +import typing as t + + +def foo(input_1: int, input_2: t.Dict[str, str]) -> bool: + ... ``` -Tells us that `foo` accepts an `int` and a `dict` and returns a `bool`. +Tells us that `foo` accepts an `int` and a `dict`, with `str` keys and values, and returns a `bool`. All function declarations should be type hinted in code contributed to the PyDis organization. @@ -63,15 +66,19 @@ Many documentation packages provide support for automatic documentation generati For example: ```py -def foo(bar: int, baz: dict=None) -> bool: +import typing as t + + +def foo(bar: int, baz: t.Optional[t.Dict[str, str]] = None) -> bool: """ Does some things with some stuff. :param bar: Some input - :param baz: Optional, some other input + :param baz: Optional, some dictionary with string keys and values :return: Some boolean """ + ... ``` Since PyDis does not utilize automatic documentation generation, use of this syntax should not be used in code contributed to the organization. Should the purpose and type of the input variables not be easily discernable from the variable name and type annotation, a prose explanation can be used. Explicit references to variables, functions, classes, etc. should be wrapped with backticks (`` ` ``). @@ -79,25 +86,33 @@ Since PyDis does not utilize automatic documentation generation, use of this syn For example, the above docstring would become: ```py -def foo(bar: int, baz: dict=None) -> bool: +import typing as t + + +def foo(bar: int, baz: t.Optional[t.Dict[str, str]] = None) -> bool: """ Does some things with some stuff. This function takes an index, `bar` and checks for its presence in the database `baz`, passed as a dictionary. Returns `False` if `baz` is not passed. """ + ... ``` ### Logging Levels -The project currently defines [`logging`](https://docs.python.org/3/library/logging.html) levels as follows: -* **TRACE:** Use this for tracing every step of a complex process. That way we can see which step of the process failed. Err on the side of verbose. **Note:** This is a PyDis-implemented logging level. -* **DEBUG:** Someone is interacting with the application, and the application is behaving as expected. -* **INFO:** Something completely ordinary happened. Like a cog loading during startup. -* **WARNING:** Someone is interacting with the application in an unexpected way or the application is responding in an unexpected way, but without causing an error. -* **ERROR:** An error that affects the specific part that is being interacted with -* **CRITICAL:** An error that affects the whole application. +The project currently defines [`logging`](https://docs.python.org/3/library/logging.html) levels as follows, from lowest to highest severity: +* **TRACE:** These events should be used to provide a *verbose* trace of every step of a complex process. This is essentially the `logging` equivalent of sprinkling `print` statements throughout the code. + * **Note:** This is a PyDis-implemented logging level. +* **DEBUG:** These events should add context to what's happening in a development setup to make it easier to follow what's going while working on a project. This is in the same vein as **TRACE** logging but at a much lower level of verbosity. +* **INFO:** These events are normal and don't need direct attention but are worth keeping track of in production, like checking which cogs were loaded during a start-up. +* **WARNING:** These events are out of the ordinary and should be fixed, but have not caused a failure. + * **NOTE:** Events at this logging level and higher should be reserved for events that require the attention of the DevOps team. +* **ERROR:** These events have caused a failure in a specific part of the application and require urgent attention. +* **CRITICAL:** These events have caused the whole application to fail and require immediate intervention. + +Ensure that log messages are succinct. Should you want to pass additional useful information that would otherwise make the log message overly verbose the `logging` module accepts an `extra` kwarg, which can be used to pass a dictionary. This is used to populate the `__dict__` of the `LogRecord` created for the logging event with user-defined attributes that can be accessed by a log handler. Additional information and caveats may be found [in Python's `logging` documentation](https://docs.python.org/3/library/logging.html#logging.Logger.debug). ### Work in Progress (WIP) PRs -Github [has introduced a new PR feature](https://github.blog/2019-02-14-introducing-draft-pull-requests/) that allows the PR author to mark it as a WIP. This provides both a visual and functional indicator that the contents of the PR are in a draft state and not yet ready for formal review. +Github [provides a PR feature](https://github.blog/2019-02-14-introducing-draft-pull-requests/) that allows the PR author to mark it as a WIP. This provides both a visual and functional indicator that the contents of the PR are in a draft state and not yet ready for formal review. This feature should be utilized in place of the traditional method of prepending `[WIP]` to the PR title. -- cgit v1.2.3 From 3597d22833096754c09e2970a80eff8e6b141132 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Sat, 21 Mar 2020 06:11:25 -0400 Subject: Fix regression in verification cog A stray `bot` was removed from the `on_message` listener, causing it to raise an exception rather than generate a `Context` object from incoming verification channel messages. --- bot/cogs/verification.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index 107bc1058..b0a493e68 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/verification.py @@ -95,7 +95,7 @@ class Verification(Cog): ping_everyone=constants.Filter.ping_everyone, ) - ctx: Context = await self.get_context(message) + ctx: Context = await self.bot.get_context(message) if ctx.command is not None and ctx.command.name == "accept": return -- cgit v1.2.3 From ece2e7a2c07d6de012052c3b44d9c9110125bcc8 Mon Sep 17 00:00:00 2001 From: Karlis S Date: Mon, 23 Mar 2020 05:51:09 +0000 Subject: Removed `zen` tag due `!zen` command exist. --- bot/resources/tags/zen.md | 20 -------------------- 1 file changed, 20 deletions(-) delete mode 100644 bot/resources/tags/zen.md diff --git a/bot/resources/tags/zen.md b/bot/resources/tags/zen.md deleted file mode 100644 index 3e132eed8..000000000 --- a/bot/resources/tags/zen.md +++ /dev/null @@ -1,20 +0,0 @@ - -Beautiful is better than ugly. -Explicit is better than implicit. -Simple is better than complex. -Complex is better than complicated. -Flat is better than nested. -Sparse is better than dense. -Readability counts. -Special cases aren't special enough to break the rules. -Although practicality beats purity. -Errors should never pass silently. -Unless explicitly silenced. -In the face of ambiguity, refuse the temptation to guess. -There should be one-- and preferably only one --obvious way to do it. -Although that way may not be obvious at first unless you're Dutch. -Now is better than never. -Although never is often better than *right* now. -If the implementation is hard to explain, it's a bad idea. -If the implementation is easy to explain, it may be a good idea. -Namespaces are one honking great idea -- let's do more of those! -- cgit v1.2.3 From 8d3a10089a9691bf1c463cd5ec3f0527f5bbc0a5 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Mon, 23 Mar 2020 10:17:06 -0400 Subject: Clarify docstring for token check function Co-Authored-By: Mark --- bot/cogs/token_remover.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index ad6d99e84..421ad23e2 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -95,7 +95,7 @@ class TokenRemover(Cog): @classmethod def find_token_in_message(cls, msg: Message) -> t.Optional[str]: - """Check for a seemingly valid token in the provided `Message` instance.""" + """Return a seemingly valid token found in `msg` or `None` if no token is found.""" if msg.author.bot: return -- cgit v1.2.3 From 30f8c8d6b4df87fbc8273126b7f110d1d3d33714 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Tue, 24 Mar 2020 13:25:37 -0400 Subject: Remove unused safety & dodgy dev dependencies Relock --- Pipfile | 2 - Pipfile.lock | 275 +++++++++++++++++++++-------------------------------------- 2 files changed, 98 insertions(+), 179 deletions(-) diff --git a/Pipfile b/Pipfile index 0dcee0e3d..04cc98427 100644 --- a/Pipfile +++ b/Pipfile @@ -33,9 +33,7 @@ flake8-tidy-imports = "~=4.0" flake8-todo = "~=0.7" pep8-naming = "~=0.9" pre-commit = "~=2.1" -safety = "~=1.8" unittest-xml-reporting = "~=3.0" -dodgy = "~=0.1" [requires] python_version = "3.8" diff --git a/Pipfile.lock b/Pipfile.lock index 348456f2c..ad9a3173a 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "b8b38e84230bdc37f8c8955e8dddc442183a2e23c4dfc6ed37c522644aecdeea" + "sha256": "2d3ba484e8467a115126b2ba39fa5f36f103ea455477813dd658797875c79cc9" }, "pipfile-spec": 6, "requires": { @@ -18,11 +18,11 @@ "default": { "aio-pika": { "hashes": [ - "sha256:0332bc13abbd8923dac657b331716778c55ea0a32ac0951306ce85edafcc916c", - "sha256:39770d8bc7e9059e28622d599e2ac9ebc16a7198b33d1743c1a496ca3b0f8170" + "sha256:9e4614636296e0040055bd6b304e97a38cc9796669ef391fc9b36649831d43ee", + "sha256:c9d242b3c7142d64b185feb6c5cce4154962610e89ec2e9b52bd69ef01f89b2f" ], "index": "pypi", - "version": "==6.5.3" + "version": "==6.6.0" }, "aiodns": { "hashes": [ @@ -159,11 +159,11 @@ }, "deepdiff": { "hashes": [ - "sha256:b3fa588d1eac7fa318ec1fb4f2004568e04cb120a1989feda8e5e7164bcbf07a", - "sha256:ed7342d3ed3c0c2058a3fb05b477c943c9959ef62223dca9baa3375718a25d87" + "sha256:59fc1e3e7a28dd0147b0f2b00e3e27181f0f0ef4286b251d5f214a5bcd9a9bc4", + "sha256:91360be1d9d93b1d9c13ae9c5048fa83d9cff17a88eb30afaa0d7ff2d0fee17d" ], "index": "pypi", - "version": "==4.2.0" + "version": "==4.3.2" }, "discord-py": { "hashes": [ @@ -189,10 +189,10 @@ }, "humanfriendly": { "hashes": [ - "sha256:2f79aaa2965c0fc3d79452e64ec2c7601d70d67e51ea2e99cb40afe3fe2824c5", - "sha256:6990c0af4b72f50ddf302900eb982edf199247e621e06d80d71b00b1a1574214" + "sha256:25c2108a45cfd1e8fbe9cdb30b825d34ef5d5675c8e11e4775c9aedbfb0bdee2", + "sha256:3a831920e40e55ad49adb64c9179ed50c604cabca72cd300e7bd5b51310e4ebb" ], - "version": "==8.0" + "version": "==8.1" }, "idna": { "hashes": [ @@ -331,10 +331,10 @@ }, "packaging": { "hashes": [ - "sha256:170748228214b70b672c581a3dd610ee51f733018650740e98c7df862a583f73", - "sha256:e665345f9eef0c621aa0bf2f8d78cf6d21904eef16a93f020240b704a57f1334" + "sha256:3c292b474fda1671ec57d46d739d072bfd495a4f51ad01a055121d81e952b7a3", + "sha256:82f77b9bee21c1bafbf35a84905d604d5d1223801d639cf3ed140bd651c08752" ], - "version": "==20.1" + "version": "==20.3" }, "pamqp": { "hashes": [ @@ -379,16 +379,17 @@ }, "pycparser": { "hashes": [ - "sha256:a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3" + "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", + "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705" ], - "version": "==2.19" + "version": "==2.20" }, "pygments": { "hashes": [ - "sha256:2a3fe295e54a20164a9df49c75fa58526d3be48e14aceba6d6b1e8ac0bfd6f1b", - "sha256:98c8aa5a9f778fcd1026a17361ddaf7330d1b7c62ae97c3bb0ae73e0b9b6b0fe" + "sha256:647344a061c249a3b74e230c739f434d7ea4d8b1d5f3721bc0f3558049b38f44", + "sha256:ff7a40b4860b727ab48fad6360eb351cc1b33cbf9b15a0f689ca5353e9463324" ], - "version": "==2.5.2" + "version": "==2.6.1" }, "pyparsing": { "hashes": [ @@ -421,20 +422,20 @@ }, "pyyaml": { "hashes": [ - "sha256:059b2ee3194d718896c0ad077dd8c043e5e909d9180f387ce42012662a4946d6", - "sha256:1cf708e2ac57f3aabc87405f04b86354f66799c8e62c28c5fc5f88b5521b2dbf", - "sha256:24521fa2890642614558b492b473bee0ac1f8057a7263156b02e8b14c88ce6f5", - "sha256:4fee71aa5bc6ed9d5f116327c04273e25ae31a3020386916905767ec4fc5317e", - "sha256:70024e02197337533eef7b85b068212420f950319cc8c580261963aefc75f811", - "sha256:74782fbd4d4f87ff04159e986886931456a1894c61229be9eaf4de6f6e44b99e", - "sha256:940532b111b1952befd7db542c370887a8611660d2b9becff75d39355303d82d", - "sha256:cb1f2f5e426dc9f07a7681419fe39cee823bb74f723f36f70399123f439e9b20", - "sha256:dbbb2379c19ed6042e8f11f2a2c66d39cceb8aeace421bfc29d085d93eda3689", - "sha256:e3a057b7a64f1222b56e47bcff5e4b94c4f61faac04c7c4ecb1985e18caa3994", - "sha256:e9f45bd5b92c7974e59bcd2dcc8631a6b6cc380a904725fce7bc08872e691615" + "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97", + "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76", + "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2", + "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648", + "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf", + "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f", + "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2", + "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee", + "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d", + "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c", + "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a" ], "index": "pypi", - "version": "==5.3" + "version": "==5.3.1" }, "requests": { "hashes": [ @@ -446,11 +447,11 @@ }, "sentry-sdk": { "hashes": [ - "sha256:480eee754e60bcae983787a9a13bc8f155a111aef199afaa4f289d6a76aa622a", - "sha256:a920387dc3ee252a66679d0afecd34479fb6fc52c2bc20763793ed69e5b0dcc0" + "sha256:23808d571d2461a4ce3784ec12bbee5bdb8c026c143fe79d36cef8a6d653e71f", + "sha256:bb90a4e19c7233a580715fc986cc44be2c48fc10b31e71580a2037e1c94b6950" ], "index": "pypi", - "version": "==0.14.2" + "version": "==0.14.3" }, "six": { "hashes": [ @@ -475,11 +476,11 @@ }, "sphinx": { "hashes": [ - "sha256:776ff8333181138fae52df65be733127539623bb46cc692e7fa0fcfc80d7aa88", - "sha256:ca762da97c3b5107cbf0ab9e11d3ec7ab8d3c31377266fd613b962ed971df709" + "sha256:b4c750d546ab6d7e05bdff6ac24db8ae3e8b8253a3569b754e445110a0a12b66", + "sha256:fc312670b56cb54920d6cc2ced455a22a547910de10b3142276495ced49231cb" ], "index": "pypi", - "version": "==2.4.3" + "version": "==2.4.4" }, "sphinxcontrib-applehelp": { "hashes": [ @@ -595,13 +596,6 @@ ], "version": "==19.3.0" }, - "certifi": { - "hashes": [ - "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3", - "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f" - ], - "version": "==2019.11.28" - }, "cfgv": { "hashes": [ "sha256:1ccf53320421aeeb915275a196e23b3b8ae87dea8ac6698b1638001d4a486d53", @@ -609,56 +603,42 @@ ], "version": "==3.1.0" }, - "chardet": { - "hashes": [ - "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", - "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" - ], - "version": "==3.0.4" - }, - "click": { - "hashes": [ - "sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13", - "sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7" - ], - "version": "==7.0" - }, "coverage": { "hashes": [ - "sha256:15cf13a6896048d6d947bf7d222f36e4809ab926894beb748fc9caa14605d9c3", - "sha256:1daa3eceed220f9fdb80d5ff950dd95112cd27f70d004c7918ca6dfc6c47054c", - "sha256:1e44a022500d944d42f94df76727ba3fc0a5c0b672c358b61067abb88caee7a0", - "sha256:25dbf1110d70bab68a74b4b9d74f30e99b177cde3388e07cc7272f2168bd1477", - "sha256:3230d1003eec018ad4a472d254991e34241e0bbd513e97a29727c7c2f637bd2a", - "sha256:3dbb72eaeea5763676a1a1efd9b427a048c97c39ed92e13336e726117d0b72bf", - "sha256:5012d3b8d5a500834783689a5d2292fe06ec75dc86ee1ccdad04b6f5bf231691", - "sha256:51bc7710b13a2ae0c726f69756cf7ffd4362f4ac36546e243136187cfcc8aa73", - "sha256:527b4f316e6bf7755082a783726da20671a0cc388b786a64417780b90565b987", - "sha256:722e4557c8039aad9592c6a4213db75da08c2cd9945320220634f637251c3894", - "sha256:76e2057e8ffba5472fd28a3a010431fd9e928885ff480cb278877c6e9943cc2e", - "sha256:77afca04240c40450c331fa796b3eab6f1e15c5ecf8bf2b8bee9706cd5452fef", - "sha256:7afad9835e7a651d3551eab18cbc0fdb888f0a6136169fbef0662d9cdc9987cf", - "sha256:9bea19ac2f08672636350f203db89382121c9c2ade85d945953ef3c8cf9d2a68", - "sha256:a8b8ac7876bc3598e43e2603f772d2353d9931709345ad6c1149009fd1bc81b8", - "sha256:b0840b45187699affd4c6588286d429cd79a99d509fe3de0f209594669bb0954", - "sha256:b26aaf69713e5674efbde4d728fb7124e429c9466aeaf5f4a7e9e699b12c9fe2", - "sha256:b63dd43f455ba878e5e9f80ba4f748c0a2156dde6e0e6e690310e24d6e8caf40", - "sha256:be18f4ae5a9e46edae3f329de2191747966a34a3d93046dbdf897319923923bc", - "sha256:c312e57847db2526bc92b9bfa78266bfbaabac3fdcd751df4d062cd4c23e46dc", - "sha256:c60097190fe9dc2b329a0eb03393e2e0829156a589bd732e70794c0dd804258e", - "sha256:c62a2143e1313944bf4a5ab34fd3b4be15367a02e9478b0ce800cb510e3bbb9d", - "sha256:cc1109f54a14d940b8512ee9f1c3975c181bbb200306c6d8b87d93376538782f", - "sha256:cd60f507c125ac0ad83f05803063bed27e50fa903b9c2cfee3f8a6867ca600fc", - "sha256:d513cc3db248e566e07a0da99c230aca3556d9b09ed02f420664e2da97eac301", - "sha256:d649dc0bcace6fcdb446ae02b98798a856593b19b637c1b9af8edadf2b150bea", - "sha256:d7008a6796095a79544f4da1ee49418901961c97ca9e9d44904205ff7d6aa8cb", - "sha256:da93027835164b8223e8e5af2cf902a4c80ed93cb0909417234f4a9df3bcd9af", - "sha256:e69215621707119c6baf99bda014a45b999d37602cb7043d943c76a59b05bf52", - "sha256:ea9525e0fef2de9208250d6c5aeeee0138921057cd67fcef90fbed49c4d62d37", - "sha256:fca1669d464f0c9831fd10be2eef6b86f5ebd76c724d1e0706ebdff86bb4adf0" - ], - "index": "pypi", - "version": "==5.0.3" + "sha256:03f630aba2b9b0d69871c2e8d23a69b7fe94a1e2f5f10df5049c0df99db639a0", + "sha256:046a1a742e66d065d16fb564a26c2a15867f17695e7f3d358d7b1ad8a61bca30", + "sha256:0a907199566269e1cfa304325cc3b45c72ae341fbb3253ddde19fa820ded7a8b", + "sha256:165a48268bfb5a77e2d9dbb80de7ea917332a79c7adb747bd005b3a07ff8caf0", + "sha256:1b60a95fc995649464e0cd48cecc8288bac5f4198f21d04b8229dc4097d76823", + "sha256:1f66cf263ec77af5b8fe14ef14c5e46e2eb4a795ac495ad7c03adc72ae43fafe", + "sha256:2e08c32cbede4a29e2a701822291ae2bc9b5220a971bba9d1e7615312efd3037", + "sha256:3844c3dab800ca8536f75ae89f3cf566848a3eb2af4d9f7b1103b4f4f7a5dad6", + "sha256:408ce64078398b2ee2ec08199ea3fcf382828d2f8a19c5a5ba2946fe5ddc6c31", + "sha256:443be7602c790960b9514567917af538cac7807a7c0c0727c4d2bbd4014920fd", + "sha256:4482f69e0701139d0f2c44f3c395d1d1d37abd81bfafbf9b6efbe2542679d892", + "sha256:4a8a259bf990044351baf69d3b23e575699dd60b18460c71e81dc565f5819ac1", + "sha256:513e6526e0082c59a984448f4104c9bf346c2da9961779ede1fc458e8e8a1f78", + "sha256:5f587dfd83cb669933186661a351ad6fc7166273bc3e3a1531ec5c783d997aac", + "sha256:62061e87071497951155cbccee487980524d7abea647a1b2a6eb6b9647df9006", + "sha256:641e329e7f2c01531c45c687efcec8aeca2a78a4ff26d49184dce3d53fc35014", + "sha256:65a7e00c00472cd0f59ae09d2fb8a8aaae7f4a0cf54b2b74f3138d9f9ceb9cb2", + "sha256:6ad6ca45e9e92c05295f638e78cd42bfaaf8ee07878c9ed73e93190b26c125f7", + "sha256:73aa6e86034dad9f00f4bbf5a666a889d17d79db73bc5af04abd6c20a014d9c8", + "sha256:7c9762f80a25d8d0e4ab3cb1af5d9dffbddb3ee5d21c43e3474c84bf5ff941f7", + "sha256:85596aa5d9aac1bf39fe39d9fa1051b0f00823982a1de5766e35d495b4a36ca9", + "sha256:86a0ea78fd851b313b2e712266f663e13b6bc78c2fb260b079e8b67d970474b1", + "sha256:8a620767b8209f3446197c0e29ba895d75a1e272a36af0786ec70fe7834e4307", + "sha256:922fb9ef2c67c3ab20e22948dcfd783397e4c043a5c5fa5ff5e9df5529074b0a", + "sha256:9fad78c13e71546a76c2f8789623eec8e499f8d2d799f4b4547162ce0a4df435", + "sha256:a37c6233b28e5bc340054cf6170e7090a4e85069513320275a4dc929144dccf0", + "sha256:c3fc325ce4cbf902d05a80daa47b645d07e796a80682c1c5800d6ac5045193e5", + "sha256:cda33311cb9fb9323958a69499a667bd728a39a7aa4718d7622597a44c4f1441", + "sha256:db1d4e38c9b15be1521722e946ee24f6db95b189d1447fa9ff18dd16ba89f732", + "sha256:eda55e6e9ea258f5e4add23bcf33dc53b2c319e70806e180aecbff8d90ea24de", + "sha256:f372cdbb240e09ee855735b9d85e7f50730dcfb6296b74b95a3e5dea0615c4c1" + ], + "index": "pypi", + "version": "==5.0.4" }, "distlib": { "hashes": [ @@ -666,21 +646,6 @@ ], "version": "==0.3.0" }, - "dodgy": { - "hashes": [ - "sha256:28323cbfc9352139fdd3d316fa17f325cc0e9ac74438cbba51d70f9b48f86c3a", - "sha256:51f54c0fd886fa3854387f354b19f429d38c04f984f38bc572558b703c0542a6" - ], - "index": "pypi", - "version": "==0.2.1" - }, - "dparse": { - "hashes": [ - "sha256:00a5fdfa900629e5159bf3600d44905b333f4059a3366f28e0dbd13eeab17b19", - "sha256:cef95156fa0adedaf042cd42f9990974bec76f25dfeca4dc01f381a243d5aa5b" - ], - "version": "==0.4.1" - }, "entrypoints": { "hashes": [ "sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19", @@ -752,11 +717,11 @@ }, "flake8-tidy-imports": { "hashes": [ - "sha256:8aa34384b45137d4cf33f5818b8e7897dc903b1d1e10a503fa7dd193a9a710ba", - "sha256:b26461561bcc80e8012e46846630ecf0aaa59314f362a94cb7800dfdb32fa413" + "sha256:5b6e75cec6d751e66534c522fbdce7dac1c2738b1216b0f6b10453995932e188", + "sha256:cf26fbb3ab31a398f265d53b6f711d80006450c19221e41b2b7b0e0b14ac39c5" ], "index": "pypi", - "version": "==4.0.0" + "version": "==4.0.1" }, "flake8-todo": { "hashes": [ @@ -767,17 +732,10 @@ }, "identify": { "hashes": [ - "sha256:1222b648251bdcb8deb240b294f450fbf704c7984e08baa92507e4ea10b436d5", - "sha256:d824ebe21f38325c771c41b08a95a761db1982f1fc0eee37c6c97df3f1636b96" + "sha256:a7577a1f55cee1d21953a5cf11a3c839ab87f5ef909a4cba6cf52ed72b4c6059", + "sha256:ab246293e6585a1c6361a505b68d5b501a0409310932b7de2c2ead667b564d89" ], - "version": "==1.4.11" - }, - "idna": { - "hashes": [ - "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb", - "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa" - ], - "version": "==2.9" + "version": "==1.4.13" }, "mccabe": { "hashes": [ @@ -792,28 +750,21 @@ ], "version": "==1.3.5" }, - "packaging": { - "hashes": [ - "sha256:170748228214b70b672c581a3dd610ee51f733018650740e98c7df862a583f73", - "sha256:e665345f9eef0c621aa0bf2f8d78cf6d21904eef16a93f020240b704a57f1334" - ], - "version": "==20.1" - }, "pep8-naming": { "hashes": [ - "sha256:45f330db8fcfb0fba57458c77385e288e7a3be1d01e8ea4268263ef677ceea5f", - "sha256:a33d38177056321a167decd6ba70b890856ba5025f0a8eca6a3eda607da93caf" + "sha256:5d9f1056cb9427ce344e98d1a7f5665710e2f20f748438e308995852cfa24164", + "sha256:f3b4a5f9dd72b991bf7d8e2a341d2e1aa3a884a769b5aaac4f56825c1763bf3a" ], "index": "pypi", - "version": "==0.9.1" + "version": "==0.10.0" }, "pre-commit": { "hashes": [ - "sha256:09ebe467f43ce24377f8c2f200fe3cd2570d328eb2ce0568c8e96ce19da45fa6", - "sha256:f8d555e31e2051892c7f7b3ad9f620bd2c09271d87e9eedb2ad831737d6211eb" + "sha256:487c675916e6f99d355ec5595ad77b325689d423ef4839db1ed2f02f639c9522", + "sha256:c0aa11bce04a7b46c5544723aedf4e81a4d5f64ad1205a30a9ea12d5e81969e1" ], "index": "pypi", - "version": "==2.1.1" + "version": "==2.2.0" }, "pycodestyle": { "hashes": [ @@ -836,45 +787,22 @@ ], "version": "==2.1.1" }, - "pyparsing": { - "hashes": [ - "sha256:4c830582a84fb022400b85429791bc551f1f4871c33f23e44f353119e92f969f", - "sha256:c342dccb5250c08d45fd6f8b4a559613ca603b57498511740e65cd11a2e7dcec" - ], - "version": "==2.4.6" - }, "pyyaml": { "hashes": [ - "sha256:059b2ee3194d718896c0ad077dd8c043e5e909d9180f387ce42012662a4946d6", - "sha256:1cf708e2ac57f3aabc87405f04b86354f66799c8e62c28c5fc5f88b5521b2dbf", - "sha256:24521fa2890642614558b492b473bee0ac1f8057a7263156b02e8b14c88ce6f5", - "sha256:4fee71aa5bc6ed9d5f116327c04273e25ae31a3020386916905767ec4fc5317e", - "sha256:70024e02197337533eef7b85b068212420f950319cc8c580261963aefc75f811", - "sha256:74782fbd4d4f87ff04159e986886931456a1894c61229be9eaf4de6f6e44b99e", - "sha256:940532b111b1952befd7db542c370887a8611660d2b9becff75d39355303d82d", - "sha256:cb1f2f5e426dc9f07a7681419fe39cee823bb74f723f36f70399123f439e9b20", - "sha256:dbbb2379c19ed6042e8f11f2a2c66d39cceb8aeace421bfc29d085d93eda3689", - "sha256:e3a057b7a64f1222b56e47bcff5e4b94c4f61faac04c7c4ecb1985e18caa3994", - "sha256:e9f45bd5b92c7974e59bcd2dcc8631a6b6cc380a904725fce7bc08872e691615" + "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97", + "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76", + "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2", + "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648", + "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf", + "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f", + "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2", + "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee", + "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d", + "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c", + "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a" ], "index": "pypi", - "version": "==5.3" - }, - "requests": { - "hashes": [ - "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee", - "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6" - ], - "index": "pypi", - "version": "==2.23.0" - }, - "safety": { - "hashes": [ - "sha256:0a3a8a178a9c96242b224f033ee8d1d130c0448b0e6622d12deaf37f6c3b4e59", - "sha256:5059f3ffab3648330548ea9c7403405bbfaf085b11235770825d14c58f24cb78" - ], - "index": "pypi", - "version": "==1.8.5" + "version": "==5.3.1" }, "six": { "hashes": [ @@ -905,19 +833,12 @@ "index": "pypi", "version": "==3.0.2" }, - "urllib3": { - "hashes": [ - "sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc", - "sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc" - ], - "version": "==1.25.8" - }, "virtualenv": { "hashes": [ - "sha256:30ea90b21dabd11da5f509710ad3be2ae47d40ccbc717dfdd2efe4367c10f598", - "sha256:4a36a96d785428278edd389d9c36d763c5755844beb7509279194647b1ef47f1" + "sha256:87831f1070534b636fea2241dd66f3afe37ac9041bcca6d0af3215cdcfbf7d82", + "sha256:f3128d882383c503003130389bf892856341c1da12c881ae24d6358c82561b55" ], - "version": "==20.0.7" + "version": "==20.0.13" } } } -- cgit v1.2.3 From 02e230ee3e3964a1eff891b493e1919cbb2f52be Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 25 Mar 2020 12:07:10 -0700 Subject: Snekbox: fix re-eval when '!eval' is removed from edited message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous parsing method was naïve in assuming there would always be something preceding the code (e.g. the '!eval' command invocation) delimited by a space. Now it will only split if it's sure the eval command was used in the edited message. --- bot/cogs/snekbox.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index cff7c5786..454836921 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -232,7 +232,7 @@ class Snekbox(Cog): timeout=10 ) - code = new_message.content.split(' ', maxsplit=1)[1] + code = await self.get_code(new_message) await ctx.message.clear_reactions() with contextlib.suppress(HTTPException): await response.delete() @@ -243,6 +243,26 @@ class Snekbox(Cog): return code + async def get_code(self, message: Message) -> Optional[str]: + """ + Return the code from `message` to be evaluated. + + If the message is an invocation of the eval command, return the first argument or None if it + doesn't exist. Otherwise, return the full content of the message. + """ + log.trace(f"Getting context for message {message.id}.") + new_ctx = await self.bot.get_context(message) + + if new_ctx.command is self.eval_command: + log.trace(f"Message {message.id} invokes eval command.") + split = message.content.split(maxsplit=1) + code = split[1] if len(split) > 1 else None + else: + log.trace(f"Message {message.id} does not invoke eval command.") + code = message.content + + return code + @command(name="eval", aliases=("e",)) @guild_only() @in_channel(Channels.bot_commands, hidden_channels=(Channels.esoteric,), bypass_roles=EVAL_ROLES) -- cgit v1.2.3 From 430c616ec4ec60a5ddb1e66d3aacc622c9a78ae6 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 25 Mar 2020 12:21:57 -0700 Subject: Snekbox tests: test `get_code` Should return 1st arg (or None) if eval cmd in message, otherwise return full content. --- tests/bot/cogs/test_snekbox.py | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index fd9468829..1fad6904b 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -3,9 +3,11 @@ import logging import unittest from unittest.mock import AsyncMock, MagicMock, Mock, call, patch +from discord.ext import commands + +from bot import constants from bot.cogs import snekbox from bot.cogs.snekbox import Snekbox -from bot.constants import URLs from tests.helpers import MockBot, MockContext, MockMessage, MockReaction, MockUser @@ -23,7 +25,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(await self.cog.post_eval("import random"), "return") self.bot.http_session.post.assert_called_with( - URLs.snekbox_eval_api, + constants.URLs.snekbox_eval_api, json={"input": "import random"}, raise_for_status=True ) @@ -43,10 +45,10 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): self.assertEqual( await self.cog.upload_output("My awesome output"), - URLs.paste_service.format(key=key) + constants.URLs.paste_service.format(key=key) ) self.bot.http_session.post.assert_called_with( - URLs.paste_service.format(key="documents"), + constants.URLs.paste_service.format(key="documents"), data="My awesome output", raise_for_status=True ) @@ -302,6 +304,32 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(actual, None) ctx.message.clear_reactions.assert_called_once() + async def test_get_code(self): + """Should return 1st arg (or None) if eval cmd in message, otherwise return full content.""" + prefix = constants.Bot.prefix + subtests = ( + (self.cog.eval_command, f"{prefix}{self.cog.eval_command.name} print(1)", "print(1)"), + (self.cog.eval_command, f"{prefix}{self.cog.eval_command.name}", None), + (MagicMock(spec=commands.Command), f"{prefix}tags get foo"), + (None, "print(123)") + ) + + for command, content, *expected_code in subtests: + if not expected_code: + expected_code = content + else: + [expected_code] = expected_code + + with self.subTest(content=content, expected_code=expected_code): + self.bot.get_context.reset_mock() + self.bot.get_context.return_value = MockContext(command=command) + message = MockMessage(content=content) + + actual_code = await self.cog.get_code(message) + + self.bot.get_context.assert_awaited_once_with(message) + self.assertEqual(actual_code, expected_code) + def test_predicate_eval_message_edit(self): """Test the predicate_eval_message_edit function.""" msg0 = MockMessage(id=1, content='abc') -- cgit v1.2.3 From c3e9a290a93c978a4dfec3ab121a0e45147855c8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 25 Mar 2020 14:08:34 -0700 Subject: Snekbox tests: use `get_code` in `test_continue_eval_does_continue` --- tests/bot/cogs/test_snekbox.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index 1fad6904b..1dec0ccaf 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -1,7 +1,7 @@ import asyncio import logging import unittest -from unittest.mock import AsyncMock, MagicMock, Mock, call, patch +from unittest.mock import AsyncMock, MagicMock, Mock, call, create_autospec, patch from discord.ext import commands @@ -281,11 +281,14 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): """Test that the continue_eval function does continue if required conditions are met.""" ctx = MockContext(message=MockMessage(add_reaction=AsyncMock(), clear_reactions=AsyncMock())) response = MockMessage(delete=AsyncMock()) - new_msg = MockMessage(content='!e NewCode') + new_msg = MockMessage() self.bot.wait_for.side_effect = ((None, new_msg), None) + expected = "NewCode" + self.cog.get_code = create_autospec(self.cog.get_code, spec_set=True, return_value=expected) actual = await self.cog.continue_eval(ctx, response) - self.assertEqual(actual, 'NewCode') + self.cog.get_code.assert_awaited_once_with(new_msg) + self.assertEqual(actual, expected) self.bot.wait_for.assert_has_awaits( ( call('message_edit', check=partial_mock(snekbox.predicate_eval_message_edit, ctx), timeout=10), -- cgit v1.2.3 From ee7cfbfca1b23408d7cb3f603498347fcef00c86 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Thu, 26 Mar 2020 09:22:36 +0100 Subject: Change Alias warnings to info Stuff like "{name} tried to run {command}" and "{command} could not be found" was set as a warning, and so Sentry issues were being created for these. Our rule of thumb is that only actionable things should be warnings. Changed these to Info logs. --- bot/cogs/alias.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py index 0b800575f..9001e18f0 100644 --- a/bot/cogs/alias.py +++ b/bot/cogs/alias.py @@ -26,9 +26,9 @@ class Alias (Cog): log.debug(f"{cmd_name} was invoked through an alias") cmd = self.bot.get_command(cmd_name) if not cmd: - return log.warning(f'Did not find command "{cmd_name}" to invoke.') + return log.info(f'Did not find command "{cmd_name}" to invoke.') elif not await cmd.can_run(ctx): - return log.warning( + return log.info( f'{str(ctx.author)} tried to run the command "{cmd_name}"' ) -- cgit v1.2.3 From 4d33b9f863bb54e69b9530a1ee05e4068cafa9a6 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Thu, 26 Mar 2020 10:56:01 -0400 Subject: Initial pass on log severity reduction With the updated definition on logging levels, there are a few events that were issuing logs at too high of a level. This also includes some kaizening of existing log messages. --- bot/bot.py | 4 ++-- bot/cogs/alias.py | 2 +- bot/cogs/bot.py | 1 - bot/cogs/moderation/scheduler.py | 2 +- bot/cogs/moderation/superstarify.py | 4 ++-- bot/cogs/snekbox.py | 2 +- bot/converters.py | 2 +- 7 files changed, 8 insertions(+), 9 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index 950ac6751..3e1b31342 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -77,7 +77,7 @@ class Bot(commands.Bot): # Its __del__ does send a warning but it doesn't always show up for some reason. if self._connector and not self._connector._closed: - log.warning( + log.info( "The previous connector was not closed; it will remain open and be overwritten" ) @@ -94,7 +94,7 @@ class Bot(commands.Bot): # Its __del__ does send a warning but it doesn't always show up for some reason. if self.http_session and not self.http_session.closed: - log.warning( + log.info( "The previous session was not closed; it will remain open and be overwritten" ) diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py index 9001e18f0..55c7efe65 100644 --- a/bot/cogs/alias.py +++ b/bot/cogs/alias.py @@ -29,7 +29,7 @@ class Alias (Cog): return log.info(f'Did not find command "{cmd_name}" to invoke.') elif not await cmd.can_run(ctx): return log.info( - f'{str(ctx.author)} tried to run the command "{cmd_name}"' + f'{str(ctx.author)} tried to run the command "{cmd_name}" but lacks permission.' ) await ctx.invoke(cmd, *args, **kwargs) diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index f17135877..e897b30ff 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -67,7 +67,6 @@ class BotCog(Cog, name="Bot"): icon_url=URLs.bot_avatar ) - log.info(f"{ctx.author} called !about. Returning information about the bot.") await ctx.send(embed=embed) @command(name='echo', aliases=('print',)) diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index f0b6b2c48..917697be9 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -222,7 +222,7 @@ class InfractionScheduler(Scheduler): # If multiple active infractions were found, mark them as inactive in the database # and cancel their expiration tasks. if len(response) > 1: - log.warning( + log.info( f"Found more than one active {infr_type} infraction for user {user.id}; " "deactivating the extra active infractions too." ) diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py index 893cb7f13..ca3dc4202 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/cogs/moderation/superstarify.py @@ -59,7 +59,7 @@ class Superstarify(InfractionScheduler, Cog): return # Nick change was triggered by this event. Ignore. log.info( - f"{after.display_name} is currently in superstar-prison. " + f"{after.display_name} ({after.id}) tried to escape superstar prison. " f"Changing the nick back to {before.display_name}." ) await after.edit( @@ -80,7 +80,7 @@ class Superstarify(InfractionScheduler, Cog): ) if not notified: - log.warning("Failed to DM user about why they cannot change their nickname.") + log.info("Failed to DM user about why they cannot change their nickname.") @Cog.listener() async def on_member_join(self, member: Member) -> None: diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index cff7c5786..b65b146ea 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -281,7 +281,7 @@ class Snekbox(Cog): code = await self.continue_eval(ctx, response) if not code: break - log.info(f"Re-evaluating message {ctx.message.id}") + log.info(f"Re-evaluating code from message {ctx.message.id}:\n{code}") def predicate_eval_message_edit(ctx: Context, old_msg: Message, new_msg: Message) -> bool: diff --git a/bot/converters.py b/bot/converters.py index 1945e1da3..98f4e33c8 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -323,7 +323,7 @@ class FetchedUser(UserConverter): except discord.HTTPException as e: # If the Discord error isn't `Unknown user`, return a proxy instead if e.code != 10013: - log.warning(f"Failed to fetch user, returning a proxy instead: status {e.status}") + log.info(f"Failed to fetch user, returning a proxy instead: status {e.status}") return proxy_user(arg) log.debug(f"Failed to fetch user {arg}: user does not exist.") -- cgit v1.2.3 From e88bb946fea8c4bc861f17772f8aca28f99be512 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 27 Mar 2020 11:22:34 -0700 Subject: Filtering: merge the word and token watch filters The only difference was the automatic addition of word boundaries. Otherwise, they shared a lot of code. The regex lists were kept separate in the config to retain the convenience of word boundaries automatically being added. * Rename filter to `watch_regex` * Expand spoilers for both words and tokens * Ignore URLs for both words and tokens --- bot/cogs/filtering.py | 56 +++++++++++++++++---------------------------------- bot/constants.py | 3 +-- config-default.yml | 3 +-- 3 files changed, 21 insertions(+), 41 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 6651d38e4..3f3dbb853 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -38,6 +38,7 @@ WORD_WATCHLIST_PATTERNS = [ TOKEN_WATCHLIST_PATTERNS = [ re.compile(fr'{expression}', flags=re.IGNORECASE) for expression in Filter.token_watchlist ] +WATCHLIST_PATTERNS = WORD_WATCHLIST_PATTERNS + TOKEN_WATCHLIST_PATTERNS def expand_spoilers(text: str) -> str: @@ -88,24 +89,18 @@ class Filtering(Cog): f"Your URL has been removed because it matched a blacklisted domain. {staff_mistake_str}" ) }, + "watch_regex": { + "enabled": Filter.watch_regex, + "function": self._has_watch_regex_match, + "type": "watchlist", + "content_only": True, + }, "watch_rich_embeds": { "enabled": Filter.watch_rich_embeds, "function": self._has_rich_embed, "type": "watchlist", "content_only": False, }, - "watch_words": { - "enabled": Filter.watch_words, - "function": self._has_watchlist_words, - "type": "watchlist", - "content_only": True, - }, - "watch_tokens": { - "enabled": Filter.watch_tokens, - "function": self._has_watchlist_tokens, - "type": "watchlist", - "content_only": True, - }, } @property @@ -191,8 +186,8 @@ class Filtering(Cog): else: channel_str = f"in {msg.channel.mention}" - # Word and match stats for watch_words and watch_tokens - if filter_name in ("watch_words", "watch_tokens"): + # Word and match stats for watch_regex + if filter_name == "watch_regex": surroundings = match.string[max(match.start() - 10, 0): match.end() + 10] message_content = ( f"**Match:** '{match[0]}'\n" @@ -248,37 +243,24 @@ class Filtering(Cog): break # We don't want multiple filters to trigger @staticmethod - async def _has_watchlist_words(text: str) -> Union[bool, re.Match]: + async def _has_watch_regex_match(text: str) -> Union[bool, re.Match]: """ - Returns True if the text contains one of the regular expressions from the word_watchlist in our filter config. + Return True if `text` matches any regex from `word_watchlist` or `token_watchlist` configs. - Only matches words with boundaries before and after the expression. + `word_watchlist`'s patterns are placed between word boundaries while `token_watchlist` is + matched as-is. Spoilers are expanded, if any, and URLs are ignored. """ if SPOILER_RE.search(text): text = expand_spoilers(text) - for regex_pattern in WORD_WATCHLIST_PATTERNS: - match = regex_pattern.search(text) - if match: - return match # match objects always have a boolean value of True - return False - - @staticmethod - async def _has_watchlist_tokens(text: str) -> Union[bool, re.Match]: - """ - Returns True if the text contains one of the regular expressions from the token_watchlist in our filter config. + # Make sure it's not a URL + if URL_RE.search(text): + return False - This will match the expression even if it does not have boundaries before and after. - """ - for regex_pattern in TOKEN_WATCHLIST_PATTERNS: - match = regex_pattern.search(text) + for pattern in WATCHLIST_PATTERNS: + match = pattern.search(text) if match: - - # Make sure it's not a URL - if not URL_RE.search(text): - return match # match objects always have a boolean value of True - - return False + return match @staticmethod async def _has_urls(text: str) -> bool: diff --git a/bot/constants.py b/bot/constants.py index 14f8dc094..549e69c8f 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -206,9 +206,8 @@ class Filter(metaclass=YAMLGetter): filter_zalgo: bool filter_invites: bool filter_domains: bool + watch_regex: bool watch_rich_embeds: bool - watch_words: bool - watch_tokens: bool # Notifications are not expected for "watchlist" type filters notify_user_zalgo: bool diff --git a/config-default.yml b/config-default.yml index 5788d1e12..ef0ed970f 100644 --- a/config-default.yml +++ b/config-default.yml @@ -248,9 +248,8 @@ filter: filter_zalgo: false filter_invites: true filter_domains: true + watch_regex: true watch_rich_embeds: true - watch_words: true - watch_tokens: true # Notify user on filter? # Notifications are not expected for "watchlist" type filters -- cgit v1.2.3 From aba5c321a5bbdaa9f47791b2aee456caa566cd98 Mon Sep 17 00:00:00 2001 From: ks123 Date: Sat, 28 Mar 2020 10:54:03 +0200 Subject: (PEP Command): Hard-coded PEP 0 --- bot/cogs/utils.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 024141d62..db8f63ff4 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -40,6 +40,14 @@ If the implementation is easy to explain, it may be a good idea. Namespaces are one honking great idea -- let's do more of those! """ +PEP0_TITLE = "Index of Python Enhancement Proposals (PEPs)" +PEP0_INFO = { + "Status": "Active", + "Created": "13-Jul-2000", + "Type": "Informational" +} +PEP0_LINK = "https://www.python.org/dev/peps/" + class Utils(Cog): """A selection of utilities which don't have a clear category.""" @@ -59,6 +67,19 @@ class Utils(Cog): await ctx.invoke(self.bot.get_command("help"), "pep") return + # Handle PEP 0 directly due it's not available like other PEPs (use constants) + if pep_number == 0: + pep_embed = Embed( + title=f"**PEP 0 - {PEP0_TITLE}**", + description=f"[Link]({PEP0_LINK})" + ) + pep_embed.set_thumbnail(url="https://www.python.org/static/opengraph-icon-200x200.png") + for field, value in PEP0_INFO.items(): + pep_embed.add_field(name=field, value=value) + + await ctx.send(embed=pep_embed) + return + possible_extensions = ['.txt', '.rst'] found_pep = False for extension in possible_extensions: -- cgit v1.2.3 From f086fb2bc65f1031542d9bfae231a31ffeaa8a43 Mon Sep 17 00:00:00 2001 From: ks123 Date: Sat, 28 Mar 2020 17:16:18 +0200 Subject: (Webhook Detection): Created cog. --- bot/cogs/webhook_remover.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 bot/cogs/webhook_remover.py diff --git a/bot/cogs/webhook_remover.py b/bot/cogs/webhook_remover.py new file mode 100644 index 000000000..982359410 --- /dev/null +++ b/bot/cogs/webhook_remover.py @@ -0,0 +1,15 @@ +from discord.ext.commands import Cog + +from bot.bot import Bot + + +class WebhookRemover(Cog): + """Scan messages to detect Discord webhooks links.""" + + def __init__(self, bot: Bot): + self.bot = bot + + +def setup(bot: Bot) -> None: + """Load `WebhookRemover` cog.""" + bot.add_cog(WebhookRemover(bot)) -- cgit v1.2.3 From f4b5718225505b2b78e4cbf75c5599ab307455d2 Mon Sep 17 00:00:00 2001 From: ks123 Date: Sat, 28 Mar 2020 17:19:40 +0200 Subject: (Webhook Detection): Added webhook match regex. --- bot/cogs/webhook_remover.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/cogs/webhook_remover.py b/bot/cogs/webhook_remover.py index 982359410..49cf94de7 100644 --- a/bot/cogs/webhook_remover.py +++ b/bot/cogs/webhook_remover.py @@ -1,7 +1,11 @@ +import re + from discord.ext.commands import Cog from bot.bot import Bot +WEBHOOK_URL_RE = re.compile(r"discordapp\.com/api/webhooks/\d+/\S+/?") + class WebhookRemover(Cog): """Scan messages to detect Discord webhooks links.""" -- cgit v1.2.3 From e5c41faf826e4a29fd21986fc828034372b18863 Mon Sep 17 00:00:00 2001 From: ks123 Date: Sat, 28 Mar 2020 17:38:11 +0200 Subject: (Webhook Detection): Added cog loading to __main__.py, created `scan_message` helper function to detect Webhook URL. --- bot/__main__.py | 1 + bot/cogs/webhook_remover.py | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/bot/__main__.py b/bot/__main__.py index 3df477a6d..9e8b1bdce 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -64,6 +64,7 @@ bot.load_extension("bot.cogs.token_remover") bot.load_extension("bot.cogs.utils") bot.load_extension("bot.cogs.watchchannels") bot.load_extension("bot.cogs.wolfram") +bot.load_extension("bot.cogs.webhook_remover") # Apply `message_edited_at` patch if discord.py did not yet release a bug fix. if not hasattr(discord.message.Message, '_handle_edited_timestamp'): diff --git a/bot/cogs/webhook_remover.py b/bot/cogs/webhook_remover.py index 49cf94de7..a3025f19f 100644 --- a/bot/cogs/webhook_remover.py +++ b/bot/cogs/webhook_remover.py @@ -1,5 +1,6 @@ import re +from discord import Message from discord.ext.commands import Cog from bot.bot import Bot @@ -13,6 +14,14 @@ class WebhookRemover(Cog): def __init__(self, bot: Bot): self.bot = bot + async def scan_message(self, msg: Message) -> bool: + """Scan message content to detect Webhook URLs. Return `bool` about does this have webhook URL.""" + matches = WEBHOOK_URL_RE.search(msg.content) + if matches: + return True + else: + return False + def setup(bot: Bot) -> None: """Load `WebhookRemover` cog.""" -- cgit v1.2.3 From 7e34c5e62eeefe1f0b8a1bb7e03435b5d2998712 Mon Sep 17 00:00:00 2001 From: ks123 Date: Sat, 28 Mar 2020 17:41:35 +0200 Subject: (Webhook Detection): Added `ModLog` fetching property. --- bot/cogs/webhook_remover.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bot/cogs/webhook_remover.py b/bot/cogs/webhook_remover.py index a3025f19f..54222b007 100644 --- a/bot/cogs/webhook_remover.py +++ b/bot/cogs/webhook_remover.py @@ -4,6 +4,7 @@ from discord import Message from discord.ext.commands import Cog from bot.bot import Bot +from bot.cogs.moderation.modlog import ModLog WEBHOOK_URL_RE = re.compile(r"discordapp\.com/api/webhooks/\d+/\S+/?") @@ -14,6 +15,11 @@ class WebhookRemover(Cog): def __init__(self, bot: Bot): self.bot = bot + @property + def mod_log(self) -> ModLog: + """Get current instance of `ModLog`.""" + return self.bot.get_cog("ModLog") + async def scan_message(self, msg: Message) -> bool: """Scan message content to detect Webhook URLs. Return `bool` about does this have webhook URL.""" matches = WEBHOOK_URL_RE.search(msg.content) -- cgit v1.2.3 From 3a9494da375a7aedf5b2c8554ae1cdd0170ba7f1 Mon Sep 17 00:00:00 2001 From: ks123 Date: Sat, 28 Mar 2020 18:03:50 +0200 Subject: (Webhook Detection): Created `delete_and_respond` helper function to handle Webhook URLs. --- bot/cogs/webhook_remover.py | 43 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/bot/cogs/webhook_remover.py b/bot/cogs/webhook_remover.py index 54222b007..a19f9c196 100644 --- a/bot/cogs/webhook_remover.py +++ b/bot/cogs/webhook_remover.py @@ -1,13 +1,24 @@ +import logging import re -from discord import Message +from discord import Colour, Message from discord.ext.commands import Cog from bot.bot import Bot from bot.cogs.moderation.modlog import ModLog +from bot.constants import Channels, Colours, Event, Icons WEBHOOK_URL_RE = re.compile(r"discordapp\.com/api/webhooks/\d+/\S+/?") +ALERT_MESSAGE_TEMPLATE = ( + "{user}, looks like you posted Discord Webhook URL to chat. " + "I removed this, but we **strongly** suggest to change this now " + "to prevent any spam abuse to channel. Please avoid doing this in future. " + "If you believe this was mistake, please let us know." +) + +log = logging.getLogger(__name__) + class WebhookRemover(Cog): """Scan messages to detect Discord webhooks links.""" @@ -21,13 +32,41 @@ class WebhookRemover(Cog): return self.bot.get_cog("ModLog") async def scan_message(self, msg: Message) -> bool: - """Scan message content to detect Webhook URLs. Return `bool` about does this have webhook URL.""" + """Scan message content to detect Webhook URLs. Return `bool` about does this have Discord webhook URL.""" matches = WEBHOOK_URL_RE.search(msg.content) if matches: return True else: return False + async def delete_and_respond(self, msg: Message, url: str) -> None: + """Delete message and show warning when message contains Discord Webhook URL.""" + # Create URL that will be sent to logs, remove token + parts = url.split("/") + parts[-1] = "xxx" + url = "/".join(parts) + + # Don't log this, due internal delete, not by user. Will make different entry. + self.mod_log.ignore(Event.message_delete, msg.id) + await msg.delete() + await msg.channel.send(ALERT_MESSAGE_TEMPLATE.format(user=msg.author.mention)) + + message = ( + f"{msg.author} ({msg.author.id}) posted Discord Webhook URL " + f"to {msg.channel}. Webhook URL was {url}" + ) + log.debug(message) + + # Send entry to moderation alerts. + await self.mod_log.send_log_message( + icon_url=Icons.token_removed, + colour=Colour(Colours.soft_red), + title="Discord Webhook URL removed!", + text=message, + thumbnail=msg.author.avatar_url_as(static_format="png"), + channel_id=Channels.mod_alerts + ) + def setup(bot: Bot) -> None: """Load `WebhookRemover` cog.""" -- cgit v1.2.3 From 3482471cd013bfc0102cc3b80c71e04dfc30349c Mon Sep 17 00:00:00 2001 From: ks123 Date: Sat, 28 Mar 2020 18:05:26 +0200 Subject: (Webhook Detection): Added URL returning to `scan_message` helper function. --- bot/cogs/webhook_remover.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bot/cogs/webhook_remover.py b/bot/cogs/webhook_remover.py index a19f9c196..d0d604bc7 100644 --- a/bot/cogs/webhook_remover.py +++ b/bot/cogs/webhook_remover.py @@ -1,5 +1,6 @@ import logging import re +import typing as t from discord import Colour, Message from discord.ext.commands import Cog @@ -31,13 +32,13 @@ class WebhookRemover(Cog): """Get current instance of `ModLog`.""" return self.bot.get_cog("ModLog") - async def scan_message(self, msg: Message) -> bool: + async def scan_message(self, msg: Message) -> t.Tuple[bool, t.Optional[str]]: """Scan message content to detect Webhook URLs. Return `bool` about does this have Discord webhook URL.""" matches = WEBHOOK_URL_RE.search(msg.content) if matches: - return True + return True, matches[0] else: - return False + return False, None async def delete_and_respond(self, msg: Message, url: str) -> None: """Delete message and show warning when message contains Discord Webhook URL.""" -- cgit v1.2.3 From 27efaf8414ec0211c0c1b3bba4b16a969eb01c0b Mon Sep 17 00:00:00 2001 From: ks123 Date: Sat, 28 Mar 2020 18:10:27 +0200 Subject: (Webhook Detection): Alert message formatting changes, added `on_message` listener. --- bot/cogs/webhook_remover.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/bot/cogs/webhook_remover.py b/bot/cogs/webhook_remover.py index d0d604bc7..d6569a72b 100644 --- a/bot/cogs/webhook_remover.py +++ b/bot/cogs/webhook_remover.py @@ -53,8 +53,8 @@ class WebhookRemover(Cog): await msg.channel.send(ALERT_MESSAGE_TEMPLATE.format(user=msg.author.mention)) message = ( - f"{msg.author} ({msg.author.id}) posted Discord Webhook URL " - f"to {msg.channel}. Webhook URL was {url}" + f"{msg.author} (`{msg.author.id}`) posted Discord Webhook URL " + f"to #{msg.channel}. Webhook URL was `{url}`" ) log.debug(message) @@ -68,6 +68,13 @@ class WebhookRemover(Cog): channel_id=Channels.mod_alerts ) + @Cog.listener() + async def on_message(self, msg: Message) -> None: + """Check is Discord Webhook URL in sent message.""" + is_url_in, url = await self.scan_message(msg) + if is_url_in: + await self.delete_and_respond(msg, url) + def setup(bot: Bot) -> None: """Load `WebhookRemover` cog.""" -- cgit v1.2.3 From 3f855231a3da94efe0e73448feaeb8f15d2799fc Mon Sep 17 00:00:00 2001 From: ks123 Date: Sat, 28 Mar 2020 18:57:03 +0200 Subject: (Webhook Detection): Added `on_message_edit` listener for Discord Webhooks detecting. --- bot/cogs/webhook_remover.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/bot/cogs/webhook_remover.py b/bot/cogs/webhook_remover.py index d6569a72b..1f758f8e6 100644 --- a/bot/cogs/webhook_remover.py +++ b/bot/cogs/webhook_remover.py @@ -75,6 +75,13 @@ class WebhookRemover(Cog): if is_url_in: await self.delete_and_respond(msg, url) + @Cog.listener() + async def on_message_edit(self, before: Message, after: Message) -> None: + """Check is Discord Webhook URL in new message content when message changed.""" + is_url_in, url = await self.scan_message(after) + if is_url_in: + await self.delete_and_respond(after, url) + def setup(bot: Bot) -> None: """Load `WebhookRemover` cog.""" -- cgit v1.2.3 From b2b9353c8b775ffa687b8bafc875786815b173ce Mon Sep 17 00:00:00 2001 From: ks123 Date: Sat, 28 Mar 2020 19:28:38 +0200 Subject: (Webhook Detection): Fixed order of cog loading. --- bot/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/__main__.py b/bot/__main__.py index 9e8b1bdce..8c3ae02e3 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -63,8 +63,8 @@ bot.load_extension("bot.cogs.tags") bot.load_extension("bot.cogs.token_remover") bot.load_extension("bot.cogs.utils") bot.load_extension("bot.cogs.watchchannels") -bot.load_extension("bot.cogs.wolfram") bot.load_extension("bot.cogs.webhook_remover") +bot.load_extension("bot.cogs.wolfram") # Apply `message_edited_at` patch if discord.py did not yet release a bug fix. if not hasattr(discord.message.Message, '_handle_edited_timestamp'): -- cgit v1.2.3 From 6a410521025299f0e1795cc9c6d756ff48caf20d Mon Sep 17 00:00:00 2001 From: ks123 Date: Sat, 28 Mar 2020 19:31:58 +0200 Subject: (Webhook Detection): Call `on_message` instead repeating code. --- bot/cogs/webhook_remover.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bot/cogs/webhook_remover.py b/bot/cogs/webhook_remover.py index 1f758f8e6..5fb676045 100644 --- a/bot/cogs/webhook_remover.py +++ b/bot/cogs/webhook_remover.py @@ -78,9 +78,7 @@ class WebhookRemover(Cog): @Cog.listener() async def on_message_edit(self, before: Message, after: Message) -> None: """Check is Discord Webhook URL in new message content when message changed.""" - is_url_in, url = await self.scan_message(after) - if is_url_in: - await self.delete_and_respond(after, url) + await self.on_message(after) def setup(bot: Bot) -> None: -- cgit v1.2.3 From 81d2cdd39316b834b6b1de36b81260c4ab8489f5 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Sat, 28 Mar 2020 13:36:28 -0400 Subject: Logging severity pass from review --- bot/bot.py | 4 ++-- bot/cogs/sync/syncers.py | 2 +- bot/utils/messages.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index 3e1b31342..950ac6751 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -77,7 +77,7 @@ class Bot(commands.Bot): # Its __del__ does send a warning but it doesn't always show up for some reason. if self._connector and not self._connector._closed: - log.info( + log.warning( "The previous connector was not closed; it will remain open and be overwritten" ) @@ -94,7 +94,7 @@ class Bot(commands.Bot): # Its __del__ does send a warning but it doesn't always show up for some reason. if self.http_session and not self.http_session.closed: - log.info( + log.warning( "The previous session was not closed; it will remain open and be overwritten" ) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index c7ce54d65..c9b3f0d40 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -131,7 +131,7 @@ class Syncer(abc.ABC): await message.edit(content=f':ok_hand: {mention}{self.name} sync will proceed.') return True else: - log.warning(f"The {self.name} syncer was aborted or timed out!") + log.trace(f"The {self.name} syncer was aborted or timed out!") await message.edit( content=f':warning: {mention}{self.name} sync aborted or timed out!' ) diff --git a/bot/utils/messages.py b/bot/utils/messages.py index a36edc774..e969ee590 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -92,7 +92,7 @@ async def send_attachments( elif link_large: large.append(attachment) else: - log.warning(f"{failure_msg} because it's too large.") + log.info(f"{failure_msg} because it's too large.") except HTTPException as e: if link_large and e.status == 413: large.append(attachment) -- cgit v1.2.3 From 2544670fa38cea1f53147307b6b1e1134265a74f Mon Sep 17 00:00:00 2001 From: ks123 Date: Sat, 28 Mar 2020 19:42:31 +0200 Subject: (Webhook Detection): Added grouping to RegEx compilation, removed unnecessary function `scan_message`, moved this content to `on_message` event. --- bot/cogs/webhook_remover.py | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/bot/cogs/webhook_remover.py b/bot/cogs/webhook_remover.py index 5fb676045..afa88ce89 100644 --- a/bot/cogs/webhook_remover.py +++ b/bot/cogs/webhook_remover.py @@ -1,6 +1,5 @@ import logging import re -import typing as t from discord import Colour, Message from discord.ext.commands import Cog @@ -9,7 +8,7 @@ from bot.bot import Bot from bot.cogs.moderation.modlog import ModLog from bot.constants import Channels, Colours, Event, Icons -WEBHOOK_URL_RE = re.compile(r"discordapp\.com/api/webhooks/\d+/\S+/?") +WEBHOOK_URL_RE = re.compile(r"(discordapp\.com/api/webhooks/)(\d+/)(\S+/?)") ALERT_MESSAGE_TEMPLATE = ( "{user}, looks like you posted Discord Webhook URL to chat. " @@ -32,14 +31,6 @@ class WebhookRemover(Cog): """Get current instance of `ModLog`.""" return self.bot.get_cog("ModLog") - async def scan_message(self, msg: Message) -> t.Tuple[bool, t.Optional[str]]: - """Scan message content to detect Webhook URLs. Return `bool` about does this have Discord webhook URL.""" - matches = WEBHOOK_URL_RE.search(msg.content) - if matches: - return True, matches[0] - else: - return False, None - async def delete_and_respond(self, msg: Message, url: str) -> None: """Delete message and show warning when message contains Discord Webhook URL.""" # Create URL that will be sent to logs, remove token @@ -71,9 +62,9 @@ class WebhookRemover(Cog): @Cog.listener() async def on_message(self, msg: Message) -> None: """Check is Discord Webhook URL in sent message.""" - is_url_in, url = await self.scan_message(msg) - if is_url_in: - await self.delete_and_respond(msg, url) + matches = WEBHOOK_URL_RE.search(msg.content) + if matches: + await self.delete_and_respond(msg, "".join(matches.groups()[:-1]) + "xxx") @Cog.listener() async def on_message_edit(self, before: Message, after: Message) -> None: -- cgit v1.2.3 From 3e342882a927298cea919c33678cd39c4a71c67e Mon Sep 17 00:00:00 2001 From: ks123 Date: Sat, 28 Mar 2020 19:44:25 +0200 Subject: (Webhook Detection): Removed unnecessary URL hiding in `delete_and_respond`. --- bot/cogs/webhook_remover.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/bot/cogs/webhook_remover.py b/bot/cogs/webhook_remover.py index afa88ce89..9f6243b3c 100644 --- a/bot/cogs/webhook_remover.py +++ b/bot/cogs/webhook_remover.py @@ -33,11 +33,6 @@ class WebhookRemover(Cog): async def delete_and_respond(self, msg: Message, url: str) -> None: """Delete message and show warning when message contains Discord Webhook URL.""" - # Create URL that will be sent to logs, remove token - parts = url.split("/") - parts[-1] = "xxx" - url = "/".join(parts) - # Don't log this, due internal delete, not by user. Will make different entry. self.mod_log.ignore(Event.message_delete, msg.id) await msg.delete() -- cgit v1.2.3 From 2532c55239a1563b34ed475bffa330e1670de6e0 Mon Sep 17 00:00:00 2001 From: ks123 Date: Sat, 28 Mar 2020 19:45:51 +0200 Subject: (Webhook Detection): Fixed docstrings. --- bot/cogs/webhook_remover.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/webhook_remover.py b/bot/cogs/webhook_remover.py index 9f6243b3c..cbece321d 100644 --- a/bot/cogs/webhook_remover.py +++ b/bot/cogs/webhook_remover.py @@ -56,14 +56,14 @@ class WebhookRemover(Cog): @Cog.listener() async def on_message(self, msg: Message) -> None: - """Check is Discord Webhook URL in sent message.""" + """Check if a Discord webhook URL is in `message`.""" matches = WEBHOOK_URL_RE.search(msg.content) if matches: await self.delete_and_respond(msg, "".join(matches.groups()[:-1]) + "xxx") @Cog.listener() async def on_message_edit(self, before: Message, after: Message) -> None: - """Check is Discord Webhook URL in new message content when message changed.""" + """Check if a Discord webhook URL is in the edited message `after`.""" await self.on_message(after) -- cgit v1.2.3 From bf20911cb7f9310450293e93babff9bea8a177f9 Mon Sep 17 00:00:00 2001 From: ks123 Date: Sat, 28 Mar 2020 20:24:28 +0200 Subject: (Webhook Detection): Renamed `url` variable to `redacted_url` to avoid confusion in `delete_and_respond` function. --- bot/cogs/webhook_remover.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/webhook_remover.py b/bot/cogs/webhook_remover.py index cbece321d..b4606eb59 100644 --- a/bot/cogs/webhook_remover.py +++ b/bot/cogs/webhook_remover.py @@ -31,7 +31,7 @@ class WebhookRemover(Cog): """Get current instance of `ModLog`.""" return self.bot.get_cog("ModLog") - async def delete_and_respond(self, msg: Message, url: str) -> None: + async def delete_and_respond(self, msg: Message, redacted_url: str) -> None: """Delete message and show warning when message contains Discord Webhook URL.""" # Don't log this, due internal delete, not by user. Will make different entry. self.mod_log.ignore(Event.message_delete, msg.id) @@ -40,7 +40,7 @@ class WebhookRemover(Cog): message = ( f"{msg.author} (`{msg.author.id}`) posted Discord Webhook URL " - f"to #{msg.channel}. Webhook URL was `{url}`" + f"to #{msg.channel}. Webhook URL was `{redacted_url}`" ) log.debug(message) -- cgit v1.2.3 From e955b83784c91c0144334b744f1d5e139a1d957f Mon Sep 17 00:00:00 2001 From: ks123 Date: Sat, 28 Mar 2020 20:28:24 +0200 Subject: (PEP Command): Fixed comment of explanation of PEP 0 different processing. --- bot/cogs/utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index db8f63ff4..f35ff0f03 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -67,7 +67,8 @@ class Utils(Cog): await ctx.invoke(self.bot.get_command("help"), "pep") return - # Handle PEP 0 directly due it's not available like other PEPs (use constants) + # Handle PEP 0 directly due it's static constant in PEPs GitHub repo in Python file, not .rst or .txt so it + # can't be accessed like other PEPs. if pep_number == 0: pep_embed = Embed( title=f"**PEP 0 - {PEP0_TITLE}**", -- cgit v1.2.3 From 608f5c5edec5804ba6dd546d25f63ce13b34b948 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 28 Mar 2020 12:54:26 -0700 Subject: Use debug log level instead of warning in `post_user` --- bot/cogs/moderation/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py index 5052b9048..3598f3b1f 100644 --- a/bot/cogs/moderation/utils.py +++ b/bot/cogs/moderation/utils.py @@ -38,7 +38,7 @@ async def post_user(ctx: Context, user: UserSnowflake) -> t.Optional[dict]: log.trace(f"Attempting to add user {user.id} to the database.") if not isinstance(user, (discord.Member, discord.User)): - log.warning("The user being added to the DB is not a Member or User object.") + log.debug("The user being added to the DB is not a Member or User object.") payload = { 'avatar_hash': getattr(user, 'avatar', 0), -- cgit v1.2.3 From bf18e1ca460427e5a973be0b15c51e1c7b5b6e60 Mon Sep 17 00:00:00 2001 From: Karlis S <45097959+ks129@users.noreply.github.com> Date: Sat, 28 Mar 2020 22:17:07 +0200 Subject: (Webhook Detection): Fixed grouping of regex, alert message content, docstrings, string formatting and URL hiding to show in logs. Co-Authored-By: Mark --- bot/cogs/webhook_remover.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/bot/cogs/webhook_remover.py b/bot/cogs/webhook_remover.py index b4606eb59..49692113d 100644 --- a/bot/cogs/webhook_remover.py +++ b/bot/cogs/webhook_remover.py @@ -8,13 +8,13 @@ from bot.bot import Bot from bot.cogs.moderation.modlog import ModLog from bot.constants import Channels, Colours, Event, Icons -WEBHOOK_URL_RE = re.compile(r"(discordapp\.com/api/webhooks/)(\d+/)(\S+/?)") +WEBHOOK_URL_RE = re.compile(r"((?:https?://)?discordapp\.com/api/webhooks/\d+/)\S+/?", re.I) ALERT_MESSAGE_TEMPLATE = ( - "{user}, looks like you posted Discord Webhook URL to chat. " - "I removed this, but we **strongly** suggest to change this now " - "to prevent any spam abuse to channel. Please avoid doing this in future. " - "If you believe this was mistake, please let us know." + "{user}, looks like you posted a Discord webhook URL. Therefore, your " + "message has been removed. Your webhook may have been **compromised** so " + "please re-create the webhook **immediately**. If you believe this was " + "mistake, please let us know." ) log = logging.getLogger(__name__) @@ -32,14 +32,14 @@ class WebhookRemover(Cog): return self.bot.get_cog("ModLog") async def delete_and_respond(self, msg: Message, redacted_url: str) -> None: - """Delete message and show warning when message contains Discord Webhook URL.""" + """Delete `msg` and send a warning that it contained the Discord webhook `redacted_url`.""" # Don't log this, due internal delete, not by user. Will make different entry. self.mod_log.ignore(Event.message_delete, msg.id) await msg.delete() await msg.channel.send(ALERT_MESSAGE_TEMPLATE.format(user=msg.author.mention)) message = ( - f"{msg.author} (`{msg.author.id}`) posted Discord Webhook URL " + f"{msg.author} (`{msg.author.id}`) posted a Discord webhook URL " f"to #{msg.channel}. Webhook URL was `{redacted_url}`" ) log.debug(message) @@ -48,7 +48,7 @@ class WebhookRemover(Cog): await self.mod_log.send_log_message( icon_url=Icons.token_removed, colour=Colour(Colours.soft_red), - title="Discord Webhook URL removed!", + title="Discord webhook URL removed!", text=message, thumbnail=msg.author.avatar_url_as(static_format="png"), channel_id=Channels.mod_alerts @@ -59,7 +59,7 @@ class WebhookRemover(Cog): """Check if a Discord webhook URL is in `message`.""" matches = WEBHOOK_URL_RE.search(msg.content) if matches: - await self.delete_and_respond(msg, "".join(matches.groups()[:-1]) + "xxx") + await self.delete_and_respond(msg, matches[1] + "xxx") @Cog.listener() async def on_message_edit(self, before: Message, after: Message) -> None: -- cgit v1.2.3 From cc153e052b765ddd8ee1494ad3eea2a552d9459c Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Sat, 28 Mar 2020 16:26:01 -0400 Subject: Increase syncer logging level --- bot/cogs/sync/syncers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index c9b3f0d40..003bf3727 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -131,7 +131,7 @@ class Syncer(abc.ABC): await message.edit(content=f':ok_hand: {mention}{self.name} sync will proceed.') return True else: - log.trace(f"The {self.name} syncer was aborted or timed out!") + log.info(f"The {self.name} syncer was aborted or timed out!") await message.edit( content=f':warning: {mention}{self.name} sync aborted or timed out!' ) -- cgit v1.2.3 From 317b5db5585ae72adf112508165fc6e161792948 Mon Sep 17 00:00:00 2001 From: ks123 Date: Sun, 29 Mar 2020 08:55:22 +0300 Subject: (PEP Command): Moved icon URL to constant instead hard-coded string. --- bot/cogs/utils.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index f35ff0f03..d15edd0a0 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -48,6 +48,8 @@ PEP0_INFO = { } PEP0_LINK = "https://www.python.org/dev/peps/" +ICON_URL = "https://www.python.org/static/opengraph-icon-200x200.png" + class Utils(Cog): """A selection of utilities which don't have a clear category.""" @@ -74,7 +76,7 @@ class Utils(Cog): title=f"**PEP 0 - {PEP0_TITLE}**", description=f"[Link]({PEP0_LINK})" ) - pep_embed.set_thumbnail(url="https://www.python.org/static/opengraph-icon-200x200.png") + pep_embed.set_thumbnail(url=ICON_URL) for field, value in PEP0_INFO.items(): pep_embed.add_field(name=field, value=value) @@ -104,7 +106,7 @@ class Utils(Cog): description=f"[Link]({self.base_pep_url}{pep_number:04})", ) - pep_embed.set_thumbnail(url="https://www.python.org/static/opengraph-icon-200x200.png") + pep_embed.set_thumbnail(url=ICON_URL) # Add the interesting information fields_to_check = ("Status", "Python-Version", "Created", "Type") -- cgit v1.2.3 From 3e819043ce0538682b4512382974b641dc6872c0 Mon Sep 17 00:00:00 2001 From: ks123 Date: Sun, 29 Mar 2020 09:03:27 +0300 Subject: (PEP Command): Moved PEP 0 information to hard-coded strings from constants, moved PEP 0 sending to function. --- bot/cogs/utils.py | 32 ++++++++++++++------------------ 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index d15edd0a0..3cd259b35 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -40,14 +40,6 @@ If the implementation is easy to explain, it may be a good idea. Namespaces are one honking great idea -- let's do more of those! """ -PEP0_TITLE = "Index of Python Enhancement Proposals (PEPs)" -PEP0_INFO = { - "Status": "Active", - "Created": "13-Jul-2000", - "Type": "Informational" -} -PEP0_LINK = "https://www.python.org/dev/peps/" - ICON_URL = "https://www.python.org/static/opengraph-icon-200x200.png" @@ -72,16 +64,7 @@ class Utils(Cog): # Handle PEP 0 directly due it's static constant in PEPs GitHub repo in Python file, not .rst or .txt so it # can't be accessed like other PEPs. if pep_number == 0: - pep_embed = Embed( - title=f"**PEP 0 - {PEP0_TITLE}**", - description=f"[Link]({PEP0_LINK})" - ) - pep_embed.set_thumbnail(url=ICON_URL) - for field, value in PEP0_INFO.items(): - pep_embed.add_field(name=field, value=value) - - await ctx.send(embed=pep_embed) - return + return await self.send_pep_zero(ctx) possible_extensions = ['.txt', '.rst'] found_pep = False @@ -302,6 +285,19 @@ class Utils(Cog): for reaction in options: await message.add_reaction(reaction) + async def send_pep_zero(self, ctx: Context) -> None: + """Send information about PEP 0.""" + pep_embed = Embed( + title=f"**PEP 0 - Index of Python Enhancement Proposals (PEPs)**", + description=f"[Link](https://www.python.org/dev/peps/)" + ) + pep_embed.set_thumbnail(url=ICON_URL) + pep_embed.add_field(name="Status", value="Active") + pep_embed.add_field(name="Created", value="13-Jul-2000") + pep_embed.add_field(name="Type", value="Informational") + + await ctx.send(embed=pep_embed) + def setup(bot: Bot) -> None: """Load the Utils cog.""" -- cgit v1.2.3 From 8bebc1e68dba2252a0a7abee456bf02512c1e60e Mon Sep 17 00:00:00 2001 From: ks123 Date: Sun, 29 Mar 2020 09:06:00 +0300 Subject: (PEP Command): Fixed comment about PEP 0 separately handling. --- bot/cogs/utils.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 3cd259b35..f0b1172e3 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -61,8 +61,7 @@ class Utils(Cog): await ctx.invoke(self.bot.get_command("help"), "pep") return - # Handle PEP 0 directly due it's static constant in PEPs GitHub repo in Python file, not .rst or .txt so it - # can't be accessed like other PEPs. + # Handle PEP 0 directly because it's not in .rst or .txt so it can't be accessed like other PEPs. if pep_number == 0: return await self.send_pep_zero(ctx) -- cgit v1.2.3 From 5d24f9a487a2d5c731a865e3ed808db6157951ea Mon Sep 17 00:00:00 2001 From: Karlis S <45097959+ks129@users.noreply.github.com> Date: Sun, 29 Mar 2020 19:27:34 +0300 Subject: (Infraction Edit): Don't let change expiration when infraction already expired. --- bot/cogs/moderation/management.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index 35448f682..531bb1743 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -100,7 +100,9 @@ class ModManagement(commands.Cog): confirm_messages = [] log_text = "" - if isinstance(duration, str): + if duration is not None and not old_infraction['active']: + confirm_messages.append("expiry unchanged (infraction already expired)") + elif isinstance(duration, str): request_data['expires_at'] = None confirm_messages.append("marked as permanent") elif duration is not None: -- cgit v1.2.3 From f9fa3e6a67a196c9b529c9a8b8b68bcd89f0dcec Mon Sep 17 00:00:00 2001 From: ks123 Date: Mon, 30 Mar 2020 09:29:50 +0300 Subject: (Tags): Added helper function `handle_trashcan_react` for tag response deletion handling. --- bot/cogs/tags.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 539105017..293fa36f6 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -1,14 +1,16 @@ import logging import re import time +from asyncio import TimeoutError from pathlib import Path from typing import Callable, Dict, Iterable, List, Optional -from discord import Colour, Embed +from discord import Colour, Embed, Message, Reaction, User from discord.ext.commands import Cog, Context, group from bot import constants from bot.bot import Bot +from bot.constants import Emojis from bot.converters import TagNameConverter from bot.pagination import LinePaginator @@ -139,6 +141,24 @@ class Tags(Cog): max_lines=15 ) + async def handle_trashcan_react(self, ctx: Context, msg: Message) -> None: + """Add `trashcan` emoji to Tag and handle deletion when user react to it.""" + await msg.add_reaction(Emojis.trashcan) + + def check_trashcan(reaction: Reaction, user: User) -> bool: + return ( + reaction.emoji == Emojis.trashcan + and user.id == ctx.author.id + and reaction.message == msg + ) + try: + await self.bot.wait_for("reaction_add", timeout=60.0, check=check_trashcan) + except TimeoutError: + await msg.remove_reaction(Emojis.trashcan, msg.author) + else: + await ctx.message.delete() + await msg.delete() + @group(name='tags', aliases=('tag', 't'), invoke_without_command=True) async def tags_group(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None: """Show all known tags, a single tag, or run a subcommand.""" -- cgit v1.2.3 From b37f221b9b68efed48409a64a802ea0df009627f Mon Sep 17 00:00:00 2001 From: ks123 Date: Mon, 30 Mar 2020 09:36:45 +0300 Subject: (Tags): Added trashcan handling to `!tags get` command. --- bot/cogs/tags.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 293fa36f6..5dbb75c73 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -225,12 +225,14 @@ class Tags(Cog): "time": time.time(), "channel": ctx.channel.id } - await ctx.send(embed=Embed.from_dict(tag['embed'])) + msg = await ctx.send(embed=Embed.from_dict(tag['embed'])) + await self.handle_trashcan_react(ctx, msg) elif founds and len(tag_name) >= 3: - await ctx.send(embed=Embed( + msg = await ctx.send(embed=Embed( title='Did you mean ...', description='\n'.join(tag['title'] for tag in founds[:10]) )) + await self.handle_trashcan_react(ctx, msg) else: tags = self._cache.values() -- cgit v1.2.3 From 307aacbf1b7304ebb52d5193f19b5a12623cdbfd Mon Sep 17 00:00:00 2001 From: ks123 Date: Mon, 30 Mar 2020 09:44:13 +0300 Subject: (Tags): Fixed trashcan handling check. --- bot/cogs/tags.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 5dbb75c73..3f9647eb5 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -148,8 +148,8 @@ class Tags(Cog): def check_trashcan(reaction: Reaction, user: User) -> bool: return ( reaction.emoji == Emojis.trashcan - and user.id == ctx.author.id - and reaction.message == msg + and user == ctx.author + and reaction.message.id == msg.id ) try: await self.bot.wait_for("reaction_add", timeout=60.0, check=check_trashcan) -- cgit v1.2.3 From 582ddbb1ca8bab2cb883781911f5f35962330995 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> Date: Mon, 30 Mar 2020 13:36:34 +0200 Subject: Set unsilence permissions to inherit instead of true The "unsilence" action of the silence/hush command used `send_messages=True` when unsilencing a hushed channel. This had the side effect of also enabling send messages permissions for those with the Muted rule, as an explicit True permission apparently overwrites an explicit False permission, even if the latter was set for a higher top-role. The solution is to revert back to the `Inherit` permission by assigning `None`. This is what we normally use when Developers are allowed to send messages to a channel. --- bot/cogs/moderation/silence.py | 2 +- tests/bot/cogs/moderation/test_silence.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index a1446089e..1ef3967a9 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -138,7 +138,7 @@ class Silence(commands.Cog): """ current_overwrite = channel.overwrites_for(self._verified_role) if current_overwrite.send_messages is False: - await channel.set_permissions(self._verified_role, **dict(current_overwrite, send_messages=True)) + await channel.set_permissions(self._verified_role, **dict(current_overwrite, send_messages=None)) log.info(f"Unsilenced channel #{channel} ({channel.id}).") self.notifier.remove_channel(channel) self.muted_channels.discard(channel) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 44682a1bd..3fd149f04 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -194,7 +194,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite)) self.assertTrue(await self.cog._unsilence(channel)) channel.set_permissions.assert_called_once() - self.assertTrue(channel.set_permissions.call_args.kwargs['send_messages']) + self.assertIsNone(channel.set_permissions.call_args.kwargs['send_messages']) @mock.patch.object(Silence, "notifier", create=True) async def test_unsilence_private_removed_notifier(self, notifier): -- cgit v1.2.3 From 6f273e96714c6de4738ec5ed2026e17cd3668594 Mon Sep 17 00:00:00 2001 From: ks123 Date: Tue, 31 Mar 2020 10:52:45 +0300 Subject: (Tags): Modified helper function `handle_trashcan_react` to `send_embed_with_trashcan`, applied to docstring and to command. --- bot/cogs/tags.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 3f9647eb5..3729b4511 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -5,7 +5,7 @@ from asyncio import TimeoutError from pathlib import Path from typing import Callable, Dict, Iterable, List, Optional -from discord import Colour, Embed, Message, Reaction, User +from discord import Colour, Embed, Reaction, User from discord.ext.commands import Cog, Context, group from bot import constants @@ -141,8 +141,9 @@ class Tags(Cog): max_lines=15 ) - async def handle_trashcan_react(self, ctx: Context, msg: Message) -> None: - """Add `trashcan` emoji to Tag and handle deletion when user react to it.""" + async def send_embed_with_trashcan(self, ctx: Context, embed: Embed) -> None: + """Send embed and handle it's and command message deletion with `trashcan` emoji.""" + msg = await ctx.send(embed=embed) await msg.add_reaction(Emojis.trashcan) def check_trashcan(reaction: Reaction, user: User) -> bool: @@ -225,14 +226,12 @@ class Tags(Cog): "time": time.time(), "channel": ctx.channel.id } - msg = await ctx.send(embed=Embed.from_dict(tag['embed'])) - await self.handle_trashcan_react(ctx, msg) + await self.send_embed_with_trashcan(ctx, Embed.from_dict(tag['embed'])) elif founds and len(tag_name) >= 3: - msg = await ctx.send(embed=Embed( + await self.send_embed_with_trashcan(ctx, Embed( title='Did you mean ...', description='\n'.join(tag['title'] for tag in founds[:10]) )) - await self.handle_trashcan_react(ctx, msg) else: tags = self._cache.values() -- cgit v1.2.3 From e28a580200243669b1a9219b9e9d19b7f5a503af Mon Sep 17 00:00:00 2001 From: ks123 Date: Tue, 31 Mar 2020 10:54:07 +0300 Subject: (Tags): Fixed `TimeoutError` shadowing with `asyncio.TimeoutError`. --- bot/cogs/tags.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 3729b4511..9548f2e43 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -1,7 +1,7 @@ +import asyncio import logging import re import time -from asyncio import TimeoutError from pathlib import Path from typing import Callable, Dict, Iterable, List, Optional @@ -154,7 +154,7 @@ class Tags(Cog): ) try: await self.bot.wait_for("reaction_add", timeout=60.0, check=check_trashcan) - except TimeoutError: + except asyncio.TimeoutError: await msg.remove_reaction(Emojis.trashcan, msg.author) else: await ctx.message.delete() -- cgit v1.2.3 From aa9757b30b4a9d4c65a994f90dfcc65f148ac655 Mon Sep 17 00:00:00 2001 From: ks123 Date: Tue, 31 Mar 2020 10:54:58 +0300 Subject: (Tags): Added blank line between check function and `try:` block on `send_embed_with_trashcan` function. --- bot/cogs/tags.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 9548f2e43..8115423cc 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -152,6 +152,7 @@ class Tags(Cog): and user == ctx.author and reaction.message.id == msg.id ) + try: await self.bot.wait_for("reaction_add", timeout=60.0, check=check_trashcan) except asyncio.TimeoutError: -- cgit v1.2.3 From 315ffa747c1e5b4527dce07061a3e0016eea7e5f Mon Sep 17 00:00:00 2001 From: ks123 Date: Tue, 31 Mar 2020 18:57:28 +0300 Subject: (Tags): Moved to existing `wait_for_deletion` function instead using custom/new one. --- bot/cogs/tags.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 8115423cc..3da05679e 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -13,6 +13,7 @@ from bot.bot import Bot from bot.constants import Emojis from bot.converters import TagNameConverter from bot.pagination import LinePaginator +from bot.utils.messages import wait_for_deletion log = logging.getLogger(__name__) @@ -189,6 +190,7 @@ class Tags(Cog): @tags_group.command(name='get', aliases=('show', 'g')) async def get_command(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None: """Get a specified tag, or a list of all tags if no tag is specified.""" + def _command_on_cooldown(tag_name: str) -> bool: """ Check if the command is currently on cooldown, on a per-tag, per-channel basis. @@ -227,12 +229,22 @@ class Tags(Cog): "time": time.time(), "channel": ctx.channel.id } - await self.send_embed_with_trashcan(ctx, Embed.from_dict(tag['embed'])) + await wait_for_deletion( + await ctx.send(embed=Embed.from_dict(tag['embed'])), + [ctx.author.id], + client=self.bot + ) elif founds and len(tag_name) >= 3: - await self.send_embed_with_trashcan(ctx, Embed( - title='Did you mean ...', - description='\n'.join(tag['title'] for tag in founds[:10]) - )) + await wait_for_deletion( + await ctx.send( + embed=Embed( + title='Did you mean ...', + description='\n'.join(tag['title'] for tag in founds[:10]) + ) + ), + [ctx.author.id], + client=self.bot + ) else: tags = self._cache.values() -- cgit v1.2.3 From 44534b650d8d69e02e7fc8b0189e533cea037e25 Mon Sep 17 00:00:00 2001 From: ks123 Date: Tue, 31 Mar 2020 18:59:36 +0300 Subject: (Tags): Removed unnecessary `send_embed_with_trashcan` function due using existing function. --- bot/cogs/tags.py | 24 +----------------------- 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 3da05679e..a6e5952ff 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -1,16 +1,14 @@ -import asyncio import logging import re import time from pathlib import Path from typing import Callable, Dict, Iterable, List, Optional -from discord import Colour, Embed, Reaction, User +from discord import Colour, Embed from discord.ext.commands import Cog, Context, group from bot import constants from bot.bot import Bot -from bot.constants import Emojis from bot.converters import TagNameConverter from bot.pagination import LinePaginator from bot.utils.messages import wait_for_deletion @@ -142,26 +140,6 @@ class Tags(Cog): max_lines=15 ) - async def send_embed_with_trashcan(self, ctx: Context, embed: Embed) -> None: - """Send embed and handle it's and command message deletion with `trashcan` emoji.""" - msg = await ctx.send(embed=embed) - await msg.add_reaction(Emojis.trashcan) - - def check_trashcan(reaction: Reaction, user: User) -> bool: - return ( - reaction.emoji == Emojis.trashcan - and user == ctx.author - and reaction.message.id == msg.id - ) - - try: - await self.bot.wait_for("reaction_add", timeout=60.0, check=check_trashcan) - except asyncio.TimeoutError: - await msg.remove_reaction(Emojis.trashcan, msg.author) - else: - await ctx.message.delete() - await msg.delete() - @group(name='tags', aliases=('tag', 't'), invoke_without_command=True) async def tags_group(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None: """Show all known tags, a single tag, or run a subcommand.""" -- cgit v1.2.3 From 7954c7503f9758eb2a1051a608da386cbef70364 Mon Sep 17 00:00:00 2001 From: ks123 Date: Tue, 31 Mar 2020 19:28:02 +0300 Subject: (Infraction Edit): Don't change infraction when user try modify duration of infraction that is already expired and reason not specified. --- bot/cogs/moderation/management.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index 531bb1743..6c68d852e 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -101,6 +101,11 @@ class ModManagement(commands.Cog): log_text = "" if duration is not None and not old_infraction['active']: + if reason is None: + await ctx.send( + "Expiry can't be changed (infraction already expired) and new reason not specified." + ) + return confirm_messages.append("expiry unchanged (infraction already expired)") elif isinstance(duration, str): request_data['expires_at'] = None -- cgit v1.2.3 From ad16fa81dbc3c8032e02652ba2f3e5d6704c054f Mon Sep 17 00:00:00 2001 From: Karlis S <45097959+ks129@users.noreply.github.com> Date: Tue, 31 Mar 2020 21:18:17 +0300 Subject: (Infraction Edit): Changed already expired and no reason provided sentence. Co-Authored-By: Mark --- bot/cogs/moderation/management.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index 6c68d852e..250a24247 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -102,9 +102,7 @@ class ModManagement(commands.Cog): if duration is not None and not old_infraction['active']: if reason is None: - await ctx.send( - "Expiry can't be changed (infraction already expired) and new reason not specified." - ) + await ctx.send(":x: Cannot edit the expiration of an expired infraction.") return confirm_messages.append("expiry unchanged (infraction already expired)") elif isinstance(duration, str): -- cgit v1.2.3 From 45306e1fb665e683d00b1f744958404b7c7eaf9b Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 1 Apr 2020 11:29:09 +0200 Subject: Add TCD to whitelist The Coding Den is a language agnostic community that's been around for years with over 12000 members. I think we can allow that invite in our community. --- config-default.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/config-default.yml b/config-default.yml index ef0ed970f..a9578d9bb 100644 --- a/config-default.yml +++ b/config-default.yml @@ -279,6 +279,7 @@ filter: - 524691714909274162 # Panda3D - 336642139381301249 # discord.py - 405403391410438165 # Sentdex + - 172018499005317120 # The Coding Den domain_blacklist: - pornhub.com -- cgit v1.2.3 From 10400899806408e1d9966fbe1a1c7c0e9ccaa087 Mon Sep 17 00:00:00 2001 From: "S. Co1" Date: Thu, 2 Apr 2020 09:19:12 -0400 Subject: Fixed missed rename for token removal method name change --- bot/cogs/bot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index e897b30ff..7b66b48c2 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -238,7 +238,7 @@ class BotCog(Cog, name="Bot"): ) and not msg.author.bot and len(msg.content.splitlines()) > 3 - and not TokenRemover.is_token_in_message(msg) + and not TokenRemover.find_token_in_message(msg) ) if parse_codeblock: # no token in the msg -- cgit v1.2.3