aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.github/FUNDING.yml2
-rw-r--r--.gitignore7
-rw-r--r--.pre-commit-config.yaml10
-rw-r--r--CONTRIBUTING.md123
-rw-r--r--Dockerfile35
-rw-r--r--Pipfile59
-rw-r--r--Pipfile.lock625
-rw-r--r--README.md12
-rw-r--r--azure-pipelines.yml108
-rw-r--r--bot/__init__.py16
-rw-r--r--bot/__main__.py34
-rw-r--r--bot/api.py152
-rw-r--r--bot/cogs/alias.py155
-rw-r--r--bot/cogs/antispam.py267
-rw-r--r--bot/cogs/bigbrother.py258
-rw-r--r--bot/cogs/bot.py113
-rw-r--r--bot/cogs/clean.py120
-rw-r--r--bot/cogs/cogs.py306
-rw-r--r--bot/cogs/defcon.py209
-rw-r--r--bot/cogs/deployment.py90
-rw-r--r--bot/cogs/doc.py155
-rw-r--r--bot/cogs/error_handler.py148
-rw-r--r--bot/cogs/eval.py26
-rw-r--r--bot/cogs/extensions.py236
-rw-r--r--bot/cogs/filtering.py262
-rw-r--r--bot/cogs/free.py106
-rw-r--r--bot/cogs/fun.py53
-rw-r--r--bot/cogs/help.py464
-rw-r--r--bot/cogs/information.py114
-rw-r--r--bot/cogs/jams.py114
-rw-r--r--bot/cogs/logging.py24
-rw-r--r--bot/cogs/moderation.py1243
-rw-r--r--bot/cogs/moderation/__init__.py25
-rw-r--r--bot/cogs/moderation/infractions.py607
-rw-r--r--bot/cogs/moderation/management.py268
-rw-r--r--bot/cogs/moderation/modlog.py (renamed from bot/cogs/modlog.py)234
-rw-r--r--bot/cogs/moderation/superstarify.py (renamed from bot/cogs/superstarify/__init__.py)164
-rw-r--r--bot/cogs/moderation/utils.py172
-rw-r--r--bot/cogs/nominations.py120
-rw-r--r--bot/cogs/off_topic_names.py112
-rw-r--r--bot/cogs/reddit.py137
-rw-r--r--bot/cogs/reminders.py126
-rw-r--r--bot/cogs/security.py21
-rw-r--r--bot/cogs/site.py123
-rw-r--r--bot/cogs/snakes.py1216
-rw-r--r--bot/cogs/snekbox.py53
-rw-r--r--bot/cogs/superstarify/stars.py86
-rw-r--r--bot/cogs/sync/__init__.py5
-rw-r--r--bot/cogs/sync/cog.py106
-rw-r--r--bot/cogs/sync/syncers.py63
-rw-r--r--bot/cogs/tags.py99
-rw-r--r--bot/cogs/token_remover.py47
-rw-r--r--bot/cogs/utils.py55
-rw-r--r--bot/cogs/verification.py106
-rw-r--r--bot/cogs/watchchannels/__init__.py18
-rw-r--r--bot/cogs/watchchannels/bigbrother.py118
-rw-r--r--bot/cogs/watchchannels/talentpool.py249
-rw-r--r--bot/cogs/watchchannels/watchchannel.py346
-rw-r--r--bot/cogs/wolfram.py119
-rw-r--r--bot/constants.py83
-rw-r--r--bot/converters.py270
-rw-r--r--bot/decorators.py181
-rw-r--r--bot/interpreter.py14
-rw-r--r--bot/pagination.py195
-rw-r--r--bot/patches/__init__.py6
-rw-r--r--bot/patches/message_edited_at.py32
-rw-r--r--bot/resources/snake_cards/backs/card_back1.jpgbin165788 -> 0 bytes
-rw-r--r--bot/resources/snake_cards/backs/card_back2.jpgbin140868 -> 0 bytes
-rw-r--r--bot/resources/snake_cards/card_bottom.pngbin18165 -> 0 bytes
-rw-r--r--bot/resources/snake_cards/card_frame.pngbin1460 -> 0 bytes
-rw-r--r--bot/resources/snake_cards/card_top.pngbin12581 -> 0 bytes
-rw-r--r--bot/resources/snake_cards/expressway.ttfbin156244 -> 0 bytes
-rw-r--r--bot/resources/snakes_and_ladders/banner.jpgbin17928 -> 0 bytes
-rw-r--r--bot/resources/snakes_and_ladders/board.jpgbin80264 -> 0 bytes
-rw-r--r--bot/resources/stars.json78
-rw-r--r--bot/rules/attachments.py12
-rw-r--r--bot/rules/burst.py8
-rw-r--r--bot/rules/burst_shared.py8
-rw-r--r--bot/rules/chars.py8
-rw-r--r--bot/rules/discord_emojis.py8
-rw-r--r--bot/rules/duplicates.py8
-rw-r--r--bot/rules/links.py8
-rw-r--r--bot/rules/mentions.py8
-rw-r--r--bot/rules/newlines.py31
-rw-r--r--bot/rules/role_mentions.py8
-rw-r--r--bot/utils/__init__.py117
-rw-r--r--bot/utils/checks.py44
-rw-r--r--bot/utils/messages.py85
-rw-r--r--bot/utils/moderation.py40
-rw-r--r--bot/utils/scheduling.py49
-rw-r--r--bot/utils/snakes/hatching.py44
-rw-r--r--bot/utils/snakes/perlin.py158
-rw-r--r--bot/utils/snakes/perlinsneks.py111
-rw-r--r--bot/utils/snakes/sal.py365
-rw-r--r--bot/utils/snakes/sal_board.py33
-rw-r--r--bot/utils/time.py52
-rw-r--r--config-default.yml91
-rw-r--r--docker-compose.yml44
-rw-r--r--docker/ci.Dockerfile20
-rw-r--r--scripts/deploy-azure.sh12
-rw-r--r--scripts/deploy.sh32
-rw-r--r--tests/cogs/sync/test_roles.py81
-rw-r--r--tests/cogs/test_antispam.py30
-rw-r--r--tests/cogs/test_information.py211
-rw-r--r--tests/cogs/test_security.py54
-rw-r--r--tests/cogs/test_token_remover.py133
-rw-r--r--tests/conftest.py32
-rw-r--r--tests/helpers.py33
-rw-r--r--tests/rules/__init__.py (renamed from bot/utils/snakes/__init__.py)0
-rw-r--r--tests/rules/test_attachments.py52
-rw-r--r--tests/test_api.py106
-rw-r--r--tests/test_constants.py23
-rw-r--r--tests/test_converters.py264
-rw-r--r--tests/test_pagination.py29
-rw-r--r--tests/test_resources.py13
-rw-r--r--tests/utils/__init__.py0
-rw-r--r--tests/utils/test_checks.py66
-rw-r--r--tests/utils/test_time.py62
-rw-r--r--tox.ini19
119 files changed, 6955 insertions, 6957 deletions
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 000000000..6d9919ef2
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1,2 @@
+patreon: python_discord
+custom: https://www.redbubble.com/people/pythondiscord
diff --git a/.gitignore b/.gitignore
index be4f43c7f..a191523b6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,7 +20,6 @@ lib64/
parts/
sdist/
var/
-wheels/
*.egg-info/
.installed.cfg
*.egg
@@ -114,3 +113,9 @@ log.*
# Custom user configuration
config.yml
+
+# JUnit XML reports from pytest
+junit.xml
+
+# Mac OS .DS_Store, which is a file that stores custom attributes of its containing folder
+.DS_Store
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 000000000..860357868
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,10 @@
+repos:
+- repo: local
+ hooks:
+ - id: flake8
+ name: Flake8
+ description: This hook runs flake8 within our project's pipenv environment.
+ entry: pipenv run lint
+ language: python
+ types: [python]
+ require_serial: true \ No newline at end of file
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 36152fc5d..39f76c7b4 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,40 +1,107 @@
# Contributing to one of our projects
-Our projects are open-source, and are deployed as commits are pushed to the `master` branch on each repository.
-We've created a set of guidelines here in order to keep everything clean and in working order. Please note that
-contributions may be rejected on the basis of a contributor failing to follow the guidelines.
+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.
+
+Note that contributions may be rejected on the basis of a contributor failing to follow these guidelines.
## Rules
1. **No force-pushes** or modifying the Git history in any way.
-1. If you have direct access to the repository, **create a branch for your changes** and create a merge request for that branch.
- If not, fork it and work on a separate branch there.
- * Some repositories require this and will reject any direct pushes to `master`. Make this a habit!
-1. If someone is working on a merge request, **do not open your own merge request for the same task**. Instead, leave some comments
- on the existing merge request. Communication is key, and there's no point in two separate implementations of the same thing.
- * One option is to fork the other contributor's repository, and submit your changes to their branch with your
- own merge request. If you do this, we suggest following these guidelines when interacting with their repository
- as well.
-1. **Adhere to the prevailing code style**, which we enforce using [flake8](http://flake8.pycqa.org/en/latest/index.html).
- * Additionally, run `flake8` against your code before you push it. Your commit will be rejected by the build server
- if it fails to lint.
-1. **Don't fight the framework**. Every framework has its flaws, but the frameworks we've picked out have been carefully
- chosen for their particular merits. If you can avoid it, please resist reimplementing swathes of framework logic - the
- work has already been done for you!
-1. **Work as a team** and cooperate where possible. Keep things friendly, and help each other out - these are shared
- projects, and nobody likes to have their feet trodden on.
-1. **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.
-
-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.
+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.
+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.
+ * 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.
+6. **Don't fight the framework**. Every framework has its flaws, but the frameworks we've picked out have been carefully chosen for their particular merits. If you can avoid it, please resist reimplementing swathes of framework logic - the work has already been done for you!
+7. If someone is working on an issue or pull request, **do not open your own pull request for the same task**. Instead, collaborate with the author(s) of the existing pull request. Duplicate PRs opened without communicating with the other author(s) and/or PyDis staff will be closed. Communication is key, and there's no point in two separate implementations of the same thing.
+ * 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**.
+ * 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.
## Changes to this arrangement
-All projects evolve over time, and this contribution guide is no different. This document may also be subject to pull
-requests or changes by contributors, where you believe you have something valuable to add or change.
+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.
+
+## Supplemental Information
+### Developer Environment
+Instructions for setting the bot developer environment can be found on the [PyDis wiki](https://pythondiscord.com/pages/contributing/bot/)
+
+To provide a standalone development environment for this project, docker compose is utilized to pull the current version of the [site backend](https://github.com/python-discord/site). While appropriate for bot-only contributions, any contributions that necessitate backend changes will require the site repository to be appropriately configured as well. Instructions for setting up the site environment can be found on the [PyDis site](https://pythondiscord.com/pages/contributing/site/).
+
+When pulling down changes from GitHub, remember to sync your environment using `pipenv sync --dev` to ensure you're using the most up-to-date versions the project's dependencies.
+
+### Type Hinting
+[PEP 484](https://www.python.org/dev/peps/pep-0484/) formally specifies type hints for Python functions, added to the Python Standard Library in version 3.5. Type hints are recognized by most modern code editing tools and provide useful insight into both the input and output types of a function, preventing the user from having to go through the codebase to determine these types.
+
+For example:
+
+```py
+def foo(input_1: int, input_2: dict) -> bool:
+```
+
+Tells us that `foo` accepts an `int` and a `dict` and returns a `bool`.
+
+All function declarations should be type hinted in code contributed to the PyDis organization.
+
+For more information, see *[PEP 483](https://www.python.org/dev/peps/pep-0483/) - The Theory of Type Hints* and Python's documentation for the [`typing`](https://docs.python.org/3/library/typing.html) module.
+
+### AutoDoc Formatting Directives
+Many documentation packages provide support for automatic documentation generation from the codebase's docstrings. These tools utilize special formatting directives to enable richer formatting in the generated documentation.
+
+For example:
+
+```py
+def foo(bar: int, baz: dict=None) -> bool:
+ """
+ Does some things with some stuff.
+
+ :param bar: Some input
+ :param baz: Optional, some other input
+
+ :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 (`` ` ``).
+
+For example, the above docstring would become:
+
+```py
+def foo(bar: int, baz: dict=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.
+
+### 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.
+
+This feature should be utilized in place of the traditional method of prepending `[WIP]` to the PR title.
+
+As stated earlier, **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.
## Footnotes
diff --git a/Dockerfile b/Dockerfile
index 864b4e557..271c25050 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,35 +1,20 @@
-FROM python:3.7-alpine3.7
+FROM python:3.7-slim
-RUN apk add --no-cache \
- build-base \
- freetype-dev \
- git \
- jpeg-dev \
- libffi-dev \
- libxml2 \
- libxml2-dev \
- libxslt-dev \
- tini \
- zlib \
- zlib-dev
-
-ENV \
- LIBRARY_PATH=/lib:/usr/lib \
- PIPENV_HIDE_EMOJIS=1 \
+# Set pip to have cleaner logs and no saved cache
+ENV PIP_NO_CACHE_DIR=false \
PIPENV_HIDE_EMOJIS=1 \
PIPENV_IGNORE_VIRTUALENVS=1 \
- PIPENV_IGNORE_VIRTUALENVS=1 \
- PIPENV_NOSPIN=1 \
- PIPENV_NOSPIN=1 \
- PIPENV_VENV_IN_PROJECT=1 \
- PIPENV_VENV_IN_PROJECT=1
+ PIPENV_NOSPIN=1
+# Install pipenv
RUN pip install -U pipenv
+# Copy project files into working directory
WORKDIR /bot
COPY . .
-RUN pipenv install --deploy --system
+# Install project dependencies
+RUN pipenv install --system --deploy
-ENTRYPOINT ["/sbin/tini", "--"]
-CMD ["pipenv", "run", "start"]
+ENTRYPOINT ["python3"]
+CMD ["-m", "bot"]
diff --git a/Pipfile b/Pipfile
index 0524f10bf..82847b23f 100644
--- a/Pipfile
+++ b/Pipfile
@@ -4,32 +4,36 @@ verify_ssl = true
name = "pypi"
[packages]
-discord-py = {git = "https://github.com/Rapptz/discord.py.git",extras = ["voice"],ref = "860d6a9ace8248dfeec18b8b159e7b757d9f56bb",editable = true}
-aiodns = "*"
-logmatic-python = "*"
-aiohttp = "*"
-sphinx = "*"
-markdownify = "*"
-lxml = "*"
-pyyaml = "*"
-fuzzywuzzy = "*"
-pillow = "*"
-aio-pika = "*"
-python-dateutil = "*"
-deepdiff = "*"
-requests = "*"
-dateparser = "*"
+discord-py = "~=1.2"
+aiodns = "~=2.0"
+logmatic-python = "~=0.1"
+aiohttp = "~=3.5"
+sphinx = "~=2.2"
+markdownify = "~=0.4"
+lxml = "~=4.4"
+pyyaml = "~=5.1"
+fuzzywuzzy = "~=0.17"
+aio-pika = "~=6.1"
+python-dateutil = "~=2.8"
+deepdiff = "~=4.0"
+requests = "~=2.22"
+more_itertools = "~=7.2"
+urllib3 = ">=1.24.2,<1.25"
[dev-packages]
-"flake8" = ">=3.6"
-"flake8-bugbear" = "*"
-"flake8-import-order" = "*"
-"flake8-tidy-imports" = "*"
-"flake8-todo" = "*"
-"flake8-string-format" = "*"
-safety = "*"
-dodgy = "*"
-pytest = "*"
+flake8 = "~=3.7"
+flake8-annotations = "~=1.1"
+flake8-bugbear = "~=19.8"
+flake8-docstrings = "~=1.4"
+flake8-import-order = "~=0.18"
+flake8-string-format = "~=0.2"
+flake8-tidy-imports = "~=2.0"
+flake8-todo = "~=0.7"
+pre-commit = "~=1.18"
+safety = "~=1.8"
+dodgy = "~=0.1"
+pytest = "~=5.1"
+pytest-cov = "~=2.7"
[requires]
python_version = "3.7"
@@ -37,9 +41,6 @@ python_version = "3.7"
[scripts]
start = "python -m bot"
lint = "python -m flake8"
-build = "docker build -t pythondiscord/bot:latest -f docker/bot.Dockerfile ."
+precommit = "pre-commit install"
+build = "docker build -t pythondiscord/bot:latest -f Dockerfile ."
push = "docker push pythondiscord/bot:latest"
-buildbase = "docker build -t pythondiscord/bot-base:latest -f docker/base.Dockerfile ."
-pushbase = "docker push pythondiscord/bot-base:latest"
-buildci = "docker build -t pythondiscord/bot-ci:latest -f docker/ci.Dockerfile ."
-pushci = "docker push pythondiscord/bot-ci:latest"
diff --git a/Pipfile.lock b/Pipfile.lock
index a3d489e31..4e6b4eaf8 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
- "sha256": "927aabf1a7c4b9e097c3521012e20e030c4f493298fbc489713b30eaff48e732"
+ "sha256": "c2537cc3c5b0886d0b38f9b48f4f4b93e1e74d925454aa71a2189bddedadde42"
},
"pipfile-spec": 6,
"requires": {
@@ -18,11 +18,11 @@
"default": {
"aio-pika": {
"hashes": [
- "sha256:300474d8b0e9ccde17b2d1e71c3b4f7ba86559cc0842b9355b9eccb12be4a02a",
- "sha256:3bc547600344beba8f36edfd1b1ec1c8b30f803ea7c11eaf249683099d07c98b"
+ "sha256:29f27a8092169924c9eefb0c5e428d216706618dc9caa75ddb7759638e16cf26",
+ "sha256:4f77ba9b6e7bc27fc88c49638bc3657ae5d4a2539e17fa0c2b25b370547b1b50"
],
"index": "pypi",
- "version": "==5.5.2"
+ "version": "==6.1.2"
},
"aiodns": {
"hashes": [
@@ -34,38 +34,38 @@
},
"aiohttp": {
"hashes": [
- "sha256:0419705a36b43c0ac6f15469f9c2a08cad5c939d78bd12a5c23ea167c8253b2b",
- "sha256:1812fc4bc6ac1bde007daa05d2d0f61199324e0cc893b11523e646595047ca08",
- "sha256:2214b5c0153f45256d5d52d1e0cafe53f9905ed035a142191727a5fb620c03dd",
- "sha256:275909137f0c92c61ba6bb1af856a522d5546f1de8ea01e4e726321c697754ac",
- "sha256:3983611922b561868428ea1e7269e757803713f55b53502423decc509fef1650",
- "sha256:51afec6ffa50a9da4cdef188971a802beb1ca8e8edb40fa429e5e529db3475fa",
- "sha256:589f2ec8a101a0f340453ee6945bdfea8e1cd84c8d88e5be08716c34c0799d95",
- "sha256:789820ddc65e1f5e71516adaca2e9022498fa5a837c79ba9c692a9f8f916c330",
- "sha256:7a968a0bdaaf9abacc260911775611c9a602214a23aeb846f2eb2eeaa350c4dc",
- "sha256:7aeefbed253f59ea39e70c5848de42ed85cb941165357fc7e87ab5d8f1f9592b",
- "sha256:7b2eb55c66512405103485bd7d285a839d53e7fdc261ab20e5bcc51d7aaff5de",
- "sha256:87bc95d3d333bb689c8d755b4a9d7095a2356108002149523dfc8e607d5d32a4",
- "sha256:9d80e40db208e29168d3723d1440ecbb06054d349c5ece6a2c5a611490830dd7",
- "sha256:a1b442195c2a77d33e4dbee67c9877ccbdd3a1f686f91eb479a9577ed8cc326b",
- "sha256:ab3d769413b322d6092f169f316f7b21cd261a7589f7e31db779d5731b0480d8",
- "sha256:b066d3dec5d0f5aee6e34e5765095dc3d6d78ef9839640141a2b20816a0642bd",
- "sha256:b24e7845ae8de3e388ef4bcfcf7f96b05f52c8e633b33cf8003a6b1d726fc7c2",
- "sha256:c59a953c3f8524a7c86eaeaef5bf702555be12f5668f6384149fe4bb75c52698",
- "sha256:cf2cc6c2c10d242790412bea7ccf73726a9a44b4c4b073d2699ef3b48971fd95",
- "sha256:e0c9c8d4150ae904f308ff27b35446990d2b1dfc944702a21925937e937394c6",
- "sha256:f1839db4c2b08a9c8f9788112644f8a8557e8e0ecc77b07091afabb941dc55d0",
- "sha256:f3df52362be39908f9c028a65490fae0475e4898b43a03d8aa29d1e765b45e07"
- ],
- "index": "pypi",
- "version": "==3.4.4"
+ "sha256:00d198585474299c9c3b4f1d5de1a576cc230d562abc5e4a0e81d71a20a6ca55",
+ "sha256:0155af66de8c21b8dba4992aaeeabf55503caefae00067a3b1139f86d0ec50ed",
+ "sha256:09654a9eca62d1bd6d64aa44db2498f60a5c1e0ac4750953fdd79d5c88955e10",
+ "sha256:199f1d106e2b44b6dacdf6f9245493c7d716b01d0b7fbe1959318ba4dc64d1f5",
+ "sha256:296f30dedc9f4b9e7a301e5cc963012264112d78a1d3094cd83ef148fdf33ca1",
+ "sha256:368ed312550bd663ce84dc4b032a962fcb3c7cae099dbbd48663afc305e3b939",
+ "sha256:40d7ea570b88db017c51392349cf99b7aefaaddd19d2c78368aeb0bddde9d390",
+ "sha256:629102a193162e37102c50713e2e31dc9a2fe7ac5e481da83e5bb3c0cee700aa",
+ "sha256:6d5ec9b8948c3d957e75ea14d41e9330e1ac3fed24ec53766c780f82805140dc",
+ "sha256:87331d1d6810214085a50749160196391a712a13336cd02ce1c3ea3d05bcf8d5",
+ "sha256:9a02a04bbe581c8605ac423ba3a74999ec9d8bce7ae37977a3d38680f5780b6d",
+ "sha256:9c4c83f4fa1938377da32bc2d59379025ceeee8e24b89f72fcbccd8ca22dc9bf",
+ "sha256:9cddaff94c0135ee627213ac6ca6d05724bfe6e7a356e5e09ec57bd3249510f6",
+ "sha256:a25237abf327530d9561ef751eef9511ab56fd9431023ca6f4803f1994104d72",
+ "sha256:a5cbd7157b0e383738b8e29d6e556fde8726823dae0e348952a61742b21aeb12",
+ "sha256:a97a516e02b726e089cffcde2eea0d3258450389bbac48cbe89e0f0b6e7b0366",
+ "sha256:acc89b29b5f4e2332d65cd1b7d10c609a75b88ef8925d487a611ca788432dfa4",
+ "sha256:b05bd85cc99b06740aad3629c2585bda7b83bd86e080b44ba47faf905fdf1300",
+ "sha256:c2bec436a2b5dafe5eaeb297c03711074d46b6eb236d002c13c42f25c4a8ce9d",
+ "sha256:cc619d974c8c11fe84527e4b5e1c07238799a8c29ea1c1285149170524ba9303",
+ "sha256:d4392defd4648badaa42b3e101080ae3313e8f4787cb517efd3f5b8157eaefd6",
+ "sha256:e1c3c582ee11af7f63a34a46f0448fca58e59889396ffdae1f482085061a2889"
+ ],
+ "index": "pypi",
+ "version": "==3.5.4"
},
"aiormq": {
"hashes": [
- "sha256:2e18576a90dfdaa91f705bd226506d9589353350f09b7121179c0bf5350a79a8",
- "sha256:be3e74b6f4a490ea1f3d393c186e98e8214cdde26f7073812b23fc002fff7383"
+ "sha256:c3e4dd01a2948a75f739fb637334dbb8c6f1a4cecf74d5ed662dc3bab7f39973",
+ "sha256:e220d3f9477bb2959b729b79bec815148ddb8a7686fc6c3d05d41c88ebd7c59e"
],
- "version": "==2.5.1"
+ "version": "==2.8.0"
},
"alabaster": {
"hashes": [
@@ -90,25 +90,25 @@
},
"babel": {
"hashes": [
- "sha256:6778d85147d5d85345c14a26aada5e478ab04e39b078b0745ee6870c2b5cf669",
- "sha256:8cba50f48c529ca3fa18cf81fa9403be176d374ac4d60738b839122dfaaa3d23"
+ "sha256:af92e6106cb7c55286b25b38ad7695f8b4efb36a90ba483d7f7a6628c46158ab",
+ "sha256:e86135ae101e31e2c8ec20a4e0c5220f4eed12487d5cf3f78be7e98d3a57fc28"
],
- "version": "==2.6.0"
+ "version": "==2.7.0"
},
"beautifulsoup4": {
"hashes": [
- "sha256:034740f6cb549b4e932ae1ab975581e6103ac8f942200a0e9759065984391858",
- "sha256:945065979fb8529dd2f37dbb58f00b661bdbcbebf954f93b32fdf5263ef35348",
- "sha256:ba6d5c59906a85ac23dadfe5c88deaf3e179ef565f4898671253e50a78680718"
+ "sha256:05668158c7b85b791c5abde53e50265e16f98ad601c402ba44d70f96c4159612",
+ "sha256:25288c9e176f354bf277c0a10aa96c782a6a18a17122dba2e8cec4a97e03343b",
+ "sha256:f040590be10520f2ea4c2ae8c3dae441c7cfff5308ec9d58a0ec0c1b8f81d469"
],
- "version": "==4.7.1"
+ "version": "==4.8.0"
},
"certifi": {
"hashes": [
- "sha256:59b7658e26ca9c7339e00f8f4636cdfe59d34fa37b9b04f6f9e9926b3cece1a5",
- "sha256:b26104d6835d1f5e49452a26eb2ff87fe7090b89dfcaee5ea2212697e1e1d7ae"
+ "sha256:e4f3620cfea4f83eedc95b24abd9cd56f3c4b146dd0177e83a21b4eb49e21e50",
+ "sha256:fd7c7c74727ddcf00e9acd26bba8da604ffec95bf1c2144e67aff7a8b50e6cef"
],
- "version": "==2019.3.9"
+ "version": "==2019.9.11"
},
"cffi": {
"hashes": [
@@ -155,40 +155,30 @@
"sha256:05eed71e2e327246ad6b38c540c4a3117230b19679b875190486ddd2d721422d",
"sha256:f8ac84de7840f5b9c4e3347b3c1eaa50f7e49c2b07596221daec5edaabbd7c48"
],
- "markers": "sys_platform == 'win32'",
"version": "==0.4.1"
},
- "dateparser": {
- "hashes": [
- "sha256:42d51be54e74a8e80a4d76d1fa6e4edd997098fce24ad2d94a2eab5ef247193e",
- "sha256:78124c458c461ea7198faa3c038f6381f37588b84bb42740e91a4cbd260b1d09"
- ],
- "index": "pypi",
- "version": "==0.7.1"
- },
"deepdiff": {
"hashes": [
- "sha256:55e461f56dcae3dc540746b84434562fb7201e5c27ecf28800e4cfdd17f61e56",
- "sha256:856966b80109df002a1ee406ba21cd66e64746167b2ea8f5353d692762326ac9"
+ "sha256:1123762580af0904621136d117c8397392a244d3ff0fa0a50de57a7939582476",
+ "sha256:6ab13e0cbb627dadc312deaca9bef38de88a737a9bbdbfbe6e3857748219c127"
],
"index": "pypi",
- "version": "==4.0.6"
+ "version": "==4.0.7"
},
"discord-py": {
- "editable": true,
- "extras": [
- "voice"
+ "hashes": [
+ "sha256:4684733fa137cc7def18087ae935af615212e423e3dbbe3e84ef01d7ae8ed17d"
],
- "git": "https://github.com/Rapptz/discord.py.git",
- "ref": "860d6a9ace8248dfeec18b8b159e7b757d9f56bb"
+ "index": "pypi",
+ "version": "==1.2.3"
},
"docutils": {
"hashes": [
- "sha256:02aec4bd92ab067f6ff27a38a38a41173bf01bed8f89157768c1573f53e474a6",
- "sha256:51e64ef2ebfb29cae1faa133b3710143496eca21c530f3f71424d77687764274",
- "sha256:7a4bd47eaf6596e1295ecb11361139febe29b084a87bf005bf899f9a42edc3c6"
+ "sha256:6c4f696463b79f1fb8ba0c594b63840ebd41f059e92b31957c46b74a4599b6d0",
+ "sha256:9e4d7ecfc600058e07ba661411a2b7de2fd0fafa17d1a7f7361cd47b1175c827",
+ "sha256:a2aeea129088da402665e92e0b25b04b073c04b2dce4ab65caaa38b7ce2e1a99"
],
- "version": "==0.14"
+ "version": "==0.15.2"
},
"fuzzywuzzy": {
"hashes": [
@@ -205,13 +195,6 @@
],
"version": "==2.8"
},
- "idna-ssl": {
- "hashes": [
- "sha256:a933e3bb13da54383f9e8f35dc4f9cb9eb9b3b78c6b36f311254d6d0d92c6c7c"
- ],
- "markers": "python_version < '3.7'",
- "version": "==1.1.0"
- },
"imagesize": {
"hashes": [
"sha256:3f349de3eb99145973fefb7dbe38554414e5c30abd0c8e4b970a7c9d09f3a1d8",
@@ -228,10 +211,10 @@
},
"jsonpickle": {
"hashes": [
- "sha256:0231d6f7ebc4723169310141352d9c9b7bbbd6f3be110cf634575d2bf2af91f0",
- "sha256:625098cc8e5854b8c23b587aec33bc8e33e0e597636bfaca76152249c78fe5c1"
+ "sha256:d0c5a4e6cb4e58f6d5406bdded44365c2bcf9c836c4f52910cc9ba7245a59dc2",
+ "sha256:d3e922d781b1d0096df2dad89a2e1f47177d7969b596aea806a9d91b4626b29b"
],
- "version": "==1.1"
+ "version": "==1.2"
},
"logmatic-python": {
"hashes": [
@@ -242,35 +225,31 @@
},
"lxml": {
"hashes": [
- "sha256:03984196d00670b2ab14ae0ea83d5cc0cfa4f5a42558afa9ab5fa745995328f5",
- "sha256:0815b0c9f897468de6a386dc15917a0becf48cc92425613aa8bbfc7f0f82951f",
- "sha256:175f3825f075cf02d15099eb52658457cf0ff103dcf11512b5d2583e1d40f58b",
- "sha256:30e14c62d88d1e01a26936ecd1c6e784d4afc9aa002bba4321c5897937112616",
- "sha256:3210da6f36cf4b835ff1be853962b22cc354d506f493b67a4303c88bbb40d57b",
- "sha256:40f60819fbd5bad6e191ba1329bfafa09ab7f3f174b3d034d413ef5266963294",
- "sha256:43b26a865a61549919f8a42e094dfdb62847113cf776d84bd6b60e4e3fc20ea3",
- "sha256:4a03dd682f8e35a10234904e0b9508d705ff98cf962c5851ed052e9340df3d90",
- "sha256:62f382cddf3d2e52cf266e161aa522d54fd624b8cc567bc18f573d9d50d40e8e",
- "sha256:7b98f0325be8450da70aa4a796c4f06852949fe031878b4aa1d6c417a412f314",
- "sha256:846a0739e595871041385d86d12af4b6999f921359b38affb99cdd6b54219a8f",
- "sha256:a3080470559938a09a5d0ec558c005282e99ac77bf8211fb7b9a5c66390acd8d",
- "sha256:ad841b78a476623955da270ab8d207c3c694aa5eba71f4792f65926dc46c6ee8",
- "sha256:afdd75d9735e44c639ffd6258ce04a2de3b208f148072c02478162d0944d9da3",
- "sha256:b4fbf9b552faff54742bcd0791ab1da5863363fb19047e68f6592be1ac2dab33",
- "sha256:b90c4e32d6ec089d3fa3518436bdf5ce4d902a0787dbd9bb09f37afe8b994317",
- "sha256:b91cfe4438c741aeff662d413fd2808ac901cc6229c838236840d11de4586d63",
- "sha256:bdb0593a42070b0a5f138b79b872289ee73c8e25b3f0bea6564e795b55b6bcdd",
- "sha256:c4e4bca2bb68ce22320297dfa1a7bf070a5b20bcbaec4ee023f83d2f6e76496f",
- "sha256:cec4ab14af9eae8501be3266ff50c3c2aecc017ba1e86c160209bb4f0423df6a",
- "sha256:e83b4b2bf029f5104bc1227dbb7bf5ace6fd8fabaebffcd4f8106fafc69fc45f",
- "sha256:e995b3734a46d41ae60b6097f7c51ba9958648c6d1e0935b7e0ee446ee4abe22",
- "sha256:f679d93dec7f7210575c85379a31322df4c46496f184ef650d3aba1484b38a2d",
- "sha256:fd213bb5166e46974f113c8228daaef1732abc47cb561ce9c4c8eaed4bd3b09b",
- "sha256:fdcb57b906dbc1f80666e6290e794ab8fb959a2e17aa5aee1758a85d1da4533f",
- "sha256:ff424b01d090ffe1947ec7432b07f536912e0300458f9a7f48ea217dd8362b86"
- ],
- "index": "pypi",
- "version": "==4.3.3"
+ "sha256:02ca7bf899da57084041bb0f6095333e4d239948ad3169443f454add9f4e9cb4",
+ "sha256:096b82c5e0ea27ce9138bcbb205313343ee66a6e132f25c5ed67e2c8d960a1bc",
+ "sha256:0a920ff98cf1aac310470c644bc23b326402d3ef667ddafecb024e1713d485f1",
+ "sha256:17cae1730a782858a6e2758fd20dd0ef7567916c47757b694a06ffafdec20046",
+ "sha256:17e3950add54c882e032527795c625929613adbd2ce5162b94667334458b5a36",
+ "sha256:1f4f214337f6ee5825bf90a65d04d70aab05526c08191ab888cb5149501923c5",
+ "sha256:2e8f77db25b0a96af679e64ff9bf9dddb27d379c9900c3272f3041c4d1327c9d",
+ "sha256:4dffd405390a45ecb95ab5ab1c1b847553c18b0ef8ed01e10c1c8b1a76452916",
+ "sha256:6b899931a5648862c7b88c795eddff7588fb585e81cecce20f8d9da16eff96e0",
+ "sha256:726c17f3e0d7a7200718c9a890ccfeab391c9133e363a577a44717c85c71db27",
+ "sha256:760c12276fee05c36f95f8040180abc7fbebb9e5011447a97cdc289b5d6ab6fc",
+ "sha256:796685d3969815a633827c818863ee199440696b0961e200b011d79b9394bbe7",
+ "sha256:891fe897b49abb7db470c55664b198b1095e4943b9f82b7dcab317a19116cd38",
+ "sha256:a471628e20f03dcdfde00770eeaf9c77811f0c331c8805219ca7b87ac17576c5",
+ "sha256:a63b4fd3e2cabdcc9d918ed280bdde3e8e9641e04f3c59a2a3109644a07b9832",
+ "sha256:b0b84408d4eabc6de9dd1e1e0bc63e7731e890c0b378a62443e5741cfd0ae90a",
+ "sha256:be78485e5d5f3684e875dab60f40cddace2f5b2a8f7fede412358ab3214c3a6f",
+ "sha256:c27eaed872185f047bb7f7da2d21a7d8913457678c9a100a50db6da890bc28b9",
+ "sha256:c81cb40bff373ab7a7446d6bbca0190bccc5be3448b47b51d729e37799bb5692",
+ "sha256:d11874b3c33ee441059464711cd365b89fa1a9cf19ae75b0c189b01fbf735b84",
+ "sha256:e9c028b5897901361d81a4718d1db217b716424a0283afe9d6735fe0caf70f79",
+ "sha256:fe489d486cd00b739be826e8c1be188ddb74c7a1ca784d93d06fda882a6a1681"
+ ],
+ "index": "pypi",
+ "version": "==4.4.1"
},
"markdownify": {
"hashes": [
@@ -312,6 +291,14 @@
],
"version": "==1.1.1"
},
+ "more-itertools": {
+ "hashes": [
+ "sha256:409cd48d4db7052af495b09dec721011634af3753ae1ef92d2b32f73a745f832",
+ "sha256:92b8c4b06dac4f0611c0729b2f2ede52b2e1bac1ab48f089c7ddc12e26bb60c4"
+ ],
+ "index": "pypi",
+ "version": "==7.2.0"
+ },
"multidict": {
"hashes": [
"sha256:024b8129695a952ebd93373e45b5d341dbb87c17ce49637b34000093f243dd4f",
@@ -354,10 +341,10 @@
},
"packaging": {
"hashes": [
- "sha256:0c98a5d0be38ed775798ece1b9727178c4469d9c3b4ada66e8e6b7849f8732af",
- "sha256:9e1cbf8c12b1f1ce0bb5344b8d7ecf66a6f8a6e91bcb0c84593ed6d3ab5c4ab3"
+ "sha256:28b924174df7a2fa32c1953825ff29c61e2f5e082343165438812f00d3a7fc47",
+ "sha256:d9551545c6d761f3def1677baf08ab2a3ca17c56879e70fecba2fc4dde4ed108"
],
- "version": "==19.0"
+ "version": "==19.2"
},
"pamqp": {
"hashes": [
@@ -366,38 +353,6 @@
],
"version": "==2.3.0"
},
- "pillow": {
- "hashes": [
- "sha256:15c056bfa284c30a7f265a41ac4cbbc93bdbfc0dfe0613b9cb8a8581b51a9e55",
- "sha256:1a4e06ba4f74494ea0c58c24de2bb752818e9d504474ec95b0aa94f6b0a7e479",
- "sha256:1c3c707c76be43c9e99cb7e3d5f1bee1c8e5be8b8a2a5eeee665efbf8ddde91a",
- "sha256:1fd0b290203e3b0882d9605d807b03c0f47e3440f97824586c173eca0aadd99d",
- "sha256:24114e4a6e1870c5a24b1da8f60d0ba77a0b4027907860188ea82bd3508c80eb",
- "sha256:258d886a49b6b058cd7abb0ab4b2b85ce78669a857398e83e8b8e28b317b5abb",
- "sha256:33c79b6dd6bc7f65079ab9ca5bebffb5f5d1141c689c9c6a7855776d1b09b7e8",
- "sha256:367385fc797b2c31564c427430c7a8630db1a00bd040555dfc1d5c52e39fcd72",
- "sha256:3c1884ff078fb8bf5f63d7d86921838b82ed4a7d0c027add773c2f38b3168754",
- "sha256:44e5240e8f4f8861d748f2a58b3f04daadab5e22bfec896bf5434745f788f33f",
- "sha256:46aa988e15f3ea72dddd81afe3839437b755fffddb5e173886f11460be909dce",
- "sha256:74d90d499c9c736d52dd6d9b7221af5665b9c04f1767e35f5dd8694324bd4601",
- "sha256:809c0a2ce9032cbcd7b5313f71af4bdc5c8c771cb86eb7559afd954cab82ebb5",
- "sha256:85d1ef2cdafd5507c4221d201aaf62fc9276f8b0f71bd3933363e62a33abc734",
- "sha256:8c3889c7681af77ecfa4431cd42a2885d093ecb811e81fbe5e203abc07e0995b",
- "sha256:9218d81b9fca98d2c47d35d688a0cea0c42fd473159dfd5612dcb0483c63e40b",
- "sha256:9aa4f3827992288edd37c9df345783a69ef58bd20cc02e64b36e44bcd157bbf1",
- "sha256:9d80f44137a70b6f84c750d11019a3419f409c944526a95219bea0ac31f4dd91",
- "sha256:b7ebd36128a2fe93991293f997e44be9286503c7530ace6a55b938b20be288d8",
- "sha256:c4c78e2c71c257c136cdd43869fd3d5e34fc2162dc22e4a5406b0ebe86958239",
- "sha256:c6a842537f887be1fe115d8abb5daa9bc8cc124e455ff995830cc785624a97af",
- "sha256:cf0a2e040fdf5a6d95f4c286c6ef1df6b36c218b528c8a9158ec2452a804b9b8",
- "sha256:cfd28aad6fc61f7a5d4ee556a997dc6e5555d9381d1390c00ecaf984d57e4232",
- "sha256:dca5660e25932771460d4688ccbb515677caaf8595f3f3240ec16c117deff89a",
- "sha256:de7aedc85918c2f887886442e50f52c1b93545606317956d65f342bd81cb4fc3",
- "sha256:e6c0bbf8e277b74196e3140c35f9a1ae3eafd818f7f2d3a15819c49135d6c062"
- ],
- "index": "pypi",
- "version": "==6.0.0"
- },
"pycares": {
"hashes": [
"sha256:2ca080db265ea238dc45f997f94effb62b979a617569889e265c26a839ed6305",
@@ -424,53 +379,17 @@
},
"pygments": {
"hashes": [
- "sha256:5ffada19f6203563680669ee7f53b64dabbeb100eb51b61996085e99c03b284a",
- "sha256:e8218dd399a61674745138520d0d4cf2621d7e032439341bc3f647bff125818d"
- ],
- "version": "==2.3.1"
- },
- "pynacl": {
- "hashes": [
- "sha256:04e30e5bdeeb2d5b34107f28cd2f5bbfdc6c616f3be88fc6f53582ff1669eeca",
- "sha256:0bfa0d94d2be6874e40f896e0a67e290749151e7de767c5aefbad1121cad7512",
- "sha256:11aa4e141b2456ce5cecc19c130e970793fa3a2c2e6fbb8ad65b28f35aa9e6b6",
- "sha256:13bdc1fe084ff9ac7653ae5a924cae03bf4bb07c6667c9eb5b6eb3c570220776",
- "sha256:14339dc233e7a9dda80a3800e64e7ff89d0878ba23360eea24f1af1b13772cac",
- "sha256:1d33e775fab3f383167afb20b9927aaf4961b953d76eeb271a5703a6d756b65b",
- "sha256:2a42b2399d0428619e58dac7734838102d35f6dcdee149e0088823629bf99fbb",
- "sha256:2dce05ac8b3c37b9e2f65eab56c544885607394753e9613fd159d5e2045c2d98",
- "sha256:63cfccdc6217edcaa48369191ae4dca0c390af3c74f23c619e954973035948cd",
- "sha256:6453b0dae593163ffc6db6f9c9c1597d35c650598e2c39c0590d1757207a1ac2",
- "sha256:73a5a96fb5fbf2215beee2353a128d382dbca83f5341f0d3c750877a236569ef",
- "sha256:8abb4ef79161a5f58848b30ab6fb98d8c466da21fdd65558ce1d7afc02c70b5f",
- "sha256:8ac1167195b32a8755de06efd5b2d2fe76fc864517dab66aaf65662cc59e1988",
- "sha256:8f505f42f659012794414fa57c498404e64db78f1d98dfd40e318c569f3c783b",
- "sha256:9c8a06556918ee8e3ab48c65574f318f5a0a4d31437fc135da7ee9d4f9080415",
- "sha256:a1e25fc5650cf64f01c9e435033e53a4aca9de30eb9929d099f3bb078e18f8f2",
- "sha256:be71cd5fce04061e1f3d39597f93619c80cdd3558a6c9ba99a546f144a8d8101",
- "sha256:c5b1a7a680218dee9da0f1b5e24072c46b3c275d35712bc1d505b85bb03441c0",
- "sha256:cb785db1a9468841a1265c9215c60fe5d7af2fb1b209e3316a152704607fc582",
- "sha256:cf6877124ae6a0698404e169b3ba534542cfbc43f939d46b927d956daf0a373a",
- "sha256:d0eb5b2795b7ee2cbcfcadacbe95a13afbda048a262bd369da9904fecb568975",
- "sha256:d3a934e2b9f20abac009d5b6951067cfb5486889cb913192b4d8288b216842f1",
- "sha256:d795f506bcc9463efb5ebb0f65ed77921dcc9e0a50499dedd89f208445de9ecb",
- "sha256:d8aaf7e5d6b0e0ef7d6dbf7abeb75085713d0100b4eb1a4e4e857de76d77ac45",
- "sha256:de2aaca8386cf4d70f1796352f2346f48ddb0bed61dc43a3ce773ba12e064031",
- "sha256:e0d38fa0a75f65f556fb912f2c6790d1fa29b7dd27a1d9cc5591b281321eaaa9",
- "sha256:eb2acabbd487a46b38540a819ef67e477a674481f84a82a7ba2234b9ba46f752",
- "sha256:eeee629828d0eb4f6d98ac41e9a3a6461d114d1d0aa111a8931c049359298da0",
- "sha256:f5836463a3c0cca300295b229b6c7003c415a9d11f8f9288ddbd728e2746524c",
- "sha256:f5ce9e26d25eb0b2d96f3ef0ad70e1d3ae89b5d60255c462252a3e456a48c053",
- "sha256:fabf73d5d0286f9e078774f3435601d2735c94ce9e514ac4fb945701edead7e4"
- ],
- "version": "==1.2.1"
+ "sha256:71e430bc85c88a430f000ac1d9b331d2407f681d6f6aec95e8bcfbc3df5b0127",
+ "sha256:881c4c157e45f30af185c1ffe8d549d48ac9127433f2c380c24b84572ad66297"
+ ],
+ "version": "==2.4.2"
},
"pyparsing": {
"hashes": [
- "sha256:1873c03321fc118f4e9746baf201ff990ceb915f433f23b395f5580d1840cb2a",
- "sha256:9b6323ef4ab914af344ba97510e966d64ba91055d6b9afa6b30799340e89cc03"
+ "sha256:6f98a7b9397e206d78cc01df10131398f1c8b8510a2f4d97d9abd82e1aacdd80",
+ "sha256:d9338df12903bbf5d65a0e4e87c2161968b10d2e489652bb47001d82a9b028b4"
],
- "version": "==2.4.0"
+ "version": "==2.4.2"
},
"python-dateutil": {
"hashes": [
@@ -488,49 +407,37 @@
},
"pytz": {
"hashes": [
- "sha256:303879e36b721603cc54604edcac9d20401bdbe31e1e4fdee5b9f98d5d31dfda",
- "sha256:d747dd3d23d77ef44c6a3526e274af6efeb0a6f1afd5a69ba4d5be4098c8e141"
+ "sha256:26c0b32e437e54a18161324a2fca3c4b9846b74a8dccddd843113109e1116b32",
+ "sha256:c894d57500a4cd2d5c71114aaab77dbab5eabd9022308ce5ac9bb93a60a6f0c7"
],
- "version": "==2019.1"
+ "version": "==2019.2"
},
"pyyaml": {
"hashes": [
- "sha256:1adecc22f88d38052fb787d959f003811ca858b799590a5eaa70e63dca50308c",
- "sha256:436bc774ecf7c103814098159fbb84c2715d25980175292c648f2da143909f95",
- "sha256:460a5a4248763f6f37ea225d19d5c205677d8d525f6a83357ca622ed541830c2",
- "sha256:5a22a9c84653debfbf198d02fe592c176ea548cccce47553f35f466e15cf2fd4",
- "sha256:7a5d3f26b89d688db27822343dfa25c599627bc92093e788956372285c6298ad",
- "sha256:9372b04a02080752d9e6f990179a4ab840227c6e2ce15b95e1278456664cf2ba",
- "sha256:a5dcbebee834eaddf3fa7366316b880ff4062e4bcc9787b78c7fbb4a26ff2dd1",
- "sha256:aee5bab92a176e7cd034e57f46e9df9a9862a71f8f37cad167c6fc74c65f5b4e",
- "sha256:c51f642898c0bacd335fc119da60baae0824f2cde95b0330b56c0553439f0673",
- "sha256:c68ea4d3ba1705da1e0d85da6684ac657912679a649e8868bd850d2c299cce13",
- "sha256:e23d0cc5299223dcc37885dae624f382297717e459ea24053709675a976a3e19"
+ "sha256:0113bc0ec2ad727182326b61326afa3d1d8280ae1122493553fd6f4397f33df9",
+ "sha256:01adf0b6c6f61bd11af6e10ca52b7d4057dd0be0343eb9283c878cf3af56aee4",
+ "sha256:5124373960b0b3f4aa7df1707e63e9f109b5263eca5976c66e08b1c552d4eaf8",
+ "sha256:5ca4f10adbddae56d824b2c09668e91219bb178a1eee1faa56af6f99f11bf696",
+ "sha256:7907be34ffa3c5a32b60b95f4d95ea25361c951383a894fec31be7252b2b6f34",
+ "sha256:7ec9b2a4ed5cad025c2278a1e6a19c011c80a3caaac804fd2d329e9cc2c287c9",
+ "sha256:87ae4c829bb25b9fe99cf71fbb2140c448f534e24c998cc60f39ae4f94396a73",
+ "sha256:9de9919becc9cc2ff03637872a440195ac4241c80536632fffeb6a1e25a74299",
+ "sha256:a5a85b10e450c66b49f98846937e8cfca1db3127a9d5d1e31ca45c3d0bef4c5b",
+ "sha256:b0997827b4f6a7c286c01c5f60384d218dca4ed7d9efa945c3e1aa623d5709ae",
+ "sha256:b631ef96d3222e62861443cc89d6563ba3eeb816eeb96b2629345ab795e53681",
+ "sha256:bf47c0607522fdbca6c9e817a6e81b08491de50f3766a7a0e6a5be7905961b41",
+ "sha256:f81025eddd0327c7d4cfe9b62cf33190e1e736cc6e97502b3ec425f574b3e7a8"
],
"index": "pypi",
- "version": "==5.1"
- },
- "regex": {
- "hashes": [
- "sha256:020429dcf9b76cc7648a99c81b3a70154e45afebc81e0b85364457fe83b525e4",
- "sha256:0552802b1c3f3c7e4fee8c85e904a13c48226020aa1a0593246888a1ac55aaaf",
- "sha256:308965a80b92e1fec263ac1e4f1094317809a72bc4d26be2ec8a5fd026301175",
- "sha256:4d627feef04eb626397aa7bdec772774f53d63a1dc7cc5ee4d1bd2786a769d19",
- "sha256:93d1f9fcb1d25e0b4bd622eeba95b080262e7f8f55e5b43c76b8a5677e67334c",
- "sha256:c3859bbf29b1345d694f069ddfe53d6907b0393fda5e3794c800ad02902d78e9",
- "sha256:d56ce4c7b1a189094b9bee3b81c4aeb3f1ba3e375e91627ec8561b6ab483d0a8",
- "sha256:ebc5ef4e10fa3312fa1967dc0a894e6bd985a046768171f042ac3974fadc9680",
- "sha256:f9cd39066048066a4abe4c18fb213bc541339728005e72263f023742fb912585"
- ],
- "version": "==2019.4.14"
+ "version": "==5.1.2"
},
"requests": {
"hashes": [
- "sha256:502a824f31acdacb3a35b6690b5fbf0bc41d63a24a45c4004352b0242707598e",
- "sha256:7bf2a778576d825600030a110f3c0e3e8edc51dfaafe1c146e39a2027784957b"
+ "sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4",
+ "sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31"
],
"index": "pypi",
- "version": "==2.21.0"
+ "version": "==2.22.0"
},
"six": {
"hashes": [
@@ -541,25 +448,24 @@
},
"snowballstemmer": {
"hashes": [
- "sha256:919f26a68b2c17a7634da993d91339e288964f93c274f1343e3bbbe2096e1128",
- "sha256:9f3bcd3c401c3e862ec0ebe6d2c069ebc012ce142cce209c098ccb5b09136e89"
+ "sha256:713e53b79cbcf97bc5245a06080a33d54a77e7cce2f789c835a143bcdb5c033e"
],
- "version": "==1.2.1"
+ "version": "==1.9.1"
},
"soupsieve": {
"hashes": [
- "sha256:6898e82ecb03772a0d82bd0d0a10c0d6dcc342f77e0701d0ec4a8271be465ece",
- "sha256:b20eff5e564529711544066d7dc0f7661df41232ae263619dede5059799cdfca"
+ "sha256:605f89ad5fdbfefe30cdc293303665eff2d188865d4dbe4eb510bba1edfbfce3",
+ "sha256:b91d676b330a0ebd5b21719cb6e9b57c57d433671f65b9c28dd3461d9a1ed0b6"
],
- "version": "==1.9.1"
+ "version": "==1.9.4"
},
"sphinx": {
"hashes": [
- "sha256:423280646fb37944dd3c85c58fb92a20d745793a9f6c511f59da82fa97cd404b",
- "sha256:de930f42600a4fef993587633984cc5027dedba2464bcf00ddace26b40f8d9ce"
+ "sha256:0d586b0f8c2fc3cc6559c5e8fd6124628110514fda0e5d7c82e682d749d2e845",
+ "sha256:839a3ed6f6b092bb60f492024489cc9e6991360fb9f52ed6361acd510d261069"
],
"index": "pypi",
- "version": "==2.0.1"
+ "version": "==2.2.0"
},
"sphinxcontrib-applehelp": {
"hashes": [
@@ -603,27 +509,13 @@
],
"version": "==1.1.3"
},
- "typing": {
- "hashes": [
- "sha256:4027c5f6127a6267a435201981ba156de91ad0d1d98e9ddc2aa173453453492d",
- "sha256:57dcf675a99b74d64dacf6fba08fb17cf7e3d5fdff53d4a30ea2a5e7e52543d4",
- "sha256:a4c8473ce11a65999c8f59cb093e70686b6c84c98df58c1dae9b3b196089858a"
- ],
- "markers": "python_version < '3.7'",
- "version": "==3.6.6"
- },
- "tzlocal": {
- "hashes": [
- "sha256:4ebeb848845ac898da6519b9b31879cf13b6626f7184c496037b818e238f2c4e"
- ],
- "version": "==1.5.1"
- },
"urllib3": {
"hashes": [
- "sha256:4c291ca23bbb55c76518905869ef34bdd5f0e46af7afe6861e8375643ffee1a0",
- "sha256:9a247273df709c4fedb38c711e44292304f73f39ab01beda9f6b9fc375669ac3"
+ "sha256:2393a695cd12afedd0dcb26fe5d50d0cf248e5a66f75dbd89a3d4eb333a61af4",
+ "sha256:a637e5fae88995b256e3409dc4d52c2e2e0ba32c42a6365fee8bbd2238de3cfb"
],
- "version": "==1.24.2"
+ "index": "pypi",
+ "version": "==1.24.3"
},
"websockets": {
"hashes": [
@@ -669,6 +561,13 @@
}
},
"develop": {
+ "aspy.yaml": {
+ "hashes": [
+ "sha256:463372c043f70160a9ec950c3f1e4c3a82db5fca01d334b6bc89c7164d744bdc",
+ "sha256:e7c742382eff2caed61f87a39d13f99109088e5e93f04d76eb8d4b28aa143f45"
+ ],
+ "version": "==1.3.0"
+ },
"atomicwrites": {
"hashes": [
"sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4",
@@ -685,10 +584,17 @@
},
"certifi": {
"hashes": [
- "sha256:59b7658e26ca9c7339e00f8f4636cdfe59d34fa37b9b04f6f9e9926b3cece1a5",
- "sha256:b26104d6835d1f5e49452a26eb2ff87fe7090b89dfcaee5ea2212697e1e1d7ae"
+ "sha256:e4f3620cfea4f83eedc95b24abd9cd56f3c4b146dd0177e83a21b4eb49e21e50",
+ "sha256:fd7c7c74727ddcf00e9acd26bba8da604ffec95bf1c2144e67aff7a8b50e6cef"
+ ],
+ "version": "==2019.9.11"
+ },
+ "cfgv": {
+ "hashes": [
+ "sha256:edb387943b665bf9c434f717bf630fa78aecd53d5900d2e05da6ad6048553144",
+ "sha256:fbd93c9ab0a523bf7daec408f3be2ed99a980e20b2d19b50fc184ca6b820d289"
],
- "version": "==2019.3.9"
+ "version": "==2.0.1"
},
"chardet": {
"hashes": [
@@ -709,9 +615,45 @@
"sha256:05eed71e2e327246ad6b38c540c4a3117230b19679b875190486ddd2d721422d",
"sha256:f8ac84de7840f5b9c4e3347b3c1eaa50f7e49c2b07596221daec5edaabbd7c48"
],
- "markers": "sys_platform == 'win32'",
"version": "==0.4.1"
},
+ "coverage": {
+ "hashes": [
+ "sha256:08907593569fe59baca0bf152c43f3863201efb6113ecb38ce7e97ce339805a6",
+ "sha256:0be0f1ed45fc0c185cfd4ecc19a1d6532d72f86a2bac9de7e24541febad72650",
+ "sha256:141f08ed3c4b1847015e2cd62ec06d35e67a3ac185c26f7635f4406b90afa9c5",
+ "sha256:19e4df788a0581238e9390c85a7a09af39c7b539b29f25c89209e6c3e371270d",
+ "sha256:23cc09ed395b03424d1ae30dcc292615c1372bfba7141eb85e11e50efaa6b351",
+ "sha256:245388cda02af78276b479f299bbf3783ef0a6a6273037d7c60dc73b8d8d7755",
+ "sha256:331cb5115673a20fb131dadd22f5bcaf7677ef758741312bee4937d71a14b2ef",
+ "sha256:386e2e4090f0bc5df274e720105c342263423e77ee8826002dcffe0c9533dbca",
+ "sha256:3a794ce50daee01c74a494919d5ebdc23d58873747fa0e288318728533a3e1ca",
+ "sha256:60851187677b24c6085248f0a0b9b98d49cba7ecc7ec60ba6b9d2e5574ac1ee9",
+ "sha256:63a9a5fc43b58735f65ed63d2cf43508f462dc49857da70b8980ad78d41d52fc",
+ "sha256:6b62544bb68106e3f00b21c8930e83e584fdca005d4fffd29bb39fb3ffa03cb5",
+ "sha256:6ba744056423ef8d450cf627289166da65903885272055fb4b5e113137cfa14f",
+ "sha256:7494b0b0274c5072bddbfd5b4a6c6f18fbbe1ab1d22a41e99cd2d00c8f96ecfe",
+ "sha256:826f32b9547c8091679ff292a82aca9c7b9650f9fda3e2ca6bf2ac905b7ce888",
+ "sha256:93715dffbcd0678057f947f496484e906bf9509f5c1c38fc9ba3922893cda5f5",
+ "sha256:9a334d6c83dfeadae576b4d633a71620d40d1c379129d587faa42ee3e2a85cce",
+ "sha256:af7ed8a8aa6957aac47b4268631fa1df984643f07ef00acd374e456364b373f5",
+ "sha256:bf0a7aed7f5521c7ca67febd57db473af4762b9622254291fbcbb8cd0ba5e33e",
+ "sha256:bf1ef9eb901113a9805287e090452c05547578eaab1b62e4ad456fcc049a9b7e",
+ "sha256:c0afd27bc0e307a1ffc04ca5ec010a290e49e3afbe841c5cafc5c5a80ecd81c9",
+ "sha256:dd579709a87092c6dbee09d1b7cfa81831040705ffa12a1b248935274aee0437",
+ "sha256:df6712284b2e44a065097846488f66840445eb987eb81b3cc6e4149e7b6982e1",
+ "sha256:e07d9f1a23e9e93ab5c62902833bf3e4b1f65502927379148b6622686223125c",
+ "sha256:e2ede7c1d45e65e209d6093b762e98e8318ddeff95317d07a27a2140b80cfd24",
+ "sha256:e4ef9c164eb55123c62411f5936b5c2e521b12356037b6e1c2617cef45523d47",
+ "sha256:eca2b7343524e7ba246cab8ff00cab47a2d6d54ada3b02772e908a45675722e2",
+ "sha256:eee64c616adeff7db37cc37da4180a3a5b6177f5c46b187894e633f088fb5b28",
+ "sha256:ef824cad1f980d27f26166f86856efe11eff9912c4fed97d3804820d43fa550c",
+ "sha256:efc89291bd5a08855829a3c522df16d856455297cf35ae827a37edac45f466a7",
+ "sha256:fa964bae817babece5aa2e8c1af841bebb6d0b9add8e637548809d040443fee0",
+ "sha256:ff37757e068ae606659c28c3bd0d923f9d29a85de79bf25b2b34b148473b5025"
+ ],
+ "version": "==4.5.4"
+ },
"dodgy": {
"hashes": [
"sha256:65e13cf878d7aff129f1461c13cb5fd1bb6dfe66bb5327e09379c3877763280c"
@@ -735,19 +677,35 @@
},
"flake8": {
"hashes": [
- "sha256:859996073f341f2670741b51ec1e67a01da142831aa1fdc6242dbf88dffbe661",
- "sha256:a796a115208f5c03b18f332f7c11729812c8c3ded6c46319c59b53efd3819da8"
+ "sha256:19241c1cbc971b9962473e4438a2ca19749a7dd002dd1a946eaba171b4114548",
+ "sha256:8e9dfa3cecb2400b3738a42c54c3043e821682b9c840b0448c0503f781130696"
+ ],
+ "index": "pypi",
+ "version": "==3.7.8"
+ },
+ "flake8-annotations": {
+ "hashes": [
+ "sha256:6ac7ca1e706307686b60af8043ff1db31dc2cfc1233c8210d67a3d9b8f364736",
+ "sha256:b51131007000d67217608fa028a35ff80aa400b474e5972f1f99c2cf9d26bd2e"
],
"index": "pypi",
- "version": "==3.7.7"
+ "version": "==1.1.0"
},
"flake8-bugbear": {
"hashes": [
- "sha256:5070774b668be92c4312e5ca82748ddf4ecaa7a24ff062662681bb745c7896eb",
- "sha256:fef9c9826d14ec23187ae1edeb3c6513c4e46bf0e70d86bac38f7d9aabae113d"
+ "sha256:d8c466ea79d5020cb20bf9f11cf349026e09517a42264f313d3f6fddb83e0571",
+ "sha256:ded4d282778969b5ab5530ceba7aa1a9f1b86fa7618fc96a19a1d512331640f8"
],
"index": "pypi",
- "version": "==19.3.0"
+ "version": "==19.8.0"
+ },
+ "flake8-docstrings": {
+ "hashes": [
+ "sha256:1666dd069c9c457ee57e80af3c1a6b37b00cc1801c6fde88e455131bb2e186cd",
+ "sha256:9c0db5a79a1affd70fdf53b8765c8a26bf968e59e0252d7f2fc546b41c0cda06"
+ ],
+ "index": "pypi",
+ "version": "==1.4.0"
},
"flake8-import-order": {
"hashes": [
@@ -780,6 +738,13 @@
"index": "pypi",
"version": "==0.7"
},
+ "identify": {
+ "hashes": [
+ "sha256:4f1fe9a59df4e80fcb0213086fcf502bc1765a01ea4fe8be48da3b65afd2a017",
+ "sha256:d8919589bd2a5f99c66302fec0ef9027b12ae150b0b0213999ad3f695fc7296e"
+ ],
+ "version": "==1.4.7"
+ },
"idna": {
"hashes": [
"sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407",
@@ -787,6 +752,14 @@
],
"version": "==2.8"
},
+ "importlib-metadata": {
+ "hashes": [
+ "sha256:aa18d7378b00b40847790e7c27e11673d7fed219354109d0e7b9e5b25dc3ad26",
+ "sha256:d5f18a79777f3aa179c145737780282e27b508fc8fd688cb17c7a813e8bd39af"
+ ],
+ "markers": "python_version < '3.8'",
+ "version": "==0.23"
+ },
"mccabe": {
"hashes": [
"sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42",
@@ -796,25 +769,39 @@
},
"more-itertools": {
"hashes": [
- "sha256:2112d2ca570bb7c3e53ea1a35cd5df42bb0fd10c45f0fb97178679c3c03d64c7",
- "sha256:c3e4748ba1aad8dba30a4886b0b1a2004f9a863837b8654e7059eebf727afa5a"
+ "sha256:409cd48d4db7052af495b09dec721011634af3753ae1ef92d2b32f73a745f832",
+ "sha256:92b8c4b06dac4f0611c0729b2f2ede52b2e1bac1ab48f089c7ddc12e26bb60c4"
],
- "markers": "python_version > '2.7'",
- "version": "==7.0.0"
+ "index": "pypi",
+ "version": "==7.2.0"
+ },
+ "nodeenv": {
+ "hashes": [
+ "sha256:ad8259494cf1c9034539f6cced78a1da4840a4b157e23640bc4a0c0546b0cb7a"
+ ],
+ "version": "==1.3.3"
},
"packaging": {
"hashes": [
- "sha256:0c98a5d0be38ed775798ece1b9727178c4469d9c3b4ada66e8e6b7849f8732af",
- "sha256:9e1cbf8c12b1f1ce0bb5344b8d7ecf66a6f8a6e91bcb0c84593ed6d3ab5c4ab3"
+ "sha256:28b924174df7a2fa32c1953825ff29c61e2f5e082343165438812f00d3a7fc47",
+ "sha256:d9551545c6d761f3def1677baf08ab2a3ca17c56879e70fecba2fc4dde4ed108"
],
- "version": "==19.0"
+ "version": "==19.2"
},
"pluggy": {
"hashes": [
- "sha256:19ecf9ce9db2fce065a7a0586e07cfb4ac8614fe96edf628a264b1c70116cf8f",
- "sha256:84d306a647cc805219916e62aab89caa97a33a1dd8c342e87a37f91073cd4746"
+ "sha256:0db4b7601aae1d35b4a033282da476845aa19185c1e6964b25cf324b5e4ec3e6",
+ "sha256:fa5fa1622fa6dd5c030e9cad086fa19ef6a0cf6d7a2d12318e10cb49d6d68f34"
+ ],
+ "version": "==0.13.0"
+ },
+ "pre-commit": {
+ "hashes": [
+ "sha256:1d3c0587bda7c4e537a46c27f2c84aa006acc18facf9970bf947df596ce91f3f",
+ "sha256:fa78ff96e8e9ac94c748388597693f18b041a181c94a4f039ad20f45287ba44a"
],
- "version": "==0.9.0"
+ "index": "pypi",
+ "version": "==1.18.3"
},
"py": {
"hashes": [
@@ -830,6 +817,13 @@
],
"version": "==2.5.0"
},
+ "pydocstyle": {
+ "hashes": [
+ "sha256:04c84e034ebb56eb6396c820442b8c4499ac5eb94a3bda88951ac3dc519b6058",
+ "sha256:66aff87ffe34b1e49bff2dd03a88ce6843be2f3346b0c9814410d34987fbab59"
+ ],
+ "version": "==4.0.1"
+ },
"pyflakes": {
"hashes": [
"sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0",
@@ -839,43 +833,53 @@
},
"pyparsing": {
"hashes": [
- "sha256:1873c03321fc118f4e9746baf201ff990ceb915f433f23b395f5580d1840cb2a",
- "sha256:9b6323ef4ab914af344ba97510e966d64ba91055d6b9afa6b30799340e89cc03"
+ "sha256:6f98a7b9397e206d78cc01df10131398f1c8b8510a2f4d97d9abd82e1aacdd80",
+ "sha256:d9338df12903bbf5d65a0e4e87c2161968b10d2e489652bb47001d82a9b028b4"
],
- "version": "==2.4.0"
+ "version": "==2.4.2"
},
"pytest": {
"hashes": [
- "sha256:3773f4c235918987d51daf1db66d51c99fac654c81d6f2f709a046ab446d5e5d",
- "sha256:b7802283b70ca24d7119b32915efa7c409982f59913c1a6c0640aacf118b95f5"
+ "sha256:813b99704b22c7d377bbd756ebe56c35252bb710937b46f207100e843440b3c2",
+ "sha256:cc6620b96bc667a0c8d4fa592a8c9c94178a1bd6cc799dbb057dfd9286d31a31"
],
"index": "pypi",
- "version": "==4.4.1"
+ "version": "==5.1.3"
+ },
+ "pytest-cov": {
+ "hashes": [
+ "sha256:2b097cde81a302e1047331b48cadacf23577e431b61e9c6f49a1170bbe3d3da6",
+ "sha256:e00ea4fdde970725482f1f35630d12f074e121a23801aabf2ae154ec6bdd343a"
+ ],
+ "index": "pypi",
+ "version": "==2.7.1"
},
"pyyaml": {
"hashes": [
- "sha256:1adecc22f88d38052fb787d959f003811ca858b799590a5eaa70e63dca50308c",
- "sha256:436bc774ecf7c103814098159fbb84c2715d25980175292c648f2da143909f95",
- "sha256:460a5a4248763f6f37ea225d19d5c205677d8d525f6a83357ca622ed541830c2",
- "sha256:5a22a9c84653debfbf198d02fe592c176ea548cccce47553f35f466e15cf2fd4",
- "sha256:7a5d3f26b89d688db27822343dfa25c599627bc92093e788956372285c6298ad",
- "sha256:9372b04a02080752d9e6f990179a4ab840227c6e2ce15b95e1278456664cf2ba",
- "sha256:a5dcbebee834eaddf3fa7366316b880ff4062e4bcc9787b78c7fbb4a26ff2dd1",
- "sha256:aee5bab92a176e7cd034e57f46e9df9a9862a71f8f37cad167c6fc74c65f5b4e",
- "sha256:c51f642898c0bacd335fc119da60baae0824f2cde95b0330b56c0553439f0673",
- "sha256:c68ea4d3ba1705da1e0d85da6684ac657912679a649e8868bd850d2c299cce13",
- "sha256:e23d0cc5299223dcc37885dae624f382297717e459ea24053709675a976a3e19"
+ "sha256:0113bc0ec2ad727182326b61326afa3d1d8280ae1122493553fd6f4397f33df9",
+ "sha256:01adf0b6c6f61bd11af6e10ca52b7d4057dd0be0343eb9283c878cf3af56aee4",
+ "sha256:5124373960b0b3f4aa7df1707e63e9f109b5263eca5976c66e08b1c552d4eaf8",
+ "sha256:5ca4f10adbddae56d824b2c09668e91219bb178a1eee1faa56af6f99f11bf696",
+ "sha256:7907be34ffa3c5a32b60b95f4d95ea25361c951383a894fec31be7252b2b6f34",
+ "sha256:7ec9b2a4ed5cad025c2278a1e6a19c011c80a3caaac804fd2d329e9cc2c287c9",
+ "sha256:87ae4c829bb25b9fe99cf71fbb2140c448f534e24c998cc60f39ae4f94396a73",
+ "sha256:9de9919becc9cc2ff03637872a440195ac4241c80536632fffeb6a1e25a74299",
+ "sha256:a5a85b10e450c66b49f98846937e8cfca1db3127a9d5d1e31ca45c3d0bef4c5b",
+ "sha256:b0997827b4f6a7c286c01c5f60384d218dca4ed7d9efa945c3e1aa623d5709ae",
+ "sha256:b631ef96d3222e62861443cc89d6563ba3eeb816eeb96b2629345ab795e53681",
+ "sha256:bf47c0607522fdbca6c9e817a6e81b08491de50f3766a7a0e6a5be7905961b41",
+ "sha256:f81025eddd0327c7d4cfe9b62cf33190e1e736cc6e97502b3ec425f574b3e7a8"
],
"index": "pypi",
- "version": "==5.1"
+ "version": "==5.1.2"
},
"requests": {
"hashes": [
- "sha256:502a824f31acdacb3a35b6690b5fbf0bc41d63a24a45c4004352b0242707598e",
- "sha256:7bf2a778576d825600030a110f3c0e3e8edc51dfaafe1c146e39a2027784957b"
+ "sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4",
+ "sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31"
],
"index": "pypi",
- "version": "==2.21.0"
+ "version": "==2.22.0"
},
"safety": {
"hashes": [
@@ -892,12 +896,67 @@
],
"version": "==1.12.0"
},
+ "snowballstemmer": {
+ "hashes": [
+ "sha256:713e53b79cbcf97bc5245a06080a33d54a77e7cce2f789c835a143bcdb5c033e"
+ ],
+ "version": "==1.9.1"
+ },
+ "toml": {
+ "hashes": [
+ "sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c",
+ "sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e"
+ ],
+ "version": "==0.10.0"
+ },
+ "typed-ast": {
+ "hashes": [
+ "sha256:18511a0b3e7922276346bcb47e2ef9f38fb90fd31cb9223eed42c85d1312344e",
+ "sha256:262c247a82d005e43b5b7f69aff746370538e176131c32dda9cb0f324d27141e",
+ "sha256:2b907eb046d049bcd9892e3076c7a6456c93a25bebfe554e931620c90e6a25b0",
+ "sha256:354c16e5babd09f5cb0ee000d54cfa38401d8b8891eefa878ac772f827181a3c",
+ "sha256:4e0b70c6fc4d010f8107726af5fd37921b666f5b31d9331f0bd24ad9a088e631",
+ "sha256:630968c5cdee51a11c05a30453f8cd65e0cc1d2ad0d9192819df9978984529f4",
+ "sha256:66480f95b8167c9c5c5c87f32cf437d585937970f3fc24386f313a4c97b44e34",
+ "sha256:71211d26ffd12d63a83e079ff258ac9d56a1376a25bc80b1cdcdf601b855b90b",
+ "sha256:95bd11af7eafc16e829af2d3df510cecfd4387f6453355188342c3e79a2ec87a",
+ "sha256:bc6c7d3fa1325a0c6613512a093bc2a2a15aeec350451cbdf9e1d4bffe3e3233",
+ "sha256:cc34a6f5b426748a507dd5d1de4c1978f2eb5626d51326e43280941206c209e1",
+ "sha256:d755f03c1e4a51e9b24d899561fec4ccaf51f210d52abdf8c07ee2849b212a36",
+ "sha256:d7c45933b1bdfaf9f36c579671fec15d25b06c8398f113dab64c18ed1adda01d",
+ "sha256:d896919306dd0aa22d0132f62a1b78d11aaf4c9fc5b3410d3c666b818191630a",
+ "sha256:ffde2fbfad571af120fcbfbbc61c72469e72f550d676c3342492a9dfdefb8f12"
+ ],
+ "version": "==1.4.0"
+ },
"urllib3": {
"hashes": [
- "sha256:4c291ca23bbb55c76518905869ef34bdd5f0e46af7afe6861e8375643ffee1a0",
- "sha256:9a247273df709c4fedb38c711e44292304f73f39ab01beda9f6b9fc375669ac3"
+ "sha256:2393a695cd12afedd0dcb26fe5d50d0cf248e5a66f75dbd89a3d4eb333a61af4",
+ "sha256:a637e5fae88995b256e3409dc4d52c2e2e0ba32c42a6365fee8bbd2238de3cfb"
+ ],
+ "index": "pypi",
+ "version": "==1.24.3"
+ },
+ "virtualenv": {
+ "hashes": [
+ "sha256:680af46846662bb38c5504b78bad9ed9e4f3ba2d54f54ba42494fdf94337fe30",
+ "sha256:f78d81b62d3147396ac33fc9d77579ddc42cc2a98dd9ea38886f616b33bc7fb2"
+ ],
+ "version": "==16.7.5"
+ },
+ "wcwidth": {
+ "hashes": [
+ "sha256:3df37372226d6e63e1b1e1eda15c594bca98a22d33a23832a90998faa96bc65e",
+ "sha256:f4ebe71925af7b40a864553f761ed559b43544f8f71746c2d756c7fe788ade7c"
+ ],
+ "version": "==0.1.7"
+ },
+ "zipp": {
+ "hashes": [
+ "sha256:3718b1cbcd963c7d4c5511a8240812904164b7f381b647143a89d3b98f9bcd8e",
+ "sha256:f06903e9f1f43b12d371004b4ac7b06ab39a44adc747266928ae6debfa7b3335"
],
- "version": "==1.24.2"
+ "version": "==0.6.0"
}
}
}
diff --git a/README.md b/README.md
index 1c9e52b71..7a7f1b992 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,13 @@
-# Python Utility Bot
+# Python Utility Bot
-[![Build Status](https://dev.azure.com/python-discord/Python%20Discord/_apis/build/status/Bot%20(Mainline))](https://dev.azure.com/python-discord/Python%20Discord/_build/latest?definitionId=1)
-[![Discord](https://discordapp.com/api/guilds/267624335836053506/embed.png)](https://discord.gg/2B963hn)
+[![Discord](https://img.shields.io/discord/267624335836053506?color=%237289DA&label=Python%20Discord&logo=discord&logoColor=white)](https://discord.gg/2B963hn)
+[![Build Status](https://dev.azure.com/python-discord/Python%20Discord/_apis/build/status/Bot?branchName=master)](https://dev.azure.com/python-discord/Python%20Discord/_build/latest?definitionId=1&branchName=master)
+[![Tests](https://img.shields.io/azure-devops/tests/python-discord/Python%20Discord/1?compact_message)](https://dev.azure.com/python-discord/Python%20Discord/_apis/build/status/Bot?branchName=master)
+[![Coverage](https://img.shields.io/azure-devops/coverage/python-discord/Python%20Discord/1/master)](https://dev.azure.com/python-discord/Python%20Discord/_apis/build/status/Bot?branchName=master)
+[![License](https://img.shields.io/github/license/python-discord/bot)](LICENSE)
+[![Website](https://img.shields.io/badge/website-visit-brightgreen)](https://pythondiscord.com)
This project is a Discord bot specifically for use with the Python Discord server. It provides numerous utilities
and other tools to help keep the server running like a well-oiled machine.
+
+Read the [Contributing Guide](https://pythondiscord.com/pages/contributing/bot/) on our website if you're interested in helping out.
diff --git a/azure-pipelines.yml b/azure-pipelines.yml
index 19df35c11..c22bac089 100644
--- a/azure-pipelines.yml
+++ b/azure-pipelines.yml
@@ -6,55 +6,59 @@ variables:
PIPENV_NOSPIN: 1
jobs:
-- job: test
- displayName: 'Lint & Test'
-
- pool:
- vmImage: ubuntu-16.04
-
- variables:
- PIPENV_CACHE_DIR: ".cache/pipenv"
- PIP_CACHE_DIR: ".cache/pip"
- PIP_SRC: ".cache/src"
-
- steps:
- - script: |
- sudo apt-get update
- sudo apt-get install build-essential curl docker libffi-dev libfreetype6-dev libxml2 libxml2-dev libxslt1-dev zlib1g zlib1g-dev
- displayName: 'Install base dependencies'
-
- - task: UsePythonVersion@0
- displayName: 'Set Python version'
- inputs:
- versionSpec: '3.7.x'
- addToPath: true
-
- - script: sudo pip install pipenv
- displayName: 'Install pipenv'
-
- - script: pipenv install --dev --deploy --system
- displayName: 'Install project using pipenv'
-
- - script: python -m flake8
- displayName: 'Run linter'
-
- - script: BOT_TOKEN=foobar python -m pytest tests
- displayName: Run tests
-
-- job: build
- displayName: 'Build Containers'
- dependsOn: 'test'
-
- steps:
- - task: Docker@1
- displayName: 'Login: Docker Hub'
-
- inputs:
- containerregistrytype: 'Container Registry'
- dockerRegistryEndpoint: 'DockerHub'
- command: 'login'
-
- - task: ShellScript@2
- displayName: 'Build and deploy containers'
- inputs:
- scriptPath: scripts/deploy-azure.sh
+ - job: test
+ displayName: 'Lint & Test'
+ pool:
+ vmImage: ubuntu-16.04
+
+ variables:
+ PIP_CACHE_DIR: ".cache/pip"
+
+ steps:
+ - task: UsePythonVersion@0
+ displayName: 'Set Python version'
+ inputs:
+ versionSpec: '3.7.x'
+ addToPath: true
+
+ - script: pip install pipenv
+ displayName: 'Install pipenv'
+
+ - script: pipenv install --dev --deploy --system
+ displayName: 'Install project using pipenv'
+
+ - script: python -m flake8
+ displayName: 'Run linter'
+
+ - script: BOT_API_KEY=foo BOT_TOKEN=bar WOLFRAM_API_KEY=baz python -m pytest --junitxml=junit.xml --cov=bot --cov-branch --cov-report=term --cov-report=xml tests
+ displayName: Run tests
+
+ - task: PublishCodeCoverageResults@1
+ displayName: 'Publish Coverage Results'
+ condition: succeededOrFailed()
+ inputs:
+ codeCoverageTool: Cobertura
+ summaryFileLocation: coverage.xml
+
+ - task: PublishTestResults@2
+ displayName: 'Publish Test Results'
+ condition: succeededOrFailed()
+ inputs:
+ testResultsFiles: junit.xml
+ testRunTitle: 'Bot Test results'
+
+ - job: build
+ displayName: 'Build & Push Container'
+ dependsOn: 'test'
+ condition: and(succeeded(), ne(variables['Build.Reason'], 'PullRequest'), eq(variables['Build.SourceBranch'], 'refs/heads/master'))
+
+ steps:
+ - task: Docker@2
+ displayName: 'Build & Push Container'
+ inputs:
+ containerRegistry: 'DockerHub'
+ repository: 'pythondiscord/bot'
+ command: 'buildAndPush'
+ Dockerfile: 'Dockerfile'
+ buildContext: '.'
+ tags: 'latest'
diff --git a/bot/__init__.py b/bot/__init__.py
index 54550842e..4a2df730d 100644
--- a/bot/__init__.py
+++ b/bot/__init__.py
@@ -2,6 +2,7 @@ import logging
import os
import sys
from logging import Logger, StreamHandler, handlers
+from pathlib import Path
from logmatic import JsonFormatter
@@ -9,7 +10,7 @@ logging.TRACE = 5
logging.addLevelName(logging.TRACE, "TRACE")
-def monkeypatch_trace(self, msg, *args, **kwargs):
+def monkeypatch_trace(self: logging.Logger, msg: str, *args, **kwargs) -> None:
"""
Log 'msg % args' with severity 'TRACE'.
@@ -30,22 +31,20 @@ logging_handlers = []
# We can't import this yet, so we have to define it ourselves
DEBUG_MODE = True if 'local' in os.environ.get("SITE_URL", "local") else False
+LOG_DIR = Path("logs")
+LOG_DIR.mkdir(exist_ok=True)
if DEBUG_MODE:
logging_handlers.append(StreamHandler(stream=sys.stdout))
- json_handler = logging.FileHandler(filename="log.json", mode="w")
+ json_handler = logging.FileHandler(filename=Path(LOG_DIR, "log.json"), mode="w")
json_handler.formatter = JsonFormatter()
logging_handlers.append(json_handler)
else:
- logdir = "log"
- logfile = logdir+os.sep+"bot.log"
+ logfile = Path(LOG_DIR, "bot.log")
megabyte = 1048576
- if not os.path.exists(logdir):
- os.makedirs(logdir)
-
filehandler = handlers.RotatingFileHandler(logfile, maxBytes=(megabyte*5), backupCount=7)
logging_handlers.append(filehandler)
@@ -55,7 +54,7 @@ else:
logging.basicConfig(
- format="%(asctime)s pd.beardfist.com Bot: | %(name)30s | %(levelname)8s | %(message)s",
+ format="%(asctime)s Bot: | %(name)33s | %(levelname)8s | %(message)s",
datefmt="%b %d %H:%M:%S",
level=logging.TRACE if DEBUG_MODE else logging.INFO,
handlers=logging_handlers
@@ -91,5 +90,4 @@ for key, value in logging.Logger.manager.loggerDict.items():
# Silence irrelevant loggers
logging.getLogger("aio_pika").setLevel(logging.ERROR)
logging.getLogger("discord").setLevel(logging.ERROR)
-logging.getLogger("PIL").setLevel(logging.ERROR)
logging.getLogger("websockets").setLevel(logging.ERROR)
diff --git a/bot/__main__.py b/bot/__main__.py
index 8afec2718..19a7e5ec6 100644
--- a/bot/__main__.py
+++ b/bot/__main__.py
@@ -2,21 +2,22 @@ import asyncio
import logging
import socket
+import discord
from aiohttp import AsyncResolver, ClientSession, TCPConnector
-from discord import Game
from discord.ext.commands import Bot, when_mentioned_or
-from bot.api import APIClient
+from bot import patches
+from bot.api import APIClient, APILoggingHandler
from bot.constants import Bot as BotConfig, DEBUG_MODE
-log = logging.getLogger(__name__)
+log = logging.getLogger('bot')
bot = Bot(
command_prefix=when_mentioned_or(BotConfig.prefix),
- activity=Game(name="Commands: !help"),
+ activity=discord.Game(name="Commands: !help"),
case_insensitive=True,
- max_messages=10_000
+ max_messages=10_000,
)
# Global aiohttp session for all cogs
@@ -29,19 +30,19 @@ bot.http_session = ClientSession(
)
)
bot.api_client = APIClient(loop=asyncio.get_event_loop())
+log.addHandler(APILoggingHandler(bot.api_client))
# Internal/debug
+bot.load_extension("bot.cogs.error_handler")
+bot.load_extension("bot.cogs.filtering")
bot.load_extension("bot.cogs.logging")
bot.load_extension("bot.cogs.security")
-bot.load_extension("bot.cogs.filtering")
-bot.load_extension("bot.cogs.modlog")
# Commands, etc
bot.load_extension("bot.cogs.antispam")
-bot.load_extension("bot.cogs.bigbrother")
bot.load_extension("bot.cogs.bot")
bot.load_extension("bot.cogs.clean")
-bot.load_extension("bot.cogs.cogs")
+bot.load_extension("bot.cogs.extensions")
bot.load_extension("bot.cogs.help")
# Only load this in production
@@ -51,26 +52,29 @@ if not DEBUG_MODE:
# Feature cogs
bot.load_extension("bot.cogs.alias")
-bot.load_extension("bot.cogs.deployment")
bot.load_extension("bot.cogs.defcon")
-bot.load_extension("bot.cogs.deployment")
bot.load_extension("bot.cogs.eval")
-bot.load_extension("bot.cogs.fun")
-bot.load_extension("bot.cogs.superstarify")
+bot.load_extension("bot.cogs.free")
bot.load_extension("bot.cogs.information")
+bot.load_extension("bot.cogs.jams")
bot.load_extension("bot.cogs.moderation")
bot.load_extension("bot.cogs.off_topic_names")
bot.load_extension("bot.cogs.reddit")
bot.load_extension("bot.cogs.reminders")
bot.load_extension("bot.cogs.site")
-bot.load_extension("bot.cogs.snakes")
bot.load_extension("bot.cogs.snekbox")
bot.load_extension("bot.cogs.sync")
bot.load_extension("bot.cogs.tags")
bot.load_extension("bot.cogs.token_remover")
bot.load_extension("bot.cogs.utils")
+bot.load_extension("bot.cogs.watchchannels")
bot.load_extension("bot.cogs.wolfram")
+# Apply `message_edited_at` patch if discord.py did not yet release a bug fix.
+if not hasattr(discord.message.Message, '_handle_edited_timestamp'):
+ patches.message_edited_at.apply_patch()
+
bot.run(BotConfig.token)
-bot.http_session.close() # Close the aiohttp session when the bot finishes running
+# This calls a coroutine, so it doesn't do anything at the moment.
+# bot.http_session.close() # Close the aiohttp session when the bot finishes running
diff --git a/bot/api.py b/bot/api.py
index 2e1a239ba..7f26e5305 100644
--- a/bot/api.py
+++ b/bot/api.py
@@ -1,11 +1,37 @@
+import asyncio
+import logging
+from typing import Optional
from urllib.parse import quote as quote_url
import aiohttp
from .constants import Keys, URLs
+log = logging.getLogger(__name__)
+
+
+class ResponseCodeError(ValueError):
+ """Raised when a non-OK HTTP response is received."""
+
+ def __init__(
+ self,
+ response: aiohttp.ClientResponse,
+ response_json: Optional[dict] = None,
+ response_text: str = ""
+ ):
+ self.status = response.status
+ self.response_json = response_json or {}
+ self.response_text = response_text
+ self.response = response
+
+ def __str__(self):
+ response = self.response_json if self.response_json else self.response_text
+ return f"Status: {self.status} Response: {response}"
+
class APIClient:
+ """Django Site API wrapper."""
+
def __init__(self, **kwargs):
auth_headers = {
'Authorization': f"Token {Keys.site_api}"
@@ -16,33 +42,139 @@ class APIClient:
else:
kwargs['headers'] = auth_headers
- self.session = aiohttp.ClientSession(
- **kwargs,
- raise_for_status=True
- )
+ self.session = aiohttp.ClientSession(**kwargs)
@staticmethod
- def _url_for(endpoint: str):
+ def _url_for(endpoint: str) -> str:
return f"{URLs.site_schema}{URLs.site_api}/{quote_url(endpoint)}"
- async def get(self, endpoint: str, *args, **kwargs):
+ async def maybe_raise_for_status(self, response: aiohttp.ClientResponse, should_raise: bool) -> None:
+ """Raise ResponseCodeError for non-OK response if an exception should be raised."""
+ if should_raise and response.status >= 400:
+ try:
+ response_json = await response.json()
+ raise ResponseCodeError(response=response, response_json=response_json)
+ except aiohttp.ContentTypeError:
+ response_text = await response.text()
+ raise ResponseCodeError(response=response, response_text=response_text)
+
+ async def get(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs) -> dict:
+ """Site API GET."""
async with self.session.get(self._url_for(endpoint), *args, **kwargs) as resp:
+ await self.maybe_raise_for_status(resp, raise_for_status)
return await resp.json()
- async def patch(self, endpoint: str, *args, **kwargs):
+ async def patch(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs) -> dict:
+ """Site API PATCH."""
async with self.session.patch(self._url_for(endpoint), *args, **kwargs) as resp:
+ await self.maybe_raise_for_status(resp, raise_for_status)
return await resp.json()
- async def post(self, endpoint: str, *args, **kwargs):
+ async def post(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs) -> dict:
+ """Site API POST."""
async with self.session.post(self._url_for(endpoint), *args, **kwargs) as resp:
+ await self.maybe_raise_for_status(resp, raise_for_status)
return await resp.json()
- async def put(self, endpoint: str, *args, **kwargs):
+ async def put(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs) -> dict:
+ """Site API PUT."""
async with self.session.put(self._url_for(endpoint), *args, **kwargs) as resp:
+ await self.maybe_raise_for_status(resp, raise_for_status)
return await resp.json()
- async def delete(self, endpoint: str, *args, **kwargs):
+ async def delete(self, endpoint: str, *args, raise_for_status: bool = True, **kwargs) -> Optional[dict]:
+ """Site API DELETE."""
async with self.session.delete(self._url_for(endpoint), *args, **kwargs) as resp:
if resp.status == 204:
return None
+
+ await self.maybe_raise_for_status(resp, raise_for_status)
return await resp.json()
+
+
+def loop_is_running() -> bool:
+ """
+ Determine if there is a running asyncio event loop.
+
+ This helps enable "call this when event loop is running" logic (see: Twisted's `callWhenRunning`),
+ which is currently not provided by asyncio.
+ """
+ try:
+ asyncio.get_running_loop()
+ except RuntimeError:
+ return False
+ return True
+
+
+class APILoggingHandler(logging.StreamHandler):
+ """Site API logging handler."""
+
+ def __init__(self, client: APIClient):
+ logging.StreamHandler.__init__(self)
+ self.client = client
+
+ # internal batch of shipoff tasks that must not be scheduled
+ # on the event loop yet - scheduled when the event loop is ready.
+ self.queue = []
+
+ async def ship_off(self, payload: dict) -> None:
+ """Ship log payload to the logging API."""
+ try:
+ await self.client.post('logs', json=payload)
+ except ResponseCodeError as err:
+ log.warning(
+ "Cannot send logging record to the site, got code %d.",
+ err.response.status,
+ extra={'via_handler': True}
+ )
+ except Exception as err:
+ log.warning(
+ "Cannot send logging record to the site: %r",
+ err,
+ extra={'via_handler': True}
+ )
+
+ def emit(self, record: logging.LogRecord) -> None:
+ """
+ Determine if a log record should be shipped to the logging API.
+
+ If the asyncio event loop is not yet running, log records will instead be put in a queue
+ which will be consumed once the event loop is running.
+
+ The following two conditions are set:
+ 1. Do not log anything below DEBUG (only applies to the monkeypatched `TRACE` level)
+ 2. Ignore log records originating from this logging handler itself to prevent infinite recursion
+ """
+ if (
+ record.levelno >= logging.DEBUG
+ and not record.__dict__.get('via_handler')
+ ):
+ payload = {
+ 'application': 'bot',
+ 'logger_name': record.name,
+ 'level': record.levelname.lower(),
+ 'module': record.module,
+ 'line': record.lineno,
+ 'message': self.format(record)
+ }
+
+ task = self.ship_off(payload)
+ if not loop_is_running():
+ self.queue.append(task)
+ else:
+ asyncio.create_task(task)
+ self.schedule_queued_tasks()
+
+ def schedule_queued_tasks(self) -> None:
+ """Consume the queue and schedule the logging of each queued record."""
+ for task in self.queue:
+ asyncio.create_task(task)
+
+ if self.queue:
+ log.debug(
+ "Scheduled %d pending logging tasks.",
+ len(self.queue),
+ extra={'via_handler': True}
+ )
+
+ self.queue.clear()
diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py
index 2ce4a51e3..6648805e9 100644
--- a/bot/cogs/alias.py
+++ b/bot/cogs/alias.py
@@ -1,37 +1,26 @@
import inspect
import logging
+from typing import Union
-from discord import Colour, Embed, User
-from discord.ext.commands import (
- Command, Context, clean_content, command, group
-)
+from discord import Colour, Embed, Member, User
+from discord.ext.commands import Bot, Cog, Command, Context, clean_content, command, group
+from bot.cogs.extensions import Extension
+from bot.cogs.watchchannels.watchchannel import proxy_user
from bot.converters import TagNameConverter
from bot.pagination import LinePaginator
log = logging.getLogger(__name__)
-class Alias:
- """
- Aliases for more used commands
- """
+class Alias (Cog):
+ """Aliases for commonly used commands."""
- def __init__(self, bot):
+ def __init__(self, bot: Bot):
self.bot = bot
- async def invoke(self, ctx, cmd_name, *args, **kwargs):
- """
- Invokes a command with args and kwargs.
- Fail early through `command.can_run`, and logs warnings.
-
- :param ctx: Context instance for command call
- :param cmd_name: Name of command/subcommand to be invoked
- :param args: args to be passed to the command
- :param kwargs: kwargs to be passed to the command
- :return: None
- """
-
+ async def invoke(self, ctx: Context, cmd_name: str, *args, **kwargs) -> None:
+ """Invokes a command with args and kwargs."""
log.debug(f"{cmd_name} was invoked through an alias")
cmd = self.bot.get_command(cmd_name)
if not cmd:
@@ -44,9 +33,8 @@ class Alias:
await ctx.invoke(cmd, *args, **kwargs)
@command(name='aliases')
- async def aliases_command(self, ctx):
+ async def aliases_command(self, ctx: Context) -> None:
"""Show configured aliases on the bot."""
-
embed = Embed(
title='Configured aliases',
colour=Colour.blue()
@@ -62,120 +50,103 @@ class Alias:
)
@command(name="resources", aliases=("resource",), hidden=True)
- async def site_resources_alias(self, ctx):
- """
- Alias for invoking <prefix>site resources.
- """
-
+ async def site_resources_alias(self, ctx: Context) -> None:
+ """Alias for invoking <prefix>site resources."""
await self.invoke(ctx, "site resources")
- @command(name="watch", hidden=True)
- async def bigbrother_watch_alias(
- self, ctx, user: User, *, reason: str = None
- ):
- """
- Alias for invoking <prefix>bigbrother watch user [text_channel].
- """
+ @command(name="tools", hidden=True)
+ async def site_tools_alias(self, ctx: Context) -> None:
+ """Alias for invoking <prefix>site tools."""
+ await self.invoke(ctx, "site tools")
+ @command(name="watch", hidden=True)
+ async def bigbrother_watch_alias(self, ctx: Context, user: Union[Member, User, proxy_user], *, reason: str) -> None:
+ """Alias for invoking <prefix>bigbrother watch [user] [reason]."""
await self.invoke(ctx, "bigbrother watch", user, reason=reason)
@command(name="unwatch", hidden=True)
- async def bigbrother_unwatch_alias(self, ctx, user: User):
- """
- Alias for invoking <prefix>bigbrother unwatch user.
-
- user: discord.User - A user instance to unwatch
- """
-
- await self.invoke(ctx, "bigbrother unwatch", user)
+ async def bigbrother_unwatch_alias(self, ctx: Context, user: Union[User, proxy_user], *, reason: str) -> None:
+ """Alias for invoking <prefix>bigbrother unwatch [user] [reason]."""
+ await self.invoke(ctx, "bigbrother unwatch", user, reason=reason)
@command(name="home", hidden=True)
- async def site_home_alias(self, ctx):
- """
- Alias for invoking <prefix>site home.
- """
-
+ async def site_home_alias(self, ctx: Context) -> None:
+ """Alias for invoking <prefix>site home."""
await self.invoke(ctx, "site home")
@command(name="faq", hidden=True)
- async def site_faq_alias(self, ctx):
- """
- Alias for invoking <prefix>site faq.
- """
-
+ async def site_faq_alias(self, ctx: Context) -> None:
+ """Alias for invoking <prefix>site faq."""
await self.invoke(ctx, "site faq")
@command(name="rules", hidden=True)
- async def site_rules_alias(self, ctx):
- """
- Alias for invoking <prefix>site rules.
- """
-
+ async def site_rules_alias(self, ctx: Context) -> None:
+ """Alias for invoking <prefix>site rules."""
await self.invoke(ctx, "site rules")
@command(name="reload", hidden=True)
- async def cogs_reload_alias(self, ctx, *, cog_name: str):
- """
- Alias for invoking <prefix>cogs reload cog_name.
-
- cog_name: str - name of the cog to be reloaded.
- """
-
- await self.invoke(ctx, "cogs reload", cog_name)
+ async def extensions_reload_alias(self, ctx: Context, *extensions: Extension) -> None:
+ """Alias for invoking <prefix>extensions reload [extensions...]."""
+ await self.invoke(ctx, "extensions reload", *extensions)
@command(name="defon", hidden=True)
- async def defcon_enable_alias(self, ctx):
- """
- Alias for invoking <prefix>defcon enable.
- """
-
+ async def defcon_enable_alias(self, ctx: Context) -> None:
+ """Alias for invoking <prefix>defcon enable."""
await self.invoke(ctx, "defcon enable")
@command(name="defoff", hidden=True)
- async def defcon_disable_alias(self, ctx):
- """
- Alias for invoking <prefix>defcon disable.
- """
-
+ async def defcon_disable_alias(self, ctx: Context) -> None:
+ """Alias for invoking <prefix>defcon disable."""
await self.invoke(ctx, "defcon disable")
+ @command(name="exception", hidden=True)
+ async def tags_get_traceback_alias(self, ctx: Context) -> None:
+ """Alias for invoking <prefix>tags get traceback."""
+ await self.invoke(ctx, "tags get", tag_name="traceback")
+
@group(name="get",
aliases=("show", "g"),
hidden=True,
invoke_without_command=True)
- async def get_group_alias(self, ctx):
- """
- Group for reverse aliases for commands like `tags get`,
- allowing for `get tags` or `get docs`.
- """
-
+ async def get_group_alias(self, ctx: Context) -> None:
+ """Group for reverse aliases for commands like `tags get`, allowing for `get tags` or `get docs`."""
pass
@get_group_alias.command(name="tags", aliases=("tag", "t"), hidden=True)
async def tags_get_alias(
self, ctx: Context, *, tag_name: TagNameConverter = None
- ):
+ ) -> None:
"""
Alias for invoking <prefix>tags get [tag_name].
tag_name: str - tag to be viewed.
"""
-
- await self.invoke(ctx, "tags get", tag_name)
+ await self.invoke(ctx, "tags get", tag_name=tag_name)
@get_group_alias.command(name="docs", aliases=("doc", "d"), hidden=True)
async def docs_get_alias(
self, ctx: Context, symbol: clean_content = None
- ):
- """
- Alias for invoking <prefix>docs get [symbol].
+ ) -> None:
+ """Alias for invoking <prefix>docs get [symbol]."""
+ await self.invoke(ctx, "docs get", symbol)
- symbol: str - name of doc to be viewed.
- """
+ @command(name="nominate", hidden=True)
+ async def nomination_add_alias(self, ctx: Context, user: Union[Member, User, proxy_user], *, reason: str) -> None:
+ """Alias for invoking <prefix>talentpool add [user] [reason]."""
+ await self.invoke(ctx, "talentpool add", user, reason=reason)
- await self.invoke(ctx, "docs get", symbol)
+ @command(name="unnominate", hidden=True)
+ async def nomination_end_alias(self, ctx: Context, user: Union[User, proxy_user], *, reason: str) -> None:
+ """Alias for invoking <prefix>nomination end [user] [reason]."""
+ await self.invoke(ctx, "nomination end", user, reason=reason)
+
+ @command(name="nominees", hidden=True)
+ async def nominees_alias(self, ctx: Context) -> None:
+ """Alias for invoking <prefix>tp watched."""
+ await self.invoke(ctx, "talentpool watched")
-def setup(bot):
+def setup(bot: Bot) -> None:
+ """Alias cog load."""
bot.add_cog(Alias(bot))
log.info("Cog loaded: Alias")
diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py
index 1c69d33ae..1b394048a 100644
--- a/bot/cogs/antispam.py
+++ b/bot/cogs/antispam.py
@@ -1,21 +1,23 @@
import asyncio
import logging
-import textwrap
+from collections.abc import Mapping
+from dataclasses import dataclass, field
from datetime import datetime, timedelta
-from typing import List
+from operator import itemgetter
+from typing import Dict, Iterable, List, Set
-from dateutil.relativedelta import relativedelta
-from discord import Colour, Member, Message, Object, TextChannel
-from discord.ext.commands import Bot
+from discord import Colour, Member, Message, NotFound, Object, TextChannel
+from discord.ext.commands import Bot, Cog
from bot import rules
-from bot.cogs.modlog import ModLog
+from bot.cogs.moderation import ModLog
from bot.constants import (
AntiSpam as AntiSpamConfig, Channels,
- Colours, DEBUG_MODE, Event,
- Guild as GuildConfig, Icons, Roles,
+ Colours, DEBUG_MODE, Event, Filter,
+ Guild as GuildConfig, Icons,
+ STAFF_ROLES,
)
-from bot.utils.time import humanize_delta
+from bot.converters import Duration
log = logging.getLogger(__name__)
@@ -32,48 +34,129 @@ RULE_FUNCTION_MAPPING = {
'newlines': rules.apply_newlines,
'role_mentions': rules.apply_role_mentions
}
-WHITELISTED_CHANNELS = (
- Channels.admins, Channels.announcements, Channels.big_brother_logs,
- Channels.devlog, Channels.devtest, Channels.helpers, Channels.message_log,
- Channels.mod_alerts, Channels.modlog, Channels.staff_lounge
-)
-WHITELISTED_ROLES = (Roles.owner, Roles.admin, Roles.moderator, Roles.helpers)
-class AntiSpam:
- def __init__(self, bot: Bot):
+@dataclass
+class DeletionContext:
+ """Represents a Deletion Context for a single spam event."""
+
+ channel: TextChannel
+ members: Dict[int, Member] = field(default_factory=dict)
+ rules: Set[str] = field(default_factory=set)
+ messages: Dict[int, Message] = field(default_factory=dict)
+
+ def add(self, rule_name: str, members: Iterable[Member], messages: Iterable[Message]) -> None:
+ """Adds new rule violation events to the deletion context."""
+ self.rules.add(rule_name)
+
+ for member in members:
+ if member.id not in self.members:
+ self.members[member.id] = member
+
+ for message in messages:
+ if message.id not in self.messages:
+ self.messages[message.id] = message
+
+ async def upload_messages(self, actor_id: int, modlog: ModLog) -> None:
+ """Method that takes care of uploading the queue and posting modlog alert."""
+ triggered_by_users = ", ".join(f"{m.display_name}#{m.discriminator} (`{m.id}`)" for m in self.members.values())
+
+ mod_alert_message = (
+ f"**Triggered by:** {triggered_by_users}\n"
+ f"**Channel:** {self.channel.mention}\n"
+ f"**Rules:** {', '.join(rule for rule in self.rules)}\n"
+ )
+
+ # For multiple messages or those with excessive newlines, use the logs API
+ if len(self.messages) > 1 or 'newlines' in self.rules:
+ url = await modlog.upload_log(self.messages.values(), actor_id)
+ mod_alert_message += f"A complete log of the offending messages can be found [here]({url})"
+ else:
+ mod_alert_message += "Message:\n"
+ [message] = self.messages.values()
+ content = message.clean_content
+ remaining_chars = 2040 - len(mod_alert_message)
+
+ if len(content) > remaining_chars:
+ content = content[:remaining_chars] + "..."
+
+ mod_alert_message += f"{content}"
+
+ *_, last_message = self.messages.values()
+ await modlog.send_log_message(
+ icon_url=Icons.filtering,
+ colour=Colour(Colours.soft_red),
+ title=f"Spam detected!",
+ text=mod_alert_message,
+ thumbnail=last_message.author.avatar_url_as(static_format="png"),
+ channel_id=Channels.mod_alerts,
+ ping_everyone=AntiSpamConfig.ping_everyone
+ )
+
+
+class AntiSpam(Cog):
+ """Cog that controls our anti-spam measures."""
+
+ def __init__(self, bot: Bot, validation_errors: bool) -> None:
self.bot = bot
- self.muted_role = None
+ self.validation_errors = validation_errors
+ role_id = AntiSpamConfig.punishment['role_id']
+ self.muted_role = Object(role_id)
+ self.expiration_date_converter = Duration()
+
+ self.message_deletion_queue = dict()
+ self.queue_consumption_tasks = dict()
+
+ self.bot.loop.create_task(self.alert_on_validation_error())
@property
def mod_log(self) -> ModLog:
+ """Allows for easy access of the ModLog cog."""
return self.bot.get_cog("ModLog")
- async def on_ready(self):
- role_id = AntiSpamConfig.punishment['role_id']
- self.muted_role = Object(role_id)
+ async def alert_on_validation_error(self) -> None:
+ """Unloads the cog and alerts admins if configuration validation failed."""
+ await self.bot.wait_until_ready()
+ if self.validation_errors:
+ body = "**The following errors were encountered:**\n"
+ body += "\n".join(f"- {error}" for error in self.validation_errors.values())
+ body += "\n\n**The cog has been unloaded.**"
- async def on_message(self, message: Message):
+ await self.mod_log.send_log_message(
+ title=f"Error: AntiSpam configuration validation failed!",
+ text=body,
+ ping_everyone=True,
+ icon_url=Icons.token_removed,
+ colour=Colour.red()
+ )
+
+ self.bot.remove_cog(self.__class__.__name__)
+ return
+
+ @Cog.listener()
+ async def on_message(self, message: Message) -> None:
+ """Applies the antispam rules to each received message."""
if (
not message.guild
or message.guild.id != GuildConfig.id
or message.author.bot
- or (message.channel.id in WHITELISTED_CHANNELS and not DEBUG_MODE)
- or (message.author.top_role.id in WHITELISTED_ROLES and not DEBUG_MODE)
+ or (message.channel.id in Filter.channel_whitelist and not DEBUG_MODE)
+ or (any(role.id in STAFF_ROLES for role in message.author.roles) and not DEBUG_MODE)
):
return
# Fetch the rule configuration with the highest rule interval.
max_interval_config = max(
AntiSpamConfig.rules.values(),
- key=lambda config: config['interval']
+ key=itemgetter('interval')
)
max_interval = max_interval_config['interval']
# Store history messages since `interval` seconds ago in a list to prevent unnecessary API calls.
earliest_relevant_at = datetime.utcnow() - timedelta(seconds=max_interval)
relevant_messages = [
- msg async for msg in message.channel.history(after=earliest_relevant_at, reverse=False)
+ msg async for msg in message.channel.history(after=earliest_relevant_at, oldest_first=False)
+ if not msg.author.bot
]
for rule_name in AntiSpamConfig.rules:
@@ -94,80 +177,55 @@ class AntiSpam:
if result is not None:
reason, members, relevant_messages = result
full_reason = f"`{rule_name}` rule: {reason}"
+
+ # If there's no spam event going on for this channel, start a new Message Deletion Context
+ if message.channel.id not in self.message_deletion_queue:
+ log.trace(f"Creating queue for channel `{message.channel.id}`")
+ self.message_deletion_queue[message.channel.id] = DeletionContext(channel=message.channel)
+ self.queue_consumption_tasks = self.bot.loop.create_task(
+ self._process_deletion_context(message.channel.id)
+ )
+
+ # Add the relevant of this trigger to the Deletion Context
+ self.message_deletion_queue[message.channel.id].add(
+ rule_name=rule_name,
+ members=members,
+ messages=relevant_messages
+ )
+
for member in members:
# Fire it off as a background task to ensure
# that the sleep doesn't block further tasks
self.bot.loop.create_task(
- self.punish(message, member, full_reason, relevant_messages)
+ self.punish(message, member, full_reason)
)
await self.maybe_delete_messages(message.channel, relevant_messages)
break
- async def punish(self, msg: Message, member: Member, reason: str, messages: List[Message]):
- # Sanity check to ensure we're not lagging behind
- if self.muted_role not in member.roles:
+ async def punish(self, msg: Message, member: Member, reason: str) -> None:
+ """Punishes the given member for triggering an antispam rule."""
+ if not any(role.id == self.muted_role.id for role in member.roles):
remove_role_after = AntiSpamConfig.punishment['remove_after']
- duration_delta = relativedelta(seconds=remove_role_after)
- human_duration = humanize_delta(duration_delta)
-
- mod_alert_message = (
- f"**Triggered by:** {member.display_name}#{member.discriminator} (`{member.id}`)\n"
- f"**Channel:** {msg.channel.mention}\n"
- f"**Reason:** {reason}\n"
- )
-
- # For multiple messages, use the logs API
- if len(messages) > 1:
- url = await self.mod_log.upload_log(messages, msg.guild.me.id)
- mod_alert_message += f"A complete log of the offending messages can be found [here]({url})"
- else:
- mod_alert_message += "Message:\n"
- content = messages[0].clean_content
- remaining_chars = 2040 - len(mod_alert_message)
-
- if len(content) > remaining_chars:
- content = content[:remaining_chars] + "..."
-
- mod_alert_message += f"{content}"
-
- await self.mod_log.send_log_message(
- icon_url=Icons.filtering,
- colour=Colour(Colours.soft_red),
- title=f"Spam detected!",
- text=mod_alert_message,
- thumbnail=msg.author.avatar_url_as(static_format="png"),
- channel_id=Channels.mod_alerts,
- ping_everyone=AntiSpamConfig.ping_everyone
- )
-
- await member.add_roles(self.muted_role, reason=reason)
- description = textwrap.dedent(f"""
- **Channel**: {msg.channel.mention}
- **User**: {msg.author.mention} (`{msg.author.id}`)
- **Reason**: {reason}
- Role will be removed after {human_duration}.
- """)
-
- await self.mod_log.send_log_message(
- icon_url=Icons.user_mute, colour=Colour(Colours.soft_red),
- title="User muted", text=description
- )
- await asyncio.sleep(remove_role_after)
- await member.remove_roles(self.muted_role, reason="AntiSpam mute expired")
-
- await self.mod_log.send_log_message(
- icon_url=Icons.user_mute, colour=Colour(Colours.soft_green),
- title="User unmuted",
- text=f"Was muted by `AntiSpam` cog for {human_duration}."
+ # Get context and make sure the bot becomes the actor of infraction by patching the `author` attributes
+ context = await self.bot.get_context(msg)
+ context.author = self.bot.user
+ context.message.author = self.bot.user
+
+ # Since we're going to invoke the tempmute command directly, we need to manually call the converter.
+ dt_remove_role_after = await self.expiration_date_converter.convert(context, f"{remove_role_after}S")
+ await context.invoke(
+ self.bot.get_command('tempmute'),
+ member,
+ dt_remove_role_after,
+ reason=reason
)
- async def maybe_delete_messages(self, channel: TextChannel, messages: List[Message]):
- # Is deletion of offending messages actually enabled?
+ async def maybe_delete_messages(self, channel: TextChannel, messages: List[Message]) -> None:
+ """Cleans the messages if cleaning is configured."""
if AntiSpamConfig.clean_offending:
-
# If we have more than one message, we can use bulk delete.
if len(messages) > 1:
message_ids = [message.id for message in messages]
@@ -178,26 +236,47 @@ class AntiSpam:
# Delete the message directly instead.
else:
self.mod_log.ignore(Event.message_delete, messages[0].id)
- await messages[0].delete()
+ try:
+ await messages[0].delete()
+ except NotFound:
+ log.info(f"Tried to delete message `{messages[0].id}`, but message could not be found.")
+
+ async def _process_deletion_context(self, context_id: int) -> None:
+ """Processes the Deletion Context queue."""
+ log.trace("Sleeping before processing message deletion queue.")
+ await asyncio.sleep(10)
+
+ if context_id not in self.message_deletion_queue:
+ log.error(f"Started processing deletion queue for context `{context_id}`, but it was not found!")
+ return
+
+ deletion_context = self.message_deletion_queue.pop(context_id)
+ await deletion_context.upload_messages(self.bot.user.id, self.mod_log)
-def validate_config():
- for name, config in AntiSpamConfig.rules.items():
+def validate_config(rules: Mapping = AntiSpamConfig.rules) -> Dict[str, str]:
+ """Validates the antispam configs."""
+ validation_errors = {}
+ for name, config in rules.items():
if name not in RULE_FUNCTION_MAPPING:
- raise ValueError(
+ log.error(
f"Unrecognized antispam rule `{name}`. "
f"Valid rules are: {', '.join(RULE_FUNCTION_MAPPING)}"
)
-
+ validation_errors[name] = f"`{name}` is not recognized as an antispam rule."
+ continue
for required_key in ('interval', 'max'):
if required_key not in config:
- raise ValueError(
+ log.error(
f"`{required_key}` is required but was not "
f"set in rule `{name}`'s configuration."
)
+ validation_errors[name] = f"Key `{required_key}` is required but not set for rule `{name}`"
+ return validation_errors
-def setup(bot: Bot):
- validate_config()
- bot.add_cog(AntiSpam(bot))
+def setup(bot: Bot) -> None:
+ """Antispam cog load."""
+ validation_errors = validate_config()
+ bot.add_cog(AntiSpam(bot, validation_errors))
log.info("Cog loaded: AntiSpam")
diff --git a/bot/cogs/bigbrother.py b/bot/cogs/bigbrother.py
deleted file mode 100644
index df7a0b576..000000000
--- a/bot/cogs/bigbrother.py
+++ /dev/null
@@ -1,258 +0,0 @@
-import asyncio
-import logging
-import re
-from collections import defaultdict, deque
-from typing import List, Union
-
-from discord import Color, Embed, Guild, Member, Message, User
-from discord.ext.commands import Bot, Context, group
-
-from bot.constants import (
- BigBrother as BigBrotherConfig, Channels, Emojis, Guild as GuildConfig, Roles
-)
-from bot.decorators import with_role
-from bot.pagination import LinePaginator
-from bot.utils import messages
-from bot.utils.moderation import post_infraction
-
-log = logging.getLogger(__name__)
-
-URL_RE = re.compile(r"(https?://[^\s]+)")
-
-
-class BigBrother:
- """User monitoring to assist with moderation."""
-
- def __init__(self, bot: Bot):
- self.bot = bot
- self.watched_users = set() # { user_id }
- self.channel_queues = defaultdict(lambda: defaultdict(deque)) # { user_id: { channel_id: queue(messages) }
- self.last_log = [None, None, 0] # [user_id, channel_id, message_count]
- self.consuming = False
-
- def update_cache(self, api_response: List[dict]):
- """
- Updates the internal cache of watched users from the given `api_response`.
- This function will only add (or update) existing keys, it will not delete
- keys that were not present in the API response.
- A user is only added if the bot can find a channel
- with the given `channel_id` in its channel cache.
- """
-
- for entry in api_response:
- user_id = entry['user']
- self.watched_users.add(user_id)
-
- async def on_ready(self):
- """Retrieves watched users from the API."""
-
- self.channel = self.bot.get_channel(Channels.big_brother_logs)
- if self.channel is None:
- log.error("Cannot find Big Brother channel. Cannot watch users.")
- else:
- data = await self.bot.api_client.get(
- 'bot/infractions',
- params={
- 'active': 'true',
- 'type': 'watch'
- }
- )
- self.update_cache(data)
-
- async def on_member_ban(self, guild: Guild, user: Union[User, Member]):
- if guild.id == GuildConfig.id and user.id in self.watched_users:
- [active_watch] = await self.bot.api_client.get(
- 'bot/infractions',
- params={
- 'active': 'true',
- 'type': 'watch',
- 'user__id': str(user.id)
- }
- )
- await self.bot.api_client.put(
- 'bot/infractions/' + str(active_watch['id']),
- json={'active': False}
- )
- self.watched_users.remove(user.id)
- del self.channel_queues[user.id]
- await self.channel.send(
- f"{Emojis.bb_message}:hammer: {user} got banned, so "
- f"`BigBrother` will no longer relay their messages."
- )
-
- async def on_message(self, msg: Message):
- """Queues up messages sent by watched users."""
-
- if msg.author.id in self.watched_users:
- if not self.consuming:
- self.bot.loop.create_task(self.consume_messages())
-
- log.trace(f"Received message: {msg.content} ({len(msg.attachments)} attachments)")
- self.channel_queues[msg.author.id][msg.channel.id].append(msg)
-
- async def consume_messages(self):
- """Consumes the message queues to log watched users' messages."""
-
- if not self.consuming:
- self.consuming = True
- log.trace("Sleeping before consuming...")
- await asyncio.sleep(BigBrotherConfig.log_delay)
-
- log.trace("Begin consuming messages.")
- channel_queues = self.channel_queues.copy()
- self.channel_queues.clear()
- for _, queues in channel_queues.items():
- for queue in queues.values():
- while queue:
- msg = queue.popleft()
- log.trace(f"Consuming message: {msg.clean_content} ({len(msg.attachments)} attachments)")
-
- self.last_log[2] += 1 # Increment message count.
- await self.send_header(msg)
- await self.log_message(msg)
-
- if self.channel_queues:
- log.trace("Queue not empty; continue consumption.")
- self.bot.loop.create_task(self.consume_messages())
- else:
- log.trace("Done consuming messages.")
- self.consuming = False
-
- async def send_header(self, message: Message):
- """
- Sends a log message header to the given channel.
-
- A header is only sent if the user or channel are different than the previous, or if the configured message
- limit for a single header has been exceeded.
-
- :param message: the first message in the queue
- """
-
- last_user, last_channel, msg_count = self.last_log
- limit = BigBrotherConfig.header_message_limit
-
- # Send header if user/channel are different or if message limit exceeded.
- if message.author.id != last_user or message.channel.id != last_channel or msg_count > limit:
- self.last_log = [message.author.id, message.channel.id, 0]
-
- embed = Embed(description=f"{message.author.mention} in [#{message.channel.name}]({message.jump_url})")
- embed.set_author(name=message.author.nick or message.author.name, icon_url=message.author.avatar_url)
- await self.channel.send(embed=embed)
-
- async def log_message(self, message: Message):
- """
- Logs a watched user's message in the given channel.
-
- Attachments are also sent. All non-image or non-video URLs are put in inline code blocks to prevent preview
- embeds from being automatically generated.
-
- :param message: the message to log
- """
-
- content = message.clean_content
- if content:
- # Put all non-media URLs in inline code blocks.
- media_urls = {embed.url for embed in message.embeds if embed.type in ("image", "video")}
- for url in URL_RE.findall(content):
- if url not in media_urls:
- content = content.replace(url, f"`{url}`")
-
- await self.channel.send(content)
-
- await messages.send_attachments(message, self.channel)
-
- @group(name='bigbrother', aliases=('bb',), invoke_without_command=True)
- @with_role(Roles.owner, Roles.admin, Roles.moderator)
- async def bigbrother_group(self, ctx: Context):
- """Monitor users, NSA-style."""
-
- await ctx.invoke(self.bot.get_command("help"), "bigbrother")
-
- @bigbrother_group.command(name='watched', aliases=('all',))
- @with_role(Roles.owner, Roles.admin, Roles.moderator)
- async def watched_command(self, ctx: Context, from_cache: bool = True):
- """
- Shows all users that are currently monitored and in which channel.
- By default, the users are returned from the cache.
- If this is not desired, `from_cache` can be given as a falsy value, e.g. e.g. 'no'.
- """
-
- if from_cache:
- lines = tuple(f"• <@{user_id}>" for user_id in self.watched_users)
- await LinePaginator.paginate(
- lines or ("There's nothing here yet.",),
- ctx,
- Embed(title="Watched users (cached)", color=Color.blue()),
- empty=False
- )
-
- else:
- active_watches = await self.bot.api_client.get(
- 'bot/infractions',
- params={
- 'active': 'true',
- 'type': 'watch'
- }
- )
- self.update_cache(active_watches)
- lines = tuple(
- f"• <@{entry['user']}>: {entry['reason'] or '*no reason provided*'}"
- for entry in active_watches
- )
-
- await LinePaginator.paginate(
- lines or ("There's nothing here yet.",),
- ctx,
- Embed(title="Watched users", color=Color.blue()),
- empty=False
- )
-
- @bigbrother_group.command(name='watch', aliases=('w',))
- @with_role(Roles.owner, Roles.admin, Roles.moderator)
- async def watch_command(self, ctx: Context, user: User, *, reason: str):
- """
- Relay messages sent by the given `user` to the `#big-brother-logs` channel
-
- A `reason` for watching is required, which is added for the user to be watched as a
- note (aka: shadow warning)
- """
-
- if user.id in self.watched_users:
- return await ctx.send(":x: That user is already watched.")
-
- await post_infraction(
- ctx, user, type='watch', reason=reason, hidden=True
- )
- self.watched_users.add(user.id)
- await ctx.send(f":ok_hand: will now relay messages sent by {user}")
-
- @bigbrother_group.command(name='unwatch', aliases=('uw',))
- @with_role(Roles.owner, Roles.admin, Roles.moderator)
- async def unwatch_command(self, ctx: Context, user: User):
- """Stop relaying messages by the given `user`."""
-
- active_watches = await self.bot.api_client.get(
- 'bot/infractions',
- params={
- 'active': 'true',
- 'type': 'watch',
- 'user__id': str(user.id)
- }
- )
- if active_watches:
- [infraction] = active_watches
- await self.bot.api_client.patch(
- 'bot/infractions/' + str(infraction['id']),
- json={'active': False}
- )
- await ctx.send(f":ok_hand: will no longer relay messages sent by {user}")
- self.watched_users.remove(user.id)
- if user.id in self.channel_queues:
- del self.channel_queues[user.id]
- else:
- await ctx.send(":x: that user is currently not being watched")
-
-
-def setup(bot: Bot):
- bot.add_cog(BigBrother(bot))
- log.info("Cog loaded: BigBrother")
diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py
index 84ddb85f4..7583b2f2d 100644
--- a/bot/cogs/bot.py
+++ b/bot/cogs/bot.py
@@ -2,23 +2,22 @@ import ast
import logging
import re
import time
+from typing import Optional, Tuple
from discord import Embed, Message, RawMessageUpdateEvent
-from discord.ext.commands import Bot, Context, command, group
+from discord.ext.commands import Bot, Cog, Context, command, group
-from bot.constants import (
- Channels, Guild, Roles, URLs
-)
+from bot.constants import Channels, DEBUG_MODE, Guild, MODERATION_ROLES, Roles, URLs
from bot.decorators import with_role
from bot.utils.messages import wait_for_deletion
log = logging.getLogger(__name__)
+RE_MARKDOWN = re.compile(r'([*_~`|>])')
-class Bot:
- """
- Bot information commands
- """
+
+class Bot(Cog):
+ """Bot information commands."""
def __init__(self, bot: Bot):
self.bot = bot
@@ -31,6 +30,8 @@ class Bot:
Channels.help_3: 0,
Channels.help_4: 0,
Channels.help_5: 0,
+ Channels.help_6: 0,
+ Channels.help_7: 0,
Channels.python: 0,
}
@@ -45,29 +46,23 @@ class Bot:
@group(invoke_without_command=True, name="bot", hidden=True)
@with_role(Roles.verified)
- async def bot_group(self, ctx: Context):
- """
- Bot informational commands
- """
-
+ async def botinfo_group(self, ctx: Context) -> None:
+ """Bot informational commands."""
await ctx.invoke(self.bot.get_command("help"), "bot")
- @bot_group.command(name='about', aliases=('info',), hidden=True)
+ @botinfo_group.command(name='about', aliases=('info',), hidden=True)
@with_role(Roles.verified)
- async def about_command(self, ctx: Context):
- """
- Get information about the bot
- """
-
+ async def about_command(self, ctx: Context) -> None:
+ """Get information about the bot."""
embed = Embed(
description="A utility bot designed just for the Python server! Try `!help` for more info.",
- url="https://gitlab.com/discord-python/projects/bot"
+ url="https://github.com/python-discord/bot"
)
embed.add_field(name="Total Users", value=str(len(self.bot.get_guild(Guild.id).members)))
embed.set_author(
name="Python Bot",
- url="https://gitlab.com/discord-python/projects/bot",
+ url="https://github.com/python-discord/bot",
icon_url=URLs.bot_avatar
)
@@ -75,25 +70,19 @@ class Bot:
await ctx.send(embed=embed)
@command(name='echo', aliases=('print',))
- @with_role(Roles.owner, Roles.admin, Roles.moderator)
- async def echo_command(self, ctx: Context, *, text: str):
- """
- Send the input verbatim to the current channel
- """
-
+ @with_role(*MODERATION_ROLES)
+ async def echo_command(self, ctx: Context, *, text: str) -> None:
+ """Send the input verbatim to the current channel."""
await ctx.send(text)
@command(name='embed')
- @with_role(Roles.owner, Roles.admin, Roles.moderator)
- async def embed_command(self, ctx: Context, *, text: str):
- """
- Send the input within an embed to the current channel
- """
-
+ @with_role(*MODERATION_ROLES)
+ async def embed_command(self, ctx: Context, *, text: str) -> None:
+ """Send the input within an embed to the current channel."""
embed = Embed(description=text)
await ctx.send(embed=embed)
- def codeblock_stripping(self, msg: str, bad_ticks: bool):
+ def codeblock_stripping(self, msg: str, bad_ticks: bool) -> Optional[Tuple[Tuple[str, ...], str]]:
"""
Strip msg in order to find Python code.
@@ -162,15 +151,10 @@ class Bot:
log.trace(f"Returning message.\n\n{content}\n\n")
return (content,), repl_code
- def fix_indentation(self, msg: str):
- """
- Attempts to fix badly indented code.
- """
-
- def unindent(code, skip_spaces=0):
- """
- Unindents all code down to the number of spaces given ins skip_spaces
- """
+ def fix_indentation(self, msg: str) -> str:
+ """Attempts to fix badly indented code."""
+ def unindent(code: str, skip_spaces: int = 0) -> str:
+ """Unindents all code down to the number of spaces given in skip_spaces."""
final = ""
current = code[0]
leading_spaces = 0
@@ -206,11 +190,13 @@ class Bot:
msg = f"{first_line}\n{unindent(code, 4)}"
return msg
- def repl_stripping(self, msg: str):
+ def repl_stripping(self, msg: str) -> Tuple[str, bool]:
"""
Strip msg in order to extract Python code out of REPL output.
Tries to strip out REPL Python code out of msg and returns the stripped msg.
+
+ Returns True for the boolean if REPL code was found in the input msg.
"""
final = ""
for line in msg.splitlines(keepends=True):
@@ -224,7 +210,8 @@ class Bot:
log.trace(f"Found REPL code in \n\n{msg}\n\n")
return final.rstrip(), True
- def has_bad_ticks(self, msg: Message):
+ def has_bad_ticks(self, msg: Message) -> bool:
+ """Check to see if msg contains ticks that aren't '`'."""
not_backticks = [
"'''", '"""', "\u00b4\u00b4\u00b4", "\u2018\u2018\u2018", "\u2019\u2019\u2019",
"\u2032\u2032\u2032", "\u201c\u201c\u201c", "\u201d\u201d\u201d", "\u2033\u2033\u2033",
@@ -233,13 +220,14 @@ class Bot:
return msg.content[:3] in not_backticks
- async def on_message(self, msg: Message):
- """
- Detect poorly formatted Python code and send the user
- a helpful message explaining how to do properly
- formatted Python syntax highlighting codeblocks.
+ @Cog.listener()
+ async def on_message(self, msg: Message) -> None:
"""
+ Detect poorly formatted Python code in new messages.
+ If poorly formatted code is detected, send the user a helpful message explaining how to do
+ properly formatted Python syntax highlighting codeblocks.
+ """
parse_codeblock = (
(
msg.channel.id in self.channel_cooldowns
@@ -251,7 +239,7 @@ class Bot:
if parse_codeblock:
on_cooldown = (time.time() - self.channel_cooldowns.get(msg.channel.id, 0)) < 300
- if not on_cooldown:
+ if not on_cooldown or DEBUG_MODE:
try:
if self.has_bad_ticks(msg):
ticks = msg.content[:3]
@@ -276,13 +264,14 @@ class Bot:
current_length += len(line)
lines_walked += 1
content = content[:current_length] + "#..."
-
+ content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content)
howto = (
"It looks like you are trying to paste code into this channel.\n\n"
"You seem to be using the wrong symbols to indicate where the codeblock should start. "
f"The correct symbols would be \\`\\`\\`, not `{ticks}`.\n\n"
"**Here is an example of how it should look:**\n"
- f"\\`\\`\\`python\n{content}\n\\`\\`\\`\n\n**This will result in the following:**\n"
+ f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n"
+ "**This will result in the following:**\n"
f"```python\n{content}\n```"
)
@@ -318,13 +307,15 @@ class Bot:
lines_walked += 1
content = content[:current_length] + "#..."
+ content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content)
howto += (
"It looks like you're trying to paste code into this channel.\n\n"
"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.\n\n"
f"**To do this, use the following method:**\n"
- f"\\`\\`\\`python\n{content}\n\\`\\`\\`\n\n**This will result in the following:**\n"
+ f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n"
+ "**This will result in the following:**\n"
f"```python\n{content}\n```"
)
@@ -354,7 +345,9 @@ class Bot:
f"The message that was posted was:\n\n{msg.content}\n\n"
)
- async def on_raw_message_edit(self, payload: RawMessageUpdateEvent):
+ @Cog.listener()
+ async def on_raw_message_edit(self, payload: RawMessageUpdateEvent) -> None:
+ """Check to see if an edited message (previously called out) still contains poorly formatted code."""
if (
# Checks to see if the message was called out by the bot
payload.message_id not in self.codeblock_message_ids
@@ -366,19 +359,21 @@ class Bot:
return
# Retrieve channel and message objects for use later
- channel = self.bot.get_channel(payload.data.get("channel_id"))
- user_message = await channel.get_message(payload.message_id)
+ channel = self.bot.get_channel(int(payload.data.get("channel_id")))
+ user_message = await channel.fetch_message(payload.message_id)
# Checks to see if the user has corrected their codeblock. If it's fixed, has_fixed_codeblock will be None
has_fixed_codeblock = self.codeblock_stripping(payload.data.get("content"), self.has_bad_ticks(user_message))
# If the message is fixed, delete the bot message and the entry from the id dictionary
if has_fixed_codeblock is None:
- bot_message = await channel.get_message(self.codeblock_message_ids[payload.message_id])
+ bot_message = await channel.fetch_message(self.codeblock_message_ids[payload.message_id])
await bot_message.delete()
del self.codeblock_message_ids[payload.message_id]
+ log.trace("User's incorrect code block has been fixed. Removing bot formatting message.")
-def setup(bot):
+def setup(bot: Bot) -> None:
+ """Bot cog load."""
bot.add_cog(Bot(bot))
log.info("Cog loaded: Bot")
diff --git a/bot/cogs/clean.py b/bot/cogs/clean.py
index 7621c4ef7..dca411d01 100644
--- a/bot/cogs/clean.py
+++ b/bot/cogs/clean.py
@@ -4,31 +4,27 @@ import re
from typing import Optional
from discord import Colour, Embed, Message, User
-from discord.ext.commands import Bot, Context, group
+from discord.ext.commands import Bot, Cog, Context, group
-from bot.cogs.modlog import ModLog
+from bot.cogs.moderation import ModLog
from bot.constants import (
Channels, CleanMessages, Colours, Event,
- Icons, NEGATIVE_REPLIES, Roles
+ Icons, MODERATION_ROLES, NEGATIVE_REPLIES
)
from bot.decorators import with_role
log = logging.getLogger(__name__)
-class Clean:
+class Clean(Cog):
"""
- A cog that allows messages to be deleted in
- bulk, while applying various filters.
+ A cog that allows messages to be deleted in bulk, while applying various filters.
- You can delete messages sent by a specific user,
- messages sent by bots, all messages, or messages
- that match a specific regular expression.
+ You can delete messages sent by a specific user, messages sent by bots, all messages, or messages that match a
+ specific regular expression.
- The deleted messages are saved and uploaded
- to the database via an API endpoint, and a URL is
- returned which can be used to view the messages
- in the Discord dark theme style.
+ The deleted messages are saved and uploaded to the database via an API endpoint, and a URL is returned which can be
+ used to view the messages in the Discord dark theme style.
"""
def __init__(self, bot: Bot):
@@ -37,44 +33,25 @@ class Clean:
@property
def mod_log(self) -> ModLog:
+ """Get currently loaded ModLog cog instance."""
return self.bot.get_cog("ModLog")
async def _clean_messages(
self, amount: int, ctx: Context,
bots_only: bool = False, user: User = None,
regex: Optional[str] = None
- ):
- """
- A helper function that does the actual message cleaning.
-
- :param bots_only: Set this to True if you only want to delete bot messages.
- :param user: Specify a user and it will only delete messages by this user.
- :param regular_expression: Specify a regular expression and it will only
- delete messages that match this.
- """
-
+ ) -> None:
+ """A helper function that does the actual message cleaning."""
def predicate_bots_only(message: Message) -> bool:
- """
- Returns true if the message was sent by a bot
- """
-
+ """Return True if the message was sent by a bot."""
return message.author.bot
def predicate_specific_user(message: Message) -> bool:
- """
- Return True if the message was sent by the
- user provided in the _clean_messages call.
- """
-
+ """Return True if the message was sent by the user provided in the _clean_messages call."""
return message.author == user
- def predicate_regex(message: Message):
- """
- Returns True if the regex provided in the
- _clean_messages matches the message content
- or any embed attributes the message may have.
- """
-
+ def predicate_regex(message: Message) -> bool:
+ """Check if the regex provided in _clean_messages matches the message content or any embed attributes."""
content = [message.content]
# Add the content for all embed attributes
@@ -133,7 +110,8 @@ class Clean:
self.cleaning = True
invocation_deleted = False
- async for message in ctx.channel.history(limit=amount):
+ # To account for the invocation message, we index `amount + 1` messages.
+ async for message in ctx.channel.history(limit=amount + 1):
# If at any point the cancel command is invoked, we should stop.
if not self.cleaning:
@@ -190,62 +168,39 @@ class Clean:
)
@group(invoke_without_command=True, name="clean", hidden=True)
- @with_role(Roles.moderator, Roles.admin, Roles.owner)
- async def clean_group(self, ctx: Context):
- """
- Commands for cleaning messages in channels
- """
-
+ @with_role(*MODERATION_ROLES)
+ async def clean_group(self, ctx: Context) -> None:
+ """Commands for cleaning messages in channels."""
await ctx.invoke(self.bot.get_command("help"), "clean")
@clean_group.command(name="user", aliases=["users"])
- @with_role(Roles.moderator, Roles.admin, Roles.owner)
- async def clean_user(self, ctx: Context, user: User, amount: int = 10):
- """
- Delete messages posted by the provided user,
- and stop cleaning after traversing `amount` messages.
- """
-
+ @with_role(*MODERATION_ROLES)
+ async def clean_user(self, ctx: Context, user: User, amount: int = 10) -> None:
+ """Delete messages posted by the provided user, stop cleaning after traversing `amount` messages."""
await self._clean_messages(amount, ctx, user=user)
@clean_group.command(name="all", aliases=["everything"])
- @with_role(Roles.moderator, Roles.admin, Roles.owner)
- async def clean_all(self, ctx: Context, amount: int = 10):
- """
- Delete all messages, regardless of poster,
- and stop cleaning after traversing `amount` messages.
- """
-
+ @with_role(*MODERATION_ROLES)
+ async def clean_all(self, ctx: Context, amount: int = 10) -> None:
+ """Delete all messages, regardless of poster, stop cleaning after traversing `amount` messages."""
await self._clean_messages(amount, ctx)
@clean_group.command(name="bots", aliases=["bot"])
- @with_role(Roles.moderator, Roles.admin, Roles.owner)
- async def clean_bots(self, ctx: Context, amount: int = 10):
- """
- Delete all messages posted by a bot,
- and stop cleaning after traversing `amount` messages.
- """
-
+ @with_role(*MODERATION_ROLES)
+ async def clean_bots(self, ctx: Context, amount: int = 10) -> None:
+ """Delete all messages posted by a bot, stop cleaning after traversing `amount` messages."""
await self._clean_messages(amount, ctx, bots_only=True)
@clean_group.command(name="regex", aliases=["word", "expression"])
- @with_role(Roles.moderator, Roles.admin, Roles.owner)
- async def clean_regex(self, ctx: Context, regex, amount: int = 10):
- """
- Delete all messages that match a certain regex,
- and stop cleaning after traversing `amount` messages.
- """
-
+ @with_role(*MODERATION_ROLES)
+ async def clean_regex(self, ctx: Context, regex: str, amount: int = 10) -> None:
+ """Delete all messages that match a certain regex, stop cleaning after traversing `amount` messages."""
await self._clean_messages(amount, ctx, regex=regex)
@clean_group.command(name="stop", aliases=["cancel", "abort"])
- @with_role(Roles.moderator, Roles.admin, Roles.owner)
- async def clean_cancel(self, ctx: Context):
- """
- If there is an ongoing cleaning process,
- attempt to immediately cancel it.
- """
-
+ @with_role(*MODERATION_ROLES)
+ async def clean_cancel(self, ctx: Context) -> None:
+ """If there is an ongoing cleaning process, attempt to immediately cancel it."""
self.cleaning = False
embed = Embed(
@@ -255,6 +210,7 @@ class Clean:
await ctx.send(embed=embed, delete_after=10)
-def setup(bot):
+def setup(bot: Bot) -> None:
+ """Clean cog load."""
bot.add_cog(Clean(bot))
log.info("Cog loaded: Clean")
diff --git a/bot/cogs/cogs.py b/bot/cogs/cogs.py
deleted file mode 100644
index cefe6b530..000000000
--- a/bot/cogs/cogs.py
+++ /dev/null
@@ -1,306 +0,0 @@
-import logging
-import os
-
-from discord import Colour, Embed
-from discord.ext.commands import Bot, Context, group
-
-from bot.constants import (
- Emojis, Roles, URLs,
-)
-from bot.decorators import with_role
-from bot.pagination import LinePaginator
-
-log = logging.getLogger(__name__)
-
-KEEP_LOADED = ["bot.cogs.cogs", "bot.cogs.modlog"]
-
-
-class Cogs:
- """
- Cog management commands
- """
-
- def __init__(self, bot: Bot):
- self.bot = bot
- self.cogs = {}
-
- # Load up the cog names
- log.info("Initializing cog names...")
- for filename in os.listdir("bot/cogs"):
- if filename.endswith(".py") and "_" not in filename:
- if os.path.isfile(f"bot/cogs/{filename}"):
- cog = filename[:-3]
-
- self.cogs[cog] = f"bot.cogs.{cog}"
-
- # Allow reverse lookups by reversing the pairs
- self.cogs.update({v: k for k, v in self.cogs.items()})
-
- @group(name='cogs', aliases=('c',), invoke_without_command=True)
- @with_role(Roles.moderator, Roles.admin, Roles.owner, Roles.devops)
- async def cogs_group(self, ctx: Context):
- """Load, unload, reload, and list active cogs."""
-
- await ctx.invoke(self.bot.get_command("help"), "cogs")
-
- @cogs_group.command(name='load', aliases=('l',))
- @with_role(Roles.moderator, Roles.admin, Roles.owner, Roles.devops)
- async def load_command(self, ctx: Context, cog: str):
- """
- Load up an unloaded cog, given the module containing it
-
- You can specify the cog name for any cogs that are placed directly within `!cogs`, or specify the
- entire module directly.
- """
-
- cog = cog.lower()
-
- embed = Embed()
- embed.colour = Colour.red()
-
- embed.set_author(
- name="Python Bot (Cogs)",
- url=URLs.gitlab_bot_repo,
- icon_url=URLs.bot_avatar
- )
-
- if cog in self.cogs:
- full_cog = self.cogs[cog]
- elif "." in cog:
- full_cog = cog
- else:
- full_cog = None
- log.warning(f"{ctx.author} requested we load the '{cog}' cog, but that cog doesn't exist.")
- embed.description = f"Unknown cog: {cog}"
-
- if full_cog:
- if full_cog not in self.bot.extensions:
- try:
- self.bot.load_extension(full_cog)
- except ImportError:
- log.error(f"{ctx.author} requested we load the '{cog}' cog, "
- f"but the cog module {full_cog} could not be found!")
- embed.description = f"Invalid cog: {cog}\n\nCould not find cog module {full_cog}"
- except Exception as e:
- log.error(f"{ctx.author} requested we load the '{cog}' cog, "
- "but the loading failed with the following error: \n"
- f"{e}")
- embed.description = f"Failed to load cog: {cog}\n\n```{e}```"
- else:
- log.debug(f"{ctx.author} requested we load the '{cog}' cog. Cog loaded!")
- embed.description = f"Cog loaded: {cog}"
- embed.colour = Colour.green()
- else:
- log.warning(f"{ctx.author} requested we load the '{cog}' cog, but the cog was already loaded!")
- embed.description = f"Cog {cog} is already loaded"
-
- await ctx.send(embed=embed)
-
- @cogs_group.command(name='unload', aliases=('ul',))
- @with_role(Roles.moderator, Roles.admin, Roles.owner, Roles.devops)
- async def unload_command(self, ctx: Context, cog: str):
- """
- Unload an already-loaded cog, given the module containing it
-
- You can specify the cog name for any cogs that are placed directly within `!cogs`, or specify the
- entire module directly.
- """
-
- cog = cog.lower()
-
- embed = Embed()
- embed.colour = Colour.red()
-
- embed.set_author(
- name="Python Bot (Cogs)",
- url=URLs.gitlab_bot_repo,
- icon_url=URLs.bot_avatar
- )
-
- if cog in self.cogs:
- full_cog = self.cogs[cog]
- elif "." in cog:
- full_cog = cog
- else:
- full_cog = None
- log.warning(f"{ctx.author} requested we unload the '{cog}' cog, but that cog doesn't exist.")
- embed.description = f"Unknown cog: {cog}"
-
- if full_cog:
- if full_cog in KEEP_LOADED:
- log.warning(f"{ctx.author} requested we unload `{full_cog}`, that sneaky pete. We said no.")
- embed.description = f"You may not unload `{full_cog}`!"
- elif full_cog in self.bot.extensions:
- try:
- self.bot.unload_extension(full_cog)
- except Exception as e:
- log.error(f"{ctx.author} requested we unload the '{cog}' cog, "
- "but the unloading failed with the following error: \n"
- f"{e}")
- embed.description = f"Failed to unload cog: {cog}\n\n```{e}```"
- else:
- log.debug(f"{ctx.author} requested we unload the '{cog}' cog. Cog unloaded!")
- embed.description = f"Cog unloaded: {cog}"
- embed.colour = Colour.green()
- else:
- log.warning(f"{ctx.author} requested we unload the '{cog}' cog, but the cog wasn't loaded!")
- embed.description = f"Cog {cog} is not loaded"
-
- await ctx.send(embed=embed)
-
- @cogs_group.command(name='reload', aliases=('r',))
- @with_role(Roles.moderator, Roles.admin, Roles.owner, Roles.devops)
- async def reload_command(self, ctx: Context, cog: str):
- """
- Reload an unloaded cog, given the module containing it
-
- You can specify the cog name for any cogs that are placed directly within `!cogs`, or specify the
- entire module directly.
-
- If you specify "*" as the cog, every cog currently loaded will be unloaded, and then every cog present in the
- bot/cogs directory will be loaded.
- """
-
- cog = cog.lower()
-
- embed = Embed()
- embed.colour = Colour.red()
-
- embed.set_author(
- name="Python Bot (Cogs)",
- url=URLs.gitlab_bot_repo,
- icon_url=URLs.bot_avatar
- )
-
- if cog == "*":
- full_cog = cog
- elif cog in self.cogs:
- full_cog = self.cogs[cog]
- elif "." in cog:
- full_cog = cog
- else:
- full_cog = None
- log.warning(f"{ctx.author} requested we reload the '{cog}' cog, but that cog doesn't exist.")
- embed.description = f"Unknown cog: {cog}"
-
- if full_cog:
- if full_cog == "*":
- all_cogs = [
- f"bot.cogs.{fn[:-3]}" for fn in os.listdir("bot/cogs")
- if os.path.isfile(f"bot/cogs/{fn}") and fn.endswith(".py") and "_" not in fn
- ]
-
- failed_unloads = {}
- failed_loads = {}
-
- unloaded = 0
- loaded = 0
-
- for loaded_cog in self.bot.extensions.copy().keys():
- try:
- self.bot.unload_extension(loaded_cog)
- except Exception as e:
- failed_unloads[loaded_cog] = str(e)
- else:
- unloaded += 1
-
- for unloaded_cog in all_cogs:
- try:
- self.bot.load_extension(unloaded_cog)
- except Exception as e:
- failed_loads[unloaded_cog] = str(e)
- else:
- loaded += 1
-
- lines = [
- "**All cogs reloaded**",
- f"**Unloaded**: {unloaded} / **Loaded**: {loaded}"
- ]
-
- if failed_unloads:
- lines.append("\n**Unload failures**")
-
- for cog, error in failed_unloads:
- lines.append(f"`{cog}` {Emojis.status_dnd} `{error}`")
-
- if failed_loads:
- lines.append("\n**Load failures**")
-
- for cog, error in failed_loads:
- lines.append(f"`{cog}` {Emojis.status_dnd} `{error}`")
-
- log.debug(f"{ctx.author} requested we reload all cogs. Here are the results: \n"
- f"{lines}")
-
- return await LinePaginator.paginate(lines, ctx, embed, empty=False)
-
- elif full_cog in self.bot.extensions:
- try:
- self.bot.unload_extension(full_cog)
- self.bot.load_extension(full_cog)
- except Exception as e:
- log.error(f"{ctx.author} requested we reload the '{cog}' cog, "
- "but the unloading failed with the following error: \n"
- f"{e}")
- embed.description = f"Failed to reload cog: {cog}\n\n```{e}```"
- else:
- log.debug(f"{ctx.author} requested we reload the '{cog}' cog. Cog reloaded!")
- embed.description = f"Cog reload: {cog}"
- embed.colour = Colour.green()
- else:
- log.warning(f"{ctx.author} requested we reload the '{cog}' cog, but the cog wasn't loaded!")
- embed.description = f"Cog {cog} is not loaded"
-
- await ctx.send(embed=embed)
-
- @cogs_group.command(name='list', aliases=('all',))
- @with_role(Roles.moderator, Roles.admin, Roles.owner, Roles.devops)
- async def list_command(self, ctx: Context):
- """
- Get a list of all cogs, including their loaded status.
-
- Gray indicates that the cog is unloaded. Green indicates that the cog is currently loaded.
- """
-
- embed = Embed()
- lines = []
- cogs = {}
-
- embed.colour = Colour.blurple()
- embed.set_author(
- name="Python Bot (Cogs)",
- url=URLs.gitlab_bot_repo,
- icon_url=URLs.bot_avatar
- )
-
- for key, _value in self.cogs.items():
- if "." not in key:
- continue
-
- if key in self.bot.extensions:
- cogs[key] = True
- else:
- cogs[key] = False
-
- for key in self.bot.extensions.keys():
- if key not in self.cogs:
- cogs[key] = True
-
- for cog, loaded in sorted(cogs.items(), key=lambda x: x[0]):
- if cog in self.cogs:
- cog = self.cogs[cog]
-
- if loaded:
- status = Emojis.status_online
- else:
- status = Emojis.status_offline
-
- lines.append(f"{status} {cog}")
-
- log.debug(f"{ctx.author} requested a list of all cogs. Returning a paginated list.")
- await LinePaginator.paginate(lines, ctx, embed, max_size=300, empty=False)
-
-
-def setup(bot):
- bot.add_cog(Cogs(bot))
- log.info("Cog loaded: Cogs")
diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py
index 29979de83..70e101baa 100644
--- a/bot/cogs/defcon.py
+++ b/bot/cogs/defcon.py
@@ -2,17 +2,14 @@ import logging
from datetime import datetime, timedelta
from discord import Colour, Embed, Member
-from discord.ext.commands import Bot, Context, group
+from discord.ext.commands import Bot, Cog, Context, group
-from bot.cogs.modlog import ModLog
-from bot.constants import Channels, Emojis, Icons, Keys, Roles
+from bot.cogs.moderation import ModLog
+from bot.constants import Channels, Colours, Emojis, Event, Icons, Roles
from bot.decorators import with_role
log = logging.getLogger(__name__)
-COLOUR_RED = Colour(0xcd6d6d)
-COLOUR_GREEN = Colour(0x68c290)
-
REJECTION_MESSAGE = """
Hi, {user} - Thanks for your interest in our server!
@@ -24,22 +21,31 @@ will be resolved soon. In the meantime, please feel free to peruse the resources
<https://pythondiscord.com/>, and have a nice day!
"""
+BASE_CHANNEL_TOPIC = "Python Discord Defense Mechanism"
+
+
+class Defcon(Cog):
+ """Time-sensitive server defense mechanisms."""
-class Defcon:
- """Time-sensitive server defense mechanisms"""
days = None # type: timedelta
enabled = False # type: bool
def __init__(self, bot: Bot):
self.bot = bot
+ self.channel = None
self.days = timedelta(days=0)
- self.headers = {"X-API-KEY": Keys.site_api}
+
+ self.bot.loop.create_task(self.sync_settings())
@property
def mod_log(self) -> ModLog:
+ """Get currently loaded ModLog cog instance."""
return self.bot.get_cog("ModLog")
- async def on_ready(self):
+ async def sync_settings(self) -> None:
+ """On cog load, try to synchronize DEFCON settings to the API."""
+ await self.bot.wait_until_ready()
+ self.channel = await self.bot.fetch_channel(Channels.defcon)
try:
response = await self.bot.api_client.get('bot/bot-settings/defcon')
data = response['data']
@@ -61,7 +67,11 @@ class Defcon:
self.days = timedelta(days=0)
log.warning(f"DEFCON disabled")
- async def on_member_join(self, member: Member):
+ await self.update_channel_topic()
+
+ @Cog.listener()
+ async def on_member_join(self, member: Member) -> None:
+ """If DEFCON is enabled, check newly joining users to see if they meet the account age threshold."""
if self.enabled and self.days.days > 0:
now = datetime.utcnow()
@@ -88,27 +98,25 @@ class Defcon:
message = f"{message}\n\nUnable to send rejection message via DM; they probably have DMs disabled."
await self.mod_log.send_log_message(
- Icons.defcon_denied, COLOUR_RED, "Entry denied",
+ Icons.defcon_denied, Colours.soft_red, "Entry denied",
message, member.avatar_url_as(static_format="png")
)
@group(name='defcon', aliases=('dc',), invoke_without_command=True)
@with_role(Roles.admin, Roles.owner)
- async def defcon_group(self, ctx: Context):
+ async def defcon_group(self, ctx: Context) -> None:
"""Check the DEFCON status or run a subcommand."""
-
await ctx.invoke(self.bot.get_command("help"), "defcon")
@defcon_group.command(name='enable', aliases=('on', 'e'))
@with_role(Roles.admin, Roles.owner)
- async def enable_command(self, ctx: Context):
+ async def enable_command(self, ctx: Context) -> None:
"""
Enable DEFCON mode. Useful in a pinch, but be sure you know what you're doing!
- Currently, this just adds an account age requirement. Use !defcon days <int> to set how old an account must
- be, in days.
+ Currently, this just adds an account age requirement. Use !defcon days <int> to set how old an account must be,
+ in days.
"""
-
self.enabled = True
try:
@@ -126,38 +134,19 @@ class Defcon:
except Exception as e:
log.exception("Unable to update DEFCON settings.")
- await ctx.send(
- f"{Emojis.defcon_enabled} DEFCON enabled.\n\n"
- "**There was a problem updating the site** - This setting may be reverted when the bot is "
- "restarted.\n\n"
- f"```py\n{e}\n```"
- )
-
- await self.mod_log.send_log_message(
- Icons.defcon_enabled, COLOUR_GREEN, "DEFCON enabled",
- f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)\n"
- f"**Days:** {self.days.days}\n\n"
- "**There was a problem updating the site** - This setting may be reverted when the bot is "
- "restarted.\n\n"
- f"```py\n{e}\n```"
- )
+ await ctx.send(self.build_defcon_msg("enabled", e))
+ await self.send_defcon_log("enabled", ctx.author, e)
else:
- await ctx.send(f"{Emojis.defcon_enabled} DEFCON enabled.")
+ await ctx.send(self.build_defcon_msg("enabled"))
+ await self.send_defcon_log("enabled", ctx.author)
- await self.mod_log.send_log_message(
- Icons.defcon_enabled, COLOUR_GREEN, "DEFCON enabled",
- f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)\n"
- f"**Days:** {self.days.days}\n\n"
- )
+ await self.update_channel_topic()
@defcon_group.command(name='disable', aliases=('off', 'd'))
@with_role(Roles.admin, Roles.owner)
- async def disable_command(self, ctx: Context):
- """
- Disable DEFCON mode. Useful in a pinch, but be sure you know what you're doing!
- """
-
+ async def disable_command(self, ctx: Context) -> None:
+ """Disable DEFCON mode. Useful in a pinch, but be sure you know what you're doing!"""
self.enabled = False
try:
@@ -173,35 +162,18 @@ class Defcon:
)
except Exception as e:
log.exception("Unable to update DEFCON settings.")
- await ctx.send(
- f"{Emojis.defcon_disabled} DEFCON disabled.\n\n"
- "**There was a problem updating the site** - This setting may be reverted when the bot is "
- "restarted.\n\n"
- f"```py\n{e}\n```"
- )
-
- await self.mod_log.send_log_message(
- Icons.defcon_disabled, COLOUR_RED, "DEFCON disabled",
- f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)\n"
- "**There was a problem updating the site** - This setting may be reverted when the bot is "
- "restarted.\n\n"
- f"```py\n{e}\n```"
- )
+ await ctx.send(self.build_defcon_msg("disabled", e))
+ await self.send_defcon_log("disabled", ctx.author, e)
else:
- await ctx.send(f"{Emojis.defcon_disabled} DEFCON disabled.")
+ await ctx.send(self.build_defcon_msg("disabled"))
+ await self.send_defcon_log("disabled", ctx.author)
- await self.mod_log.send_log_message(
- Icons.defcon_disabled, COLOUR_RED, "DEFCON disabled",
- f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)"
- )
+ await self.update_channel_topic()
@defcon_group.command(name='status', aliases=('s',))
@with_role(Roles.admin, Roles.owner)
- async def status_command(self, ctx: Context):
- """
- Check the current status of DEFCON mode.
- """
-
+ async def status_command(self, ctx: Context) -> None:
+ """Check the current status of DEFCON mode."""
embed = Embed(
colour=Colour.blurple(), title="DEFCON Status",
description=f"**Enabled:** {self.enabled}\n"
@@ -212,11 +184,8 @@ class Defcon:
@defcon_group.command(name='days')
@with_role(Roles.admin, Roles.owner)
- async def days_command(self, ctx: Context, days: int):
- """
- Set how old an account must be to join the server, in days, with DEFCON mode enabled.
- """
-
+ async def days_command(self, ctx: Context, days: int) -> None:
+ """Set how old an account must be to join the server, in days, with DEFCON mode enabled."""
self.days = timedelta(days=days)
try:
@@ -232,34 +201,86 @@ class Defcon:
)
except Exception as e:
log.exception("Unable to update DEFCON settings.")
- await ctx.send(
- f"{Emojis.defcon_updated} DEFCON days updated; accounts must be {days} "
- f"days old to join to the server.\n\n"
- "**There was a problem updating the site** - This setting may be reverted when the bot is "
- "restarted.\n\n"
- f"```py\n{e}\n```"
+ await ctx.send(self.build_defcon_msg("updated", e))
+ await self.send_defcon_log("updated", ctx.author, e)
+ else:
+ await ctx.send(self.build_defcon_msg("updated"))
+ await self.send_defcon_log("updated", ctx.author)
+
+ # Enable DEFCON if it's not already
+ if not self.enabled:
+ self.enabled = True
+
+ await self.update_channel_topic()
+
+ async def update_channel_topic(self) -> None:
+ """Update the #defcon channel topic with the current DEFCON status."""
+ if self.enabled:
+ day_str = "days" if self.days.days > 1 else "day"
+ new_topic = f"{BASE_CHANNEL_TOPIC}\n(Status: Enabled, Threshold: {self.days.days} {day_str})"
+ else:
+ new_topic = f"{BASE_CHANNEL_TOPIC}\n(Status: Disabled)"
+
+ self.mod_log.ignore(Event.guild_channel_update, Channels.defcon)
+ await self.channel.edit(topic=new_topic)
+
+ def build_defcon_msg(self, change: str, e: Exception = None) -> str:
+ """
+ Build in-channel response string for DEFCON action.
+
+ `change` string may be one of the following: ('enabled', 'disabled', 'updated')
+ """
+ if change.lower() == "enabled":
+ msg = f"{Emojis.defcon_enabled} DEFCON enabled.\n\n"
+ elif change.lower() == "disabled":
+ msg = f"{Emojis.defcon_disabled} DEFCON disabled.\n\n"
+ elif change.lower() == "updated":
+ msg = (
+ f"{Emojis.defcon_updated} DEFCON days updated; accounts must be {self.days} "
+ "days old to join the server.\n\n"
)
- await self.mod_log.send_log_message(
- Icons.defcon_updated, Colour.blurple(), "DEFCON updated",
- f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)\n"
- f"**Days:** {self.days.days}\n\n"
- "**There was a problem updating the site** - This setting may be reverted when the bot is "
- "restarted.\n\n"
+ if e:
+ msg += (
+ "**There was a problem updating the site** - This setting may be reverted when the bot restarts.\n\n"
f"```py\n{e}\n```"
)
- else:
- await ctx.send(
- f"{Emojis.defcon_updated} DEFCON days updated; accounts must be {days} days old to join to the server"
- )
- await self.mod_log.send_log_message(
- Icons.defcon_updated, Colour.blurple(), "DEFCON updated",
- f"**Staffer:** {ctx.author.name}#{ctx.author.discriminator} (`{ctx.author.id}`)\n"
- f"**Days:** {self.days.days}"
+ return msg
+
+ async def send_defcon_log(self, change: str, actor: Member, e: Exception = None) -> None:
+ """
+ Send log message for DEFCON action.
+
+ `change` string may be one of the following: ('enabled', 'disabled', 'updated')
+ """
+ log_msg = f"**Staffer:** {actor.name}#{actor.discriminator} (`{actor.id}`)\n"
+
+ if change.lower() == "enabled":
+ icon = Icons.defcon_enabled
+ color = Colours.soft_green
+ status_msg = "DEFCON enabled"
+ log_msg += f"**Days:** {self.days.days}\n\n"
+ elif change.lower() == "disabled":
+ icon = Icons.defcon_disabled
+ color = Colours.soft_red
+ status_msg = "DEFCON enabled"
+ elif change.lower() == "updated":
+ icon = Icons.defcon_updated
+ color = Colour.blurple()
+ status_msg = "DEFCON updated"
+ log_msg += f"**Days:** {self.days.days}\n\n"
+
+ if e:
+ log_msg += (
+ "**There was a problem updating the site** - This setting may be reverted when the bot restarts.\n\n"
+ f"```py\n{e}\n```"
)
+ await self.mod_log.send_log_message(icon, color, status_msg, log_msg)
+
-def setup(bot: Bot):
+def setup(bot: Bot) -> None:
+ """DEFCON cog load."""
bot.add_cog(Defcon(bot))
log.info("Cog loaded: Defcon")
diff --git a/bot/cogs/deployment.py b/bot/cogs/deployment.py
deleted file mode 100644
index bc9dbf5ab..000000000
--- a/bot/cogs/deployment.py
+++ /dev/null
@@ -1,90 +0,0 @@
-import logging
-
-from discord import Colour, Embed
-from discord.ext.commands import Bot, Context, command, group
-
-from bot.constants import Keys, Roles, URLs
-from bot.decorators import with_role
-
-log = logging.getLogger(__name__)
-
-
-class Deployment:
- """
- Bot information commands
- """
-
- def __init__(self, bot: Bot):
- self.bot = bot
-
- @group(name='redeploy', invoke_without_command=True)
- @with_role(Roles.owner, Roles.admin, Roles.moderator)
- async def redeploy_group(self, ctx: Context):
- """Redeploy the bot or the site."""
-
- await ctx.invoke(self.bot.get_command("help"), "redeploy")
-
- @redeploy_group.command(name='bot')
- @with_role(Roles.admin, Roles.owner, Roles.devops)
- async def bot_command(self, ctx: Context):
- """
- Trigger bot deployment on the server - will only redeploy if there were changes to deploy
- """
-
- response = await self.bot.http_session.get(URLs.deploy, headers={"token": Keys.deploy_bot})
- result = await response.text()
-
- if result == "True":
- log.debug(f"{ctx.author} triggered deployment for bot. Deployment was started.")
- await ctx.send(f"{ctx.author.mention} Bot deployment started.")
- else:
- log.error(f"{ctx.author} triggered deployment for bot. Deployment failed to start.")
- await ctx.send(f"{ctx.author.mention} Bot deployment failed - check the logs!")
-
- @redeploy_group.command(name='site')
- @with_role(Roles.admin, Roles.owner, Roles.devops)
- async def site_command(self, ctx: Context):
- """
- Trigger website deployment on the server - will only redeploy if there were changes to deploy
- """
-
- response = await self.bot.http_session.get(URLs.deploy, headers={"token": Keys.deploy_bot})
- result = await response.text()
-
- if result == "True":
- log.debug(f"{ctx.author} triggered deployment for site. Deployment was started.")
- await ctx.send(f"{ctx.author.mention} Site deployment started.")
- else:
- log.error(f"{ctx.author} triggered deployment for site. Deployment failed to start.")
- await ctx.send(f"{ctx.author.mention} Site deployment failed - check the logs!")
-
- @command(name='uptimes')
- @with_role(Roles.admin, Roles.owner, Roles.devops)
- async def uptimes_command(self, ctx: Context):
- """
- Check the various deployment uptimes for each service
- """
-
- log.debug(f"{ctx.author} requested service uptimes.")
- response = await self.bot.http_session.get(URLs.status)
- data = await response.json()
-
- embed = Embed(
- title="Service status",
- color=Colour.blurple()
- )
-
- for obj in data:
- key, value = list(obj.items())[0]
-
- embed.add_field(
- name=key, value=value, inline=True
- )
-
- log.debug("Uptimes retrieved and parsed, returning data.")
- await ctx.send(embed=embed)
-
-
-def setup(bot):
- bot.add_cog(Deployment(bot))
- log.info("Cog loaded: Deployment")
diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py
index d427acc3a..a13464bff 100644
--- a/bot/cogs/doc.py
+++ b/bot/cogs/doc.py
@@ -4,16 +4,17 @@ import logging
import re
import textwrap
from collections import OrderedDict
-from typing import Optional, Tuple
+from typing import Any, Callable, Optional, Tuple
import discord
from bs4 import BeautifulSoup
+from bs4.element import PageElement
from discord.ext import commands
from markdownify import MarkdownConverter
from requests import ConnectionError
from sphinx.ext import intersphinx
-from bot.constants import Roles
+from bot.constants import MODERATION_ROLES
from bot.converters import ValidPythonIdentifier, ValidURL
from bot.decorators import with_role
from bot.pagination import LinePaginator
@@ -27,24 +28,22 @@ UNWANTED_SIGNATURE_SYMBOLS = ('[source]', '¶')
WHITESPACE_AFTER_NEWLINES_RE = re.compile(r"(?<=\n\n)(\s+)")
-def async_cache(max_size=128, arg_offset=0):
+def async_cache(max_size: int = 128, arg_offset: int = 0) -> Callable:
"""
LRU cache implementation for coroutines.
- :param max_size:
- Specifies the maximum size the cache should have.
- Once it exceeds the maximum size, keys are deleted in FIFO order.
- :param arg_offset:
- The offset that should be applied to the coroutine's arguments
- when creating the cache key. Defaults to `0`.
- """
+ Once the cache exceeds the maximum size, keys are deleted in FIFO order.
+ An offset may be optionally provided to be applied to the coroutine's arguments when creating the cache key.
+ """
# Assign the cache to the function itself so we can clear it from outside.
async_cache.cache = OrderedDict()
- def decorator(function):
+ def decorator(function: Callable) -> Callable:
+ """Define the async_cache decorator."""
@functools.wraps(function)
- async def wrapper(*args):
+ async def wrapper(*args) -> Any:
+ """Decorator wrapper for the caching logic."""
key = ':'.join(args[arg_offset:])
value = async_cache.cache.get(key)
@@ -59,27 +58,25 @@ def async_cache(max_size=128, arg_offset=0):
class DocMarkdownConverter(MarkdownConverter):
- def convert_code(self, el, text):
- """Undo `markdownify`s underscore escaping."""
+ """Subclass markdownify's MarkdownCoverter to provide custom conversion methods."""
+ def convert_code(self, el: PageElement, text: str) -> str:
+ """Undo `markdownify`s underscore escaping."""
return f"`{text}`".replace('\\', '')
- def convert_pre(self, el, text):
+ def convert_pre(self, el: PageElement, text: str) -> str:
"""Wrap any codeblocks in `py` for syntax highlighting."""
-
code = ''.join(el.strings)
return f"```py\n{code}```"
-def markdownify(html):
+def markdownify(html: str) -> DocMarkdownConverter:
+ """Create a DocMarkdownConverter object from the input html."""
return DocMarkdownConverter(bullets='•').convert(html)
class DummyObject(object):
- """
- A dummy object which supports assigning anything,
- which the builtin `object()` does not support normally.
- """
+ """A dummy object which supports assigning anything, which the builtin `object()` does not support normally."""
class SphinxConfiguration:
@@ -94,14 +91,15 @@ class InventoryURL(commands.Converter):
"""
Represents an Intersphinx inventory URL.
- This converter checks whether intersphinx
- accepts the given inventory URL, and raises
+ This converter checks whether intersphinx accepts the given inventory URL, and raises
`BadArgument` if that is not the case.
+
Otherwise, it simply passes through the given URL.
"""
@staticmethod
- async def convert(ctx, url: str):
+ async def convert(ctx: commands.Context, url: str) -> str:
+ """Convert url to Intersphinx inventory URL."""
try:
intersphinx.fetch_inventory(SphinxConfiguration(), '', url)
except AttributeError:
@@ -120,31 +118,36 @@ class InventoryURL(commands.Converter):
return url
-class Doc:
- def __init__(self, bot):
+class Doc(commands.Cog):
+ """A set of commands for querying & displaying documentation."""
+
+ def __init__(self, bot: commands.Bot):
self.base_urls = {}
self.bot = bot
self.inventories = {}
- async def on_ready(self):
+ self.bot.loop.create_task(self.init_refresh_inventory())
+
+ async def init_refresh_inventory(self) -> None:
+ """Refresh documentation inventory on cog initialization."""
+ await self.bot.wait_until_ready()
await self.refresh_inventory()
async def update_single(
self, package_name: str, base_url: str, inventory_url: str, config: SphinxConfiguration
- ):
+ ) -> None:
"""
Rebuild the inventory for a single package.
- :param package_name: The package name to use, appears in the log.
- :param base_url: The root documentation URL for the specified package.
- Used to build absolute paths that link to specific symbols.
- :param inventory_url: The absolute URL to the intersphinx inventory.
- Fetched by running `intersphinx.fetch_inventory` in an
- executor on the bot's event loop.
- :param config: A `SphinxConfiguration` instance to mock the regular sphinx
- project layout. Required for use with intersphinx.
+ Where:
+ * `package_name` is the package name to use, appears in the log
+ * `base_url` is the root documentation URL for the specified package, used to build
+ absolute paths that link to specific symbols
+ * `inventory_url` is the absolute URL to the intersphinx inventory, fetched by running
+ `intersphinx.fetch_inventory` in an executor on the bot's event loop
+ * `config` is a `SphinxConfiguration` instance to mock the regular sphinx
+ project layout, required for use with intersphinx
"""
-
self.base_urls[package_name] = base_url
fetch_func = functools.partial(intersphinx.fetch_inventory, config, '', inventory_url)
@@ -158,7 +161,8 @@ class Doc:
log.trace(f"Fetched inventory for {package_name}.")
- async def refresh_inventory(self):
+ async def refresh_inventory(self) -> None:
+ """Refresh internal documentation inventory."""
log.debug("Refreshing documentation inventory...")
# Clear the old base URLS and inventories to ensure
@@ -185,16 +189,13 @@ class Doc:
"""
Given a Python symbol, return its signature and description.
- :param symbol: The symbol for which HTML data should be returned.
- :return:
- A tuple in the form (str, str), or `None`.
- The first tuple element is the signature of the given
- symbol as a markup-free string, and the second tuple
- element is the description of the given symbol with HTML
- markup included. If the given symbol could not be found,
- returns `None`.
- """
+ Returns a tuple in the form (str, str), or `None`.
+
+ The first tuple element is the signature of the given symbol as a markup-free string, and
+ the second tuple element is the description of the given symbol with HTML markup included.
+ If the given symbol could not be found, returns `None`.
+ """
url = self.inventories.get(symbol)
if url is None:
return None
@@ -208,6 +209,9 @@ class Doc:
symbol_heading = soup.find(id=symbol_id)
signature_buffer = []
+ if symbol_heading is None:
+ return None
+
# Traverse the tags of the signature header and ignore any
# unwanted symbols from it. Add all of it to a temporary buffer.
for tag in symbol_heading.strings:
@@ -222,16 +226,10 @@ class Doc:
@async_cache(arg_offset=1)
async def get_symbol_embed(self, symbol: str) -> Optional[discord.Embed]:
"""
- Using `get_symbol_html`, attempt to scrape and
- fetch the data for the given `symbol`, and build
- a formatted embed out of its contents.
-
- :param symbol: The symbol for which the embed should be returned
- :return:
- If the symbol is known, an Embed with documentation about it.
- Otherwise, `None`.
- """
+ Attempt to scrape and fetch the data for the given `symbol`, and build an embed from its contents.
+ If the symbol is known, an Embed with documentation about it is returned.
+ """
scraped_html = await self.get_symbol_html(symbol)
if scraped_html is None:
return None
@@ -266,20 +264,16 @@ class Doc:
)
@commands.group(name='docs', aliases=('doc', 'd'), invoke_without_command=True)
- async def docs_group(self, ctx, symbol: commands.clean_content = None):
+ async def docs_group(self, ctx: commands.Context, symbol: commands.clean_content = None) -> None:
"""Lookup documentation for Python symbols."""
-
- await ctx.invoke(self.get_command)
+ await ctx.invoke(self.get_command, symbol)
@docs_group.command(name='get', aliases=('g',))
- async def get_command(self, ctx, symbol: commands.clean_content = None):
+ async def get_command(self, ctx: commands.Context, symbol: commands.clean_content = None) -> None:
"""
Return a documentation embed for a given symbol.
- If no symbol is given, return a list of all available inventories.
- :param ctx: Discord message context
- :param symbol: The symbol for which documentation should be returned,
- or nothing to get a list of all inventories
+ If no symbol is given, return a list of all available inventories.
Examples:
!docs
@@ -287,7 +281,6 @@ class Doc:
!docs aiohttp.ClientSession
!docs get aiohttp.ClientSession
"""
-
if symbol is None:
inventory_embed = discord.Embed(
title=f"All inventories (`{len(self.base_urls)}` total)",
@@ -319,28 +312,22 @@ class Doc:
await ctx.send(embed=doc_embed)
@docs_group.command(name='set', aliases=('s',))
- @with_role(Roles.admin, Roles.owner, Roles.moderator)
+ @with_role(*MODERATION_ROLES)
async def set_command(
- self, ctx, package_name: ValidPythonIdentifier,
+ self, ctx: commands.Context, package_name: ValidPythonIdentifier,
base_url: ValidURL, inventory_url: InventoryURL
- ):
+ ) -> None:
"""
Adds a new documentation metadata object to the site's database.
- The database will update the object, should an existing item
- with the specified `package_name` already exist.
- :param ctx: Discord message context
- :param package_name: The package name, for example `aiohttp`.
- :param base_url: The package documentation's root URL, used to build absolute links.
- :param inventory_url: The intersphinx inventory URL.
+ The database will update the object, should an existing item with the specified `package_name` already exist.
Example:
!docs set \
- discord \
- https://discordpy.readthedocs.io/en/rewrite/ \
- https://discordpy.readthedocs.io/en/rewrite/objects.inv
+ python \
+ https://docs.python.org/3/ \
+ https://docs.python.org/3/objects.inv
"""
-
body = {
'package': package_name,
'base_url': base_url,
@@ -363,18 +350,14 @@ class Doc:
await ctx.send(f"Added package `{package_name}` to database and refreshed inventory.")
@docs_group.command(name='delete', aliases=('remove', 'rm', 'd'))
- @with_role(Roles.admin, Roles.owner, Roles.moderator)
- async def delete_command(self, ctx, package_name: ValidPythonIdentifier):
+ @with_role(*MODERATION_ROLES)
+ async def delete_command(self, ctx: commands.Context, package_name: ValidPythonIdentifier) -> None:
"""
Removes the specified package from the database.
- :param ctx: Discord message context
- :param package_name: The package name, for example `aiohttp`.
-
Examples:
!docs delete aiohttp
"""
-
await self.bot.api_client.delete(f'bot/documentation-links/{package_name}')
async with ctx.typing():
@@ -384,5 +367,7 @@ class Doc:
await ctx.send(f"Successfully deleted `{package_name}` and refreshed inventory.")
-def setup(bot):
+def setup(bot: commands.Bot) -> None:
+ """Doc cog load."""
bot.add_cog(Doc(bot))
+ log.info("Cog loaded: Doc")
diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py
new file mode 100644
index 000000000..49411814c
--- /dev/null
+++ b/bot/cogs/error_handler.py
@@ -0,0 +1,148 @@
+import contextlib
+import logging
+
+from discord.ext.commands import (
+ BadArgument,
+ BotMissingPermissions,
+ CheckFailure,
+ CommandError,
+ CommandInvokeError,
+ CommandNotFound,
+ CommandOnCooldown,
+ DisabledCommand,
+ MissingPermissions,
+ NoPrivateMessage,
+ UserInputError,
+)
+from discord.ext.commands import Bot, Cog, Context
+
+from bot.api import ResponseCodeError
+from bot.constants import Channels
+from bot.decorators import InChannelCheckFailure
+
+log = logging.getLogger(__name__)
+
+
+class ErrorHandler(Cog):
+ """Handles errors emitted from commands."""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+
+ @Cog.listener()
+ async def on_command_error(self, ctx: Context, e: CommandError) -> None:
+ """
+ Provide generic command error handling.
+
+ Error handling is deferred to any local error handler, if present.
+
+ Error handling emits a single error response, prioritized as follows:
+ 1. If the name fails to match a command but matches a tag, the tag is invoked
+ 2. Send a BadArgument error message to the invoking context & invoke the command's help
+ 3. Send a UserInputError error message to the invoking context & invoke the command's help
+ 4. Send a NoPrivateMessage error message to the invoking context
+ 5. Send a BotMissingPermissions error message to the invoking context
+ 6. Log a MissingPermissions error, no message is sent
+ 7. Send a InChannelCheckFailure error message to the invoking context
+ 8. Log CheckFailure, CommandOnCooldown, and DisabledCommand errors, no message is sent
+ 9. For CommandInvokeErrors, response is based on the type of error:
+ * 404: Error message is sent to the invoking context
+ * 400: Log the resopnse JSON, no message is sent
+ * 500 <= status <= 600: Error message is sent to the invoking context
+ 10. Otherwise, handling is deferred to `handle_unexpected_error`
+ """
+ command = ctx.command
+ parent = None
+
+ if command is not None:
+ parent = command.parent
+
+ # Retrieve the help command for the invoked command.
+ if parent and command:
+ help_command = (self.bot.get_command("help"), parent.name, command.name)
+ elif command:
+ help_command = (self.bot.get_command("help"), command.name)
+ else:
+ help_command = (self.bot.get_command("help"),)
+
+ if hasattr(e, "handled"):
+ 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, CommandNotFound) and not hasattr(ctx, "invoked_from_error_handler"):
+ if not ctx.channel.id == Channels.verification:
+ tags_get_command = self.bot.get_command("tags get")
+ ctx.invoked_from_error_handler = True
+
+ # Return to not raise the exception
+ with contextlib.suppress(ResponseCodeError):
+ await ctx.invoke(tags_get_command, tag_name=ctx.invoked_with)
+ return
+ elif isinstance(e, BadArgument):
+ await ctx.send(f"Bad argument: {e}\n")
+ await ctx.invoke(*help_command)
+ elif isinstance(e, UserInputError):
+ await ctx.send("Something about your input seems off. Check the arguments:")
+ await ctx.invoke(*help_command)
+ log.debug(
+ f"Command {command} invoked by {ctx.message.author} with error "
+ f"{e.__class__.__name__}: {e}"
+ )
+ elif isinstance(e, NoPrivateMessage):
+ await ctx.send("Sorry, this command can't be used in a private message!")
+ elif isinstance(e, BotMissingPermissions):
+ await ctx.send(f"Sorry, it looks like I don't have the permissions I need to do that.")
+ log.warning(
+ f"The bot is missing permissions to execute command {command}: {e.missing_perms}"
+ )
+ elif isinstance(e, MissingPermissions):
+ log.debug(
+ f"{ctx.message.author} is missing permissions to invoke command {command}: "
+ f"{e.missing_perms}"
+ )
+ elif isinstance(e, InChannelCheckFailure):
+ await ctx.send(e)
+ elif isinstance(e, (CheckFailure, CommandOnCooldown, DisabledCommand)):
+ log.debug(
+ f"Command {command} invoked by {ctx.message.author} with error "
+ f"{e.__class__.__name__}: {e}"
+ )
+ elif isinstance(e, CommandInvokeError):
+ if isinstance(e.original, ResponseCodeError):
+ status = e.original.response.status
+
+ if status == 404:
+ await ctx.send("There does not seem to be anything matching your query.")
+ elif status == 400:
+ content = await e.original.response.json()
+ log.debug(f"API responded with 400 for command {command}: %r.", content)
+ await ctx.send("According to the API, your request is malformed.")
+ elif 500 <= status < 600:
+ await ctx.send("Sorry, there seems to be an internal issue with the API.")
+ log.warning(f"API responded with {status} for command {command}")
+ else:
+ await ctx.send(f"Got an unexpected status code from the API (`{status}`).")
+ log.warning(f"Unexpected API response for command {command}: {status}")
+ else:
+ await self.handle_unexpected_error(ctx, e.original)
+ else:
+ await self.handle_unexpected_error(ctx, e)
+
+ @staticmethod
+ async def handle_unexpected_error(ctx: Context, e: CommandError) -> None:
+ """Generic handler for errors without an explicit handler."""
+ await ctx.send(
+ f"Sorry, an unexpected error occurred. Please let us know!\n\n"
+ f"```{e.__class__.__name__}: {e}```"
+ )
+ log.error(
+ f"Error executing command invoked by {ctx.message.author}: {ctx.message.content}"
+ )
+ raise e
+
+
+def setup(bot: Bot) -> None:
+ """Error handler cog load."""
+ bot.add_cog(ErrorHandler(bot))
+ log.info("Cog loaded: Events")
diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py
index 8e97a35a2..9ce854f2c 100644
--- a/bot/cogs/eval.py
+++ b/bot/cogs/eval.py
@@ -6,9 +6,10 @@ import re
import textwrap
import traceback
from io import StringIO
+from typing import Any, Optional, Tuple
import discord
-from discord.ext.commands import Bot, group
+from discord.ext.commands import Bot, Cog, Context, group
from bot.constants import Roles
from bot.decorators import with_role
@@ -17,11 +18,8 @@ from bot.interpreter import Interpreter
log = logging.getLogger(__name__)
-class CodeEval:
- """
- Owner and admin feature that evaluates code
- and returns the result to the channel.
- """
+class CodeEval(Cog):
+ """Owner and admin feature that evaluates code and returns the result to the channel."""
def __init__(self, bot: Bot):
self.bot = bot
@@ -31,7 +29,8 @@ class CodeEval:
self.interpreter = Interpreter(bot)
- def _format(self, inp, out): # (str, Any) -> (str, discord.Embed)
+ def _format(self, inp: str, out: Any) -> Tuple[str, Optional[discord.Embed]]:
+ """Format the eval output into a string & attempt to format it into an Embed."""
self._ = out
res = ""
@@ -124,7 +123,8 @@ class CodeEval:
return res # Return (text, embed)
- async def _eval(self, ctx, code): # (discord.Context, str) -> None
+ async def _eval(self, ctx: Context, code: str) -> Optional[discord.Message]:
+ """Eval the input code string & send an embed to the invoking context."""
self.ln += 1
if code.startswith("exit"):
@@ -174,16 +174,15 @@ async def func(): # (None,) -> Any
@group(name='internal', aliases=('int',))
@with_role(Roles.owner, Roles.admin)
- async def internal_group(self, ctx):
+ async def internal_group(self, ctx: Context) -> None:
"""Internal commands. Top secret!"""
-
if not ctx.invoked_subcommand:
await ctx.invoke(self.bot.get_command("help"), "internal")
@internal_group.command(name='eval', aliases=('e',))
@with_role(Roles.admin, Roles.owner)
- async def eval(self, ctx, *, code: str):
- """ Run eval in a REPL-like format. """
+ async def eval(self, ctx: Context, *, code: str) -> None:
+ """Run eval in a REPL-like format."""
code = code.strip("`")
if re.match('py(thon)?\n', code):
code = "\n".join(code.split("\n")[1:])
@@ -197,6 +196,7 @@ async def func(): # (None,) -> Any
await self._eval(ctx, code)
-def setup(bot):
+def setup(bot: Bot) -> None:
+ """Code eval cog load."""
bot.add_cog(CodeEval(bot))
log.info("Cog loaded: Eval")
diff --git a/bot/cogs/extensions.py b/bot/cogs/extensions.py
new file mode 100644
index 000000000..bb66e0b8e
--- /dev/null
+++ b/bot/cogs/extensions.py
@@ -0,0 +1,236 @@
+import functools
+import logging
+import typing as t
+from enum import Enum
+from pkgutil import iter_modules
+
+from discord import Colour, Embed
+from discord.ext import commands
+from discord.ext.commands import Bot, Context, group
+
+from bot.constants import Emojis, MODERATION_ROLES, Roles, URLs
+from bot.pagination import LinePaginator
+from bot.utils.checks import with_role_check
+
+log = logging.getLogger(__name__)
+
+UNLOAD_BLACKLIST = {"bot.cogs.extensions", "bot.cogs.modlog"}
+EXTENSIONS = frozenset(
+ ext.name
+ for ext in iter_modules(("bot/cogs",), "bot.cogs.")
+ if ext.name[-1] != "_"
+)
+
+
+class Action(Enum):
+ """Represents an action to perform on an extension."""
+
+ # Need to be partial otherwise they are considered to be function definitions.
+ LOAD = functools.partial(Bot.load_extension)
+ UNLOAD = functools.partial(Bot.unload_extension)
+ RELOAD = functools.partial(Bot.reload_extension)
+
+
+class Extension(commands.Converter):
+ """
+ Fully qualify the name of an extension and ensure it exists.
+
+ The * and ** values bypass this when used with the reload command.
+ """
+
+ async def convert(self, ctx: Context, argument: str) -> str:
+ """Fully qualify the name of an extension and ensure it exists."""
+ # Special values to reload all extensions
+ if argument == "*" or argument == "**":
+ return argument
+
+ argument = argument.lower()
+
+ if "." not in argument:
+ argument = f"bot.cogs.{argument}"
+
+ if argument in EXTENSIONS:
+ return argument
+ else:
+ raise commands.BadArgument(f":x: Could not find the extension `{argument}`.")
+
+
+class Extensions(commands.Cog):
+ """Extension management commands."""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+
+ @group(name="extensions", aliases=("ext", "exts", "c", "cogs"), invoke_without_command=True)
+ async def extensions_group(self, ctx: Context) -> None:
+ """Load, unload, reload, and list loaded extensions."""
+ await ctx.invoke(self.bot.get_command("help"), "extensions")
+
+ @extensions_group.command(name="load", aliases=("l",))
+ async def load_command(self, ctx: Context, *extensions: Extension) -> None:
+ """
+ Load extensions given their fully qualified or unqualified names.
+
+ If '\*' or '\*\*' is given as the name, all unloaded extensions will be loaded.
+ """ # noqa: W605
+ if not extensions:
+ await ctx.invoke(self.bot.get_command("help"), "extensions load")
+ return
+
+ if "*" in extensions or "**" in extensions:
+ extensions = set(EXTENSIONS) - set(self.bot.extensions.keys())
+
+ msg = self.batch_manage(Action.LOAD, *extensions)
+ await ctx.send(msg)
+
+ @extensions_group.command(name="unload", aliases=("ul",))
+ async def unload_command(self, ctx: Context, *extensions: Extension) -> None:
+ """
+ Unload currently loaded extensions given their fully qualified or unqualified names.
+
+ If '\*' or '\*\*' is given as the name, all loaded extensions will be unloaded.
+ """ # noqa: W605
+ if not extensions:
+ await ctx.invoke(self.bot.get_command("help"), "extensions unload")
+ return
+
+ blacklisted = "\n".join(UNLOAD_BLACKLIST & set(extensions))
+
+ if blacklisted:
+ msg = f":x: The following extension(s) may not be unloaded:```{blacklisted}```"
+ else:
+ if "*" in extensions or "**" in extensions:
+ extensions = set(self.bot.extensions.keys()) - UNLOAD_BLACKLIST
+
+ msg = self.batch_manage(Action.UNLOAD, *extensions)
+
+ await ctx.send(msg)
+
+ @extensions_group.command(name="reload", aliases=("r",))
+ async def reload_command(self, ctx: Context, *extensions: Extension) -> None:
+ """
+ Reload extensions given their fully qualified or unqualified names.
+
+ If an extension fails to be reloaded, it will be rolled-back to the prior working state.
+
+ If '\*' is given as the name, all currently loaded extensions will be reloaded.
+ If '\*\*' is given as the name, all extensions, including unloaded ones, will be reloaded.
+ """ # noqa: W605
+ if not extensions:
+ await ctx.invoke(self.bot.get_command("help"), "extensions reload")
+ return
+
+ if "**" in extensions:
+ extensions = EXTENSIONS
+ elif "*" in extensions:
+ extensions = set(self.bot.extensions.keys()) | set(extensions)
+ extensions.remove("*")
+
+ msg = self.batch_manage(Action.RELOAD, *extensions)
+
+ await ctx.send(msg)
+
+ @extensions_group.command(name="list", aliases=("all",))
+ async def list_command(self, ctx: Context) -> None:
+ """
+ Get a list of all extensions, including their loaded status.
+
+ Grey indicates that the extension is unloaded.
+ Green indicates that the extension is currently loaded.
+ """
+ embed = Embed()
+ lines = []
+
+ embed.colour = Colour.blurple()
+ embed.set_author(
+ name="Extensions List",
+ url=URLs.github_bot_repo,
+ icon_url=URLs.bot_avatar
+ )
+
+ for ext in sorted(list(EXTENSIONS)):
+ if ext in self.bot.extensions:
+ status = Emojis.status_online
+ else:
+ status = Emojis.status_offline
+
+ ext = ext.rsplit(".", 1)[1]
+ lines.append(f"{status} {ext}")
+
+ log.debug(f"{ctx.author} requested a list of all cogs. Returning a paginated list.")
+ await LinePaginator.paginate(lines, ctx, embed, max_size=300, empty=False)
+
+ def batch_manage(self, action: Action, *extensions: str) -> str:
+ """
+ Apply an action to multiple extensions and return a message with the results.
+
+ If only one extension is given, it is deferred to `manage()`.
+ """
+ if len(extensions) == 1:
+ msg, _ = self.manage(action, extensions[0])
+ return msg
+
+ verb = action.name.lower()
+ failures = {}
+
+ for extension in extensions:
+ _, error = self.manage(action, extension)
+ if error:
+ failures[extension] = error
+
+ emoji = ":x:" if failures else ":ok_hand:"
+ msg = f"{emoji} {len(extensions) - len(failures)} / {len(extensions)} extensions {verb}ed."
+
+ if failures:
+ failures = "\n".join(f"{ext}\n {err}" for ext, err in failures.items())
+ msg += f"\nFailures:```{failures}```"
+
+ log.debug(f"Batch {verb}ed extensions.")
+
+ return msg
+
+ def manage(self, action: Action, ext: str) -> t.Tuple[str, t.Optional[str]]:
+ """Apply an action to an extension and return the status message and any error message."""
+ verb = action.name.lower()
+ error_msg = None
+
+ try:
+ action.value(self.bot, ext)
+ except (commands.ExtensionAlreadyLoaded, commands.ExtensionNotLoaded):
+ if action is Action.RELOAD:
+ # When reloading, just load the extension if it was not loaded.
+ return self.manage(Action.LOAD, ext)
+
+ msg = f":x: Extension `{ext}` is already {verb}ed."
+ log.debug(msg[4:])
+ except Exception as e:
+ if hasattr(e, "original"):
+ e = e.original
+
+ log.exception(f"Extension '{ext}' failed to {verb}.")
+
+ error_msg = f"{e.__class__.__name__}: {e}"
+ msg = f":x: Failed to {verb} extension `{ext}`:\n```{error_msg}```"
+ else:
+ msg = f":ok_hand: Extension successfully {verb}ed: `{ext}`."
+ log.debug(msg[10:])
+
+ return msg, error_msg
+
+ # This cannot be static (must have a __func__ attribute).
+ def cog_check(self, ctx: Context) -> bool:
+ """Only allow moderators and core developers to invoke the commands in this cog."""
+ return with_role_check(ctx, *MODERATION_ROLES, Roles.core_developer)
+
+ # This cannot be static (must have a __func__ attribute).
+ async def cog_command_error(self, ctx: Context, error: Exception) -> None:
+ """Handle BadArgument errors locally to prevent the help command from showing."""
+ if isinstance(error, commands.BadArgument):
+ await ctx.send(str(error))
+ error.handled = True
+
+
+def setup(bot: Bot) -> None:
+ """Load the Extensions cog."""
+ bot.add_cog(Extensions(bot))
+ log.info("Cog loaded: Extensions")
diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py
index d1a0de89e..265ae5160 100644
--- a/bot/cogs/filtering.py
+++ b/bot/cogs/filtering.py
@@ -1,10 +1,13 @@
import logging
import re
+from typing import Optional, Union
-from discord import Colour, Member, Message
-from discord.ext.commands import Bot
+import discord.errors
+from dateutil.relativedelta import relativedelta
+from discord import Colour, DMChannel, Member, Message, TextChannel
+from discord.ext.commands import Bot, Cog
-from bot.cogs.modlog import ModLog
+from bot.cogs.moderation import ModLog
from bot.constants import (
Channels, Colours, DEBUG_MODE,
Filter, Icons, URLs
@@ -12,75 +15,113 @@ from bot.constants import (
log = logging.getLogger(__name__)
-INVITE_RE = (
+INVITE_RE = re.compile(
r"(?:discord(?:[\.,]|dot)gg|" # Could be discord.gg/
r"discord(?:[\.,]|dot)com(?:\/|slash)invite|" # or discord.com/invite/
r"discordapp(?:[\.,]|dot)com(?:\/|slash)invite|" # or discordapp.com/invite/
r"discord(?:[\.,]|dot)me|" # or discord.me
r"discord(?:[\.,]|dot)io" # or discord.io.
r")(?:[\/]|slash)" # / or 'slash'
- r"([a-zA-Z0-9]+)" # the invite code itself
+ r"([a-zA-Z0-9]+)", # the invite code itself
+ flags=re.IGNORECASE
)
-URL_RE = r"(https?://[^\s]+)"
-ZALGO_RE = r"[\u0300-\u036F\u0489]"
+URL_RE = re.compile(r"(https?://[^\s]+)", flags=re.IGNORECASE)
+ZALGO_RE = re.compile(r"[\u0300-\u036F\u0489]")
+WORD_WATCHLIST_PATTERNS = [
+ re.compile(fr'\b{expression}\b', flags=re.IGNORECASE) for expression in Filter.word_watchlist
+]
+TOKEN_WATCHLIST_PATTERNS = [
+ re.compile(fr'{expression}', flags=re.IGNORECASE) for expression in Filter.token_watchlist
+]
-class Filtering:
- """
- Filtering out invites, blacklisting domains,
- and warning us of certain regular expressions
- """
+
+class Filtering(Cog):
+ """Filtering out invites, blacklisting domains, and warning us of certain regular expressions."""
def __init__(self, bot: Bot):
self.bot = bot
+ _staff_mistake_str = "If you believe this was a mistake, please let staff know!"
self.filters = {
"filter_zalgo": {
"enabled": Filter.filter_zalgo,
"function": self._has_zalgo,
- "type": "filter"
+ "type": "filter",
+ "content_only": True,
+ "user_notification": Filter.notify_user_zalgo,
+ "notification_msg": (
+ "Your post has been removed for abusing Unicode character rendering (aka Zalgo text). "
+ f"{_staff_mistake_str}"
+ )
},
"filter_invites": {
"enabled": Filter.filter_invites,
"function": self._has_invites,
- "type": "filter"
+ "type": "filter",
+ "content_only": True,
+ "user_notification": Filter.notify_user_invites,
+ "notification_msg": (
+ f"Per Rule 10, your invite link has been removed. {_staff_mistake_str}\n\n"
+ r"Our server rules can be found here: <https://pythondiscord.com/pages/rules>"
+ )
},
"filter_domains": {
"enabled": Filter.filter_domains,
"function": self._has_urls,
- "type": "filter"
+ "type": "filter",
+ "content_only": True,
+ "user_notification": Filter.notify_user_domains,
+ "notification_msg": (
+ f"Your URL has been removed because it matched a blacklisted domain. {_staff_mistake_str}"
+ )
+ },
+ "watch_rich_embeds": {
+ "enabled": Filter.watch_rich_embeds,
+ "function": self._has_rich_embed,
+ "type": "watchlist",
+ "content_only": False,
},
"watch_words": {
"enabled": Filter.watch_words,
"function": self._has_watchlist_words,
- "type": "watchlist"
+ "type": "watchlist",
+ "content_only": True,
},
"watch_tokens": {
"enabled": Filter.watch_tokens,
"function": self._has_watchlist_tokens,
- "type": "watchlist"
+ "type": "watchlist",
+ "content_only": True,
},
}
@property
def mod_log(self) -> ModLog:
+ """Get currently loaded ModLog cog instance."""
return self.bot.get_cog("ModLog")
- async def on_message(self, msg: Message):
+ @Cog.listener()
+ async def on_message(self, msg: Message) -> None:
+ """Invoke message filter for new messages."""
await self._filter_message(msg)
- async def on_message_edit(self, _: Message, after: Message):
- await self._filter_message(after)
-
- async def _filter_message(self, msg: Message):
- """
- Whenever a message is sent or edited,
- run it through our filters to see if it
- violates any of our rules, and then respond
- accordingly.
+ @Cog.listener()
+ async def on_message_edit(self, before: Message, after: Message) -> None:
"""
+ Invoke message filter for message edits.
+ If there have been multiple edits, calculate the time delta from the previous edit.
+ """
+ if not before.edited_at:
+ delta = relativedelta(after.edited_at, before.created_at).microseconds
+ else:
+ delta = relativedelta(after.edited_at, before.edited_at).microseconds
+ await self._filter_message(after, delta)
+
+ async def _filter_message(self, msg: Message, delta: Optional[int] = None) -> None:
+ """Filter the input message to see if it violates any of our rules, and then respond accordingly."""
# Should we filter this message?
role_whitelisted = False
@@ -102,22 +143,77 @@ class Filtering:
# If none of the above, we can start filtering.
if filter_message:
for filter_name, _filter in self.filters.items():
-
# Is this specific filter enabled in the config?
if _filter["enabled"]:
- triggered = await _filter["function"](msg.content)
+ # Double trigger check for the embeds filter
+ if filter_name == "watch_rich_embeds":
+ # If the edit delta is less than 0.001 seconds, then we're probably dealing
+ # with a double filter trigger.
+ if delta is not None and delta < 100:
+ continue
+
+ # Does the filter only need the message content or the full message?
+ if _filter["content_only"]:
+ triggered = await _filter["function"](msg.content)
+ else:
+ triggered = await _filter["function"](msg)
if triggered:
+ # If this is a filter (not a watchlist), we should delete the message.
+ if _filter["type"] == "filter":
+ try:
+ # Embeds (can?) trigger both the `on_message` and `on_message_edit`
+ # event handlers, triggering filtering twice for the same message.
+ #
+ # If `on_message`-triggered filtering already deleted the message
+ # then `on_message_edit`-triggered filtering will raise exception
+ # since the message no longer exists.
+ #
+ # In addition, to avoid sending two notifications to the user, the
+ # logs, and mod_alert, we return if the message no longer exists.
+ await msg.delete()
+ except discord.errors.NotFound:
+ return
+
+ # Notify the user if the filter specifies
+ if _filter["user_notification"]:
+ await self.notify_member(msg.author, _filter["notification_msg"], msg.channel)
+
+ if isinstance(msg.channel, DMChannel):
+ channel_str = "via DM"
+ else:
+ channel_str = f"in {msg.channel.mention}"
+
message = (
f"The {filter_name} {_filter['type']} was triggered "
f"by **{msg.author.name}#{msg.author.discriminator}** "
- f"(`{msg.author.id}`) in <#{msg.channel.id}> with [the "
+ f"(`{msg.author.id}`) {channel_str} with [the "
f"following message]({msg.jump_url}):\n\n"
f"{msg.content}"
)
log.debug(message)
+ additional_embeds = None
+ additional_embeds_msg = None
+
+ if filter_name == "filter_invites":
+ additional_embeds = []
+ for invite, data in triggered.items():
+ embed = discord.Embed(description=(
+ f"**Members:**\n{data['members']}\n"
+ f"**Active:**\n{data['active']}"
+ ))
+ embed.set_author(name=data["name"])
+ embed.set_thumbnail(url=data["icon"])
+ embed.set_footer(text=f"Guild Invite Code: {invite}")
+ additional_embeds.append(embed)
+ additional_embeds_msg = "For the following guild(s):"
+
+ elif filter_name == "watch_rich_embeds":
+ additional_embeds = msg.embeds
+ additional_embeds_msg = "With the following embed(s):"
+
# Send pretty mod log embed to mod-alerts
await self.mod_log.send_log_message(
icon_url=Icons.filtering,
@@ -127,27 +223,21 @@ class Filtering:
thumbnail=msg.author.avatar_url_as(static_format="png"),
channel_id=Channels.mod_alerts,
ping_everyone=Filter.ping_everyone,
+ additional_embeds=additional_embeds,
+ additional_embeds_msg=additional_embeds_msg
)
- # If this is a filter (not a watchlist), we should delete the message.
- if _filter["type"] == "filter":
- await msg.delete()
-
break # We don't want multiple filters to trigger
@staticmethod
async def _has_watchlist_words(text: str) -> bool:
"""
- Returns True if the text contains
- one of the regular expressions from the
- word_watchlist in our filter config.
+ Returns True if the text contains one of the regular expressions from the word_watchlist in our filter config.
- Only matches words with boundaries before
- and after the expression.
+ Only matches words with boundaries before and after the expression.
"""
-
- for expression in Filter.word_watchlist:
- if re.search(fr"\b{expression}\b", text, re.IGNORECASE):
+ for regex_pattern in WORD_WATCHLIST_PATTERNS:
+ if regex_pattern.search(text):
return True
return False
@@ -155,31 +245,23 @@ class Filtering:
@staticmethod
async def _has_watchlist_tokens(text: str) -> bool:
"""
- Returns True if the text contains
- one of the regular expressions from the
- token_watchlist in our filter config.
+ Returns True if the text contains one of the regular expressions from the token_watchlist in our filter config.
- This will match the expression even if it
- does not have boundaries before and after
+ This will match the expression even if it does not have boundaries before and after.
"""
-
- for expression in Filter.token_watchlist:
- if re.search(fr"{expression}", text, re.IGNORECASE):
+ for regex_pattern in TOKEN_WATCHLIST_PATTERNS:
+ if regex_pattern.search(text):
# Make sure it's not a URL
- if not re.search(URL_RE, text, re.IGNORECASE):
+ if not URL_RE.search(text):
return True
return False
@staticmethod
async def _has_urls(text: str) -> bool:
- """
- Returns True if the text contains one of
- the blacklisted URLs from the config file.
- """
-
- if not re.search(URL_RE, text, re.IGNORECASE):
+ """Returns True if the text contains one of the blacklisted URLs from the config file."""
+ if not URL_RE.search(text):
return False
text = text.lower()
@@ -197,26 +279,29 @@ class Filtering:
Zalgo range is \u0300 – \u036F and \u0489.
"""
+ return bool(ZALGO_RE.search(text))
- return bool(re.search(ZALGO_RE, text))
-
- async def _has_invites(self, text: str) -> bool:
+ async def _has_invites(self, text: str) -> Union[dict, bool]:
"""
- Returns True if the text contains an invite which is not on the guild_invite_whitelist in
- config.yml
+ Checks if there's any invites in the text content that aren't in the guild whitelist.
+
+ If any are detected, a dictionary of invite data is returned, with a key per invite.
+ If none are detected, False is returned.
Attempts to catch some of common ways to try to cheat the system.
"""
-
# Remove backslashes to prevent escape character aroundfuckery like
# discord\.gg/gdudes-pony-farm
text = text.replace("\\", "")
- invites = re.findall(INVITE_RE, text, re.IGNORECASE)
+ invites = INVITE_RE.findall(text)
+ invite_data = dict()
for invite in invites:
+ if invite in invite_data:
+ continue
response = await self.bot.http_session.get(
- f"{URLs.discord_invite_api}/{invite}"
+ f"{URLs.discord_invite_api}/{invite}", params={"with_counts": "true"}
)
response = await response.json()
guild = response.get("guild")
@@ -229,10 +314,53 @@ class Filtering:
guild_id = int(guild.get("id"))
if guild_id not in Filter.guild_invite_whitelist:
- return True
+ guild_icon_hash = guild["icon"]
+ guild_icon = (
+ "https://cdn.discordapp.com/icons/"
+ f"{guild_id}/{guild_icon_hash}.png?size=512"
+ )
+
+ invite_data[invite] = {
+ "name": guild["name"],
+ "icon": guild_icon,
+ "members": response["approximate_member_count"],
+ "active": response["approximate_presence_count"]
+ }
+
+ return invite_data if invite_data else False
+
+ @staticmethod
+ async def _has_rich_embed(msg: Message) -> bool:
+ """Determines if `msg` contains any rich embeds not auto-generated from a URL."""
+ if msg.embeds:
+ for embed in msg.embeds:
+ if embed.type == "rich":
+ urls = URL_RE.findall(msg.content)
+ if not embed.url or embed.url not in urls:
+ # If `embed.url` does not exist or if `embed.url` is not part of the content
+ # of the message, it's unlikely to be an auto-generated embed by Discord.
+ return True
+ else:
+ log.trace(
+ "Found a rich embed sent by a regular user account, "
+ "but it was likely just an automatic URL embed."
+ )
+ return False
return False
+ async def notify_member(self, filtered_member: Member, reason: str, channel: TextChannel) -> None:
+ """
+ Notify filtered_member about a moderation action with the reason str.
+
+ First attempts to DM the user, fall back to in-channel notification if user has DMs disabled
+ """
+ try:
+ await filtered_member.send(reason)
+ except discord.errors.Forbidden:
+ await channel.send(f"{filtered_member.mention} {reason}")
+
-def setup(bot: Bot):
+def setup(bot: Bot) -> None:
+ """Filtering cog load."""
bot.add_cog(Filtering(bot))
log.info("Cog loaded: Filtering")
diff --git a/bot/cogs/free.py b/bot/cogs/free.py
new file mode 100644
index 000000000..269c5c1b9
--- /dev/null
+++ b/bot/cogs/free.py
@@ -0,0 +1,106 @@
+import logging
+from datetime import datetime
+from operator import itemgetter
+
+from discord import Colour, Embed, Member, utils
+from discord.ext.commands import Bot, Cog, Context, command
+
+from bot.constants import Categories, Channels, Free, STAFF_ROLES
+from bot.decorators import redirect_output
+
+log = logging.getLogger(__name__)
+
+TIMEOUT = Free.activity_timeout
+RATE = Free.cooldown_rate
+PER = Free.cooldown_per
+
+
+class Free(Cog):
+ """Tries to figure out which help channels are free."""
+
+ PYTHON_HELP_ID = Categories.python_help
+
+ @command(name="free", aliases=('f',))
+ @redirect_output(destination_channel=Channels.bot, bypass_roles=STAFF_ROLES)
+ async def free(self, ctx: Context, user: Member = None, seek: int = 2) -> None:
+ """
+ Lists free help channels by likeliness of availability.
+
+ seek is used only when this command is invoked in a help channel.
+ You cannot override seek without mentioning a user first.
+
+ When seek is 2, we are avoiding considering the last active message
+ in a channel to be the one that invoked this command.
+
+ When seek is 3 or more, a user has been mentioned on the assumption
+ that they asked if the channel is free or they asked their question
+ in an active channel, and we want the message before that happened.
+ """
+ free_channels = []
+ python_help = utils.get(ctx.guild.categories, id=self.PYTHON_HELP_ID)
+
+ if user is not None and seek == 2:
+ seek = 3
+ elif not 0 < seek < 10:
+ seek = 3
+
+ # Iterate through all the help channels
+ # to check latest activity
+ for channel in python_help.channels:
+ # Seek further back in the help channel
+ # the command was invoked in
+ if channel.id == ctx.channel.id:
+ messages = await channel.history(limit=seek).flatten()
+ msg = messages[seek - 1]
+ # Otherwise get last message
+ else:
+ msg = await channel.history(limit=1).next() # noqa (False positive)
+
+ inactive = (datetime.utcnow() - msg.created_at).seconds
+ if inactive > TIMEOUT:
+ free_channels.append((inactive, channel))
+
+ embed = Embed()
+ embed.colour = Colour.blurple()
+ embed.title = "**Looking for a free help channel?**"
+
+ if user is not None:
+ embed.description = f"**Hey {user.mention}!**\n\n"
+ else:
+ embed.description = ""
+
+ # Display all potentially inactive channels
+ # in descending order of inactivity
+ if free_channels:
+ embed.description += "**The following channel{0} look{1} free:**\n\n**".format(
+ 's' if len(free_channels) > 1 else '',
+ '' if len(free_channels) > 1 else 's'
+ )
+
+ # Sort channels in descending order by seconds
+ # Get position in list, inactivity, and channel object
+ # For each channel, add to embed.description
+ sorted_channels = sorted(free_channels, key=itemgetter(0), reverse=True)
+ for i, (inactive, channel) in enumerate(sorted_channels, 1):
+ minutes, seconds = divmod(inactive, 60)
+ if minutes > 59:
+ hours, minutes = divmod(minutes, 60)
+ embed.description += f"{i}. {channel.mention} inactive for {hours}h{minutes}m{seconds}s\n\n"
+ else:
+ embed.description += f"{i}. {channel.mention} inactive for {minutes}m{seconds}s\n\n"
+
+ embed.description += ("**\nThese channels aren't guaranteed to be free, "
+ "so use your best judgement and check for yourself.")
+ else:
+ embed.description = ("**Doesn't look like any channels are available right now. "
+ "You're welcome to check for yourself to be sure. "
+ "If all channels are truly busy, please be patient "
+ "as one will likely be available soon.**")
+
+ await ctx.send(embed=embed)
+
+
+def setup(bot: Bot) -> None:
+ """Free cog load."""
+ bot.add_cog(Free())
+ log.info("Cog loaded: Free")
diff --git a/bot/cogs/fun.py b/bot/cogs/fun.py
deleted file mode 100644
index 57fa7cb1c..000000000
--- a/bot/cogs/fun.py
+++ /dev/null
@@ -1,53 +0,0 @@
-import logging
-
-from discord import Message
-from discord.ext.commands import Bot
-
-from bot.constants import Channels
-
-RESPONSES = {
- "_pokes {us}_": "_Pokes {them}_",
- "_eats {us}_": "_Tastes slimy and snake-like_",
- "_pets {us}_": "_Purrs_"
-}
-
-log = logging.getLogger(__name__)
-
-
-class Fun:
- """
- Fun, entirely useless stuff
- """
-
- def __init__(self, bot: Bot):
- self.bot = bot
-
- async def on_ready(self):
- keys = list(RESPONSES.keys())
-
- for key in keys:
- changed_key = key.replace("{us}", self.bot.user.mention)
-
- if key != changed_key:
- RESPONSES[changed_key] = RESPONSES[key]
- del RESPONSES[key]
-
- async def on_message(self, message: Message):
- if message.channel.id != Channels.bot:
- return
-
- content = message.content
-
- if content and content[0] == "*" and content[-1] == "*":
- content = f"_{content[1:-1]}_"
-
- response = RESPONSES.get(content)
-
- if response:
- log.debug(f"{message.author} said '{message.clean_content}'. Responding with '{response}'.")
- await message.channel.send(response.format(them=message.author.mention))
-
-
-def setup(bot):
- bot.add_cog(Fun(bot))
- log.info("Cog loaded: Fun")
diff --git a/bot/cogs/help.py b/bot/cogs/help.py
index d30ff0dfb..9607dbd8d 100644
--- a/bot/cogs/help.py
+++ b/bot/cogs/help.py
@@ -1,19 +1,23 @@
import asyncio
-import inspect
import itertools
from collections import namedtuple
from contextlib import suppress
+from typing import Union
-from discord import Colour, Embed, HTTPException
+from discord import Colour, Embed, HTTPException, Message, Reaction, User
from discord.ext import commands
+from discord.ext.commands import Bot, CheckFailure, Cog as DiscordCog, Command, Context
from fuzzywuzzy import fuzz, process
from bot import constants
+from bot.constants import Channels, STAFF_ROLES
+from bot.decorators import redirect_output
from bot.pagination import (
DELETE_EMOJI, FIRST_EMOJI, LAST_EMOJI,
LEFT_EMOJI, LinePaginator, RIGHT_EMOJI,
)
+
REACTIONS = {
FIRST_EMOJI: 'first',
LEFT_EMOJI: 'back',
@@ -31,15 +35,11 @@ class HelpQueryNotFound(ValueError):
Contains the custom attribute of ``possible_matches``.
- Attributes
- ----------
- possible_matches: dict
- Any commands that were close to matching the Query.
- The possible matched command names are the keys.
- The likeness match scores are the values.
+ Instances of this object contain a dictionary of any command(s) that were close to matching the
+ query, where keys are the possible matched command names and values are the likeness match scores.
"""
- def __init__(self, arg, possible_matches=None):
+ def __init__(self, arg: str, possible_matches: dict = None):
super().__init__(arg)
self.possible_matches = possible_matches
@@ -48,48 +48,36 @@ class HelpSession:
"""
An interactive session for bot and command help output.
- Attributes
- ----------
- title: str
- The title of the help message.
- query: Union[:class:`discord.ext.commands.Bot`,
- :class:`discord.ext.commands.Command]
- description: str
- The description of the query.
- pages: list[str]
- A list of the help content split into manageable pages.
- message: :class:`discord.Message`
- The message object that's showing the help contents.
- destination: :class:`discord.abc.Messageable`
- Where the help message is to be sent to.
+ Expected attributes include:
+ * title: str
+ The title of the help message.
+ * query: Union[discord.ext.commands.Bot, discord.ext.commands.Command]
+ * description: str
+ The description of the query.
+ * pages: list[str]
+ A list of the help content split into manageable pages.
+ * message: `discord.Message`
+ The message object that's showing the help contents.
+ * destination: `discord.abc.Messageable`
+ Where the help message is to be sent to.
+
+ Cogs can be grouped into custom categories. All cogs with the same category will be displayed
+ under a single category name in the help output. Custom categories are defined inside the cogs
+ as a class attribute named `category`. A description can also be specified with the attribute
+ `category_description`. If a description is not found in at least one cog, the default will be
+ the regular description (class docstring) of the first cog found in the category.
"""
- def __init__(self, ctx, *command, cleanup=False, only_can_run=True, show_hidden=False, max_lines=15):
- """
- Creates an instance of the HelpSession class.
-
- Parameters
- ----------
- ctx: :class:`discord.Context`
- The context of the invoked help command.
- *command: str
- A variable argument of the command being queried.
- cleanup: Optional[bool]
- Set to ``True`` to have the message deleted on timeout.
- If ``False``, it will clear all reactions on timeout.
- Defaults to ``False``.
- only_can_run: Optional[bool]
- Set to ``True`` to hide commands the user can't run.
- Defaults to ``False``.
- show_hidden: Optional[bool]
- Set to ``True`` to include hidden commands.
- Defaults to ``False``.
- max_lines: Optional[int]
- Sets the max number of lines the paginator will add to a
- single page.
- Defaults to 20.
- """
-
+ def __init__(
+ self,
+ ctx: Context,
+ *command,
+ cleanup: bool = False,
+ only_can_run: bool = True,
+ show_hidden: bool = False,
+ max_lines: int = 15
+ ):
+ """Creates an instance of the HelpSession class."""
self._ctx = ctx
self._bot = ctx.bot
self.title = "Command Help"
@@ -103,7 +91,7 @@ class HelpSession:
self.query = ctx.bot
self.description = self.query.description
self.author = ctx.author
- self.destination = ctx.author if ctx.bot.pm_help else ctx.channel
+ self.destination = ctx.channel
# set the config for the session
self._cleanup = cleanup
@@ -118,76 +106,61 @@ class HelpSession:
self._timeout_task = None
self.reset_timeout()
- def _get_query(self, query):
- """
- Attempts to match the provided query with a valid command or cog.
-
- Parameters
- ----------
- query: str
- The joined string representing the session query.
-
- Returns
- -------
- Union[:class:`discord.ext.commands.Command`, :class:`Cog`]
- """
-
+ def _get_query(self, query: str) -> Union[Command, Cog]:
+ """Attempts to match the provided query with a valid command or cog."""
command = self._bot.get_command(query)
if command:
return command
- cog = self._bot.cogs.get(query)
- if cog:
+ # Find all cog categories that match.
+ cog_matches = []
+ description = None
+ for cog in self._bot.cogs.values():
+ if hasattr(cog, "category") and cog.category == query:
+ cog_matches.append(cog)
+ if hasattr(cog, "category_description"):
+ description = cog.category_description
+
+ # Try to search by cog name if no categories match.
+ if not cog_matches:
+ cog = self._bot.cogs.get(query)
+
+ # Don't consider it a match if the cog has a category.
+ if cog and not hasattr(cog, "category"):
+ cog_matches = [cog]
+
+ if cog_matches:
+ cog = cog_matches[0]
+ cmds = (cog.get_commands() for cog in cog_matches) # Commands of all cogs
+
return Cog(
- name=cog.__class__.__name__,
- description=inspect.getdoc(cog),
- commands=[c for c in self._bot.commands if c.instance is cog]
+ name=cog.category if hasattr(cog, "category") else cog.qualified_name,
+ description=description or cog.description,
+ commands=tuple(itertools.chain.from_iterable(cmds)) # Flatten the list
)
self._handle_not_found(query)
- def _handle_not_found(self, query):
+ def _handle_not_found(self, query: str) -> None:
"""
Handles when a query does not match a valid command or cog.
- Will pass on possible close matches along with the
- ``HelpQueryNotFound`` exception.
-
- Parameters
- ----------
- query: str
- The full query that was requested.
-
- Raises
- ------
- HelpQueryNotFound
+ Will pass on possible close matches along with the `HelpQueryNotFound` exception.
"""
-
- # combine command and cog names
+ # Combine command and cog names
choices = list(self._bot.all_commands) + list(self._bot.cogs)
result = process.extractBests(query, choices, scorer=fuzz.ratio, score_cutoff=90)
raise HelpQueryNotFound(f'Query "{query}" not found.', dict(result))
- async def timeout(self, seconds=30):
- """
- Waits for a set number of seconds, then stops the help session.
-
- Parameters
- ----------
- seconds: int
- Number of seconds to wait.
- """
-
+ async def timeout(self, seconds: int = 30) -> None:
+ """Waits for a set number of seconds, then stops the help session."""
await asyncio.sleep(seconds)
await self.stop()
- def reset_timeout(self):
- """
- Cancels the original timeout task and sets it again from the start.
- """
-
+ def reset_timeout(self) -> None:
+ """Cancels the original timeout task and sets it again from the start."""
# cancel original if it exists
if self._timeout_task:
if not self._timeout_task.cancelled():
@@ -196,18 +169,8 @@ class HelpSession:
# recreate the timeout task
self._timeout_task = self._bot.loop.create_task(self.timeout())
- async def on_reaction_add(self, reaction, user):
- """
- Event handler for when reactions are added on the help message.
-
- Parameters
- ----------
- reaction: :class:`discord.Reaction`
- The reaction that was added.
- user: :class:`discord.User`
- The user who added the reaction.
- """
-
+ async def on_reaction_add(self, reaction: Reaction, user: User) -> None:
+ """Event handler for when reactions are added on the help message."""
# ensure it was the relevant session message
if reaction.message.id != self.message.id:
return
@@ -233,24 +196,13 @@ class HelpSession:
with suppress(HTTPException):
await self.message.remove_reaction(reaction, user)
- async def on_message_delete(self, message):
- """
- Closes the help session when the help message is deleted.
-
- Parameters
- ----------
- message: :class:`discord.Message`
- The message that was deleted.
- """
-
+ async def on_message_delete(self, message: Message) -> None:
+ """Closes the help session when the help message is deleted."""
if message.id == self.message.id:
await self.stop()
- async def prepare(self):
- """
- Sets up the help session pages, events, message and reactions.
- """
-
+ async def prepare(self) -> None:
+ """Sets up the help session pages, events, message and reactions."""
# create paginated content
await self.build_pages()
@@ -262,12 +214,8 @@ class HelpSession:
await self.update_page()
self.add_reactions()
- def add_reactions(self):
- """
- Adds the relevant reactions to the help message based on if
- pagination is required.
- """
-
+ def add_reactions(self) -> None:
+ """Adds the relevant reactions to the help message based on if pagination is required."""
# if paginating
if len(self._pages) > 1:
for reaction in REACTIONS:
@@ -277,44 +225,30 @@ class HelpSession:
else:
self._bot.loop.create_task(self.message.add_reaction(DELETE_EMOJI))
- def _category_key(self, cmd):
+ def _category_key(self, cmd: Command) -> str:
"""
- Returns a cog name of a given command. Used as a key for
- ``sorted`` and ``groupby``.
-
- A zero width space is used as a prefix for results with no cogs
- to force them last in ordering.
+ Returns a cog name of a given command for use as a key for `sorted` and `groupby`.
- Parameters
- ----------
- cmd: :class:`discord.ext.commands.Command`
- The command object being checked.
-
- Returns
- -------
- str
+ A zero width space is used as a prefix for results with no cogs to force them last in ordering.
"""
+ if cmd.cog:
+ try:
+ if cmd.cog.category:
+ return f'**{cmd.cog.category}**'
+ except AttributeError:
+ pass
- cog = cmd.cog_name
- return f'**{cog}**' if cog else f'**\u200bNo Category:**'
+ return f'**{cmd.cog_name}**'
+ else:
+ return "**\u200bNo Category:**"
- def _get_command_params(self, cmd):
+ def _get_command_params(self, cmd: Command) -> str:
"""
Returns the command usage signature.
- This is a custom implementation of ``command.signature`` in
- order to format the command signature without aliases.
-
- Parameters
- ----------
- cmd: :class:`discord.ext.commands.Command`
- The command object to get the parameters of.
-
- Returns
- -------
- str
+ This is a custom implementation of `command.signature` in order to format the command
+ signature without aliases.
"""
-
results = []
for name, param in cmd.clean_params.items():
@@ -342,16 +276,8 @@ class HelpSession:
return f"{cmd.name} {' '.join(results)}"
- async def build_pages(self):
- """
- Builds the list of content pages to be paginated through in the
- help message.
-
- Returns
- -------
- list[str]
- """
-
+ async def build_pages(self) -> None:
+ """Builds the list of content pages to be paginated through in the help message, as a list of str."""
# Use LinePaginator to restrict embed line height
paginator = LinePaginator(prefix='', suffix='', max_lines=self._max_lines)
@@ -427,7 +353,15 @@ class HelpSession:
# see if the user can run the command
strikeout = ''
- can_run = await command.can_run(self._ctx)
+
+ # Patch to make the !help command work outside of #bot-commands again
+ # This probably needs a proper rewrite, but this will make it work in
+ # the mean time.
+ try:
+ can_run = await command.can_run(self._ctx)
+ except CheckFailure:
+ can_run = False
+
if not can_run:
# skip if we don't show commands they can't run
if self._only_can_run:
@@ -470,20 +404,8 @@ class HelpSession:
# save organised pages to session
self._pages = paginator.pages
- def embed_page(self, page_number=0):
- """
- Returns an Embed with the requested page formatted within.
-
- Parameters
- ----------
- page_number: int
- The page to be retrieved. Zero indexed.
-
- Returns
- -------
- :class:`discord.Embed`
- """
-
+ def embed_page(self, page_number: int = 0) -> Embed:
+ """Returns an Embed with the requested page formatted within."""
embed = Embed()
# if command or cog, add query to title for pages other than first
@@ -502,17 +424,8 @@ class HelpSession:
return embed
- async def update_page(self, page_number=0):
- """
- Sends the intial message, or changes the existing one to the
- given page number.
-
- Parameters
- ----------
- page_number: int
- The page number to show in the help message.
- """
-
+ async def update_page(self, page_number: int = 0) -> None:
+ """Sends the intial message, or changes the existing one to the given page number."""
self._current_page = page_number
embed_page = self.embed_page(page_number)
@@ -522,47 +435,27 @@ class HelpSession:
await self.message.edit(embed=embed_page)
@classmethod
- async def start(cls, ctx, *command, **options):
- """
- Create and begin a help session based on the given command
- context.
-
- Parameters
- ----------
- ctx: :class:`discord.ext.commands.Context`
- The context of the invoked help command.
- *command: str
- A variable argument of the command being queried.
- cleanup: Optional[bool]
- Set to ``True`` to have the message deleted on session end.
- Defaults to ``False``.
- only_can_run: Optional[bool]
- Set to ``True`` to hide commands the user can't run.
- Defaults to ``False``.
- show_hidden: Optional[bool]
- Set to ``True`` to include hidden commands.
- Defaults to ``False``.
- max_lines: Optional[int]
- Sets the max number of lines the paginator will add to a
- single page.
- Defaults to 20.
-
- Returns
- -------
- :class:`HelpSession`
+ async def start(cls, ctx: Context, *command, **options) -> "HelpSession":
"""
+ Create and begin a help session based on the given command context.
+ Available options kwargs:
+ * cleanup: Optional[bool]
+ Set to `True` to have the message deleted on session end. Defaults to `False`.
+ * only_can_run: Optional[bool]
+ Set to `True` to hide commands the user can't run. Defaults to `False`.
+ * show_hidden: Optional[bool]
+ Set to `True` to include hidden commands. Defaults to `False`.
+ * max_lines: Optional[int]
+ Sets the max number of lines the paginator will add to a single page. Defaults to 20.
+ """
session = cls(ctx, *command, **options)
await session.prepare()
return session
- async def stop(self):
- """
- Stops the help session, removes event listeners and attempts to
- delete the help message.
- """
-
+ async def stop(self) -> None:
+ """Stops the help session, removes event listeners and attempts to delete the help message."""
self._bot.remove_listener(self.on_reaction_add)
self._bot.remove_listener(self.on_message_delete)
@@ -574,79 +467,47 @@ class HelpSession:
await self.message.clear_reactions()
@property
- def is_first_page(self):
- """
- A bool reflecting if session is currently showing the first page.
-
- Returns
- -------
- bool
- """
-
+ def is_first_page(self) -> bool:
+ """Check if session is currently showing the first page."""
return self._current_page == 0
@property
- def is_last_page(self):
- """
- A bool reflecting if the session is currently showing the last page.
-
- Returns
- -------
- bool
- """
-
+ def is_last_page(self) -> bool:
+ """Check if the session is currently showing the last page."""
return self._current_page == (len(self._pages)-1)
- async def do_first(self):
- """
- Event that is called when the user requests the first page.
- """
-
+ async def do_first(self) -> None:
+ """Event that is called when the user requests the first page."""
if not self.is_first_page:
await self.update_page(0)
- async def do_back(self):
- """
- Event that is called when the user requests the previous page.
- """
-
+ async def do_back(self) -> None:
+ """Event that is called when the user requests the previous page."""
if not self.is_first_page:
await self.update_page(self._current_page-1)
- async def do_next(self):
- """
- Event that is called when the user requests the next page.
- """
-
+ async def do_next(self) -> None:
+ """Event that is called when the user requests the next page."""
if not self.is_last_page:
await self.update_page(self._current_page+1)
- async def do_end(self):
- """
- Event that is called when the user requests the last page.
- """
-
+ async def do_end(self) -> None:
+ """Event that is called when the user requests the last page."""
if not self.is_last_page:
await self.update_page(len(self._pages)-1)
- async def do_stop(self):
- """
- Event that is called when the user requests to stop the help session.
- """
-
+ async def do_stop(self) -> None:
+ """Event that is called when the user requests to stop the help session."""
await self.message.delete()
-class Help:
- """
- Custom Embed Pagination Help feature
- """
- @commands.command('help')
- async def new_help(self, ctx, *commands):
- """
- Shows Command Help.
- """
+class Help(DiscordCog):
+ """Custom Embed Pagination Help feature."""
+ @commands.command('help')
+ @redirect_output(destination_channel=Channels.bot, bypass_roles=STAFF_ROLES)
+ async def new_help(self, ctx: Context, *commands) -> None:
+ """Shows Command Help."""
try:
await HelpSession.start(ctx, *commands)
except HelpQueryNotFound as error:
@@ -661,42 +522,29 @@ class Help:
await ctx.send(embed=embed)
-def unload(bot):
+def unload(bot: Bot) -> None:
"""
Reinstates the original help command.
- This is run if the cog raises an exception on load, or if the
- extension is unloaded.
-
- Parameters
- ----------
- bot: :class:`discord.ext.commands.Bot`
- The discord bot client.
+ This is run if the cog raises an exception on load, or if the extension is unloaded.
"""
-
bot.remove_command('help')
bot.add_command(bot._old_help)
-def setup(bot):
+def setup(bot: Bot) -> None:
"""
The setup for the help extension.
This is called automatically on `bot.load_extension` being run.
- Stores the original help command instance on the ``bot._old_help``
- attribute for later reinstatement, before removing it from the
- command registry so the new help command can be loaded successfully.
-
- If an exception is raised during the loading of the cog, ``unload``
- will be called in order to reinstate the original help command.
+ Stores the original help command instance on the `bot._old_help` attribute for later
+ reinstatement, before removing it from the command registry so the new help command can be
+ loaded successfully.
- Parameters
- ----------
- bot: `discord.ext.commands.Bot`
- The discord bot client.
+ If an exception is raised during the loading of the cog, `unload` will be called in order to
+ reinstate the original help command.
"""
-
bot._old_help = bot.get_command('help')
bot.remove_command('help')
@@ -707,18 +555,12 @@ def setup(bot):
raise
-def teardown(bot):
+def teardown(bot: Bot) -> None:
"""
The teardown for the help extension.
This is called automatically on `bot.unload_extension` being run.
- Calls ``unload`` in order to reinstate the original help command.
-
- Parameters
- ----------
- bot: `discord.ext.commands.Bot`
- The discord bot client.
+ Calls `unload` in order to reinstate the original help command.
"""
-
unload(bot)
diff --git a/bot/cogs/information.py b/bot/cogs/information.py
index 92b2444a3..1afb37103 100644
--- a/bot/cogs/information.py
+++ b/bot/cogs/information.py
@@ -1,37 +1,29 @@
+import colorsys
import logging
import textwrap
+import typing
-from discord import CategoryChannel, Colour, Embed, Member, TextChannel, VoiceChannel
-from discord.ext.commands import Bot, Context, command
+from discord import CategoryChannel, Colour, Embed, Member, Role, TextChannel, VoiceChannel, utils
+from discord.ext.commands import Bot, Cog, Context, command
-from bot.constants import Emojis, Keys, Roles
-from bot.decorators import with_role
+from bot.constants import Channels, Emojis, MODERATION_ROLES, STAFF_ROLES
+from bot.decorators import InChannelCheckFailure, with_role
+from bot.utils.checks import with_role_check
from bot.utils.time import time_since
log = logging.getLogger(__name__)
-MODERATION_ROLES = Roles.owner, Roles.admin, Roles.moderator
-
-class Information:
- """
- A cog with commands for generating embeds with
- server information, such as server statistics
- and user information.
- """
+class Information(Cog):
+ """A cog with commands for generating embeds with server info, such as server stats and user info."""
def __init__(self, bot: Bot):
self.bot = bot
- self.headers = {"X-API-Key": Keys.site_api}
@with_role(*MODERATION_ROLES)
@command(name="roles")
- async def roles_info(self, ctx: Context):
- """
- Returns a list of all roles and their
- corresponding IDs.
- """
-
+ async def roles_info(self, ctx: Context) -> None:
+ """Returns a list of all roles and their corresponding IDs."""
# Sort the roles alphabetically and remove the @everyone role
roles = sorted(ctx.guild.roles, key=lambda role: role.name)
roles = [role for role in roles if role.name != "@everyone"]
@@ -52,13 +44,55 @@ class Information:
await ctx.send(embed=embed)
- @command(name="server", aliases=["server_info", "guild", "guild_info"])
- async def server_info(self, ctx: Context):
+ @with_role(*MODERATION_ROLES)
+ @command(name="role")
+ async def role_info(self, ctx: Context, *roles: typing.Union[Role, str]) -> None:
"""
- Returns an embed full of
- server information.
+ Return information on a role or list of roles.
+
+ To specify multiple roles just add to the arguments, delimit roles with spaces in them using quotation marks.
"""
+ parsed_roles = []
+
+ for role_name in roles:
+ if isinstance(role_name, Role):
+ # Role conversion has already succeeded
+ parsed_roles.append(role_name)
+ continue
+
+ role = utils.find(lambda r: r.name.lower() == role_name.lower(), ctx.guild.roles)
+
+ if not role:
+ await ctx.send(f":x: Could not convert `{role_name}` to a role")
+ continue
+
+ parsed_roles.append(role)
+
+ for role in parsed_roles:
+ embed = Embed(
+ title=f"{role.name} info",
+ colour=role.colour,
+ )
+
+ embed.add_field(name="ID", value=role.id, inline=True)
+
+ embed.add_field(name="Colour (RGB)", value=f"#{role.colour.value:0>6x}", inline=True)
+
+ h, s, v = colorsys.rgb_to_hsv(*role.colour.to_rgb())
+ embed.add_field(name="Colour (HSV)", value=f"{h:.2f} {s:.2f} {v}", inline=True)
+
+ embed.add_field(name="Member count", value=len(role.members), inline=True)
+
+ embed.add_field(name="Position", value=role.position)
+
+ embed.add_field(name="Permission code", value=role.permissions.value, inline=True)
+
+ await ctx.send(embed=embed)
+
+ @command(name="server", aliases=["server_info", "guild", "guild_info"])
+ async def server_info(self, ctx: Context) -> None:
+ """Returns an embed full of server information."""
created = time_since(ctx.guild.created_at, precision="days")
features = ", ".join(ctx.guild.features)
region = ctx.guild.region
@@ -121,23 +155,28 @@ class Information:
await ctx.send(embed=embed)
- @with_role(*MODERATION_ROLES)
@command(name="user", aliases=["user_info", "member", "member_info"])
- async def user_info(self, ctx: Context, user: Member = None, hidden: bool = False):
- """
- Returns info about a user.
- """
-
- # Validates hidden input
- hidden = str(hidden)
-
+ async def user_info(self, ctx: Context, user: Member = None, hidden: bool = False) -> None:
+ """Returns info about a user."""
if user is None:
user = ctx.author
+ # Do a role check if this is being executed on someone other than the caller
+ if user != ctx.author and not with_role_check(ctx, *MODERATION_ROLES):
+ await ctx.send("You may not use this command on users other than yourself.")
+ return
+
+ # Non-moderators may only do this in #bot-commands and can't see hidden infractions.
+ if not with_role_check(ctx, *STAFF_ROLES):
+ if not ctx.channel.id == Channels.bot:
+ raise InChannelCheckFailure(Channels.bot)
+ # Hide hidden infractions for users without a moderation role
+ hidden = False
+
# User information
created = time_since(user.created_at, max_units=3)
- name = f"{user.name}#{user.discriminator}"
+ name = str(user)
if user.nick:
name = f"{user.nick} ({name})"
@@ -145,15 +184,13 @@ class Information:
joined = time_since(user.joined_at, precision="days")
# You're welcome, Volcyyyyyyyyyyyyyyyy
- roles = ", ".join(
- role.mention for role in user.roles if role.name != "@everyone"
- )
+ roles = ", ".join(role.mention for role in user.roles if role.name != "@everyone")
# Infractions
infractions = await self.bot.api_client.get(
'bot/infractions',
params={
- 'hidden': hidden,
+ 'hidden': str(hidden),
'user__id': str(user.id)
}
)
@@ -193,6 +230,7 @@ class Information:
await ctx.send(embed=embed)
-def setup(bot):
+def setup(bot: Bot) -> None:
+ """Information cog load."""
bot.add_cog(Information(bot))
log.info("Cog loaded: Information")
diff --git a/bot/cogs/jams.py b/bot/cogs/jams.py
new file mode 100644
index 000000000..be9d33e3e
--- /dev/null
+++ b/bot/cogs/jams.py
@@ -0,0 +1,114 @@
+import logging
+
+from discord import Member, PermissionOverwrite, utils
+from discord.ext import commands
+from more_itertools import unique_everseen
+
+from bot.constants import Roles
+from bot.decorators import with_role
+
+log = logging.getLogger(__name__)
+
+
+class CodeJams(commands.Cog):
+ """Manages the code-jam related parts of our server."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ @commands.command()
+ @with_role(Roles.admin)
+ async def createteam(self, ctx: commands.Context, team_name: str, members: commands.Greedy[Member]) -> None:
+ """
+ Create team channels (voice and text) in the Code Jams category, assign roles, and add overwrites for the team.
+
+ The first user passed will always be the team leader.
+ """
+ # Ignore duplicate members
+ members = list(unique_everseen(members))
+
+ # We had a little issue during Code Jam 4 here, the greedy converter did it's job
+ # and ignored anything which wasn't a valid argument which left us with teams of
+ # two members or at some times even 1 member. This fixes that by checking that there
+ # are always 3 members in the members list.
+ if len(members) < 3:
+ await ctx.send(
+ ":no_entry_sign: One of your arguments was invalid\n"
+ f"There must be a minimum of 3 valid members in your team. Found: {len(members)}"
+ " members"
+ )
+ return
+
+ code_jam_category = utils.get(ctx.guild.categories, name="Code Jam")
+
+ if code_jam_category is None:
+ log.info("Code Jam category not found, creating it.")
+
+ category_overwrites = {
+ ctx.guild.default_role: PermissionOverwrite(read_messages=False),
+ ctx.guild.me: PermissionOverwrite(read_messages=True)
+ }
+
+ code_jam_category = await ctx.guild.create_category_channel(
+ "Code Jam",
+ overwrites=category_overwrites,
+ reason="It's code jam time!"
+ )
+
+ # First member is always the team leader
+ team_channel_overwrites = {
+ members[0]: PermissionOverwrite(
+ manage_messages=True,
+ read_messages=True,
+ manage_webhooks=True,
+ connect=True
+ ),
+ ctx.guild.default_role: PermissionOverwrite(read_messages=False, connect=False),
+ ctx.guild.get_role(Roles.verified): PermissionOverwrite(
+ read_messages=False,
+ connect=False
+ )
+ }
+
+ # Rest of members should just have read_messages
+ for member in members[1:]:
+ team_channel_overwrites[member] = PermissionOverwrite(
+ read_messages=True,
+ connect=True
+ )
+
+ # Create a text channel for the team
+ team_channel = await ctx.guild.create_text_channel(
+ team_name,
+ overwrites=team_channel_overwrites,
+ category=code_jam_category
+ )
+
+ # Create a voice channel for the team
+ team_voice_name = " ".join(team_name.split("-")).title()
+
+ await ctx.guild.create_voice_channel(
+ team_voice_name,
+ overwrites=team_channel_overwrites,
+ category=code_jam_category
+ )
+
+ # Assign team leader role
+ await members[0].add_roles(ctx.guild.get_role(Roles.team_leader))
+
+ # Assign rest of roles
+ jammer_role = ctx.guild.get_role(Roles.jammer)
+ for member in members:
+ await member.add_roles(jammer_role)
+
+ await ctx.send(
+ f":ok_hand: Team created: {team_channel.mention}\n"
+ f"**Team Leader:** {members[0].mention}\n"
+ f"**Team Members:** {' '.join(member.mention for member in members[1:])}"
+ )
+
+
+def setup(bot: commands.Bot) -> None:
+ """Code Jams cog load."""
+ bot.add_cog(CodeJams(bot))
+ log.info("Cog loaded: CodeJams")
diff --git a/bot/cogs/logging.py b/bot/cogs/logging.py
index 6b8462f3b..c92b619ff 100644
--- a/bot/cogs/logging.py
+++ b/bot/cogs/logging.py
@@ -1,7 +1,7 @@
import logging
from discord import Embed
-from discord.ext.commands import Bot
+from discord.ext.commands import Bot, Cog
from bot.constants import Channels, DEBUG_MODE
@@ -9,28 +9,34 @@ from bot.constants import Channels, DEBUG_MODE
log = logging.getLogger(__name__)
-class Logging:
- """
- Debug logging module
- """
+class Logging(Cog):
+ """Debug logging module."""
def __init__(self, bot: Bot):
self.bot = bot
- async def on_ready(self):
+ self.bot.loop.create_task(self.startup_greeting())
+
+ async def startup_greeting(self) -> None:
+ """Announce our presence to the configured devlog channel."""
+ await self.bot.wait_until_ready()
log.info("Bot connected!")
embed = Embed(description="Connected!")
embed.set_author(
name="Python Bot",
- url="https://gitlab.com/discord-python/projects/bot",
- icon_url="https://gitlab.com/python-discord/branding/raw/master/logos/logo_circle/logo_circle.png"
+ url="https://github.com/python-discord/bot",
+ icon_url=(
+ "https://raw.githubusercontent.com/"
+ "python-discord/branding/master/logos/logo_circle/logo_circle_large.png"
+ )
)
if not DEBUG_MODE:
await self.bot.get_channel(Channels.devlog).send(embed=embed)
-def setup(bot):
+def setup(bot: Bot) -> None:
+ """Logging cog load."""
bot.add_cog(Logging(bot))
log.info("Cog loaded: Logging")
diff --git a/bot/cogs/moderation.py b/bot/cogs/moderation.py
deleted file mode 100644
index 256d38866..000000000
--- a/bot/cogs/moderation.py
+++ /dev/null
@@ -1,1243 +0,0 @@
-import asyncio
-import logging
-import textwrap
-from datetime import datetime
-from typing import Union
-
-from discord import (
- Colour, Embed, Forbidden, Guild, HTTPException, Member, Object, User
-)
-from discord.ext.commands import (
- BadArgument, BadUnionArgument, Bot, Context, command, group
-)
-
-from bot import constants
-from bot.cogs.modlog import ModLog
-from bot.constants import Colours, Event, Icons, Roles
-from bot.converters import ExpirationDate, InfractionSearchQuery
-from bot.decorators import with_role
-from bot.pagination import LinePaginator
-from bot.utils.moderation import post_infraction
-from bot.utils.scheduling import Scheduler, create_task
-from bot.utils.time import wait_until
-
-log = logging.getLogger(__name__)
-
-MODERATION_ROLES = Roles.owner, Roles.admin, Roles.moderator
-INFRACTION_ICONS = {
- "Mute": Icons.user_mute,
- "Kick": Icons.sign_out,
- "Ban": Icons.user_ban
-}
-RULES_URL = "https://pythondiscord.com/about/rules"
-APPEALABLE_INFRACTIONS = ("Ban", "Mute")
-
-
-def proxy_user(user_id: str) -> Object:
- try:
- user_id = int(user_id)
- except ValueError:
- raise BadArgument
- user = Object(user_id)
- user.mention = user.id
- user.avatar_url_as = lambda static_format: None
- return user
-
-
-class Moderation(Scheduler):
- """
- Rowboat replacement moderation tools.
- """
-
- def __init__(self, bot: Bot):
- self.bot = bot
- self._muted_role = Object(constants.Roles.muted)
- super().__init__()
-
- @property
- def mod_log(self) -> ModLog:
- return self.bot.get_cog("ModLog")
-
- async def on_ready(self):
- # Schedule expiration for previous infractions
- infractions = await self.bot.api_client.get(
- 'bot/infractions', params={'active': 'true'}
- )
- loop = asyncio.get_event_loop()
- for infraction in infractions:
- if infraction["expires_at"] is not None:
- self.schedule_task(loop, infraction["id"], infraction)
-
- # region: Permanent infractions
-
- @with_role(*MODERATION_ROLES)
- @command(name="warn")
- async def warn(self, ctx: Context, user: Union[User, proxy_user], *, reason: str = None):
- """
- Create a warning infraction in the database for a user.
- :param user: accepts user mention, ID, etc.
- :param reason: The reason for the warning.
- """
-
- notified = await self.notify_infraction(
- user=user,
- infr_type="Warning",
- reason=reason
- )
-
- response_object = await post_infraction(
- ctx, user, type="warning", reason=reason
- )
- if response_object is None:
- return
-
- dm_result = ":incoming_envelope: " if notified else ""
- action = f"{dm_result}:ok_hand: warned {user.mention}"
-
- if reason is None:
- await ctx.send(f"{action}.")
- else:
- await ctx.send(f"{action} ({reason}).")
-
- if not notified:
- await self.log_notify_failure(user, ctx.author, "warning")
-
- # Send a message to the mod log
- await self.mod_log.send_log_message(
- icon_url=Icons.user_warn,
- colour=Colour(Colours.soft_red),
- title="Member warned",
- thumbnail=user.avatar_url_as(static_format="png"),
- text=textwrap.dedent(f"""
- Member: {user.mention} (`{user.id}`)
- Actor: {ctx.message.author}
- Reason: {reason}
- """)
- )
-
- @with_role(*MODERATION_ROLES)
- @command(name="kick")
- async def kick(self, ctx: Context, user: Member, *, reason: str = None):
- """
- Kicks a user.
- :param user: accepts user mention, ID, etc.
- :param reason: The reason for the kick.
- """
-
- notified = await self.notify_infraction(
- user=user,
- infr_type="Kick",
- reason=reason
- )
-
- response_object = await post_infraction(ctx, user, type="kick", reason=reason)
- if response_object is None:
- return
-
- self.mod_log.ignore(Event.member_remove, user.id)
- await user.kick(reason=reason)
-
- dm_result = ":incoming_envelope: " if notified else ""
- action = f"{dm_result}:ok_hand: kicked {user.mention}"
-
- if reason is None:
- await ctx.send(f"{action}.")
- else:
- await ctx.send(f"{action} ({reason}).")
-
- if not notified:
- await self.log_notify_failure(user, ctx.author, "kick")
-
- # Send a log message to the mod log
- await self.mod_log.send_log_message(
- icon_url=Icons.sign_out,
- colour=Colour(Colours.soft_red),
- title="Member kicked",
- thumbnail=user.avatar_url_as(static_format="png"),
- text=textwrap.dedent(f"""
- Member: {user.mention} (`{user.id}`)
- Actor: {ctx.message.author}
- Reason: {reason}
- """)
- )
-
- @with_role(*MODERATION_ROLES)
- @command(name="ban")
- async def ban(self, ctx: Context, user: Union[User, proxy_user], *, reason: str = None):
- """
- Create a permanent ban infraction in the database for a user.
- :param user: Accepts user mention, ID, etc.
- :param reason: The reason for the ban.
- """
-
- active_bans = await self.bot.api_client.get(
- 'bot/infractions',
- params={
- 'active': 'true',
- 'type': 'ban',
- 'user__id': str(user.id)
- }
- )
- if active_bans:
- return await ctx.send(
- ":x: According to my records, this user is already banned. "
- f"See infraction **#{active_bans[0]['id']}**."
- )
-
- notified = await self.notify_infraction(
- user=user,
- infr_type="Ban",
- reason=reason
- )
-
- response_object = await post_infraction(ctx, user, type="ban", reason=reason)
- if response_object is None:
- return
-
- self.mod_log.ignore(Event.member_ban, user.id)
- self.mod_log.ignore(Event.member_remove, user.id)
- await ctx.guild.ban(user, reason=reason, delete_message_days=0)
-
- dm_result = ":incoming_envelope: " if notified else ""
- action = f"{dm_result}:ok_hand: permanently banned {user.mention}"
-
- if reason is None:
- await ctx.send(f"{action}.")
- else:
- await ctx.send(f"{action} ({reason}).")
-
- if not notified:
- await self.log_notify_failure(user, ctx.author, "ban")
-
- # Send a log message to the mod log
- await self.mod_log.send_log_message(
- icon_url=Icons.user_ban,
- colour=Colour(Colours.soft_red),
- title="Member permanently banned",
- thumbnail=user.avatar_url_as(static_format="png"),
- text=textwrap.dedent(f"""
- Member: {user.mention} (`{user.id}`)
- Actor: {ctx.message.author}
- Reason: {reason}
- """)
- )
-
- @with_role(*MODERATION_ROLES)
- @command(name="mute")
- async def mute(self, ctx: Context, user: Member, *, reason: str = None):
- """
- Create a permanent mute infraction in the database for a user.
- :param user: Accepts user mention, ID, etc.
- :param reason: The reason for the mute.
- """
-
- active_mutes = await self.bot.api_client.get(
- 'bot/infractions',
- params={
- 'active': 'true',
- 'type': 'mute',
- 'user__id': str(user.id)
- }
- )
- if active_mutes:
- return await ctx.send(
- ":x: According to my records, this user is already muted. "
- f"See infraction **#{active_mutes[0]['id']}**."
- )
-
- notified = await self.notify_infraction(
- user=user, infr_type="Mute",
- expires_at="Permanent", reason=reason
- )
-
- response_object = await post_infraction(ctx, user, type="mute", reason=reason)
- if response_object is None:
- return
-
- # add the mute role
- self.mod_log.ignore(Event.member_update, user.id)
- await user.add_roles(self._muted_role, reason=reason)
-
- dm_result = ":incoming_envelope: " if notified else ""
- action = f"{dm_result}:ok_hand: permanently muted {user.mention}"
-
- if reason is None:
- await ctx.send(f"{action}.")
- else:
- await ctx.send(f"{action} ({reason}).")
-
- if not notified:
- await self.log_notify_failure(user, ctx.author, "mute")
-
- # Send a log message to the mod log
- await self.mod_log.send_log_message(
- icon_url=Icons.user_mute,
- colour=Colour(Colours.soft_red),
- title="Member permanently muted",
- thumbnail=user.avatar_url_as(static_format="png"),
- text=textwrap.dedent(f"""
- Member: {user.mention} (`{user.id}`)
- Actor: {ctx.message.author}
- Reason: {reason}
- """)
- )
-
- # endregion
- # region: Temporary infractions
-
- @with_role(*MODERATION_ROLES)
- @command(name="tempmute")
- async def tempmute(
- self, ctx: Context, user: Member, expiration: ExpirationDate,
- *, reason: str = None
- ):
- """
- Create a temporary mute infraction in the database for a user.
- :param user: Accepts user mention, ID, etc.
- :param duration: The duration for the temporary mute infraction
- :param reason: The reason for the temporary mute.
- """
-
- active_mutes = await self.bot.api_client.get(
- 'bot/infractions',
- params={
- 'active': 'true',
- 'type': 'mute',
- 'user__id': str(user.id)
- }
- )
- if active_mutes:
- return await ctx.send(
- ":x: According to my records, this user is already muted. "
- f"See infraction **#{active_mutes[0]['id']}**."
- )
-
- notified = await self.notify_infraction(
- user=user, infr_type="Mute",
- expires_at=expiration, reason=reason
- )
-
- infraction = await post_infraction(
- ctx, user,
- type="mute", reason=reason,
- expires_at=expiration
- )
-
- self.mod_log.ignore(Event.member_update, user.id)
- await user.add_roles(self._muted_role, reason=reason)
-
- infraction_expiration = (
- datetime
- .fromisoformat(infraction["expires_at"][:-1])
- .strftime('%c')
- )
-
- loop = asyncio.get_event_loop()
- self.schedule_task(loop, infraction["id"], infraction)
-
- dm_result = ":incoming_envelope: " if notified else ""
- action = f"{dm_result}:ok_hand: muted {user.mention} until {infraction_expiration}"
-
- if reason is None:
- await ctx.send(f"{action}.")
- else:
- await ctx.send(f"{action} ({reason}).")
-
- if not notified:
- await self.log_notify_failure(user, ctx.author, "mute")
-
- # Send a log message to the mod log
- await self.mod_log.send_log_message(
- icon_url=Icons.user_mute,
- colour=Colour(Colours.soft_red),
- title="Member temporarily muted",
- thumbnail=user.avatar_url_as(static_format="png"),
- text=textwrap.dedent(f"""
- Member: {user.mention} (`{user.id}`)
- Actor: {ctx.message.author}
- Reason: {reason}
- Expires: {infraction_expiration}
- """)
- )
-
- @with_role(*MODERATION_ROLES)
- @command(name="tempban")
- async def tempban(
- self, ctx: Context, user: Union[User, proxy_user], expiry: ExpirationDate,
- *, reason: str = None
- ):
- """
- Create a temporary ban infraction in the database for a user.
- :param user: Accepts user mention, ID, etc.
- :param expiry: The duration for the temporary ban infraction
- :param reason: The reason for the temporary ban.
- """
-
- active_bans = await self.bot.api_client.get(
- 'bot/infractions',
- params={
- 'active': 'true',
- 'type': 'ban',
- 'user__id': str(user.id)
- }
- )
- if active_bans:
- return await ctx.send(
- ":x: According to my records, this user is already banned. "
- f"See infraction **#{active_bans[0]['id']}**."
- )
-
- notified = await self.notify_infraction(
- user=user, infr_type="Ban",
- expires_at=expiry, reason=reason
- )
-
- infraction = await post_infraction(
- ctx, user, type="ban",
- reason=reason, expires_at=expiry
- )
- if infraction is None:
- return
-
- self.mod_log.ignore(Event.member_ban, user.id)
- self.mod_log.ignore(Event.member_remove, user.id)
- guild: Guild = ctx.guild
- await guild.ban(user, reason=reason, delete_message_days=0)
-
- infraction_expiration = (
- datetime
- .fromisoformat(infraction["expires_at"][:-1])
- .strftime('%c')
- )
-
- loop = asyncio.get_event_loop()
- self.schedule_task(loop, infraction["id"], infraction)
-
- dm_result = ":incoming_envelope: " if notified else ""
- action = f"{dm_result}:ok_hand: banned {user.mention} until {infraction_expiration}"
-
- if reason is None:
- await ctx.send(f"{action}.")
- else:
- await ctx.send(f"{action} ({reason}).")
-
- if not notified:
- await self.log_notify_failure(user, ctx.author, "ban")
-
- # Send a log message to the mod log
- await self.mod_log.send_log_message(
- icon_url=Icons.user_ban,
- colour=Colour(Colours.soft_red),
- thumbnail=user.avatar_url_as(static_format="png"),
- title="Member temporarily banned",
- text=textwrap.dedent(f"""
- Member: {user.mention} (`{user.id}`)
- Actor: {ctx.message.author}
- Reason: {reason}
- Expires: {infraction_expiration}
- """)
- )
-
- # endregion
- # region: Permanent shadow infractions
-
- @with_role(*MODERATION_ROLES)
- @command(name="shadow_warn", hidden=True, aliases=['shadowwarn', 'swarn', 'note'])
- async def shadow_warn(self, ctx: Context, user: Union[User, proxy_user], *, reason: str = None):
- """
- Create a warning infraction in the database for a user.
- :param user: accepts user mention, ID, etc.
- :param reason: The reason for the warning.
- """
-
- response_object = await post_infraction(
- ctx, user, type="warning", reason=reason, hidden=True
- )
-
- if response_object is None:
- return
-
- if reason is None:
- result_message = f":ok_hand: note added for {user.mention}."
- else:
- result_message = f":ok_hand: note added for {user.mention} ({reason})."
-
- await ctx.send(result_message)
-
- # Send a message to the mod log
- await self.mod_log.send_log_message(
- icon_url=Icons.user_warn,
- colour=Colour(Colours.soft_red),
- title="Member shadow warned",
- thumbnail=user.avatar_url_as(static_format="png"),
- text=textwrap.dedent(f"""
- Member: {user.mention} (`{user.id}`)
- Actor: {ctx.message.author}
- Reason: {reason}
- """)
- )
-
- @with_role(*MODERATION_ROLES)
- @command(name="shadow_kick", hidden=True, aliases=['shadowkick', 'skick'])
- async def shadow_kick(self, ctx: Context, user: Member, *, reason: str = None):
- """
- Kicks a user.
- :param user: accepts user mention, ID, etc.
- :param reason: The reason for the kick.
- """
-
- response_object = await post_infraction(ctx, user, type="kick", reason=reason, hidden=True)
- if response_object is None:
- return
-
- self.mod_log.ignore(Event.member_remove, user.id)
- await user.kick(reason=reason)
-
- if reason is None:
- result_message = f":ok_hand: kicked {user.mention}."
- else:
- result_message = f":ok_hand: kicked {user.mention} ({reason})."
-
- await ctx.send(result_message)
-
- # Send a log message to the mod log
- await self.mod_log.send_log_message(
- icon_url=Icons.sign_out,
- colour=Colour(Colours.soft_red),
- title="Member shadow kicked",
- thumbnail=user.avatar_url_as(static_format="png"),
- text=textwrap.dedent(f"""
- Member: {user.mention} (`{user.id}`)
- Actor: {ctx.message.author}
- Reason: {reason}
- """)
- )
-
- @with_role(*MODERATION_ROLES)
- @command(name="shadow_ban", hidden=True, aliases=['shadowban', 'sban'])
- async def shadow_ban(self, ctx: Context, user: Union[User, proxy_user], *, reason: str = None):
- """
- Create a permanent ban infraction in the database for a user.
- :param user: Accepts user mention, ID, etc.
- :param reason: The reason for the ban.
- """
-
- response_object = await post_infraction(ctx, user, type="ban", reason=reason, hidden=True)
- if response_object is None:
- return
-
- self.mod_log.ignore(Event.member_ban, user.id)
- self.mod_log.ignore(Event.member_remove, user.id)
- await ctx.guild.ban(user, reason=reason, delete_message_days=0)
-
- if reason is None:
- result_message = f":ok_hand: permanently banned {user.mention}."
- else:
- result_message = f":ok_hand: permanently banned {user.mention} ({reason})."
-
- await ctx.send(result_message)
-
- # Send a log message to the mod log
- await self.mod_log.send_log_message(
- icon_url=Icons.user_ban,
- colour=Colour(Colours.soft_red),
- title="Member permanently banned",
- thumbnail=user.avatar_url_as(static_format="png"),
- text=textwrap.dedent(f"""
- Member: {user.mention} (`{user.id}`)
- Actor: {ctx.message.author}
- Reason: {reason}
- """)
- )
-
- @with_role(*MODERATION_ROLES)
- @command(name="shadow_mute", hidden=True, aliases=['shadowmute', 'smute'])
- async def shadow_mute(self, ctx: Context, user: Member, *, reason: str = None):
- """
- Create a permanent mute infraction in the database for a user.
- :param user: Accepts user mention, ID, etc.
- :param reason: The reason for the mute.
- """
-
- response_object = await post_infraction(ctx, user, type="mute", reason=reason, hidden=True)
- if response_object is None:
- return
-
- # add the mute role
- self.mod_log.ignore(Event.member_update, user.id)
- await user.add_roles(self._muted_role, reason=reason)
-
- if reason is None:
- result_message = f":ok_hand: permanently muted {user.mention}."
- else:
- result_message = f":ok_hand: permanently muted {user.mention} ({reason})."
-
- await ctx.send(result_message)
-
- # Send a log message to the mod log
- await self.mod_log.send_log_message(
- icon_url=Icons.user_mute,
- colour=Colour(Colours.soft_red),
- title="Member permanently muted",
- thumbnail=user.avatar_url_as(static_format="png"),
- text=textwrap.dedent(f"""
- Member: {user.mention} (`{user.id}`)
- Actor: {ctx.message.author}
- Reason: {reason}
- """)
- )
-
- # endregion
- # region: Temporary shadow infractions
-
- @with_role(*MODERATION_ROLES)
- @command(name="shadow_tempmute", hidden=True, aliases=["shadowtempmute, stempmute"])
- async def shadow_tempmute(self, ctx: Context, user: Member, duration: str, *, reason: str = None):
- """
- Create a temporary mute infraction in the database for a user.
- :param user: Accepts user mention, ID, etc.
- :param duration: The duration for the temporary mute infraction
- :param reason: The reason for the temporary mute.
- """
-
- response_object = await post_infraction(ctx, user, type="mute", reason=reason, duration=duration, hidden=True)
- if response_object is None:
- return
-
- self.mod_log.ignore(Event.member_update, user.id)
- await user.add_roles(self._muted_role, reason=reason)
-
- infraction_object = response_object["infraction"]
- infraction_expiration = infraction_object["expires_at"]
-
- loop = asyncio.get_event_loop()
- self.schedule_expiration(loop, infraction_object)
-
- if reason is None:
- result_message = f":ok_hand: muted {user.mention} until {infraction_expiration}."
- else:
- result_message = f":ok_hand: muted {user.mention} until {infraction_expiration} ({reason})."
-
- await ctx.send(result_message)
-
- # Send a log message to the mod log
- await self.mod_log.send_log_message(
- icon_url=Icons.user_mute,
- colour=Colour(Colours.soft_red),
- title="Member temporarily muted",
- thumbnail=user.avatar_url_as(static_format="png"),
- text=textwrap.dedent(f"""
- Member: {user.mention} (`{user.id}`)
- Actor: {ctx.message.author}
- Reason: {reason}
- Duration: {duration}
- Expires: {infraction_expiration}
- """)
- )
-
- @with_role(*MODERATION_ROLES)
- @command(name="shadow_tempban", hidden=True, aliases=["shadowtempban, stempban"])
- async def shadow_tempban(
- self, ctx: Context, user: Union[User, proxy_user], duration: str, *, reason: str = None
- ):
- """
- Create a temporary ban infraction in the database for a user.
- :param user: Accepts user mention, ID, etc.
- :param duration: The duration for the temporary ban infraction
- :param reason: The reason for the temporary ban.
- """
-
- response_object = await post_infraction(ctx, user, type="ban", reason=reason, duration=duration, hidden=True)
- if response_object is None:
- return
-
- self.mod_log.ignore(Event.member_ban, user.id)
- self.mod_log.ignore(Event.member_remove, user.id)
- guild: Guild = ctx.guild
- await guild.ban(user, reason=reason, delete_message_days=0)
-
- infraction_object = response_object["infraction"]
- infraction_expiration = infraction_object["expires_at"]
-
- loop = asyncio.get_event_loop()
- self.schedule_expiration(loop, infraction_object)
-
- if reason is None:
- result_message = f":ok_hand: banned {user.mention} until {infraction_expiration}."
- else:
- result_message = f":ok_hand: banned {user.mention} until {infraction_expiration} ({reason})."
-
- await ctx.send(result_message)
-
- # Send a log message to the mod log
- await self.mod_log.send_log_message(
- icon_url=Icons.user_ban,
- colour=Colour(Colours.soft_red),
- thumbnail=user.avatar_url_as(static_format="png"),
- title="Member temporarily banned",
- text=textwrap.dedent(f"""
- Member: {user.mention} (`{user.id}`)
- Actor: {ctx.message.author}
- Reason: {reason}
- Duration: {duration}
- Expires: {infraction_expiration}
- """)
- )
-
- # endregion
- # region: Remove infractions (un- commands)
-
- @with_role(*MODERATION_ROLES)
- @command(name="unmute")
- async def unmute(self, ctx: Context, user: Member):
- """
- Deactivates the active mute infraction for a user.
- :param user: Accepts user mention, ID, etc.
- """
-
- try:
- # check the current active infraction
- response = await self.bot.api_client.get(
- 'bot/infractions',
- params={
- 'active': 'true',
- 'type': 'mute',
- 'user__id': user.id
- }
- )
- if len(response) > 1:
- log.warning("Found more than one active mute infraction for user `%d`", user.id)
-
- if not response:
- # no active infraction
- await ctx.send(f":x: There is no active mute infraction for user {user.mention}.")
- return
-
- infraction = response[0]
- await self._deactivate_infraction(infraction)
- if infraction["expires_at"] is not None:
- self.cancel_expiration(infraction["id"])
-
- notified = await self.notify_pardon(
- user=user,
- title="You have been unmuted.",
- content="You may now send messages in the server.",
- icon_url=Icons.user_unmute
- )
-
- dm_result = ":incoming_envelope: " if notified else ""
- await ctx.send(f"{dm_result}:ok_hand: Un-muted {user.mention}.")
-
- if not notified:
- await self.log_notify_failure(user, ctx.author, "unmute")
-
- # Send a log message to the mod log
- await self.mod_log.send_log_message(
- icon_url=Icons.user_unmute,
- colour=Colour(Colours.soft_green),
- title="Member unmuted",
- thumbnail=user.avatar_url_as(static_format="png"),
- text=textwrap.dedent(f"""
- Member: {user.mention} (`{user.id}`)
- Actor: {ctx.message.author}
- Intended expiry: {infraction['expires_at']}
- """)
- )
- except Exception:
- log.exception("There was an error removing an infraction.")
- await ctx.send(":x: There was an error removing the infraction.")
- return
-
- @with_role(*MODERATION_ROLES)
- @command(name="unban")
- async def unban(self, ctx: Context, user: Union[User, proxy_user]):
- """
- Deactivates the active ban infraction for a user.
- :param user: Accepts user mention, ID, etc.
- """
-
- try:
- # check the current active infraction
- response = await self.bot.api_client.get(
- 'bot/infractions',
- params={
- 'active': 'true',
- 'type': 'ban',
- 'user__id': str(user.id)
- }
- )
- if len(response) > 1:
- log.warning(
- "More than one active ban infraction found for user `%d`.",
- user.id
- )
-
- if not response:
- # no active infraction
- await ctx.send(f":x: There is no active ban infraction for user {user.mention}.")
- return
-
- infraction = response[0]
- await self._deactivate_infraction(infraction)
- if infraction["expires_at"] is not None:
- self.cancel_expiration(infraction["id"])
-
- await ctx.send(f":ok_hand: Un-banned {user.mention}.")
-
- # Send a log message to the mod log
- await self.mod_log.send_log_message(
- icon_url=Icons.user_unban,
- colour=Colour(Colours.soft_green),
- title="Member unbanned",
- thumbnail=user.avatar_url_as(static_format="png"),
- text=textwrap.dedent(f"""
- Member: {user.mention} (`{user.id}`)
- Actor: {ctx.message.author}
- Intended expiry: {infraction['expires_at']}
- """)
- )
- except Exception:
- log.exception("There was an error removing an infraction.")
- await ctx.send(":x: There was an error removing the infraction.")
- return
-
- # endregion
- # region: Edit infraction commands
-
- @with_role(*MODERATION_ROLES)
- @group(name='infraction', aliases=('infr', 'infractions', 'inf'), invoke_without_command=True)
- async def infraction_group(self, ctx: Context):
- """Infraction manipulation commands."""
-
- await ctx.invoke(self.bot.get_command("help"), "infraction")
-
- @with_role(*MODERATION_ROLES)
- @infraction_group.group(name='edit', invoke_without_command=True)
- async def infraction_edit_group(self, ctx: Context):
- """Infraction editing commands."""
-
- await ctx.invoke(self.bot.get_command("help"), "infraction", "edit")
-
- @with_role(*MODERATION_ROLES)
- @infraction_edit_group.command(name="duration")
- async def edit_duration(
- self, ctx: Context,
- infraction_id: int, expires_at: Union[ExpirationDate, str]
- ):
- """
- Sets the duration of the given infraction, relative to the time of updating.
- :param infraction_id: the id of the infraction
- :param expires_at: the new expiration date of the infraction.
- Use "permanent" to mark the infraction as permanent.
- """
-
- if isinstance(expires_at, str) and expires_at != 'permanent':
- raise BadArgument(
- "If `expires_at` is given as a non-datetime, "
- "it must be `permanent`."
- )
- if expires_at == 'permanent':
- expires_at = None
-
- try:
- previous_infraction = await self.bot.api_client.get(
- 'bot/infractions/' + str(infraction_id)
- )
-
- # check the current active infraction
- infraction = await self.bot.api_client.patch(
- 'bot/infractions/' + str(infraction_id),
- json={
- 'expires_at': (
- expires_at.isoformat()
- if expires_at is not None
- else None
- )
- }
- )
-
- # Re-schedule
- self.cancel_task(infraction['id'])
- loop = asyncio.get_event_loop()
- self.schedule_task(loop, infraction['id'], infraction)
-
- if expires_at is None:
- await ctx.send(f":ok_hand: Updated infraction: marked as permanent.")
- else:
- human_expiry = (
- datetime
- .fromisoformat(infraction['expires_at'][:-1])
- .strftime('%c')
- )
- await ctx.send(f":ok_hand: Updated infraction: set to expire on {human_expiry}.")
-
- except Exception:
- log.exception("There was an error updating an infraction.")
- await ctx.send(":x: There was an error updating the infraction.")
- return
-
- # Get information about the infraction's user
- user_id = infraction["user"]
- user = ctx.guild.get_member(user_id)
-
- if user:
- member_text = f"{user.mention} (`{user.id}`)"
- thumbnail = user.avatar_url_as(static_format="png")
- else:
- member_text = f"`{user_id}`"
- thumbnail = None
-
- # The infraction's actor
- actor_id = infraction["actor"]
- actor = ctx.guild.get_member(actor_id) or f"`{actor_id}`"
-
- await self.mod_log.send_log_message(
- icon_url=Icons.pencil,
- colour=Colour.blurple(),
- title="Infraction edited",
- thumbnail=thumbnail,
- text=textwrap.dedent(f"""
- Member: {member_text}
- Actor: {actor}
- Edited by: {ctx.message.author}
- Previous expiry: {previous_infraction['expires_at']}
- New expiry: {infraction['expires_at']}
- """)
- )
-
- @with_role(*MODERATION_ROLES)
- @infraction_edit_group.command(name="reason")
- async def edit_reason(self, ctx: Context, infraction_id: int, *, reason: str):
- """
- Sets the reason of the given infraction.
- :param infraction_id: the id of the infraction
- :param reason: The new reason of the infraction
- """
-
- try:
- old_infraction = await self.bot.api_client.get(
- 'bot/infractions/' + str(infraction_id)
- )
-
- updated_infraction = await self.bot.api_client.patch(
- 'bot/infractions/' + str(infraction_id),
- json={'reason': reason}
- )
- await ctx.send(f":ok_hand: Updated infraction: set reason to \"{reason}\".")
-
- except Exception:
- log.exception("There was an error updating an infraction.")
- await ctx.send(":x: There was an error updating the infraction.")
- return
-
- # Get information about the infraction's user
- user_id = updated_infraction['user']
- user = ctx.guild.get_member(user_id)
-
- if user:
- user_text = f"{user.mention} (`{user.id}`)"
- thumbnail = user.avatar_url_as(static_format="png")
- else:
- user_text = f"`{user_id}`"
- thumbnail = None
-
- # The infraction's actor
- actor_id = updated_infraction['actor']
- actor = ctx.guild.get_member(actor_id) or f"`{actor_id}`"
-
- await self.mod_log.send_log_message(
- icon_url=Icons.pencil,
- colour=Colour.blurple(),
- title="Infraction edited",
- thumbnail=thumbnail,
- text=textwrap.dedent(f"""
- Member: {user_text}
- Actor: {actor}
- Edited by: {ctx.message.author}
- Previous reason: {old_infraction['reason']}
- New reason: {updated_infraction['reason']}
- """)
- )
-
- # endregion
- # region: Search infractions
-
- @with_role(*MODERATION_ROLES)
- @infraction_group.group(name="search", invoke_without_command=True)
- async def infraction_search_group(self, ctx: Context, query: InfractionSearchQuery):
- """
- Searches for infractions in the database.
- """
-
- if isinstance(query, User):
- await ctx.invoke(self.search_user, query)
-
- else:
- await ctx.invoke(self.search_reason, query)
-
- @with_role(*MODERATION_ROLES)
- @infraction_search_group.command(name="user", aliases=("member", "id"))
- async def search_user(self, ctx: Context, user: Union[User, proxy_user]):
- """
- Search for infractions by member.
- """
-
- infraction_list = await self.bot.api_client.get(
- 'bot/infractions',
- params={'user__id': str(user.id)}
- )
- embed = Embed(
- title=f"Infractions for {user} ({len(infraction_list)} total)",
- colour=Colour.orange()
- )
- await self.send_infraction_list(ctx, embed, infraction_list)
-
- @with_role(*MODERATION_ROLES)
- @infraction_search_group.command(name="reason", aliases=("match", "regex", "re"))
- async def search_reason(self, ctx: Context, reason: str):
- """
- Search for infractions by their reason. Use Re2 for matching.
- """
-
- infraction_list = await self.bot.api_client.get(
- 'bot/infractions', params={'search': reason}
- )
- embed = Embed(
- title=f"Infractions matching `{reason}` ({len(infraction_list)} total)",
- colour=Colour.orange()
- )
- await self.send_infraction_list(ctx, embed, infraction_list)
-
- # endregion
- # region: Utility functions
-
- async def send_infraction_list(self, ctx: Context, embed: Embed, infractions: list):
-
- if not infractions:
- await ctx.send(f":warning: No infractions could be found for that query.")
- return
-
- lines = tuple(
- self._infraction_to_string(infraction)
- for infraction in infractions
- )
-
- await LinePaginator.paginate(
- lines,
- ctx=ctx,
- embed=embed,
- empty=True,
- max_lines=3,
- max_size=1000
- )
-
- # endregion
- # region: Utility functions
-
- def schedule_expiration(self, loop: asyncio.AbstractEventLoop, infraction_object: dict):
- """
- Schedules a task to expire a temporary infraction.
- :param loop: the asyncio event loop
- :param infraction_object: the infraction object to expire at the end of the task
- """
-
- infraction_id = infraction_object["id"]
- if infraction_id in self.scheduled_tasks:
- return
-
- task: asyncio.Task = create_task(loop, self._scheduled_expiration(infraction_object))
-
- self.scheduled_tasks[infraction_id] = task
-
- def cancel_expiration(self, infraction_id: str):
- """
- Un-schedules a task set to expire a temporary infraction.
- :param infraction_id: the ID of the infraction in question
- """
-
- task = self.scheduled_tasks.get(infraction_id)
- if task is None:
- log.warning(f"Failed to unschedule {infraction_id}: no task found.")
- return
- task.cancel()
- log.debug(f"Unscheduled {infraction_id}.")
- del self.scheduled_tasks[infraction_id]
-
- async def _scheduled_task(self, infraction_object: dict):
- """
- A co-routine which marks an infraction as expired after the delay from the time of scheduling
- to the time of expiration. At the time of expiration, the infraction is marked as inactive on the website,
- and the expiration task is cancelled.
- :param infraction_object: the infraction in question
- """
-
- infraction_id = infraction_object["id"]
-
- # transform expiration to delay in seconds
- expiration_datetime = datetime.fromisoformat(infraction_object["expires_at"][:-1])
- await wait_until(expiration_datetime)
-
- log.debug(f"Marking infraction {infraction_id} as inactive (expired).")
- await self._deactivate_infraction(infraction_object)
-
- self.cancel_task(infraction_object["id"])
-
- # Notify the user that they've been unmuted.
- user_id = infraction_object["user"]
- guild = self.bot.get_guild(constants.Guild.id)
- await self.notify_pardon(
- user=guild.get_member(user_id),
- title="You have been unmuted.",
- content="You may now send messages in the server.",
- icon_url=Icons.user_unmute
- )
-
- async def _deactivate_infraction(self, infraction_object):
- """
- A co-routine which marks an infraction as inactive on the website.
- This co-routine does not cancel or un-schedule an expiration task.
- :param infraction_object: the infraction in question
- """
-
- guild: Guild = self.bot.get_guild(constants.Guild.id)
- user_id = infraction_object["user"]
- infraction_type = infraction_object["type"]
-
- if infraction_type == "mute":
- member: Member = guild.get_member(user_id)
- if member:
- # remove the mute role
- self.mod_log.ignore(Event.member_update, member.id)
- await member.remove_roles(self._muted_role)
- else:
- log.warning(f"Failed to un-mute user: {user_id} (not found)")
- elif infraction_type == "ban":
- user: Object = Object(user_id)
- await guild.unban(user)
-
- await self.bot.api_client.patch(
- 'bot/infractions/' + str(infraction_object['id']),
- json={"active": False}
- )
-
- def _infraction_to_string(self, infraction_object):
- actor_id = infraction_object["actor"]
- guild: Guild = self.bot.get_guild(constants.Guild.id)
- actor = guild.get_member(actor_id)
- active = infraction_object["active"]
- user_id = infraction_object["user"]
- hidden = infraction_object["hidden"]
-
- lines = textwrap.dedent(f"""
- {"**===============**" if active else "==============="}
- Status: {"__**Active**__" if active else "Inactive"}
- User: {self.bot.get_user(user_id)} (`{user_id}`)
- Type: **{infraction_object["type"]}**
- Shadow: {hidden}
- Reason: {infraction_object["reason"] or "*None*"}
- Created: {infraction_object["inserted_at"]}
- Expires: {infraction_object["expires_at"] or "*Permanent*"}
- Actor: {actor.mention if actor else actor_id}
- ID: `{infraction_object["id"]}`
- {"**===============**" if active else "==============="}
- """)
-
- return lines.strip()
-
- async def notify_infraction(
- self, user: Union[User, Member], infr_type: str,
- expires_at: Union[datetime, str] = 'N/A', reason: str = "No reason provided."
- ):
- """
- Notify a user of their fresh infraction :)
-
- :param user: The user to send the message to.
- :param infr_type: The type of infraction, as a string.
- :param duration: The duration of the infraction.
- :param reason: The reason for the infraction.
- """
-
- if isinstance(expires_at, datetime):
- expires_at = expires_at.strftime('%c')
-
- embed = Embed(
- description=textwrap.dedent(f"""
- **Type:** {infr_type}
- **Expires:** {expires_at}
- **Reason:** {reason}
- """),
- colour=Colour(Colours.soft_red)
- )
-
- icon_url = INFRACTION_ICONS.get(infr_type, Icons.token_removed)
- embed.set_author(name="Infraction Information", icon_url=icon_url, url=RULES_URL)
- embed.title = f"Please review our rules over at {RULES_URL}"
- embed.url = RULES_URL
-
- if infr_type in APPEALABLE_INFRACTIONS:
- embed.set_footer(text="To appeal this infraction, send an e-mail to [email protected]")
-
- return await self.send_private_embed(user, embed)
-
- async def notify_pardon(
- self, user: Union[User, Member], title: str, content: str, icon_url: str = Icons.user_verified
- ):
- """
- Notify a user that an infraction has been lifted.
-
- :param user: The user to send the message to.
- :param title: The title of the embed.
- :param content: The content of the embed.
- :param icon_url: URL for the title icon.
- """
-
- embed = Embed(
- description=content,
- colour=Colour(Colours.soft_green)
- )
-
- embed.set_author(name=title, icon_url=icon_url)
-
- return await self.send_private_embed(user, embed)
-
- async def send_private_embed(self, user: Union[User, Member], embed: Embed):
- """
- A helper method for sending an embed to a user's DMs.
-
- :param user: The user to send the embed to.
- :param embed: The embed to send.
- """
-
- # sometimes `user` is a `discord.Object`, so let's make it a proper user.
- user = await self.bot.get_user_info(user.id)
-
- try:
- await user.send(embed=embed)
- return True
- except (HTTPException, Forbidden):
- log.debug(
- f"Infraction-related information could not be sent to user {user} ({user.id}). "
- "They've probably just disabled private messages."
- )
- return False
-
- async def log_notify_failure(self, target: str, actor: Member, infraction_type: str):
- await self.mod_log.send_log_message(
- icon_url=Icons.token_removed,
- content=actor.mention,
- colour=Colour(Colours.soft_red),
- title="Notification Failed",
- text=f"Direct message was unable to be sent.\nUser: {target.mention}\nType: {infraction_type}"
- )
-
- # endregion
-
- async def __error(self, ctx, error):
- if isinstance(error, BadUnionArgument):
- if User in error.converters:
- await ctx.send(str(error.errors[0]))
-
-
-def setup(bot):
- bot.add_cog(Moderation(bot))
- log.info("Cog loaded: Moderation")
diff --git a/bot/cogs/moderation/__init__.py b/bot/cogs/moderation/__init__.py
new file mode 100644
index 000000000..7383ed44e
--- /dev/null
+++ b/bot/cogs/moderation/__init__.py
@@ -0,0 +1,25 @@
+import logging
+
+from discord.ext.commands import Bot
+
+from .infractions import Infractions
+from .management import ModManagement
+from .modlog import ModLog
+from .superstarify import Superstarify
+
+log = logging.getLogger(__name__)
+
+
+def setup(bot: Bot) -> None:
+ """Load the moderation extension (Infractions, ModManagement, ModLog, & Superstarify cogs)."""
+ bot.add_cog(Infractions(bot))
+ log.info("Cog loaded: Infractions")
+
+ bot.add_cog(ModLog(bot))
+ log.info("Cog loaded: ModLog")
+
+ bot.add_cog(ModManagement(bot))
+ log.info("Cog loaded: ModManagement")
+
+ bot.add_cog(Superstarify(bot))
+ log.info("Cog loaded: Superstarify")
diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py
new file mode 100644
index 000000000..592ead60f
--- /dev/null
+++ b/bot/cogs/moderation/infractions.py
@@ -0,0 +1,607 @@
+import logging
+import textwrap
+import typing as t
+from datetime import datetime
+
+import dateutil.parser
+import discord
+from discord import Member
+from discord.ext import commands
+from discord.ext.commands import Context, command
+
+from bot import constants
+from bot.api import ResponseCodeError
+from bot.constants import Colours, Event
+from bot.decorators import respect_role_hierarchy
+from bot.utils import time
+from bot.utils.checks import with_role_check
+from bot.utils.scheduling import Scheduler
+from . import utils
+from .modlog import ModLog
+from .utils import MemberObject
+
+log = logging.getLogger(__name__)
+
+MemberConverter = t.Union[utils.UserTypes, utils.proxy_user]
+
+
+class Infractions(Scheduler, commands.Cog):
+ """Apply and pardon infractions on users for moderation purposes."""
+
+ category = "Moderation"
+ category_description = "Server moderation tools."
+
+ def __init__(self, bot: commands.Bot):
+ super().__init__()
+
+ self.bot = bot
+ self.category = "Moderation"
+ self._muted_role = discord.Object(constants.Roles.muted)
+
+ self.bot.loop.create_task(self.reschedule_infractions())
+
+ @property
+ def mod_log(self) -> ModLog:
+ """Get currently loaded ModLog cog instance."""
+ return self.bot.get_cog("ModLog")
+
+ async def reschedule_infractions(self) -> None:
+ """Schedule expiration for previous infractions."""
+ await self.bot.wait_until_ready()
+
+ infractions = await self.bot.api_client.get(
+ 'bot/infractions',
+ params={'active': 'true'}
+ )
+ for infraction in infractions:
+ if infraction["expires_at"] is not None:
+ self.schedule_task(self.bot.loop, infraction["id"], infraction)
+
+ @commands.Cog.listener()
+ async def on_member_join(self, member: Member) -> None:
+ """Reapply active mute infractions for returning members."""
+ active_mutes = await self.bot.api_client.get(
+ 'bot/infractions',
+ params={
+ 'user__id': str(member.id),
+ 'type': 'mute',
+ 'active': 'true'
+ }
+ )
+ if not active_mutes:
+ return
+
+ # Assume a single mute because of restrictions elsewhere.
+ mute = active_mutes[0]
+
+ # Calculate the time remaining, in seconds, for the mute.
+ expiry = dateutil.parser.isoparse(mute["expires_at"]).replace(tzinfo=None)
+ delta = (expiry - datetime.utcnow()).total_seconds()
+
+ # Mark as inactive if less than a minute remains.
+ if delta < 60:
+ await self.deactivate_infraction(mute)
+ return
+
+ # Allowing mod log since this is a passive action that should be logged.
+ await member.add_roles(self._muted_role, reason=f"Re-applying active mute: {mute['id']}")
+ log.debug(f"User {member.id} has been re-muted on rejoin.")
+
+ # region: Permanent infractions
+
+ @command()
+ async def warn(self, ctx: Context, user: Member, *, reason: str = None) -> None:
+ """Warn a user for the given reason."""
+ infraction = await utils.post_infraction(ctx, user, "warning", reason, active=False)
+ if infraction is None:
+ return
+
+ await self.apply_infraction(ctx, infraction, user)
+
+ @command()
+ async def kick(self, ctx: Context, user: Member, *, reason: str = None) -> None:
+ """Kick a user for the given reason."""
+ await self.apply_kick(ctx, user, reason, active=False)
+
+ @command()
+ async def ban(self, ctx: Context, user: MemberConverter, *, reason: str = None) -> None:
+ """Permanently ban a user for the given reason."""
+ await self.apply_ban(ctx, user, reason)
+
+ # endregion
+ # region: Temporary infractions
+
+ @command(aliases=["mute"])
+ async def tempmute(self, ctx: Context, user: Member, duration: utils.Expiry, *, reason: str = None) -> None:
+ """
+ Temporarily mute a user for the given reason and duration.
+
+ A unit of time should be appended to the duration.
+ Units (∗case-sensitive):
+ \u2003`y` - years
+ \u2003`m` - months∗
+ \u2003`w` - weeks
+ \u2003`d` - days
+ \u2003`h` - hours
+ \u2003`M` - minutes∗
+ \u2003`s` - seconds
+
+ Alternatively, an ISO 8601 timestamp can be provided for the duration.
+ """
+ await self.apply_mute(ctx, user, reason, expires_at=duration)
+
+ @command()
+ async def tempban(self, ctx: Context, user: MemberConverter, duration: utils.Expiry, *, reason: str = None) -> None:
+ """
+ Temporarily ban a user for the given reason and duration.
+
+ A unit of time should be appended to the duration.
+ Units (∗case-sensitive):
+ \u2003`y` - years
+ \u2003`m` - months∗
+ \u2003`w` - weeks
+ \u2003`d` - days
+ \u2003`h` - hours
+ \u2003`M` - minutes∗
+ \u2003`s` - seconds
+
+ Alternatively, an ISO 8601 timestamp can be provided for the duration.
+ """
+ await self.apply_ban(ctx, user, reason, expires_at=duration)
+
+ # endregion
+ # region: Permanent shadow infractions
+
+ @command(hidden=True)
+ async def note(self, ctx: Context, user: MemberConverter, *, reason: str = None) -> None:
+ """Create a private note for a user with the given reason without notifying the user."""
+ infraction = await utils.post_infraction(ctx, user, "note", reason, hidden=True, active=False)
+ if infraction is None:
+ return
+
+ await self.apply_infraction(ctx, infraction, user)
+
+ @command(hidden=True, aliases=['shadowkick', 'skick'])
+ async def shadow_kick(self, ctx: Context, user: Member, *, reason: str = None) -> None:
+ """Kick a user for the given reason without notifying the user."""
+ await self.apply_kick(ctx, user, reason, hidden=True, active=False)
+
+ @command(hidden=True, aliases=['shadowban', 'sban'])
+ async def shadow_ban(self, ctx: Context, user: MemberConverter, *, reason: str = None) -> None:
+ """Permanently ban a user for the given reason without notifying the user."""
+ await self.apply_ban(ctx, user, reason, hidden=True)
+
+ # endregion
+ # region: Temporary shadow infractions
+
+ @command(hidden=True, aliases=["shadowtempmute, stempmute", "shadowmute", "smute"])
+ async def shadow_tempmute(self, ctx: Context, user: Member, duration: utils.Expiry, *, reason: str = None) -> None:
+ """
+ Temporarily mute a user for the given reason and duration without notifying the user.
+
+ A unit of time should be appended to the duration.
+ Units (∗case-sensitive):
+ \u2003`y` - years
+ \u2003`m` - months∗
+ \u2003`w` - weeks
+ \u2003`d` - days
+ \u2003`h` - hours
+ \u2003`M` - minutes∗
+ \u2003`s` - seconds
+
+ Alternatively, an ISO 8601 timestamp can be provided for the duration.
+ """
+ await self.apply_mute(ctx, user, reason, expires_at=duration, hidden=True)
+
+ @command(hidden=True, aliases=["shadowtempban, stempban"])
+ async def shadow_tempban(
+ self,
+ ctx: Context,
+ user: MemberConverter,
+ duration: utils.Expiry,
+ *,
+ reason: str = None
+ ) -> None:
+ """
+ Temporarily ban a user for the given reason and duration without notifying the user.
+
+ A unit of time should be appended to the duration.
+ Units (∗case-sensitive):
+ \u2003`y` - years
+ \u2003`m` - months∗
+ \u2003`w` - weeks
+ \u2003`d` - days
+ \u2003`h` - hours
+ \u2003`M` - minutes∗
+ \u2003`s` - seconds
+
+ Alternatively, an ISO 8601 timestamp can be provided for the duration.
+ """
+ await self.apply_ban(ctx, user, reason, expires_at=duration, hidden=True)
+
+ # endregion
+ # region: Remove infractions (un- commands)
+
+ @command()
+ async def unmute(self, ctx: Context, user: MemberConverter) -> None:
+ """Prematurely end the active mute infraction for the user."""
+ await self.pardon_infraction(ctx, "mute", user)
+
+ @command()
+ async def unban(self, ctx: Context, user: MemberConverter) -> None:
+ """Prematurely end the active ban infraction for the user."""
+ await self.pardon_infraction(ctx, "ban", user)
+
+ # endregion
+ # region: Base infraction functions
+
+ async def apply_mute(self, ctx: Context, user: Member, reason: str, **kwargs) -> None:
+ """Apply a mute infraction with kwargs passed to `post_infraction`."""
+ if await utils.has_active_infraction(ctx, user, "mute"):
+ return
+
+ infraction = await utils.post_infraction(ctx, user, "mute", reason, **kwargs)
+ if infraction is None:
+ return
+
+ self.mod_log.ignore(Event.member_update, user.id)
+
+ action = user.add_roles(self._muted_role, reason=reason)
+ await self.apply_infraction(ctx, infraction, user, action)
+
+ @respect_role_hierarchy()
+ async def apply_kick(self, ctx: Context, user: Member, reason: str, **kwargs) -> None:
+ """Apply a kick infraction with kwargs passed to `post_infraction`."""
+ infraction = await utils.post_infraction(ctx, user, "kick", reason, **kwargs)
+ if infraction is None:
+ return
+
+ self.mod_log.ignore(Event.member_remove, user.id)
+
+ action = user.kick(reason=reason)
+ await self.apply_infraction(ctx, infraction, user, action)
+
+ @respect_role_hierarchy()
+ async def apply_ban(self, ctx: Context, user: MemberObject, reason: str, **kwargs) -> None:
+ """Apply a ban infraction with kwargs passed to `post_infraction`."""
+ if await utils.has_active_infraction(ctx, user, "ban"):
+ return
+
+ infraction = await utils.post_infraction(ctx, user, "ban", reason, **kwargs)
+ if infraction is None:
+ return
+
+ self.mod_log.ignore(Event.member_remove, user.id)
+
+ action = ctx.guild.ban(user, reason=reason, delete_message_days=0)
+ await self.apply_infraction(ctx, infraction, user, action)
+
+ # endregion
+ # region: Utility functions
+
+ async def _scheduled_task(self, infraction: utils.Infraction) -> None:
+ """
+ Marks an infraction expired after the delay from time of scheduling to time of expiration.
+
+ At the time of expiration, the infraction is marked as inactive on the website and the
+ expiration task is cancelled.
+ """
+ _id = infraction["id"]
+
+ expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None)
+ await time.wait_until(expiry)
+
+ log.debug(f"Marking infraction {_id} as inactive (expired).")
+ await self.deactivate_infraction(infraction)
+
+ async def deactivate_infraction(
+ self,
+ infraction: utils.Infraction,
+ send_log: bool = True
+ ) -> t.Dict[str, str]:
+ """
+ Deactivate an active infraction and return a dictionary of lines to send in a mod log.
+
+ The infraction is removed from Discord, marked as inactive in the database, and has its
+ expiration task cancelled. If `send_log` is True, a mod log is sent for the
+ deactivation of the infraction.
+
+ Supported infraction types are mute and ban. Other types will raise a ValueError.
+ """
+ guild = self.bot.get_guild(constants.Guild.id)
+ mod_role = guild.get_role(constants.Roles.moderator)
+ user_id = infraction["user"]
+ _type = infraction["type"]
+ _id = infraction["id"]
+ reason = f"Infraction #{_id} expired or was pardoned."
+
+ log.debug(f"Marking infraction #{_id} as inactive (expired).")
+
+ log_content = None
+ log_text = {
+ "Member": str(user_id),
+ "Actor": str(self.bot.user),
+ "Reason": infraction["reason"]
+ }
+
+ try:
+ if _type == "mute":
+ user = guild.get_member(user_id)
+ if user:
+ # Remove the muted role.
+ self.mod_log.ignore(Event.member_update, user.id)
+ await user.remove_roles(self._muted_role, reason=reason)
+
+ # DM the user about the expiration.
+ notified = await utils.notify_pardon(
+ user=user,
+ title="You have been unmuted.",
+ content="You may now send messages in the server.",
+ icon_url=utils.INFRACTION_ICONS["mute"][1]
+ )
+
+ log_text["Member"] = f"{user.mention}(`{user.id}`)"
+ log_text["DM"] = "Sent" if notified else "**Failed**"
+ else:
+ log.info(f"Failed to unmute user {user_id}: user not found")
+ log_text["Failure"] = "User was not found in the guild."
+ elif _type == "ban":
+ user = discord.Object(user_id)
+ self.mod_log.ignore(Event.member_unban, user_id)
+ try:
+ await guild.unban(user, reason=reason)
+ except discord.NotFound:
+ log.info(f"Failed to unban user {user_id}: no active ban found on Discord")
+ log_text["Note"] = "No active ban found on Discord."
+ else:
+ raise ValueError(
+ f"Attempted to deactivate an unsupported infraction #{_id} ({_type})!"
+ )
+ except discord.Forbidden:
+ log.warning(f"Failed to deactivate infraction #{_id} ({_type}): bot lacks permissions")
+ log_text["Failure"] = f"The bot lacks permissions to do this (role hierarchy?)"
+ log_content = mod_role.mention
+ except discord.HTTPException as e:
+ log.exception(f"Failed to deactivate infraction #{_id} ({_type})")
+ log_text["Failure"] = f"HTTPException with code {e.code}."
+ log_content = mod_role.mention
+
+ # Check if the user is currently being watched by Big Brother.
+ try:
+ active_watch = await self.bot.api_client.get(
+ "bot/infractions",
+ params={
+ "active": "true",
+ "type": "watch",
+ "user__id": user_id
+ }
+ )
+
+ log_text["Watching"] = "Yes" if active_watch else "No"
+ except ResponseCodeError:
+ log.exception(f"Failed to fetch watch status for user {user_id}")
+ log_text["Watching"] = "Unknown - failed to fetch watch status."
+
+ try:
+ # Mark infraction as inactive in the database.
+ await self.bot.api_client.patch(
+ f"bot/infractions/{_id}",
+ json={"active": False}
+ )
+ except ResponseCodeError as e:
+ log.exception(f"Failed to deactivate infraction #{_id} ({_type})")
+ log_line = f"API request failed with code {e.status}."
+ log_content = mod_role.mention
+
+ # Append to an existing failure message if possible
+ if "Failure" in log_text:
+ log_text["Failure"] += f" {log_line}"
+ else:
+ log_text["Failure"] = log_line
+
+ # Cancel the expiration task.
+ if infraction["expires_at"] is not None:
+ self.cancel_task(infraction["id"])
+
+ # Send a log message to the mod log.
+ if send_log:
+ log_title = f"expiration failed" if "Failure" in log_text else "expired"
+
+ await self.mod_log.send_log_message(
+ icon_url=utils.INFRACTION_ICONS[_type][1],
+ colour=Colours.soft_green,
+ title=f"Infraction {log_title}: {_type}",
+ text="\n".join(f"{k}: {v}" for k, v in log_text.items()),
+ footer=f"ID: {_id}",
+ content=log_content,
+ )
+
+ return log_text
+
+ async def apply_infraction(
+ self,
+ ctx: Context,
+ infraction: utils.Infraction,
+ user: MemberObject,
+ action_coro: t.Optional[t.Awaitable] = None
+ ) -> None:
+ """Apply an infraction to the user, log the infraction, and optionally notify the user."""
+ infr_type = infraction["type"]
+ icon = utils.INFRACTION_ICONS[infr_type][0]
+ reason = infraction["reason"]
+ expiry = infraction["expires_at"]
+
+ if expiry:
+ expiry = time.format_infraction(expiry)
+
+ # Default values for the confirmation message and mod log.
+ confirm_msg = f":ok_hand: applied"
+ expiry_msg = f" until {expiry}" if expiry else " permanently"
+ dm_result = ""
+ dm_log_text = ""
+ expiry_log_text = f"Expires: {expiry}" if expiry else ""
+ log_title = "applied"
+ log_content = None
+
+ # DM the user about the infraction if it's not a shadow/hidden infraction.
+ if not infraction["hidden"]:
+ # Sometimes user is a discord.Object; make it a proper user.
+ await self.bot.fetch_user(user.id)
+
+ # Accordingly display whether the user was successfully notified via DM.
+ if await utils.notify_infraction(user, infr_type, expiry, reason, icon):
+ dm_result = ":incoming_envelope: "
+ dm_log_text = "\nDM: Sent"
+ else:
+ dm_log_text = "\nDM: **Failed**"
+ log_content = ctx.author.mention
+
+ if infraction["actor"] == self.bot.user.id:
+ end_msg = f" (reason: {infraction['reason']})"
+ else:
+ infractions = await self.bot.api_client.get(
+ "bot/infractions",
+ params={"user__id": str(user.id)}
+ )
+ end_msg = f" ({len(infractions)} infractions total)"
+
+ # Execute the necessary actions to apply the infraction on Discord.
+ if action_coro:
+ try:
+ await action_coro
+ if expiry:
+ # Schedule the expiration of the infraction.
+ self.schedule_task(ctx.bot.loop, infraction["id"], infraction)
+ except discord.Forbidden:
+ # Accordingly display that applying the infraction failed.
+ confirm_msg = f":x: failed to apply"
+ expiry_msg = ""
+ log_content = ctx.author.mention
+ log_title = "failed to apply"
+
+ # Send a confirmation message to the invoking context.
+ await ctx.send(
+ f"{dm_result}{confirm_msg} **{infr_type}** to {user.mention}{expiry_msg}{end_msg}."
+ )
+
+ # Send a log message to the mod log.
+ await self.mod_log.send_log_message(
+ icon_url=icon,
+ colour=Colours.soft_red,
+ title=f"Infraction {log_title}: {infr_type}",
+ thumbnail=user.avatar_url_as(static_format="png"),
+ text=textwrap.dedent(f"""
+ Member: {user.mention} (`{user.id}`)
+ Actor: {ctx.message.author}{dm_log_text}
+ Reason: {reason}
+ {expiry_log_text}
+ """),
+ content=log_content,
+ footer=f"ID {infraction['id']}"
+ )
+
+ async def pardon_infraction(self, ctx: Context, infr_type: str, user: MemberObject) -> None:
+ """Prematurely end an infraction for a user and log the action in the mod log."""
+ # Check the current active infraction
+ response = await self.bot.api_client.get(
+ 'bot/infractions',
+ params={
+ 'active': 'true',
+ 'type': infr_type,
+ 'user__id': user.id
+ }
+ )
+
+ if not response:
+ await ctx.send(f":x: There's no active {infr_type} infraction for user {user.mention}.")
+ return
+
+ # Deactivate the infraction and cancel its scheduled expiration task.
+ log_text = await self.deactivate_infraction(response[0], send_log=False)
+
+ log_text["Member"] = f"{user.mention}(`{user.id}`)"
+ log_text["Actor"] = str(ctx.message.author)
+ log_content = None
+ footer = f"ID: {response[0]['id']}"
+
+ # If multiple active infractions were found, mark them as inactive in the database
+ # and cancel their expiration tasks.
+ if len(response) > 1:
+ log.warning(f"Found more than one active {infr_type} infraction for user {user.id}")
+
+ footer = f"Infraction IDs: {', '.join(str(infr['id']) for infr in response)}"
+
+ log_note = f"Found multiple **active** {infr_type} infractions in the database."
+ if "Note" in log_text:
+ log_text["Note"] = f" {log_note}"
+ else:
+ log_text["Note"] = log_note
+
+ # deactivate_infraction() is not called again because:
+ # 1. Discord cannot store multiple active bans or assign multiples of the same role
+ # 2. It would send a pardon DM for each active infraction, which is redundant
+ for infraction in response[1:]:
+ _id = infraction['id']
+ try:
+ # Mark infraction as inactive in the database.
+ await self.bot.api_client.patch(
+ f"bot/infractions/{_id}",
+ json={"active": False}
+ )
+ except ResponseCodeError:
+ log.exception(f"Failed to deactivate infraction #{_id} ({infr_type})")
+ # This is simpler and cleaner than trying to concatenate all the errors.
+ log_text["Failure"] = "See bot's logs for details."
+
+ # Cancel pending expiration task.
+ if infraction["expires_at"] is not None:
+ self.cancel_task(infraction["id"])
+
+ # Accordingly display whether the user was successfully notified via DM.
+ dm_emoji = ""
+ if log_text.get("DM") == "Sent":
+ dm_emoji = ":incoming_envelope: "
+ elif "DM" in log_text:
+ # Mention the actor because the DM failed to send.
+ log_content = ctx.author.mention
+
+ # Accordingly display whether the pardon failed.
+ if "Failure" in log_text:
+ confirm_msg = ":x: failed to pardon"
+ log_title = "pardon failed"
+ log_content = ctx.author.mention
+ else:
+ confirm_msg = f":ok_hand: pardoned"
+ log_title = "pardoned"
+
+ # Send a confirmation message to the invoking context.
+ await ctx.send(
+ f"{dm_emoji}{confirm_msg} infraction **{infr_type}** for {user.mention}. "
+ f"{log_text.get('Failure', '')}"
+ )
+
+ # Send a log message to the mod log.
+ await self.mod_log.send_log_message(
+ icon_url=utils.INFRACTION_ICONS[infr_type][1],
+ colour=Colours.soft_green,
+ title=f"Infraction {log_title}: {infr_type}",
+ thumbnail=user.avatar_url_as(static_format="png"),
+ text="\n".join(f"{k}: {v}" for k, v in log_text.items()),
+ footer=footer,
+ content=log_content,
+ )
+
+ # endregion
+
+ # 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, *constants.MODERATION_ROLES)
+
+ # This cannot be static (must have a __func__ attribute).
+ async def cog_command_error(self, ctx: Context, error: Exception) -> None:
+ """Send a notification to the invoking context on a Union failure."""
+ if isinstance(error, commands.BadUnionArgument):
+ if discord.User in error.converters:
+ await ctx.send(str(error.errors[0]))
+ error.handled = True
diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py
new file mode 100644
index 000000000..491f6d400
--- /dev/null
+++ b/bot/cogs/moderation/management.py
@@ -0,0 +1,268 @@
+import asyncio
+import logging
+import textwrap
+import typing as t
+
+import discord
+from discord.ext import commands
+from discord.ext.commands import Context
+
+from bot import constants
+from bot.converters import InfractionSearchQuery
+from bot.pagination import LinePaginator
+from bot.utils import time
+from bot.utils.checks import with_role_check
+from . import utils
+from .infractions import Infractions
+from .modlog import ModLog
+
+log = logging.getLogger(__name__)
+
+UserConverter = t.Union[discord.User, utils.proxy_user]
+
+
+def permanent_duration(expires_at: str) -> str:
+ """Only allow an expiration to be 'permanent' if it is a string."""
+ expires_at = expires_at.lower()
+ if expires_at != "permanent":
+ raise commands.BadArgument
+ else:
+ return expires_at
+
+
+class ModManagement(commands.Cog):
+ """Management of infractions."""
+
+ category = "Moderation"
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ @property
+ def mod_log(self) -> ModLog:
+ """Get currently loaded ModLog cog instance."""
+ return self.bot.get_cog("ModLog")
+
+ @property
+ def infractions_cog(self) -> Infractions:
+ """Get currently loaded Infractions cog instance."""
+ return self.bot.get_cog("Infractions")
+
+ # region: Edit infraction commands
+
+ @commands.group(name='infraction', aliases=('infr', 'infractions', 'inf'), invoke_without_command=True)
+ async def infraction_group(self, ctx: Context) -> None:
+ """Infraction manipulation commands."""
+ await ctx.invoke(self.bot.get_command("help"), "infraction")
+
+ @infraction_group.command(name='edit')
+ async def infraction_edit(
+ self,
+ ctx: Context,
+ infraction_id: int,
+ duration: t.Union[utils.Expiry, permanent_duration, None],
+ *,
+ reason: str = None
+ ) -> None:
+ """
+ Edit the duration and/or the reason of an infraction.
+
+ Durations are relative to the time of updating and should be appended with a unit of time.
+ Units (∗case-sensitive):
+ \u2003`y` - years
+ \u2003`m` - months∗
+ \u2003`w` - weeks
+ \u2003`d` - days
+ \u2003`h` - hours
+ \u2003`M` - minutes∗
+ \u2003`s` - seconds
+
+ Use "permanent" to mark the infraction as permanent. Alternatively, an ISO 8601 timestamp
+ can be provided for the duration.
+ """
+ if duration is None and reason is None:
+ # Unlike UserInputError, the error handler will show a specified message for BadArgument
+ raise commands.BadArgument("Neither a new expiry nor a new reason was specified.")
+
+ # Retrieve the previous infraction for its information.
+ old_infraction = await self.bot.api_client.get(f'bot/infractions/{infraction_id}')
+
+ request_data = {}
+ confirm_messages = []
+ log_text = ""
+
+ if duration == "permanent":
+ request_data['expires_at'] = None
+ confirm_messages.append("marked as permanent")
+ elif duration is not None:
+ request_data['expires_at'] = duration.isoformat()
+ expiry = duration.strftime(time.INFRACTION_FORMAT)
+ confirm_messages.append(f"set to expire on {expiry}")
+ else:
+ confirm_messages.append("expiry unchanged")
+
+ if reason:
+ request_data['reason'] = reason
+ confirm_messages.append("set a new reason")
+ log_text += f"""
+ Previous reason: {old_infraction['reason']}
+ New reason: {reason}
+ """.rstrip()
+ else:
+ confirm_messages.append("reason unchanged")
+
+ # Update the infraction
+ new_infraction = await self.bot.api_client.patch(
+ f'bot/infractions/{infraction_id}',
+ json=request_data,
+ )
+
+ # Re-schedule infraction if the expiration has been updated
+ if 'expires_at' in request_data:
+ self.infractions_cog.cancel_task(new_infraction['id'])
+ loop = asyncio.get_event_loop()
+ self.infractions_cog.schedule_task(loop, new_infraction['id'], new_infraction)
+
+ log_text += f"""
+ Previous expiry: {old_infraction['expires_at'] or "Permanent"}
+ New expiry: {new_infraction['expires_at'] or "Permanent"}
+ """.rstrip()
+
+ await ctx.send(f":ok_hand: Updated infraction: {' & '.join(confirm_messages)}")
+
+ # Get information about the infraction's user
+ user_id = new_infraction['user']
+ user = ctx.guild.get_member(user_id)
+
+ if user:
+ user_text = f"{user.mention} (`{user.id}`)"
+ thumbnail = user.avatar_url_as(static_format="png")
+ else:
+ user_text = f"`{user_id}`"
+ thumbnail = None
+
+ # The infraction's actor
+ actor_id = new_infraction['actor']
+ actor = ctx.guild.get_member(actor_id) or f"`{actor_id}`"
+
+ await self.mod_log.send_log_message(
+ icon_url=constants.Icons.pencil,
+ colour=discord.Colour.blurple(),
+ title="Infraction edited",
+ thumbnail=thumbnail,
+ text=textwrap.dedent(f"""
+ Member: {user_text}
+ Actor: {actor}
+ Edited by: {ctx.message.author}{log_text}
+ """)
+ )
+
+ # endregion
+ # region: Search infractions
+
+ @infraction_group.group(name="search", invoke_without_command=True)
+ async def infraction_search_group(self, ctx: Context, query: InfractionSearchQuery) -> None:
+ """Searches for infractions in the database."""
+ if isinstance(query, discord.User):
+ await ctx.invoke(self.search_user, query)
+ else:
+ await ctx.invoke(self.search_reason, query)
+
+ @infraction_search_group.command(name="user", aliases=("member", "id"))
+ async def search_user(self, ctx: Context, user: UserConverter) -> None:
+ """Search for infractions by member."""
+ infraction_list = await self.bot.api_client.get(
+ 'bot/infractions',
+ params={'user__id': str(user.id)}
+ )
+ embed = discord.Embed(
+ title=f"Infractions for {user} ({len(infraction_list)} total)",
+ colour=discord.Colour.orange()
+ )
+ await self.send_infraction_list(ctx, embed, infraction_list)
+
+ @infraction_search_group.command(name="reason", aliases=("match", "regex", "re"))
+ async def search_reason(self, ctx: Context, reason: str) -> None:
+ """Search for infractions by their reason. Use Re2 for matching."""
+ infraction_list = await self.bot.api_client.get(
+ 'bot/infractions',
+ params={'search': reason}
+ )
+ embed = discord.Embed(
+ title=f"Infractions matching `{reason}` ({len(infraction_list)} total)",
+ colour=discord.Colour.orange()
+ )
+ await self.send_infraction_list(ctx, embed, infraction_list)
+
+ # endregion
+ # region: Utility functions
+
+ async def send_infraction_list(
+ self,
+ ctx: Context,
+ embed: discord.Embed,
+ infractions: t.Iterable[utils.Infraction]
+ ) -> None:
+ """Send a paginated embed of infractions for the specified user."""
+ if not infractions:
+ await ctx.send(f":warning: No infractions could be found for that query.")
+ return
+
+ lines = tuple(
+ self.infraction_to_string(infraction)
+ for infraction in infractions
+ )
+
+ await LinePaginator.paginate(
+ lines,
+ ctx=ctx,
+ embed=embed,
+ empty=True,
+ max_lines=3,
+ max_size=1000
+ )
+
+ def infraction_to_string(self, infraction: utils.Infraction) -> str:
+ """Convert the infraction object to a string representation."""
+ actor_id = infraction["actor"]
+ guild = self.bot.get_guild(constants.Guild.id)
+ actor = guild.get_member(actor_id)
+ active = infraction["active"]
+ user_id = infraction["user"]
+ hidden = infraction["hidden"]
+ created = time.format_infraction(infraction["inserted_at"])
+ if infraction["expires_at"] is None:
+ expires = "*Permanent*"
+ else:
+ expires = time.format_infraction(infraction["expires_at"])
+
+ lines = textwrap.dedent(f"""
+ {"**===============**" if active else "==============="}
+ Status: {"__**Active**__" if active else "Inactive"}
+ User: {self.bot.get_user(user_id)} (`{user_id}`)
+ Type: **{infraction["type"]}**
+ Shadow: {hidden}
+ Reason: {infraction["reason"] or "*None*"}
+ Created: {created}
+ Expires: {expires}
+ Actor: {actor.mention if actor else actor_id}
+ ID: `{infraction["id"]}`
+ {"**===============**" if active else "==============="}
+ """)
+
+ return lines.strip()
+
+ # endregion
+
+ # 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, *constants.MODERATION_ROLES)
+
+ # This cannot be static (must have a __func__ attribute).
+ async def cog_command_error(self, ctx: Context, error: Exception) -> None:
+ """Send a notification to the invoking context on a Union failure."""
+ if isinstance(error, commands.BadUnionArgument):
+ if discord.User in error.converters:
+ await ctx.send(str(error.errors[0]))
+ error.handled = True
diff --git a/bot/cogs/modlog.py b/bot/cogs/moderation/modlog.py
index a3876bab7..118503517 100644
--- a/bot/cogs/modlog.py
+++ b/bot/cogs/moderation/modlog.py
@@ -1,37 +1,31 @@
import asyncio
import logging
+import typing as t
from datetime import datetime
-from typing import List, Optional, Union
+import discord
from dateutil.relativedelta import relativedelta
from deepdiff import DeepDiff
-from discord import (
- CategoryChannel, Colour, Embed, File, Guild,
- Member, Message, NotFound, RawMessageDeleteEvent,
- RawMessageUpdateEvent, Role, TextChannel, User, VoiceChannel
-)
+from discord import Colour
from discord.abc import GuildChannel
-from discord.ext.commands import Bot
+from discord.ext.commands import Bot, Cog, Context
-from bot.constants import (
- Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, URLs
-)
+from bot.constants import Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, URLs
from bot.utils.time import humanize_delta
+from .utils import UserTypes
log = logging.getLogger(__name__)
-GUILD_CHANNEL = Union[CategoryChannel, TextChannel, VoiceChannel]
+GUILD_CHANNEL = t.Union[discord.CategoryChannel, discord.TextChannel, discord.VoiceChannel]
CHANNEL_CHANGES_UNSUPPORTED = ("permissions",)
CHANNEL_CHANGES_SUPPRESSED = ("_overwrites", "position")
-MEMBER_CHANGES_SUPPRESSED = ("activity", "status")
+MEMBER_CHANGES_SUPPRESSED = ("status", "activities", "_client_status", "nick")
ROLE_CHANGES_UNSUPPORTED = ("colour", "permissions")
-class ModLog:
- """
- Logging for server events and staff actions
- """
+class ModLog(Cog, name="ModLog"):
+ """Logging for server events and staff actions."""
def __init__(self, bot: Bot):
self.bot = bot
@@ -40,16 +34,14 @@ class ModLog:
self._cached_deletes = []
self._cached_edits = []
- async def upload_log(self, messages: List[Message], actor_id: int) -> Optional[str]:
+ async def upload_log(self, messages: t.List[discord.Message], actor_id: int) -> str:
"""
- Uploads the log data to the database via
- an API endpoint for uploading logs.
+ Uploads the log data to the database via an API endpoint for uploading logs.
Used in several mod log embeds.
Returns a URL that can be used to view the log.
"""
-
response = await self.bot.api_client.post(
'bot/deleted-messages',
json={
@@ -70,25 +62,41 @@ class ModLog:
return f"{URLs.site_logs_view}/{response['id']}"
- def ignore(self, event: Event, *items: int):
+ def ignore(self, event: Event, *items: int) -> None:
+ """Add event to ignored events to suppress log emission."""
for item in items:
if item not in self._ignored[event]:
self._ignored[event].append(item)
async def send_log_message(
- self, icon_url: Optional[str], colour: Colour, title: Optional[str], text: str,
- thumbnail: str = None, channel_id: int = Channels.modlog, ping_everyone: bool = False,
- files: List[File] = None, content: str = None
- ):
- embed = Embed(description=text)
+ self,
+ icon_url: t.Optional[str],
+ colour: t.Union[discord.Colour, int],
+ title: t.Optional[str],
+ text: str,
+ thumbnail: t.Optional[t.Union[str, discord.Asset]] = None,
+ channel_id: int = Channels.modlog,
+ ping_everyone: bool = False,
+ files: t.Optional[t.List[discord.File]] = None,
+ content: t.Optional[str] = None,
+ additional_embeds: t.Optional[t.List[discord.Embed]] = None,
+ additional_embeds_msg: t.Optional[str] = None,
+ timestamp_override: t.Optional[datetime] = None,
+ footer: t.Optional[str] = None,
+ ) -> Context:
+ """Generate log embed and send to logging channel."""
+ embed = discord.Embed(description=text)
if title and icon_url:
embed.set_author(name=title, icon_url=icon_url)
embed.colour = colour
- embed.timestamp = datetime.utcnow()
+ embed.timestamp = timestamp_override or datetime.utcnow()
+
+ if footer:
+ embed.set_footer(text=footer)
- if thumbnail is not None:
+ if thumbnail:
embed.set_thumbnail(url=thumbnail)
if ping_everyone:
@@ -97,16 +105,27 @@ class ModLog:
else:
content = "@everyone"
- await self.bot.get_channel(channel_id).send(content=content, embed=embed, files=files)
+ channel = self.bot.get_channel(channel_id)
+ log_message = await channel.send(content=content, embed=embed, files=files)
+
+ if additional_embeds:
+ if additional_embeds_msg:
+ await channel.send(additional_embeds_msg)
+ for additional_embed in additional_embeds:
+ await channel.send(embed=additional_embed)
+
+ return await self.bot.get_context(log_message) # Optionally return for use with antispam
- async def on_guild_channel_create(self, channel: GUILD_CHANNEL):
+ @Cog.listener()
+ async def on_guild_channel_create(self, channel: GUILD_CHANNEL) -> None:
+ """Log channel create event to mod log."""
if channel.guild.id != GuildConstant.id:
return
- if isinstance(channel, CategoryChannel):
+ if isinstance(channel, discord.CategoryChannel):
title = "Category created"
message = f"{channel.name} (`{channel.id}`)"
- elif isinstance(channel, VoiceChannel):
+ elif isinstance(channel, discord.VoiceChannel):
title = "Voice channel created"
if channel.category:
@@ -121,33 +140,41 @@ class ModLog:
else:
message = f"{channel.name} (`{channel.id}`)"
- await self.send_log_message(Icons.hash_green, Colour(Colours.soft_green), title, message)
+ await self.send_log_message(Icons.hash_green, Colours.soft_green, title, message)
- async def on_guild_channel_delete(self, channel: GUILD_CHANNEL):
+ @Cog.listener()
+ async def on_guild_channel_delete(self, channel: GUILD_CHANNEL) -> None:
+ """Log channel delete event to mod log."""
if channel.guild.id != GuildConstant.id:
return
- if isinstance(channel, CategoryChannel):
+ if isinstance(channel, discord.CategoryChannel):
title = "Category deleted"
- elif isinstance(channel, VoiceChannel):
+ elif isinstance(channel, discord.VoiceChannel):
title = "Voice channel deleted"
else:
title = "Text channel deleted"
- if channel.category and not isinstance(channel, CategoryChannel):
+ if channel.category and not isinstance(channel, discord.CategoryChannel):
message = f"{channel.category}/{channel.name} (`{channel.id}`)"
else:
message = f"{channel.name} (`{channel.id}`)"
await self.send_log_message(
- Icons.hash_red, Colour(Colours.soft_red),
+ Icons.hash_red, Colours.soft_red,
title, message
)
- async def on_guild_channel_update(self, before: GUILD_CHANNEL, after: GuildChannel):
+ @Cog.listener()
+ async def on_guild_channel_update(self, before: GUILD_CHANNEL, after: GuildChannel) -> None:
+ """Log channel update event to mod log."""
if before.guild.id != GuildConstant.id:
return
+ if before.id in self._ignored[Event.guild_channel_update]:
+ self._ignored[Event.guild_channel_update].remove(before.id)
+ return
+
diff = DeepDiff(before, after)
changes = []
done = []
@@ -198,25 +225,31 @@ class ModLog:
"Channel updated", message
)
- async def on_guild_role_create(self, role: Role):
+ @Cog.listener()
+ async def on_guild_role_create(self, role: discord.Role) -> None:
+ """Log role create event to mod log."""
if role.guild.id != GuildConstant.id:
return
await self.send_log_message(
- Icons.crown_green, Colour(Colours.soft_green),
+ Icons.crown_green, Colours.soft_green,
"Role created", f"`{role.id}`"
)
- async def on_guild_role_delete(self, role: Role):
+ @Cog.listener()
+ async def on_guild_role_delete(self, role: discord.Role) -> None:
+ """Log role delete event to mod log."""
if role.guild.id != GuildConstant.id:
return
await self.send_log_message(
- Icons.crown_red, Colour(Colours.soft_red),
+ Icons.crown_red, Colours.soft_red,
"Role removed", f"{role.name} (`{role.id}`)"
)
- async def on_guild_role_update(self, before: Role, after: Role):
+ @Cog.listener()
+ async def on_guild_role_update(self, before: discord.Role, after: discord.Role) -> None:
+ """Log role update event to mod log."""
if before.guild.id != GuildConstant.id:
return
@@ -267,7 +300,9 @@ class ModLog:
"Role updated", message
)
- async def on_guild_update(self, before: Guild, after: Guild):
+ @Cog.listener()
+ async def on_guild_update(self, before: discord.Guild, after: discord.Guild) -> None:
+ """Log guild update event to mod log."""
if before.id != GuildConstant.id:
return
@@ -316,7 +351,9 @@ class ModLog:
thumbnail=after.icon_url_as(format="png")
)
- async def on_member_ban(self, guild: Guild, member: Union[Member, User]):
+ @Cog.listener()
+ async def on_member_ban(self, guild: discord.Guild, member: UserTypes) -> None:
+ """Log ban event to user log."""
if guild.id != GuildConstant.id:
return
@@ -325,17 +362,20 @@ class ModLog:
return
await self.send_log_message(
- Icons.user_ban, Colour(Colours.soft_red),
+ Icons.user_ban, Colours.soft_red,
"User banned", f"{member.name}#{member.discriminator} (`{member.id}`)",
- thumbnail=member.avatar_url_as(static_format="png")
+ thumbnail=member.avatar_url_as(static_format="png"),
+ channel_id=Channels.userlog
)
- async def on_member_join(self, member: Member):
+ @Cog.listener()
+ async def on_member_join(self, member: discord.Member) -> None:
+ """Log member join event to user log."""
if member.guild.id != GuildConstant.id:
return
message = f"{member.name}#{member.discriminator} (`{member.id}`)"
- now = datetime.datetime.utcnow()
+ now = datetime.utcnow()
difference = abs(relativedelta(now, member.created_at))
message += "\n\n**Account age:** " + humanize_delta(difference)
@@ -344,12 +384,15 @@ class ModLog:
message = f"{Emojis.new} {message}"
await self.send_log_message(
- Icons.sign_in, Colour(Colours.soft_green),
+ Icons.sign_in, Colours.soft_green,
"User joined", message,
- thumbnail=member.avatar_url_as(static_format="png")
+ thumbnail=member.avatar_url_as(static_format="png"),
+ channel_id=Channels.userlog
)
- async def on_member_remove(self, member: Member):
+ @Cog.listener()
+ async def on_member_remove(self, member: discord.Member) -> None:
+ """Log member leave event to user log."""
if member.guild.id != GuildConstant.id:
return
@@ -358,12 +401,15 @@ class ModLog:
return
await self.send_log_message(
- Icons.sign_out, Colour(Colours.soft_red),
+ Icons.sign_out, Colours.soft_red,
"User left", f"{member.name}#{member.discriminator} (`{member.id}`)",
- thumbnail=member.avatar_url_as(static_format="png")
+ thumbnail=member.avatar_url_as(static_format="png"),
+ channel_id=Channels.userlog
)
- async def on_member_unban(self, guild: Guild, member: User):
+ @Cog.listener()
+ async def on_member_unban(self, guild: discord.Guild, member: discord.User) -> None:
+ """Log member unban event to mod log."""
if guild.id != GuildConstant.id:
return
@@ -374,10 +420,13 @@ class ModLog:
await self.send_log_message(
Icons.user_unban, Colour.blurple(),
"User unbanned", f"{member.name}#{member.discriminator} (`{member.id}`)",
- thumbnail=member.avatar_url_as(static_format="png")
+ thumbnail=member.avatar_url_as(static_format="png"),
+ channel_id=Channels.modlog
)
- async def on_member_update(self, before: Member, after: Member):
+ @Cog.listener()
+ async def on_member_update(self, before: discord.Member, after: discord.Member) -> None:
+ """Log member update event to user log."""
if before.guild.id != GuildConstant.id:
return
@@ -449,6 +498,11 @@ class ModLog:
f"**Discriminator:** `{before.discriminator}` **->** `{after.discriminator}`"
)
+ if before.display_name != after.display_name:
+ changes.append(
+ f"**Display name:** `{before.display_name}` **->** `{after.display_name}`"
+ )
+
if not changes:
return
@@ -462,10 +516,13 @@ class ModLog:
await self.send_log_message(
Icons.user_update, Colour.blurple(),
"Member updated", message,
- thumbnail=after.avatar_url_as(static_format="png")
+ thumbnail=after.avatar_url_as(static_format="png"),
+ channel_id=Channels.userlog
)
- async def on_message_delete(self, message: Message):
+ @Cog.listener()
+ async def on_message_delete(self, message: discord.Message) -> None:
+ """Log message delete event to message change log."""
channel = message.channel
author = message.author
@@ -496,19 +553,22 @@ class ModLog:
"\n"
)
+ if message.attachments:
+ # Prepend the message metadata with the number of attachments
+ response = f"**Attachments:** {len(message.attachments)}\n" + response
+
# Shorten the message content if necessary
content = message.clean_content
remaining_chars = 2040 - len(response)
if len(content) > remaining_chars:
- content = content[:remaining_chars] + "..."
+ botlog_url = await self.upload_log(messages=[message], actor_id=message.author.id)
+ ending = f"\n\nMessage truncated, [full message here]({botlog_url})."
+ truncation_point = remaining_chars - len(ending)
+ content = f"{content[:truncation_point]}...{ending}"
response += f"{content}"
- if message.attachments:
- # Prepend the message metadata with the number of attachments
- response = f"**Attachments:** {len(message.attachments)}\n" + response
-
await self.send_log_message(
Icons.message_delete, Colours.soft_red,
"Message deleted",
@@ -516,7 +576,9 @@ class ModLog:
channel_id=Channels.message_log
)
- async def on_raw_message_delete(self, event: RawMessageDeleteEvent):
+ @Cog.listener()
+ async def on_raw_message_delete(self, event: discord.RawMessageDeleteEvent) -> None:
+ """Log raw message delete event to message change log."""
if event.guild_id != GuildConstant.id or event.channel_id in GuildConstant.ignored:
return
@@ -549,13 +611,15 @@ class ModLog:
)
await self.send_log_message(
- Icons.message_delete, Colour(Colours.soft_red),
+ Icons.message_delete, Colours.soft_red,
"Message deleted",
response,
channel_id=Channels.message_log
)
- async def on_message_edit(self, before: Message, after: Message):
+ @Cog.listener()
+ async def on_message_edit(self, before: discord.Message, after: discord.Message) -> None:
+ """Log message edit event to message change log."""
if (
not before.guild
or before.guild.id != GuildConstant.id
@@ -605,21 +669,36 @@ class ModLog:
f"{after.clean_content}"
)
+ if before.edited_at:
+ # Message was previously edited, to assist with self-bot detection, use the edited_at
+ # datetime as the baseline and create a human-readable delta between this edit event
+ # and the last time the message was edited
+ timestamp = before.edited_at
+ delta = humanize_delta(relativedelta(after.edited_at, before.edited_at))
+ footer = f"Last edited {delta} ago"
+ else:
+ # Message was not previously edited, use the created_at datetime as the baseline, no
+ # delta calculation needed
+ timestamp = before.created_at
+ footer = None
+
await self.send_log_message(
- Icons.message_edit, Colour.blurple(), "Message edited (Before)",
- before_response, channel_id=Channels.message_log
+ Icons.message_edit, Colour.blurple(), "Message edited (Before)", before_response,
+ channel_id=Channels.message_log, timestamp_override=timestamp, footer=footer
)
await self.send_log_message(
- Icons.message_edit, Colour.blurple(), "Message edited (After)",
- after_response, channel_id=Channels.message_log
+ Icons.message_edit, Colour.blurple(), "Message edited (After)", after_response,
+ channel_id=Channels.message_log, timestamp_override=after.edited_at
)
- async def on_raw_message_edit(self, event: RawMessageUpdateEvent):
+ @Cog.listener()
+ async def on_raw_message_edit(self, event: discord.RawMessageUpdateEvent) -> None:
+ """Log raw message edit event to message change log."""
try:
channel = self.bot.get_channel(int(event.data["channel_id"]))
- message = await channel.get_message(event.message_id)
- except NotFound: # Was deleted before we got the event
+ message = await channel.fetch_message(event.message_id)
+ except discord.NotFound: # Was deleted before we got the event
return
if (
@@ -682,8 +761,3 @@ class ModLog:
Icons.message_edit, Colour.blurple(), "Message edited (After)",
after_response, channel_id=Channels.message_log
)
-
-
-def setup(bot):
- bot.add_cog(ModLog(bot))
- log.info("Cog loaded: ModLog")
diff --git a/bot/cogs/superstarify/__init__.py b/bot/cogs/moderation/superstarify.py
index efa02cb43..ccc6395d9 100644
--- a/bot/cogs/superstarify/__init__.py
+++ b/bot/cogs/moderation/superstarify.py
@@ -1,47 +1,45 @@
+import json
import logging
import random
-from datetime import datetime
+from pathlib import Path
from discord import Colour, Embed, Member
from discord.errors import Forbidden
-from discord.ext.commands import Bot, Context, command
+from discord.ext.commands import Bot, Cog, Context, command
-from bot.cogs.moderation import Moderation
-from bot.cogs.modlog import ModLog
-from bot.cogs.superstarify.stars import get_nick
-from bot.constants import Icons, POSITIVE_REPLIES, Roles
-from bot.converters import ExpirationDate
-from bot.decorators import with_role
-from bot.utils.moderation import post_infraction
+from bot import constants
+from bot.utils.checks import with_role_check
+from bot.utils.time import format_infraction
+from . import utils
+from .modlog import ModLog
log = logging.getLogger(__name__)
-NICKNAME_POLICY_URL = "https://pythondiscord.com/about/rules#nickname-policy"
+NICKNAME_POLICY_URL = "https://pythondiscord.com/pages/rules/#nickname-policy"
+with Path("bot/resources/stars.json").open(encoding="utf-8") as stars_file:
+ STAR_NAMES = json.load(stars_file)
-class Superstarify:
- """
- A set of commands to moderate terrible nicknames.
- """
+
+class Superstarify(Cog):
+ """A set of commands to moderate terrible nicknames."""
def __init__(self, bot: Bot):
self.bot = bot
@property
- def moderation(self) -> Moderation:
- return self.bot.get_cog("Moderation")
-
- @property
def modlog(self) -> ModLog:
+ """Get currently loaded ModLog cog instance."""
return self.bot.get_cog("ModLog")
- async def on_member_update(self, before: Member, after: Member):
+ @Cog.listener()
+ async def on_member_update(self, before: Member, after: Member) -> None:
"""
This event will trigger when someone changes their name.
- At this point we will look up the user in our database and check
- whether they are allowed to change their names, or if they are in
- superstar-prison. If they are not allowed, we will change it back.
- """
+ At this point we will look up the user in our database and check whether they are allowed to
+ change their names, or if they are in superstar-prison. If they are not allowed, we will
+ change it back.
+ """
if before.display_name == after.display_name:
return # User didn't change their nickname. Abort!
@@ -61,7 +59,7 @@ class Superstarify:
if active_superstarifies:
[infraction] = active_superstarifies
- forced_nick = get_nick(infraction['id'], before.id)
+ forced_nick = self.get_nick(infraction['id'], before.id)
if after.display_name == forced_nick:
return # Nick change was triggered by this event. Ignore.
@@ -70,10 +68,7 @@ class Superstarify:
f"Changing the nick back to {before.display_name}."
)
await after.edit(nick=forced_nick)
- end_timestamp_human = (
- datetime.fromisoformat(infraction['expires_at'][:-1])
- .strftime('%c')
- )
+ end_timestamp_human = format_infraction(infraction['expires_at'])
try:
await after.send(
@@ -91,30 +86,28 @@ class Superstarify:
"to DM them, and a discord.errors.Forbidden error was incurred."
)
- async def on_member_join(self, member: Member):
+ @Cog.listener()
+ async def on_member_join(self, member: Member) -> None:
"""
This event will trigger when someone (re)joins the server.
- At this point we will look up the user in our database and check
- whether they are in superstar-prison. If so, we will change their name
- back to the forced nickname.
- """
+ At this point we will look up the user in our database and check whether they are in
+ superstar-prison. If so, we will change their name back to the forced nickname.
+ """
active_superstarifies = await self.bot.api_client.get(
'bot/infractions',
params={
'active': 'true',
- 'type': 'superstarify',
+ 'type': 'superstar',
'user__id': member.id
}
)
if active_superstarifies:
[infraction] = active_superstarifies
- forced_nick = get_nick(infraction['id'], member.id)
+ forced_nick = self.get_nick(infraction['id'], member.id)
await member.edit(nick=forced_nick)
- end_timestamp_human = (
- datetime.fromisoformat(infraction['expires_at'][:-1]).strftime('%c')
- )
+ end_timestamp_human = format_infraction(infraction['expires_at'])
try:
await member.send(
@@ -142,7 +135,7 @@ class Superstarify:
f"Superstardom ends: **{end_timestamp_human}**"
)
await self.modlog.send_log_message(
- icon_url=Icons.user_update,
+ icon_url=constants.Icons.user_update,
colour=Colour.gold(),
title="Superstar member rejoined server",
text=mod_log_message,
@@ -150,43 +143,39 @@ class Superstarify:
)
@command(name='superstarify', aliases=('force_nick', 'star'))
- @with_role(Roles.admin, Roles.owner, Roles.moderator)
- async def superstarify(
- self, ctx: Context, member: Member, expiration: ExpirationDate, reason: str = None
- ):
+ async def superstarify(self, ctx: Context, member: Member, duration: utils.Expiry, reason: str = None) -> None:
"""
- This command will force a random superstar name (like Taylor Swift) to be the user's
- nickname for a specified duration. An optional reason can be provided.
- If no reason is given, the original name will be shown in a generated reason.
+ Force a random superstar name (like Taylor Swift) to be the user's nickname for a specified duration.
+
+ A unit of time should be appended to the duration.
+ Units (∗case-sensitive):
+ \u2003`y` - years
+ \u2003`m` - months∗
+ \u2003`w` - weeks
+ \u2003`d` - days
+ \u2003`h` - hours
+ \u2003`M` - minutes∗
+ \u2003`s` - seconds
+
+ Alternatively, an ISO 8601 timestamp can be provided for the duration.
+
+ An optional reason can be provided. If no reason is given, the original name will be shown
+ in a generated reason.
"""
+ if await utils.has_active_infraction(ctx, member, "superstar"):
+ return
- active_superstarifies = await self.bot.api_client.get(
- 'bot/infractions',
- params={
- 'active': 'true',
- 'type': 'superstar',
- 'user__id': str(member.id)
- }
- )
- if active_superstarifies:
- return await ctx.send(
- ":x: According to my records, this user is already superstarified. "
- f"See infraction **#{active_superstarifies[0]['id']}**."
- )
-
- infraction = await post_infraction(
- ctx, member,
- type='superstar', reason=reason or ('old nick: ' + member.display_name),
- expires_at=expiration
- )
- forced_nick = get_nick(infraction['id'], member.id)
+ reason = reason or ('old nick: ' + member.display_name)
+ infraction = await utils.post_infraction(ctx, member, 'superstar', reason, expires_at=duration)
+ forced_nick = self.get_nick(infraction['id'], member.id)
+ expiry_str = format_infraction(infraction["expires_at"])
embed = Embed()
embed.title = "Congratulations!"
embed.description = (
f"Your previous nickname, **{member.display_name}**, was so bad that we have decided to change it. "
f"Your new nickname will be **{forced_nick}**.\n\n"
- f"You will be unable to change your nickname until \n**{expiration}**.\n\n"
+ f"You will be unable to change your nickname until \n**{expiry_str}**.\n\n"
"If you're confused by this, please read our "
f"[official nickname policy]({NICKNAME_POLICY_URL})."
)
@@ -198,20 +187,20 @@ class Superstarify:
f"Superstarified by **{ctx.author.name}**\n"
f"Old nickname: `{member.display_name}`\n"
f"New nickname: `{forced_nick}`\n"
- f"Superstardom ends: **{expiration}**"
+ f"Superstardom ends: **{expiry_str}**"
)
await self.modlog.send_log_message(
- icon_url=Icons.user_update,
+ icon_url=constants.Icons.user_update,
colour=Colour.gold(),
title="Member Achieved Superstardom",
text=mod_log_message,
thumbnail=member.avatar_url_as(static_format="png")
)
- await self.moderation.notify_infraction(
+ await utils.notify_infraction(
user=member,
infr_type="Superstarify",
- expires_at=expiration,
+ expires_at=expiry_str,
reason=f"Your nickname didn't comply with our [nickname policy]({NICKNAME_POLICY_URL})."
)
@@ -221,16 +210,8 @@ class Superstarify:
await ctx.send(embed=embed)
@command(name='unsuperstarify', aliases=('release_nick', 'unstar'))
- @with_role(Roles.admin, Roles.owner, Roles.moderator)
- async def unsuperstarify(self, ctx: Context, member: Member):
- """
- This command will remove the entry from our database, allowing the user
- to once again change their nickname.
-
- :param ctx: Discord message context
- :param member: The member to unsuperstarify
- """
-
+ async def unsuperstarify(self, ctx: Context, member: Member) -> None:
+ """Remove the superstarify entry from our database, allowing the user to change their nickname."""
log.debug(f"Attempting to unsuperstarify the following user: {member.display_name}")
embed = Embed()
@@ -245,9 +226,8 @@ class Superstarify:
}
)
if not active_superstarifies:
- return await ctx.send(
- ":x: There is no active superstarify infraction for this user."
- )
+ await ctx.send(":x: There is no active superstarify infraction for this user.")
+ return
[infraction] = active_superstarifies
await self.bot.api_client.patch(
@@ -257,9 +237,9 @@ class Superstarify:
embed = Embed()
embed.description = "User has been released from superstar-prison."
- embed.title = random.choice(POSITIVE_REPLIES)
+ embed.title = random.choice(constants.POSITIVE_REPLIES)
- await self.moderation.notify_pardon(
+ await utils.notify_pardon(
user=member,
title="You are no longer superstarified.",
content="You may now change your nickname on the server."
@@ -267,7 +247,13 @@ class Superstarify:
log.trace(f"{member.display_name} was successfully released from superstar-prison.")
await ctx.send(embed=embed)
+ @staticmethod
+ def get_nick(infraction_id: int, member_id: int) -> str:
+ """Randomly select a nickname from the Superstarify nickname list."""
+ rng = random.Random(str(infraction_id) + str(member_id))
+ return rng.choice(STAR_NAMES)
-def setup(bot):
- bot.add_cog(Superstarify(bot))
- log.info("Cog loaded: Superstarify")
+ # 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, *constants.MODERATION_ROLES)
diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py
new file mode 100644
index 000000000..788a40d40
--- /dev/null
+++ b/bot/cogs/moderation/utils.py
@@ -0,0 +1,172 @@
+import logging
+import textwrap
+import typing as t
+from datetime import datetime
+
+import discord
+from discord.ext import commands
+from discord.ext.commands import Context
+
+from bot.api import ResponseCodeError
+from bot.constants import Colours, Icons
+from bot.converters import Duration, ISODateTime
+
+log = logging.getLogger(__name__)
+
+# apply icon, pardon icon
+INFRACTION_ICONS = {
+ "mute": (Icons.user_mute, Icons.user_unmute),
+ "kick": (Icons.sign_out, None),
+ "ban": (Icons.user_ban, Icons.user_unban),
+ "warning": (Icons.user_warn, None),
+ "note": (Icons.user_warn, None),
+}
+RULES_URL = "https://pythondiscord.com/pages/rules"
+APPEALABLE_INFRACTIONS = ("ban", "mute")
+
+UserTypes = t.Union[discord.Member, discord.User]
+MemberObject = t.Union[UserTypes, discord.Object]
+Infraction = t.Dict[str, t.Union[str, int, bool]]
+Expiry = t.Union[Duration, ISODateTime]
+
+
+def proxy_user(user_id: str) -> discord.Object:
+ """
+ Create a proxy user object from the given id.
+
+ Used when a Member or User object cannot be resolved.
+ """
+ try:
+ user_id = int(user_id)
+ except ValueError:
+ raise commands.BadArgument
+
+ user = discord.Object(user_id)
+ user.mention = user.id
+ user.avatar_url_as = lambda static_format: None
+
+ return user
+
+
+async def post_infraction(
+ ctx: Context,
+ user: MemberObject,
+ infr_type: str,
+ reason: str,
+ expires_at: datetime = None,
+ hidden: bool = False,
+ active: bool = True,
+) -> t.Optional[dict]:
+ """Posts an infraction to the API."""
+ payload = {
+ "actor": ctx.message.author.id,
+ "hidden": hidden,
+ "reason": reason,
+ "type": infr_type,
+ "user": user.id,
+ "active": active
+ }
+ if expires_at:
+ payload['expires_at'] = expires_at.isoformat()
+
+ try:
+ response = await ctx.bot.api_client.post('bot/infractions', json=payload)
+ except ResponseCodeError as exp:
+ if exp.status == 400 and 'user' in exp.response_json:
+ log.info(
+ f"{ctx.author} tried to add a {infr_type} infraction to `{user.id}`, "
+ "but that user id was not found in the database."
+ )
+ await ctx.send(
+ f":x: Cannot add infraction, the specified user is not known to the database."
+ )
+ return
+ else:
+ log.exception("An unexpected ResponseCodeError occurred while adding an infraction:")
+ await ctx.send(":x: There was an error adding the infraction.")
+ return
+
+ return response
+
+
+async def has_active_infraction(ctx: Context, user: MemberObject, infr_type: str) -> bool:
+ """Checks if a user already has an active infraction of the given type."""
+ active_infractions = await ctx.bot.api_client.get(
+ 'bot/infractions',
+ params={
+ 'active': 'true',
+ 'type': infr_type,
+ 'user__id': str(user.id)
+ }
+ )
+ if active_infractions:
+ await ctx.send(
+ f":x: According to my records, this user already has a {infr_type} infraction. "
+ f"See infraction **#{active_infractions[0]['id']}**."
+ )
+ return True
+ else:
+ return False
+
+
+async def notify_infraction(
+ user: UserTypes,
+ infr_type: str,
+ expires_at: t.Optional[str] = None,
+ reason: t.Optional[str] = None,
+ icon_url: str = Icons.token_removed
+) -> bool:
+ """DM a user about their new infraction and return True if the DM is successful."""
+ embed = discord.Embed(
+ description=textwrap.dedent(f"""
+ **Type:** {infr_type.capitalize()}
+ **Expires:** {expires_at or "N/A"}
+ **Reason:** {reason or "No reason provided."}
+ """),
+ colour=Colours.soft_red
+ )
+
+ embed.set_author(name="Infraction Information", icon_url=icon_url, url=RULES_URL)
+ embed.title = f"Please review our rules over at {RULES_URL}"
+ embed.url = RULES_URL
+
+ if infr_type in APPEALABLE_INFRACTIONS:
+ embed.set_footer(
+ text="To appeal this infraction, send an e-mail to [email protected]"
+ )
+
+ return await send_private_embed(user, embed)
+
+
+async def notify_pardon(
+ user: UserTypes,
+ title: str,
+ content: str,
+ icon_url: str = Icons.user_verified
+) -> bool:
+ """DM a user about their pardoned infraction and return True if the DM is successful."""
+ embed = discord.Embed(
+ description=content,
+ colour=Colours.soft_green
+ )
+
+ embed.set_author(name=title, icon_url=icon_url)
+
+ return await send_private_embed(user, embed)
+
+
+async def send_private_embed(user: UserTypes, embed: discord.Embed) -> bool:
+ """
+ A helper method for sending an embed to a user's DMs.
+
+ Returns a boolean indicator of DM success.
+ """
+ try:
+ await user.send(embed=embed)
+ return True
+ except (discord.HTTPException, discord.Forbidden, discord.NotFound):
+ log.debug(
+ f"Infraction-related information could not be sent to user {user} ({user.id}). "
+ "The user either could not be retrieved or probably disabled their DMs."
+ )
+ return False
diff --git a/bot/cogs/nominations.py b/bot/cogs/nominations.py
deleted file mode 100644
index 93ee0d885..000000000
--- a/bot/cogs/nominations.py
+++ /dev/null
@@ -1,120 +0,0 @@
-import logging
-
-from discord import Color, Embed, User
-from discord.ext.commands import Context, group
-
-from bot.cogs.bigbrother import BigBrother, Roles
-from bot.constants import Channels
-from bot.decorators import with_role
-from bot.pagination import LinePaginator
-
-
-log = logging.getLogger(__name__)
-
-
-class Nominations(BigBrother):
- """Monitor potential helpers, NSA-style."""
-
- async def on_ready(self):
- """Retrieve nominees from the API."""
-
- self.channel = self.bot.get_channel(Channels.talent_pool)
- if self.channel is None:
- log.error("Cannot find talent pool channel. Cannot watch nominees.")
- else:
- nominations = await self.bot.api_client.get(
- 'bot/nominations',
- params={'active': 'true'}
- )
- self.update_cache(nominations)
-
- async def on_member_ban(self, *_):
- pass
-
- @group(name='nominations', aliases=('n',), invoke_without_command=True)
- @with_role(Roles.owner, Roles.admin, Roles.moderator)
- async def bigbrother_group(self, ctx: Context):
- """Nominate helpers, NSA-style."""
-
- await ctx.invoke(self.bot.get_command("help"), "nominations")
-
- @bigbrother_group.command(name='nominated', aliases=('nominees', 'all'))
- @with_role(Roles.owner, Roles.admin, Roles.moderator)
- async def watched_command(self, ctx: Context, from_cache: bool = True):
- if from_cache:
- lines = tuple(f"• <@{user_id}>" for user_id in self.watched_users)
-
- else:
- active_nominations = await self.bot.api_client.get(
- 'bot/nominations',
- params={'active': 'true'}
- )
- self.update_cache(active_nominations)
- lines = tuple(
- f"• <@{entry['user']}>: {entry['reason'] or '*no reason provided*'}"
- for entry in active_nominations
- )
-
- await LinePaginator.paginate(
- lines or ("There's nothing here yet.",),
- ctx,
- Embed(
- title="Nominated users" + " (cached)" * from_cache,
- color=Color.blue()
- ),
- empty=False
- )
-
- @bigbrother_group.command(name='nominate', aliases=('n',))
- @with_role(Roles.owner, Roles.admin, Roles.moderator)
- async def watch_command(self, ctx: Context, user: User, *, reason: str):
- """Talent pool the given `user`."""
-
- active_nominations = await self.bot.api_client.get(
- 'bot/nominations/' + str(user.id),
- )
- if active_nominations:
- active_nominations = await self.bot.api_client.put(
- 'bot/nominations/' + str(user.id),
- json={'active': True}
- )
- await ctx.send(":ok_hand: user's watch was updated")
-
- else:
- active_nominations = await self.bot.api_client.post(
- 'bot/nominations/' + str(user.id),
- json={
- 'active': True,
- 'author': ctx.author.id,
- 'reason': reason,
- }
- )
- self.watched_users.add(user.id)
- await ctx.send(":ok_hand: user added to talent pool")
-
- @bigbrother_group.command(name='unnominate', aliases=('un',))
- @with_role(Roles.owner, Roles.admin, Roles.moderator)
- async def unwatch_command(self, ctx: Context, user: User):
- """Stop talent pooling the given `user`."""
-
- nomination = await self.bot.api_client.get(
- 'bot/nominations/' + str(user.id)
- )
-
- if not nomination['active']:
- await ctx.send(":x: the nomination is already inactive")
-
- else:
- await self.bot.api_client.put(
- 'bot/nominations/' + str(user.id),
- json={'active': False}
- )
- self.watched_users.remove(user.id)
- if user.id in self.channel_queues:
- del self.channel_queues[user.id]
- await ctx.send(f":ok_hand: {user} is no longer part of the talent pool")
-
-
-def setup(bot):
- bot.add_cog(Nominations(bot))
- log.info("Cog loaded: Nominations")
diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py
index b22926664..2977e4ebb 100644
--- a/bot/cogs/off_topic_names.py
+++ b/bot/cogs/off_topic_names.py
@@ -1,11 +1,13 @@
import asyncio
+import difflib
import logging
from datetime import datetime, timedelta
from discord import Colour, Embed
-from discord.ext.commands import BadArgument, Bot, Context, Converter, group
+from discord.ext.commands import BadArgument, Bot, Cog, Context, Converter, group
-from bot.constants import Channels, Keys, Roles
+from bot.api import ResponseCodeError
+from bot.constants import Channels, MODERATION_ROLES
from bot.decorators import with_role
from bot.pagination import LinePaginator
@@ -18,8 +20,9 @@ class OffTopicName(Converter):
"""A converter that ensures an added off-topic name is valid."""
@staticmethod
- async def convert(ctx: Context, argument: str):
- allowed_characters = ("-", "’", "'", "`")
+ async def convert(ctx: Context, argument: str) -> str:
+ """Attempt to replace any invalid characters with their approximate Unicode equivalent."""
+ allowed_characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ!?'`-"
if not (2 <= len(argument) <= 96):
raise BadArgument("Channel name must be between 2 and 96 chars long")
@@ -30,32 +33,30 @@ class OffTopicName(Converter):
"alphanumeric characters, minus signs or apostrophes."
)
- elif not argument.islower():
- raise BadArgument("Channel name must be lowercase")
-
- # Replace some unusable apostrophe-like characters with "’".
- return argument.replace("'", "’").replace("`", "’")
-
-
-async def update_names(bot: Bot, headers: dict):
- """
- The background updater task that performs a channel name update daily.
+ # Replace invalid characters with unicode alternatives.
+ table = str.maketrans(
+ allowed_characters, '𝖠𝖡𝖢𝖣𝖤𝖥𝖦𝖧𝖨𝖩𝖪𝖫𝖬𝖭𝖮𝖯𝖰𝖱𝖲𝖳𝖴𝖵𝖶𝖷𝖸𝖹ǃ?’’-'
+ )
+ return argument.translate(table)
- Args:
- bot (Bot):
- The running bot instance, used for fetching data from the
- website via the bot's `api_client`.
- """
+async def update_names(bot: Bot) -> None:
+ """Background updater task that performs the daily channel name update."""
while True:
+ # Since we truncate the compute timedelta to seconds, we add one second to ensure
+ # we go past midnight in the `seconds_to_sleep` set below.
today_at_midnight = datetime.utcnow().replace(microsecond=0, second=0, minute=0, hour=0)
next_midnight = today_at_midnight + timedelta(days=1)
- seconds_to_sleep = (next_midnight - datetime.utcnow()).seconds
+ seconds_to_sleep = (next_midnight - datetime.utcnow()).seconds + 1
await asyncio.sleep(seconds_to_sleep)
- channel_0_name, channel_1_name, channel_2_name = await bot.api_client.get(
- 'bot/off-topic-channel-names', params={'random_items': 3}
- )
+ try:
+ channel_0_name, channel_1_name, channel_2_name = await bot.api_client.get(
+ 'bot/off-topic-channel-names', params={'random_items': 3}
+ )
+ except ResponseCodeError as e:
+ log.error(f"Failed to get new off topic channel names: code {e.response.status}")
+ continue
channel_0, channel_1, channel_2 = (bot.get_channel(channel_id) for channel_id in CHANNELS)
await channel_0.edit(name=f'ot0-{channel_0_name}')
@@ -67,62 +68,69 @@ async def update_names(bot: Bot, headers: dict):
)
-class OffTopicNames:
+class OffTopicNames(Cog):
"""Commands related to managing the off-topic category channel names."""
def __init__(self, bot: Bot):
self.bot = bot
- self.headers = {"X-API-KEY": Keys.site_api}
self.updater_task = None
- def __cleanup(self):
+ self.bot.loop.create_task(self.init_offtopic_updater())
+
+ def cog_unload(self) -> None:
+ """Cancel any running updater tasks on cog unload."""
if self.updater_task is not None:
self.updater_task.cancel()
- async def on_ready(self):
+ async def init_offtopic_updater(self) -> None:
+ """Start off-topic channel updating event loop if it hasn't already started."""
+ await self.bot.wait_until_ready()
if self.updater_task is None:
- coro = update_names(self.bot, self.headers)
- self.updater_task = await self.bot.loop.create_task(coro)
+ coro = update_names(self.bot)
+ self.updater_task = self.bot.loop.create_task(coro)
@group(name='otname', aliases=('otnames', 'otn'), invoke_without_command=True)
- @with_role(Roles.owner, Roles.admin, Roles.moderator)
- async def otname_group(self, ctx):
+ @with_role(*MODERATION_ROLES)
+ async def otname_group(self, ctx: Context) -> None:
"""Add or list items from the off-topic channel name rotation."""
-
await ctx.invoke(self.bot.get_command("help"), "otname")
@otname_group.command(name='add', aliases=('a',))
- @with_role(Roles.owner, Roles.admin, Roles.moderator)
- async def add_command(self, ctx, name: OffTopicName):
+ @with_role(*MODERATION_ROLES)
+ async def add_command(self, ctx: Context, *names: OffTopicName) -> None:
"""Adds a new off-topic name to the rotation."""
+ # Chain multiple words to a single one
+ name = "-".join(names)
await self.bot.api_client.post(f'bot/off-topic-channel-names', params={'name': name})
log.info(
f"{ctx.author.name}#{ctx.author.discriminator}"
f" added the off-topic channel name '{name}"
)
- await ctx.send(":ok_hand:")
+ await ctx.send(f":ok_hand: Added `{name}` to the names list.")
@otname_group.command(name='delete', aliases=('remove', 'rm', 'del', 'd'))
- @with_role(Roles.owner, Roles.admin, Roles.moderator)
- async def delete_command(self, ctx, name: OffTopicName):
+ @with_role(*MODERATION_ROLES)
+ async def delete_command(self, ctx: Context, *names: OffTopicName) -> None:
"""Removes a off-topic name from the rotation."""
+ # Chain multiple words to a single one
+ name = "-".join(names)
await self.bot.api_client.delete(f'bot/off-topic-channel-names/{name}')
log.info(
f"{ctx.author.name}#{ctx.author.discriminator}"
f" deleted the off-topic channel name '{name}"
)
- await ctx.send(":ok_hand:")
+ await ctx.send(f":ok_hand: Removed `{name}` from the names list.")
@otname_group.command(name='list', aliases=('l',))
- @with_role(Roles.owner, Roles.admin, Roles.moderator)
- async def list_command(self, ctx):
+ @with_role(*MODERATION_ROLES)
+ async def list_command(self, ctx: Context) -> None:
"""
Lists all currently known off-topic channel names in a paginator.
+
Restricted to Moderator and above to not spoil the surprise.
"""
-
result = await self.bot.api_client.get('bot/off-topic-channel-names')
lines = sorted(f"• {name}" for name in result)
embed = Embed(
@@ -135,7 +143,27 @@ class OffTopicNames:
embed.description = "Hmmm, seems like there's nothing here yet."
await ctx.send(embed=embed)
+ @otname_group.command(name='search', aliases=('s',))
+ @with_role(*MODERATION_ROLES)
+ async def search_command(self, ctx: Context, *, query: OffTopicName) -> None:
+ """Search for an off-topic name."""
+ result = await self.bot.api_client.get('bot/off-topic-channel-names')
+ in_matches = {name for name in result if query in name}
+ close_matches = difflib.get_close_matches(query, result, n=10, cutoff=0.70)
+ lines = sorted(f"• {name}" for name in in_matches.union(close_matches))
+ embed = Embed(
+ title=f"Query results",
+ colour=Colour.blue()
+ )
+
+ if lines:
+ await LinePaginator.paginate(lines, ctx, embed, max_size=400, empty=False)
+ else:
+ embed.description = "Nothing found."
+ await ctx.send(embed=embed)
+
-def setup(bot: Bot):
+def setup(bot: Bot) -> None:
+ """Off topic names cog load."""
bot.add_cog(OffTopicNames(bot))
log.info("Cog loaded: OffTopicNames")
diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py
index 952fa4682..0f575cece 100644
--- a/bot/cogs/reddit.py
+++ b/bot/cogs/reddit.py
@@ -3,11 +3,12 @@ import logging
import random
import textwrap
from datetime import datetime, timedelta
+from typing import List
-from discord import Colour, Embed, TextChannel
-from discord.ext.commands import Bot, Context, group
+from discord import Colour, Embed, Message, TextChannel
+from discord.ext.commands import Bot, Cog, Context, group
-from bot.constants import Channels, ERROR_REPLIES, Reddit as RedditConfig, Roles
+from bot.constants import Channels, ERROR_REPLIES, Reddit as RedditConfig, STAFF_ROLES
from bot.converters import Subreddit
from bot.decorators import with_role
from bot.pagination import LinePaginator
@@ -15,13 +16,12 @@ from bot.pagination import LinePaginator
log = logging.getLogger(__name__)
-class Reddit:
- """
- Track subreddit posts and show detailed statistics about them.
- """
+class Reddit(Cog):
+ """Track subreddit posts and show detailed statistics about them."""
HEADERS = {"User-Agent": "Discord Bot: PythonDiscord (https://pythondiscord.com/)"}
URL = "https://www.reddit.com"
+ MAX_FETCH_RETRIES = 3
def __init__(self, bot: Bot):
self.bot = bot
@@ -31,11 +31,13 @@ class Reddit:
self.prev_lengths = {}
self.last_ids = {}
- async def fetch_posts(self, route: str, *, amount: int = 25, params=None):
- """
- A helper method to fetch a certain amount of Reddit posts at a given route.
- """
+ self.new_posts_task = None
+ self.top_weekly_posts_task = None
+ self.bot.loop.create_task(self.init_reddit_polling())
+
+ async def fetch_posts(self, route: str, *, amount: int = 25, params: dict = None) -> List[dict]:
+ """A helper method to fetch a certain amount of Reddit posts at a given route."""
# Reddit's JSON responses only provide 25 posts at most.
if not 25 >= amount > 0:
raise ValueError("Invalid amount of subreddit posts requested.")
@@ -43,34 +45,41 @@ class Reddit:
if params is None:
params = {}
- response = await self.bot.http_session.get(
- url=f"{self.URL}/{route}.json",
- headers=self.HEADERS,
- params=params
- )
-
- content = await response.json()
- posts = content["data"]["children"]
+ url = f"{self.URL}/{route}.json"
+ for _ in range(self.MAX_FETCH_RETRIES):
+ response = await self.bot.http_session.get(
+ url=url,
+ headers=self.HEADERS,
+ params=params
+ )
+ if response.status == 200 and response.content_type == 'application/json':
+ # Got appropriate response - process and return.
+ content = await response.json()
+ posts = content["data"]["children"]
+ return posts[:amount]
- return posts[:amount]
+ await asyncio.sleep(3)
- async def send_top_posts(self, channel: TextChannel, subreddit: Subreddit, content=None, time="all"):
- """
- Create an embed for the top posts, then send it in a given TextChannel.
- """
+ log.debug(f"Invalid response from: {url} - status code {response.status}, mimetype {response.content_type}")
+ return list() # Failed to get appropriate response within allowed number of retries.
+ async def send_top_posts(
+ self, channel: TextChannel, subreddit: Subreddit, content: str = None, time: str = "all"
+ ) -> Message:
+ """Create an embed for the top posts, then send it in a given TextChannel."""
# Create the new spicy embed.
embed = Embed()
embed.description = ""
# Get the posts
- posts = await self.fetch_posts(
- route=f"{subreddit}/top",
- amount=5,
- params={
- "t": time
- }
- )
+ async with channel.typing():
+ posts = await self.fetch_posts(
+ route=f"{subreddit}/top",
+ amount=5,
+ params={
+ "t": time
+ }
+ )
if not posts:
embed.title = random.choice(ERROR_REPLIES)
@@ -112,11 +121,8 @@ class Reddit:
embed=embed
)
- async def poll_new_posts(self):
- """
- Periodically search for new subreddit posts.
- """
-
+ async def poll_new_posts(self) -> None:
+ """Periodically search for new subreddit posts."""
while True:
await asyncio.sleep(RedditConfig.request_delay)
@@ -176,11 +182,8 @@ class Reddit:
log.trace(f"Sent {len(new_posts)} new {subreddit} posts to channel {self.reddit_channel.id}.")
- async def poll_top_weekly_posts(self):
- """
- Post a summary of the top posts every week.
- """
-
+ async def poll_top_weekly_posts(self) -> None:
+ """Post a summary of the top posts every week."""
while True:
now = datetime.utcnow()
@@ -211,19 +214,13 @@ class Reddit:
await message.pin()
@group(name="reddit", invoke_without_command=True)
- async def reddit_group(self, ctx: Context):
- """
- View the top posts from various subreddits.
- """
-
+ async def reddit_group(self, ctx: Context) -> None:
+ """View the top posts from various subreddits."""
await ctx.invoke(self.bot.get_command("help"), "reddit")
@reddit_group.command(name="top")
- async def top_command(self, ctx: Context, subreddit: Subreddit = "r/Python"):
- """
- Send the top posts of all time from a given subreddit.
- """
-
+ async def top_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None:
+ """Send the top posts of all time from a given subreddit."""
await self.send_top_posts(
channel=ctx.channel,
subreddit=subreddit,
@@ -232,11 +229,8 @@ class Reddit:
)
@reddit_group.command(name="daily")
- async def daily_command(self, ctx: Context, subreddit: Subreddit = "r/Python"):
- """
- Send the top posts of today from a given subreddit.
- """
-
+ async def daily_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None:
+ """Send the top posts of today from a given subreddit."""
await self.send_top_posts(
channel=ctx.channel,
subreddit=subreddit,
@@ -245,11 +239,8 @@ class Reddit:
)
@reddit_group.command(name="weekly")
- async def weekly_command(self, ctx: Context, subreddit: Subreddit = "r/Python"):
- """
- Send the top posts of this week from a given subreddit.
- """
-
+ async def weekly_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None:
+ """Send the top posts of this week from a given subreddit."""
await self.send_top_posts(
channel=ctx.channel,
subreddit=subreddit,
@@ -257,13 +248,10 @@ class Reddit:
time="week"
)
- @with_role(Roles.owner, Roles.admin, Roles.moderator, Roles.helpers)
+ @with_role(*STAFF_ROLES)
@reddit_group.command(name="subreddits", aliases=("subs",))
- async def subreddits_command(self, ctx: Context):
- """
- Send a paginated embed of all the subreddits we're relaying.
- """
-
+ async def subreddits_command(self, ctx: Context) -> None:
+ """Send a paginated embed of all the subreddits we're relaying."""
embed = Embed()
embed.title = "Relayed subreddits."
embed.colour = Colour.blurple()
@@ -276,16 +264,21 @@ class Reddit:
max_lines=15
)
- async def on_ready(self):
- self.reddit_channel = self.bot.get_channel(Channels.reddit)
+ async def init_reddit_polling(self) -> None:
+ """Initiate reddit post event loop."""
+ await self.bot.wait_until_ready()
+ self.reddit_channel = await self.bot.fetch_channel(Channels.reddit)
if self.reddit_channel is not None:
- self.bot.loop.create_task(self.poll_new_posts())
- self.bot.loop.create_task(self.poll_top_weekly_posts())
+ if self.new_posts_task is None:
+ self.new_posts_task = self.bot.loop.create_task(self.poll_new_posts())
+ if self.top_weekly_posts_task is None:
+ self.top_weekly_posts_task = self.bot.loop.create_task(self.poll_top_weekly_posts())
else:
log.warning("Couldn't locate a channel for subreddit relaying.")
-def setup(bot):
+def setup(bot: Bot) -> None:
+ """Reddit cog load."""
bot.add_cog(Reddit(bot))
log.info("Cog loaded: Reddit")
diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py
index fa1be307c..b54622306 100644
--- a/bot/cogs/reminders.py
+++ b/bot/cogs/reminders.py
@@ -4,34 +4,37 @@ import random
import textwrap
from datetime import datetime
from operator import itemgetter
+from typing import Optional
from dateutil.relativedelta import relativedelta
-from discord import Colour, Embed
-from discord.ext.commands import Bot, Context, group
+from discord import Colour, Embed, Message
+from discord.ext.commands import Bot, Cog, Context, group
-from bot.constants import (
- Channels, Icons, NEGATIVE_REPLIES, POSITIVE_REPLIES, Roles
-)
-from bot.converters import ExpirationDate
+from bot.constants import Channels, Icons, NEGATIVE_REPLIES, POSITIVE_REPLIES, STAFF_ROLES
+from bot.converters import Duration
from bot.pagination import LinePaginator
+from bot.utils.checks import without_role_check
from bot.utils.scheduling import Scheduler
from bot.utils.time import humanize_delta, wait_until
log = logging.getLogger(__name__)
-STAFF_ROLES = (Roles.owner, Roles.admin, Roles.moderator, Roles.helpers)
WHITELISTED_CHANNELS = (Channels.bot,)
MAXIMUM_REMINDERS = 5
-class Reminders(Scheduler):
+class Reminders(Scheduler, Cog):
+ """Provide in-channel reminder functionality."""
def __init__(self, bot: Bot):
self.bot = bot
super().__init__()
- async def on_ready(self):
- # Get all the current reminders for re-scheduling
+ self.bot.loop.create_task(self.reschedule_reminders())
+
+ async def reschedule_reminders(self) -> None:
+ """Get all current reminders from the API and reschedule them."""
+ await self.bot.wait_until_ready()
response = await self.bot.api_client.get(
'bot/reminders',
params={'active': 'true'}
@@ -52,25 +55,16 @@ class Reminders(Scheduler):
self.schedule_task(loop, reminder["id"], reminder)
@staticmethod
- async def _send_confirmation(ctx: Context, on_success: str):
- """
- Send an embed confirming the change was made successfully.
- """
-
+ async def _send_confirmation(ctx: Context, on_success: str) -> None:
+ """Send an embed confirming the reminder change was made successfully."""
embed = Embed()
embed.colour = Colour.green()
embed.title = random.choice(POSITIVE_REPLIES)
embed.description = on_success
await ctx.send(embed=embed)
- async def _scheduled_task(self, reminder: dict):
- """
- A coroutine which sends the reminder once the time is reached.
-
- :param reminder: the data of the reminder.
- :return:
- """
-
+ async def _scheduled_task(self, reminder: dict) -> None:
+ """A coroutine which sends the reminder once the time is reached, and cancels the running task."""
reminder_id = reminder["id"]
reminder_datetime = datetime.fromisoformat(reminder['expiration'][:-1])
@@ -84,38 +78,22 @@ class Reminders(Scheduler):
# Now we can begone with it from our schedule list.
self.cancel_task(reminder_id)
- async def _delete_reminder(self, reminder_id: str):
- """
- Delete a reminder from the database, given its ID.
-
- :param reminder_id: The ID of the reminder.
- """
-
+ async def _delete_reminder(self, reminder_id: str) -> None:
+ """Delete a reminder from the database, given its ID, and cancel the running task."""
await self.bot.api_client.delete('bot/reminders/' + str(reminder_id))
# Now we can remove it from the schedule list
self.cancel_task(reminder_id)
- async def _reschedule_reminder(self, reminder):
- """
- Reschedule a reminder object.
-
- :param reminder: The reminder to be rescheduled.
- """
-
+ async def _reschedule_reminder(self, reminder: dict) -> None:
+ """Reschedule a reminder object."""
loop = asyncio.get_event_loop()
self.cancel_task(reminder["id"])
self.schedule_task(loop, reminder["id"], reminder)
- async def send_reminder(self, reminder, late: relativedelta = None):
- """
- Send the reminder.
-
- :param reminder: The data about the reminder.
- :param late: How late the reminder is (if at all)
- """
-
+ async def send_reminder(self, reminder: dict, late: relativedelta = None) -> None:
+ """Send the reminder."""
channel = self.bot.get_channel(reminder["channel_id"])
user = self.bot.get_user(reminder["author"])
@@ -142,23 +120,21 @@ class Reminders(Scheduler):
await self._delete_reminder(reminder["id"])
@group(name="remind", aliases=("reminder", "reminders"), invoke_without_command=True)
- async def remind_group(self, ctx: Context, expiration: ExpirationDate, *, content: str):
- """
- Commands for managing your reminders.
- """
-
+ async def remind_group(self, ctx: Context, expiration: Duration, *, content: str) -> None:
+ """Commands for managing your reminders."""
await ctx.invoke(self.new_reminder, expiration=expiration, content=content)
@remind_group.command(name="new", aliases=("add", "create"))
- async def new_reminder(self, ctx: Context, expiration: ExpirationDate, *, content: str):
+ async def new_reminder(self, ctx: Context, expiration: Duration, *, content: str) -> Optional[Message]:
"""
Set yourself a simple reminder.
- """
+ Expiration is parsed per: http://strftime.org/
+ """
embed = Embed()
- # Make sure the reminder should actually be made.
- if ctx.author.top_role.id not in STAFF_ROLES:
+ # If the user is not staff, we need to verify whether or not to make a reminder at all.
+ if without_role_check(ctx, *STAFF_ROLES):
# If they don't have permission to set a reminder in this channel
if ctx.channel.id not in WHITELISTED_CHANNELS:
@@ -172,7 +148,7 @@ class Reminders(Scheduler):
active_reminders = await self.bot.api_client.get(
'bot/reminders',
params={
- 'user__id': str(ctx.author.id)
+ 'author__id': str(ctx.author.id)
}
)
@@ -205,15 +181,12 @@ class Reminders(Scheduler):
self.schedule_task(loop, reminder["id"], reminder)
@remind_group.command(name="list")
- async def list_reminders(self, ctx: Context):
- """
- View a paginated embed of all reminders for your user.
- """
-
+ async def list_reminders(self, ctx: Context) -> Optional[Message]:
+ """View a paginated embed of all reminders for your user."""
# Get all the user's reminders from the database.
data = await self.bot.api_client.get(
'bot/reminders',
- params={'user__id': str(ctx.author.id)}
+ params={'author__id': str(ctx.author.id)}
)
now = datetime.utcnow()
@@ -261,19 +234,17 @@ class Reminders(Scheduler):
)
@remind_group.group(name="edit", aliases=("change", "modify"), invoke_without_command=True)
- async def edit_reminder_group(self, ctx: Context):
- """
- Commands for modifying your current reminders.
- """
-
+ async def edit_reminder_group(self, ctx: Context) -> None:
+ """Commands for modifying your current reminders."""
await ctx.invoke(self.bot.get_command("help"), "reminders", "edit")
@edit_reminder_group.command(name="duration", aliases=("time",))
- async def edit_reminder_duration(self, ctx: Context, id_: int, expiration: ExpirationDate):
- """
- Edit one of your reminders' expiration.
+ async def edit_reminder_duration(self, ctx: Context, id_: int, expiration: Duration) -> None:
"""
+ Edit one of your reminder's expiration.
+ Expiration is parsed per: http://strftime.org/
+ """
# Send the request to update the reminder in the database
reminder = await self.bot.api_client.patch(
'bot/reminders/' + str(id_),
@@ -288,11 +259,8 @@ class Reminders(Scheduler):
await self._reschedule_reminder(reminder)
@edit_reminder_group.command(name="content", aliases=("reason",))
- async def edit_reminder_content(self, ctx: Context, id_: int, *, content: str):
- """
- Edit one of your reminders' content.
- """
-
+ async def edit_reminder_content(self, ctx: Context, id_: int, *, content: str) -> None:
+ """Edit one of your reminder's content."""
# Send the request to update the reminder in the database
reminder = await self.bot.api_client.patch(
'bot/reminders/' + str(id_),
@@ -306,17 +274,15 @@ class Reminders(Scheduler):
await self._reschedule_reminder(reminder)
@remind_group.command("delete", aliases=("remove",))
- async def delete_reminder(self, ctx: Context, id_: int):
- """
- Delete one of your active reminders.
- """
-
+ async def delete_reminder(self, ctx: Context, id_: int) -> None:
+ """Delete one of your active reminders."""
await self._delete_reminder(id_)
await self._send_confirmation(
ctx, on_success="That reminder has been deleted successfully!"
)
-def setup(bot: Bot):
+def setup(bot: Bot) -> None:
+ """Reminders cog load."""
bot.add_cog(Reminders(bot))
log.info("Cog loaded: Reminders")
diff --git a/bot/cogs/security.py b/bot/cogs/security.py
index f4a843fbf..316b33d6b 100644
--- a/bot/cogs/security.py
+++ b/bot/cogs/security.py
@@ -1,27 +1,30 @@
import logging
-from discord.ext.commands import Bot, Context
+from discord.ext.commands import Bot, Cog, Context, NoPrivateMessage
log = logging.getLogger(__name__)
-class Security:
- """
- Security-related helpers
- """
+class Security(Cog):
+ """Security-related helpers."""
def __init__(self, bot: Bot):
self.bot = bot
self.bot.check(self.check_not_bot) # Global commands check - no bots can run any commands at all
self.bot.check(self.check_on_guild) # Global commands check - commands can't be run in a DM
- def check_not_bot(self, ctx: Context):
+ def check_not_bot(self, ctx: Context) -> bool:
+ """Check if the context is a bot user."""
return not ctx.author.bot
- def check_on_guild(self, ctx: Context):
- return ctx.guild is not None
+ def check_on_guild(self, ctx: Context) -> bool:
+ """Check if the context is in a guild."""
+ if ctx.guild is None:
+ raise NoPrivateMessage("This command cannot be used in private messages.")
+ return True
-def setup(bot):
+def setup(bot: Bot) -> None:
+ """Security cog load."""
bot.add_cog(Security(bot))
log.info("Cog loaded: Security")
diff --git a/bot/cogs/site.py b/bot/cogs/site.py
index dee58ea76..c3bdf85e4 100644
--- a/bot/cogs/site.py
+++ b/bot/cogs/site.py
@@ -1,32 +1,31 @@
-import gettext
import logging
from discord import Colour, Embed
-from discord.ext.commands import Bot, Context, group
+from discord.ext.commands import Bot, Cog, Context, group
-from bot.constants import URLs
+from bot.constants import Channels, STAFF_ROLES, URLs
+from bot.decorators import redirect_output
+from bot.pagination import LinePaginator
log = logging.getLogger(__name__)
-INFO_URL = f"{URLs.site_schema}{URLs.site}/info"
+PAGES_URL = f"{URLs.site_schema}{URLs.site}/pages"
-class Site:
+class Site(Cog):
"""Commands for linking to different parts of the site."""
def __init__(self, bot: Bot):
self.bot = bot
@group(name="site", aliases=("s",), invoke_without_command=True)
- async def site_group(self, ctx):
+ async def site_group(self, ctx: Context) -> None:
"""Commands for getting info about our website."""
-
await ctx.invoke(self.bot.get_command("help"), "site")
@site_group.command(name="home", aliases=("about",))
- async def site_main(self, ctx: Context):
+ async def site_main(self, ctx: Context) -> None:
"""Info about the website itself."""
-
url = f"{URLs.site_schema}{URLs.site}/"
embed = Embed(title="Python Discord website")
@@ -42,29 +41,42 @@ class Site:
await ctx.send(embed=embed)
@site_group.command(name="resources")
- async def site_resources(self, ctx: Context):
+ async def site_resources(self, ctx: Context) -> None:
"""Info about the site's Resources page."""
-
- url = f"{INFO_URL}/resources"
+ learning_url = f"{PAGES_URL}/resources"
embed = Embed(title="Resources")
- embed.set_footer(text=url)
+ embed.set_footer(text=f"{learning_url}")
+ embed.colour = Colour.blurple()
+ embed.description = (
+ f"The [Resources page]({learning_url}) on our website contains a "
+ "list of hand-selected learning resources that we regularly recommend "
+ f"to both beginners and experts."
+ )
+
+ await ctx.send(embed=embed)
+
+ @site_group.command(name="tools")
+ async def site_tools(self, ctx: Context) -> None:
+ """Info about the site's Tools page."""
+ tools_url = f"{PAGES_URL}/tools"
+
+ embed = Embed(title="Tools")
+ embed.set_footer(text=f"{tools_url}")
embed.colour = Colour.blurple()
embed.description = (
- f"The [Resources page]({url}) on our website contains a "
- "list of hand-selected goodies that we regularly recommend "
- "to both beginners and experts."
+ f"The [Tools page]({tools_url}) on our website contains a "
+ f"couple of the most popular tools for programming in Python."
)
await ctx.send(embed=embed)
@site_group.command(name="help")
- async def site_help(self, ctx: Context):
+ async def site_help(self, ctx: Context) -> None:
"""Info about the site's Getting Help page."""
+ url = f"{PAGES_URL}/asking-good-questions"
- url = f"{INFO_URL}/help"
-
- embed = Embed(title="Getting Help")
+ embed = Embed(title="Asking Good Questions")
embed.set_footer(text=url)
embed.colour = Colour.blurple()
embed.description = (
@@ -76,10 +88,9 @@ class Site:
await ctx.send(embed=embed)
@site_group.command(name="faq")
- async def site_faq(self, ctx: Context):
+ async def site_faq(self, ctx: Context) -> None:
"""Info about the site's FAQ page."""
-
- url = f"{INFO_URL}/faq"
+ url = f"{PAGES_URL}/frequently-asked-questions"
embed = Embed(title="FAQ")
embed.set_footer(text=url)
@@ -93,48 +104,42 @@ class Site:
await ctx.send(embed=embed)
- @site_group.command(name="rules")
- async def site_rules(self, ctx: Context, *selection: int):
- """Info about the server's rules."""
-
- url = f"{URLs.site_schema}{URLs.site}/about/rules"
- full_rules = await self.bot.api_client.get(
- 'rules', params={'link_format': 'md'}
- )
- if selection:
- invalid_indices = tuple(
- pick
- for pick in selection
- if pick < 0 or pick >= len(full_rules)
+ @site_group.command(aliases=['r', 'rule'], name='rules')
+ @redirect_output(destination_channel=Channels.bot, bypass_roles=STAFF_ROLES)
+ async def site_rules(self, ctx: Context, *rules: int) -> None:
+ """Provides a link to all rules or, if specified, displays specific rule(s)."""
+ rules_embed = Embed(title='Rules', color=Colour.blurple())
+ rules_embed.url = f"{PAGES_URL}/rules"
+
+ if not rules:
+ # Rules were not submitted. Return the default description.
+ rules_embed.description = (
+ "The rules and guidelines that apply to this community can be found on"
+ f" our [rules page]({PAGES_URL}/rules). We expect"
+ " all members of the community to have read and understood these."
)
- if invalid_indices:
- return await ctx.send(
- embed=Embed(
- title='Invalid rule indices',
- description=', '.join(map(str, invalid_indices)),
- colour=Colour.red()
- )
- )
- title = (
- gettext.ngettext("Rule", 'Rules', len(selection))
- + " " + ", ".join(map(str, selection))
- )
- else:
- title = "Full rules"
- selection = range(len(full_rules))
+ await ctx.send(embed=rules_embed)
+ return
- embed = Embed(title=title)
- embed.set_footer(text=url)
- embed.colour = Colour.blurple()
- embed.description = '\n'.join(
- f"**{pick}**: {full_rules[pick]}"
- for pick in selection
+ full_rules = await self.bot.api_client.get('rules', params={'link_format': 'md'})
+ invalid_indices = tuple(
+ pick
+ for pick in rules
+ if pick < 0 or pick >= len(full_rules)
)
- await ctx.send(embed=embed)
+ if invalid_indices:
+ indices = ', '.join(map(str, invalid_indices))
+ await ctx.send(f":x: Invalid rule indices {indices}")
+ return
+
+ final_rules = tuple(f"**{pick}.** {full_rules[pick]}" for pick in rules)
+
+ await LinePaginator.paginate(final_rules, ctx, rules_embed, max_lines=3)
-def setup(bot):
+def setup(bot: Bot) -> None:
+ """Site cog load."""
bot.add_cog(Site(bot))
log.info("Cog loaded: Site")
diff --git a/bot/cogs/snakes.py b/bot/cogs/snakes.py
deleted file mode 100644
index 8dee13dca..000000000
--- a/bot/cogs/snakes.py
+++ /dev/null
@@ -1,1216 +0,0 @@
-import asyncio
-import colorsys
-import logging
-import os
-import random
-import re
-import string
-import textwrap
-import urllib
-from functools import partial
-from io import BytesIO
-from typing import Any, Dict
-
-import aiohttp
-import async_timeout
-from PIL import Image, ImageDraw, ImageFont
-from discord import Colour, Embed, File, Member, Message, Reaction
-from discord.ext.commands import BadArgument, Bot, Context, bot_has_permissions, group
-
-from bot.constants import ERROR_REPLIES, Keys, URLs
-from bot.converters import Snake
-from bot.decorators import locked
-from bot.utils.snakes import hatching, perlin, perlinsneks, sal
-
-
-log = logging.getLogger(__name__)
-
-
-# region: Constants
-# Color
-SNAKE_COLOR = 0x399600
-
-# Antidote constants
-SYRINGE_EMOJI = "\U0001F489" # :syringe:
-PILL_EMOJI = "\U0001F48A" # :pill:
-HOURGLASS_EMOJI = "\u231B" # :hourglass:
-CROSSBONES_EMOJI = "\u2620" # :skull_crossbones:
-ALEMBIC_EMOJI = "\u2697" # :alembic:
-TICK_EMOJI = "\u2705" # :white_check_mark: - Correct peg, correct hole
-CROSS_EMOJI = "\u274C" # :x: - Wrong peg, wrong hole
-BLANK_EMOJI = "\u26AA" # :white_circle: - Correct peg, wrong hole
-HOLE_EMOJI = "\u2B1C" # :white_square: - Used in guesses
-EMPTY_UNICODE = "\u200b" # literally just an empty space
-
-ANTIDOTE_EMOJI = (
- SYRINGE_EMOJI,
- PILL_EMOJI,
- HOURGLASS_EMOJI,
- CROSSBONES_EMOJI,
- ALEMBIC_EMOJI,
-)
-
-# Quiz constants
-ANSWERS_EMOJI = {
- "a": "\U0001F1E6", # :regional_indicator_a: 🇦
- "b": "\U0001F1E7", # :regional_indicator_b: 🇧
- "c": "\U0001F1E8", # :regional_indicator_c: 🇨
- "d": "\U0001F1E9", # :regional_indicator_d: 🇩
-}
-
-ANSWERS_EMOJI_REVERSE = {
- "\U0001F1E6": "A", # :regional_indicator_a: 🇦
- "\U0001F1E7": "B", # :regional_indicator_b: 🇧
- "\U0001F1E8": "C", # :regional_indicator_c: 🇨
- "\U0001F1E9": "D", # :regional_indicator_d: 🇩
-}
-
-# Zzzen of pythhhon constant
-ZEN = """
-Beautiful is better than ugly.
-Explicit is better than implicit.
-Simple is better than complex.
-Complex is better than complicated.
-Flat is better than nested.
-Sparse is better than dense.
-Readability counts.
-Special cases aren't special enough to break the rules.
-Although practicality beats purity.
-Errors should never pass silently.
-Unless explicitly silenced.
-In the face of ambiguity, refuse the temptation to guess.
-There should be one-- and preferably only one --obvious way to do it.
-Now is better than never.
-Although never is often better than *right* now.
-If the implementation is hard to explain, it's a bad idea.
-If the implementation is easy to explain, it may be a good idea.
-"""
-
-# Max messages to train snake_chat on
-MSG_MAX = 100
-
-# get_snek constants
-URL = "https://en.wikipedia.org/w/api.php?"
-
-# snake guess responses
-INCORRECT_GUESS = (
- "Nope, that's not what it is.",
- "Not quite.",
- "Not even close.",
- "Terrible guess.",
- "Nnnno.",
- "Dude. No.",
- "I thought everyone knew this one.",
- "Guess you suck at snakes.",
- "Bet you feel stupid now.",
- "Hahahaha, no.",
- "Did you hit the wrong key?"
-)
-
-CORRECT_GUESS = (
- "**WRONG**. Wait, no, actually you're right.",
- "Yeah, you got it!",
- "Yep, that's exactly what it is.",
- "Uh-huh. Yep yep yep.",
- "Yeah that's right.",
- "Yup. How did you know that?",
- "Are you a herpetologist?",
- "Sure, okay, but I bet you can't pronounce it.",
- "Are you cheating?"
-)
-
-# snake card consts
-CARD = {
- "top": Image.open("bot/resources/snake_cards/card_top.png"),
- "frame": Image.open("bot/resources/snake_cards/card_frame.png"),
- "bottom": Image.open("bot/resources/snake_cards/card_bottom.png"),
- "backs": [
- Image.open(f"bot/resources/snake_cards/backs/{file}")
- for file in os.listdir("bot/resources/snake_cards/backs")
- ],
- "font": ImageFont.truetype("bot/resources/snake_cards/expressway.ttf", 20)
-}
-# endregion
-
-
-class Snakes:
- """
- Commands related to snakes. These were created by our
- community during the first code jam.
-
- More information can be found in the code-jam-1 repo.
-
- https://gitlab_bot_repo.com/discord-python/code-jams/code-jam-1
- """
-
- wiki_brief = re.compile(r'(.*?)(=+ (.*?) =+)', flags=re.DOTALL)
- valid_image_extensions = ('gif', 'png', 'jpeg', 'jpg', 'webp')
-
- def __init__(self, bot: Bot):
- self.active_sal = {}
- self.bot = bot
- self.headers = {"X-API-KEY": Keys.site_api}
-
- # region: Helper methods
- @staticmethod
- def _beautiful_pastel(hue):
- """
- Returns random bright pastels.
- """
-
- light = random.uniform(0.7, 0.85)
- saturation = 1
-
- rgb = colorsys.hls_to_rgb(hue, light, saturation)
- hex_rgb = ""
-
- for part in rgb:
- value = int(part * 0xFF)
- hex_rgb += f"{value:02x}"
-
- return int(hex_rgb, 16)
-
- @staticmethod
- def _generate_card(buffer: BytesIO, content: dict) -> BytesIO:
- """
- Generate a card from snake information.
-
- Written by juan and Someone during the first code jam.
- """
-
- snake = Image.open(buffer)
-
- # Get the size of the snake icon, configure the height of the image box (yes, it changes)
- icon_width = 347 # Hardcoded, not much i can do about that
- icon_height = int((icon_width / snake.width) * snake.height)
- frame_copies = icon_height // CARD['frame'].height + 1
- snake.thumbnail((icon_width, icon_height))
-
- # Get the dimensions of the final image
- main_height = icon_height + CARD['top'].height + CARD['bottom'].height
- main_width = CARD['frame'].width
-
- # Start creating the foreground
- foreground = Image.new("RGBA", (main_width, main_height), (0, 0, 0, 0))
- foreground.paste(CARD['top'], (0, 0))
-
- # Generate the frame borders to the correct height
- for offset in range(frame_copies):
- position = (0, CARD['top'].height + offset * CARD['frame'].height)
- foreground.paste(CARD['frame'], position)
-
- # Add the image and bottom part of the image
- foreground.paste(snake, (36, CARD['top'].height)) # Also hardcoded :(
- foreground.paste(CARD['bottom'], (0, CARD['top'].height + icon_height))
-
- # Setup the background
- back = random.choice(CARD['backs'])
- back_copies = main_height // back.height + 1
- full_image = Image.new("RGBA", (main_width, main_height), (0, 0, 0, 0))
-
- # Generate the tiled background
- for offset in range(back_copies):
- full_image.paste(back, (16, 16 + offset * back.height))
-
- # Place the foreground onto the final image
- full_image.paste(foreground, (0, 0), foreground)
-
- # Get the first two sentences of the info
- description = '.'.join(content['info'].split(".")[:2]) + '.'
-
- # Setup positioning variables
- margin = 36
- offset = CARD['top'].height + icon_height + margin
-
- # Create blank rectangle image which will be behind the text
- rectangle = Image.new(
- "RGBA",
- (main_width, main_height),
- (0, 0, 0, 0)
- )
-
- # Draw a semi-transparent rectangle on it
- rect = ImageDraw.Draw(rectangle)
- rect.rectangle(
- (margin, offset, main_width - margin, main_height - margin),
- fill=(63, 63, 63, 128)
- )
-
- # Paste it onto the final image
- full_image.paste(rectangle, (0, 0), mask=rectangle)
-
- # Draw the text onto the final image
- draw = ImageDraw.Draw(full_image)
- for line in textwrap.wrap(description, 36):
- draw.text([margin + 4, offset], line, font=CARD['font'])
- offset += CARD['font'].getsize(line)[1]
-
- # Get the image contents as a BufferIO object
- buffer = BytesIO()
- full_image.save(buffer, 'PNG')
- buffer.seek(0)
-
- return buffer
-
- @staticmethod
- def _snakify(message):
- """
- Sssnakifffiesss a sstring.
- """
-
- # Replace fricatives with exaggerated snake fricatives.
- simple_fricatives = [
- "f", "s", "z", "h",
- "F", "S", "Z", "H",
- ]
- complex_fricatives = [
- "th", "sh", "Th", "Sh"
- ]
-
- for letter in simple_fricatives:
- if letter.islower():
- message = message.replace(letter, letter * random.randint(2, 4))
- else:
- message = message.replace(letter, (letter * random.randint(2, 4)).title())
-
- for fricative in complex_fricatives:
- message = message.replace(fricative, fricative[0] + fricative[1] * random.randint(2, 4))
-
- return message
-
- async def _fetch(self, session, url, params=None):
- """
- Asyncronous web request helper method.
- """
-
- if params is None:
- params = {}
-
- async with async_timeout.timeout(10):
- async with session.get(url, params=params) as response:
- return await response.json()
-
- def _get_random_long_message(self, messages, retries=10):
- """
- Fetch a message that's at least 3 words long,
- but only if it is possible to do so in retries
- attempts. Else, just return whatever the last
- message is.
- """
-
- long_message = random.choice(messages)
- if len(long_message.split()) < 3 and retries > 0:
- return self._get_random_long_message(
- messages,
- retries=retries - 1
- )
-
- return long_message
-
- async def _get_snek(self, name: str) -> Dict[str, Any]:
- """
- Goes online and fetches all the data from a wikipedia article
- about a snake. Builds a dict that the .get() method can use.
-
- Created by Ava and eivl.
-
- :param name: The name of the snake to get information for - omit for a random snake
- :return: A dict containing information on a snake
- """
-
- snake_info = {}
-
- async with aiohttp.ClientSession() as session:
- params = {
- 'format': 'json',
- 'action': 'query',
- 'list': 'search',
- 'srsearch': name,
- 'utf8': '',
- 'srlimit': '1',
- }
-
- json = await self._fetch(session, URL, params=params)
-
- # wikipedia does have a error page
- try:
- pageid = json["query"]["search"][0]["pageid"]
- except KeyError:
- # Wikipedia error page ID(?)
- pageid = 41118
- except IndexError:
- return None
-
- params = {
- 'format': 'json',
- 'action': 'query',
- 'prop': 'extracts|images|info',
- 'exlimit': 'max',
- 'explaintext': '',
- 'inprop': 'url',
- 'pageids': pageid
- }
-
- json = await self._fetch(session, URL, params=params)
-
- # constructing dict - handle exceptions later
- try:
- snake_info["title"] = json["query"]["pages"][f"{pageid}"]["title"]
- snake_info["extract"] = json["query"]["pages"][f"{pageid}"]["extract"]
- snake_info["images"] = json["query"]["pages"][f"{pageid}"]["images"]
- snake_info["fullurl"] = json["query"]["pages"][f"{pageid}"]["fullurl"]
- snake_info["pageid"] = json["query"]["pages"][f"{pageid}"]["pageid"]
- except KeyError:
- snake_info["error"] = True
-
- if snake_info["images"]:
- i_url = 'https://commons.wikimedia.org/wiki/Special:FilePath/'
- image_list = []
- map_list = []
- thumb_list = []
-
- # Wikipedia has arbitrary images that are not snakes
- banned = [
- 'Commons-logo.svg',
- 'Red%20Pencil%20Icon.png',
- 'distribution',
- 'The%20Death%20of%20Cleopatra%20arthur.jpg',
- 'Head%20of%20holotype',
- 'locator',
- 'Woma.png',
- '-map.',
- '.svg',
- 'ange.',
- 'Adder%20(PSF).png'
- ]
-
- for image in snake_info["images"]:
- # images come in the format of `File:filename.extension`
- file, sep, filename = image["title"].partition(':')
- filename = filename.replace(" ", "%20") # Wikipedia returns good data!
-
- if not filename.startswith('Map'):
- if any(ban in filename for ban in banned):
- pass
- else:
- image_list.append(f"{i_url}{filename}")
- thumb_list.append(f"{i_url}{filename}?width=100")
- else:
- map_list.append(f"{i_url}{filename}")
-
- snake_info["image_list"] = image_list
- snake_info["map_list"] = map_list
- snake_info["thumb_list"] = thumb_list
- snake_info["name"] = name
-
- match = self.wiki_brief.match(snake_info['extract'])
- info = match.group(1) if match else None
-
- if info:
- info = info.replace("\n", "\n\n") # Give us some proper paragraphs.
-
- snake_info["info"] = info
-
- return snake_info
-
- async def _get_snake_name(self) -> Dict[str, str]:
- """
- Gets a random snake name.
- :return: A random snake name, as a string.
- """
-
- response = await self.bot.http_session.get(URLs.site_names_api, headers=self.headers)
- name_data = await response.json()
-
- return name_data
-
- async def _validate_answer(self, ctx: Context, message: Message, answer: str, options: list):
- """
- Validate the answer using a reaction event loop
- :return:
- """
-
- def predicate(reaction, user):
- """
- Test if the the answer is valid and can be evaluated.
- """
- return (
- reaction.message.id == message.id # The reaction is attached to the question we asked.
- and user == ctx.author # It's the user who triggered the quiz.
- and str(reaction.emoji) in ANSWERS_EMOJI.values() # The reaction is one of the options.
- )
-
- for emoji in ANSWERS_EMOJI.values():
- await message.add_reaction(emoji)
-
- # Validate the answer
- try:
- reaction, user = await ctx.bot.wait_for("reaction_add", timeout=45.0, check=predicate)
- except asyncio.TimeoutError:
- await ctx.channel.send(f"You took too long. The correct answer was **{options[answer]}**.")
- await message.clear_reactions()
- return
-
- if str(reaction.emoji) == ANSWERS_EMOJI[answer]:
- await ctx.send(f"{random.choice(CORRECT_GUESS)} The correct answer was **{options[answer]}**.")
- else:
- await ctx.send(
- f"{random.choice(INCORRECT_GUESS)} The correct answer was **{options[answer]}**."
- )
-
- await message.clear_reactions()
- # endregion
-
- # region: Commands
- @group(name='snakes', aliases=('snake',), invoke_without_command=True)
- async def snakes_group(self, ctx: Context):
- """Commands from our first code jam."""
-
- await ctx.invoke(self.bot.get_command("help"), "snake")
-
- @bot_has_permissions(manage_messages=True)
- @snakes_group.command(name='antidote')
- @locked()
- async def antidote_command(self, ctx: Context):
- """
- Antidote - Can you create the antivenom before the patient dies?
-
- Rules: You have 4 ingredients for each antidote, you only have 10 attempts
- Once you synthesize the antidote, you will be presented with 4 markers
- Tick: This means you have a CORRECT ingredient in the CORRECT position
- Circle: This means you have a CORRECT ingredient in the WRONG position
- Cross: This means you have a WRONG ingredient in the WRONG position
-
- Info: The game automatically ends after 5 minutes inactivity.
- You should only use each ingredient once.
-
- This game was created by Lord Bisk and Runew0lf.
- """
-
- def predicate(reaction_: Reaction, user_: Member):
- """
- Make sure that this reaction is what we want to operate on
- """
-
- return (
- all((
- reaction_.message.id == board_id.id, # Reaction is on this message
- reaction_.emoji in ANTIDOTE_EMOJI, # Reaction is one of the pagination emotes
- user_.id != self.bot.user.id, # Reaction was not made by the Bot
- user_.id == ctx.author.id # Reaction was made by author
- ))
- )
-
- # Initialize variables
- antidote_tries = 0
- antidote_guess_count = 0
- antidote_guess_list = []
- guess_result = []
- board = []
- page_guess_list = []
- page_result_list = []
- win = False
-
- antidote_embed = Embed(color=SNAKE_COLOR, title="Antidote")
- antidote_embed.set_author(name=ctx.author.name, icon_url=ctx.author.avatar_url)
-
- # Generate answer
- antidote_answer = list(ANTIDOTE_EMOJI) # Duplicate list, not reference it
- random.shuffle(antidote_answer)
- antidote_answer.pop()
-
- # Begin initial board building
- for i in range(0, 10):
- page_guess_list.append(f"{HOLE_EMOJI} {HOLE_EMOJI} {HOLE_EMOJI} {HOLE_EMOJI}")
- page_result_list.append(f"{CROSS_EMOJI} {CROSS_EMOJI} {CROSS_EMOJI} {CROSS_EMOJI}")
- board.append(f"`{i+1:02d}` "
- f"{page_guess_list[i]} - "
- f"{page_result_list[i]}")
- board.append(EMPTY_UNICODE)
- antidote_embed.add_field(name="10 guesses remaining", value="\n".join(board))
- board_id = await ctx.send(embed=antidote_embed) # Display board
-
- # Add our player reactions
- for emoji in ANTIDOTE_EMOJI:
- await board_id.add_reaction(emoji)
-
- # Begin main game loop
- while not win and antidote_tries < 10:
- try:
- reaction, user = await ctx.bot.wait_for("reaction_add", timeout=300, check=predicate)
- except asyncio.TimeoutError:
- log.debug("Antidote timed out waiting for a reaction")
- break # We're done, no reactions for the last 5 minutes
-
- if antidote_tries < 10:
- if antidote_guess_count < 4:
- if reaction.emoji in ANTIDOTE_EMOJI:
- antidote_guess_list.append(reaction.emoji)
- antidote_guess_count += 1
-
- if antidote_guess_count == 4: # Guesses complete
- antidote_guess_count = 0
- page_guess_list[antidote_tries] = " ".join(antidote_guess_list)
-
- # Now check guess
- for i in range(0, len(antidote_answer)):
- if antidote_guess_list[i] == antidote_answer[i]:
- guess_result.append(TICK_EMOJI)
- elif antidote_guess_list[i] in antidote_answer:
- guess_result.append(BLANK_EMOJI)
- else:
- guess_result.append(CROSS_EMOJI)
- guess_result.sort()
- page_result_list[antidote_tries] = " ".join(guess_result)
-
- # Rebuild the board
- board = []
- for i in range(0, 10):
- board.append(f"`{i+1:02d}` "
- f"{page_guess_list[i]} - "
- f"{page_result_list[i]}")
- board.append(EMPTY_UNICODE)
-
- # Remove Reactions
- for emoji in antidote_guess_list:
- await board_id.remove_reaction(emoji, user)
-
- if antidote_guess_list == antidote_answer:
- win = True
-
- antidote_tries += 1
- guess_result = []
- antidote_guess_list = []
-
- antidote_embed.clear_fields()
- antidote_embed.add_field(name=f"{10 - antidote_tries} "
- f"guesses remaining",
- value="\n".join(board))
- # Redisplay the board
- await board_id.edit(embed=antidote_embed)
-
- # Winning / Ending Screen
- if win is True:
- antidote_embed = Embed(color=SNAKE_COLOR, title="Antidote")
- antidote_embed.set_author(name=ctx.author.name, icon_url=ctx.author.avatar_url)
- antidote_embed.set_image(url="https://i.makeagif.com/media/7-12-2015/Cj1pts.gif")
- antidote_embed.add_field(name=f"You have created the snake antidote!",
- value=f"The solution was: {' '.join(antidote_answer)}\n"
- f"You had {10 - antidote_tries} tries remaining.")
- await board_id.edit(embed=antidote_embed)
- else:
- antidote_embed = Embed(color=SNAKE_COLOR, title="Antidote")
- antidote_embed.set_author(name=ctx.author.name, icon_url=ctx.author.avatar_url)
- antidote_embed.set_image(url="https://media.giphy.com/media/ceeN6U57leAhi/giphy.gif")
- antidote_embed.add_field(name=EMPTY_UNICODE,
- value=f"Sorry you didnt make the antidote in time.\n"
- f"The formula was {' '.join(antidote_answer)}")
- await board_id.edit(embed=antidote_embed)
-
- log.debug("Ending pagination and removing all reactions...")
- await board_id.clear_reactions()
-
- @snakes_group.command(name='draw')
- async def draw_command(self, ctx: Context):
- """
- Draws a random snek using Perlin noise
-
- Written by Momo and kel.
- Modified by juan and lemon.
- """
-
- with ctx.typing():
-
- # Generate random snake attributes
- width = random.randint(6, 10)
- length = random.randint(15, 22)
- random_hue = random.random()
- snek_color = self._beautiful_pastel(random_hue)
- text_color = self._beautiful_pastel((random_hue + 0.5) % 1)
- bg_color = (
- random.randint(32, 50),
- random.randint(32, 50),
- random.randint(50, 70),
- )
-
- # Get a snake idiom from the API
- response = await self.bot.http_session.get(URLs.site_idioms_api, headers=self.headers)
- text = await response.json()
-
- # Build and send the snek
- factory = perlin.PerlinNoiseFactory(dimension=1, octaves=2)
- image_frame = perlinsneks.create_snek_frame(
- factory,
- snake_width=width,
- snake_length=length,
- snake_color=snek_color,
- text=text,
- text_color=text_color,
- bg_color=bg_color
- )
- png_bytes = perlinsneks.frame_to_png_bytes(image_frame)
-
- file = File(png_bytes, filename='snek.png')
-
- await ctx.send(file=file)
-
- @snakes_group.command(name='get')
- @bot_has_permissions(manage_messages=True)
- @locked()
- async def get_command(self, ctx: Context, *, name: Snake = None):
- """
- Fetches information about a snake from Wikipedia.
- :param ctx: Context object passed from discord.py
- :param name: Optional, the name of the snake to get information for - omit for a random snake
-
- Created by Ava and eivl.
- """
-
- with ctx.typing():
- if name is None:
- name = await Snake.random()
-
- if isinstance(name, dict):
- data = name
- else:
- data = await self._get_snek(name)
-
- if data.get('error'):
- return await ctx.send('Could not fetch data from Wikipedia.')
-
- description = data["info"]
-
- # Shorten the description if needed
- if len(description) > 1000:
- description = description[:1000]
- last_newline = description.rfind("\n")
- if last_newline > 0:
- description = description[:last_newline]
-
- # Strip and add the Wiki link.
- if "fullurl" in data:
- description = description.strip("\n")
- description += f"\n\nRead more on [Wikipedia]({data['fullurl']})"
-
- # Build and send the embed.
- embed = Embed(
- title=data.get("title", data.get('name')),
- description=description,
- colour=0x59982F,
- )
-
- emoji = 'https://emojipedia-us.s3.amazonaws.com/thumbs/60/google/3/snake_1f40d.png'
- image = next((url for url in data['image_list'] if url.endswith(self.valid_image_extensions)), emoji)
- embed.set_image(url=image)
-
- await ctx.send(embed=embed)
-
- @snakes_group.command(name='guess', aliases=('identify',))
- @locked()
- async def guess_command(self, ctx):
- """
- Snake identifying game!
-
- Made by Ava and eivl.
- Modified by lemon.
- """
-
- with ctx.typing():
-
- image = None
-
- while image is None:
- snakes = [await Snake.random() for _ in range(4)]
- snake = random.choice(snakes)
- answer = "abcd"[snakes.index(snake)]
-
- data = await self._get_snek(snake)
-
- image = next((url for url in data['image_list'] if url.endswith(self.valid_image_extensions)), None)
-
- embed = Embed(
- title='Which of the following is the snake in the image?',
- description="\n".join(f"{'ABCD'[snakes.index(snake)]}: {snake}" for snake in snakes),
- colour=SNAKE_COLOR
- )
- embed.set_image(url=image)
-
- guess = await ctx.send(embed=embed)
- options = {f"{'abcd'[snakes.index(snake)]}": snake for snake in snakes}
- await self._validate_answer(ctx, guess, answer, options)
-
- @snakes_group.command(name='hatch')
- async def hatch_command(self, ctx: Context):
- """
- Hatches your personal snake
-
- Written by Momo and kel.
- """
-
- # Pick a random snake to hatch.
- snake_name = random.choice(list(hatching.snakes.keys()))
- snake_image = hatching.snakes[snake_name]
-
- # Hatch the snake
- message = await ctx.channel.send(embed=Embed(description="Hatching your snake :snake:..."))
- await asyncio.sleep(1)
-
- for stage in hatching.stages:
- hatch_embed = Embed(description=stage)
- await message.edit(embed=hatch_embed)
- await asyncio.sleep(1)
- await asyncio.sleep(1)
- await message.delete()
-
- # Build and send the embed.
- my_snake_embed = Embed(description=":tada: Congrats! You hatched: **{0}**".format(snake_name))
- my_snake_embed.set_thumbnail(url=snake_image)
- my_snake_embed.set_footer(
- text=" Owner: {0}#{1}".format(ctx.message.author.name, ctx.message.author.discriminator)
- )
-
- await ctx.channel.send(embed=my_snake_embed)
-
- @snakes_group.command(name='movie')
- async def movie_command(self, ctx: Context):
- """
- Gets a random snake-related movie from OMDB.
-
- Written by Samuel.
- Modified by gdude.
- """
-
- url = "http://www.omdbapi.com/"
- page = random.randint(1, 27)
-
- response = await self.bot.http_session.get(
- url,
- params={
- "s": "snake",
- "page": page,
- "type": "movie",
- "apikey": Keys.omdb
- }
- )
- data = await response.json()
- movie = random.choice(data["Search"])["imdbID"]
-
- response = await self.bot.http_session.get(
- url,
- params={
- "i": movie,
- "apikey": Keys.omdb
- }
- )
- data = await response.json()
-
- embed = Embed(
- title=data["Title"],
- color=SNAKE_COLOR
- )
-
- del data["Response"], data["imdbID"], data["Title"]
-
- for key, value in data.items():
- if not value or value == "N/A" or key in ("Response", "imdbID", "Title", "Type"):
- continue
-
- if key == "Ratings": # [{'Source': 'Internet Movie Database', 'Value': '7.6/10'}]
- rating = random.choice(value)
-
- if rating["Source"] != "Internet Movie Database":
- embed.add_field(name=f"Rating: {rating['Source']}", value=rating["Value"])
-
- continue
-
- if key == "Poster":
- embed.set_image(url=value)
- continue
-
- elif key == "imdbRating":
- key = "IMDB Rating"
-
- elif key == "imdbVotes":
- key = "IMDB Votes"
-
- embed.add_field(name=key, value=value, inline=True)
-
- embed.set_footer(text="Data provided by the OMDB API")
-
- await ctx.channel.send(
- embed=embed
- )
-
- @snakes_group.command(name='quiz')
- @locked()
- async def quiz_command(self, ctx: Context):
- """
- Asks a snake-related question in the chat and validates the user's guess.
-
- This was created by Mushy and Cardium,
- and modified by Urthas and lemon.
- """
-
- # Prepare a question.
- response = await self.bot.http_session.get(URLs.site_quiz_api, headers=self.headers)
- question = await response.json()
- answer = question["answerkey"]
- options = {key: question["options"][key] for key in ANSWERS_EMOJI.keys()}
-
- # Build and send the embed.
- embed = Embed(
- color=SNAKE_COLOR,
- title=question["question"],
- description="\n".join(
- [f"**{key.upper()}**: {answer}" for key, answer in options.items()]
- )
- )
-
- quiz = await ctx.channel.send("", embed=embed)
- await self._validate_answer(ctx, quiz, answer, options)
-
- @snakes_group.command(name='name', aliases=('name_gen',))
- async def name_command(self, ctx: Context, *, name: str = None):
- """
- Slices the users name at the last vowel (or second last if the name
- ends with a vowel), and then combines it with a random snake name,
- which is sliced at the first vowel (or second if the name starts with
- a vowel).
-
- If the name contains no vowels, it just appends the snakename
- to the end of the name.
-
- Examples:
- lemon + anaconda = lemoconda
- krzsn + anaconda = krzsnconda
- gdude + anaconda = gduconda
- aperture + anaconda = apertuconda
- lucy + python = luthon
- joseph + taipan = joseipan
-
- This was written by Iceman, and modified for inclusion into the bot by lemon.
- """
-
- snake_name = await self._get_snake_name()
- snake_name = snake_name['name']
- snake_prefix = ""
-
- # Set aside every word in the snake name except the last.
- if " " in snake_name:
- snake_prefix = " ".join(snake_name.split()[:-1])
- snake_name = snake_name.split()[-1]
-
- # If no name is provided, use whoever called the command.
- if name:
- user_name = name
- else:
- user_name = ctx.author.display_name
-
- # Get the index of the vowel to slice the username at
- user_slice_index = len(user_name)
- for index, char in enumerate(reversed(user_name)):
- if index == 0:
- continue
- if char.lower() in "aeiouy":
- user_slice_index -= index
- break
-
- # Now, get the index of the vowel to slice the snake_name at
- snake_slice_index = 0
- for index, char in enumerate(snake_name):
- if index == 0:
- continue
- if char.lower() in "aeiouy":
- snake_slice_index = index + 1
- break
-
- # Combine!
- snake_name = snake_name[snake_slice_index:]
- user_name = user_name[:user_slice_index]
- result = f"{snake_prefix} {user_name}{snake_name}"
- result = string.capwords(result)
-
- # Embed and send
- embed = Embed(
- title="Snake name",
- description=f"Your snake-name is **{result}**",
- color=SNAKE_COLOR
- )
-
- return await ctx.send(embed=embed)
-
- @snakes_group.command(name='sal')
- @locked()
- async def sal_command(self, ctx: Context):
- """
- Play a game of Snakes and Ladders!
-
- Written by Momo and kel.
- Modified by lemon.
- """
-
- # check if there is already a game in this channel
- if ctx.channel in self.active_sal:
- await ctx.send(f"{ctx.author.mention} A game is already in progress in this channel.")
- return
-
- game = sal.SnakeAndLaddersGame(snakes=self, context=ctx)
- self.active_sal[ctx.channel] = game
-
- await game.open_game()
-
- @snakes_group.command(name='about')
- async def about_command(self, ctx: Context):
- """
- A command that shows an embed with information about the event,
- it's participants, and its winners.
- """
-
- contributors = [
- "<@!245270749919576066>",
- "<@!396290259907903491>",
- "<@!172395097705414656>",
- "<@!361708843425726474>",
- "<@!300302216663793665>",
- "<@!210248051430916096>",
- "<@!174588005745557505>",
- "<@!87793066227822592>",
- "<@!211619754039967744>",
- "<@!97347867923976192>",
- "<@!136081839474343936>",
- "<@!263560579770220554>",
- "<@!104749643715387392>",
- "<@!303940835005825024>",
- ]
-
- embed = Embed(
- title="About the snake cog",
- description=(
- "The features in this cog were created by members of the community "
- "during our first ever [code jam event](https://gitlab.com/discord-python/code-jams/code-jam-1). \n\n"
- "The event saw over 50 participants, who competed to write a discord bot cog with a snake theme over "
- "48 hours. The staff then selected the best features from all the best teams, and made modifications "
- "to ensure they would all work together before integrating them into the community bot.\n\n"
- "It was a tight race, but in the end, <@!104749643715387392> and <@!303940835005825024> "
- "walked away as grand champions. Make sure you check out `!snakes sal`, `!snakes draw` "
- "and `!snakes hatch` to see what they came up with."
- )
- )
-
- embed.add_field(
- name="Contributors",
- value=(
- ", ".join(contributors)
- )
- )
-
- await ctx.channel.send(embed=embed)
-
- @snakes_group.command(name='card')
- async def card_command(self, ctx: Context, *, name: Snake = None):
- """
- Create an interesting little card from a snake!
-
- Created by juan and Someone during the first code jam.
- """
-
- # Get the snake data we need
- if not name:
- name_obj = await self._get_snake_name()
- name = name_obj['scientific']
- content = await self._get_snek(name)
-
- elif isinstance(name, dict):
- content = name
-
- else:
- content = await self._get_snek(name)
-
- # Make the card
- async with ctx.typing():
-
- stream = BytesIO()
- async with async_timeout.timeout(10):
- async with self.bot.http_session.get(content['image_list'][0]) as response:
- stream.write(await response.read())
-
- stream.seek(0)
-
- func = partial(self._generate_card, stream, content)
- final_buffer = await self.bot.loop.run_in_executor(None, func)
-
- # Send it!
- await ctx.send(
- f"A wild {content['name'].title()} appears!",
- file=File(final_buffer, filename=content['name'].replace(" ", "") + ".png")
- )
-
- @snakes_group.command(name='fact')
- async def fact_command(self, ctx: Context):
- """
- Gets a snake-related fact
-
- Written by Andrew and Prithaj.
- Modified by lemon.
- """
-
- # Get a fact from the API.
- response = await self.bot.http_session.get(URLs.site_facts_api, headers=self.headers)
- question = await response.json()
-
- # Build and send the embed.
- embed = Embed(
- title="Snake fact",
- color=SNAKE_COLOR,
- description=question
- )
- await ctx.channel.send(embed=embed)
-
- @snakes_group.command(name='help')
- async def help_command(self, ctx: Context):
- """
- This just invokes the help command on this cog.
- """
-
- log.debug(f"{ctx.author} requested info about the snakes cog")
- return await ctx.invoke(self.bot.get_command("help"), "Snakes")
-
- @snakes_group.command(name='snakify')
- async def snakify_command(self, ctx: Context, *, message: str = None):
- """
- How would I talk if I were a snake?
- :param ctx: context
- :param message: If this is passed, it will snakify the message.
- If not, it will snakify a random message from
- the users history.
-
- Written by Momo and kel.
- Modified by lemon.
- """
-
- with ctx.typing():
- embed = Embed()
- user = ctx.message.author
-
- if not message:
-
- # Get a random message from the users history
- messages = []
- async for message in ctx.channel.history(limit=500).filter(
- lambda msg: msg.author == ctx.message.author # Message was sent by author.
- ):
- messages.append(message.content)
-
- message = self._get_random_long_message(messages)
-
- # Set the avatar
- if user.avatar is not None:
- avatar = f"https://cdn.discordapp.com/avatars/{user.id}/{user.avatar}"
- else:
- avatar = ctx.author.default_avatar_url
-
- # Build and send the embed
- embed.set_author(
- name=f"{user.name}#{user.discriminator}",
- icon_url=avatar,
- )
- embed.description = f"*{self._snakify(message)}*"
-
- await ctx.channel.send(embed=embed)
-
- @snakes_group.command(name='video', aliases=('get_video',))
- async def video_command(self, ctx: Context, *, search: str = None):
- """
- Gets a YouTube video about snakes
- :param name: Optional, a name of a snake. Used to search for videos with that name
- :param ctx: Context object passed from discord.py
-
- Written by Andrew and Prithaj.
- """
-
- # Are we searching for anything specific?
- if search:
- query = search + ' snake'
- else:
- snake = await self._get_snake_name()
- query = snake['name']
-
- # Build the URL and make the request
- url = f'https://www.googleapis.com/youtube/v3/search'
- response = await self.bot.http_session.get(
- url,
- params={
- "part": "snippet",
- "q": urllib.parse.quote(query),
- "type": "video",
- "key": Keys.youtube
- }
- )
- response = await response.json()
- data = response['items']
-
- # Send the user a video
- if len(data) > 0:
- num = random.randint(0, len(data) - 1)
- youtube_base_url = 'https://www.youtube.com/watch?v='
- await ctx.channel.send(
- content=f"{youtube_base_url}{data[num]['id']['videoId']}"
- )
- else:
- log.warning(f"YouTube API error. Full response looks like {response}")
-
- @snakes_group.command(name='zen')
- async def zen_command(self, ctx: Context):
- """
- Gets a random quote from the Zen of Python,
- except as if spoken by a snake.
-
- Written by Prithaj and Andrew.
- Modified by lemon.
- """
-
- embed = Embed(
- title="Zzzen of Pythhon",
- color=SNAKE_COLOR
- )
-
- # Get the zen quote and snakify it
- zen_quote = random.choice(ZEN.splitlines())
- zen_quote = self._snakify(zen_quote)
-
- # Embed and send
- embed.description = zen_quote
- await ctx.channel.send(
- embed=embed
- )
- # endregion
-
- # region: Error handlers
- @get_command.error
- @card_command.error
- @video_command.error
- async def command_error(self, ctx, error):
-
- embed = Embed()
- embed.colour = Colour.red()
-
- if isinstance(error, BadArgument):
- embed.description = str(error)
- embed.title = random.choice(ERROR_REPLIES)
-
- elif isinstance(error, OSError):
- log.error(f"snake_card encountered an OSError: {error} ({error.original})")
- embed.description = "Could not generate the snake card! Please try again."
- embed.title = random.choice(ERROR_REPLIES)
-
- else:
- log.error(f"Unhandled tag command error: {error} ({error.original})")
- return
-
- await ctx.send(embed=embed)
- # endregion
-
-
-def setup(bot):
- bot.add_cog(Snakes(bot))
- log.info("Cog loaded: Snakes")
diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py
index 0f8d3e4b6..81185cf3e 100644
--- a/bot/cogs/snekbox.py
+++ b/bot/cogs/snekbox.py
@@ -1,21 +1,16 @@
import datetime
import logging
-import random
import re
import textwrap
from signal import Signals
from typing import Optional, Tuple
-from discord import Colour, Embed
-from discord.ext.commands import (
- Bot, CommandError, Context, NoPrivateMessage, command, guild_only
-)
+from discord.ext.commands import Bot, Cog, Context, command, guild_only
-from bot.constants import Channels, ERROR_REPLIES, NEGATIVE_REPLIES, Roles, URLs
-from bot.decorators import InChannelCheckFailure, in_channel
+from bot.constants import Channels, Roles, URLs
+from bot.decorators import in_channel
from bot.utils.messages import wait_for_deletion
-
log = logging.getLogger(__name__)
ESCAPE_REGEX = re.compile("[`\u202E\u200B]{3,}")
@@ -37,14 +32,12 @@ RAW_CODE_REGEX = re.compile(
re.DOTALL # "." also matches newlines
)
-BYPASS_ROLES = (Roles.owner, Roles.admin, Roles.moderator, Roles.helpers)
MAX_PASTE_LEN = 1000
+EVAL_ROLES = (Roles.helpers, Roles.moderator, Roles.admin, Roles.owner, Roles.rockstars, Roles.partners)
-class Snekbox:
- """
- Safe evaluation of Python code using Snekbox
- """
+class Snekbox(Cog):
+ """Safe evaluation of Python code using Snekbox."""
def __init__(self, bot: Bot):
self.bot = bot
@@ -173,8 +166,8 @@ class Snekbox:
@command(name="eval", aliases=("e",))
@guild_only()
- @in_channel(Channels.bot, bypass_roles=BYPASS_ROLES)
- async def eval_command(self, ctx: Context, *, code: str = None):
+ @in_channel(Channels.bot, bypass_roles=EVAL_ROLES)
+ async def eval_command(self, ctx: Context, *, code: str = None) -> None:
"""
Run Python code and get the results.
@@ -183,13 +176,15 @@ class Snekbox:
issue with it!
"""
if ctx.author.id in self.jobs:
- return await ctx.send(
+ await ctx.send(
f"{ctx.author.mention} You've already got a job running - "
f"please wait for it to finish!"
)
+ return
if not code: # None or empty string
- return await ctx.invoke(self.bot.get_command("help"), "eval")
+ await ctx.invoke(self.bot.get_command("help"), "eval")
+ return
log.info(
f"Received code from {ctx.author.name}#{ctx.author.discriminator} "
@@ -225,28 +220,8 @@ class Snekbox:
finally:
del self.jobs[ctx.author.id]
- @eval_command.error
- async def eval_command_error(self, ctx: Context, error: CommandError):
- embed = Embed(colour=Colour.red())
-
- if isinstance(error, NoPrivateMessage):
- embed.title = random.choice(NEGATIVE_REPLIES)
- embed.description = "You're not allowed to use this command in private messages."
- await ctx.send(embed=embed)
-
- elif isinstance(error, InChannelCheckFailure):
- embed.title = random.choice(NEGATIVE_REPLIES)
- embed.description = str(error)
- await ctx.send(embed=embed)
-
- else:
- original_error = getattr(error, 'original', "no original error")
- log.error(f"Unhandled error in snekbox eval: {error} ({original_error})")
- embed.title = random.choice(ERROR_REPLIES)
- embed.description = "Some unhandled error occurred. Sorry for that!"
- await ctx.send(embed=embed)
-
-def setup(bot):
+def setup(bot: Bot) -> None:
+ """Snekbox cog load."""
bot.add_cog(Snekbox(bot))
log.info("Cog loaded: Snekbox")
diff --git a/bot/cogs/superstarify/stars.py b/bot/cogs/superstarify/stars.py
deleted file mode 100644
index 9b49d7175..000000000
--- a/bot/cogs/superstarify/stars.py
+++ /dev/null
@@ -1,86 +0,0 @@
-import random
-
-
-STAR_NAMES = (
- "Adele",
- "Aerosmith",
- "Aretha Franklin",
- "Ayumi Hamasaki",
- "B'z",
- "Barbra Streisand",
- "Barry Manilow",
- "Barry White",
- "Beyonce",
- "Billy Joel",
- "Bob Dylan",
- "Bob Marley",
- "Bob Seger",
- "Bon Jovi",
- "Britney Spears",
- "Bruce Springsteen",
- "Bruno Mars",
- "Bryan Adams",
- "Celine Dion",
- "Cher",
- "Christina Aguilera",
- "David Bowie",
- "Donna Summer",
- "Drake",
- "Ed Sheeran",
- "Elton John",
- "Elvis Presley",
- "Eminem",
- "Enya",
- "Flo Rida",
- "Frank Sinatra",
- "Garth Brooks",
- "George Michael",
- "George Strait",
- "James Taylor",
- "Janet Jackson",
- "Jay-Z",
- "Johnny Cash",
- "Johnny Hallyday",
- "Julio Iglesias",
- "Justin Bieber",
- "Justin Timberlake",
- "Kanye West",
- "Katy Perry",
- "Kenny G",
- "Kenny Rogers",
- "Lady Gaga",
- "Lil Wayne",
- "Linda Ronstadt",
- "Lionel Richie",
- "Madonna",
- "Mariah Carey",
- "Meat Loaf",
- "Michael Jackson",
- "Neil Diamond",
- "Nicki Minaj",
- "Olivia Newton-John",
- "Paul McCartney",
- "Phil Collins",
- "Pink",
- "Prince",
- "Reba McEntire",
- "Rihanna",
- "Robbie Williams",
- "Rod Stewart",
- "Santana",
- "Shania Twain",
- "Stevie Wonder",
- "Taylor Swift",
- "Tim McGraw",
- "Tina Turner",
- "Tom Petty",
- "Tupac Shakur",
- "Usher",
- "Van Halen",
- "Whitney Houston",
-)
-
-
-def get_nick(infraction_id, member_id):
- rng = random.Random(str(infraction_id) + str(member_id))
- return rng.choice(STAR_NAMES)
diff --git a/bot/cogs/sync/__init__.py b/bot/cogs/sync/__init__.py
index e4f960620..d4565f848 100644
--- a/bot/cogs/sync/__init__.py
+++ b/bot/cogs/sync/__init__.py
@@ -1,10 +1,13 @@
import logging
+from discord.ext.commands import Bot
+
from .cog import Sync
log = logging.getLogger(__name__)
-def setup(bot):
+def setup(bot: Bot) -> None:
+ """Sync cog load."""
bot.add_cog(Sync(bot))
log.info("Cog loaded: Sync")
diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py
index ab591ebf8..aaa581f96 100644
--- a/bot/cogs/sync/cog.py
+++ b/bot/cogs/sync/cog.py
@@ -1,18 +1,18 @@
import logging
from typing import Callable, Iterable
-import aiohttp
from discord import Guild, Member, Role
from discord.ext import commands
-from discord.ext.commands import Bot
+from discord.ext.commands import Bot, Cog, Context
from bot import constants
+from bot.api import ResponseCodeError
from bot.cogs.sync import syncers
log = logging.getLogger(__name__)
-class Sync:
+class Sync(Cog):
"""Captures relevant events and sends them to the site."""
# The server to synchronize events on.
@@ -26,58 +26,78 @@ class Sync:
syncers.sync_users
)
- def __init__(self, bot):
+ def __init__(self, bot: Bot) -> None:
self.bot = bot
- async def on_ready(self):
+ self.bot.loop.create_task(self.sync_guild())
+
+ async def sync_guild(self) -> None:
+ """Syncs the roles/users of the guild with the database."""
+ await self.bot.wait_until_ready()
guild = self.bot.get_guild(self.SYNC_SERVER_ID)
if guild is not None:
for syncer in self.ON_READY_SYNCERS:
syncer_name = syncer.__name__[5:] # drop off `sync_`
log.info("Starting `%s` syncer.", syncer_name)
- total_created, total_updated = await syncer(self.bot, guild)
- log.info(
- "`%s` syncer finished, created `%d`, updated `%d`.",
- syncer_name, total_created, total_updated
- )
-
- async def on_guild_role_create(self, role: Role):
+ total_created, total_updated, total_deleted = await syncer(self.bot, guild)
+ if total_deleted is None:
+ log.info(
+ f"`{syncer_name}` syncer finished, created `{total_created}`, updated `{total_updated}`."
+ )
+ else:
+ log.info(
+ f"`{syncer_name}` syncer finished, created `{total_created}`, updated `{total_updated}`, "
+ f"deleted `{total_deleted}`."
+ )
+
+ @Cog.listener()
+ async def on_guild_role_create(self, role: Role) -> None:
+ """Adds newly create role to the database table over the API."""
await self.bot.api_client.post(
'bot/roles',
json={
'colour': role.colour.value,
'id': role.id,
'name': role.name,
- 'permissions': role.permissions.value
+ 'permissions': role.permissions.value,
+ 'position': role.position,
}
)
- async def on_guild_role_delete(self, role: Role):
- log.warning(
- (
- "Attempted to delete role `%s` (`%d`), but role deletion "
- "is currently not implementeed."
- ),
- role.name, role.id
- )
+ @Cog.listener()
+ async def on_guild_role_delete(self, role: Role) -> None:
+ """Deletes role from the database when it's deleted from the guild."""
+ await self.bot.api_client.delete(f'bot/roles/{role.id}')
- async def on_guild_role_update(self, before: Role, after: Role):
+ @Cog.listener()
+ async def on_guild_role_update(self, before: Role, after: Role) -> None:
+ """Syncs role with the database if any of the stored attributes were updated."""
if (
before.name != after.name
or before.colour != after.colour
or before.permissions != after.permissions
+ or before.position != after.position
):
await self.bot.api_client.put(
- 'bot/roles/' + str(after.id),
+ f'bot/roles/{after.id}',
json={
'colour': after.colour.value,
'id': after.id,
'name': after.name,
- 'permissions': after.permissions.value
+ 'permissions': after.permissions.value,
+ 'position': after.position,
}
)
- async def on_member_join(self, member: Member):
+ @Cog.listener()
+ async def on_member_join(self, member: Member) -> None:
+ """
+ Adds a new user or updates existing user to the database when a member joins the guild.
+
+ If the joining member is a user that is already known to the database (i.e., a user that
+ previously left), it will update the user's information. If the user is not yet known by
+ the database, the user is added.
+ """
packed = {
'avatar_hash': member.avatar,
'discriminator': int(member.discriminator),
@@ -92,11 +112,11 @@ class Sync:
try:
# First try an update of the user to set the `in_guild` field and other
# fields that may have changed since the last time we've seen them.
- await self.bot.api_client.put('bot/users/' + str(member.id), json=packed)
+ await self.bot.api_client.put(f'bot/users/{member.id}', json=packed)
- except aiohttp.client_exceptions.ClientResponseError as e:
+ except ResponseCodeError as e:
# If we didn't get 404, something else broke - propagate it up.
- if e.status != 404:
+ if e.response.status != 404:
raise
got_error = True # yikes
@@ -105,20 +125,24 @@ class Sync:
# If we got `404`, the user is new. Create them.
await self.bot.api_client.post('bot/users', json=packed)
- async def on_member_leave(self, member: Member):
+ @Cog.listener()
+ async def on_member_remove(self, member: Member) -> None:
+ """Updates the user information when a member leaves the guild."""
await self.bot.api_client.put(
- 'bot/users/' + str(member.id),
+ f'bot/users/{member.id}',
json={
'avatar_hash': member.avatar,
'discriminator': int(member.discriminator),
'id': member.id,
- 'in_guild': True,
+ 'in_guild': False,
'name': member.name,
'roles': sorted(role.id for role in member.roles)
}
)
- async def on_member_update(self, before: Member, after: Member):
+ @Cog.listener()
+ async def on_member_update(self, before: Member, after: Member) -> None:
+ """Updates the user information if any of relevant attributes have changed."""
if (
before.name != after.name
or before.avatar != after.avatar
@@ -137,8 +161,8 @@ class Sync:
'roles': sorted(role.id for role in after.roles)
}
)
- except aiohttp.client_exceptions.ClientResponseError as e:
- if e.status != 404:
+ except ResponseCodeError as e:
+ if e.response.status != 404:
raise
log.warning(
@@ -148,30 +172,28 @@ class Sync:
@commands.group(name='sync')
@commands.has_permissions(administrator=True)
- async def sync_group(self, ctx):
+ async def sync_group(self, ctx: Context) -> None:
"""Run synchronizations between the bot and site manually."""
@sync_group.command(name='roles')
@commands.has_permissions(administrator=True)
- async def sync_roles_command(self, ctx):
+ async def sync_roles_command(self, ctx: Context) -> None:
"""Manually synchronize the guild's roles with the roles on the site."""
-
initial_response = await ctx.send("📊 Synchronizing roles.")
- total_created, total_updated = await syncers.sync_roles(self.bot, ctx.guild)
+ total_created, total_updated, total_deleted = await syncers.sync_roles(self.bot, ctx.guild)
await initial_response.edit(
content=(
f"👌 Role synchronization complete, created **{total_created}** "
- f"and updated **{total_created}** roles."
+ f", updated **{total_created}** roles, and deleted **{total_deleted}** roles."
)
)
@sync_group.command(name='users')
@commands.has_permissions(administrator=True)
- async def sync_users_command(self, ctx):
+ async def sync_users_command(self, ctx: Context) -> None:
"""Manually synchronize the guild's users with the users on the site."""
-
initial_response = await ctx.send("📊 Synchronizing users.")
- total_created, total_updated = await syncers.sync_users(self.bot, ctx.guild)
+ total_created, total_updated, total_deleted = await syncers.sync_users(self.bot, ctx.guild)
await initial_response.edit(
content=(
f"👌 User synchronization complete, created **{total_created}** "
diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py
index 3037d2e31..2cc5a66e1 100644
--- a/bot/cogs/sync/syncers.py
+++ b/bot/cogs/sync/syncers.py
@@ -6,13 +6,13 @@ from discord.ext.commands import Bot
# These objects are declared as namedtuples because tuples are hashable,
# something that we make use of when diffing site roles against guild roles.
-Role = namedtuple('Role', ('id', 'name', 'colour', 'permissions'))
+Role = namedtuple('Role', ('id', 'name', 'colour', 'permissions', 'position'))
User = namedtuple('User', ('id', 'name', 'discriminator', 'avatar_hash', 'roles', 'in_guild'))
def get_roles_for_sync(
guild_roles: Set[Role], api_roles: Set[Role]
-) -> Tuple[Set[Role], Set[Role]]:
+) -> Tuple[Set[Role], Set[Role], Set[Role]]:
"""
Determine which roles should be created or updated on the site.
@@ -24,27 +24,30 @@ def get_roles_for_sync(
Roles that were retrieved from the API at startup.
Returns:
- Tuple[Set[Role], Set[Role]]:
- A tuple with two elements. The first element represents
+ Tuple[Set[Role], Set[Role]. Set[Role]]:
+ A tuple with three elements. The first element represents
roles to be created on the site, meaning that they were
present on the cached guild but not on the API. The second
element represents roles to be updated, meaning they were
present on both the cached guild and the API but non-ID
- fields have changed inbetween.
+ fields have changed inbetween. The third represents roles
+ to be deleted on the site, meaning the roles are present on
+ the API but not in the cached guild.
"""
-
guild_role_ids = {role.id for role in guild_roles}
api_role_ids = {role.id for role in api_roles}
new_role_ids = guild_role_ids - api_role_ids
+ deleted_role_ids = api_role_ids - guild_role_ids
# New roles are those which are on the cached guild but not on the
# API guild, going by the role ID. We need to send them in for creation.
roles_to_create = {role for role in guild_roles if role.id in new_role_ids}
roles_to_update = guild_roles - api_roles - roles_to_create
- return roles_to_create, roles_to_update
+ roles_to_delete = {role for role in api_roles if role.id in deleted_role_ids}
+ return roles_to_create, roles_to_update, roles_to_delete
-async def sync_roles(bot: Bot, guild: Guild):
+async def sync_roles(bot: Bot, guild: Guild) -> Tuple[int, int, int]:
"""
Synchronize roles found on the given `guild` with the ones on the API.
@@ -57,11 +60,11 @@ async def sync_roles(bot: Bot, guild: Guild):
to synchronize roles with.
Returns:
- Tuple[int, int]:
- A tuple with two integers representing how many roles were created
- (element `0`) and how many roles were updated (element `1`).
+ Tuple[int, int, int]:
+ A tuple with three integers representing how many roles were created
+ (element `0`) , how many roles were updated (element `1`), and how many
+ roles were deleted (element `2`) on the API.
"""
-
roles = await bot.api_client.get('bot/roles')
# Pack API roles and guild roles into one common format,
@@ -71,11 +74,12 @@ async def sync_roles(bot: Bot, guild: Guild):
guild_roles = {
Role(
id=role.id, name=role.name,
- colour=role.colour.value, permissions=role.permissions.value
+ colour=role.colour.value, permissions=role.permissions.value,
+ position=role.position,
)
for role in guild.roles
}
- roles_to_create, roles_to_update = get_roles_for_sync(guild_roles, api_roles)
+ roles_to_create, roles_to_update, roles_to_delete = get_roles_for_sync(guild_roles, api_roles)
for role in roles_to_create:
await bot.api_client.post(
@@ -84,22 +88,27 @@ async def sync_roles(bot: Bot, guild: Guild):
'id': role.id,
'name': role.name,
'colour': role.colour,
- 'permissions': role.permissions
+ 'permissions': role.permissions,
+ 'position': role.position,
}
)
for role in roles_to_update:
await bot.api_client.put(
- 'bot/roles/' + str(role.id),
+ f'bot/roles/{role.id}',
json={
'id': role.id,
'name': role.name,
'colour': role.colour,
- 'permissions': role.permissions
+ 'permissions': role.permissions,
+ 'position': role.position,
}
)
- return (len(roles_to_create), len(roles_to_update))
+ for role in roles_to_delete:
+ await bot.api_client.delete(f'bot/roles/{role.id}')
+
+ return len(roles_to_create), len(roles_to_update), len(roles_to_delete)
def get_users_for_sync(
@@ -127,7 +136,6 @@ def get_users_for_sync(
guild, but where the attribute of a user on the API is not
equal to the attribute of the user on the guild.
"""
-
users_to_create = set()
users_to_update = set()
@@ -156,10 +164,9 @@ def get_users_for_sync(
return users_to_create, users_to_update
-async def sync_users(bot: Bot, guild: Guild):
+async def sync_users(bot: Bot, guild: Guild) -> Tuple[int, int, None]:
"""
- Synchronize users found on the given
- `guild` with the ones on the API.
+ Synchronize users found in the given `guild` with the ones in the API.
Arguments:
bot (discord.ext.commands.Bot):
@@ -170,11 +177,11 @@ async def sync_users(bot: Bot, guild: Guild):
to synchronize roles with.
Returns:
- Tuple[int, int]:
- A tuple with two integers representing how many users were created
- (element `0`) and how many users were updated (element `1`).
+ Tuple[int, int, None]:
+ A tuple with two integers, representing how many users were created
+ (element `0`) and how many users were updated (element `1`), and `None`
+ to indicate that a user sync never deletes entries from the API.
"""
-
current_users = await bot.api_client.get('bot/users')
# Pack API users and guild users into one common format,
@@ -213,7 +220,7 @@ async def sync_users(bot: Bot, guild: Guild):
for user in users_to_update:
await bot.api_client.put(
- 'bot/users/' + str(user.id),
+ f'bot/users/{user.id}',
json={
'avatar_hash': user.avatar_hash,
'discriminator': user.discriminator,
@@ -224,4 +231,4 @@ async def sync_users(bot: Bot, guild: Guild):
}
)
- return (len(users_to_create), len(users_to_update))
+ return len(users_to_create), len(users_to_update), None
diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py
index bb4d6ba71..cd70e783a 100644
--- a/bot/cogs/tags.py
+++ b/bot/cogs/tags.py
@@ -2,9 +2,9 @@ import logging
import time
from discord import Colour, Embed
-from discord.ext.commands import Bot, Context, group
+from discord.ext.commands import Bot, Cog, Context, group
-from bot.constants import Channels, Cooldowns, Keys, Roles
+from bot.constants import Channels, Cooldowns, MODERATION_ROLES, Roles
from bot.converters import TagContentConverter, TagNameConverter
from bot.decorators import with_role
from bot.pagination import LinePaginator
@@ -19,43 +19,27 @@ TEST_CHANNELS = (
)
-class Tags:
- """
- Save new tags and fetch existing tags.
- """
+class Tags(Cog):
+ """Save new tags and fetch existing tags."""
def __init__(self, bot: Bot):
self.bot = bot
self.tag_cooldowns = {}
- self.headers = {"Authorization": f"Token {Keys.site_api}"}
- @group(name='tags', aliases=('tag', 't'), hidden=True, invoke_without_command=True)
- async def tags_group(self, ctx: Context, *, tag_name: TagNameConverter = None):
+ @group(name='tags', aliases=('tag', 't'), invoke_without_command=True)
+ async def tags_group(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None:
"""Show all known tags, a single tag, or run a subcommand."""
-
await ctx.invoke(self.get_command, tag_name=tag_name)
@tags_group.command(name='get', aliases=('show', 'g'))
- async def get_command(self, ctx: Context, *, tag_name: TagNameConverter = None):
- """
- Get a list of all tags or a specified tag.
-
- :param ctx: Discord message context
- :param tag_name:
- If provided, this function shows data for that specific tag.
- If not provided, this function shows the caller a list of all tags.
- """
-
- def _command_on_cooldown(tag_name) -> bool:
+ async def get_command(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None:
+ """Get a specified tag, or a list of all tags if no tag is specified."""
+ def _command_on_cooldown(tag_name: str) -> bool:
"""
- Check if the command is currently on cooldown.
- The cooldown duration is set in constants.py.
+ Check if the command is currently on cooldown, on a per-tag, per-channel basis.
- This works on a per-tag, per-channel basis.
- :param tag_name: The name of the command to check.
- :return: True if the command is cooling down. Otherwise False.
+ The cooldown duration is set in constants.py.
"""
-
now = time.time()
cooldown_conditions = (
@@ -82,7 +66,7 @@ class Tags:
"time": time.time(),
"channel": ctx.channel.id
}
- await ctx.send(embed=Embed.from_data(tag['embed']))
+ await ctx.send(embed=Embed.from_dict(tag['embed']))
else:
tags = await self.bot.api_client.get('bot/tags')
@@ -92,7 +76,7 @@ class Tags:
colour=Colour.red()
))
else:
- embed = Embed(title="**Current tags**")
+ embed: Embed = Embed(title="**Current tags**")
await LinePaginator.paginate(
sorted(f"**»** {tag['title']}" for tag in tags),
ctx,
@@ -102,23 +86,16 @@ class Tags:
max_lines=15
)
- @tags_group.command(name='set', aliases=('add', 'edit', 's'))
- @with_role(Roles.admin, Roles.owner, Roles.moderator)
+ @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,
- ):
- """
- Create a new tag or update an existing one.
-
- :param ctx: discord message context
- :param tag_name: The name of the tag to create or edit.
- :param tag_content: The content of the tag.
- """
-
+ ) -> None:
+ """Create a new tag."""
body = {
'title': tag_name.lower().strip(),
'embed': {
@@ -139,16 +116,39 @@ class Tags:
colour=Colour.blurple()
))
- @tags_group.command(name='delete', aliases=('remove', 'rm', 'd'))
- @with_role(Roles.admin, Roles.owner)
- async def delete_command(self, ctx: Context, *, tag_name: TagNameConverter):
- """
- Remove a tag from the database.
+ @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
+ }
+ }
- :param ctx: discord message context
- :param tag_name: The name of the tag to delete.
- """
+ await self.bot.api_client.patch(f'bot/tags/{tag_name}', json=body)
+ log.debug(f"{ctx.author} successfully edited the following tag in our database: \n"
+ f"tag_name: {tag_name}\n"
+ f"tag_content: '{tag_content}'\n")
+
+ await ctx.send(embed=Embed(
+ title="Tag successfully edited",
+ description=f"**{tag_name}** edited in the database.",
+ colour=Colour.blurple()
+ ))
+
+ @tags_group.command(name='delete', aliases=('remove', 'rm', 'd'))
+ @with_role(Roles.admin, Roles.owner)
+ async def delete_command(self, ctx: Context, *, tag_name: TagNameConverter) -> None:
+ """Remove a tag from the database."""
await self.bot.api_client.delete(f'bot/tags/{tag_name}')
log.debug(f"{ctx.author} successfully deleted the tag called '{tag_name}'")
@@ -159,6 +159,7 @@ class Tags:
))
-def setup(bot):
+def setup(bot: Bot) -> None:
+ """Tags cog load."""
bot.add_cog(Tags(bot))
log.info("Cog loaded: Tags")
diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py
index 8277513a7..5a0d20e57 100644
--- a/bot/cogs/token_remover.py
+++ b/bot/cogs/token_remover.py
@@ -6,36 +6,35 @@ import struct
from datetime import datetime
from discord import Colour, Message
-from discord.ext.commands import Bot
+from discord.ext.commands import Bot, Cog
from discord.utils import snowflake_time
-from bot.cogs.modlog import ModLog
+from bot.cogs.moderation import ModLog
from bot.constants import Channels, Colours, Event, Icons
log = logging.getLogger(__name__)
DELETION_MESSAGE_TEMPLATE = (
"Hey {mention}! I noticed you posted a seemingly valid Discord API "
- "token in your message and have removed your message to prevent abuse. "
- "We recommend regenerating your token regardless, which you can do here: "
- "<https://discordapp.com/developers/applications/me>\n"
+ "token in your message and have removed your message. "
+ "This means that your token has been **compromised**. "
+ "Please change your token **immediately** at: "
+ "<https://discordapp.com/developers/applications/me>\n\n"
"Feel free to re-post it with the token removed. "
"If you believe this was a mistake, please let us know!"
)
DISCORD_EPOCH_TIMESTAMP = datetime(2017, 1, 1)
TOKEN_EPOCH = 1_293_840_000
TOKEN_RE = re.compile(
- r"(?<=(\"|'))" # Lookbehind: Only match if there's a double or single quote in front
- r"[^\s\.]+" # Matches token part 1: The user ID string, encoded as base64
- r"\." # Matches a literal dot between the token parts
- r"[^\s\.]+" # Matches token part 2: The creation timestamp, as an integer
- r"\." # Matches a literal dot between the token parts
- r"[^\s\.]+" # Matches token part 3: The HMAC, unused by us, but check that it isn't empty
- r"(?=(\"|'))" # Lookahead: Only match if there's a double or single quote after
+ r"[^\s\.()\"']+" # Matches token part 1: The user ID string, encoded as base64
+ r"\." # Matches a literal dot between the token parts
+ r"[^\s\.()\"']+" # Matches token part 2: The creation timestamp, as an integer
+ r"\." # Matches a literal dot between the token parts
+ r"[^\s\.()\"']+" # Matches token part 3: The HMAC, unused by us, but check that it isn't empty
)
-class TokenRemover:
+class TokenRemover(Cog):
"""Scans messages for potential discord.py bot tokens and removes them."""
def __init__(self, bot: Bot):
@@ -43,9 +42,16 @@ class TokenRemover:
@property
def mod_log(self) -> ModLog:
+ """Get currently loaded ModLog cog instance."""
return self.bot.get_cog("ModLog")
- async def on_message(self, msg: Message):
+ @Cog.listener()
+ async def on_message(self, msg: Message) -> None:
+ """
+ Check each message for a string that matches Discord's token pattern.
+
+ See: https://discordapp.com/developers/docs/reference#snowflakes
+ """
if msg.author.bot:
return
@@ -82,6 +88,11 @@ class TokenRemover:
@staticmethod
def is_valid_user_id(b64_content: str) -> bool:
+ """
+ Check potential token to see if it contains a valid Discord user ID.
+
+ See: https://discordapp.com/developers/docs/reference#snowflakes
+ """
b64_content += '=' * (-len(b64_content) % 4)
try:
@@ -92,6 +103,11 @@ class TokenRemover:
@staticmethod
def is_valid_timestamp(b64_content: str) -> bool:
+ """
+ Check potential token to see if it contains a valid timestamp.
+
+ See: https://discordapp.com/developers/docs/reference#snowflakes
+ """
b64_content += '=' * (-len(b64_content) % 4)
try:
@@ -102,6 +118,7 @@ class TokenRemover:
return snowflake_time(snowflake + TOKEN_EPOCH) < DISCORD_EPOCH_TIMESTAMP
-def setup(bot: Bot):
+def setup(bot: Bot) -> None:
+ """Token Remover cog load."""
bot.add_cog(TokenRemover(bot))
log.info("Cog loaded: TokenRemover")
diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py
index 62d46d5a1..57f5d6197 100644
--- a/bot/cogs/utils.py
+++ b/bot/cogs/utils.py
@@ -1,42 +1,36 @@
import logging
-import random
import re
import unicodedata
from email.parser import HeaderParser
from io import StringIO
+from typing import Tuple
from discord import Colour, Embed
-from discord.ext.commands import AutoShardedBot, Context, command
+from discord.ext.commands import Bot, Cog, Context, command
-from bot.constants import Channels, NEGATIVE_REPLIES, Roles
-from bot.decorators import InChannelCheckFailure, in_channel
+from bot.constants import Channels, STAFF_ROLES
+from bot.decorators import in_channel
log = logging.getLogger(__name__)
-BYPASS_ROLES = (Roles.owner, Roles.admin, Roles.moderator, Roles.helpers)
+class Utils(Cog):
+ """A selection of utilities which don't have a clear category."""
-class Utils:
- """
- A selection of utilities which don't have a clear category.
- """
-
- def __init__(self, bot: AutoShardedBot):
+ def __init__(self, bot: Bot):
self.bot = bot
self.base_pep_url = "http://www.python.org/dev/peps/pep-"
self.base_github_pep_url = "https://raw.githubusercontent.com/python/peps/master/pep-"
@command(name='pep', aliases=('get_pep', 'p'))
- async def pep_command(self, ctx: Context, pep_number: str):
- """
- Fetches information about a PEP and sends it to the channel.
- """
-
+ async def pep_command(self, ctx: Context, pep_number: str) -> None:
+ """Fetches information about a PEP and sends it to the channel."""
if pep_number.isdigit():
pep_number = int(pep_number)
else:
- return await ctx.invoke(self.bot.get_command("help"), "pep")
+ await ctx.invoke(self.bot.get_command("help"), "pep")
+ return
possible_extensions = ['.txt', '.rst']
found_pep = False
@@ -93,12 +87,9 @@ class Utils:
await ctx.message.channel.send(embed=pep_embed)
@command()
- @in_channel(Channels.bot, bypass_roles=BYPASS_ROLES)
- async def charinfo(self, ctx, *, characters: str):
- """
- Shows you information on up to 25 unicode characters.
- """
-
+ @in_channel(Channels.bot, bypass_roles=STAFF_ROLES)
+ async def charinfo(self, ctx: Context, *, characters: str) -> None:
+ """Shows you information on up to 25 unicode characters."""
match = re.match(r"<(a?):(\w+):(\d+)>", characters)
if match:
embed = Embed(
@@ -109,14 +100,16 @@ class Utils:
)
)
embed.colour = Colour.red()
- return await ctx.send(embed=embed)
+ await ctx.send(embed=embed)
+ return
if len(characters) > 25:
embed = Embed(title=f"Too many characters ({len(characters)}/25)")
embed.colour = Colour.red()
- return await ctx.send(embed=embed)
+ await ctx.send(embed=embed)
+ return
- def get_info(char):
+ def get_info(char: str) -> Tuple[str, str]:
digit = f"{ord(char):x}"
if len(digit) <= 4:
u_code = f"\\u{digit:>04}"
@@ -137,14 +130,8 @@ class Utils:
await ctx.send(embed=embed)
- async def __error(self, ctx, error):
- embed = Embed(colour=Colour.red())
- if isinstance(error, InChannelCheckFailure):
- embed.title = random.choice(NEGATIVE_REPLIES)
- embed.description = str(error)
- await ctx.send(embed=embed)
-
-def setup(bot):
+def setup(bot: Bot) -> None:
+ """Utils cog load."""
bot.add_cog(Utils(bot))
log.info("Cog loaded: Utils")
diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py
index 56fcd63eb..5b115deaa 100644
--- a/bot/cogs/verification.py
+++ b/bot/cogs/verification.py
@@ -1,11 +1,13 @@
import logging
+from datetime import datetime
from discord import Message, NotFound, Object
-from discord.ext.commands import Bot, Context, command
+from discord.ext import tasks
+from discord.ext.commands import Bot, Cog, Context, command
-from bot.cogs.modlog import ModLog
-from bot.constants import Channels, Event, Roles
-from bot.decorators import in_channel, without_role
+from bot.cogs.moderation import ModLog
+from bot.constants import Bot as BotConfig, Channels, Event, Roles
+from bot.decorators import InChannelCheckFailure, in_channel, without_role
log = logging.getLogger(__name__)
@@ -14,8 +16,8 @@ Hello! Welcome to the server, and thanks for verifying yourself!
For your records, these are the documents you accepted:
-`1)` Our rules, here: <https://pythondiscord.com/about/rules>
-`2)` Our privacy policy, here: <https://pythondiscord.com/about/privacy> - you can find information on how to have \
+`1)` Our rules, here: <https://pythondiscord.com/pages/rules>
+`2)` Our privacy policy, here: <https://pythondiscord.com/pages/privacy> - you can find information on how to have \
your information removed here as well.
Feel free to review them at any point!
@@ -27,20 +29,27 @@ from time to time, you can send `!subscribe` to <#{Channels.bot}> at any time to
If you'd like to unsubscribe from the announcement notifications, simply send `!unsubscribe` to <#{Channels.bot}>.
"""
+PERIODIC_PING = (
+ f"@everyone To verify that you have read our rules, please type `{BotConfig.prefix}accept`."
+ f" Ping <@&{Roles.admin}> if you encounter any problems during the verification process."
+)
-class Verification:
- """
- User verification and role self-management
- """
+
+class Verification(Cog):
+ """User verification and role self-management."""
def __init__(self, bot: Bot):
self.bot = bot
+ self.periodic_ping.start()
@property
def mod_log(self) -> ModLog:
+ """Get currently loaded ModLog cog instance."""
return self.bot.get_cog("ModLog")
- async def on_message(self, message: Message):
+ @Cog.listener()
+ async def on_message(self, message: Message) -> None:
+ """Check new message event for messages to the checkpoint channel & process."""
if message.author.bot:
return # They're a bot, ignore
@@ -74,11 +83,8 @@ class Verification:
@command(name='accept', aliases=('verify', 'verified', 'accepted'), hidden=True)
@without_role(Roles.verified)
@in_channel(Channels.verification)
- async def accept_command(self, ctx: Context, *_): # We don't actually care about the args
- """
- Accept our rules and gain access to the rest of the server
- """
-
+ 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")
try:
@@ -97,11 +103,8 @@ class Verification:
@command(name='subscribe')
@in_channel(Channels.bot)
- async def subscribe_command(self, ctx: Context, *_): # We don't actually care about the args
- """
- Subscribe to announcement notifications by assigning yourself the role
- """
-
+ 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:
@@ -110,9 +113,8 @@ class Verification:
break
if has_role:
- return await ctx.send(
- f"{ctx.author.mention} You're already subscribed!",
- )
+ await ctx.send(f"{ctx.author.mention} You're already subscribed!")
+ return
log.debug(f"{ctx.author} called !subscribe. Assigning the 'Announcements' role.")
await ctx.author.add_roles(Object(Roles.announcements), reason="Subscribed to announcements")
@@ -125,11 +127,8 @@ class Verification:
@command(name='unsubscribe')
@in_channel(Channels.bot)
- async def unsubscribe_command(self, ctx: Context, *_): # We don't actually care about the args
- """
- Unsubscribe from announcement notifications by removing the role from yourself
- """
-
+ 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:
@@ -138,9 +137,8 @@ class Verification:
break
if not has_role:
- return await ctx.send(
- f"{ctx.author.mention} You're already unsubscribed!"
- )
+ await ctx.send(f"{ctx.author.mention} You're already unsubscribed!")
+ return
log.debug(f"{ctx.author} called !unsubscribe. Removing the 'Announcements' role.")
await ctx.author.remove_roles(Object(Roles.announcements), reason="Unsubscribed from announcements")
@@ -151,18 +149,50 @@ class Verification:
f"{ctx.author.mention} Unsubscribed from <#{Channels.announcements}> notifications."
)
- @staticmethod
- def __global_check(ctx: Context):
- """
- Block any command within the verification channel that is not !accept.
- """
+ # This cannot be static (must have a __func__ attribute).
+ async def cog_command_error(self, ctx: Context, error: Exception) -> None:
+ """Check for & ignore any InChannelCheckFailure."""
+ if isinstance(error, InChannelCheckFailure):
+ error.handled = True
+ @staticmethod
+ def bot_check(ctx: Context) -> bool:
+ """Block any command within the verification channel that is not !accept."""
if ctx.channel.id == Channels.verification:
return ctx.command.name == "accept"
else:
return True
+ @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)
+ need_to_post = True # True if a new message needs to be sent.
+
+ async for message in messages:
+ if message.author == self.bot.user and message.content == PERIODIC_PING:
+ delta = datetime.utcnow() - message.created_at # Time since last message.
+ if delta.days >= 7: # Message is older than a week.
+ await message.delete()
+ else:
+ need_to_post = False
+
+ break
+
+ if need_to_post:
+ await self.bot.get_channel(Channels.verification).send(PERIODIC_PING)
+
+ @periodic_ping.before_loop
+ async def before_ping(self) -> None:
+ """Only start the loop when the bot is ready."""
+ await self.bot.wait_until_ready()
+
+ def cog_unload(self) -> None:
+ """Cancel the periodic ping task when the cog is unloaded."""
+ self.periodic_ping.cancel()
+
-def setup(bot):
+def setup(bot: Bot) -> None:
+ """Verification cog load."""
bot.add_cog(Verification(bot))
log.info("Cog loaded: Verification")
diff --git a/bot/cogs/watchchannels/__init__.py b/bot/cogs/watchchannels/__init__.py
new file mode 100644
index 000000000..86e1050fa
--- /dev/null
+++ b/bot/cogs/watchchannels/__init__.py
@@ -0,0 +1,18 @@
+import logging
+
+from discord.ext.commands import Bot
+
+from .bigbrother import BigBrother
+from .talentpool import TalentPool
+
+
+log = logging.getLogger(__name__)
+
+
+def setup(bot: Bot) -> None:
+ """Monitoring cogs load."""
+ bot.add_cog(BigBrother(bot))
+ log.info("Cog loaded: BigBrother")
+
+ bot.add_cog(TalentPool(bot))
+ log.info("Cog loaded: TalentPool")
diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py
new file mode 100644
index 000000000..c516508ca
--- /dev/null
+++ b/bot/cogs/watchchannels/bigbrother.py
@@ -0,0 +1,118 @@
+import logging
+from collections import ChainMap
+from typing import Union
+
+from discord import User
+from discord.ext.commands import Bot, Cog, Context, group
+
+from bot.cogs.moderation.utils import post_infraction
+from bot.constants import Channels, Roles, Webhooks
+from bot.decorators import with_role
+from .watchchannel import WatchChannel, proxy_user
+
+log = logging.getLogger(__name__)
+
+
+class BigBrother(WatchChannel, Cog, name="Big Brother"):
+ """Monitors users by relaying their messages to a watch channel to assist with moderation."""
+
+ def __init__(self, bot: Bot) -> None:
+ super().__init__(
+ bot,
+ destination=Channels.big_brother_logs,
+ webhook_id=Webhooks.big_brother,
+ api_endpoint='bot/infractions',
+ api_default_params={'active': 'true', 'type': 'watch', 'ordering': '-inserted_at'},
+ logger=log
+ )
+
+ @group(name='bigbrother', aliases=('bb',), invoke_without_command=True)
+ @with_role(Roles.owner, Roles.admin, Roles.moderator)
+ async def bigbrother_group(self, ctx: Context) -> None:
+ """Monitors users by relaying their messages to the Big Brother watch channel."""
+ await ctx.invoke(self.bot.get_command("help"), "bigbrother")
+
+ @bigbrother_group.command(name='watched', aliases=('all', 'list'))
+ @with_role(Roles.owner, Roles.admin, Roles.moderator)
+ async def watched_command(self, ctx: Context, update_cache: bool = True) -> None:
+ """
+ Shows the users that are currently being monitored by Big Brother.
+
+ The optional kwarg `update_cache` can be used to update the user
+ cache using the API before listing the users.
+ """
+ await self.list_watched_users(ctx, update_cache)
+
+ @bigbrother_group.command(name='watch', aliases=('w',))
+ @with_role(Roles.owner, Roles.admin, Roles.moderator)
+ async def watch_command(self, ctx: Context, user: Union[User, proxy_user], *, reason: str) -> None:
+ """
+ Relay messages sent by the given `user` to the `#big-brother` channel.
+
+ 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.
+ """
+ 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
+
+ if not await self.fetch_user_cache():
+ await ctx.send(f":x: Updating the user cache failed, can't watch user {user}")
+ return
+
+ if user.id in self.watched_users:
+ await ctx.send(":x: The specified user is already being watched.")
+ return
+
+ response = await post_infraction(ctx, user, 'watch', reason, hidden=True)
+
+ if response is not None:
+ self.watched_users[user.id] = response
+ msg = f":white_check_mark: Messages sent by {user} will now be relayed to Big Brother."
+
+ history = await self.bot.api_client.get(
+ self.api_endpoint,
+ params={
+ "user__id": str(user.id),
+ "active": "false",
+ 'type': 'watch',
+ 'ordering': '-inserted_at'
+ }
+ )
+
+ if len(history) > 1:
+ total = f"({len(history) // 2} previous infractions in total)"
+ end_reason = history[0]["reason"]
+ start_reason = f"Watched: {history[1]['reason']}"
+ msg += f"\n\nUser's previous watch reasons {total}:```{start_reason}\n\n{end_reason}```"
+ else:
+ msg = ":x: Failed to post the infraction: response was empty."
+
+ await ctx.send(msg)
+
+ @bigbrother_group.command(name='unwatch', aliases=('uw',))
+ @with_role(Roles.owner, Roles.admin, Roles.moderator)
+ async def unwatch_command(self, ctx: Context, user: Union[User, proxy_user], *, reason: str) -> None:
+ """Stop relaying messages by the given `user`."""
+ active_watches = await self.bot.api_client.get(
+ self.api_endpoint,
+ params=ChainMap(
+ self.api_default_params,
+ {"user__id": str(user.id)}
+ )
+ )
+ if active_watches:
+ [infraction] = active_watches
+
+ await self.bot.api_client.patch(
+ f"{self.api_endpoint}/{infraction['id']}",
+ json={'active': False}
+ )
+
+ 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)
+ else:
+ await ctx.send(":x: The specified user is currently not being watched.")
diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py
new file mode 100644
index 000000000..176c6f760
--- /dev/null
+++ b/bot/cogs/watchchannels/talentpool.py
@@ -0,0 +1,249 @@
+import logging
+import textwrap
+from collections import ChainMap
+from typing import Union
+
+from discord import Color, Embed, Member, User
+from discord.ext.commands import Bot, Cog, Context, group
+
+from bot.api import ResponseCodeError
+from bot.constants import Channels, Guild, Roles, Webhooks
+from bot.decorators import with_role
+from bot.pagination import LinePaginator
+from bot.utils import time
+from .watchchannel import WatchChannel, proxy_user
+
+log = logging.getLogger(__name__)
+STAFF_ROLES = Roles.owner, Roles.admin, Roles.moderator, Roles.helpers # <- In constants after the merge?
+
+
+class TalentPool(WatchChannel, Cog, name="Talentpool"):
+ """Relays messages of helper candidates to a watch channel to observe them."""
+
+ def __init__(self, bot: Bot) -> None:
+ super().__init__(
+ bot,
+ destination=Channels.talent_pool,
+ webhook_id=Webhooks.talent_pool,
+ api_endpoint='bot/nominations',
+ api_default_params={'active': 'true', 'ordering': '-inserted_at'},
+ logger=log,
+ )
+
+ @group(name='talentpool', aliases=('tp', 'talent', 'nomination', 'n'), invoke_without_command=True)
+ @with_role(Roles.owner, Roles.admin, Roles.moderator)
+ async def nomination_group(self, ctx: Context) -> None:
+ """Highlights the activity of helper nominees by relaying their messages to the talent pool channel."""
+ await ctx.invoke(self.bot.get_command("help"), "talentpool")
+
+ @nomination_group.command(name='watched', aliases=('all', 'list'))
+ @with_role(Roles.owner, Roles.admin, Roles.moderator)
+ async def watched_command(self, ctx: Context, update_cache: bool = True) -> None:
+ """
+ Shows the users that are currently being monitored in the talent pool.
+
+ The optional kwarg `update_cache` can be used to update the user
+ cache using the API before listing the users.
+ """
+ await self.list_watched_users(ctx, update_cache)
+
+ @nomination_group.command(name='watch', aliases=('w', 'add', 'a'))
+ @with_role(Roles.owner, Roles.admin, Roles.moderator)
+ async def watch_command(self, ctx: Context, user: Union[Member, User, proxy_user], *, reason: str) -> None:
+ """
+ Relay messages sent by the given `user` to the `#talent-pool` channel.
+
+ A `reason` for adding the user to the talent pool is required and will be displayed
+ in the header when relaying messages of this user to the channel.
+ """
+ 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
+
+ if isinstance(user, Member) and any(role.id in STAFF_ROLES for role in user.roles):
+ await ctx.send(f":x: Nominating staff members, eh? Here's a cookie :cookie:")
+ return
+
+ if not await self.fetch_user_cache():
+ await ctx.send(f":x: Failed to update the user cache; can't add {user}")
+ return
+
+ if user.id in self.watched_users:
+ await ctx.send(":x: The specified user is already being watched in the talent pool")
+ return
+
+ # Manual request with `raise_for_status` as False because we want the actual response
+ session = self.bot.api_client.session
+ url = self.bot.api_client._url_for(self.api_endpoint)
+ kwargs = {
+ 'json': {
+ 'actor': ctx.author.id,
+ 'reason': reason,
+ 'user': user.id
+ },
+ 'raise_for_status': False,
+ }
+ async with session.post(url, **kwargs) as resp:
+ response_data = await resp.json()
+
+ if resp.status == 400 and response_data.get('user', False):
+ await ctx.send(":x: The specified user can't be found in the database tables")
+ return
+ else:
+ resp.raise_for_status()
+
+ self.watched_users[user.id] = response_data
+ msg = f":white_check_mark: Messages sent by {user} will now be relayed to the talent pool channel"
+
+ history = await self.bot.api_client.get(
+ self.api_endpoint,
+ params={
+ "user__id": str(user.id),
+ "active": "false",
+ "ordering": "-inserted_at"
+ }
+ )
+
+ if history:
+ total = f"({len(history)} previous nominations in total)"
+ start_reason = f"Watched: {history[0]['reason']}"
+ end_reason = f"Unwatched: {history[0]['end_reason']}"
+ msg += f"\n\nUser's previous watch reasons {total}:```{start_reason}\n\n{end_reason}```"
+
+ await ctx.send(msg)
+
+ @nomination_group.command(name='history', aliases=('info', 'search'))
+ @with_role(Roles.owner, Roles.admin, Roles.moderator)
+ async def history_command(self, ctx: Context, user: Union[User, proxy_user]) -> None:
+ """Shows the specified user's nomination history."""
+ result = await self.bot.api_client.get(
+ self.api_endpoint,
+ params={
+ 'user__id': str(user.id),
+ 'ordering': "-active,-inserted_at"
+ }
+ )
+ if not result:
+ await ctx.send(":warning: This user has never been nominated")
+ return
+
+ embed = Embed(
+ title=f"Nominations for {user.display_name} `({user.id})`",
+ color=Color.blue()
+ )
+ lines = [self._nomination_to_string(nomination) for nomination in result]
+ await LinePaginator.paginate(
+ lines,
+ ctx=ctx,
+ embed=embed,
+ empty=True,
+ max_lines=3,
+ max_size=1000
+ )
+
+ @nomination_group.command(name='unwatch', aliases=('end', ))
+ @with_role(Roles.owner, Roles.admin, Roles.moderator)
+ async def unwatch_command(self, ctx: Context, user: Union[User, proxy_user], *, reason: str) -> None:
+ """
+ Ends the active nomination of the specified user with the given reason.
+
+ Providing a `reason` is required.
+ """
+ active_nomination = await self.bot.api_client.get(
+ self.api_endpoint,
+ params=ChainMap(
+ self.api_default_params,
+ {"user__id": str(user.id)}
+ )
+ )
+
+ if not active_nomination:
+ await ctx.send(":x: The specified user does not have an active nomination")
+ return
+
+ [nomination] = active_nomination
+ await self.bot.api_client.patch(
+ f"{self.api_endpoint}/{nomination['id']}",
+ json={'end_reason': reason, 'active': False}
+ )
+ await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed")
+ self._remove_user(user.id)
+
+ @nomination_group.group(name='edit', aliases=('e',), invoke_without_command=True)
+ @with_role(Roles.owner, Roles.admin, Roles.moderator)
+ async def nomination_edit_group(self, ctx: Context) -> None:
+ """Commands to edit nominations."""
+ await ctx.invoke(self.bot.get_command("help"), "talentpool", "edit")
+
+ @nomination_edit_group.command(name='reason')
+ @with_role(Roles.owner, Roles.admin, Roles.moderator)
+ async def edit_reason_command(self, ctx: Context, nomination_id: int, *, reason: str) -> None:
+ """
+ Edits the reason/unnominate reason for the nomination with the given `id` depending on the status.
+
+ If the nomination is active, the reason for nominating the user will be edited;
+ If the nomination is no longer active, the reason for ending the nomination will be edited instead.
+ """
+ try:
+ nomination = await self.bot.api_client.get(f"{self.api_endpoint}/{nomination_id}")
+ except ResponseCodeError as e:
+ if e.response.status == 404:
+ self.log.trace(f"Nomination API 404: Can't nomination with id {nomination_id}")
+ await ctx.send(f":x: Can't find a nomination with id `{nomination_id}`")
+ return
+ else:
+ raise
+
+ field = "reason" if nomination["active"] else "end_reason"
+
+ self.log.trace(f"Changing {field} for nomination with id {nomination_id} to {reason}")
+
+ await self.bot.api_client.patch(
+ f"{self.api_endpoint}/{nomination_id}",
+ json={field: reason}
+ )
+
+ await ctx.send(f":white_check_mark: Updated the {field} of the nomination!")
+
+ def _nomination_to_string(self, nomination_object: dict) -> str:
+ """Creates a string representation of a nomination."""
+ guild = self.bot.get_guild(Guild.id)
+
+ actor_id = nomination_object["actor"]
+ actor = guild.get_member(actor_id)
+
+ active = nomination_object["active"]
+ log.debug(active)
+ log.debug(type(nomination_object["inserted_at"]))
+
+ start_date = time.format_infraction(nomination_object["inserted_at"])
+ if active:
+ lines = textwrap.dedent(
+ f"""
+ ===============
+ Status: **Active**
+ Date: {start_date}
+ Actor: {actor.mention if actor else actor_id}
+ Reason: {nomination_object["reason"]}
+ Nomination ID: `{nomination_object["id"]}`
+ ===============
+ """
+ )
+ else:
+ end_date = time.format_infraction(nomination_object["ended_at"])
+ lines = textwrap.dedent(
+ f"""
+ ===============
+ Status: Inactive
+ Date: {start_date}
+ Actor: {actor.mention if actor else actor_id}
+ Reason: {nomination_object["reason"]}
+
+ End date: {end_date}
+ Unwatch reason: {nomination_object["end_reason"]}
+ Nomination ID: `{nomination_object["id"]}`
+ ===============
+ """
+ )
+
+ return lines.strip()
diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py
new file mode 100644
index 000000000..0bf75a924
--- /dev/null
+++ b/bot/cogs/watchchannels/watchchannel.py
@@ -0,0 +1,346 @@
+import asyncio
+import logging
+import re
+import textwrap
+from abc import abstractmethod
+from collections import defaultdict, deque
+from dataclasses import dataclass
+from typing import Optional
+
+import dateutil.parser
+import discord
+from discord import Color, Embed, HTTPException, Message, Object, errors
+from discord.ext.commands import BadArgument, Bot, Cog, Context
+
+from bot.api import ResponseCodeError
+from bot.cogs.moderation import ModLog
+from bot.constants import BigBrother as BigBrotherConfig, Guild as GuildConfig, Icons
+from bot.pagination import LinePaginator
+from bot.utils import CogABCMeta, messages
+from bot.utils.time import time_since
+
+log = logging.getLogger(__name__)
+
+URL_RE = re.compile(r"(https?://[^\s]+)")
+
+
+def proxy_user(user_id: str) -> Object:
+ """A proxy user object that mocks a real User instance for when the later is not available."""
+ try:
+ user_id = int(user_id)
+ except ValueError:
+ raise BadArgument
+
+ user = Object(user_id)
+ user.mention = user.id
+ user.display_name = f"<@{user.id}>"
+ user.avatar_url_as = lambda static_format: None
+ user.bot = False
+
+ return user
+
+
+@dataclass
+class MessageHistory:
+ """Represents a watch channel's message history."""
+
+ last_author: Optional[int] = None
+ last_channel: Optional[int] = None
+ message_count: int = 0
+
+
+class WatchChannel(metaclass=CogABCMeta):
+ """ABC with functionality for relaying users' messages to a certain channel."""
+
+ @abstractmethod
+ def __init__(
+ self,
+ bot: Bot,
+ destination: int,
+ webhook_id: int,
+ api_endpoint: str,
+ api_default_params: dict,
+ logger: logging.Logger
+ ) -> None:
+ self.bot = bot
+
+ self.destination = destination # E.g., Channels.big_brother_logs
+ self.webhook_id = webhook_id # E.g., Webhooks.big_brother
+ self.api_endpoint = api_endpoint # E.g., 'bot/infractions'
+ self.api_default_params = api_default_params # E.g., {'active': 'true', 'type': 'watch'}
+ self.log = logger # Logger of the child cog for a correct name in the logs
+
+ self._consume_task = None
+ self.watched_users = defaultdict(dict)
+ self.message_queue = defaultdict(lambda: defaultdict(deque))
+ self.consumption_queue = {}
+ self.retries = 5
+ self.retry_delay = 10
+ self.channel = None
+ self.webhook = None
+ self.message_history = MessageHistory()
+
+ self._start = self.bot.loop.create_task(self.start_watchchannel())
+
+ @property
+ def modlog(self) -> ModLog:
+ """Provides access to the ModLog cog for alert purposes."""
+ return self.bot.get_cog("ModLog")
+
+ @property
+ def consuming_messages(self) -> bool:
+ """Checks if a consumption task is currently running."""
+ if self._consume_task is None:
+ return False
+
+ if self._consume_task.done():
+ exc = self._consume_task.exception()
+ if exc:
+ self.log.exception(
+ f"The message queue consume task has failed with:",
+ exc_info=exc
+ )
+ return False
+
+ return True
+
+ async def start_watchchannel(self) -> None:
+ """Starts the watch channel by getting the channel, webhook, and user cache ready."""
+ await self.bot.wait_until_ready()
+
+ try:
+ self.channel = await self.bot.fetch_channel(self.destination)
+ except HTTPException:
+ self.log.exception(f"Failed to retrieve the text channel with id `{self.destination}`")
+
+ try:
+ self.webhook = await self.bot.fetch_webhook(self.webhook_id)
+ except discord.HTTPException:
+ self.log.exception(f"Failed to fetch webhook with id `{self.webhook_id}`")
+
+ if self.channel is None or self.webhook is None:
+ self.log.error("Failed to start the watch channel; unloading the cog.")
+
+ message = textwrap.dedent(
+ f"""
+ An error occurred while loading the text channel or webhook.
+
+ TextChannel: {"**Failed to load**" if self.channel is None else "Loaded successfully"}
+ Webhook: {"**Failed to load**" if self.webhook is None else "Loaded successfully"}
+
+ The Cog has been unloaded.
+ """
+ )
+
+ await self.modlog.send_log_message(
+ title=f"Error: Failed to initialize the {self.__class__.__name__} watch channel",
+ text=message,
+ ping_everyone=True,
+ icon_url=Icons.token_removed,
+ colour=Color.red()
+ )
+
+ self.bot.remove_cog(self.__class__.__name__)
+ return
+
+ if not await self.fetch_user_cache():
+ await self.modlog.send_log_message(
+ title=f"Warning: Failed to retrieve user cache for the {self.__class__.__name__} watch channel",
+ text="Could not retrieve the list of watched users from the API and messages will not be relayed.",
+ ping_everyone=True,
+ icon_url=Icons.token_removed,
+ colour=Color.red()
+ )
+
+ async def fetch_user_cache(self) -> bool:
+ """
+ Fetches watched users from the API and updates the watched user cache accordingly.
+
+ This function returns `True` if the update succeeded.
+ """
+ try:
+ data = await self.bot.api_client.get(self.api_endpoint, params=self.api_default_params)
+ except ResponseCodeError as err:
+ self.log.exception(f"Failed to fetch the watched users from the API", exc_info=err)
+ return False
+
+ self.watched_users = defaultdict(dict)
+
+ for entry in data:
+ user_id = entry.pop('user')
+ self.watched_users[user_id] = entry
+
+ return True
+
+ @Cog.listener()
+ async def on_message(self, msg: Message) -> None:
+ """Queues up messages sent by watched users."""
+ if msg.author.id in self.watched_users:
+ if not self.consuming_messages:
+ self._consume_task = self.bot.loop.create_task(self.consume_messages())
+
+ self.log.trace(f"Received message: {msg.content} ({len(msg.attachments)} attachments)")
+ self.message_queue[msg.author.id][msg.channel.id].append(msg)
+
+ async def consume_messages(self, delay_consumption: bool = True) -> None:
+ """Consumes the message queues to log watched users' messages."""
+ if delay_consumption:
+ self.log.trace(f"Sleeping {BigBrotherConfig.log_delay} seconds before consuming message queue")
+ await asyncio.sleep(BigBrotherConfig.log_delay)
+
+ self.log.trace(f"Started consuming the message queue")
+
+ # If the previous consumption Task failed, first consume the existing comsumption_queue
+ if not self.consumption_queue:
+ self.consumption_queue = self.message_queue.copy()
+ self.message_queue.clear()
+
+ for user_channel_queues in self.consumption_queue.values():
+ for channel_queue in user_channel_queues.values():
+ while channel_queue:
+ msg = channel_queue.popleft()
+
+ self.log.trace(f"Consuming message {msg.id} ({len(msg.attachments)} attachments)")
+ await self.relay_message(msg)
+
+ self.consumption_queue.clear()
+
+ if self.message_queue:
+ self.log.trace("Channel queue not empty: Continuing consuming queues")
+ self._consume_task = self.bot.loop.create_task(self.consume_messages(delay_consumption=False))
+ else:
+ self.log.trace("Done consuming messages.")
+
+ async def webhook_send(
+ self,
+ content: Optional[str] = None,
+ username: Optional[str] = None,
+ avatar_url: Optional[str] = None,
+ embed: Optional[Embed] = None,
+ ) -> None:
+ """Sends a message to the webhook with the specified kwargs."""
+ try:
+ await self.webhook.send(content=content, username=username, avatar_url=avatar_url, embed=embed)
+ except discord.HTTPException as exc:
+ self.log.exception(
+ f"Failed to send a message to the webhook",
+ exc_info=exc
+ )
+
+ async def relay_message(self, msg: Message) -> None:
+ """Relays the message to the relevant watch channel."""
+ limit = BigBrotherConfig.header_message_limit
+
+ if (
+ msg.author.id != self.message_history.last_author
+ or msg.channel.id != self.message_history.last_channel
+ or self.message_history.message_count >= limit
+ ):
+ self.message_history = MessageHistory(last_author=msg.author.id, last_channel=msg.channel.id)
+
+ await self.send_header(msg)
+
+ cleaned_content = msg.clean_content
+
+ if cleaned_content:
+ # Put all non-media URLs in a code block to prevent embeds
+ media_urls = {embed.url for embed in msg.embeds if embed.type in ("image", "video")}
+ for url in URL_RE.findall(cleaned_content):
+ if url not in media_urls:
+ cleaned_content = cleaned_content.replace(url, f"`{url}`")
+ await self.webhook_send(
+ cleaned_content,
+ username=msg.author.display_name,
+ avatar_url=msg.author.avatar_url
+ )
+
+ if msg.attachments:
+ try:
+ await messages.send_attachments(msg, self.webhook)
+ except (errors.Forbidden, errors.NotFound):
+ e = Embed(
+ description=":x: **This message contained an attachment, but it could not be retrieved**",
+ color=Color.red()
+ )
+ await self.webhook_send(
+ embed=e,
+ username=msg.author.display_name,
+ avatar_url=msg.author.avatar_url
+ )
+ except discord.HTTPException as exc:
+ self.log.exception(
+ f"Failed to send an attachment to the webhook",
+ exc_info=exc
+ )
+
+ self.message_history.message_count += 1
+
+ async def send_header(self, msg: Message) -> None:
+ """Sends a header embed with information about the relayed messages to the watch channel."""
+ user_id = msg.author.id
+
+ guild = self.bot.get_guild(GuildConfig.id)
+ actor = guild.get_member(self.watched_users[user_id]['actor'])
+ actor = actor.display_name if actor else self.watched_users[user_id]['actor']
+
+ inserted_at = self.watched_users[user_id]['inserted_at']
+ time_delta = self._get_time_delta(inserted_at)
+
+ reason = self.watched_users[user_id]['reason']
+
+ embed = Embed(description=f"{msg.author.mention} in [#{msg.channel.name}]({msg.jump_url})")
+ embed.set_footer(text=f"Added {time_delta} by {actor} | Reason: {reason}")
+
+ await self.webhook_send(embed=embed, username=msg.author.display_name, avatar_url=msg.author.avatar_url)
+
+ async def list_watched_users(self, ctx: Context, update_cache: bool = True) -> None:
+ """
+ Gives an overview of the watched user list for this channel.
+
+ The optional kwarg `update_cache` specifies whether the cache should
+ be refreshed by polling the API.
+ """
+ if update_cache:
+ if not await self.fetch_user_cache():
+ await ctx.send(f":x: Failed to update {self.__class__.__name__} user cache, serving from cache")
+ update_cache = False
+
+ lines = []
+ for user_id, user_data in self.watched_users.items():
+ inserted_at = user_data['inserted_at']
+ time_delta = self._get_time_delta(inserted_at)
+ lines.append(f"• <@{user_id}> (added {time_delta})")
+
+ lines = lines or ("There's nothing here yet.",)
+ embed = Embed(
+ title=f"{self.__class__.__name__} watched users ({'updated' if update_cache else 'cached'})",
+ color=Color.blue()
+ )
+ await LinePaginator.paginate(lines, ctx, embed, empty=False)
+
+ @staticmethod
+ def _get_time_delta(time_string: str) -> str:
+ """Returns the time in human-readable time delta format."""
+ date_time = dateutil.parser.isoparse(time_string).replace(tzinfo=None)
+ time_delta = time_since(date_time, precision="minutes", max_units=1)
+
+ return time_delta
+
+ def _remove_user(self, user_id: int) -> None:
+ """Removes a user from a watch channel."""
+ self.watched_users.pop(user_id, None)
+ self.message_queue.pop(user_id, None)
+ self.consumption_queue.pop(user_id, None)
+
+ def cog_unload(self) -> None:
+ """Takes care of unloading the cog and canceling the consumption task."""
+ self.log.trace(f"Unloading the cog")
+ if self._consume_task and not self._consume_task.done():
+ self._consume_task.cancel()
+ try:
+ self._consume_task.result()
+ except asyncio.CancelledError as e:
+ self.log.exception(
+ f"The consume task was canceled. Messages may be lost.",
+ exc_info=e
+ )
diff --git a/bot/cogs/wolfram.py b/bot/cogs/wolfram.py
index c36ef6075..ab0ed2472 100644
--- a/bot/cogs/wolfram.py
+++ b/bot/cogs/wolfram.py
@@ -1,15 +1,17 @@
import logging
from io import BytesIO
-from typing import List, Optional, Tuple
+from typing import Callable, List, Optional, Tuple
from urllib import parse
import discord
+from dateutil.relativedelta import relativedelta
from discord import Embed
from discord.ext import commands
-from discord.ext.commands import BucketType, Context, check, group
+from discord.ext.commands import Bot, BucketType, Cog, Context, check, group
-from bot.constants import Colours, Roles, Wolfram
+from bot.constants import Colours, STAFF_ROLES, Wolfram
from bot.pagination import ImagePaginator
+from bot.utils.time import humanize_delta
log = logging.getLogger(__name__)
@@ -18,7 +20,6 @@ DEFAULT_OUTPUT_FORMAT = "JSON"
QUERY = "http://api.wolframalpha.com/v2/{request}?{data}"
WOLF_IMAGE = "https://www.symbols.com/gi.php?type=1&id=2886&i=1"
-COOLDOWN_IGNORERS = Roles.moderator, Roles.owner, Roles.admin, Roles.helpers
MAX_PODS = 20
# Allows for 10 wolfram calls pr user pr day
@@ -36,18 +37,7 @@ async def send_embed(
img_url: str = None,
f: discord.File = None
) -> None:
- """
- Generates an embed with wolfram as the author, with message_txt as description,
- adds custom colour if specified, a footer and image (could be a file with f param) and sends
- the embed through ctx
- :param ctx: Context
- :param message_txt: str - Message to be sent
- :param colour: int - Default: Colours.soft_red - Colour of embed
- :param footer: str - Default: None - Adds a footer to the embed
- :param img_url:str - Default: None - Adds an image to the embed
- :param f: discord.File - Default: None - Add a file to the msg, often attached as image to embed
- """
-
+ """Generate & send a response embed with Wolfram as the author."""
embed = Embed(colour=colour)
embed.description = message_txt
embed.set_author(name="Wolfram Alpha",
@@ -62,27 +52,25 @@ async def send_embed(
await ctx.send(embed=embed, file=f)
-def custom_cooldown(*ignore: List[int]) -> check:
+def custom_cooldown(*ignore: List[int]) -> Callable:
"""
- Custom cooldown mapping that applies a specific requests per day to users.
- Staff is ignored by the user cooldown, however the cooldown implements a
- total amount of uses per day for the entire guild. (Configurable in configs)
+ Implement per-user and per-guild cooldowns for requests to the Wolfram API.
- :param ignore: List[int] -- list of ids of roles to be ignored by user cooldown
- :return: check
+ A list of roles may be provided to ignore the per-user cooldown
"""
-
async def predicate(ctx: Context) -> bool:
user_bucket = usercd.get_bucket(ctx.message)
- if ctx.author.top_role.id not in ignore:
+ if all(role.id not in ignore for role in ctx.author.roles):
user_rate = user_bucket.update_rate_limit()
if user_rate:
# Can't use api; cause: member limit
+ delta = relativedelta(seconds=int(user_rate))
+ cooldown = humanize_delta(delta)
message = (
"You've used up your limit for Wolfram|Alpha requests.\n"
- f"Cooldown: {int(user_rate)}"
+ f"Cooldown: {cooldown}"
)
await send_embed(ctx, message)
return False
@@ -106,8 +94,8 @@ def custom_cooldown(*ignore: List[int]) -> check:
return check(predicate)
-async def get_pod_pages(ctx, bot, query: str) -> Optional[List[Tuple]]:
- # Give feedback that the bot is working.
+async def get_pod_pages(ctx: Context, bot: Bot, query: str) -> Optional[List[Tuple]]:
+ """Get the Wolfram API pod pages for the provided query."""
async with ctx.channel.typing():
url_str = parse.urlencode({
"input": query,
@@ -122,17 +110,27 @@ async def get_pod_pages(ctx, bot, query: str) -> Optional[List[Tuple]]:
result = json["queryresult"]
- if not result["success"]:
- message = f"I couldn't find anything for {query}."
- await send_embed(ctx, message)
- return
-
if result["error"]:
+ # API key not set up correctly
+ if result["error"]["msg"] == "Invalid appid":
+ message = "Wolfram API key is invalid or missing."
+ log.warning(
+ "API key seems to be missing, or invalid when "
+ f"processing a wolfram request: {url_str}, Response: {json}"
+ )
+ await send_embed(ctx, message)
+ return
+
message = "Something went wrong internally with your request, please notify staff!"
log.warning(f"Something went wrong getting a response from wolfram: {url_str}, Response: {json}")
await send_embed(ctx, message)
return
+ if not result["success"]:
+ message = f"I couldn't find anything for {query}."
+ await send_embed(ctx, message)
+ return
+
if not result["numpods"]:
message = "Could not find any results."
await send_embed(ctx, message)
@@ -150,25 +148,16 @@ async def get_pod_pages(ctx, bot, query: str) -> Optional[List[Tuple]]:
return pages
-class Wolfram:
- """
- Commands for interacting with the Wolfram|Alpha API.
- """
+class Wolfram(Cog):
+ """Commands for interacting with the Wolfram|Alpha API."""
def __init__(self, bot: commands.Bot):
self.bot = bot
@group(name="wolfram", aliases=("wolf", "wa"), invoke_without_command=True)
- @custom_cooldown(*COOLDOWN_IGNORERS)
+ @custom_cooldown(*STAFF_ROLES)
async def wolfram_command(self, ctx: Context, *, query: str) -> None:
- """
- Requests all answers on a single image,
- sends an image of all related pods
-
- :param ctx: Context
- :param query: str - string request to api
- """
-
+ """Requests all answers on a single image, sends an image of all related pods."""
url_str = parse.urlencode({
"i": query,
"appid": APPID,
@@ -192,6 +181,10 @@ class Wolfram:
message = "No input found"
footer = ""
color = Colours.soft_red
+ elif status == 403:
+ message = "Wolfram API key is invalid or missing."
+ footer = ""
+ color = Colours.soft_red
else:
message = ""
footer = "View original for a bigger picture."
@@ -201,16 +194,13 @@ class Wolfram:
await send_embed(ctx, message, color, footer=footer, img_url=image_url, f=f)
@wolfram_command.command(name="page", aliases=("pa", "p"))
- @custom_cooldown(*COOLDOWN_IGNORERS)
+ @custom_cooldown(*STAFF_ROLES)
async def wolfram_page_command(self, ctx: Context, *, query: str) -> None:
"""
- Requests a drawn image of given query
- Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc
+ Requests a drawn image of given query.
- :param ctx: Context
- :param query: str - string request to api
+ Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc.
"""
-
pages = await get_pod_pages(ctx, self.bot, query)
if not pages:
@@ -225,16 +215,13 @@ class Wolfram:
await ImagePaginator.paginate(pages, ctx, embed)
@wolfram_command.command(name="cut", aliases=("c",))
- @custom_cooldown(*COOLDOWN_IGNORERS)
- async def wolfram_cut_command(self, ctx, *, query: str) -> None:
+ @custom_cooldown(*STAFF_ROLES)
+ async def wolfram_cut_command(self, ctx: Context, *, query: str) -> None:
"""
- Requests a drawn image of given query
- Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc
+ Requests a drawn image of given query.
- :param ctx: Context
- :param query: str - string request to api
+ Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc.
"""
-
pages = await get_pod_pages(ctx, self.bot, query)
if not pages:
@@ -248,16 +235,9 @@ class Wolfram:
await send_embed(ctx, page[0], colour=Colours.soft_orange, img_url=page[1])
@wolfram_command.command(name="short", aliases=("sh", "s"))
- @custom_cooldown(*COOLDOWN_IGNORERS)
+ @custom_cooldown(*STAFF_ROLES)
async def wolfram_short_command(self, ctx: Context, *, query: str) -> None:
- """
- Requests an answer to a simple question
- Responds in plaintext
-
- :param ctx: Context
- :param query: str - string request to api
- """
-
+ """Requests an answer to a simple question."""
url_str = parse.urlencode({
"i": query,
"appid": APPID,
@@ -273,10 +253,12 @@ class Wolfram:
if status == 501:
message = "Failed to get response"
color = Colours.soft_red
-
elif status == 400:
message = "No input found"
color = Colours.soft_red
+ elif response_text == "Error 1: Invalid appid":
+ message = "Wolfram API key is invalid or missing."
+ color = Colours.soft_red
else:
message = response_text
color = Colours.soft_orange
@@ -285,5 +267,6 @@ class Wolfram:
def setup(bot: commands.Bot) -> None:
+ """Wolfram cog load."""
bot.add_cog(Wolfram(bot))
log.info("Cog loaded: Wolfram")
diff --git a/bot/constants.py b/bot/constants.py
index 0bd950a7d..1deeaa3b8 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -201,9 +201,15 @@ class Filter(metaclass=YAMLGetter):
filter_zalgo: bool
filter_invites: bool
filter_domains: bool
+ watch_rich_embeds: bool
watch_words: bool
watch_tokens: bool
+ # Notifications are not expected for "watchlist" type filters
+ notify_user_zalgo: bool
+ notify_user_invites: bool
+ notify_user_domains: bool
+
ping_everyone: bool
guild_invite_whitelist: List[int]
domain_blacklist: List[str]
@@ -310,6 +316,13 @@ class CleanMessages(metaclass=YAMLGetter):
message_limit: int
+class Categories(metaclass=YAMLGetter):
+ section = "guild"
+ subsection = "categories"
+
+ python_help: int
+
+
class Channels(metaclass=YAMLGetter):
section = "guild"
subsection = "channels"
@@ -319,6 +332,7 @@ class Channels(metaclass=YAMLGetter):
big_brother_logs: int
bot: int
checkpoint_test: int
+ defcon: int
devlog: int
devtest: int
help_0: int
@@ -327,18 +341,31 @@ class Channels(metaclass=YAMLGetter):
help_3: int
help_4: int
help_5: int
+ help_6: int
+ help_7: int
helpers: int
message_log: int
mod_alerts: int
modlog: int
+ off_topic_0: int
off_topic_1: int
off_topic_2: int
- off_topic_3: int
python: int
reddit: int
+ talent_pool: int
+ userlog: int
+ user_event_a: int
verification: int
+class Webhooks(metaclass=YAMLGetter):
+ section = "guild"
+ subsection = "webhooks"
+
+ talent_pool: int
+ big_brother: int
+
+
class Roles(metaclass=YAMLGetter):
section = "guild"
subsection = "roles"
@@ -347,14 +374,16 @@ class Roles(metaclass=YAMLGetter):
announcements: int
champion: int
contributor: int
- developer: int
- devops: int
+ core_developer: int
+ helpers: int
jammer: int
moderator: int
muted: int
owner: int
- verified: int
- helpers: int
+ partners: int
+ rockstars: int
+ team_leader: int
+ verified: int # This is the Developers role on PyDis, here named verified for readability reasons.
class Guild(metaclass=YAMLGetter):
@@ -367,11 +396,7 @@ class Guild(metaclass=YAMLGetter):
class Keys(metaclass=YAMLGetter):
section = "keys"
- deploy_bot: str
- deploy_site: str
- omdb: str
site_api: str
- youtube: str
class URLs(metaclass=YAMLGetter):
@@ -386,27 +411,18 @@ class URLs(metaclass=YAMLGetter):
# Misc endpoints
bot_avatar: str
- deploy: str
- gitlab_bot_repo: str
- omdb: str
- status: str
+ github_bot_repo: str
# Site endpoints
site: str
site_api: str
- site_facts_api: str
- site_clean_api: str
site_superstarify_api: str
- site_idioms_api: str
site_logs_api: str
site_logs_view: str
- site_names_api: str
- site_quiz_api: str
site_reminders_api: str
site_reminders_user_api: str
site_schema: str
site_settings_api: str
- site_special_api: str
site_tags_api: str
site_user_api: str
site_user_complete_api: str
@@ -451,6 +467,21 @@ class BigBrother(metaclass=YAMLGetter):
header_message_limit: int
+class Free(metaclass=YAMLGetter):
+ section = 'free'
+
+ activity_timeout: int
+ cooldown_rate: int
+ cooldown_per: float
+
+
+class RedirectOutput(metaclass=YAMLGetter):
+ section = 'redirect_output'
+
+ delete_invocation: bool
+ delete_delay: int
+
+
# Debug mode
DEBUG_MODE = True if 'local' in os.environ.get("SITE_URL", "local") else False
@@ -458,6 +489,11 @@ DEBUG_MODE = True if 'local' in os.environ.get("SITE_URL", "local") else False
BOT_DIR = os.path.dirname(__file__)
PROJECT_ROOT = os.path.abspath(os.path.join(BOT_DIR, os.pardir))
+# Default role combinations
+MODERATION_ROLES = Roles.moderator, Roles.admin, Roles.owner
+STAFF_ROLES = Roles.helpers, Roles.moderator, Roles.admin, Roles.owner
+
+
# Bot replies
NEGATIVE_REPLIES = [
"Noooooo!!",
@@ -474,7 +510,9 @@ NEGATIVE_REPLIES = [
"Not in a million years.",
"Fat chance.",
"Certainly not.",
- "NEGATORY."
+ "NEGATORY.",
+ "Nuh-uh.",
+ "Not in my house!",
]
POSITIVE_REPLIES = [
@@ -494,7 +532,7 @@ POSITIVE_REPLIES = [
"ROGER THAT",
"Of course!",
"Aye aye, cap'n!",
- "I'll allow it."
+ "I'll allow it.",
]
ERROR_REPLIES = [
@@ -506,7 +544,8 @@ ERROR_REPLIES = [
"You blew it.",
"You're bad at computers.",
"Are you trying to kill me?",
- "Noooooo!!"
+ "Noooooo!!",
+ "I can't believe you've done this",
]
diff --git a/bot/converters.py b/bot/converters.py
index 1100b502c..cf0496541 100644
--- a/bot/converters.py
+++ b/bot/converters.py
@@ -1,134 +1,34 @@
import logging
-import random
-import socket
+import re
from datetime import datetime
from ssl import CertificateError
+from typing import Union
-import dateparser
+import dateutil.parser
+import dateutil.tz
import discord
-from aiohttp import AsyncResolver, ClientConnectorError, ClientSession, TCPConnector
+from aiohttp import ClientConnectorError
+from dateutil.relativedelta import relativedelta
from discord.ext.commands import BadArgument, Context, Converter
-from fuzzywuzzy import fuzz
-
-from bot.constants import DEBUG_MODE, Keys, URLs
-from bot.utils import disambiguate
log = logging.getLogger(__name__)
-class Snake(Converter):
- snakes = None
- special_cases = None
-
- async def convert(self, ctx, name):
- await self.build_list()
- name = name.lower()
-
- if name == 'python':
- return 'Python (programming language)'
-
- def get_potential(iterable, *, threshold=80):
- nonlocal name
- potential = []
-
- for item in iterable:
- original, item = item, item.lower()
-
- if name == item:
- return [original]
-
- a, b = fuzz.ratio(name, item), fuzz.partial_ratio(name, item)
- if a >= threshold or b >= threshold:
- potential.append(original)
-
- return potential
-
- # Handle special cases
- if name.lower() in self.special_cases:
- return self.special_cases.get(name.lower(), name.lower())
-
- names = {snake['name']: snake['scientific'] for snake in self.snakes}
- all_names = names.keys() | names.values()
- timeout = len(all_names) * (3 / 4)
-
- embed = discord.Embed(title='Found multiple choices. Please choose the correct one.', colour=0x59982F)
- embed.set_author(name=ctx.author.display_name, icon_url=ctx.author.avatar_url)
-
- name = await disambiguate(ctx, get_potential(all_names), timeout=timeout, embed=embed)
- return names.get(name, name)
-
- @classmethod
- async def build_list(cls):
-
- headers = {"X-API-KEY": Keys.site_api}
-
- # Set up the session
- if DEBUG_MODE:
- http_session = ClientSession(
- connector=TCPConnector(
- resolver=AsyncResolver(),
- family=socket.AF_INET,
- verify_ssl=False,
- )
- )
- else:
- http_session = ClientSession(
- connector=TCPConnector(
- resolver=AsyncResolver()
- )
- )
-
- # Get all the snakes
- if cls.snakes is None:
- response = await http_session.get(
- URLs.site_names_api,
- params={"get_all": "true"},
- headers=headers
- )
- cls.snakes = await response.json()
-
- # Get the special cases
- if cls.special_cases is None:
- response = await http_session.get(
- URLs.site_special_api,
- headers=headers
- )
- special_cases = await response.json()
- cls.special_cases = {snake['name'].lower(): snake for snake in special_cases}
-
- # Close the session
- http_session.close()
-
- @classmethod
- async def random(cls):
- """
- This is stupid. We should find a way to
- somehow get the global session into a
- global context, so I can get it from here.
- :return:
- """
-
- await cls.build_list()
- names = [snake['scientific'] for snake in cls.snakes]
- return random.choice(names)
-
-
class ValidPythonIdentifier(Converter):
"""
A converter that checks whether the given string is a valid Python identifier.
- This is used to have package names
- that correspond to how you would use
- the package in your code, e.g.
- `import package`. Raises `BadArgument`
- if the argument is not a valid Python
- identifier, and simply passes through
+ This is used to have package names that correspond to how you would use the package in your
+ code, e.g. `import package`.
+
+ Raises `BadArgument` if the argument is not a valid Python identifier, and simply passes through
the given argument otherwise.
"""
@staticmethod
- async def convert(ctx, argument: str):
+ async def convert(ctx: Context, argument: str) -> str:
+ """Checks whether the given string is a valid Python identifier."""
if not argument.isidentifier():
raise BadArgument(f"`{argument}` is not a valid Python identifier")
return argument
@@ -138,19 +38,20 @@ class ValidURL(Converter):
"""
Represents a valid webpage URL.
- This converter checks whether the given
- URL can be reached and requesting it returns
- a status code of 200. If not, `BadArgument`
- is raised. Otherwise, it simply passes through the given URL.
+ This converter checks whether the given URL can be reached and requesting it returns a status
+ code of 200. If not, `BadArgument` is raised.
+
+ Otherwise, it simply passes through the given URL.
"""
@staticmethod
- async def convert(ctx, url: str):
+ async def convert(ctx: Context, url: str) -> str:
+ """This converter checks whether the given URL can be reached with a status code of 200."""
try:
async with ctx.bot.http_session.get(url) as resp:
if resp.status != 200:
raise BadArgument(
- f"HTTP GET on `{url}` returned status `{resp.status_code}`, expected 200"
+ f"HTTP GET on `{url}` returned status `{resp.status}`, expected 200"
)
except CertificateError:
if url.startswith('https'):
@@ -166,26 +67,28 @@ class ValidURL(Converter):
class InfractionSearchQuery(Converter):
- """
- A converter that checks if the argument is a Discord user, and if not, falls back to a string.
- """
+ """A converter that checks if the argument is a Discord user, and if not, falls back to a string."""
@staticmethod
- async def convert(ctx, arg):
+ async def convert(ctx: Context, arg: str) -> Union[discord.Member, str]:
+ """Check if the argument is a Discord user, and if not, falls back to a string."""
try:
maybe_snowflake = arg.strip("<@!>")
- return await ctx.bot.get_user_info(maybe_snowflake)
+ return await ctx.bot.fetch_user(maybe_snowflake)
except (discord.NotFound, discord.HTTPException):
return arg
class Subreddit(Converter):
- """
- Forces a string to begin with "r/" and checks if it's a valid subreddit.
- """
+ """Forces a string to begin with "r/" and checks if it's a valid subreddit."""
@staticmethod
- async def convert(ctx, sub: str):
+ async def convert(ctx: Context, sub: str) -> str:
+ """
+ Force sub to begin with "r/" and check if it's a valid subreddit.
+
+ If sub is a valid subreddit, return it prepended with "r/"
+ """
sub = sub.lower()
if not sub.startswith("r/"):
@@ -206,9 +109,21 @@ class Subreddit(Converter):
class TagNameConverter(Converter):
+ """
+ Ensure that a proposed tag name is valid.
+
+ Valid tag names meet the following conditions:
+ * All ASCII characters
+ * Has at least one non-whitespace character
+ * Not solely numeric
+ * Shorter than 127 characters
+ """
+
@staticmethod
- async def convert(ctx: Context, tag_name: str):
- def is_number(value):
+ async def convert(ctx: Context, tag_name: str) -> str:
+ """Lowercase & strip whitespace from proposed tag_name & ensure it's valid."""
+ def is_number(value: str) -> bool:
+ """Check to see if the input string is numeric."""
try:
float(value)
except ValueError:
@@ -245,8 +160,15 @@ class TagNameConverter(Converter):
class TagContentConverter(Converter):
+ """Ensure proposed tag content is not empty and contains at least one non-whitespace character."""
+
@staticmethod
- async def convert(ctx: Context, tag_content: str):
+ async def convert(ctx: Context, tag_content: str) -> str:
+ """
+ Ensure tag_content is non-empty and contains at least one non-whitespace character.
+
+ If tag_content is valid, return the stripped version.
+ """
tag_content = tag_content.strip()
# The tag contents should not be empty, or filled with whitespace.
@@ -258,20 +180,82 @@ class TagContentConverter(Converter):
return tag_content
-class ExpirationDate(Converter):
- DATEPARSER_SETTINGS = {
- 'PREFER_DATES_FROM': 'future',
- 'TIMEZONE': 'UTC',
- 'TO_TIMEZONE': 'UTC'
- }
+class Duration(Converter):
+ """Convert duration strings into UTC datetime.datetime objects."""
- async def convert(self, ctx, expiration_string: str):
- expiry = dateparser.parse(expiration_string, settings=self.DATEPARSER_SETTINGS)
- if expiry is None:
- raise BadArgument(f"Failed to parse expiration date from `{expiration_string}`")
+ duration_parser = re.compile(
+ r"((?P<years>\d+?) ?(years|year|Y|y) ?)?"
+ r"((?P<months>\d+?) ?(months|month|m) ?)?"
+ r"((?P<weeks>\d+?) ?(weeks|week|W|w) ?)?"
+ r"((?P<days>\d+?) ?(days|day|D|d) ?)?"
+ r"((?P<hours>\d+?) ?(hours|hour|H|h) ?)?"
+ r"((?P<minutes>\d+?) ?(minutes|minute|M) ?)?"
+ r"((?P<seconds>\d+?) ?(seconds|second|S|s))?"
+ )
+ async def convert(self, ctx: Context, duration: str) -> datetime:
+ """
+ Converts a `duration` string to a datetime object that's `duration` in the future.
+
+ The converter supports the following symbols for each unit of time:
+ - years: `Y`, `y`, `year`, `years`
+ - months: `m`, `month`, `months`
+ - weeks: `w`, `W`, `week`, `weeks`
+ - days: `d`, `D`, `day`, `days`
+ - hours: `H`, `h`, `hour`, `hours`
+ - minutes: `M`, `minute`, `minutes`
+ - seconds: `S`, `s`, `second`, `seconds`
+
+ The units need to be provided in descending order of magnitude.
+ """
+ match = self.duration_parser.fullmatch(duration)
+ if not match:
+ raise BadArgument(f"`{duration}` is not a valid duration string.")
+
+ duration_dict = {unit: int(amount) for unit, amount in match.groupdict(default=0).items()}
+ delta = relativedelta(**duration_dict)
now = datetime.utcnow()
- if expiry < now:
- expiry = now + (now - expiry)
- return expiry
+ return now + delta
+
+
+class ISODateTime(Converter):
+ """Converts an ISO-8601 datetime string into a datetime.datetime."""
+
+ async def convert(self, ctx: Context, datetime_string: str) -> datetime:
+ """
+ Converts a ISO-8601 `datetime_string` into a `datetime.datetime` object.
+
+ The converter is flexible in the formats it accepts, as it uses the `isoparse` method of
+ `dateutil.parser`. In general, it accepts datetime strings that start with a date,
+ optionally followed by a time. Specifying a timezone offset in the datetime string is
+ supported, but the `datetime` object will be converted to UTC and will be returned without
+ `tzinfo` as a timezone-unaware `datetime` object.
+
+ See: https://dateutil.readthedocs.io/en/stable/parser.html#dateutil.parser.isoparse
+
+ Formats that are guaranteed to be valid by our tests are:
+
+ - `YYYY-mm-ddTHH:MM:SSZ` | `YYYY-mm-dd HH:MM:SSZ`
+ - `YYYY-mm-ddTHH:MM:SS±HH:MM` | `YYYY-mm-dd HH:MM:SS±HH:MM`
+ - `YYYY-mm-ddTHH:MM:SS±HHMM` | `YYYY-mm-dd HH:MM:SS±HHMM`
+ - `YYYY-mm-ddTHH:MM:SS±HH` | `YYYY-mm-dd HH:MM:SS±HH`
+ - `YYYY-mm-ddTHH:MM:SS` | `YYYY-mm-dd HH:MM:SS`
+ - `YYYY-mm-ddTHH:MM` | `YYYY-mm-dd HH:MM`
+ - `YYYY-mm-dd`
+ - `YYYY-mm`
+ - `YYYY`
+
+ Note: ISO-8601 specifies a `T` as the separator between the date and the time part of the
+ datetime string. The converter accepts both a `T` and a single space character.
+ """
+ try:
+ dt = dateutil.parser.isoparse(datetime_string)
+ except ValueError:
+ raise BadArgument(f"`{datetime_string}` is not a valid ISO-8601 datetime string")
+
+ if dt.tzinfo:
+ dt = dt.astimezone(dateutil.tz.UTC)
+ dt = dt.replace(tzinfo=None)
+
+ return dt
diff --git a/bot/decorators.py b/bot/decorators.py
index 87877ecbf..935df4af0 100644
--- a/bot/decorators.py
+++ b/bot/decorators.py
@@ -1,28 +1,36 @@
import logging
import random
-import typing
-from asyncio import Lock
+from asyncio import Lock, sleep
+from contextlib import suppress
from functools import wraps
+from typing import Callable, Container, Union
from weakref import WeakValueDictionary
-from discord import Colour, Embed
+from discord import Colour, Embed, Member
+from discord.errors import NotFound
from discord.ext import commands
-from discord.ext.commands import CheckFailure, Context
+from discord.ext.commands import CheckFailure, Cog, Context
-from bot.constants import ERROR_REPLIES
+from bot.constants import ERROR_REPLIES, RedirectOutput
+from bot.utils.checks import with_role_check, without_role_check
log = logging.getLogger(__name__)
class InChannelCheckFailure(CheckFailure):
- pass
+ """Raised when a check fails for a message being sent in a whitelisted channel."""
+ def __init__(self, *channels: int):
+ self.channels = channels
+ channels_str = ', '.join(f"<#{c_id}>" for c_id in channels)
-def in_channel(*channels: int, bypass_roles: typing.Container[int] = None):
- """
- Checks that the message is in a whitelisted channel or optionally has a bypass role.
- """
- def predicate(ctx: Context):
+ super().__init__(f"Sorry, but you may only use this command within {channels_str}.")
+
+
+def in_channel(*channels: int, bypass_roles: Container[int] = None) -> Callable:
+ """Checks that the message is in a whitelisted channel or optionally has a bypass role."""
+ def predicate(ctx: Context) -> bool:
+ """In-channel checker predicate."""
if ctx.channel.id in channels:
log.debug(f"{ctx.author} tried to call the '{ctx.command.name}' command. "
f"The command was used in a whitelisted channel.")
@@ -38,61 +46,39 @@ def in_channel(*channels: int, bypass_roles: typing.Container[int] = None):
log.debug(f"{ctx.author} tried to call the '{ctx.command.name}' command. "
f"The in_channel check failed.")
- channels_str = ', '.join(f"<#{c_id}>" for c_id in channels)
- raise InChannelCheckFailure(
- f"Sorry, but you may only use this command within {channels_str}."
- )
+ raise InChannelCheckFailure(*channels)
return commands.check(predicate)
-def with_role(*role_ids: int):
- async def predicate(ctx: Context):
- if not ctx.guild: # Return False in a DM
- log.debug(f"{ctx.author} tried to use the '{ctx.command.name}'command from a DM. "
- "This command is restricted by the with_role decorator. Rejecting request.")
- return False
-
- for role in ctx.author.roles:
- if role.id in role_ids:
- log.debug(f"{ctx.author} has the '{role.name}' role, and passes the check.")
- return True
-
- log.debug(f"{ctx.author} does not have the required role to use "
- f"the '{ctx.command.name}' command, so the request is rejected.")
- return False
+def with_role(*role_ids: int) -> Callable:
+ """Returns True if the user has any one of the roles in role_ids."""
+ async def predicate(ctx: Context) -> bool:
+ """With role checker predicate."""
+ return with_role_check(ctx, *role_ids)
return commands.check(predicate)
-def without_role(*role_ids: int):
- async def predicate(ctx: Context):
- if not ctx.guild: # Return False in a DM
- log.debug(f"{ctx.author} tried to use the '{ctx.command.name}' command from a DM. "
- "This command is restricted by the without_role decorator. Rejecting request.")
- return False
-
- author_roles = [role.id for role in ctx.author.roles]
- check = all(role not in author_roles for role in role_ids)
- log.debug(f"{ctx.author} tried to call the '{ctx.command.name}' command. "
- f"The result of the without_role check was {check}.")
- return check
+def without_role(*role_ids: int) -> Callable:
+ """Returns True if the user does not have any of the roles in role_ids."""
+ async def predicate(ctx: Context) -> bool:
+ return without_role_check(ctx, *role_ids)
return commands.check(predicate)
-def locked():
+def locked() -> Callable:
"""
Allows the user to only run one instance of the decorated command at a time.
- Subsequent calls to the command from the same author are
- ignored until the command has completed invocation.
- This decorator has to go before (below) the `command` decorator.
- """
+ Subsequent calls to the command from the same author are ignored until the command has completed invocation.
- def wrap(func):
+ This decorator must go before (below) the `command` decorator.
+ """
+ def wrap(func: Callable) -> Callable:
func.__locks = WeakValueDictionary()
@wraps(func)
- async def inner(self, ctx, *args, **kwargs):
+ async def inner(self: Cog, ctx: Context, *args, **kwargs) -> None:
lock = func.__locks.setdefault(ctx.author.id, Lock())
if lock.locked():
embed = Embed()
@@ -107,6 +93,101 @@ def locked():
return
async with func.__locks.setdefault(ctx.author.id, Lock()):
- return await func(self, ctx, *args, **kwargs)
+ await func(self, ctx, *args, **kwargs)
+ return inner
+ return wrap
+
+
+def redirect_output(destination_channel: int, bypass_roles: Container[int] = None) -> Callable:
+ """
+ Changes the channel in the context of the command to redirect the output to a certain channel.
+
+ Redirect is bypassed if the author has a role to bypass redirection.
+
+ This decorator must go before (below) the `command` decorator.
+ """
+ def wrap(func: Callable) -> Callable:
+ @wraps(func)
+ async def inner(self: Cog, ctx: Context, *args, **kwargs) -> None:
+ if ctx.channel.id == destination_channel:
+ log.trace(f"Command {ctx.command.name} was invoked in destination_channel, not redirecting")
+ await func(self, ctx, *args, **kwargs)
+ return
+
+ if bypass_roles and any(role.id in bypass_roles for role in ctx.author.roles):
+ log.trace(f"{ctx.author} has role to bypass output redirection")
+ await func(self, ctx, *args, **kwargs)
+ return
+
+ redirect_channel = ctx.guild.get_channel(destination_channel)
+ old_channel = ctx.channel
+
+ log.trace(f"Redirecting output of {ctx.author}'s command '{ctx.command.name}' to {redirect_channel.name}")
+ ctx.channel = redirect_channel
+ await ctx.channel.send(f"Here's the output of your command, {ctx.author.mention}")
+ await func(self, ctx, *args, **kwargs)
+
+ message = await old_channel.send(
+ f"Hey, {ctx.author.mention}, you can find the output of your command here: "
+ f"{redirect_channel.mention}"
+ )
+
+ if RedirectOutput.delete_invocation:
+ await sleep(RedirectOutput.delete_delay)
+
+ with suppress(NotFound):
+ await message.delete()
+ log.trace("Redirect output: Deleted user redirection message")
+
+ with suppress(NotFound):
+ await ctx.message.delete()
+ log.trace("Redirect output: Deleted invocation message")
+ return inner
+ return wrap
+
+
+def respect_role_hierarchy(target_arg: Union[int, str] = 0) -> Callable:
+ """
+ Ensure the highest role of the invoking member is greater than that of the target member.
+
+ If the condition fails, a warning is sent to the invoking context. A target which is not an
+ instance of discord.Member will always pass.
+
+ A value of 0 (i.e. position 0) for `target_arg` corresponds to the argument which comes after
+ `ctx`. If the target argument is a kwarg, its name can instead be given.
+
+ This decorator must go before (below) the `command` decorator.
+ """
+ def wrap(func: Callable) -> Callable:
+ @wraps(func)
+ async def inner(self: Cog, ctx: Context, *args, **kwargs) -> None:
+ try:
+ target = kwargs[target_arg]
+ except KeyError:
+ try:
+ target = args[target_arg]
+ except IndexError:
+ raise ValueError(f"Could not find target argument at position {target_arg}")
+ except TypeError:
+ raise ValueError(f"Could not find target kwarg with key {target_arg!r}")
+
+ if not isinstance(target, Member):
+ log.trace("The target is not a discord.Member; skipping role hierarchy check.")
+ await func(self, ctx, *args, **kwargs)
+ return
+
+ cmd = ctx.command.name
+ actor = ctx.author
+ if target.top_role >= actor.top_role:
+ log.info(
+ f"{actor} ({actor.id}) attempted to {cmd} "
+ f"{target} ({target.id}), who has an equal or higher top role."
+ )
+ await ctx.send(
+ f":x: {actor.mention}, you may not {cmd} "
+ "someone with an equal or higher top role."
+ )
+ else:
+ await func(self, ctx, *args, **kwargs)
return inner
return wrap
diff --git a/bot/interpreter.py b/bot/interpreter.py
index 06343db39..a42b45a2d 100644
--- a/bot/interpreter.py
+++ b/bot/interpreter.py
@@ -1,5 +1,8 @@
from code import InteractiveInterpreter
from io import StringIO
+from typing import Any
+
+from discord.ext.commands import Bot, Context
CODE_TEMPLATE = """
async def _func():
@@ -8,13 +11,20 @@ async def _func():
class Interpreter(InteractiveInterpreter):
+ """
+ Subclass InteractiveInterpreter to specify custom run functionality.
+
+ Helper class for internal eval.
+ """
+
write_callable = None
- def __init__(self, bot):
+ def __init__(self, bot: Bot):
_locals = {"bot": bot}
super().__init__(_locals)
- async def run(self, code, ctx, io, *args, **kwargs):
+ async def run(self, code: str, ctx: Context, io: StringIO, *args, **kwargs) -> Any:
+ """Execute the provided source code as the bot & return the output."""
self.locals["_rvalue"] = []
self.locals["ctx"] = ctx
self.locals["print"] = lambda x: io.write(f"{x}\n")
diff --git a/bot/pagination.py b/bot/pagination.py
index 0d8e8aaa3..76082f459 100644
--- a/bot/pagination.py
+++ b/bot/pagination.py
@@ -2,7 +2,7 @@ import asyncio
import logging
from typing import Iterable, List, Optional, Tuple
-from discord import Embed, Member, Reaction
+from discord import Embed, Member, Message, Reaction
from discord.abc import User
from discord.ext.commands import Context, Paginator
@@ -17,29 +17,34 @@ PAGINATION_EMOJI = [FIRST_EMOJI, LEFT_EMOJI, RIGHT_EMOJI, LAST_EMOJI, DELETE_EMO
log = logging.getLogger(__name__)
+class EmptyPaginatorEmbed(Exception):
+ """Raised when attempting to paginate with empty contents."""
+
+ pass
+
+
class LinePaginator(Paginator):
"""
A class that aids in paginating code blocks for Discord messages.
- Attributes
- -----------
- prefix: :class:`str`
+ Available attributes include:
+ * prefix: `str`
The prefix inserted to every page. e.g. three backticks.
- suffix: :class:`str`
+ * suffix: `str`
The suffix appended at the end of every page. e.g. three backticks.
- max_size: :class:`int`
+ * max_size: `int`
The maximum amount of codepoints allowed in a page.
- max_lines: :class:`int`
+ * max_lines: `int`
The maximum amount of lines allowed in a page.
"""
- def __init__(self, prefix='```', suffix='```',
- max_size=2000, max_lines=None):
+ def __init__(
+ self, prefix: str = '```', suffix: str = '```', max_size: int = 2000, max_lines: int = None
+ ) -> None:
"""
- This function overrides the Paginator.__init__
- from inside discord.ext.commands.
- It overrides in order to allow us to configure
- the maximum number of lines per page.
+ This function overrides the Paginator.__init__ from inside discord.ext.commands.
+
+ It overrides in order to allow us to configure the maximum number of lines per page.
"""
self.prefix = prefix
self.suffix = suffix
@@ -50,28 +55,15 @@ class LinePaginator(Paginator):
self._count = len(prefix) + 1 # prefix + newline
self._pages = []
- def add_line(self, line='', *, empty=False):
- """Adds a line to the current page.
-
- If the line exceeds the :attr:`max_size` then an exception
- is raised.
+ def add_line(self, line: str = '', *, empty: bool = False) -> None:
+ """
+ Adds a line to the current page.
- This function overrides the Paginator.add_line
- from inside discord.ext.commands.
- It overrides in order to allow us to configure
- the maximum number of lines per page.
+ If the line exceeds the `self.max_size` then an exception is raised.
- Parameters
- -----------
- line: str
- The line to add.
- empty: bool
- Indicates if another empty line should be added.
+ This function overrides the `Paginator.add_line` from inside `discord.ext.commands`.
- Raises
- ------
- RuntimeError
- The line was too big for the current :attr:`max_size`.
+ It overrides in order to allow us to configure the maximum number of lines per page.
"""
if len(line) > self.max_size - len(self.prefix) - 2:
raise RuntimeError('Line exceeds maximum page size %s' % (self.max_size - len(self.prefix) - 2))
@@ -93,40 +85,39 @@ class LinePaginator(Paginator):
self._count += 1
@classmethod
- async def paginate(cls, lines: Iterable[str], ctx: Context, embed: Embed,
- prefix: str = "", suffix: str = "", max_lines: Optional[int] = None, max_size: int = 500,
- empty: bool = True, restrict_to_user: User = None, timeout: int = 300,
- footer_text: str = None):
+ async def paginate(
+ cls,
+ lines: Iterable[str],
+ ctx: Context,
+ embed: Embed,
+ prefix: str = "",
+ suffix: str = "",
+ max_lines: Optional[int] = None,
+ max_size: int = 500,
+ empty: bool = True,
+ restrict_to_user: User = None,
+ timeout: int = 300,
+ footer_text: str = None,
+ url: str = None,
+ exception_on_empty_embed: bool = False
+ ) -> Optional[Message]:
"""
- Use a paginator and set of reactions to provide pagination over a set of lines. The reactions are used to
- switch page, or to finish with pagination.
+ Use a paginator and set of reactions to provide pagination over a set of lines.
+
+ The reactions are used to switch page, or to finish with pagination.
+
When used, this will send a message using `ctx.send()` and apply a set of reactions to it. These reactions may
- be used to change page, or to remove pagination from the message. Pagination will also be removed automatically
- if no reaction is added for five minutes (300 seconds).
+ be used to change page, or to remove pagination from the message.
+
+ Pagination will also be removed automatically if no reaction is added for five minutes (300 seconds).
+
+ Example:
>>> embed = Embed()
>>> embed.set_author(name="Some Operation", url=url, icon_url=icon)
- >>> await LinePaginator.paginate(
- ... (line for line in lines),
- ... ctx, embed
- ... )
- :param lines: The lines to be paginated
- :param ctx: Current context object
- :param embed: A pre-configured embed to be used as a template for each page
- :param prefix: Text to place before each page
- :param suffix: Text to place after each page
- :param max_lines: The maximum number of lines on each page
- :param max_size: The maximum number of characters on each page
- :param empty: Whether to place an empty line between each given line
- :param restrict_to_user: A user to lock pagination operations to for this message, if supplied
- :param timeout: The amount of time in seconds to disable pagination of no reaction is added
- :param footer_text: Text to prefix the page number in the footer with
+ >>> await LinePaginator.paginate((line for line in lines), ctx, embed)
"""
-
- def event_check(reaction_: Reaction, user_: Member):
- """
- Make sure that this reaction is what we want to operate on
- """
-
+ def event_check(reaction_: Reaction, user_: Member) -> bool:
+ """Make sure that this reaction is what we want to operate on."""
no_restrictions = (
# Pagination is not restricted
not restrict_to_user
@@ -151,6 +142,14 @@ class LinePaginator(Paginator):
paginator = cls(prefix=prefix, suffix=suffix, max_size=max_size, max_lines=max_lines)
current_page = 0
+ if not lines:
+ if exception_on_empty_embed:
+ log.exception(f"Pagination asked for empty lines iterable")
+ raise EmptyPaginatorEmbed("No lines to paginate")
+
+ log.debug("No lines to add to paginator, adding '(nothing to display)' message")
+ lines.append("(nothing to display)")
+
for line in lines:
try:
paginator.add_line(line, empty=empty)
@@ -169,6 +168,10 @@ class LinePaginator(Paginator):
embed.set_footer(text=footer_text)
log.trace(f"Setting embed footer to '{footer_text}'")
+ if url:
+ embed.url = url
+ log.trace(f"Setting embed url to '{url}'")
+
log.debug("There's less than two pages, so we won't paginate - sending single page on its own")
return await ctx.send(embed=embed)
else:
@@ -176,9 +179,12 @@ class LinePaginator(Paginator):
embed.set_footer(text=f"{footer_text} (Page {current_page + 1}/{len(paginator.pages)})")
else:
embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}")
-
log.trace(f"Setting embed footer to '{embed.footer.text}'")
+ if url:
+ embed.url = url
+ log.trace(f"Setting embed url to '{url}'")
+
log.debug("Sending first page to channel...")
message = await ctx.send(embed=embed)
@@ -280,24 +286,20 @@ class LinePaginator(Paginator):
class ImagePaginator(Paginator):
"""
Helper class that paginates images for embeds in messages.
+
Close resemblance to LinePaginator, except focuses on images over text.
Refer to ImagePaginator.paginate for documentation on how to use.
"""
- def __init__(self, prefix="", suffix=""):
+ def __init__(self, prefix: str = "", suffix: str = ""):
super().__init__(prefix, suffix)
self._current_page = [prefix]
self.images = []
self._pages = []
def add_line(self, line: str = '', *, empty: bool = False) -> None:
- """
- Adds a line to each page, usually just 1 line in this context
- :param line: str to be page content / title
- :param empty: if there should be new lines between entries
- """
-
+ """Adds a line to each page."""
if line:
self._count = len(line)
else:
@@ -306,49 +308,36 @@ class ImagePaginator(Paginator):
self.close_page()
def add_image(self, image: str = None) -> None:
- """
- Adds an image to a page
- :param image: image url to be appended
- """
-
+ """Adds an image to a page."""
self.images.append(image)
@classmethod
- async def paginate(cls, pages: List[Tuple[str, str]], ctx: Context, embed: Embed,
- prefix: str = "", suffix: str = "", timeout: int = 300):
+ async def paginate(
+ cls,
+ pages: List[Tuple[str, str]],
+ ctx: Context, embed: Embed,
+ prefix: str = "",
+ suffix: str = "",
+ timeout: int = 300,
+ exception_on_empty_embed: bool = False
+ ) -> Optional[Message]:
"""
- Use a paginator and set of reactions to provide
- pagination over a set of title/image pairs.The reactions are
- used to switch page, or to finish with pagination.
+ Use a paginator and set of reactions to provide pagination over a set of title/image pairs.
+
+ The reactions are used to switch page, or to finish with pagination.
- When used, this will send a message using `ctx.send()` and
- apply a set of reactions to it. These reactions may
+ When used, this will send a message using `ctx.send()` and apply a set of reactions to it. These reactions may
be used to change page, or to remove pagination from the message.
- Note: Pagination will be removed automatically
- if no reaction is added for five minutes (300 seconds).
+ Note: Pagination will be removed automatically if no reaction is added for five minutes (300 seconds).
+ Example:
>>> embed = Embed()
>>> embed.set_author(name="Some Operation", url=url, icon_url=icon)
>>> await ImagePaginator.paginate(pages, ctx, embed)
-
- Parameters
- -----------
- :param pages: An iterable of tuples with title for page, and img url
- :param ctx: ctx for message
- :param embed: base embed to modify
- :param prefix: prefix of message
- :param suffix: suffix of message
- :param timeout: timeout for when reactions get auto-removed
"""
-
def check_event(reaction_: Reaction, member: Member) -> bool:
- """
- Checks each reaction added, if it matches our conditions pass the wait_for
- :param reaction_: reaction added
- :param member: reaction added by member
- """
-
+ """Checks each reaction added, if it matches our conditions pass the wait_for."""
return all((
# Reaction is on the same message sent
reaction_.message.id == message.id,
@@ -361,6 +350,14 @@ class ImagePaginator(Paginator):
paginator = cls(prefix=prefix, suffix=suffix)
current_page = 0
+ if not pages:
+ if exception_on_empty_embed:
+ log.exception(f"Pagination asked for empty image list")
+ raise EmptyPaginatorEmbed("No images to paginate")
+
+ log.debug("No images to add to paginator, adding '(no images to display)' message")
+ pages.append(("(no images to display)", ""))
+
for text, image_url in pages:
paginator.add_line(text)
paginator.add_image(image_url)
diff --git a/bot/patches/__init__.py b/bot/patches/__init__.py
new file mode 100644
index 000000000..60f6becaa
--- /dev/null
+++ b/bot/patches/__init__.py
@@ -0,0 +1,6 @@
+"""Subpackage that contains patches for discord.py."""
+from . import message_edited_at
+
+__all__ = [
+ message_edited_at,
+]
diff --git a/bot/patches/message_edited_at.py b/bot/patches/message_edited_at.py
new file mode 100644
index 000000000..a0154f12d
--- /dev/null
+++ b/bot/patches/message_edited_at.py
@@ -0,0 +1,32 @@
+"""
+# message_edited_at patch.
+
+Date: 2019-09-16
+Author: Scragly
+Added by: Ves Zappa
+
+Due to a bug in our current version of discord.py (1.2.3), the edited_at timestamp of
+`discord.Messages` are not being handled correctly. This patch fixes that until a new
+release of discord.py is released (and we've updated to it).
+"""
+import logging
+
+from discord import message, utils
+
+log = logging.getLogger(__name__)
+
+
+def _handle_edited_timestamp(self: message.Message, value: str) -> None:
+ """Helper function that takes care of parsing the edited timestamp."""
+ self._edited_timestamp = utils.parse_time(value)
+
+
+def apply_patch() -> None:
+ """Applies the `edited_at` patch to the `discord.message.Message` class."""
+ message.Message._handle_edited_timestamp = _handle_edited_timestamp
+ message.Message._HANDLERS['edited_timestamp'] = message.Message._handle_edited_timestamp
+ log.info("Patch applied: message_edited_at")
+
+
+if __name__ == "__main__":
+ apply_patch()
diff --git a/bot/resources/snake_cards/backs/card_back1.jpg b/bot/resources/snake_cards/backs/card_back1.jpg
deleted file mode 100644
index 22959fa73..000000000
--- a/bot/resources/snake_cards/backs/card_back1.jpg
+++ /dev/null
Binary files differ
diff --git a/bot/resources/snake_cards/backs/card_back2.jpg b/bot/resources/snake_cards/backs/card_back2.jpg
deleted file mode 100644
index d56edc320..000000000
--- a/bot/resources/snake_cards/backs/card_back2.jpg
+++ /dev/null
Binary files differ
diff --git a/bot/resources/snake_cards/card_bottom.png b/bot/resources/snake_cards/card_bottom.png
deleted file mode 100644
index 8b2b91c5c..000000000
--- a/bot/resources/snake_cards/card_bottom.png
+++ /dev/null
Binary files differ
diff --git a/bot/resources/snake_cards/card_frame.png b/bot/resources/snake_cards/card_frame.png
deleted file mode 100644
index 149a0a5f6..000000000
--- a/bot/resources/snake_cards/card_frame.png
+++ /dev/null
Binary files differ
diff --git a/bot/resources/snake_cards/card_top.png b/bot/resources/snake_cards/card_top.png
deleted file mode 100644
index e329c873a..000000000
--- a/bot/resources/snake_cards/card_top.png
+++ /dev/null
Binary files differ
diff --git a/bot/resources/snake_cards/expressway.ttf b/bot/resources/snake_cards/expressway.ttf
deleted file mode 100644
index 39e157947..000000000
--- a/bot/resources/snake_cards/expressway.ttf
+++ /dev/null
Binary files differ
diff --git a/bot/resources/snakes_and_ladders/banner.jpg b/bot/resources/snakes_and_ladders/banner.jpg
deleted file mode 100644
index 69eaaf129..000000000
--- a/bot/resources/snakes_and_ladders/banner.jpg
+++ /dev/null
Binary files differ
diff --git a/bot/resources/snakes_and_ladders/board.jpg b/bot/resources/snakes_and_ladders/board.jpg
deleted file mode 100644
index 20032e391..000000000
--- a/bot/resources/snakes_and_ladders/board.jpg
+++ /dev/null
Binary files differ
diff --git a/bot/resources/stars.json b/bot/resources/stars.json
new file mode 100644
index 000000000..c0b253120
--- /dev/null
+++ b/bot/resources/stars.json
@@ -0,0 +1,78 @@
+[
+ "Adele",
+ "Aerosmith",
+ "Aretha Franklin",
+ "Ayumi Hamasaki",
+ "B'z",
+ "Barbra Streisand",
+ "Barry Manilow",
+ "Barry White",
+ "Beyonce",
+ "Billy Joel",
+ "Bob Dylan",
+ "Bob Marley",
+ "Bob Seger",
+ "Bon Jovi",
+ "Britney Spears",
+ "Bruce Springsteen",
+ "Bruno Mars",
+ "Bryan Adams",
+ "Celine Dion",
+ "Cher",
+ "Christina Aguilera",
+ "David Bowie",
+ "Donna Summer",
+ "Drake",
+ "Ed Sheeran",
+ "Elton John",
+ "Elvis Presley",
+ "Eminem",
+ "Enya",
+ "Flo Rida",
+ "Frank Sinatra",
+ "Garth Brooks",
+ "George Michael",
+ "George Strait",
+ "James Taylor",
+ "Janet Jackson",
+ "Jay-Z",
+ "Johnny Cash",
+ "Johnny Hallyday",
+ "Julio Iglesias",
+ "Justin Bieber",
+ "Justin Timberlake",
+ "Kanye West",
+ "Katy Perry",
+ "Kenny G",
+ "Kenny Rogers",
+ "Lady Gaga",
+ "Lil Wayne",
+ "Linda Ronstadt",
+ "Lionel Richie",
+ "Madonna",
+ "Mariah Carey",
+ "Meat Loaf",
+ "Michael Jackson",
+ "Neil Diamond",
+ "Nicki Minaj",
+ "Olivia Newton-John",
+ "Paul McCartney",
+ "Phil Collins",
+ "Pink",
+ "Prince",
+ "Reba McEntire",
+ "Rihanna",
+ "Robbie Williams",
+ "Rod Stewart",
+ "Santana",
+ "Shania Twain",
+ "Stevie Wonder",
+ "Taylor Swift",
+ "Tim McGraw",
+ "Tina Turner",
+ "Tom Petty",
+ "Tupac Shakur",
+ "Usher",
+ "Van Halen",
+ "Whitney Houston"
+]
diff --git a/bot/rules/attachments.py b/bot/rules/attachments.py
index 47b927101..c550aed76 100644
--- a/bot/rules/attachments.py
+++ b/bot/rules/attachments.py
@@ -1,24 +1,20 @@
-"""Detects total attachments exceeding the limit sent by a single user."""
-
from typing import Dict, Iterable, List, Optional, Tuple
from discord import Member, Message
async def apply(
- last_message: Message,
- recent_messages: List[Message],
- config: Dict[str, int]
+ last_message: Message, recent_messages: List[Message], config: Dict[str, int]
) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]:
-
- relevant_messages = tuple(
+ """Detects total attachments exceeding the limit sent by a single user."""
+ relevant_messages = [last_message] + [
msg
for msg in recent_messages
if (
msg.author == last_message.author
and len(msg.attachments) > 0
)
- )
+ ]
total_recent_attachments = sum(len(msg.attachments) for msg in relevant_messages)
if total_recent_attachments > config['max']:
diff --git a/bot/rules/burst.py b/bot/rules/burst.py
index 80c79be60..25c5a2f33 100644
--- a/bot/rules/burst.py
+++ b/bot/rules/burst.py
@@ -1,16 +1,12 @@
-"""Detects repeated messages sent by a single user."""
-
from typing import Dict, Iterable, List, Optional, Tuple
from discord import Member, Message
async def apply(
- last_message: Message,
- recent_messages: List[Message],
- config: Dict[str, int]
+ last_message: Message, recent_messages: List[Message], config: Dict[str, int]
) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]:
-
+ """Detects repeated messages sent by a single user."""
relevant_messages = tuple(
msg
for msg in recent_messages
diff --git a/bot/rules/burst_shared.py b/bot/rules/burst_shared.py
index 2cb7b5200..bbe9271b3 100644
--- a/bot/rules/burst_shared.py
+++ b/bot/rules/burst_shared.py
@@ -1,16 +1,12 @@
-"""Detects repeated messages sent by multiple users."""
-
from typing import Dict, Iterable, List, Optional, Tuple
from discord import Member, Message
async def apply(
- last_message: Message,
- recent_messages: List[Message],
- config: Dict[str, int]
+ last_message: Message, recent_messages: List[Message], config: Dict[str, int]
) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]:
-
+ """Detects repeated messages sent by multiple users."""
total_recent = len(recent_messages)
if total_recent > config['max']:
diff --git a/bot/rules/chars.py b/bot/rules/chars.py
index d05e3cd83..1f587422c 100644
--- a/bot/rules/chars.py
+++ b/bot/rules/chars.py
@@ -1,16 +1,12 @@
-"""Detects total message char count exceeding the limit sent by a single user."""
-
from typing import Dict, Iterable, List, Optional, Tuple
from discord import Member, Message
async def apply(
- last_message: Message,
- recent_messages: List[Message],
- config: Dict[str, int]
+ last_message: Message, recent_messages: List[Message], config: Dict[str, int]
) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]:
-
+ """Detects total message char count exceeding the limit sent by a single user."""
relevant_messages = tuple(
msg
for msg in recent_messages
diff --git a/bot/rules/discord_emojis.py b/bot/rules/discord_emojis.py
index e4f957ddb..5bab514f2 100644
--- a/bot/rules/discord_emojis.py
+++ b/bot/rules/discord_emojis.py
@@ -1,5 +1,3 @@
-"""Detects total Discord emojis (excluding Unicode emojis) exceeding the limit sent by a single user."""
-
import re
from typing import Dict, Iterable, List, Optional, Tuple
@@ -10,11 +8,9 @@ DISCORD_EMOJI_RE = re.compile(r"<:\w+:\d+>")
async def apply(
- last_message: Message,
- recent_messages: List[Message],
- config: Dict[str, int]
+ last_message: Message, recent_messages: List[Message], config: Dict[str, int]
) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]:
-
+ """Detects total Discord emojis (excluding Unicode emojis) exceeding the limit sent by a single user."""
relevant_messages = tuple(
msg
for msg in recent_messages
diff --git a/bot/rules/duplicates.py b/bot/rules/duplicates.py
index 763fc9983..455764b53 100644
--- a/bot/rules/duplicates.py
+++ b/bot/rules/duplicates.py
@@ -1,16 +1,12 @@
-"""Detects duplicated messages sent by a single user."""
-
from typing import Dict, Iterable, List, Optional, Tuple
from discord import Member, Message
async def apply(
- last_message: Message,
- recent_messages: List[Message],
- config: Dict[str, int]
+ last_message: Message, recent_messages: List[Message], config: Dict[str, int]
) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]:
-
+ """Detects duplicated messages sent by a single user."""
relevant_messages = tuple(
msg
for msg in recent_messages
diff --git a/bot/rules/links.py b/bot/rules/links.py
index fa4043fcb..ec75a19c5 100644
--- a/bot/rules/links.py
+++ b/bot/rules/links.py
@@ -1,5 +1,3 @@
-"""Detects total links exceeding the limit sent by a single user."""
-
import re
from typing import Dict, Iterable, List, Optional, Tuple
@@ -10,11 +8,9 @@ LINK_RE = re.compile(r"(https?://[^\s]+)")
async def apply(
- last_message: Message,
- recent_messages: List[Message],
- config: Dict[str, int]
+ last_message: Message, recent_messages: List[Message], config: Dict[str, int]
) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]:
-
+ """Detects total links exceeding the limit sent by a single user."""
relevant_messages = tuple(
msg
for msg in recent_messages
diff --git a/bot/rules/mentions.py b/bot/rules/mentions.py
index 45c47b6ba..79725a4b1 100644
--- a/bot/rules/mentions.py
+++ b/bot/rules/mentions.py
@@ -1,16 +1,12 @@
-"""Detects total mentions exceeding the limit sent by a single user."""
-
from typing import Dict, Iterable, List, Optional, Tuple
from discord import Member, Message
async def apply(
- last_message: Message,
- recent_messages: List[Message],
- config: Dict[str, int]
+ last_message: Message, recent_messages: List[Message], config: Dict[str, int]
) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]:
-
+ """Detects total mentions exceeding the limit sent by a single user."""
relevant_messages = tuple(
msg
for msg in recent_messages
diff --git a/bot/rules/newlines.py b/bot/rules/newlines.py
index a6a1a52d0..4e66e1359 100644
--- a/bot/rules/newlines.py
+++ b/bot/rules/newlines.py
@@ -1,28 +1,45 @@
-"""Detects total newlines exceeding the set limit sent by a single user."""
-
+import re
from typing import Dict, Iterable, List, Optional, Tuple
from discord import Member, Message
async def apply(
- last_message: Message,
- recent_messages: List[Message],
- config: Dict[str, int]
+ last_message: Message, recent_messages: List[Message], config: Dict[str, int]
) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]:
-
+ """Detects total newlines exceeding the set limit sent by a single user."""
relevant_messages = tuple(
msg
for msg in recent_messages
if msg.author == last_message.author
)
- total_recent_newlines = sum(msg.content.count('\n') for msg in relevant_messages)
+ # Identify groups of newline characters and get group & total counts
+ exp = r"(\n+)"
+ newline_counts = []
+ for msg in relevant_messages:
+ newline_counts += [len(group) for group in re.findall(exp, msg.content)]
+ total_recent_newlines = sum(newline_counts)
+ # Get maximum newline group size
+ if newline_counts:
+ max_newline_group = max(newline_counts)
+ else:
+ # If no newlines are found, newline_counts will be an empty list, which will error out max()
+ max_newline_group = 0
+
+ # Check first for total newlines, if this passes then check for large groupings
if total_recent_newlines > config['max']:
return (
f"sent {total_recent_newlines} newlines in {config['interval']}s",
(last_message.author,),
relevant_messages
)
+ elif max_newline_group > config['max_consecutive']:
+ return (
+ f"sent {max_newline_group} consecutive newlines in {config['interval']}s",
+ (last_message.author,),
+ relevant_messages
+ )
+
return None
diff --git a/bot/rules/role_mentions.py b/bot/rules/role_mentions.py
index 2177a73b5..0649540b6 100644
--- a/bot/rules/role_mentions.py
+++ b/bot/rules/role_mentions.py
@@ -1,16 +1,12 @@
-"""Detects total role mentions exceeding the limit sent by a single user."""
-
from typing import Dict, Iterable, List, Optional, Tuple
from discord import Member, Message
async def apply(
- last_message: Message,
- recent_messages: List[Message],
- config: Dict[str, int]
+ last_message: Message, recent_messages: List[Message], config: Dict[str, int]
) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]:
-
+ """Detects total role mentions exceeding the limit sent by a single user."""
relevant_messages = tuple(
msg
for msg in recent_messages
diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py
index 87351eaf3..8184be824 100644
--- a/bot/utils/__init__.py
+++ b/bot/utils/__init__.py
@@ -1,83 +1,13 @@
-import asyncio
-from typing import List
+from abc import ABCMeta
+from typing import Any, Generator, Hashable, Iterable
-import discord
-from discord.ext.commands import BadArgument, Context
+from discord.ext.commands import CogMeta
-from bot.pagination import LinePaginator
+class CogABCMeta(CogMeta, ABCMeta):
+ """Metaclass for ABCs meant to be implemented as Cogs."""
-async def disambiguate(
- ctx: Context, entries: List[str], *, timeout: float = 30,
- per_page: int = 20, empty: bool = False, embed: discord.Embed = None
-):
- """
- Has the user choose between multiple entries in case one could not be chosen automatically.
-
- This will raise a BadArgument if entries is empty, if the disambiguation event times out,
- or if the user makes an invalid choice.
-
- :param ctx: Context object from discord.py
- :param entries: List of items for user to choose from
- :param timeout: Number of seconds to wait before canceling disambiguation
- :param per_page: Entries per embed page
- :param empty: Whether the paginator should have an extra line between items
- :param embed: The embed that the paginator will use.
- :return: Users choice for correct entry.
- """
-
- if len(entries) == 0:
- raise BadArgument('No matches found.')
-
- if len(entries) == 1:
- return entries[0]
-
- choices = (f'{index}: {entry}' for index, entry in enumerate(entries, start=1))
-
- def check(message):
- return (message.content.isdigit()
- and message.author == ctx.author
- and message.channel == ctx.channel)
-
- try:
- if embed is None:
- embed = discord.Embed()
-
- coro1 = ctx.bot.wait_for('message', check=check, timeout=timeout)
- coro2 = LinePaginator.paginate(choices, ctx, embed=embed, max_lines=per_page,
- empty=empty, max_size=6000, timeout=9000)
-
- # wait_for timeout will go to except instead of the wait_for thing as I expected
- futures = [asyncio.ensure_future(coro1), asyncio.ensure_future(coro2)]
- done, pending = await asyncio.wait(futures, return_when=asyncio.FIRST_COMPLETED, loop=ctx.bot.loop)
-
- # :yert:
- result = list(done)[0].result()
-
- # Pagination was canceled - result is None
- if result is None:
- for coro in pending:
- coro.cancel()
- raise BadArgument('Canceled.')
-
- # Pagination was not initiated, only one page
- if result.author == ctx.bot.user:
- # Continue the wait_for
- result = await list(pending)[0]
-
- # Love that duplicate code
- for coro in pending:
- coro.cancel()
- except asyncio.TimeoutError:
- raise BadArgument('Timed out.')
-
- # Guaranteed to not error because of isdigit() in check
- index = int(result.content)
-
- try:
- return entries[index - 1]
- except IndexError:
- raise BadArgument('Invalid choice.')
+ pass
class CaseInsensitiveDict(dict):
@@ -88,50 +18,59 @@ class CaseInsensitiveDict(dict):
"""
@classmethod
- def _k(cls, key):
+ def _k(cls, key: Hashable) -> Hashable:
+ """Return lowered key if a string-like is passed, otherwise pass key straight through."""
return key.lower() if isinstance(key, str) else key
def __init__(self, *args, **kwargs):
super(CaseInsensitiveDict, self).__init__(*args, **kwargs)
self._convert_keys()
- def __getitem__(self, key):
+ def __getitem__(self, key: Hashable) -> Any:
+ """Case insensitive __setitem__."""
return super(CaseInsensitiveDict, self).__getitem__(self.__class__._k(key))
- def __setitem__(self, key, value):
+ def __setitem__(self, key: Hashable, value: Any):
+ """Case insensitive __setitem__."""
super(CaseInsensitiveDict, self).__setitem__(self.__class__._k(key), value)
- def __delitem__(self, key):
+ def __delitem__(self, key: Hashable) -> Any:
+ """Case insensitive __delitem__."""
return super(CaseInsensitiveDict, self).__delitem__(self.__class__._k(key))
- def __contains__(self, key):
+ def __contains__(self, key: Hashable) -> bool:
+ """Case insensitive __contains__."""
return super(CaseInsensitiveDict, self).__contains__(self.__class__._k(key))
- def pop(self, key, *args, **kwargs):
+ def pop(self, key: Hashable, *args, **kwargs) -> Any:
+ """Case insensitive pop."""
return super(CaseInsensitiveDict, self).pop(self.__class__._k(key), *args, **kwargs)
- def get(self, key, *args, **kwargs):
+ def get(self, key: Hashable, *args, **kwargs) -> Any:
+ """Case insensitive get."""
return super(CaseInsensitiveDict, self).get(self.__class__._k(key), *args, **kwargs)
- def setdefault(self, key, *args, **kwargs):
+ def setdefault(self, key: Hashable, *args, **kwargs) -> Any:
+ """Case insensitive setdefault."""
return super(CaseInsensitiveDict, self).setdefault(self.__class__._k(key), *args, **kwargs)
- def update(self, E=None, **F):
+ def update(self, E: Any = None, **F) -> None:
+ """Case insensitive update."""
super(CaseInsensitiveDict, self).update(self.__class__(E))
super(CaseInsensitiveDict, self).update(self.__class__(**F))
- def _convert_keys(self):
+ def _convert_keys(self) -> None:
+ """Helper method to lowercase all existing string-like keys."""
for k in list(self.keys()):
v = super(CaseInsensitiveDict, self).pop(k)
self.__setitem__(k, v)
-def chunks(iterable, size):
+def chunks(iterable: Iterable, size: int) -> Generator[Any, None, None]:
"""
- Generator that allows you to iterate over any indexable collection in `size`-length chunks
+ Generator that allows you to iterate over any indexable collection in `size`-length chunks.
Found: https://stackoverflow.com/a/312464/4022104
"""
-
for i in range(0, len(iterable), size):
yield iterable[i:i + size]
diff --git a/bot/utils/checks.py b/bot/utils/checks.py
new file mode 100644
index 000000000..19f64ff9f
--- /dev/null
+++ b/bot/utils/checks.py
@@ -0,0 +1,44 @@
+import logging
+
+from discord.ext.commands import Context
+
+log = logging.getLogger(__name__)
+
+
+def with_role_check(ctx: Context, *role_ids: int) -> bool:
+ """Returns True if the user has any one of the roles in role_ids."""
+ if not ctx.guild: # Return False in a DM
+ log.trace(f"{ctx.author} tried to use the '{ctx.command.name}'command from a DM. "
+ "This command is restricted by the with_role decorator. Rejecting request.")
+ return False
+
+ for role in ctx.author.roles:
+ if role.id in role_ids:
+ log.trace(f"{ctx.author} has the '{role.name}' role, and passes the check.")
+ return True
+
+ log.trace(f"{ctx.author} does not have the required role to use "
+ f"the '{ctx.command.name}' command, so the request is rejected.")
+ return False
+
+
+def without_role_check(ctx: Context, *role_ids: int) -> bool:
+ """Returns True if the user does not have any of the roles in role_ids."""
+ if not ctx.guild: # Return False in a DM
+ log.trace(f"{ctx.author} tried to use the '{ctx.command.name}' command from a DM. "
+ "This command is restricted by the without_role decorator. Rejecting request.")
+ return False
+
+ author_roles = [role.id for role in ctx.author.roles]
+ check = all(role not in author_roles for role in role_ids)
+ log.trace(f"{ctx.author} tried to call the '{ctx.command.name}' command. "
+ f"The result of the without_role check was {check}.")
+ return check
+
+
+def in_channel_check(ctx: Context, channel_id: int) -> bool:
+ """Checks if the command was executed inside of the specified channel."""
+ check = ctx.channel.id == channel_id
+ log.trace(f"{ctx.author} tried to call the '{ctx.command.name}' command. "
+ f"The result of the in_channel check was {check}.")
+ return check
diff --git a/bot/utils/messages.py b/bot/utils/messages.py
index fc38b0127..549b33ca6 100644
--- a/bot/utils/messages.py
+++ b/bot/utils/messages.py
@@ -1,9 +1,9 @@
import asyncio
import contextlib
from io import BytesIO
-from typing import Sequence
+from typing import Optional, Sequence, Union
-from discord import Embed, File, Message, TextChannel
+from discord import Client, Embed, File, Member, Message, Reaction, TextChannel, Webhook
from discord.abc import Snowflake
from discord.errors import HTTPException
@@ -17,42 +17,18 @@ async def wait_for_deletion(
user_ids: Sequence[Snowflake],
deletion_emojis: Sequence[str] = (Emojis.cross_mark,),
timeout: float = 60 * 5,
- attach_emojis=True,
- client=None
-):
- """
- Waits for up to `timeout` seconds for a reaction by
- any of the specified `user_ids` to delete the message.
-
- Args:
- message (Message):
- The message that should be monitored for reactions
- and possibly deleted. Must be a message sent on a
- guild since access to the bot instance is required.
-
- user_ids (Sequence[Snowflake]):
- A sequence of users that are allowed to delete
- this message.
-
- Kwargs:
- deletion_emojis (Sequence[str]):
- A sequence of emojis that are considered deletion
- emojis.
-
- timeout (float):
- A positive float denoting the maximum amount of
- time to wait for a deletion reaction.
-
- attach_emojis (bool):
- Whether to attach the given `deletion_emojis`
- to the message in the given `context`
-
- client (Optional[discord.Client]):
- The client instance handling the original command.
- If not given, will take the client from the guild
- of the message.
+ attach_emojis: bool = True,
+ client: Optional[Client] = None
+) -> None:
"""
+ Wait for up to `timeout` seconds for a reaction by any of the specified `user_ids` to delete the message.
+
+ An `attach_emojis` bool may be specified to determine whether to attach the given
+ `deletion_emojis` to the message in the given `context`
+ A `client` instance may be optionally specified, otherwise client will be taken from the
+ guild of the message.
+ """
if message.guild is None and client is None:
raise ValueError("Message must be sent on a guild")
@@ -62,7 +38,8 @@ async def wait_for_deletion(
for emoji in deletion_emojis:
await message.add_reaction(emoji)
- def check(reaction, user):
+ def check(reaction: Reaction, user: Member) -> bool:
+ """Check that the deletion emoji is reacted by the approprite user."""
return (
reaction.message.id == message.id
and reaction.emoji in deletion_emojis
@@ -70,25 +47,17 @@ async def wait_for_deletion(
)
with contextlib.suppress(asyncio.TimeoutError):
- await bot.wait_for(
- 'reaction_add',
- check=check,
- timeout=timeout
- )
+ await bot.wait_for('reaction_add', check=check, timeout=timeout)
await message.delete()
-async def send_attachments(message: Message, destination: TextChannel):
+async def send_attachments(message: Message, destination: Union[TextChannel, Webhook]) -> None:
"""
- Re-uploads each attachment in a message to the given channel.
+ Re-uploads each attachment in a message to the given channel or webhook.
Each attachment is sent as a separate message to more easily comply with the 8 MiB request size limit.
If attachments are too large, they are instead grouped into a single embed which links to them.
-
- :param message: the message whose attachments to re-upload
- :param destination: the channel in which to re-upload the attachments
"""
-
large = []
for attachment in message.attachments:
try:
@@ -97,7 +66,16 @@ async def send_attachments(message: Message, destination: TextChannel):
if attachment.size <= MAX_SIZE - 512:
with BytesIO() as file:
await attachment.save(file)
- await destination.send(file=File(file, filename=attachment.filename))
+ attachment_file = File(file, filename=attachment.filename)
+
+ if isinstance(destination, TextChannel):
+ await destination.send(file=attachment_file)
+ else:
+ await destination.send(
+ file=attachment_file,
+ username=message.author.display_name,
+ avatar_url=message.author.avatar_url
+ )
else:
large.append(attachment)
except HTTPException as e:
@@ -109,4 +87,11 @@ async def send_attachments(message: Message, destination: TextChannel):
if large:
embed = Embed(description=f"\n".join(f"[{attachment.filename}]({attachment.url})" for attachment in large))
embed.set_footer(text="Attachments exceed upload size limit.")
- await destination.send(embed=embed)
+ if isinstance(destination, TextChannel):
+ await destination.send(embed=embed)
+ else:
+ await destination.send(
+ embed=embed,
+ username=message.author.display_name,
+ avatar_url=message.author.avatar_url
+ )
diff --git a/bot/utils/moderation.py b/bot/utils/moderation.py
deleted file mode 100644
index fcdf3c4d5..000000000
--- a/bot/utils/moderation.py
+++ /dev/null
@@ -1,40 +0,0 @@
-import logging
-from datetime import datetime
-from typing import Union
-
-from aiohttp import ClientError
-from discord import Member, Object, User
-from discord.ext.commands import Context
-
-from bot.constants import Keys
-
-log = logging.getLogger(__name__)
-
-HEADERS = {"X-API-KEY": Keys.site_api}
-
-
-async def post_infraction(
- ctx: Context, user: Union[Member, Object, User],
- type: str, reason: str, expires_at: datetime = None, hidden: bool = False
-):
-
- payload = {
- "actor": ctx.message.author.id,
- "hidden": hidden,
- "reason": reason,
- "type": type,
- "user": user.id
- }
- if expires_at:
- payload['expires_at'] = expires_at.isoformat()
-
- try:
- response = await ctx.bot.api_client.post(
- 'bot/infractions', json=payload
- )
- except ClientError:
- log.exception("There was an error adding an infraction.")
- await ctx.send(":x: There was an error adding the infraction.")
- return
-
- return response
diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py
index ded6401b0..08abd91d7 100644
--- a/bot/utils/scheduling.py
+++ b/bot/utils/scheduling.py
@@ -1,13 +1,16 @@
import asyncio
import contextlib
import logging
-from abc import ABC, abstractmethod
-from typing import Dict
+from abc import abstractmethod
+from typing import Coroutine, Dict, Union
+
+from bot.utils import CogABCMeta
log = logging.getLogger(__name__)
-class Scheduler(ABC):
+class Scheduler(metaclass=CogABCMeta):
+ """Task scheduler."""
def __init__(self):
@@ -15,24 +18,23 @@ class Scheduler(ABC):
self.scheduled_tasks: Dict[str, asyncio.Task] = {}
@abstractmethod
- async def _scheduled_task(self, task_object: dict):
+ async def _scheduled_task(self, task_object: dict) -> None:
"""
- A coroutine which handles the scheduling. This is added to the scheduled tasks,
- and should wait the task duration, execute the desired code, and clean up the task.
+ A coroutine which handles the scheduling.
+
+ This is added to the scheduled tasks, and should wait the task duration, execute the desired
+ code, then clean up the task.
+
For example, in Reminders this will wait for the reminder duration, send the reminder,
then make a site API request to delete the reminder from the database.
-
- :param task_object:
"""
- def schedule_task(self, loop: asyncio.AbstractEventLoop, task_id: str, task_data: dict):
+ def schedule_task(self, loop: asyncio.AbstractEventLoop, task_id: str, task_data: dict) -> None:
"""
Schedules a task.
- :param loop: the asyncio event loop
- :param task_id: the ID of the task.
- :param task_data: the data of the task, passed to `Scheduler._scheduled_expiration`.
- """
+ `task_data` is passed to `Scheduler._scheduled_expiration`
+ """
if task_id in self.scheduled_tasks:
return
@@ -40,12 +42,8 @@ class Scheduler(ABC):
self.scheduled_tasks[task_id] = task
- def cancel_task(self, task_id: str):
- """
- Un-schedules a task.
- :param task_id: the ID of the infraction in question
- """
-
+ def cancel_task(self, task_id: str) -> None:
+ """Un-schedules a task."""
task = self.scheduled_tasks.get(task_id)
if task is None:
@@ -57,14 +55,8 @@ class Scheduler(ABC):
del self.scheduled_tasks[task_id]
-def create_task(loop: asyncio.AbstractEventLoop, coro_or_future):
- """
- Creates an asyncio.Task object from a coroutine or future object.
-
- :param loop: the asyncio event loop.
- :param coro_or_future: the coroutine or future object to be scheduled.
- """
-
+def create_task(loop: asyncio.AbstractEventLoop, coro_or_future: Union[Coroutine, asyncio.Future]) -> asyncio.Task:
+ """Creates an asyncio.Task object from a coroutine or future object."""
task: asyncio.Task = asyncio.ensure_future(coro_or_future, loop=loop)
# Silently ignore exceptions in a callback (handles the CancelledError nonsense)
@@ -72,6 +64,7 @@ def create_task(loop: asyncio.AbstractEventLoop, coro_or_future):
return task
-def _silent_exception(future):
+def _silent_exception(future: asyncio.Future) -> None:
+ """Suppress future's exception."""
with contextlib.suppress(Exception):
future.exception()
diff --git a/bot/utils/snakes/hatching.py b/bot/utils/snakes/hatching.py
deleted file mode 100644
index b9d29583f..000000000
--- a/bot/utils/snakes/hatching.py
+++ /dev/null
@@ -1,44 +0,0 @@
-h1 = r'''```
- ----
- ------
- /--------\
- |--------|
- |--------|
- \------/
- ----```'''
-
-h2 = r'''```
- ----
- ------
- /---\-/--\
- |-----\--|
- |--------|
- \------/
- ----```'''
-
-h3 = r'''```
- ----
- ------
- /---\-/--\
- |-----\--|
- |-----/--|
- \----\-/
- ----```'''
-
-h4 = r'''```
- -----
- ----- \
- /--| /---\
- |--\ -\---|
- |--\--/-- /
- \------- /
- ------```'''
-
-stages = [h1, h2, h3, h4]
-snakes = {
- "Baby Python": "https://i.imgur.com/SYOcmSa.png",
- "Baby Rattle Snake": "https://i.imgur.com/i5jYA8f.png",
- "Baby Dragon Snake": "https://i.imgur.com/SuMKM4m.png",
- "Baby Garden Snake": "https://i.imgur.com/5vYx3ah.png",
- "Baby Cobra": "https://i.imgur.com/jk14ryt.png"
-}
diff --git a/bot/utils/snakes/perlin.py b/bot/utils/snakes/perlin.py
deleted file mode 100644
index 0401787ef..000000000
--- a/bot/utils/snakes/perlin.py
+++ /dev/null
@@ -1,158 +0,0 @@
-"""
-Perlin noise implementation.
-Taken from: https://gist.github.com/eevee/26f547457522755cb1fb8739d0ea89a1
-Licensed under ISC
-"""
-
-import math
-import random
-from itertools import product
-
-
-def smoothstep(t):
- """Smooth curve with a zero derivative at 0 and 1, making it useful for
- interpolating.
- """
- return t * t * (3. - 2. * t)
-
-
-def lerp(t, a, b):
- """Linear interpolation between a and b, given a fraction t."""
- return a + t * (b - a)
-
-
-class PerlinNoiseFactory(object):
- """Callable that produces Perlin noise for an arbitrary point in an
- arbitrary number of dimensions. The underlying grid is aligned with the
- integers.
- There is no limit to the coordinates used; new gradients are generated on
- the fly as necessary.
- """
-
- def __init__(self, dimension, octaves=1, tile=(), unbias=False):
- """Create a new Perlin noise factory in the given number of dimensions,
- which should be an integer and at least 1.
- More octaves create a foggier and more-detailed noise pattern. More
- than 4 octaves is rather excessive.
- ``tile`` can be used to make a seamlessly tiling pattern. For example:
- pnf = PerlinNoiseFactory(2, tile=(0, 3))
- This will produce noise that tiles every 3 units vertically, but never
- tiles horizontally.
- If ``unbias`` is true, the smoothstep function will be applied to the
- output before returning it, to counteract some of Perlin noise's
- significant bias towards the center of its output range.
- """
- self.dimension = dimension
- self.octaves = octaves
- self.tile = tile + (0,) * dimension
- self.unbias = unbias
-
- # For n dimensions, the range of Perlin noise is ±sqrt(n)/2; multiply
- # by this to scale to ±1
- self.scale_factor = 2 * dimension ** -0.5
-
- self.gradient = {}
-
- def _generate_gradient(self):
- # Generate a random unit vector at each grid point -- this is the
- # "gradient" vector, in that the grid tile slopes towards it
-
- # 1 dimension is special, since the only unit vector is trivial;
- # instead, use a slope between -1 and 1
- if self.dimension == 1:
- return (random.uniform(-1, 1),)
-
- # Generate a random point on the surface of the unit n-hypersphere;
- # this is the same as a random unit vector in n dimensions. Thanks
- # to: http://mathworld.wolfram.com/SpherePointPicking.html
- # Pick n normal random variables with stddev 1
- random_point = [random.gauss(0, 1) for _ in range(self.dimension)]
- # Then scale the result to a unit vector
- scale = sum(n * n for n in random_point) ** -0.5
- return tuple(coord * scale for coord in random_point)
-
- def get_plain_noise(self, *point):
- """Get plain noise for a single point, without taking into account
- either octaves or tiling.
- """
- if len(point) != self.dimension:
- raise ValueError("Expected {0} values, got {1}".format(
- self.dimension, len(point)))
-
- # Build a list of the (min, max) bounds in each dimension
- grid_coords = []
- for coord in point:
- min_coord = math.floor(coord)
- max_coord = min_coord + 1
- grid_coords.append((min_coord, max_coord))
-
- # Compute the dot product of each gradient vector and the point's
- # distance from the corresponding grid point. This gives you each
- # gradient's "influence" on the chosen point.
- dots = []
- for grid_point in product(*grid_coords):
- if grid_point not in self.gradient:
- self.gradient[grid_point] = self._generate_gradient()
- gradient = self.gradient[grid_point]
-
- dot = 0
- for i in range(self.dimension):
- dot += gradient[i] * (point[i] - grid_point[i])
- dots.append(dot)
-
- # Interpolate all those dot products together. The interpolation is
- # done with smoothstep to smooth out the slope as you pass from one
- # grid cell into the next.
- # Due to the way product() works, dot products are ordered such that
- # the last dimension alternates: (..., min), (..., max), etc. So we
- # can interpolate adjacent pairs to "collapse" that last dimension. Then
- # the results will alternate in their second-to-last dimension, and so
- # forth, until we only have a single value left.
- dim = self.dimension
- while len(dots) > 1:
- dim -= 1
- s = smoothstep(point[dim] - grid_coords[dim][0])
-
- next_dots = []
- while dots:
- next_dots.append(lerp(s, dots.pop(0), dots.pop(0)))
-
- dots = next_dots
-
- return dots[0] * self.scale_factor
-
- def __call__(self, *point):
- """Get the value of this Perlin noise function at the given point. The
- number of values given should match the number of dimensions.
- """
- ret = 0
- for o in range(self.octaves):
- o2 = 1 << o
- new_point = []
- for i, coord in enumerate(point):
- coord *= o2
- if self.tile[i]:
- coord %= self.tile[i] * o2
- new_point.append(coord)
- ret += self.get_plain_noise(*new_point) / o2
-
- # Need to scale n back down since adding all those extra octaves has
- # probably expanded it beyond ±1
- # 1 octave: ±1
- # 2 octaves: ±1½
- # 3 octaves: ±1¾
- ret /= 2 - 2 ** (1 - self.octaves)
-
- if self.unbias:
- # The output of the plain Perlin noise algorithm has a fairly
- # strong bias towards the center due to the central limit theorem
- # -- in fact the top and bottom 1/8 virtually never happen. That's
- # a quarter of our entire output range! If only we had a function
- # in [0..1] that could introduce a bias towards the endpoints...
- r = (ret + 1) / 2
- # Doing it this many times is a completely made-up heuristic.
- for _ in range(int(self.octaves / 2 + 0.5)):
- r = smoothstep(r)
- ret = r * 2 - 1
-
- return ret
diff --git a/bot/utils/snakes/perlinsneks.py b/bot/utils/snakes/perlinsneks.py
deleted file mode 100644
index 662281775..000000000
--- a/bot/utils/snakes/perlinsneks.py
+++ /dev/null
@@ -1,111 +0,0 @@
-# perlin sneks!
-import io
-import math
-import random
-from typing import Tuple
-
-from PIL.ImageDraw import Image, ImageDraw
-
-from bot.utils.snakes import perlin
-
-DEFAULT_SNAKE_COLOR: int = 0x15c7ea
-DEFAULT_BACKGROUND_COLOR: int = 0
-DEFAULT_IMAGE_DIMENSIONS: Tuple[int] = (200, 200)
-DEFAULT_SNAKE_LENGTH: int = 22
-DEFAULT_SNAKE_WIDTH: int = 8
-DEFAULT_SEGMENT_LENGTH_RANGE: Tuple[int] = (7, 10)
-DEFAULT_IMAGE_MARGINS: Tuple[int] = (50, 50)
-DEFAULT_TEXT: str = "snek\nit\nup"
-DEFAULT_TEXT_POSITION: Tuple[int] = (
- 10,
- 10
-)
-DEFAULT_TEXT_COLOR: int = 0xf2ea15
-
-X = 0
-Y = 1
-ANGLE_RANGE = math.pi * 2
-
-
-def create_snek_frame(
- perlin_factory: perlin.PerlinNoiseFactory, perlin_lookup_vertical_shift: float = 0,
- image_dimensions: Tuple[int] = DEFAULT_IMAGE_DIMENSIONS, image_margins: Tuple[int] = DEFAULT_IMAGE_MARGINS,
- snake_length: int = DEFAULT_SNAKE_LENGTH,
- snake_color: int = DEFAULT_SNAKE_COLOR, bg_color: int = DEFAULT_BACKGROUND_COLOR,
- segment_length_range: Tuple[int] = DEFAULT_SEGMENT_LENGTH_RANGE, snake_width: int = DEFAULT_SNAKE_WIDTH,
- text: str = DEFAULT_TEXT, text_position: Tuple[int] = DEFAULT_TEXT_POSITION,
- text_color: Tuple[int] = DEFAULT_TEXT_COLOR
-) -> Image:
- """
- Creates a single random snek frame using Perlin noise.
- :param perlin_factory: the perlin noise factory used. Required.
- :param perlin_lookup_vertical_shift: the Perlin noise shift in the Y-dimension for this frame
- :param image_dimensions: the size of the output image.
- :param image_margins: the margins to respect inside of the image.
- :param snake_length: the length of the snake, in segments.
- :param snake_color: the color of the snake.
- :param bg_color: the background color.
- :param segment_length_range: the range of the segment length. Values will be generated inside this range, including
- the bounds.
- :param snake_width: the width of the snek, in pixels.
- :param text: the text to display with the snek. Set to None for no text.
- :param text_position: the position of the text.
- :param text_color: the color of the text.
- :return: a PIL image, representing a single frame.
- """
- start_x = random.randint(image_margins[X], image_dimensions[X] - image_margins[X])
- start_y = random.randint(image_margins[Y], image_dimensions[Y] - image_margins[Y])
- points = [(start_x, start_y)]
-
- for index in range(0, snake_length):
- angle = perlin_factory.get_plain_noise(
- ((1 / (snake_length + 1)) * (index + 1)) + perlin_lookup_vertical_shift
- ) * ANGLE_RANGE
- current_point = points[index]
- segment_length = random.randint(segment_length_range[0], segment_length_range[1])
- points.append((
- current_point[X] + segment_length * math.cos(angle),
- current_point[Y] + segment_length * math.sin(angle)
- ))
-
- # normalize bounds
- min_dimensions = [start_x, start_y]
- max_dimensions = [start_x, start_y]
- for point in points:
- min_dimensions[X] = min(point[X], min_dimensions[X])
- min_dimensions[Y] = min(point[Y], min_dimensions[Y])
- max_dimensions[X] = max(point[X], max_dimensions[X])
- max_dimensions[Y] = max(point[Y], max_dimensions[Y])
-
- # shift towards middle
- dimension_range = (max_dimensions[X] - min_dimensions[X], max_dimensions[Y] - min_dimensions[Y])
- shift = (
- image_dimensions[X] / 2 - (dimension_range[X] / 2 + min_dimensions[X]),
- image_dimensions[Y] / 2 - (dimension_range[Y] / 2 + min_dimensions[Y])
- )
-
- image = Image.new(mode='RGB', size=image_dimensions, color=bg_color)
- draw = ImageDraw(image)
- for index in range(1, len(points)):
- point = points[index]
- previous = points[index - 1]
- draw.line(
- (
- shift[X] + previous[X],
- shift[Y] + previous[Y],
- shift[X] + point[X],
- shift[Y] + point[Y]
- ),
- width=snake_width,
- fill=snake_color
- )
- if text is not None:
- draw.multiline_text(text_position, text, fill=text_color)
- del draw
- return image
-
-
-def frame_to_png_bytes(image: Image):
- stream = io.BytesIO()
- image.save(stream, format='PNG')
- return stream.getvalue()
diff --git a/bot/utils/snakes/sal.py b/bot/utils/snakes/sal.py
deleted file mode 100644
index 2528664aa..000000000
--- a/bot/utils/snakes/sal.py
+++ /dev/null
@@ -1,365 +0,0 @@
-import asyncio
-import io
-import logging
-import math
-import os
-import random
-
-import aiohttp
-from PIL import Image
-from discord import File, Member, Reaction
-from discord.ext.commands import Context
-
-from bot.utils.snakes.sal_board import (
- BOARD, BOARD_MARGIN, BOARD_PLAYER_SIZE,
- BOARD_TILE_SIZE, MAX_PLAYERS, PLAYER_ICON_IMAGE_SIZE
-)
-
-log = logging.getLogger(__name__)
-
-# Emoji constants
-START_EMOJI = "\u2611" # :ballot_box_with_check: - Start the game
-CANCEL_EMOJI = "\u274C" # :x: - Cancel or leave the game
-ROLL_EMOJI = "\U0001F3B2" # :game_die: - Roll the die!
-JOIN_EMOJI = "\U0001F64B" # :raising_hand: - Join the game.
-
-STARTUP_SCREEN_EMOJI = [
- JOIN_EMOJI,
- START_EMOJI,
- CANCEL_EMOJI
-]
-
-GAME_SCREEN_EMOJI = [
- ROLL_EMOJI,
- CANCEL_EMOJI
-]
-
-
-class SnakeAndLaddersGame:
- def __init__(self, snakes, context: Context):
- self.snakes = snakes
- self.ctx = context
- self.channel = self.ctx.channel
- self.state = 'booting'
- self.started = False
- self.author = self.ctx.author
- self.players = []
- self.player_tiles = {}
- self.round_has_rolled = {}
- self.avatar_images = {}
- self.board = None
- self.positions = None
- self.rolls = []
-
- async def open_game(self):
- """
- Create a new Snakes and Ladders game.
-
- Listen for reactions until players have joined,
- and the game has been started.
- """
-
- def startup_event_check(reaction_: Reaction, user_: Member):
- """
- Make sure that this reaction is what we want to operate on
- """
- return (
- all((
- reaction_.message.id == startup.id, # Reaction is on startup message
- reaction_.emoji in STARTUP_SCREEN_EMOJI, # Reaction is one of the startup emotes
- user_.id != self.ctx.bot.user.id, # Reaction was not made by the bot
- ))
- )
-
- # Check to see if the bot can remove reactions
- if not self.channel.permissions_for(self.ctx.guild.me).manage_messages:
- log.warning(
- "Unable to start Snakes and Ladders - "
- f"Missing manage_messages permissions in {self.channel}"
- )
- return
-
- await self._add_player(self.author)
- await self.channel.send(
- "**Snakes and Ladders**: A new game is about to start!",
- file=File(
- os.path.join("bot", "resources", "snakes_and_ladders", "banner.jpg"),
- filename='Snakes and Ladders.jpg'
- )
- )
- startup = await self.channel.send(
- f"Press {JOIN_EMOJI} to participate, and press "
- f"{START_EMOJI} to start the game"
- )
- for emoji in STARTUP_SCREEN_EMOJI:
- await startup.add_reaction(emoji)
-
- self.state = 'waiting'
-
- while not self.started:
- try:
- reaction, user = await self.ctx.bot.wait_for(
- "reaction_add",
- timeout=300,
- check=startup_event_check
- )
- if reaction.emoji == JOIN_EMOJI:
- await self.player_join(user)
- elif reaction.emoji == CANCEL_EMOJI:
- if self.ctx.author == user:
- await self.cancel_game(user)
- return
- else:
- await self.player_leave(user)
- elif reaction.emoji == START_EMOJI:
- if self.ctx.author == user:
- self.started = True
- await self.start_game(user)
- await startup.delete()
- break
-
- await startup.remove_reaction(reaction.emoji, user)
-
- except asyncio.TimeoutError:
- log.debug("Snakes and Ladders timed out waiting for a reaction")
- self.cancel_game(self.author)
- return # We're done, no reactions for the last 5 minutes
-
- async def _add_player(self, user: Member):
- self.players.append(user)
- self.player_tiles[user.id] = 1
- avatar_url = user.avatar_url_as(format='jpeg', size=PLAYER_ICON_IMAGE_SIZE)
- async with aiohttp.ClientSession() as session:
- async with session.get(avatar_url) as res:
- avatar_bytes = await res.read()
- im = Image.open(io.BytesIO(avatar_bytes)).resize((BOARD_PLAYER_SIZE, BOARD_PLAYER_SIZE))
- self.avatar_images[user.id] = im
-
- async def player_join(self, user: Member):
- for p in self.players:
- if user == p:
- await self.channel.send(user.mention + " You are already in the game.", delete_after=10)
- return
- if self.state != 'waiting':
- await self.channel.send(user.mention + " You cannot join at this time.", delete_after=10)
- return
- if len(self.players) is MAX_PLAYERS:
- await self.channel.send(user.mention + " The game is full!", delete_after=10)
- return
-
- await self._add_player(user)
-
- await self.channel.send(
- f"**Snakes and Ladders**: {user.mention} has joined the game.\n"
- f"There are now {str(len(self.players))} players in the game.",
- delete_after=10
- )
-
- async def player_leave(self, user: Member):
- if user == self.author:
- await self.channel.send(
- user.mention + " You are the author, and cannot leave the game. Execute "
- "`sal cancel` to cancel the game.",
- delete_after=10
- )
- return
- for p in self.players:
- if user == p:
- self.players.remove(p)
- self.player_tiles.pop(p.id, None)
- self.round_has_rolled.pop(p.id, None)
- await self.channel.send(
- "**Snakes and Ladders**: " + user.mention + " has left the game.",
- delete_after=10
- )
-
- if self.state != 'waiting' and len(self.players) == 1:
- await self.channel.send("**Snakes and Ladders**: The game has been surrendered!")
- self._destruct()
- return
- await self.channel.send(user.mention + " You are not in the match.", delete_after=10)
-
- async def cancel_game(self, user: Member):
- if not user == self.author:
- await self.channel.send(user.mention + " Only the author of the game can cancel it.", delete_after=10)
- return
- await self.channel.send("**Snakes and Ladders**: Game has been canceled.")
- self._destruct()
-
- async def start_game(self, user: Member):
- if not user == self.author:
- await self.channel.send(user.mention + " Only the author of the game can start it.", delete_after=10)
- return
- if len(self.players) < 1:
- await self.channel.send(
- user.mention + " A minimum of 2 players is required to start the game.",
- delete_after=10
- )
- return
- if not self.state == 'waiting':
- await self.channel.send(user.mention + " The game cannot be started at this time.", delete_after=10)
- return
- self.state = 'starting'
- player_list = ', '.join(user.mention for user in self.players)
- await self.channel.send("**Snakes and Ladders**: The game is starting!\nPlayers: " + player_list)
- await self.start_round()
-
- async def start_round(self):
-
- def game_event_check(reaction_: Reaction, user_: Member):
- """
- Make sure that this reaction is what we want to operate on
- """
- return (
- all((
- reaction_.message.id == self.positions.id, # Reaction is on positions message
- reaction_.emoji in GAME_SCREEN_EMOJI, # Reaction is one of the game emotes
- user_.id != self.ctx.bot.user.id, # Reaction was not made by the bot
- ))
- )
-
- self.state = 'roll'
- for user in self.players:
- self.round_has_rolled[user.id] = False
- board_img = Image.open(os.path.join("bot", "resources", "snakes_and_ladders", "board.jpg"))
- player_row_size = math.ceil(MAX_PLAYERS / 2)
-
- for i, player in enumerate(self.players):
- tile = self.player_tiles[player.id]
- tile_coordinates = self._board_coordinate_from_index(tile)
- x_offset = BOARD_MARGIN[0] + tile_coordinates[0] * BOARD_TILE_SIZE
- y_offset = \
- BOARD_MARGIN[1] + (
- (10 * BOARD_TILE_SIZE) - (9 - tile_coordinates[1]) * BOARD_TILE_SIZE - BOARD_PLAYER_SIZE)
- x_offset += BOARD_PLAYER_SIZE * (i % player_row_size)
- y_offset -= BOARD_PLAYER_SIZE * math.floor(i / player_row_size)
- board_img.paste(self.avatar_images[player.id],
- box=(x_offset, y_offset))
- stream = io.BytesIO()
- board_img.save(stream, format='JPEG')
- board_file = File(stream.getvalue(), filename='Board.jpg')
- player_list = '\n'.join((user.mention + ": Tile " + str(self.player_tiles[user.id])) for user in self.players)
-
- # Store and send new messages
- temp_board = await self.channel.send(
- "**Snakes and Ladders**: A new round has started! Current board:",
- file=board_file
- )
- temp_positions = await self.channel.send(
- f"**Current positions**:\n{player_list}\n\nUse {ROLL_EMOJI} to roll the dice!"
- )
-
- # Delete the previous messages
- if self.board and self.positions:
- await self.board.delete()
- await self.positions.delete()
-
- # remove the roll messages
- for roll in self.rolls:
- await roll.delete()
- self.rolls = []
-
- # Save new messages
- self.board = temp_board
- self.positions = temp_positions
-
- # Wait for rolls
- for emoji in GAME_SCREEN_EMOJI:
- await self.positions.add_reaction(emoji)
-
- while True:
- try:
- reaction, user = await self.ctx.bot.wait_for(
- "reaction_add",
- timeout=300,
- check=game_event_check
- )
-
- if reaction.emoji == ROLL_EMOJI:
- await self.player_roll(user)
- elif reaction.emoji == CANCEL_EMOJI:
- if self.ctx.author == user:
- await self.cancel_game(user)
- return
- else:
- await self.player_leave(user)
-
- await self.positions.remove_reaction(reaction.emoji, user)
-
- if self._check_all_rolled():
- break
-
- except asyncio.TimeoutError:
- log.debug("Snakes and Ladders timed out waiting for a reaction")
- await self.cancel_game(self.author)
- return # We're done, no reactions for the last 5 minutes
-
- # Round completed
- await self._complete_round()
-
- async def player_roll(self, user: Member):
- if user.id not in self.player_tiles:
- await self.channel.send(user.mention + " You are not in the match.", delete_after=10)
- return
- if self.state != 'roll':
- await self.channel.send(user.mention + " You may not roll at this time.", delete_after=10)
- return
- if self.round_has_rolled[user.id]:
- return
- roll = random.randint(1, 6)
- self.rolls.append(await self.channel.send(f"{user.mention} rolled a **{roll}**!"))
- next_tile = self.player_tiles[user.id] + roll
-
- # apply snakes and ladders
- if next_tile in BOARD:
- target = BOARD[next_tile]
- if target < next_tile:
- await self.channel.send(
- f"{user.mention} slips on a snake and falls back to **{target}**",
- delete_after=15
- )
- else:
- await self.channel.send(
- f"{user.mention} climbs a ladder to **{target}**",
- delete_after=15
- )
- next_tile = target
-
- self.player_tiles[user.id] = min(100, next_tile)
- self.round_has_rolled[user.id] = True
-
- async def _complete_round(self):
-
- self.state = 'post_round'
-
- # check for winner
- winner = self._check_winner()
- if winner is None:
- # there is no winner, start the next round
- await self.start_round()
- return
-
- # announce winner and exit
- await self.channel.send("**Snakes and Ladders**: " + winner.mention + " has won the game! :tada:")
- self._destruct()
-
- def _check_winner(self) -> Member:
- if self.state != 'post_round':
- return None
- return next((player for player in self.players if self.player_tiles[player.id] == 100),
- None)
-
- def _check_all_rolled(self):
- return all(rolled for rolled in self.round_has_rolled.values())
-
- def _destruct(self):
- del self.snakes.active_sal[self.channel]
-
- def _board_coordinate_from_index(self, index: int):
- # converts the tile number to the x/y coordinates for graphical purposes
- y_level = 9 - math.floor((index - 1) / 10)
- is_reversed = math.floor((index - 1) / 10) % 2 != 0
- x_level = (index - 1) % 10
- if is_reversed:
- x_level = 9 - x_level
- return x_level, y_level
diff --git a/bot/utils/snakes/sal_board.py b/bot/utils/snakes/sal_board.py
deleted file mode 100644
index 1b8eab451..000000000
--- a/bot/utils/snakes/sal_board.py
+++ /dev/null
@@ -1,33 +0,0 @@
-BOARD_TILE_SIZE = 56 # the size of each board tile
-BOARD_PLAYER_SIZE = 20 # the size of each player icon
-BOARD_MARGIN = (10, 0) # margins, in pixels (for player icons)
-PLAYER_ICON_IMAGE_SIZE = 32 # the size of the image to download, should a power of 2 and higher than BOARD_PLAYER_SIZE
-MAX_PLAYERS = 4 # depends on the board size/quality, 4 is for the default board
-
-# board definition (from, to)
-BOARD = {
- # ladders
- 2: 38,
- 7: 14,
- 8: 31,
- 15: 26,
- 21: 42,
- 28: 84,
- 36: 44,
- 51: 67,
- 71: 91,
- 78: 98,
- 87: 94,
-
- # snakes
- 99: 80,
- 95: 75,
- 92: 88,
- 89: 68,
- 74: 53,
- 64: 60,
- 62: 19,
- 49: 11,
- 46: 25,
- 16: 6
-}
diff --git a/bot/utils/time.py b/bot/utils/time.py
index a330c9cd8..2aea2c099 100644
--- a/bot/utils/time.py
+++ b/bot/utils/time.py
@@ -1,15 +1,17 @@
import asyncio
import datetime
+from typing import Optional
+import dateutil.parser
from dateutil.relativedelta import relativedelta
RFC1123_FORMAT = "%a, %d %b %Y %H:%M:%S GMT"
+INFRACTION_FORMAT = "%Y-%m-%d %H:%M"
-def _stringify_time_unit(value: int, unit: str):
+def _stringify_time_unit(value: int, unit: str) -> str:
"""
- Returns a string to represent a value and time unit,
- ensuring that it uses the right plural form of the unit.
+ Returns a string to represent a value and time unit, ensuring that it uses the right plural form of the unit.
>>> _stringify_time_unit(1, "seconds")
"1 second"
@@ -18,7 +20,6 @@ def _stringify_time_unit(value: int, unit: str):
>>> _stringify_time_unit(0, "minutes")
"less than a minute"
"""
-
if value == 1:
return f"{value} {unit[:-1]}"
elif value == 0:
@@ -27,17 +28,15 @@ def _stringify_time_unit(value: int, unit: str):
return f"{value} {unit}"
-def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units: int = 6):
+def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units: int = 6) -> str:
"""
Returns a human-readable version of the relativedelta.
- :param delta: A dateutil.relativedelta.relativedelta object
- :param precision: The smallest unit that should be included.
- :param max_units: The maximum number of time-units to return.
-
- :return: A string like `4 days, 12 hours and 1 second`,
- `1 minute`, or `less than a minute`.
+ precision specifies the smallest unit of time to include (e.g. "seconds", "minutes").
+ max_units specifies the maximum number of units of time to include (e.g. 1 may include days but not hours).
"""
+ if max_units <= 0:
+ raise ValueError("max_units must be positive")
units = (
("years", delta.years),
@@ -73,19 +72,13 @@ def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units:
return humanized
-def time_since(past_datetime: datetime.datetime, precision: str = "seconds", max_units: int = 6):
+def time_since(past_datetime: datetime.datetime, precision: str = "seconds", max_units: int = 6) -> str:
"""
- Takes a datetime and returns a human-readable string that
- describes how long ago that datetime was.
+ Takes a datetime and returns a human-readable string that describes how long ago that datetime was.
- :param past_datetime: A datetime.datetime object
- :param precision: The smallest unit that should be included.
- :param max_units: The maximum number of time-units to return.
-
- :return: A string like `4 days, 12 hours and 1 second ago`,
- `1 minute ago`, or `less than a minute ago`.
+ precision specifies the smallest unit of time to include (e.g. "seconds", "minutes").
+ max_units specifies the maximum number of units of time to include (e.g. 1 may include days but not hours).
"""
-
now = datetime.datetime.utcnow()
delta = abs(relativedelta(now, past_datetime))
@@ -94,20 +87,27 @@ def time_since(past_datetime: datetime.datetime, precision: str = "seconds", max
return f"{humanized} ago"
-def parse_rfc1123(time_str):
- return datetime.datetime.strptime(time_str, RFC1123_FORMAT).replace(tzinfo=datetime.timezone.utc)
+def parse_rfc1123(stamp: str) -> datetime.datetime:
+ """Parse RFC1123 time string into datetime."""
+ return datetime.datetime.strptime(stamp, RFC1123_FORMAT).replace(tzinfo=datetime.timezone.utc)
# Hey, this could actually be used in the off_topic_names and reddit cogs :)
-async def wait_until(time: datetime.datetime):
+async def wait_until(time: datetime.datetime, start: Optional[datetime.datetime] = None) -> None:
"""
Wait until a given time.
:param time: A datetime.datetime object to wait until.
+ :param start: The start from which to calculate the waiting duration. Defaults to UTC time.
"""
-
- delay = time - datetime.datetime.utcnow()
+ delay = time - (start or datetime.datetime.utcnow())
delay_seconds = delay.total_seconds()
+ # Incorporate a small delay so we don't rapid-fire the event due to time precision errors
if delay_seconds > 1.0:
await asyncio.sleep(delay_seconds)
+
+
+def format_infraction(timestamp: str) -> str:
+ """Format an infraction timestamp to a more readable ISO 8601 format."""
+ return dateutil.parser.isoparse(timestamp).strftime(INFRACTION_FORMAT)
diff --git a/config-default.yml b/config-default.yml
index 7854b5db9..0dac9bf9f 100644
--- a/config-default.yml
+++ b/config-default.yml
@@ -25,7 +25,7 @@ style:
green_chevron: "<:greenchevron:418104310329769993>"
red_chevron: "<:redchevron:418112778184818698>"
white_chevron: "<:whitechevron:418110396973711363>"
- bb_message: "<:bbmessage:472476937504423936>"
+ bb_message: "<:bbmessage:476273120999636992>"
status_online: "<:status_online:470326272351010816>"
status_idle: "<:status_idle:470326266625785866>"
@@ -85,13 +85,17 @@ style:
guild:
id: 267624335836053506
+ categories:
+ python_help: 356013061213126657
+
channels:
admins: &ADMINS 365960823622991872
announcements: 354619224620138496
big_brother_logs: &BBLOGS 468507907357409333
bot: 267659945086812160
checkpoint_test: 422077681434099723
- devlog: &DEVLOG 409308876241108992
+ defcon: 464469101889454091
+ devlog: &DEVLOG 622895325144940554
devtest: &DEVTEST 414574275865870337
help_0: 303906576991780866
help_1: 303906556754395136
@@ -99,6 +103,8 @@ guild:
help_3: 439702951246692352
help_4: 451312046647148554
help_5: 454941769734422538
+ help_6: 587375753306570782
+ help_7: 587375768556797982
helpers: 385474242440986624
message_log: &MESSAGE_LOG 467752170159079424
mod_alerts: 473092532147060736
@@ -109,6 +115,9 @@ guild:
python: 267624335836053506
reddit: 458224812528238616
staff_lounge: &STAFF_LOUNGE 464905259261755392
+ talent_pool: &TALENT_POOL 534321732593647616
+ userlog: 528976905546760203
+ user_event_a: &USER_EVENT_A 592000283102674944
verification: 352442727016693763
ignored: [*ADMINS, *MESSAGE_LOG, *MODLOG]
@@ -118,25 +127,37 @@ guild:
announcements: 463658397560995840
champion: 430492892331769857
contributor: 295488872404484098
- developer: 352427296948486144
- devops: &DEVOPS_ROLE 409416496733880320
+ core_developer: 587606783669829632
+ helpers: 267630620367257601
jammer: 423054537079783434
moderator: &MOD_ROLE 267629731250176001
muted: &MUTED_ROLE 277914926603829249
owner: &OWNER_ROLE 267627879762755584
- verified: 352427296948486144
- helpers: 267630620367257601
+ partners: 323426753857191936
rockstars: &ROCKSTARS_ROLE 458226413825294336
+ team_leader: 501324292341104650
+ verified: 352427296948486144
+
+ webhooks:
+ talent_pool: 569145364800602132
+ big_brother: 569133704568373283
filter:
# What do we filter?
- filter_zalgo: false
- filter_invites: true
- filter_domains: true
- watch_words: true
- watch_tokens: true
+ filter_zalgo: false
+ filter_invites: true
+ filter_domains: true
+ watch_rich_embeds: true
+ watch_words: true
+ watch_tokens: true
+
+ # Notify user on filter?
+ # Notifications are not expected for "watchlist" type filters
+ notify_user_zalgo: false
+ notify_user_invites: true
+ notify_user_domains: false
# Filter configuration
ping_everyone: true # Ping @everyone when we send a mod-alert?
@@ -146,6 +167,13 @@ filter:
- 267624335836053506 # Python Discord
- 440186186024222721 # Python Discord: ModLog Emojis
- 273944235143593984 # STEM
+ - 348658686962696195 # RLBot
+ - 531221516914917387 # Pallets
+ - 249111029668249601 # Gentoo
+ - 327254708534116352 # Adafruit
+ - 544525886180032552 # kennethreitz.org
+ - 590806733924859943 # Discord Hack Week
+ - 423249981340778496 # Kivy
domain_blacklist:
- pornhub.com
@@ -169,6 +197,8 @@ filter:
- (re+)tar+(d+|t+)(ed)?
- ta+r+d+
- cunts*
+ - trann*y
+ - shemale
token_watchlist:
- fa+g+s*
@@ -187,21 +217,18 @@ filter:
- *BBLOGS
- *STAFF_LOUNGE
- *DEVTEST
+ - *TALENT_POOL
+ - *USER_EVENT_A
role_whitelist:
- *ADMIN_ROLE
- *MOD_ROLE
- *OWNER_ROLE
- - *DEVOPS_ROLE
- *ROCKSTARS_ROLE
keys:
- deploy_bot: !ENV "DEPLOY_BOT_KEY"
- deploy_site: !ENV "DEPLOY_SITE"
- omdb: !ENV "OMDB_API_KEY"
site_api: !ENV "BOT_API_KEY"
- youtube: !ENV "YOUTUBE_API_KEY"
urls:
@@ -209,38 +236,31 @@ urls:
site: &DOMAIN "pythondiscord.com"
site_api: &API !JOIN ["api.", *DOMAIN]
site_paste: &PASTE !JOIN ["paste.", *DOMAIN]
+ site_staff: &STAFF !JOIN ["staff.", *DOMAIN]
site_schema: &SCHEMA "https://"
site_bigbrother_api: !JOIN [*SCHEMA, *API, "/bot/bigbrother"]
site_docs_api: !JOIN [*SCHEMA, *API, "/bot/docs"]
- site_facts_api: !JOIN [*SCHEMA, *API, "/bot/snake_facts"]
site_superstarify_api: !JOIN [*SCHEMA, *API, "/bot/superstarify"]
- site_idioms_api: !JOIN [*SCHEMA, *API, "/bot/snake_idioms"]
site_infractions: !JOIN [*SCHEMA, *API, "/bot/infractions"]
site_infractions_user: !JOIN [*SCHEMA, *API, "/bot/infractions/user/{user_id}"]
site_infractions_type: !JOIN [*SCHEMA, *API, "/bot/infractions/type/{infraction_type}"]
site_infractions_by_id: !JOIN [*SCHEMA, *API, "/bot/infractions/id/{infraction_id}"]
site_infractions_user_type_current: !JOIN [*SCHEMA, *API, "/bot/infractions/user/{user_id}/{infraction_type}/current"]
+ site_infractions_user_type: !JOIN [*SCHEMA, *API, "/bot/infractions/user/{user_id}/{infraction_type}"]
site_logs_api: !JOIN [*SCHEMA, *API, "/bot/logs"]
- site_logs_view: !JOIN [*SCHEMA, *DOMAIN, "/bot/logs"]
- site_names_api: !JOIN [*SCHEMA, *API, "/bot/snake_names"]
+ site_logs_view: !JOIN [*SCHEMA, *STAFF, "/bot/logs"]
site_off_topic_names_api: !JOIN [*SCHEMA, *API, "/bot/off-topic-names"]
- site_quiz_api: !JOIN [*SCHEMA, *API, "/bot/snake_quiz"]
site_reminders_api: !JOIN [*SCHEMA, *API, "/bot/reminders"]
site_reminders_user_api: !JOIN [*SCHEMA, *API, "/bot/reminders/user"]
site_settings_api: !JOIN [*SCHEMA, *API, "/bot/settings"]
- site_special_api: !JOIN [*SCHEMA, *API, "/bot/special_snakes"]
site_tags_api: !JOIN [*SCHEMA, *API, "/bot/tags"]
site_user_api: !JOIN [*SCHEMA, *API, "/bot/users"]
site_user_complete_api: !JOIN [*SCHEMA, *API, "/bot/users/complete"]
paste_service: !JOIN [*SCHEMA, *PASTE, "/{key}"]
# Snekbox
- snekbox_eval_api: "http://localhost:8060/eval"
-
- # Env vars
- deploy: !ENV "DEPLOY_URL"
- status: !ENV "STATUS_URL"
+ snekbox_eval_api: "https://snekbox.pythondiscord.com/eval"
# Discord API URLs
discord_api: &DISCORD_API "https://discordapp.com/api/v7/"
@@ -248,8 +268,7 @@ urls:
# Misc URLs
bot_avatar: "https://raw.githubusercontent.com/discord-python/branding/master/logos/logo_circle/logo_circle.png"
- gitlab_bot_repo: "https://gitlab.com/python-discord/projects/bot"
- omdb: "http://omdbapi.com"
+ github_bot_repo: "https://github.com/python-discord/bot"
anti_spam:
# Clean messages that violate a rule.
@@ -263,7 +282,7 @@ anti_spam:
rules:
attachments:
interval: 10
- max: 3
+ max: 9
burst:
interval: 10
@@ -296,6 +315,7 @@ anti_spam:
newlines:
interval: 10
max: 100
+ max_consecutive: 10
role_mentions:
interval: 10
@@ -320,5 +340,16 @@ big_brother:
header_message_limit: 15
+free:
+ # Seconds to elapse for a channel
+ # to be considered inactive.
+ activity_timeout: 600
+ cooldown_rate: 1
+ cooldown_per: 60.0
+
+redirect_output:
+ delete_invocation: true
+ delete_delay: 15
+
config:
required_keys: ['bot.token']
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 000000000..f79fdba58
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,44 @@
+# This docker compose is used for quick setups of the site and database which
+# the bot project relies on for testing. Use it if you haven't got a
+# ready-to-use site environment already setup.
+
+version: "3.7"
+
+services:
+ postgres:
+ image: postgres:12-alpine
+ environment:
+ POSTGRES_DB: pysite
+ POSTGRES_PASSWORD: pysite
+ POSTGRES_USER: pysite
+
+ web:
+ image: pythondiscord/site:latest
+ command: ["run", "--debug"]
+ networks:
+ default:
+ aliases:
+ - api.web
+ - admin.web
+ - staff.web
+ ports:
+ - "127.0.0.1:8000:8000"
+ depends_on:
+ - postgres
+ environment:
+ DATABASE_URL: postgres://pysite:pysite@postgres:5432/pysite
+ SECRET_KEY: suitable-for-development-only
+ STATIC_ROOT: /var/www/static
+
+ bot:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ volumes:
+ - ./logs:/bot/logs
+ - .:/bot:ro
+ depends_on:
+ - web
+ environment:
+ BOT_TOKEN: ${BOT_TOKEN}
+ BOT_API_KEY: badbot13m0n8f570f942013fc818f234916ca531
diff --git a/docker/ci.Dockerfile b/docker/ci.Dockerfile
deleted file mode 100644
index fd7e25239..000000000
--- a/docker/ci.Dockerfile
+++ /dev/null
@@ -1,20 +0,0 @@
-FROM python:3.6-alpine3.7
-
-RUN apk add --update docker \
- curl \
- tini \
- build-base \
- libffi-dev \
- zlib \
- jpeg-dev \
- libxml2 libxml2-dev libxslt-dev \
- zlib-dev \
- freetype-dev
-
-RUN pip install pipenv
-
-ENV LIBRARY_PATH=/lib:/usr/lib
-ENV PIPENV_VENV_IN_PROJECT=1
-ENV PIPENV_IGNORE_VIRTUALENVS=1
-ENV PIPENV_NOSPIN=1
-ENV PIPENV_HIDE_EMOJIS=1
diff --git a/scripts/deploy-azure.sh b/scripts/deploy-azure.sh
deleted file mode 100644
index af69ab46b..000000000
--- a/scripts/deploy-azure.sh
+++ /dev/null
@@ -1,12 +0,0 @@
-#!/bin/bash
-
-cd ..
-
-# Build and deploy on django branch, only if not a pull request
-if [[ ($BUILD_SOURCEBRANCHNAME == 'django') && ($SYSTEM_PULLREQUEST_PULLREQUESTID == '') ]]; then
- echo "Building image"
- docker build -t pythondiscord/bot:django .
-
- echo "Pushing image"
- docker push pythondiscord/bot:django
-fi
diff --git a/scripts/deploy.sh b/scripts/deploy.sh
deleted file mode 100644
index 070d0ec26..000000000
--- a/scripts/deploy.sh
+++ /dev/null
@@ -1,32 +0,0 @@
-#!/bin/bash
-
-# Build and deploy on master branch
-if [[ $CI_COMMIT_REF_SLUG == 'master' ]]; then
- echo "Connecting to docker hub"
- echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin
-
- changed_lines=$(git diff HEAD~1 HEAD docker/base.Dockerfile | wc -l)
-
- if [ $changed_lines != '0' ]; then
- echo "base.Dockerfile was changed"
-
- echo "Building bot base"
- docker build -t pythondiscord/bot-base:latest -f docker/base.Dockerfile .
-
- echo "Pushing image to Docker Hub"
- docker push pythondiscord/bot-base:latest
- else
- echo "base.Dockerfile was not changed, not building"
- fi
-
- echo "Building image"
- docker build -t pythondiscord/bot:latest -f docker/bot.Dockerfile .
-
- echo "Pushing image"
- docker push pythondiscord/bot:latest
-
- echo "Deploying container"
- curl -H "token: $AUTODEPLOY_TOKEN" $AUTODEPLOY_WEBHOOK
-else
- echo "Skipping deploy"
-fi
diff --git a/tests/cogs/sync/test_roles.py b/tests/cogs/sync/test_roles.py
index 18682f39f..c561ba447 100644
--- a/tests/cogs/sync/test_roles.py
+++ b/tests/cogs/sync/test_roles.py
@@ -2,63 +2,102 @@ from bot.cogs.sync.syncers import Role, get_roles_for_sync
def test_get_roles_for_sync_empty_return_for_equal_roles():
- api_roles = {Role(id=41, name='name', colour=33, permissions=0x8)}
- guild_roles = {Role(id=41, name='name', colour=33, permissions=0x8)}
+ api_roles = {Role(id=41, name='name', colour=33, permissions=0x8, position=1)}
+ guild_roles = {Role(id=41, name='name', colour=33, permissions=0x8, position=1)}
- assert get_roles_for_sync(guild_roles, api_roles) == (set(), set())
+ assert get_roles_for_sync(guild_roles, api_roles) == (set(), set(), set())
def test_get_roles_for_sync_returns_roles_to_update_with_non_id_diff():
- api_roles = {Role(id=41, name='old name', colour=35, permissions=0x8)}
- guild_roles = {Role(id=41, name='new name', colour=33, permissions=0x8)}
+ api_roles = {Role(id=41, name='old name', colour=35, permissions=0x8, position=1)}
+ guild_roles = {Role(id=41, name='new name', colour=33, permissions=0x8, position=2)}
assert get_roles_for_sync(guild_roles, api_roles) == (
set(),
- guild_roles
+ guild_roles,
+ set(),
)
def test_get_roles_only_returns_roles_that_require_update():
api_roles = {
- Role(id=41, name='old name', colour=33, permissions=0x8),
- Role(id=53, name='other role', colour=55, permissions=0)
+ Role(id=41, name='old name', colour=33, permissions=0x8, position=1),
+ Role(id=53, name='other role', colour=55, permissions=0, position=3)
}
guild_roles = {
- Role(id=41, name='new name', colour=35, permissions=0x8),
- Role(id=53, name='other role', colour=55, permissions=0)
+ Role(id=41, name='new name', colour=35, permissions=0x8, position=2),
+ Role(id=53, name='other role', colour=55, permissions=0, position=3)
}
assert get_roles_for_sync(guild_roles, api_roles) == (
set(),
- {Role(id=41, name='new name', colour=35, permissions=0x8)},
+ {Role(id=41, name='new name', colour=35, permissions=0x8, position=2)},
+ set(),
)
def test_get_roles_returns_new_roles_in_first_tuple_element():
api_roles = {
- Role(id=41, name='name', colour=35, permissions=0x8),
+ Role(id=41, name='name', colour=35, permissions=0x8, position=1),
}
guild_roles = {
- Role(id=41, name='name', colour=35, permissions=0x8),
- Role(id=53, name='other role', colour=55, permissions=0)
+ Role(id=41, name='name', colour=35, permissions=0x8, position=1),
+ Role(id=53, name='other role', colour=55, permissions=0, position=2)
}
assert get_roles_for_sync(guild_roles, api_roles) == (
- {Role(id=53, name='other role', colour=55, permissions=0)},
- set()
+ {Role(id=53, name='other role', colour=55, permissions=0, position=2)},
+ set(),
+ set(),
)
def test_get_roles_returns_roles_to_update_and_new_roles():
api_roles = {
- Role(id=41, name='old name', colour=35, permissions=0x8),
+ Role(id=41, name='old name', colour=35, permissions=0x8, position=1),
+ }
+ guild_roles = {
+ Role(id=41, name='new name', colour=40, permissions=0x16, position=2),
+ Role(id=53, name='other role', colour=55, permissions=0, position=3)
+ }
+
+ assert get_roles_for_sync(guild_roles, api_roles) == (
+ {Role(id=53, name='other role', colour=55, permissions=0, position=3)},
+ {Role(id=41, name='new name', colour=40, permissions=0x16, position=2)},
+ set(),
+ )
+
+
+def test_get_roles_returns_roles_to_delete():
+ api_roles = {
+ Role(id=41, name='name', colour=35, permissions=0x8, position=1),
+ Role(id=61, name='to delete', colour=99, permissions=0x9, position=2),
+ }
+ guild_roles = {
+ Role(id=41, name='name', colour=35, permissions=0x8, position=1),
+ }
+
+ assert get_roles_for_sync(guild_roles, api_roles) == (
+ set(),
+ set(),
+ {Role(id=61, name='to delete', colour=99, permissions=0x9, position=2)},
+ )
+
+
+def test_get_roles_returns_roles_to_delete_update_and_new_roles():
+ api_roles = {
+ Role(id=41, name='not changed', colour=35, permissions=0x8, position=1),
+ Role(id=61, name='to delete', colour=99, permissions=0x9, position=2),
+ Role(id=71, name='to update', colour=99, permissions=0x9, position=3),
}
guild_roles = {
- Role(id=41, name='new name', colour=40, permissions=0x16),
- Role(id=53, name='other role', colour=55, permissions=0)
+ Role(id=41, name='not changed', colour=35, permissions=0x8, position=1),
+ Role(id=81, name='to create', colour=99, permissions=0x9, position=4),
+ Role(id=71, name='updated', colour=101, permissions=0x5, position=3),
}
assert get_roles_for_sync(guild_roles, api_roles) == (
- {Role(id=53, name='other role', colour=55, permissions=0)},
- {Role(id=41, name='new name', colour=40, permissions=0x16)}
+ {Role(id=81, name='to create', colour=99, permissions=0x9, position=4)},
+ {Role(id=71, name='updated', colour=101, permissions=0x5, position=3)},
+ {Role(id=61, name='to delete', colour=99, permissions=0x9, position=2)},
)
diff --git a/tests/cogs/test_antispam.py b/tests/cogs/test_antispam.py
new file mode 100644
index 000000000..67900b275
--- /dev/null
+++ b/tests/cogs/test_antispam.py
@@ -0,0 +1,30 @@
+import pytest
+
+from bot.cogs import antispam
+
+
+def test_default_antispam_config_is_valid():
+ validation_errors = antispam.validate_config()
+ assert not validation_errors
+
+
+ ('config', 'expected'),
+ (
+ (
+ {'invalid-rule': {}},
+ {'invalid-rule': "`invalid-rule` is not recognized as an antispam rule."}
+ ),
+ (
+ {'burst': {'interval': 10}},
+ {'burst': "Key `max` is required but not set for rule `burst`"}
+ ),
+ (
+ {'burst': {'max': 10}},
+ {'burst': "Key `interval` is required but not set for rule `burst`"}
+ )
+ )
+)
+def test_invalid_antispam_config_returns_validation_errors(config, expected):
+ validation_errors = antispam.validate_config(config)
+ assert validation_errors == expected
diff --git a/tests/cogs/test_information.py b/tests/cogs/test_information.py
new file mode 100644
index 000000000..184bd2595
--- /dev/null
+++ b/tests/cogs/test_information.py
@@ -0,0 +1,211 @@
+import asyncio
+import logging
+import textwrap
+from datetime import datetime
+from unittest.mock import MagicMock, patch
+
+import pytest
+from discord import (
+ CategoryChannel,
+ Colour,
+ Permissions,
+ Role,
+ TextChannel,
+ VoiceChannel,
+)
+
+from bot.cogs import information
+from bot.constants import Emojis
+from bot.decorators import InChannelCheckFailure
+from tests.helpers import AsyncMock
+
+
+def cog(simple_bot):
+ return information.Information(simple_bot)
+
+
+def role(name: str, id_: int):
+ r = MagicMock()
+ r.name = name
+ r.id = id_
+ r.mention = f'&{name}'
+ return r
+
+
+def member(status: str):
+ m = MagicMock()
+ m.status = status
+ return m
+
+
+def ctx(moderator_role, simple_ctx):
+ simple_ctx.author.roles = [moderator_role]
+ simple_ctx.guild.created_at = datetime(2001, 1, 1)
+ simple_ctx.send = AsyncMock()
+ return simple_ctx
+
+
+def test_roles_info_command(cog, ctx):
+ everyone_role = MagicMock()
+ everyone_role.name = '@everyone' # should be excluded in the output
+ ctx.author.roles.append(everyone_role)
+ ctx.guild.roles = ctx.author.roles
+
+ cog.roles_info.can_run = AsyncMock()
+ cog.roles_info.can_run.return_value = True
+
+ coroutine = cog.roles_info.callback(cog, ctx)
+
+ assert asyncio.run(coroutine) is None # no rval
+ ctx.send.assert_called_once()
+ _, kwargs = ctx.send.call_args
+ embed = kwargs.pop('embed')
+ assert embed.title == "Role information"
+ assert embed.colour == Colour.blurple()
+ assert embed.description == f"`{ctx.guild.roles[0].id}` - {ctx.guild.roles[0].mention}\n"
+ assert embed.footer.text == "Total roles: 1"
+
+
+def test_role_info_command(cog, ctx):
+ dummy_role = MagicMock(spec=Role)
+ dummy_role.name = "Dummy"
+ dummy_role.colour = Colour.blurple()
+ dummy_role.id = 112233445566778899
+ dummy_role.position = 10
+ dummy_role.permissions = Permissions(0)
+ dummy_role.members = [ctx.author]
+
+ admin_role = MagicMock(spec=Role)
+ admin_role.name = "Admin"
+ admin_role.colour = Colour.red()
+ admin_role.id = 998877665544332211
+ admin_role.position = 3
+ admin_role.permissions = Permissions(0)
+ admin_role.members = [ctx.author]
+
+ ctx.guild.roles = [dummy_role, admin_role]
+
+ cog.role_info.can_run = AsyncMock()
+ cog.role_info.can_run.return_value = True
+
+ coroutine = cog.role_info.callback(cog, ctx, dummy_role, admin_role)
+
+ assert asyncio.run(coroutine) is None
+
+ assert ctx.send.call_count == 2
+
+ (_, dummy_kwargs), (_, admin_kwargs) = ctx.send.call_args_list
+
+ dummy_embed = dummy_kwargs["embed"]
+ admin_embed = admin_kwargs["embed"]
+
+ assert dummy_embed.title == "Dummy info"
+ assert dummy_embed.colour == Colour.blurple()
+
+ assert dummy_embed.fields[0].value == str(dummy_role.id)
+ assert dummy_embed.fields[1].value == f"#{dummy_role.colour.value:0>6x}"
+ assert dummy_embed.fields[2].value == "0.63 0.48 218"
+ assert dummy_embed.fields[3].value == "1"
+ assert dummy_embed.fields[4].value == "10"
+ assert dummy_embed.fields[5].value == "0"
+
+ assert admin_embed.title == "Admin info"
+ assert admin_embed.colour == Colour.red()
+
+# There is no argument passed in here that we can use to test,
+# so the return value would change constantly.
+@patch('bot.cogs.information.time_since')
+def test_server_info_command(time_since_patch, cog, ctx, moderator_role):
+ time_since_patch.return_value = '2 days ago'
+
+ ctx.guild.created_at = datetime(2001, 1, 1)
+ ctx.guild.features = ('lemons', 'apples')
+ ctx.guild.region = 'The Moon'
+ ctx.guild.roles = [moderator_role]
+ ctx.guild.channels = [
+ TextChannel(
+ state={},
+ guild=ctx.guild,
+ data={'id': 42, 'name': 'lemons-offering', 'position': 22, 'type': 'text'}
+ ),
+ CategoryChannel(
+ state={},
+ guild=ctx.guild,
+ data={'id': 5125, 'name': 'the-lemon-collection', 'position': 22, 'type': 'category'}
+ ),
+ VoiceChannel(
+ state={},
+ guild=ctx.guild,
+ data={'id': 15290, 'name': 'listen-to-lemon', 'position': 22, 'type': 'voice'}
+ )
+ ]
+ ctx.guild.members = [
+ member('online'), member('online'),
+ member('idle'),
+ member('dnd'), member('dnd'), member('dnd'), member('dnd'),
+ member('offline'), member('offline'), member('offline')
+ ]
+ ctx.guild.member_count = 1_234
+ ctx.guild.icon_url = 'a-lemon.png'
+
+ coroutine = cog.server_info.callback(cog, ctx)
+ assert asyncio.run(coroutine) is None # no rval
+
+ time_since_patch.assert_called_once_with(ctx.guild.created_at, precision='days')
+ _, kwargs = ctx.send.call_args
+ embed = kwargs.pop('embed')
+ assert embed.colour == Colour.blurple()
+ assert embed.description == textwrap.dedent(f"""
+ **Server information**
+ Created: {time_since_patch.return_value}
+ Voice region: {ctx.guild.region}
+ Features: {', '.join(ctx.guild.features)}
+
+ **Counts**
+ Members: {ctx.guild.member_count:,}
+ Roles: {len(ctx.guild.roles)}
+ Text: 1
+ Voice: 1
+ Channel categories: 1
+
+ **Members**
+ {Emojis.status_online} 2
+ {Emojis.status_idle} 1
+ {Emojis.status_dnd} 4
+ {Emojis.status_offline} 3
+ """)
+ assert embed.thumbnail.url == 'a-lemon.png'
+
+
+def test_user_info_on_other_users_from_non_moderator(ctx, cog):
+ ctx.author = MagicMock()
+ ctx.author.__eq__.return_value = False
+ ctx.author.roles = []
+ coroutine = cog.user_info.callback(cog, ctx, user='scragly') # skip checks, pass args
+
+ assert asyncio.run(coroutine) is None # no rval
+ ctx.send.assert_called_once_with(
+ "You may not use this command on users other than yourself."
+ )
+
+
+def test_user_info_in_wrong_channel_from_non_moderator(ctx, cog):
+ ctx.author = MagicMock()
+ ctx.author.__eq__.return_value = False
+ ctx.author.roles = []
+
+ coroutine = cog.user_info.callback(cog, ctx)
+ message = 'Sorry, but you may only use this command within <#267659945086812160>.'
+ with pytest.raises(InChannelCheckFailure, match=message):
+ assert asyncio.run(coroutine) is None # no rval
+
+
+def test_setup(simple_bot, caplog):
+ information.setup(simple_bot)
+ simple_bot.add_cog.assert_called_once()
+ [record] = caplog.records
+
+ assert record.message == "Cog loaded: Information"
+ assert record.levelno == logging.INFO
diff --git a/tests/cogs/test_security.py b/tests/cogs/test_security.py
new file mode 100644
index 000000000..1efb460fe
--- /dev/null
+++ b/tests/cogs/test_security.py
@@ -0,0 +1,54 @@
+import logging
+from unittest.mock import MagicMock
+
+import pytest
+from discord.ext.commands import NoPrivateMessage
+
+from bot.cogs import security
+
+
+def cog():
+ bot = MagicMock()
+ return security.Security(bot)
+
+
+def context():
+ return MagicMock()
+
+
+def test_check_additions(cog):
+ cog.bot.check.assert_any_call(cog.check_on_guild)
+ cog.bot.check.assert_any_call(cog.check_not_bot)
+
+
+def test_check_not_bot_for_humans(cog, context):
+ context.author.bot = False
+ assert cog.check_not_bot(context)
+
+
+def test_check_not_bot_for_robots(cog, context):
+ context.author.bot = True
+ assert not cog.check_not_bot(context)
+
+
+def test_check_on_guild_outside_of_guild(cog, context):
+ context.guild = None
+
+ with pytest.raises(NoPrivateMessage, match="This command cannot be used in private messages."):
+ cog.check_on_guild(context)
+
+
+def test_check_on_guild_on_guild(cog, context):
+ context.guild = "lemon's lemonade stand"
+ assert cog.check_on_guild(context)
+
+
+def test_security_cog_load(caplog):
+ bot = MagicMock()
+ security.setup(bot)
+ bot.add_cog.assert_called_once()
+ [record] = caplog.records
+ assert record.message == "Cog loaded: Security"
+ assert record.levelno == logging.INFO
diff --git a/tests/cogs/test_token_remover.py b/tests/cogs/test_token_remover.py
new file mode 100644
index 000000000..9d46b3a05
--- /dev/null
+++ b/tests/cogs/test_token_remover.py
@@ -0,0 +1,133 @@
+import asyncio
+from unittest.mock import MagicMock
+
+import pytest
+from discord import Colour
+
+from bot.cogs.token_remover import (
+ DELETION_MESSAGE_TEMPLATE,
+ TokenRemover,
+ setup as setup_cog,
+)
+from bot.constants import Channels, Colours, Event, Icons
+from tests.helpers import AsyncMock
+
+
+def token_remover():
+ bot = MagicMock()
+ bot.get_cog.return_value = MagicMock()
+ bot.get_cog.return_value.send_log_message = AsyncMock()
+ return TokenRemover(bot=bot)
+
+
+def message():
+ message = MagicMock()
+ message.author.__str__.return_value = 'lemon'
+ message.author.bot = False
+ message.author.avatar_url_as.return_value = 'picture-lemon.png'
+ message.author.id = 42
+ message.author.mention = '@lemon'
+ message.channel.send = AsyncMock()
+ message.channel.mention = '#lemonade-stand'
+ message.content = ''
+ message.delete = AsyncMock()
+ message.id = 555
+ return message
+
+
+ ('content', 'expected'),
+ (
+ ('MTIz', True), # 123
+ ('YWJj', False), # abc
+ )
+)
+def test_is_valid_user_id(content: str, expected: bool):
+ assert TokenRemover.is_valid_user_id(content) is expected
+
+
+ ('content', 'expected'),
+ (
+ ('DN9r_A', True), # stolen from dapi, thanks to the author of the 'token' tag!
+ ('MTIz', False), # 123
+ )
+)
+def test_is_valid_timestamp(content: str, expected: bool):
+ assert TokenRemover.is_valid_timestamp(content) is expected
+
+
+def test_mod_log_property(token_remover):
+ token_remover.bot.get_cog.return_value = 'lemon'
+ assert token_remover.mod_log == 'lemon'
+ token_remover.bot.get_cog.assert_called_once_with('ModLog')
+
+
+def test_ignores_bot_messages(token_remover, message):
+ message.author.bot = True
+ coroutine = token_remover.on_message(message)
+ assert asyncio.run(coroutine) is None
+
+
[email protected]('content', ('', 'lemon wins'))
+def test_ignores_messages_without_tokens(token_remover, message, content):
+ message.content = content
+ coroutine = token_remover.on_message(message)
+ assert asyncio.run(coroutine) is None
+
+
[email protected]('content', ('foo.bar.baz', 'x.y.'))
+def test_ignores_invalid_tokens(token_remover, message, content):
+ message.content = content
+ coroutine = token_remover.on_message(message)
+ assert asyncio.run(coroutine) is None
+
+
+ 'content, censored_token',
+ (
+ ('MTIz.DN9R_A.xyz', 'MTIz.DN9R_A.xxx'),
+ )
+)
+def test_censors_valid_tokens(
+ token_remover, message, content, censored_token, caplog
+):
+ message.content = content
+ coroutine = token_remover.on_message(message)
+ assert asyncio.run(coroutine) is None # still no rval
+
+ # asyncio logs some stuff about its reactor, discard it
+ [_, record] = caplog.records
+ assert record.message == (
+ "Censored a seemingly valid token sent by lemon (`42`) in #lemonade-stand, "
+ f"token was `{censored_token}`"
+ )
+
+ message.delete.assert_called_once_with()
+ message.channel.send.assert_called_once_with(
+ DELETION_MESSAGE_TEMPLATE.format(mention='@lemon')
+ )
+ token_remover.bot.get_cog.assert_called_with('ModLog')
+ message.author.avatar_url_as.assert_called_once_with(static_format='png')
+
+ mod_log = token_remover.bot.get_cog.return_value
+ mod_log.ignore.assert_called_once_with(Event.message_delete, message.id)
+ mod_log.send_log_message.assert_called_once_with(
+ icon_url=Icons.token_removed,
+ colour=Colour(Colours.soft_red),
+ title="Token removed!",
+ text=record.message,
+ thumbnail='picture-lemon.png',
+ channel_id=Channels.mod_alerts
+ )
+
+
+def test_setup(caplog):
+ bot = MagicMock()
+ setup_cog(bot)
+ [record] = caplog.records
+
+ bot.add_cog.assert_called_once()
+ assert record.message == "Cog loaded: TokenRemover"
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 000000000..d3de4484d
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,32 @@
+from unittest.mock import MagicMock
+
+import pytest
+
+from bot.constants import Roles
+from tests.helpers import AsyncMock
+
+
+def moderator_role():
+ mock = MagicMock()
+ mock.id = Roles.moderator
+ mock.name = 'Moderator'
+ mock.mention = f'&{mock.name}'
+ return mock
+
+
+def simple_bot():
+ mock = MagicMock()
+ mock._before_invoke = AsyncMock()
+ mock._after_invoke = AsyncMock()
+ mock.can_run = AsyncMock()
+ mock.can_run.return_value = True
+ return mock
+
+
+def simple_ctx(simple_bot):
+ mock = MagicMock()
+ mock.bot = simple_bot
+ return mock
diff --git a/tests/helpers.py b/tests/helpers.py
new file mode 100644
index 000000000..25059fa3a
--- /dev/null
+++ b/tests/helpers.py
@@ -0,0 +1,33 @@
+import asyncio
+import functools
+from unittest.mock import MagicMock
+
+
+__all__ = ('AsyncMock', 'async_test')
+
+
+# TODO: Remove me on 3.8
+# Allows you to mock a coroutine. Since the default `__call__` of `MagicMock`
+# is not a coroutine, trying to mock a coroutine with it will result in errors
+# as the default `__call__` is not awaitable. Use this class for monkeypatching
+# coroutines instead.
+class AsyncMock(MagicMock):
+ async def __call__(self, *args, **kwargs):
+ return super(AsyncMock, self).__call__(*args, **kwargs)
+
+
+def async_test(wrapped):
+ """
+ Run a test case via asyncio.
+
+ Example:
+
+ >>> @async_test
+ ... async def lemon_wins():
+ ... assert True
+ """
+
+ @functools.wraps(wrapped)
+ def wrapper(*args, **kwargs):
+ return asyncio.run(wrapped(*args, **kwargs))
+ return wrapper
diff --git a/bot/utils/snakes/__init__.py b/tests/rules/__init__.py
index e69de29bb..e69de29bb 100644
--- a/bot/utils/snakes/__init__.py
+++ b/tests/rules/__init__.py
diff --git a/tests/rules/test_attachments.py b/tests/rules/test_attachments.py
new file mode 100644
index 000000000..6f025b3cb
--- /dev/null
+++ b/tests/rules/test_attachments.py
@@ -0,0 +1,52 @@
+import asyncio
+from dataclasses import dataclass
+from typing import Any, List
+
+import pytest
+
+from bot.rules import attachments
+
+
+# Using `MagicMock` sadly doesn't work for this usecase
+# since it's __eq__ compares the MagicMock's ID. We just
+# want to compare the actual attributes we set.
+@dataclass
+class FakeMessage:
+ author: str
+ attachments: List[Any]
+
+
+def msg(total_attachments: int):
+ return FakeMessage(author='lemon', attachments=list(range(total_attachments)))
+
+
+ 'messages',
+ (
+ (msg(0), msg(0), msg(0)),
+ (msg(2), msg(2)),
+ (msg(0),),
+ )
+)
+def test_allows_messages_without_too_many_attachments(messages):
+ last_message, *recent_messages = messages
+ coro = attachments.apply(last_message, recent_messages, {'max': 5})
+ assert asyncio.run(coro) is None
+
+
+ ('messages', 'relevant_messages', 'total'),
+ (
+ ((msg(4), msg(0), msg(6)), [msg(4), msg(6)], 10),
+ ((msg(6),), [msg(6)], 6),
+ ((msg(1),) * 6, [msg(1)] * 6, 6),
+ )
+)
+def test_disallows_messages_with_too_many_attachments(messages, relevant_messages, total):
+ last_message, *recent_messages = messages
+ coro = attachments.apply(last_message, recent_messages, {'max': 5})
+ assert asyncio.run(coro) == (
+ f"sent {total} attachments in 5s",
+ ('lemon',),
+ relevant_messages
+ )
diff --git a/tests/test_api.py b/tests/test_api.py
new file mode 100644
index 000000000..ce69ef187
--- /dev/null
+++ b/tests/test_api.py
@@ -0,0 +1,106 @@
+import logging
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from bot import api
+from tests.helpers import async_test
+
+
+def test_loop_is_not_running_by_default():
+ assert not api.loop_is_running()
+
+
+@async_test
+async def test_loop_is_running_in_async_test():
+ assert api.loop_is_running()
+
+
+def error_api_response():
+ response = MagicMock()
+ response.status = 999
+ return response
+
+
+def api_log_handler():
+ return api.APILoggingHandler(None)
+
+
+def debug_log_record():
+ return logging.LogRecord(
+ name='my.logger', level=logging.DEBUG,
+ pathname='my/logger.py', lineno=666,
+ msg="Lemon wins", args=(),
+ exc_info=None
+ )
+
+
+def test_response_code_error_default_initialization(error_api_response):
+ error = api.ResponseCodeError(response=error_api_response)
+ assert error.status is error_api_response.status
+ assert not error.response_json
+ assert not error.response_text
+ assert error.response is error_api_response
+
+
+def test_response_code_error_default_representation(error_api_response):
+ error = api.ResponseCodeError(response=error_api_response)
+ assert str(error) == f"Status: {error_api_response.status} Response: "
+
+
+def test_response_code_error_representation_with_nonempty_response_json(error_api_response):
+ error = api.ResponseCodeError(
+ response=error_api_response,
+ response_json={'hello': 'world'}
+ )
+ assert str(error) == f"Status: {error_api_response.status} Response: {{'hello': 'world'}}"
+
+
+def test_response_code_error_representation_with_nonempty_response_text(error_api_response):
+ error = api.ResponseCodeError(
+ response=error_api_response,
+ response_text='Lemon will eat your soul'
+ )
+ assert str(error) == f"Status: {error_api_response.status} Response: Lemon will eat your soul"
+
+
+@patch('bot.api.APILoggingHandler.ship_off')
+def test_emit_appends_to_queue_with_stopped_event_loop(
+ ship_off_patch, api_log_handler, debug_log_record
+):
+ # This is a coroutine so returns something we should await,
+ # but asyncio complains about that. To ease testing, we patch
+ # `ship_off` to just return a regular value instead.
+ ship_off_patch.return_value = 42
+ api_log_handler.emit(debug_log_record)
+
+ assert api_log_handler.queue == [42]
+
+
+def test_emit_ignores_less_than_debug(debug_log_record, api_log_handler):
+ debug_log_record.levelno = logging.DEBUG - 5
+ api_log_handler.emit(debug_log_record)
+ assert not api_log_handler.queue
+
+
+def test_schedule_queued_tasks_for_empty_queue(api_log_handler, caplog):
+ api_log_handler.schedule_queued_tasks()
+ # Logs when tasks are scheduled
+ assert not caplog.records
+
+
+@patch('asyncio.create_task')
+def test_schedule_queued_tasks_for_nonempty_queue(create_task_patch, api_log_handler, caplog):
+ api_log_handler.queue = [555]
+ api_log_handler.schedule_queued_tasks()
+ assert not api_log_handler.queue
+ create_task_patch.assert_called_once_with(555)
+
+ [record] = caplog.records
+ assert record.message == "Scheduled 1 pending logging tasks."
+ assert record.levelno == logging.DEBUG
+ assert record.name == 'bot.api'
+ assert record.__dict__['via_handler']
diff --git a/tests/test_constants.py b/tests/test_constants.py
new file mode 100644
index 000000000..e4a29d994
--- /dev/null
+++ b/tests/test_constants.py
@@ -0,0 +1,23 @@
+import inspect
+
+import pytest
+
+from bot import constants
+
+
+ 'section',
+ (
+ cls
+ for (name, cls) in inspect.getmembers(constants)
+ if hasattr(cls, 'section') and isinstance(cls, type)
+ )
+)
+def test_section_configuration_matches_typespec(section):
+ for (name, annotation) in section.__annotations__.items():
+ value = getattr(section, name)
+
+ if getattr(annotation, '_name', None) in ('Dict', 'List'):
+ pytest.skip("Cannot validate containers yet")
+
+ assert isinstance(value, annotation)
diff --git a/tests/test_converters.py b/tests/test_converters.py
new file mode 100644
index 000000000..f69995ec6
--- /dev/null
+++ b/tests/test_converters.py
@@ -0,0 +1,264 @@
+import asyncio
+import datetime
+from unittest.mock import MagicMock, patch
+
+import pytest
+from dateutil.relativedelta import relativedelta
+from discord.ext.commands import BadArgument
+
+from bot.converters import (
+ Duration,
+ ISODateTime,
+ TagContentConverter,
+ TagNameConverter,
+ ValidPythonIdentifier,
+)
+
+
+ ('value', 'expected'),
+ (
+ ('hello', 'hello'),
+ (' h ello ', 'h ello')
+ )
+)
+def test_tag_content_converter_for_valid(value: str, expected: str):
+ assert asyncio.run(TagContentConverter.convert(None, value)) == expected
+
+
+ ('value', 'expected'),
+ (
+ ('', "Tag contents should not be empty, or filled with whitespace."),
+ (' ', "Tag contents should not be empty, or filled with whitespace.")
+ )
+)
+def test_tag_content_converter_for_invalid(value: str, expected: str):
+ context = MagicMock()
+ context.author = 'bob'
+
+ with pytest.raises(BadArgument, match=expected):
+ asyncio.run(TagContentConverter.convert(context, value))
+
+
+ ('value', 'expected'),
+ (
+ ('tracebacks', 'tracebacks'),
+ ('Tracebacks', 'tracebacks'),
+ (' Tracebacks ', 'tracebacks'),
+ )
+)
+def test_tag_name_converter_for_valid(value: str, expected: str):
+ assert asyncio.run(TagNameConverter.convert(None, value)) == expected
+
+
+ ('value', 'expected'),
+ (
+ ('👋', "Don't be ridiculous, you can't use that character!"),
+ ('', "Tag names should not be empty, or filled with whitespace."),
+ (' ', "Tag names should not be empty, or filled with whitespace."),
+ ('42', "Tag names can't be numbers."),
+ # Escape question mark as this is evaluated as regular expression.
+ ('x' * 128, r"Are you insane\? That's way too long!"),
+ )
+)
+def test_tag_name_converter_for_invalid(value: str, expected: str):
+ context = MagicMock()
+ context.author = 'bob'
+
+ with pytest.raises(BadArgument, match=expected):
+ asyncio.run(TagNameConverter.convert(context, value))
+
+
[email protected]('value', ('foo', 'lemon'))
+def test_valid_python_identifier_for_valid(value: str):
+ assert asyncio.run(ValidPythonIdentifier.convert(None, value)) == value
+
+
[email protected]('value', ('nested.stuff', '#####'))
+def test_valid_python_identifier_for_invalid(value: str):
+ with pytest.raises(BadArgument, match=f'`{value}` is not a valid Python identifier'):
+ asyncio.run(ValidPythonIdentifier.convert(None, value))
+
+
+FIXED_UTC_NOW = datetime.datetime.fromisoformat('2019-01-01T00:00:00')
+
+
+ params=(
+ # Simple duration strings
+ ('1Y', {"years": 1}),
+ ('1y', {"years": 1}),
+ ('1year', {"years": 1}),
+ ('1years', {"years": 1}),
+ ('1m', {"months": 1}),
+ ('1month', {"months": 1}),
+ ('1months', {"months": 1}),
+ ('1w', {"weeks": 1}),
+ ('1W', {"weeks": 1}),
+ ('1week', {"weeks": 1}),
+ ('1weeks', {"weeks": 1}),
+ ('1d', {"days": 1}),
+ ('1D', {"days": 1}),
+ ('1day', {"days": 1}),
+ ('1days', {"days": 1}),
+ ('1h', {"hours": 1}),
+ ('1H', {"hours": 1}),
+ ('1hour', {"hours": 1}),
+ ('1hours', {"hours": 1}),
+ ('1M', {"minutes": 1}),
+ ('1minute', {"minutes": 1}),
+ ('1minutes', {"minutes": 1}),
+ ('1s', {"seconds": 1}),
+ ('1S', {"seconds": 1}),
+ ('1second', {"seconds": 1}),
+ ('1seconds', {"seconds": 1}),
+
+ # Complex duration strings
+ (
+ '1y1m1w1d1H1M1S',
+ {
+ "years": 1,
+ "months": 1,
+ "weeks": 1,
+ "days": 1,
+ "hours": 1,
+ "minutes": 1,
+ "seconds": 1
+ }
+ ),
+ ('5y100S', {"years": 5, "seconds": 100}),
+ ('2w28H', {"weeks": 2, "hours": 28}),
+
+ # Duration strings with spaces
+ ('1 year 2 months', {"years": 1, "months": 2}),
+ ('1d 2H', {"days": 1, "hours": 2}),
+ ('1 week2 days', {"weeks": 1, "days": 2}),
+ )
+)
+def create_future_datetime(request):
+ """Yields duration string and target datetime.datetime object."""
+ duration, duration_dict = request.param
+ future_datetime = FIXED_UTC_NOW + relativedelta(**duration_dict)
+ yield duration, future_datetime
+
+
+def test_duration_converter_for_valid(create_future_datetime: tuple):
+ converter = Duration()
+ duration, expected = create_future_datetime
+ with patch('bot.converters.datetime') as mock_datetime:
+ mock_datetime.utcnow.return_value = FIXED_UTC_NOW
+ assert asyncio.run(converter.convert(None, duration)) == expected
+
+
+ ('duration'),
+ (
+ # Units in wrong order
+ ('1d1w'),
+ ('1s1y'),
+
+ # Duplicated units
+ ('1 year 2 years'),
+ ('1 M 10 minutes'),
+
+ # Unknown substrings
+ ('1MVes'),
+ ('1y3breads'),
+
+ # Missing amount
+ ('ym'),
+
+ # Incorrect whitespace
+ (" 1y"),
+ ("1S "),
+ ("1y 1m"),
+
+ # Garbage
+ ('Guido van Rossum'),
+ ('lemon lemon lemon lemon lemon lemon lemon'),
+ )
+)
+def test_duration_converter_for_invalid(duration: str):
+ converter = Duration()
+ with pytest.raises(BadArgument, match=f'`{duration}` is not a valid duration string.'):
+ asyncio.run(converter.convert(None, duration))
+
+
+ ("datetime_string", "expected_dt"),
+ (
+
+ # `YYYY-mm-ddTHH:MM:SSZ` | `YYYY-mm-dd HH:MM:SSZ`
+ ('2019-09-02T02:03:05Z', datetime.datetime(2019, 9, 2, 2, 3, 5)),
+ ('2019-09-02 02:03:05Z', datetime.datetime(2019, 9, 2, 2, 3, 5)),
+
+ # `YYYY-mm-ddTHH:MM:SS±HH:MM` | `YYYY-mm-dd HH:MM:SS±HH:MM`
+ ('2019-09-02T03:18:05+01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)),
+ ('2019-09-02 03:18:05+01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)),
+ ('2019-09-02T00:48:05-01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)),
+ ('2019-09-02 00:48:05-01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)),
+
+ # `YYYY-mm-ddTHH:MM:SS±HHMM` | `YYYY-mm-dd HH:MM:SS±HHMM`
+ ('2019-09-02T03:18:05+0115', datetime.datetime(2019, 9, 2, 2, 3, 5)),
+ ('2019-09-02 03:18:05+0115', datetime.datetime(2019, 9, 2, 2, 3, 5)),
+ ('2019-09-02T00:48:05-0115', datetime.datetime(2019, 9, 2, 2, 3, 5)),
+ ('2019-09-02 00:48:05-0115', datetime.datetime(2019, 9, 2, 2, 3, 5)),
+
+ # `YYYY-mm-ddTHH:MM:SS±HH` | `YYYY-mm-dd HH:MM:SS±HH`
+ ('2019-09-02 03:03:05+01', datetime.datetime(2019, 9, 2, 2, 3, 5)),
+ ('2019-09-02T01:03:05-01', datetime.datetime(2019, 9, 2, 2, 3, 5)),
+
+ # `YYYY-mm-ddTHH:MM:SS` | `YYYY-mm-dd HH:MM:SS`
+ ('2019-09-02T02:03:05', datetime.datetime(2019, 9, 2, 2, 3, 5)),
+ ('2019-09-02 02:03:05', datetime.datetime(2019, 9, 2, 2, 3, 5)),
+
+ # `YYYY-mm-ddTHH:MM` | `YYYY-mm-dd HH:MM`
+ ('2019-11-12T09:15', datetime.datetime(2019, 11, 12, 9, 15)),
+ ('2019-11-12 09:15', datetime.datetime(2019, 11, 12, 9, 15)),
+
+ # `YYYY-mm-dd`
+ ('2019-04-01', datetime.datetime(2019, 4, 1)),
+
+ # `YYYY-mm`
+ ('2019-02-01', datetime.datetime(2019, 2, 1)),
+
+ # `YYYY`
+ ('2025', datetime.datetime(2025, 1, 1)),
+ ),
+)
+def test_isodatetime_converter_for_valid(datetime_string: str, expected_dt: datetime.datetime):
+ converter = ISODateTime()
+ converted_dt = asyncio.run(converter.convert(None, datetime_string))
+ assert converted_dt.tzinfo is None
+ assert converted_dt == expected_dt
+
+
+ ("datetime_string"),
+ (
+ # Make sure it doesn't interfere with the Duration converter
+ ('1Y'),
+ ('1d'),
+ ('1H'),
+
+ # Check if it fails when only providing the optional time part
+ ('10:10:10'),
+ ('10:00'),
+
+ # Invalid date format
+ ('19-01-01'),
+
+ # Other non-valid strings
+ ('fisk the tag master'),
+ ),
+)
+def test_isodatetime_converter_for_invalid(datetime_string: str):
+ converter = ISODateTime()
+ with pytest.raises(
+ BadArgument,
+ match=f"`{datetime_string}` is not a valid ISO-8601 datetime string",
+ ):
+ asyncio.run(converter.convert(None, datetime_string))
diff --git a/tests/test_pagination.py b/tests/test_pagination.py
new file mode 100644
index 000000000..11d6541ae
--- /dev/null
+++ b/tests/test_pagination.py
@@ -0,0 +1,29 @@
+from unittest import TestCase
+
+import pytest
+
+from bot import pagination
+
+
+class LinePaginatorTests(TestCase):
+ def setUp(self):
+ self.paginator = pagination.LinePaginator(prefix='', suffix='', max_size=30)
+
+ def test_add_line_raises_on_too_long_lines(self):
+ message = f"Line exceeds maximum page size {self.paginator.max_size - 2}"
+ with pytest.raises(RuntimeError, match=message):
+ self.paginator.add_line('x' * self.paginator.max_size)
+
+ def test_add_line_works_on_small_lines(self):
+ self.paginator.add_line('x' * (self.paginator.max_size - 3))
+
+
+class ImagePaginatorTests(TestCase):
+ def setUp(self):
+ self.paginator = pagination.ImagePaginator()
+
+ def test_add_image_appends_image(self):
+ image = 'lemon'
+ self.paginator.add_image(image)
+
+ assert self.paginator.images == [image]
diff --git a/tests/test_resources.py b/tests/test_resources.py
new file mode 100644
index 000000000..bcf124f05
--- /dev/null
+++ b/tests/test_resources.py
@@ -0,0 +1,13 @@
+import json
+from pathlib import Path
+
+
+def test_stars_valid():
+ """Validates that `bot/resources/stars.json` contains a list of strings."""
+
+ path = Path('bot', 'resources', 'stars.json')
+ content = path.read_text()
+ data = json.loads(content)
+
+ for name in data:
+ assert type(name) is str
diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/tests/utils/__init__.py
diff --git a/tests/utils/test_checks.py b/tests/utils/test_checks.py
new file mode 100644
index 000000000..7121acebd
--- /dev/null
+++ b/tests/utils/test_checks.py
@@ -0,0 +1,66 @@
+from unittest.mock import MagicMock
+
+import pytest
+
+from bot.utils import checks
+
+
+def context():
+ return MagicMock()
+
+
+def test_with_role_check_without_guild(context):
+ context.guild = None
+
+ assert not checks.with_role_check(context)
+
+
+def test_with_role_check_with_guild_without_required_role(context):
+ context.guild = True
+ context.author.roles = []
+
+ assert not checks.with_role_check(context)
+
+
+def test_with_role_check_with_guild_with_required_role(context):
+ context.guild = True
+ role = MagicMock()
+ role.id = 42
+ context.author.roles = (role,)
+
+ assert checks.with_role_check(context, role.id)
+
+
+def test_without_role_check_without_guild(context):
+ context.guild = None
+
+ assert not checks.without_role_check(context)
+
+
+def test_without_role_check_with_unwanted_role(context):
+ context.guild = True
+ role = MagicMock()
+ role.id = 42
+ context.author.roles = (role,)
+
+ assert not checks.without_role_check(context, role.id)
+
+
+def test_without_role_check_without_unwanted_role(context):
+ context.guild = True
+ role = MagicMock()
+ role.id = 42
+ context.author.roles = (role,)
+
+ assert checks.without_role_check(context, role.id + 10)
+
+
+def test_in_channel_check_for_correct_channel(context):
+ context.channel.id = 42
+ assert checks.in_channel_check(context, context.channel.id)
+
+
+def test_in_channel_check_for_incorrect_channel(context):
+ context.channel.id = 42
+ assert not checks.in_channel_check(context, context.channel.id + 10)
diff --git a/tests/utils/test_time.py b/tests/utils/test_time.py
new file mode 100644
index 000000000..4baa6395c
--- /dev/null
+++ b/tests/utils/test_time.py
@@ -0,0 +1,62 @@
+import asyncio
+from datetime import datetime, timezone
+from unittest.mock import patch
+
+import pytest
+from dateutil.relativedelta import relativedelta
+
+from bot.utils import time
+from tests.helpers import AsyncMock
+
+
+ ('delta', 'precision', 'max_units', 'expected'),
+ (
+ (relativedelta(days=2), 'seconds', 1, '2 days'),
+ (relativedelta(days=2, hours=2), 'seconds', 2, '2 days and 2 hours'),
+ (relativedelta(days=2, hours=2), 'seconds', 1, '2 days'),
+ (relativedelta(days=2, hours=2), 'days', 2, '2 days'),
+
+ # Does not abort for unknown units, as the unit name is checked
+ # against the attribute of the relativedelta instance.
+ (relativedelta(days=2, hours=2), 'elephants', 2, '2 days and 2 hours'),
+
+ # Very high maximum units, but it only ever iterates over
+ # each value the relativedelta might have.
+ (relativedelta(days=2, hours=2), 'hours', 20, '2 days and 2 hours'),
+ )
+)
+def test_humanize_delta(
+ delta: relativedelta,
+ precision: str,
+ max_units: int,
+ expected: str
+):
+ assert time.humanize_delta(delta, precision, max_units) == expected
+
+
[email protected]('max_units', (-1, 0))
+def test_humanize_delta_raises_for_invalid_max_units(max_units: int):
+ with pytest.raises(ValueError, match='max_units must be positive'):
+ time.humanize_delta(relativedelta(days=2, hours=2), 'hours', max_units)
+
+
+ ('stamp', 'expected'),
+ (
+ ('Sun, 15 Sep 2019 12:00:00 GMT', datetime(2019, 9, 15, 12, 0, 0, tzinfo=timezone.utc)),
+ )
+)
+def test_parse_rfc1123(stamp: str, expected: str):
+ assert time.parse_rfc1123(stamp) == expected
+
+
+@patch('asyncio.sleep', new_callable=AsyncMock)
+def test_wait_until(sleep_patch):
+ start = datetime(2019, 1, 1, 0, 0)
+ then = datetime(2019, 1, 1, 0, 10)
+
+ # No return value
+ assert asyncio.run(time.wait_until(then, start)) is None
+
+ sleep_patch.assert_called_once_with(10 * 60)
diff --git a/tox.ini b/tox.ini
index c84827570..d14819d57 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,6 +1,19 @@
[flake8]
max-line-length=120
-application_import_names=bot
-exclude=.cache,.venv
-ignore=B311,W503,E226,S311,T000
+docstring-convention=all
import-order-style=pycharm
+application_import_names=bot,tests
+exclude=.cache,.venv,constants.py
+ignore=
+ B311,W503,E226,S311,T000
+ # Missing Docstrings
+ D100,D104,D105,D107,
+ # Docstring Whitespace
+ D203,D212,D214,D215,
+ # Docstring Quotes
+ D301,D302,
+ # Docstring Content
+ D400,D401,D402,D404,D405,D406,D407,D408,D409,D410,D411,D412,D413,D414,D416,D417
+ # Type Annotations
+ TYP002,TYP003,TYP101,TYP102,TYP204,TYP206
+per-file-ignores=tests/*:D,TYP