aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar ks129 <[email protected]>2020-11-21 09:37:57 +0200
committerGravatar GitHub <[email protected]>2020-11-21 09:37:57 +0200
commit95429b10aab81d849ab86241f411bbc122ea1594 (patch)
tree707b7f6055e8cd6d11f1034ed6296470bc437416
parentEH tests: Fix InWhitelistCheckFailure import path (diff)
parentMerge pull request #1287 from python-discord/help-channel-msg (diff)
Merge branch 'master' into error-handler-test
-rw-r--r--.github/workflows/build.yml57
-rw-r--r--.github/workflows/codeql-analysis.yml32
-rw-r--r--.github/workflows/deploy.yml39
-rw-r--r--.github/workflows/lint-test.yml115
-rw-r--r--.gitignore2
-rw-r--r--LICENSE-THIRD-PARTY88
-rw-r--r--Pipfile9
-rw-r--r--Pipfile.lock420
-rw-r--r--README.md18
-rw-r--r--azure-pipelines.yml107
-rw-r--r--bot/__init__.py67
-rw-r--r--bot/__main__.py76
-rw-r--r--bot/bot.py60
-rw-r--r--bot/constants.py43
-rw-r--r--bot/decorators.py99
-rw-r--r--bot/errors.py20
-rw-r--r--bot/exts/backend/alias.py87
-rw-r--r--bot/exts/backend/error_handler.py3
-rw-r--r--bot/exts/backend/sync/_cog.py9
-rw-r--r--bot/exts/backend/sync/_syncers.py323
-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/fun/duck_pond.py17
-rw-r--r--bot/exts/fun/off_topic_names.py5
-rw-r--r--bot/exts/help_channels.py71
-rw-r--r--bot/exts/info/codeblock/__init__.py8
-rw-r--r--bot/exts/info/codeblock/_cog.py186
-rw-r--r--bot/exts/info/codeblock/_instructions.py184
-rw-r--r--bot/exts/info/codeblock/_parsing.py228
-rw-r--r--bot/exts/info/doc.py41
-rw-r--r--bot/exts/info/help.py8
-rw-r--r--bot/exts/info/information.py102
-rw-r--r--bot/exts/info/reddit.py16
-rw-r--r--bot/exts/info/site.py21
-rw-r--r--bot/exts/info/source.py24
-rw-r--r--bot/exts/info/stats.py42
-rw-r--r--bot/exts/info/tags.py4
-rw-r--r--bot/exts/moderation/dm_relay.py6
-rw-r--r--bot/exts/moderation/incidents.py2
-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.py112
-rw-r--r--bot/exts/moderation/infraction/management.py14
-rw-r--r--bot/exts/moderation/infraction/superstarify.py5
-rw-r--r--bot/exts/moderation/modlog.py2
-rw-r--r--bot/exts/moderation/silence.py202
-rw-r--r--bot/exts/moderation/verification.py131
-rw-r--r--bot/exts/moderation/voice_gate.py265
-rw-r--r--bot/exts/utils/bot.py326
-rw-r--r--bot/exts/utils/internal.py (renamed from bot/exts/utils/eval.py)46
-rw-r--r--bot/exts/utils/ping.py2
-rw-r--r--bot/exts/utils/reminders.py61
-rw-r--r--bot/exts/utils/snekbox.py53
-rw-r--r--bot/exts/utils/utils.py196
-rw-r--r--bot/interpreter.py6
-rw-r--r--bot/log.py86
-rw-r--r--bot/patches/__init__.py6
-rw-r--r--bot/patches/message_edited_at.py32
-rw-r--r--bot/resources/tags/codeblock.md16
-rw-r--r--bot/resources/tags/guilds.md3
-rw-r--r--bot/rules/discord_emojis.py8
-rw-r--r--bot/utils/__init__.py4
-rw-r--r--bot/utils/cache.py41
-rw-r--r--bot/utils/channel.py50
-rw-r--r--bot/utils/function.py75
-rw-r--r--bot/utils/helpers.py9
-rw-r--r--bot/utils/lock.py114
-rw-r--r--bot/utils/messages.py41
-rw-r--r--bot/utils/services.py8
-rw-r--r--config-default.yml65
-rw-r--r--deployment.yaml21
-rw-r--r--docker-compose.yml5
-rw-r--r--tests/_autospec.py64
-rw-r--r--tests/bot/exts/backend/sync/test_base.py378
-rw-r--r--tests/bot/exts/backend/sync/test_cog.py38
-rw-r--r--tests/bot/exts/backend/sync/test_roles.py26
-rw-r--r--tests/bot/exts/backend/sync/test_users.py147
-rw-r--r--tests/bot/exts/info/test_information.py175
-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.py25
-rw-r--r--tests/bot/patches/__init__.py0
-rw-r--r--tests/bot/rules/test_discord_emojis.py29
-rw-r--r--tests/bot/utils/test_services.py39
-rw-r--r--tests/helpers.py21
86 files changed, 3832 insertions, 2526 deletions
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 000000000..706ab462f
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,57 @@
+name: Build
+
+on:
+ workflow_run:
+ workflows: ["Lint & Test"]
+ branches:
+ - master
+ types:
+ - completed
+
+jobs:
+ build:
+ if: github.event.workflow_run.conclusion == 'success'
+ name: Build & Push
+ 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
+
+ # The current version (v2) of Docker's build-push action uses
+ # buildx, which comes with BuildKit features that help us speed
+ # up our builds using additional cache features. Buildx also
+ # has a lot of other features that are not as relevant to us.
+ #
+ # See https://github.com/docker/build-push-action
+ - 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 }}
+
+ # Build and push the container to the GitHub Container
+ # Repository. The container will be tagged as "latest"
+ # and with 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
+ cache-to: type=inline
+ tags: |
+ ghcr.io/python-discord/bot:latest
+ ghcr.io/python-discord/bot:${{ steps.sha_tag.outputs.tag }}
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/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 000000000..90555a8ee
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,39 @@
+name: Deploy
+
+on:
+ workflow_run:
+ workflows: ["Build"]
+ branches:
+ - master
+ types:
+ - completed
+
+jobs:
+ build:
+ if: github.event.workflow_run.conclusion == 'success'
+ name: Build & Push
+ runs-on: ubuntu-latest
+
+ steps:
+ - 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: Authenticate with Kubernetes
+ uses: azure/k8s-set-context@v1
+ with:
+ method: kubeconfig
+ kubeconfig: ${{ secrets.KUBECONFIG }}
+
+ - name: Deploy to Kubernetes
+ uses: Azure/k8s-deploy@v1
+ with:
+ manifests: |
+ deployment.yaml
+ images: 'ghcr.io/python-discord/bot:${{ steps.sha_tag.outputs.tag }}'
+ kubectl-version: 'latest'
diff --git a/.github/workflows/lint-test.yml b/.github/workflows/lint-test.yml
new file mode 100644
index 000000000..5444fc3de
--- /dev/null
+++ b/.github/workflows/lint-test.yml
@@ -0,0 +1,115 @@
+name: Lint & Test
+
+on:
+ push:
+ branches:
+ - master
+ pull_request:
+
+
+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
+
+ - name: Checkout repository
+ uses: actions/checkout@v2
+
+ - 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
+
+ # Run flake8 and have it format the linting errors in the format of
+ # the GitHub Workflow command to register error annotations. This
+ # means that our flake8 output is automatically added as an error
+ # annotation to both the run result and in the "Files" tab of a
+ # pull request.
+ #
+ # Format used:
+ # ::error file={filename},line={line},col={col}::{message}
+ - name: Run flake8
+ run: "flake8 \
+ --format='::error file=%(path)s,line=%(row)d,col=%(col)d::\
+ [flake8] %(code)s: %(text)s'"
+
+ # 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
diff --git a/.gitignore b/.gitignore
index fb3156ab1..9186dbe06 100644
--- a/.gitignore
+++ b/.gitignore
@@ -110,6 +110,8 @@ ENV/
# Logfiles
log.*
+*.log.*
+!log.py
# Custom user configuration
config.yml
diff --git a/LICENSE-THIRD-PARTY b/LICENSE-THIRD-PARTY
new file mode 100644
index 000000000..eacd9b952
--- /dev/null
+++ b/LICENSE-THIRD-PARTY
@@ -0,0 +1,88 @@
+---------------------------------------------------------------------------------------------------
+ BSD 3-Clause License
+Applies to:
+ - 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:
+
+* Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+* Neither the name of the copyright holder nor the names of its
+ contributors may be used to endorse or promote products derived from
+ this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+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 e6f84d911..ae80ae2ae 100644
--- a/Pipfile
+++ b/Pipfile
@@ -14,7 +14,7 @@ beautifulsoup4 = "~=4.9"
colorama = {version = "~=0.4.3",sys_platform = "== 'win32'"}
coloredlogs = "~=14.0"
deepdiff = "~=4.0"
-discord.py = "~=1.4.0"
+"discord.py" = "~=1.5.0"
feedparser = "~=5.2"
fuzzywuzzy = "~=0.17"
lxml = "~=4.4"
@@ -26,6 +26,7 @@ requests = "~=2.22"
sentry-sdk = "~=0.14"
sphinx = "~=2.2"
statsd = "~=3.3"
+emoji = "~=0.6"
[dev-packages]
coverage = "~=5.0"
@@ -39,7 +40,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"
@@ -48,8 +49,8 @@ python_version = "3.8"
start = "python -m bot"
lint = "pre-commit run --all-files"
precommit = "pre-commit install"
-build = "docker build -t pythondiscord/bot:latest -f Dockerfile ."
-push = "docker push pythondiscord/bot:latest"
+build = "docker build -t ghcr.io/python-discord/bot:latest -f Dockerfile ."
+push = "docker push ghcr.io/python-discord/bot:latest"
test = "coverage run -m unittest"
html = "coverage html"
report = "coverage report"
diff --git a/Pipfile.lock b/Pipfile.lock
index f75852081..541db1627 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
- "sha256": "644012a1c3fa3e3a30f8b8f8e672c468dfaa155d9e43d26e2be8713c8dc5ebb3"
+ "sha256": "3ccb368599709d2970f839fc3721cfeebcd5a2700fed7231b2ce38a080828325"
},
"pipfile-spec": 6,
"requires": {
@@ -18,11 +18,11 @@
"default": {
"aio-pika": {
"hashes": [
- "sha256:4a20d4d941e1f113a950ea529a90bd9159c8d7aafaa1c71e9c707c8c2b526ea6",
- "sha256:7bf3f183df1eb348d007210a0c1a3c5c755f1b3def1a9a395e93f30b91da1daf"
+ "sha256:9773440a89840941ac3099a7720bf9d51e8764a484066b82ede4d395660ff430",
+ "sha256:a8065be3c722eb8f9fff8c0e7590729e7782202cdb9363d9830d7d5d47b45c7c"
],
"index": "pypi",
- "version": "==6.7.0"
+ "version": "==6.7.1"
},
"aiodns": {
"hashes": [
@@ -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": [
@@ -86,12 +87,12 @@
"fakeredis"
],
"hashes": [
- "sha256:407aed1aad97bf22f690eca5369806d22eefc8ca104a52c1f1bd47dd6db45fc2",
- "sha256:563aaff79ec611a92a0ad78e39ff159e3a4b4cf0bea41e061de5f3701a17d50c"
+ "sha256:6be8a657d724ccbcfb1946d29a80c3478c5f9ecd2f78a0a26d2f4013a622258f",
+ "sha256:c25e4fff73f64d20645254783c3224a4c49e083e3fab67c44f17af944c5e26af"
],
"index": "pypi",
"markers": "python_version ~= '3.7'",
- "version": "==0.1.2"
+ "version": "==0.1.4"
},
"async-timeout": {
"hashes": [
@@ -103,68 +104,76 @@
},
"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:73cc4d115b96f79c7d77c1c7f7a0a8d4c57860d1041df407dd1aae7f07a77fd7",
- "sha256:a6237df3c32ccfaee4fd201c8f5f9d9df619b93121d01353a64a73ce8c6ef9a8",
- "sha256:e718f2342e2e099b640a34ab782407b7b676f47ee272d6739e60b8ea23829f2c"
+ "sha256:4c98143716ef1cb40bf7f39a8e3eec8f8b009509e74904ba3a7b315431577e35",
+ "sha256:84729e322ad1d5b4d25f805bfa05b902dd96450f43842c4e99067d5e1369eb25",
+ "sha256:fff47e031e34ec82bf17e00da8f592fe7de69aeea38be00523c04623c04fb666"
],
"index": "pypi",
- "version": "==4.9.1"
+ "version": "==4.9.3"
},
"certifi": {
"hashes": [
- "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3",
- "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41"
+ "sha256:1f422849db327d534e3d0c5f02a263458c3955ec0aae4ff09b95f195c59f4edd",
+ "sha256:f05def092c44fbf25834a51509ef6e631dc19765ab8a57b4e7ab85531f0a9cf4"
],
- "version": "==2020.6.20"
+ "version": "==2020.11.8"
},
"cffi": {
"hashes": [
- "sha256:0da50dcbccd7cb7e6c741ab7912b2eff48e85af217d72b57f80ebc616257125e",
- "sha256:12a453e03124069b6896107ee133ae3ab04c624bb10683e1ed1c1663df17c13c",
- "sha256:15419020b0e812b40d96ec9d369b2bc8109cc3295eac6e013d3261343580cc7e",
- "sha256:15a5f59a4808f82d8ec7364cbace851df591c2d43bc76bcbe5c4543a7ddd1bf1",
- "sha256:23e44937d7695c27c66a54d793dd4b45889a81b35c0751ba91040fe825ec59c4",
- "sha256:29c4688ace466a365b85a51dcc5e3c853c1d283f293dfcc12f7a77e498f160d2",
- "sha256:57214fa5430399dffd54f4be37b56fe22cedb2b98862550d43cc085fb698dc2c",
- "sha256:577791f948d34d569acb2d1add5831731c59d5a0c50a6d9f629ae1cefd9ca4a0",
- "sha256:6539314d84c4d36f28d73adc1b45e9f4ee2a89cdc7e5d2b0a6dbacba31906798",
- "sha256:65867d63f0fd1b500fa343d7798fa64e9e681b594e0a07dc934c13e76ee28fb1",
- "sha256:672b539db20fef6b03d6f7a14b5825d57c98e4026401fce838849f8de73fe4d4",
- "sha256:6843db0343e12e3f52cc58430ad559d850a53684f5b352540ca3f1bc56df0731",
- "sha256:7057613efefd36cacabbdbcef010e0a9c20a88fc07eb3e616019ea1692fa5df4",
- "sha256:76ada88d62eb24de7051c5157a1a78fd853cca9b91c0713c2e973e4196271d0c",
- "sha256:837398c2ec00228679513802e3744d1e8e3cb1204aa6ad408b6aff081e99a487",
- "sha256:8662aabfeab00cea149a3d1c2999b0731e70c6b5bac596d95d13f643e76d3d4e",
- "sha256:95e9094162fa712f18b4f60896e34b621df99147c2cee216cfa8f022294e8e9f",
- "sha256:99cc66b33c418cd579c0f03b77b94263c305c389cb0c6972dac420f24b3bf123",
- "sha256:9b219511d8b64d3fa14261963933be34028ea0e57455baf6781fe399c2c3206c",
- "sha256:ae8f34d50af2c2154035984b8b5fc5d9ed63f32fe615646ab435b05b132ca91b",
- "sha256:b9aa9d8818c2e917fa2c105ad538e222a5bce59777133840b93134022a7ce650",
- "sha256:bf44a9a0141a082e89c90e8d785b212a872db793a0080c20f6ae6e2a0ebf82ad",
- "sha256:c0b48b98d79cf795b0916c57bebbc6d16bb43b9fc9b8c9f57f4cf05881904c75",
- "sha256:da9d3c506f43e220336433dffe643fbfa40096d408cb9b7f2477892f369d5f82",
- "sha256:e4082d832e36e7f9b2278bc774886ca8207346b99f278e54c9de4834f17232f7",
- "sha256:e4b9b7af398c32e408c00eb4e0d33ced2f9121fd9fb978e6c1b57edd014a7d15",
- "sha256:e613514a82539fc48291d01933951a13ae93b6b444a88782480be32245ed4afa",
- "sha256:f5033952def24172e60493b68717792e3aebb387a8d186c43c020d9363ee7281"
- ],
- "version": "==1.14.2"
+ "sha256:005f2bfe11b6745d726dbb07ace4d53f057de66e336ff92d61b8c7e9c8f4777d",
+ "sha256:09e96138280241bd355cd585148dec04dbbedb4f46128f340d696eaafc82dd7b",
+ "sha256:0b1ad452cc824665ddc682400b62c9e4f5b64736a2ba99110712fdee5f2505c4",
+ "sha256:0ef488305fdce2580c8b2708f22d7785ae222d9825d3094ab073e22e93dfe51f",
+ "sha256:15f351bed09897fbda218e4db5a3d5c06328862f6198d4fb385f3e14e19decb3",
+ "sha256:22399ff4870fb4c7ef19fff6eeb20a8bbf15571913c181c78cb361024d574579",
+ "sha256:23e5d2040367322824605bc29ae8ee9175200b92cb5483ac7d466927a9b3d537",
+ "sha256:2791f68edc5749024b4722500e86303a10d342527e1e3bcac47f35fbd25b764e",
+ "sha256:2f9674623ca39c9ebe38afa3da402e9326c245f0f5ceff0623dccdac15023e05",
+ "sha256:3363e77a6176afb8823b6e06db78c46dbc4c7813b00a41300a4873b6ba63b171",
+ "sha256:33c6cdc071ba5cd6d96769c8969a0531be2d08c2628a0143a10a7dcffa9719ca",
+ "sha256:3b8eaf915ddc0709779889c472e553f0d3e8b7bdf62dab764c8921b09bf94522",
+ "sha256:3cb3e1b9ec43256c4e0f8d2837267a70b0e1ca8c4f456685508ae6106b1f504c",
+ "sha256:3eeeb0405fd145e714f7633a5173318bd88d8bbfc3dd0a5751f8c4f70ae629bc",
+ "sha256:44f60519595eaca110f248e5017363d751b12782a6f2bd6a7041cba275215f5d",
+ "sha256:4d7c26bfc1ea9f92084a1d75e11999e97b62d63128bcc90c3624d07813c52808",
+ "sha256:529c4ed2e10437c205f38f3691a68be66c39197d01062618c55f74294a4a4828",
+ "sha256:6642f15ad963b5092d65aed022d033c77763515fdc07095208f15d3563003869",
+ "sha256:85ba797e1de5b48aa5a8427b6ba62cf69607c18c5d4eb747604b7302f1ec382d",
+ "sha256:8f0f1e499e4000c4c347a124fa6a27d37608ced4fe9f7d45070563b7c4c370c9",
+ "sha256:a624fae282e81ad2e4871bdb767e2c914d0539708c0f078b5b355258293c98b0",
+ "sha256:b0358e6fefc74a16f745afa366acc89f979040e0cbc4eec55ab26ad1f6a9bfbc",
+ "sha256:bbd2f4dfee1079f76943767fce837ade3087b578aeb9f69aec7857d5bf25db15",
+ "sha256:bf39a9e19ce7298f1bd6a9758fa99707e9e5b1ebe5e90f2c3913a47bc548747c",
+ "sha256:c11579638288e53fc94ad60022ff1b67865363e730ee41ad5e6f0a17188b327a",
+ "sha256:c150eaa3dadbb2b5339675b88d4573c1be3cb6f2c33a6c83387e10cc0bf05bd3",
+ "sha256:c53af463f4a40de78c58b8b2710ade243c81cbca641e34debf3396a9640d6ec1",
+ "sha256:cb763ceceae04803adcc4e2d80d611ef201c73da32d8f2722e9d0ab0c7f10768",
+ "sha256:cc75f58cdaf043fe6a7a6c04b3b5a0e694c6a9e24050967747251fb80d7bce0d",
+ "sha256:d80998ed59176e8cba74028762fbd9b9153b9afc71ea118e63bbf5d4d0f9552b",
+ "sha256:de31b5164d44ef4943db155b3e8e17929707cac1e5bd2f363e67a56e3af4af6e",
+ "sha256:e66399cf0fc07de4dce4f588fc25bfe84a6d1285cc544e67987d22663393926d",
+ "sha256:f0620511387790860b249b9241c2f13c3a80e21a73e0b861a2df24e9d6f56730",
+ "sha256:f4eae045e6ab2bb54ca279733fe4eb85f1effda392666308250714e01907f394",
+ "sha256:f92cdecb618e5fa4658aeb97d5eb3d2f47aa94ac6477c6daf0f306c5a3b9e6b1",
+ "sha256:f92f789e4f9241cd262ad7a555ca2c648a98178a953af117ef7fad46aa1d5591"
+ ],
+ "version": "==1.14.3"
},
"chardet": {
"hashes": [
@@ -175,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": [
@@ -197,22 +206,13 @@
"index": "pypi",
"version": "==4.3.2"
},
- "discord": {
- "hashes": [
- "sha256:9d4debb4a37845543bd4b92cb195bc53a302797333e768e70344222857ff1559",
- "sha256:ff6653655e342e7721dfb3f10421345fd852c2a33f2cca912b1c39b3778a9429"
- ],
- "index": "pypi",
- "py": "~=1.4.0",
- "version": "==1.0.1"
- },
"discord.py": {
"hashes": [
- "sha256:98ea3096a3585c9c379209926f530808f5fcf4930928d8cfb579d2562d119570",
- "sha256:f9decb3bfa94613d922376288617e6a6f969260923643e2897f4540c34793442"
+ "sha256:2367359e31f6527f8a936751fc20b09d7495dd6a76b28c8fb13d4ca6c55b7563",
+ "sha256:def00dc50cf36d21346d71bc89f0cad8f18f9a3522978dc18c7796287d47de8b"
],
- "markers": "python_full_version >= '3.5.3'",
- "version": "==1.4.1"
+ "index": "pypi",
+ "version": "==1.5.1"
},
"docutils": {
"hashes": [
@@ -222,12 +222,19 @@
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==0.16"
},
+ "emoji": {
+ "hashes": [
+ "sha256:e42da4f8d648f8ef10691bc246f682a1ec6b18373abfd9be10ec0b398823bd11"
+ ],
+ "index": "pypi",
+ "version": "==0.6.0"
+ },
"fakeredis": {
"hashes": [
- "sha256:7ea0866ba5edb40fe2e9b1722535df0c7e6b91d518aa5f50d96c2fff3ea7f4c2",
- "sha256:aad8836ffe0319ffbba66dcf872ac6e7e32d1f19790e31296ba58445efb0a5c7"
+ "sha256:8070b7fce16f828beaef2c757a4354af91698685d5232404f1aeeb233529c7a5",
+ "sha256:f8c8ea764d7b6fd801e7f5486e3edd32ca991d506186f1923a01fc072e33c271"
],
- "version": "==1.4.3"
+ "version": "==1.4.4"
},
"feedparser": {
"hashes": [
@@ -332,40 +339,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": [
@@ -416,11 +429,11 @@
},
"more-itertools": {
"hashes": [
- "sha256:6f83822ae94818eae2612063a5101a7311e68ae8002005b5e05f03fd74a86a20",
- "sha256:9b30f12df9393f0d28af9210ff8efe48d10c94f73e5daf886f10c4b0b0b4f03c"
+ "sha256:8e1a2a43b2f2727425f2b5839587ae37093f19153dc26c0927d1048ff6557330",
+ "sha256:b3a9005928e5bed54076e6e549c792b306fddfe72b2d1d22dd63d42d5d3899cf"
],
"index": "pypi",
- "version": "==8.5.0"
+ "version": "==8.6.0"
},
"multidict": {
"hashes": [
@@ -511,11 +524,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": [
@@ -535,23 +548,25 @@
},
"pytz": {
"hashes": [
- "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed",
- "sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048"
+ "sha256:3e6b7dd2d1e0a59084bcee14a17af60c5c562cdc16d828e8eba2e683d3a7e268",
+ "sha256:5c55e189b682d420be27c6995ba6edce0c0a77dd67bfbe2ae6607134d5851ffd"
],
- "version": "==2020.1"
+ "version": "==2020.4"
},
"pyyaml": {
"hashes": [
- "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97",
+ "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf",
+ "sha256:ad9c67312c84def58f3c04504727ca879cb0013b2517c85a9a253f0cb6380c0a",
+ "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c",
"sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76",
- "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2",
+ "sha256:6034f55dab5fea9e53f436aa68fa3ace2634918e8b5994d82f3621c04ff5ed2e",
"sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648",
- "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf",
+ "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d",
+ "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97",
"sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f",
- "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2",
+ "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2",
"sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee",
- "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d",
- "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c",
+ "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2",
"sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a"
],
"index": "pypi",
@@ -567,19 +582,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:1a086486ff9da15791f294f6e9915eb3747d161ef64dee2d038a4d0b4a369b24",
- "sha256:45486deb031cea6bbb25a540d7adb4dd48cd8a1cc31e6a5ce9fb4f792a572e9a"
+ "sha256:1052f0ed084e532f66cb3e4ba617960d820152aee8b93fc6c05bd53861768c1c",
+ "sha256:4c42910a55a6b1fe694d5e4790d5188d105d77b5a6346c1c64cbea8c06c0e8b7"
],
"index": "pypi",
- "version": "==0.17.6"
+ "version": "==0.19.4"
},
"six": {
"hashes": [
@@ -598,17 +613,17 @@
},
"sortedcontainers": {
"hashes": [
- "sha256:4e73a757831fc3ca4de2859c422564239a31d8213d09a2a666e375807034d2ba",
- "sha256:c633ebde8580f241f274c1f8994a665c0e54a17724fecd0cae2f079e09c36d3f"
+ "sha256:37257a32add0a3ee490bb170b599e93095eed89a55da91fa9f48753ea12fd73f",
+ "sha256:59cc937650cf60d677c16775597c89a960658a09cf7c1a668f86e1e4464b10a1"
],
- "version": "==2.2.2"
+ "version": "==2.3.0"
},
"soupsieve": {
"hashes": [
"sha256:1634eea42ab371d3d346309b93df7870a88610f0725d47528be902a0d95ecc55",
"sha256:a59dc181727e95d25f781f0eb4fd1825ff45590ec8ff49eadfd7f1a537cc0232"
],
- "markers": "python_version >= '3.5'",
+ "markers": "python_version >= '3.0'",
"version": "==2.0.1"
},
"sphinx": {
@@ -677,11 +692,11 @@
},
"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": [
@@ -717,11 +732,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": [
@@ -731,6 +753,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",
@@ -771,6 +800,14 @@
"index": "pypi",
"version": "==5.3"
},
+ "coveralls": {
+ "hashes": [
+ "sha256:2301a19500b06649d2ec4f2858f9c69638d7699a4c63027c5d53daba666147cc",
+ "sha256:b990ba1f7bc4288e63340be0433698c1efe8217f78c689d254c2540af3d38617"
+ ],
+ "index": "pypi",
+ "version": "==2.2.0"
+ },
"distlib": {
"hashes": [
"sha256:8c09de2c67b3e7deef7184574fc060ab8a793e7adbb183d942c389c8b13c52fb",
@@ -778,6 +815,12 @@
],
"version": "==0.3.1"
},
+ "docopt": {
+ "hashes": [
+ "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"
+ ],
+ "version": "==0.6.2"
+ },
"filelock": {
"hashes": [
"sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59",
@@ -787,19 +830,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": [
@@ -857,11 +900,19 @@
},
"identify": {
"hashes": [
- "sha256:c770074ae1f19e08aadbda1c886bc6d0cb55ffdc503a8c0fe8699af2fc9664ae",
- "sha256:d02d004568c5a01261839a05e91705e3e9f5c57a3551648f9b3fb2b9c62c0f62"
+ "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.3"
+ "version": "==2.10"
},
"mccabe": {
"hashes": [
@@ -887,11 +938,11 @@
},
"pre-commit": {
"hashes": [
- "sha256:810aef2a2ba4f31eed1941fc270e72696a1ad5590b9751839c90807d0fff6b9a",
- "sha256:c54fd3e574565fe128ecc5e7d2f91279772ddb03f8729645fa812fe809084a70"
+ "sha256:22e6aa3bd571debb01eb7d34483f11c01b65237be4eebbf30c3d4fb65762d315",
+ "sha256:905ebc9b534b991baec87e934431f2d0606ba27f2b90f7f652985f5a5b8b6ae6"
],
"index": "pypi",
- "version": "==2.7.1"
+ "version": "==2.8.2"
},
"pycodestyle": {
"hashes": [
@@ -919,21 +970,31 @@
},
"pyyaml": {
"hashes": [
- "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97",
+ "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf",
+ "sha256:ad9c67312c84def58f3c04504727ca879cb0013b2517c85a9a253f0cb6380c0a",
+ "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c",
"sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76",
- "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2",
+ "sha256:6034f55dab5fea9e53f436aa68fa3ace2634918e8b5994d82f3621c04ff5ed2e",
"sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648",
- "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf",
+ "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d",
+ "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97",
"sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f",
- "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2",
+ "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2",
"sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee",
- "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d",
- "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c",
+ "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2",
"sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a"
],
"index": "pypi",
"version": "==5.3.1"
},
+ "requests": {
+ "hashes": [
+ "sha256:7f1a0b932f4a60a1a65caa4263921bb7d9ee911957e0ae4a23a6dd08185ad5f8",
+ "sha256:e786fa28d8c9154e6a4de5d46a1d921b8749f8b74e28bde23768e5e16eece998"
+ ],
+ "index": "pypi",
+ "version": "==2.25.0"
+ },
"six": {
"hashes": [
"sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
@@ -951,26 +1012,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..c813997e7 100644
--- a/README.md
+++ b/README.md
@@ -1,9 +1,10 @@
# 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][7]][8]
+[![Lint & Test][1]][2]
+[![Build][3]][4]
+[![Deploy][5]][6]
+[![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)
@@ -11,3 +12,12 @@ This project is a Discord bot specifically for use with the Python Discord serve
and other tools to help keep the server running like a well-oiled machine.
Read the [Contributing Guide](https://pythondiscord.com/pages/contributing/bot/) on our website if you're interested in helping out.
+
+[1]: https://github.com/python-discord/bot/workflows/Lint%20&%20Test/badge.svg?branch=master
+[2]: https://github.com/python-discord/bot/actions?query=workflow%3A%22Lint+%26+Test%22+branch%3Amaster
+[3]: https://github.com/python-discord/bot/workflows/Build/badge.svg?branch=master
+[4]: https://github.com/python-discord/bot/actions?query=workflow%3ABuild+branch%3Amaster
+[5]: https://github.com/python-discord/bot/workflows/Deploy/badge.svg?branch=master
+[6]: https://github.com/python-discord/bot/actions?query=workflow%3ADeploy+branch%3Amaster
+[7]: https://img.shields.io/static/v1?label=Python%20Discord&logo=discord&message=%3E100k%20members&color=%237289DA&logoColor=white
+[8]: https://discord.gg/2B963hn
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/__init__.py b/bot/__init__.py
index 3ee70c4e9..8f880b8e6 100644
--- a/bot/__init__.py
+++ b/bot/__init__.py
@@ -1,78 +1,25 @@
import asyncio
-import logging
import os
-import sys
from functools import partial, partialmethod
-from logging import Logger, handlers
-from pathlib import Path
+from typing import TYPE_CHECKING
-import coloredlogs
from discord.ext import commands
+from bot import log
from bot.command import Command
-TRACE_LEVEL = logging.TRACE = 5
-logging.addLevelName(TRACE_LEVEL, "TRACE")
-
-
-def monkeypatch_trace(self: logging.Logger, msg: str, *args, **kwargs) -> None:
- """
- Log 'msg % args' with severity 'TRACE'.
-
- To pass exception information, use the keyword argument exc_info with
- a true value, e.g.
-
- logger.trace("Houston, we have an %s", "interesting problem", exc_info=1)
- """
- if self.isEnabledFor(TRACE_LEVEL):
- self._log(TRACE_LEVEL, msg, args, **kwargs)
-
-
-Logger.trace = monkeypatch_trace
-
-DEBUG_MODE = 'local' in os.environ.get("SITE_URL", "local")
-
-log_level = TRACE_LEVEL if DEBUG_MODE else logging.INFO
-format_string = "%(asctime)s | %(name)s | %(levelname)s | %(message)s"
-log_format = logging.Formatter(format_string)
-
-log_file = Path("logs", "bot.log")
-log_file.parent.mkdir(exist_ok=True)
-file_handler = handlers.RotatingFileHandler(log_file, maxBytes=5242880, backupCount=7, encoding="utf8")
-file_handler.setFormatter(log_format)
-
-root_log = logging.getLogger()
-root_log.setLevel(log_level)
-root_log.addHandler(file_handler)
-
-if "COLOREDLOGS_LEVEL_STYLES" not in os.environ:
- coloredlogs.DEFAULT_LEVEL_STYLES = {
- **coloredlogs.DEFAULT_LEVEL_STYLES,
- "trace": {"color": 246},
- "critical": {"background": "red"},
- "debug": coloredlogs.DEFAULT_LEVEL_STYLES["info"]
- }
-
-if "COLOREDLOGS_LOG_FORMAT" not in os.environ:
- coloredlogs.DEFAULT_LOG_FORMAT = format_string
-
-if "COLOREDLOGS_LOG_LEVEL" not in os.environ:
- coloredlogs.DEFAULT_LOG_LEVEL = log_level
-
-coloredlogs.install(logger=root_log, stream=sys.stdout)
-
-logging.getLogger("discord").setLevel(logging.WARNING)
-logging.getLogger("websockets").setLevel(logging.WARNING)
-logging.getLogger("chardet").setLevel(logging.WARNING)
-logging.getLogger(__name__)
+if TYPE_CHECKING:
+ from bot.bot import Bot
+log.setup()
# On Windows, the selector event loop is required for aiodns.
if os.name == "nt":
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
-
# Monkey-patch discord.py decorators to use the Command subclass which supports root aliases.
# Must be patched before any cogs are added.
commands.command = partial(commands.command, cls=Command)
commands.GroupMixin.command = partialmethod(commands.GroupMixin.command, cls=Command)
+
+instance: "Bot" = None # Global Bot instance.
diff --git a/bot/__main__.py b/bot/__main__.py
index a07bc21d6..257216fa7 100644
--- a/bot/__main__.py
+++ b/bot/__main__.py
@@ -1,72 +1,10 @@
-import asyncio
-import logging
-
-import discord
-import sentry_sdk
-from async_rediscache import RedisSession
-from discord.ext.commands import when_mentioned_or
-from sentry_sdk.integrations.aiohttp import AioHttpIntegration
-from sentry_sdk.integrations.logging import LoggingIntegration
-from sentry_sdk.integrations.redis import RedisIntegration
-
-from bot import constants, patches
+import bot
+from bot import constants
from bot.bot import Bot
-from bot.utils.extensions import EXTENSIONS
-
-# Set up Sentry.
-sentry_logging = LoggingIntegration(
- level=logging.DEBUG,
- event_level=logging.WARNING
-)
-
-sentry_sdk.init(
- dsn=constants.Bot.sentry_dsn,
- integrations=[
- sentry_logging,
- AioHttpIntegration(),
- RedisIntegration(),
- ]
-)
-
-# Create the redis session instance.
-redis_session = RedisSession(
- address=(constants.Redis.host, constants.Redis.port),
- password=constants.Redis.password,
- minsize=1,
- maxsize=20,
- use_fakeredis=constants.Redis.use_fakeredis,
- global_namespace="bot",
-)
-
-# Connect redis session to ensure it's connected before we try to access Redis
-# from somewhere within the bot. We create the event loop in the same way
-# discord.py normally does and pass it to the bot's __init__.
-loop = asyncio.get_event_loop()
-loop.run_until_complete(redis_session.connect())
-
-
-# Instantiate the bot.
-allowed_roles = [discord.Object(id_) for id_ in constants.MODERATION_ROLES]
-bot = Bot(
- redis_session=redis_session,
- loop=loop,
- command_prefix=when_mentioned_or(constants.Bot.prefix),
- activity=discord.Game(name="Commands: !help"),
- case_insensitive=True,
- max_messages=10_000,
- allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles)
-)
-
-# Load extensions.
-extensions = set(EXTENSIONS) # Create a mutable copy.
-if not constants.HelpChannels.enable:
- extensions.remove("bot.exts.help_channels")
-
-for extension in extensions:
- bot.load_extension(extension)
+from bot.log import setup_sentry
-# Apply `message_edited_at` patch if discord.py did not yet release a bug fix.
-if not hasattr(discord.message.Message, '_handle_edited_timestamp'):
- patches.message_edited_at.apply_patch()
+setup_sentry()
-bot.run(constants.Bot.token)
+bot.instance = Bot.create()
+bot.instance.load_extensions()
+bot.instance.run(constants.Bot.token)
diff --git a/bot/bot.py b/bot/bot.py
index b2e5237fe..36cf7d30a 100644
--- a/bot/bot.py
+++ b/bot/bot.py
@@ -11,7 +11,7 @@ from async_rediscache import RedisSession
from discord.ext import commands
from sentry_sdk import push_scope
-from bot import DEBUG_MODE, api, constants
+from bot import api, constants
from bot.async_stats import AsyncStatsClient
log = logging.getLogger('bot')
@@ -40,7 +40,7 @@ class Bot(commands.Bot):
statsd_url = constants.Stats.statsd_host
- if DEBUG_MODE:
+ if constants.DEBUG_MODE:
# Since statsd is UDP, there are no errors for sending to a down port.
# For this reason, setting the statsd host to 127.0.0.1 for development
# will effectively disable stats.
@@ -95,6 +95,43 @@ class Bot(commands.Bot):
# Build the FilterList cache
self.loop.create_task(self.cache_filter_list_data())
+ @classmethod
+ def create(cls) -> "Bot":
+ """Create and return an instance of a Bot."""
+ loop = asyncio.get_event_loop()
+ allowed_roles = [discord.Object(id_) for id_ in constants.MODERATION_ROLES]
+
+ intents = discord.Intents().all()
+ intents.presences = False
+ intents.dm_typing = False
+ intents.dm_reactions = False
+ intents.invites = False
+ intents.webhooks = False
+ intents.integrations = False
+
+ return cls(
+ redis_session=_create_redis_session(loop),
+ loop=loop,
+ command_prefix=commands.when_mentioned_or(constants.Bot.prefix),
+ activity=discord.Game(name=f"Commands: {constants.Bot.prefix}help"),
+ case_insensitive=True,
+ max_messages=10_000,
+ allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles),
+ intents=intents,
+ )
+
+ def load_extensions(self) -> None:
+ """Load all enabled extensions."""
+ # Must be done here to avoid a circular import.
+ from bot.utils.extensions import EXTENSIONS
+
+ extensions = set(EXTENSIONS) # Create a mutable copy.
+ if not constants.HelpChannels.enable:
+ extensions.remove("bot.exts.help_channels")
+
+ for extension in extensions:
+ self.load_extension(extension)
+
def add_cog(self, cog: commands.Cog) -> None:
"""Adds a "cog" to the bot and logs the operation."""
super().add_cog(cog)
@@ -243,3 +280,22 @@ class Bot(commands.Bot):
for alias in getattr(command, "root_aliases", ()):
self.all_commands.pop(alias, None)
+
+
+def _create_redis_session(loop: asyncio.AbstractEventLoop) -> RedisSession:
+ """
+ Create and connect to a redis session.
+
+ Ensure the connection is established before returning to prevent race conditions.
+ `loop` is the event loop on which to connect. The Bot should use this same event loop.
+ """
+ redis_session = RedisSession(
+ address=(constants.Redis.host, constants.Redis.port),
+ password=constants.Redis.password,
+ minsize=1,
+ maxsize=20,
+ use_fakeredis=constants.Redis.use_fakeredis,
+ global_namespace="bot",
+ )
+ loop.run_until_complete(redis_session.connect())
+ return redis_session
diff --git a/bot/constants.py b/bot/constants.py
index c710e2dff..6bb6aacd2 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -248,6 +248,7 @@ class Colours(metaclass=YAMLGetter):
soft_red: int
soft_green: int
soft_orange: int
+ bright_green: int
class DuckPond(metaclass=YAMLGetter):
@@ -354,6 +355,8 @@ class Icons(metaclass=YAMLGetter):
voice_state_green: str
voice_state_red: str
+ green_checkmark: str
+
class CleanMessages(metaclass=YAMLGetter):
section = "bot"
@@ -361,6 +364,7 @@ class CleanMessages(metaclass=YAMLGetter):
message_limit: int
+
class Stats(metaclass=YAMLGetter):
section = "bot"
subsection = "stats"
@@ -377,6 +381,7 @@ class Categories(metaclass=YAMLGetter):
help_in_use: int
help_dormant: int
modmail: int
+ voice: int
class Channels(metaclass=YAMLGetter):
@@ -391,6 +396,8 @@ class Channels(metaclass=YAMLGetter):
big_brother_logs: int
bot_commands: int
change_log: int
+ code_help_voice: int
+ code_help_voice_2: int
cooldown: int
defcon: int
dev_contrib: int
@@ -423,6 +430,8 @@ class Channels(metaclass=YAMLGetter):
user_event_announcements: int
user_log: int
verification: int
+ voice_chat: int
+ voice_gate: int
voice_log: int
@@ -455,9 +464,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):
@@ -466,6 +477,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]
@@ -527,6 +539,15 @@ class BigBrother(metaclass=YAMLGetter):
header_message_limit: int
+class CodeBlock(metaclass=YAMLGetter):
+ section = 'code_block'
+
+ channel_whitelist: List[int]
+ cooldown_channels: List[int]
+ cooldown_seconds: int
+ minimum_lines: int
+
+
class Free(metaclass=YAMLGetter):
section = 'free'
@@ -559,13 +580,6 @@ class RedirectOutput(metaclass=YAMLGetter):
delete_delay: int
-class Sync(metaclass=YAMLGetter):
- section = 'sync'
-
- confirm_timeout: int
- max_diff: int
-
-
class PythonNews(metaclass=YAMLGetter):
section = 'python_news'
@@ -584,6 +598,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
@@ -611,7 +635,7 @@ class Event(Enum):
# Debug mode
-DEBUG_MODE = True if 'local' in os.environ.get("SITE_URL", "local") else False
+DEBUG_MODE = 'local' in os.environ.get("SITE_URL", "local")
# Paths
BOT_DIR = os.path.dirname(__file__)
@@ -624,6 +648,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/decorators.py b/bot/decorators.py
index 2518124da..063c8f878 100644
--- a/bot/decorators.py
+++ b/bot/decorators.py
@@ -1,16 +1,15 @@
+import asyncio
import logging
-import random
-from asyncio import Lock, create_task, sleep
+import typing as t
from contextlib import suppress
from functools import wraps
-from typing import Callable, Container, Optional, Union
-from weakref import WeakValueDictionary
-from discord import Colour, Embed, Member, NotFound
+from discord import Member, NotFound
from discord.ext import commands
from discord.ext.commands import Cog, Context
-from bot.constants import Channels, ERROR_REPLIES, RedirectOutput
+from bot.constants import Channels, RedirectOutput
+from bot.utils import function
from bot.utils.checks import in_whitelist_check
log = logging.getLogger(__name__)
@@ -18,12 +17,12 @@ log = logging.getLogger(__name__)
def in_whitelist(
*,
- channels: Container[int] = (),
- categories: Container[int] = (),
- roles: Container[int] = (),
- redirect: Optional[int] = Channels.bot_commands,
+ channels: t.Container[int] = (),
+ categories: t.Container[int] = (),
+ roles: t.Container[int] = (),
+ redirect: t.Optional[int] = Channels.bot_commands,
fail_silently: bool = False,
-) -> Callable:
+) -> t.Callable:
"""
Check if a command was issued in a whitelisted context.
@@ -31,7 +30,7 @@ def in_whitelist(
- `channels`: a container with channel ids for whitelisted channels
- `categories`: a container with category ids for whitelisted categories
- - `roles`: a container with with role ids for whitelisted roles
+ - `roles`: a container with role ids for whitelisted roles
If the command was invoked in a context that was not whitelisted, the member is either
redirected to the `redirect` channel that was passed (default: #bot-commands) or simply
@@ -44,7 +43,7 @@ def in_whitelist(
return commands.check(predicate)
-def has_no_roles(*roles: Union[str, int]) -> Callable:
+def has_no_roles(*roles: t.Union[str, int]) -> t.Callable:
"""
Returns True if the user does not have any of the roles specified.
@@ -63,39 +62,7 @@ def has_no_roles(*roles: Union[str, int]) -> Callable:
return commands.check(predicate)
-def locked() -> Callable:
- """
- Allows the user to only run one instance of the decorated command at a time.
-
- Subsequent calls to the command from the same author are ignored until the command has completed invocation.
-
- This decorator must go before (below) the `command` decorator.
- """
- def wrap(func: Callable) -> Callable:
- func.__locks = WeakValueDictionary()
-
- @wraps(func)
- async def inner(self: Cog, ctx: Context, *args, **kwargs) -> None:
- lock = func.__locks.setdefault(ctx.author.id, Lock())
- if lock.locked():
- embed = Embed()
- embed.colour = Colour.red()
-
- log.debug("User tried to invoke a locked command.")
- embed.description = (
- "You're already using this command. Please wait until it is done before you use it again."
- )
- embed.title = random.choice(ERROR_REPLIES)
- await ctx.send(embed=embed)
- return
-
- async with func.__locks.setdefault(ctx.author.id, Lock()):
- await func(self, ctx, *args, **kwargs)
- return inner
- return wrap
-
-
-def redirect_output(destination_channel: int, bypass_roles: Container[int] = None) -> Callable:
+def redirect_output(destination_channel: int, bypass_roles: t.Container[int] = None) -> t.Callable:
"""
Changes the channel in the context of the command to redirect the output to a certain channel.
@@ -103,7 +70,7 @@ def redirect_output(destination_channel: int, bypass_roles: Container[int] = Non
This decorator must go before (below) the `command` decorator.
"""
- def wrap(func: Callable) -> Callable:
+ def wrap(func: t.Callable) -> t.Callable:
@wraps(func)
async def inner(self: Cog, ctx: Context, *args, **kwargs) -> None:
if ctx.channel.id == destination_channel:
@@ -122,14 +89,14 @@ def redirect_output(destination_channel: int, bypass_roles: Container[int] = Non
log.trace(f"Redirecting output of {ctx.author}'s command '{ctx.command.name}' to {redirect_channel.name}")
ctx.channel = redirect_channel
await ctx.channel.send(f"Here's the output of your command, {ctx.author.mention}")
- create_task(func(self, ctx, *args, **kwargs))
+ asyncio.create_task(func(self, ctx, *args, **kwargs))
message = await old_channel.send(
f"Hey, {ctx.author.mention}, you can find the output of your command here: "
f"{redirect_channel.mention}"
)
if RedirectOutput.delete_invocation:
- await sleep(RedirectOutput.delete_delay)
+ await asyncio.sleep(RedirectOutput.delete_delay)
with suppress(NotFound):
await message.delete()
@@ -143,38 +110,35 @@ def redirect_output(destination_channel: int, bypass_roles: Container[int] = Non
return wrap
-def respect_role_hierarchy(target_arg: Union[int, str] = 0) -> Callable:
+def respect_role_hierarchy(member_arg: function.Argument) -> t.Callable:
"""
Ensure the highest role of the invoking member is greater than that of the target member.
If the condition fails, a warning is sent to the invoking context. A target which is not an
instance of discord.Member will always pass.
- A value of 0 (i.e. position 0) for `target_arg` corresponds to the argument which comes after
- `ctx`. If the target argument is a kwarg, its name can instead be given.
+ `member_arg` is the keyword name or position index of the parameter of the decorated command
+ whose value is the target member.
This decorator must go before (below) the `command` decorator.
"""
- def wrap(func: Callable) -> Callable:
+ def decorator(func: t.Callable) -> t.Callable:
@wraps(func)
- async def inner(self: Cog, ctx: Context, *args, **kwargs) -> None:
- try:
- target = kwargs[target_arg]
- except KeyError:
- try:
- target = args[target_arg]
- except IndexError:
- raise ValueError(f"Could not find target argument at position {target_arg}")
- except TypeError:
- raise ValueError(f"Could not find target kwarg with key {target_arg!r}")
+ async def wrapper(*args, **kwargs) -> None:
+ log.trace(f"{func.__name__}: respect role hierarchy decorator called")
+
+ bound_args = function.get_bound_args(func, args, kwargs)
+ target = function.get_arg_value(member_arg, bound_args)
if not isinstance(target, Member):
log.trace("The target is not a discord.Member; skipping role hierarchy check.")
- await func(self, ctx, *args, **kwargs)
+ await func(*args, **kwargs)
return
+ ctx = function.get_arg_value(1, bound_args)
cmd = ctx.command.name
actor = ctx.author
+
if target.top_role >= actor.top_role:
log.info(
f"{actor} ({actor.id}) attempted to {cmd} "
@@ -185,6 +149,7 @@ def respect_role_hierarchy(target_arg: Union[int, str] = 0) -> Callable:
"someone with an equal or higher top role."
)
else:
- await func(self, ctx, *args, **kwargs)
- return inner
- return wrap
+ log.trace(f"{func.__name__}: {target.top_role=} < {actor.top_role=}; calling func")
+ await func(*args, **kwargs)
+ return wrapper
+ return decorator
diff --git a/bot/errors.py b/bot/errors.py
new file mode 100644
index 000000000..65d715203
--- /dev/null
+++ b/bot/errors.py
@@ -0,0 +1,20 @@
+from typing import Hashable
+
+
+class LockedResourceError(RuntimeError):
+ """
+ Exception raised when an operation is attempted on a locked resource.
+
+ Attributes:
+ `type` -- name of the locked resource's type
+ `id` -- ID of the locked resource
+ """
+
+ def __init__(self, resource_type: str, resource_id: Hashable):
+ self.type = resource_type
+ self.id = resource_id
+
+ super().__init__(
+ f"Cannot operate on {self.type.lower()} `{self.id}`; "
+ "it is currently locked and in use by another operation."
+ )
diff --git a/bot/exts/backend/alias.py b/bot/exts/backend/alias.py
deleted file mode 100644
index c6ba8d6f3..000000000
--- a/bot/exts/backend/alias.py
+++ /dev/null
@@ -1,87 +0,0 @@
-import inspect
-import logging
-
-from discord import Colour, Embed
-from discord.ext.commands import (
- Cog, Command, Context,
- clean_content, command, group,
-)
-
-from bot.bot import Bot
-from bot.converters import TagNameConverter
-from bot.pagination import LinePaginator
-
-log = logging.getLogger(__name__)
-
-
-class Alias (Cog):
- """Aliases for commonly used commands."""
-
- def __init__(self, bot: Bot):
- self.bot = bot
-
- async def invoke(self, ctx: Context, cmd_name: str, *args, **kwargs) -> None:
- """Invokes a command with args and kwargs."""
- log.debug(f"{cmd_name} was invoked through an alias")
- cmd = self.bot.get_command(cmd_name)
- if not cmd:
- return log.info(f'Did not find command "{cmd_name}" to invoke.')
- elif not await cmd.can_run(ctx):
- return log.info(
- f'{str(ctx.author)} tried to run the command "{cmd_name}" but lacks permission.'
- )
-
- await ctx.invoke(cmd, *args, **kwargs)
-
- @command(name='aliases')
- async def aliases_command(self, ctx: Context) -> None:
- """Show configured aliases on the bot."""
- embed = Embed(
- title='Configured aliases',
- colour=Colour.blue()
- )
- await LinePaginator.paginate(
- (
- f"• `{ctx.prefix}{value.name}` "
- f"=> `{ctx.prefix}{name[:-len('_alias')].replace('_', ' ')}`"
- for name, value in inspect.getmembers(self)
- if isinstance(value, Command) and name.endswith('_alias')
- ),
- ctx, embed, empty=False, max_lines=20
- )
-
- @command(name="exception", hidden=True)
- async def tags_get_traceback_alias(self, ctx: Context) -> None:
- """Alias for invoking <prefix>tags get traceback."""
- await self.invoke(ctx, "tags get", tag_name="traceback")
-
- @group(name="get",
- aliases=("show", "g"),
- hidden=True,
- invoke_without_command=True)
- async def get_group_alias(self, ctx: Context) -> None:
- """Group for reverse aliases for commands like `tags get`, allowing for `get tags` or `get docs`."""
- pass
-
- @get_group_alias.command(name="tags", aliases=("tag", "t"), hidden=True)
- async def tags_get_alias(
- self, ctx: Context, *, tag_name: TagNameConverter = None
- ) -> None:
- """
- Alias for invoking <prefix>tags get [tag_name].
-
- tag_name: str - tag to be viewed.
- """
- await self.invoke(ctx, "tags get", tag_name=tag_name)
-
- @get_group_alias.command(name="docs", aliases=("doc", "d"), hidden=True)
- async def docs_get_alias(
- self, ctx: Context, symbol: clean_content = None
- ) -> None:
- """Alias for invoking <prefix>docs get [symbol]."""
- await self.invoke(ctx, "docs get", symbol)
-
-
-def setup(bot: Bot) -> None:
- """Load the Alias cog."""
- bot.add_cog(Alias(bot))
diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py
index 83d5a0c94..533d8024b 100644
--- a/bot/exts/backend/error_handler.py
+++ b/bot/exts/backend/error_handler.py
@@ -10,6 +10,7 @@ from bot.api import ResponseCodeError
from bot.bot import Bot
from bot.constants import Channels, Colours
from bot.converters import TagNameConverter
+from bot.errors import LockedResourceError
from bot.utils.checks import InWhitelistCheckFailure
log = logging.getLogger(__name__)
@@ -75,6 +76,8 @@ class ErrorHandler(Cog):
elif isinstance(e, errors.CommandInvokeError):
if isinstance(e.original, ResponseCodeError):
await self.handle_api_error(ctx, e.original)
+ elif isinstance(e.original, LockedResourceError):
+ await ctx.send(f"{e.original} Please wait for it to finish and try again later.")
else:
await self.handle_unexpected_error(ctx, e.original)
return # Exit early to avoid logging.
diff --git a/bot/exts/backend/sync/_cog.py b/bot/exts/backend/sync/_cog.py
index 6e85e2b7d..48d2b6f02 100644
--- a/bot/exts/backend/sync/_cog.py
+++ b/bot/exts/backend/sync/_cog.py
@@ -18,9 +18,6 @@ class Sync(Cog):
def __init__(self, bot: Bot) -> None:
self.bot = bot
- self.role_syncer = _syncers.RoleSyncer(self.bot)
- self.user_syncer = _syncers.UserSyncer(self.bot)
-
self.bot.loop.create_task(self.sync_guild())
async def sync_guild(self) -> None:
@@ -31,7 +28,7 @@ class Sync(Cog):
if guild is None:
return
- for syncer in (self.role_syncer, self.user_syncer):
+ for syncer in (_syncers.RoleSyncer, _syncers.UserSyncer):
await syncer.sync(guild)
async def patch_user(self, user_id: int, json: Dict[str, Any], ignore_404: bool = False) -> None:
@@ -171,10 +168,10 @@ class Sync(Cog):
@commands.has_permissions(administrator=True)
async def sync_roles_command(self, ctx: Context) -> None:
"""Manually synchronise the guild's roles with the roles on the site."""
- await self.role_syncer.sync(ctx.guild, ctx)
+ await _syncers.RoleSyncer.sync(ctx.guild, ctx)
@sync_group.command(name='users')
@commands.has_permissions(administrator=True)
async def sync_users_command(self, ctx: Context) -> None:
"""Manually synchronise the guild's users with the users on the site."""
- await self.user_syncer.sync(ctx.guild, ctx)
+ await _syncers.UserSyncer.sync(ctx.guild, ctx)
diff --git a/bot/exts/backend/sync/_syncers.py b/bot/exts/backend/sync/_syncers.py
index f7ba811bc..2eb9f9971 100644
--- a/bot/exts/backend/sync/_syncers.py
+++ b/bot/exts/backend/sync/_syncers.py
@@ -1,227 +1,76 @@
import abc
-import asyncio
import logging
import typing as t
from collections import namedtuple
-from functools import partial
-import discord
-from discord import Guild, HTTPException, Member, Message, Reaction, User
+from discord import Guild
from discord.ext.commands import Context
-from bot import constants
+import bot
from bot.api import ResponseCodeError
-from bot.bot import Bot
log = logging.getLogger(__name__)
# These objects are declared as namedtuples because tuples are hashable,
# something that we make use of when diffing site roles against guild roles.
_Role = namedtuple('Role', ('id', 'name', 'colour', 'permissions', 'position'))
-_User = namedtuple('User', ('id', 'name', 'discriminator', 'roles', 'in_guild'))
_Diff = namedtuple('Diff', ('created', 'updated', 'deleted'))
+# Implementation of static abstract methods are not enforced if the subclass is never instantiated.
+# However, methods are kept abstract to at least symbolise that they should be abstract.
class Syncer(abc.ABC):
"""Base class for synchronising the database with objects in the Discord cache."""
- _CORE_DEV_MENTION = f"<@&{constants.Roles.core_developers}> "
- _REACTION_EMOJIS = (constants.Emojis.check_mark, constants.Emojis.cross_mark)
-
- def __init__(self, bot: Bot) -> None:
- self.bot = bot
-
+ @staticmethod
@property
@abc.abstractmethod
- def name(self) -> str:
+ def name() -> str:
"""The name of the syncer; used in output messages and logging."""
raise NotImplementedError # pragma: no cover
- async def _send_prompt(self, message: t.Optional[Message] = None) -> t.Optional[Message]:
- """
- Send a prompt to confirm or abort a sync using reactions and return the sent message.
-
- If a message is given, it is edited to display the prompt and reactions. Otherwise, a new
- message is sent to the dev-core channel and mentions the core developers role. If the
- channel cannot be retrieved, return None.
- """
- log.trace(f"Sending {self.name} sync confirmation prompt.")
-
- msg_content = (
- f'Possible cache issue while syncing {self.name}s. '
- f'More than {constants.Sync.max_diff} {self.name}s were changed. '
- f'React to confirm or abort the sync.'
- )
-
- # Send to core developers if it's an automatic sync.
- if not message:
- log.trace("Message not provided for confirmation; creating a new one in dev-core.")
- channel = self.bot.get_channel(constants.Channels.dev_core)
-
- if not channel:
- log.debug("Failed to get the dev-core channel from cache; attempting to fetch it.")
- try:
- channel = await self.bot.fetch_channel(constants.Channels.dev_core)
- except HTTPException:
- log.exception(
- f"Failed to fetch channel for sending sync confirmation prompt; "
- f"aborting {self.name} sync."
- )
- return None
-
- allowed_roles = [discord.Object(constants.Roles.core_developers)]
- message = await channel.send(
- f"{self._CORE_DEV_MENTION}{msg_content}",
- allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles)
- )
- else:
- await message.edit(content=msg_content)
-
- # Add the initial reactions.
- log.trace(f"Adding reactions to {self.name} syncer confirmation prompt.")
- for emoji in self._REACTION_EMOJIS:
- await message.add_reaction(emoji)
-
- return message
-
- def _reaction_check(
- self,
- author: Member,
- message: Message,
- reaction: Reaction,
- user: t.Union[Member, User]
- ) -> bool:
- """
- Return True if the `reaction` is a valid confirmation or abort reaction on `message`.
-
- If the `author` of the prompt is a bot, then a reaction by any core developer will be
- considered valid. Otherwise, the author of the reaction (`user`) will have to be the
- `author` of the prompt.
- """
- # For automatic syncs, check for the core dev role instead of an exact author
- has_role = any(constants.Roles.core_developers == role.id for role in user.roles)
- return (
- reaction.message.id == message.id
- and not user.bot
- and (has_role if author.bot else user == author)
- and str(reaction.emoji) in self._REACTION_EMOJIS
- )
-
- async def _wait_for_confirmation(self, author: Member, message: Message) -> bool:
- """
- Wait for a confirmation reaction by `author` on `message` and return True if confirmed.
-
- Uses the `_reaction_check` function to determine if a reaction is valid.
-
- If there is no reaction within `bot.constants.Sync.confirm_timeout` seconds, return False.
- To acknowledge the reaction (or lack thereof), `message` will be edited.
- """
- # Preserve the core-dev role mention in the message edits so users aren't confused about
- # where notifications came from.
- mention = self._CORE_DEV_MENTION if author.bot else ""
-
- reaction = None
- try:
- log.trace(f"Waiting for a reaction to the {self.name} syncer confirmation prompt.")
- reaction, _ = await self.bot.wait_for(
- 'reaction_add',
- check=partial(self._reaction_check, author, message),
- timeout=constants.Sync.confirm_timeout
- )
- except asyncio.TimeoutError:
- # reaction will remain none thus sync will be aborted in the finally block below.
- log.debug(f"The {self.name} syncer confirmation prompt timed out.")
-
- if str(reaction) == constants.Emojis.check_mark:
- log.trace(f"The {self.name} syncer was confirmed.")
- await message.edit(content=f':ok_hand: {mention}{self.name} sync will proceed.')
- return True
- else:
- log.info(f"The {self.name} syncer was aborted or timed out!")
- await message.edit(
- content=f':warning: {mention}{self.name} sync aborted or timed out!'
- )
- return False
-
+ @staticmethod
@abc.abstractmethod
- async def _get_diff(self, guild: Guild) -> _Diff:
+ async def _get_diff(guild: Guild) -> _Diff:
"""Return the difference between the cache of `guild` and the database."""
raise NotImplementedError # pragma: no cover
+ @staticmethod
@abc.abstractmethod
- async def _sync(self, diff: _Diff) -> None:
+ async def _sync(diff: _Diff) -> None:
"""Perform the API calls for synchronisation."""
raise NotImplementedError # pragma: no cover
- async def _get_confirmation_result(
- self,
- diff_size: int,
- author: Member,
- message: t.Optional[Message] = None
- ) -> t.Tuple[bool, t.Optional[Message]]:
- """
- Prompt for confirmation and return a tuple of the result and the prompt message.
-
- `diff_size` is the size of the diff of the sync. If it is greater than
- `bot.constants.Sync.max_diff`, the prompt will be sent. The `author` is the invoked of the
- sync and the `message` is an extant message to edit to display the prompt.
-
- If confirmed or no confirmation was needed, the result is True. The returned message will
- either be the given `message` or a new one which was created when sending the prompt.
- """
- log.trace(f"Determining if confirmation prompt should be sent for {self.name} syncer.")
- if diff_size > constants.Sync.max_diff:
- message = await self._send_prompt(message)
- if not message:
- return False, None # Couldn't get channel.
-
- confirmed = await self._wait_for_confirmation(author, message)
- if not confirmed:
- return False, message # Sync aborted.
-
- return True, message
-
- async def sync(self, guild: Guild, ctx: t.Optional[Context] = None) -> None:
+ @classmethod
+ async def sync(cls, guild: Guild, ctx: t.Optional[Context] = None) -> None:
"""
Synchronise the database with the cache of `guild`.
- If the differences between the cache and the database are greater than
- `bot.constants.Sync.max_diff`, then a confirmation prompt will be sent to the dev-core
- channel. The confirmation can be optionally redirect to `ctx` instead.
+ If `ctx` is given, send a message with the results.
"""
- log.info(f"Starting {self.name} syncer.")
+ log.info(f"Starting {cls.name} syncer.")
- message = None
- author = self.bot.user
if ctx:
- message = await ctx.send(f"📊 Synchronising {self.name}s.")
- author = ctx.author
-
- diff = await self._get_diff(guild)
- diff_dict = diff._asdict() # Ugly method for transforming the NamedTuple into a dict
- totals = {k: len(v) for k, v in diff_dict.items() if v is not None}
- diff_size = sum(totals.values())
-
- confirmed, message = await self._get_confirmation_result(diff_size, author, message)
- if not confirmed:
- return
-
- # Preserve the core-dev role mention in the message edits so users aren't confused about
- # where notifications came from.
- mention = self._CORE_DEV_MENTION if author.bot else ""
+ message = await ctx.send(f"📊 Synchronising {cls.name}s.")
+ else:
+ message = None
+ diff = await cls._get_diff(guild)
try:
- await self._sync(diff)
+ await cls._sync(diff)
except ResponseCodeError as e:
- log.exception(f"{self.name} syncer failed!")
+ log.exception(f"{cls.name} syncer failed!")
# Don't show response text because it's probably some really long HTML.
results = f"status {e.status}\n```{e.response_json or 'See log output for details'}```"
- content = f":x: {mention}Synchronisation of {self.name}s failed: {results}"
+ content = f":x: Synchronisation of {cls.name}s failed: {results}"
else:
- results = ", ".join(f"{name} `{total}`" for name, total in totals.items())
- log.info(f"{self.name} syncer finished: {results}.")
- content = f":ok_hand: {mention}Synchronisation of {self.name}s complete: {results}"
+ diff_dict = diff._asdict()
+ results = (f"{name} `{len(val)}`" for name, val in diff_dict.items() if val is not None)
+ results = ", ".join(results)
+
+ log.info(f"{cls.name} syncer finished: {results}.")
+ content = f":ok_hand: Synchronisation of {cls.name}s complete: {results}"
if message:
await message.edit(content=content)
@@ -232,10 +81,11 @@ class RoleSyncer(Syncer):
name = "role"
- async def _get_diff(self, guild: Guild) -> _Diff:
+ @staticmethod
+ async def _get_diff(guild: Guild) -> _Diff:
"""Return the difference of roles between the cache of `guild` and the database."""
log.trace("Getting the diff for roles.")
- roles = await self.bot.api_client.get('bot/roles')
+ roles = await bot.instance.api_client.get('bot/roles')
# Pack DB roles and guild roles into one common, hashable format.
# They're hashable so that they're easily comparable with sets later.
@@ -264,19 +114,20 @@ class RoleSyncer(Syncer):
return _Diff(roles_to_create, roles_to_update, roles_to_delete)
- async def _sync(self, diff: _Diff) -> None:
+ @staticmethod
+ async def _sync(diff: _Diff) -> None:
"""Synchronise the database with the role cache of `guild`."""
log.trace("Syncing created roles...")
for role in diff.created:
- await self.bot.api_client.post('bot/roles', json=role._asdict())
+ await bot.instance.api_client.post('bot/roles', json=role._asdict())
log.trace("Syncing updated roles...")
for role in diff.updated:
- await self.bot.api_client.put(f'bot/roles/{role.id}', json=role._asdict())
+ await bot.instance.api_client.put(f'bot/roles/{role.id}', json=role._asdict())
log.trace("Syncing deleted roles...")
for role in diff.deleted:
- await self.bot.api_client.delete(f'bot/roles/{role.id}')
+ await bot.instance.api_client.delete(f'bot/roles/{role.id}')
class UserSyncer(Syncer):
@@ -284,64 +135,82 @@ class UserSyncer(Syncer):
name = "user"
- async def _get_diff(self, guild: Guild) -> _Diff:
+ @staticmethod
+ async def _get_diff(guild: Guild) -> _Diff:
"""Return the difference of users between the cache of `guild` and the database."""
log.trace("Getting the diff for users.")
- users = await self.bot.api_client.get('bot/users')
- # Pack DB roles and guild roles into one common, hashable format.
- # They're hashable so that they're easily comparable with sets later.
- db_users = {
- user_dict['id']: _User(
- roles=tuple(sorted(user_dict.pop('roles'))),
- **user_dict
- )
- for user_dict in users
- }
- guild_users = {
- member.id: _User(
- id=member.id,
- name=member.name,
- discriminator=int(member.discriminator),
- roles=tuple(sorted(role.id for role in member.roles)),
- in_guild=True
- )
- for member in guild.members
- }
+ users_to_create = []
+ users_to_update = []
+ seen_guild_users = set()
+
+ async for db_user in UserSyncer._get_users():
+ # Store user fields which are to be updated.
+ updated_fields = {}
+
+ def maybe_update(db_field: str, guild_value: t.Union[str, int]) -> None:
+ # Equalize DB user and guild user attributes.
+ if db_user[db_field] != guild_value:
+ updated_fields[db_field] = guild_value
- users_to_create = set()
- users_to_update = set()
+ if guild_user := guild.get_member(db_user["id"]):
+ seen_guild_users.add(guild_user.id)
- for db_user in db_users.values():
- guild_user = guild_users.get(db_user.id)
- if guild_user is not None:
- if db_user != guild_user:
- users_to_update.add(guild_user)
+ maybe_update("name", guild_user.name)
+ maybe_update("discriminator", int(guild_user.discriminator))
+ maybe_update("in_guild", True)
- elif db_user.in_guild:
+ guild_roles = [role.id for role in guild_user.roles]
+ if set(db_user["roles"]) != set(guild_roles):
+ updated_fields["roles"] = guild_roles
+
+ elif db_user["in_guild"]:
# The user is known in the DB but not the guild, and the
# DB currently specifies that the user is a member of the guild.
# This means that the user has left since the last sync.
# Update the `in_guild` attribute of the user on the site
# to signify that the user left.
- new_api_user = db_user._replace(in_guild=False)
- users_to_update.add(new_api_user)
-
- new_user_ids = set(guild_users.keys()) - set(db_users.keys())
- for user_id in new_user_ids:
- # The user is known on the guild but not on the API. This means
- # that the user has joined since the last sync. Create it.
- new_user = guild_users[user_id]
- users_to_create.add(new_user)
+ updated_fields["in_guild"] = False
+
+ if updated_fields:
+ updated_fields["id"] = db_user["id"]
+ users_to_update.append(updated_fields)
+
+ for member in guild.members:
+ if member.id not in seen_guild_users:
+ # The user is known on the guild but not on the API. This means
+ # that the user has joined since the last sync. Create it.
+ new_user = {
+ "id": member.id,
+ "name": member.name,
+ "discriminator": int(member.discriminator),
+ "roles": [role.id for role in member.roles],
+ "in_guild": True
+ }
+ users_to_create.append(new_user)
return _Diff(users_to_create, users_to_update, None)
- async def _sync(self, diff: _Diff) -> None:
+ @staticmethod
+ async def _get_users() -> t.AsyncIterable:
+ """GET users from database."""
+ query_params = {
+ "page": 1
+ }
+ while query_params["page"]:
+ res = await bot.instance.api_client.get("bot/users", params=query_params)
+ for user in res["results"]:
+ yield user
+
+ query_params["page"] = res["next_page_no"]
+
+ @staticmethod
+ async def _sync(diff: _Diff) -> None:
"""Synchronise the database with the user cache of `guild`."""
log.trace("Syncing created users...")
- for user in diff.created:
- await self.bot.api_client.post('bot/users', json=user._asdict())
+ if diff.created:
+ await bot.instance.api_client.post("bot/users", json=diff.created)
log.trace("Syncing updated users...")
- for user in diff.updated:
- await self.bot.api_client.put(f'bot/users/{user.id}', json=user._asdict())
+ if diff.updated:
+ await bot.instance.api_client.patch("bot/users/bulk_patch", json=diff.updated)
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/fun/duck_pond.py b/bot/exts/fun/duck_pond.py
index 6c2d22b9c..48aa2749c 100644
--- a/bot/exts/fun/duck_pond.py
+++ b/bot/exts/fun/duck_pond.py
@@ -22,6 +22,7 @@ class DuckPond(Cog):
self.bot = bot
self.webhook_id = constants.Webhooks.duck_pond
self.webhook = None
+ self.ducked_messages = []
self.bot.loop.create_task(self.fetch_webhook())
self.relay_lock = None
@@ -145,6 +146,10 @@ class DuckPond(Cog):
amount of ducks specified in the config under duck_pond/threshold, it will
send the message off to the duck pond.
"""
+ # Ignore other guilds and DMs.
+ if payload.guild_id != constants.Guild.id:
+ return
+
# Was this reaction issued in a blacklisted channel?
if payload.channel_id in constants.DuckPond.channel_blacklist:
return
@@ -154,6 +159,9 @@ class DuckPond(Cog):
return
channel = discord.utils.get(self.bot.get_all_channels(), id=payload.channel_id)
+ if channel is None:
+ return
+
message = await channel.fetch_message(payload.message_id)
member = discord.utils.get(message.guild.members, id=payload.user_id)
@@ -169,13 +177,20 @@ class DuckPond(Cog):
duck_count = await self.count_ducks(message)
# If we've got more than the required amount of ducks, send the message to the duck_pond.
- if duck_count >= constants.DuckPond.threshold:
+ if duck_count >= constants.DuckPond.threshold and message.id not in self.ducked_messages:
+ self.ducked_messages.append(message.id)
await self.locked_relay(message)
@Cog.listener()
async def on_raw_reaction_remove(self, payload: RawReactionActionEvent) -> None:
"""Ensure that people don't remove the green checkmark from duck ponded messages."""
+ # Ignore other guilds and DMs.
+ if payload.guild_id != constants.Guild.id:
+ return
+
channel = discord.utils.get(self.bot.get_all_channels(), id=payload.channel_id)
+ if channel is None:
+ return
# Prevent the green checkmark from being removed
if payload.emoji.name == "✅":
diff --git a/bot/exts/fun/off_topic_names.py b/bot/exts/fun/off_topic_names.py
index b9d235fa2..7fc93b88c 100644
--- a/bot/exts/fun/off_topic_names.py
+++ b/bot/exts/fun/off_topic_names.py
@@ -1,10 +1,10 @@
-import asyncio
import difflib
import logging
from datetime import datetime, timedelta
from discord import Colour, Embed
from discord.ext.commands import Cog, Context, group, has_any_role
+from discord.utils import sleep_until
from bot.api import ResponseCodeError
from bot.bot import Bot
@@ -23,8 +23,7 @@ async def update_names(bot: Bot) -> None:
# we go past midnight in the `seconds_to_sleep` set below.
today_at_midnight = datetime.utcnow().replace(microsecond=0, second=0, minute=0, hour=0)
next_midnight = today_at_midnight + timedelta(days=1)
- seconds_to_sleep = (next_midnight - datetime.utcnow()).seconds + 1
- await asyncio.sleep(seconds_to_sleep)
+ await sleep_until(next_midnight)
try:
channel_0_name, channel_1_name, channel_2_name = await bot.api_client.get(
diff --git a/bot/exts/help_channels.py b/bot/exts/help_channels.py
index 9e33a6aba..ced2f72ef 100644
--- a/bot/exts/help_channels.py
+++ b/bot/exts/help_channels.py
@@ -14,6 +14,7 @@ from discord.ext import commands
from bot import constants
from bot.bot import Bot
+from bot.utils import channel as channel_utils
from bot.utils.scheduling import Scheduler
log = logging.getLogger(__name__)
@@ -27,17 +28,21 @@ This is a Python help channel. You can claim your own help channel in the Python
"""
AVAILABLE_MSG = f"""
-This help channel is now **available**, which means that you can claim it by simply typing your \
-question into it. Once claimed, the channel will move into the **Python Help: Occupied** category, \
-and will be yours until it has been inactive for {constants.HelpChannels.idle_minutes} minutes or \
-is closed manually with `!close`. When that happens, it will be set to **dormant** and moved into \
-the **Help: Dormant** category.
-
-Try to write the best question you can by providing a detailed description and telling us what \
-you've tried already. For more information on asking a good question, \
-check out our guide on **[asking good questions]({ASKING_GUIDE_URL})**.
+**Send your question here to claim the channel**
+This channel will be dedicated to answering your question only. Others will try to answer and help you solve the issue.
+
+**Keep in mind:**
+• It's always ok to just ask your question. You don't need permission.
+• Explain what you expect to happen and what actually happens.
+• Include a code sample and error message, if you got any.
+
+For more tips, check out our guide on **[asking good questions]({ASKING_GUIDE_URL})**.
"""
+AVAILABLE_TITLE = "Available help channel"
+
+AVAILABLE_FOOTER = f"Closes after {constants.HelpChannels.idle_minutes} minutes of inactivity or when you send !close."
+
DORMANT_MSG = f"""
This help channel has been marked as **dormant**, and has been moved into the **Help: Dormant** \
category at the bottom of the channel list. It is no longer possible to send messages in this \
@@ -378,11 +383,15 @@ class HelpChannels(commands.Cog):
log.trace("Getting the CategoryChannel objects for the help categories.")
try:
- self.available_category = await self.try_get_channel(
+ self.available_category = await channel_utils.try_get_channel(
constants.Categories.help_available
)
- self.in_use_category = await self.try_get_channel(constants.Categories.help_in_use)
- self.dormant_category = await self.try_get_channel(constants.Categories.help_dormant)
+ self.in_use_category = await channel_utils.try_get_channel(
+ constants.Categories.help_in_use
+ )
+ self.dormant_category = await channel_utils.try_get_channel(
+ constants.Categories.help_dormant
+ )
except discord.HTTPException:
log.exception("Failed to get a category; cog will be removed")
self.bot.remove_cog(self.qualified_name)
@@ -442,12 +451,6 @@ class HelpChannels(commands.Cog):
return False
return message.author == self.bot.user and bot_msg_desc.strip() == description.strip()
- @staticmethod
- def is_in_category(channel: discord.TextChannel, category_id: int) -> bool:
- """Return True if `channel` is within a category with `category_id`."""
- actual_category = getattr(channel, "category", None)
- return actual_category is not None and actual_category.id == category_id
-
async def move_idle_channel(self, channel: discord.TextChannel, has_task: bool = True) -> None:
"""
Make the `channel` dormant if idle or schedule the move if still active.
@@ -494,11 +497,11 @@ class HelpChannels(commands.Cog):
If `options` are provided, the channel will be edited after the move is completed. This is the
same order of operations that `discord.TextChannel.edit` uses. For information on available
- options, see the documention on `discord.TextChannel.edit`. While possible, position-related
+ options, see the documentation on `discord.TextChannel.edit`. While possible, position-related
options should be avoided, as it may interfere with the category move we perform.
"""
# Get a fresh copy of the category from the bot to avoid the cache mismatch issue we had.
- category = await self.try_get_channel(category_id)
+ category = await channel_utils.try_get_channel(category_id)
payload = [{"id": c.id, "position": c.position} for c in category.channels]
@@ -646,7 +649,7 @@ class HelpChannels(commands.Cog):
channel = message.channel
# Confirm the channel is an in use help channel
- if self.is_in_category(channel, constants.Categories.help_in_use):
+ if channel_utils.is_in_category(channel, constants.Categories.help_in_use):
log.trace(f"Checking if #{channel} ({channel.id}) has been answered.")
# Check if there is an entry in unanswered
@@ -671,7 +674,8 @@ class HelpChannels(commands.Cog):
await self.check_for_answer(message)
- if not self.is_in_category(channel, constants.Categories.help_available) or self.is_excluded_channel(channel):
+ is_available = channel_utils.is_in_category(channel, constants.Categories.help_available)
+ if not is_available or self.is_excluded_channel(channel):
return # Ignore messages outside the Available category or in excluded channels.
log.trace("Waiting for the cog to be ready before processing messages.")
@@ -681,7 +685,7 @@ class HelpChannels(commands.Cog):
async with self.on_message_lock:
log.trace(f"on_message lock acquired for {message.id}.")
- if not self.is_in_category(channel, constants.Categories.help_available):
+ if not channel_utils.is_in_category(channel, constants.Categories.help_available):
log.debug(
f"Message {message.id} will not make #{channel} ({channel.id}) in-use "
f"because another message in the channel already triggered that."
@@ -719,7 +723,7 @@ class HelpChannels(commands.Cog):
The new time for the dormant task is configured with `HelpChannels.deleted_idle_minutes`.
"""
- if not self.is_in_category(msg.channel, constants.Categories.help_in_use):
+ if not channel_utils.is_in_category(msg.channel, constants.Categories.help_in_use):
return
if not await self.is_empty(msg.channel):
@@ -834,7 +838,12 @@ class HelpChannels(commands.Cog):
channel_info = f"#{channel} ({channel.id})"
log.trace(f"Sending available message in {channel_info}.")
- embed = discord.Embed(description=AVAILABLE_MSG)
+ embed = discord.Embed(
+ color=constants.Colours.bright_green,
+ description=AVAILABLE_MSG,
+ )
+ embed.set_author(name=AVAILABLE_TITLE, icon_url=constants.Icons.green_checkmark)
+ embed.set_footer(text=AVAILABLE_FOOTER)
msg = await self.get_last_message(channel)
if self.match_bot_embed(msg, DORMANT_MSG):
@@ -844,18 +853,6 @@ class HelpChannels(commands.Cog):
log.trace(f"Dormant message not found in {channel_info}; sending a new message.")
await channel.send(embed=embed)
- async def try_get_channel(self, channel_id: int) -> discord.abc.GuildChannel:
- """Attempt to get or fetch a channel and return it."""
- log.trace(f"Getting the channel {channel_id}.")
-
- channel = self.bot.get_channel(channel_id)
- if not channel:
- log.debug(f"Channel {channel_id} is not in cache; fetching from API.")
- channel = await self.bot.fetch_channel(channel_id)
-
- log.trace(f"Channel #{channel} ({channel_id}) retrieved.")
- return channel
-
async def pin_wrapper(self, msg_id: int, channel: discord.TextChannel, *, pin: bool) -> bool:
"""
Pin message `msg_id` in `channel` if `pin` is True or unpin if it's False.
diff --git a/bot/exts/info/codeblock/__init__.py b/bot/exts/info/codeblock/__init__.py
new file mode 100644
index 000000000..5c55bc5e3
--- /dev/null
+++ b/bot/exts/info/codeblock/__init__.py
@@ -0,0 +1,8 @@
+from bot.bot import Bot
+
+
+def setup(bot: Bot) -> None:
+ """Load the CodeBlockCog cog."""
+ # Defer import to reduce side effects from importing the codeblock package.
+ from bot.exts.info.codeblock._cog import CodeBlockCog
+ bot.add_cog(CodeBlockCog(bot))
diff --git a/bot/exts/info/codeblock/_cog.py b/bot/exts/info/codeblock/_cog.py
new file mode 100644
index 000000000..9094d9d15
--- /dev/null
+++ b/bot/exts/info/codeblock/_cog.py
@@ -0,0 +1,186 @@
+import logging
+import time
+from typing import Optional
+
+import discord
+from discord import Message, RawMessageUpdateEvent
+from discord.ext.commands import Cog
+
+from bot import constants
+from bot.bot import Bot
+from bot.exts.filters.token_remover import TokenRemover
+from bot.exts.filters.webhook_remover import WEBHOOK_URL_RE
+from bot.exts.info.codeblock._instructions import get_instructions
+from bot.utils import has_lines
+from bot.utils.channel import is_help_channel
+from bot.utils.messages import wait_for_deletion
+
+log = logging.getLogger(__name__)
+
+
+class CodeBlockCog(Cog, name="Code Block"):
+ """
+ Detect improperly formatted Markdown code blocks and suggest proper formatting.
+
+ There are four basic ways in which a code block is considered improperly formatted:
+
+ 1. The code is not within a code block at all
+ * Ignored if the code is not valid Python or Python REPL code
+ 2. Incorrect characters are used for backticks
+ 3. A language for syntax highlighting is not specified
+ * Ignored if the code is not valid Python or Python REPL code
+ 4. A syntax highlighting language is incorrectly specified
+ * Ignored if the language specified doesn't look like it was meant for Python
+ * This can go wrong in two ways:
+ 1. Spaces before the language
+ 2. No newline immediately following the language
+
+ Messages or code blocks must meet a minimum line count to be detected. Detecting multiple code
+ blocks is supported. However, if at least one code block is correct, then instructions will not
+ be sent even if others are incorrect. When multiple incorrect code blocks are found, only the
+ first one is used as the basis for the instructions sent.
+
+ When an issue is detected, an embed is sent containing specific instructions on fixing what
+ is wrong. If the user edits their message to fix the code block, the instructions will be
+ removed. If they fail to fix the code block with an edit, the instructions will be updated to
+ show what is still incorrect after the user's edit. The embed can be manually deleted with a
+ reaction. Otherwise, it will automatically be removed after 5 minutes.
+
+ The cog only detects messages in whitelisted channels. Channels may also have a cooldown on the
+ instructions being sent. Note all help channels are also whitelisted with cooldowns enabled.
+
+ For configurable parameters, see the `code_block` section in config-default.py.
+ """
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+
+ # Stores allowed channels plus epoch times since the last instructional messages sent.
+ self.channel_cooldowns = dict.fromkeys(constants.CodeBlock.cooldown_channels, 0.0)
+
+ # Maps users' messages to the messages the bot sent with instructions.
+ self.codeblock_message_ids = {}
+
+ @staticmethod
+ def create_embed(instructions: str) -> discord.Embed:
+ """Return an embed which displays code block formatting `instructions`."""
+ return discord.Embed(description=instructions)
+
+ async def get_sent_instructions(self, payload: RawMessageUpdateEvent) -> Optional[Message]:
+ """
+ Return the bot's sent instructions message associated with a user's message `payload`.
+
+ Return None if the message cannot be found. In this case, it's likely the message was
+ deleted either manually via a reaction or automatically by a timer.
+ """
+ log.trace(f"Retrieving instructions message for ID {payload.message_id}")
+ channel = self.bot.get_channel(payload.channel_id)
+
+ try:
+ return await channel.fetch_message(self.codeblock_message_ids[payload.message_id])
+ except discord.NotFound:
+ log.debug("Could not find instructions message; it was probably deleted.")
+ return None
+
+ def is_on_cooldown(self, channel: discord.TextChannel) -> bool:
+ """
+ Return True if an embed was sent too recently for `channel`.
+
+ The cooldown is configured by `constants.CodeBlock.cooldown_seconds`.
+ Note: only channels in the `channel_cooldowns` have cooldowns enabled.
+ """
+ log.trace(f"Checking if #{channel} is on cooldown.")
+ cooldown = constants.CodeBlock.cooldown_seconds
+ return (time.time() - self.channel_cooldowns.get(channel.id, 0)) < cooldown
+
+ def is_valid_channel(self, channel: discord.TextChannel) -> bool:
+ """Return True if `channel` is a help channel, may be on a cooldown, or is whitelisted."""
+ log.trace(f"Checking if #{channel} qualifies for code block detection.")
+ return (
+ is_help_channel(channel)
+ or channel.id in self.channel_cooldowns
+ or channel.id in constants.CodeBlock.channel_whitelist
+ )
+
+ async def send_instructions(self, message: discord.Message, instructions: str) -> None:
+ """
+ Send an embed with `instructions` on fixing an incorrect code block in a `message`.
+
+ The embed will be deleted automatically after 5 minutes.
+ """
+ log.info(f"Sending code block formatting instructions for message {message.id}.")
+
+ embed = self.create_embed(instructions)
+ bot_message = await message.channel.send(f"Hey {message.author.mention}!", embed=embed)
+ self.codeblock_message_ids[message.id] = bot_message.id
+
+ self.bot.loop.create_task(wait_for_deletion(bot_message, (message.author.id,)))
+
+ # Increase amount of codeblock correction in stats
+ self.bot.stats.incr("codeblock_corrections")
+
+ def should_parse(self, message: discord.Message) -> bool:
+ """
+ Return True if `message` should be parsed.
+
+ A qualifying message:
+
+ 1. Is not authored by a bot
+ 2. Is in a valid channel
+ 3. Has more than 3 lines
+ 4. Has no bot or webhook token
+ """
+ return (
+ not message.author.bot
+ and self.is_valid_channel(message.channel)
+ and has_lines(message.content, constants.CodeBlock.minimum_lines)
+ and not TokenRemover.find_token_in_message(message)
+ and not WEBHOOK_URL_RE.search(message.content)
+ )
+
+ @Cog.listener()
+ async def on_message(self, msg: Message) -> None:
+ """Detect incorrect Markdown code blocks in `msg` and send instructions to fix them."""
+ if not self.should_parse(msg):
+ log.trace(f"Skipping code block detection of {msg.id}: message doesn't qualify.")
+ return
+
+ # When debugging, ignore cooldowns.
+ if self.is_on_cooldown(msg.channel) and not constants.DEBUG_MODE:
+ log.trace(f"Skipping code block detection of {msg.id}: #{msg.channel} is on cooldown.")
+ return
+
+ instructions = get_instructions(msg.content)
+ if instructions:
+ await self.send_instructions(msg, instructions)
+
+ if msg.channel.id not in constants.CodeBlock.channel_whitelist:
+ log.debug(f"Adding #{msg.channel} to the channel cooldowns.")
+ self.channel_cooldowns[msg.channel.id] = time.time()
+
+ @Cog.listener()
+ async def on_raw_message_edit(self, payload: RawMessageUpdateEvent) -> None:
+ """Delete the instructional message if an edited message had its code blocks fixed."""
+ if payload.message_id not in self.codeblock_message_ids:
+ log.trace(f"Ignoring message edit {payload.message_id}: message isn't being tracked.")
+ return
+
+ if payload.data.get("content") is None or payload.data.get("channel_id") is None:
+ log.trace(f"Ignoring message edit {payload.message_id}: missing content or channel ID.")
+ return
+
+ # Parse the message to see if the code blocks have been fixed.
+ content = payload.data.get("content")
+ instructions = get_instructions(content)
+
+ bot_message = await self.get_sent_instructions(payload)
+ if not bot_message:
+ return
+
+ if not instructions:
+ log.info("User's incorrect code block has been fixed. Removing instructions message.")
+ await bot_message.delete()
+ del self.codeblock_message_ids[payload.message_id]
+ else:
+ log.info("Message edited but still has invalid code blocks; editing the instructions.")
+ await bot_message.edit(embed=self.create_embed(instructions))
diff --git a/bot/exts/info/codeblock/_instructions.py b/bot/exts/info/codeblock/_instructions.py
new file mode 100644
index 000000000..dadb5e1ef
--- /dev/null
+++ b/bot/exts/info/codeblock/_instructions.py
@@ -0,0 +1,184 @@
+"""This module generates and formats instructional messages about fixing Markdown code blocks."""
+
+import logging
+from typing import Optional
+
+from bot.exts.info.codeblock import _parsing
+
+log = logging.getLogger(__name__)
+
+_EXAMPLE_PY = "{lang}\nprint('Hello, world!')" # Make sure to escape any Markdown symbols here.
+_EXAMPLE_CODE_BLOCKS = (
+ "\\`\\`\\`{content}\n\\`\\`\\`\n\n"
+ "**This will result in the following:**\n"
+ "```{content}```"
+)
+
+
+def _get_example(language: str) -> str:
+ """Return an example of a correct code block using `language` for syntax highlighting."""
+ # Determine the example code to put in the code block based on the language specifier.
+ if language.lower() in _parsing.PY_LANG_CODES:
+ log.trace(f"Code block has a Python language specifier `{language}`.")
+ content = _EXAMPLE_PY.format(lang=language)
+ elif language:
+ log.trace(f"Code block has a foreign language specifier `{language}`.")
+ # It's not feasible to determine what would be a valid example for other languages.
+ content = f"{language}\n..."
+ else:
+ log.trace("Code block has no language specifier.")
+ content = "\nHello, world!"
+
+ return _EXAMPLE_CODE_BLOCKS.format(content=content)
+
+
+def _get_bad_ticks_message(code_block: _parsing.CodeBlock) -> Optional[str]:
+ """Return instructions on using the correct ticks for `code_block`."""
+ log.trace("Creating instructions for incorrect code block ticks.")
+
+ valid_ticks = f"\\{_parsing.BACKTICK}" * 3
+ instructions = (
+ "It looks like you are trying to paste code into this channel.\n\n"
+ "You seem to be using the wrong symbols to indicate where the code block should start. "
+ f"The correct symbols would be {valid_ticks}, not `{code_block.tick * 3}`."
+ )
+
+ log.trace("Check if the bad ticks code block also has issues with the language specifier.")
+ addition_msg = _get_bad_lang_message(code_block.content)
+ if not addition_msg and not code_block.language:
+ addition_msg = _get_no_lang_message(code_block.content)
+
+ # Combine the back ticks message with the language specifier message. The latter will
+ # already have an example code block.
+ if addition_msg:
+ log.trace("Language specifier issue found; appending additional instructions.")
+
+ # The first line has double newlines which are not desirable when appending the msg.
+ addition_msg = addition_msg.replace("\n\n", " ", 1)
+
+ # Make the first character of the addition lower case.
+ instructions += "\n\nFurthermore, " + addition_msg[0].lower() + addition_msg[1:]
+ else:
+ log.trace("No issues with the language specifier found.")
+ example_blocks = _get_example(code_block.language)
+ instructions += f"\n\n**Here is an example of how it should look:**\n{example_blocks}"
+
+ return instructions
+
+
+def _get_no_ticks_message(content: str) -> Optional[str]:
+ """If `content` is Python/REPL code, return instructions on using code blocks."""
+ log.trace("Creating instructions for a missing code block.")
+
+ if _parsing.is_python_code(content):
+ 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 "
+ "syntax highlighting. Please use these whenever you paste code, as this "
+ "helps improve the legibility and makes it easier for us to help you.\n\n"
+ f"**To do this, use the following method:**\n{example_blocks}"
+ )
+ else:
+ log.trace("Aborting missing code block instructions: content is not Python code.")
+
+
+def _get_bad_lang_message(content: str) -> Optional[str]:
+ """
+ Return instructions on fixing the Python language specifier for a code block.
+
+ If `code_block` does not have a Python language specifier, return None.
+ If there's nothing wrong with the language specifier, return None.
+ """
+ log.trace("Creating instructions for a poorly specified language.")
+
+ info = _parsing.parse_bad_language(content)
+ if not info:
+ log.trace("Aborting bad language instructions: language specified isn't Python.")
+ return
+
+ lines = []
+ language = info.language
+
+ if info.has_leading_spaces:
+ log.trace("Language specifier was preceded by a space.")
+ lines.append(f"Make sure there are no spaces between the back ticks and `{language}`.")
+
+ if not info.has_terminal_newline:
+ log.trace("Language specifier was not followed by a newline.")
+ lines.append(
+ f"Make sure you put your code on a new line following `{language}`. "
+ f"There must not be any spaces after `{language}`."
+ )
+
+ if lines:
+ lines = " ".join(lines)
+ example_blocks = _get_example(language)
+
+ # Note that _get_bad_ticks_message expects the first line to have two newlines.
+ return (
+ f"It looks like you incorrectly specified a language for your code block.\n\n{lines}"
+ f"\n\n**Here is an example of how it should look:**\n{example_blocks}"
+ )
+ else:
+ log.trace("Nothing wrong with the language specifier; no instructions to return.")
+
+
+def _get_no_lang_message(content: str) -> Optional[str]:
+ """
+ Return instructions on specifying a language for a code block.
+
+ If `content` is not valid Python or Python REPL code, return None.
+ """
+ log.trace("Creating instructions for a missing language.")
+
+ if _parsing.is_python_code(content):
+ example_blocks = _get_example("py")
+
+ # Note that _get_bad_ticks_message expects the first line to have two newlines.
+ return (
+ "It looks like you pasted Python code without syntax highlighting.\n\n"
+ "Please use syntax highlighting to improve the legibility of your code and make "
+ "it easier for us to help you.\n\n"
+ f"**To do this, use the following method:**\n{example_blocks}"
+ )
+ else:
+ log.trace("Aborting missing language instructions: content is not Python code.")
+
+
+def get_instructions(content: str) -> Optional[str]:
+ """
+ Parse `content` and return code block formatting instructions if something is wrong.
+
+ Return None if `content` lacks code block formatting issues.
+ """
+ log.trace("Getting formatting instructions.")
+
+ blocks = _parsing.find_code_blocks(content)
+ if blocks is None:
+ log.trace("At least one valid code block found; no instructions to return.")
+ return
+
+ if not blocks:
+ log.trace("No code blocks were found in message.")
+ instructions = _get_no_ticks_message(content)
+ else:
+ log.trace("Searching results for a code block with invalid ticks.")
+ block = next((block for block in blocks if block.tick != _parsing.BACKTICK), None)
+
+ if block:
+ log.trace("A code block exists but has invalid ticks.")
+ instructions = _get_bad_ticks_message(block)
+ else:
+ log.trace("A code block exists but is missing a language.")
+ block = blocks[0]
+
+ # Check for a bad language first to avoid parsing content into an AST.
+ instructions = _get_bad_lang_message(block.content)
+ if not instructions:
+ instructions = _get_no_lang_message(block.content)
+
+ if instructions:
+ instructions += "\nYou can **edit your original message** to correct your code block."
+
+ return instructions
diff --git a/bot/exts/info/codeblock/_parsing.py b/bot/exts/info/codeblock/_parsing.py
new file mode 100644
index 000000000..e35fbca22
--- /dev/null
+++ b/bot/exts/info/codeblock/_parsing.py
@@ -0,0 +1,228 @@
+"""This module provides functions for parsing Markdown code blocks."""
+
+import ast
+import logging
+import re
+import textwrap
+from typing import NamedTuple, Optional, Sequence
+
+from bot import constants
+from bot.utils import has_lines
+
+log = logging.getLogger(__name__)
+
+BACKTICK = "`"
+PY_LANG_CODES = ("python-repl", "python", "pycon", "py") # Order is important; "py" is last cause it's a subset.
+_TICKS = {
+ BACKTICK,
+ "'",
+ '"',
+ "\u00b4", # ACUTE ACCENT
+ "\u2018", # LEFT SINGLE QUOTATION MARK
+ "\u2019", # RIGHT SINGLE QUOTATION MARK
+ "\u2032", # PRIME
+ "\u201c", # LEFT DOUBLE QUOTATION MARK
+ "\u201d", # RIGHT DOUBLE QUOTATION MARK
+ "\u2033", # DOUBLE PRIME
+ "\u3003", # VERTICAL KANA REPEAT MARK UPPER HALF
+}
+
+_RE_PYTHON_REPL = re.compile(r"^(>>>|\.\.\.)( |$)")
+_RE_IPYTHON_REPL = re.compile(r"^((In|Out) \[\d+\]: |\s*\.{3,}: ?)")
+
+_RE_CODE_BLOCK = re.compile(
+ fr"""
+ (?P<ticks>
+ (?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>[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.
+ """,
+ re.DOTALL | re.VERBOSE
+)
+
+_RE_LANGUAGE = re.compile(
+ fr"""
+ ^(?P<spaces>\s+)? # Optionally match leading spaces from the beginning.
+ (?P<lang>{'|'.join(PY_LANG_CODES)}) # Match a Python language.
+ (?P<newline>\n)? # Optionally match a newline following the language.
+ """,
+ re.IGNORECASE | re.VERBOSE
+)
+
+
+class CodeBlock(NamedTuple):
+ """Represents a Markdown code block."""
+
+ content: str
+ language: str
+ tick: str
+
+
+class BadLanguage(NamedTuple):
+ """Parsed information about a poorly formatted language specifier."""
+
+ language: str
+ has_leading_spaces: bool
+ has_terminal_newline: bool
+
+
+def find_code_blocks(message: str) -> Optional[Sequence[CodeBlock]]:
+ """
+ Find and return all Markdown code blocks in the `message`.
+
+ Code blocks with 3 or fewer lines are excluded.
+
+ If the `message` contains at least one code block with valid ticks and a specified language,
+ return None. This is based on the assumption that if the user managed to get one code block
+ right, they already know how to fix the rest themselves.
+ """
+ log.trace("Finding all code blocks in a message.")
+
+ code_blocks = []
+ for match in _RE_CODE_BLOCK.finditer(message):
+ # Used to ensure non-matched groups have an empty string as the default value.
+ groups = match.groupdict("")
+ language = groups["lang"].strip() # Strip the newline cause it's included in the group.
+
+ if groups["tick"] == BACKTICK and language:
+ log.trace("Message has a valid code block with a language; returning None.")
+ return None
+ elif has_lines(groups["code"], constants.CodeBlock.minimum_lines):
+ code_block = CodeBlock(groups["code"], language, groups["tick"])
+ code_blocks.append(code_block)
+ else:
+ log.trace("Skipped a code block shorter than 4 lines.")
+
+ return code_blocks
+
+
+def _is_python_code(content: str) -> bool:
+ """Return True if `content` is valid Python consisting of more than just expressions."""
+ log.trace("Checking if content is Python code.")
+ try:
+ # Attempt to parse the message into an AST node.
+ # Invalid Python code will raise a SyntaxError.
+ tree = ast.parse(content)
+ except SyntaxError:
+ log.trace("Code is not valid Python.")
+ return False
+
+ # Multiple lines of single words could be interpreted as expressions.
+ # This check is to avoid all nodes being parsed as expressions.
+ # (e.g. words over multiple lines)
+ if not all(isinstance(node, ast.Expr) for node in tree.body):
+ log.trace("Code is valid python.")
+ return True
+ else:
+ log.trace("Code consists only of expressions.")
+ return False
+
+
+def _is_repl_code(content: str, threshold: int = 3) -> bool:
+ """Return True if `content` has at least `threshold` number of (I)Python REPL-like lines."""
+ log.trace(f"Checking if content is (I)Python REPL code using a threshold of {threshold}.")
+
+ repl_lines = 0
+ patterns = (_RE_PYTHON_REPL, _RE_IPYTHON_REPL)
+
+ for line in content.splitlines():
+ # Check the line against all patterns.
+ for pattern in patterns:
+ if pattern.match(line):
+ repl_lines += 1
+
+ # Once a pattern is matched, only use that pattern for the remaining lines.
+ patterns = (pattern,)
+ break
+
+ if repl_lines == threshold:
+ log.trace("Content is (I)Python REPL code.")
+ return True
+
+ log.trace("Content is not (I)Python REPL code.")
+ return False
+
+
+def is_python_code(content: str) -> bool:
+ """Return True if `content` is valid Python code or (I)Python REPL output."""
+ dedented = textwrap.dedent(content)
+
+ # Parse AST twice in case _fix_indentation ends up breaking code due to its inaccuracies.
+ return (
+ _is_python_code(dedented)
+ or _is_repl_code(dedented)
+ or _is_python_code(_fix_indentation(content))
+ )
+
+
+def parse_bad_language(content: str) -> Optional[BadLanguage]:
+ """
+ Return information about a poorly formatted Python language in code block `content`.
+
+ If the language is not Python, return None.
+ """
+ log.trace("Parsing bad language.")
+
+ match = _RE_LANGUAGE.match(content)
+ if not match:
+ return None
+
+ return BadLanguage(
+ language=match["lang"],
+ has_leading_spaces=match["spaces"] is not None,
+ has_terminal_newline=match["newline"] is not None,
+ )
+
+
+def _get_leading_spaces(content: str) -> int:
+ """Return the number of spaces at the start of the first line in `content`."""
+ leading_spaces = 0
+ for char in content:
+ if char == " ":
+ leading_spaces += 1
+ else:
+ return leading_spaces
+
+
+def _fix_indentation(content: str) -> str:
+ """
+ Attempt to fix badly indented code in `content`.
+
+ In most cases, this works like textwrap.dedent. However, if the first line ends with a colon,
+ all subsequent lines are re-indented to only be one level deep relative to the first line.
+ The intent is to fix cases where the leading spaces of the first line of code were accidentally
+ not copied, which makes the first line appear not indented.
+
+ This is fairly naïve and inaccurate. Therefore, it may break some code that was otherwise valid.
+ It's meant to catch really common cases, so that's acceptable. Its flaws are:
+
+ - It assumes that if the first line ends with a colon, it is the start of an indented block
+ - It uses 4 spaces as the indentation, regardless of what the rest of the code uses
+ """
+ lines = content.splitlines(keepends=True)
+
+ # Dedent the first line
+ first_indent = _get_leading_spaces(content)
+ first_line = lines[0][first_indent:]
+
+ # Can't assume there'll be multiple lines cause line counts of edited messages aren't checked.
+ if len(lines) == 1:
+ return first_line
+
+ second_indent = _get_leading_spaces(lines[1])
+
+ # If the first line ends with a colon, all successive lines need to be indented one
+ # additional level (assumes an indent width of 4).
+ if first_line.rstrip().endswith(":"):
+ second_indent -= 4
+
+ # All lines must be dedented at least by the same amount as the first line.
+ first_indent = max(first_indent, second_indent)
+
+ # Dedent the rest of the lines and join them together with the first line.
+ content = first_line + "".join(line[first_indent:] for line in lines[1:])
+
+ return content
diff --git a/bot/exts/info/doc.py b/bot/exts/info/doc.py
index e50b9b32b..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.
@@ -345,7 +318,7 @@ class Doc(commands.Cog):
@commands.group(name='docs', aliases=('doc', 'd'), invoke_without_command=True)
async def docs_group(self, ctx: commands.Context, symbol: commands.clean_content = None) -> None:
"""Lookup documentation for Python symbols."""
- await ctx.invoke(self.get_command, symbol)
+ await self.get_command(ctx, symbol)
@docs_group.command(name='get', aliases=('g',))
async def get_command(self, ctx: commands.Context, symbol: commands.clean_content = None) -> None:
@@ -392,7 +365,7 @@ class Doc(commands.Cog):
await ctx.message.delete(delay=NOT_FOUND_DELETE_DELAY)
else:
msg = await ctx.send(embed=doc_embed)
- await wait_for_deletion(msg, (ctx.author.id,), client=self.bot)
+ await wait_for_deletion(msg, (ctx.author.id,))
@docs_group.command(name='set', aliases=('s',))
@commands.has_any_role(*MODERATION_ROLES)
diff --git a/bot/exts/info/help.py b/bot/exts/info/help.py
index 99d503f5c..461ff82fd 100644
--- a/bot/exts/info/help.py
+++ b/bot/exts/info/help.py
@@ -186,7 +186,7 @@ class CustomHelpCommand(HelpCommand):
"""Send help for a single command."""
embed = await self.command_formatting(command)
message = await self.context.send(embed=embed)
- await wait_for_deletion(message, (self.context.author.id,), self.context.bot)
+ await wait_for_deletion(message, (self.context.author.id,))
@staticmethod
def get_commands_brief_details(commands_: List[Command], return_as_list: bool = False) -> Union[List[str], str]:
@@ -225,11 +225,11 @@ class CustomHelpCommand(HelpCommand):
embed.description += f"\n**Subcommands:**\n{command_details}"
message = await self.context.send(embed=embed)
- await wait_for_deletion(message, (self.context.author.id,), self.context.bot)
+ await wait_for_deletion(message, (self.context.author.id,))
async def send_cog_help(self, cog: Cog) -> None:
"""Send help for a cog."""
- # sort commands by name, and remove any the user cant run or are hidden.
+ # sort commands by name, and remove any the user can't run or are hidden.
commands_ = await self.filter_commands(cog.get_commands(), sort=True)
embed = Embed()
@@ -241,7 +241,7 @@ class CustomHelpCommand(HelpCommand):
embed.description += f"\n\n**Commands:**\n{command_details}"
message = await self.context.send(embed=embed)
- await wait_for_deletion(message, (self.context.author.id,), self.context.bot)
+ await wait_for_deletion(message, (self.context.author.id,))
@staticmethod
def _category_key(command: Command) -> str:
diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py
index 156dfec35..5aaf85e5a 100644
--- a/bot/exts/info/information.py
+++ b/bot/exts/info/information.py
@@ -6,15 +6,16 @@ from collections import Counter, defaultdict
from string import Template
from typing import Any, Mapping, Optional, Tuple, Union
-from discord import ChannelType, Colour, CustomActivity, 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 discord.utils import escape_markdown
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
@@ -77,7 +78,7 @@ class Information(Cog):
channel_type_list = sorted(channel_type_list)
return "\n".join(channel_type_list)
- @has_any_role(*constants.MODERATION_ROLES)
+ @has_any_role(*constants.STAFF_ROLES)
@command(name="roles")
async def roles_info(self, ctx: Context) -> None:
"""Returns a list of all roles and their corresponding IDs."""
@@ -97,7 +98,7 @@ class Information(Cog):
await LinePaginator.paginate(role_list, ctx, embed, empty=False)
- @has_any_role(*constants.MODERATION_ROLES)
+ @has_any_role(*constants.STAFF_ROLES)
@command(name="role")
async def role_info(self, ctx: Context, *roles: Union[Role, str]) -> None:
"""
@@ -153,7 +154,9 @@ class Information(Cog):
channel_counts = self.get_channel_type_counts(ctx.guild)
# How many of each user status?
- statuses = Counter(member.status for member in ctx.guild.members)
+ py_invite = await self.bot.fetch_invite(constants.Guild.invite)
+ online_presences = py_invite.approximate_presence_count
+ offline_presences = py_invite.approximate_member_count - online_presences
embed = Embed(colour=Colour.blurple())
# How many staff members and staff channels do we have?
@@ -161,9 +164,9 @@ class Information(Cog):
staff_channel_count = self.get_staff_channel_count(ctx.guild)
# Because channel_counts lacks leading whitespace, it breaks the dedent if it's inserted directly by the
- # f-string. While this is correctly formated by Discord, it makes unit testing difficult. To keep the formatting
- # without joining a tuple of strings we can use a Template string to insert the already-formatted channel_counts
- # after the dedent is made.
+ # f-string. While this is correctly formatted by Discord, it makes unit testing difficult. To keep the
+ # formatting without joining a tuple of strings we can use a Template string to insert the already-formatted
+ # channel_counts after the dedent is made.
embed.description = Template(
textwrap.dedent(f"""
**Server information**
@@ -181,10 +184,8 @@ class Information(Cog):
Roles: {roles}
**Member statuses**
- {constants.Emojis.status_online} {statuses[Status.online]:,}
- {constants.Emojis.status_idle} {statuses[Status.idle]:,}
- {constants.Emojis.status_dnd} {statuses[Status.dnd]:,}
- {constants.Emojis.status_offline} {statuses[Status.offline]:,}
+ {constants.Emojis.status_online} {online_presences:,}
+ {constants.Emojis.status_offline} {offline_presences:,}
""")
).substitute({"channel_counts": channel_counts})
embed.set_thumbnail(url=ctx.guild.icon_url)
@@ -192,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
@@ -207,31 +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`."""
- created = time_since(user.created_at, max_units=3)
-
- # Custom status
- custom_status = ''
- for activity in user.activities:
- if isinstance(activity, CustomActivity):
- state = ""
-
- if activity.name:
- state = escape_markdown(activity.name)
-
- emoji = ""
- if activity.emoji:
- # If an emoji is unicode use the emoji, else write the emote like :abc:
- if not activity.emoji.id:
- emoji += activity.emoji.name + " "
- else:
- emoji += f"`:{activity.emoji.name}:` "
+ on_server = bool(ctx.guild.get_member(user.id))
- custom_status = f'Status: {emoji}{state}\n'
+ 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 = []
@@ -240,12 +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:])
-
- desktop_status = STATUS_EMOTES.get(user.desktop_status, constants.Emojis.status_online)
- web_status = STATUS_EMOTES.get(user.web_status, constants.Emojis.status_online)
- mobile_status = STATUS_EMOTES.get(user.mobile_status, constants.Emojis.status_online)
+ 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 = [
(
@@ -254,34 +242,16 @@ class Information(Cog):
Created: {created}
Profile: {user.mention}
ID: {user.id}
- {custom_status}
""").strip()
),
(
"Member information",
- textwrap.dedent(f"""
- Joined: {joined}
- Roles: {roles or None}
- """).strip()
+ membership
),
- (
- "Status",
- textwrap.dedent(f"""
- {desktop_status} Desktop
- {web_status} Web
- {mobile_status} Mobile
- """).strip()
- )
]
- # 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:
@@ -301,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)
}
)
@@ -318,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`.
@@ -328,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)
}
)
@@ -359,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 635162308..bad4c504d 100644
--- a/bot/exts/info/reddit.py
+++ b/bot/exts/info/reddit.py
@@ -10,7 +10,7 @@ from aiohttp import BasicAuth, ClientError
from discord import Colour, Embed, TextChannel
from discord.ext.commands import Cog, Context, group, has_any_role
from discord.ext.tasks import loop
-from discord.utils import escape_markdown
+from discord.utils import escape_markdown, sleep_until
from bot.bot import Bot
from bot.constants import Channels, ERROR_REPLIES, Emojis, Reddit as RedditConfig, STAFF_ROLES, Webhooks
@@ -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."
)
@@ -203,13 +205,13 @@ class Reddit(Cog):
@loop()
async def auto_poster_loop(self) -> None:
"""Post the top 5 posts daily, and the top 5 posts weekly."""
- # once we upgrade to d.py 1.3 this can be removed and the loop can use the `time=datetime.time.min` parameter
+ # once d.py get support for `time` parameter in loop decorator,
+ # this can be removed and the loop can use the `time=datetime.time.min` parameter
now = datetime.utcnow()
tomorrow = now + timedelta(days=1)
midnight_tomorrow = tomorrow.replace(hour=0, minute=0, second=0)
- seconds_until = (midnight_tomorrow - now).total_seconds()
- await asyncio.sleep(seconds_until)
+ await sleep_until(midnight_tomorrow)
await self.bot.wait_until_guild_available()
if not self.webhook:
diff --git a/bot/exts/info/site.py b/bot/exts/info/site.py
index 2d3a3d9f3..fb5b99086 100644
--- a/bot/exts/info/site.py
+++ b/bot/exts/info/site.py
@@ -1,7 +1,7 @@
import logging
from discord import Colour, Embed
-from discord.ext.commands import Cog, Context, group
+from discord.ext.commands import Cog, Context, Greedy, group
from bot.bot import Bot
from bot.constants import URLs
@@ -105,10 +105,9 @@ class Site(Cog):
await ctx.send(embed=embed)
@site_group.command(name="rules", aliases=("r", "rule"), root_aliases=("rules", "rule"))
- async def site_rules(self, ctx: Context, *rules: int) -> None:
+ async def site_rules(self, ctx: Context, rules: Greedy[int]) -> None:
"""Provides a link to all rules or, if specified, displays specific rule(s)."""
- rules_embed = Embed(title='Rules', color=Colour.blurple())
- rules_embed.url = f"{PAGES_URL}/rules"
+ rules_embed = Embed(title='Rules', color=Colour.blurple(), url=f'{PAGES_URL}/rules')
if not rules:
# Rules were not submitted. Return the default description.
@@ -122,15 +121,13 @@ class Site(Cog):
return
full_rules = await self.bot.api_client.get('rules', params={'link_format': 'md'})
- invalid_indices = tuple(
- pick
- for pick in rules
- if pick < 1 or pick > len(full_rules)
- )
- if invalid_indices:
- indices = ', '.join(map(str, invalid_indices))
- await ctx.send(f":x: Invalid rule indices: {indices}")
+ # Remove duplicates and sort the rule indices
+ rules = sorted(set(rules))
+ invalid = ', '.join(str(index) for index in rules if index < 1 or index > len(full_rules))
+
+ if invalid:
+ await ctx.send(f":x: Invalid rule indices: {invalid}")
return
for rule in rules:
diff --git a/bot/exts/info/source.py b/bot/exts/info/source.py
index 205e0ba81..7b41352d4 100644
--- a/bot/exts/info/source.py
+++ b/bot/exts/info/source.py
@@ -2,7 +2,7 @@ import inspect
from pathlib import Path
from typing import Optional, Tuple, Union
-from discord import Embed
+from discord import Embed, utils
from discord.ext import commands
from bot.bot import Bot
@@ -35,8 +35,10 @@ class SourceConverter(commands.Converter):
elif argument.lower() in tags_cog._cache:
return argument.lower()
+ escaped_arg = utils.escape_markdown(argument)
+
raise commands.BadArgument(
- f"Unable to convert `{argument}` to valid command{', tag,' if show_tag else ''} or Cog."
+ f"Unable to convert '{escaped_arg}' to valid command{', tag,' if show_tag else ''} or Cog."
)
@@ -66,14 +68,8 @@ class BotSource(commands.Cog):
Raise BadArgument if `source_item` is a dynamically-created object (e.g. via internal eval).
"""
if isinstance(source_item, commands.Command):
- if source_item.cog_name == "Alias":
- cmd_name = source_item.callback.__name__.replace("_alias", "")
- cmd = self.bot.get_command(cmd_name.replace("_", " "))
- src = cmd.callback.__code__
- filename = src.co_filename
- else:
- src = source_item.callback.__code__
- filename = src.co_filename
+ src = source_item.callback.__code__
+ filename = src.co_filename
elif isinstance(source_item, str):
tags_cog = self.bot.get_cog("Tags")
filename = tags_cog._cache[source_item]["location"]
@@ -113,13 +109,7 @@ class BotSource(commands.Cog):
title = "Help Command"
description = source_object.__doc__.splitlines()[1]
elif isinstance(source_object, commands.Command):
- if source_object.cog_name == "Alias":
- cmd_name = source_object.callback.__name__.replace("_alias", "")
- cmd = self.bot.get_command(cmd_name.replace("_", " "))
- description = cmd.short_doc
- else:
- description = source_object.short_doc
-
+ description = source_object.short_doc
title = f"Command: {source_object.qualified_name}"
elif isinstance(source_object, str):
title = f"Tag: {source_object}"
diff --git a/bot/exts/info/stats.py b/bot/exts/info/stats.py
index d42f55466..4d8bb645e 100644
--- a/bot/exts/info/stats.py
+++ b/bot/exts/info/stats.py
@@ -1,13 +1,12 @@
import string
-from datetime import datetime
-from discord import Member, Message, Status
+from discord import Member, Message
from discord.ext.commands import Cog, Context
from discord.ext.tasks import loop
from bot.bot import Bot
-from bot.constants import Categories, Channels, Guild, Stats as StatConf
-
+from bot.constants import Categories, Channels, Guild
+from bot.utils.channel import is_in_category
CHANNEL_NAME_OVERRIDES = {
Channels.off_topic_0: "off_topic_0",
@@ -36,8 +35,7 @@ class Stats(Cog):
if message.guild.id != Guild.id:
return
- cat = getattr(message.channel, "category", None)
- if cat is not None and cat.id == Categories.modmail:
+ if is_in_category(message.channel, Categories.modmail):
if message.channel.id != Channels.incidents:
# Do not report modmail channels to stats, there are too many
# of them for interesting statistics to be drawn out of this.
@@ -79,38 +77,6 @@ class Stats(Cog):
self.bot.stats.gauge("guild.total_members", len(member.guild.members))
- @Cog.listener()
- async def on_member_update(self, _before: Member, after: Member) -> None:
- """Update presence estimates on member update."""
- if after.guild.id != Guild.id:
- return
-
- if self.last_presence_update:
- if (datetime.now() - self.last_presence_update).seconds < StatConf.presence_update_timeout:
- return
-
- self.last_presence_update = datetime.now()
-
- online = 0
- idle = 0
- dnd = 0
- offline = 0
-
- for member in after.guild.members:
- if member.status is Status.online:
- online += 1
- elif member.status is Status.dnd:
- dnd += 1
- elif member.status is Status.idle:
- idle += 1
- elif member.status is Status.offline:
- offline += 1
-
- self.bot.stats.gauge("guild.status.online", online)
- self.bot.stats.gauge("guild.status.idle", idle)
- self.bot.stats.gauge("guild.status.do_not_disturb", dnd)
- self.bot.stats.gauge("guild.status.offline", offline)
-
@loop(hours=1)
async def update_guild_boost(self) -> None:
"""Post the server boost level and tier every hour."""
diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py
index d01647312..8f15f932b 100644
--- a/bot/exts/info/tags.py
+++ b/bot/exts/info/tags.py
@@ -160,7 +160,7 @@ class Tags(Cog):
@group(name='tags', aliases=('tag', 't'), invoke_without_command=True)
async def tags_group(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None:
"""Show all known tags, a single tag, or run a subcommand."""
- await ctx.invoke(self.get_command, tag_name=tag_name)
+ await self.get_command(ctx, tag_name=tag_name)
@tags_group.group(name='search', invoke_without_command=True)
async def search_tag_content(self, ctx: Context, *, keywords: str) -> None:
@@ -236,7 +236,6 @@ class Tags(Cog):
await wait_for_deletion(
await ctx.send(embed=Embed.from_dict(tag['embed'])),
[ctx.author.id],
- self.bot
)
elif founds and len(tag_name) >= 3:
await wait_for_deletion(
@@ -247,7 +246,6 @@ class Tags(Cog):
)
),
[ctx.author.id],
- self.bot
)
else:
diff --git a/bot/exts/moderation/dm_relay.py b/bot/exts/moderation/dm_relay.py
index 14263e004..4d5142b55 100644
--- a/bot/exts/moderation/dm_relay.py
+++ b/bot/exts/moderation/dm_relay.py
@@ -90,7 +90,11 @@ class DMRelay(Cog):
# Handle any attachments
if message.attachments:
try:
- await send_attachments(message, self.webhook)
+ await send_attachments(
+ message,
+ self.webhook,
+ username=f"{message.author.display_name} ({message.author.id})"
+ )
except (discord.errors.Forbidden, discord.errors.NotFound):
e = discord.Embed(
description=":x: **This message contained an attachment, but it could not be retrieved**",
diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py
index 31be48a43..0e479d33f 100644
--- a/bot/exts/moderation/incidents.py
+++ b/bot/exts/moderation/incidents.py
@@ -319,7 +319,7 @@ class Incidents(Cog):
try:
await confirmation_task
except asyncio.TimeoutError:
- log.warning(f"Did not receive incident deletion confirmation within {timeout} seconds!")
+ log.info(f"Did not receive incident deletion confirmation within {timeout} seconds!")
else:
log.trace("Deletion was confirmed")
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 ef6f6e3c6..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:
@@ -71,6 +72,28 @@ class Infractions(InfractionScheduler, commands.Cog):
"""Permanently ban a user for the given reason and stop watching them with Big Brother."""
await self.apply_ban(ctx, user, reason)
+ @command(aliases=('pban',))
+ async def purgeban(
+ self,
+ ctx: Context,
+ user: FetchedMember,
+ purge_days: t.Optional[int] = 1,
+ *,
+ reason: t.Optional[str] = None
+ ) -> None:
+ """
+ Same as ban but removes all their messages for the given number of days, default being 1.
+
+ `purge_days` can only be values between 0 and 7.
+ Anything outside these bounds are automatically adjusted to their respective limits.
+ """
+ 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
@@ -119,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
@@ -208,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
@@ -230,7 +284,7 @@ class Infractions(InfractionScheduler, commands.Cog):
await self.apply_infraction(ctx, infraction, user, action())
- @respect_role_hierarchy()
+ @respect_role_hierarchy(member_arg=2)
async def apply_kick(self, ctx: Context, user: Member, reason: t.Optional[str], **kwargs) -> None:
"""Apply a kick infraction with kwargs passed to `post_infraction`."""
infraction = await _utils.post_infraction(ctx, user, "kick", reason, active=False, **kwargs)
@@ -245,8 +299,15 @@ class Infractions(InfractionScheduler, commands.Cog):
action = user.kick(reason=reason)
await self.apply_infraction(ctx, infraction, user, action)
- @respect_role_hierarchy()
- async def apply_ban(self, ctx: Context, user: UserSnowflake, reason: t.Optional[str], **kwargs) -> None:
+ @respect_role_hierarchy(member_arg=2)
+ async def apply_ban(
+ self,
+ ctx: Context,
+ user: UserSnowflake,
+ reason: t.Optional[str],
+ purge_days: t.Optional[int] = 0,
+ **kwargs
+ ) -> None:
"""
Apply a ban infraction with kwargs passed to `post_infraction`.
@@ -278,7 +339,7 @@ class Infractions(InfractionScheduler, commands.Cog):
if reason:
reason = textwrap.shorten(reason, width=512, placeholder="...")
- action = ctx.guild.ban(user, reason=reason, delete_message_days=0)
+ action = ctx.guild.ban(user, reason=reason, delete_message_days=purge_days)
await self.apply_infraction(ctx, infraction, user, action)
if infraction.get('expires_at') is not None:
@@ -295,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
@@ -339,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.
@@ -353,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 856a4e1a2..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__)
@@ -179,9 +179,9 @@ class ModManagement(commands.Cog):
async def infraction_search_group(self, ctx: Context, query: t.Union[UserMention, Snowflake, str]) -> None:
"""Searches for infractions in the database."""
if isinstance(query, int):
- await ctx.invoke(self.search_user, discord.Object(query))
+ await self.search_user(ctx, discord.Object(query))
else:
- await ctx.invoke(self.search_reason, query)
+ await self.search_reason(ctx, query)
@infraction_search_group.command(name="user", aliases=("member", "id"))
async def search_user(self, ctx: Context, user: t.Union[discord.User, proxy_user]) -> None:
@@ -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/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py
index eec63f5b3..adfe42fcd 100644
--- a/bot/exts/moderation/infraction/superstarify.py
+++ b/bot/exts/moderation/infraction/superstarify.py
@@ -135,7 +135,8 @@ class Superstarify(InfractionScheduler, Cog):
return
# Post the infraction to the API
- reason = reason or f"old nick: {member.display_name}"
+ old_nick = member.display_name
+ reason = reason or f"old nick: {old_nick}"
infraction = await _utils.post_infraction(ctx, member, "superstar", reason, duration, active=True)
id_ = infraction["id"]
@@ -148,7 +149,7 @@ class Superstarify(InfractionScheduler, Cog):
await member.edit(nick=forced_nick, reason=reason)
self.schedule_expiration(infraction)
- old_nick = escape_markdown(member.display_name)
+ old_nick = escape_markdown(old_nick)
forced_nick = escape_markdown(forced_nick)
# Send a DM to the user to notify them of their new infraction.
diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py
index 41ed46b69..b01de0ee3 100644
--- a/bot/exts/moderation/modlog.py
+++ b/bot/exts/moderation/modlog.py
@@ -63,7 +63,7 @@ class ModLog(Cog, name="ModLog"):
'id': message.id,
'author': message.author.id,
'channel_id': message.channel.id,
- 'content': message.content,
+ 'content': message.content.replace("\0", ""), # Null chars cause 400.
'embeds': [embed.to_dict() for embed in message.embeds],
'attachments': attachment,
}
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 6bbe81701..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
@@ -21,12 +22,15 @@ log = logging.getLogger(__name__)
# Sent via DMs once user joins the guild
ON_JOIN_MESSAGE = f"""
-Hello! Welcome to Python Discord!
+Welcome to Python Discord!
-As a new user, you have read-only access to a few select channels to give you a taste of what our server is like.
+To show you what kind of community we are, we've created this video:
+https://youtu.be/ZH26PuX3re0
-In order to see the rest of the channels and to send messages, you first have to accept our rules. To do so, \
-please visit <#{constants.Channels.verification}>. Thank you!
+As a new user, you have read-only access to a few select channels to give you a taste of what our server is like. \
+In order to see the rest of the channels and to send messages, you first have to accept our rules.
+
+Please visit <#{constants.Channels.verification}> to get started. Thank you!
"""
# Sent via DMs once user verifies
@@ -50,6 +54,23 @@ If you'd like to unsubscribe from the announcement notifications, simply send `!
<#{constants.Channels.bot_commands}>.
"""
+ALTERNATE_VERIFIED_MESSAGE = f"""
+Thanks for accepting our rules!
+
+You can find a copy of our rules for reference at <https://pythondiscord.com/pages/rules>.
+
+Additionally, if you'd like to receive notifications for the announcements \
+we post in <#{constants.Channels.announcements}>
+from time to time, you can send `!subscribe` to <#{constants.Channels.bot_commands}> at any time \
+to assign yourself the **Announcements** role. We'll mention this role every time we make an announcement.
+
+If you'd like to unsubscribe from the announcement notifications, simply send `!unsubscribe` to \
+<#{constants.Channels.bot_commands}>.
+
+To introduce you to our community, we've made the following video:
+https://youtu.be/ZH26PuX3re0
+"""
+
# Sent via DMs to users kicked for failing to verify
KICKED_MESSAGE = f"""
Hi! You have been automatically kicked from Python Discord as you have failed to accept our rules \
@@ -106,6 +127,25 @@ def is_verified(member: discord.Member) -> bool:
return len(set(member.roles) - unverified_roles) > 0
+async def safe_dm(coro: t.Coroutine) -> None:
+ """
+ Execute `coro` ignoring disabled DM warnings.
+
+ The 50_0007 error code indicates that the target user does not accept DMs.
+ As it turns out, this error code can appear on both 400 and 403 statuses,
+ we therefore catch any Discord exception.
+
+ If the request fails on any other error code, the exception propagates,
+ and must be handled by the caller.
+ """
+ try:
+ await coro
+ except discord.HTTPException as discord_exc:
+ log.trace(f"DM dispatch failed on status {discord_exc.status} with code: {discord_exc.code}")
+ if discord_exc.code != 50_007: # If any reason other than disabled DMs
+ raise
+
+
class Verification(Cog):
"""
User verification and role management.
@@ -134,6 +174,9 @@ class Verification(Cog):
# ]
task_cache = RedisCache()
+ # Create a cache for storing recipients of the alternate welcome DM.
+ member_gating_cache = RedisCache()
+
def __init__(self, bot: Bot) -> None:
"""Start internal tasks."""
self.bot = bot
@@ -313,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.
@@ -327,12 +392,11 @@ class Verification(Cog):
async def kick_request(member: discord.Member) -> None:
"""Send `KICKED_MESSAGE` to `member` and kick them from the guild."""
try:
- await member.send(KICKED_MESSAGE)
- except discord.Forbidden as exc_403:
- log.trace(f"DM dispatch failed on 403 error with code: {exc_403.code}")
- if exc_403.code != 50_007: # 403 raised for any other reason than disabled DMs
- raise StopExecution(reason=exc_403)
+ await safe_dm(member.send(KICKED_MESSAGE)) # Suppress disabled DMs
+ 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)
@@ -499,9 +563,48 @@ class Verification(Cog):
if member.guild.id != constants.Guild.id:
return # Only listen for PyDis events
+ raw_member = await self.bot.http.get_member(member.guild.id, member.id)
+
+ # If the user has the is_pending flag set, they will be using the alternate
+ # gate and will not need a welcome DM with verification instructions.
+ # We will send them an alternate DM once they verify with the welcome
+ # video.
+ if raw_member.get("is_pending"):
+ await self.member_gating_cache.set(member.id, True)
+
+ # TODO: Temporary, remove soon after asking joe.
+ await self.mod_log.send_log_message(
+ icon_url=self.bot.user.avatar_url,
+ colour=discord.Colour.blurple(),
+ title="New native gated user",
+ channel_id=constants.Channels.user_log,
+ text=f"<@{member.id}> ({member.id})",
+ )
+
+ return
+
log.trace(f"Sending on join message to new member: {member.id}")
- with suppress(discord.Forbidden):
- await member.send(ON_JOIN_MESSAGE)
+ try:
+ await safe_dm(member.send(ON_JOIN_MESSAGE))
+ except discord.HTTPException:
+ log.exception("DM dispatch failed on unexpected error code")
+
+ @Cog.listener()
+ async def on_member_update(self, before: discord.Member, after: discord.Member) -> None:
+ """Check if we need to send a verification DM to a gated user."""
+ before_roles = [role.id for role in before.roles]
+ after_roles = [role.id for role in after.roles]
+
+ if constants.Roles.verified not in before_roles and constants.Roles.verified in after_roles:
+ if await self.member_gating_cache.pop(after.id):
+ try:
+ # If the member has not received a DM from our !accept command
+ # and has gone through the alternate gating system we should send
+ # our alternate welcome DM which includes info such as our welcome
+ # video.
+ await safe_dm(after.send(ALTERNATE_VERIFIED_MESSAGE))
+ except discord.HTTPException:
+ log.exception("DM dispatch failed on unexpected error code")
@Cog.listener()
async def on_message(self, message: discord.Message) -> None:
@@ -668,9 +771,9 @@ class Verification(Cog):
await ctx.author.remove_roles(discord.Object(constants.Roles.unverified))
try:
- await ctx.author.send(VERIFIED_MESSAGE)
- except discord.Forbidden:
- log.info(f"Sending welcome message failed for {ctx.author}.")
+ await safe_dm(ctx.author.send(VERIFIED_MESSAGE))
+ except discord.HTTPException:
+ log.exception(f"Sending welcome message failed for {ctx.author}.")
finally:
log.trace(f"Deleting accept message by {ctx.author}.")
with suppress(discord.NotFound):
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/bot.py b/bot/exts/utils/bot.py
index 7ed487d47..69d623581 100644
--- a/bot/exts/utils/bot.py
+++ b/bot/exts/utils/bot.py
@@ -1,22 +1,14 @@
-import ast
import logging
-import re
-import time
-from typing import Optional, Tuple
+from typing import Optional
-from discord import Embed, Message, RawMessageUpdateEvent, TextChannel
+from discord import Embed, TextChannel
from discord.ext.commands import Cog, Context, command, group, has_any_role
from bot.bot import Bot
-from bot.constants import Categories, Channels, DEBUG_MODE, Guild, MODERATION_ROLES, Roles, URLs
-from bot.exts.filters.token_remover import TokenRemover
-from bot.exts.filters.webhook_remover import WEBHOOK_URL_RE
-from bot.utils.messages import wait_for_deletion
+from bot.constants import Guild, MODERATION_ROLES, Roles, URLs
log = logging.getLogger(__name__)
-RE_MARKDOWN = re.compile(r'([*_~`|>])')
-
class BotCog(Cog, name="Bot"):
"""Bot information commands."""
@@ -24,19 +16,6 @@ class BotCog(Cog, name="Bot"):
def __init__(self, bot: Bot):
self.bot = bot
- # Stores allowed channels plus epoch time since last call.
- self.channel_cooldowns = {
- Channels.python_discussion: 0,
- }
-
- # These channels will also work, but will not be subject to cooldown
- self.channel_whitelist = (
- Channels.bot_commands,
- )
-
- # Stores improperly formatted Python codeblock message ids and the corresponding bot message
- self.codeblock_message_ids = {}
-
@group(invoke_without_command=True, name="bot", hidden=True)
@has_any_role(Roles.verified)
async def botinfo_group(self, ctx: Context) -> None:
@@ -81,305 +60,6 @@ class BotCog(Cog, name="Bot"):
else:
await channel.send(embed=embed)
- def codeblock_stripping(self, msg: str, bad_ticks: bool) -> Optional[Tuple[Tuple[str, ...], str]]:
- """
- Strip msg in order to find Python code.
-
- Tries to strip out Python code out of msg and returns the stripped block or
- None if the block is a valid Python codeblock.
- """
- if msg.count("\n") >= 3:
- # Filtering valid Python codeblocks and exiting if a valid Python codeblock is found.
- if re.search("```(?:py|python)\n(.*?)```", msg, re.IGNORECASE | re.DOTALL) and not bad_ticks:
- log.trace(
- "Someone wrote a message that was already a "
- "valid Python syntax highlighted code block. No action taken."
- )
- return None
-
- else:
- # Stripping backticks from every line of the message.
- log.trace(f"Stripping backticks from message.\n\n{msg}\n\n")
- content = ""
- for line in msg.splitlines(keepends=True):
- content += line.strip("`")
-
- content = content.strip()
-
- # Remove "Python" or "Py" from start of the message if it exists.
- log.trace(f"Removing 'py' or 'python' from message.\n\n{content}\n\n")
- pycode = False
- if content.lower().startswith("python"):
- content = content[6:]
- pycode = True
- elif content.lower().startswith("py"):
- content = content[2:]
- pycode = True
-
- if pycode:
- content = content.splitlines(keepends=True)
-
- # Check if there might be code in the first line, and preserve it.
- first_line = content[0]
- if " " in content[0]:
- first_space = first_line.index(" ")
- content[0] = first_line[first_space:]
- content = "".join(content)
-
- # If there's no code we can just get rid of the first line.
- else:
- content = "".join(content[1:])
-
- # Strip it again to remove any leading whitespace. This is neccessary
- # if the first line of the message looked like ```python <code>
- old = content.strip()
-
- # Strips REPL code out of the message if there is any.
- content, repl_code = self.repl_stripping(old)
- if old != content:
- return (content, old), repl_code
-
- # Try to apply indentation fixes to the code.
- content = self.fix_indentation(content)
-
- # Check if the code contains backticks, if it does ignore the message.
- if "`" in content:
- log.trace("Detected ` inside the code, won't reply")
- return None
- else:
- log.trace(f"Returning message.\n\n{content}\n\n")
- return (content,), repl_code
-
- def fix_indentation(self, msg: str) -> str:
- """Attempts to fix badly indented code."""
- def unindent(code: str, skip_spaces: int = 0) -> str:
- """Unindents all code down to the number of spaces given in skip_spaces."""
- final = ""
- current = code[0]
- leading_spaces = 0
-
- # Get numbers of spaces before code in the first line.
- while current == " ":
- current = code[leading_spaces + 1]
- leading_spaces += 1
- leading_spaces -= skip_spaces
-
- # If there are any, remove that number of spaces from every line.
- if leading_spaces > 0:
- for line in code.splitlines(keepends=True):
- line = line[leading_spaces:]
- final += line
- return final
- else:
- return code
-
- # Apply fix for "all lines are overindented" case.
- msg = unindent(msg)
-
- # If the first line does not end with a colon, we can be
- # certain the next line will be on the same indentation level.
- #
- # If it does end with a colon, we will need to indent all successive
- # lines one additional level.
- first_line = msg.splitlines()[0]
- code = "".join(msg.splitlines(keepends=True)[1:])
- if not first_line.endswith(":"):
- msg = f"{first_line}\n{unindent(code)}"
- else:
- msg = f"{first_line}\n{unindent(code, 4)}"
- return msg
-
- def repl_stripping(self, msg: str) -> Tuple[str, bool]:
- """
- Strip msg in order to extract Python code out of REPL output.
-
- Tries to strip out REPL Python code out of msg and returns the stripped msg.
-
- Returns True for the boolean if REPL code was found in the input msg.
- """
- final = ""
- for line in msg.splitlines(keepends=True):
- if line.startswith(">>>") or line.startswith("..."):
- final += line[4:]
- log.trace(f"Formatted: \n\n{msg}\n\n to \n\n{final}\n\n")
- if not final:
- log.trace(f"Found no REPL code in \n\n{msg}\n\n")
- return msg, False
- else:
- log.trace(f"Found REPL code in \n\n{msg}\n\n")
- return final.rstrip(), True
-
- def has_bad_ticks(self, msg: Message) -> bool:
- """Check to see if msg contains ticks that aren't '`'."""
- not_backticks = [
- "'''", '"""', "\u00b4\u00b4\u00b4", "\u2018\u2018\u2018", "\u2019\u2019\u2019",
- "\u2032\u2032\u2032", "\u201c\u201c\u201c", "\u201d\u201d\u201d", "\u2033\u2033\u2033",
- "\u3003\u3003\u3003"
- ]
-
- return msg.content[:3] in not_backticks
-
- @Cog.listener()
- async def on_message(self, msg: Message) -> None:
- """
- Detect poorly formatted Python code in new messages.
-
- If poorly formatted code is detected, send the user a helpful message explaining how to do
- properly formatted Python syntax highlighting codeblocks.
- """
- is_help_channel = (
- getattr(msg.channel, "category", None)
- and msg.channel.category.id in (Categories.help_available, Categories.help_in_use)
- )
- parse_codeblock = (
- (
- is_help_channel
- or msg.channel.id in self.channel_cooldowns
- or msg.channel.id in self.channel_whitelist
- )
- and not msg.author.bot
- and len(msg.content.splitlines()) > 3
- and not TokenRemover.find_token_in_message(msg)
- and not WEBHOOK_URL_RE.search(msg.content)
- )
-
- if parse_codeblock: # no token in the msg
- on_cooldown = (time.time() - self.channel_cooldowns.get(msg.channel.id, 0)) < 300
- if not on_cooldown or DEBUG_MODE:
- try:
- if self.has_bad_ticks(msg):
- ticks = msg.content[:3]
- content = self.codeblock_stripping(f"```{msg.content[3:-3]}```", True)
- if content is None:
- return
-
- content, repl_code = content
-
- if len(content) == 2:
- content = content[1]
- else:
- content = content[0]
-
- space_left = 204
- if len(content) >= space_left:
- current_length = 0
- lines_walked = 0
- for line in content.splitlines(keepends=True):
- if current_length + len(line) > space_left or lines_walked == 10:
- break
- current_length += len(line)
- lines_walked += 1
- content = content[:current_length] + "#..."
- content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content)
- howto = (
- "It looks like you are trying to paste code into this channel.\n\n"
- "You seem to be using the wrong symbols to indicate where the codeblock should start. "
- f"The correct symbols would be \\`\\`\\`, not `{ticks}`.\n\n"
- "**Here is an example of how it should look:**\n"
- f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n"
- "**This will result in the following:**\n"
- f"```python\n{content}\n```"
- )
-
- else:
- howto = ""
- content = self.codeblock_stripping(msg.content, False)
- if content is None:
- return
-
- content, repl_code = content
- # Attempts to parse the message into an AST node.
- # Invalid Python code will raise a SyntaxError.
- tree = ast.parse(content[0])
-
- # Multiple lines of single words could be interpreted as expressions.
- # This check is to avoid all nodes being parsed as expressions.
- # (e.g. words over multiple lines)
- if not all(isinstance(node, ast.Expr) for node in tree.body) or repl_code:
- # Shorten the code to 10 lines and/or 204 characters.
- space_left = 204
- if content and repl_code:
- content = content[1]
- else:
- content = content[0]
-
- if len(content) >= space_left:
- current_length = 0
- lines_walked = 0
- for line in content.splitlines(keepends=True):
- if current_length + len(line) > space_left or lines_walked == 10:
- break
- current_length += len(line)
- lines_walked += 1
- content = content[:current_length] + "#..."
-
- content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content)
- howto += (
- "It looks like you're trying to paste code into this channel.\n\n"
- "Discord has support for Markdown, which allows you to post code with full "
- "syntax highlighting. Please use these whenever you paste code, as this "
- "helps improve the legibility and makes it easier for us to help you.\n\n"
- f"**To do this, use the following method:**\n"
- f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n"
- "**This will result in the following:**\n"
- f"```python\n{content}\n```"
- )
-
- log.debug(f"{msg.author} posted something that needed to be put inside python code "
- "blocks. Sending the user some instructions.")
- else:
- log.trace("The code consists only of expressions, not sending instructions")
-
- if howto != "":
- # Increase amount of codeblock correction in stats
- self.bot.stats.incr("codeblock_corrections")
- howto_embed = Embed(description=howto)
- bot_message = await msg.channel.send(f"Hey {msg.author.mention}!", embed=howto_embed)
- self.codeblock_message_ids[msg.id] = bot_message.id
-
- self.bot.loop.create_task(
- wait_for_deletion(bot_message, (msg.author.id,), self.bot)
- )
- else:
- return
-
- if msg.channel.id not in self.channel_whitelist:
- self.channel_cooldowns[msg.channel.id] = time.time()
-
- except SyntaxError:
- log.trace(
- f"{msg.author} posted in a help channel, and when we tried to parse it as Python code, "
- "ast.parse raised a SyntaxError. This probably just means it wasn't Python code. "
- f"The message that was posted was:\n\n{msg.content}\n\n"
- )
-
- @Cog.listener()
- async def on_raw_message_edit(self, payload: RawMessageUpdateEvent) -> None:
- """Check to see if an edited message (previously called out) still contains poorly formatted code."""
- if (
- # Checks to see if the message was called out by the bot
- payload.message_id not in self.codeblock_message_ids
- # Makes sure that there is content in the message
- or payload.data.get("content") is None
- # Makes sure there's a channel id in the message payload
- or payload.data.get("channel_id") is None
- ):
- return
-
- # Retrieve channel and message objects for use later
- channel = self.bot.get_channel(int(payload.data.get("channel_id")))
- user_message = await channel.fetch_message(payload.message_id)
-
- # Checks to see if the user has corrected their codeblock. If it's fixed, has_fixed_codeblock will be None
- has_fixed_codeblock = self.codeblock_stripping(payload.data.get("content"), self.has_bad_ticks(user_message))
-
- # If the message is fixed, delete the bot message and the entry from the id dictionary
- if has_fixed_codeblock is None:
- bot_message = await channel.fetch_message(self.codeblock_message_ids[payload.message_id])
- await bot_message.delete()
- del self.codeblock_message_ids[payload.message_id]
- log.trace("User's incorrect code block has been fixed. Removing bot formatting message.")
-
def setup(bot: Bot) -> None:
"""Load the Bot cog."""
diff --git a/bot/exts/utils/eval.py b/bot/exts/utils/internal.py
index 6419b320e..3521c8fd4 100644
--- a/bot/exts/utils/eval.py
+++ b/bot/exts/utils/internal.py
@@ -5,6 +5,8 @@ import pprint
import re
import textwrap
import traceback
+from collections import Counter
+from datetime import datetime
from io import StringIO
from typing import Any, Optional, Tuple
@@ -19,8 +21,8 @@ from bot.utils import find_nth_occurrence, send_to_paste_service
log = logging.getLogger(__name__)
-class CodeEval(Cog):
- """Owner and admin feature that evaluates code and returns the result to the channel."""
+class Internal(Cog):
+ """Administrator and Core Developer commands."""
def __init__(self, bot: Bot):
self.bot = bot
@@ -28,7 +30,18 @@ class CodeEval(Cog):
self.ln = 0
self.stdout = StringIO()
- self.interpreter = Interpreter(bot)
+ self.interpreter = Interpreter()
+
+ self.socket_since = datetime.utcnow()
+ self.socket_event_total = 0
+ self.socket_events = Counter()
+
+ @Cog.listener()
+ async def on_socket_response(self, msg: dict) -> None:
+ """When a websocket event is received, increase our counters."""
+ if event_type := msg.get("t"):
+ self.socket_event_total += 1
+ self.socket_events[event_type] += 1
def _format(self, inp: str, out: Any) -> Tuple[str, Optional[discord.Embed]]:
"""Format the eval output into a string & attempt to format it into an Embed."""
@@ -182,7 +195,7 @@ async def func(): # (None,) -> Any
truncate_index = newline_truncate_index
if len(out) > truncate_index:
- paste_link = await send_to_paste_service(self.bot.http_session, out, extension="py")
+ paste_link = await send_to_paste_service(out, extension="py")
if paste_link is not None:
paste_text = f"full contents at {paste_link}"
else:
@@ -198,7 +211,7 @@ async def func(): # (None,) -> Any
await ctx.send(f"```py\n{out}```", embed=embed)
@group(name='internal', aliases=('int',))
- @has_any_role(Roles.owners, Roles.admins)
+ @has_any_role(Roles.owners, Roles.admins, Roles.core_developers)
async def internal_group(self, ctx: Context) -> None:
"""Internal commands. Top secret!"""
if not ctx.invoked_subcommand:
@@ -220,7 +233,26 @@ async def func(): # (None,) -> Any
await self._eval(ctx, code)
+ @internal_group.command(name='socketstats', aliases=('socket', 'stats'))
+ @has_any_role(Roles.admins, Roles.owners, Roles.core_developers)
+ async def socketstats(self, ctx: Context) -> None:
+ """Fetch information on the socket events received from Discord."""
+ running_s = (datetime.utcnow() - self.socket_since).total_seconds()
+
+ per_s = self.socket_event_total / running_s
+
+ stats_embed = discord.Embed(
+ title="WebSocket statistics",
+ description=f"Receiving {per_s:0.2f} event per second.",
+ color=discord.Color.blurple()
+ )
+
+ for event_type, count in self.socket_events.most_common(25):
+ stats_embed.add_field(name=event_type, value=count, inline=False)
+
+ await ctx.send(embed=stats_embed)
+
def setup(bot: Bot) -> None:
- """Load the CodeEval cog."""
- bot.add_cog(CodeEval(bot))
+ """Load the Internal cog."""
+ bot.add_cog(Internal(bot))
diff --git a/bot/exts/utils/ping.py b/bot/exts/utils/ping.py
index a9ca3dbeb..572fc934b 100644
--- a/bot/exts/utils/ping.py
+++ b/bot/exts/utils/ping.py
@@ -33,7 +33,7 @@ class Latency(commands.Cog):
"""
# datetime.datetime objects do not have the "milliseconds" attribute.
# It must be converted to seconds before converting to milliseconds.
- bot_ping = (datetime.utcnow() - ctx.message.created_at).total_seconds() / 1000
+ bot_ping = (datetime.utcnow() - ctx.message.created_at).total_seconds() * 1000
bot_ping = f"{bot_ping:.{ROUND_LATENCY}f} ms"
try:
diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py
index 6806f2889..3113a1149 100644
--- a/bot/exts/utils/reminders.py
+++ b/bot/exts/utils/reminders.py
@@ -16,12 +16,14 @@ from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, Role
from bot.converters import Duration
from bot.pagination import LinePaginator
from bot.utils.checks import has_any_role_check, has_no_roles_check
+from bot.utils.lock import lock_arg
from bot.utils.messages import send_denial
from bot.utils.scheduling import Scheduler
from bot.utils.time import humanize_delta
log = logging.getLogger(__name__)
+LOCK_NAMESPACE = "reminder"
WHITELISTED_CHANNELS = Guild.reminder_whitelist
MAXIMUM_REMINDERS = 5
@@ -52,7 +54,7 @@ class Reminders(Cog):
now = datetime.utcnow()
for reminder in response:
- is_valid, *_ = self.ensure_valid_reminder(reminder, cancel_task=False)
+ is_valid, *_ = self.ensure_valid_reminder(reminder)
if not is_valid:
continue
@@ -65,11 +67,7 @@ class Reminders(Cog):
else:
self.schedule_reminder(reminder)
- def ensure_valid_reminder(
- self,
- reminder: dict,
- cancel_task: bool = True
- ) -> t.Tuple[bool, discord.User, discord.TextChannel]:
+ def ensure_valid_reminder(self, reminder: dict) -> t.Tuple[bool, discord.User, discord.TextChannel]:
"""Ensure reminder author and channel can be fetched otherwise delete the reminder."""
user = self.bot.get_user(reminder['author'])
channel = self.bot.get_channel(reminder['channel_id'])
@@ -80,7 +78,7 @@ class Reminders(Cog):
f"Reminder {reminder['id']} invalid: "
f"User {reminder['author']}={user}, Channel {reminder['channel_id']}={channel}."
)
- asyncio.create_task(self._delete_reminder(reminder['id'], cancel_task))
+ asyncio.create_task(self.bot.api_client.delete(f"bot/reminders/{reminder['id']}"))
return is_valid, user, channel
@@ -88,7 +86,7 @@ class Reminders(Cog):
async def _send_confirmation(
ctx: Context,
on_success: str,
- reminder_id: str,
+ reminder_id: t.Union[str, int],
delivery_dt: t.Optional[datetime],
) -> None:
"""Send an embed confirming the reminder change was made successfully."""
@@ -148,24 +146,8 @@ class Reminders(Cog):
def schedule_reminder(self, reminder: dict) -> None:
"""A coroutine which sends the reminder once the time is reached, and cancels the running task."""
- reminder_id = reminder["id"]
reminder_datetime = isoparse(reminder['expiration']).replace(tzinfo=None)
-
- async def _remind() -> None:
- await self.send_reminder(reminder)
-
- log.debug(f"Deleting reminder {reminder_id} (the user has been reminded).")
- await self._delete_reminder(reminder_id)
-
- self.scheduler.schedule_at(reminder_datetime, reminder_id, _remind())
-
- async def _delete_reminder(self, reminder_id: str, cancel_task: bool = True) -> None:
- """Delete a reminder from the database, given its ID, and cancel the running task."""
- await self.bot.api_client.delete('bot/reminders/' + str(reminder_id))
-
- if cancel_task:
- # Now we can remove it from the schedule list
- self.scheduler.cancel(reminder_id)
+ self.scheduler.schedule_at(reminder_datetime, reminder["id"], self.send_reminder(reminder))
async def _edit_reminder(self, reminder_id: int, payload: dict) -> dict:
"""
@@ -188,10 +170,12 @@ class Reminders(Cog):
log.trace(f"Scheduling new task #{reminder['id']}")
self.schedule_reminder(reminder)
+ @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)
if not is_valid:
+ # No need to cancel the task too; it'll simply be done once this coroutine returns.
return
embed = discord.Embed()
@@ -217,18 +201,17 @@ class Reminders(Cog):
mentionable.mention for mentionable in self.get_mentionables(reminder["mentions"])
)
- await channel.send(
- content=f"{user.mention} {additional_mentions}",
- embed=embed
- )
- await self._delete_reminder(reminder["id"])
+ await channel.send(content=f"{user.mention} {additional_mentions}", embed=embed)
+
+ log.debug(f"Deleting reminder #{reminder['id']} (the user has been reminded).")
+ await self.bot.api_client.delete(f"bot/reminders/{reminder['id']}")
@group(name="remind", aliases=("reminder", "reminders", "remindme"), invoke_without_command=True)
async def remind_group(
self, ctx: Context, mentions: Greedy[Mentionable], expiration: Duration, *, content: str
) -> None:
"""Commands for managing your reminders."""
- await ctx.invoke(self.new_reminder, mentions=mentions, expiration=expiration, content=content)
+ await self.new_reminder(ctx, mentions=mentions, expiration=expiration, content=content)
@remind_group.command(name="new", aliases=("add", "create"))
async def new_reminder(
@@ -286,10 +269,11 @@ class Reminders(Cog):
now = datetime.utcnow() - timedelta(seconds=1)
humanized_delta = humanize_delta(relativedelta(expiration, now))
- mention_string = (
- f"Your reminder will arrive in {humanized_delta} "
- f"and will mention {len(mentions)} other(s)!"
- )
+ mention_string = f"Your reminder will arrive in {humanized_delta}"
+
+ if mentions:
+ mention_string += f" and will mention {len(mentions)} other(s)"
+ mention_string += "!"
# Confirm to the user that it worked.
await self._send_confirmation(
@@ -394,6 +378,7 @@ class Reminders(Cog):
mention_ids = [mention.id for mention in mentions]
await self.edit_reminder(ctx, id_, {"mentions": mention_ids})
+ @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_):
@@ -413,11 +398,15 @@ class Reminders(Cog):
await self._reschedule_reminder(reminder)
@remind_group.command("delete", aliases=("remove", "cancel"))
+ @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_):
return
- await self._delete_reminder(id_)
+
+ await self.bot.api_client.delete(f"bot/reminders/{id_}")
+ self.scheduler.cancel(id_)
+
await self._send_confirmation(
ctx,
on_success="That reminder has been deleted successfully!",
diff --git a/bot/exts/utils/snekbox.py b/bot/exts/utils/snekbox.py
index b3baffba2..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(
@@ -38,11 +36,11 @@ RAW_CODE_REGEX = re.compile(
re.DOTALL # "." also matches newlines
)
-MAX_PASTE_LEN = 1000
+MAX_PASTE_LEN = 10000
# `!eval` command whitelists
EVAL_CHANNELS = (Channels.bot_commands, Channels.esoteric)
-EVAL_CATEGORIES = (Categories.help_available, Categories.help_in_use)
+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
@@ -72,27 +70,36 @@ class Snekbox(Cog):
if len(output) > MAX_PASTE_LEN:
log.info("Full output is too long to upload")
return "too long to upload"
- return await send_to_paste_service(self.bot.http_session, output, extension="txt")
+ return await send_to_paste_service(output, extension="txt")
@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
@@ -212,7 +219,7 @@ class Snekbox(Cog):
response = await ctx.send("Attempt to circumvent filter detected. Moderator team has been alerted.")
else:
response = await ctx.send(msg)
- self.bot.loop.create_task(wait_for_deletion(response, (ctx.author.id,), ctx.bot))
+ self.bot.loop.create_task(wait_for_deletion(response, (ctx.author.id,)))
log.info(f"{ctx.author}'s job had a return code of {results['returncode']}")
return response
@@ -241,12 +248,12 @@ class Snekbox(Cog):
)
code = await self.get_code(new_message)
- await ctx.message.clear_reactions()
+ await ctx.message.clear_reaction(REEVAL_EMOJI)
with contextlib.suppress(HTTPException):
await response.delete()
except asyncio.TimeoutError:
- await ctx.message.clear_reactions()
+ await ctx.message.clear_reaction(REEVAL_EMOJI)
return None
return code
diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py
index 6b6941064..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']}**",
- description=f"[Link]({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,18 +189,125 @@ 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)**",
- description="[Link](https://www.python.org/dev/peps/)"
+ url="https://www.python.org/dev/peps/"
)
pep_embed.set_thumbnail(url=ICON_URL)
pep_embed.add_field(name="Status", value="Active")
pep_embed.add_field(name="Created", value="13-Jul-2000")
pep_embed.add_field(name="Type", value="Informational")
- await ctx.send(embed=pep_embed)
+ 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/interpreter.py b/bot/interpreter.py
index 8b7268746..b58f7a6b0 100644
--- a/bot/interpreter.py
+++ b/bot/interpreter.py
@@ -4,7 +4,7 @@ from typing import Any
from discord.ext.commands import Context
-from bot.bot import Bot
+import bot
CODE_TEMPLATE = """
async def _func():
@@ -21,8 +21,8 @@ class Interpreter(InteractiveInterpreter):
write_callable = None
- def __init__(self, bot: Bot):
- locals_ = {"bot": bot}
+ def __init__(self):
+ locals_ = {"bot": bot.instance}
super().__init__(locals_)
async def run(self, code: str, ctx: Context, io: StringIO, *args, **kwargs) -> Any:
diff --git a/bot/log.py b/bot/log.py
new file mode 100644
index 000000000..13141de40
--- /dev/null
+++ b/bot/log.py
@@ -0,0 +1,86 @@
+import logging
+import os
+import sys
+from logging import Logger, handlers
+from pathlib import Path
+
+import coloredlogs
+import sentry_sdk
+from sentry_sdk.integrations.aiohttp import AioHttpIntegration
+from sentry_sdk.integrations.logging import LoggingIntegration
+from sentry_sdk.integrations.redis import RedisIntegration
+
+from bot import constants
+
+TRACE_LEVEL = 5
+
+
+def setup() -> None:
+ """Set up loggers."""
+ logging.TRACE = TRACE_LEVEL
+ logging.addLevelName(TRACE_LEVEL, "TRACE")
+ Logger.trace = _monkeypatch_trace
+
+ log_level = TRACE_LEVEL if constants.DEBUG_MODE else logging.INFO
+ format_string = "%(asctime)s | %(name)s | %(levelname)s | %(message)s"
+ log_format = logging.Formatter(format_string)
+
+ log_file = Path("logs", "bot.log")
+ log_file.parent.mkdir(exist_ok=True)
+ file_handler = handlers.RotatingFileHandler(log_file, maxBytes=5242880, backupCount=7, encoding="utf8")
+ file_handler.setFormatter(log_format)
+
+ root_log = logging.getLogger()
+ root_log.setLevel(log_level)
+ root_log.addHandler(file_handler)
+
+ if "COLOREDLOGS_LEVEL_STYLES" not in os.environ:
+ coloredlogs.DEFAULT_LEVEL_STYLES = {
+ **coloredlogs.DEFAULT_LEVEL_STYLES,
+ "trace": {"color": 246},
+ "critical": {"background": "red"},
+ "debug": coloredlogs.DEFAULT_LEVEL_STYLES["info"]
+ }
+
+ if "COLOREDLOGS_LOG_FORMAT" not in os.environ:
+ coloredlogs.DEFAULT_LOG_FORMAT = format_string
+
+ if "COLOREDLOGS_LOG_LEVEL" not in os.environ:
+ coloredlogs.DEFAULT_LOG_LEVEL = log_level
+
+ coloredlogs.install(logger=root_log, stream=sys.stdout)
+
+ logging.getLogger("discord").setLevel(logging.WARNING)
+ logging.getLogger("websockets").setLevel(logging.WARNING)
+ logging.getLogger("chardet").setLevel(logging.WARNING)
+ logging.getLogger("async_rediscache").setLevel(logging.WARNING)
+
+
+def setup_sentry() -> None:
+ """Set up the Sentry logging integrations."""
+ sentry_logging = LoggingIntegration(
+ level=logging.DEBUG,
+ event_level=logging.WARNING
+ )
+
+ sentry_sdk.init(
+ dsn=constants.Bot.sentry_dsn,
+ integrations=[
+ sentry_logging,
+ AioHttpIntegration(),
+ RedisIntegration(),
+ ]
+ )
+
+
+def _monkeypatch_trace(self: logging.Logger, msg: str, *args, **kwargs) -> None:
+ """
+ Log 'msg % args' with severity 'TRACE'.
+
+ To pass exception information, use the keyword argument exc_info with
+ a true value, e.g.
+
+ logger.trace("Houston, we have an %s", "interesting problem", exc_info=1)
+ """
+ if self.isEnabledFor(TRACE_LEVEL):
+ self._log(TRACE_LEVEL, msg, args, **kwargs)
diff --git a/bot/patches/__init__.py b/bot/patches/__init__.py
deleted file mode 100644
index 60f6becaa..000000000
--- a/bot/patches/__init__.py
+++ /dev/null
@@ -1,6 +0,0 @@
-"""Subpackage that contains patches for discord.py."""
-from . import message_edited_at
-
-__all__ = [
- message_edited_at,
-]
diff --git a/bot/patches/message_edited_at.py b/bot/patches/message_edited_at.py
deleted file mode 100644
index a0154f12d..000000000
--- a/bot/patches/message_edited_at.py
+++ /dev/null
@@ -1,32 +0,0 @@
-"""
-# message_edited_at patch.
-
-Date: 2019-09-16
-Author: Scragly
-Added by: Ves Zappa
-
-Due to a bug in our current version of discord.py (1.2.3), the edited_at timestamp of
-`discord.Messages` are not being handled correctly. This patch fixes that until a new
-release of discord.py is released (and we've updated to it).
-"""
-import logging
-
-from discord import message, utils
-
-log = logging.getLogger(__name__)
-
-
-def _handle_edited_timestamp(self: message.Message, value: str) -> None:
- """Helper function that takes care of parsing the edited timestamp."""
- self._edited_timestamp = utils.parse_time(value)
-
-
-def apply_patch() -> None:
- """Applies the `edited_at` patch to the `discord.message.Message` class."""
- message.Message._handle_edited_timestamp = _handle_edited_timestamp
- message.Message._HANDLERS['edited_timestamp'] = message.Message._handle_edited_timestamp
- log.info("Patch applied: message_edited_at")
-
-
-if __name__ == "__main__":
- apply_patch()
diff --git a/bot/resources/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/rules/discord_emojis.py b/bot/rules/discord_emojis.py
index 6e47f0197..41faf7ee8 100644
--- a/bot/rules/discord_emojis.py
+++ b/bot/rules/discord_emojis.py
@@ -2,16 +2,17 @@ import re
from typing import Dict, Iterable, List, Optional, Tuple
from discord import Member, Message
+from emoji import demojize
-DISCORD_EMOJI_RE = re.compile(r"<:\w+:\d+>")
+DISCORD_EMOJI_RE = re.compile(r"<:\w+:\d+>|:\w+:")
CODE_BLOCK_RE = re.compile(r"```.*?```", flags=re.DOTALL)
async def apply(
last_message: Message, recent_messages: List[Message], config: Dict[str, int]
) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]:
- """Detects total Discord emojis (excluding Unicode emojis) exceeding the limit sent by a single user."""
+ """Detects total Discord emojis exceeding the limit sent by a single user."""
relevant_messages = tuple(
msg
for msg in recent_messages
@@ -19,8 +20,9 @@ async def apply(
)
# Get rid of code blocks in the message before searching for emojis.
+ # Convert Unicode emojis to :emoji: format to get their count.
total_emojis = sum(
- len(DISCORD_EMOJI_RE.findall(CODE_BLOCK_RE.sub("", msg.content)))
+ len(DISCORD_EMOJI_RE.findall(demojize(CODE_BLOCK_RE.sub("", msg.content))))
for msg in relevant_messages
)
diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py
index 60170a88f..13533a467 100644
--- a/bot/utils/__init__.py
+++ b/bot/utils/__init__.py
@@ -1,4 +1,4 @@
-from bot.utils.helpers import CogABCMeta, find_nth_occurrence, pad_base64
+from bot.utils.helpers import CogABCMeta, find_nth_occurrence, has_lines, pad_base64
from bot.utils.services import send_to_paste_service
-__all__ = ['CogABCMeta', 'find_nth_occurrence', 'pad_base64', 'send_to_paste_service']
+__all__ = ['CogABCMeta', 'find_nth_occurrence', 'has_lines', 'pad_base64', 'send_to_paste_service']
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
new file mode 100644
index 000000000..0c072184c
--- /dev/null
+++ b/bot/utils/channel.py
@@ -0,0 +1,50 @@
+import logging
+
+import discord
+
+import bot
+from bot import constants
+from bot.constants import Categories
+
+log = logging.getLogger(__name__)
+
+
+def is_help_channel(channel: discord.TextChannel) -> bool:
+ """Return True if `channel` is in one of the help categories (excluding dormant)."""
+ log.trace(f"Checking if #{channel} is a help channel.")
+ categories = (Categories.help_available, Categories.help_in_use)
+
+ 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
+
+
+async def try_get_channel(channel_id: int) -> discord.abc.GuildChannel:
+ """Attempt to get or fetch a channel and return it."""
+ log.trace(f"Getting the channel {channel_id}.")
+
+ channel = bot.instance.get_channel(channel_id)
+ if not channel:
+ log.debug(f"Channel {channel_id} is not in cache; fetching from API.")
+ channel = await bot.instance.fetch_channel(channel_id)
+
+ log.trace(f"Channel #{channel} ({channel_id}) retrieved.")
+ return channel
diff --git a/bot/utils/function.py b/bot/utils/function.py
new file mode 100644
index 000000000..3ab32fe3c
--- /dev/null
+++ b/bot/utils/function.py
@@ -0,0 +1,75 @@
+"""Utilities for interaction with functions."""
+
+import inspect
+import typing as t
+
+Argument = t.Union[int, str]
+BoundArgs = t.OrderedDict[str, t.Any]
+Decorator = t.Callable[[t.Callable], t.Callable]
+ArgValGetter = t.Callable[[BoundArgs], t.Any]
+
+
+def get_arg_value(name_or_pos: Argument, arguments: BoundArgs) -> t.Any:
+ """
+ Return a value from `arguments` based on a name or position.
+
+ `arguments` is an ordered mapping of parameter names to argument values.
+
+ Raise TypeError if `name_or_pos` isn't a str or int.
+ Raise ValueError if `name_or_pos` does not match any argument.
+ """
+ if isinstance(name_or_pos, int):
+ # Convert arguments to a tuple to make them indexable.
+ arg_values = tuple(arguments.items())
+ arg_pos = name_or_pos
+
+ try:
+ name, value = arg_values[arg_pos]
+ return value
+ except IndexError:
+ raise ValueError(f"Argument position {arg_pos} is out of bounds.")
+ elif isinstance(name_or_pos, str):
+ arg_name = name_or_pos
+ try:
+ return arguments[arg_name]
+ except KeyError:
+ raise ValueError(f"Argument {arg_name!r} doesn't exist.")
+ else:
+ raise TypeError("'arg' must either be an int (positional index) or a str (keyword).")
+
+
+def get_arg_value_wrapper(
+ decorator_func: t.Callable[[ArgValGetter], Decorator],
+ name_or_pos: Argument,
+ func: t.Callable[[t.Any], t.Any] = None,
+) -> Decorator:
+ """
+ Call `decorator_func` with the value of the arg at the given name/position.
+
+ `decorator_func` must accept a callable as a parameter to which it will pass a mapping of
+ parameter names to argument values of the function it's decorating.
+
+ `func` is an optional callable which will return a new value given the argument's value.
+
+ Return the decorator returned by `decorator_func`.
+ """
+ def wrapper(args: BoundArgs) -> t.Any:
+ value = get_arg_value(name_or_pos, args)
+ if func:
+ value = func(value)
+ return value
+
+ return decorator_func(wrapper)
+
+
+def get_bound_args(func: t.Callable, args: t.Tuple, kwargs: t.Dict[str, t.Any]) -> BoundArgs:
+ """
+ Bind `args` and `kwargs` to `func` and return a mapping of parameter names to argument values.
+
+ Default parameter values are also set.
+ """
+ sig = inspect.signature(func)
+ bound_args = sig.bind(*args, **kwargs)
+ bound_args.apply_defaults()
+
+ return bound_args.arguments
diff --git a/bot/utils/helpers.py b/bot/utils/helpers.py
index d9b60af07..3501a3933 100644
--- a/bot/utils/helpers.py
+++ b/bot/utils/helpers.py
@@ -18,6 +18,15 @@ def find_nth_occurrence(string: str, substring: str, n: int) -> Optional[int]:
return index
+def has_lines(string: str, count: int) -> bool:
+ """Return True if `string` has at least `count` lines."""
+ # Benchmarks show this is significantly faster than using str.count("\n") or a for loop & break.
+ split = string.split("\n", count - 1)
+
+ # Make sure the last part isn't empty, which would happen if there was a final newline.
+ return split[-1] and len(split) == count
+
+
def pad_base64(data: str) -> str:
"""Return base64 `data` with padding characters to ensure its length is a multiple of 4."""
return data + "=" * (-len(data) % 4)
diff --git a/bot/utils/lock.py b/bot/utils/lock.py
new file mode 100644
index 000000000..7aaafbc88
--- /dev/null
+++ b/bot/utils/lock.py
@@ -0,0 +1,114 @@
+import inspect
+import logging
+from collections import defaultdict
+from functools import partial, wraps
+from typing import Any, Awaitable, Callable, Hashable, Union
+from weakref import WeakValueDictionary
+
+from bot.errors import LockedResourceError
+from bot.utils import function
+
+log = logging.getLogger(__name__)
+__lock_dicts = defaultdict(WeakValueDictionary)
+
+_IdCallableReturn = Union[Hashable, Awaitable[Hashable]]
+_IdCallable = Callable[[function.BoundArgs], _IdCallableReturn]
+ResourceId = Union[Hashable, _IdCallable]
+
+
+class LockGuard:
+ """
+ A context manager which acquires and releases a lock (mutex).
+
+ Raise RuntimeError if trying to acquire a locked lock.
+ """
+
+ def __init__(self):
+ self._locked = False
+
+ @property
+ def locked(self) -> bool:
+ """Return True if currently locked or False if unlocked."""
+ return self._locked
+
+ def __enter__(self):
+ if self._locked:
+ raise RuntimeError("Cannot acquire a locked lock.")
+
+ self._locked = True
+
+ def __exit__(self, _exc_type, _exc_value, _traceback): # noqa: ANN001
+ self._locked = False
+ return False # Indicate any raised exception shouldn't be suppressed.
+
+
+def lock(namespace: Hashable, resource_id: ResourceId, *, raise_error: bool = False) -> Callable:
+ """
+ Turn the decorated coroutine function into a mutually exclusive operation on a `resource_id`.
+
+ If any other mutually exclusive function currently holds the lock for a resource, do not run the
+ decorated function and return None. If `raise_error` is True, raise `LockedResourceError` if
+ the lock cannot be acquired.
+
+ `namespace` is an identifier used to prevent collisions among resource IDs.
+
+ `resource_id` identifies a resource on which to perform a mutually exclusive operation.
+ It may also be a callable or awaitable which will return the resource ID given an ordered
+ mapping of the parameters' names to arguments' values.
+
+ If decorating a command, this decorator must go before (below) the `command` decorator.
+ """
+ def decorator(func: Callable) -> Callable:
+ name = func.__name__
+
+ @wraps(func)
+ async def wrapper(*args, **kwargs) -> Any:
+ log.trace(f"{name}: mutually exclusive decorator called")
+
+ if callable(resource_id):
+ log.trace(f"{name}: binding args to signature")
+ bound_args = function.get_bound_args(func, args, kwargs)
+
+ log.trace(f"{name}: calling the given callable to get the resource ID")
+ id_ = resource_id(bound_args)
+
+ if inspect.isawaitable(id_):
+ log.trace(f"{name}: awaiting to get resource ID")
+ id_ = await id_
+ else:
+ id_ = resource_id
+
+ log.trace(f"{name}: getting lock for resource {id_!r} under namespace {namespace!r}")
+
+ # Get the lock for the ID. Create a lock if one doesn't exist yet.
+ locks = __lock_dicts[namespace]
+ lock_guard = locks.setdefault(id_, LockGuard())
+
+ if not lock_guard.locked:
+ log.debug(f"{name}: resource {namespace!r}:{id_!r} is free; acquiring it...")
+ with lock_guard:
+ return await func(*args, **kwargs)
+ else:
+ log.info(f"{name}: aborted because resource {namespace!r}:{id_!r} is locked")
+ if raise_error:
+ raise LockedResourceError(str(namespace), id_)
+
+ return wrapper
+ return decorator
+
+
+def lock_arg(
+ namespace: Hashable,
+ name_or_pos: function.Argument,
+ func: Callable[[Any], _IdCallableReturn] = None,
+ *,
+ raise_error: bool = False,
+) -> Callable:
+ """
+ Apply the `lock` decorator using the value of the arg at the given name/position as the ID.
+
+ `func` is an optional callable or awaitable which will return the ID given the argument value.
+ See `lock` docs for more information.
+ """
+ decorator_func = partial(lock, namespace, raise_error=raise_error)
+ return function.get_arg_value_wrapper(decorator_func, name_or_pos, func)
diff --git a/bot/utils/messages.py b/bot/utils/messages.py
index 9cc0d8a34..42bde358d 100644
--- a/bot/utils/messages.py
+++ b/bot/utils/messages.py
@@ -10,6 +10,7 @@ import discord
from discord.errors import HTTPException
from discord.ext.commands import Context
+import bot
from bot.constants import Emojis, NEGATIVE_REPLIES
log = logging.getLogger(__name__)
@@ -18,7 +19,6 @@ log = logging.getLogger(__name__)
async def wait_for_deletion(
message: discord.Message,
user_ids: Sequence[discord.abc.Snowflake],
- client: discord.Client,
deletion_emojis: Sequence[str] = (Emojis.trashcan,),
timeout: float = 60 * 5,
attach_emojis: bool = True,
@@ -34,7 +34,11 @@ async def wait_for_deletion(
if attach_emojis:
for emoji in deletion_emojis:
- await message.add_reaction(emoji)
+ try:
+ await message.add_reaction(emoji)
+ except discord.NotFound:
+ log.trace(f"Aborting wait_for_deletion: message {message.id} deleted prematurely.")
+ return
def check(reaction: discord.Reaction, user: discord.Member) -> bool:
"""Check that the deletion emoji is reacted by the appropriate user."""
@@ -45,22 +49,31 @@ async def wait_for_deletion(
)
with contextlib.suppress(asyncio.TimeoutError):
- await client.wait_for('reaction_add', check=check, timeout=timeout)
+ await bot.instance.wait_for('reaction_add', check=check, timeout=timeout)
await message.delete()
async def send_attachments(
message: discord.Message,
destination: Union[discord.TextChannel, discord.Webhook],
- link_large: bool = True
+ link_large: bool = True,
+ use_cached: bool = False,
+ **kwargs
) -> List[str]:
"""
Re-upload the message's attachments to the destination and return a list of their new URLs.
Each attachment is sent as a separate message to more easily comply with the request/file size
limit. If link_large is True, attachments which are too large are instead grouped into a single
- embed which links to them.
+ embed which links to them. Extra kwargs will be passed to send() when sending the attachment.
"""
+ webhook_send_kwargs = {
+ 'username': message.author.display_name,
+ 'avatar_url': message.author.avatar_url,
+ }
+ webhook_send_kwargs.update(kwargs)
+ webhook_send_kwargs['username'] = sub_clyde(webhook_send_kwargs['username'])
+
large = []
urls = []
for attachment in message.attachments:
@@ -74,18 +87,14 @@ async def send_attachments(
# but some may get through hence the try-catch.
if attachment.size <= destination.guild.filesize_limit - 512:
with BytesIO() as file:
- await attachment.save(file, use_cached=True)
+ await attachment.save(file, use_cached=use_cached)
attachment_file = discord.File(file, filename=attachment.filename)
if isinstance(destination, discord.TextChannel):
- msg = await destination.send(file=attachment_file)
+ msg = await destination.send(file=attachment_file, **kwargs)
urls.append(msg.attachments[0].url)
else:
- await destination.send(
- file=attachment_file,
- username=sub_clyde(message.author.display_name),
- avatar_url=message.author.avatar_url
- )
+ await destination.send(file=attachment_file, **webhook_send_kwargs)
elif link_large:
large.append(attachment)
else:
@@ -102,13 +111,9 @@ async def send_attachments(
embed.set_footer(text="Attachments exceed upload size limit.")
if isinstance(destination, discord.TextChannel):
- await destination.send(embed=embed)
+ await destination.send(embed=embed, **kwargs)
else:
- await destination.send(
- embed=embed,
- username=sub_clyde(message.author.display_name),
- avatar_url=message.author.avatar_url
- )
+ await destination.send(embed=embed, **webhook_send_kwargs)
return urls
diff --git a/bot/utils/services.py b/bot/utils/services.py
index 087b9f969..5949c9e48 100644
--- a/bot/utils/services.py
+++ b/bot/utils/services.py
@@ -1,8 +1,9 @@
import logging
from typing import Optional
-from aiohttp import ClientConnectorError, ClientSession
+from aiohttp import ClientConnectorError
+import bot
from bot.constants import URLs
log = logging.getLogger(__name__)
@@ -10,11 +11,10 @@ log = logging.getLogger(__name__)
FAILED_REQUEST_ATTEMPTS = 3
-async def send_to_paste_service(http_session: ClientSession, contents: str, *, extension: str = "") -> Optional[str]:
+async def send_to_paste_service(contents: str, *, extension: str = "") -> Optional[str]:
"""
Upload `contents` to the paste service.
- `http_session` should be the current running ClientSession from aiohttp
`extension` is added to the output URL
When an error occurs, `None` is returned, otherwise the generated URL with the suffix.
@@ -24,7 +24,7 @@ async def send_to_paste_service(http_session: ClientSession, contents: str, *, e
paste_url = URLs.paste_service.format(key="documents")
for attempt in range(1, FAILED_REQUEST_ATTEMPTS + 1):
try:
- async with http_session.post(paste_url, data=contents) as response:
+ async with bot.instance.http_session.post(paste_url, data=contents) as response:
response_json = await response.json()
except ClientConnectorError:
log.warning(
diff --git a/config-default.yml b/config-default.yml
index e7669e6db..60eb437af 100644
--- a/config-default.yml
+++ b/config-default.yml
@@ -4,13 +4,13 @@ bot:
sentry_dsn: !ENV "BOT_SENTRY_DSN"
redis:
- host: "redis"
+ host: "redis.default.svc.cluster.local"
port: 6379
password: !ENV "REDIS_PASSWORD"
use_fakeredis: false
stats:
- statsd_host: "graphite"
+ statsd_host: "graphite.default.svc.cluster.local"
presence_update_timeout: 300
cooldowns:
@@ -27,6 +27,7 @@ style:
soft_red: 0xcd6d6d
soft_green: 0x68c290
soft_orange: 0xf9cb54
+ bright_green: 0x01d277
emojis:
defcon_disabled: "<:defcondisabled:470326273952972810>"
@@ -119,6 +120,9 @@ style:
voice_state_green: "https://cdn.discordapp.com/emojis/656899770094452754.png"
voice_state_red: "https://cdn.discordapp.com/emojis/656899769905709076.png"
+ green_checkmark: "https://raw.githubusercontent.com/python-discord/branding/master/icons/checkmark/green-checkmark-dist.png"
+
+
guild:
id: 267624335836053506
invite: "https://discord.gg/python"
@@ -127,7 +131,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
@@ -145,8 +151,8 @@ guild:
dev_log: &DEV_LOG 622895325144940554
# Discussion
- meta: 429409067623251969
- python_discussion: 267624335836053506
+ meta: 429409067623251969
+ python_discussion: &PY_DISCUSSION 267624335836053506
# Python Help: Available
how_to_get_help: 704250143020417084
@@ -169,6 +175,7 @@ guild:
bot_commands: &BOT_CMD 267659945086812160
esoteric: 470884583684964352
verification: 352442727016693763
+ voice_gate: 764802555427029012
# Staff
admins: &ADMINS 365960823622991872
@@ -178,7 +185,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
@@ -190,6 +197,9 @@ guild:
admin_announcements: &ADMIN_ANNOUNCEMENTS 749736155569848370
# Voice
+ code_help_voice: 755154969761677312
+ code_help_voice_2: 766330079135268884
+ voice_chat: 412357430186344448
admins_voice: &ADMINS_VOICE 500734494840717332
staff_voice: &STAFF_VOICE 412375055910043655
@@ -197,10 +207,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
@@ -224,9 +237,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
@@ -260,6 +275,7 @@ guild:
reddit: 635408384794951680
talent_pool: 569145364800602132
+
filter:
# What do we filter?
filter_zalgo: false
@@ -297,6 +313,7 @@ filter:
- *OWNERS_ROLE
- *HELPERS_ROLE
- *PY_COMMUNITY_ROLE
+ - *SPRINTERS
keys:
@@ -315,7 +332,7 @@ urls:
paste_service: !JOIN [*SCHEMA, *PASTE, "/{key}"]
# Snekbox
- snekbox_eval_api: "http://snekbox:8060/eval"
+ snekbox_eval_api: "http://snekbox.default.svc.cluster.local/eval"
# Discord API URLs
discord_api: &DISCORD_API "https://discordapp.com/api/v7/"
@@ -325,6 +342,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
@@ -393,6 +411,23 @@ big_brother:
header_message_limit: 15
+code_block:
+ # The channels in which code blocks will be detected. They are not subject to a cooldown.
+ channel_whitelist:
+ - *BOT_CMD
+
+ # The channels which will be affected by a cooldown. These channels are also whitelisted.
+ cooldown_channels:
+ - *PY_DISCUSSION
+
+ # Sending instructions triggers a cooldown on a per-channel basis.
+ # More instruction messages will not be sent in the same channel until the cooldown has elapsed.
+ cooldown_seconds: 300
+
+ # The minimum amount of lines a message or code block must have for instructions to be sent.
+ minimum_lines: 4
+
+
free:
# Seconds to elapse for a channel
# to be considered inactive.
@@ -441,13 +476,11 @@ help_channels:
notify_roles:
- *HELPERS_ROLE
+
redirect_output:
delete_invocation: true
delete_delay: 15
-sync:
- confirm_timeout: 300
- max_diff: 10
duck_pond:
threshold: 4
@@ -464,11 +497,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
@@ -485,5 +520,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/deployment.yaml b/deployment.yaml
new file mode 100644
index 000000000..ca5ff5941
--- /dev/null
+++ b/deployment.yaml
@@ -0,0 +1,21 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: bot
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: bot
+ template:
+ metadata:
+ labels:
+ app: bot
+ spec:
+ containers:
+ - name: bot
+ image: ghcr.io/python-discord/bot:latest
+ imagePullPolicy: Always
+ envFrom:
+ - secretRef:
+ name: bot-env
diff --git a/docker-compose.yml b/docker-compose.yml
index cff7d33d6..0002d1d56 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -18,7 +18,7 @@ services:
- "127.0.0.1:6379:6379"
snekbox:
- image: pythondiscord/snekbox:latest
+ image: ghcr.io/python-discord/snekbox:latest
init: true
ipc: none
ports:
@@ -26,7 +26,7 @@ services:
privileged: true
web:
- image: pythondiscord/site:latest
+ image: ghcr.io/python-discord/site:latest
command: ["run", "--debug"]
networks:
default:
@@ -41,6 +41,7 @@ services:
- postgres
environment:
DATABASE_URL: postgres://pysite:pysite@postgres:5432/pysite
+ METRICITY_DB_URL: postgres://pysite:pysite@postgres:5432/metricity
SECRET_KEY: suitable-for-development-only
STATIC_ROOT: /var/www/static
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/backend/sync/test_base.py b/tests/bot/exts/backend/sync/test_base.py
index 886c243cf..3ad9db9c3 100644
--- a/tests/bot/exts/backend/sync/test_base.py
+++ b/tests/bot/exts/backend/sync/test_base.py
@@ -1,12 +1,9 @@
-import asyncio
import unittest
from unittest import mock
-import discord
-from bot import constants
from bot.api import ResponseCodeError
-from bot.exts.backend.sync._syncers import Syncer, _Diff
+from bot.exts.backend.sync._syncers import Syncer
from tests import helpers
@@ -18,292 +15,21 @@ class TestSyncer(Syncer):
_sync = mock.AsyncMock()
-class SyncerBaseTests(unittest.TestCase):
- """Tests for the syncer base class."""
-
- def setUp(self):
- self.bot = helpers.MockBot()
-
- def test_instantiation_fails_without_abstract_methods(self):
- """The class must have abstract methods implemented."""
- with self.assertRaisesRegex(TypeError, "Can't instantiate abstract class"):
- Syncer(self.bot)
-
-
-class SyncerSendPromptTests(unittest.IsolatedAsyncioTestCase):
- """Tests for sending the sync confirmation prompt."""
-
- def setUp(self):
- self.bot = helpers.MockBot()
- self.syncer = TestSyncer(self.bot)
-
- def mock_get_channel(self):
- """Fixture to return a mock channel and message for when `get_channel` is used."""
- self.bot.reset_mock()
-
- mock_channel = helpers.MockTextChannel()
- mock_message = helpers.MockMessage()
-
- mock_channel.send.return_value = mock_message
- self.bot.get_channel.return_value = mock_channel
-
- return mock_channel, mock_message
-
- def mock_fetch_channel(self):
- """Fixture to return a mock channel and message for when `fetch_channel` is used."""
- self.bot.reset_mock()
-
- mock_channel = helpers.MockTextChannel()
- mock_message = helpers.MockMessage()
-
- self.bot.get_channel.return_value = None
- mock_channel.send.return_value = mock_message
- self.bot.fetch_channel.return_value = mock_channel
-
- return mock_channel, mock_message
-
- async def test_send_prompt_edits_and_returns_message(self):
- """The given message should be edited to display the prompt and then should be returned."""
- msg = helpers.MockMessage()
- ret_val = await self.syncer._send_prompt(msg)
-
- msg.edit.assert_called_once()
- self.assertIn("content", msg.edit.call_args[1])
- self.assertEqual(ret_val, msg)
-
- async def test_send_prompt_gets_dev_core_channel(self):
- """The dev-core channel should be retrieved if an extant message isn't given."""
- subtests = (
- (self.bot.get_channel, self.mock_get_channel),
- (self.bot.fetch_channel, self.mock_fetch_channel),
- )
-
- for method, mock_ in subtests:
- with self.subTest(method=method, msg=mock_.__name__):
- mock_()
- await self.syncer._send_prompt()
-
- method.assert_called_once_with(constants.Channels.dev_core)
-
- async def test_send_prompt_returns_none_if_channel_fetch_fails(self):
- """None should be returned if there's an HTTPException when fetching the channel."""
- self.bot.get_channel.return_value = None
- self.bot.fetch_channel.side_effect = discord.HTTPException(mock.MagicMock(), "test error!")
-
- ret_val = await self.syncer._send_prompt()
-
- self.assertIsNone(ret_val)
-
- async def test_send_prompt_sends_and_returns_new_message_if_not_given(self):
- """A new message mentioning core devs should be sent and returned if message isn't given."""
- for mock_ in (self.mock_get_channel, self.mock_fetch_channel):
- with self.subTest(msg=mock_.__name__):
- mock_channel, mock_message = mock_()
- ret_val = await self.syncer._send_prompt()
-
- mock_channel.send.assert_called_once()
- self.assertIn(self.syncer._CORE_DEV_MENTION, mock_channel.send.call_args[0][0])
- self.assertEqual(ret_val, mock_message)
-
- async def test_send_prompt_adds_reactions(self):
- """The message should have reactions for confirmation added."""
- extant_message = helpers.MockMessage()
- subtests = (
- (extant_message, lambda: (None, extant_message)),
- (None, self.mock_get_channel),
- (None, self.mock_fetch_channel),
- )
-
- for message_arg, mock_ in subtests:
- subtest_msg = "Extant message" if mock_.__name__ == "<lambda>" else mock_.__name__
-
- with self.subTest(msg=subtest_msg):
- _, mock_message = mock_()
- await self.syncer._send_prompt(message_arg)
-
- calls = [mock.call(emoji) for emoji in self.syncer._REACTION_EMOJIS]
- mock_message.add_reaction.assert_has_calls(calls)
-
-
-class SyncerConfirmationTests(unittest.IsolatedAsyncioTestCase):
- """Tests for waiting for a sync confirmation reaction on the prompt."""
-
- def setUp(self):
- self.bot = helpers.MockBot()
- self.syncer = TestSyncer(self.bot)
- self.core_dev_role = helpers.MockRole(id=constants.Roles.core_developers)
-
- @staticmethod
- def get_message_reaction(emoji):
- """Fixture to return a mock message an reaction from the given `emoji`."""
- message = helpers.MockMessage()
- reaction = helpers.MockReaction(emoji=emoji, message=message)
-
- return message, reaction
-
- def test_reaction_check_for_valid_emoji_and_authors(self):
- """Should return True if authors are identical or are a bot and a core dev, respectively."""
- user_subtests = (
- (
- helpers.MockMember(id=77),
- helpers.MockMember(id=77),
- "identical users",
- ),
- (
- helpers.MockMember(id=77, bot=True),
- helpers.MockMember(id=43, roles=[self.core_dev_role]),
- "bot author and core-dev reactor",
- ),
- )
-
- for emoji in self.syncer._REACTION_EMOJIS:
- for author, user, msg in user_subtests:
- with self.subTest(author=author, user=user, emoji=emoji, msg=msg):
- message, reaction = self.get_message_reaction(emoji)
- ret_val = self.syncer._reaction_check(author, message, reaction, user)
-
- self.assertTrue(ret_val)
-
- def test_reaction_check_for_invalid_reactions(self):
- """Should return False for invalid reaction events."""
- valid_emoji = self.syncer._REACTION_EMOJIS[0]
- subtests = (
- (
- helpers.MockMember(id=77),
- *self.get_message_reaction(valid_emoji),
- helpers.MockMember(id=43, roles=[self.core_dev_role]),
- "users are not identical",
- ),
- (
- helpers.MockMember(id=77, bot=True),
- *self.get_message_reaction(valid_emoji),
- helpers.MockMember(id=43),
- "reactor lacks the core-dev role",
- ),
- (
- helpers.MockMember(id=77, bot=True, roles=[self.core_dev_role]),
- *self.get_message_reaction(valid_emoji),
- helpers.MockMember(id=77, bot=True, roles=[self.core_dev_role]),
- "reactor is a bot",
- ),
- (
- helpers.MockMember(id=77),
- helpers.MockMessage(id=95),
- helpers.MockReaction(emoji=valid_emoji, message=helpers.MockMessage(id=26)),
- helpers.MockMember(id=77),
- "messages are not identical",
- ),
- (
- helpers.MockMember(id=77),
- *self.get_message_reaction("InVaLiD"),
- helpers.MockMember(id=77),
- "emoji is invalid",
- ),
- )
-
- for *args, msg in subtests:
- kwargs = dict(zip(("author", "message", "reaction", "user"), args))
- with self.subTest(**kwargs, msg=msg):
- ret_val = self.syncer._reaction_check(*args)
- self.assertFalse(ret_val)
-
- async def test_wait_for_confirmation(self):
- """The message should always be edited and only return True if the emoji is a check mark."""
- subtests = (
- (constants.Emojis.check_mark, True, None),
- ("InVaLiD", False, None),
- (None, False, asyncio.TimeoutError),
- )
-
- for emoji, ret_val, side_effect in subtests:
- for bot in (True, False):
- with self.subTest(emoji=emoji, ret_val=ret_val, side_effect=side_effect, bot=bot):
- # Set up mocks
- message = helpers.MockMessage()
- member = helpers.MockMember(bot=bot)
-
- self.bot.wait_for.reset_mock()
- self.bot.wait_for.return_value = (helpers.MockReaction(emoji=emoji), None)
- self.bot.wait_for.side_effect = side_effect
-
- # Call the function
- actual_return = await self.syncer._wait_for_confirmation(member, message)
-
- # Perform assertions
- self.bot.wait_for.assert_called_once()
- self.assertIn("reaction_add", self.bot.wait_for.call_args[0])
-
- message.edit.assert_called_once()
- kwargs = message.edit.call_args[1]
- self.assertIn("content", kwargs)
-
- # Core devs should only be mentioned if the author is a bot.
- if bot:
- self.assertIn(self.syncer._CORE_DEV_MENTION, kwargs["content"])
- else:
- self.assertNotIn(self.syncer._CORE_DEV_MENTION, kwargs["content"])
-
- self.assertIs(actual_return, ret_val)
-
-
class SyncerSyncTests(unittest.IsolatedAsyncioTestCase):
"""Tests for main function orchestrating the sync."""
def setUp(self):
- self.bot = helpers.MockBot(user=helpers.MockMember(bot=True))
- self.syncer = TestSyncer(self.bot)
-
- async def test_sync_respects_confirmation_result(self):
- """The sync should abort if confirmation fails and continue if confirmed."""
- mock_message = helpers.MockMessage()
- subtests = (
- (True, mock_message),
- (False, None),
- )
-
- for confirmed, message in subtests:
- with self.subTest(confirmed=confirmed):
- self.syncer._sync.reset_mock()
- self.syncer._get_diff.reset_mock()
-
- diff = _Diff({1, 2, 3}, {4, 5}, None)
- self.syncer._get_diff.return_value = diff
- self.syncer._get_confirmation_result = mock.AsyncMock(
- return_value=(confirmed, message)
- )
-
- guild = helpers.MockGuild()
- await self.syncer.sync(guild)
+ patcher = mock.patch("bot.instance", new=helpers.MockBot(user=helpers.MockMember(bot=True)))
+ self.bot = patcher.start()
+ self.addCleanup(patcher.stop)
- self.syncer._get_diff.assert_called_once_with(guild)
- self.syncer._get_confirmation_result.assert_called_once()
+ self.guild = helpers.MockGuild()
- if confirmed:
- self.syncer._sync.assert_called_once_with(diff)
- else:
- self.syncer._sync.assert_not_called()
+ TestSyncer._get_diff.reset_mock(return_value=True, side_effect=True)
+ TestSyncer._sync.reset_mock(return_value=True, side_effect=True)
- async def test_sync_diff_size(self):
- """The diff size should be correctly calculated."""
- subtests = (
- (6, _Diff({1, 2}, {3, 4}, {5, 6})),
- (5, _Diff({1, 2, 3}, None, {4, 5})),
- (0, _Diff(None, None, None)),
- (0, _Diff(set(), set(), set())),
- )
-
- for size, diff in subtests:
- with self.subTest(size=size, diff=diff):
- self.syncer._get_diff.reset_mock()
- self.syncer._get_diff.return_value = diff
- self.syncer._get_confirmation_result = mock.AsyncMock(return_value=(False, None))
-
- guild = helpers.MockGuild()
- await self.syncer.sync(guild)
-
- self.syncer._get_diff.assert_called_once_with(guild)
- self.syncer._get_confirmation_result.assert_called_once()
- self.assertEqual(self.syncer._get_confirmation_result.call_args[0][0], size)
+ # Make sure `_get_diff` returns a MagicMock, not an AsyncMock
+ TestSyncer._get_diff.return_value = mock.MagicMock()
async def test_sync_message_edited(self):
"""The message should be edited if one was sent, even if the sync has an API error."""
@@ -315,90 +41,26 @@ class SyncerSyncTests(unittest.IsolatedAsyncioTestCase):
for message, side_effect, should_edit in subtests:
with self.subTest(message=message, side_effect=side_effect, should_edit=should_edit):
- self.syncer._sync.side_effect = side_effect
- self.syncer._get_confirmation_result = mock.AsyncMock(
- return_value=(True, message)
- )
+ TestSyncer._sync.side_effect = side_effect
+ ctx = helpers.MockContext()
+ ctx.send.return_value = message
- guild = helpers.MockGuild()
- await self.syncer.sync(guild)
+ await TestSyncer.sync(self.guild, ctx)
if should_edit:
message.edit.assert_called_once()
self.assertIn("content", message.edit.call_args[1])
- async def test_sync_confirmation_context_redirect(self):
- """If ctx is given, a new message should be sent and author should be ctx's author."""
- mock_member = helpers.MockMember()
+ async def test_sync_message_sent(self):
+ """If ctx is given, a new message should be sent."""
subtests = (
- (None, self.bot.user, None),
- (helpers.MockContext(author=mock_member), mock_member, helpers.MockMessage()),
+ (None, None),
+ (helpers.MockContext(), helpers.MockMessage()),
)
- for ctx, author, message in subtests:
- with self.subTest(ctx=ctx, author=author, message=message):
- if ctx is not None:
- ctx.send.return_value = message
-
- # Make sure `_get_diff` returns a MagicMock, not an AsyncMock
- self.syncer._get_diff.return_value = mock.MagicMock()
-
- self.syncer._get_confirmation_result = mock.AsyncMock(return_value=(False, None))
-
- guild = helpers.MockGuild()
- await self.syncer.sync(guild, ctx)
+ for ctx, message in subtests:
+ with self.subTest(ctx=ctx, message=message):
+ await TestSyncer.sync(self.guild, ctx)
if ctx is not None:
ctx.send.assert_called_once()
-
- self.syncer._get_confirmation_result.assert_called_once()
- self.assertEqual(self.syncer._get_confirmation_result.call_args[0][1], author)
- self.assertEqual(self.syncer._get_confirmation_result.call_args[0][2], message)
-
- @mock.patch.object(constants.Sync, "max_diff", new=3)
- async def test_confirmation_result_small_diff(self):
- """Should always return True and the given message if the diff size is too small."""
- author = helpers.MockMember()
- expected_message = helpers.MockMessage()
-
- for size in (3, 2): # pragma: no cover
- with self.subTest(size=size):
- self.syncer._send_prompt = mock.AsyncMock()
- self.syncer._wait_for_confirmation = mock.AsyncMock()
-
- coro = self.syncer._get_confirmation_result(size, author, expected_message)
- result, actual_message = await coro
-
- self.assertTrue(result)
- self.assertEqual(actual_message, expected_message)
- self.syncer._send_prompt.assert_not_called()
- self.syncer._wait_for_confirmation.assert_not_called()
-
- @mock.patch.object(constants.Sync, "max_diff", new=3)
- async def test_confirmation_result_large_diff(self):
- """Should return True if confirmed and False if _send_prompt fails or aborted."""
- author = helpers.MockMember()
- mock_message = helpers.MockMessage()
-
- subtests = (
- (True, mock_message, True, "confirmed"),
- (False, None, False, "_send_prompt failed"),
- (False, mock_message, False, "aborted"),
- )
-
- for expected_result, expected_message, confirmed, msg in subtests: # pragma: no cover
- with self.subTest(msg=msg):
- self.syncer._send_prompt = mock.AsyncMock(return_value=expected_message)
- self.syncer._wait_for_confirmation = mock.AsyncMock(return_value=confirmed)
-
- coro = self.syncer._get_confirmation_result(4, author)
- actual_result, actual_message = await coro
-
- self.syncer._send_prompt.assert_called_once_with(None) # message defaults to None
- self.assertIs(actual_result, expected_result)
- self.assertEqual(actual_message, expected_message)
-
- if expected_message:
- self.syncer._wait_for_confirmation.assert_called_once_with(
- author, expected_message
- )
diff --git a/tests/bot/exts/backend/sync/test_cog.py b/tests/bot/exts/backend/sync/test_cog.py
index 1b89564f2..22a07313e 100644
--- a/tests/bot/exts/backend/sync/test_cog.py
+++ b/tests/bot/exts/backend/sync/test_cog.py
@@ -29,24 +29,24 @@ class SyncCogTestCase(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.bot = helpers.MockBot()
- self.role_syncer_patcher = mock.patch(
+ role_syncer_patcher = mock.patch(
"bot.exts.backend.sync._syncers.RoleSyncer",
autospec=Syncer,
spec_set=True
)
- self.user_syncer_patcher = mock.patch(
+ user_syncer_patcher = mock.patch(
"bot.exts.backend.sync._syncers.UserSyncer",
autospec=Syncer,
spec_set=True
)
- self.RoleSyncer = self.role_syncer_patcher.start()
- self.UserSyncer = self.user_syncer_patcher.start()
- self.cog = Sync(self.bot)
+ self.RoleSyncer = role_syncer_patcher.start()
+ self.UserSyncer = user_syncer_patcher.start()
- def tearDown(self):
- self.role_syncer_patcher.stop()
- self.user_syncer_patcher.stop()
+ self.addCleanup(role_syncer_patcher.stop)
+ self.addCleanup(user_syncer_patcher.stop)
+
+ self.cog = Sync(self.bot)
@staticmethod
def response_error(status: int) -> ResponseCodeError:
@@ -73,8 +73,6 @@ class SyncCogTests(SyncCogTestCase):
Sync(self.bot)
- self.RoleSyncer.assert_called_once_with(self.bot)
- self.UserSyncer.assert_called_once_with(self.bot)
sync_guild.assert_called_once_with()
self.bot.loop.create_task.assert_called_once_with(mock_sync_guild_coro)
@@ -83,8 +81,8 @@ class SyncCogTests(SyncCogTestCase):
for guild in (helpers.MockGuild(), None):
with self.subTest(guild=guild):
self.bot.reset_mock()
- self.cog.role_syncer.reset_mock()
- self.cog.user_syncer.reset_mock()
+ self.RoleSyncer.reset_mock()
+ self.UserSyncer.reset_mock()
self.bot.get_guild = mock.MagicMock(return_value=guild)
@@ -94,11 +92,11 @@ class SyncCogTests(SyncCogTestCase):
self.bot.get_guild.assert_called_once_with(constants.Guild.id)
if guild is None:
- self.cog.role_syncer.sync.assert_not_called()
- self.cog.user_syncer.sync.assert_not_called()
+ self.RoleSyncer.sync.assert_not_called()
+ self.UserSyncer.sync.assert_not_called()
else:
- self.cog.role_syncer.sync.assert_called_once_with(guild)
- self.cog.user_syncer.sync.assert_called_once_with(guild)
+ self.RoleSyncer.sync.assert_called_once_with(guild)
+ self.UserSyncer.sync.assert_called_once_with(guild)
async def patch_user_helper(self, side_effect: BaseException) -> None:
"""Helper to set a side effect for bot.api_client.patch and then assert it is called."""
@@ -392,16 +390,16 @@ class SyncCogCommandTests(SyncCogTestCase, CommandTestCase):
async def test_sync_roles_command(self):
"""sync() should be called on the RoleSyncer."""
ctx = helpers.MockContext()
- await self.cog.sync_roles_command.callback(self.cog, ctx)
+ await self.cog.sync_roles_command(self.cog, ctx)
- self.cog.role_syncer.sync.assert_called_once_with(ctx.guild, ctx)
+ self.RoleSyncer.sync.assert_called_once_with(ctx.guild, ctx)
async def test_sync_users_command(self):
"""sync() should be called on the UserSyncer."""
ctx = helpers.MockContext()
- await self.cog.sync_users_command.callback(self.cog, ctx)
+ await self.cog.sync_users_command(self.cog, ctx)
- self.cog.user_syncer.sync.assert_called_once_with(ctx.guild, ctx)
+ self.UserSyncer.sync.assert_called_once_with(ctx.guild, ctx)
async def test_commands_require_admin(self):
"""The sync commands should only run if the author has the administrator permission."""
diff --git a/tests/bot/exts/backend/sync/test_roles.py b/tests/bot/exts/backend/sync/test_roles.py
index 7b9f40cad..541074336 100644
--- a/tests/bot/exts/backend/sync/test_roles.py
+++ b/tests/bot/exts/backend/sync/test_roles.py
@@ -22,8 +22,9 @@ class RoleSyncerDiffTests(unittest.IsolatedAsyncioTestCase):
"""Tests for determining differences between roles in the DB and roles in the Guild cache."""
def setUp(self):
- self.bot = helpers.MockBot()
- self.syncer = RoleSyncer(self.bot)
+ patcher = mock.patch("bot.instance", new=helpers.MockBot())
+ self.bot = patcher.start()
+ self.addCleanup(patcher.stop)
@staticmethod
def get_guild(*roles):
@@ -44,7 +45,7 @@ class RoleSyncerDiffTests(unittest.IsolatedAsyncioTestCase):
self.bot.api_client.get.return_value = [fake_role()]
guild = self.get_guild(fake_role())
- actual_diff = await self.syncer._get_diff(guild)
+ actual_diff = await RoleSyncer._get_diff(guild)
expected_diff = (set(), set(), set())
self.assertEqual(actual_diff, expected_diff)
@@ -56,7 +57,7 @@ class RoleSyncerDiffTests(unittest.IsolatedAsyncioTestCase):
self.bot.api_client.get.return_value = [fake_role(id=41, name="old"), fake_role()]
guild = self.get_guild(updated_role, fake_role())
- actual_diff = await self.syncer._get_diff(guild)
+ actual_diff = await RoleSyncer._get_diff(guild)
expected_diff = (set(), {_Role(**updated_role)}, set())
self.assertEqual(actual_diff, expected_diff)
@@ -68,7 +69,7 @@ class RoleSyncerDiffTests(unittest.IsolatedAsyncioTestCase):
self.bot.api_client.get.return_value = [fake_role()]
guild = self.get_guild(fake_role(), new_role)
- actual_diff = await self.syncer._get_diff(guild)
+ actual_diff = await RoleSyncer._get_diff(guild)
expected_diff = ({_Role(**new_role)}, set(), set())
self.assertEqual(actual_diff, expected_diff)
@@ -80,7 +81,7 @@ class RoleSyncerDiffTests(unittest.IsolatedAsyncioTestCase):
self.bot.api_client.get.return_value = [fake_role(), deleted_role]
guild = self.get_guild(fake_role())
- actual_diff = await self.syncer._get_diff(guild)
+ actual_diff = await RoleSyncer._get_diff(guild)
expected_diff = (set(), set(), {_Role(**deleted_role)})
self.assertEqual(actual_diff, expected_diff)
@@ -98,7 +99,7 @@ class RoleSyncerDiffTests(unittest.IsolatedAsyncioTestCase):
]
guild = self.get_guild(fake_role(), new, updated)
- actual_diff = await self.syncer._get_diff(guild)
+ actual_diff = await RoleSyncer._get_diff(guild)
expected_diff = ({_Role(**new)}, {_Role(**updated)}, {_Role(**deleted)})
self.assertEqual(actual_diff, expected_diff)
@@ -108,8 +109,9 @@ class RoleSyncerSyncTests(unittest.IsolatedAsyncioTestCase):
"""Tests for the API requests that sync roles."""
def setUp(self):
- self.bot = helpers.MockBot()
- self.syncer = RoleSyncer(self.bot)
+ patcher = mock.patch("bot.instance", new=helpers.MockBot())
+ self.bot = patcher.start()
+ self.addCleanup(patcher.stop)
async def test_sync_created_roles(self):
"""Only POST requests should be made with the correct payload."""
@@ -117,7 +119,7 @@ class RoleSyncerSyncTests(unittest.IsolatedAsyncioTestCase):
role_tuples = {_Role(**role) for role in roles}
diff = _Diff(role_tuples, set(), set())
- await self.syncer._sync(diff)
+ await RoleSyncer._sync(diff)
calls = [mock.call("bot/roles", json=role) for role in roles]
self.bot.api_client.post.assert_has_calls(calls, any_order=True)
@@ -132,7 +134,7 @@ class RoleSyncerSyncTests(unittest.IsolatedAsyncioTestCase):
role_tuples = {_Role(**role) for role in roles}
diff = _Diff(set(), role_tuples, set())
- await self.syncer._sync(diff)
+ await RoleSyncer._sync(diff)
calls = [mock.call(f"bot/roles/{role['id']}", json=role) for role in roles]
self.bot.api_client.put.assert_has_calls(calls, any_order=True)
@@ -147,7 +149,7 @@ class RoleSyncerSyncTests(unittest.IsolatedAsyncioTestCase):
role_tuples = {_Role(**role) for role in roles}
diff = _Diff(set(), set(), role_tuples)
- await self.syncer._sync(diff)
+ await RoleSyncer._sync(diff)
calls = [mock.call(f"bot/roles/{role['id']}") for role in roles]
self.bot.api_client.delete.assert_has_calls(calls, any_order=True)
diff --git a/tests/bot/exts/backend/sync/test_users.py b/tests/bot/exts/backend/sync/test_users.py
index c0a1da35c..61673e1bb 100644
--- a/tests/bot/exts/backend/sync/test_users.py
+++ b/tests/bot/exts/backend/sync/test_users.py
@@ -1,7 +1,7 @@
import unittest
from unittest import mock
-from bot.exts.backend.sync._syncers import UserSyncer, _Diff, _User
+from bot.exts.backend.sync._syncers import UserSyncer, _Diff
from tests import helpers
@@ -10,7 +10,7 @@ def fake_user(**kwargs):
kwargs.setdefault("id", 43)
kwargs.setdefault("name", "bob the test man")
kwargs.setdefault("discriminator", 1337)
- kwargs.setdefault("roles", (666,))
+ kwargs.setdefault("roles", [666])
kwargs.setdefault("in_guild", True)
return kwargs
@@ -20,8 +20,9 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase):
"""Tests for determining differences between users in the DB and users in the Guild cache."""
def setUp(self):
- self.bot = helpers.MockBot()
- self.syncer = UserSyncer(self.bot)
+ patcher = mock.patch("bot.instance", new=helpers.MockBot())
+ self.bot = patcher.start()
+ self.addCleanup(patcher.stop)
@staticmethod
def get_guild(*members):
@@ -40,22 +41,42 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase):
return guild
+ @staticmethod
+ def get_mock_member(member: dict):
+ member = member.copy()
+ del member["in_guild"]
+ mock_member = helpers.MockMember(**member)
+ mock_member.roles = [helpers.MockRole(id=role_id) for role_id in member["roles"]]
+ return mock_member
+
async def test_empty_diff_for_no_users(self):
"""When no users are given, an empty diff should be returned."""
+ self.bot.api_client.get.return_value = {
+ "count": 3,
+ "next_page_no": None,
+ "previous_page_no": None,
+ "results": []
+ }
guild = self.get_guild()
- actual_diff = await self.syncer._get_diff(guild)
- expected_diff = (set(), set(), None)
+ actual_diff = await UserSyncer._get_diff(guild)
+ expected_diff = ([], [], None)
self.assertEqual(actual_diff, expected_diff)
async def test_empty_diff_for_identical_users(self):
"""No differences should be found if the users in the guild and DB are identical."""
- self.bot.api_client.get.return_value = [fake_user()]
+ self.bot.api_client.get.return_value = {
+ "count": 3,
+ "next_page_no": None,
+ "previous_page_no": None,
+ "results": [fake_user()]
+ }
guild = self.get_guild(fake_user())
- actual_diff = await self.syncer._get_diff(guild)
- expected_diff = (set(), set(), None)
+ guild.get_member.return_value = self.get_mock_member(fake_user())
+ actual_diff = await UserSyncer._get_diff(guild)
+ expected_diff = ([], [], None)
self.assertEqual(actual_diff, expected_diff)
@@ -63,59 +84,102 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase):
"""Only updated users should be added to the 'updated' set of the diff."""
updated_user = fake_user(id=99, name="new")
- self.bot.api_client.get.return_value = [fake_user(id=99, name="old"), fake_user()]
+ self.bot.api_client.get.return_value = {
+ "count": 3,
+ "next_page_no": None,
+ "previous_page_no": None,
+ "results": [fake_user(id=99, name="old"), fake_user()]
+ }
guild = self.get_guild(updated_user, fake_user())
+ guild.get_member.side_effect = [
+ self.get_mock_member(updated_user),
+ self.get_mock_member(fake_user())
+ ]
- actual_diff = await self.syncer._get_diff(guild)
- expected_diff = (set(), {_User(**updated_user)}, None)
+ actual_diff = await UserSyncer._get_diff(guild)
+ expected_diff = ([], [{"id": 99, "name": "new"}], None)
self.assertEqual(actual_diff, expected_diff)
async def test_diff_for_new_users(self):
- """Only new users should be added to the 'created' set of the diff."""
+ """Only new users should be added to the 'created' list of the diff."""
new_user = fake_user(id=99, name="new")
- self.bot.api_client.get.return_value = [fake_user()]
+ self.bot.api_client.get.return_value = {
+ "count": 3,
+ "next_page_no": None,
+ "previous_page_no": None,
+ "results": [fake_user()]
+ }
guild = self.get_guild(fake_user(), new_user)
-
- actual_diff = await self.syncer._get_diff(guild)
- expected_diff = ({_User(**new_user)}, set(), None)
+ guild.get_member.side_effect = [
+ self.get_mock_member(fake_user()),
+ self.get_mock_member(new_user)
+ ]
+ actual_diff = await UserSyncer._get_diff(guild)
+ expected_diff = ([new_user], [], None)
self.assertEqual(actual_diff, expected_diff)
async def test_diff_sets_in_guild_false_for_leaving_users(self):
"""When a user leaves the guild, the `in_guild` flag is updated to `False`."""
- leaving_user = fake_user(id=63, in_guild=False)
-
- self.bot.api_client.get.return_value = [fake_user(), fake_user(id=63)]
+ self.bot.api_client.get.return_value = {
+ "count": 3,
+ "next_page_no": None,
+ "previous_page_no": None,
+ "results": [fake_user(), fake_user(id=63)]
+ }
guild = self.get_guild(fake_user())
+ guild.get_member.side_effect = [
+ self.get_mock_member(fake_user()),
+ None
+ ]
- actual_diff = await self.syncer._get_diff(guild)
- expected_diff = (set(), {_User(**leaving_user)}, None)
+ actual_diff = await UserSyncer._get_diff(guild)
+ expected_diff = ([], [{"id": 63, "in_guild": False}], None)
self.assertEqual(actual_diff, expected_diff)
async def test_diff_for_new_updated_and_leaving_users(self):
"""When users are added, updated, and removed, all of them are returned properly."""
new_user = fake_user(id=99, name="new")
+
updated_user = fake_user(id=55, name="updated")
- leaving_user = fake_user(id=63, in_guild=False)
- self.bot.api_client.get.return_value = [fake_user(), fake_user(id=55), fake_user(id=63)]
+ self.bot.api_client.get.return_value = {
+ "count": 3,
+ "next_page_no": None,
+ "previous_page_no": None,
+ "results": [fake_user(), fake_user(id=55), fake_user(id=63)]
+ }
guild = self.get_guild(fake_user(), new_user, updated_user)
+ guild.get_member.side_effect = [
+ self.get_mock_member(fake_user()),
+ self.get_mock_member(updated_user),
+ None
+ ]
- actual_diff = await self.syncer._get_diff(guild)
- expected_diff = ({_User(**new_user)}, {_User(**updated_user), _User(**leaving_user)}, None)
+ actual_diff = await UserSyncer._get_diff(guild)
+ expected_diff = ([new_user], [{"id": 55, "name": "updated"}, {"id": 63, "in_guild": False}], None)
self.assertEqual(actual_diff, expected_diff)
async def test_empty_diff_for_db_users_not_in_guild(self):
- """When the DB knows a user the guild doesn't, no difference is found."""
- self.bot.api_client.get.return_value = [fake_user(), fake_user(id=63, in_guild=False)]
+ """When the DB knows a user, but the guild doesn't, no difference is found."""
+ self.bot.api_client.get.return_value = {
+ "count": 3,
+ "next_page_no": None,
+ "previous_page_no": None,
+ "results": [fake_user(), fake_user(id=63, in_guild=False)]
+ }
guild = self.get_guild(fake_user())
+ guild.get_member.side_effect = [
+ self.get_mock_member(fake_user()),
+ None
+ ]
- actual_diff = await self.syncer._get_diff(guild)
- expected_diff = (set(), set(), None)
+ actual_diff = await UserSyncer._get_diff(guild)
+ expected_diff = ([], [], None)
self.assertEqual(actual_diff, expected_diff)
@@ -124,20 +188,18 @@ class UserSyncerSyncTests(unittest.IsolatedAsyncioTestCase):
"""Tests for the API requests that sync users."""
def setUp(self):
- self.bot = helpers.MockBot()
- self.syncer = UserSyncer(self.bot)
+ patcher = mock.patch("bot.instance", new=helpers.MockBot())
+ self.bot = patcher.start()
+ self.addCleanup(patcher.stop)
async def test_sync_created_users(self):
"""Only POST requests should be made with the correct payload."""
users = [fake_user(id=111), fake_user(id=222)]
- user_tuples = {_User(**user) for user in users}
- diff = _Diff(user_tuples, set(), None)
- await self.syncer._sync(diff)
+ diff = _Diff(users, [], None)
+ await UserSyncer._sync(diff)
- calls = [mock.call("bot/users", json=user) for user in users]
- self.bot.api_client.post.assert_has_calls(calls, any_order=True)
- self.assertEqual(self.bot.api_client.post.call_count, len(users))
+ self.bot.api_client.post.assert_called_once_with("bot/users", json=diff.created)
self.bot.api_client.put.assert_not_called()
self.bot.api_client.delete.assert_not_called()
@@ -146,13 +208,10 @@ class UserSyncerSyncTests(unittest.IsolatedAsyncioTestCase):
"""Only PUT requests should be made with the correct payload."""
users = [fake_user(id=111), fake_user(id=222)]
- user_tuples = {_User(**user) for user in users}
- diff = _Diff(set(), user_tuples, None)
- await self.syncer._sync(diff)
+ diff = _Diff([], users, None)
+ await UserSyncer._sync(diff)
- calls = [mock.call(f"bot/users/{user['id']}", json=user) for user in users]
- self.bot.api_client.put.assert_has_calls(calls, any_order=True)
- self.assertEqual(self.bot.api_client.put.call_count, len(users))
+ self.bot.api_client.patch.assert_called_once_with("bot/users/bulk_patch", json=diff.updated)
self.bot.api_client.post.assert_not_called()
self.bot.api_client.delete.assert_not_called()
diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py
index d3f2995fb..daede54c5 100644
--- a/tests/bot/exts/info/test_information.py
+++ b/tests/bot/exts/info/test_information.py
@@ -1,4 +1,3 @@
-import asyncio
import textwrap
import unittest
import unittest.mock
@@ -13,7 +12,7 @@ from tests import helpers
COG_PATH = "bot.exts.info.information.Information"
-class InformationCogTests(unittest.TestCase):
+class InformationCogTests(unittest.IsolatedAsyncioTestCase):
"""Tests the Information cog."""
@classmethod
@@ -29,16 +28,14 @@ class InformationCogTests(unittest.TestCase):
self.ctx = helpers.MockContext()
self.ctx.author.roles.append(self.moderator_role)
- def test_roles_command_command(self):
+ async def test_roles_command_command(self):
"""Test if the `role_info` command correctly returns the `moderator_role`."""
self.ctx.guild.roles.append(self.moderator_role)
self.cog.roles_info.can_run = unittest.mock.AsyncMock()
self.cog.roles_info.can_run.return_value = True
- coroutine = self.cog.roles_info.callback(self.cog, self.ctx)
-
- self.assertIsNone(asyncio.run(coroutine))
+ self.assertIsNone(await self.cog.roles_info(self.cog, self.ctx))
self.ctx.send.assert_called_once()
_, kwargs = self.ctx.send.call_args
@@ -48,7 +45,7 @@ class InformationCogTests(unittest.TestCase):
self.assertEqual(embed.colour, discord.Colour.blurple())
self.assertEqual(embed.description, f"\n`{self.moderator_role.id}` - {self.moderator_role.mention}\n")
- def test_role_info_command(self):
+ async def test_role_info_command(self):
"""Tests the `role info` command."""
dummy_role = helpers.MockRole(
name="Dummy",
@@ -73,9 +70,7 @@ class InformationCogTests(unittest.TestCase):
self.cog.role_info.can_run = unittest.mock.AsyncMock()
self.cog.role_info.can_run.return_value = True
- coroutine = self.cog.role_info.callback(self.cog, self.ctx, dummy_role, admin_role)
-
- self.assertIsNone(asyncio.run(coroutine))
+ self.assertIsNone(await self.cog.role_info(self.cog, self.ctx, dummy_role, admin_role))
self.assertEqual(self.ctx.send.call_count, 2)
@@ -97,80 +92,8 @@ class InformationCogTests(unittest.TestCase):
self.assertEqual(admin_embed.title, "Admins info")
self.assertEqual(admin_embed.colour, discord.Colour.red())
- @unittest.mock.patch('bot.exts.info.information.time_since')
- def test_server_info_command(self, time_since_patch):
- time_since_patch.return_value = '2 days ago'
-
- self.ctx.guild = helpers.MockGuild(
- features=('lemons', 'apples'),
- region="The Moon",
- roles=[self.moderator_role],
- channels=[
- discord.TextChannel(
- state={},
- guild=self.ctx.guild,
- data={'id': 42, 'name': 'lemons-offering', 'position': 22, 'type': 'text'}
- ),
- discord.CategoryChannel(
- state={},
- guild=self.ctx.guild,
- data={'id': 5125, 'name': 'the-lemon-collection', 'position': 22, 'type': 'category'}
- ),
- discord.VoiceChannel(
- state={},
- guild=self.ctx.guild,
- data={'id': 15290, 'name': 'listen-to-lemon', 'position': 22, 'type': 'voice'}
- )
- ],
- members=[
- *(helpers.MockMember(status=discord.Status.online) for _ in range(2)),
- *(helpers.MockMember(status=discord.Status.idle) for _ in range(1)),
- *(helpers.MockMember(status=discord.Status.dnd) for _ in range(4)),
- *(helpers.MockMember(status=discord.Status.offline) for _ in range(3)),
- ],
- member_count=1_234,
- icon_url='a-lemon.jpg',
- )
-
- coroutine = self.cog.server_info.callback(self.cog, self.ctx)
- self.assertIsNone(asyncio.run(coroutine))
-
- time_since_patch.assert_called_once_with(self.ctx.guild.created_at, precision='days')
- _, kwargs = self.ctx.send.call_args
- embed = kwargs.pop('embed')
- self.assertEqual(embed.colour, discord.Colour.blurple())
- self.assertEqual(
- embed.description,
- textwrap.dedent(
- f"""
- **Server information**
- Created: {time_since_patch.return_value}
- Voice region: {self.ctx.guild.region}
- Features: {', '.join(self.ctx.guild.features)}
-
- **Channel counts**
- Category channels: 1
- Text channels: 1
- Voice channels: 1
- Staff channels: 0
-
- **Member counts**
- Members: {self.ctx.guild.member_count:,}
- Staff members: 0
- Roles: {len(self.ctx.guild.roles)}
-
- **Member statuses**
- {constants.Emojis.status_online} 2
- {constants.Emojis.status_idle} 1
- {constants.Emojis.status_dnd} 4
- {constants.Emojis.status_offline} 3
- """
- )
- )
- self.assertEqual(embed.thumbnail.url, 'a-lemon.jpg')
-
-class UserInfractionHelperMethodTests(unittest.TestCase):
+class UserInfractionHelperMethodTests(unittest.IsolatedAsyncioTestCase):
"""Tests for the helper methods of the `!user` command."""
def setUp(self):
@@ -180,7 +103,7 @@ class UserInfractionHelperMethodTests(unittest.TestCase):
self.cog = information.Information(self.bot)
self.member = helpers.MockMember(id=1234)
- def test_user_command_helper_method_get_requests(self):
+ async def test_user_command_helper_method_get_requests(self):
"""The helper methods should form the correct get requests."""
test_values = (
{
@@ -202,11 +125,11 @@ class UserInfractionHelperMethodTests(unittest.TestCase):
endpoint, params = test_value["expected_args"]
with self.subTest(method=helper_method, endpoint=endpoint, params=params):
- asyncio.run(helper_method(self.member))
+ await helper_method(self.member)
self.bot.api_client.get.assert_called_once_with(endpoint, params=params)
self.bot.api_client.get.reset_mock()
- def _method_subtests(self, method, test_values, default_header):
+ async def _method_subtests(self, method, test_values, default_header):
"""Helper method that runs the subtests for the different helper methods."""
for test_value in test_values:
api_response = test_value["api response"]
@@ -216,11 +139,11 @@ class UserInfractionHelperMethodTests(unittest.TestCase):
self.bot.api_client.get.return_value = api_response
expected_output = "\n".join(expected_lines)
- actual_output = asyncio.run(method(self.member))
+ actual_output = await method(self.member)
self.assertEqual((default_header, expected_output), actual_output)
- def test_basic_user_infraction_counts_returns_correct_strings(self):
+ async def test_basic_user_infraction_counts_returns_correct_strings(self):
"""The method should correctly list both the total and active number of non-hidden infractions."""
test_values = (
# No infractions means zero counts
@@ -251,9 +174,9 @@ class UserInfractionHelperMethodTests(unittest.TestCase):
header = "Infractions"
- self._method_subtests(self.cog.basic_user_infraction_counts, test_values, header)
+ await self._method_subtests(self.cog.basic_user_infraction_counts, test_values, header)
- def test_expanded_user_infraction_counts_returns_correct_strings(self):
+ async def test_expanded_user_infraction_counts_returns_correct_strings(self):
"""The method should correctly list the total and active number of all infractions split by infraction type."""
test_values = (
{
@@ -306,9 +229,9 @@ class UserInfractionHelperMethodTests(unittest.TestCase):
header = "Infractions"
- self._method_subtests(self.cog.expanded_user_infraction_counts, test_values, header)
+ await self._method_subtests(self.cog.expanded_user_infraction_counts, test_values, header)
- def test_user_nomination_counts_returns_correct_strings(self):
+ async def test_user_nomination_counts_returns_correct_strings(self):
"""The method should list the number of active and historical nominations for the user."""
test_values = (
{
@@ -336,12 +259,12 @@ class UserInfractionHelperMethodTests(unittest.TestCase):
header = "Nominations"
- self._method_subtests(self.cog.user_nomination_counts, test_values, header)
+ await self._method_subtests(self.cog.user_nomination_counts, test_values, header)
@unittest.mock.patch("bot.exts.info.information.time_since", new=unittest.mock.MagicMock(return_value="1 year ago"))
@unittest.mock.patch("bot.exts.info.information.constants.MODERATION_CHANNELS", new=[50])
-class UserEmbedTests(unittest.TestCase):
+class UserEmbedTests(unittest.IsolatedAsyncioTestCase):
"""Tests for the creation of the `!user` embed."""
def setUp(self):
@@ -354,14 +277,14 @@ class UserEmbedTests(unittest.TestCase):
f"{COG_PATH}.basic_user_infraction_counts",
new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions"))
)
- def test_create_user_embed_uses_string_representation_of_user_in_title_if_nick_is_not_available(self):
+ async def test_create_user_embed_uses_string_representation_of_user_in_title_if_nick_is_not_available(self):
"""The embed should use the string representation of the user if they don't have a nick."""
ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1))
user = helpers.MockMember()
user.nick = None
user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock")
- embed = asyncio.run(self.cog.create_user_embed(ctx, user))
+ embed = await self.cog.create_user_embed(ctx, user)
self.assertEqual(embed.title, "Mr. Hemlock")
@@ -369,14 +292,14 @@ class UserEmbedTests(unittest.TestCase):
f"{COG_PATH}.basic_user_infraction_counts",
new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions"))
)
- def test_create_user_embed_uses_nick_in_title_if_available(self):
+ async def test_create_user_embed_uses_nick_in_title_if_available(self):
"""The embed should use the nick if it's available."""
ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1))
user = helpers.MockMember()
user.nick = "Cat lover"
user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock")
- embed = asyncio.run(self.cog.create_user_embed(ctx, user))
+ embed = await self.cog.create_user_embed(ctx, user)
self.assertEqual(embed.title, "Cat lover (Mr. Hemlock)")
@@ -384,7 +307,7 @@ class UserEmbedTests(unittest.TestCase):
f"{COG_PATH}.basic_user_infraction_counts",
new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions"))
)
- def test_create_user_embed_ignores_everyone_role(self):
+ async def test_create_user_embed_ignores_everyone_role(self):
"""Created `!user` embeds should not contain mention of the @everyone-role."""
ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1))
admins_role = helpers.MockRole(name='Admins')
@@ -393,14 +316,18 @@ class UserEmbedTests(unittest.TestCase):
# A `MockMember` has the @Everyone role by default; we add the Admins to that.
user = helpers.MockMember(roles=[admins_role], top_role=admins_role)
- embed = asyncio.run(self.cog.create_user_embed(ctx, user))
+ embed = await self.cog.create_user_embed(ctx, user)
self.assertIn("&Admins", embed.fields[1].value)
self.assertNotIn("&Everyone", embed.fields[1].value)
@unittest.mock.patch(f"{COG_PATH}.expanded_user_infraction_counts", new_callable=unittest.mock.AsyncMock)
@unittest.mock.patch(f"{COG_PATH}.user_nomination_counts", new_callable=unittest.mock.AsyncMock)
- def test_create_user_embed_expanded_information_in_moderation_channels(self, nomination_counts, infraction_counts):
+ async def test_create_user_embed_expanded_information_in_moderation_channels(
+ self,
+ nomination_counts,
+ infraction_counts
+ ):
"""The embed should contain expanded infractions and nomination info in mod channels."""
ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=50))
@@ -411,7 +338,7 @@ class UserEmbedTests(unittest.TestCase):
nomination_counts.return_value = ("Nominations", "nomination info")
user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role)
- embed = asyncio.run(self.cog.create_user_embed(ctx, user))
+ embed = await self.cog.create_user_embed(ctx, user)
infraction_counts.assert_called_once_with(user)
nomination_counts.assert_called_once_with(user)
@@ -434,7 +361,7 @@ class UserEmbedTests(unittest.TestCase):
)
@unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new_callable=unittest.mock.AsyncMock)
- def test_create_user_embed_basic_information_outside_of_moderation_channels(self, infraction_counts):
+ async def test_create_user_embed_basic_information_outside_of_moderation_channels(self, infraction_counts):
"""The embed should contain only basic infraction data outside of mod channels."""
ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=100))
@@ -444,7 +371,7 @@ class UserEmbedTests(unittest.TestCase):
infraction_counts.return_value = ("Infractions", "basic infractions info")
user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role)
- embed = asyncio.run(self.cog.create_user_embed(ctx, user))
+ embed = await self.cog.create_user_embed(ctx, user)
infraction_counts.assert_called_once_with(user)
@@ -467,14 +394,14 @@ class UserEmbedTests(unittest.TestCase):
self.assertEqual(
"basic infractions info",
- embed.fields[3].value
+ embed.fields[2].value
)
@unittest.mock.patch(
f"{COG_PATH}.basic_user_infraction_counts",
new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions"))
)
- def test_create_user_embed_uses_top_role_colour_when_user_has_roles(self):
+ async def test_create_user_embed_uses_top_role_colour_when_user_has_roles(self):
"""The embed should be created with the colour of the top role, if a top role is available."""
ctx = helpers.MockContext()
@@ -482,7 +409,7 @@ class UserEmbedTests(unittest.TestCase):
moderators_role.colour = 100
user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role)
- embed = asyncio.run(self.cog.create_user_embed(ctx, user))
+ embed = await self.cog.create_user_embed(ctx, user)
self.assertEqual(embed.colour, discord.Colour(moderators_role.colour))
@@ -490,12 +417,12 @@ class UserEmbedTests(unittest.TestCase):
f"{COG_PATH}.basic_user_infraction_counts",
new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions"))
)
- def test_create_user_embed_uses_blurple_colour_when_user_has_no_roles(self):
+ async def test_create_user_embed_uses_blurple_colour_when_user_has_no_roles(self):
"""The embed should be created with a blurple colour if the user has no assigned roles."""
ctx = helpers.MockContext()
user = helpers.MockMember(id=217)
- embed = asyncio.run(self.cog.create_user_embed(ctx, user))
+ embed = await self.cog.create_user_embed(ctx, user)
self.assertEqual(embed.colour, discord.Colour.blurple())
@@ -503,20 +430,20 @@ class UserEmbedTests(unittest.TestCase):
f"{COG_PATH}.basic_user_infraction_counts",
new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions"))
)
- def test_create_user_embed_uses_png_format_of_user_avatar_as_thumbnail(self):
+ async def test_create_user_embed_uses_png_format_of_user_avatar_as_thumbnail(self):
"""The embed thumbnail should be set to the user's avatar in `png` format."""
ctx = helpers.MockContext()
user = helpers.MockMember(id=217)
user.avatar_url_as.return_value = "avatar url"
- embed = asyncio.run(self.cog.create_user_embed(ctx, user))
+ embed = await self.cog.create_user_embed(ctx, user)
user.avatar_url_as.assert_called_once_with(static_format="png")
self.assertEqual(embed.thumbnail.url, "avatar url")
@unittest.mock.patch("bot.exts.info.information.constants")
-class UserCommandTests(unittest.TestCase):
+class UserCommandTests(unittest.IsolatedAsyncioTestCase):
"""Tests for the `!user` command."""
def setUp(self):
@@ -536,16 +463,16 @@ class UserCommandTests(unittest.TestCase):
# used as a default value for a parameter, which gets defined upon import.
self.bot_command_channel = helpers.MockTextChannel(id=constants.Channels.bot_commands)
- def test_regular_member_cannot_target_another_member(self, constants):
+ async def test_regular_member_cannot_target_another_member(self, constants):
"""A regular user should not be able to use `!user` targeting another user."""
constants.MODERATION_ROLES = [self.moderator_role.id]
ctx = helpers.MockContext(author=self.author)
- asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.target))
+ await self.cog.user_info(self.cog, ctx, self.target)
ctx.send.assert_called_once_with("You may not use this command on users other than yourself.")
- def test_regular_member_cannot_use_command_outside_of_bot_commands(self, constants):
+ async def test_regular_member_cannot_use_command_outside_of_bot_commands(self, constants):
"""A regular user should not be able to use this command outside of bot-commands."""
constants.MODERATION_ROLES = [self.moderator_role.id]
constants.STAFF_ROLES = [self.moderator_role.id]
@@ -553,49 +480,49 @@ class UserCommandTests(unittest.TestCase):
msg = "Sorry, but you may only use this command within <#50>."
with self.assertRaises(InWhitelistCheckFailure, msg=msg):
- asyncio.run(self.cog.user_info.callback(self.cog, ctx))
+ await self.cog.user_info(self.cog, ctx)
@unittest.mock.patch("bot.exts.info.information.Information.create_user_embed")
- def test_regular_user_may_use_command_in_bot_commands_channel(self, create_embed, constants):
+ async def test_regular_user_may_use_command_in_bot_commands_channel(self, create_embed, constants):
"""A regular user should be allowed to use `!user` targeting themselves in bot-commands."""
constants.STAFF_ROLES = [self.moderator_role.id]
ctx = helpers.MockContext(author=self.author, channel=self.bot_command_channel)
- asyncio.run(self.cog.user_info.callback(self.cog, ctx))
+ await self.cog.user_info(self.cog, ctx)
create_embed.assert_called_once_with(ctx, self.author)
ctx.send.assert_called_once()
@unittest.mock.patch("bot.exts.info.information.Information.create_user_embed")
- def test_regular_user_can_explicitly_target_themselves(self, create_embed, _):
+ async def test_regular_user_can_explicitly_target_themselves(self, create_embed, _):
"""A user should target itself with `!user` when a `user` argument was not provided."""
constants.STAFF_ROLES = [self.moderator_role.id]
ctx = helpers.MockContext(author=self.author, channel=self.bot_command_channel)
- asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.author))
+ await self.cog.user_info(self.cog, ctx, self.author)
create_embed.assert_called_once_with(ctx, self.author)
ctx.send.assert_called_once()
@unittest.mock.patch("bot.exts.info.information.Information.create_user_embed")
- def test_staff_members_can_bypass_channel_restriction(self, create_embed, constants):
+ async def test_staff_members_can_bypass_channel_restriction(self, create_embed, constants):
"""Staff members should be able to bypass the bot-commands channel restriction."""
constants.STAFF_ROLES = [self.moderator_role.id]
ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(id=200))
- asyncio.run(self.cog.user_info.callback(self.cog, ctx))
+ await self.cog.user_info(self.cog, ctx)
create_embed.assert_called_once_with(ctx, self.moderator)
ctx.send.assert_called_once()
@unittest.mock.patch("bot.exts.info.information.Information.create_user_embed")
- def test_moderators_can_target_another_member(self, create_embed, constants):
+ async def test_moderators_can_target_another_member(self, create_embed, constants):
"""A moderator should be able to use `!user` targeting another user."""
constants.MODERATION_ROLES = [self.moderator_role.id]
constants.STAFF_ROLES = [self.moderator_role.id]
ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(id=50))
- asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.target))
+ await self.cog.user_info(self.cog, ctx, self.target)
create_embed.assert_called_once_with(ctx, self.target)
ctx.send.assert_called_once()
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 e2d44c637..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.callback(self.cog, self.ctx, duration)
- self.ctx.send.assert_called_once_with(result_message)
- self.ctx.reset_mock()
-
- async def test_unsilence_sent_correct_discord_message(self):
- """Check if proper message was sent 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.callback(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 40b2202aa..321a92445 100644
--- a/tests/bot/exts/utils/test_snekbox.py
+++ b/tests/bot/exts/utils/test_snekbox.py
@@ -42,9 +42,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):
async def test_upload_output(self, mock_paste_util):
"""Upload the eval output to the URLs.paste_service.format(key="documents") endpoint."""
await self.cog.upload_output("Test output.")
- mock_paste_util.assert_called_once_with(
- self.bot.http_session, "Test output.", extension="txt"
- )
+ mock_paste_util.assert_called_once_with("Test output.", extension="txt")
def test_prepare_input(self):
cases = (
@@ -52,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}.'):
@@ -154,7 +159,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):
self.cog.send_eval = AsyncMock(return_value=response)
self.cog.continue_eval = AsyncMock(return_value=None)
- await self.cog.eval_command.callback(self.cog, ctx=ctx, code='MyAwesomeCode')
+ await self.cog.eval_command(self.cog, ctx=ctx, code='MyAwesomeCode')
self.cog.prepare_input.assert_called_once_with('MyAwesomeCode')
self.cog.send_eval.assert_called_once_with(ctx, 'MyAwesomeFormattedCode')
self.cog.continue_eval.assert_called_once_with(ctx, response)
@@ -168,7 +173,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):
self.cog.continue_eval = AsyncMock()
self.cog.continue_eval.side_effect = ('MyAwesomeCode-2', None)
- await self.cog.eval_command.callback(self.cog, ctx=ctx, code='MyAwesomeCode')
+ await self.cog.eval_command(self.cog, ctx=ctx, code='MyAwesomeCode')
self.cog.prepare_input.has_calls(call('MyAwesomeCode'), call('MyAwesomeCode-2'))
self.cog.send_eval.assert_called_with(ctx, 'MyAwesomeFormattedCode')
self.cog.continue_eval.assert_called_with(ctx, response)
@@ -180,7 +185,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):
ctx.author.mention = '@LemonLemonishBeard#0042'
ctx.send = AsyncMock()
self.cog.jobs = (42,)
- await self.cog.eval_command.callback(self.cog, ctx=ctx, code='MyAwesomeCode')
+ await self.cog.eval_command(self.cog, ctx=ctx, code='MyAwesomeCode')
ctx.send.assert_called_once_with(
"@LemonLemonishBeard#0042 You've already got a job running - please wait for it to finish!"
)
@@ -188,8 +193,8 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):
async def test_eval_command_call_help(self):
"""Test if the eval command call the help command if no code is provided."""
ctx = MockContext(command="sentinel")
- await self.cog.eval_command.callback(self.cog, ctx=ctx, code='')
- ctx.send_help.assert_called_once_with("sentinel")
+ await self.cog.eval_command(self.cog, ctx=ctx, code='')
+ ctx.send_help.assert_called_once_with(ctx.command)
async def test_send_eval(self):
"""Test the send_eval function."""
@@ -290,7 +295,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):
)
)
ctx.message.add_reaction.assert_called_once_with(snekbox.REEVAL_EMOJI)
- ctx.message.clear_reactions.assert_called_once()
+ ctx.message.clear_reaction.assert_called_once_with(snekbox.REEVAL_EMOJI)
response.delete.assert_called_once()
async def test_continue_eval_does_not_continue(self):
@@ -299,7 +304,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):
actual = await self.cog.continue_eval(ctx, MockMessage())
self.assertEqual(actual, None)
- ctx.message.clear_reactions.assert_called_once()
+ ctx.message.clear_reaction.assert_called_once_with(snekbox.REEVAL_EMOJI)
async def test_get_code(self):
"""Should return 1st arg (or None) if eval cmd in message, otherwise return full content."""
diff --git a/tests/bot/patches/__init__.py b/tests/bot/patches/__init__.py
deleted file mode 100644
index e69de29bb..000000000
--- a/tests/bot/patches/__init__.py
+++ /dev/null
diff --git a/tests/bot/rules/test_discord_emojis.py b/tests/bot/rules/test_discord_emojis.py
index 9a72723e2..66c2d9f92 100644
--- a/tests/bot/rules/test_discord_emojis.py
+++ b/tests/bot/rules/test_discord_emojis.py
@@ -5,11 +5,12 @@ from tests.bot.rules import DisallowedCase, RuleTest
from tests.helpers import MockMessage
discord_emoji = "<:abcd:1234>" # Discord emojis follow the format <:name:id>
+unicode_emoji = "🧪"
-def make_msg(author: str, n_emojis: int) -> MockMessage:
+def make_msg(author: str, n_emojis: int, emoji: str = discord_emoji) -> MockMessage:
"""Build a MockMessage instance with content containing `n_emojis` arbitrary emojis."""
- return MockMessage(author=author, content=discord_emoji * n_emojis)
+ return MockMessage(author=author, content=emoji * n_emojis)
class DiscordEmojisRuleTests(RuleTest):
@@ -20,16 +21,22 @@ class DiscordEmojisRuleTests(RuleTest):
self.config = {"max": 2, "interval": 10}
async def test_allows_messages_within_limit(self):
- """Cases with a total amount of discord emojis within limit."""
+ """Cases with a total amount of discord and unicode emojis within limit."""
cases = (
[make_msg("bob", 2)],
[make_msg("alice", 1), make_msg("bob", 2), make_msg("alice", 1)],
+ [make_msg("bob", 2, unicode_emoji)],
+ [
+ make_msg("alice", 1, unicode_emoji),
+ make_msg("bob", 2, unicode_emoji),
+ make_msg("alice", 1, unicode_emoji)
+ ],
)
await self.run_allowed(cases)
async def test_disallows_messages_beyond_limit(self):
- """Cases with more than the allowed amount of discord emojis."""
+ """Cases with more than the allowed amount of discord and unicode emojis."""
cases = (
DisallowedCase(
[make_msg("bob", 3)],
@@ -41,6 +48,20 @@ class DiscordEmojisRuleTests(RuleTest):
("alice",),
4,
),
+ DisallowedCase(
+ [make_msg("bob", 3, unicode_emoji)],
+ ("bob",),
+ 3,
+ ),
+ DisallowedCase(
+ [
+ make_msg("alice", 2, unicode_emoji),
+ make_msg("bob", 2, unicode_emoji),
+ make_msg("alice", 2, unicode_emoji)
+ ],
+ ("alice",),
+ 4
+ )
)
await self.run_disallowed(cases)
diff --git a/tests/bot/utils/test_services.py b/tests/bot/utils/test_services.py
index 5e0855704..1b48f6560 100644
--- a/tests/bot/utils/test_services.py
+++ b/tests/bot/utils/test_services.py
@@ -5,11 +5,14 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch
from aiohttp import ClientConnectorError
from bot.utils.services import FAILED_REQUEST_ATTEMPTS, send_to_paste_service
+from tests.helpers import MockBot
class PasteTests(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
- self.http_session = MagicMock()
+ patcher = patch("bot.instance", new=MockBot())
+ self.bot = patcher.start()
+ self.addCleanup(patcher.stop)
@patch("bot.utils.services.URLs.paste_service", "https://paste_service.com/{key}")
async def test_url_and_sent_contents(self):
@@ -17,10 +20,10 @@ class PasteTests(unittest.IsolatedAsyncioTestCase):
response = MagicMock(
json=AsyncMock(return_value={"key": ""})
)
- self.http_session.post().__aenter__.return_value = response
- self.http_session.post.reset_mock()
- await send_to_paste_service(self.http_session, "Content")
- self.http_session.post.assert_called_once_with("https://paste_service.com/documents", data="Content")
+ self.bot.http_session.post.return_value.__aenter__.return_value = response
+ self.bot.http_session.post.reset_mock()
+ await send_to_paste_service("Content")
+ self.bot.http_session.post.assert_called_once_with("https://paste_service.com/documents", data="Content")
@patch("bot.utils.services.URLs.paste_service", "https://paste_service.com/{key}")
async def test_paste_returns_correct_url_on_success(self):
@@ -34,41 +37,41 @@ class PasteTests(unittest.IsolatedAsyncioTestCase):
response = MagicMock(
json=AsyncMock(return_value={"key": key})
)
- self.http_session.post().__aenter__.return_value = response
+ self.bot.http_session.post.return_value.__aenter__.return_value = response
for expected_output, extension in test_cases:
with self.subTest(msg=f"Send contents with extension {repr(extension)}"):
self.assertEqual(
- await send_to_paste_service(self.http_session, "", extension=extension),
+ await send_to_paste_service("", extension=extension),
expected_output
)
async def test_request_repeated_on_json_errors(self):
"""Json with error message and invalid json are handled as errors and requests repeated."""
test_cases = ({"message": "error"}, {"unexpected_key": None}, {})
- self.http_session.post().__aenter__.return_value = response = MagicMock()
- self.http_session.post.reset_mock()
+ self.bot.http_session.post.return_value.__aenter__.return_value = response = MagicMock()
+ self.bot.http_session.post.reset_mock()
for error_json in test_cases:
with self.subTest(error_json=error_json):
response.json = AsyncMock(return_value=error_json)
- result = await send_to_paste_service(self.http_session, "")
- self.assertEqual(self.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS)
+ result = await send_to_paste_service("")
+ self.assertEqual(self.bot.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS)
self.assertIsNone(result)
- self.http_session.post.reset_mock()
+ self.bot.http_session.post.reset_mock()
async def test_request_repeated_on_connection_errors(self):
"""Requests are repeated in the case of connection errors."""
- self.http_session.post = MagicMock(side_effect=ClientConnectorError(Mock(), Mock()))
- result = await send_to_paste_service(self.http_session, "")
- self.assertEqual(self.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS)
+ self.bot.http_session.post = MagicMock(side_effect=ClientConnectorError(Mock(), Mock()))
+ result = await send_to_paste_service("")
+ self.assertEqual(self.bot.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS)
self.assertIsNone(result)
async def test_general_error_handled_and_request_repeated(self):
"""All `Exception`s are handled, logged and request repeated."""
- self.http_session.post = MagicMock(side_effect=Exception)
- result = await send_to_paste_service(self.http_session, "")
- self.assertEqual(self.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS)
+ self.bot.http_session.post = MagicMock(side_effect=Exception)
+ result = await send_to_paste_service("")
+ self.assertEqual(self.bot.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS)
self.assertLogs("bot.utils", logging.ERROR)
self.assertIsNone(result)
diff --git a/tests/helpers.py b/tests/helpers.py
index d35012278..ea5e2822c 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.