aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitattributes1
-rw-r--r--.github/CODEOWNERS7
-rw-r--r--.github/FUNDING.yml2
-rw-r--r--.github/workflows/build.yml2
-rw-r--r--.github/workflows/deploy.yml3
-rw-r--r--.github/workflows/lint-test.yml2
-rw-r--r--.github/workflows/sentry_release.yml4
-rw-r--r--.pre-commit-config.yaml2
-rw-r--r--CONTRIBUTING.md6
-rw-r--r--Dockerfile12
-rw-r--r--Pipfile2
-rw-r--r--Pipfile.lock535
-rw-r--r--README.md14
-rw-r--r--bot/__main__.py26
-rw-r--r--bot/api.py2
-rw-r--r--bot/bot.py34
-rw-r--r--bot/constants.py226
-rw-r--r--bot/converters.py44
-rw-r--r--bot/errors.py19
-rw-r--r--bot/exts/backend/branding/_constants.py2
-rw-r--r--bot/exts/backend/error_handler.py14
-rw-r--r--bot/exts/backend/logging.py2
-rw-r--r--bot/exts/filters/filtering.py66
-rw-r--r--bot/exts/filters/token_remover.py4
-rw-r--r--bot/exts/filters/webhook_remover.py2
-rw-r--r--bot/exts/fun/duck_pond.py21
-rw-r--r--bot/exts/fun/off_topic_names.py18
-rw-r--r--bot/exts/help_channels/_cog.py6
-rw-r--r--bot/exts/help_channels/_message.py33
-rw-r--r--bot/exts/help_channels/_name.py12
-rw-r--r--bot/exts/info/codeblock/_parsing.py3
-rw-r--r--bot/exts/info/information.py171
-rw-r--r--bot/exts/info/pypi.py78
-rw-r--r--bot/exts/info/source.py2
-rw-r--r--bot/exts/info/tags.py7
-rw-r--r--bot/exts/moderation/defcon.py315
-rw-r--r--bot/exts/moderation/infraction/_scheduler.py7
-rw-r--r--bot/exts/moderation/infraction/_utils.py51
-rw-r--r--bot/exts/moderation/infraction/infractions.py21
-rw-r--r--bot/exts/moderation/infraction/superstarify.py4
-rw-r--r--bot/exts/moderation/modlog.py2
-rw-r--r--bot/exts/moderation/slowmode.py4
-rw-r--r--bot/exts/moderation/watchchannels/_watchchannel.py86
-rw-r--r--bot/exts/recruitment/__init__.py0
-rw-r--r--bot/exts/recruitment/talentpool/__init__.py8
-rw-r--r--bot/exts/recruitment/talentpool/_cog.py (renamed from bot/exts/moderation/watchchannels/talentpool.py)189
-rw-r--r--bot/exts/recruitment/talentpool/_review.py324
-rw-r--r--bot/exts/utils/internal.py7
-rw-r--r--bot/exts/utils/utils.py27
-rw-r--r--bot/interpreter.py51
-rw-r--r--bot/pagination.py14
-rw-r--r--bot/resources/elements.json119
-rw-r--r--bot/resources/foods.json52
-rw-r--r--bot/resources/stars.json2
-rw-r--r--bot/resources/tags/comparison.md12
-rw-r--r--bot/resources/tags/defaultdict.md21
-rw-r--r--bot/resources/tags/dict-get.md16
-rw-r--r--bot/resources/tags/dictcomps.md24
-rw-r--r--bot/resources/tags/empty-json.md11
-rw-r--r--bot/resources/tags/environments.md26
-rw-r--r--bot/resources/tags/f-strings.md20
-rw-r--r--bot/resources/tags/floats.md20
-rw-r--r--bot/resources/tags/free.md5
-rw-r--r--bot/resources/tags/inline.md15
-rw-r--r--bot/resources/tags/listcomps.md23
-rw-r--r--bot/resources/tags/local-file.md23
-rw-r--r--bot/resources/tags/off-topic.md2
-rw-r--r--bot/resources/tags/pep8.md6
-rw-r--r--bot/resources/tags/voice-verification.md3
-rw-r--r--bot/rules/duplicates.py1
-rw-r--r--bot/utils/channel.py16
-rw-r--r--bot/utils/messages.py10
-rw-r--r--bot/utils/time.py50
-rw-r--r--config-default.yml211
-rw-r--r--docker-compose.yml5
-rw-r--r--tests/bot/exts/filters/test_token_remover.py4
-rw-r--r--tests/bot/exts/moderation/infraction/test_infractions.py52
-rw-r--r--tests/bot/exts/moderation/infraction/test_utils.py12
78 files changed, 2118 insertions, 1137 deletions
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 000000000..176a458f9
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+* text=auto
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index ad813d893..634bb4bca 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -7,10 +7,15 @@ bot/exts/utils/extensions.py @MarkKoz
bot/exts/utils/snekbox.py @MarkKoz @Akarys42
bot/exts/help_channels/** @MarkKoz @Akarys42
bot/exts/moderation/** @Akarys42 @mbaruh @Den4200 @ks129
-bot/exts/info/** @Akarys42 @mbaruh @Den4200
+bot/exts/info/** @Akarys42 @Den4200
+bot/exts/info/information.py @mbaruh
bot/exts/filters/** @mbaruh
bot/exts/fun/** @ks129
bot/exts/utils/** @ks129
+bot/exts/recruitment/** @wookie184
+
+# Rules
+bot/rules/** @mbaruh
# Utils
bot/utils/extensions.py @MarkKoz
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
deleted file mode 100644
index 6d9919ef2..000000000
--- a/.github/FUNDING.yml
+++ /dev/null
@@ -1,2 +0,0 @@
-patreon: python_discord
-custom: https://www.redbubble.com/people/pythondiscord
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 6c97e8784..e6826e09b 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -4,7 +4,7 @@ on:
workflow_run:
workflows: ["Lint & Test"]
branches:
- - master
+ - main
types:
- completed
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 5a4aede30..8b809b777 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -4,12 +4,13 @@ on:
workflow_run:
workflows: ["Build"]
branches:
- - master
+ - main
types:
- completed
jobs:
build:
+ environment: production
if: github.event.workflow_run.conclusion == 'success'
name: Build & Push
runs-on: ubuntu-latest
diff --git a/.github/workflows/lint-test.yml b/.github/workflows/lint-test.yml
index 6fa8e8333..95bed2e14 100644
--- a/.github/workflows/lint-test.yml
+++ b/.github/workflows/lint-test.yml
@@ -3,7 +3,7 @@ name: Lint & Test
on:
push:
branches:
- - master
+ - main
pull_request:
diff --git a/.github/workflows/sentry_release.yml b/.github/workflows/sentry_release.yml
index b8d92e90a..f6a1e1f0e 100644
--- a/.github/workflows/sentry_release.yml
+++ b/.github/workflows/sentry_release.yml
@@ -3,14 +3,14 @@ name: Create Sentry release
on:
push:
branches:
- - master
+ - main
jobs:
create_sentry_release:
runs-on: ubuntu-latest
steps:
- name: Checkout code
- uses: actions/checkout@master
+ uses: actions/checkout@main
- name: Create a Sentry.io release
uses: tclindner/[email protected]
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 1597592ca..52500a282 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -7,8 +7,6 @@ repos:
- id: check-yaml
args: [--unsafe] # Required due to custom constructors (e.g. !ENV)
- id: end-of-file-fixer
- - id: mixed-line-ending
- args: [--fix=lf]
- id: trailing-whitespace
args: [--markdown-linebreak-ext=md]
- repo: https://github.com/pre-commit/pygrep-hooks
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index be591d17e..addab32ff 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,6 +1,6 @@
# Contributing to one of Our Projects
-Our projects are open-source and are automatically deployed whenever commits are pushed to the `master` branch on each repository, so we've created a set of guidelines in order to keep everything clean and in working order.
+Our projects are open-source and are automatically deployed whenever commits are pushed to the `main` 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.
@@ -8,7 +8,7 @@ Note that contributions may be rejected on the basis of a contributor failing to
1. **No force-pushes** or modifying the Git history in any way.
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!
+ * It's common practice for a repository to reject direct pushes to `main`, 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) and [`pre-commit`](https://pre-commit.com/).
* Run `flake8` and `pre-commit` against your code [**before** you push it](https://soundcloud.com/lemonsaurusrex/lint-before-you-push). Your commit will be rejected by the build server if it fails to lint.
@@ -18,7 +18,7 @@ Note that contributions may be rejected on the basis of a contributor failing to
* Avoid making minor commits for fixing typos or linting errors. Since you've already set up a `pre-commit` hook to run the linting pipeline before a commit, you shouldn't be committing linting issues anyway.
* A more in-depth guide to writing great commit messages can be found in Chris Beam's [*How to Write a Git Commit Message*](https://chris.beams.io/posts/git-commit/)
5. **Avoid frequent pushes to the main repository**. This goes for PRs opened against your fork as well. Our test build pipelines are triggered every time a push to the repository (or PR) is made. Try to batch your commits until you've finished working for that session, or you've reached a point where collaborators need your commits to continue their own work. This also provides you the opportunity to amend commits for minor changes rather than having to commit them on their own because you've already pushed.
- * This includes merging master into your branch. Try to leave merging from master for after your PR passes review; a maintainer will bring your PR up to date before merging. Exceptions to this include: resolving merge conflicts, needing something that was pushed to master for your branch, or something was pushed to master that could potentionally affect the functionality of what you're writing.
+ * This includes merging main into your branch. Try to leave merging from main 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 main for your branch, or something was pushed to main 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.
diff --git a/Dockerfile b/Dockerfile
index 5d0380b44..1a75e5669 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,14 +1,10 @@
FROM python:3.8-slim
-# Define Git SHA build argument
-ARG git_sha="development"
-
# 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_NOSPIN=1 \
- GIT_SHA=$git_sha
+ PIPENV_NOSPIN=1
RUN apt-get -y update \
&& apt-get install -y \
@@ -25,6 +21,12 @@ WORKDIR /bot
COPY Pipfile* ./
RUN pipenv install --system --deploy
+# Define Git SHA build argument
+ARG git_sha="development"
+
+# Set Git SHA environment variable for Sentry
+ENV GIT_SHA=$git_sha
+
# Copy the source code in last to optimize rebuilding the image
COPY . .
diff --git a/Pipfile b/Pipfile
index 2e76d2ede..1d22faeae 100644
--- a/Pipfile
+++ b/Pipfile
@@ -6,7 +6,7 @@ name = "pypi"
[packages]
aio-pika = "~=6.1"
aiodns = "~=2.0"
-aiohttp = "~=3.5"
+aiohttp = "~=3.7"
aioping = "~=0.3.1"
aioredis = "~=1.3.1"
"async-rediscache[fakeredis]" = "~=0.1.2"
diff --git a/Pipfile.lock b/Pipfile.lock
index ec88e5530..a5e57a3fb 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
- "sha256": "cd61b7be63278d2f5b073e98c507aa50affe97e590bb25e37c521754e65bc110"
+ "sha256": "d5106d76a47c287ef74fc610be4977a76f261be003739cabf639ace310eeac57"
},
"pipfile-spec": 6,
"requires": {
@@ -18,11 +18,11 @@
"default": {
"aio-pika": {
"hashes": [
- "sha256:9773440a89840941ac3099a7720bf9d51e8764a484066b82ede4d395660ff430",
- "sha256:a8065be3c722eb8f9fff8c0e7590729e7782202cdb9363d9830d7d5d47b45c7c"
+ "sha256:1d4305a5f78af3857310b4fe48348cdcf6c097e0e275ea88c2cd08570531a369",
+ "sha256:e69afef8695f47c5d107bbdba21bdb845d5c249acb3be53ef5c2d497b02657c0"
],
"index": "pypi",
- "version": "==6.7.1"
+ "version": "==6.8.0"
},
"aiodns": {
"hashes": [
@@ -34,46 +34,46 @@
},
"aiohttp": {
"hashes": [
- "sha256:0b795072bb1bf87b8620120a6373a3c61bfcb8da7e5c2377f4bb23ff4f0b62c9",
- "sha256:0d438c8ca703b1b714e82ed5b7a4412c82577040dadff479c08405e2a715564f",
- "sha256:16a3cb5df5c56f696234ea9e65e227d1ebe9c18aa774d36ff42f532139066a5f",
- "sha256:1edfd82a98c5161497bbb111b2b70c0813102ad7e0aa81cbeb34e64c93863005",
- "sha256:2406dc1dda01c7f6060ab586e4601f18affb7a6b965c50a8c90ff07569cf782a",
- "sha256:2858b2504c8697beb9357be01dc47ef86438cc1cb36ecb6991796d19475faa3e",
- "sha256:2a7b7640167ab536c3cb90cfc3977c7094f1c5890d7eeede8b273c175c3910fd",
- "sha256:3228b7a51e3ed533f5472f54f70fd0b0a64c48dc1649a0f0e809bec312934d7a",
- "sha256:328b552513d4f95b0a2eea4c8573e112866107227661834652a8984766aa7656",
- "sha256:39f4b0a6ae22a1c567cb0630c30dd082481f95c13ca528dc501a7766b9c718c0",
- "sha256:3b0036c978cbcc4a4512278e98e3e6d9e6b834dc973206162eddf98b586ef1c6",
- "sha256:3ea8c252d8df5e9166bcf3d9edced2af132f4ead8ac422eac723c5781063709a",
- "sha256:41608c0acbe0899c852281978492f9ce2c6fbfaf60aff0cefc54a7c4516b822c",
- "sha256:59d11674964b74a81b149d4ceaff2b674b3b0e4d0f10f0be1533e49c4a28408b",
- "sha256:5e479df4b2d0f8f02133b7e4430098699450e1b2a826438af6bec9a400530957",
- "sha256:684850fb1e3e55c9220aad007f8386d8e3e477c4ec9211ae54d968ecdca8c6f9",
- "sha256:6ccc43d68b81c424e46192a778f97da94ee0630337c9bbe5b2ecc9b0c1c59001",
- "sha256:6d42debaf55450643146fabe4b6817bb2a55b23698b0434107e892a43117285e",
- "sha256:710376bf67d8ff4500a31d0c207b8941ff4fba5de6890a701d71680474fe2a60",
- "sha256:756ae7efddd68d4ea7d89c636b703e14a0c686688d42f588b90778a3c2fc0564",
- "sha256:77149002d9386fae303a4a162e6bce75cc2161347ad2ba06c2f0182561875d45",
- "sha256:78e2f18a82b88cbc37d22365cf8d2b879a492faedb3f2975adb4ed8dfe994d3a",
- "sha256:7d9b42127a6c0bdcc25c3dcf252bb3ddc70454fac593b1b6933ae091396deb13",
- "sha256:8389d6044ee4e2037dca83e3f6994738550f6ee8cfb746762283fad9b932868f",
- "sha256:9c1a81af067e72261c9cbe33ea792893e83bc6aa987bfbd6fdc1e5e7b22777c4",
- "sha256:c1e0920909d916d3375c7a1fdb0b1c78e46170e8bb42792312b6eb6676b2f87f",
- "sha256:c68fdf21c6f3573ae19c7ee65f9ff185649a060c9a06535e9c3a0ee0bbac9235",
- "sha256:c733ef3bdcfe52a1a75564389bad4064352274036e7e234730526d155f04d914",
- "sha256:c9c58b0b84055d8bc27b7df5a9d141df4ee6ff59821f922dd73155861282f6a3",
- "sha256:d03abec50df423b026a5aa09656bd9d37f1e6a49271f123f31f9b8aed5dc3ea3",
- "sha256:d2cfac21e31e841d60dc28c0ec7d4ec47a35c608cb8906435d47ef83ffb22150",
- "sha256:dcc119db14757b0c7bce64042158307b9b1c76471e655751a61b57f5a0e4d78e",
- "sha256:df3a7b258cc230a65245167a202dd07320a5af05f3d41da1488ba0fa05bc9347",
- "sha256:df48a623c58180874d7407b4d9ec06a19b84ed47f60a3884345b1a5099c1818b",
- "sha256:e1b95972a0ae3f248a899cdbac92ba2e01d731225f566569311043ce2226f5e7",
- "sha256:f326b3c1bbfda5b9308252ee0dcb30b612ee92b0e105d4abec70335fab5b1245",
- "sha256:f411cb22115cb15452d099fec0ee636b06cf81bfb40ed9c02d30c8dc2bc2e3d1"
- ],
- "index": "pypi",
- "version": "==3.7.3"
+ "sha256:02f46fc0e3c5ac58b80d4d56eb0a7c7d97fcef69ace9326289fb9f1955e65cfe",
+ "sha256:0563c1b3826945eecd62186f3f5c7d31abb7391fedc893b7e2b26303b5a9f3fe",
+ "sha256:114b281e4d68302a324dd33abb04778e8557d88947875cbf4e842c2c01a030c5",
+ "sha256:14762875b22d0055f05d12abc7f7d61d5fd4fe4642ce1a249abdf8c700bf1fd8",
+ "sha256:15492a6368d985b76a2a5fdd2166cddfea5d24e69eefed4630cbaae5c81d89bd",
+ "sha256:17c073de315745a1510393a96e680d20af8e67e324f70b42accbd4cb3315c9fb",
+ "sha256:209b4a8ee987eccc91e2bd3ac36adee0e53a5970b8ac52c273f7f8fd4872c94c",
+ "sha256:230a8f7e24298dea47659251abc0fd8b3c4e38a664c59d4b89cca7f6c09c9e87",
+ "sha256:2e19413bf84934d651344783c9f5e22dee452e251cfd220ebadbed2d9931dbf0",
+ "sha256:393f389841e8f2dfc86f774ad22f00923fdee66d238af89b70ea314c4aefd290",
+ "sha256:3cf75f7cdc2397ed4442594b935a11ed5569961333d49b7539ea741be2cc79d5",
+ "sha256:3d78619672183be860b96ed96f533046ec97ca067fd46ac1f6a09cd9b7484287",
+ "sha256:40eced07f07a9e60e825554a31f923e8d3997cfc7fb31dbc1328c70826e04cde",
+ "sha256:493d3299ebe5f5a7c66b9819eacdcfbbaaf1a8e84911ddffcdc48888497afecf",
+ "sha256:4b302b45040890cea949ad092479e01ba25911a15e648429c7c5aae9650c67a8",
+ "sha256:515dfef7f869a0feb2afee66b957cc7bbe9ad0cdee45aec7fdc623f4ecd4fb16",
+ "sha256:547da6cacac20666422d4882cfcd51298d45f7ccb60a04ec27424d2f36ba3eaf",
+ "sha256:5df68496d19f849921f05f14f31bd6ef53ad4b00245da3195048c69934521809",
+ "sha256:64322071e046020e8797117b3658b9c2f80e3267daec409b350b6a7a05041213",
+ "sha256:7615dab56bb07bff74bc865307aeb89a8bfd9941d2ef9d817b9436da3a0ea54f",
+ "sha256:79ebfc238612123a713a457d92afb4096e2148be17df6c50fb9bf7a81c2f8013",
+ "sha256:7b18b97cf8ee5452fa5f4e3af95d01d84d86d32c5e2bfa260cf041749d66360b",
+ "sha256:932bb1ea39a54e9ea27fc9232163059a0b8855256f4052e776357ad9add6f1c9",
+ "sha256:a00bb73540af068ca7390e636c01cbc4f644961896fa9363154ff43fd37af2f5",
+ "sha256:a5ca29ee66f8343ed336816c553e82d6cade48a3ad702b9ffa6125d187e2dedb",
+ "sha256:af9aa9ef5ba1fd5b8c948bb11f44891968ab30356d65fd0cc6707d989cd521df",
+ "sha256:bb437315738aa441251214dad17428cafda9cdc9729499f1d6001748e1d432f4",
+ "sha256:bdb230b4943891321e06fc7def63c7aace16095be7d9cf3b1e01be2f10fba439",
+ "sha256:c6e9dcb4cb338d91a73f178d866d051efe7c62a7166653a91e7d9fb18274058f",
+ "sha256:cffe3ab27871bc3ea47df5d8f7013945712c46a3cc5a95b6bee15887f1675c22",
+ "sha256:d012ad7911653a906425d8473a1465caa9f8dea7fcf07b6d870397b774ea7c0f",
+ "sha256:d9e13b33afd39ddeb377eff2c1c4f00544e191e1d1dee5b6c51ddee8ea6f0cf5",
+ "sha256:e4b2b334e68b18ac9817d828ba44d8fcb391f6acb398bcc5062b14b2cbeac970",
+ "sha256:e54962802d4b8b18b6207d4a927032826af39395a3bd9196a5af43fc4e60b009",
+ "sha256:f705e12750171c0ab4ef2a3c76b9a4024a62c4103e3a55dd6f99265b9bc6fcfc",
+ "sha256:f881853d2643a29e643609da57b96d5f9c9b93f62429dcc1cbb413c7d07f0e1a",
+ "sha256:fe60131d21b31fd1a14bd43e6bb88256f69dfc3188b3a89d736d6c71ed43ec95"
+ ],
+ "index": "pypi",
+ "version": "==3.7.4.post0"
},
"aioping": {
"hashes": [
@@ -153,51 +153,53 @@
},
"cffi": {
"hashes": [
- "sha256:00a1ba5e2e95684448de9b89888ccd02c98d512064b4cb987d48f4b40aa0421e",
- "sha256:00e28066507bfc3fe865a31f325c8391a1ac2916219340f87dfad602c3e48e5d",
- "sha256:045d792900a75e8b1e1b0ab6787dd733a8190ffcf80e8c8ceb2fb10a29ff238a",
- "sha256:0638c3ae1a0edfb77c6765d487fee624d2b1ee1bdfeffc1f0b58c64d149e7eec",
- "sha256:105abaf8a6075dc96c1fe5ae7aae073f4696f2905fde6aeada4c9d2926752362",
- "sha256:155136b51fd733fa94e1c2ea5211dcd4c8879869008fc811648f16541bf99668",
- "sha256:1a465cbe98a7fd391d47dce4b8f7e5b921e6cd805ef421d04f5f66ba8f06086c",
- "sha256:1d2c4994f515e5b485fd6d3a73d05526aa0fcf248eb135996b088d25dfa1865b",
- "sha256:2c24d61263f511551f740d1a065eb0212db1dbbbbd241db758f5244281590c06",
- "sha256:51a8b381b16ddd370178a65360ebe15fbc1c71cf6f584613a7ea08bfad946698",
- "sha256:594234691ac0e9b770aee9fcdb8fa02c22e43e5c619456efd0d6c2bf276f3eb2",
- "sha256:5cf4be6c304ad0b6602f5c4e90e2f59b47653ac1ed9c662ed379fe48a8f26b0c",
- "sha256:64081b3f8f6f3c3de6191ec89d7dc6c86a8a43911f7ecb422c60e90c70be41c7",
- "sha256:6bc25fc545a6b3d57b5f8618e59fc13d3a3a68431e8ca5fd4c13241cd70d0009",
- "sha256:798caa2a2384b1cbe8a2a139d80734c9db54f9cc155c99d7cc92441a23871c03",
- "sha256:7c6b1dece89874d9541fc974917b631406233ea0440d0bdfbb8e03bf39a49b3b",
- "sha256:840793c68105fe031f34d6a086eaea153a0cd5c491cde82a74b420edd0a2b909",
- "sha256:8d6603078baf4e11edc4168a514c5ce5b3ba6e3e9c374298cb88437957960a53",
- "sha256:9cc46bc107224ff5b6d04369e7c595acb700c3613ad7bcf2e2012f62ece80c35",
- "sha256:9f7a31251289b2ab6d4012f6e83e58bc3b96bd151f5b5262467f4bb6b34a7c26",
- "sha256:9ffb888f19d54a4d4dfd4b3f29bc2c16aa4972f1c2ab9c4ab09b8ab8685b9c2b",
- "sha256:a5ed8c05548b54b998b9498753fb9cadbfd92ee88e884641377d8a8b291bcc01",
- "sha256:a7711edca4dcef1a75257b50a2fbfe92a65187c47dab5a0f1b9b332c5919a3fb",
- "sha256:af5c59122a011049aad5dd87424b8e65a80e4a6477419c0c1015f73fb5ea0293",
- "sha256:b18e0a9ef57d2b41f5c68beefa32317d286c3d6ac0484efd10d6e07491bb95dd",
- "sha256:b4e248d1087abf9f4c10f3c398896c87ce82a9856494a7155823eb45a892395d",
- "sha256:ba4e9e0ae13fc41c6b23299545e5ef73055213e466bd107953e4a013a5ddd7e3",
- "sha256:c6332685306b6417a91b1ff9fae889b3ba65c2292d64bd9245c093b1b284809d",
- "sha256:d5ff0621c88ce83a28a10d2ce719b2ee85635e85c515f12bac99a95306da4b2e",
- "sha256:d9efd8b7a3ef378dd61a1e77367f1924375befc2eba06168b6ebfa903a5e59ca",
- "sha256:df5169c4396adc04f9b0a05f13c074df878b6052430e03f50e68adf3a57aa28d",
- "sha256:ebb253464a5d0482b191274f1c8bf00e33f7e0b9c66405fbffc61ed2c839c775",
- "sha256:ec80dc47f54e6e9a78181ce05feb71a0353854cc26999db963695f950b5fb375",
- "sha256:f032b34669220030f905152045dfa27741ce1a6db3324a5bc0b96b6c7420c87b",
- "sha256:f60567825f791c6f8a592f3c6e3bd93dd2934e3f9dac189308426bd76b00ef3b",
- "sha256:f803eaa94c2fcda012c047e62bc7a51b0bdabda1cad7a92a522694ea2d76e49f"
- ],
- "version": "==1.14.4"
+ "sha256:005a36f41773e148deac64b08f233873a4d0c18b053d37da83f6af4d9087b813",
+ "sha256:0857f0ae312d855239a55c81ef453ee8fd24136eaba8e87a2eceba644c0d4c06",
+ "sha256:1071534bbbf8cbb31b498d5d9db0f274f2f7a865adca4ae429e147ba40f73dea",
+ "sha256:158d0d15119b4b7ff6b926536763dc0714313aa59e320ddf787502c70c4d4bee",
+ "sha256:1f436816fc868b098b0d63b8920de7d208c90a67212546d02f84fe78a9c26396",
+ "sha256:2894f2df484ff56d717bead0a5c2abb6b9d2bf26d6960c4604d5c48bbc30ee73",
+ "sha256:29314480e958fd8aab22e4a58b355b629c59bf5f2ac2492b61e3dc06d8c7a315",
+ "sha256:34eff4b97f3d982fb93e2831e6750127d1355a923ebaeeb565407b3d2f8d41a1",
+ "sha256:35f27e6eb43380fa080dccf676dece30bef72e4a67617ffda586641cd4508d49",
+ "sha256:3d3dd4c9e559eb172ecf00a2a7517e97d1e96de2a5e610bd9b68cea3925b4892",
+ "sha256:43e0b9d9e2c9e5d152946b9c5fe062c151614b262fda2e7b201204de0b99e482",
+ "sha256:48e1c69bbacfc3d932221851b39d49e81567a4d4aac3b21258d9c24578280058",
+ "sha256:51182f8927c5af975fece87b1b369f722c570fe169f9880764b1ee3bca8347b5",
+ "sha256:58e3f59d583d413809d60779492342801d6e82fefb89c86a38e040c16883be53",
+ "sha256:5de7970188bb46b7bf9858eb6890aad302577a5f6f75091fd7cdd3ef13ef3045",
+ "sha256:65fa59693c62cf06e45ddbb822165394a288edce9e276647f0046e1ec26920f3",
+ "sha256:69e395c24fc60aad6bb4fa7e583698ea6cc684648e1ffb7fe85e3c1ca131a7d5",
+ "sha256:6c97d7350133666fbb5cf4abdc1178c812cb205dc6f41d174a7b0f18fb93337e",
+ "sha256:6e4714cc64f474e4d6e37cfff31a814b509a35cb17de4fb1999907575684479c",
+ "sha256:72d8d3ef52c208ee1c7b2e341f7d71c6fd3157138abf1a95166e6165dd5d4369",
+ "sha256:8ae6299f6c68de06f136f1f9e69458eae58f1dacf10af5c17353eae03aa0d827",
+ "sha256:8b198cec6c72df5289c05b05b8b0969819783f9418e0409865dac47288d2a053",
+ "sha256:99cd03ae7988a93dd00bcd9d0b75e1f6c426063d6f03d2f90b89e29b25b82dfa",
+ "sha256:9cf8022fb8d07a97c178b02327b284521c7708d7c71a9c9c355c178ac4bbd3d4",
+ "sha256:9de2e279153a443c656f2defd67769e6d1e4163952b3c622dcea5b08a6405322",
+ "sha256:9e93e79c2551ff263400e1e4be085a1210e12073a31c2011dbbda14bda0c6132",
+ "sha256:9ff227395193126d82e60319a673a037d5de84633f11279e336f9c0f189ecc62",
+ "sha256:a465da611f6fa124963b91bf432d960a555563efe4ed1cc403ba5077b15370aa",
+ "sha256:ad17025d226ee5beec591b52800c11680fca3df50b8b29fe51d882576e039ee0",
+ "sha256:afb29c1ba2e5a3736f1c301d9d0abe3ec8b86957d04ddfa9d7a6a42b9367e396",
+ "sha256:b85eb46a81787c50650f2392b9b4ef23e1f126313b9e0e9013b35c15e4288e2e",
+ "sha256:bb89f306e5da99f4d922728ddcd6f7fcebb3241fc40edebcb7284d7514741991",
+ "sha256:cbde590d4faaa07c72bf979734738f328d239913ba3e043b1e98fe9a39f8b2b6",
+ "sha256:cd2868886d547469123fadc46eac7ea5253ea7fcb139f12e1dfc2bbd406427d1",
+ "sha256:d42b11d692e11b6634f7613ad8df5d6d5f8875f5d48939520d351007b3c13406",
+ "sha256:f2d45f97ab6bb54753eab54fffe75aaf3de4ff2341c9daee1987ee1837636f1d",
+ "sha256:fd78e5fee591709f32ef6edb9a015b4aa1a5022598e36227500c8f4e02328d9c"
+ ],
+ "version": "==1.14.5"
},
"chardet": {
"hashes": [
- "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
- "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
+ "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa",
+ "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"
],
- "version": "==3.0.4"
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
+ "version": "==4.0.0"
},
"colorama": {
"hashes": [
@@ -241,10 +243,10 @@
},
"fakeredis": {
"hashes": [
- "sha256:01cb47d2286825a171fb49c0e445b1fa9307087e07cbb3d027ea10dbff108b6a",
- "sha256:2c6041cf0225889bc403f3949838b2c53470a95a9e2d4272422937786f5f8f73"
+ "sha256:1ac0cef767c37f51718874a33afb5413e69d132988cb6a80c6e6dbeddf8c7623",
+ "sha256:e0416e4941cecd3089b0d901e60c8dc3c944f6384f5e29e2261c0d3c5fa99669"
],
- "version": "==1.4.5"
+ "version": "==1.5.0"
},
"feedparser": {
"hashes": [
@@ -333,46 +335,45 @@
},
"lxml": {
"hashes": [
- "sha256:0448576c148c129594d890265b1a83b9cd76fd1f0a6a04620753d9a6bcfd0a4d",
- "sha256:127f76864468d6630e1b453d3ffbbd04b024c674f55cf0a30dc2595137892d37",
- "sha256:1471cee35eba321827d7d53d104e7b8c593ea3ad376aa2df89533ce8e1b24a01",
- "sha256:2363c35637d2d9d6f26f60a208819e7eafc4305ce39dc1d5005eccc4593331c2",
- "sha256:2e5cc908fe43fe1aa299e58046ad66981131a66aea3129aac7770c37f590a644",
- "sha256:2e6fd1b8acd005bd71e6c94f30c055594bbd0aa02ef51a22bbfa961ab63b2d75",
- "sha256:366cb750140f221523fa062d641393092813b81e15d0e25d9f7c6025f910ee80",
- "sha256:42ebca24ba2a21065fb546f3e6bd0c58c3fe9ac298f3a320147029a4850f51a2",
- "sha256:4e751e77006da34643ab782e4a5cc21ea7b755551db202bc4d3a423b307db780",
- "sha256:4fb85c447e288df535b17ebdebf0ec1cf3a3f1a8eba7e79169f4f37af43c6b98",
- "sha256:50c348995b47b5a4e330362cf39fc503b4a43b14a91c34c83b955e1805c8e308",
- "sha256:535332fe9d00c3cd455bd3dd7d4bacab86e2d564bdf7606079160fa6251caacf",
- "sha256:535f067002b0fd1a4e5296a8f1bf88193080ff992a195e66964ef2a6cfec5388",
- "sha256:5be4a2e212bb6aa045e37f7d48e3e1e4b6fd259882ed5a00786f82e8c37ce77d",
- "sha256:60a20bfc3bd234d54d49c388950195d23a5583d4108e1a1d47c9eef8d8c042b3",
- "sha256:648914abafe67f11be7d93c1a546068f8eff3c5fa938e1f94509e4a5d682b2d8",
- "sha256:681d75e1a38a69f1e64ab82fe4b1ed3fd758717bed735fb9aeaa124143f051af",
- "sha256:68a5d77e440df94011214b7db907ec8f19e439507a70c958f750c18d88f995d2",
- "sha256:69a63f83e88138ab7642d8f61418cf3180a4d8cd13995df87725cb8b893e950e",
- "sha256:6e4183800f16f3679076dfa8abf2db3083919d7e30764a069fb66b2b9eff9939",
- "sha256:6fd8d5903c2e53f49e99359b063df27fdf7acb89a52b6a12494208bf61345a03",
- "sha256:791394449e98243839fa822a637177dd42a95f4883ad3dec2a0ce6ac99fb0a9d",
- "sha256:7a7669ff50f41225ca5d6ee0a1ec8413f3a0d8aa2b109f86d540887b7ec0d72a",
- "sha256:7e9eac1e526386df7c70ef253b792a0a12dd86d833b1d329e038c7a235dfceb5",
- "sha256:7ee8af0b9f7de635c61cdd5b8534b76c52cd03536f29f51151b377f76e214a1a",
- "sha256:8246f30ca34dc712ab07e51dc34fea883c00b7ccb0e614651e49da2c49a30711",
- "sha256:8c88b599e226994ad4db29d93bc149aa1aff3dc3a4355dd5757569ba78632bdf",
- "sha256:923963e989ffbceaa210ac37afc9b906acebe945d2723e9679b643513837b089",
- "sha256:94d55bd03d8671686e3f012577d9caa5421a07286dd351dfef64791cf7c6c505",
- "sha256:97db258793d193c7b62d4e2586c6ed98d51086e93f9a3af2b2034af01450a74b",
- "sha256:a9d6bc8642e2c67db33f1247a77c53476f3a166e09067c0474facb045756087f",
- "sha256:cd11c7e8d21af997ee8079037fff88f16fda188a9776eb4b81c7e4c9c0a7d7fc",
- "sha256:d8d3d4713f0c28bdc6c806a278d998546e8efc3498949e3ace6e117462ac0a5e",
- "sha256:e0bfe9bb028974a481410432dbe1b182e8191d5d40382e5b8ff39cdd2e5c5931",
- "sha256:f4822c0660c3754f1a41a655e37cb4dbbc9be3d35b125a37fab6f82d47674ebc",
- "sha256:f83d281bb2a6217cd806f4cf0ddded436790e66f393e124dfe9731f6b3fb9afe",
- "sha256:fc37870d6716b137e80d19241d0e2cff7a7643b925dfa49b4c8ebd1295eb506e"
- ],
- "index": "pypi",
- "version": "==4.6.2"
+ "sha256:079f3ae844f38982d156efce585bc540c16a926d4436712cf4baee0cce487a3d",
+ "sha256:0fbcf5565ac01dff87cbfc0ff323515c823081c5777a9fc7703ff58388c258c3",
+ "sha256:122fba10466c7bd4178b07dba427aa516286b846b2cbd6f6169141917283aae2",
+ "sha256:1b7584d421d254ab86d4f0b13ec662a9014397678a7c4265a02a6d7c2b18a75f",
+ "sha256:26e761ab5b07adf5f555ee82fb4bfc35bf93750499c6c7614bd64d12aaa67927",
+ "sha256:289e9ca1a9287f08daaf796d96e06cb2bc2958891d7911ac7cae1c5f9e1e0ee3",
+ "sha256:2a9d50e69aac3ebee695424f7dbd7b8c6d6eb7de2a2eb6b0f6c7db6aa41e02b7",
+ "sha256:33bb934a044cf32157c12bfcfbb6649807da20aa92c062ef51903415c704704f",
+ "sha256:3439c71103ef0e904ea0a1901611863e51f50b5cd5e8654a151740fde5e1cade",
+ "sha256:39b78571b3b30645ac77b95f7c69d1bffc4cf8c3b157c435a34da72e78c82468",
+ "sha256:4289728b5e2000a4ad4ab8da6e1db2e093c63c08bdc0414799ee776a3f78da4b",
+ "sha256:4bff24dfeea62f2e56f5bab929b4428ae6caba2d1eea0c2d6eb618e30a71e6d4",
+ "sha256:542d454665a3e277f76954418124d67516c5f88e51a900365ed54a9806122b83",
+ "sha256:5a0a14e264069c03e46f926be0d8919f4105c1623d620e7ec0e612a2e9bf1c04",
+ "sha256:66e575c62792c3f9ca47cb8b6fab9e35bab91360c783d1606f758761810c9791",
+ "sha256:74f7d8d439b18fa4c385f3f5dfd11144bb87c1da034a466c5b5577d23a1d9b51",
+ "sha256:7610b8c31688f0b1be0ef882889817939490a36d0ee880ea562a4e1399c447a1",
+ "sha256:76fa7b1362d19f8fbd3e75fe2fb7c79359b0af8747e6f7141c338f0bee2f871a",
+ "sha256:7728e05c35412ba36d3e9795ae8995e3c86958179c9770e65558ec3fdfd3724f",
+ "sha256:8157dadbb09a34a6bd95a50690595e1fa0af1a99445e2744110e3dca7831c4ee",
+ "sha256:820628b7b3135403540202e60551e741f9b6d3304371712521be939470b454ec",
+ "sha256:884ab9b29feaca361f7f88d811b1eea9bfca36cf3da27768d28ad45c3ee6f969",
+ "sha256:89b8b22a5ff72d89d48d0e62abb14340d9e99fd637d046c27b8b257a01ffbe28",
+ "sha256:92e821e43ad382332eade6812e298dc9701c75fe289f2a2d39c7960b43d1e92a",
+ "sha256:b007cbb845b28db4fb8b6a5cdcbf65bacb16a8bd328b53cbc0698688a68e1caa",
+ "sha256:bc4313cbeb0e7a416a488d72f9680fffffc645f8a838bd2193809881c67dd106",
+ "sha256:bccbfc27563652de7dc9bdc595cb25e90b59c5f8e23e806ed0fd623755b6565d",
+ "sha256:c4f05c5a7c49d2fb70223d0d5bcfbe474cf928310ac9fa6a7c6dddc831d0b1d4",
+ "sha256:ce256aaa50f6cc9a649c51be3cd4ff142d67295bfc4f490c9134d0f9f6d58ef0",
+ "sha256:d2e35d7bf1c1ac8c538f88d26b396e73dd81440d59c1ef8522e1ea77b345ede4",
+ "sha256:df7c53783a46febb0e70f6b05df2ba104610f2fb0d27023409734a3ecbb78fb2",
+ "sha256:efac139c3f0bf4f0939f9375af4b02c5ad83a622de52d6dfa8e438e8e01d0eb0",
+ "sha256:efd7a09678fd8b53117f6bae4fa3825e0a22b03ef0a932e070c0bdbb3a35e654",
+ "sha256:f2380a6376dfa090227b663f9678150ef27543483055cc327555fb592c5967e2",
+ "sha256:f8380c03e45cf09f8557bdaa41e1fa7c81f3ae22828e1db470ab2a6c96d8bc23",
+ "sha256:f90ba11136bfdd25cae3951af8da2e95121c9b9b93727b1b896e3fa105b2f586"
+ ],
+ "index": "pypi",
+ "version": "==4.6.3"
},
"markdownify": {
"hashes": [
@@ -384,11 +385,11 @@
},
"more-itertools": {
"hashes": [
- "sha256:8e1a2a43b2f2727425f2b5839587ae37093f19153dc26c0927d1048ff6557330",
- "sha256:b3a9005928e5bed54076e6e549c792b306fddfe72b2d1d22dd63d42d5d3899cf"
+ "sha256:5652a9ac72209ed7df8d9c15daf4e1aa0e3d2ccd3c87f8265a0673cd9cbc9ced",
+ "sha256:c5d6da9ca3ff65220c3bfd2a8db06d698f05d4d2b9be57e1deb2be5a45019713"
],
"index": "pypi",
- "version": "==8.6.0"
+ "version": "==8.7.0"
},
"multidict": {
"hashes": [
@@ -508,27 +509,35 @@
},
"pyyaml": {
"hashes": [
- "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf",
- "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696",
- "sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393",
- "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77",
- "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922",
+ "sha256:fd7f6999a8070df521b6384004ef42833b9bd62cfee11a09bda1079b4b704247",
"sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5",
- "sha256:4465124ef1b18d9ace298060f4eccc64b0850899ac4ac53294547536533800c8",
+ "sha256:d483ad4e639292c90170eb6f7783ad19490e7a8defb3e46f97dfe4bacae89122",
+ "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db",
+ "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df",
"sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10",
- "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc",
- "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018",
+ "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46",
+ "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77",
+ "sha256:fe69978f3f768926cfa37b867e3843918e012cf83f680806599ddce33c2c68b0",
+ "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb",
+ "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b",
+ "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922",
"sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e",
+ "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf",
+ "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018",
+ "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc",
+ "sha256:fdc842473cd33f45ff6bce46aea678a54e3d21f1b61a7750ce3c498eedfe25d6",
"sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253",
- "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183",
- "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb",
"sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185",
- "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db",
- "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46",
- "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b",
+ "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc",
+ "sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393",
+ "sha256:cb333c16912324fd5f769fff6bc5de372e9e7a202247b48870bc251ed40239aa",
+ "sha256:895f61ef02e8fed38159bb70f7e100e00f471eae2bc838cd0f4ebb21e28f8541",
+ "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183",
+ "sha256:bfb51918d4ff3d77c1c856a9699f8492c612cde32fd3bcd344af9be34999bfdc",
+ "sha256:4465124ef1b18d9ace298060f4eccc64b0850899ac4ac53294547536533800c8",
"sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63",
- "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df",
- "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc"
+ "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696",
+ "sha256:72a01f726a9c7851ca9bfad6fd09ca4e090a023c00945ea05ba1638c09dc3347"
],
"index": "pypi",
"version": "==5.4.1"
@@ -543,11 +552,11 @@
},
"sentry-sdk": {
"hashes": [
- "sha256:0a711ec952441c2ec89b8f5d226c33bc697914f46e876b44a4edd3e7864cf4d0",
- "sha256:737a094e49a529dd0fdcaafa9e97cf7c3d5eb964bd229821d640bc77f3502b3f"
+ "sha256:4ae8d1ced6c67f1c8ea51d82a16721c166c489b76876c9f2c202b8a50334b237",
+ "sha256:e75c8c58932bda8cd293ea8e4b242527129e1caaec91433d21b8b2f20fee030b"
],
"index": "pypi",
- "version": "==0.19.5"
+ "version": "==0.20.3"
},
"six": {
"hashes": [
@@ -566,11 +575,11 @@
},
"soupsieve": {
"hashes": [
- "sha256:4bb21a6ee4707bf43b61230e80740e71bfe56e55d1f1f50924b087bb2975c851",
- "sha256:6dc52924dc0bc710a5d16794e6b3480b2c7c08b07729505feab2b2c16661ff6e"
+ "sha256:052774848f448cf19c7e959adf5566904d525f33a3f8b6ba6f6f8f26ec7de0cc",
+ "sha256:c2c1c2d44f158cdbddab7824a9af8c4f83c76b1e23e049479aa432feb6c4c23b"
],
"markers": "python_version >= '3.0'",
- "version": "==2.1"
+ "version": "==2.2.1"
},
"statsd": {
"hashes": [
@@ -590,11 +599,11 @@
},
"urllib3": {
"hashes": [
- "sha256:19188f96923873c92ccb987120ec4acaa12f0461fa9ce5d3d0772bc965a39e08",
- "sha256:d8ff90d979214d7b4f8ce956e80f4028fc6860e4431f731ea4a8c08f23f99473"
+ "sha256:2f4da4594db7e1e110a944bb1b551fdf4e6c136ad42e4234131391e21eb5b0df",
+ "sha256:e7b021f7241115872f92f43c6508082facffbd1c048e3c6e2bb9c2a157e28937"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'",
- "version": "==1.26.2"
+ "version": "==1.26.4"
},
"yarl": {
"hashes": [
@@ -673,65 +682,69 @@
},
"chardet": {
"hashes": [
- "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
- "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
+ "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa",
+ "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"
],
- "version": "==3.0.4"
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
+ "version": "==4.0.0"
},
"coverage": {
"hashes": [
- "sha256:08b3ba72bd981531fd557f67beee376d6700fba183b167857038997ba30dd297",
- "sha256:2757fa64e11ec12220968f65d086b7a29b6583d16e9a544c889b22ba98555ef1",
- "sha256:3102bb2c206700a7d28181dbe04d66b30780cde1d1c02c5f3c165cf3d2489497",
- "sha256:3498b27d8236057def41de3585f317abae235dd3a11d33e01736ffedb2ef8606",
- "sha256:378ac77af41350a8c6b8801a66021b52da8a05fd77e578b7380e876c0ce4f528",
- "sha256:38f16b1317b8dd82df67ed5daa5f5e7c959e46579840d77a67a4ceb9cef0a50b",
- "sha256:3911c2ef96e5ddc748a3c8b4702c61986628bb719b8378bf1e4a6184bbd48fe4",
- "sha256:3a3c3f8863255f3c31db3889f8055989527173ef6192a283eb6f4db3c579d830",
- "sha256:3b14b1da110ea50c8bcbadc3b82c3933974dbeea1832e814aab93ca1163cd4c1",
- "sha256:535dc1e6e68fad5355f9984d5637c33badbdc987b0c0d303ee95a6c979c9516f",
- "sha256:6f61319e33222591f885c598e3e24f6a4be3533c1d70c19e0dc59e83a71ce27d",
- "sha256:723d22d324e7997a651478e9c5a3120a0ecbc9a7e94071f7e1954562a8806cf3",
- "sha256:76b2775dda7e78680d688daabcb485dc87cf5e3184a0b3e012e1d40e38527cc8",
- "sha256:782a5c7df9f91979a7a21792e09b34a658058896628217ae6362088b123c8500",
- "sha256:7e4d159021c2029b958b2363abec4a11db0ce8cd43abb0d9ce44284cb97217e7",
- "sha256:8dacc4073c359f40fcf73aede8428c35f84639baad7e1b46fce5ab7a8a7be4bb",
- "sha256:8f33d1156241c43755137288dea619105477961cfa7e47f48dbf96bc2c30720b",
- "sha256:8ffd4b204d7de77b5dd558cdff986a8274796a1e57813ed005b33fd97e29f059",
- "sha256:93a280c9eb736a0dcca19296f3c30c720cb41a71b1f9e617f341f0a8e791a69b",
- "sha256:9a4f66259bdd6964d8cf26142733c81fb562252db74ea367d9beb4f815478e72",
- "sha256:9a9d4ff06804920388aab69c5ea8a77525cf165356db70131616acd269e19b36",
- "sha256:a2070c5affdb3a5e751f24208c5c4f3d5f008fa04d28731416e023c93b275277",
- "sha256:a4857f7e2bc6921dbd487c5c88b84f5633de3e7d416c4dc0bb70256775551a6c",
- "sha256:a607ae05b6c96057ba86c811d9c43423f35e03874ffb03fbdcd45e0637e8b631",
- "sha256:a66ca3bdf21c653e47f726ca57f46ba7fc1f260ad99ba783acc3e58e3ebdb9ff",
- "sha256:ab110c48bc3d97b4d19af41865e14531f300b482da21783fdaacd159251890e8",
- "sha256:b239711e774c8eb910e9b1ac719f02f5ae4bf35fa0420f438cdc3a7e4e7dd6ec",
- "sha256:be0416074d7f253865bb67630cf7210cbc14eb05f4099cc0f82430135aaa7a3b",
- "sha256:c46643970dff9f5c976c6512fd35768c4a3819f01f61169d8cdac3f9290903b7",
- "sha256:c5ec71fd4a43b6d84ddb88c1df94572479d9a26ef3f150cef3dacefecf888105",
- "sha256:c6e5174f8ca585755988bc278c8bb5d02d9dc2e971591ef4a1baabdf2d99589b",
- "sha256:c89b558f8a9a5a6f2cfc923c304d49f0ce629c3bd85cb442ca258ec20366394c",
- "sha256:cc44e3545d908ecf3e5773266c487ad1877be718d9dc65fc7eb6e7d14960985b",
- "sha256:cc6f8246e74dd210d7e2b56c76ceaba1cc52b025cd75dbe96eb48791e0250e98",
- "sha256:cd556c79ad665faeae28020a0ab3bda6cd47d94bec48e36970719b0b86e4dcf4",
- "sha256:ce6f3a147b4b1a8b09aae48517ae91139b1b010c5f36423fa2b866a8b23df879",
- "sha256:ceb499d2b3d1d7b7ba23abe8bf26df5f06ba8c71127f188333dddcf356b4b63f",
- "sha256:cef06fb382557f66d81d804230c11ab292d94b840b3cb7bf4450778377b592f4",
- "sha256:e448f56cfeae7b1b3b5bcd99bb377cde7c4eb1970a525c770720a352bc4c8044",
- "sha256:e52d3d95df81c8f6b2a1685aabffadf2d2d9ad97203a40f8d61e51b70f191e4e",
- "sha256:ee2f1d1c223c3d2c24e3afbb2dd38be3f03b1a8d6a83ee3d9eb8c36a52bee899",
- "sha256:f2c6888eada180814b8583c3e793f3f343a692fc802546eed45f40a001b1169f",
- "sha256:f51dbba78d68a44e99d484ca8c8f604f17e957c1ca09c3ebc2c7e3bbd9ba0448",
- "sha256:f54de00baf200b4539a5a092a759f000b5f45fd226d6d25a76b0dff71177a714",
- "sha256:fa10fee7e32213f5c7b0d6428ea92e3a3fdd6d725590238a3f92c0de1c78b9d2",
- "sha256:fabeeb121735d47d8eab8671b6b031ce08514c86b7ad8f7d5490a7b6dcd6267d",
- "sha256:fac3c432851038b3e6afe086f777732bcf7f6ebbfd90951fa04ee53db6d0bcdd",
- "sha256:fda29412a66099af6d6de0baa6bd7c52674de177ec2ad2630ca264142d69c6c7",
- "sha256:ff1330e8bc996570221b450e2d539134baa9465f5cb98aff0e0f73f34172e0ae"
- ],
- "index": "pypi",
- "version": "==5.3.1"
+ "sha256:004d1880bed2d97151facef49f08e255a20ceb6f9432df75f4eef018fdd5a78c",
+ "sha256:01d84219b5cdbfc8122223b39a954820929497a1cb1422824bb86b07b74594b6",
+ "sha256:040af6c32813fa3eae5305d53f18875bedd079960822ef8ec067a66dd8afcd45",
+ "sha256:06191eb60f8d8a5bc046f3799f8a07a2d7aefb9504b0209aff0b47298333302a",
+ "sha256:13034c4409db851670bc9acd836243aeee299949bd5673e11844befcb0149f03",
+ "sha256:13c4ee887eca0f4c5a247b75398d4114c37882658300e153113dafb1d76de529",
+ "sha256:184a47bbe0aa6400ed2d41d8e9ed868b8205046518c52464fde713ea06e3a74a",
+ "sha256:18ba8bbede96a2c3dde7b868de9dcbd55670690af0988713f0603f037848418a",
+ "sha256:1aa846f56c3d49205c952d8318e76ccc2ae23303351d9270ab220004c580cfe2",
+ "sha256:217658ec7187497e3f3ebd901afdca1af062b42cfe3e0dafea4cced3983739f6",
+ "sha256:24d4a7de75446be83244eabbff746d66b9240ae020ced65d060815fac3423759",
+ "sha256:2910f4d36a6a9b4214bb7038d537f015346f413a975d57ca6b43bf23d6563b53",
+ "sha256:2949cad1c5208b8298d5686d5a85b66aae46d73eec2c3e08c817dd3513e5848a",
+ "sha256:2a3859cb82dcbda1cfd3e6f71c27081d18aa251d20a17d87d26d4cd216fb0af4",
+ "sha256:2cafbbb3af0733db200c9b5f798d18953b1a304d3f86a938367de1567f4b5bff",
+ "sha256:2e0d881ad471768bf6e6c2bf905d183543f10098e3b3640fc029509530091502",
+ "sha256:30c77c1dc9f253283e34c27935fded5015f7d1abe83bc7821680ac444eaf7793",
+ "sha256:3487286bc29a5aa4b93a072e9592f22254291ce96a9fbc5251f566b6b7343cdb",
+ "sha256:372da284cfd642d8e08ef606917846fa2ee350f64994bebfbd3afb0040436905",
+ "sha256:41179b8a845742d1eb60449bdb2992196e211341818565abded11cfa90efb821",
+ "sha256:44d654437b8ddd9eee7d1eaee28b7219bec228520ff809af170488fd2fed3e2b",
+ "sha256:4a7697d8cb0f27399b0e393c0b90f0f1e40c82023ea4d45d22bce7032a5d7b81",
+ "sha256:51cb9476a3987c8967ebab3f0fe144819781fca264f57f89760037a2ea191cb0",
+ "sha256:52596d3d0e8bdf3af43db3e9ba8dcdaac724ba7b5ca3f6358529d56f7a166f8b",
+ "sha256:53194af30d5bad77fcba80e23a1441c71abfb3e01192034f8246e0d8f99528f3",
+ "sha256:5fec2d43a2cc6965edc0bb9e83e1e4b557f76f843a77a2496cbe719583ce8184",
+ "sha256:6c90e11318f0d3c436a42409f2749ee1a115cd8b067d7f14c148f1ce5574d701",
+ "sha256:74d881fc777ebb11c63736622b60cb9e4aee5cace591ce274fb69e582a12a61a",
+ "sha256:7501140f755b725495941b43347ba8a2777407fc7f250d4f5a7d2a1050ba8e82",
+ "sha256:796c9c3c79747146ebd278dbe1e5c5c05dd6b10cc3bcb8389dfdf844f3ead638",
+ "sha256:869a64f53488f40fa5b5b9dcb9e9b2962a66a87dab37790f3fcfb5144b996ef5",
+ "sha256:8963a499849a1fc54b35b1c9f162f4108017b2e6db2c46c1bed93a72262ed083",
+ "sha256:8d0a0725ad7c1a0bcd8d1b437e191107d457e2ec1084b9f190630a4fb1af78e6",
+ "sha256:900fbf7759501bc7807fd6638c947d7a831fc9fdf742dc10f02956ff7220fa90",
+ "sha256:92b017ce34b68a7d67bd6d117e6d443a9bf63a2ecf8567bb3d8c6c7bc5014465",
+ "sha256:970284a88b99673ccb2e4e334cfb38a10aab7cd44f7457564d11898a74b62d0a",
+ "sha256:972c85d205b51e30e59525694670de6a8a89691186012535f9d7dbaa230e42c3",
+ "sha256:9a1ef3b66e38ef8618ce5fdc7bea3d9f45f3624e2a66295eea5e57966c85909e",
+ "sha256:af0e781009aaf59e25c5a678122391cb0f345ac0ec272c7961dc5455e1c40066",
+ "sha256:b6d534e4b2ab35c9f93f46229363e17f63c53ad01330df9f2d6bd1187e5eaacf",
+ "sha256:b7895207b4c843c76a25ab8c1e866261bcfe27bfaa20c192de5190121770672b",
+ "sha256:c0891a6a97b09c1f3e073a890514d5012eb256845c451bd48f7968ef939bf4ae",
+ "sha256:c2723d347ab06e7ddad1a58b2a821218239249a9e4365eaff6649d31180c1669",
+ "sha256:d1f8bf7b90ba55699b3a5e44930e93ff0189aa27186e96071fac7dd0d06a1873",
+ "sha256:d1f9ce122f83b2305592c11d64f181b87153fc2c2bbd3bb4a3dde8303cfb1a6b",
+ "sha256:d314ed732c25d29775e84a960c3c60808b682c08d86602ec2c3008e1202e3bb6",
+ "sha256:d636598c8305e1f90b439dbf4f66437de4a5e3c31fdf47ad29542478c8508bbb",
+ "sha256:deee1077aae10d8fa88cb02c845cfba9b62c55e1183f52f6ae6a2df6a2187160",
+ "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c",
+ "sha256:f030f8873312a16414c0d8e1a1ddff2d3235655a2174e3648b4fa66b3f2f1079",
+ "sha256:f0b278ce10936db1a37e6954e15a3730bea96a0997c26d7fee88e6c396c2086d",
+ "sha256:f11642dddbb0253cc8853254301b51390ba0081750a8ac03f20ea8103f0c56b6"
+ ],
+ "index": "pypi",
+ "version": "==5.5"
},
"coveralls": {
"hashes": [
@@ -763,19 +776,19 @@
},
"flake8": {
"hashes": [
- "sha256:749dbbd6bfd0cf1318af27bf97a14e28e5ff548ef8e5b1566ccfb25a11e7c839",
- "sha256:aadae8761ec651813c24be05c6f7b4680857ef6afaae4651a4eccaef97ce6c3b"
+ "sha256:12d05ab02614b6aee8df7c36b97d1a3b2372761222b19b58621355e82acddcff",
+ "sha256:78873e372b12b093da7b5e5ed302e8ad9e988b38b063b61ad937f26ca58fc5f0"
],
"index": "pypi",
- "version": "==3.8.4"
+ "version": "==3.9.0"
},
"flake8-annotations": {
"hashes": [
- "sha256:3a377140556aecf11fa9f3bb18c10db01f5ea56dc79a730e2ec9b4f1f49e2055",
- "sha256:e17947a48a5b9f632fe0c72682fc797c385e451048e7dfb20139f448a074cb3e"
+ "sha256:40a4d504cdf64126ea0bdca39edab1608bc6d515e96569b7e7c3c59c84f66c36",
+ "sha256:eabbfb2dd59ae0e9835f509f930e79cd99fa4ff1026fe6ca073503a57407037c"
],
"index": "pypi",
- "version": "==2.5.0"
+ "version": "==2.6.1"
},
"flake8-bugbear": {
"hashes": [
@@ -787,11 +800,11 @@
},
"flake8-docstrings": {
"hashes": [
- "sha256:3d5a31c7ec6b7367ea6506a87ec293b94a0a46c0bce2bb4975b7f1d09b6f3717",
- "sha256:a256ba91bc52307bef1de59e2a009c3cf61c3d0952dbe035d6ff7208940c2edc"
+ "sha256:99cac583d6c7e32dd28bbfbef120a7c0d1b6dde4adb5a9fd441c4227a6534bde",
+ "sha256:9fe7c6a306064af8e62a055c2f61e9eb1da55f84bb39caef2b84ce53708ac34b"
],
"index": "pypi",
- "version": "==1.5.0"
+ "version": "==1.6.0"
},
"flake8-import-order": {
"hashes": [
@@ -833,11 +846,11 @@
},
"identify": {
"hashes": [
- "sha256:70b638cf4743f33042bebb3b51e25261a0a10e80f978739f17e7fd4837664a66",
- "sha256:9dfb63a2e871b807e3ba62f029813552a24b5289504f5b071dea9b041aee9fe4"
+ "sha256:1cfb05b578de996677836d5a2dde14b3dffde313cf7d2b3e793a0787a36e26dd",
+ "sha256:9cc5f58996cd359b7b72f0a5917d8639de5323917e6952a3bfbf36301b576f40"
],
- "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
- "version": "==1.5.13"
+ "markers": "python_full_version >= '3.6.1'",
+ "version": "==2.2.1"
},
"idna": {
"hashes": [
@@ -871,59 +884,67 @@
},
"pre-commit": {
"hashes": [
- "sha256:6c86d977d00ddc8a60d68eec19f51ef212d9462937acf3ea37c7adec32284ac0",
- "sha256:ee784c11953e6d8badb97d19bc46b997a3a9eded849881ec587accd8608d74a4"
+ "sha256:94c82f1bf5899d56edb1d926732f4e75a7df29a0c8c092559c77420c9d62428b",
+ "sha256:de55c5c72ce80d79106e48beb1b54104d16495ce7f95b0c7b13d4784193a00af"
],
"index": "pypi",
- "version": "==2.9.3"
+ "version": "==2.11.1"
},
"pycodestyle": {
"hashes": [
- "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367",
- "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"
+ "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068",
+ "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
- "version": "==2.6.0"
+ "version": "==2.7.0"
},
"pydocstyle": {
"hashes": [
- "sha256:19b86fa8617ed916776a11cd8bc0197e5b9856d5433b777f51a3defe13075325",
- "sha256:aca749e190a01726a4fb472dd4ef23b5c9da7b9205c0a7857c06533de13fd678"
+ "sha256:164befb520d851dbcf0e029681b91f4f599c62c5cd8933fd54b1bfbd50e89e1f",
+ "sha256:d4449cf16d7e6709f63192146706933c7a334af7c0f083904799ccb851c50f6d"
],
- "markers": "python_version >= '3.5'",
- "version": "==5.1.1"
+ "markers": "python_version >= '3.6'",
+ "version": "==6.0.0"
},
"pyflakes": {
"hashes": [
- "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92",
- "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"
+ "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3",
+ "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
- "version": "==2.2.0"
+ "version": "==2.3.1"
},
"pyyaml": {
"hashes": [
- "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf",
- "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696",
- "sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393",
- "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77",
- "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922",
+ "sha256:fd7f6999a8070df521b6384004ef42833b9bd62cfee11a09bda1079b4b704247",
"sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5",
- "sha256:4465124ef1b18d9ace298060f4eccc64b0850899ac4ac53294547536533800c8",
+ "sha256:d483ad4e639292c90170eb6f7783ad19490e7a8defb3e46f97dfe4bacae89122",
+ "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db",
+ "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df",
"sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10",
- "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc",
- "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018",
+ "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46",
+ "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77",
+ "sha256:fe69978f3f768926cfa37b867e3843918e012cf83f680806599ddce33c2c68b0",
+ "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb",
+ "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b",
+ "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922",
"sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e",
+ "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf",
+ "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018",
+ "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc",
+ "sha256:fdc842473cd33f45ff6bce46aea678a54e3d21f1b61a7750ce3c498eedfe25d6",
"sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253",
- "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183",
- "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb",
"sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185",
- "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db",
- "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46",
- "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b",
+ "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc",
+ "sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393",
+ "sha256:cb333c16912324fd5f769fff6bc5de372e9e7a202247b48870bc251ed40239aa",
+ "sha256:895f61ef02e8fed38159bb70f7e100e00f471eae2bc838cd0f4ebb21e28f8541",
+ "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183",
+ "sha256:bfb51918d4ff3d77c1c856a9699f8492c612cde32fd3bcd344af9be34999bfdc",
+ "sha256:4465124ef1b18d9ace298060f4eccc64b0850899ac4ac53294547536533800c8",
"sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63",
- "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df",
- "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc"
+ "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696",
+ "sha256:72a01f726a9c7851ca9bfad6fd09ca4e090a023c00945ea05ba1638c09dc3347"
],
"index": "pypi",
"version": "==5.4.1"
@@ -961,19 +982,19 @@
},
"urllib3": {
"hashes": [
- "sha256:19188f96923873c92ccb987120ec4acaa12f0461fa9ce5d3d0772bc965a39e08",
- "sha256:d8ff90d979214d7b4f8ce956e80f4028fc6860e4431f731ea4a8c08f23f99473"
+ "sha256:2f4da4594db7e1e110a944bb1b551fdf4e6c136ad42e4234131391e21eb5b0df",
+ "sha256:e7b021f7241115872f92f43c6508082facffbd1c048e3c6e2bb9c2a157e28937"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'",
- "version": "==1.26.2"
+ "version": "==1.26.4"
},
"virtualenv": {
"hashes": [
- "sha256:219ee956e38b08e32d5639289aaa5bd190cfbe7dafcb8fa65407fca08e808f9c",
- "sha256:227a8fed626f2f20a6cdb0870054989f82dd27b2560a911935ba905a2a5e0034"
+ "sha256:49ec4eb4c224c6f7dd81bb6d0a28a09ecae5894f4e593c89b0db0885f565a107",
+ "sha256:83f95875d382c7abafe06bd2a4cdd1b363e1bb77e02f155ebe8ac082a916b37c"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
- "version": "==20.4.0"
+ "version": "==20.4.3"
}
}
}
diff --git a/README.md b/README.md
index ac45e6340..9df905dc8 100644
--- a/README.md
+++ b/README.md
@@ -12,11 +12,11 @@ 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.
-[1]: https://github.com/python-discord/bot/workflows/Lint%20&%20Test/badge.svg?branch=master
-[2]: https://github.com/python-discord/bot/actions?query=workflow%3A%22Lint+%26+Test%22+branch%3Amaster
-[3]: https://github.com/python-discord/bot/workflows/Build/badge.svg?branch=master
-[4]: https://github.com/python-discord/bot/actions?query=workflow%3ABuild+branch%3Amaster
-[5]: https://github.com/python-discord/bot/workflows/Deploy/badge.svg?branch=master
-[6]: https://github.com/python-discord/bot/actions?query=workflow%3ADeploy+branch%3Amaster
-[7]: https://raw.githubusercontent.com/python-discord/branding/master/logos/badge/badge_github.svg
+[1]: https://github.com/python-discord/bot/workflows/Lint%20&%20Test/badge.svg?branch=main
+[2]: https://github.com/python-discord/bot/actions?query=workflow%3A%22Lint+%26+Test%22+branch%3Amain
+[3]: https://github.com/python-discord/bot/workflows/Build/badge.svg?branch=main
+[4]: https://github.com/python-discord/bot/actions?query=workflow%3ABuild+branch%3Amain
+[5]: https://github.com/python-discord/bot/workflows/Deploy/badge.svg?branch=main
+[6]: https://github.com/python-discord/bot/actions?query=workflow%3ADeploy+branch%3Amain
+[7]: https://raw.githubusercontent.com/python-discord/branding/main/logos/badge/badge_github.svg
[8]: https://discord.gg/python
diff --git a/bot/__main__.py b/bot/__main__.py
index 257216fa7..9317563c8 100644
--- a/bot/__main__.py
+++ b/bot/__main__.py
@@ -1,10 +1,28 @@
+import logging
+
+import aiohttp
+
import bot
from bot import constants
-from bot.bot import Bot
+from bot.bot import Bot, StartupError
from bot.log import setup_sentry
setup_sentry()
-bot.instance = Bot.create()
-bot.instance.load_extensions()
-bot.instance.run(constants.Bot.token)
+try:
+ bot.instance = Bot.create()
+ bot.instance.load_extensions()
+ bot.instance.run(constants.Bot.token)
+except StartupError as e:
+ message = "Unknown Startup Error Occurred."
+ if isinstance(e.exception, (aiohttp.ClientConnectorError, aiohttp.ServerDisconnectedError)):
+ message = "Could not connect to site API. Is it running?"
+ elif isinstance(e.exception, OSError):
+ message = "Could not connect to Redis. Is it running?"
+
+ # The exception is logged with an empty message so the actual message is visible at the bottom
+ log = logging.getLogger("bot")
+ log.fatal("", exc_info=e.exception)
+ log.fatal(message)
+
+ exit(69)
diff --git a/bot/api.py b/bot/api.py
index d93f9f2ba..6ce9481f4 100644
--- a/bot/api.py
+++ b/bot/api.py
@@ -53,7 +53,7 @@ class APIClient:
@staticmethod
def _url_for(endpoint: str) -> str:
- return f"{URLs.site_schema}{URLs.site_api}/{quote_url(endpoint)}"
+ return f"{URLs.site_api_schema}{URLs.site_api}/{quote_url(endpoint)}"
async def close(self) -> None:
"""Close the aiohttp session."""
diff --git a/bot/bot.py b/bot/bot.py
index d5f108575..3a2af472d 100644
--- a/bot/bot.py
+++ b/bot/bot.py
@@ -19,6 +19,14 @@ log = logging.getLogger('bot')
LOCALHOST = "127.0.0.1"
+class StartupError(Exception):
+ """Exception class for startup errors."""
+
+ def __init__(self, base: Exception):
+ super().__init__()
+ self.exception = base
+
+
class Bot(commands.Bot):
"""A subclass of `discord.ext.commands.Bot` with an aiohttp session and an API client."""
@@ -81,6 +89,22 @@ class Bot(commands.Bot):
for item in full_cache:
self.insert_item_into_filter_list_cache(item)
+ async def ping_services(self) -> None:
+ """A helper to make sure all the services the bot relies on are available on startup."""
+ # Connect Site/API
+ attempts = 0
+ while True:
+ try:
+ log.info(f"Attempting site connection: {attempts + 1}/{constants.URLs.connect_max_retries}")
+ await self.api_client.get("healthcheck")
+ break
+
+ except (aiohttp.ClientConnectorError, aiohttp.ServerDisconnectedError):
+ attempts += 1
+ if attempts == constants.URLs.connect_max_retries:
+ raise
+ await asyncio.sleep(constants.URLs.connect_cooldown)
+
@classmethod
def create(cls) -> "Bot":
"""Create and return an instance of a Bot."""
@@ -223,6 +247,11 @@ class Bot(commands.Bot):
# here. Normally, this shouldn't happen.
await self.redis_session.connect()
+ try:
+ await self.ping_services()
+ except Exception as e:
+ raise StartupError(e)
+
# Build the FilterList cache
await self.cache_filter_list_data()
@@ -318,5 +347,8 @@ def _create_redis_session(loop: asyncio.AbstractEventLoop) -> RedisSession:
use_fakeredis=constants.Redis.use_fakeredis,
global_namespace="bot",
)
- loop.run_until_complete(redis_session.connect())
+ try:
+ loop.run_until_complete(redis_session.connect())
+ except OSError as e:
+ raise StartupError(e)
return redis_session
diff --git a/bot/constants.py b/bot/constants.py
index 2f5cf0e8a..467a4a2c4 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -13,7 +13,7 @@ their default values from `config-default.yml`.
import logging
import os
from collections.abc import Mapping
-from enum import Enum, IntEnum
+from enum import Enum
from pathlib import Path
from typing import Dict, List, Optional
@@ -197,8 +197,8 @@ class Bot(metaclass=YAMLGetter):
section = "bot"
prefix: str
- token: str
sentry_dsn: Optional[str]
+ token: str
class Redis(metaclass=YAMLGetter):
@@ -206,29 +206,30 @@ class Redis(metaclass=YAMLGetter):
subsection = "redis"
host: str
- port: int
password: Optional[str]
+ port: int
use_fakeredis: bool # If this is True, Bot will use fakeredis.aioredis
class Filter(metaclass=YAMLGetter):
section = "filter"
- filter_zalgo: bool
- filter_invites: bool
filter_domains: bool
filter_everyone_ping: bool
+ filter_invites: bool
+ filter_zalgo: bool
watch_regex: bool
watch_rich_embeds: bool
# Notifications are not expected for "watchlist" type filters
- notify_user_zalgo: bool
- notify_user_invites: bool
+
notify_user_domains: bool
notify_user_everyone_ping: bool
+ notify_user_invites: bool
+ notify_user_zalgo: bool
- ping_everyone: bool
offensive_msg_delete_days: int
+ ping_everyone: bool
channel_whitelist: List[int]
role_whitelist: List[int]
@@ -245,13 +246,16 @@ class Colours(metaclass=YAMLGetter):
section = "style"
subsection = "colours"
- soft_red: int
- soft_green: int
- soft_orange: int
+ blue: int
bright_green: int
orange: int
pink: int
purple: int
+ soft_green: int
+ soft_orange: int
+ soft_red: int
+ white: int
+ yellow: int
class DuckPond(metaclass=YAMLGetter):
@@ -265,41 +269,42 @@ class Emojis(metaclass=YAMLGetter):
section = "style"
subsection = "emojis"
- defcon_disabled: str # noqa: E704
- defcon_enabled: str # noqa: E704
- defcon_updated: str # noqa: E704
-
- status_online: str
- status_offline: str
- status_idle: str
- status_dnd: str
-
- badge_staff: str
- badge_partner: str
- badge_hypesquad: str
badge_bug_hunter: str
+ badge_bug_hunter_level_2: str
+ badge_early_supporter: str
+ badge_hypesquad: str
+ badge_hypesquad_balance: str
badge_hypesquad_bravery: str
badge_hypesquad_brilliance: str
- badge_hypesquad_balance: str
- badge_early_supporter: str
- badge_bug_hunter_level_2: str
+ badge_partner: str
+ badge_staff: str
badge_verified_bot_developer: str
+ defcon_shutdown: str # noqa: E704
+ defcon_unshutdown: str # noqa: E704
+ defcon_update: str # noqa: E704
+
+ failmail: str
+
incident_actioned: str
- incident_unactioned: str
incident_investigating: str
+ incident_unactioned: str
+
+ status_dnd: str
+ status_idle: str
+ status_offline: str
+ status_online: str
- failmail: str
trashcan: str
bullet: str
+ check_mark: str
+ cross_mark: str
new: str
pencil: str
- cross_mark: str
- check_mark: str
- upvotes: str
comments: str
+ upvotes: str
user: str
ok_hand: str
@@ -314,12 +319,14 @@ class Icons(metaclass=YAMLGetter):
crown_red: str
defcon_denied: str # noqa: E704
- defcon_disabled: str # noqa: E704
- defcon_enabled: str # noqa: E704
- defcon_updated: str # noqa: E704
+ defcon_shutdown: str # noqa: E704
+ defcon_unshutdown: str # noqa: E704
+ defcon_update: str # noqa: E704
filtering: str
+ green_checkmark: str
+ green_questionmark: str
guild_update: str
hash_blurple: str
@@ -330,38 +337,34 @@ class Icons(metaclass=YAMLGetter):
message_delete: str
message_edit: str
+ pencil: str
+
+ questionmark: str
+
+ remind_blurple: str
+ remind_green: str
+ remind_red: str
+
sign_in: str
sign_out: str
+ superstarify: str
+ unsuperstarify: str
+
token_removed: str
user_ban: str
- user_unban: str
- user_update: str
-
user_mute: str
+ user_unban: str
user_unmute: str
+ user_update: str
user_verified: str
-
user_warn: str
- pencil: str
-
- remind_blurple: str
- remind_green: str
- remind_red: str
-
- questionmark: str
-
- superstarify: str
- unsuperstarify: str
-
voice_state_blue: str
voice_state_green: str
voice_state_red: str
- green_checkmark: str
-
class CleanMessages(metaclass=YAMLGetter):
section = "bot"
@@ -383,8 +386,8 @@ class Categories(metaclass=YAMLGetter):
subsection = "categories"
help_available: int
- help_in_use: int
help_dormant: int
+ help_in_use: int
modmail: int
voice: int
@@ -393,56 +396,68 @@ class Channels(metaclass=YAMLGetter):
section = "guild"
subsection = "channels"
- admin_announcements: int
- admin_spam: int
- admins: int
- admins_voice: int
announcements: int
- attachment_log: int
- big_brother_logs: int
- bot_commands: int
change_log: int
- code_help_chat_1: int
- code_help_chat_2: int
- code_help_voice_1: int
- code_help_voice_2: int
- cooldown: int
- defcon: int
- discord_py: int
+ mailing_lists: int
+ python_events: int
+ python_news: int
+ reddit: int
+ user_event_announcements: int
+
dev_contrib: int
dev_core: int
dev_log: int
+
+ meta: int
+ python_general: int
+
+ cooldown: int
+
+ attachment_log: int
dm_log: int
+ message_log: int
+ mod_log: int
+ user_log: int
+ voice_log: int
+
+ off_topic_0: int
+ off_topic_1: int
+ off_topic_2: int
+
+ bot_commands: int
+ discord_py: int
esoteric: int
- general_voice: int
+ voice_gate: int
+
+ admins: int
+ admin_spam: int
+ defcon: int
helpers: int
incidents: int
incidents_archive: int
- mailing_lists: int
- message_log: int
- meta: int
+ mods: int
mod_alerts: int
- mod_announcements: int
- mod_log: int
mod_spam: int
- mods: int
- off_topic_0: int
- off_topic_1: int
- off_topic_2: int
+ nomination_voting: int
organisation: int
- python_general: int
- python_events: int
- python_news: int
- reddit: int
+
+ admin_announcements: int
+ mod_announcements: int
staff_announcements: int
+
+ admins_voice: int
+ code_help_voice_1: int
+ code_help_voice_2: int
+ general_voice: int
staff_voice: int
+
+ code_help_chat_1: int
+ code_help_chat_2: int
staff_voice_chat: int
- talent_pool: int
- user_event_announcements: int
- user_log: int
voice_chat: int
- voice_gate: int
- voice_log: int
+
+ big_brother_logs: int
+ talent_pool: int
class Webhooks(metaclass=YAMLGetter):
@@ -462,41 +477,45 @@ class Roles(metaclass=YAMLGetter):
section = "guild"
subsection = "roles"
- admins: int
announcements: int
contributors: int
- core_developers: int
help_cooldown: int
- helpers: int
- jammers: int
- moderators: int
muted: int
- owners: int
partners: int
python_community: int
sprinters: int
- team_leaders: int
voice_verified: int
+ admins: int
+ core_developers: int
+ devops: int
+ helpers: int
+ moderators: int
+ owners: int
+
+ jammers: int
+ team_leaders: int
+
class Guild(metaclass=YAMLGetter):
section = "guild"
id: int
invite: str # Discord invite, gets embedded in chat
- moderation_channels: List[int]
+
moderation_categories: List[int]
- moderation_roles: List[int]
+ moderation_channels: List[int]
modlog_blacklist: List[int]
reminder_whitelist: List[int]
+ moderation_roles: List[int]
staff_roles: List[int]
class Keys(metaclass=YAMLGetter):
section = "keys"
- site_api: Optional[str]
github: Optional[str]
+ site_api: Optional[str]
class URLs(metaclass=YAMLGetter):
@@ -514,9 +533,12 @@ class URLs(metaclass=YAMLGetter):
github_bot_repo: str
# Base site vars
+ connect_max_retries: int
+ connect_cooldown: int
site: str
site_api: str
site_schema: str
+ site_api_schema: str
# Site endpoints
site_logs_view: str
@@ -526,9 +548,9 @@ class URLs(metaclass=YAMLGetter):
class Reddit(metaclass=YAMLGetter):
section = "reddit"
- subreddits: list
client_id: Optional[str]
secret: Optional[str]
+ subreddits: list
class AntiSpam(metaclass=YAMLGetter):
@@ -544,8 +566,8 @@ class AntiSpam(metaclass=YAMLGetter):
class BigBrother(metaclass=YAMLGetter):
section = 'big_brother'
- log_delay: int
header_message_limit: int
+ log_delay: int
class CodeBlock(metaclass=YAMLGetter):
@@ -561,8 +583,8 @@ class Free(metaclass=YAMLGetter):
section = 'free'
activity_timeout: int
- cooldown_rate: int
cooldown_per: float
+ cooldown_rate: int
class HelpChannels(metaclass=YAMLGetter):
@@ -585,25 +607,25 @@ class HelpChannels(metaclass=YAMLGetter):
class RedirectOutput(metaclass=YAMLGetter):
section = 'redirect_output'
- delete_invocation: bool
delete_delay: int
+ delete_invocation: bool
class PythonNews(metaclass=YAMLGetter):
section = 'python_news'
- mail_lists: List[str]
channel: int
webhook: int
+ mail_lists: List[str]
class VoiceGate(metaclass=YAMLGetter):
section = "voice_gate"
- minimum_days_member: int
- minimum_messages: int
bot_message_delete_delay: int
minimum_activity_blocks: int
+ minimum_days_member: int
+ minimum_messages: int
voice_ping_delete_delay: int
diff --git a/bot/converters.py b/bot/converters.py
index 4fbf3c124..6ea2d887b 100644
--- a/bot/converters.py
+++ b/bot/converters.py
@@ -17,6 +17,7 @@ from bot.api import ResponseCodeError
from bot.constants import URLs
from bot.exts.info.doc import _inventory_parser
from bot.utils.regex import INVITE_RE
+from bot.utils.time import parse_duration_string
log = logging.getLogger(__name__)
@@ -319,16 +320,6 @@ class TagContentConverter(Converter):
class DurationDelta(Converter):
"""Convert duration strings into dateutil.relativedelta.relativedelta objects."""
- 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) -> relativedelta:
"""
Converts a `duration` string to a relativedelta object.
@@ -344,13 +335,9 @@ class DurationDelta(Converter):
The units need to be provided in descending order of magnitude.
"""
- match = self.duration_parser.fullmatch(duration)
- if not match:
+ if not (delta := parse_duration_string(duration)):
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)
-
return delta
@@ -368,34 +355,45 @@ class Duration(DurationDelta):
try:
return now + delta
- except ValueError:
+ except (ValueError, OverflowError):
raise BadArgument(f"`{duration}` results in a datetime outside the supported range.")
class OffTopicName(Converter):
"""A converter that ensures an added off-topic name is valid."""
+ ALLOWED_CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ!?'`-"
+
+ @classmethod
+ def translate_name(cls, name: str, *, from_unicode: bool = True) -> str:
+ """
+ Translates `name` into a format that is allowed in discord channel names.
+
+ If `from_unicode` is True, the name is translated from a discord-safe format, back to normalized text.
+ """
+ if from_unicode:
+ table = str.maketrans(cls.ALLOWED_CHARACTERS, '𝖠𝖡𝖢𝖣𝖤𝖥𝖦𝖧𝖨𝖩𝖪𝖫𝖬𝖭𝖮𝖯𝖰𝖱𝖲𝖳𝖴𝖵𝖶𝖷𝖸𝖹ǃ?’’-')
+ else:
+ table = str.maketrans('𝖠𝖡𝖢𝖣𝖤𝖥𝖦𝖧𝖨𝖩𝖪𝖫𝖬𝖭𝖮𝖯𝖰𝖱𝖲𝖳𝖴𝖵𝖶𝖷𝖸𝖹ǃ?’’-', cls.ALLOWED_CHARACTERS)
+
+ return name.translate(table)
+
async def convert(self, ctx: Context, argument: str) -> str:
"""Attempt to replace any invalid characters with their approximate Unicode equivalent."""
- allowed_characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ!?'`-"
-
# Chain multiple words to a single one
argument = "-".join(argument.split())
if not (2 <= len(argument) <= 96):
raise BadArgument("Channel name must be between 2 and 96 chars long")
- elif not all(c.isalnum() or c in allowed_characters for c in argument):
+ elif not all(c.isalnum() or c in self.ALLOWED_CHARACTERS for c in argument):
raise BadArgument(
"Channel name must only consist of "
"alphanumeric characters, minus signs or apostrophes."
)
# Replace invalid characters with unicode alternatives.
- table = str.maketrans(
- allowed_characters, '𝖠𝖡𝖢𝖣𝖤𝖥𝖦𝖧𝖨𝖩𝖪𝖫𝖬𝖭𝖮𝖯𝖰𝖱𝖲𝖳𝖴𝖵𝖶𝖷𝖸𝖹ǃ?’’-'
- )
- return argument.translate(table)
+ return self.translate_name(argument)
class ISODateTime(Converter):
diff --git a/bot/errors.py b/bot/errors.py
index 65d715203..ab0adcd42 100644
--- a/bot/errors.py
+++ b/bot/errors.py
@@ -1,4 +1,6 @@
-from typing import Hashable
+from typing import Hashable, Union
+
+from discord import Member, User
class LockedResourceError(RuntimeError):
@@ -18,3 +20,18 @@ class LockedResourceError(RuntimeError):
f"Cannot operate on {self.type.lower()} `{self.id}`; "
"it is currently locked and in use by another operation."
)
+
+
+class InvalidInfractedUser(Exception):
+ """
+ Exception raised upon attempt of infracting an invalid user.
+
+ Attributes:
+ `user` -- User or Member which is invalid
+ """
+
+ def __init__(self, user: Union[Member, User], reason: str = "User infracted is a bot."):
+ self.user = user
+ self.reason = reason
+
+ super().__init__(reason)
diff --git a/bot/exts/backend/branding/_constants.py b/bot/exts/backend/branding/_constants.py
index dbc7615f2..ca8e8c5f5 100644
--- a/bot/exts/backend/branding/_constants.py
+++ b/bot/exts/backend/branding/_constants.py
@@ -42,7 +42,7 @@ SERVER_ICONS = "server_icons"
BRANDING_URL = "https://api.github.com/repos/python-discord/branding/contents"
-PARAMS = {"ref": "master"} # Target branch
+PARAMS = {"ref": "main"} # Target branch
HEADERS = {"Accept": "application/vnd.github.v3+json"} # Ensure we use API v3
# A GitHub token is not necessary for the cog to operate,
diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py
index b8bb3757f..9cb54cdab 100644
--- a/bot/exts/backend/error_handler.py
+++ b/bot/exts/backend/error_handler.py
@@ -12,7 +12,7 @@ from bot.api import ResponseCodeError
from bot.bot import Bot
from bot.constants import Colours, ERROR_REPLIES, Icons, MODERATION_ROLES
from bot.converters import TagNameConverter
-from bot.errors import LockedResourceError
+from bot.errors import InvalidInfractedUser, LockedResourceError
from bot.exts.backend.branding._errors import BrandingError
from bot.utils.checks import InWhitelistCheckFailure
@@ -82,11 +82,19 @@ class ErrorHandler(Cog):
elif isinstance(e.original, BrandingError):
await ctx.send(embed=self._get_error_embed(random.choice(ERROR_REPLIES), str(e.original)))
return
+ elif isinstance(e.original, InvalidInfractedUser):
+ await ctx.send(f"Cannot infract that user. {e.original.reason}")
+ else:
+ await self.handle_unexpected_error(ctx, e.original)
+ return # Exit early to avoid logging.
+ elif isinstance(e, errors.ConversionError):
+ if isinstance(e.original, ResponseCodeError):
+ await self.handle_api_error(ctx, e.original)
else:
await self.handle_unexpected_error(ctx, e.original)
return # Exit early to avoid logging.
elif not isinstance(e, errors.DisabledCommand):
- # ConversionError, MaxConcurrencyReached, ExtensionError
+ # MaxConcurrencyReached, ExtensionError
await self.handle_unexpected_error(ctx, e)
return # Exit early to avoid logging.
@@ -231,10 +239,12 @@ class ErrorHandler(Cog):
elif isinstance(e, errors.BadUnionArgument):
embed = self._get_error_embed("Bad argument", f"{e}\n{e.errors[-1]}")
await ctx.send(embed=embed)
+ await prepared_help_command
self.bot.stats.incr("errors.bad_union_argument")
elif isinstance(e, errors.ArgumentParsingError):
embed = self._get_error_embed("Argument parsing error", str(e))
await ctx.send(embed=embed)
+ prepared_help_command.close()
self.bot.stats.incr("errors.argument_parsing_error")
else:
embed = self._get_error_embed(
diff --git a/bot/exts/backend/logging.py b/bot/exts/backend/logging.py
index 94fa2b139..823f14ea4 100644
--- a/bot/exts/backend/logging.py
+++ b/bot/exts/backend/logging.py
@@ -29,7 +29,7 @@ class Logging(Cog):
url="https://github.com/python-discord/bot",
icon_url=(
"https://raw.githubusercontent.com/"
- "python-discord/branding/master/logos/logo_circle/logo_circle_large.png"
+ "python-discord/branding/main/logos/logo_circle/logo_circle_large.png"
)
)
diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py
index 3527bf8bb..c90b18dcb 100644
--- a/bot/exts/filters/filtering.py
+++ b/bot/exts/filters/filtering.py
@@ -2,7 +2,7 @@ import asyncio
import logging
import re
from datetime import datetime, timedelta
-from typing import Any, Dict, List, Mapping, NamedTuple, Optional, Union
+from typing import Any, Dict, List, Mapping, NamedTuple, Optional, Tuple, Union
import dateutil
import discord.errors
@@ -137,6 +137,10 @@ class Filtering(Cog):
"""Fetch items from the filter_list_cache."""
return self.bot.filter_list_cache[f"{list_type.upper()}.{allowed}"].keys()
+ def _get_filterlist_value(self, list_type: str, value: Any, *, allowed: bool) -> dict:
+ """Fetch one specific value from filter_list_cache."""
+ return self.bot.filter_list_cache[f"{list_type.upper()}.{allowed}"][value]
+
@staticmethod
def _expand_spoilers(text: str) -> str:
"""Return a string containing all interpretations of a spoilered message."""
@@ -236,7 +240,13 @@ class Filtering(Cog):
# We also do not need to worry about filters that take the full message,
# since all we have is an arbitrary string.
if _filter["enabled"] and _filter["content_only"]:
- match = await _filter["function"](result)
+ filter_result = await _filter["function"](result)
+ reason = None
+
+ if isinstance(filter_result, tuple):
+ match, reason = filter_result
+ else:
+ match = filter_result
if match:
# If this is a filter (not a watchlist), we set the variable so we know
@@ -245,7 +255,7 @@ class Filtering(Cog):
filter_triggered = True
stats = self._add_stats(filter_name, match, result)
- await self._send_log(filter_name, _filter, msg, stats, is_eval=True)
+ await self._send_log(filter_name, _filter, msg, stats, reason, is_eval=True)
break # We don't want multiple filters to trigger
@@ -267,9 +277,17 @@ class Filtering(Cog):
# Does the filter only need the message content or the full message?
if _filter["content_only"]:
- match = await _filter["function"](msg.content)
+ payload = msg.content
+ else:
+ payload = msg
+
+ result = await _filter["function"](payload)
+ reason = None
+
+ if isinstance(result, tuple):
+ match, reason = result
else:
- match = await _filter["function"](msg)
+ match = result
if match:
is_private = msg.channel.type is discord.ChannelType.private
@@ -316,7 +334,7 @@ class Filtering(Cog):
log.trace(f"Offensive message {msg.id} will be deleted on {delete_date}")
stats = self._add_stats(filter_name, match, msg.content)
- await self._send_log(filter_name, _filter, msg, stats)
+ await self._send_log(filter_name, _filter, msg, stats, reason)
break # We don't want multiple filters to trigger
@@ -326,6 +344,7 @@ class Filtering(Cog):
_filter: Dict[str, Any],
msg: discord.Message,
stats: Stats,
+ reason: Optional[str] = None,
*,
is_eval: bool = False,
) -> None:
@@ -339,6 +358,7 @@ class Filtering(Cog):
ping_everyone = Filter.ping_everyone and _filter.get("ping_everyone", True)
eval_msg = "using !eval " if is_eval else ""
+ footer = f"Reason: {reason}" if reason else None
message = (
f"The {filter_name} {_filter['type']} was triggered by {format_user(msg.author)} "
f"{channel_str} {eval_msg}with [the following message]({msg.jump_url}):\n\n"
@@ -357,6 +377,7 @@ class Filtering(Cog):
channel_id=Channels.mod_alerts,
ping_everyone=ping_everyone,
additional_embeds=stats.additional_embeds,
+ footer=footer,
)
def _add_stats(self, name: str, match: FilterMatch, content: str) -> Stats:
@@ -381,13 +402,14 @@ class Filtering(Cog):
if name == "filter_invites" and match is not True:
additional_embeds = []
for _, data in match.items():
+ reason = f"Reason: {data['reason']} | " if data.get('reason') else ""
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 ID: {data['id']}")
+ embed.set_footer(text=f"{reason}Guild ID: {data['id']}")
additional_embeds.append(embed)
elif name == "watch_rich_embeds":
@@ -411,39 +433,46 @@ class Filtering(Cog):
and not msg.author.bot # Author not a bot
)
- async def _has_watch_regex_match(self, text: str) -> Union[bool, re.Match]:
+ async def _has_watch_regex_match(self, text: str) -> Tuple[Union[bool, re.Match], Optional[str]]:
"""
Return True if `text` matches any regex from `word_watchlist` or `token_watchlist` configs.
`word_watchlist`'s patterns are placed between word boundaries while `token_watchlist` is
matched as-is. Spoilers are expanded, if any, and URLs are ignored.
+ Second return value is a reason written to database about blacklist entry (can be None).
"""
if SPOILER_RE.search(text):
text = self._expand_spoilers(text)
# Make sure it's not a URL
if URL_RE.search(text):
- return False
+ return False, None
watchlist_patterns = self._get_filterlist_items('filter_token', allowed=False)
for pattern in watchlist_patterns:
match = re.search(pattern, text, flags=re.IGNORECASE)
if match:
- return match
+ return match, self._get_filterlist_value('filter_token', pattern, allowed=False)['comment']
+
+ return False, None
- async def _has_urls(self, text: str) -> bool:
- """Returns True if the text contains one of the blacklisted URLs from the config file."""
+ async def _has_urls(self, text: str) -> Tuple[bool, Optional[str]]:
+ """
+ Returns True if the text contains one of the blacklisted URLs from the config file.
+
+ Second return value is a reason of URL blacklisting (can be None).
+ """
if not URL_RE.search(text):
- return False
+ return False, None
text = text.lower()
domain_blacklist = self._get_filterlist_items("domain_name", allowed=False)
for url in domain_blacklist:
if url.lower() in text:
- return True
+ return True, self._get_filterlist_value("domain_name", url, allowed=False)["comment"]
- return False
+ return False, None
@staticmethod
async def _has_zalgo(text: str) -> bool:
@@ -500,6 +529,10 @@ class Filtering(Cog):
)
if invite_not_allowed:
+ reason = None
+ if guild_id in guild_invite_blacklist:
+ reason = self._get_filterlist_value("guild_invite", guild_id, allowed=False)["comment"]
+
guild_icon_hash = guild["icon"]
guild_icon = (
"https://cdn.discordapp.com/icons/"
@@ -511,7 +544,8 @@ class Filtering(Cog):
"id": guild['id'],
"icon": guild_icon,
"members": response["approximate_member_count"],
- "active": response["approximate_presence_count"]
+ "active": response["approximate_presence_count"],
+ "reason": reason
}
return invite_data if invite_data else False
diff --git a/bot/exts/filters/token_remover.py b/bot/exts/filters/token_remover.py
index bd6a1f97a..93f1f3c33 100644
--- a/bot/exts/filters/token_remover.py
+++ b/bot/exts/filters/token_remover.py
@@ -135,7 +135,7 @@ class TokenRemover(Cog):
user_id=user_id,
user_name=str(user),
kind="BOT" if user.bot else "USER",
- ), not user.bot
+ ), True
else:
return UNKNOWN_USER_LOG_MESSAGE.format(user_id=user_id), False
@@ -147,7 +147,7 @@ class TokenRemover(Cog):
channel=msg.channel.mention,
user_id=token.user_id,
timestamp=token.timestamp,
- hmac='x' * len(token.hmac),
+ hmac='x' * (len(token.hmac) - 3) + token.hmac[-3:],
)
@classmethod
diff --git a/bot/exts/filters/webhook_remover.py b/bot/exts/filters/webhook_remover.py
index 08fe94055..f11fc8912 100644
--- a/bot/exts/filters/webhook_remover.py
+++ b/bot/exts/filters/webhook_remover.py
@@ -14,7 +14,7 @@ WEBHOOK_URL_RE = re.compile(r"((?:https?://)?discord(?:app)?\.com/api/webhooks/\
ALERT_MESSAGE_TEMPLATE = (
"{user}, looks like you posted a Discord webhook URL. Therefore, your "
"message has been removed. Your webhook may have been **compromised** so "
- "please re-create the webhook **immediately**. If you believe this was "
+ "please re-create the webhook **immediately**. If you believe this was a "
"mistake, please let us know."
)
diff --git a/bot/exts/fun/duck_pond.py b/bot/exts/fun/duck_pond.py
index 48aa2749c..ee440dec2 100644
--- a/bot/exts/fun/duck_pond.py
+++ b/bot/exts/fun/duck_pond.py
@@ -3,7 +3,7 @@ import logging
from typing import Union
import discord
-from discord import Color, Embed, Member, Message, RawReactionActionEvent, User, errors
+from discord import Color, Embed, Member, Message, RawReactionActionEvent, TextChannel, User, errors
from discord.ext.commands import Cog, Context, command
from bot import constants
@@ -44,6 +44,17 @@ class DuckPond(Cog):
return True
return False
+ @staticmethod
+ def is_helper_viewable(channel: TextChannel) -> bool:
+ """Check if helpers can view a specific channel."""
+ guild = channel.guild
+ helper_role = guild.get_role(constants.Roles.helpers)
+ # check channel overwrites for both the Helper role and @everyone and
+ # return True for channels that they have permissions to view.
+ helper_overwrites = channel.overwrites_for(helper_role)
+ default_overwrites = channel.overwrites_for(guild.default_role)
+ return default_overwrites.view_channel is None or helper_overwrites.view_channel is True
+
async def has_green_checkmark(self, message: Message) -> bool:
"""Check if the message has a green checkmark reaction."""
for reaction in message.reactions:
@@ -107,7 +118,7 @@ class DuckPond(Cog):
except discord.HTTPException:
log.exception("Failed to send an attachment to the webhook")
- async def locked_relay(self, message: discord.Message) -> bool:
+ async def locked_relay(self, message: Message) -> bool:
"""Relay a message after obtaining the relay lock."""
if self.relay_lock is None:
# Lazily load the lock to ensure it's created within the
@@ -162,6 +173,10 @@ class DuckPond(Cog):
if channel is None:
return
+ # Was the message sent in a channel Helpers can see?
+ if not self.is_helper_viewable(channel):
+ return
+
message = await channel.fetch_message(payload.message_id)
member = discord.utils.get(message.guild.members, id=payload.user_id)
@@ -201,7 +216,7 @@ class DuckPond(Cog):
@command(name="duckify", aliases=("duckpond", "pondify"))
@has_any_role(constants.Roles.admins)
- async def duckify(self, ctx: Context, message: discord.Message) -> None:
+ async def duckify(self, ctx: Context, message: Message) -> None:
"""Relay a message to the duckpond, no ducks required!"""
if await self.locked_relay(message):
await ctx.message.add_reaction("🦆")
diff --git a/bot/exts/fun/off_topic_names.py b/bot/exts/fun/off_topic_names.py
index 7fc93b88c..845b8175c 100644
--- a/bot/exts/fun/off_topic_names.py
+++ b/bot/exts/fun/off_topic_names.py
@@ -139,10 +139,20 @@ class OffTopicNames(Cog):
@has_any_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))
+ query = OffTopicName.translate_name(query, from_unicode=False).lower()
+
+ # Map normalized names to returned names for search purposes
+ result = {
+ OffTopicName.translate_name(name, from_unicode=False).lower(): name
+ for name in await self.bot.api_client.get('bot/off-topic-channel-names')
+ }
+
+ # Search normalized keys
+ in_matches = {name for name in result.keys() if query in name}
+ close_matches = difflib.get_close_matches(query, result.keys(), n=10, cutoff=0.70)
+
+ # Send Results
+ lines = sorted(f"• {result[name]}" for name in in_matches.union(close_matches))
embed = Embed(
title="Query results",
colour=Colour.blue()
diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py
index 0995c8a79..1c730dce9 100644
--- a/bot/exts/help_channels/_cog.py
+++ b/bot/exts/help_channels/_cog.py
@@ -54,7 +54,7 @@ class HelpChannels(commands.Cog):
* Contains channels which aren't in use
* Channels are used to refill the Available category
- Help channels are named after the chemical elements in `bot/resources/elements.json`.
+ Help channels are named after the foods in `bot/resources/foods.json`.
"""
def __init__(self, bot: Bot):
@@ -102,6 +102,10 @@ class HelpChannels(commands.Cog):
await _cooldown.revoke_send_permissions(message.author, self.scheduler)
await _message.pin(message)
+ try:
+ await _message.dm_on_open(message)
+ except Exception as e:
+ log.warning("Error occurred while sending DM:", exc_info=e)
# Add user with channel for dormant check.
await _caches.claimants.set(message.channel.id, message.author.id)
diff --git a/bot/exts/help_channels/_message.py b/bot/exts/help_channels/_message.py
index 2bbd4bdd6..36388f9bd 100644
--- a/bot/exts/help_channels/_message.py
+++ b/bot/exts/help_channels/_message.py
@@ -1,4 +1,5 @@
import logging
+import textwrap
import typing as t
from datetime import datetime
@@ -92,6 +93,38 @@ async def is_empty(channel: discord.TextChannel) -> bool:
return False
+async def dm_on_open(message: discord.Message) -> None:
+ """
+ DM claimant with a link to the claimed channel's first message, with a 100 letter preview of the message.
+
+ Does nothing if the user has DMs disabled.
+ """
+ embed = discord.Embed(
+ title="Help channel opened",
+ description=f"You claimed {message.channel.mention}.",
+ colour=bot.constants.Colours.bright_green,
+ timestamp=message.created_at,
+ )
+
+ embed.set_thumbnail(url=constants.Icons.green_questionmark)
+ formatted_message = textwrap.shorten(message.content, width=100, placeholder="...")
+ if formatted_message:
+ embed.add_field(name="Your message", value=formatted_message, inline=False)
+ embed.add_field(
+ name="Conversation",
+ value=f"[Jump to message!]({message.jump_url})",
+ inline=False,
+ )
+
+ try:
+ await message.author.send(embed=embed)
+ log.trace(f"Sent DM to {message.author.id} after claiming help channel.")
+ except discord.errors.Forbidden:
+ log.trace(
+ f"Ignoring to send DM to {message.author.id} after claiming help channel: DMs disabled."
+ )
+
+
async def notify(channel: discord.TextChannel, last_notification: t.Optional[datetime]) -> t.Optional[datetime]:
"""
Send a message in `channel` notifying about a lack of available help channels.
diff --git a/bot/exts/help_channels/_name.py b/bot/exts/help_channels/_name.py
index 728234b1e..061f855ae 100644
--- a/bot/exts/help_channels/_name.py
+++ b/bot/exts/help_channels/_name.py
@@ -14,11 +14,11 @@ log = logging.getLogger(__name__)
def create_name_queue(*categories: discord.CategoryChannel) -> deque:
"""
- Return a queue of element names to use for creating new channels.
+ Return a queue of food names to use for creating new channels.
Skip names that are already in use by channels in `categories`.
"""
- log.trace("Creating the chemical element name queue.")
+ log.trace("Creating the food name queue.")
used_names = _get_used_names(*categories)
@@ -31,7 +31,7 @@ def create_name_queue(*categories: discord.CategoryChannel) -> deque:
def _get_names() -> t.List[str]:
"""
- Return a truncated list of prefixed element names.
+ Return a truncated list of prefixed food names.
The amount of names is configured with `HelpChannels.max_total_channels`.
The prefix is configured with `HelpChannels.name_prefix`.
@@ -39,10 +39,10 @@ def _get_names() -> t.List[str]:
count = constants.HelpChannels.max_total_channels
prefix = constants.HelpChannels.name_prefix
- log.trace(f"Getting the first {count} element names from JSON.")
+ log.trace(f"Getting the first {count} food names from JSON.")
- with Path("bot/resources/elements.json").open(encoding="utf-8") as elements_file:
- all_names = json.load(elements_file)
+ with Path("bot/resources/foods.json").open(encoding="utf-8") as foods_file:
+ all_names = json.load(foods_file)
if prefix:
return [prefix + name for name in all_names[:count]]
diff --git a/bot/exts/info/codeblock/_parsing.py b/bot/exts/info/codeblock/_parsing.py
index e35fbca22..73fd11b94 100644
--- a/bot/exts/info/codeblock/_parsing.py
+++ b/bot/exts/info/codeblock/_parsing.py
@@ -103,6 +103,9 @@ def _is_python_code(content: str) -> bool:
"""Return True if `content` is valid Python consisting of more than just expressions."""
log.trace("Checking if content is Python code.")
try:
+ # Remove null bytes because they cause ast.parse to raise a ValueError.
+ content = content.replace("\x00", "")
+
# Attempt to parse the message into an AST node.
# Invalid Python code will raise a SyntaxError.
tree = ast.parse(content)
diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py
index 9fb875925..c54ca96bf 100644
--- a/bot/exts/info/information.py
+++ b/bot/exts/info/information.py
@@ -2,13 +2,11 @@ import colorsys
import logging
import pprint
import textwrap
-from collections import Counter, defaultdict
-from string import Template
-from typing import Any, Mapping, Optional, Tuple, Union
+from collections import defaultdict
+from typing import Any, DefaultDict, Dict, Mapping, Optional, Tuple, Union
import fuzzywuzzy
-from discord import ChannelType, Colour, Embed, Guild, Message, Role, Status
-from discord.abc import GuildChannel
+from discord import Colour, Embed, Guild, Message, Role
from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group, has_any_role
from bot import constants
@@ -17,18 +15,12 @@ from bot.bot import Bot
from bot.converters import FetchedMember
from bot.decorators import in_whitelist
from bot.pagination import LinePaginator
-from bot.utils.channel import is_mod_channel
+from bot.utils.channel import is_mod_channel, is_staff_channel
from bot.utils.checks import cooldown_with_role_bypass, has_no_roles_check, in_whitelist_check
-from bot.utils.time import time_since
+from bot.utils.time import humanize_delta, time_since
log = logging.getLogger(__name__)
-STATUS_EMOTES = {
- Status.offline: constants.Emojis.status_offline,
- Status.dnd: constants.Emojis.status_dnd,
- Status.idle: constants.Emojis.status_idle
-}
-
class Information(Cog):
"""A cog with commands for generating embeds with server info, such as server stats and user info."""
@@ -37,47 +29,55 @@ class Information(Cog):
self.bot = bot
@staticmethod
- def role_can_read(channel: GuildChannel, role: Role) -> bool:
- """Return True if `role` can read messages in `channel`."""
- overwrites = channel.overwrites_for(role)
- return overwrites.read_messages is True
+ def get_channel_type_counts(guild: Guild) -> DefaultDict[str, int]:
+ """Return the total amounts of the various types of channels in `guild`."""
+ channel_counter = defaultdict(int)
- def get_staff_channel_count(self, guild: Guild) -> int:
- """
- Get the number of channels that are staff-only.
+ for channel in guild.channels:
+ if is_staff_channel(channel):
+ channel_counter["staff"] += 1
+ else:
+ channel_counter[str(channel.type)] += 1
- We need to know two things about a channel:
- - Does the @everyone role have explicit read deny permissions?
- - Do staff roles have explicit read allow permissions?
+ return channel_counter
- If the answer to both of these questions is yes, it's a staff channel.
- """
- channel_ids = set()
- for channel in guild.channels:
- if channel.type is ChannelType.category:
- continue
+ @staticmethod
+ def get_member_counts(guild: Guild) -> Dict[str, int]:
+ """Return the total number of members for certain roles in `guild`."""
+ roles = (
+ guild.get_role(role_id) for role_id in (
+ constants.Roles.helpers, constants.Roles.moderators, constants.Roles.admins,
+ constants.Roles.owners, constants.Roles.contributors,
+ )
+ )
+ return {role.name.title(): len(role.members) for role in roles}
- everyone_can_read = self.role_can_read(channel, guild.default_role)
+ def get_extended_server_info(self, ctx: Context) -> str:
+ """Return additional server info only visible in moderation channels."""
+ talentpool_info = ""
+ if cog := self.bot.get_cog("Talentpool"):
+ talentpool_info = f"Nominated: {len(cog.watched_users)}\n"
- for role in constants.STAFF_ROLES:
- role_can_read = self.role_can_read(channel, guild.get_role(role))
- if role_can_read and not everyone_can_read:
- channel_ids.add(channel.id)
- break
+ bb_info = ""
+ if cog := self.bot.get_cog("Big Brother"):
+ bb_info = f"BB-watched: {len(cog.watched_users)}\n"
- return len(channel_ids)
+ defcon_info = ""
+ if cog := self.bot.get_cog("Defcon"):
+ threshold = humanize_delta(cog.threshold) if cog.threshold else "-"
+ defcon_info = f"Defcon threshold: {threshold}\n"
- @staticmethod
- def get_channel_type_counts(guild: Guild) -> str:
- """Return the total amounts of the various types of channels in `guild`."""
- channel_counter = Counter(c.type for c in guild.channels)
- channel_type_list = []
- for channel, count in channel_counter.items():
- channel_type = str(channel).title()
- channel_type_list.append(f"{channel_type} channels: {count}")
+ verification = f"Verification level: {ctx.guild.verification_level.name}\n"
- channel_type_list = sorted(channel_type_list)
- return "\n".join(channel_type_list)
+ python_general = self.bot.get_channel(constants.Channels.python_general)
+
+ return textwrap.dedent(f"""
+ {talentpool_info}\
+ {bb_info}\
+ {defcon_info}\
+ {verification}\
+ {python_general.mention} cooldown: {python_general.slowmode_delay}s
+ """)
@has_any_role(*constants.STAFF_ROLES)
@command(name="roles")
@@ -152,54 +152,59 @@ class Information(Cog):
@command(name="server", aliases=["server_info", "guild", "guild_info"])
async def server_info(self, ctx: Context) -> None:
"""Returns an embed full of server information."""
+ embed = Embed(colour=Colour.blurple(), title="Server Information")
+
created = time_since(ctx.guild.created_at, precision="days")
- features = ", ".join(ctx.guild.features)
region = ctx.guild.region
+ num_roles = len(ctx.guild.roles) - 1 # Exclude @everyone
- roles = len(ctx.guild.roles)
- member_count = ctx.guild.member_count
- channel_counts = self.get_channel_type_counts(ctx.guild)
+ # Server Features are only useful in certain channels
+ if ctx.channel.id in (
+ *constants.MODERATION_CHANNELS, constants.Channels.dev_core, constants.Channels.dev_contrib
+ ):
+ features = f"\nFeatures: {', '.join(ctx.guild.features)}"
+ else:
+ features = ""
- # How many of each user status?
+ # Member status
py_invite = await self.bot.fetch_invite(constants.Guild.invite)
online_presences = py_invite.approximate_presence_count
offline_presences = py_invite.approximate_member_count - online_presences
- embed = Embed(colour=Colour.blurple())
-
- # How many staff members and staff channels do we have?
- staff_member_count = len(ctx.guild.get_role(constants.Roles.helpers).members)
- staff_channel_count = self.get_staff_channel_count(ctx.guild)
-
- # Because channel_counts lacks leading whitespace, it breaks the dedent if it's inserted directly by the
- # f-string. While this is correctly formatted by Discord, it makes unit testing difficult. To keep the
- # formatting without joining a tuple of strings we can use a Template string to insert the already-formatted
- # channel_counts after the dedent is made.
- embed.description = Template(
- textwrap.dedent(f"""
- **Server information**
- Created: {created}
- Voice region: {region}
- Features: {features}
-
- **Channel counts**
- $channel_counts
- Staff channels: {staff_channel_count}
-
- **Member counts**
- Members: {member_count:,}
- Staff members: {staff_member_count}
- Roles: {roles}
-
- **Member statuses**
- {constants.Emojis.status_online} {online_presences:,}
- {constants.Emojis.status_offline} {offline_presences:,}
- """)
- ).substitute({"channel_counts": channel_counts})
+ member_status = (
+ f"{constants.Emojis.status_online} {online_presences} "
+ f"{constants.Emojis.status_offline} {offline_presences}"
+ )
+
+ embed.description = textwrap.dedent(f"""
+ Created: {created}
+ Voice region: {region}\
+ {features}
+ Roles: {num_roles}
+ Member status: {member_status}
+ """)
embed.set_thumbnail(url=ctx.guild.icon_url)
+ # Members
+ total_members = ctx.guild.member_count
+ member_counts = self.get_member_counts(ctx.guild)
+ member_info = "\n".join(f"{role}: {count}" for role, count in member_counts.items())
+ embed.add_field(name=f"Members: {total_members}", value=member_info)
+
+ # Channels
+ total_channels = len(ctx.guild.channels)
+ channel_counts = self.get_channel_type_counts(ctx.guild)
+ channel_info = "\n".join(
+ f"{channel.title()}: {count}" for channel, count in sorted(channel_counts.items())
+ )
+ embed.add_field(name=f"Channels: {total_channels}", value=channel_info)
+
+ # Additional info if ran in moderation channels
+ if is_mod_channel(ctx.channel):
+ embed.add_field(name="Moderation:", value=self.get_extended_server_info(ctx))
+
await ctx.send(embed=embed)
- @command(name="user", aliases=["user_info", "member", "member_info"])
+ @command(name="user", aliases=["user_info", "member", "member_info", "u"])
async def user_info(self, ctx: Context, user: FetchedMember = None) -> None:
"""Returns info about a user."""
if user is None:
diff --git a/bot/exts/info/pypi.py b/bot/exts/info/pypi.py
new file mode 100644
index 000000000..2e42e7d6b
--- /dev/null
+++ b/bot/exts/info/pypi.py
@@ -0,0 +1,78 @@
+import itertools
+import logging
+import random
+import re
+
+from discord import Embed
+from discord.ext.commands import Cog, Context, command
+from discord.utils import escape_markdown
+
+from bot.bot import Bot
+from bot.constants import Colours, NEGATIVE_REPLIES, RedirectOutput
+
+URL = "https://pypi.org/pypi/{package}/json"
+PYPI_ICON = "https://cdn.discordapp.com/emojis/766274397257334814.png"
+
+PYPI_COLOURS = itertools.cycle((Colours.yellow, Colours.blue, Colours.white))
+
+ILLEGAL_CHARACTERS = re.compile(r"[^-_.a-zA-Z0-9]+")
+INVALID_INPUT_DELETE_DELAY = RedirectOutput.delete_delay
+
+log = logging.getLogger(__name__)
+
+
+class PyPi(Cog):
+ """Cog for getting information about PyPi packages."""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+
+ @command(name="pypi", aliases=("package", "pack"))
+ async def get_package_info(self, ctx: Context, package: str) -> None:
+ """Provide information about a specific package from PyPI."""
+ embed = Embed(title=random.choice(NEGATIVE_REPLIES), colour=Colours.soft_red)
+ embed.set_thumbnail(url=PYPI_ICON)
+
+ error = True
+
+ if characters := re.search(ILLEGAL_CHARACTERS, package):
+ embed.description = f"Illegal character(s) passed into command: '{escape_markdown(characters.group(0))}'"
+
+ else:
+ async with self.bot.http_session.get(URL.format(package=package)) as response:
+ if response.status == 404:
+ embed.description = "Package could not be found."
+
+ elif response.status == 200 and response.content_type == "application/json":
+ response_json = await response.json()
+ info = response_json["info"]
+
+ embed.title = f"{info['name']} v{info['version']}"
+
+ embed.url = info["package_url"]
+ embed.colour = next(PYPI_COLOURS)
+
+ summary = escape_markdown(info["summary"])
+
+ # Summary could be completely empty, or just whitespace.
+ if summary and not summary.isspace():
+ embed.description = summary
+ else:
+ embed.description = "No summary provided."
+
+ error = False
+
+ else:
+ embed.description = "There was an error when fetching your PyPi package."
+ log.trace(f"Error when fetching PyPi package: {response.status}.")
+
+ if error:
+ await ctx.send(embed=embed, delete_after=INVALID_INPUT_DELETE_DELAY)
+ await ctx.message.delete(delay=INVALID_INPUT_DELETE_DELAY)
+ else:
+ await ctx.send(embed=embed)
+
+
+def setup(bot: Bot) -> None:
+ """Load the PyPi cog."""
+ bot.add_cog(PyPi(bot))
diff --git a/bot/exts/info/source.py b/bot/exts/info/source.py
index f03b6a46f..78a6f68ac 100644
--- a/bot/exts/info/source.py
+++ b/bot/exts/info/source.py
@@ -98,7 +98,7 @@ class BotSource(commands.Cog):
else:
file_location = Path(filename).relative_to(Path.cwd()).as_posix()
- url = f"{URLs.github_bot_repo}/blob/master/{file_location}{lines_extension}"
+ url = f"{URLs.github_bot_repo}/blob/main/{file_location}{lines_extension}"
return url, file_location, first_line_no or None
diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py
index 00b4d1a78..bb91a8563 100644
--- a/bot/exts/info/tags.py
+++ b/bot/exts/info/tags.py
@@ -189,7 +189,7 @@ class Tags(Cog):
If a tag is not specified, display a paginated embed of all tags.
Tags are on cooldowns on a per-tag, per-channel basis. If a tag is on cooldown, display
- nothing and return False.
+ nothing and return True.
"""
def _command_on_cooldown(tag_name: str) -> bool:
"""
@@ -217,7 +217,7 @@ class Tags(Cog):
f"{ctx.author} tried to get the '{tag_name}' tag, but the tag is on cooldown. "
f"Cooldown ends in {time_left:.1f} seconds."
)
- return False
+ return True
if tag_name is not None:
temp_founds = self._get_tag(tag_name)
@@ -285,7 +285,8 @@ class Tags(Cog):
"""
Get a specified tag, or a list of all tags if no tag is specified.
- Returns False if a tag is on cooldown, or if no matches are found.
+ Returns True if something can be sent, or if the tag is on cooldown.
+ Returns False if no matches are found.
"""
return await self.display_tag(ctx, tag_name)
diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py
index caa6fb917..bab95405c 100644
--- a/bot/exts/moderation/defcon.py
+++ b/bot/exts/moderation/defcon.py
@@ -1,17 +1,25 @@
-from __future__ import annotations
-
+import asyncio
import logging
+import traceback
from collections import namedtuple
-from datetime import datetime, timedelta
+from datetime import datetime
from enum import Enum
+from typing import Optional, Union
-from discord import Colour, Embed, Member
+from aioredis import RedisError
+from async_rediscache import RedisCache
+from dateutil.relativedelta import relativedelta
+from discord import Colour, Embed, Member, User
+from discord.ext import tasks
from discord.ext.commands import Cog, Context, group, has_any_role
from bot.bot import Bot
from bot.constants import Channels, Colours, Emojis, Event, Icons, MODERATION_ROLES, Roles
+from bot.converters import DurationDelta, Expiry
from bot.exts.moderation.modlog import ModLog
from bot.utils.messages import format_user
+from bot.utils.scheduling import Scheduler
+from bot.utils.time import humanize_delta, parse_duration_string, relativedelta_to_timedelta
log = logging.getLogger(__name__)
@@ -28,71 +36,81 @@ will be resolved soon. In the meantime, please feel free to peruse the resources
BASE_CHANNEL_TOPIC = "Python Discord Defense Mechanism"
+SECONDS_IN_DAY = 86400
+
class Action(Enum):
"""Defcon Action."""
- ActionInfo = namedtuple('LogInfoDetails', ['icon', 'color', 'template'])
+ ActionInfo = namedtuple('LogInfoDetails', ['icon', 'emoji', 'color', 'template'])
- ENABLED = ActionInfo(Icons.defcon_enabled, Colours.soft_green, "**Days:** {days}\n\n")
- DISABLED = ActionInfo(Icons.defcon_disabled, Colours.soft_red, "")
- UPDATED = ActionInfo(Icons.defcon_updated, Colour.blurple(), "**Days:** {days}\n\n")
+ SERVER_OPEN = ActionInfo(Icons.defcon_unshutdown, Emojis.defcon_unshutdown, Colours.soft_green, "")
+ SERVER_SHUTDOWN = ActionInfo(Icons.defcon_shutdown, Emojis.defcon_shutdown, Colours.soft_red, "")
+ DURATION_UPDATE = ActionInfo(
+ Icons.defcon_update, Emojis.defcon_update, Colour.blurple(), "**Threshold:** {threshold}\n\n"
+ )
class Defcon(Cog):
"""Time-sensitive server defense mechanisms."""
- days = None # type: timedelta
- enabled = False # type: bool
+ # RedisCache[str, str]
+ # The cache's keys are "threshold" and "expiry".
+ # The caches' values are strings formatted as valid input to the DurationDelta converter, or empty when off.
+ defcon_settings = RedisCache()
def __init__(self, bot: Bot):
self.bot = bot
self.channel = None
- self.days = timedelta(days=0)
+ self.threshold = relativedelta(days=0)
+ self.expiry = None
+
+ self.scheduler = Scheduler(self.__class__.__name__)
- self.bot.loop.create_task(self.sync_settings())
+ 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 sync_settings(self) -> None:
+ @defcon_settings.atomic_transaction
+ async def _sync_settings(self) -> None:
"""On cog load, try to synchronize DEFCON settings to the API."""
+ log.trace("Waiting for the guild to become available before syncing.")
await self.bot.wait_until_guild_available()
self.channel = await self.bot.fetch_channel(Channels.defcon)
- try:
- response = await self.bot.api_client.get('bot/bot-settings/defcon')
- data = response['data']
+ log.trace("Syncing settings.")
- except Exception: # Yikes!
+ try:
+ settings = await self.defcon_settings.to_dict()
+ self.threshold = parse_duration_string(settings["threshold"]) if settings.get("threshold") else None
+ self.expiry = datetime.fromisoformat(settings["expiry"]) if settings.get("expiry") else None
+ except RedisError:
log.exception("Unable to get DEFCON settings!")
- await self.bot.get_channel(Channels.dev_log).send(
- f"<@&{Roles.admins}> **WARNING**: Unable to get DEFCON settings!"
+ await self.channel.send(
+ f"<@&{Roles.moderators}> <@&{Roles.devops}> **WARNING**: Unable to get DEFCON settings!"
+ f"\n\n```{traceback.format_exc()}```"
)
else:
- if data["enabled"]:
- self.enabled = True
- self.days = timedelta(days=data["days"])
- log.info(f"DEFCON enabled: {self.days.days} days")
+ if self.expiry:
+ self.scheduler.schedule_at(self.expiry, 0, self._remove_threshold())
- else:
- self.enabled = False
- self.days = timedelta(days=0)
- log.info("DEFCON disabled")
+ self._update_notifier()
+ log.info(f"DEFCON synchronized: {humanize_delta(self.threshold) if self.threshold else '-'}")
- await self.update_channel_topic()
+ 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:
+ """Check newly joining users to see if they meet the account age threshold."""
+ if self.threshold:
now = datetime.utcnow()
- if now - member.created_at < self.days:
- log.info(f"Rejecting user {member}: Account is too new and DEFCON is enabled")
+ if now - member.created_at < relativedelta_to_timedelta(self.threshold):
+ log.info(f"Rejecting user {member}: Account is too new")
message_sent = False
@@ -124,134 +142,163 @@ class Defcon(Cog):
"""Check the DEFCON status or run a subcommand."""
await ctx.send_help(ctx.command)
- async def _defcon_action(self, ctx: Context, days: int, action: Action) -> None:
- """Providing a structured way to do an defcon action."""
- try:
- response = await self.bot.api_client.get('bot/bot-settings/defcon')
- data = response['data']
-
- if "enable_date" in data and action is Action.DISABLED:
- enabled = datetime.fromisoformat(data["enable_date"])
-
- delta = datetime.now() - enabled
-
- self.bot.stats.timing("defcon.enabled", delta)
- except Exception:
- pass
-
- error = None
- try:
- await self.bot.api_client.put(
- 'bot/bot-settings/defcon',
- json={
- 'name': 'defcon',
- 'data': {
- # TODO: retrieve old days count
- 'days': days,
- 'enabled': action is not Action.DISABLED,
- 'enable_date': datetime.now().isoformat()
- }
- }
- )
- except Exception as err:
- log.exception("Unable to update DEFCON settings.")
- error = err
- finally:
- await ctx.send(self.build_defcon_msg(action, error))
- await self.send_defcon_log(action, ctx.author, error)
-
- self.bot.stats.gauge("defcon.threshold", days)
-
- @defcon_group.command(name='enable', aliases=('on', 'e'), root_aliases=("defon",))
- @has_any_role(*MODERATION_ROLES)
- 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.
- """
- self.enabled = True
- await self._defcon_action(ctx, days=0, action=Action.ENABLED)
- await self.update_channel_topic()
-
- @defcon_group.command(name='disable', aliases=('off', 'd'), root_aliases=("defoff",))
- @has_any_role(*MODERATION_ROLES)
- 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
- await self._defcon_action(ctx, days=0, action=Action.DISABLED)
- await self.update_channel_topic()
-
- @defcon_group.command(name='status', aliases=('s',))
+ @defcon_group.command(aliases=('s',))
@has_any_role(*MODERATION_ROLES)
- async def status_command(self, ctx: Context) -> None:
+ async def status(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"
- f"**Days:** {self.days.days}"
+ description=f"""
+ **Threshold:** {humanize_delta(self.threshold) if self.threshold else "-"}
+ **Expires in:** {humanize_delta(relativedelta(self.expiry, datetime.utcnow())) if self.expiry else "-"}
+ **Verification level:** {ctx.guild.verification_level.name}
+ """
)
await ctx.send(embed=embed)
- @defcon_group.command(name='days')
+ @defcon_group.command(name="threshold", aliases=('t', 'd'))
@has_any_role(*MODERATION_ROLES)
- 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)
- self.enabled = True
- await self._defcon_action(ctx, days=days, action=Action.UPDATED)
- await self.update_channel_topic()
-
- async def update_channel_topic(self) -> None:
+ async def threshold_command(
+ self, ctx: Context, threshold: Union[DurationDelta, int], expiry: Optional[Expiry] = None
+ ) -> None:
+ """
+ Set how old an account must be to join the server.
+
+ The threshold is the minimum required account age. Can accept either a duration string or a number of days.
+ Set it to 0 to have no threshold.
+ The expiry allows to automatically remove the threshold after a designated time. If no expiry is specified,
+ the cog will remind to remove the threshold hourly.
+ """
+ if isinstance(threshold, int):
+ threshold = relativedelta(days=threshold)
+ await self._update_threshold(ctx.author, threshold=threshold, expiry=expiry)
+
+ @defcon_group.command()
+ @has_any_role(Roles.admins)
+ async def shutdown(self, ctx: Context) -> None:
+ """Shut down the server by setting send permissions of everyone to False."""
+ role = ctx.guild.default_role
+ permissions = role.permissions
+
+ permissions.update(send_messages=False, add_reactions=False)
+ await role.edit(reason="DEFCON shutdown", permissions=permissions)
+ await ctx.send(f"{Action.SERVER_SHUTDOWN.value.emoji} Server shut down.")
+
+ @defcon_group.command()
+ @has_any_role(Roles.admins)
+ async def unshutdown(self, ctx: Context) -> None:
+ """Open up the server again by setting send permissions of everyone to None."""
+ role = ctx.guild.default_role
+ permissions = role.permissions
+
+ permissions.update(send_messages=True, add_reactions=True)
+ await role.edit(reason="DEFCON unshutdown", permissions=permissions)
+ await ctx.send(f"{Action.SERVER_OPEN.value.emoji} Server reopened.")
+
+ 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)"
+ new_topic = f"{BASE_CHANNEL_TOPIC}\n(Threshold: {humanize_delta(self.threshold) if self.threshold else '-'})"
self.mod_log.ignore(Event.guild_channel_update, Channels.defcon)
- await self.channel.edit(topic=new_topic)
-
- def build_defcon_msg(self, action: Action, e: Exception = None) -> str:
- """Build in-channel response string for DEFCON action."""
- if action is Action.ENABLED:
- msg = f"{Emojis.defcon_enabled} DEFCON enabled.\n\n"
- elif action is Action.DISABLED:
- msg = f"{Emojis.defcon_disabled} DEFCON disabled.\n\n"
- elif action is Action.UPDATED:
- msg = (
- f"{Emojis.defcon_updated} DEFCON days updated; accounts must be {self.days.days} "
- f"day{'s' if self.days.days > 1 else ''} old to join the server.\n\n"
+ asyncio.create_task(self.channel.edit(topic=new_topic))
+
+ @defcon_settings.atomic_transaction
+ async def _update_threshold(self, author: User, threshold: relativedelta, expiry: Optional[Expiry] = None) -> None:
+ """Update the new threshold in the cog, cache, defcon channel, and logs, and additionally schedule expiry."""
+ self.threshold = threshold
+ if threshold == relativedelta(days=0): # If the threshold is 0, we don't need to schedule anything
+ expiry = None
+ self.expiry = expiry
+
+ # Either way, we cancel the old task.
+ self.scheduler.cancel_all()
+ if self.expiry is not None:
+ self.scheduler.schedule_at(expiry, 0, self._remove_threshold())
+
+ self._update_notifier()
+
+ # Make sure to handle the critical part of the update before writing to Redis.
+ error = ""
+ try:
+ await self.defcon_settings.update(
+ {
+ 'threshold': Defcon._stringify_relativedelta(self.threshold) if self.threshold else "",
+ 'expiry': expiry.isoformat() if expiry else 0
+ }
)
+ except RedisError:
+ error = ", but failed to write to cache"
+
+ action = Action.DURATION_UPDATE
- 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```"
+ expiry_message = ""
+ if expiry:
+ expiry_message = f" for the next {humanize_delta(relativedelta(expiry, datetime.utcnow()), max_units=2)}"
+
+ if self.threshold:
+ channel_message = (
+ f"updated; accounts must be {humanize_delta(self.threshold)} "
+ f"old to join the server{expiry_message}"
)
+ else:
+ channel_message = "removed"
+
+ await self.channel.send(
+ f"{action.value.emoji} DEFCON threshold {channel_message}{error}."
+ )
+ await self._send_defcon_log(action, author)
+ self._update_channel_topic()
+
+ self._log_threshold_stat(threshold)
- return msg
+ async def _remove_threshold(self) -> None:
+ """Resets the threshold back to 0."""
+ await self._update_threshold(self.bot.user, relativedelta(days=0))
- async def send_defcon_log(self, action: Action, actor: Member, e: Exception = None) -> None:
+ @staticmethod
+ def _stringify_relativedelta(delta: relativedelta) -> str:
+ """Convert a relativedelta object to a duration string."""
+ units = [("years", "y"), ("months", "m"), ("days", "d"), ("hours", "h"), ("minutes", "m"), ("seconds", "s")]
+ return "".join(f"{getattr(delta, unit)}{symbol}" for unit, symbol in units if getattr(delta, unit)) or "0s"
+
+ def _log_threshold_stat(self, threshold: relativedelta) -> None:
+ """Adds the threshold to the bot stats in days."""
+ threshold_days = relativedelta_to_timedelta(threshold).total_seconds() / SECONDS_IN_DAY
+ self.bot.stats.gauge("defcon.threshold", threshold_days)
+
+ async def _send_defcon_log(self, action: Action, actor: User) -> None:
"""Send log message for DEFCON action."""
info = action.value
log_msg: str = (
f"**Staffer:** {actor.mention} {actor} (`{actor.id}`)\n"
- f"{info.template.format(days=self.days.days)}"
+ f"{info.template.format(threshold=(humanize_delta(self.threshold) if self.threshold else '-'))}"
)
status_msg = f"DEFCON {action.name.lower()}"
- 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(info.icon, info.color, status_msg, log_msg)
+ def _update_notifier(self) -> None:
+ """Start or stop the notifier according to the DEFCON status."""
+ if self.threshold and self.expiry is None and not self.defcon_notifier.is_running():
+ log.info("DEFCON notifier started.")
+ self.defcon_notifier.start()
+
+ elif (not self.threshold or self.expiry is not None) and self.defcon_notifier.is_running():
+ log.info("DEFCON notifier stopped.")
+ self.defcon_notifier.cancel()
+
+ @tasks.loop(hours=1)
+ async def defcon_notifier(self) -> None:
+ """Routinely notify moderators that DEFCON is active."""
+ await self.channel.send(f"Defcon is on and is set to {humanize_delta(self.threshold)}.")
+
+ def cog_unload(self) -> None:
+ """Cancel the notifer and threshold removal tasks when the cog unloads."""
+ log.trace("Cog unload: canceling defcon notifier task.")
+ self.defcon_notifier.cancel()
+ self.scheduler.cancel_all()
+
def setup(bot: Bot) -> None:
"""Load the Defcon cog."""
diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py
index 242b2d30f..988fb7220 100644
--- a/bot/exts/moderation/infraction/_scheduler.py
+++ b/bot/exts/moderation/infraction/_scheduler.py
@@ -102,6 +102,7 @@ class InfractionScheduler:
"""
Apply an infraction to the user, log the infraction, and optionally notify the user.
+ `action_coro`, if not provided, will result in the infraction not getting scheduled for deletion.
`user_reason`, if provided, will be sent to the user in place of the infraction reason.
`additional_info` will be attached to the text field in the mod-log embed.
@@ -172,6 +173,8 @@ class InfractionScheduler:
total = len(infractions)
end_msg = f" (#{id_} ; {total} infraction{ngettext('', 's', total)} total)"
+ purge = infraction.get("purge", "")
+
# Execute the necessary actions to apply the infraction on Discord.
if action_coro:
log.trace(f"Awaiting the infraction #{id_} application action coroutine.")
@@ -209,7 +212,7 @@ class InfractionScheduler:
log.error(f"Deletion of {infr_type} infraction #{id_} failed with error code {e.status}.")
infr_message = ""
else:
- infr_message = f" **{' '.join(infr_type.split('_'))}** to {user.mention}{expiry_msg}{end_msg}"
+ infr_message = f" **{purge}{' '.join(infr_type.split('_'))}** to {user.mention}{expiry_msg}{end_msg}"
# Send a confirmation message to the invoking context.
log.trace(f"Sending infraction #{id_} confirmation message.")
@@ -233,7 +236,7 @@ class InfractionScheduler:
footer=f"ID {infraction['id']}"
)
- log.info(f"Applied {infr_type} infraction #{id_} to {user}.")
+ log.info(f"Applied {purge}{infr_type} infraction #{id_} to {user}.")
return not failed
async def pardon_infraction(
diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py
index d0dc3f0a1..a98b4828b 100644
--- a/bot/exts/moderation/infraction/_utils.py
+++ b/bot/exts/moderation/infraction/_utils.py
@@ -7,6 +7,7 @@ from discord.ext.commands import Context
from bot.api import ResponseCodeError
from bot.constants import Colours, Icons
+from bot.errors import InvalidInfractedUser
log = logging.getLogger(__name__)
@@ -21,7 +22,6 @@ INFRACTION_ICONS = {
"voice_ban": (Icons.voice_state_red, Icons.voice_state_green),
}
RULES_URL = "https://pythondiscord.com/pages/rules"
-APPEALABLE_INFRACTIONS = ("ban", "mute", "voice_ban")
# Type aliases
UserObject = t.Union[discord.Member, discord.User]
@@ -30,8 +30,12 @@ Infraction = t.Dict[str, t.Union[str, int, bool]]
APPEAL_EMAIL = "[email protected]"
-INFRACTION_TITLE = f"Please review our rules over at {RULES_URL}"
-INFRACTION_APPEAL_FOOTER = f"To appeal this infraction, send an e-mail to {APPEAL_EMAIL}"
+INFRACTION_TITLE = "Please review our rules"
+INFRACTION_APPEAL_EMAIL_FOOTER = f"To appeal this infraction, send an e-mail to {APPEAL_EMAIL}"
+INFRACTION_APPEAL_MODMAIL_FOOTER = (
+ 'If you would like to discuss or appeal this infraction, '
+ 'send a message to the ModMail bot'
+)
INFRACTION_AUTHOR_NAME = "Infraction information"
INFRACTION_DESCRIPTION_TEMPLATE = (
@@ -70,15 +74,19 @@ async def post_user(ctx: Context, user: UserSnowflake) -> t.Optional[dict]:
async def post_infraction(
- ctx: Context,
- user: UserSnowflake,
- infr_type: str,
- reason: str,
- expires_at: datetime = None,
- hidden: bool = False,
- active: bool = True
+ ctx: Context,
+ user: UserSnowflake,
+ infr_type: str,
+ reason: str,
+ expires_at: datetime = None,
+ hidden: bool = False,
+ active: bool = True
) -> t.Optional[dict]:
"""Posts an infraction to the API."""
+ if isinstance(user, (discord.Member, discord.User)) and user.bot:
+ log.trace(f"Posting of {infr_type} infraction for {user} to the API aborted. User is a bot.")
+ raise InvalidInfractedUser(user)
+
log.trace(f"Posting {infr_type} infraction for {user} to the API.")
payload = {
@@ -145,11 +153,11 @@ async def get_active_infraction(
async def notify_infraction(
- user: UserObject,
- infr_type: str,
- expires_at: t.Optional[str] = None,
- reason: t.Optional[str] = None,
- icon_url: str = Icons.token_removed
+ user: UserObject,
+ 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."""
log.trace(f"Sending {user} a DM about their {infr_type} infraction.")
@@ -173,17 +181,18 @@ async def notify_infraction(
embed.title = INFRACTION_TITLE
embed.url = RULES_URL
- if infr_type in APPEALABLE_INFRACTIONS:
- embed.set_footer(text=INFRACTION_APPEAL_FOOTER)
+ embed.set_footer(
+ text=INFRACTION_APPEAL_EMAIL_FOOTER if infr_type == 'Ban' else INFRACTION_APPEAL_MODMAIL_FOOTER
+ )
return await send_private_embed(user, embed)
async def notify_pardon(
- user: UserObject,
- title: str,
- content: str,
- icon_url: str = Icons.user_verified
+ user: UserObject,
+ 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."""
log.trace(f"Sending {user} a DM about their pardoned infraction.")
diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py
index b3d069b34..d89e80acc 100644
--- a/bot/exts/moderation/infraction/infractions.py
+++ b/bot/exts/moderation/infraction/infractions.py
@@ -126,7 +126,7 @@ class Infractions(InfractionScheduler, commands.Cog):
duration = await Duration().convert(ctx, "1h")
await self.apply_mute(ctx, user, reason, expires_at=duration)
- @command()
+ @command(aliases=("tban",))
async def tempban(
self,
ctx: Context,
@@ -198,7 +198,7 @@ class Infractions(InfractionScheduler, commands.Cog):
# endregion
# region: Temporary shadow infractions
- @command(hidden=True, aliases=["shadowtempban", "stempban"])
+ @command(hidden=True, aliases=["shadowtempban", "stempban", "stban"])
async def shadow_tempban(
self,
ctx: Context,
@@ -257,6 +257,10 @@ class Infractions(InfractionScheduler, commands.Cog):
self.mod_log.ignore(Event.member_update, user.id)
async def action() -> None:
+ # Skip members that left the server
+ if not isinstance(user, Member):
+ return
+
await user.add_roles(self._muted_role, reason=reason)
log.trace(f"Attempting to kick {user} from voice because they've been muted.")
@@ -314,6 +318,8 @@ class Infractions(InfractionScheduler, commands.Cog):
if infraction is None:
return
+ infraction["purge"] = "purge " if purge_days else ""
+
self.mod_log.ignore(Event.member_remove, user.id)
if reason:
@@ -351,10 +357,15 @@ class Infractions(InfractionScheduler, commands.Cog):
if reason:
reason = textwrap.shorten(reason, width=512, placeholder="...")
- await user.move_to(None, reason="Disconnected from voice to apply voiceban.")
+ async def action() -> None:
+ # Skip members that left the server
+ if not isinstance(user, Member):
+ return
- action = user.remove_roles(self._voice_verified_role, reason=reason)
- await self.apply_infraction(ctx, infraction, user, action)
+ await user.move_to(None, reason="Disconnected from voice to apply voiceban.")
+ await user.remove_roles(self._voice_verified_role, reason=reason)
+
+ await self.apply_infraction(ctx, infraction, user, action())
# endregion
# region: Base pardon functions
diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py
index ffc470c54..704dddf9c 100644
--- a/bot/exts/moderation/infraction/superstarify.py
+++ b/bot/exts/moderation/infraction/superstarify.py
@@ -104,7 +104,7 @@ class Superstarify(InfractionScheduler, Cog):
await self.reapply_infraction(infraction, action)
- @command(name="superstarify", aliases=("force_nick", "star", "starify"))
+ @command(name="superstarify", aliases=("force_nick", "star", "starify", "superstar"))
async def superstarify(
self,
ctx: Context,
@@ -183,7 +183,7 @@ class Superstarify(InfractionScheduler, Cog):
)
await ctx.send(embed=embed)
- @command(name="unsuperstarify", aliases=("release_nick", "unstar", "unstarify"))
+ @command(name="unsuperstarify", aliases=("release_nick", "unstar", "unstarify", "unsuperstar"))
async def unsuperstarify(self, ctx: Context, member: Member) -> None:
"""Remove the superstarify infraction and allow the user to change their nickname."""
await self.pardon_infraction(ctx, "superstar", member)
diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py
index e4b119f41..2dae9d268 100644
--- a/bot/exts/moderation/modlog.py
+++ b/bot/exts/moderation/modlog.py
@@ -546,6 +546,7 @@ class ModLog(Cog, name="ModLog"):
f"**Author:** {format_user(author)}\n"
f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n"
f"**Message ID:** `{message.id}`\n"
+ f"[Jump to message]({message.jump_url})\n"
"\n"
)
else:
@@ -553,6 +554,7 @@ class ModLog(Cog, name="ModLog"):
f"**Author:** {format_user(author)}\n"
f"**Channel:** #{channel.name} (`{channel.id}`)\n"
f"**Message ID:** `{message.id}`\n"
+ f"[Jump to message]({message.jump_url})\n"
"\n"
)
diff --git a/bot/exts/moderation/slowmode.py b/bot/exts/moderation/slowmode.py
index c449752e1..d8baff76a 100644
--- a/bot/exts/moderation/slowmode.py
+++ b/bot/exts/moderation/slowmode.py
@@ -1,5 +1,4 @@
import logging
-from datetime import datetime
from typing import Optional
from dateutil.relativedelta import relativedelta
@@ -54,8 +53,7 @@ class Slowmode(Cog):
# Convert `dateutil.relativedelta.relativedelta` to `datetime.timedelta`
# Must do this to get the delta in a particular unit of time
- utcnow = datetime.utcnow()
- slowmode_delay = (utcnow + delay - utcnow).total_seconds()
+ slowmode_delay = time.relativedelta_to_timedelta(delay).total_seconds()
humanized_delay = time.humanize_delta(delay)
diff --git a/bot/exts/moderation/watchchannels/_watchchannel.py b/bot/exts/moderation/watchchannels/_watchchannel.py
index f9fc12dc3..9f26c34f2 100644
--- a/bot/exts/moderation/watchchannels/_watchchannel.py
+++ b/bot/exts/moderation/watchchannels/_watchchannel.py
@@ -5,9 +5,8 @@ import textwrap
from abc import abstractmethod
from collections import defaultdict, deque
from dataclasses import dataclass
-from typing import Optional
+from typing import Any, Dict, Optional
-import dateutil.parser
import discord
from discord import Color, DMChannel, Embed, HTTPException, Message, errors
from discord.ext.commands import Cog, Context
@@ -20,7 +19,7 @@ from bot.exts.filters.webhook_remover import WEBHOOK_URL_RE
from bot.exts.moderation.modlog import ModLog
from bot.pagination import LinePaginator
from bot.utils import CogABCMeta, messages
-from bot.utils.time import time_since
+from bot.utils.time import get_time_delta
log = logging.getLogger(__name__)
@@ -47,7 +46,9 @@ class WatchChannel(metaclass=CogABCMeta):
webhook_id: int,
api_endpoint: str,
api_default_params: dict,
- logger: logging.Logger
+ logger: logging.Logger,
+ *,
+ disable_header: bool = False
) -> None:
self.bot = bot
@@ -66,6 +67,7 @@ class WatchChannel(metaclass=CogABCMeta):
self.channel = None
self.webhook = None
self.message_history = MessageHistory()
+ self.disable_header = disable_header
self._start = self.bot.loop.create_task(self.start_watchchannel())
@@ -133,7 +135,10 @@ class WatchChannel(metaclass=CogABCMeta):
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.",
+ text=(
+ "Could not retrieve the list of watched users from the API. "
+ "Messages will not be relayed, and reviews not rescheduled."
+ ),
ping_everyone=True,
icon_url=Icons.token_removed,
colour=Color.red()
@@ -267,6 +272,9 @@ class WatchChannel(metaclass=CogABCMeta):
async def send_header(self, msg: Message) -> None:
"""Sends a header embed with information about the relayed messages to the watch channel."""
+ if self.disable_header:
+ return
+
user_id = msg.author.id
guild = self.bot.get_guild(GuildConfig.id)
@@ -274,7 +282,7 @@ class WatchChannel(metaclass=CogABCMeta):
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)
+ time_delta = get_time_delta(inserted_at)
reason = self.watched_users[user_id]['reason']
@@ -302,35 +310,61 @@ class WatchChannel(metaclass=CogABCMeta):
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
+ watched_data = await self.prepare_watched_users_data(ctx, oldest_first, update_cache)
- 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})")
+ if update_cache and not watched_data["updated"]:
+ await ctx.send(f":x: Failed to update {self.__class__.__name__} user cache, serving from cache")
- if oldest_first:
- lines.reverse()
-
- lines = lines or ("There's nothing here yet.",)
+ lines = watched_data["info"].values() or ("There's nothing here yet.",)
embed = Embed(
- title=f"{self.__class__.__name__} watched users ({'updated' if update_cache else 'cached'})",
+ title=watched_data["title"],
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)
+ async def prepare_watched_users_data(
+ self, ctx: Context, oldest_first: bool = False, update_cache: bool = True
+ ) -> Dict[str, Any]:
+ """
+ Prepare overview information of watched users to list.
+
+ The optional kwarg `oldest_first` orders the list by oldest entry.
+
+ The optional kwarg `update_cache` specifies whether the cache should
+ be refreshed by polling the API.
+
+ Returns a dictionary with a "title" key for the list's title, and a "info" key with
+ information about each user.
+
+ The dictionary additionally has an "updated" field which is true if a cache update was
+ requested and it succeeded.
+ """
+ list_data = {}
+ if update_cache:
+ if not await self.fetch_user_cache():
+ update_cache = False
+ list_data["updated"] = update_cache
+
+ watched_iter = self.watched_users.items()
+ if oldest_first:
+ watched_iter = reversed(watched_iter)
+
+ list_data["info"] = {}
+ for user_id, user_data in watched_iter:
+ member = ctx.guild.get_member(user_id)
+ line = f"• `{user_id}`"
+ if member:
+ line += f" ({member.name}#{member.discriminator})"
+ inserted_at = user_data['inserted_at']
+ line += f", added {get_time_delta(inserted_at)}"
+ if not member: # Cross off users who left the server.
+ line = f"~~{line}~~"
+ list_data["info"][user_id] = line
+
+ list_data["title"] = f"{self.__class__.__name__} watched users ({'updated' if update_cache else 'cached'})"
- return time_delta
+ return list_data
def _remove_user(self, user_id: int) -> None:
"""Removes a user from a watch channel."""
diff --git a/bot/exts/recruitment/__init__.py b/bot/exts/recruitment/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/bot/exts/recruitment/__init__.py
diff --git a/bot/exts/recruitment/talentpool/__init__.py b/bot/exts/recruitment/talentpool/__init__.py
new file mode 100644
index 000000000..52d27eb99
--- /dev/null
+++ b/bot/exts/recruitment/talentpool/__init__.py
@@ -0,0 +1,8 @@
+from bot.bot import Bot
+
+
+def setup(bot: Bot) -> None:
+ """Load the TalentPool cog."""
+ from bot.exts.recruitment.talentpool._cog import TalentPool
+
+ bot.add_cog(TalentPool(bot))
diff --git a/bot/exts/moderation/watchchannels/talentpool.py b/bot/exts/recruitment/talentpool/_cog.py
index dd3349c3a..b809cea17 100644
--- a/bot/exts/moderation/watchchannels/talentpool.py
+++ b/bot/exts/recruitment/talentpool/_cog.py
@@ -11,9 +11,12 @@ from bot.bot import Bot
from bot.constants import Channels, Guild, MODERATION_ROLES, STAFF_ROLES, Webhooks
from bot.converters import FetchedMember
from bot.exts.moderation.watchchannels._watchchannel import WatchChannel
+from bot.exts.recruitment.talentpool._review import Reviewer
from bot.pagination import LinePaginator
from bot.utils import time
+REASON_MAX_CHARS = 1000
+
log = logging.getLogger(__name__)
@@ -28,8 +31,12 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
api_endpoint='bot/nominations',
api_default_params={'active': 'true', 'ordering': '-inserted_at'},
logger=log,
+ disable_header=True,
)
+ self.reviewer = Reviewer(self.__class__.__name__, bot, self)
+ self.bot.loop.create_task(self.reviewer.reschedule_reviews())
+
@group(name='talentpool', aliases=('tp', 'talent', 'nomination', 'n'), invoke_without_command=True)
@has_any_role(*MODERATION_ROLES)
async def nomination_group(self, ctx: Context) -> None:
@@ -39,7 +46,10 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
@nomination_group.command(name='watched', aliases=('all', 'list'), root_aliases=("nominees",))
@has_any_role(*MODERATION_ROLES)
async def watched_command(
- self, ctx: Context, oldest_first: bool = False, update_cache: bool = True
+ self,
+ ctx: Context,
+ oldest_first: bool = False,
+ update_cache: bool = True
) -> None:
"""
Shows the users that are currently being monitored in the talent pool.
@@ -51,6 +61,47 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
"""
await self.list_watched_users(ctx, oldest_first=oldest_first, update_cache=update_cache)
+ async def list_watched_users(
+ self,
+ ctx: Context,
+ oldest_first: bool = False,
+ update_cache: bool = True
+ ) -> None:
+ """
+ Gives an overview of the nominated users list.
+
+ It specifies the users' mention, name, how long ago they were nominated, and whether their
+ review was scheduled or already posted.
+
+ The optional kwarg `oldest_first` orders the list by oldest entry.
+
+ The optional kwarg `update_cache` specifies whether the cache should
+ be refreshed by polling the API.
+ """
+ # TODO Once the watch channel is removed, this can be done in a smarter way, without splitting and overriding
+ # the list_watched_users function.
+ watched_data = await self.prepare_watched_users_data(ctx, oldest_first, update_cache)
+
+ if update_cache and not watched_data["updated"]:
+ await ctx.send(f":x: Failed to update {self.__class__.__name__} user cache, serving from cache")
+
+ lines = []
+ for user_id, line in watched_data["info"].items():
+ if self.watched_users[user_id]['reviewed']:
+ line += " *(reviewed)*"
+ elif user_id in self.reviewer:
+ line += " *(scheduled)*"
+ lines.append(line)
+
+ if not lines:
+ lines = ("There's nothing here yet.",)
+
+ embed = Embed(
+ title=watched_data["title"],
+ color=Color.blue()
+ )
+ await LinePaginator.paginate(lines, ctx, embed, empty=False)
+
@nomination_group.command(name='oldest')
@has_any_role(*MODERATION_ROLES)
async def oldest_command(self, ctx: Context, update_cache: bool = True) -> None:
@@ -83,8 +134,8 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
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(f":x: {user} is already being watched in the talent pool")
+ if len(reason) > REASON_MAX_CHARS:
+ await ctx.send(f":x: Maxiumum allowed characters for the reason is {REASON_MAX_CHARS}.")
return
# Manual request with `raise_for_status` as False because we want the actual response
@@ -101,14 +152,20 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
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")
+ if resp.status == 400:
+ if response_data.get('user', False):
+ await ctx.send(":x: The specified user can't be found in the database tables")
+ elif response_data.get('actor', False):
+ await ctx.send(":x: You have already nominated this user")
+
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"
+
+ if user.id not in self.reviewer:
+ self.reviewer.schedule_review(user.id)
history = await self.bot.api_client.get(
self.api_endpoint,
@@ -119,10 +176,9 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
}
)
+ msg = f"✅ The nomination for {user} has been added to the talent pool"
if history:
- total = f"({len(history)} previous nominations in total)"
- start_reason = f"Watched: {textwrap.shorten(history[0]['reason'], width=500, placeholder='...')}"
- msg += f"\n\nUser's previous watch reasons {total}:```{start_reason}```"
+ msg += f"\n\n({len(history)} previous nominations in total)"
await ctx.send(msg)
@@ -163,6 +219,10 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
Providing a `reason` is required.
"""
+ if len(reason) > REASON_MAX_CHARS:
+ await ctx.send(f":x: Maxiumum allowed characters for the end reason is {REASON_MAX_CHARS}.")
+ return
+
if await self.unwatch(user.id, reason):
await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed")
else:
@@ -176,33 +236,87 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
@nomination_edit_group.command(name='reason')
@has_any_role(*MODERATION_ROLES)
- 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.
+ async def edit_reason_command(self, ctx: Context, nomination_id: int, actor: FetchedMember, *, reason: str) -> None:
+ """Edits the reason of a specific nominator in a specific active nomination."""
+ if len(reason) > REASON_MAX_CHARS:
+ await ctx.send(f":x: Maxiumum allowed characters for the reason is {REASON_MAX_CHARS}.")
+ return
+
+ 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 find a nomination with id {nomination_id}")
+ await ctx.send(f":x: Can't find a nomination with id `{nomination_id}`")
+ return
+ else:
+ raise
+
+ if not nomination["active"]:
+ await ctx.send(":x: Can't edit the reason of an inactive nomination.")
+ return
+
+ if not any(entry["actor"] == actor.id for entry in nomination["entries"]):
+ await ctx.send(f":x: {actor} doesn't have an entry in this nomination.")
+ return
+
+ self.log.trace(f"Changing reason for nomination with id {nomination_id} of actor {actor} to {repr(reason)}")
+
+ await self.bot.api_client.patch(
+ f"{self.api_endpoint}/{nomination_id}",
+ json={"actor": actor.id, "reason": reason}
+ )
+ await self.fetch_user_cache() # Update cache
+ await ctx.send(":white_check_mark: Successfully updated nomination reason.")
+
+ @nomination_edit_group.command(name='end_reason')
+ @has_any_role(*MODERATION_ROLES)
+ async def edit_end_reason_command(self, ctx: Context, nomination_id: int, *, reason: str) -> None:
+ """Edits the unnominate reason for the nomination with the given `id`."""
+ if len(reason) > REASON_MAX_CHARS:
+ await ctx.send(f":x: Maxiumum allowed characters for the end reason is {REASON_MAX_CHARS}.")
+ return
- 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}")
+ self.log.trace(f"Nomination API 404: Can't find a 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"
+ if nomination["active"]:
+ await ctx.send(":x: Can't edit the end reason of an active nomination.")
+ return
- self.log.trace(f"Changing {field} for nomination with id {nomination_id} to {reason}")
+ self.log.trace(f"Changing end reason for nomination with id {nomination_id} to {repr(reason)}")
await self.bot.api_client.patch(
f"{self.api_endpoint}/{nomination_id}",
- json={field: reason}
+ json={"end_reason": reason}
)
await self.fetch_user_cache() # Update cache.
- await ctx.send(f":white_check_mark: Updated the {field} of the nomination!")
+ await ctx.send(":white_check_mark: Updated the end reason of the nomination!")
+
+ @nomination_group.command(aliases=('mr',))
+ @has_any_role(*MODERATION_ROLES)
+ async def mark_reviewed(self, ctx: Context, user_id: int) -> None:
+ """Mark a user's nomination as reviewed and cancel the review task."""
+ if not await self.reviewer.mark_reviewed(ctx, user_id):
+ return
+ await ctx.send(f"✅ The user with ID `{user_id}` was marked as reviewed.")
+
+ @nomination_group.command(aliases=('review',))
+ @has_any_role(*MODERATION_ROLES)
+ async def post_review(self, ctx: Context, user_id: int) -> None:
+ """Post the automatic review for the user ahead of time."""
+ if not await self.reviewer.mark_reviewed(ctx, user_id):
+ return
+
+ await self.reviewer.post_review(user_id, update_database=False)
+ await ctx.message.add_reaction("✅")
@Cog.listener()
async def on_member_ban(self, guild: Guild, user: Union[User, Member]) -> None:
@@ -232,19 +346,28 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
)
self._remove_user(user_id)
+ self.reviewer.cancel(user_id)
+
return True
def _nomination_to_string(self, nomination_object: dict) -> str:
"""Creates a string representation of a nomination."""
guild = self.bot.get_guild(Guild.id)
+ entries = []
+ for site_entry in nomination_object["entries"]:
+ actor_id = site_entry["actor"]
+ actor = guild.get_member(actor_id)
+
+ reason = site_entry["reason"] or "*None*"
+ created = time.format_infraction(site_entry["inserted_at"])
+ entries.append(
+ f"Actor: {actor.mention if actor else actor_id}\nCreated: {created}\nReason: {reason}"
+ )
- actor_id = nomination_object["actor"]
- actor = guild.get_member(actor_id)
+ entries_string = "\n\n".join(entries)
active = nomination_object["active"]
- reason = nomination_object["reason"] or "*None*"
-
start_date = time.format_infraction(nomination_object["inserted_at"])
if active:
lines = textwrap.dedent(
@@ -252,9 +375,9 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
===============
Status: **Active**
Date: {start_date}
- Actor: {actor.mention if actor else actor_id}
- Reason: {reason}
Nomination ID: `{nomination_object["id"]}`
+
+ {entries_string}
===============
"""
)
@@ -265,19 +388,19 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
===============
Status: Inactive
Date: {start_date}
- Actor: {actor.mention if actor else actor_id}
- Reason: {reason}
+ Nomination ID: `{nomination_object["id"]}`
+
+ {entries_string}
End date: {end_date}
Unwatch reason: {nomination_object["end_reason"]}
- Nomination ID: `{nomination_object["id"]}`
===============
"""
)
return lines.strip()
-
-def setup(bot: Bot) -> None:
- """Load the TalentPool cog."""
- bot.add_cog(TalentPool(bot))
+ def cog_unload(self) -> None:
+ """Cancels all review tasks on cog unload."""
+ super().cog_unload()
+ self.reviewer.cancel_all()
diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py
new file mode 100644
index 000000000..fb3461238
--- /dev/null
+++ b/bot/exts/recruitment/talentpool/_review.py
@@ -0,0 +1,324 @@
+import asyncio
+import logging
+import random
+import textwrap
+import typing
+from collections import Counter
+from datetime import datetime, timedelta
+from typing import List, Optional, Union
+
+from dateutil.parser import isoparse
+from dateutil.relativedelta import relativedelta
+from discord import Emoji, Member, Message, TextChannel
+from discord.ext.commands import Context
+
+from bot.api import ResponseCodeError
+from bot.bot import Bot
+from bot.constants import Channels, Guild, Roles
+from bot.utils.scheduling import Scheduler
+from bot.utils.time import get_time_delta, humanize_delta, time_since
+
+if typing.TYPE_CHECKING:
+ from bot.exts.recruitment.talentpool._cog import TalentPool
+
+log = logging.getLogger(__name__)
+
+# Maximum amount of days before an automatic review is posted.
+MAX_DAYS_IN_POOL = 30
+
+# Maximum amount of characters allowed in a message
+MAX_MESSAGE_SIZE = 2000
+
+
+class Reviewer:
+ """Schedules, formats, and publishes reviews of helper nominees."""
+
+ def __init__(self, name: str, bot: Bot, pool: 'TalentPool'):
+ self.bot = bot
+ self._pool = pool
+ self._review_scheduler = Scheduler(name)
+
+ def __contains__(self, user_id: int) -> bool:
+ """Return True if the user with ID user_id is scheduled for review, False otherwise."""
+ return user_id in self._review_scheduler
+
+ async def reschedule_reviews(self) -> None:
+ """Reschedule all active nominations to be reviewed at the appropriate time."""
+ log.trace("Rescheduling reviews")
+ await self.bot.wait_until_guild_available()
+ # TODO Once the watch channel is removed, this can be done in a smarter way, e.g create a sync function.
+ await self._pool.fetch_user_cache()
+
+ for user_id, user_data in self._pool.watched_users.items():
+ if not user_data["reviewed"]:
+ self.schedule_review(user_id)
+
+ def schedule_review(self, user_id: int) -> None:
+ """Schedules a single user for review."""
+ log.trace(f"Scheduling review of user with ID {user_id}")
+
+ user_data = self._pool.watched_users[user_id]
+ inserted_at = isoparse(user_data['inserted_at']).replace(tzinfo=None)
+ review_at = inserted_at + timedelta(days=MAX_DAYS_IN_POOL)
+
+ # If it's over a day overdue, it's probably an old nomination and shouldn't be automatically reviewed.
+ if datetime.utcnow() - review_at < timedelta(days=1):
+ self._review_scheduler.schedule_at(review_at, user_id, self.post_review(user_id, update_database=True))
+
+ async def post_review(self, user_id: int, update_database: bool) -> None:
+ """Format a generic review of a user and post it to the nomination voting channel."""
+ log.trace(f"Posting the review of {user_id}")
+
+ nomination = self._pool.watched_users[user_id]
+ if not nomination:
+ log.trace(f"There doesn't appear to be an active nomination for {user_id}")
+ return
+
+ guild = self.bot.get_guild(Guild.id)
+ channel = guild.get_channel(Channels.nomination_voting)
+ member = guild.get_member(user_id)
+
+ if update_database:
+ await self.bot.api_client.patch(f"{self._pool.api_endpoint}/{nomination['id']}", json={"reviewed": True})
+
+ if not member:
+ await channel.send(
+ f"I tried to review the user with ID `{user_id}`, but they don't appear to be on the server 😔"
+ )
+ return
+
+ opening = f"<@&{Roles.moderators}> <@&{Roles.admins}>\n{member.mention} ({member}) for Helper!"
+
+ current_nominations = "\n\n".join(
+ f"**<@{entry['actor']}>:** {entry['reason'] or '*no reason given*'}" for entry in nomination['entries']
+ )
+ current_nominations = f"**Nominated by:**\n{current_nominations}"
+
+ review_body = await self._construct_review_body(member)
+
+ seen_emoji = self._random_ducky(guild)
+ vote_request = (
+ "*Refer to their nomination and infraction histories for further details*.\n"
+ f"*Please react {seen_emoji} if you've seen this post."
+ " Then react 👍 for approval, or 👎 for disapproval*."
+ )
+
+ review = "\n\n".join(part for part in (opening, current_nominations, review_body, vote_request))
+
+ message = (await self._bulk_send(channel, review))[-1]
+ for reaction in (seen_emoji, "👍", "👎"):
+ await message.add_reaction(reaction)
+
+ async def _construct_review_body(self, member: Member) -> str:
+ """Formats the body of the nomination, with details of activity, infractions, and previous nominations."""
+ activity = await self._activity_review(member)
+ infractions = await self._infractions_review(member)
+ prev_nominations = await self._previous_nominations_review(member)
+
+ body = f"{activity}\n\n{infractions}"
+ if prev_nominations:
+ body += f"\n\n{prev_nominations}"
+ return body
+
+ async def _activity_review(self, member: Member) -> str:
+ """
+ Format the activity of the nominee.
+
+ Adds details on how long they've been on the server, their total message count,
+ and the channels they're the most active in.
+ """
+ log.trace(f"Fetching the metricity data for {member.id}'s review")
+ try:
+ user_activity = await self.bot.api_client.get(f"bot/users/{member.id}/metricity_review_data")
+ except ResponseCodeError as e:
+ if e.status == 404:
+ log.trace(f"The user {member.id} seems to have no activity logged in Metricity.")
+ messages = "no"
+ channels = ""
+ else:
+ log.trace(f"An unexpected error occured while fetching information of user {member.id}.")
+ raise
+ else:
+ log.trace(f"Activity found for {member.id}, formatting review.")
+ messages = user_activity["total_messages"]
+ # Making this part flexible to the amount of expected and returned channels.
+ first_channel = user_activity["top_channel_activity"][0]
+ channels = f", with {first_channel[1]} messages in {first_channel[0]}"
+
+ if len(user_activity["top_channel_activity"]) > 1:
+ channels += ", " + ", ".join(
+ f"{count} in {channel}" for channel, count in user_activity["top_channel_activity"][1: -1]
+ )
+ last_channel = user_activity["top_channel_activity"][-1]
+ channels += f", and {last_channel[1]} in {last_channel[0]}"
+
+ time_on_server = humanize_delta(relativedelta(datetime.utcnow(), member.joined_at), max_units=2)
+ review = (
+ f"{member.name} has been on the server for **{time_on_server}**"
+ f" and has **{messages} messages**{channels}."
+ )
+
+ return review
+
+ async def _infractions_review(self, member: Member) -> str:
+ """
+ Formats the review of the nominee's infractions, if any.
+
+ The infractions are listed by type and amount, and it is stated how long ago the last one was issued.
+ """
+ log.trace(f"Fetching the infraction data for {member.id}'s review")
+ infraction_list = await self.bot.api_client.get(
+ 'bot/infractions/expanded',
+ params={'user__id': str(member.id), 'ordering': '-inserted_at'}
+ )
+
+ log.trace(f"{len(infraction_list)} infractions found for {member.id}, formatting review.")
+ if not infraction_list:
+ return "They have no infractions."
+
+ # Count the amount of each type of infraction.
+ infr_stats = list(Counter(infr["type"] for infr in infraction_list).items())
+
+ # Format into a sentence.
+ if len(infr_stats) == 1:
+ infr_type, count = infr_stats[0]
+ infractions = f"{count} {self._format_infr_name(infr_type, count)}"
+ else: # We already made sure they have infractions.
+ infractions = ", ".join(
+ f"{count} {self._format_infr_name(infr_type, count)}"
+ for infr_type, count in infr_stats[:-1]
+ )
+ last_infr, last_count = infr_stats[-1]
+ infractions += f", and {last_count} {self._format_infr_name(last_infr, last_count)}"
+
+ infractions = f"**{infractions}**"
+
+ # Show when the last one was issued.
+ if len(infraction_list) == 1:
+ infractions += ", issued "
+ else:
+ infractions += ", with the last infraction issued "
+
+ # Infractions were ordered by time since insertion descending.
+ infractions += get_time_delta(infraction_list[0]['inserted_at'])
+
+ return f"They have {infractions}."
+
+ @staticmethod
+ def _format_infr_name(infr_type: str, count: int) -> str:
+ """
+ Format the infraction type in a way readable in a sentence.
+
+ Underscores are replaced with spaces, as well as *attempting* to show the appropriate plural form if necessary.
+ This function by no means covers all rules of grammar.
+ """
+ formatted = infr_type.replace("_", " ")
+ if count > 1:
+ if infr_type.endswith(('ch', 'sh')):
+ formatted += "e"
+ formatted += "s"
+
+ return formatted
+
+ async def _previous_nominations_review(self, member: Member) -> Optional[str]:
+ """
+ Formats the review of the nominee's previous nominations.
+
+ The number of previous nominations and unnominations are shown, as well as the reason the last one ended.
+ """
+ log.trace(f"Fetching the nomination history data for {member.id}'s review")
+ history = await self.bot.api_client.get(
+ self._pool.api_endpoint,
+ params={
+ "user__id": str(member.id),
+ "active": "false",
+ "ordering": "-inserted_at"
+ }
+ )
+
+ log.trace(f"{len(history)} previous nominations found for {member.id}, formatting review.")
+ if not history:
+ return
+
+ num_entries = sum(len(nomination["entries"]) for nomination in history)
+
+ nomination_times = f"{num_entries} times" if num_entries > 1 else "once"
+ rejection_times = f"{len(history)} times" if len(history) > 1 else "once"
+ end_time = time_since(isoparse(history[0]['ended_at']).replace(tzinfo=None), max_units=2)
+
+ review = (
+ f"They were nominated **{nomination_times}** before"
+ f", but their nomination was called off **{rejection_times}**."
+ f"\nThe last one ended {end_time} with the reason: {history[0]['end_reason']}"
+ )
+
+ return review
+
+ @staticmethod
+ def _random_ducky(guild: Guild) -> Union[Emoji, str]:
+ """Picks a random ducky emoji to be used to mark the vote as seen. If no duckies found returns 👀."""
+ duckies = [emoji for emoji in guild.emojis if emoji.name.startswith("ducky")]
+ if not duckies:
+ return "👀"
+ return random.choice(duckies)
+
+ @staticmethod
+ async def _bulk_send(channel: TextChannel, text: str) -> List[Message]:
+ """
+ Split a text into several if necessary, and post them to the channel.
+
+ Returns the resulting message objects.
+ """
+ messages = textwrap.wrap(text, width=MAX_MESSAGE_SIZE, replace_whitespace=False)
+ log.trace(f"The provided string will be sent to the channel {channel.id} as {len(messages)} messages.")
+
+ results = []
+ for message in messages:
+ await asyncio.sleep(1)
+ results.append(await channel.send(message))
+
+ return results
+
+ async def mark_reviewed(self, ctx: Context, user_id: int) -> bool:
+ """
+ Mark an active nomination as reviewed, updating the database and canceling the review task.
+
+ Returns True if the user was successfully marked as reviewed, False otherwise.
+ """
+ log.trace(f"Updating user {user_id} as reviewed")
+ await self._pool.fetch_user_cache()
+ if user_id not in self._pool.watched_users:
+ log.trace(f"Can't find a nominated user with id {user_id}")
+ await ctx.send(f"❌ Can't find a currently nominated user with id `{user_id}`")
+ return False
+
+ nomination = self._pool.watched_users[user_id]
+ if nomination["reviewed"]:
+ await ctx.send("❌ This nomination was already reviewed, but here's a cookie 🍪")
+ return False
+
+ await self.bot.api_client.patch(f"{self._pool.api_endpoint}/{nomination['id']}", json={"reviewed": True})
+ if user_id in self._review_scheduler:
+ self._review_scheduler.cancel(user_id)
+
+ return True
+
+ def cancel(self, user_id: int) -> None:
+ """
+ Cancels the review of the nominee with ID `user_id`.
+
+ It's important to note that this applies only until reschedule_reviews is called again.
+ To permanently cancel someone's review, either remove them from the pool, or use mark_reviewed.
+ """
+ log.trace(f"Canceling the review of user {user_id}.")
+ self._review_scheduler.cancel(user_id)
+
+ def cancel_all(self) -> None:
+ """
+ Cancels all reviews.
+
+ It's important to note that this applies only until reschedule_reviews is called again.
+ To permanently cancel someone's review, either remove them from the pool, or use mark_reviewed.
+ """
+ log.trace("Canceling all reviews.")
+ self._review_scheduler.cancel_all()
diff --git a/bot/exts/utils/internal.py b/bot/exts/utils/internal.py
index 3521c8fd4..6f2da3131 100644
--- a/bot/exts/utils/internal.py
+++ b/bot/exts/utils/internal.py
@@ -15,7 +15,6 @@ from discord.ext.commands import Cog, Context, group, has_any_role
from bot.bot import Bot
from bot.constants import Roles
-from bot.interpreter import Interpreter
from bot.utils import find_nth_occurrence, send_to_paste_service
log = logging.getLogger(__name__)
@@ -30,8 +29,6 @@ class Internal(Cog):
self.ln = 0
self.stdout = StringIO()
- self.interpreter = Interpreter()
-
self.socket_since = datetime.utcnow()
self.socket_event_total = 0
self.socket_events = Counter()
@@ -243,12 +240,12 @@ async def func(): # (None,) -> Any
stats_embed = discord.Embed(
title="WebSocket statistics",
- description=f"Receiving {per_s:0.2f} event per second.",
+ description=f"Receiving {per_s:0.2f} events per second.",
color=discord.Color.blurple()
)
for event_type, count in self.socket_events.most_common(25):
- stats_embed.add_field(name=event_type, value=count, inline=False)
+ stats_embed.add_field(name=event_type, value=f"{count:,}", inline=True)
await ctx.send(embed=stats_embed)
diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py
index eb92dfca7..a5d6f69b9 100644
--- a/bot/exts/utils/utils.py
+++ b/bot/exts/utils/utils.py
@@ -14,6 +14,7 @@ from bot.converters import Snowflake
from bot.decorators import in_whitelist
from bot.pagination import LinePaginator
from bot.utils import messages
+from bot.utils.checks import has_no_roles_check
from bot.utils.time import time_since
log = logging.getLogger(__name__)
@@ -156,18 +157,22 @@ class Utils(Cog):
@command(aliases=("snf", "snfl", "sf"))
@in_whitelist(channels=(Channels.bot_commands,), roles=STAFF_ROLES)
- async def snowflake(self, ctx: Context, snowflake: Snowflake) -> None:
+ async def snowflake(self, ctx: Context, *snowflakes: Snowflake) -> None:
"""Get Discord snowflake creation time."""
- created_at = snowflake_time(snowflake)
- embed = Embed(
- description=f"**Created at {created_at}** ({time_since(created_at, max_units=3)}).",
- colour=Colour.blue()
- )
- embed.set_author(
- name=f"Snowflake: {snowflake}",
- icon_url="https://github.com/twitter/twemoji/blob/master/assets/72x72/2744.png?raw=true"
- )
- await ctx.send(embed=embed)
+ if len(snowflakes) > 1 and await has_no_roles_check(ctx, *STAFF_ROLES):
+ raise BadArgument("Cannot process more than one snowflake in one invocation.")
+
+ for snowflake in snowflakes:
+ created_at = snowflake_time(snowflake)
+ embed = Embed(
+ description=f"**Created at {created_at}** ({time_since(created_at, max_units=3)}).",
+ colour=Colour.blue()
+ )
+ embed.set_author(
+ name=f"Snowflake: {snowflake}",
+ icon_url="https://github.com/twitter/twemoji/blob/master/assets/72x72/2744.png?raw=true"
+ )
+ await ctx.send(embed=embed)
@command(aliases=("poll",))
@has_any_role(*MODERATION_ROLES)
diff --git a/bot/interpreter.py b/bot/interpreter.py
deleted file mode 100644
index b58f7a6b0..000000000
--- a/bot/interpreter.py
+++ /dev/null
@@ -1,51 +0,0 @@
-from code import InteractiveInterpreter
-from io import StringIO
-from typing import Any
-
-from discord.ext.commands import Context
-
-import bot
-
-CODE_TEMPLATE = """
-async def _func():
-{0}
-"""
-
-
-class Interpreter(InteractiveInterpreter):
- """
- Subclass InteractiveInterpreter to specify custom run functionality.
-
- Helper class for internal eval.
- """
-
- write_callable = None
-
- def __init__(self):
- locals_ = {"bot": bot.instance}
- super().__init__(locals_)
-
- 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")
-
- code_io = StringIO()
-
- for line in code.split("\n"):
- code_io.write(f" {line}\n")
-
- code = CODE_TEMPLATE.format(code_io.getvalue())
- del code_io
-
- self.runsource(code, *args, **kwargs)
- self.runsource("_rvalue = _func()", *args, **kwargs)
-
- rvalue = await self.locals["_rvalue"]
-
- del self.locals["_rvalue"]
- del self.locals["ctx"]
- del self.locals["print"]
-
- return rvalue
diff --git a/bot/pagination.py b/bot/pagination.py
index 182b2fa76..3b16cc9ff 100644
--- a/bot/pagination.py
+++ b/bot/pagination.py
@@ -4,10 +4,12 @@ import typing as t
from contextlib import suppress
import discord
+from discord import Member
from discord.abc import User
from discord.ext.commands import Context, Paginator
from bot import constants
+from bot.constants import MODERATION_ROLES
FIRST_EMOJI = "\u23EE" # [:track_previous:]
LEFT_EMOJI = "\u2B05" # [:arrow_left:]
@@ -210,6 +212,9 @@ class LinePaginator(Paginator):
Pagination will also be removed automatically if no reaction is added for five minutes (300 seconds).
+ The interaction will be limited to `restrict_to_user` (ctx.author by default) or
+ to any user with a moderation role.
+
Example:
>>> embed = discord.Embed()
>>> embed.set_author(name="Some Operation", url=url, icon_url=icon)
@@ -218,10 +223,10 @@ class LinePaginator(Paginator):
def event_check(reaction_: discord.Reaction, user_: discord.Member) -> bool:
"""Make sure that this reaction is what we want to operate on."""
no_restrictions = (
- # Pagination is not restricted
- not restrict_to_user
# The reaction was by a whitelisted user
- or user_.id == restrict_to_user.id
+ user_.id == restrict_to_user.id
+ # The reaction was by a moderator
+ or isinstance(user_, Member) and any(role.id in MODERATION_ROLES for role in user_.roles)
)
return (
@@ -242,6 +247,9 @@ class LinePaginator(Paginator):
scale_to_size=scale_to_size)
current_page = 0
+ if not restrict_to_user:
+ restrict_to_user = ctx.author
+
if not lines:
if exception_on_empty_embed:
log.exception("Pagination asked for empty lines iterable")
diff --git a/bot/resources/elements.json b/bot/resources/elements.json
deleted file mode 100644
index a3ac5b99f..000000000
--- a/bot/resources/elements.json
+++ /dev/null
@@ -1,119 +0,0 @@
-[
- "hydrogen",
- "helium",
- "lithium",
- "beryllium",
- "boron",
- "carbon",
- "nitrogen",
- "oxygen",
- "fluorine",
- "neon",
- "sodium",
- "magnesium",
- "aluminium",
- "silicon",
- "phosphorus",
- "sulfur",
- "chlorine",
- "argon",
- "potassium",
- "calcium",
- "scandium",
- "titanium",
- "vanadium",
- "chromium",
- "manganese",
- "iron",
- "cobalt",
- "nickel",
- "copper",
- "zinc",
- "gallium",
- "germanium",
- "arsenic",
- "bromine",
- "krypton",
- "rubidium",
- "strontium",
- "yttrium",
- "zirconium",
- "niobium",
- "molybdenum",
- "technetium",
- "ruthenium",
- "rhodium",
- "palladium",
- "silver",
- "cadmium",
- "indium",
- "tin",
- "antimony",
- "tellurium",
- "iodine",
- "xenon",
- "caesium",
- "barium",
- "lanthanum",
- "cerium",
- "praseodymium",
- "neodymium",
- "promethium",
- "samarium",
- "europium",
- "gadolinium",
- "terbium",
- "dysprosium",
- "holmium",
- "erbium",
- "thulium",
- "ytterbium",
- "lutetium",
- "hafnium",
- "tantalum",
- "tungsten",
- "rhenium",
- "osmium",
- "iridium",
- "platinum",
- "gold",
- "mercury",
- "thallium",
- "lead",
- "bismuth",
- "polonium",
- "astatine",
- "radon",
- "francium",
- "radium",
- "actinium",
- "thorium",
- "protactinium",
- "uranium",
- "neptunium",
- "plutonium",
- "americium",
- "curium",
- "berkelium",
- "californium",
- "einsteinium",
- "fermium",
- "mendelevium",
- "nobelium",
- "lawrencium",
- "rutherfordium",
- "dubnium",
- "seaborgium",
- "bohrium",
- "hassium",
- "meitnerium",
- "darmstadtium",
- "roentgenium",
- "copernicium",
- "nihonium",
- "flerovium",
- "moscovium",
- "livermorium",
- "tennessine",
- "oganesson"
-]
diff --git a/bot/resources/foods.json b/bot/resources/foods.json
new file mode 100644
index 000000000..61d9ea98f
--- /dev/null
+++ b/bot/resources/foods.json
@@ -0,0 +1,52 @@
+[
+ "apple",
+ "avocado",
+ "bagel",
+ "banana",
+ "bread",
+ "broccoli",
+ "burrito",
+ "cake",
+ "candy",
+ "carrot",
+ "cheese",
+ "cherries",
+ "chestnut",
+ "chili",
+ "chocolate",
+ "coconut",
+ "coffee",
+ "cookie",
+ "corn",
+ "croissant",
+ "cupcake",
+ "donut",
+ "dumpling",
+ "falafel",
+ "grapes",
+ "honey",
+ "kiwi",
+ "lemon",
+ "lollipop",
+ "mango",
+ "mushroom",
+ "orange",
+ "pancakes",
+ "peanut",
+ "pear",
+ "pie",
+ "pineapple",
+ "popcorn",
+ "potato",
+ "pretzel",
+ "ramen",
+ "rice",
+ "salad",
+ "spaghetti",
+ "stew",
+ "strawberry",
+ "sushi",
+ "taco",
+ "tomato",
+ "watermelon"
+]
diff --git a/bot/resources/stars.json b/bot/resources/stars.json
index c0b253120..5ecad0213 100644
--- a/bot/resources/stars.json
+++ b/bot/resources/stars.json
@@ -17,7 +17,7 @@
"Bruce Springsteen",
"Bruno Mars",
"Bryan Adams",
- "Celine Dion",
+ "Céline Dion",
"Cher",
"Christina Aguilera",
"David Bowie",
diff --git a/bot/resources/tags/comparison.md b/bot/resources/tags/comparison.md
new file mode 100644
index 000000000..12844bd2f
--- /dev/null
+++ b/bot/resources/tags/comparison.md
@@ -0,0 +1,12 @@
+**Assignment vs. Comparison**
+
+The assignment operator (`=`) is used to assign variables.
+```python
+x = 5
+print(x) # Prints 5
+```
+The equality operator (`==`) is used to compare values.
+```python
+if x == 5:
+ print("The value of x is 5")
+```
diff --git a/bot/resources/tags/defaultdict.md b/bot/resources/tags/defaultdict.md
new file mode 100644
index 000000000..b6c3175fc
--- /dev/null
+++ b/bot/resources/tags/defaultdict.md
@@ -0,0 +1,21 @@
+**[`collections.defaultdict`](https://docs.python.org/3/library/collections.html#collections.defaultdict)**
+
+The Python `defaultdict` type behaves almost exactly like a regular Python dictionary, but if you try to access or modify a missing key, the `defaultdict` will automatically insert the key and generate a default value for it.
+While instantiating a `defaultdict`, we pass in a function that tells it how to create a default value for missing keys.
+
+```py
+>>> from collections import defaultdict
+>>> my_dict = defaultdict(int)
+>>> my_dict
+defaultdict(<class 'int'>, {})
+```
+
+In this example, we've used the `int` class which returns 0 when called like a function, so any missing key will get a default value of 0. You can also get an empty list by default with `list` or an empty string with `str`.
+
+```py
+>>> my_dict["foo"]
+0
+>>> my_dict["bar"] += 5
+>>> my_dict
+defaultdict(<class 'int'>, {'foo': 0, 'bar': 5})
+```
diff --git a/bot/resources/tags/dict-get.md b/bot/resources/tags/dict-get.md
new file mode 100644
index 000000000..e02df03ab
--- /dev/null
+++ b/bot/resources/tags/dict-get.md
@@ -0,0 +1,16 @@
+Often while using dictionaries in Python, you may run into `KeyErrors`. This error is raised when you try to access a key that isn't present in your dictionary. Python gives you some neat ways to handle them.
+
+**The `dict.get` method**
+
+The [`dict.get`](https://docs.python.org/3/library/stdtypes.html#dict.get) method will return the value for the key if it exists, and None (or a default value that you specify) if the key doesn't exist. Hence it will _never raise_ a KeyError.
+```py
+>>> my_dict = {"foo": 1, "bar": 2}
+>>> print(my_dict.get("foobar"))
+None
+```
+Below, 3 is the default value to be returned, because the key doesn't exist-
+```py
+>>> print(my_dict.get("foobar", 3))
+3
+```
+Some other methods for handling `KeyError`s gracefully are the [`dict.setdefault`](https://docs.python.org/3/library/stdtypes.html#dict.setdefault) method and [`collections.defaultdict`](https://docs.python.org/3/library/collections.html#collections.defaultdict) (check out the `!defaultdict` tag).
diff --git a/bot/resources/tags/dictcomps.md b/bot/resources/tags/dictcomps.md
index 11867d77b..6c8018761 100644
--- a/bot/resources/tags/dictcomps.md
+++ b/bot/resources/tags/dictcomps.md
@@ -1,20 +1,14 @@
-**Dictionary Comprehensions**
-
-Like lists, there is a convenient way of creating dictionaries:
+Dictionary comprehensions (*dict comps*) provide a convenient way to make dictionaries, just like list comps:
```py
->>> ftoc = {f: round((5/9)*(f-32)) for f in range(-40,101,20)}
->>> print(ftoc)
-{-40: -40, -20: -29, 0: -18, 20: -7, 40: 4, 60: 16, 80: 27, 100: 38}
+>>> {word.lower(): len(word) for word in ('I', 'love', 'Python')}
+{'i': 1, 'love': 4, 'python': 6}
```
-In the example above, I created a dictionary of temperatures in Fahrenheit, that are mapped to (*roughly*) their Celsius counterpart within a small range. These comprehensions are useful for succinctly creating dictionaries from some other sequence.
+The syntax is very similar to list comps except that you surround it with curly braces and have two expressions: one for the key and one for the value.
-They are also very useful for inverting the key value pairs of a dictionary that already exists, such that the value in the old dictionary is now the key, and the corresponding key is now its value:
+One can use a dict comp to change an existing dictionary using its `items` method
```py
->>> ctof = {v:k for k, v in ftoc.items()}
->>> print(ctof)
-{-40: -40, -29: -20, -18: 0, -7: 20, 4: 40, 16: 60, 27: 80, 38: 100}
+>>> first_dict = {'i': 1, 'love': 4, 'python': 6}
+>>> {key.upper(): value * 2 for key, value in first_dict.items()}
+{'I': 2, 'LOVE': 8, 'PYTHON': 12}
```
-
-Also like list comprehensions, you can add a conditional to it in order to filter out items you don't want.
-
-For more information and examples, check [PEP 274](https://www.python.org/dev/peps/pep-0274/)
+For more information and examples, check out [PEP 274](https://www.python.org/dev/peps/pep-0274/)
diff --git a/bot/resources/tags/empty-json.md b/bot/resources/tags/empty-json.md
new file mode 100644
index 000000000..935544bb7
--- /dev/null
+++ b/bot/resources/tags/empty-json.md
@@ -0,0 +1,11 @@
+When using JSON, you might run into the following error:
+```
+JSONDecodeError: Expecting value: line 1 column 1 (char 0)
+```
+This error could have appeared because you just created the JSON file and there is nothing in it at the moment.
+
+Whilst having empty data is no problem, the file itself may never be completely empty.
+
+You most likely wanted to structure your JSON as a dictionary. To do this, edit your empty JSON file so that it instead contains `{}`.
+
+Different data types are also supported. If you wish to read more on these, please refer to [this article](https://www.tutorialspoint.com/json/json_data_types.htm).
diff --git a/bot/resources/tags/environments.md b/bot/resources/tags/environments.md
new file mode 100644
index 000000000..7bc69bde4
--- /dev/null
+++ b/bot/resources/tags/environments.md
@@ -0,0 +1,26 @@
+**Python Environments**
+
+The main purpose of Python [virtual environments](https://docs.Python.org/3/library/venv.html#venv-def) is to create an isolated environment for Python projects. This means that each project can have its own dependencies, such as third party packages installed using pip, regardless of what dependencies every other project has.
+
+To see the current environment in use by Python, you can run:
+```py
+>>> import sys
+>>> print(sys.executable)
+/usr/bin/python3
+```
+
+To see the environment in use by pip, you can do `pip debug` (`pip3 debug` for Linux/macOS). The 3rd line of the output will contain the path in use e.g. `sys.executable: /usr/bin/python3`.
+
+If Python's `sys.executable` doesn't match pip's, then they are currently using different environments! This may cause Python to raise a `ModuleNotFoundError` when you try to use a package you just installed with pip, as it was installed to a different environment.
+
+**Why use a virtual environment?**
+
+• Resolve dependency issues by allowing the use of different versions of a package for different projects. For example, you could use Package A v2.7 for Project X and Package A v1.3 for Project Y.
+• Make your project self-contained and reproducible by capturing all package dependencies in a requirements file. Try running `pip freeze` to see what you currently have installed!
+• Keep your global `site-packages/` directory tidy by removing the need to install packages system-wide which you might only need for one project.
+
+
+**Further reading:**
+
+• [Python Virtual Environments: A Primer](https://realpython.com/python-virtual-environments-a-primer)
+• [pyenv: Simple Python Version Management](https://github.com/pyenv/pyenv)
diff --git a/bot/resources/tags/f-strings.md b/bot/resources/tags/f-strings.md
index 69bc82487..5ccafe723 100644
--- a/bot/resources/tags/f-strings.md
+++ b/bot/resources/tags/f-strings.md
@@ -1,17 +1,9 @@
-In Python, there are several ways to do string interpolation, including using `%s`\'s and by using the `+` operator to concatenate strings together. However, because some of these methods offer poor readability and require typecasting to prevent errors, you should for the most part be using a feature called format strings.
+Creating a Python string with your variables using the `+` operator can be difficult to write and read. F-strings (*format-strings*) make it easy to insert values into a string. If you put an `f` in front of the first quote, you can then put Python expressions between curly braces in the string.
-**In Python 3.6 or later, we can use f-strings like this:**
```py
-snake = "Pythons"
-print(f"{snake} are some of the largest snakes in the world")
-```
-**In earlier versions of Python or in projects where backwards compatibility is very important, use str.format() like this:**
-```py
-snake = "Pythons"
-
-# With str.format() you can either use indexes
-print("{0} are some of the largest snakes in the world".format(snake))
-
-# Or keyword arguments
-print("{family} are some of the largest snakes in the world".format(family=snake))
+>>> snake = "pythons"
+>>> number = 21
+>>> f"There are {number * 2} {snake} on the plane."
+"There are 42 pythons on the plane."
```
+Note that even when you include an expression that isn't a string, like `number * 2`, Python will convert it to a string for you.
diff --git a/bot/resources/tags/floats.md b/bot/resources/tags/floats.md
new file mode 100644
index 000000000..7129b91bb
--- /dev/null
+++ b/bot/resources/tags/floats.md
@@ -0,0 +1,20 @@
+**Floating Point Arithmetic**
+You may have noticed that when doing arithmetic with floats in Python you sometimes get strange results, like this:
+```python
+>>> 0.1 + 0.2
+0.30000000000000004
+```
+**Why this happens**
+Internally your computer stores floats as as binary fractions. Many decimal values cannot be stored as exact binary fractions, which means an approximation has to be used.
+
+**How you can avoid this**
+ You can use [math.isclose](https://docs.python.org/3/library/math.html#math.isclose) to check if two floats are close, or to get an exact decimal representation, you can use the [decimal](https://docs.python.org/3/library/decimal.html) or [fractions](https://docs.python.org/3/library/fractions.html) module. Here are some examples:
+```python
+>>> math.isclose(0.1 + 0.2, 0.3)
+True
+>>> decimal.Decimal('0.1') + decimal.Decimal('0.2')
+Decimal('0.3')
+```
+Note that with `decimal.Decimal` we enter the number we want as a string so we don't pass on the imprecision from the float.
+
+For more details on why this happens check out this [page in the python docs](https://docs.python.org/3/tutorial/floatingpoint.html) or this [Computerphile video](https://www.youtube.com/watch/PZRI1IfStY0).
diff --git a/bot/resources/tags/free.md b/bot/resources/tags/free.md
deleted file mode 100644
index 1493076c7..000000000
--- a/bot/resources/tags/free.md
+++ /dev/null
@@ -1,5 +0,0 @@
-**We have a new help channel system!**
-
-Please see <#704250143020417084> for further information.
-
-A more detailed guide can be found on [our website](https://pythondiscord.com/pages/resources/guides/help-channels/).
diff --git a/bot/resources/tags/inline.md b/bot/resources/tags/inline.md
index a6a7c35d6..4ece74ef7 100644
--- a/bot/resources/tags/inline.md
+++ b/bot/resources/tags/inline.md
@@ -1,16 +1,7 @@
**Inline codeblocks**
-In addition to multi-line codeblocks, discord has support for inline codeblocks as well. These are small codeblocks that are usually a single line, that can fit between non-codeblocks on the same line.
+Inline codeblocks look `like this`. To create them you surround text with single backticks, so \`hello\` would become `hello`.
-The following is an example of how it's done:
+Note that backticks are not quotes, see [this](https://superuser.com/questions/254076/how-do-i-type-the-tick-and-backtick-characters-on-windows/254077#254077) if you are struggling to find the backtick key.
-The \`\_\_init\_\_\` method customizes the newly created instance.
-
-And results in the following:
-
-The `__init__` method customizes the newly created instance.
-
-**Note:**
-• These are **backticks** not quotes
-• Avoid using them for multiple lines
-• Useful for negating formatting you don't want
+For how to make multiline codeblocks see the `!codeblock` tag.
diff --git a/bot/resources/tags/listcomps.md b/bot/resources/tags/listcomps.md
index 0003b9bb8..ba00a4bf7 100644
--- a/bot/resources/tags/listcomps.md
+++ b/bot/resources/tags/listcomps.md
@@ -1,14 +1,19 @@
-Do you ever find yourself writing something like:
+Do you ever find yourself writing something like this?
```py
-even_numbers = []
-for n in range(20):
- if n % 2 == 0:
- even_numbers.append(n)
+>>> squares = []
+>>> for n in range(5):
+... squares.append(n ** 2)
+[0, 1, 4, 9, 16]
```
-Using list comprehensions can simplify this significantly, and greatly improve code readability. If we rewrite the example above to use list comprehensions, it would look like this:
+Using list comprehensions can make this both shorter and more readable. As a list comprehension, the same code would look like this:
```py
-even_numbers = [n for n in range(20) if n % 2 == 0]
+>>> [n ** 2 for n in range(5)]
+[0, 1, 4, 9, 16]
+```
+List comprehensions also get an `if` statement:
+```python
+>>> [n ** 2 for n in range(5) if n % 2 == 0]
+[0, 4, 16]
```
-This also works for generators, dicts and sets by using `()` or `{}` instead of `[]`.
-For more info, see [this pythonforbeginners.com post](http://www.pythonforbeginners.com/basics/list-comprehensions-in-python) or [PEP 202](https://www.python.org/dev/peps/pep-0202/).
+For more info, see [this pythonforbeginners.com post](http://www.pythonforbeginners.com/basics/list-comprehensions-in-python).
diff --git a/bot/resources/tags/local-file.md b/bot/resources/tags/local-file.md
new file mode 100644
index 000000000..ae41d589c
--- /dev/null
+++ b/bot/resources/tags/local-file.md
@@ -0,0 +1,23 @@
+Thanks to discord.py, sending local files as embed images is simple. You have to create an instance of [`discord.File`](https://discordpy.readthedocs.io/en/latest/api.html#discord.File) class:
+```py
+# When you know the file exact path, you can pass it.
+file = discord.File("/this/is/path/to/my/file.png", filename="file.png")
+
+# When you have the file-like object, then you can pass this instead path.
+with open("/this/is/path/to/my/file.png", "rb") as f:
+ file = discord.File(f)
+```
+When using the file-like object, you have to open it in `rb` mode. Also, in this case, passing `filename` to it is not necessary.
+Please note that `filename` can't contain underscores. This is a Discord limitation.
+
+[`discord.Embed`](https://discordpy.readthedocs.io/en/latest/api.html#discord.Embed) instances have a [`set_image`](https://discordpy.readthedocs.io/en/latest/api.html#discord.Embed.set_image) method which can be used to set an attachment as an image:
+```py
+embed = discord.Embed()
+# Set other fields
+embed.set_image(url="attachment://file.png") # Filename here must be exactly same as attachment filename.
+```
+After this, you can send an embed with an attachment to Discord:
+```py
+await channel.send(file=file, embed=embed)
+```
+This example uses [`discord.TextChannel`](https://discordpy.readthedocs.io/en/latest/api.html#discord.TextChannel) for sending, but any instance of [`discord.abc.Messageable`](https://discordpy.readthedocs.io/en/latest/api.html#discord.abc.Messageable) can be used for sending.
diff --git a/bot/resources/tags/off-topic.md b/bot/resources/tags/off-topic.md
index c7f98a813..6a864a1d5 100644
--- a/bot/resources/tags/off-topic.md
+++ b/bot/resources/tags/off-topic.md
@@ -6,3 +6,5 @@ There are three off-topic channels:
• <#463035268514185226>
Their names change randomly every 24 hours, but you can always find them under the `OFF-TOPIC/GENERAL` category in the channel list.
+
+Please read our [off-topic etiquette](https://pythondiscord.com/pages/resources/guides/off-topic-etiquette/) before participating in conversations.
diff --git a/bot/resources/tags/pep8.md b/bot/resources/tags/pep8.md
index cab4c4db8..57b176122 100644
--- a/bot/resources/tags/pep8.md
+++ b/bot/resources/tags/pep8.md
@@ -1,3 +1,5 @@
-**PEP 8** is the official style guide for Python. It includes comprehensive guidelines for code formatting, variable naming, and making your code easy to read. Professional Python developers are usually required to follow the guidelines, and will often use code-linters like `flake8` to verify that the code they\'re writing complies with the style guide.
+**PEP 8** is the official style guide for Python. It includes comprehensive guidelines for code formatting, variable naming, and making your code easy to read. Professional Python developers are usually required to follow the guidelines, and will often use code-linters like flake8 to verify that the code they're writing complies with the style guide.
-You can find the PEP 8 document [here](https://www.python.org/dev/peps/pep-0008).
+More information:
+• [PEP 8 document](https://www.python.org/dev/peps/pep-0008)
+• [Our PEP 8 song!](https://www.youtube.com/watch?v=hgI0p1zf31k) :notes:
diff --git a/bot/resources/tags/voice-verification.md b/bot/resources/tags/voice-verification.md
new file mode 100644
index 000000000..3d88b0c71
--- /dev/null
+++ b/bot/resources/tags/voice-verification.md
@@ -0,0 +1,3 @@
+**Voice verification**
+
+Can’t talk in voice chat? Check out <#764802555427029012> to get access. The criteria for verifying are specified there.
diff --git a/bot/rules/duplicates.py b/bot/rules/duplicates.py
index 455764b53..8e4fbc12d 100644
--- a/bot/rules/duplicates.py
+++ b/bot/rules/duplicates.py
@@ -13,6 +13,7 @@ async def apply(
if (
msg.author == last_message.author
and msg.content == last_message.content
+ and msg.content
)
)
diff --git a/bot/utils/channel.py b/bot/utils/channel.py
index 0c072184c..72603c521 100644
--- a/bot/utils/channel.py
+++ b/bot/utils/channel.py
@@ -32,6 +32,22 @@ def is_mod_channel(channel: discord.TextChannel) -> bool:
return False
+def is_staff_channel(channel: discord.TextChannel) -> bool:
+ """True if `channel` is considered a staff channel."""
+ guild = bot.instance.get_guild(constants.Guild.id)
+
+ if channel.type is discord.ChannelType.category:
+ return False
+
+ # Channel is staff-only if staff have explicit read allow perms
+ # and @everyone has explicit read deny perms
+ return any(
+ channel.overwrites_for(guild.get_role(staff_role)).read_messages is True
+ and channel.overwrites_for(guild.default_role).read_messages is False
+ for staff_role in constants.STAFF_ROLES
+ )
+
+
def is_in_category(channel: discord.TextChannel, category_id: int) -> bool:
"""Return True if `channel` is within a category with `category_id`."""
return getattr(channel, "category_id", None) == category_id
diff --git a/bot/utils/messages.py b/bot/utils/messages.py
index c42e4bacc..0bcaed43d 100644
--- a/bot/utils/messages.py
+++ b/bot/utils/messages.py
@@ -11,7 +11,7 @@ from discord.errors import HTTPException
from discord.ext.commands import Context
import bot
-from bot.constants import Emojis, NEGATIVE_REPLIES
+from bot.constants import Emojis, MODERATION_ROLES, NEGATIVE_REPLIES
log = logging.getLogger(__name__)
@@ -22,12 +22,15 @@ async def wait_for_deletion(
deletion_emojis: Sequence[str] = (Emojis.trashcan,),
timeout: float = 60 * 5,
attach_emojis: bool = True,
+ allow_moderation_roles: bool = True
) -> 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`.
+ An `allow_moderation_roles` bool may also be specified to allow anyone with a role in `MODERATION_ROLES` to delete
+ the message.
"""
if message.guild is None:
raise ValueError("Message must be sent on a guild")
@@ -45,7 +48,10 @@ async def wait_for_deletion(
return (
reaction.message.id == message.id
and str(reaction.emoji) in deletion_emojis
- and user.id in user_ids
+ and (
+ user.id in user_ids
+ or allow_moderation_roles and any(role.id in MODERATION_ROLES for role in user.roles)
+ )
)
with contextlib.suppress(asyncio.TimeoutError):
diff --git a/bot/utils/time.py b/bot/utils/time.py
index 47e49904b..466f0adc2 100644
--- a/bot/utils/time.py
+++ b/bot/utils/time.py
@@ -1,5 +1,6 @@
import asyncio
import datetime
+import re
from typing import Optional
import dateutil.parser
@@ -8,6 +9,16 @@ from dateutil.relativedelta import relativedelta
RFC1123_FORMAT = "%a, %d %b %Y %H:%M:%S GMT"
INFRACTION_FORMAT = "%Y-%m-%d %H:%M"
+_DURATION_REGEX = 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))?"
+)
+
def _stringify_time_unit(value: int, unit: str) -> str:
"""
@@ -74,6 +85,45 @@ def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units:
return humanized
+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 parse_duration_string(duration: str) -> Optional[relativedelta]:
+ """
+ Converts a `duration` string to a relativedelta object.
+
+ The function 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.
+ If the string does represent a durationdelta object, it will return None.
+ """
+ match = _DURATION_REGEX.fullmatch(duration)
+ if not match:
+ return None
+
+ duration_dict = {unit: int(amount) for unit, amount in match.groupdict(default=0).items()}
+ delta = relativedelta(**duration_dict)
+
+ return delta
+
+
+def relativedelta_to_timedelta(delta: relativedelta) -> datetime.timedelta:
+ """Converts a relativedelta object to a timedelta object."""
+ utcnow = datetime.datetime.utcnow()
+ return utcnow + delta - utcnow
+
+
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.
diff --git a/config-default.yml b/config-default.yml
index 6695cffed..502f0f861 100644
--- a/config-default.yml
+++ b/config-default.yml
@@ -1,74 +1,78 @@
bot:
prefix: "!"
- token: !ENV "BOT_TOKEN"
sentry_dsn: !ENV "BOT_SENTRY_DSN"
+ token: !ENV "BOT_TOKEN"
+
+ clean:
+ # Maximum number of messages to traverse for clean commands
+ message_limit: 10000
+
+ cooldowns:
+ # Per channel, per tag.
+ tags: 60
redis:
host: "redis.default.svc.cluster.local"
- port: 6379
password: !ENV "REDIS_PASSWORD"
+ port: 6379
use_fakeredis: false
stats:
- statsd_host: "graphite.default.svc.cluster.local"
presence_update_timeout: 300
-
- cooldowns:
- # Per channel, per tag.
- tags: 60
-
- clean:
- # Maximum number of messages to traverse for clean commands
- message_limit: 10000
+ statsd_host: "graphite.default.svc.cluster.local"
style:
colours:
- soft_red: 0xcd6d6d
- soft_green: 0x68c290
- soft_orange: 0xf9cb54
+ blue: 0x3775a8
bright_green: 0x01d277
orange: 0xe67e22
pink: 0xcf84e0
purple: 0xb734eb
+ soft_green: 0x68c290
+ soft_orange: 0xf9cb54
+ soft_red: 0xcd6d6d
+ white: 0xfffffe
+ yellow: 0xffd241
emojis:
- defcon_disabled: "<:defcondisabled:470326273952972810>"
- defcon_enabled: "<:defconenabled:470326274213150730>"
- defcon_updated: "<:defconsettingsupdated:470326274082996224>"
-
- status_online: "<:status_online:470326272351010816>"
- status_idle: "<:status_idle:470326266625785866>"
- status_dnd: "<:status_dnd:470326272082313216>"
- status_offline: "<:status_offline:470326266537705472>"
-
- badge_staff: "<:discord_staff:743882896498098226>"
- badge_partner: "<:partner:748666453242413136>"
- badge_hypesquad: "<:hypesquad_events:743882896892362873>"
badge_bug_hunter: "<:bug_hunter_lvl1:743882896372269137>"
+ badge_bug_hunter_level_2: "<:bug_hunter_lvl2:743882896611344505>"
+ badge_early_supporter: "<:early_supporter:743882896909140058>"
+ badge_hypesquad: "<:hypesquad_events:743882896892362873>"
+ badge_hypesquad_balance: "<:hypesquad_balance:743882896460480625>"
badge_hypesquad_bravery: "<:hypesquad_bravery:743882896745693335>"
badge_hypesquad_brilliance: "<:hypesquad_brilliance:743882896938631248>"
- badge_hypesquad_balance: "<:hypesquad_balance:743882896460480625>"
- badge_early_supporter: "<:early_supporter:743882896909140058>"
- badge_bug_hunter_level_2: "<:bug_hunter_lvl2:743882896611344505>"
+ badge_partner: "<:partner:748666453242413136>"
+ badge_staff: "<:discord_staff:743882896498098226>"
badge_verified_bot_developer: "<:verified_bot_dev:743882897299210310>"
- incident_actioned: "<:incident_actioned:719645530128646266>"
- incident_unactioned: "<:incident_unactioned:719645583245180960>"
- incident_investigating: "<:incident_investigating:719645658671480924>"
+ defcon_shutdown: "<:defcondisabled:470326273952972810>"
+ defcon_unshutdown: "<:defconenabled:470326274213150730>"
+ defcon_update: "<:defconsettingsupdated:470326274082996224>"
failmail: "<:failmail:633660039931887616>"
+
+ incident_actioned: "<:incident_actioned:719645530128646266>"
+ incident_investigating: "<:incident_investigating:719645658671480924>"
+ incident_unactioned: "<:incident_unactioned:719645583245180960>"
+
+ status_dnd: "<:status_dnd:470326272082313216>"
+ status_idle: "<:status_idle:470326266625785866>"
+ status_offline: "<:status_offline:470326266537705472>"
+ status_online: "<:status_online:470326272351010816>"
+
trashcan: "<:trashcan:637136429717389331>"
bullet: "\u2022"
- pencil: "\u270F"
- new: "\U0001F195"
- cross_mark: "\u274C"
check_mark: "\u2705"
+ cross_mark: "\u274C"
+ new: "\U0001F195"
+ pencil: "\u270F"
# emotes used for #reddit
- upvotes: "<:reddit_upvotes:755845219890757644>"
comments: "<:reddit_comments:755845255001014384>"
+ upvotes: "<:reddit_upvotes:755845219890757644>"
user: "<:reddit_users:755845303822974997>"
ok_hand: ":ok_hand:"
@@ -79,12 +83,14 @@ style:
crown_red: "https://cdn.discordapp.com/emojis/469964154879344640.png"
defcon_denied: "https://cdn.discordapp.com/emojis/472475292078964738.png"
- defcon_disabled: "https://cdn.discordapp.com/emojis/470326273952972810.png"
- defcon_enabled: "https://cdn.discordapp.com/emojis/470326274213150730.png"
- defcon_updated: "https://cdn.discordapp.com/emojis/472472638342561793.png"
+ defcon_shutdown: "https://cdn.discordapp.com/emojis/470326273952972810.png"
+ defcon_unshutdown: "https://cdn.discordapp.com/emojis/470326274213150730.png"
+ defcon_update: "https://cdn.discordapp.com/emojis/472472638342561793.png"
filtering: "https://cdn.discordapp.com/emojis/472472638594482195.png"
+ green_checkmark: "https://raw.githubusercontent.com/python-discord/branding/main/icons/checkmark/green-checkmark-dist.png"
+ green_questionmark: "https://raw.githubusercontent.com/python-discord/branding/main/icons/checkmark/green-question-mark-dist.png"
guild_update: "https://cdn.discordapp.com/emojis/469954765141442561.png"
hash_blurple: "https://cdn.discordapp.com/emojis/469950142942806017.png"
@@ -95,38 +101,34 @@ style:
message_delete: "https://cdn.discordapp.com/emojis/472472641320648704.png"
message_edit: "https://cdn.discordapp.com/emojis/472472638976163870.png"
+ pencil: "https://cdn.discordapp.com/emojis/470326272401211415.png"
+
+ questionmark: "https://cdn.discordapp.com/emojis/512367613339369475.png"
+
+ remind_blurple: "https://cdn.discordapp.com/emojis/477907609215827968.png"
+ remind_green: "https://cdn.discordapp.com/emojis/477907607785570310.png"
+ remind_red: "https://cdn.discordapp.com/emojis/477907608057937930.png"
+
sign_in: "https://cdn.discordapp.com/emojis/469952898181234698.png"
sign_out: "https://cdn.discordapp.com/emojis/469952898089091082.png"
+ superstarify: "https://cdn.discordapp.com/emojis/636288153044516874.png"
+ unsuperstarify: "https://cdn.discordapp.com/emojis/636288201258172446.png"
+
token_removed: "https://cdn.discordapp.com/emojis/470326273298792469.png"
user_ban: "https://cdn.discordapp.com/emojis/469952898026045441.png"
- user_unban: "https://cdn.discordapp.com/emojis/469952898692808704.png"
- user_update: "https://cdn.discordapp.com/emojis/469952898684551168.png"
-
user_mute: "https://cdn.discordapp.com/emojis/472472640100106250.png"
+ user_unban: "https://cdn.discordapp.com/emojis/469952898692808704.png"
user_unmute: "https://cdn.discordapp.com/emojis/472472639206719508.png"
+ user_update: "https://cdn.discordapp.com/emojis/469952898684551168.png"
user_verified: "https://cdn.discordapp.com/emojis/470326274519334936.png"
-
user_warn: "https://cdn.discordapp.com/emojis/470326274238447633.png"
- pencil: "https://cdn.discordapp.com/emojis/470326272401211415.png"
-
- remind_blurple: "https://cdn.discordapp.com/emojis/477907609215827968.png"
- remind_green: "https://cdn.discordapp.com/emojis/477907607785570310.png"
- remind_red: "https://cdn.discordapp.com/emojis/477907608057937930.png"
-
- questionmark: "https://cdn.discordapp.com/emojis/512367613339369475.png"
-
- superstarify: "https://cdn.discordapp.com/emojis/636288153044516874.png"
- unsuperstarify: "https://cdn.discordapp.com/emojis/636288201258172446.png"
-
voice_state_blue: "https://cdn.discordapp.com/emojis/656899769662439456.png"
voice_state_green: "https://cdn.discordapp.com/emojis/656899770094452754.png"
voice_state_red: "https://cdn.discordapp.com/emojis/656899769905709076.png"
- green_checkmark: "https://raw.githubusercontent.com/python-discord/branding/master/icons/checkmark/green-checkmark-dist.png"
-
guild:
id: 267624335836053506
@@ -134,19 +136,19 @@ guild:
categories:
help_available: 691405807388196926
- help_in_use: 696958401460043776
help_dormant: 691405908919451718
- modmail: &MODMAIL 714494672835444826
+ help_in_use: 696958401460043776
logs: &LOGS 468520609152892958
+ modmail: &MODMAIL 714494672835444826
voice: 356013253765234688
channels:
# Public announcement and news channels
- change_log: &CHANGE_LOG 748238795236704388
announcements: &ANNOUNCEMENTS 354619224620138496
- python_news: &PYNEWS_CHANNEL 704372456592506880
- python_events: &PYEVENTS_CHANNEL 729674110270963822
+ change_log: &CHANGE_LOG 748238795236704388
mailing_lists: &MAILING_LISTS 704372456592506880
+ python_events: &PYEVENTS_CHANNEL 729674110270963822
+ python_news: &PYNEWS_CHANNEL 704372456592506880
reddit: &REDDIT_CHANNEL 458224812528238616
user_event_announcements: &USER_EVENT_A 592000283102674944
@@ -167,11 +169,11 @@ guild:
# Logs
attachment_log: &ATTACH_LOG 649243850006855680
+ dm_log: 653713721625018428
message_log: &MESSAGE_LOG 467752170159079424
mod_log: &MOD_LOG 282638479504965634
user_log: 528976905546760203
voice_log: 640292421988646961
- dm_log: 653713721625018428
# Off-topic
off_topic_0: 291284109232308226
@@ -187,22 +189,24 @@ guild:
admins: &ADMINS 365960823622991872
admin_spam: &ADMIN_SPAM 563594791770914816
defcon: &DEFCON 464469101889454091
+ duck_pond: &DUCK_POND 637820308341915648
helpers: &HELPERS 385474242440986624
incidents: 714214212200562749
incidents_archive: 720668923636351037
mods: &MODS 305126844661760000
mod_alerts: 473092532147060736
+ mod_appeals: &MOD_APPEALS 808790025688711198
+ mod_meta: &MOD_META 775412552795947058
mod_spam: &MOD_SPAM 620607373828030464
mod_tools: &MOD_TOOLS 775413915391098921
- mod_meta: &MOD_META 775412552795947058
+ nomination_voting: 822853512709931008
organisation: &ORGANISATION 551789653284356126
staff_lounge: &STAFF_LOUNGE 464905259261755392
- duck_pond: &DUCK_POND 637820308341915648
# Staff announcement channels
- staff_announcements: &STAFF_ANNOUNCEMENTS 464033278631084042
- mod_announcements: &MOD_ANNOUNCEMENTS 372115205867700225
admin_announcements: &ADMIN_ANNOUNCEMENTS 749736155569848370
+ mod_announcements: &MOD_ANNOUNCEMENTS 372115205867700225
+ staff_announcements: &STAFF_ANNOUNCEMENTS 464033278631084042
# Voice Channels
admins_voice: &ADMINS_VOICE 500734494840717332
@@ -228,6 +232,7 @@ guild:
moderation_channels:
- *ADMINS
- *ADMIN_SPAM
+ - *MOD_APPEALS
- *MOD_META
- *MOD_TOOLS
- *MODS
@@ -254,12 +259,12 @@ guild:
partners: 323426753857191936
python_community: &PY_COMMUNITY_ROLE 458226413825294336
sprinters: &SPRINTERS 758422482289426471
-
voice_verified: 764802720779337729
# Staff
admins: &ADMINS_ROLE 267628507062992896
core_developers: 587606783669829632
+ devops: 409416496733880320
helpers: &HELPERS_ROLE 267630620367257601
moderators: &MODS_ROLE 267629731250176001
owners: &OWNERS_ROLE 267627879762755584
@@ -269,15 +274,15 @@ guild:
team_leaders: 737250302834638889
moderation_roles:
- - *OWNERS_ROLE
- *ADMINS_ROLE
- *MODS_ROLE
+ - *OWNERS_ROLE
staff_roles:
- - *OWNERS_ROLE
- *ADMINS_ROLE
- - *MODS_ROLE
- *HELPERS_ROLE
+ - *MODS_ROLE
+ - *OWNERS_ROLE
webhooks:
big_brother: 569133704568373283
@@ -292,59 +297,62 @@ guild:
filter:
# What do we filter?
- filter_zalgo: false
- filter_invites: true
filter_domains: true
filter_everyone_ping: true
+ filter_invites: true
+ filter_zalgo: false
watch_regex: true
watch_rich_embeds: 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
notify_user_everyone_ping: true
+ notify_user_invites: true
+ notify_user_zalgo: false
# Filter configuration
- ping_everyone: true
offensive_msg_delete_days: 7 # How many days before deleting an offensive message?
+ ping_everyone: true
# Censor doesn't apply to these
channel_whitelist:
- *ADMINS
- - *MOD_LOG
- - *MESSAGE_LOG
- - *DEV_LOG
- *BB_LOGS
+ - *DEV_LOG
+ - *MESSAGE_LOG
+ - *MOD_LOG
- *STAFF_LOUNGE
- *TALENT_POOL
- *USER_EVENT_A
role_whitelist:
- *ADMINS_ROLE
+ - *HELPERS_ROLE
- *MODS_ROLE
- *OWNERS_ROLE
- - *HELPERS_ROLE
- *PY_COMMUNITY_ROLE
- *SPRINTERS
keys:
- site_api: !ENV "BOT_API_KEY"
github: !ENV "GITHUB_API_KEY"
+ site_api: !ENV "BOT_API_KEY"
urls:
# PyDis site vars
+ connect_max_retries: 3
+ connect_cooldown: 5
site: &DOMAIN "pythondiscord.com"
- site_api: &API !JOIN ["api.", *DOMAIN]
+ site_api: &API "pydis-api.default.svc.cluster.local"
+ site_api_schema: "http://"
site_paste: &PASTE !JOIN ["paste.", *DOMAIN]
- site_staff: &STAFF !JOIN ["staff.", *DOMAIN]
site_schema: &SCHEMA "https://"
+ site_staff: &STAFF !JOIN ["staff.", *DOMAIN]
- site_logs_view: !JOIN [*SCHEMA, *STAFF, "/bot/logs"]
paste_service: !JOIN [*SCHEMA, *PASTE, "/{key}"]
+ site_logs_view: !JOIN [*SCHEMA, *STAFF, "/bot/logs"]
# Snekbox
snekbox_eval_api: "http://snekbox.default.svc.cluster.local/eval"
@@ -353,8 +361,8 @@ urls:
discord_api: &DISCORD_API "https://discordapp.com/api/v7/"
discord_invite_api: !JOIN [*DISCORD_API, "invites"]
- # Misc URLs
- bot_avatar: "https://raw.githubusercontent.com/discord-python/branding/master/logos/logo_circle/logo_circle.png"
+ # Misc URLsw
+ bot_avatar: "https://raw.githubusercontent.com/python-discord/branding/main/logos/logo_circle/logo_circle.png"
github_bot_repo: "https://github.com/python-discord/bot"
@@ -364,13 +372,13 @@ anti_spam:
ping_everyone: true
punishment:
- role_id: *MUTED_ROLE
remove_after: 600
+ role_id: *MUTED_ROLE
rules:
attachments:
interval: 10
- max: 9
+ max: 6
burst:
interval: 10
@@ -388,14 +396,14 @@ anti_spam:
interval: 5
max: 3_000
- duplicates:
- interval: 10
- max: 3
-
discord_emojis:
interval: 10
max: 20
+ duplicates:
+ interval: 10
+ max: 3
+
links:
interval: 10
max: 10
@@ -415,15 +423,15 @@ anti_spam:
reddit:
+ client_id: !ENV "REDDIT_CLIENT_ID"
+ secret: !ENV "REDDIT_SECRET"
subreddits:
- 'r/Python'
- client_id: !ENV "REDDIT_CLIENT_ID"
- secret: !ENV "REDDIT_SECRET"
big_brother:
- log_delay: 15
header_message_limit: 15
+ log_delay: 15
code_block:
@@ -447,8 +455,8 @@ free:
# Seconds to elapse for a channel
# to be considered inactive.
activity_timeout: 600
- cooldown_rate: 1
cooldown_per: 60.0
+ cooldown_rate: 1
help_channels:
@@ -469,7 +477,7 @@ help_channels:
deleted_idle_minutes: 5
# Maximum number of channels to put in the available category
- max_available: 2
+ max_available: 3
# Maximum number of channels across all 3 categories
# Note Discord has a hard limit of 50 channels per category, so this shouldn't be > 50
@@ -493,8 +501,8 @@ help_channels:
redirect_output:
- delete_invocation: true
delete_delay: 15
+ delete_invocation: true
duck_pond:
@@ -514,20 +522,21 @@ duck_pond:
python_news:
+ channel: *PYNEWS_CHANNEL
+ webhook: *PYNEWS_WEBHOOK
+
mail_lists:
- 'python-ideas'
- 'python-announce-list'
- 'pypi-announce'
- 'python-dev'
- channel: *PYNEWS_CHANNEL
- webhook: *PYNEWS_WEBHOOK
voice_gate:
- minimum_days_member: 3 # How many days the user must have been a member for
- minimum_messages: 50 # How many messages a user must have to be eligible for voice
bot_message_delete_delay: 10 # Seconds before deleting bot's response in Voice Gate
minimum_activity_blocks: 3 # Number of 10 minute blocks during which a user must have been active
+ minimum_days_member: 3 # How many days the user must have been a member for
+ minimum_messages: 50 # How many messages a user must have to be eligible for voice
voice_ping_delete_delay: 60 # Seconds before deleting the bot's ping to user in Voice Gate
diff --git a/docker-compose.yml b/docker-compose.yml
index 0002d1d56..8afdd6ef1 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -57,8 +57,7 @@ services:
- web
- redis
- snekbox
+ env_file:
+ - .env
environment:
- BOT_TOKEN: ${BOT_TOKEN}
BOT_API_KEY: badbot13m0n8f570f942013fc818f234916ca531
- REDDIT_CLIENT_ID: ${REDDIT_CLIENT_ID}
- REDDIT_SECRET: ${REDDIT_SECRET}
diff --git a/tests/bot/exts/filters/test_token_remover.py b/tests/bot/exts/filters/test_token_remover.py
index f99cc3370..51feae9cb 100644
--- a/tests/bot/exts/filters/test_token_remover.py
+++ b/tests/bot/exts/filters/test_token_remover.py
@@ -291,7 +291,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):
channel=self.msg.channel.mention,
user_id=token.user_id,
timestamp=token.timestamp,
- hmac="x" * len(token.hmac),
+ hmac="xxxxxxxxxxxxxxxxxxxxxxxxjf4",
)
@autospec("bot.exts.filters.token_remover", "UNKNOWN_USER_LOG_MESSAGE")
@@ -318,7 +318,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):
return_value = TokenRemover.format_userid_log_message(msg, token)
- self.assertEqual(return_value, (known_user_log_message.format.return_value, False))
+ self.assertEqual(return_value, (known_user_log_message.format.return_value, True))
known_user_log_message.format.assert_called_once_with(
user_id=472265943062413332,
diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py
index bf557a484..08f39cd50 100644
--- a/tests/bot/exts/moderation/infraction/test_infractions.py
+++ b/tests/bot/exts/moderation/infraction/test_infractions.py
@@ -1,10 +1,12 @@
+import inspect
import textwrap
import unittest
-from unittest.mock import AsyncMock, MagicMock, Mock, patch
+from unittest.mock import ANY, AsyncMock, MagicMock, Mock, patch
from bot.constants import Event
+from bot.exts.moderation.infraction import _utils
from bot.exts.moderation.infraction.infractions import Infractions
-from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole
+from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole, MockUser, autospec
class TruncationTests(unittest.IsolatedAsyncioTestCase):
@@ -37,7 +39,7 @@ class TruncationTests(unittest.IsolatedAsyncioTestCase):
delete_message_days=0
)
self.cog.apply_infraction.assert_awaited_once_with(
- self.ctx, {"foo": "bar"}, self.target, self.ctx.guild.ban.return_value
+ self.ctx, {"foo": "bar", "purge": ""}, self.target, self.ctx.guild.ban.return_value
)
@patch("bot.exts.moderation.infraction._utils.post_infraction")
@@ -132,20 +134,29 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase):
self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar"))
self.cog.mod_log.ignore.assert_called_once_with(Event.member_update, self.user.id)
+ async def action_tester(self, action, reason: str) -> None:
+ """Helper method to test voice ban action."""
+ self.assertTrue(inspect.iscoroutine(action))
+ await action
+
+ self.user.move_to.assert_called_once_with(None, reason=ANY)
+ self.user.remove_roles.assert_called_once_with(self.cog._voice_verified_role, reason=reason)
+
@patch("bot.exts.moderation.infraction.infractions._utils.post_infraction")
@patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction")
async def test_voice_ban_apply_infraction(self, get_active_infraction, post_infraction_mock):
"""Should ignore Voice Verified role removing."""
self.cog.mod_log.ignore = MagicMock()
self.cog.apply_infraction = AsyncMock()
- self.user.remove_roles = MagicMock(return_value="my_return_value")
get_active_infraction.return_value = None
post_infraction_mock.return_value = {"foo": "bar"}
- self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar"))
- self.user.remove_roles.assert_called_once_with(self.cog._voice_verified_role, reason="foobar")
- self.cog.apply_infraction.assert_awaited_once_with(self.ctx, {"foo": "bar"}, self.user, "my_return_value")
+ reason = "foobar"
+ self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, reason))
+ self.cog.apply_infraction.assert_awaited_once_with(self.ctx, {"foo": "bar"}, self.user, ANY)
+
+ await self.action_tester(self.cog.apply_infraction.call_args[0][-1], reason)
@patch("bot.exts.moderation.infraction.infractions._utils.post_infraction")
@patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction")
@@ -153,16 +164,33 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase):
"""Should truncate reason for voice ban."""
self.cog.mod_log.ignore = MagicMock()
self.cog.apply_infraction = AsyncMock()
- self.user.remove_roles = MagicMock(return_value="my_return_value")
get_active_infraction.return_value = None
post_infraction_mock.return_value = {"foo": "bar"}
self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar" * 3000))
- self.user.remove_roles.assert_called_once_with(
- self.cog._voice_verified_role, reason=textwrap.shorten("foobar" * 3000, 512, placeholder="...")
- )
- self.cog.apply_infraction.assert_awaited_once_with(self.ctx, {"foo": "bar"}, self.user, "my_return_value")
+ self.cog.apply_infraction.assert_awaited_once_with(self.ctx, {"foo": "bar"}, self.user, ANY)
+
+ # Test action
+ action = self.cog.apply_infraction.call_args[0][-1]
+ await self.action_tester(action, textwrap.shorten("foobar" * 3000, 512, placeholder="..."))
+
+ @autospec(_utils, "post_infraction", "get_active_infraction", return_value=None)
+ @autospec(Infractions, "apply_infraction")
+ async def test_voice_ban_user_left_guild(self, apply_infraction_mock, post_infraction_mock, _):
+ """Should voice ban user that left the guild without throwing an error."""
+ infraction = {"foo": "bar"}
+ post_infraction_mock.return_value = {"foo": "bar"}
+
+ user = MockUser()
+ await self.cog.voiceban(self.cog, self.ctx, user, reason=None)
+ post_infraction_mock.assert_called_once_with(self.ctx, user, "voice_ban", None, active=True)
+ apply_infraction_mock.assert_called_once_with(self.cog, self.ctx, infraction, user, ANY)
+
+ # Test action
+ action = self.cog.apply_infraction.call_args[0][-1]
+ self.assertTrue(inspect.iscoroutine(action))
+ await action
async def test_voice_unban_user_not_found(self):
"""Should include info to return dict when user was not found from guild."""
diff --git a/tests/bot/exts/moderation/infraction/test_utils.py b/tests/bot/exts/moderation/infraction/test_utils.py
index 5b62463e0..ee9ff650c 100644
--- a/tests/bot/exts/moderation/infraction/test_utils.py
+++ b/tests/bot/exts/moderation/infraction/test_utils.py
@@ -146,7 +146,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):
name=utils.INFRACTION_AUTHOR_NAME,
url=utils.RULES_URL,
icon_url=Icons.token_removed
- ).set_footer(text=utils.INFRACTION_APPEAL_FOOTER),
+ ).set_footer(text=utils.INFRACTION_APPEAL_MODMAIL_FOOTER),
"send_result": True
},
{
@@ -164,9 +164,11 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):
name=utils.INFRACTION_AUTHOR_NAME,
url=utils.RULES_URL,
icon_url=Icons.token_removed
- ),
+ ).set_footer(text=utils.INFRACTION_APPEAL_MODMAIL_FOOTER),
"send_result": False
},
+ # Note that this test case asserts that the DM that *would* get sent to the user is formatted
+ # correctly, even though that message is deliberately never sent.
{
"args": (self.user, "note", None, None, Icons.defcon_denied),
"expected_output": Embed(
@@ -182,7 +184,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):
name=utils.INFRACTION_AUTHOR_NAME,
url=utils.RULES_URL,
icon_url=Icons.defcon_denied
- ),
+ ).set_footer(text=utils.INFRACTION_APPEAL_MODMAIL_FOOTER),
"send_result": False
},
{
@@ -200,7 +202,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):
name=utils.INFRACTION_AUTHOR_NAME,
url=utils.RULES_URL,
icon_url=Icons.defcon_denied
- ).set_footer(text=utils.INFRACTION_APPEAL_FOOTER),
+ ).set_footer(text=utils.INFRACTION_APPEAL_MODMAIL_FOOTER),
"send_result": False
},
{
@@ -218,7 +220,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):
name=utils.INFRACTION_AUTHOR_NAME,
url=utils.RULES_URL,
icon_url=Icons.defcon_denied
- ).set_footer(text=utils.INFRACTION_APPEAL_FOOTER),
+ ).set_footer(text=utils.INFRACTION_APPEAL_MODMAIL_FOOTER),
"send_result": True
}
]