aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar MarkKoz <[email protected]>2020-11-16 11:39:32 -0800
committerGravatar MarkKoz <[email protected]>2020-11-16 11:39:32 -0800
commiteef1252124ee163f78788f22fe192cb15540e9d3 (patch)
tree2b0e3ff371caeaffe200d90781a432edff9d22cd
parentSet up Sentry when running rather than upon import (diff)
parentMerge pull request #1284 from python-discord/sebastiaan/features/move-ci-to-g... (diff)
Merge master and fix conflicts
-rw-r--r--.github/workflows/codeql-analysis.yml32
-rw-r--r--.github/workflows/lint-test-build.yml157
-rw-r--r--LICENSE-THIRD-PARTY72
-rw-r--r--Pipfile2
-rw-r--r--Pipfile.lock328
-rw-r--r--README.md7
-rw-r--r--azure-pipelines.yml107
-rw-r--r--bot/constants.py21
-rw-r--r--bot/exts/filters/antimalware.py4
-rw-r--r--bot/exts/filters/antispam.py3
-rw-r--r--bot/exts/filters/filtering.py2
-rw-r--r--bot/exts/info/codeblock/_instructions.py4
-rw-r--r--bot/exts/info/codeblock/_parsing.py4
-rw-r--r--bot/exts/info/doc.py37
-rw-r--r--bot/exts/info/information.py49
-rw-r--r--bot/exts/info/reddit.py8
-rw-r--r--bot/exts/moderation/infraction/_scheduler.py23
-rw-r--r--bot/exts/moderation/infraction/_utils.py5
-rw-r--r--bot/exts/moderation/infraction/infractions.py80
-rw-r--r--bot/exts/moderation/infraction/management.py10
-rw-r--r--bot/exts/moderation/silence.py202
-rw-r--r--bot/exts/moderation/verification.py24
-rw-r--r--bot/exts/moderation/voice_gate.py265
-rw-r--r--bot/exts/utils/reminders.py8
-rw-r--r--bot/exts/utils/snekbox.py45
-rw-r--r--bot/exts/utils/utils.py194
-rw-r--r--bot/log.py2
-rw-r--r--bot/resources/tags/codeblock.md16
-rw-r--r--bot/resources/tags/guilds.md3
-rw-r--r--bot/utils/cache.py41
-rw-r--r--bot/utils/channel.py16
-rw-r--r--config-default.yml32
-rw-r--r--docker-compose.yml2
-rw-r--r--tests/_autospec.py64
-rw-r--r--tests/bot/exts/moderation/infraction/test_infractions.py148
-rw-r--r--tests/bot/exts/moderation/test_silence.py587
-rw-r--r--tests/bot/exts/utils/test_snekbox.py7
-rw-r--r--tests/helpers.py21
38 files changed, 1885 insertions, 747 deletions
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
deleted file mode 100644
index 8760b35ec..000000000
--- a/.github/workflows/codeql-analysis.yml
+++ /dev/null
@@ -1,32 +0,0 @@
-name: "Code scanning - action"
-
-on:
- push:
- pull_request:
- schedule:
- - cron: '0 12 * * *'
-
-jobs:
- CodeQL-Build:
-
- runs-on: ubuntu-latest
-
- steps:
- - name: Checkout repository
- uses: actions/checkout@v2
- with:
- fetch-depth: 2
-
- - run: git checkout HEAD^2
- if: ${{ github.event_name == 'pull_request' }}
-
- - name: Initialize CodeQL
- uses: github/codeql-action/init@v1
- with:
- languages: python
-
- - name: Autobuild
- uses: github/codeql-action/autobuild@v1
-
- - name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@v1
diff --git a/.github/workflows/lint-test-build.yml b/.github/workflows/lint-test-build.yml
new file mode 100644
index 000000000..c63f78ff6
--- /dev/null
+++ b/.github/workflows/lint-test-build.yml
@@ -0,0 +1,157 @@
+name: Lint, Test, Build
+
+on:
+ push:
+ branches:
+ - master
+ # We use pull_request_target as we get PRs from
+ # forks, but need to be able to add annotations
+ # for our flake8 step.
+ pull_request_target:
+
+
+jobs:
+ lint-test:
+ runs-on: ubuntu-latest
+ env:
+ # Dummy values for required bot environment variables
+ BOT_API_KEY: foo
+ BOT_SENTRY_DSN: blah
+ BOT_TOKEN: bar
+ REDDIT_CLIENT_ID: spam
+ REDDIT_SECRET: ham
+ REDIS_PASSWORD: ''
+
+ # Configure pip to cache dependencies and do a user install
+ PIP_NO_CACHE_DIR: false
+ PIP_USER: 1
+
+ # Hide the graphical elements from pipenv's output
+ PIPENV_HIDE_EMOJIS: 1
+ PIPENV_NOSPIN: 1
+
+ # Make sure pipenv does not try reuse an environment it's running in
+ PIPENV_IGNORE_VIRTUALENVS: 1
+
+ # Specify explicit paths for python dependencies and the pre-commit
+ # environment so we know which directories to cache
+ PYTHONUSERBASE: ${{ github.workspace }}/.cache/py-user-base
+ PRE_COMMIT_HOME: ${{ github.workspace }}/.cache/pre-commit-cache
+
+ steps:
+ - name: Add custom PYTHONUSERBASE to PATH
+ run: echo '${{ env.PYTHONUSERBASE }}/bin/' >> $GITHUB_PATH
+
+ # We don't want to persist credentials, as our GitHub Action
+ # may be run when a PR is made from a fork.
+ - name: Checkout repository
+ uses: actions/checkout@v2
+ with:
+ persist-credentials: false
+
+ - name: Setup python
+ id: python
+ uses: actions/setup-python@v2
+ with:
+ python-version: '3.8'
+
+ # This step caches our Python dependencies. To make sure we
+ # only restore a cache when the dependencies, the python version,
+ # the runner operating system, and the dependency location haven't
+ # changed, we create a cache key that is a composite of those states.
+ #
+ # Only when the context is exactly the same, we will restore the cache.
+ - name: Python Dependency Caching
+ uses: actions/cache@v2
+ id: python_cache
+ with:
+ path: ${{ env.PYTHONUSERBASE }}
+ key: "python-0-${{ runner.os }}-${{ env.PYTHONUSERBASE }}-\
+ ${{ steps.python.outputs.python-version }}-\
+ ${{ hashFiles('./Pipfile', './Pipfile.lock') }}"
+
+ # Install our dependencies if we did not restore a dependency cache
+ - name: Install dependencies using pipenv
+ if: steps.python_cache.outputs.cache-hit != 'true'
+ run: |
+ pip install pipenv
+ pipenv install --dev --deploy --system
+
+ # This step caches our pre-commit environment. To make sure we
+ # do create a new environment when our pre-commit setup changes,
+ # we create a cache key based on relevant factors.
+ - name: Pre-commit Environment Caching
+ uses: actions/cache@v2
+ with:
+ path: ${{ env.PRE_COMMIT_HOME }}
+ key: "precommit-0-${{ runner.os }}-${{ env.PRE_COMMIT_HOME }}-\
+ ${{ steps.python.outputs.python-version }}-\
+ ${{ hashFiles('./.pre-commit-config.yaml') }}"
+
+ # We will not run `flake8` here, as we will use a separate flake8
+ # action. As pre-commit does not support user installs, we set
+ # PIP_USER=0 to not do a user install.
+ - name: Run pre-commit hooks
+ run: export PIP_USER=0; SKIP=flake8 pre-commit run --all-files
+
+ # This step requires `pull_request_target`, as adding annotations
+ # requires "write" permissions to the repo.
+ - name: Run flake8
+ uses: julianwachholz/flake8-action@v1
+ with:
+ checkName: lint-test
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ # We run `coverage` using the `python` command so we can suppress
+ # irrelevant warnings in our CI output.
+ - name: Run tests and generate coverage report
+ run: |
+ python -Wignore -m coverage run -m unittest
+ coverage report -m
+
+ # This step will publish the coverage reports coveralls.io and
+ # print a "job" link in the output of the GitHub Action
+ - name: Publish coverage report to coveralls.io
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: coveralls
+
+ build-and-push:
+ needs: lint-test
+ if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/master'
+ runs-on: ubuntu-latest
+
+ steps:
+ # Create a commit SHA-based tag for the container repositories
+ - name: Create SHA Container Tag
+ id: sha_tag
+ run: |
+ tag=$(cut -c 1-7 <<< $GITHUB_SHA)
+ echo "::set-output name=tag::$tag"
+ - name: Checkout code
+ uses: actions/checkout@v2
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v1
+
+ - name: Login to Github Container Registry
+ uses: docker/login-action@v1
+ with:
+ registry: ghcr.io
+ username: ${{ github.repository_owner }}
+ password: ${{ secrets.GHCR_TOKEN }}
+
+ # This step builds and pushed the container to the
+ # Github Container Registry tagged with "latest" and
+ # the short SHA of the commit.
+ - name: Build and push
+ uses: docker/build-push-action@v2
+ with:
+ context: .
+ file: ./Dockerfile
+ push: true
+ cache-from: type=registry,ref=ghcr.io/python-discord/bot:latest
+ tags: |
+ ghcr.io/python-discord/bot:latest
+ ghcr.io/python-discord/bot:${{ steps.sha_tag.outputs.tag }}
diff --git a/LICENSE-THIRD-PARTY b/LICENSE-THIRD-PARTY
index 3349d7c05..eacd9b952 100644
--- a/LICENSE-THIRD-PARTY
+++ b/LICENSE-THIRD-PARTY
@@ -1,14 +1,13 @@
-BSD 3-Clause License
-
+---------------------------------------------------------------------------------------------------
+ BSD 3-Clause License
Applies to:
-- _RE_PYTHON_REPL and portions of _RE_IPYTHON_REPL in bot/cogs/codeblock/parsing.py
-
-- Copyright (c) 2008-Present, IPython Development Team
-- Copyright (c) 2001-2007, Fernando Perez <[email protected]>
-- Copyright (c) 2001, Janko Hauser <[email protected]>
-- Copyright (c) 2001, Nathaniel Gray <[email protected]>
-
-All rights reserved.
+ - Copyright (c) 2008-Present, IPython Development Team
+ Copyright (c) 2001-2007, Fernando Perez <[email protected]>
+ Copyright (c) 2001, Janko Hauser <[email protected]>
+ Copyright (c) 2001, Nathaniel Gray <[email protected]>
+ All rights reserved.
+ - bot/exts/info/codeblock/_parsing.py: _RE_PYTHON_REPL and portions of _RE_IPYTHON_REPL
+---------------------------------------------------------------------------------------------------
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
@@ -34,3 +33,56 @@ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+---------------------------------------------------------------------------------------------------
+ PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2
+Applies to:
+ - Copyright © 2001-2020 Python Software Foundation. All rights reserved.
+ - tests/_autospec.py: _decoration_helper
+---------------------------------------------------------------------------------------------------
+
+1. This LICENSE AGREEMENT is between the Python Software Foundation
+("PSF"), and the Individual or Organization ("Licensee") accessing and
+otherwise using this software ("Python") in source or binary form and
+its associated documentation.
+
+2. Subject to the terms and conditions of this License Agreement, PSF hereby
+grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce,
+analyze, test, perform and/or display publicly, prepare derivative works,
+distribute, and otherwise use Python alone or in any derivative version,
+provided, however, that PSF's License Agreement and PSF's notice of copyright,
+i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010,
+2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020 Python Software Foundation;
+All Rights Reserved" are retained in Python alone or in any derivative version
+prepared by Licensee.
+
+3. In the event Licensee prepares a derivative work that is based on
+or incorporates Python or any part thereof, and wants to make
+the derivative work available to others as provided herein, then
+Licensee hereby agrees to include in any such work a brief summary of
+the changes made to Python.
+
+4. PSF is making Python available to Licensee on an "AS IS"
+basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
+IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND
+DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
+FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT
+INFRINGE ANY THIRD PARTY RIGHTS.
+
+5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON
+FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS
+A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON,
+OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
+
+6. This License Agreement will automatically terminate upon a material
+breach of its terms and conditions.
+
+7. Nothing in this License Agreement shall be deemed to create any
+relationship of agency, partnership, or joint venture between PSF and
+Licensee. This License Agreement does not grant permission to use PSF
+trademarks or trade name in a trademark sense to endorse or promote
+products or services of Licensee, or any third party.
+
+8. By copying, installing or otherwise using Python, Licensee
+agrees to be bound by the terms and conditions of this License
+Agreement.
diff --git a/Pipfile b/Pipfile
index 99fc70b46..0730b9150 100644
--- a/Pipfile
+++ b/Pipfile
@@ -39,7 +39,7 @@ flake8-tidy-imports = "~=4.0"
flake8-todo = "~=0.7"
pep8-naming = "~=0.9"
pre-commit = "~=2.1"
-unittest-xml-reporting = "~=3.0"
+coveralls = "~=2.1"
[requires]
python_version = "3.8"
diff --git a/Pipfile.lock b/Pipfile.lock
index becd85c55..6a6a1aaf6 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
- "sha256": "073fd0c51749aafa188fdbe96c5b90dd157cb1d23bdd144801fb0d0a369ffa88"
+ "sha256": "ca6b100f7ee2e6e01eec413a754fc11be064e965a255b2c4927d4a2dd1c451ec"
},
"pipfile-spec": 6,
"requires": {
@@ -34,21 +34,22 @@
},
"aiohttp": {
"hashes": [
- "sha256:1e984191d1ec186881ffaed4581092ba04f7c61582a177b187d3a2f07ed9719e",
- "sha256:259ab809ff0727d0e834ac5e8a283dc5e3e0ecc30c4d80b3cd17a4139ce1f326",
- "sha256:2f4d1a4fdce595c947162333353d4a44952a724fba9ca3205a3df99a33d1307a",
- "sha256:32e5f3b7e511aa850829fbe5aa32eb455e5534eaa4b1ce93231d00e2f76e5654",
- "sha256:344c780466b73095a72c616fac5ea9c4665add7fc129f285fbdbca3cccf4612a",
- "sha256:460bd4237d2dbecc3b5ed57e122992f60188afe46e7319116da5eb8a9dfedba4",
- "sha256:4c6efd824d44ae697814a2a85604d8e992b875462c6655da161ff18fd4f29f17",
- "sha256:50aaad128e6ac62e7bf7bd1f0c0a24bc968a0c0590a726d5a955af193544bcec",
- "sha256:6206a135d072f88da3e71cc501c59d5abffa9d0bb43269a6dcd28d66bfafdbdd",
- "sha256:65f31b622af739a802ca6fd1a3076fd0ae523f8485c52924a89561ba10c49b48",
- "sha256:ae55bac364c405caa23a4f2d6cfecc6a0daada500274ffca4a9230e7129eac59",
- "sha256:b778ce0c909a2653741cb4b1ac7015b5c130ab9c897611df43ae6a58523cb965"
+ "sha256:1a4160579ffbc1b69e88cb6ca8bb0fbd4947dfcbf9fb1e2a4fc4c7a4a986c1fe",
+ "sha256:206c0ccfcea46e1bddc91162449c20c72f308aebdcef4977420ef329c8fcc599",
+ "sha256:2ad493de47a8f926386fa6d256832de3095ba285f325db917c7deae0b54a9fc8",
+ "sha256:319b490a5e2beaf06891f6711856ea10591cfe84fe9f3e71a721aa8f20a0872a",
+ "sha256:470e4c90da36b601676fe50c49a60d34eb8c6593780930b1aa4eea6f508dfa37",
+ "sha256:60f4caa3b7f7a477f66ccdd158e06901e1d235d572283906276e3803f6b098f5",
+ "sha256:66d64486172b032db19ea8522328b19cfb78a3e1e5b62ab6a0567f93f073dea0",
+ "sha256:687461cd974722110d1763b45c5db4d2cdee8d50f57b00c43c7590d1dd77fc5c",
+ "sha256:698cd7bc3c7d1b82bb728bae835724a486a8c376647aec336aa21a60113c3645",
+ "sha256:797456399ffeef73172945708810f3277f794965eb6ec9bd3a0c007c0476be98",
+ "sha256:a885432d3cabc1287bcf88ea94e1826d3aec57fd5da4a586afae4591b061d40d",
+ "sha256:c506853ba52e516b264b106321c424d03f3ddef2813246432fa9d1cefd361c81",
+ "sha256:fb83326d8295e8840e4ba774edf346e87eca78ba8a89c55d2690352842c15ba5"
],
"index": "pypi",
- "version": "==3.6.2"
+ "version": "==3.6.3"
},
"aioping": {
"hashes": [
@@ -68,11 +69,11 @@
},
"aiormq": {
"hashes": [
- "sha256:106695a836f19c1af6c46b58e8aac80e00f86c5b3287a3c6483a1ee369cc95c9",
- "sha256:9f6dbf6155fe2b7a3d24bf68de97fb812db0fac0a54e96bc1af14ea95078ba7f"
+ "sha256:8218dd9f7198d6e7935855468326bbacf0089f926c70baa8dd92944cb2496573",
+ "sha256:e584dac13a242589aaf42470fd3006cb0dc5aed6506cbd20357c7ec8bbe4a89e"
],
"markers": "python_version >= '3.6'",
- "version": "==3.2.3"
+ "version": "==3.3.1"
},
"alabaster": {
"hashes": [
@@ -103,35 +104,35 @@
},
"attrs": {
"hashes": [
- "sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594",
- "sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc"
+ "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6",
+ "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
- "version": "==20.2.0"
+ "version": "==20.3.0"
},
"babel": {
"hashes": [
- "sha256:1aac2ae2d0d8ea368fa90906567f5c08463d98ade155c0c4bfedd6a0f7160e38",
- "sha256:d670ea0b10f8b723672d3a6abeb87b565b244da220d76b4dba1b66269ec152d4"
+ "sha256:9d35c22fcc79893c3ecc85ac4a56cde1ecf3f19c540bba0922308a6c06ca6fa5",
+ "sha256:da031ab54472314f210b0adcff1588ee5d1d1d0ba4dbd07b94dba82bde791e05"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
- "version": "==2.8.0"
+ "version": "==2.9.0"
},
"beautifulsoup4": {
"hashes": [
- "sha256:1edf5e39f3a5bc6e38b235b369128416c7239b34f692acccececb040233032a1",
- "sha256:5dfe44f8fddc89ac5453f02659d3ab1668f2c0d9684839f0785037e8c6d9ac8d",
- "sha256:645d833a828722357038299b7f6879940c11dddd95b900fe5387c258b72bb883"
+ "sha256:4c98143716ef1cb40bf7f39a8e3eec8f8b009509e74904ba3a7b315431577e35",
+ "sha256:84729e322ad1d5b4d25f805bfa05b902dd96450f43842c4e99067d5e1369eb25",
+ "sha256:fff47e031e34ec82bf17e00da8f592fe7de69aeea38be00523c04623c04fb666"
],
"index": "pypi",
- "version": "==4.9.2"
+ "version": "==4.9.3"
},
"certifi": {
"hashes": [
- "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3",
- "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41"
+ "sha256:1f422849db327d534e3d0c5f02a263458c3955ec0aae4ff09b95f195c59f4edd",
+ "sha256:f05def092c44fbf25834a51509ef6e631dc19765ab8a57b4e7ab85531f0a9cf4"
],
- "version": "==2020.6.20"
+ "version": "==2020.11.8"
},
"cffi": {
"hashes": [
@@ -183,11 +184,11 @@
},
"colorama": {
"hashes": [
- "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff",
- "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"
+ "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b",
+ "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"
],
"markers": "sys_platform == 'win32'",
- "version": "==0.4.3"
+ "version": "==0.4.4"
},
"coloredlogs": {
"hashes": [
@@ -207,11 +208,11 @@
},
"discord.py": {
"hashes": [
- "sha256:3acb61fde0d862ed346a191d69c46021e6063673f63963bc984ae09a685ab211",
- "sha256:e71089886aa157341644bdecad63a72ff56b44406b1a6467b66db31c8e5a5a15"
+ "sha256:2367359e31f6527f8a936751fc20b09d7495dd6a76b28c8fb13d4ca6c55b7563",
+ "sha256:def00dc50cf36d21346d71bc89f0cad8f18f9a3522978dc18c7796287d47de8b"
],
"index": "pypi",
- "version": "==1.5.0"
+ "version": "==1.5.1"
},
"docutils": {
"hashes": [
@@ -223,10 +224,10 @@
},
"fakeredis": {
"hashes": [
- "sha256:7ea0866ba5edb40fe2e9b1722535df0c7e6b91d518aa5f50d96c2fff3ea7f4c2",
- "sha256:aad8836ffe0319ffbba66dcf872ac6e7e32d1f19790e31296ba58445efb0a5c7"
+ "sha256:8070b7fce16f828beaef2c757a4354af91698685d5232404f1aeeb233529c7a5",
+ "sha256:f8c8ea764d7b6fd801e7f5486e3edd32ca991d506186f1923a01fc072e33c271"
],
- "version": "==1.4.3"
+ "version": "==1.4.4"
},
"feedparser": {
"hashes": [
@@ -331,40 +332,46 @@
},
"lxml": {
"hashes": [
- "sha256:05a444b207901a68a6526948c7cc8f9fe6d6f24c70781488e32fd74ff5996e3f",
- "sha256:08fc93257dcfe9542c0a6883a25ba4971d78297f63d7a5a26ffa34861ca78730",
- "sha256:107781b213cf7201ec3806555657ccda67b1fccc4261fb889ef7fc56976db81f",
- "sha256:121b665b04083a1e85ff1f5243d4a93aa1aaba281bc12ea334d5a187278ceaf1",
- "sha256:1fa21263c3aba2b76fd7c45713d4428dbcc7644d73dcf0650e9d344e433741b3",
- "sha256:2b30aa2bcff8e958cd85d907d5109820b01ac511eae5b460803430a7404e34d7",
- "sha256:4b4a111bcf4b9c948e020fd207f915c24a6de3f1adc7682a2d92660eb4e84f1a",
- "sha256:5591c4164755778e29e69b86e425880f852464a21c7bb53c7ea453bbe2633bbe",
- "sha256:59daa84aef650b11bccd18f99f64bfe44b9f14a08a28259959d33676554065a1",
- "sha256:5a9c8d11aa2c8f8b6043d845927a51eb9102eb558e3f936df494e96393f5fd3e",
- "sha256:5dd20538a60c4cc9a077d3b715bb42307239fcd25ef1ca7286775f95e9e9a46d",
- "sha256:74f48ec98430e06c1fa8949b49ebdd8d27ceb9df8d3d1c92e1fdc2773f003f20",
- "sha256:786aad2aa20de3dbff21aab86b2fb6a7be68064cbbc0219bde414d3a30aa47ae",
- "sha256:7ad7906e098ccd30d8f7068030a0b16668ab8aa5cda6fcd5146d8d20cbaa71b5",
- "sha256:80a38b188d20c0524fe8959c8ce770a8fdf0e617c6912d23fc97c68301bb9aba",
- "sha256:8f0ec6b9b3832e0bd1d57af41f9238ea7709bbd7271f639024f2fc9d3bb01293",
- "sha256:92282c83547a9add85ad658143c76a64a8d339028926d7dc1998ca029c88ea6a",
- "sha256:94150231f1e90c9595ccc80d7d2006c61f90a5995db82bccbca7944fd457f0f6",
- "sha256:9dc9006dcc47e00a8a6a029eb035c8f696ad38e40a27d073a003d7d1443f5d88",
- "sha256:a76979f728dd845655026ab991df25d26379a1a8fc1e9e68e25c7eda43004bed",
- "sha256:aa8eba3db3d8761db161003e2d0586608092e217151d7458206e243be5a43843",
- "sha256:bea760a63ce9bba566c23f726d72b3c0250e2fa2569909e2d83cda1534c79443",
- "sha256:c3f511a3c58676147c277eff0224c061dd5a6a8e1373572ac817ac6324f1b1e0",
- "sha256:c9d317efde4bafbc1561509bfa8a23c5cab66c44d49ab5b63ff690f5159b2304",
- "sha256:cc411ad324a4486b142c41d9b2b6a722c534096963688d879ea6fa8a35028258",
- "sha256:cdc13a1682b2a6241080745b1953719e7fe0850b40a5c71ca574f090a1391df6",
- "sha256:cfd7c5dd3c35c19cec59c63df9571c67c6d6e5c92e0fe63517920e97f61106d1",
- "sha256:e1cacf4796b20865789083252186ce9dc6cc59eca0c2e79cca332bdff24ac481",
- "sha256:e70d4e467e243455492f5de463b72151cc400710ac03a0678206a5f27e79ddef",
- "sha256:ecc930ae559ea8a43377e8b60ca6f8d61ac532fc57efb915d899de4a67928efd",
- "sha256:f161af26f596131b63b236372e4ce40f3167c1b5b5d459b29d2514bd8c9dc9ee"
- ],
- "index": "pypi",
- "version": "==4.5.2"
+ "sha256:098fb713b31050463751dcc694878e1d39f316b86366fb9fe3fbbe5396ac9fab",
+ "sha256:0e89f5d422988c65e6936e4ec0fe54d6f73f3128c80eb7ecc3b87f595523607b",
+ "sha256:189ad47203e846a7a4951c17694d845b6ade7917c47c64b29b86526eefc3adf5",
+ "sha256:1d87936cb5801c557f3e981c9c193861264c01209cb3ad0964a16310ca1b3301",
+ "sha256:211b3bcf5da70c2d4b84d09232534ad1d78320762e2c59dedc73bf01cb1fc45b",
+ "sha256:2358809cc64394617f2719147a58ae26dac9e21bae772b45cfb80baa26bfca5d",
+ "sha256:23c83112b4dada0b75789d73f949dbb4e8f29a0a3511647024a398ebd023347b",
+ "sha256:24e811118aab6abe3ce23ff0d7d38932329c513f9cef849d3ee88b0f848f2aa9",
+ "sha256:2d5896ddf5389560257bbe89317ca7bcb4e54a02b53a3e572e1ce4226512b51b",
+ "sha256:2d6571c48328be4304aee031d2d5046cbc8aed5740c654575613c5a4f5a11311",
+ "sha256:2e311a10f3e85250910a615fe194839a04a0f6bc4e8e5bb5cac221344e3a7891",
+ "sha256:302160eb6e9764168e01d8c9ec6becddeb87776e81d3fcb0d97954dd51d48e0a",
+ "sha256:3a7a380bfecc551cfd67d6e8ad9faa91289173bdf12e9cfafbd2bdec0d7b1ec1",
+ "sha256:3d9b2b72eb0dbbdb0e276403873ecfae870599c83ba22cadff2db58541e72856",
+ "sha256:475325e037fdf068e0c2140b818518cf6bc4aa72435c407a798b2db9f8e90810",
+ "sha256:4b7572145054330c8e324a72d808c8c8fbe12be33368db28c39a255ad5f7fb51",
+ "sha256:4fff34721b628cce9eb4538cf9a73d02e0f3da4f35a515773cce6f5fe413b360",
+ "sha256:56eff8c6fb7bc4bcca395fdff494c52712b7a57486e4fbde34c31bb9da4c6cc4",
+ "sha256:573b2f5496c7e9f4985de70b9bbb4719ffd293d5565513e04ac20e42e6e5583f",
+ "sha256:7ecaef52fd9b9535ae5f01a1dd2651f6608e4ec9dc136fc4dfe7ebe3c3ddb230",
+ "sha256:803a80d72d1f693aa448566be46ffd70882d1ad8fc689a2e22afe63035eb998a",
+ "sha256:8862d1c2c020cb7a03b421a9a7b4fe046a208db30994fc8ff68c627a7915987f",
+ "sha256:9b06690224258db5cd39a84e993882a6874676f5de582da57f3df3a82ead9174",
+ "sha256:a71400b90b3599eb7bf241f947932e18a066907bf84617d80817998cee81e4bf",
+ "sha256:bb252f802f91f59767dcc559744e91efa9df532240a502befd874b54571417bd",
+ "sha256:be1ebf9cc25ab5399501c9046a7dcdaa9e911802ed0e12b7d620cd4bbf0518b3",
+ "sha256:be7c65e34d1b50ab7093b90427cbc488260e4b3a38ef2435d65b62e9fa3d798a",
+ "sha256:c0dac835c1a22621ffa5e5f999d57359c790c52bbd1c687fe514ae6924f65ef5",
+ "sha256:c152b2e93b639d1f36ec5a8ca24cde4a8eefb2b6b83668fcd8e83a67badcb367",
+ "sha256:d182eada8ea0de61a45a526aa0ae4bcd222f9673424e65315c35820291ff299c",
+ "sha256:d18331ea905a41ae71596502bd4c9a2998902328bbabd29e3d0f5f8569fabad1",
+ "sha256:d20d32cbb31d731def4b1502294ca2ee99f9249b63bc80e03e67e8f8e126dea8",
+ "sha256:d4ad7fd3269281cb471ad6c7bafca372e69789540d16e3755dd717e9e5c9d82f",
+ "sha256:d6f8c23f65a4bfe4300b85f1f40f6c32569822d08901db3b6454ab785d9117cc",
+ "sha256:d84d741c6e35c9f3e7406cb7c4c2e08474c2a6441d59322a00dcae65aac6315d",
+ "sha256:e65c221b2115a91035b55a593b6eb94aa1206fa3ab374f47c6dc10d364583ff9",
+ "sha256:f98b6f256be6cec8dd308a8563976ddaff0bdc18b730720f6f4bee927ffe926f"
+ ],
+ "index": "pypi",
+ "version": "==4.6.1"
},
"markdownify": {
"hashes": [
@@ -415,11 +422,11 @@
},
"more-itertools": {
"hashes": [
- "sha256:6f83822ae94818eae2612063a5101a7311e68ae8002005b5e05f03fd74a86a20",
- "sha256:9b30f12df9393f0d28af9210ff8efe48d10c94f73e5daf886f10c4b0b0b4f03c"
+ "sha256:8e1a2a43b2f2727425f2b5839587ae37093f19153dc26c0927d1048ff6557330",
+ "sha256:b3a9005928e5bed54076e6e549c792b306fddfe72b2d1d22dd63d42d5d3899cf"
],
"index": "pypi",
- "version": "==8.5.0"
+ "version": "==8.6.0"
},
"multidict": {
"hashes": [
@@ -510,11 +517,11 @@
},
"pygments": {
"hashes": [
- "sha256:307543fe65c0947b126e83dd5a61bd8acbd84abec11f43caebaf5534cbc17998",
- "sha256:926c3f319eda178d1bd90851e4317e6d8cdb5e292a3386aac9bd75eca29cf9c7"
+ "sha256:381985fcc551eb9d37c52088a32914e00517e57f4a21609f48141ba08e193fa0",
+ "sha256:88a0bbcd659fcb9573703957c6b9cff9fab7295e6e76db54c9d00ae42df32773"
],
"markers": "python_version >= '3.5'",
- "version": "==2.7.1"
+ "version": "==2.7.2"
},
"pyparsing": {
"hashes": [
@@ -534,10 +541,10 @@
},
"pytz": {
"hashes": [
- "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed",
- "sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048"
+ "sha256:3e6b7dd2d1e0a59084bcee14a17af60c5c562cdc16d828e8eba2e683d3a7e268",
+ "sha256:5c55e189b682d420be27c6995ba6edce0c0a77dd67bfbe2ae6607134d5851ffd"
],
- "version": "==2020.1"
+ "version": "==2020.4"
},
"pyyaml": {
"hashes": [
@@ -566,19 +573,19 @@
},
"requests": {
"hashes": [
- "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b",
- "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898"
+ "sha256:7f1a0b932f4a60a1a65caa4263921bb7d9ee911957e0ae4a23a6dd08185ad5f8",
+ "sha256:e786fa28d8c9154e6a4de5d46a1d921b8749f8b74e28bde23768e5e16eece998"
],
"index": "pypi",
- "version": "==2.24.0"
+ "version": "==2.25.0"
},
"sentry-sdk": {
"hashes": [
- "sha256:c9c0fa1412bad87104c4eee8dd36c7bbf60b0d92ae917ab519094779b22e6d9a",
- "sha256:e159f7c919d19ae86e5a4ff370fccc45149fab461fbeb93fb5a735a0b33a9cb1"
+ "sha256:81d7a5d8ca0b13a16666e8280127b004565aa988bfeec6481e98a8601804b215",
+ "sha256:fd48f627945511c140546939b4d73815be4860cd1d2b9149577d7f6563e7bd60"
],
"index": "pypi",
- "version": "==0.17.8"
+ "version": "==0.19.3"
},
"six": {
"hashes": [
@@ -597,10 +604,10 @@
},
"sortedcontainers": {
"hashes": [
- "sha256:4e73a757831fc3ca4de2859c422564239a31d8213d09a2a666e375807034d2ba",
- "sha256:c633ebde8580f241f274c1f8994a665c0e54a17724fecd0cae2f079e09c36d3f"
+ "sha256:37257a32add0a3ee490bb170b599e93095eed89a55da91fa9f48753ea12fd73f",
+ "sha256:59cc937650cf60d677c16775597c89a960658a09cf7c1a668f86e1e4464b10a1"
],
- "version": "==2.2.2"
+ "version": "==2.3.0"
},
"soupsieve": {
"hashes": [
@@ -676,34 +683,34 @@
},
"urllib3": {
"hashes": [
- "sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a",
- "sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461"
+ "sha256:19188f96923873c92ccb987120ec4acaa12f0461fa9ce5d3d0772bc965a39e08",
+ "sha256:d8ff90d979214d7b4f8ce956e80f4028fc6860e4431f731ea4a8c08f23f99473"
],
"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.25.10"
+ "version": "==1.26.2"
},
"yarl": {
"hashes": [
- "sha256:04a54f126a0732af75e5edc9addeaa2113e2ca7c6fce8974a63549a70a25e50e",
- "sha256:3cc860d72ed989f3b1f3abbd6ecf38e412de722fb38b8f1b1a086315cf0d69c5",
- "sha256:5d84cc36981eb5a8533be79d6c43454c8e6a39ee3118ceaadbd3c029ab2ee580",
- "sha256:5e447e7f3780f44f890360ea973418025e8c0cdcd7d6a1b221d952600fd945dc",
- "sha256:61d3ea3c175fe45f1498af868879c6ffeb989d4143ac542163c45538ba5ec21b",
- "sha256:67c5ea0970da882eaf9efcf65b66792557c526f8e55f752194eff8ec722c75c2",
- "sha256:6f6898429ec3c4cfbef12907047136fd7b9e81a6ee9f105b45505e633427330a",
- "sha256:7ce35944e8e61927a8f4eb78f5bc5d1e6da6d40eadd77e3f79d4e9399e263921",
- "sha256:b7c199d2cbaf892ba0f91ed36d12ff41ecd0dde46cbf64ff4bfe997a3ebc925e",
- "sha256:c15d71a640fb1f8e98a1423f9c64d7f1f6a3a168f803042eaf3a5b5022fde0c1",
- "sha256:c22607421f49c0cb6ff3ed593a49b6a99c6ffdeaaa6c944cdda83c2393c8864d",
- "sha256:c604998ab8115db802cc55cb1b91619b2831a6128a62ca7eea577fc8ea4d3131",
- "sha256:d088ea9319e49273f25b1c96a3763bf19a882cff774d1792ae6fba34bd40550a",
- "sha256:db9eb8307219d7e09b33bcb43287222ef35cbcf1586ba9472b0a4b833666ada1",
- "sha256:e31fef4e7b68184545c3d68baec7074532e077bd1906b040ecfba659737df188",
- "sha256:e32f0fb443afcfe7f01f95172b66f279938fbc6bdaebe294b0ff6747fb6db020",
- "sha256:fcbe419805c9b20db9a51d33b942feddbf6e7fb468cb20686fd7089d4164c12a"
+ "sha256:040b237f58ff7d800e6e0fd89c8439b841f777dd99b4a9cca04d6935564b9409",
+ "sha256:17668ec6722b1b7a3a05cc0167659f6c95b436d25a36c2d52db0eca7d3f72593",
+ "sha256:3a584b28086bc93c888a6c2aa5c92ed1ae20932f078c46509a66dce9ea5533f2",
+ "sha256:4439be27e4eee76c7632c2427ca5e73703151b22cae23e64adb243a9c2f565d8",
+ "sha256:48e918b05850fffb070a496d2b5f97fc31d15d94ca33d3d08a4f86e26d4e7c5d",
+ "sha256:9102b59e8337f9874638fcfc9ac3734a0cfadb100e47d55c20d0dc6087fb4692",
+ "sha256:9b930776c0ae0c691776f4d2891ebc5362af86f152dd0da463a6614074cb1b02",
+ "sha256:b3b9ad80f8b68519cc3372a6ca85ae02cc5a8807723ac366b53c0f089db19e4a",
+ "sha256:bc2f976c0e918659f723401c4f834deb8a8e7798a71be4382e024bcc3f7e23a8",
+ "sha256:c22c75b5f394f3d47105045ea551e08a3e804dc7e01b37800ca35b58f856c3d6",
+ "sha256:c52ce2883dc193824989a9b97a76ca86ecd1fa7955b14f87bf367a61b6232511",
+ "sha256:ce584af5de8830d8701b8979b18fcf450cef9a382b1a3c8ef189bedc408faf1e",
+ "sha256:da456eeec17fa8aa4594d9a9f27c0b1060b6a75f2419fe0c00609587b2695f4a",
+ "sha256:db6db0f45d2c63ddb1a9d18d1b9b22f308e52c83638c26b422d520a815c4b3fb",
+ "sha256:df89642981b94e7db5596818499c4b2219028f2a528c9c37cc1de45bf2fd3a3f",
+ "sha256:f18d68f2be6bf0e89f1521af2b1bb46e66ab0018faafa81d70f358153170a317",
+ "sha256:f379b7f83f23fe12823085cd6b906edc49df969eb99757f58ff382349a3303c6"
],
"markers": "python_version >= '3.5'",
- "version": "==1.6.0"
+ "version": "==1.5.1"
}
},
"develop": {
@@ -716,11 +723,18 @@
},
"attrs": {
"hashes": [
- "sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594",
- "sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc"
+ "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6",
+ "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
- "version": "==20.2.0"
+ "version": "==20.3.0"
+ },
+ "certifi": {
+ "hashes": [
+ "sha256:1f422849db327d534e3d0c5f02a263458c3955ec0aae4ff09b95f195c59f4edd",
+ "sha256:f05def092c44fbf25834a51509ef6e631dc19765ab8a57b4e7ab85531f0a9cf4"
+ ],
+ "version": "==2020.11.8"
},
"cfgv": {
"hashes": [
@@ -730,6 +744,13 @@
"markers": "python_full_version >= '3.6.1'",
"version": "==3.2.0"
},
+ "chardet": {
+ "hashes": [
+ "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
+ "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
+ ],
+ "version": "==3.0.4"
+ },
"coverage": {
"hashes": [
"sha256:0203acd33d2298e19b57451ebb0bed0ab0c602e5cf5a818591b4918b1f97d516",
@@ -770,6 +791,14 @@
"index": "pypi",
"version": "==5.3"
},
+ "coveralls": {
+ "hashes": [
+ "sha256:4430b862baabb3cf090d36d84d331966615e4288d8a8c5957e0fd456d0dd8bd6",
+ "sha256:b3b60c17b03a0dee61952a91aed6f131e0b2ac8bd5da909389c53137811409e1"
+ ],
+ "index": "pypi",
+ "version": "==2.1.2"
+ },
"distlib": {
"hashes": [
"sha256:8c09de2c67b3e7deef7184574fc060ab8a793e7adbb183d942c389c8b13c52fb",
@@ -777,6 +806,12 @@
],
"version": "==0.3.1"
},
+ "docopt": {
+ "hashes": [
+ "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"
+ ],
+ "version": "==0.6.2"
+ },
"filelock": {
"hashes": [
"sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59",
@@ -786,19 +821,19 @@
},
"flake8": {
"hashes": [
- "sha256:15e351d19611c887e482fb960eae4d44845013cc142d42896e9862f775d8cf5c",
- "sha256:f04b9fcbac03b0a3e58c0ab3a0ecc462e023a9faf046d57794184028123aa208"
+ "sha256:749dbbd6bfd0cf1318af27bf97a14e28e5ff548ef8e5b1566ccfb25a11e7c839",
+ "sha256:aadae8761ec651813c24be05c6f7b4680857ef6afaae4651a4eccaef97ce6c3b"
],
"index": "pypi",
- "version": "==3.8.3"
+ "version": "==3.8.4"
},
"flake8-annotations": {
"hashes": [
- "sha256:09fe1aa3f40cb8fef632a0ab3614050a7584bb884b6134e70cf1fc9eeee642fa",
- "sha256:5bda552f074fd6e34276c7761756fa07d824ffac91ce9c0a8555eb2bc5b92d7a"
+ "sha256:0bcebb0792f1f96d617ded674dca7bf64181870bfe5dace353a1483551f8e5f1",
+ "sha256:bebd11a850f6987a943ce8cdff4159767e0f5f89b3c88aca64680c2175ee02df"
],
"index": "pypi",
- "version": "==2.4.0"
+ "version": "==2.4.1"
},
"flake8-bugbear": {
"hashes": [
@@ -856,11 +891,19 @@
},
"identify": {
"hashes": [
- "sha256:7c22c384a2c9b32c5cc891d13f923f6b2653aa83e2d75d8f79be240d6c86c4f4",
- "sha256:da683bfb7669fa749fc7731f378229e2dbf29a1d1337cbde04106f02236eb29d"
+ "sha256:5dd84ac64a9a115b8e0b27d1756b244b882ad264c3c423f42af8235a6e71ca12",
+ "sha256:c9504ba6a043ee2db0a9d69e43246bc138034895f6338d5aed1b41e4a73b1513"
+ ],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "version": "==1.5.9"
+ },
+ "idna": {
+ "hashes": [
+ "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",
+ "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
- "version": "==1.5.5"
+ "version": "==2.10"
},
"mccabe": {
"hashes": [
@@ -886,11 +929,11 @@
},
"pre-commit": {
"hashes": [
- "sha256:810aef2a2ba4f31eed1941fc270e72696a1ad5590b9751839c90807d0fff6b9a",
- "sha256:c54fd3e574565fe128ecc5e7d2f91279772ddb03f8729645fa812fe809084a70"
+ "sha256:22e6aa3bd571debb01eb7d34483f11c01b65237be4eebbf30c3d4fb65762d315",
+ "sha256:905ebc9b534b991baec87e934431f2d0606ba27f2b90f7f652985f5a5b8b6ae6"
],
"index": "pypi",
- "version": "==2.7.1"
+ "version": "==2.8.2"
},
"pycodestyle": {
"hashes": [
@@ -933,6 +976,14 @@
"index": "pypi",
"version": "==5.3.1"
},
+ "requests": {
+ "hashes": [
+ "sha256:7f1a0b932f4a60a1a65caa4263921bb7d9ee911957e0ae4a23a6dd08185ad5f8",
+ "sha256:e786fa28d8c9154e6a4de5d46a1d921b8749f8b74e28bde23768e5e16eece998"
+ ],
+ "index": "pypi",
+ "version": "==2.25.0"
+ },
"six": {
"hashes": [
"sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
@@ -950,26 +1001,27 @@
},
"toml": {
"hashes": [
- "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f",
- "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"
+ "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b",
+ "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"
],
- "version": "==0.10.1"
+ "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "version": "==0.10.2"
},
- "unittest-xml-reporting": {
+ "urllib3": {
"hashes": [
- "sha256:7bf515ea8cb244255a25100cd29db611a73f8d3d0aaf672ed3266307e14cc1ca",
- "sha256:984cebba69e889401bfe3adb9088ca376b3a1f923f0590d005126c1bffd1a695"
+ "sha256:19188f96923873c92ccb987120ec4acaa12f0461fa9ce5d3d0772bc965a39e08",
+ "sha256:d8ff90d979214d7b4f8ce956e80f4028fc6860e4431f731ea4a8c08f23f99473"
],
- "index": "pypi",
- "version": "==3.0.4"
+ "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"
},
"virtualenv": {
"hashes": [
- "sha256:43add625c53c596d38f971a465553f6318decc39d98512bc100fa1b1e839c8dc",
- "sha256:e0305af10299a7fb0d69393d8f04cb2965dda9351140d11ac8db4e5e3970451b"
+ "sha256:b0011228208944ce71052987437d3843e05690b2f23d1c7da4263fde104c97a2",
+ "sha256:b8d6110f493af256a40d65e29846c69340a947669eec8ce784fcf3dd3af28380"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
- "version": "==20.0.31"
+ "version": "==20.1.0"
}
}
}
diff --git a/README.md b/README.md
index cae7c3454..482ada08c 100644
--- a/README.md
+++ b/README.md
@@ -1,9 +1,8 @@
# Python Utility Bot
-[![Discord](https://img.shields.io/static/v1?label=Python%20Discord&logo=discord&message=%3E60k%20members&color=%237289DA&logoColor=white)](https://discord.gg/2B963hn)
-[![Build Status](https://dev.azure.com/python-discord/Python%20Discord/_apis/build/status/Bot?branchName=master)](https://dev.azure.com/python-discord/Python%20Discord/_build/latest?definitionId=1&branchName=master)
-[![Tests](https://img.shields.io/azure-devops/tests/python-discord/Python%20Discord/1?compact_message)](https://dev.azure.com/python-discord/Python%20Discord/_apis/build/status/Bot?branchName=master)
-[![Coverage](https://img.shields.io/azure-devops/coverage/python-discord/Python%20Discord/1/master)](https://dev.azure.com/python-discord/Python%20Discord/_apis/build/status/Bot?branchName=master)
+[![Discord](https://img.shields.io/static/v1?label=Python%20Discord&logo=discord&message=%3E100k%20members&color=%237289DA&logoColor=white)](https://discord.gg/2B963hn)
+![Lint, Test, Build](https://github.com/python-discord/bot/workflows/Lint,%20Test,%20Build/badge.svg?branch=master)
+[![Coverage Status](https://coveralls.io/repos/github/python-discord/bot/badge.svg)](https://coveralls.io/github/python-discord/bot)
[![License](https://img.shields.io/github/license/python-discord/bot)](LICENSE)
[![Website](https://img.shields.io/badge/website-visit-brightgreen)](https://pythondiscord.com)
diff --git a/azure-pipelines.yml b/azure-pipelines.yml
deleted file mode 100644
index 4500cb6e8..000000000
--- a/azure-pipelines.yml
+++ /dev/null
@@ -1,107 +0,0 @@
-# https://aka.ms/yaml
-
-variables:
- PIP_NO_CACHE_DIR: false
- PIP_USER: 1
- PIPENV_HIDE_EMOJIS: 1
- PIPENV_IGNORE_VIRTUALENVS: 1
- PIPENV_NOSPIN: 1
- PRE_COMMIT_HOME: $(Pipeline.Workspace)/pre-commit-cache
- PYTHONUSERBASE: $(Pipeline.Workspace)/py-user-base
-
-jobs:
- - job: test
- displayName: 'Lint & Test'
- pool:
- vmImage: ubuntu-18.04
-
- variables:
- BOT_API_KEY: foo
- BOT_SENTRY_DSN: blah
- BOT_TOKEN: bar
- REDDIT_CLIENT_ID: spam
- REDDIT_SECRET: ham
- WOLFRAM_API_KEY: baz
- REDIS_PASSWORD: ''
-
- steps:
- - task: UsePythonVersion@0
- displayName: 'Set Python version'
- name: python
- inputs:
- versionSpec: '3.8.x'
- addToPath: true
-
- - task: Cache@2
- displayName: 'Restore Python environment'
- inputs:
- key: python | $(Agent.OS) | "$(python.pythonLocation)" | 0 | ./Pipfile | ./Pipfile.lock
- cacheHitVar: PY_ENV_RESTORED
- path: $(PYTHONUSERBASE)
-
- - script: echo '##vso[task.prependpath]$(PYTHONUSERBASE)/bin'
- displayName: 'Prepend PATH'
-
- - script: pip install pipenv
- displayName: 'Install pipenv'
- condition: and(succeeded(), ne(variables.PY_ENV_RESTORED, 'true'))
-
- - script: pipenv install --dev --deploy --system
- displayName: 'Install project using pipenv'
- condition: and(succeeded(), ne(variables.PY_ENV_RESTORED, 'true'))
-
- # Create an executable shell script which replaces the original pipenv binary.
- # The shell script ignores the first argument and executes the rest of the args as a command.
- # It makes the `pipenv run flake8` command in the pre-commit hook work by circumventing
- # pipenv entirely, which is too dumb to know it should use the system interpreter rather than
- # creating a new venv.
- - script: |
- printf '%s\n%s' '#!/bin/bash' '"${@:2}"' > $(python.pythonLocation)/bin/pipenv \
- && chmod +x $(python.pythonLocation)/bin/pipenv
- displayName: 'Mock pipenv binary'
-
- - task: Cache@2
- displayName: 'Restore pre-commit environment'
- inputs:
- key: pre-commit | "$(python.pythonLocation)" | 0 | .pre-commit-config.yaml
- path: $(PRE_COMMIT_HOME)
-
- # pre-commit's venv doesn't allow user installs - not that they're really needed anyway.
- - script: export PIP_USER=0; pre-commit run --all-files
- displayName: 'Run pre-commit hooks'
-
- - script: coverage run -m xmlrunner
- displayName: Run tests
-
- - script: coverage report -m && coverage xml -o coverage.xml
- displayName: Generate test coverage report
-
- - task: PublishCodeCoverageResults@1
- displayName: 'Publish Coverage Results'
- condition: succeededOrFailed()
- inputs:
- codeCoverageTool: Cobertura
- summaryFileLocation: coverage.xml
-
- - task: PublishTestResults@2
- condition: succeededOrFailed()
- displayName: 'Publish Test Results'
- inputs:
- testResultsFiles: '**/TEST-*.xml'
- testRunTitle: 'Bot Test Results'
-
- - job: build
- displayName: 'Build & Push Container'
- dependsOn: 'test'
- condition: and(succeeded(), ne(variables['Build.Reason'], 'PullRequest'), eq(variables['Build.SourceBranch'], 'refs/heads/master'))
-
- steps:
- - task: Docker@2
- displayName: 'Build & Push Container'
- inputs:
- containerRegistry: 'DockerHub'
- repository: 'pythondiscord/bot'
- command: 'buildAndPush'
- Dockerfile: 'Dockerfile'
- buildContext: '.'
- tags: 'latest'
diff --git a/bot/constants.py b/bot/constants.py
index 3bc25e767..2126b2b37 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -361,6 +361,7 @@ class CleanMessages(metaclass=YAMLGetter):
message_limit: int
+
class Stats(metaclass=YAMLGetter):
section = "bot"
subsection = "stats"
@@ -377,6 +378,7 @@ class Categories(metaclass=YAMLGetter):
help_in_use: int
help_dormant: int
modmail: int
+ voice: int
class Channels(metaclass=YAMLGetter):
@@ -392,6 +394,7 @@ class Channels(metaclass=YAMLGetter):
bot_commands: int
change_log: int
code_help_voice: int
+ code_help_voice_2: int
cooldown: int
defcon: int
dev_contrib: int
@@ -424,6 +427,8 @@ class Channels(metaclass=YAMLGetter):
user_event_announcements: int
user_log: int
verification: int
+ voice_chat: int
+ voice_gate: int
voice_log: int
@@ -456,9 +461,11 @@ class Roles(metaclass=YAMLGetter):
owners: int
partners: int
python_community: int
+ sprinters: int
team_leaders: int
unverified: int
verified: int # This is the Developers role on PyDis, here named verified for readability reasons.
+ voice_verified: int
class Guild(metaclass=YAMLGetter):
@@ -467,6 +474,7 @@ class Guild(metaclass=YAMLGetter):
id: int
invite: str # Discord invite, gets embedded in chat
moderation_channels: List[int]
+ moderation_categories: List[int]
moderation_roles: List[int]
modlog_blacklist: List[int]
reminder_whitelist: List[int]
@@ -587,6 +595,16 @@ class Verification(metaclass=YAMLGetter):
kick_confirmation_threshold: float
+class VoiceGate(metaclass=YAMLGetter):
+ section = "voice_gate"
+
+ minimum_days_verified: int
+ minimum_messages: int
+ bot_message_delete_delay: int
+ minimum_activity_blocks: int
+ voice_ping_delete_delay: int
+
+
class Event(Enum):
"""
Event names. This does not include every event (for example, raw
@@ -627,6 +645,9 @@ STAFF_ROLES = Guild.staff_roles
# Channel combinations
MODERATION_CHANNELS = Guild.moderation_channels
+# Category combinations
+MODERATION_CATEGORIES = Guild.moderation_categories
+
# Bot replies
NEGATIVE_REPLIES = [
"Noooooo!!",
diff --git a/bot/exts/filters/antimalware.py b/bot/exts/filters/antimalware.py
index 7894ec48f..26f00e91f 100644
--- a/bot/exts/filters/antimalware.py
+++ b/bot/exts/filters/antimalware.py
@@ -6,7 +6,7 @@ from discord import Embed, Message, NotFound
from discord.ext.commands import Cog
from bot.bot import Bot
-from bot.constants import Channels, STAFF_ROLES, URLs
+from bot.constants import Channels, Filter, URLs
log = logging.getLogger(__name__)
@@ -61,7 +61,7 @@ class AntiMalware(Cog):
# Check if user is staff, if is, return
# Since we only care that roles exist to iterate over, check for the attr rather than a User/Member instance
- if hasattr(message.author, "roles") and any(role.id in STAFF_ROLES for role in message.author.roles):
+ if hasattr(message.author, "roles") and any(role.id in Filter.role_whitelist for role in message.author.roles):
return
embed = Embed()
diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py
index 4964283f1..af8528a68 100644
--- a/bot/exts/filters/antispam.py
+++ b/bot/exts/filters/antispam.py
@@ -15,7 +15,6 @@ from bot.constants import (
AntiSpam as AntiSpamConfig, Channels,
Colours, DEBUG_MODE, Event, Filter,
Guild as GuildConfig, Icons,
- STAFF_ROLES,
)
from bot.converters import Duration
from bot.exts.moderation.modlog import ModLog
@@ -149,7 +148,7 @@ class AntiSpam(Cog):
or message.guild.id != GuildConfig.id
or message.author.bot
or (message.channel.id in Filter.channel_whitelist and not DEBUG_MODE)
- or (any(role.id in STAFF_ROLES for role in message.author.roles) and not DEBUG_MODE)
+ or (any(role.id in Filter.role_whitelist for role in message.author.roles) and not DEBUG_MODE)
):
return
diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py
index 92cdfb8f5..208fc9e1f 100644
--- a/bot/exts/filters/filtering.py
+++ b/bot/exts/filters/filtering.py
@@ -246,7 +246,7 @@ class Filtering(Cog):
filter_triggered = True
stats = self._add_stats(filter_name, match, result)
- await self._send_log(filter_name, _filter["type"], msg, stats, is_eval=True)
+ await self._send_log(filter_name, _filter, msg, stats, is_eval=True)
break # We don't want multiple filters to trigger
diff --git a/bot/exts/info/codeblock/_instructions.py b/bot/exts/info/codeblock/_instructions.py
index 508f157fb..dadb5e1ef 100644
--- a/bot/exts/info/codeblock/_instructions.py
+++ b/bot/exts/info/codeblock/_instructions.py
@@ -71,7 +71,7 @@ def _get_no_ticks_message(content: str) -> Optional[str]:
log.trace("Creating instructions for a missing code block.")
if _parsing.is_python_code(content):
- example_blocks = _get_example("python")
+ example_blocks = _get_example("py")
return (
"It looks like you're trying to paste code into this channel.\n\n"
"Discord has support for Markdown, which allows you to post code with full "
@@ -133,7 +133,7 @@ def _get_no_lang_message(content: str) -> Optional[str]:
log.trace("Creating instructions for a missing language.")
if _parsing.is_python_code(content):
- example_blocks = _get_example("python")
+ example_blocks = _get_example("py")
# Note that _get_bad_ticks_message expects the first line to have two newlines.
return (
diff --git a/bot/exts/info/codeblock/_parsing.py b/bot/exts/info/codeblock/_parsing.py
index a98218dfb..e35fbca22 100644
--- a/bot/exts/info/codeblock/_parsing.py
+++ b/bot/exts/info/codeblock/_parsing.py
@@ -12,7 +12,7 @@ from bot.utils import has_lines
log = logging.getLogger(__name__)
BACKTICK = "`"
-PY_LANG_CODES = ("python", "pycon", "py") # Order is important; "py" is last cause it's a subset.
+PY_LANG_CODES = ("python-repl", "python", "pycon", "py") # Order is important; "py" is last cause it's a subset.
_TICKS = {
BACKTICK,
"'",
@@ -36,7 +36,7 @@ _RE_CODE_BLOCK = re.compile(
(?P<tick>[{''.join(_TICKS)}]) # Put all ticks into a character class within a group.
\2{{2}} # Match previous group 2 more times to ensure the same char.
)
- (?P<lang>[^\W_]+\n)? # Optionally match a language specifier followed by a newline.
+ (?P<lang>[A-Za-z0-9\+\-\.]+\n)? # Optionally match a language specifier followed by a newline.
(?P<code>.+?) # Match the actual code within the block.
\1 # Match the same 3 ticks used at the start of the block.
""",
diff --git a/bot/exts/info/doc.py b/bot/exts/info/doc.py
index aba7f7e48..9b5bd6504 100644
--- a/bot/exts/info/doc.py
+++ b/bot/exts/info/doc.py
@@ -3,10 +3,9 @@ import functools
import logging
import re
import textwrap
-from collections import OrderedDict
from contextlib import suppress
from types import SimpleNamespace
-from typing import Any, Callable, Optional, Tuple
+from typing import Optional, Tuple
import discord
from bs4 import BeautifulSoup
@@ -22,6 +21,7 @@ from bot.bot import Bot
from bot.constants import MODERATION_ROLES, RedirectOutput
from bot.converters import ValidPythonIdentifier, ValidURL
from bot.pagination import LinePaginator
+from bot.utils.cache import AsyncCache
from bot.utils.messages import wait_for_deletion
@@ -65,34 +65,7 @@ WHITESPACE_AFTER_NEWLINES_RE = re.compile(r"(?<=\n\n)(\s+)")
FAILED_REQUEST_RETRY_AMOUNT = 3
NOT_FOUND_DELETE_DELAY = RedirectOutput.delete_delay
-
-def async_cache(max_size: int = 128, arg_offset: int = 0) -> Callable:
- """
- LRU cache implementation for coroutines.
-
- Once the cache exceeds the maximum size, keys are deleted in FIFO order.
-
- An offset may be optionally provided to be applied to the coroutine's arguments when creating the cache key.
- """
- # Assign the cache to the function itself so we can clear it from outside.
- async_cache.cache = OrderedDict()
-
- def decorator(function: Callable) -> Callable:
- """Define the async_cache decorator."""
- @functools.wraps(function)
- async def wrapper(*args) -> Any:
- """Decorator wrapper for the caching logic."""
- key = ':'.join(args[arg_offset:])
-
- value = async_cache.cache.get(key)
- if value is None:
- if len(async_cache.cache) > max_size:
- async_cache.cache.popitem(last=False)
-
- async_cache.cache[key] = await function(*args)
- return async_cache.cache[key]
- return wrapper
- return decorator
+symbol_cache = AsyncCache()
class DocMarkdownConverter(MarkdownConverter):
@@ -215,7 +188,7 @@ class Doc(commands.Cog):
self.base_urls.clear()
self.inventories.clear()
self.renamed_symbols.clear()
- async_cache.cache = OrderedDict()
+ symbol_cache.clear()
# Run all coroutines concurrently - since each of them performs a HTTP
# request, this speeds up fetching the inventory data heavily.
@@ -280,7 +253,7 @@ class Doc(commands.Cog):
return signatures, description.replace('¶', '')
- @async_cache(arg_offset=1)
+ @symbol_cache(arg_offset=1)
async def get_symbol_embed(self, symbol: str) -> Optional[discord.Embed]:
"""
Attempt to scrape and fetch the data for the given `symbol`, and build an embed from its contents.
diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py
index 0f50138e7..5aaf85e5a 100644
--- a/bot/exts/info/information.py
+++ b/bot/exts/info/information.py
@@ -6,14 +6,16 @@ from collections import Counter, defaultdict
from string import Template
from typing import Any, Mapping, Optional, Tuple, Union
-from discord import ChannelType, Colour, Embed, Guild, Member, Message, Role, Status, utils
+from discord import ChannelType, Colour, Embed, Guild, Message, Role, Status, utils
from discord.abc import GuildChannel
from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group, has_any_role
from bot import constants
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.checks import cooldown_with_role_bypass, has_no_roles_check, in_whitelist_check
from bot.utils.time import time_since
@@ -191,7 +193,7 @@ class Information(Cog):
await ctx.send(embed=embed)
@command(name="user", aliases=["user_info", "member", "member_info"])
- async def user_info(self, ctx: Context, user: Member = None) -> None:
+ async def user_info(self, ctx: Context, user: FetchedMember = None) -> None:
"""Returns info about a user."""
if user is None:
user = ctx.author
@@ -206,12 +208,14 @@ class Information(Cog):
embed = await self.create_user_embed(ctx, user)
await ctx.send(embed=embed)
- async def create_user_embed(self, ctx: Context, user: Member) -> Embed:
+ async def create_user_embed(self, ctx: Context, user: FetchedMember) -> Embed:
"""Creates an embed containing information on the `user`."""
+ on_server = bool(ctx.guild.get_member(user.id))
+
created = time_since(user.created_at, max_units=3)
name = str(user)
- if user.nick:
+ if on_server and user.nick:
name = f"{user.nick} ({name})"
badges = []
@@ -220,8 +224,16 @@ class Information(Cog):
if is_set and (emoji := getattr(constants.Emojis, f"badge_{badge}", None)):
badges.append(emoji)
- joined = time_since(user.joined_at, max_units=3)
- roles = ", ".join(role.mention for role in user.roles[1:])
+ if on_server:
+ joined = time_since(user.joined_at, max_units=3)
+ roles = ", ".join(role.mention for role in user.roles[1:])
+ membership = textwrap.dedent(f"""
+ Joined: {joined}
+ Roles: {roles or None}
+ """).strip()
+ else:
+ roles = None
+ membership = "The user is not a member of the server"
fields = [
(
@@ -234,21 +246,12 @@ class Information(Cog):
),
(
"Member information",
- textwrap.dedent(f"""
- Joined: {joined}
- Roles: {roles or None}
- """).strip()
+ membership
),
]
- # Use getattr to future-proof for commands invoked via DMs.
- show_verbose = (
- ctx.channel.id in constants.MODERATION_CHANNELS
- or getattr(ctx.channel, "category_id", None) == constants.Categories.modmail
- )
-
# Show more verbose output in moderation channels for infractions and nominations
- if show_verbose:
+ if is_mod_channel(ctx.channel):
fields.append(await self.expanded_user_infraction_counts(user))
fields.append(await self.user_nomination_counts(user))
else:
@@ -268,13 +271,13 @@ class Information(Cog):
return embed
- async def basic_user_infraction_counts(self, member: Member) -> Tuple[str, str]:
+ async def basic_user_infraction_counts(self, user: FetchedMember) -> Tuple[str, str]:
"""Gets the total and active infraction counts for the given `member`."""
infractions = await self.bot.api_client.get(
'bot/infractions',
params={
'hidden': 'False',
- 'user__id': str(member.id)
+ 'user__id': str(user.id)
}
)
@@ -285,7 +288,7 @@ class Information(Cog):
return "Infractions", infraction_output
- async def expanded_user_infraction_counts(self, member: Member) -> Tuple[str, str]:
+ async def expanded_user_infraction_counts(self, user: FetchedMember) -> Tuple[str, str]:
"""
Gets expanded infraction counts for the given `member`.
@@ -295,7 +298,7 @@ class Information(Cog):
infractions = await self.bot.api_client.get(
'bot/infractions',
params={
- 'user__id': str(member.id)
+ 'user__id': str(user.id)
}
)
@@ -326,12 +329,12 @@ class Information(Cog):
return "Infractions", "\n".join(infraction_output)
- async def user_nomination_counts(self, member: Member) -> Tuple[str, str]:
+ async def user_nomination_counts(self, user: FetchedMember) -> Tuple[str, str]:
"""Gets the active and historical nomination counts for the given `member`."""
nominations = await self.bot.api_client.get(
'bot/nominations',
params={
- 'user__id': str(member.id)
+ 'user__id': str(user.id)
}
)
diff --git a/bot/exts/info/reddit.py b/bot/exts/info/reddit.py
index debe40c82..bad4c504d 100644
--- a/bot/exts/info/reddit.py
+++ b/bot/exts/info/reddit.py
@@ -140,7 +140,10 @@ class Reddit(Cog):
# Got appropriate response - process and return.
content = await response.json()
posts = content["data"]["children"]
- return posts[:amount]
+
+ filtered_posts = [post for post in posts if not post["data"]["over_18"]]
+
+ return filtered_posts[:amount]
await asyncio.sleep(3)
@@ -163,12 +166,11 @@ class Reddit(Cog):
amount=amount,
params={"t": time}
)
-
if not posts:
embed.title = random.choice(ERROR_REPLIES)
embed.colour = Colour.red()
embed.description = (
- "Sorry! We couldn't find any posts from that subreddit. "
+ "Sorry! We couldn't find any SFW posts from that subreddit. "
"If this problem persists, please let us know."
)
diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py
index 814b17830..bebade0ae 100644
--- a/bot/exts/moderation/infraction/_scheduler.py
+++ b/bot/exts/moderation/infraction/_scheduler.py
@@ -12,11 +12,12 @@ from discord.ext.commands import Context
from bot import constants
from bot.api import ResponseCodeError
from bot.bot import Bot
-from bot.constants import Colours, MODERATION_CHANNELS
+from bot.constants import Colours
from bot.exts.moderation.infraction import _utils
from bot.exts.moderation.infraction._utils import UserSnowflake
from bot.exts.moderation.modlog import ModLog
from bot.utils import messages, scheduling, time
+from bot.utils.channel import is_mod_channel
log = logging.getLogger(__name__)
@@ -125,7 +126,7 @@ class InfractionScheduler:
log.error(f"Failed to DM {user.id}: could not fetch user (status {e.status})")
else:
# Accordingly display whether the user was successfully notified via DM.
- if await _utils.notify_infraction(user, infr_type, expiry, reason, icon):
+ if await _utils.notify_infraction(user, " ".join(infr_type.split("_")).title(), expiry, reason, icon):
dm_result = ":incoming_envelope: "
dm_log_text = "\nDM: Sent"
@@ -136,11 +137,7 @@ class InfractionScheduler:
)
if reason:
end_msg = f" (reason: {textwrap.shorten(reason, width=1500, placeholder='...')})"
- elif ctx.channel.id not in MODERATION_CHANNELS:
- log.trace(
- f"Infraction #{id_} context is not in a mod channel; omitting infraction count."
- )
- else:
+ elif is_mod_channel(ctx.channel):
log.trace(f"Fetching total infraction count for {user}.")
infractions = await self.bot.api_client.get(
@@ -148,7 +145,7 @@ class InfractionScheduler:
params={"user__id": str(user.id)}
)
total = len(infractions)
- end_msg = f" ({total} infraction{ngettext('', 's', total)} total)"
+ end_msg = f" (#{id_} ; {total} infraction{ngettext('', 's', total)} total)"
# Execute the necessary actions to apply the infraction on Discord.
if action_coro:
@@ -166,7 +163,7 @@ class InfractionScheduler:
log_content = ctx.author.mention
log_title = "failed to apply"
- log_msg = f"Failed to apply {infr_type} infraction #{id_} to {user}"
+ log_msg = f"Failed to apply {' '.join(infr_type.split('_'))} infraction #{id_} to {user}"
if isinstance(e, discord.Forbidden):
log.warning(f"{log_msg}: bot lacks permissions.")
else:
@@ -183,7 +180,7 @@ class InfractionScheduler:
log.error(f"Deletion of {infr_type} infraction #{id_} failed with error code {e.status}.")
infr_message = ""
else:
- infr_message = f" **{infr_type}** to {user.mention}{expiry_msg}{end_msg}"
+ infr_message = f" **{' '.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.")
@@ -195,7 +192,7 @@ class InfractionScheduler:
await self.mod_log.send_log_message(
icon_url=icon,
colour=Colours.soft_red,
- title=f"Infraction {log_title}: {infr_type}",
+ title=f"Infraction {log_title}: {' '.join(infr_type.split('_'))}",
thumbnail=user.avatar_url_as(static_format="png"),
text=textwrap.dedent(f"""
Member: {messages.format_user(user)}
@@ -272,7 +269,7 @@ class InfractionScheduler:
if send_msg:
log.trace(f"Sending infraction #{id_} pardon confirmation message.")
await ctx.send(
- f"{dm_emoji}{confirm_msg} infraction **{infr_type}** for {user.mention}. "
+ f"{dm_emoji}{confirm_msg} infraction **{' '.join(infr_type.split('_'))}** for {user.mention}. "
f"{log_text.get('Failure', '')}"
)
@@ -283,7 +280,7 @@ class InfractionScheduler:
await self.mod_log.send_log_message(
icon_url=_utils.INFRACTION_ICONS[infr_type][1],
colour=Colours.soft_green,
- title=f"Infraction {log_title}: {infr_type}",
+ title=f"Infraction {log_title}: {' '.join(infr_type.split('_'))}",
thumbnail=user.avatar_url_as(static_format="png"),
text="\n".join(f"{k}: {v}" for k, v in log_text.items()),
footer=footer,
diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py
index 1d91964f1..d0dc3f0a1 100644
--- a/bot/exts/moderation/infraction/_utils.py
+++ b/bot/exts/moderation/infraction/_utils.py
@@ -18,9 +18,10 @@ INFRACTION_ICONS = {
"note": (Icons.user_warn, None),
"superstar": (Icons.superstarify, Icons.unsuperstarify),
"warning": (Icons.user_warn, None),
+ "voice_ban": (Icons.voice_state_red, Icons.voice_state_green),
}
RULES_URL = "https://pythondiscord.com/pages/rules"
-APPEALABLE_INFRACTIONS = ("ban", "mute")
+APPEALABLE_INFRACTIONS = ("ban", "mute", "voice_ban")
# Type aliases
UserObject = t.Union[discord.Member, discord.User]
@@ -154,7 +155,7 @@ async def notify_infraction(
log.trace(f"Sending {user} a DM about their {infr_type} infraction.")
text = INFRACTION_DESCRIPTION_TEMPLATE.format(
- type=infr_type.capitalize(),
+ type=infr_type.title(),
expires=expires_at or "N/A",
reason=reason or "No reason provided."
)
diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py
index 7cf7075e6..746d4e154 100644
--- a/bot/exts/moderation/infraction/infractions.py
+++ b/bot/exts/moderation/infraction/infractions.py
@@ -31,6 +31,7 @@ class Infractions(InfractionScheduler, commands.Cog):
self.category = "Moderation"
self._muted_role = discord.Object(constants.Roles.muted)
+ self._voice_verified_role = discord.Object(constants.Roles.voice_verified)
@commands.Cog.listener()
async def on_member_join(self, member: Member) -> None:
@@ -88,6 +89,11 @@ class Infractions(InfractionScheduler, commands.Cog):
"""
await self.apply_ban(ctx, user, reason, max(min(purge_days, 7), 0))
+ @command(aliases=('vban',))
+ async def voiceban(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str]) -> None:
+ """Permanently ban user from using voice channels."""
+ await self.apply_voice_ban(ctx, user, reason)
+
# endregion
# region: Temporary infractions
@@ -136,6 +142,32 @@ class Infractions(InfractionScheduler, commands.Cog):
"""
await self.apply_ban(ctx, user, reason, expires_at=duration)
+ @command(aliases=("tempvban", "tvban"))
+ async def tempvoiceban(
+ self,
+ ctx: Context,
+ user: FetchedMember,
+ duration: Expiry,
+ *,
+ reason: t.Optional[str]
+ ) -> None:
+ """
+ Temporarily voice ban a user for the given reason and duration.
+
+ A unit of time should be appended to the duration.
+ Units (∗case-sensitive):
+ \u2003`y` - years
+ \u2003`m` - months∗
+ \u2003`w` - weeks
+ \u2003`d` - days
+ \u2003`h` - hours
+ \u2003`M` - minutes∗
+ \u2003`s` - seconds
+
+ Alternatively, an ISO 8601 timestamp can be provided for the duration.
+ """
+ await self.apply_voice_ban(ctx, user, reason, expires_at=duration)
+
# endregion
# region: Permanent shadow infractions
@@ -225,6 +257,11 @@ class Infractions(InfractionScheduler, commands.Cog):
"""Prematurely end the active ban infraction for the user."""
await self.pardon_infraction(ctx, "ban", user)
+ @command(aliases=("uvban",))
+ async def unvoiceban(self, ctx: Context, user: FetchedMember) -> None:
+ """Prematurely end the active voice ban infraction for the user."""
+ await self.pardon_infraction(ctx, "voice_ban", user)
+
# endregion
# region: Base apply functions
@@ -319,6 +356,26 @@ class Infractions(InfractionScheduler, commands.Cog):
bb_reason = "User has been permanently banned from the server. Automatically removed."
await bb_cog.apply_unwatch(ctx, user, bb_reason, send_message=False)
+ @respect_role_hierarchy(member_arg=2)
+ async def apply_voice_ban(self, ctx: Context, user: UserSnowflake, reason: t.Optional[str], **kwargs) -> None:
+ """Apply a voice ban infraction with kwargs passed to `post_infraction`."""
+ if await _utils.get_active_infraction(ctx, user, "voice_ban"):
+ return
+
+ infraction = await _utils.post_infraction(ctx, user, "voice_ban", reason, active=True, **kwargs)
+ if infraction is None:
+ return
+
+ self.mod_log.ignore(Event.member_update, user.id)
+
+ if reason:
+ reason = textwrap.shorten(reason, width=512, placeholder="...")
+
+ await user.move_to(None, reason="Disconnected from voice to apply voiceban.")
+
+ action = user.remove_roles(self._voice_verified_role, reason=reason)
+ await self.apply_infraction(ctx, infraction, user, action)
+
# endregion
# region: Base pardon functions
@@ -363,6 +420,27 @@ class Infractions(InfractionScheduler, commands.Cog):
return log_text
+ async def pardon_voice_ban(self, user_id: int, guild: discord.Guild, reason: t.Optional[str]) -> t.Dict[str, str]:
+ """Add Voice Verified role back to user, DM them a notification, and return a log dict."""
+ user = guild.get_member(user_id)
+ log_text = {}
+
+ if user:
+ # DM user about infraction expiration
+ notified = await _utils.notify_pardon(
+ user=user,
+ title="Voice ban ended",
+ content="You have been unbanned and can verify yourself again in the server.",
+ icon_url=_utils.INFRACTION_ICONS["voice_ban"][1]
+ )
+
+ log_text["Member"] = format_user(user)
+ log_text["DM"] = "Sent" if notified else "**Failed**"
+ else:
+ log_text["Info"] = "User was not found in the guild."
+
+ return log_text
+
async def _pardon_action(self, infraction: _utils.Infraction) -> t.Optional[t.Dict[str, str]]:
"""
Execute deactivation steps specific to the infraction's type and return a log dict.
@@ -377,6 +455,8 @@ class Infractions(InfractionScheduler, commands.Cog):
return await self.pardon_mute(user_id, guild, reason)
elif infraction["type"] == "ban":
return await self.pardon_ban(user_id, guild, reason)
+ elif infraction["type"] == "voice_ban":
+ return await self.pardon_voice_ban(user_id, guild, reason)
# endregion
diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py
index cdab1a6c7..394f63da3 100644
--- a/bot/exts/moderation/infraction/management.py
+++ b/bot/exts/moderation/infraction/management.py
@@ -15,7 +15,7 @@ from bot.exts.moderation.infraction.infractions import Infractions
from bot.exts.moderation.modlog import ModLog
from bot.pagination import LinePaginator
from bot.utils import messages, time
-from bot.utils.checks import in_whitelist_check
+from bot.utils.channel import is_mod_channel
log = logging.getLogger(__name__)
@@ -295,13 +295,7 @@ class ModManagement(commands.Cog):
"""Only allow moderators inside moderator channels to invoke the commands in this cog."""
checks = [
await commands.has_any_role(*constants.MODERATION_ROLES).predicate(ctx),
- in_whitelist_check(
- ctx,
- channels=constants.MODERATION_CHANNELS,
- categories=[constants.Categories.modmail],
- redirect=None,
- fail_silently=True,
- )
+ is_mod_channel(ctx.channel)
]
return all(checks)
diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py
index ac0c1c85e..e6712b3b6 100644
--- a/bot/exts/moderation/silence.py
+++ b/bot/exts/moderation/silence.py
@@ -1,8 +1,11 @@
-import asyncio
+import json
import logging
from contextlib import suppress
+from datetime import datetime, timedelta, timezone
+from operator import attrgetter
from typing import Optional
+from async_rediscache import RedisCache
from discord import TextChannel
from discord.ext import commands, tasks
from discord.ext.commands import Context
@@ -10,10 +13,25 @@ from discord.ext.commands import Context
from bot.bot import Bot
from bot.constants import Channels, Emojis, Guild, MODERATION_ROLES, Roles
from bot.converters import HushDurationConverter
+from bot.utils.lock import LockedResourceError, lock_arg
from bot.utils.scheduling import Scheduler
log = logging.getLogger(__name__)
+LOCK_NAMESPACE = "silence"
+
+MSG_SILENCE_FAIL = f"{Emojis.cross_mark} current channel is already silenced."
+MSG_SILENCE_PERMANENT = f"{Emojis.check_mark} silenced current channel indefinitely."
+MSG_SILENCE_SUCCESS = f"{Emojis.check_mark} silenced current channel for {{duration}} minute(s)."
+
+MSG_UNSILENCE_FAIL = f"{Emojis.cross_mark} current channel was not silenced."
+MSG_UNSILENCE_MANUAL = (
+ f"{Emojis.cross_mark} current channel was not unsilenced because the current overwrites were "
+ f"set manually or the cache was prematurely cleared. "
+ f"Please edit the overwrites manually to unsilence."
+)
+MSG_UNSILENCE_SUCCESS = f"{Emojis.check_mark} unsilenced current channel."
+
class SilenceNotifier(tasks.Loop):
"""Loop notifier for posting notices to `alert_channel` containing added channels."""
@@ -56,25 +74,32 @@ class SilenceNotifier(tasks.Loop):
class Silence(commands.Cog):
"""Commands for stopping channel messages for `verified` role in a channel."""
+ # Maps muted channel IDs to their previous overwrites for send_message and add_reactions.
+ # Overwrites are stored as JSON.
+ previous_overwrites = RedisCache()
+
+ # Maps muted channel IDs to POSIX timestamps of when they'll be unsilenced.
+ # A timestamp equal to -1 means it's indefinite.
+ unsilence_timestamps = RedisCache()
+
def __init__(self, bot: Bot):
self.bot = bot
self.scheduler = Scheduler(self.__class__.__name__)
- self.muted_channels = set()
- self._get_instance_vars_task = self.bot.loop.create_task(self._get_instance_vars())
- self._get_instance_vars_event = asyncio.Event()
+ self._init_task = self.bot.loop.create_task(self._async_init())
- async def _get_instance_vars(self) -> None:
- """Get instance variables after they're available to get from the guild."""
+ async def _async_init(self) -> None:
+ """Set instance attributes once the guild is available and reschedule unsilences."""
await self.bot.wait_until_guild_available()
+
guild = self.bot.get_guild(Guild.id)
self._verified_role = guild.get_role(Roles.verified)
self._mod_alerts_channel = self.bot.get_channel(Channels.mod_alerts)
- self._mod_log_channel = self.bot.get_channel(Channels.mod_log)
- self.notifier = SilenceNotifier(self._mod_log_channel)
- self._get_instance_vars_event.set()
+ self.notifier = SilenceNotifier(self.bot.get_channel(Channels.mod_log))
+ await self._reschedule()
@commands.command(aliases=("hush",))
+ @lock_arg(LOCK_NAMESPACE, "ctx", attrgetter("channel"), raise_error=True)
async def silence(self, ctx: Context, duration: HushDurationConverter = 10) -> None:
"""
Silence the current channel for `duration` minutes or `forever`.
@@ -82,18 +107,25 @@ class Silence(commands.Cog):
Duration is capped at 15 minutes, passing forever makes the silence indefinite.
Indefinitely silenced channels get added to a notifier which posts notices every 15 minutes from the start.
"""
- await self._get_instance_vars_event.wait()
- log.debug(f"{ctx.author} is silencing channel #{ctx.channel}.")
- if not await self._silence(ctx.channel, persistent=(duration is None), duration=duration):
- await ctx.send(f"{Emojis.cross_mark} current channel is already silenced.")
- return
- if duration is None:
- await ctx.send(f"{Emojis.check_mark} silenced current channel indefinitely.")
+ await self._init_task
+
+ channel_info = f"#{ctx.channel} ({ctx.channel.id})"
+ log.debug(f"{ctx.author} is silencing channel {channel_info}.")
+
+ if not await self._set_silence_overwrites(ctx.channel):
+ log.info(f"Tried to silence channel {channel_info} but the channel was already silenced.")
+ await ctx.send(MSG_SILENCE_FAIL)
return
- await ctx.send(f"{Emojis.check_mark} silenced current channel for {duration} minute(s).")
+ await self._schedule_unsilence(ctx, duration)
- self.scheduler.schedule_later(duration * 60, ctx.channel.id, ctx.invoke(self.unsilence))
+ if duration is None:
+ self.notifier.add_channel(ctx.channel)
+ log.info(f"Silenced {channel_info} indefinitely.")
+ await ctx.send(MSG_SILENCE_PERMANENT)
+ else:
+ log.info(f"Silenced {channel_info} for {duration} minute(s).")
+ await ctx.send(MSG_SILENCE_SUCCESS.format(duration=duration))
@commands.command(aliases=("unhush",))
async def unsilence(self, ctx: Context) -> None:
@@ -102,61 +134,115 @@ class Silence(commands.Cog):
If the channel was silenced indefinitely, notifications for the channel will stop.
"""
- await self._get_instance_vars_event.wait()
+ await self._init_task
log.debug(f"Unsilencing channel #{ctx.channel} from {ctx.author}'s command.")
- if not await self._unsilence(ctx.channel):
- await ctx.send(f"{Emojis.cross_mark} current channel was not silenced.")
+ await self._unsilence_wrapper(ctx.channel)
+
+ @lock_arg(LOCK_NAMESPACE, "channel", raise_error=True)
+ async def _unsilence_wrapper(self, channel: TextChannel) -> None:
+ """Unsilence `channel` and send a success/failure message."""
+ if not await self._unsilence(channel):
+ overwrite = channel.overwrites_for(self._verified_role)
+ if overwrite.send_messages is False or overwrite.add_reactions is False:
+ await channel.send(MSG_UNSILENCE_MANUAL)
+ else:
+ await channel.send(MSG_UNSILENCE_FAIL)
else:
- await ctx.send(f"{Emojis.check_mark} unsilenced current channel.")
+ await channel.send(MSG_UNSILENCE_SUCCESS)
- async def _silence(self, channel: TextChannel, persistent: bool, duration: Optional[int]) -> bool:
- """
- Silence `channel` for `self._verified_role`.
+ async def _set_silence_overwrites(self, channel: TextChannel) -> bool:
+ """Set silence permission overwrites for `channel` and return True if successful."""
+ overwrite = channel.overwrites_for(self._verified_role)
+ prev_overwrites = dict(send_messages=overwrite.send_messages, add_reactions=overwrite.add_reactions)
- If `persistent` is `True` add `channel` to notifier.
- `duration` is only used for logging; if None is passed `persistent` should be True to not log None.
- Return `True` if channel permissions were changed, `False` otherwise.
- """
- current_overwrite = channel.overwrites_for(self._verified_role)
- if current_overwrite.send_messages is False:
- log.info(f"Tried to silence channel #{channel} ({channel.id}) but the channel was already silenced.")
+ if channel.id in self.scheduler or all(val is False for val in prev_overwrites.values()):
return False
- await channel.set_permissions(self._verified_role, **dict(current_overwrite, send_messages=False))
- self.muted_channels.add(channel)
- if persistent:
- log.info(f"Silenced #{channel} ({channel.id}) indefinitely.")
- self.notifier.add_channel(channel)
- return True
-
- log.info(f"Silenced #{channel} ({channel.id}) for {duration} minute(s).")
+
+ overwrite.update(send_messages=False, add_reactions=False)
+ await channel.set_permissions(self._verified_role, overwrite=overwrite)
+ await self.previous_overwrites.set(channel.id, json.dumps(prev_overwrites))
+
return True
+ async def _schedule_unsilence(self, ctx: Context, duration: Optional[int]) -> None:
+ """Schedule `ctx.channel` to be unsilenced if `duration` is not None."""
+ if duration is None:
+ await self.unsilence_timestamps.set(ctx.channel.id, -1)
+ else:
+ self.scheduler.schedule_later(duration * 60, ctx.channel.id, ctx.invoke(self.unsilence))
+ unsilence_time = datetime.now(tz=timezone.utc) + timedelta(minutes=duration)
+ await self.unsilence_timestamps.set(ctx.channel.id, unsilence_time.timestamp())
+
async def _unsilence(self, channel: TextChannel) -> bool:
"""
Unsilence `channel`.
- Check if `channel` is silenced through a `PermissionOverwrite`,
- if it is unsilence it and remove it from the notifier.
+ If `channel` has a silence task scheduled or has its previous overwrites cached, unsilence
+ it, cancel the task, and remove it from the notifier. Notify admins if it has a task but
+ not cached overwrites.
+
Return `True` if channel permissions were changed, `False` otherwise.
"""
- current_overwrite = channel.overwrites_for(self._verified_role)
- if current_overwrite.send_messages is False:
- await channel.set_permissions(self._verified_role, **dict(current_overwrite, send_messages=None))
- log.info(f"Unsilenced channel #{channel} ({channel.id}).")
- self.scheduler.cancel(channel.id)
- self.notifier.remove_channel(channel)
- self.muted_channels.discard(channel)
- return True
- log.info(f"Tried to unsilence channel #{channel} ({channel.id}) but the channel was not silenced.")
- return False
+ prev_overwrites = await self.previous_overwrites.get(channel.id)
+ if channel.id not in self.scheduler and prev_overwrites is None:
+ log.info(f"Tried to unsilence channel #{channel} ({channel.id}) but the channel was not silenced.")
+ return False
+
+ overwrite = channel.overwrites_for(self._verified_role)
+ if prev_overwrites is None:
+ log.info(f"Missing previous overwrites for #{channel} ({channel.id}); defaulting to None.")
+ overwrite.update(send_messages=None, add_reactions=None)
+ else:
+ overwrite.update(**json.loads(prev_overwrites))
+
+ await channel.set_permissions(self._verified_role, overwrite=overwrite)
+ log.info(f"Unsilenced channel #{channel} ({channel.id}).")
+
+ self.scheduler.cancel(channel.id)
+ self.notifier.remove_channel(channel)
+ await self.previous_overwrites.delete(channel.id)
+ await self.unsilence_timestamps.delete(channel.id)
+
+ if prev_overwrites is None:
+ await self._mod_alerts_channel.send(
+ f"<@&{Roles.admins}> Restored overwrites with default values after unsilencing "
+ f"{channel.mention}. Please check that the `Send Messages` and `Add Reactions` "
+ f"overwrites for {self._verified_role.mention} are at their desired values."
+ )
+
+ return True
+
+ async def _reschedule(self) -> None:
+ """Reschedule unsilencing of active silences and add permanent ones to the notifier."""
+ for channel_id, timestamp in await self.unsilence_timestamps.items():
+ channel = self.bot.get_channel(channel_id)
+ if channel is None:
+ log.info(f"Can't reschedule silence for {channel_id}: channel not found.")
+ continue
+
+ if timestamp == -1:
+ log.info(f"Adding permanent silence for #{channel} ({channel.id}) to the notifier.")
+ self.notifier.add_channel(channel)
+ continue
+
+ dt = datetime.fromtimestamp(timestamp, tz=timezone.utc)
+ delta = (dt - datetime.now(tz=timezone.utc)).total_seconds()
+ if delta <= 0:
+ # Suppress the error since it's not being invoked by a user via the command.
+ with suppress(LockedResourceError):
+ await self._unsilence_wrapper(channel)
+ else:
+ log.info(f"Rescheduling silence for #{channel} ({channel.id}).")
+ self.scheduler.schedule_later(delta, channel_id, self._unsilence_wrapper(channel))
def cog_unload(self) -> None:
- """Send alert with silenced channels and cancel scheduled tasks on unload."""
- self.scheduler.cancel_all()
- if self.muted_channels:
- channels_string = ''.join(channel.mention for channel in self.muted_channels)
- message = f"<@&{Roles.moderators}> channels left silenced on cog unload: {channels_string}"
- asyncio.create_task(self._mod_alerts_channel.send(message))
+ """Cancel the init task and scheduled tasks."""
+ # It's important to wait for _init_task (specifically for _reschedule) to be cancelled
+ # before cancelling scheduled tasks. Otherwise, it's possible for _reschedule to schedule
+ # more tasks after cancel_all has finished, despite _init_task.cancel being called first.
+ # This is cause cancel() on its own doesn't block until the task is cancelled.
+ self._init_task.cancel()
+ self._init_task.add_done_callback(lambda _: self.scheduler.cancel_all())
# This cannot be static (must have a __func__ attribute).
async def cog_check(self, ctx: Context) -> bool:
diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py
index d28114298..c599156d0 100644
--- a/bot/exts/moderation/verification.py
+++ b/bot/exts/moderation/verification.py
@@ -11,6 +11,7 @@ from discord.ext.commands import Cog, Context, command, group, has_any_role
from discord.utils import snowflake_time
from bot import constants
+from bot.api import ResponseCodeError
from bot.bot import Bot
from bot.decorators import has_no_roles, in_whitelist
from bot.exts.moderation.modlog import ModLog
@@ -355,6 +356,28 @@ class Verification(Cog):
return n_success
+ async def _add_kick_note(self, member: discord.Member) -> None:
+ """
+ Post a note regarding `member` being kicked to site.
+
+ Allows keeping track of kicked members for auditing purposes.
+ """
+ payload = {
+ "active": False,
+ "actor": self.bot.user.id, # Bot actions this autonomously
+ "expires_at": None,
+ "hidden": True,
+ "reason": "Verification kick",
+ "type": "note",
+ "user": member.id,
+ }
+
+ log.trace(f"Posting kick note for member {member} ({member.id})")
+ try:
+ await self.bot.api_client.post("bot/infractions", json=payload)
+ except ResponseCodeError as api_exc:
+ log.warning("Failed to post kick note", exc_info=api_exc)
+
async def _kick_members(self, members: t.Collection[discord.Member]) -> int:
"""
Kick `members` from the PyDis guild.
@@ -373,6 +396,7 @@ class Verification(Cog):
except discord.HTTPException as suspicious_exception:
raise StopExecution(reason=suspicious_exception)
await member.kick(reason=f"User has not verified in {constants.Verification.kicked_after} days")
+ await self._add_kick_note(member)
n_kicked = await self._send_requests(members, kick_request, Limit(batch_size=2, sleep_secs=1))
self.bot.stats.incr("verification.kicked", count=n_kicked)
diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py
new file mode 100644
index 000000000..4d48d2c1b
--- /dev/null
+++ b/bot/exts/moderation/voice_gate.py
@@ -0,0 +1,265 @@
+import asyncio
+import logging
+from contextlib import suppress
+from datetime import datetime, timedelta
+
+import discord
+from async_rediscache import RedisCache
+from dateutil import parser
+from discord import Colour, Member, VoiceState
+from discord.ext.commands import Cog, Context, command
+
+from bot.api import ResponseCodeError
+from bot.bot import Bot
+from bot.constants import Channels, Event, MODERATION_ROLES, Roles, VoiceGate as GateConf
+from bot.decorators import has_no_roles, in_whitelist
+from bot.exts.moderation.modlog import ModLog
+from bot.utils.checks import InWhitelistCheckFailure
+
+log = logging.getLogger(__name__)
+
+# Flag written to the cog's RedisCache as a value when the Member's (key) notification
+# was already removed ~ this signals both that no further notifications should be sent,
+# and that the notification does not need to be removed. The implementation relies on
+# this being falsey!
+NO_MSG = 0
+
+FAILED_MESSAGE = (
+ """You are not currently eligible to use voice inside Python Discord for the following reasons:\n\n{reasons}"""
+)
+
+MESSAGE_FIELD_MAP = {
+ "verified_at": f"have been verified for less than {GateConf.minimum_days_verified} days",
+ "voice_banned": "have an active voice ban infraction",
+ "total_messages": f"have sent less than {GateConf.minimum_messages} messages",
+ "activity_blocks": f"have been active for fewer than {GateConf.minimum_activity_blocks} ten-minute blocks",
+}
+
+VOICE_PING = (
+ "Wondering why you can't talk in the voice channels? "
+ "Use the `!voiceverify` command in here to verify. "
+ "If you don't yet qualify, you'll be told why!"
+)
+
+
+class VoiceGate(Cog):
+ """Voice channels verification management."""
+
+ # RedisCache[t.Union[discord.User.id, discord.Member.id], t.Union[discord.Message.id, int]]
+ # The cache's keys are the IDs of members who are verified or have joined a voice channel
+ # The cache's values are either the message ID of the ping message or 0 (NO_MSG) if no message is present
+ redis_cache = RedisCache()
+
+ def __init__(self, bot: Bot) -> None:
+ self.bot = bot
+
+ @property
+ def mod_log(self) -> ModLog:
+ """Get the currently loaded ModLog cog instance."""
+ return self.bot.get_cog("ModLog")
+
+ @redis_cache.atomic_transaction # Fully process each call until starting the next
+ async def _delete_ping(self, member_id: int) -> None:
+ """
+ If `redis_cache` holds a message ID for `member_id`, delete the message.
+
+ If the message was deleted, the value under the `member_id` key is then set to `NO_MSG`.
+ When `member_id` is not in the cache, or has a value of `NO_MSG` already, this function
+ does nothing.
+ """
+ if message_id := await self.redis_cache.get(member_id):
+ log.trace(f"Removing voice gate reminder message for user: {member_id}")
+ with suppress(discord.NotFound):
+ await self.bot.http.delete_message(Channels.voice_gate, message_id)
+ await self.redis_cache.set(member_id, NO_MSG)
+ else:
+ log.trace(f"Voice gate reminder message for user {member_id} was already removed")
+
+ @redis_cache.atomic_transaction
+ async def _ping_newcomer(self, member: discord.Member) -> bool:
+ """
+ See if `member` should be sent a voice verification notification, and send it if so.
+
+ Returns False if the notification was not sent. This happens when:
+ * The `member` has already received the notification
+ * The `member` is already voice-verified
+
+ Otherwise, the notification message ID is stored in `redis_cache` and True is returned.
+ """
+ if await self.redis_cache.contains(member.id):
+ log.trace("User already in cache. Ignore.")
+ return False
+
+ log.trace("User not in cache and is in a voice channel.")
+ verified = any(Roles.voice_verified == role.id for role in member.roles)
+ if verified:
+ log.trace("User is verified, add to the cache and ignore.")
+ await self.redis_cache.set(member.id, NO_MSG)
+ return False
+
+ log.trace("User is unverified. Send ping.")
+ await self.bot.wait_until_guild_available()
+ voice_verification_channel = self.bot.get_channel(Channels.voice_gate)
+
+ message = await voice_verification_channel.send(f"Hello, {member.mention}! {VOICE_PING}")
+ await self.redis_cache.set(member.id, message.id)
+
+ return True
+
+ @command(aliases=('voiceverify',))
+ @has_no_roles(Roles.voice_verified)
+ @in_whitelist(channels=(Channels.voice_gate,), redirect=None)
+ async def voice_verify(self, ctx: Context, *_) -> None:
+ """
+ Apply to be able to use voice within the Discord server.
+
+ In order to use voice you must meet all three of the following criteria:
+ - You must have over a certain number of messages within the Discord server
+ - You must have accepted our rules over a certain number of days ago
+ - You must not be actively banned from using our voice channels
+ - You must have been active for over a certain number of 10-minute blocks
+ """
+ await self._delete_ping(ctx.author.id) # If user has received a ping in voice_verification, delete the message
+
+ try:
+ data = await self.bot.api_client.get(f"bot/users/{ctx.author.id}/metricity_data")
+ except ResponseCodeError as e:
+ if e.status == 404:
+ embed = discord.Embed(
+ title="Not found",
+ description=(
+ "We were unable to find user data for you. "
+ "Please try again shortly, "
+ "if this problem persists please contact the server staff through Modmail."
+ ),
+ color=Colour.red()
+ )
+ log.info(f"Unable to find Metricity data about {ctx.author} ({ctx.author.id})")
+ else:
+ embed = discord.Embed(
+ title="Unexpected response",
+ description=(
+ "We encountered an error while attempting to find data for your user. "
+ "Please try again and let us know if the problem persists."
+ ),
+ color=Colour.red()
+ )
+ log.warning(f"Got response code {e.status} while trying to get {ctx.author.id} Metricity data.")
+
+ await ctx.author.send(embed=embed)
+ return
+
+ # Pre-parse this for better code style
+ if data["verified_at"] is not None:
+ data["verified_at"] = parser.isoparse(data["verified_at"])
+ else:
+ data["verified_at"] = datetime.utcnow() - timedelta(days=3)
+
+ checks = {
+ "verified_at": data["verified_at"] > datetime.utcnow() - timedelta(days=GateConf.minimum_days_verified),
+ "total_messages": data["total_messages"] < GateConf.minimum_messages,
+ "voice_banned": data["voice_banned"],
+ "activity_blocks": data["activity_blocks"] < GateConf.minimum_activity_blocks
+ }
+ failed = any(checks.values())
+ failed_reasons = [MESSAGE_FIELD_MAP[key] for key, value in checks.items() if value is True]
+ [self.bot.stats.incr(f"voice_gate.failed.{key}") for key, value in checks.items() if value is True]
+
+ if failed:
+ embed = discord.Embed(
+ title="Voice Gate failed",
+ description=FAILED_MESSAGE.format(reasons="\n".join(f'• You {reason}.' for reason in failed_reasons)),
+ color=Colour.red()
+ )
+ try:
+ await ctx.author.send(embed=embed)
+ await ctx.send(f"{ctx.author}, please check your DMs.")
+ except discord.Forbidden:
+ await ctx.channel.send(ctx.author.mention, embed=embed)
+ return
+
+ self.mod_log.ignore(Event.member_update, ctx.author.id)
+ embed = discord.Embed(
+ title="Voice gate passed",
+ description="You have been granted permission to use voice channels in Python Discord.",
+ color=Colour.green()
+ )
+
+ if ctx.author.voice:
+ embed.description += "\n\nPlease reconnect to your voice channel to be granted your new permissions."
+
+ try:
+ await ctx.author.send(embed=embed)
+ await ctx.send(f"{ctx.author}, please check your DMs.")
+ except discord.Forbidden:
+ await ctx.channel.send(ctx.author.mention, embed=embed)
+
+ # wait a little bit so those who don't get DMs see the response in-channel before losing perms to see it.
+ await asyncio.sleep(3)
+ await ctx.author.add_roles(discord.Object(Roles.voice_verified), reason="Voice Gate passed")
+
+ self.bot.stats.incr("voice_gate.passed")
+
+ @Cog.listener()
+ async def on_message(self, message: discord.Message) -> None:
+ """Delete all non-staff messages from voice gate channel that don't invoke voice verify command."""
+ # Check is channel voice gate
+ if message.channel.id != Channels.voice_gate:
+ return
+
+ ctx = await self.bot.get_context(message)
+ is_verify_command = ctx.command is not None and ctx.command.name == "voice_verify"
+
+ # When it's a bot sent message, delete it after some time
+ if message.author.bot:
+ # Comparing the message with the voice ping constant
+ if message.content.endswith(VOICE_PING):
+ log.trace("Message is the voice verification ping. Ignore.")
+ return
+ with suppress(discord.NotFound):
+ await message.delete(delay=GateConf.bot_message_delete_delay)
+ return
+
+ # Then check is member moderator+, because we don't want to delete their messages.
+ if any(role.id in MODERATION_ROLES for role in message.author.roles) and is_verify_command is False:
+ log.trace(f"Excluding moderator message {message.id} from deletion in #{message.channel}.")
+ return
+
+ # Ignore deleted voice verification messages
+ if ctx.command is not None and ctx.command.name == "voice_verify":
+ self.mod_log.ignore(Event.message_delete, message.id)
+
+ with suppress(discord.NotFound):
+ await message.delete()
+
+ @Cog.listener()
+ async def on_voice_state_update(self, member: Member, before: VoiceState, after: VoiceState) -> None:
+ """Pings a user if they've never joined the voice chat before and aren't voice verified."""
+ if member.bot:
+ log.trace("User is a bot. Ignore.")
+ return
+
+ # member.voice will return None if the user is not in a voice channel
+ if member.voice is None:
+ log.trace("User not in a voice channel. Ignore.")
+ return
+
+ # To avoid race conditions, checking if the user should receive a notification
+ # and sending it if appropriate is delegated to an atomic helper
+ notification_sent = await self._ping_newcomer(member)
+
+ # Schedule the notification to be deleted after the configured delay, which is
+ # again delegated to an atomic helper
+ if notification_sent:
+ await asyncio.sleep(GateConf.voice_ping_delete_delay)
+ await self._delete_ping(member.id)
+
+ async def cog_command_error(self, ctx: Context, error: Exception) -> None:
+ """Check for & ignore any InWhitelistCheckFailure."""
+ if isinstance(error, InWhitelistCheckFailure):
+ error.handled = True
+
+
+def setup(bot: Bot) -> None:
+ """Loads the VoiceGate cog."""
+ bot.add_cog(VoiceGate(bot))
diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py
index bf4e24661..3113a1149 100644
--- a/bot/exts/utils/reminders.py
+++ b/bot/exts/utils/reminders.py
@@ -23,7 +23,7 @@ from bot.utils.time import humanize_delta
log = logging.getLogger(__name__)
-NAMESPACE = "reminder" # Used for the mutually_exclusive decorator; constant to prevent typos
+LOCK_NAMESPACE = "reminder"
WHITELISTED_CHANNELS = Guild.reminder_whitelist
MAXIMUM_REMINDERS = 5
@@ -170,7 +170,7 @@ class Reminders(Cog):
log.trace(f"Scheduling new task #{reminder['id']}")
self.schedule_reminder(reminder)
- @lock_arg(NAMESPACE, "reminder", itemgetter("id"), raise_error=True)
+ @lock_arg(LOCK_NAMESPACE, "reminder", itemgetter("id"), raise_error=True)
async def send_reminder(self, reminder: dict, late: relativedelta = None) -> None:
"""Send the reminder."""
is_valid, user, channel = self.ensure_valid_reminder(reminder)
@@ -378,7 +378,7 @@ class Reminders(Cog):
mention_ids = [mention.id for mention in mentions]
await self.edit_reminder(ctx, id_, {"mentions": mention_ids})
- @lock_arg(NAMESPACE, "id_", raise_error=True)
+ @lock_arg(LOCK_NAMESPACE, "id_", raise_error=True)
async def edit_reminder(self, ctx: Context, id_: int, payload: dict) -> None:
"""Edits a reminder with the given payload, then sends a confirmation message."""
if not await self._can_modify(ctx, id_):
@@ -398,7 +398,7 @@ class Reminders(Cog):
await self._reschedule_reminder(reminder)
@remind_group.command("delete", aliases=("remove", "cancel"))
- @lock_arg(NAMESPACE, "id_", raise_error=True)
+ @lock_arg(LOCK_NAMESPACE, "id_", raise_error=True)
async def delete_reminder(self, ctx: Context, id_: int) -> None:
"""Delete one of your active reminders."""
if not await self._can_modify(ctx, id_):
diff --git a/bot/exts/utils/snekbox.py b/bot/exts/utils/snekbox.py
index bde1684d8..9f480c067 100644
--- a/bot/exts/utils/snekbox.py
+++ b/bot/exts/utils/snekbox.py
@@ -21,14 +21,12 @@ log = logging.getLogger(__name__)
ESCAPE_REGEX = re.compile("[`\u202E\u200B]{3,}")
FORMATTED_CODE_REGEX = re.compile(
- r"^\s*" # any leading whitespace from the beginning of the string
r"(?P<delim>(?P<block>```)|``?)" # code delimiter: 1-3 backticks; (?P=block) only matches if it's a block
r"(?(block)(?:(?P<lang>[a-z]+)\n)?)" # if we're in a block, match optional language (only letters plus newline)
r"(?:[ \t]*\n)*" # any blank (empty or tabs/spaces only) lines before the code
r"(?P<code>.*?)" # extract all code inside the markup
r"\s*" # any more whitespace before the end of the code markup
- r"(?P=delim)" # match the exact same delimiter from the start again
- r"\s*$", # any trailing whitespace until the end of the string
+ r"(?P=delim)", # match the exact same delimiter from the start again
re.DOTALL | re.IGNORECASE # "." also matches newlines, case insensitive
)
RAW_CODE_REGEX = re.compile(
@@ -41,8 +39,8 @@ RAW_CODE_REGEX = re.compile(
MAX_PASTE_LEN = 10000
# `!eval` command whitelists
-EVAL_CHANNELS = (Channels.bot_commands, Channels.esoteric, Channels.code_help_voice)
-EVAL_CATEGORIES = (Categories.help_available, Categories.help_in_use)
+EVAL_CHANNELS = (Channels.bot_commands, Channels.esoteric)
+EVAL_CATEGORIES = (Categories.help_available, Categories.help_in_use, Categories.voice)
EVAL_ROLES = (Roles.helpers, Roles.moderators, Roles.admins, Roles.owners, Roles.python_community, Roles.partners)
SIGKILL = 9
@@ -76,23 +74,32 @@ class Snekbox(Cog):
@staticmethod
def prepare_input(code: str) -> str:
- """Extract code from the Markdown, format it, and insert it into the code template."""
- match = FORMATTED_CODE_REGEX.fullmatch(code)
- if match:
- code, block, lang, delim = match.group("code", "block", "lang", "delim")
- code = textwrap.dedent(code)
- if block:
- info = (f"'{lang}' highlighted" if lang else "plain") + " code block"
+ """
+ Extract code from the Markdown, format it, and insert it into the code template.
+
+ If there is any code block, ignore text outside the code block.
+ Use the first code block, but prefer a fenced code block.
+ If there are several fenced code blocks, concatenate only the fenced code blocks.
+ """
+ if match := list(FORMATTED_CODE_REGEX.finditer(code)):
+ blocks = [block for block in match if block.group("block")]
+
+ if len(blocks) > 1:
+ code = '\n'.join(block.group("code") for block in blocks)
+ info = "several code blocks"
else:
- info = f"{delim}-enclosed inline code"
- log.trace(f"Extracted {info} for evaluation:\n{code}")
+ match = match[0] if len(blocks) == 0 else blocks[0]
+ code, block, lang, delim = match.group("code", "block", "lang", "delim")
+ if block:
+ info = (f"'{lang}' highlighted" if lang else "plain") + " code block"
+ else:
+ info = f"{delim}-enclosed inline code"
else:
- code = textwrap.dedent(RAW_CODE_REGEX.fullmatch(code).group("code"))
- log.trace(
- f"Eval message contains unformatted or badly formatted code, "
- f"stripping whitespace only:\n{code}"
- )
+ code = RAW_CODE_REGEX.fullmatch(code).group("code")
+ info = "unformatted or badly formatted code"
+ code = textwrap.dedent(code)
+ log.trace(f"Extracted {info} for evaluation:\n{code}")
return code
@staticmethod
diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py
index 3e9230414..6d8d98695 100644
--- a/bot/exts/utils/utils.py
+++ b/bot/exts/utils/utils.py
@@ -2,9 +2,10 @@ import difflib
import logging
import re
import unicodedata
+from datetime import datetime, timedelta
from email.parser import HeaderParser
from io import StringIO
-from typing import Tuple, Union
+from typing import Dict, Optional, Tuple, Union
from discord import Colour, Embed, utils
from discord.ext.commands import BadArgument, Cog, Context, clean_content, command, has_any_role
@@ -14,6 +15,7 @@ from bot.constants import Channels, MODERATION_ROLES, STAFF_ROLES
from bot.decorators import in_whitelist
from bot.pagination import LinePaginator
from bot.utils import messages
+from bot.utils.cache import AsyncCache
log = logging.getLogger(__name__)
@@ -41,80 +43,21 @@ Namespaces are one honking great idea -- let's do more of those!
ICON_URL = "https://www.python.org/static/opengraph-icon-200x200.png"
+pep_cache = AsyncCache()
+
class Utils(Cog):
"""A selection of utilities which don't have a clear category."""
+ BASE_PEP_URL = "http://www.python.org/dev/peps/pep-"
+ BASE_GITHUB_PEP_URL = "https://raw.githubusercontent.com/python/peps/master/pep-"
+ PEPS_LISTING_API_URL = "https://api.github.com/repos/python/peps/contents?ref=master"
+
def __init__(self, bot: Bot):
self.bot = bot
-
- self.base_pep_url = "http://www.python.org/dev/peps/pep-"
- self.base_github_pep_url = "https://raw.githubusercontent.com/python/peps/master/pep-"
-
- @command(name='pep', aliases=('get_pep', 'p'))
- async def pep_command(self, ctx: Context, pep_number: str) -> None:
- """Fetches information about a PEP and sends it to the channel."""
- if pep_number.isdigit():
- pep_number = int(pep_number)
- else:
- await ctx.send_help(ctx.command)
- return
-
- # Handle PEP 0 directly because it's not in .rst or .txt so it can't be accessed like other PEPs.
- if pep_number == 0:
- return await self.send_pep_zero(ctx)
-
- possible_extensions = ['.txt', '.rst']
- found_pep = False
- for extension in possible_extensions:
- # Attempt to fetch the PEP
- pep_url = f"{self.base_github_pep_url}{pep_number:04}{extension}"
- log.trace(f"Requesting PEP {pep_number} with {pep_url}")
- response = await self.bot.http_session.get(pep_url)
-
- if response.status == 200:
- log.trace("PEP found")
- found_pep = True
-
- pep_content = await response.text()
-
- # Taken from https://github.com/python/peps/blob/master/pep0/pep.py#L179
- pep_header = HeaderParser().parse(StringIO(pep_content))
-
- # Assemble the embed
- pep_embed = Embed(
- title=f"**PEP {pep_number} - {pep_header['Title']}**",
- url=f"{self.base_pep_url}{pep_number:04}"
- )
-
- pep_embed.set_thumbnail(url=ICON_URL)
-
- # Add the interesting information
- fields_to_check = ("Status", "Python-Version", "Created", "Type")
- for field in fields_to_check:
- # Check for a PEP metadata field that is present but has an empty value
- # embed field values can't contain an empty string
- if pep_header.get(field, ""):
- pep_embed.add_field(name=field, value=pep_header[field])
-
- elif response.status != 404:
- # any response except 200 and 404 is expected
- found_pep = True # actually not, but it's easier to display this way
- log.trace(f"The user requested PEP {pep_number}, but the response had an unexpected status code: "
- f"{response.status}.\n{response.text}")
-
- error_message = "Unexpected HTTP error during PEP search. Please let us know."
- pep_embed = Embed(title="Unexpected error", description=error_message)
- pep_embed.colour = Colour.red()
- break
-
- if not found_pep:
- log.trace("PEP was not found")
- not_found = f"PEP {pep_number} does not exist."
- pep_embed = Embed(title="PEP not found", description=not_found)
- pep_embed.colour = Colour.red()
-
- await ctx.message.channel.send(embed=pep_embed)
+ self.peps: Dict[int, str] = {}
+ self.last_refreshed_peps: Optional[datetime] = None
+ self.bot.loop.create_task(self.refresh_peps_urls())
@command()
@in_whitelist(channels=(Channels.bot_commands,), roles=STAFF_ROLES)
@@ -246,8 +189,53 @@ class Utils(Cog):
for reaction in options:
await message.add_reaction(reaction)
- async def send_pep_zero(self, ctx: Context) -> None:
- """Send information about PEP 0."""
+ # region: PEP
+
+ async def refresh_peps_urls(self) -> None:
+ """Refresh PEP URLs listing in every 3 hours."""
+ # Wait until HTTP client is available
+ await self.bot.wait_until_ready()
+ log.trace("Started refreshing PEP URLs.")
+
+ async with self.bot.http_session.get(self.PEPS_LISTING_API_URL) as resp:
+ listing = await resp.json()
+
+ log.trace("Got PEP URLs listing from GitHub API")
+
+ for file in listing:
+ name = file["name"]
+ if name.startswith("pep-") and name.endswith((".rst", ".txt")):
+ pep_number = name.replace("pep-", "").split(".")[0]
+ self.peps[int(pep_number)] = file["download_url"]
+
+ self.last_refreshed_peps = datetime.now()
+ log.info("Successfully refreshed PEP URLs listing.")
+
+ @command(name='pep', aliases=('get_pep', 'p'))
+ async def pep_command(self, ctx: Context, pep_number: int) -> None:
+ """Fetches information about a PEP and sends it to the channel."""
+ # Trigger typing in chat to show users that bot is responding
+ await ctx.trigger_typing()
+
+ # Handle PEP 0 directly because it's not in .rst or .txt so it can't be accessed like other PEPs.
+ if pep_number == 0:
+ pep_embed = self.get_pep_zero_embed()
+ success = True
+ else:
+ success = False
+ if not (pep_embed := await self.validate_pep_number(pep_number)):
+ pep_embed, success = await self.get_pep_embed(pep_number)
+
+ await ctx.send(embed=pep_embed)
+ if success:
+ log.trace(f"PEP {pep_number} getting and sending finished successfully. Increasing stat.")
+ self.bot.stats.incr(f"pep_fetches.{pep_number}")
+ else:
+ log.trace(f"Getting PEP {pep_number} failed. Error embed sent.")
+
+ @staticmethod
+ def get_pep_zero_embed() -> Embed:
+ """Get information embed about PEP 0."""
pep_embed = Embed(
title="**PEP 0 - Index of Python Enhancement Proposals (PEPs)**",
url="https://www.python.org/dev/peps/"
@@ -257,7 +245,69 @@ class Utils(Cog):
pep_embed.add_field(name="Created", value="13-Jul-2000")
pep_embed.add_field(name="Type", value="Informational")
- await ctx.send(embed=pep_embed)
+ return pep_embed
+
+ async def validate_pep_number(self, pep_nr: int) -> Optional[Embed]:
+ """Validate is PEP number valid. When it isn't, return error embed, otherwise None."""
+ if (
+ pep_nr not in self.peps
+ and (self.last_refreshed_peps + timedelta(minutes=30)) <= datetime.now()
+ and len(str(pep_nr)) < 5
+ ):
+ await self.refresh_peps_urls()
+
+ if pep_nr not in self.peps:
+ log.trace(f"PEP {pep_nr} was not found")
+ return Embed(
+ title="PEP not found",
+ description=f"PEP {pep_nr} does not exist.",
+ colour=Colour.red()
+ )
+
+ return None
+
+ def generate_pep_embed(self, pep_header: Dict, pep_nr: int) -> Embed:
+ """Generate PEP embed based on PEP headers data."""
+ # Assemble the embed
+ pep_embed = Embed(
+ title=f"**PEP {pep_nr} - {pep_header['Title']}**",
+ description=f"[Link]({self.BASE_PEP_URL}{pep_nr:04})",
+ )
+
+ pep_embed.set_thumbnail(url=ICON_URL)
+
+ # Add the interesting information
+ fields_to_check = ("Status", "Python-Version", "Created", "Type")
+ for field in fields_to_check:
+ # Check for a PEP metadata field that is present but has an empty value
+ # embed field values can't contain an empty string
+ if pep_header.get(field, ""):
+ pep_embed.add_field(name=field, value=pep_header[field])
+
+ return pep_embed
+
+ @pep_cache(arg_offset=1)
+ async def get_pep_embed(self, pep_nr: int) -> Tuple[Embed, bool]:
+ """Fetch, generate and return PEP embed. Second item of return tuple show does getting success."""
+ response = await self.bot.http_session.get(self.peps[pep_nr])
+
+ if response.status == 200:
+ log.trace(f"PEP {pep_nr} found")
+ pep_content = await response.text()
+
+ # Taken from https://github.com/python/peps/blob/master/pep0/pep.py#L179
+ pep_header = HeaderParser().parse(StringIO(pep_content))
+ return self.generate_pep_embed(pep_header, pep_nr), True
+ else:
+ log.trace(
+ f"The user requested PEP {pep_nr}, but the response had an unexpected status code: {response.status}."
+ )
+ return Embed(
+ title="Unexpected error",
+ description="Unexpected HTTP error during PEP search. Please let us know.",
+ colour=Colour.red()
+ ), False
+ # endregion
def setup(bot: Bot) -> None:
diff --git a/bot/log.py b/bot/log.py
index 5583c7070..13141de40 100644
--- a/bot/log.py
+++ b/bot/log.py
@@ -53,7 +53,7 @@ def setup() -> None:
logging.getLogger("discord").setLevel(logging.WARNING)
logging.getLogger("websockets").setLevel(logging.WARNING)
logging.getLogger("chardet").setLevel(logging.WARNING)
- logging.getLogger(__name__)
+ logging.getLogger("async_rediscache").setLevel(logging.WARNING)
def setup_sentry() -> None:
diff --git a/bot/resources/tags/codeblock.md b/bot/resources/tags/codeblock.md
index a28ae397b..8d48bdf06 100644
--- a/bot/resources/tags/codeblock.md
+++ b/bot/resources/tags/codeblock.md
@@ -1,17 +1,7 @@
-Discord has support for Markdown, which allows you to post code with full syntax highlighting. Please use these whenever you paste code, as this helps improve the legibility and makes it easier for us to help you.
+Here's how to format Python code on Discord:
-To do this, use the following method:
-
-\```python
+\```py
print('Hello world!')
\```
-Note:
-• **These are backticks, not quotes.** Backticks can usually be found on the tilde key.
-• You can also use py as the language instead of python
-• The language must be on the first line next to the backticks with **no** space between them
-
-This will result in the following:
-```py
-print('Hello world!')
-```
+**These are backticks, not quotes.** Check [this](https://superuser.com/questions/254076/how-do-i-type-the-tick-and-backtick-characters-on-windows/254077#254077) out if you can't find the backtick key.
diff --git a/bot/resources/tags/guilds.md b/bot/resources/tags/guilds.md
new file mode 100644
index 000000000..571abb99b
--- /dev/null
+++ b/bot/resources/tags/guilds.md
@@ -0,0 +1,3 @@
+**Communities**
+
+The [communities page](https://pythondiscord.com/pages/resources/communities/) on our website contains a number of communities we have partnered with as well as a [curated list](https://github.com/mhxion/awesome-discord-communities) of other communities relating to programming and technology.
diff --git a/bot/utils/cache.py b/bot/utils/cache.py
new file mode 100644
index 000000000..68ce15607
--- /dev/null
+++ b/bot/utils/cache.py
@@ -0,0 +1,41 @@
+import functools
+from collections import OrderedDict
+from typing import Any, Callable
+
+
+class AsyncCache:
+ """
+ LRU cache implementation for coroutines.
+
+ Once the cache exceeds the maximum size, keys are deleted in FIFO order.
+
+ An offset may be optionally provided to be applied to the coroutine's arguments when creating the cache key.
+ """
+
+ def __init__(self, max_size: int = 128):
+ self._cache = OrderedDict()
+ self._max_size = max_size
+
+ def __call__(self, arg_offset: int = 0) -> Callable:
+ """Decorator for async cache."""
+
+ def decorator(function: Callable) -> Callable:
+ """Define the async cache decorator."""
+
+ @functools.wraps(function)
+ async def wrapper(*args) -> Any:
+ """Decorator wrapper for the caching logic."""
+ key = args[arg_offset:]
+
+ if key not in self._cache:
+ if len(self._cache) > self._max_size:
+ self._cache.popitem(last=False)
+
+ self._cache[key] = await function(*args)
+ return self._cache[key]
+ return wrapper
+ return decorator
+
+ def clear(self) -> None:
+ """Clear cache instance."""
+ self._cache.clear()
diff --git a/bot/utils/channel.py b/bot/utils/channel.py
index d9d1b4b86..0c072184c 100644
--- a/bot/utils/channel.py
+++ b/bot/utils/channel.py
@@ -3,6 +3,7 @@ import logging
import discord
import bot
+from bot import constants
from bot.constants import Categories
log = logging.getLogger(__name__)
@@ -16,6 +17,21 @@ def is_help_channel(channel: discord.TextChannel) -> bool:
return any(is_in_category(channel, category) for category in categories)
+def is_mod_channel(channel: discord.TextChannel) -> bool:
+ """True if `channel` is considered a mod channel."""
+ if channel.id in constants.MODERATION_CHANNELS:
+ log.trace(f"Channel #{channel} is a configured mod channel")
+ return True
+
+ elif any(is_in_category(channel, category) for category in constants.MODERATION_CATEGORIES):
+ log.trace(f"Channel #{channel} is in a configured mod category")
+ return True
+
+ else:
+ log.trace(f"Channel #{channel} is not a mod channel")
+ return False
+
+
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/config-default.yml b/config-default.yml
index 0e7ebf2e3..8912841ff 100644
--- a/config-default.yml
+++ b/config-default.yml
@@ -119,6 +119,7 @@ style:
voice_state_green: "https://cdn.discordapp.com/emojis/656899770094452754.png"
voice_state_red: "https://cdn.discordapp.com/emojis/656899769905709076.png"
+
guild:
id: 267624335836053506
invite: "https://discord.gg/python"
@@ -127,7 +128,9 @@ guild:
help_available: 691405807388196926
help_in_use: 696958401460043776
help_dormant: 691405908919451718
- modmail: 714494672835444826
+ modmail: &MODMAIL 714494672835444826
+ logs: &LOGS 468520609152892958
+ voice: 356013253765234688
channels:
# Public announcement and news channels
@@ -169,6 +172,7 @@ guild:
bot_commands: &BOT_CMD 267659945086812160
esoteric: 470884583684964352
verification: 352442727016693763
+ voice_gate: 764802555427029012
# Staff
admins: &ADMINS 365960823622991872
@@ -178,7 +182,7 @@ guild:
incidents: 714214212200562749
incidents_archive: 720668923636351037
mods: &MODS 305126844661760000
- mod_alerts: &MOD_ALERTS 473092532147060736
+ mod_alerts: 473092532147060736
mod_spam: &MOD_SPAM 620607373828030464
organisation: &ORGANISATION 551789653284356126
staff_lounge: &STAFF_LOUNGE 464905259261755392
@@ -191,6 +195,8 @@ guild:
# Voice
code_help_voice: 755154969761677312
+ code_help_voice_2: 766330079135268884
+ voice_chat: 412357430186344448
admins_voice: &ADMINS_VOICE 500734494840717332
staff_voice: &STAFF_VOICE 412375055910043655
@@ -198,10 +204,13 @@ guild:
big_brother_logs: &BB_LOGS 468507907357409333
talent_pool: &TALENT_POOL 534321732593647616
+ moderation_categories:
+ - *MODMAIL
+ - *LOGS
+
moderation_channels:
- *ADMINS
- *ADMIN_SPAM
- - *MOD_ALERTS
- *MODS
- *MOD_SPAM
@@ -225,9 +234,11 @@ guild:
muted: &MUTED_ROLE 277914926603829249
partners: 323426753857191936
python_community: &PY_COMMUNITY_ROLE 458226413825294336
+ sprinters: &SPRINTERS 758422482289426471
unverified: 739794855945044069
verified: 352427296948486144 # @Developers on PyDis
+ voice_verified: 764802720779337729
# Staff
admins: &ADMINS_ROLE 267628507062992896
@@ -261,6 +272,7 @@ guild:
reddit: 635408384794951680
talent_pool: 569145364800602132
+
filter:
# What do we filter?
filter_zalgo: false
@@ -298,6 +310,7 @@ filter:
- *OWNERS_ROLE
- *HELPERS_ROLE
- *PY_COMMUNITY_ROLE
+ - *SPRINTERS
keys:
@@ -326,6 +339,7 @@ urls:
bot_avatar: "https://raw.githubusercontent.com/discord-python/branding/master/logos/logo_circle/logo_circle.png"
github_bot_repo: "https://github.com/python-discord/bot"
+
anti_spam:
# Clean messages that violate a rule.
clean_offending: true
@@ -459,10 +473,12 @@ help_channels:
notify_roles:
- *HELPERS_ROLE
+
redirect_output:
delete_invocation: true
delete_delay: 15
+
duck_pond:
threshold: 4
channel_blacklist:
@@ -478,11 +494,13 @@ duck_pond:
- *MOD_ANNOUNCEMENTS
- *ADMIN_ANNOUNCEMENTS
+
python_news:
mail_lists:
- 'python-ideas'
- 'python-announce-list'
- 'pypi-announce'
+ - 'python-dev'
channel: *PYNEWS_CHANNEL
webhook: *PYNEWS_WEBHOOK
@@ -499,5 +517,13 @@ verification:
kick_confirmation_threshold: 0.01 # 1%
+voice_gate:
+ minimum_days_verified: 3 # How many days the user must have been verified 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
+ voice_ping_delete_delay: 60 # Seconds before deleting the bot's ping to user in Voice Gate
+
+
config:
required_keys: ['bot.token']
diff --git a/docker-compose.yml b/docker-compose.yml
index 8be5aac0e..dc89e8885 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -26,7 +26,7 @@ services:
privileged: true
web:
- image: pythondiscord/site:latest
+ image: ghcr.io/python-discord/site:latest
command: ["run", "--debug"]
networks:
default:
diff --git a/tests/_autospec.py b/tests/_autospec.py
new file mode 100644
index 000000000..ee2fc1973
--- /dev/null
+++ b/tests/_autospec.py
@@ -0,0 +1,64 @@
+import contextlib
+import functools
+import unittest.mock
+from typing import Callable
+
+
[email protected](unittest.mock._patch.decoration_helper)
+def _decoration_helper(self, patched, args, keywargs):
+ """Skips adding patchings as args if their `dont_pass` attribute is True."""
+ # Don't ask what this does. It's just a copy from stdlib, but with the dont_pass check added.
+ extra_args = []
+ with contextlib.ExitStack() as exit_stack:
+ for patching in patched.patchings:
+ arg = exit_stack.enter_context(patching)
+ if not getattr(patching, "dont_pass", False):
+ # Only add the patching as an arg if dont_pass is False.
+ if patching.attribute_name is not None:
+ keywargs.update(arg)
+ elif patching.new is unittest.mock.DEFAULT:
+ extra_args.append(arg)
+
+ args += tuple(extra_args)
+ yield args, keywargs
+
+
[email protected](unittest.mock._patch.copy)
+def _copy(self):
+ """Copy the `dont_pass` attribute along with the standard copy operation."""
+ patcher_copy = _copy.original(self)
+ patcher_copy.dont_pass = getattr(self, "dont_pass", False)
+ return patcher_copy
+
+
+# Monkey-patch the patcher class :)
+_copy.original = unittest.mock._patch.copy
+unittest.mock._patch.copy = _copy
+unittest.mock._patch.decoration_helper = _decoration_helper
+
+
+def autospec(target, *attributes: str, pass_mocks: bool = True, **patch_kwargs) -> Callable:
+ """
+ Patch multiple `attributes` of a `target` with autospecced mocks and `spec_set` as True.
+
+ If `pass_mocks` is True, pass the autospecced mocks as arguments to the decorated object.
+ """
+ # Caller's kwargs should take priority and overwrite the defaults.
+ kwargs = dict(spec_set=True, autospec=True)
+ kwargs.update(patch_kwargs)
+
+ # Import the target if it's a string.
+ # This is to support both object and string targets like patch.multiple.
+ if type(target) is str:
+ target = unittest.mock._importer(target)
+
+ def decorator(func):
+ for attribute in attributes:
+ patcher = unittest.mock.patch.object(target, attribute, **kwargs)
+ if not pass_mocks:
+ # A custom attribute to keep track of which patchings should be skipped.
+ patcher.dont_pass = True
+ func = patcher(func)
+ return func
+ return decorator
diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py
index be1b649e1..bf557a484 100644
--- a/tests/bot/exts/moderation/infraction/test_infractions.py
+++ b/tests/bot/exts/moderation/infraction/test_infractions.py
@@ -1,7 +1,8 @@
import textwrap
import unittest
-from unittest.mock import AsyncMock, Mock, patch
+from unittest.mock import AsyncMock, MagicMock, Mock, patch
+from bot.constants import Event
from bot.exts.moderation.infraction.infractions import Infractions
from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole
@@ -53,3 +54,148 @@ class TruncationTests(unittest.IsolatedAsyncioTestCase):
self.cog.apply_infraction.assert_awaited_once_with(
self.ctx, {"foo": "bar"}, self.target, self.target.kick.return_value
)
+
+
+@patch("bot.exts.moderation.infraction.infractions.constants.Roles.voice_verified", new=123456)
+class VoiceBanTests(unittest.IsolatedAsyncioTestCase):
+ """Tests for voice ban related functions and commands."""
+
+ def setUp(self):
+ self.bot = MockBot()
+ self.mod = MockMember(top_role=10)
+ self.user = MockMember(top_role=1, roles=[MockRole(id=123456)])
+ self.guild = MockGuild()
+ self.ctx = MockContext(bot=self.bot, author=self.mod)
+ self.cog = Infractions(self.bot)
+
+ async def test_permanent_voice_ban(self):
+ """Should call voice ban applying function without expiry."""
+ self.cog.apply_voice_ban = AsyncMock()
+ self.assertIsNone(await self.cog.voiceban(self.cog, self.ctx, self.user, reason="foobar"))
+ self.cog.apply_voice_ban.assert_awaited_once_with(self.ctx, self.user, "foobar")
+
+ async def test_temporary_voice_ban(self):
+ """Should call voice ban applying function with expiry."""
+ self.cog.apply_voice_ban = AsyncMock()
+ self.assertIsNone(await self.cog.tempvoiceban(self.cog, self.ctx, self.user, "baz", reason="foobar"))
+ self.cog.apply_voice_ban.assert_awaited_once_with(self.ctx, self.user, "foobar", expires_at="baz")
+
+ async def test_voice_unban(self):
+ """Should call infraction pardoning function."""
+ self.cog.pardon_infraction = AsyncMock()
+ self.assertIsNone(await self.cog.unvoiceban(self.cog, self.ctx, self.user))
+ self.cog.pardon_infraction.assert_awaited_once_with(self.ctx, "voice_ban", self.user)
+
+ @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction")
+ @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction")
+ async def test_voice_ban_user_have_active_infraction(self, get_active_infraction, post_infraction_mock):
+ """Should return early when user already have Voice Ban infraction."""
+ get_active_infraction.return_value = {"foo": "bar"}
+ self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar"))
+ get_active_infraction.assert_awaited_once_with(self.ctx, self.user, "voice_ban")
+ post_infraction_mock.assert_not_awaited()
+
+ @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction")
+ @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction")
+ async def test_voice_ban_infraction_post_failed(self, get_active_infraction, post_infraction_mock):
+ """Should return early when posting infraction fails."""
+ self.cog.mod_log.ignore = MagicMock()
+ get_active_infraction.return_value = None
+ post_infraction_mock.return_value = None
+ self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar"))
+ post_infraction_mock.assert_awaited_once()
+ self.cog.mod_log.ignore.assert_not_called()
+
+ @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction")
+ @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction")
+ async def test_voice_ban_infraction_post_add_kwargs(self, get_active_infraction, post_infraction_mock):
+ """Should pass all kwargs passed to apply_voice_ban to post_infraction."""
+ get_active_infraction.return_value = None
+ # We don't want that this continue yet
+ post_infraction_mock.return_value = None
+ self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar", my_kwarg=23))
+ post_infraction_mock.assert_awaited_once_with(
+ self.ctx, self.user, "voice_ban", "foobar", active=True, my_kwarg=23
+ )
+
+ @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction")
+ @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction")
+ async def test_voice_ban_mod_log_ignore(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.cog.mod_log.ignore.assert_called_once_with(Event.member_update, self.user.id)
+
+ @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")
+
+ @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction")
+ @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction")
+ async def test_voice_ban_truncate_reason(self, get_active_infraction, post_infraction_mock):
+ """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")
+
+ async def test_voice_unban_user_not_found(self):
+ """Should include info to return dict when user was not found from guild."""
+ self.guild.get_member.return_value = None
+ result = await self.cog.pardon_voice_ban(self.user.id, self.guild, "foobar")
+ self.assertEqual(result, {"Info": "User was not found in the guild."})
+
+ @patch("bot.exts.moderation.infraction.infractions._utils.notify_pardon")
+ @patch("bot.exts.moderation.infraction.infractions.format_user")
+ async def test_voice_unban_user_found(self, format_user_mock, notify_pardon_mock):
+ """Should add role back with ignoring, notify user and return log dictionary.."""
+ self.guild.get_member.return_value = self.user
+ notify_pardon_mock.return_value = True
+ format_user_mock.return_value = "my-user"
+
+ result = await self.cog.pardon_voice_ban(self.user.id, self.guild, "foobar")
+ self.assertEqual(result, {
+ "Member": "my-user",
+ "DM": "Sent"
+ })
+ notify_pardon_mock.assert_awaited_once()
+
+ @patch("bot.exts.moderation.infraction.infractions._utils.notify_pardon")
+ @patch("bot.exts.moderation.infraction.infractions.format_user")
+ async def test_voice_unban_dm_fail(self, format_user_mock, notify_pardon_mock):
+ """Should add role back with ignoring, notify user and return log dictionary.."""
+ self.guild.get_member.return_value = self.user
+ notify_pardon_mock.return_value = False
+ format_user_mock.return_value = "my-user"
+
+ result = await self.cog.pardon_voice_ban(self.user.id, self.guild, "foobar")
+ self.assertEqual(result, {
+ "Member": "my-user",
+ "DM": "**Failed**"
+ })
+ notify_pardon_mock.assert_awaited_once()
diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py
index 3c2d52ae0..104293d8e 100644
--- a/tests/bot/exts/moderation/test_silence.py
+++ b/tests/bot/exts/moderation/test_silence.py
@@ -1,23 +1,49 @@
+import asyncio
import unittest
+from datetime import datetime, timezone
from unittest import mock
-from unittest.mock import MagicMock, Mock
+from unittest.mock import Mock
+from async_rediscache import RedisSession
from discord import PermissionOverwrite
-from bot.constants import Channels, Emojis, Guild, Roles
-from bot.exts.moderation.silence import Silence, SilenceNotifier
-from tests.helpers import MockBot, MockContext, MockTextChannel
+from bot.constants import Channels, Guild, Roles
+from bot.exts.moderation import silence
+from tests.helpers import MockBot, MockContext, MockTextChannel, autospec
+
+redis_session = None
+redis_loop = asyncio.get_event_loop()
+
+
+def setUpModule(): # noqa: N802
+ """Create and connect to the fakeredis session."""
+ global redis_session
+ redis_session = RedisSession(use_fakeredis=True)
+ redis_loop.run_until_complete(redis_session.connect())
+
+
+def tearDownModule(): # noqa: N802
+ """Close the fakeredis session."""
+ if redis_session:
+ redis_loop.run_until_complete(redis_session.close())
+
+
+# Have to subclass it because builtins can't be patched.
+class PatchedDatetime(datetime):
+ """A datetime object with a mocked now() function."""
+
+ now = mock.create_autospec(datetime, "now")
class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
self.alert_channel = MockTextChannel()
- self.notifier = SilenceNotifier(self.alert_channel)
+ self.notifier = silence.SilenceNotifier(self.alert_channel)
self.notifier.stop = self.notifier_stop_mock = Mock()
self.notifier.start = self.notifier_start_mock = Mock()
def test_add_channel_adds_channel(self):
- """Channel in FirstHash with current loop is added to internal set."""
+ """Channel is added to `_silenced_channels` with the current loop."""
channel = Mock()
with mock.patch.object(self.notifier, "_silenced_channels") as silenced_channels:
self.notifier.add_channel(channel)
@@ -35,7 +61,7 @@ class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase):
self.notifier_start_mock.assert_not_called()
def test_remove_channel_removes_channel(self):
- """Channel in FirstHash is removed from `_silenced_channels`."""
+ """Channel is removed from `_silenced_channels`."""
channel = Mock()
with mock.patch.object(self.notifier, "_silenced_channels") as silenced_channels:
self.notifier.remove_channel(channel)
@@ -59,7 +85,9 @@ class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase):
with self.subTest(current_loop=current_loop):
with mock.patch.object(self.notifier, "_current_loop", new=current_loop):
await self.notifier._notifier()
- self.alert_channel.send.assert_called_once_with(f"<@&{Roles.moderators}> currently silenced channels: ")
+ self.alert_channel.send.assert_called_once_with(
+ f"<@&{Roles.moderators}> currently silenced channels: "
+ )
self.alert_channel.send.reset_mock()
async def test_notifier_skips_alert(self):
@@ -72,192 +100,403 @@ class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase):
self.alert_channel.send.assert_not_called()
-class SilenceTests(unittest.IsolatedAsyncioTestCase):
+@autospec(silence.Silence, "previous_overwrites", "unsilence_timestamps", pass_mocks=False)
+class SilenceCogTests(unittest.IsolatedAsyncioTestCase):
+ """Tests for the general functionality of the Silence cog."""
+
+ @autospec(silence, "Scheduler", pass_mocks=False)
def setUp(self) -> None:
self.bot = MockBot()
- self.cog = Silence(self.bot)
- self.ctx = MockContext()
- self.cog._verified_role = None
- # Set event so command callbacks can continue.
- self.cog._get_instance_vars_event.set()
+ self.cog = silence.Silence(self.bot)
- async def test_instance_vars_got_guild(self):
+ @autospec(silence, "SilenceNotifier", pass_mocks=False)
+ async def test_async_init_got_guild(self):
"""Bot got guild after it became available."""
- await self.cog._get_instance_vars()
- self.bot.wait_until_guild_available.assert_called_once()
+ await self.cog._async_init()
+ self.bot.wait_until_guild_available.assert_awaited_once()
self.bot.get_guild.assert_called_once_with(Guild.id)
- async def test_instance_vars_got_role(self):
+ @autospec(silence, "SilenceNotifier", pass_mocks=False)
+ async def test_async_init_got_role(self):
"""Got `Roles.verified` role from guild."""
- await self.cog._get_instance_vars()
guild = self.bot.get_guild()
- guild.get_role.assert_called_once_with(Roles.verified)
+ guild.get_role.side_effect = lambda id_: Mock(id=id_)
- async def test_instance_vars_got_channels(self):
+ await self.cog._async_init()
+ self.assertEqual(self.cog._verified_role.id, Roles.verified)
+
+ @autospec(silence, "SilenceNotifier", pass_mocks=False)
+ async def test_async_init_got_channels(self):
"""Got channels from bot."""
- await self.cog._get_instance_vars()
- self.bot.get_channel.called_once_with(Channels.mod_alerts)
- self.bot.get_channel.called_once_with(Channels.mod_log)
+ self.bot.get_channel.side_effect = lambda id_: MockTextChannel(id=id_)
+
+ await self.cog._async_init()
+ self.assertEqual(self.cog._mod_alerts_channel.id, Channels.mod_alerts)
- @mock.patch("bot.exts.moderation.silence.SilenceNotifier")
- async def test_instance_vars_got_notifier(self, notifier):
+ @autospec(silence, "SilenceNotifier")
+ async def test_async_init_got_notifier(self, notifier):
"""Notifier was started with channel."""
- mod_log = MockTextChannel()
- self.bot.get_channel.side_effect = (None, mod_log)
- await self.cog._get_instance_vars()
- notifier.assert_called_once_with(mod_log)
- self.bot.get_channel.side_effect = None
-
- async def test_silence_sent_correct_discord_message(self):
- """Check if proper message was sent when called with duration in channel with previous state."""
+ self.bot.get_channel.side_effect = lambda id_: MockTextChannel(id=id_)
+
+ await self.cog._async_init()
+ notifier.assert_called_once_with(MockTextChannel(id=Channels.mod_log))
+ self.assertEqual(self.cog.notifier, notifier.return_value)
+
+ @autospec(silence, "SilenceNotifier", pass_mocks=False)
+ async def test_async_init_rescheduled(self):
+ """`_reschedule_` coroutine was awaited."""
+ self.cog._reschedule = mock.create_autospec(self.cog._reschedule)
+ await self.cog._async_init()
+ self.cog._reschedule.assert_awaited_once_with()
+
+ def test_cog_unload_cancelled_tasks(self):
+ """The init task was cancelled."""
+ self.cog._init_task = asyncio.Future()
+ self.cog.cog_unload()
+
+ # It's too annoying to test cancel_all since it's a done callback and wrapped in a lambda.
+ self.assertTrue(self.cog._init_task.cancelled())
+
+ @autospec("discord.ext.commands", "has_any_role")
+ @mock.patch.object(silence, "MODERATION_ROLES", new=(1, 2, 3))
+ async def test_cog_check(self, role_check):
+ """Role check was called with `MODERATION_ROLES`"""
+ ctx = MockContext()
+ role_check.return_value.predicate = mock.AsyncMock()
+
+ await self.cog.cog_check(ctx)
+ role_check.assert_called_once_with(*(1, 2, 3))
+ role_check.return_value.predicate.assert_awaited_once_with(ctx)
+
+
+@autospec(silence.Silence, "previous_overwrites", "unsilence_timestamps", pass_mocks=False)
+class RescheduleTests(unittest.IsolatedAsyncioTestCase):
+ """Tests for the rescheduling of cached unsilences."""
+
+ @autospec(silence, "Scheduler", "SilenceNotifier", pass_mocks=False)
+ def setUp(self):
+ self.bot = MockBot()
+ self.cog = silence.Silence(self.bot)
+ self.cog._unsilence_wrapper = mock.create_autospec(self.cog._unsilence_wrapper)
+
+ with mock.patch.object(self.cog, "_reschedule", autospec=True):
+ asyncio.run(self.cog._async_init()) # Populate instance attributes.
+
+ async def test_skipped_missing_channel(self):
+ """Did nothing because the channel couldn't be retrieved."""
+ self.cog.unsilence_timestamps.items.return_value = [(123, -1), (123, 1), (123, 10000000000)]
+ self.bot.get_channel.return_value = None
+
+ await self.cog._reschedule()
+
+ self.cog.notifier.add_channel.assert_not_called()
+ self.cog._unsilence_wrapper.assert_not_called()
+ self.cog.scheduler.schedule_later.assert_not_called()
+
+ async def test_added_permanent_to_notifier(self):
+ """Permanently silenced channels were added to the notifier."""
+ channels = [MockTextChannel(id=123), MockTextChannel(id=456)]
+ self.bot.get_channel.side_effect = channels
+ self.cog.unsilence_timestamps.items.return_value = [(123, -1), (456, -1)]
+
+ await self.cog._reschedule()
+
+ self.cog.notifier.add_channel.assert_any_call(channels[0])
+ self.cog.notifier.add_channel.assert_any_call(channels[1])
+
+ self.cog._unsilence_wrapper.assert_not_called()
+ self.cog.scheduler.schedule_later.assert_not_called()
+
+ async def test_unsilenced_expired(self):
+ """Unsilenced expired silences."""
+ channels = [MockTextChannel(id=123), MockTextChannel(id=456)]
+ self.bot.get_channel.side_effect = channels
+ self.cog.unsilence_timestamps.items.return_value = [(123, 100), (456, 200)]
+
+ await self.cog._reschedule()
+
+ self.cog._unsilence_wrapper.assert_any_call(channels[0])
+ self.cog._unsilence_wrapper.assert_any_call(channels[1])
+
+ self.cog.notifier.add_channel.assert_not_called()
+ self.cog.scheduler.schedule_later.assert_not_called()
+
+ @mock.patch.object(silence, "datetime", new=PatchedDatetime)
+ async def test_rescheduled_active(self):
+ """Rescheduled active silences."""
+ channels = [MockTextChannel(id=123), MockTextChannel(id=456)]
+ self.bot.get_channel.side_effect = channels
+ self.cog.unsilence_timestamps.items.return_value = [(123, 2000), (456, 3000)]
+ silence.datetime.now.return_value = datetime.fromtimestamp(1000, tz=timezone.utc)
+
+ self.cog._unsilence_wrapper = mock.MagicMock()
+ unsilence_return = self.cog._unsilence_wrapper.return_value
+
+ await self.cog._reschedule()
+
+ # Yuck.
+ calls = [mock.call(1000, 123, unsilence_return), mock.call(2000, 456, unsilence_return)]
+ self.cog.scheduler.schedule_later.assert_has_calls(calls)
+
+ unsilence_calls = [mock.call(channel) for channel in channels]
+ self.cog._unsilence_wrapper.assert_has_calls(unsilence_calls)
+
+ self.cog.notifier.add_channel.assert_not_called()
+
+
+@autospec(silence.Silence, "previous_overwrites", "unsilence_timestamps", pass_mocks=False)
+class SilenceTests(unittest.IsolatedAsyncioTestCase):
+ """Tests for the silence command and its related helper methods."""
+
+ @autospec(silence.Silence, "_reschedule", pass_mocks=False)
+ @autospec(silence, "Scheduler", "SilenceNotifier", pass_mocks=False)
+ def setUp(self) -> None:
+ self.bot = MockBot()
+ self.cog = silence.Silence(self.bot)
+ self.cog._init_task = asyncio.Future()
+ self.cog._init_task.set_result(None)
+
+ # Avoid unawaited coroutine warnings.
+ self.cog.scheduler.schedule_later.side_effect = lambda delay, task_id, coro: coro.close()
+
+ asyncio.run(self.cog._async_init()) # Populate instance attributes.
+
+ self.channel = MockTextChannel()
+ self.overwrite = PermissionOverwrite(stream=True, send_messages=True, add_reactions=False)
+ self.channel.overwrites_for.return_value = self.overwrite
+
+ async def test_sent_correct_message(self):
+ """Appropriate failure/success message was sent by the command."""
test_cases = (
- (0.0001, f"{Emojis.check_mark} silenced current channel for 0.0001 minute(s).", True,),
- (None, f"{Emojis.check_mark} silenced current channel indefinitely.", True,),
- (5, f"{Emojis.cross_mark} current channel is already silenced.", False,),
+ (0.0001, silence.MSG_SILENCE_SUCCESS.format(duration=0.0001), True,),
+ (None, silence.MSG_SILENCE_PERMANENT, True,),
+ (5, silence.MSG_SILENCE_FAIL, False,),
)
- for duration, result_message, _silence_patch_return in test_cases:
- with self.subTest(
- silence_duration=duration,
- result_message=result_message,
- starting_unsilenced_state=_silence_patch_return
- ):
- with mock.patch.object(self.cog, "_silence", return_value=_silence_patch_return):
- await self.cog.silence(self.cog, self.ctx, duration)
- self.ctx.send.assert_called_once_with(result_message)
- self.ctx.reset_mock()
-
- async def test_unsilence_sent_correct_discord_message(self):
- """Check if proper message was sent when unsilencing channel."""
- test_cases = (
- (True, f"{Emojis.check_mark} unsilenced current channel."),
- (False, f"{Emojis.cross_mark} current channel was not silenced.")
+ for duration, message, was_silenced in test_cases:
+ ctx = MockContext()
+ with mock.patch.object(self.cog, "_set_silence_overwrites", return_value=was_silenced):
+ with self.subTest(was_silenced=was_silenced, message=message, duration=duration):
+ await self.cog.silence.callback(self.cog, ctx, duration)
+ ctx.send.assert_called_once_with(message)
+
+ async def test_skipped_already_silenced(self):
+ """Permissions were not set and `False` was returned for an already silenced channel."""
+ subtests = (
+ (False, PermissionOverwrite(send_messages=False, add_reactions=False)),
+ (True, PermissionOverwrite(send_messages=True, add_reactions=True)),
+ (True, PermissionOverwrite(send_messages=False, add_reactions=False)),
)
- for _unsilence_patch_return, result_message in test_cases:
- with self.subTest(
- starting_silenced_state=_unsilence_patch_return,
- result_message=result_message
- ):
- with mock.patch.object(self.cog, "_unsilence", return_value=_unsilence_patch_return):
- await self.cog.unsilence(self.cog, self.ctx)
- self.ctx.send.assert_called_once_with(result_message)
- self.ctx.reset_mock()
-
- async def test_silence_private_for_false(self):
- """Permissions are not set and `False` is returned in an already silenced channel."""
- perm_overwrite = Mock(send_messages=False)
- channel = Mock(overwrites_for=Mock(return_value=perm_overwrite))
-
- self.assertFalse(await self.cog._silence(channel, True, None))
- channel.set_permissions.assert_not_called()
- async def test_silence_private_silenced_channel(self):
- """Channel had `send_message` permissions revoked."""
- channel = MockTextChannel()
- self.assertTrue(await self.cog._silence(channel, False, None))
- channel.set_permissions.assert_called_once()
- self.assertFalse(channel.set_permissions.call_args.kwargs['send_messages'])
+ for contains, overwrite in subtests:
+ with self.subTest(contains=contains, overwrite=overwrite):
+ self.cog.scheduler.__contains__.return_value = contains
+ channel = MockTextChannel()
+ channel.overwrites_for.return_value = overwrite
+
+ self.assertFalse(await self.cog._set_silence_overwrites(channel))
+ channel.set_permissions.assert_not_called()
+
+ async def test_silenced_channel(self):
+ """Channel had `send_message` and `add_reactions` permissions revoked for verified role."""
+ self.assertTrue(await self.cog._set_silence_overwrites(self.channel))
+ self.assertFalse(self.overwrite.send_messages)
+ self.assertFalse(self.overwrite.add_reactions)
+ self.channel.set_permissions.assert_awaited_once_with(
+ self.cog._verified_role,
+ overwrite=self.overwrite
+ )
- async def test_silence_private_preserves_permissions(self):
- """Previous permissions were preserved when channel was silenced."""
- channel = MockTextChannel()
- # Set up mock channel permission state.
- mock_permissions = PermissionOverwrite()
- mock_permissions_dict = dict(mock_permissions)
- channel.overwrites_for.return_value = mock_permissions
- await self.cog._silence(channel, False, None)
- new_permissions = channel.set_permissions.call_args.kwargs
- # Remove 'send_messages' key because it got changed in the method.
- del new_permissions['send_messages']
- del mock_permissions_dict['send_messages']
- self.assertDictEqual(mock_permissions_dict, new_permissions)
-
- async def test_silence_private_notifier(self):
- """Channel should be added to notifier with `persistent` set to `True`, and the other way around."""
- channel = MockTextChannel()
- with mock.patch.object(self.cog, "notifier", create=True):
- with self.subTest(persistent=True):
- await self.cog._silence(channel, True, None)
- self.cog.notifier.add_channel.assert_called_once()
-
- with mock.patch.object(self.cog, "notifier", create=True):
- with self.subTest(persistent=False):
- await self.cog._silence(channel, False, None)
- self.cog.notifier.add_channel.assert_not_called()
-
- async def test_silence_private_added_muted_channel(self):
- """Channel was added to `muted_channels` on silence."""
+ async def test_preserved_other_overwrites(self):
+ """Channel's other unrelated overwrites were not changed."""
+ prev_overwrite_dict = dict(self.overwrite)
+ await self.cog._set_silence_overwrites(self.channel)
+ new_overwrite_dict = dict(self.overwrite)
+
+ # Remove 'send_messages' & 'add_reactions' keys because they were changed by the method.
+ del prev_overwrite_dict['send_messages']
+ del prev_overwrite_dict['add_reactions']
+ del new_overwrite_dict['send_messages']
+ del new_overwrite_dict['add_reactions']
+
+ self.assertDictEqual(prev_overwrite_dict, new_overwrite_dict)
+
+ async def test_temp_not_added_to_notifier(self):
+ """Channel was not added to notifier if a duration was set for the silence."""
+ with mock.patch.object(self.cog, "_set_silence_overwrites", return_value=True):
+ await self.cog.silence.callback(self.cog, MockContext(), 15)
+ self.cog.notifier.add_channel.assert_not_called()
+
+ async def test_indefinite_added_to_notifier(self):
+ """Channel was added to notifier if a duration was not set for the silence."""
+ with mock.patch.object(self.cog, "_set_silence_overwrites", return_value=True):
+ await self.cog.silence.callback(self.cog, MockContext(), None)
+ self.cog.notifier.add_channel.assert_called_once()
+
+ async def test_silenced_not_added_to_notifier(self):
+ """Channel was not added to the notifier if it was already silenced."""
+ with mock.patch.object(self.cog, "_set_silence_overwrites", return_value=False):
+ await self.cog.silence.callback(self.cog, MockContext(), 15)
+ self.cog.notifier.add_channel.assert_not_called()
+
+ async def test_cached_previous_overwrites(self):
+ """Channel's previous overwrites were cached."""
+ overwrite_json = '{"send_messages": true, "add_reactions": false}'
+ await self.cog._set_silence_overwrites(self.channel)
+ self.cog.previous_overwrites.set.assert_called_once_with(self.channel.id, overwrite_json)
+
+ @autospec(silence, "datetime")
+ async def test_cached_unsilence_time(self, datetime_mock):
+ """The UTC POSIX timestamp for the unsilence was cached."""
+ now_timestamp = 100
+ duration = 15
+ timestamp = now_timestamp + duration * 60
+ datetime_mock.now.return_value = datetime.fromtimestamp(now_timestamp, tz=timezone.utc)
+
+ ctx = MockContext(channel=self.channel)
+ await self.cog.silence.callback(self.cog, ctx, duration)
+
+ self.cog.unsilence_timestamps.set.assert_awaited_once_with(ctx.channel.id, timestamp)
+ datetime_mock.now.assert_called_once_with(tz=timezone.utc) # Ensure it's using an aware dt.
+
+ async def test_cached_indefinite_time(self):
+ """A value of -1 was cached for a permanent silence."""
+ ctx = MockContext(channel=self.channel)
+ await self.cog.silence.callback(self.cog, ctx, None)
+ self.cog.unsilence_timestamps.set.assert_awaited_once_with(ctx.channel.id, -1)
+
+ async def test_scheduled_task(self):
+ """An unsilence task was scheduled."""
+ ctx = MockContext(channel=self.channel, invoke=mock.MagicMock())
+
+ await self.cog.silence.callback(self.cog, ctx, 5)
+
+ args = (300, ctx.channel.id, ctx.invoke.return_value)
+ self.cog.scheduler.schedule_later.assert_called_once_with(*args)
+ ctx.invoke.assert_called_once_with(self.cog.unsilence)
+
+ async def test_permanent_not_scheduled(self):
+ """A task was not scheduled for a permanent silence."""
+ ctx = MockContext(channel=self.channel)
+ await self.cog.silence.callback(self.cog, ctx, None)
+ self.cog.scheduler.schedule_later.assert_not_called()
+
+
+@autospec(silence.Silence, "unsilence_timestamps", pass_mocks=False)
+class UnsilenceTests(unittest.IsolatedAsyncioTestCase):
+ """Tests for the unsilence command and its related helper methods."""
+
+ @autospec(silence.Silence, "_reschedule", pass_mocks=False)
+ @autospec(silence, "Scheduler", "SilenceNotifier", pass_mocks=False)
+ def setUp(self) -> None:
+ self.bot = MockBot(get_channel=lambda _: MockTextChannel())
+ self.cog = silence.Silence(self.bot)
+ self.cog._init_task = asyncio.Future()
+ self.cog._init_task.set_result(None)
+
+ overwrites_cache = mock.create_autospec(self.cog.previous_overwrites, spec_set=True)
+ self.cog.previous_overwrites = overwrites_cache
+
+ asyncio.run(self.cog._async_init()) # Populate instance attributes.
+
+ self.cog.scheduler.__contains__.return_value = True
+ overwrites_cache.get.return_value = '{"send_messages": true, "add_reactions": false}'
+ self.channel = MockTextChannel()
+ self.overwrite = PermissionOverwrite(stream=True, send_messages=False, add_reactions=False)
+ self.channel.overwrites_for.return_value = self.overwrite
+
+ async def test_sent_correct_message(self):
+ """Appropriate failure/success message was sent by the command."""
+ unsilenced_overwrite = PermissionOverwrite(send_messages=True, add_reactions=True)
+ test_cases = (
+ (True, silence.MSG_UNSILENCE_SUCCESS, unsilenced_overwrite),
+ (False, silence.MSG_UNSILENCE_FAIL, unsilenced_overwrite),
+ (False, silence.MSG_UNSILENCE_MANUAL, self.overwrite),
+ (False, silence.MSG_UNSILENCE_MANUAL, PermissionOverwrite(send_messages=False)),
+ (False, silence.MSG_UNSILENCE_MANUAL, PermissionOverwrite(add_reactions=False)),
+ )
+ for was_unsilenced, message, overwrite in test_cases:
+ ctx = MockContext()
+ with self.subTest(was_unsilenced=was_unsilenced, message=message, overwrite=overwrite):
+ with mock.patch.object(self.cog, "_unsilence", return_value=was_unsilenced):
+ ctx.channel.overwrites_for.return_value = overwrite
+ await self.cog.unsilence.callback(self.cog, ctx)
+ ctx.channel.send.assert_called_once_with(message)
+
+ async def test_skipped_already_unsilenced(self):
+ """Permissions were not set and `False` was returned for an already unsilenced channel."""
+ self.cog.scheduler.__contains__.return_value = False
+ self.cog.previous_overwrites.get.return_value = None
channel = MockTextChannel()
- with mock.patch.object(self.cog, "muted_channels") as muted_channels:
- await self.cog._silence(channel, False, None)
- muted_channels.add.assert_called_once_with(channel)
- async def test_unsilence_private_for_false(self):
- """Permissions are not set and `False` is returned in an unsilenced channel."""
- channel = Mock()
self.assertFalse(await self.cog._unsilence(channel))
channel.set_permissions.assert_not_called()
- @mock.patch.object(Silence, "notifier", create=True)
- async def test_unsilence_private_unsilenced_channel(self, _):
- """Channel had `send_message` permissions restored"""
- perm_overwrite = MagicMock(send_messages=False)
- channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite))
- self.assertTrue(await self.cog._unsilence(channel))
- channel.set_permissions.assert_called_once()
- self.assertIsNone(channel.set_permissions.call_args.kwargs['send_messages'])
-
- @mock.patch.object(Silence, "notifier", create=True)
- async def test_unsilence_private_removed_notifier(self, notifier):
- """Channel was removed from `notifier` on unsilence."""
- perm_overwrite = MagicMock(send_messages=False)
- channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite))
- await self.cog._unsilence(channel)
- notifier.remove_channel.assert_called_once_with(channel)
-
- @mock.patch.object(Silence, "notifier", create=True)
- async def test_unsilence_private_removed_muted_channel(self, _):
- """Channel was removed from `muted_channels` on unsilence."""
- perm_overwrite = MagicMock(send_messages=False)
- channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite))
- with mock.patch.object(self.cog, "muted_channels") as muted_channels:
- await self.cog._unsilence(channel)
- muted_channels.discard.assert_called_once_with(channel)
-
- @mock.patch.object(Silence, "notifier", create=True)
- async def test_unsilence_private_preserves_permissions(self, _):
- """Previous permissions were preserved when channel was unsilenced."""
- channel = MockTextChannel()
- # Set up mock channel permission state.
- mock_permissions = PermissionOverwrite(send_messages=False)
- mock_permissions_dict = dict(mock_permissions)
- channel.overwrites_for.return_value = mock_permissions
- await self.cog._unsilence(channel)
- new_permissions = channel.set_permissions.call_args.kwargs
- # Remove 'send_messages' key because it got changed in the method.
- del new_permissions['send_messages']
- del mock_permissions_dict['send_messages']
- self.assertDictEqual(mock_permissions_dict, new_permissions)
-
- @mock.patch("bot.exts.moderation.silence.asyncio")
- @mock.patch.object(Silence, "_mod_alerts_channel", create=True)
- def test_cog_unload_starts_task(self, alert_channel, asyncio_mock):
- """Task for sending an alert was created with present `muted_channels`."""
- with mock.patch.object(self.cog, "muted_channels"):
- self.cog.cog_unload()
- alert_channel.send.assert_called_once_with(f"<@&{Roles.moderators}> channels left silenced on cog unload: ")
- asyncio_mock.create_task.assert_called_once_with(alert_channel.send())
-
- @mock.patch("bot.exts.moderation.silence.asyncio")
- def test_cog_unload_skips_task_start(self, asyncio_mock):
- """No task created with no channels."""
- self.cog.cog_unload()
- asyncio_mock.create_task.assert_not_called()
+ async def test_restored_overwrites(self):
+ """Channel's `send_message` and `add_reactions` overwrites were restored."""
+ await self.cog._unsilence(self.channel)
+ self.channel.set_permissions.assert_awaited_once_with(
+ self.cog._verified_role,
+ overwrite=self.overwrite,
+ )
- @mock.patch("discord.ext.commands.has_any_role")
- @mock.patch("bot.exts.moderation.silence.MODERATION_ROLES", new=(1, 2, 3))
- async def test_cog_check(self, role_check):
- """Role check is called with `MODERATION_ROLES`"""
- role_check.return_value.predicate = mock.AsyncMock()
- await self.cog.cog_check(self.ctx)
- role_check.assert_called_once_with(*(1, 2, 3))
- role_check.return_value.predicate.assert_awaited_once_with(self.ctx)
+ # Recall that these values are determined by the fixture.
+ self.assertTrue(self.overwrite.send_messages)
+ self.assertFalse(self.overwrite.add_reactions)
+
+ async def test_cache_miss_used_default_overwrites(self):
+ """Both overwrites were set to None due previous values not being found in the cache."""
+ self.cog.previous_overwrites.get.return_value = None
+
+ await self.cog._unsilence(self.channel)
+ self.channel.set_permissions.assert_awaited_once_with(
+ self.cog._verified_role,
+ overwrite=self.overwrite,
+ )
+
+ self.assertIsNone(self.overwrite.send_messages)
+ self.assertIsNone(self.overwrite.add_reactions)
+
+ async def test_cache_miss_sent_mod_alert(self):
+ """A message was sent to the mod alerts channel."""
+ self.cog.previous_overwrites.get.return_value = None
+
+ await self.cog._unsilence(self.channel)
+ self.cog._mod_alerts_channel.send.assert_awaited_once()
+
+ async def test_removed_notifier(self):
+ """Channel was removed from `notifier`."""
+ await self.cog._unsilence(self.channel)
+ self.cog.notifier.remove_channel.assert_called_once_with(self.channel)
+
+ async def test_deleted_cached_overwrite(self):
+ """Channel was deleted from the overwrites cache."""
+ await self.cog._unsilence(self.channel)
+ self.cog.previous_overwrites.delete.assert_awaited_once_with(self.channel.id)
+
+ async def test_deleted_cached_time(self):
+ """Channel was deleted from the timestamp cache."""
+ await self.cog._unsilence(self.channel)
+ self.cog.unsilence_timestamps.delete.assert_awaited_once_with(self.channel.id)
+
+ async def test_cancelled_task(self):
+ """The scheduled unsilence task should be cancelled."""
+ await self.cog._unsilence(self.channel)
+ self.cog.scheduler.cancel.assert_called_once_with(self.channel.id)
+
+ async def test_preserved_other_overwrites(self):
+ """Channel's other unrelated overwrites were not changed, including cache misses."""
+ for overwrite_json in ('{"send_messages": true, "add_reactions": null}', None):
+ with self.subTest(overwrite_json=overwrite_json):
+ self.cog.previous_overwrites.get.return_value = overwrite_json
+
+ prev_overwrite_dict = dict(self.overwrite)
+ await self.cog._unsilence(self.channel)
+ new_overwrite_dict = dict(self.overwrite)
+
+ # Remove these keys because they were modified by the unsilence.
+ del prev_overwrite_dict['send_messages']
+ del prev_overwrite_dict['add_reactions']
+ del new_overwrite_dict['send_messages']
+ del new_overwrite_dict['add_reactions']
+
+ self.assertDictEqual(prev_overwrite_dict, new_overwrite_dict)
diff --git a/tests/bot/exts/utils/test_snekbox.py b/tests/bot/exts/utils/test_snekbox.py
index 9d3e07e7c..321a92445 100644
--- a/tests/bot/exts/utils/test_snekbox.py
+++ b/tests/bot/exts/utils/test_snekbox.py
@@ -50,6 +50,13 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):
('`print("Hello world!")`', 'print("Hello world!")', 'one line code block'),
('```\nprint("Hello world!")```', 'print("Hello world!")', 'multiline code block'),
('```py\nprint("Hello world!")```', 'print("Hello world!")', 'multiline python code block'),
+ ('text```print("Hello world!")```text', 'print("Hello world!")', 'code block surrounded by text'),
+ ('```print("Hello world!")```\ntext\n```py\nprint("Hello world!")```',
+ 'print("Hello world!")\nprint("Hello world!")', 'two code blocks with text in-between'),
+ ('`print("Hello world!")`\ntext\n```print("How\'s it going?")```',
+ 'print("How\'s it going?")', 'code block preceded by inline code'),
+ ('`print("Hello world!")`\ntext\n`print("Hello world!")`',
+ 'print("Hello world!")', 'one inline code block of two')
)
for case, expected, testname in cases:
with self.subTest(msg=f'Extract code from {testname}.'):
diff --git a/tests/helpers.py b/tests/helpers.py
index e47fdf28f..870f66197 100644
--- a/tests/helpers.py
+++ b/tests/helpers.py
@@ -5,7 +5,7 @@ import itertools
import logging
import unittest.mock
from asyncio import AbstractEventLoop
-from typing import Callable, Iterable, Optional
+from typing import Iterable, Optional
import discord
from aiohttp import ClientSession
@@ -14,6 +14,7 @@ from discord.ext.commands import Context
from bot.api import APIClient
from bot.async_stats import AsyncStatsClient
from bot.bot import Bot
+from tests._autospec import autospec # noqa: F401 other modules import it via this module
for logger in logging.Logger.manager.loggerDict.values():
@@ -26,24 +27,6 @@ for logger in logging.Logger.manager.loggerDict.values():
logger.setLevel(logging.CRITICAL)
-def autospec(target, *attributes: str, **kwargs) -> Callable:
- """Patch multiple `attributes` of a `target` with autospecced mocks and `spec_set` as True."""
- # Caller's kwargs should take priority and overwrite the defaults.
- kwargs = {'spec_set': True, 'autospec': True, **kwargs}
-
- # Import the target if it's a string.
- # This is to support both object and string targets like patch.multiple.
- if type(target) is str:
- target = unittest.mock._importer(target)
-
- def decorator(func):
- for attribute in attributes:
- patcher = unittest.mock.patch.object(target, attribute, **kwargs)
- func = patcher(func)
- return func
- return decorator
-
-
class HashableMixin(discord.mixins.EqualityComparable):
"""
Mixin that provides similar hashing and equality functionality as discord.py's `Hashable` mixin.