aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar mathsman5133 <[email protected]>2020-03-31 20:16:42 +1100
committerGravatar mathsman5133 <[email protected]>2020-03-31 20:16:42 +1100
commit4b20bc77d0f3778df2e916f2afa2a45af6386638 (patch)
tree512e75c4ffeb1137158dfa80106fcbb4f16749a1
parentApply suggestions from review. (diff)
parentSet unsilence permissions to inherit instead of true (diff)
Merge branch 'master' of https://github.com/python-discord/bot into help-refactor
-rw-r--r--CONTRIBUTING.md59
-rw-r--r--Pipfile2
-rw-r--r--Pipfile.lock275
-rw-r--r--bot/__main__.py1
-rw-r--r--bot/cogs/alias.py4
-rw-r--r--bot/cogs/error_handler.py35
-rw-r--r--bot/cogs/filtering.py16
-rw-r--r--bot/cogs/moderation/__init__.py4
-rw-r--r--bot/cogs/moderation/infractions.py22
-rw-r--r--bot/cogs/moderation/modlog.py30
-rw-r--r--bot/cogs/moderation/silence.py159
-rw-r--r--bot/cogs/snekbox.py22
-rw-r--r--bot/cogs/tags.py140
-rw-r--r--bot/cogs/utils.py21
-rw-r--r--bot/cogs/verification.py69
-rw-r--r--bot/cogs/watchchannels/bigbrother.py45
-rw-r--r--bot/cogs/webhook_remover.py72
-rw-r--r--bot/converters.py28
-rw-r--r--bot/resources/tags/args-kwargs.md17
-rw-r--r--bot/resources/tags/ask.md9
-rw-r--r--bot/resources/tags/class.md25
-rw-r--r--bot/resources/tags/classmethod.md20
-rw-r--r--bot/resources/tags/codeblock.md17
-rw-r--r--bot/resources/tags/decorators.md31
-rw-r--r--bot/resources/tags/dictcomps.md20
-rw-r--r--bot/resources/tags/enumerate.md13
-rw-r--r--bot/resources/tags/except.md17
-rw-r--r--bot/resources/tags/exit().md8
-rw-r--r--bot/resources/tags/f-strings.md17
-rw-r--r--bot/resources/tags/foo.md10
-rw-r--r--bot/resources/tags/functions-are-objects.md39
-rw-r--r--bot/resources/tags/global.md16
-rw-r--r--bot/resources/tags/if-name-main.md26
-rw-r--r--bot/resources/tags/indent.md24
-rw-r--r--bot/resources/tags/inline.md16
-rw-r--r--bot/resources/tags/iterate-dict.md10
-rw-r--r--bot/resources/tags/listcomps.md14
-rw-r--r--bot/resources/tags/mutable-default-args.md48
-rw-r--r--bot/resources/tags/names.md37
-rw-r--r--bot/resources/tags/off-topic.md8
-rw-r--r--bot/resources/tags/open.md26
-rw-r--r--bot/resources/tags/or-gotcha.md17
-rw-r--r--bot/resources/tags/param-arg.md12
-rw-r--r--bot/resources/tags/paste.md6
-rw-r--r--bot/resources/tags/pathlib.md21
-rw-r--r--bot/resources/tags/pep8.md3
-rw-r--r--bot/resources/tags/positional-keyword.md38
-rw-r--r--bot/resources/tags/precedence.md13
-rw-r--r--bot/resources/tags/quotes.md20
-rw-r--r--bot/resources/tags/relative-path.md7
-rw-r--r--bot/resources/tags/repl.md13
-rw-r--r--bot/resources/tags/return.md35
-rw-r--r--bot/resources/tags/round.md24
-rw-r--r--bot/resources/tags/scope.md24
-rw-r--r--bot/resources/tags/seek.md22
-rw-r--r--bot/resources/tags/self.md25
-rw-r--r--bot/resources/tags/star-imports.md48
-rw-r--r--bot/resources/tags/traceback.md18
-rw-r--r--bot/resources/tags/windows-path.md30
-rw-r--r--bot/resources/tags/with.md8
-rw-r--r--bot/resources/tags/xy-problem.md7
-rw-r--r--bot/resources/tags/ytdl.md12
-rw-r--r--bot/resources/tags/zip.md12
-rw-r--r--tests/bot/cogs/moderation/__init__.py0
-rw-r--r--tests/bot/cogs/moderation/test_silence.py251
-rw-r--r--tests/bot/cogs/test_cogs.py80
-rw-r--r--tests/bot/cogs/test_snekbox.py45
-rw-r--r--tests/bot/test_converters.py30
68 files changed, 1918 insertions, 375 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.
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"
}
}
}
diff --git a/bot/__main__.py b/bot/__main__.py
index 3df477a6d..8c3ae02e3 100644
--- a/bot/__main__.py
+++ b/bot/__main__.py
@@ -63,6 +63,7 @@ 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.webhook_remover")
bot.load_extension("bot.cogs.wolfram")
# Apply `message_edited_at` patch if discord.py did not yet release a bug fix.
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}"'
)
diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py
index 261769efc..6a622d2ce 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
@@ -48,9 +50,11 @@ 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) 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.
elif isinstance(e, errors.UserInputError):
@@ -89,6 +93,33 @@ 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")
+ 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"):
+ 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.
diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py
index 74538542a..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
@@ -27,6 +28,7 @@ INVITE_RE = re.compile(
flags=re.IGNORECASE
)
+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]")
@@ -38,6 +40,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."""
@@ -186,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
@@ -244,6 +254,8 @@ 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:
match = regex_pattern.search(text)
if match:
diff --git a/bot/cogs/moderation/__init__.py b/bot/cogs/moderation/__init__.py
index 5243cb92d..6880ca1bd 100644
--- a/bot/cogs/moderation/__init__.py
+++ b/bot/cogs/moderation/__init__.py
@@ -2,12 +2,14 @@ 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
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))
+ bot.add_cog(Silence(bot))
bot.add_cog(Superstarify(bot))
diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py
index 9ea17b2b3..efa19f59e 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 and stop watching them with Big Brother."""
await self.apply_ban(ctx, user, reason)
# endregion
@@ -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,6 +247,20 @@ class Infractions(InfractionScheduler, commands.Cog):
action = ctx.guild.ban(user, reason=reason, delete_message_days=0)
await self.apply_infraction(ctx, infraction, user, action)
+ 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.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}.")
+
+ bb_reason = "User has been permanently banned from the server. Automatically removed."
+ await bb_cog.apply_unwatch(ctx, user, bb_reason, send_message=False)
+
# endregion
# region: Base pardon functions
diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py
index 81d95298d..c63b4bab9 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
@@ -215,7 +216,10 @@ class ModLog(Cog, name="ModLog"):
new = value["new_value"]
old = value["old_value"]
- changes.append(f"**{key.title()}:** `{old}` **→** `{new}`")
+ # 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)
@@ -386,7 +390,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))
@@ -412,9 +417,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
)
@@ -429,9 +435,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
)
@@ -523,7 +530,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(),
@@ -550,16 +558,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"
@@ -646,6 +655,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}"
@@ -677,7 +688,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"
@@ -820,8 +831,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,
diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py
new file mode 100644
index 000000000..1ef3967a9
--- /dev/null
+++ b/bot/cogs/moderation/silence.py
@@ -0,0 +1,159 @@
+import asyncio
+import logging
+from contextlib import suppress
+from typing import Optional
+
+from discord import TextChannel
+from discord.ext import commands, tasks
+from discord.ext.commands import Context
+
+from bot.bot import Bot
+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__)
+
+
+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 = {}
+ 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.info("Starting notifier 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):
+ del self._silenced_channels[channel]
+ if not self._silenced_channels:
+ self.stop()
+ log.info("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.items()
+ )
+ 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.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."""
+ 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)
+ self._get_instance_vars_event.set()
+
+ @commands.command(aliases=("hush",))
+ async def silence(self, ctx: Context, duration: HushDurationConverter = 10) -> None:
+ """
+ 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.
+ """
+ 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.")
+ return
+ if duration is None:
+ await ctx.send(f"{Emojis.check_mark} silenced current channel indefinitely.")
+ return
+
+ 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",))
+ async def unsilence(self, ctx: Context) -> None:
+ """
+ Unsilence the current channel.
+
+ 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.")
+ if await self._unsilence(ctx.channel):
+ await ctx.send(f"{Emojis.check_mark} unsilenced current channel.")
+
+ async def _silence(self, channel: TextChannel, persistent: bool, duration: Optional[int]) -> bool:
+ """
+ Silence `channel` for `self._verified_role`.
+
+ 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:
+ 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, **dict(current_overwrite, send_messages=False))
+ self.muted_channels.add(channel)
+ if persistent:
+ log.info(f"Silenced #{channel} ({channel.id}) indefinitely.")
+ self.notifier.add_channel(channel)
+ return True
+
+ log.info(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 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:
+ 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)
+ return True
+ log.info(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."""
+ return with_role_check(ctx, *MODERATION_ROLES)
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)
diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py
index c6b442912..539105017 100644
--- a/bot/cogs/tags.py
+++ b/bot/cogs/tags.py
@@ -1,25 +1,26 @@
import logging
import re
import time
+from pathlib import Path
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, MODERATION_ROLES, Roles
-from bot.converters import TagContentConverter, TagNameConverter
-from bot.decorators import with_role
+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 <tagname>."
class Tags(Cog):
@@ -28,21 +29,27 @@ class Tags(Cog):
def __init__(self, bot: Bot):
self.bot = bot
self.tag_cooldowns = {}
+ self._cache = self.get_tags()
- self._cache = {}
- self._last_fetch: float = 0.0
-
- async def _get_tags(self, is_forced: bool = False) -> None:
+ @staticmethod
+ def get_tags() -> dict:
"""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:
- tags = await self.bot.api_client.get('bot/tags')
- self._cache = {tag['title'].lower(): tag for tag in tags}
- self._last_fetch = time_now
+ # Save all tags in memory.
+ cache = {}
+ tag_files = Path("bot", "resources", "tags").iterdir()
+ for file in tag_files:
+ tag_title = file.stem
+ tag = {
+ "title": tag_title,
+ "embed": {
+ "description": file.read_text()
+ }
+ }
+ cache[tag_title] = tag
+ return cache
@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())
@@ -78,22 +85,19 @@ 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."""
- await self._get_tags()
found = [self._cache.get(tag_name.lower(), None)]
if not found[0]:
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.
`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()
@@ -130,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 <tagname>.",
+ footer_text=FOOTER_TEXT,
empty=False,
max_lines=15
)
@@ -147,17 +151,17 @@ 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')
- 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.
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'))
@@ -174,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
)
@@ -183,17 +187,16 @@ 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."
)
return
- await self._get_tags()
-
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]
@@ -222,86 +225,11 @@ class Tags(Cog):
sorted(f"**»** {tag['title']}" for tag in tags),
ctx,
embed,
- footer_text="To show a tag, type !tags <tagname>.",
+ footer_text=FOOTER_TEXT,
empty=False,
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.admins, Roles.owners)
- 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/cogs/utils.py b/bot/cogs/utils.py
index 024141d62..f0b1172e3 100644
--- a/bot/cogs/utils.py
+++ b/bot/cogs/utils.py
@@ -40,6 +40,8 @@ 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!
"""
+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."""
@@ -59,6 +61,10 @@ class Utils(Cog):
await ctx.invoke(self.bot.get_command("help"), "pep")
return
+ # 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)
+
possible_extensions = ['.txt', '.rst']
found_pep = False
for extension in possible_extensions:
@@ -82,7 +88,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")
@@ -278,6 +284,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."""
diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py
index 57b50c34f..b0a493e68 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)
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:
diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py
index c601e0d4d..903c87f85 100644
--- a/bot/cogs/watchchannels/bigbrother.py
+++ b/bot/cogs/watchchannels/bigbrother.py
@@ -52,6 +52,21 @@ 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:
+ """
+ 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
@@ -90,10 +105,13 @@ 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, send_message: bool = True) -> None:
+ """
+ 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(
@@ -102,6 +120,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(
@@ -111,8 +130,20 @@ 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.")
-
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:
- await ctx.send(":x: The specified user is currently not being watched.")
+ log.trace("No active watches found for user.")
+ 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)
diff --git a/bot/cogs/webhook_remover.py b/bot/cogs/webhook_remover.py
new file mode 100644
index 000000000..49692113d
--- /dev/null
+++ b/bot/cogs/webhook_remover.py
@@ -0,0 +1,72 @@
+import logging
+import re
+
+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"((?:https?://)?discordapp\.com/api/webhooks/\d+/)\S+/?", re.I)
+
+ALERT_MESSAGE_TEMPLATE = (
+ "{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__)
+
+
+class WebhookRemover(Cog):
+ """Scan messages to detect Discord webhooks links."""
+
+ 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 delete_and_respond(self, msg: Message, redacted_url: str) -> None:
+ """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 a Discord webhook URL "
+ f"to #{msg.channel}. Webhook URL was `{redacted_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
+ )
+
+ @Cog.listener()
+ async def on_message(self, msg: Message) -> None:
+ """Check if a Discord webhook URL is in `message`."""
+ matches = WEBHOOK_URL_RE.search(msg.content)
+ if matches:
+ await self.delete_and_respond(msg, matches[1] + "xxx")
+
+ @Cog.listener()
+ async def on_message_edit(self, before: Message, after: Message) -> None:
+ """Check if a Discord webhook URL is in the edited message `after`."""
+ await self.on_message(after)
+
+
+def setup(bot: Bot) -> None:
+ """Load `WebhookRemover` cog."""
+ bot.add_cog(WebhookRemover(bot))
diff --git a/bot/converters.py b/bot/converters.py
index 1945e1da3..2b413f039 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:
+ * <duration>,
+ * <duration>m,
+ * <duration>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 at most 15 minutes.")
+ return duration
+
+
def proxy_user(user_id: str) -> discord.Object:
"""
Create a proxy user object from the given id.
diff --git a/bot/resources/tags/args-kwargs.md b/bot/resources/tags/args-kwargs.md
new file mode 100644
index 000000000..b440a2346
--- /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*
diff --git a/bot/resources/tags/ask.md b/bot/resources/tags/ask.md
new file mode 100644
index 000000000..e2c2a88f6
--- /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/).
diff --git a/bot/resources/tags/class.md b/bot/resources/tags/class.md
new file mode 100644
index 000000000..4f73fc974
--- /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`.
diff --git a/bot/resources/tags/classmethod.md b/bot/resources/tags/classmethod.md
new file mode 100644
index 000000000..a4e803093
--- /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).
diff --git a/bot/resources/tags/codeblock.md b/bot/resources/tags/codeblock.md
new file mode 100644
index 000000000..a28ae397b
--- /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!')
+```
diff --git a/bot/resources/tags/decorators.md b/bot/resources/tags/decorators.md
new file mode 100644
index 000000000..39c943f0a
--- /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/)
diff --git a/bot/resources/tags/dictcomps.md b/bot/resources/tags/dictcomps.md
new file mode 100644
index 000000000..11867d77b
--- /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/)
diff --git a/bot/resources/tags/enumerate.md b/bot/resources/tags/enumerate.md
new file mode 100644
index 000000000..dd984af52
--- /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/).
diff --git a/bot/resources/tags/except.md b/bot/resources/tags/except.md
new file mode 100644
index 000000000..8f0abf156
--- /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).
diff --git a/bot/resources/tags/exit().md b/bot/resources/tags/exit().md
new file mode 100644
index 000000000..27da9f866
--- /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.
diff --git a/bot/resources/tags/f-strings.md b/bot/resources/tags/f-strings.md
new file mode 100644
index 000000000..69bc82487
--- /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))
+```
diff --git a/bot/resources/tags/foo.md b/bot/resources/tags/foo.md
new file mode 100644
index 000000000..98529bfc0
--- /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)
diff --git a/bot/resources/tags/functions-are-objects.md b/bot/resources/tags/functions-are-objects.md
new file mode 100644
index 000000000..01af7a721
--- /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
+```
diff --git a/bot/resources/tags/global.md b/bot/resources/tags/global.md
new file mode 100644
index 000000000..64c316b62
--- /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).
diff --git a/bot/resources/tags/if-name-main.md b/bot/resources/tags/if-name-main.md
new file mode 100644
index 000000000..9d88bb897
--- /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
diff --git a/bot/resources/tags/indent.md b/bot/resources/tags/indent.md
new file mode 100644
index 000000000..dec8407b0
--- /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)
diff --git a/bot/resources/tags/inline.md b/bot/resources/tags/inline.md
new file mode 100644
index 000000000..a6a7c35d6
--- /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
diff --git a/bot/resources/tags/iterate-dict.md b/bot/resources/tags/iterate-dict.md
new file mode 100644
index 000000000..78c067b20
--- /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)
+```
diff --git a/bot/resources/tags/listcomps.md b/bot/resources/tags/listcomps.md
new file mode 100644
index 000000000..0003b9bb8
--- /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/).
diff --git a/bot/resources/tags/mutable-default-args.md b/bot/resources/tags/mutable-default-args.md
new file mode 100644
index 000000000..a8f0c38b3
--- /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.
diff --git a/bot/resources/tags/names.md b/bot/resources/tags/names.md
new file mode 100644
index 000000000..3e76269f7
--- /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)
diff --git a/bot/resources/tags/off-topic.md b/bot/resources/tags/off-topic.md
new file mode 100644
index 000000000..c7f98a813
--- /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.
diff --git a/bot/resources/tags/open.md b/bot/resources/tags/open.md
new file mode 100644
index 000000000..13b4555b9
--- /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)
diff --git a/bot/resources/tags/or-gotcha.md b/bot/resources/tags/or-gotcha.md
new file mode 100644
index 000000000..00c2db1f8
--- /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.")
+```
diff --git a/bot/resources/tags/param-arg.md b/bot/resources/tags/param-arg.md
new file mode 100644
index 000000000..88069d8bd
--- /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`
diff --git a/bot/resources/tags/paste.md b/bot/resources/tags/paste.md
new file mode 100644
index 000000000..2ed51def7
--- /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.
diff --git a/bot/resources/tags/pathlib.md b/bot/resources/tags/pathlib.md
new file mode 100644
index 000000000..dfeb7ecac
--- /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/)
diff --git a/bot/resources/tags/pep8.md b/bot/resources/tags/pep8.md
new file mode 100644
index 000000000..cab4c4db8
--- /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).
diff --git a/bot/resources/tags/positional-keyword.md b/bot/resources/tags/positional-keyword.md
new file mode 100644
index 000000000..dd6ddfc4b
--- /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)
diff --git a/bot/resources/tags/precedence.md b/bot/resources/tags/precedence.md
new file mode 100644
index 000000000..ed399143c
--- /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)
diff --git a/bot/resources/tags/quotes.md b/bot/resources/tags/quotes.md
new file mode 100644
index 000000000..8421748a1
--- /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/)
diff --git a/bot/resources/tags/relative-path.md b/bot/resources/tags/relative-path.md
new file mode 100644
index 000000000..6e97b78af
--- /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 <module>.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/<module>.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.
diff --git a/bot/resources/tags/repl.md b/bot/resources/tags/repl.md
new file mode 100644
index 000000000..875b4ec47
--- /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 <return> 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 <return> on a blank line. In other words, for the last expression you write in the clause, <return> 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()`.
diff --git a/bot/resources/tags/return.md b/bot/resources/tags/return.md
new file mode 100644
index 000000000..e37f0eebc
--- /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)
diff --git a/bot/resources/tags/round.md b/bot/resources/tags/round.md
new file mode 100644
index 000000000..0392bb41b
--- /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)
diff --git a/bot/resources/tags/scope.md b/bot/resources/tags/scope.md
new file mode 100644
index 000000000..5c1e64e1c
--- /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)
diff --git a/bot/resources/tags/seek.md b/bot/resources/tags/seek.md
new file mode 100644
index 000000000..bc013fe03
--- /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.
diff --git a/bot/resources/tags/self.md b/bot/resources/tags/self.md
new file mode 100644
index 000000000..d20154fd5
--- /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.
diff --git a/bot/resources/tags/star-imports.md b/bot/resources/tags/star-imports.md
new file mode 100644
index 000000000..2be6aab6e
--- /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 <module_name> 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/)
diff --git a/bot/resources/tags/traceback.md b/bot/resources/tags/traceback.md
new file mode 100644
index 000000000..46ef40aa1
--- /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)
diff --git a/bot/resources/tags/windows-path.md b/bot/resources/tags/windows-path.md
new file mode 100644
index 000000000..da8edf685
--- /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 ...
+```
diff --git a/bot/resources/tags/with.md b/bot/resources/tags/with.md
new file mode 100644
index 000000000..62d5612f2
--- /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/).
diff --git a/bot/resources/tags/xy-problem.md b/bot/resources/tags/xy-problem.md
new file mode 100644
index 000000000..b77bd27e8
--- /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/
diff --git a/bot/resources/tags/ytdl.md b/bot/resources/tags/ytdl.md
new file mode 100644
index 000000000..e34ecff44
--- /dev/null
+++ b/bot/resources/tags/ytdl.md
@@ -0,0 +1,12 @@
+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 2019-07-22:
+```
+The following restrictions apply to your use of the Service. You are not allowed to:
+
+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)
+```
diff --git a/bot/resources/tags/zip.md b/bot/resources/tags/zip.md
new file mode 100644
index 000000000..6b05f0282
--- /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).
diff --git a/tests/bot/cogs/moderation/__init__.py b/tests/bot/cogs/moderation/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/tests/bot/cogs/moderation/__init__.py
diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py
new file mode 100644
index 000000000..3fd149f04
--- /dev/null
+++ b/tests/bot/cogs/moderation/test_silence.py
@@ -0,0 +1,251 @@
+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
+
+
+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()
+
+ 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.__setitem__.assert_called_with(channel, self.notifier._current_loop)
+
+ 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()
+
+ 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.__delitem__.assert_called_with(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()
+
+ 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:
+ self.bot = MockBot()
+ 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."""
+ 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 = (
+ (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(
+ 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)
+ self.ctx.send.assert_called_once_with(result_message)
+ self.ctx.reset_mock()
+
+ async def test_unsilence_sent_correct_discord_message(self):
+ """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.")
+
+ 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(await self.cog._silence(channel, True, None))
+ channel.set_permissions.assert_not_called()
+
+ async def test_silence_private_silenced_channel(self):
+ """Channel had `send_message` permissions revoked."""
+ 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['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()
+ with mock.patch.object(self.cog, "notifier", create=True):
+ with self.subTest(persistent=True):
+ 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):
+ await self.cog._silence(channel, False, None)
+ 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)
+ 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."""
+ channel = Mock()
+ self.assertFalse(await self.cog._unsilence(channel))
+ channel.set_permissions.assert_not_called()
+
+ @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))
+ self.assertTrue(await self.cog._unsilence(channel))
+ channel.set_permissions.assert_called_once()
+ 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):
+ """Channel was removed from `notifier` on unsilence."""
+ perm_overwrite = MagicMock(send_messages=False)
+ channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite))
+ await self.cog._unsilence(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, _):
+ """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") as muted_channels:
+ 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):
+ """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())
+
+ @mock.patch("bot.cogs.moderation.silence.asyncio")
+ 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()
+
+ @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))
diff --git a/tests/bot/cogs/test_cogs.py b/tests/bot/cogs/test_cogs.py
new file mode 100644
index 000000000..39f6492cb
--- /dev/null
+++ b/tests/bot/cogs/test_cogs.py
@@ -0,0 +1,80 @@
+"""Test suite for general tests which apply to all cogs."""
+
+import importlib
+import pkgutil
+import typing as t
+import unittest
+from collections import defaultdict
+from types import ModuleType
+from unittest import mock
+
+from discord.ext import commands
+
+from bot import cogs
+
+
+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."""
+ # 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
+ if isinstance(command, commands.GroupMixin):
+ # 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]:
+ """Yield imported modules from the bot.cogs subpackage."""
+ 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:
+ yield importlib.import_module(module.name)
+
+ @staticmethod
+ 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
+
+ @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}".strip() for alias in command.aliases]
+ 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 module in self.walk_modules():
+ 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)
diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py
index fd9468829..1dec0ccaf 100644
--- a/tests/bot/cogs/test_snekbox.py
+++ b/tests/bot/cogs/test_snekbox.py
@@ -1,11 +1,13 @@
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
+
+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
)
@@ -279,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),
@@ -302,6 +307,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')
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))