aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Andi Qu <[email protected]>2020-10-27 15:23:38 +0200
committerGravatar Andi Qu <[email protected]>2020-10-27 15:23:38 +0200
commit420e4fda56e4d8bb3335c36cb0af08f084e0957b (patch)
treec0697ac3beb344eeeeb7ff953c078c9ee49e80c9
parentMerge branch 'master' of https://github.com/dolphingarlic/bot (diff)
parentPR #1248: Set async-rediscache logging level to warning (diff)
Merge branch 'master' of https://github.com/python-discord/bot into python-discord-master
-rw-r--r--.gitignore1
-rw-r--r--LICENSE-THIRD-PARTY88
-rw-r--r--Pipfile7
-rw-r--r--Pipfile.lock600
-rw-r--r--README.md2
-rw-r--r--bot/__init__.py12
-rw-r--r--bot/__main__.py91
-rw-r--r--bot/bot.py173
-rw-r--r--bot/cogs/alias.py153
-rw-r--r--bot/cogs/bot.py381
-rw-r--r--bot/cogs/moderation/__init__.py15
-rw-r--r--bot/cogs/moderation/silence.py184
-rw-r--r--bot/cogs/sync/__init__.py7
-rw-r--r--bot/cogs/sync/syncers.py342
-rw-r--r--bot/cogs/verification.py191
-rw-r--r--bot/cogs/watchchannels/__init__.py9
-rw-r--r--bot/cogs/wolfram.py280
-rw-r--r--bot/command.py18
-rw-r--r--bot/constants.py138
-rw-r--r--bot/converters.py217
-rw-r--r--bot/decorators.py122
-rw-r--r--bot/errors.py20
-rw-r--r--bot/exts/__init__.py (renamed from bot/cogs/__init__.py)0
-rw-r--r--bot/exts/backend/__init__.py (renamed from tests/bot/cogs/__init__.py)0
-rw-r--r--bot/exts/backend/config_verifier.py (renamed from bot/cogs/config_verifier.py)0
-rw-r--r--bot/exts/backend/error_handler.py (renamed from bot/cogs/error_handler.py)35
-rw-r--r--bot/exts/backend/logging.py (renamed from bot/cogs/logging.py)0
-rw-r--r--bot/exts/backend/sync/__init__.py8
-rw-r--r--bot/exts/backend/sync/_cog.py (renamed from bot/cogs/sync/cog.py)6
-rw-r--r--bot/exts/backend/sync/_syncers.py208
-rw-r--r--bot/exts/filters/__init__.py (renamed from tests/bot/cogs/moderation/__init__.py)0
-rw-r--r--bot/exts/filters/antimalware.py (renamed from bot/cogs/antimalware.py)30
-rw-r--r--bot/exts/filters/antispam.py (renamed from bot/cogs/antispam.py)15
-rw-r--r--bot/exts/filters/filter_lists.py272
-rw-r--r--bot/exts/filters/filtering.py (renamed from bot/cogs/filtering.py)378
-rw-r--r--bot/exts/filters/security.py (renamed from bot/cogs/security.py)0
-rw-r--r--bot/exts/filters/token_remover.py (renamed from bot/cogs/token_remover.py)85
-rw-r--r--bot/exts/filters/webhook_remover.py (renamed from bot/cogs/webhook_remover.py)9
-rw-r--r--bot/exts/fun/__init__.py (renamed from tests/bot/cogs/sync/__init__.py)0
-rw-r--r--bot/exts/fun/duck_pond.py (renamed from bot/cogs/duck_pond.py)150
-rw-r--r--bot/exts/fun/off_topic_names.py (renamed from bot/cogs/off_topic_names.py)49
-rw-r--r--bot/exts/help_channels.py (renamed from bot/cogs/help_channels.py)225
-rw-r--r--bot/exts/info/__init__.py (renamed from tests/bot/patches/__init__.py)0
-rw-r--r--bot/exts/info/code_snippets.py (renamed from bot/cogs/code_snippets.py)0
-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.py (renamed from bot/cogs/doc.py)13
-rw-r--r--bot/exts/info/help.py (renamed from bot/cogs/help.py)50
-rw-r--r--bot/exts/info/information.py (renamed from bot/cogs/information.py)167
-rw-r--r--bot/exts/info/python_news.py (renamed from bot/cogs/python_news.py)72
-rw-r--r--bot/exts/info/reddit.py (renamed from bot/cogs/reddit.py)22
-rw-r--r--bot/exts/info/site.py (renamed from bot/cogs/site.py)31
-rw-r--r--bot/exts/info/source.py131
-rw-r--r--bot/exts/info/stats.py (renamed from bot/cogs/stats.py)42
-rw-r--r--bot/exts/info/tags.py (renamed from bot/cogs/tags.py)7
-rw-r--r--bot/exts/moderation/__init__.py0
-rw-r--r--bot/exts/moderation/defcon.py (renamed from bot/cogs/defcon.py)24
-rw-r--r--bot/exts/moderation/dm_relay.py128
-rw-r--r--bot/exts/moderation/incidents.py412
-rw-r--r--bot/exts/moderation/infraction/__init__.py0
-rw-r--r--bot/exts/moderation/infraction/_scheduler.py (renamed from bot/cogs/moderation/scheduler.py)119
-rw-r--r--bot/exts/moderation/infraction/_utils.py (renamed from bot/cogs/moderation/utils.py)42
-rw-r--r--bot/exts/moderation/infraction/infractions.py (renamed from bot/cogs/moderation/infractions.py)155
-rw-r--r--bot/exts/moderation/infraction/management.py (renamed from bot/cogs/moderation/management.py)96
-rw-r--r--bot/exts/moderation/infraction/superstarify.py (renamed from bot/cogs/moderation/superstarify.py)53
-rw-r--r--bot/exts/moderation/modlog.py (renamed from bot/cogs/moderation/modlog.py)150
-rw-r--r--bot/exts/moderation/silence.py255
-rw-r--r--bot/exts/moderation/slowmode.py96
-rw-r--r--bot/exts/moderation/verification.py856
-rw-r--r--bot/exts/moderation/voice_gate.py168
-rw-r--r--bot/exts/moderation/watchchannels/__init__.py0
-rw-r--r--bot/exts/moderation/watchchannels/_watchchannel.py (renamed from bot/cogs/watchchannels/watchchannel.py)22
-rw-r--r--bot/exts/moderation/watchchannels/bigbrother.py (renamed from bot/cogs/watchchannels/bigbrother.py)45
-rw-r--r--bot/exts/moderation/watchchannels/talentpool.py (renamed from bot/cogs/watchchannels/talentpool.py)103
-rw-r--r--bot/exts/utils/__init__.py0
-rw-r--r--bot/exts/utils/bot.py66
-rw-r--r--bot/exts/utils/clean.py (renamed from bot/cogs/clean.py)55
-rw-r--r--bot/exts/utils/extensions.py (renamed from bot/cogs/extensions.py)74
-rw-r--r--bot/exts/utils/internal.py (renamed from bot/cogs/eval.py)72
-rw-r--r--bot/exts/utils/jams.py (renamed from bot/cogs/jams.py)89
-rw-r--r--bot/exts/utils/ping.py59
-rw-r--r--bot/exts/utils/reminders.py (renamed from bot/cogs/reminders.py)260
-rw-r--r--bot/exts/utils/snekbox.py (renamed from bot/cogs/snekbox.py)78
-rw-r--r--bot/exts/utils/utils.py (renamed from bot/cogs/utils.py)50
-rw-r--r--bot/pagination.py174
-rw-r--r--bot/patches/__init__.py6
-rw-r--r--bot/patches/message_edited_at.py32
-rw-r--r--bot/resources/tags/ask.md9
-rw-r--r--bot/resources/tags/kindling-projects.md3
-rw-r--r--bot/resources/tags/or-gotcha.md2
-rw-r--r--bot/resources/tags/range-len.md11
-rw-r--r--bot/resources/tags/traceback.md2
-rw-r--r--bot/rules/burst_shared.py11
-rw-r--r--bot/rules/discord_emojis.py4
-rw-r--r--bot/utils/__init__.py20
-rw-r--r--bot/utils/channel.py49
-rw-r--r--bot/utils/checks.py49
-rw-r--r--bot/utils/extensions.py34
-rw-r--r--bot/utils/function.py75
-rw-r--r--bot/utils/helpers.py32
-rw-r--r--bot/utils/lock.py114
-rw-r--r--bot/utils/messages.py90
-rw-r--r--bot/utils/redis_cache.py415
-rw-r--r--bot/utils/regex.py12
-rw-r--r--bot/utils/scheduling.py146
-rw-r--r--bot/utils/services.py54
-rw-r--r--bot/utils/time.py4
-rw-r--r--bot/utils/webhooks.py34
-rw-r--r--config-default.yml361
-rw-r--r--docker-compose.yml1
-rw-r--r--tests/_autospec.py64
-rw-r--r--tests/bot/cogs/moderation/test_infractions.py55
-rw-r--r--tests/bot/cogs/moderation/test_silence.py261
-rw-r--r--tests/bot/cogs/sync/test_base.py404
-rw-r--r--tests/bot/cogs/test_duck_pond.py575
-rw-r--r--tests/bot/exts/__init__.py0
-rw-r--r--tests/bot/exts/backend/__init__.py0
-rw-r--r--tests/bot/exts/backend/sync/__init__.py0
-rw-r--r--tests/bot/exts/backend/sync/test_base.py73
-rw-r--r--tests/bot/exts/backend/sync/test_cog.py (renamed from tests/bot/cogs/sync/test_cog.py)21
-rw-r--r--tests/bot/exts/backend/sync/test_roles.py (renamed from tests/bot/cogs/sync/test_roles.py)2
-rw-r--r--tests/bot/exts/backend/sync/test_users.py (renamed from tests/bot/cogs/sync/test_users.py)120
-rw-r--r--tests/bot/exts/backend/test_logging.py (renamed from tests/bot/cogs/test_logging.py)6
-rw-r--r--tests/bot/exts/filters/__init__.py0
-rw-r--r--tests/bot/exts/filters/test_antimalware.py (renamed from tests/bot/cogs/test_antimalware.py)48
-rw-r--r--tests/bot/exts/filters/test_antispam.py (renamed from tests/bot/cogs/test_antispam.py)2
-rw-r--r--tests/bot/exts/filters/test_security.py (renamed from tests/bot/cogs/test_security.py)2
-rw-r--r--tests/bot/exts/filters/test_token_remover.py (renamed from tests/bot/cogs/test_token_remover.py)176
-rw-r--r--tests/bot/exts/info/__init__.py0
-rw-r--r--tests/bot/exts/info/test_information.py (renamed from tests/bot/cogs/test_information.py)296
-rw-r--r--tests/bot/exts/moderation/__init__.py0
-rw-r--r--tests/bot/exts/moderation/infraction/__init__.py0
-rw-r--r--tests/bot/exts/moderation/infraction/test_infractions.py201
-rw-r--r--tests/bot/exts/moderation/infraction/test_utils.py359
-rw-r--r--tests/bot/exts/moderation/test_incidents.py770
-rw-r--r--tests/bot/exts/moderation/test_modlog.py (renamed from tests/bot/cogs/moderation/test_modlog.py)2
-rw-r--r--tests/bot/exts/moderation/test_silence.py502
-rw-r--r--tests/bot/exts/moderation/test_slowmode.py113
-rw-r--r--tests/bot/exts/test_cogs.py (renamed from tests/bot/cogs/test_cogs.py)8
-rw-r--r--tests/bot/exts/utils/__init__.py0
-rw-r--r--tests/bot/exts/utils/test_jams.py173
-rw-r--r--tests/bot/exts/utils/test_snekbox.py (renamed from tests/bot/cogs/test_snekbox.py)93
-rw-r--r--tests/bot/test_pagination.py15
-rw-r--r--tests/bot/utils/test_checks.py44
-rw-r--r--tests/bot/utils/test_redis_cache.py265
-rw-r--r--tests/bot/utils/test_services.py74
-rw-r--r--tests/helpers.py27
149 files changed, 9471 insertions, 6213 deletions
diff --git a/.gitignore b/.gitignore
index fb3156ab1..2074887ad 100644
--- a/.gitignore
+++ b/.gitignore
@@ -110,6 +110,7 @@ ENV/
# Logfiles
log.*
+*.log.*
# 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 33be99587..99fc70b46 100644
--- a/Pipfile
+++ b/Pipfile
@@ -7,13 +7,14 @@ name = "pypi"
aio-pika = "~=6.1"
aiodns = "~=2.0"
aiohttp = "~=3.5"
+aioping = "~=0.3.1"
aioredis = "~=1.3.1"
+"async-rediscache[fakeredis]" = "~=0.1.2"
beautifulsoup4 = "~=4.9"
colorama = {version = "~=0.4.3",sys_platform = "== 'win32'"}
coloredlogs = "~=14.0"
deepdiff = "~=4.0"
-discord.py = "~=1.3.2"
-fakeredis = "~=1.4"
+"discord.py" = "~=1.5.0"
feedparser = "~=5.2"
fuzzywuzzy = "~=0.17"
lxml = "~=4.4"
@@ -28,7 +29,7 @@ statsd = "~=3.3"
[dev-packages]
coverage = "~=5.0"
-flake8 = "~=3.7"
+flake8 = "~=3.8"
flake8-annotations = "~=2.0"
flake8-bugbear = "~=20.1"
flake8-docstrings = "~=1.4"
diff --git a/Pipfile.lock b/Pipfile.lock
index 0e591710c..becd85c55 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
- "sha256": "0297accc3d614d3da8080b89d56ef7fe489c28a0ada8102df396a604af7ee330"
+ "sha256": "073fd0c51749aafa188fdbe96c5b90dd157cb1d23bdd144801fb0d0a369ffa88"
},
"pipfile-spec": 6,
"requires": {
@@ -18,11 +18,11 @@
"default": {
"aio-pika": {
"hashes": [
- "sha256:c4cbbeb85b3c7bf81bc127371846cd949e6231717ce1e6ac7ee1dd5ede21f866",
- "sha256:ec7fef24f588d90314873463ab4f2c3debce0bd8830e49e3786586be96bc2e8e"
+ "sha256:9773440a89840941ac3099a7720bf9d51e8764a484066b82ede4d395660ff430",
+ "sha256:a8065be3c722eb8f9fff8c0e7590729e7782202cdb9363d9830d7d5d47b45c7c"
],
"index": "pypi",
- "version": "==6.6.1"
+ "version": "==6.7.1"
},
"aiodns": {
"hashes": [
@@ -50,6 +50,14 @@
"index": "pypi",
"version": "==3.6.2"
},
+ "aioping": {
+ "hashes": [
+ "sha256:8900ef2f5a589ba0c12aaa9c2d586f5371820d468d21b374ddb47ef5fc8f297c",
+ "sha256:f983d86acab3a04c322731ce88d42c55d04d2842565fc8532fe10c838abfd275"
+ ],
+ "index": "pypi",
+ "version": "==0.3.1"
+ },
"aioredis": {
"hashes": [
"sha256:15f8af30b044c771aee6787e5ec24694c048184c7b9e54c3b60c750a4b93273a",
@@ -60,10 +68,11 @@
},
"aiormq": {
"hashes": [
- "sha256:41a9d4eb17db805f30ed172f3f609fe0c2b16657fb15b1b67df19d251dd93c0d",
- "sha256:7c19477a9450824cb79f9949fd238f4148e2c0dca67756a2868863c387209f04"
+ "sha256:106695a836f19c1af6c46b58e8aac80e00f86c5b3287a3c6483a1ee369cc95c9",
+ "sha256:9f6dbf6155fe2b7a3d24bf68de97fb812db0fac0a54e96bc1af14ea95078ba7f"
],
- "version": "==3.2.2"
+ "markers": "python_version >= '3.6'",
+ "version": "==3.2.3"
},
"alabaster": {
"hashes": [
@@ -72,75 +81,98 @@
],
"version": "==0.7.12"
},
+ "async-rediscache": {
+ "extras": [
+ "fakeredis"
+ ],
+ "hashes": [
+ "sha256:6be8a657d724ccbcfb1946d29a80c3478c5f9ecd2f78a0a26d2f4013a622258f",
+ "sha256:c25e4fff73f64d20645254783c3224a4c49e083e3fab67c44f17af944c5e26af"
+ ],
+ "index": "pypi",
+ "markers": "python_version ~= '3.7'",
+ "version": "==0.1.4"
+ },
"async-timeout": {
"hashes": [
"sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f",
"sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3"
],
+ "markers": "python_full_version >= '3.5.3'",
"version": "==3.0.1"
},
"attrs": {
"hashes": [
- "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c",
- "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"
+ "sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594",
+ "sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc"
],
- "version": "==19.3.0"
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "version": "==20.2.0"
},
"babel": {
"hashes": [
"sha256:1aac2ae2d0d8ea368fa90906567f5c08463d98ade155c0c4bfedd6a0f7160e38",
"sha256:d670ea0b10f8b723672d3a6abeb87b565b244da220d76b4dba1b66269ec152d4"
],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.8.0"
},
"beautifulsoup4": {
"hashes": [
- "sha256:73cc4d115b96f79c7d77c1c7f7a0a8d4c57860d1041df407dd1aae7f07a77fd7",
- "sha256:a6237df3c32ccfaee4fd201c8f5f9d9df619b93121d01353a64a73ce8c6ef9a8",
- "sha256:e718f2342e2e099b640a34ab782407b7b676f47ee272d6739e60b8ea23829f2c"
+ "sha256:1edf5e39f3a5bc6e38b235b369128416c7239b34f692acccececb040233032a1",
+ "sha256:5dfe44f8fddc89ac5453f02659d3ab1668f2c0d9684839f0785037e8c6d9ac8d",
+ "sha256:645d833a828722357038299b7f6879940c11dddd95b900fe5387c258b72bb883"
],
"index": "pypi",
- "version": "==4.9.1"
+ "version": "==4.9.2"
},
"certifi": {
"hashes": [
- "sha256:1d987a998c75633c40847cc966fcf5904906c920a7f17ef374f5aa4282abd304",
- "sha256:51fcb31174be6e6664c5f69e3e1691a2d72a1a12e90f872cbdb1567eb47b6519"
+ "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3",
+ "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41"
],
- "version": "==2020.4.5.1"
+ "version": "==2020.6.20"
},
"cffi": {
"hashes": [
- "sha256:001bf3242a1bb04d985d63e138230802c6c8d4db3668fb545fb5005ddf5bb5ff",
- "sha256:00789914be39dffba161cfc5be31b55775de5ba2235fe49aa28c148236c4e06b",
- "sha256:028a579fc9aed3af38f4892bdcc7390508adabc30c6af4a6e4f611b0c680e6ac",
- "sha256:14491a910663bf9f13ddf2bc8f60562d6bc5315c1f09c704937ef17293fb85b0",
- "sha256:1cae98a7054b5c9391eb3249b86e0e99ab1e02bb0cc0575da191aedadbdf4384",
- "sha256:2089ed025da3919d2e75a4d963d008330c96751127dd6f73c8dc0c65041b4c26",
- "sha256:2d384f4a127a15ba701207f7639d94106693b6cd64173d6c8988e2c25f3ac2b6",
- "sha256:337d448e5a725bba2d8293c48d9353fc68d0e9e4088d62a9571def317797522b",
- "sha256:399aed636c7d3749bbed55bc907c3288cb43c65c4389964ad5ff849b6370603e",
- "sha256:3b911c2dbd4f423b4c4fcca138cadde747abdb20d196c4a48708b8a2d32b16dd",
- "sha256:3d311bcc4a41408cf5854f06ef2c5cab88f9fded37a3b95936c9879c1640d4c2",
- "sha256:62ae9af2d069ea2698bf536dcfe1e4eed9090211dbaafeeedf5cb6c41b352f66",
- "sha256:66e41db66b47d0d8672d8ed2708ba91b2f2524ece3dee48b5dfb36be8c2f21dc",
- "sha256:675686925a9fb403edba0114db74e741d8181683dcf216be697d208857e04ca8",
- "sha256:7e63cbcf2429a8dbfe48dcc2322d5f2220b77b2e17b7ba023d6166d84655da55",
- "sha256:8a6c688fefb4e1cd56feb6c511984a6c4f7ec7d2a1ff31a10254f3c817054ae4",
- "sha256:8c0ffc886aea5df6a1762d0019e9cb05f825d0eec1f520c51be9d198701daee5",
- "sha256:95cd16d3dee553f882540c1ffe331d085c9e629499ceadfbda4d4fde635f4b7d",
- "sha256:99f748a7e71ff382613b4e1acc0ac83bf7ad167fb3802e35e90d9763daba4d78",
- "sha256:b8c78301cefcf5fd914aad35d3c04c2b21ce8629b5e4f4e45ae6812e461910fa",
- "sha256:c420917b188a5582a56d8b93bdd8e0f6eca08c84ff623a4c16e809152cd35793",
- "sha256:c43866529f2f06fe0edc6246eb4faa34f03fe88b64a0a9a942561c8e22f4b71f",
- "sha256:cab50b8c2250b46fe738c77dbd25ce017d5e6fb35d3407606e7a4180656a5a6a",
- "sha256:cef128cb4d5e0b3493f058f10ce32365972c554572ff821e175dbc6f8ff6924f",
- "sha256:cf16e3cf6c0a5fdd9bc10c21687e19d29ad1fe863372b5543deaec1039581a30",
- "sha256:e56c744aa6ff427a607763346e4170629caf7e48ead6921745986db3692f987f",
- "sha256:e577934fc5f8779c554639376beeaa5657d54349096ef24abe8c74c5d9c117c3",
- "sha256:f2b0fa0c01d8a0c7483afd9f31d7ecf2d71760ca24499c8697aeb5ca37dc090c"
- ],
- "version": "==1.14.0"
+ "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": [
@@ -154,7 +186,6 @@
"sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff",
"sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"
],
- "index": "pypi",
"markers": "sys_platform == 'win32'",
"version": "==0.4.3"
},
@@ -174,35 +205,28 @@
"index": "pypi",
"version": "==4.3.2"
},
- "discord": {
- "hashes": [
- "sha256:9d4debb4a37845543bd4b92cb195bc53a302797333e768e70344222857ff1559",
- "sha256:ff6653655e342e7721dfb3f10421345fd852c2a33f2cca912b1c39b3778a9429"
- ],
- "index": "pypi",
- "version": "==1.0.1"
- },
"discord.py": {
"hashes": [
- "sha256:406871b06d86c3dc49fba63238519f28628dac946fef8a0e22988ff58ec05580",
- "sha256:ad00e34c72d2faa8db2157b651d05f3c415d7d05078e7e41dc9e8dc240051beb"
+ "sha256:3acb61fde0d862ed346a191d69c46021e6063673f63963bc984ae09a685ab211",
+ "sha256:e71089886aa157341644bdecad63a72ff56b44406b1a6467b66db31c8e5a5a15"
],
- "version": "==1.3.3"
+ "index": "pypi",
+ "version": "==1.5.0"
},
"docutils": {
"hashes": [
"sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af",
"sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc"
],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==0.16"
},
"fakeredis": {
"hashes": [
- "sha256:4d170886865a91dbc8b7f8cbd4e5d488f4c5f2f25dfae127f001617bbe9e8f97",
- "sha256:647b2593d349d9d4e566c8dadb2e4c71ba35be5bdc4f1f7ac2d565a12a965053"
+ "sha256:7ea0866ba5edb40fe2e9b1722535df0c7e6b91d518aa5f50d96c2fff3ea7f4c2",
+ "sha256:aad8836ffe0319ffbba66dcf872ac6e7e32d1f19790e31296ba58445efb0a5c7"
],
- "index": "pypi",
- "version": "==1.4.1"
+ "version": "==1.4.3"
},
"feedparser": {
"hashes": [
@@ -223,68 +247,78 @@
},
"hiredis": {
"hashes": [
- "sha256:01b577f84c20ecc9c07fc4c184231b08e3c3942de096fa99978e053de231c423",
- "sha256:01ff0900134166961c9e339df77c33b72f7edc5cb41739f0babcd9faa345926e",
- "sha256:03ed34a13316d0c34213c4fd46e0fa3a5299073f4d4f08e93fed8c2108b399b3",
- "sha256:040436e91df5143aff9e0debb49530d0b17a6bd52200ce568621c31ef581b10d",
- "sha256:091eb38fbf968d1c5b703e412bbbd25f43a7967d8400842cee33a5a07b33c27b",
- "sha256:102f9b9dc6ed57feb3a7c9bdf7e71cb7c278fe8df1edfcfe896bc3e0c2be9447",
- "sha256:2b4b392c7e3082860c8371fab3ae762139090f9115819e12d9f56060f9ede05d",
- "sha256:2c9cc0b986397b833073f466e6b9e9c70d1d4dc2c2c1b3e9cae3a23102ff296c",
- "sha256:2fa65a9df683bca72073cd77709ddeb289ea2b114d3775d225fbbcc5faf808c5",
- "sha256:38437a681f17c975fd22349e72c29bc643f8e7eb2d6dc5df419eac59afa4d7ce",
- "sha256:3b3428fa3cf1ee178807b52c9bee8950ab94cd4eaa9bfae8c1bbae3c49501d34",
- "sha256:3dd8c2fae7f5494978facb0e93297dd627b1a3f536f3b070cf0a7d9157a07dcb",
- "sha256:4414a96c212e732723b5c3d7c04d386ebbb2ec359e1de646322cbc3f875cbd0d",
- "sha256:48c627581ad4ef60adbac980981407939acf13a0e18f093502c7b542223c4f19",
- "sha256:4a60e71625a2d78d8ab84dfb2fa2cfd9458c964b6e6c04fea76d9ade153fb371",
- "sha256:585ace09f434e43d8a8dbeb366865b1a044d7c06319b3c7372a0a00e63b860f4",
- "sha256:74b364b3f06c9cf0a53f7df611045bc9437ed972a283fa1f0b12537236d23ddc",
- "sha256:75c65c3850e89e9daa68d1b9bedd5806f177d60aa5a7b0953b4829481cfc1f72",
- "sha256:7f052de8bf744730a9120dbdc67bfeb7605a01f69fb8e7ba5c475af33c24e145",
- "sha256:8113a7d5e87ecf57cd4ae263cc9e429adb9a3e59f5a7768da5d3312a8d0a051a",
- "sha256:84857ce239eb8ed191ac78e77ff65d52902f00f30f4ee83bf80eb71da73b70e6",
- "sha256:8644a48ddc4a40b3e3a6b9443f396c2ee353afb2d45656c4fc68d04a82e8e3f7",
- "sha256:936aa565e673536e8a211e43ec43197406f24cd1f290138bd143765079c8ba00",
- "sha256:9afeb88c67bbc663b9f27385c496da056d06ad87f55df6e393e1516cfecb0461",
- "sha256:9d62cc7880110e4f83b0a51d218f465d3095e2751fbddd34e553dbd106a929ff",
- "sha256:a1fadd062fc8d647ff39220c57ea2b48c99bb73f18223828ec97f88fc27e7898",
- "sha256:a7754a783b1e5d6f627c19d099b178059c62f782ab62b4d8ba165b9fbc2ee34c",
- "sha256:aa59dd63bb3f736de4fc2d080114429d5d369dfb3265f771778e8349d67a97a4",
- "sha256:ae2ee0992f8de249715435942137843a93db204dd7db1e7cc9bdc5a8436443e8",
- "sha256:b36842d7cf32929d568f37ec5b3173b72b2ec6572dec4d6be6ce774762215aee",
- "sha256:bcbf9379c553b5facc6c04c1e5569b44b38ff16bcbf354676287698d61ee0c92",
- "sha256:cbccbda6f1c62ab460449d9c85fdf24d0d32a6bf45176581151e53cc26a5d910",
- "sha256:d0caf98dfb8af395d6732bd16561c0a2458851bea522e39f12f04802dbf6f502",
- "sha256:d6456afeddba036def1a36d8a2758eca53202308d83db20ab5d0b66590919627",
- "sha256:dbaef9a21a4f10bc281684ee4124f169e62bb533c2a92b55f8c06f64f9af7b8f",
- "sha256:dce84916c09aaece006272b37234ae84a8ed13abb3a4d341a23933b8701abfb5",
- "sha256:eb8c9c8b9869539d58d60ff4a28373a22514d40495911451343971cb4835b7a9",
- "sha256:efc98b14ee3a8595e40b1425e8d42f5fd26f11a7b215a81ef9259068931754f4",
- "sha256:fa2dc05b87d97acc1c6ae63f3e0f39eae5246565232484b08db6bf2dc1580678",
- "sha256:fe7d6ce9f6a5fbe24f09d95ea93e9c7271abc4e1565da511e1449b107b4d7848"
- ],
- "version": "==1.0.1"
+ "sha256:06a039208f83744a702279b894c8cf24c14fd63c59cd917dcde168b79eef0680",
+ "sha256:0a909bf501459062aa1552be1461456518f367379fdc9fdb1f2ca5e4a1fdd7c0",
+ "sha256:18402d9e54fb278cb9a8c638df6f1550aca36a009d47ecf5aa263a38600f35b0",
+ "sha256:1e4cbbc3858ec7e680006e5ca590d89a5e083235988f26a004acf7244389ac01",
+ "sha256:23344e3c2177baf6975fbfa361ed92eb7d36d08f454636e5054b3faa7c2aff8a",
+ "sha256:289b31885b4996ce04cadfd5fc03d034dce8e2a8234479f7c9e23b9e245db06b",
+ "sha256:2c1c570ae7bf1bab304f29427e2475fe1856814312c4a1cf1cd0ee133f07a3c6",
+ "sha256:2c227c0ed371771ffda256034427320870e8ea2e4fd0c0a618c766e7c49aad73",
+ "sha256:3bb9b63d319402cead8bbd9dd55dca3b667d2997e9a0d8a1f9b6cc274db4baee",
+ "sha256:3ef2183de67b59930d2db8b8e8d4d58e00a50fcc5e92f4f678f6eed7a1c72d55",
+ "sha256:43b8ed3dbfd9171e44c554cb4acf4ee4505caa84c5e341858b50ea27dd2b6e12",
+ "sha256:47bcf3c5e6c1e87ceb86cdda2ee983fa0fe56a999e6185099b3c93a223f2fa9b",
+ "sha256:5263db1e2e1e8ae30500cdd75a979ff99dcc184201e6b4b820d0de74834d2323",
+ "sha256:5b1451727f02e7acbdf6aae4e06d75f66ee82966ff9114550381c3271a90f56c",
+ "sha256:6996883a8a6ff9117cbb3d6f5b0dcbbae6fb9e31e1a3e4e2f95e0214d9a1c655",
+ "sha256:6c96f64a54f030366657a54bb90b3093afc9c16c8e0dfa29fc0d6dbe169103a5",
+ "sha256:7332d5c3e35154cd234fd79573736ddcf7a0ade7a986db35b6196b9171493e75",
+ "sha256:7885b6f32c4a898e825bb7f56f36a02781ac4a951c63e4169f0afcf9c8c30dfb",
+ "sha256:7b0f63f10a166583ab744a58baad04e0f52cfea1ac27bfa1b0c21a48d1003c23",
+ "sha256:819f95d4eba3f9e484dd115ab7ab72845cf766b84286a00d4ecf76d33f1edca1",
+ "sha256:8968eeaa4d37a38f8ca1f9dbe53526b69628edc9c42229a5b2f56d98bb828c1f",
+ "sha256:89ebf69cb19a33d625db72d2ac589d26e936b8f7628531269accf4a3196e7872",
+ "sha256:8daecd778c1da45b8bd54fd41ffcd471a86beed3d8e57a43acf7a8d63bba4058",
+ "sha256:955ba8ea73cf3ed8bd2f963b4cb9f8f0dcb27becd2f4b3dd536fd24c45533454",
+ "sha256:964f18a59f5a64c0170f684c417f4fe3e695a536612e13074c4dd5d1c6d7c882",
+ "sha256:969843fbdfbf56cdb71da6f0bdf50f9985b8b8aeb630102945306cf10a9c6af2",
+ "sha256:996021ef33e0f50b97ff2d6b5f422a0fe5577de21a8873b58a779a5ddd1c3132",
+ "sha256:9e9c9078a7ce07e6fce366bd818be89365a35d2e4b163268f0ca9ba7e13bb2f6",
+ "sha256:a04901757cb0fb0f5602ac11dda48f5510f94372144d06c2563ba56c480b467c",
+ "sha256:a7bf1492429f18d205f3a818da3ff1f242f60aa59006e53dee00b4ef592a3363",
+ "sha256:aa0af2deb166a5e26e0d554b824605e660039b161e37ed4f01b8d04beec184f3",
+ "sha256:abfb15a6a7822f0fae681785cb38860e7a2cb1616a708d53df557b3d76c5bfd4",
+ "sha256:b253fe4df2afea4dfa6b1fa8c5fef212aff8bcaaeb4207e81eed05cb5e4a7919",
+ "sha256:b27f082f47d23cffc4cf1388b84fdc45c4ef6015f906cd7e0d988d9e35d36349",
+ "sha256:b33aea449e7f46738811fbc6f0b3177c6777a572207412bbbf6f525ffed001ae",
+ "sha256:b44f9421c4505c548435244d74037618f452844c5d3c67719d8a55e2613549da",
+ "sha256:bcc371151d1512201d0214c36c0c150b1dc64f19c2b1a8c9cb1d7c7c15ebd93f",
+ "sha256:c2851deeabd96d3f6283e9c6b26e0bfed4de2dc6fb15edf913e78b79fc5909ed",
+ "sha256:cdfd501c7ac5b198c15df800a3a34c38345f5182e5f80770caf362bccca65628",
+ "sha256:d2c0caffa47606d6d7c8af94ba42547bd2a441f06c74fd90a1ffe328524a6c64",
+ "sha256:dcb2db95e629962db5a355047fb8aefb012df6c8ae608930d391619dbd96fd86",
+ "sha256:e0eeb9c112fec2031927a1745788a181d0eecbacbed941fc5c4f7bc3f7b273bf",
+ "sha256:e154891263306200260d7f3051982774d7b9ef35af3509d5adbbe539afd2610c",
+ "sha256:e2e023a42dcbab8ed31f97c2bcdb980b7fbe0ada34037d87ba9d799664b58ded",
+ "sha256:e64be68255234bb489a574c4f2f8df7029c98c81ec4d160d6cd836e7f0679390",
+ "sha256:e82d6b930e02e80e5109b678c663a9ed210680ded81c1abaf54635d88d1da298"
+ ],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "version": "==1.1.0"
},
"humanfriendly": {
"hashes": [
"sha256:bf52ec91244819c780341a3438d5d7b09f431d3f113a475147ac9b7b167a3d12",
"sha256:e78960b31198511f45fd455534ae7645a6207d33e512d2e842c766d15d9c8080"
],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==8.2"
},
"idna": {
"hashes": [
- "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb",
- "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa"
+ "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",
+ "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"
],
- "version": "==2.9"
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "version": "==2.10"
},
"imagesize": {
"hashes": [
"sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1",
"sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1"
],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.2.0"
},
"jinja2": {
@@ -292,47 +326,53 @@
"sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0",
"sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035"
],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==2.11.2"
},
"lxml": {
"hashes": [
- "sha256:06748c7192eab0f48e3d35a7adae609a329c6257495d5e53878003660dc0fec6",
- "sha256:0790ddca3f825dd914978c94c2545dbea5f56f008b050e835403714babe62a5f",
- "sha256:1aa7a6197c1cdd65d974f3e4953764eee3d9c7b67e3966616b41fab7f8f516b7",
- "sha256:22c6d34fdb0e65d5f782a4d1a1edb52e0a8365858dafb1c08cb1d16546cf0786",
- "sha256:2754d4406438c83144f9ffd3628bbe2dcc6d62b20dbc5c1ec4bc4385e5d44b42",
- "sha256:27ee0faf8077c7c1a589573b1450743011117f1aa1a91d5ae776bbc5ca6070f2",
- "sha256:2b02c106709466a93ed424454ce4c970791c486d5fcdf52b0d822a7e29789626",
- "sha256:2d1ddce96cf15f1254a68dba6935e6e0f1fe39247de631c115e84dd404a6f031",
- "sha256:4f282737d187ae723b2633856085c31ae5d4d432968b7f3f478a48a54835f5c4",
- "sha256:51bb4edeb36d24ec97eb3e6a6007be128b720114f9a875d6b370317d62ac80b9",
- "sha256:7eee37c1b9815e6505847aa5e68f192e8a1b730c5c7ead39ff317fde9ce29448",
- "sha256:7fd88cb91a470b383aafad554c3fe1ccf6dfb2456ff0e84b95335d582a799804",
- "sha256:9144ce36ca0824b29ebc2e02ca186e54040ebb224292072250467190fb613b96",
- "sha256:925baf6ff1ef2c45169f548cc85204433e061360bfa7d01e1be7ae38bef73194",
- "sha256:a636346c6c0e1092ffc202d97ec1843a75937d8c98aaf6771348ad6422e44bb0",
- "sha256:a87dbee7ad9dce3aaefada2081843caf08a44a8f52e03e0a4cc5819f8398f2f4",
- "sha256:a9e3b8011388e7e373565daa5e92f6c9cb844790dc18e43073212bb3e76f7007",
- "sha256:afb53edf1046599991fb4a7d03e601ab5f5422a5435c47ee6ba91ec3b61416a6",
- "sha256:b26719890c79a1dae7d53acac5f089d66fd8cc68a81f4e4bd355e45470dc25e1",
- "sha256:b7462cdab6fffcda853338e1741ce99706cdf880d921b5a769202ea7b94e8528",
- "sha256:b77975465234ff49fdad871c08aa747aae06f5e5be62866595057c43f8d2f62c",
- "sha256:c47a8a5d00060122ca5908909478abce7bbf62d812e3fc35c6c802df8fb01fe7",
- "sha256:c79e5debbe092e3c93ca4aee44c9a7631bdd407b2871cb541b979fd350bbbc29",
- "sha256:d8d40e0121ca1606aa9e78c28a3a7d88a05c06b3ca61630242cded87d8ce55fa",
- "sha256:ee2be8b8f72a2772e72ab926a3bccebf47bb727bda41ae070dc91d1fb759b726",
- "sha256:f95d28193c3863132b1f55c1056036bf580b5a488d908f7d22a04ace8935a3a9",
- "sha256:fadd2a63a2bfd7fb604508e553d1cf68eca250b2fbdbd81213b5f6f2fbf23529"
- ],
- "index": "pypi",
- "version": "==4.5.1"
+ "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"
},
"markdownify": {
"hashes": [
- "sha256:28ce67d1888e4908faaab7b04d2193cda70ea4f902f156a21d0aaea55e63e0a1"
+ "sha256:30be8340724e706c9e811c27fe8c1542cf74a15b46827924fff5c54b40dd9b0d",
+ "sha256:a69588194fd76634f0139d6801b820fd652dc5eeba9530e90d323dfdc0155252"
],
"index": "pypi",
- "version": "==0.4.1"
+ "version": "==0.5.3"
},
"markupsafe": {
"hashes": [
@@ -370,15 +410,16 @@
"sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7",
"sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"
],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.1.1"
},
"more-itertools": {
"hashes": [
- "sha256:558bb897a2232f5e4f8e2399089e35aecb746e1f9191b6584a151647e89267be",
- "sha256:7818f596b1e87be009031c7653d01acc46ed422e6656b394b0f765ce66ed4982"
+ "sha256:6f83822ae94818eae2612063a5101a7311e68ae8002005b5e05f03fd74a86a20",
+ "sha256:9b30f12df9393f0d28af9210ff8efe48d10c94f73e5daf886f10c4b0b0b4f03c"
],
"index": "pypi",
- "version": "==8.3.0"
+ "version": "==8.5.0"
},
"multidict": {
"hashes": [
@@ -400,19 +441,22 @@
"sha256:fcfbb44c59af3f8ea984de67ec7c306f618a3ec771c2843804069917a8f2e255",
"sha256:feed85993dbdb1dbc29102f50bca65bdc68f2c0c8d352468c25b54874f23c39d"
],
+ "markers": "python_version >= '3.5'",
"version": "==4.7.6"
},
"ordered-set": {
"hashes": [
- "sha256:a31008c57f9c9776b12eb8841b1f61d1e4d70dfbbe8875ccfa2403c54af3d51b"
+ "sha256:ba93b2df055bca202116ec44b9bead3df33ea63a7d5827ff8e16738b97f33a95"
],
- "version": "==4.0.1"
+ "markers": "python_version >= '3.5'",
+ "version": "==4.0.2"
},
"packaging": {
"hashes": [
"sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8",
"sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181"
],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==20.4"
},
"pamqp": {
@@ -461,20 +505,23 @@
"sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0",
"sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705"
],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.20"
},
"pygments": {
"hashes": [
- "sha256:647344a061c249a3b74e230c739f434d7ea4d8b1d5f3721bc0f3558049b38f44",
- "sha256:ff7a40b4860b727ab48fad6360eb351cc1b33cbf9b15a0f689ca5353e9463324"
+ "sha256:307543fe65c0947b126e83dd5a61bd8acbd84abec11f43caebaf5534cbc17998",
+ "sha256:926c3f319eda178d1bd90851e4317e6d8cdb5e292a3386aac9bd75eca29cf9c7"
],
- "version": "==2.6.1"
+ "markers": "python_version >= '3.5'",
+ "version": "==2.7.1"
},
"pyparsing": {
"hashes": [
"sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1",
"sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"
],
+ "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.4.7"
},
"python-dateutil": {
@@ -511,32 +558,34 @@
},
"redis": {
"hashes": [
- "sha256:2ef11f489003f151777c064c5dbc6653dfb9f3eade159bcadc524619fddc2242",
- "sha256:6d65e84bc58091140081ee9d9c187aab0480097750fac44239307a3bdf0b1251"
+ "sha256:0e7e0cfca8660dea8b7d5cd8c4f6c5e29e11f31158c0b0ae91a397f00e5a05a2",
+ "sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24"
],
- "version": "==3.5.2"
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
+ "version": "==3.5.3"
},
"requests": {
"hashes": [
- "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee",
- "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6"
+ "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b",
+ "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898"
],
"index": "pypi",
- "version": "==2.23.0"
+ "version": "==2.24.0"
},
"sentry-sdk": {
"hashes": [
- "sha256:0e5e947d0f7a969314aa23669a94a9712be5a688ff069ff7b9fc36c66adc160c",
- "sha256:799a8bf76b012e3030a881be00e97bc0b922ce35dde699c6537122b751d80e2c"
+ "sha256:c9c0fa1412bad87104c4eee8dd36c7bbf60b0d92ae917ab519094779b22e6d9a",
+ "sha256:e159f7c919d19ae86e5a4ff370fccc45149fab461fbeb93fb5a735a0b33a9cb1"
],
"index": "pypi",
- "version": "==0.14.4"
+ "version": "==0.17.8"
},
"six": {
"hashes": [
"sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
"sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.15.0"
},
"snowballstemmer": {
@@ -548,16 +597,17 @@
},
"sortedcontainers": {
"hashes": [
- "sha256:974e9a32f56b17c1bac2aebd9dcf197f3eb9cd30553c5852a3187ad162e1a03a",
- "sha256:d9e96492dd51fae31e60837736b38fe42a187b5404c16606ff7ee7cd582d4c60"
+ "sha256:4e73a757831fc3ca4de2859c422564239a31d8213d09a2a666e375807034d2ba",
+ "sha256:c633ebde8580f241f274c1f8994a665c0e54a17724fecd0cae2f079e09c36d3f"
],
- "version": "==2.1.0"
+ "version": "==2.2.2"
},
"soupsieve": {
"hashes": [
"sha256:1634eea42ab371d3d346309b93df7870a88610f0725d47528be902a0d95ecc55",
"sha256:a59dc181727e95d25f781f0eb4fd1825ff45590ec8ff49eadfd7f1a537cc0232"
],
+ "markers": "python_version >= '3.0'",
"version": "==2.0.1"
},
"sphinx": {
@@ -573,6 +623,7 @@
"sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a",
"sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58"
],
+ "markers": "python_version >= '3.5'",
"version": "==1.0.2"
},
"sphinxcontrib-devhelp": {
@@ -580,6 +631,7 @@
"sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e",
"sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4"
],
+ "markers": "python_version >= '3.5'",
"version": "==1.0.2"
},
"sphinxcontrib-htmlhelp": {
@@ -587,6 +639,7 @@
"sha256:3c0bc24a2c41e340ac37c85ced6dafc879ab485c095b1d65d2461ac2f7cca86f",
"sha256:e8f5bb7e31b2dbb25b9cc435c8ab7a79787ebf7f906155729338f3156d93659b"
],
+ "markers": "python_version >= '3.5'",
"version": "==1.0.3"
},
"sphinxcontrib-jsmath": {
@@ -594,6 +647,7 @@
"sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178",
"sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"
],
+ "markers": "python_version >= '3.5'",
"version": "==1.0.1"
},
"sphinxcontrib-qthelp": {
@@ -601,6 +655,7 @@
"sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72",
"sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6"
],
+ "markers": "python_version >= '3.5'",
"version": "==1.0.3"
},
"sphinxcontrib-serializinghtml": {
@@ -608,6 +663,7 @@
"sha256:eaa0eccc86e982a9b939b2b82d12cc5d013385ba5eadcc7e4fed23f4405f77bc",
"sha256:f242a81d423f59617a8e5cf16f5d4d74e28ee9a66f9e5b637a18082991db5a9a"
],
+ "markers": "python_version >= '3.5'",
"version": "==1.1.4"
},
"statsd": {
@@ -620,59 +676,34 @@
},
"urllib3": {
"hashes": [
- "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527",
- "sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115"
- ],
- "version": "==1.25.9"
- },
- "websockets": {
- "hashes": [
- "sha256:0e4fb4de42701340bd2353bb2eee45314651caa6ccee80dbd5f5d5978888fed5",
- "sha256:1d3f1bf059d04a4e0eb4985a887d49195e15ebabc42364f4eb564b1d065793f5",
- "sha256:20891f0dddade307ffddf593c733a3fdb6b83e6f9eef85908113e628fa5a8308",
- "sha256:295359a2cc78736737dd88c343cd0747546b2174b5e1adc223824bcaf3e164cb",
- "sha256:2db62a9142e88535038a6bcfea70ef9447696ea77891aebb730a333a51ed559a",
- "sha256:3762791ab8b38948f0c4d281c8b2ddfa99b7e510e46bd8dfa942a5fff621068c",
- "sha256:3db87421956f1b0779a7564915875ba774295cc86e81bc671631379371af1170",
- "sha256:3ef56fcc7b1ff90de46ccd5a687bbd13a3180132268c4254fc0fa44ecf4fc422",
- "sha256:4f9f7d28ce1d8f1295717c2c25b732c2bc0645db3215cf757551c392177d7cb8",
- "sha256:5c01fd846263a75bc8a2b9542606927cfad57e7282965d96b93c387622487485",
- "sha256:5c65d2da8c6bce0fca2528f69f44b2f977e06954c8512a952222cea50dad430f",
- "sha256:751a556205d8245ff94aeef23546a1113b1dd4f6e4d102ded66c39b99c2ce6c8",
- "sha256:7ff46d441db78241f4c6c27b3868c9ae71473fe03341340d2dfdbe8d79310acc",
- "sha256:965889d9f0e2a75edd81a07592d0ced54daa5b0785f57dc429c378edbcffe779",
- "sha256:9b248ba3dd8a03b1a10b19efe7d4f7fa41d158fdaa95e2cf65af5a7b95a4f989",
- "sha256:9bef37ee224e104a413f0780e29adb3e514a5b698aabe0d969a6ba426b8435d1",
- "sha256:c1ec8db4fac31850286b7cd3b9c0e1b944204668b8eb721674916d4e28744092",
- "sha256:c8a116feafdb1f84607cb3b14aa1418424ae71fee131642fc568d21423b51824",
- "sha256:ce85b06a10fc65e6143518b96d3dca27b081a740bae261c2fb20375801a9d56d",
- "sha256:d705f8aeecdf3262379644e4b55107a3b55860eb812b673b28d0fbc347a60c55",
- "sha256:e898a0863421650f0bebac8ba40840fc02258ef4714cb7e1fd76b6a6354bda36",
- "sha256:f8a7bff6e8664afc4e6c28b983845c5bc14965030e3fb98789734d416af77c4b"
- ],
- "version": "==8.1"
+ "sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a",
+ "sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461"
+ ],
+ "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"
},
"yarl": {
"hashes": [
- "sha256:0c2ab325d33f1b824734b3ef51d4d54a54e0e7a23d13b86974507602334c2cce",
- "sha256:0ca2f395591bbd85ddd50a82eb1fde9c1066fafe888c5c7cc1d810cf03fd3cc6",
- "sha256:2098a4b4b9d75ee352807a95cdf5f10180db903bc5b7270715c6bbe2551f64ce",
- "sha256:25e66e5e2007c7a39541ca13b559cd8ebc2ad8fe00ea94a2aad28a9b1e44e5ae",
- "sha256:26d7c90cb04dee1665282a5d1a998defc1a9e012fdca0f33396f81508f49696d",
- "sha256:308b98b0c8cd1dfef1a0311dc5e38ae8f9b58349226aa0533f15a16717ad702f",
- "sha256:3ce3d4f7c6b69c4e4f0704b32eca8123b9c58ae91af740481aa57d7857b5e41b",
- "sha256:58cd9c469eced558cd81aa3f484b2924e8897049e06889e8ff2510435b7ef74b",
- "sha256:5b10eb0e7f044cf0b035112446b26a3a2946bca9d7d7edb5e54a2ad2f6652abb",
- "sha256:6faa19d3824c21bcbfdfce5171e193c8b4ddafdf0ac3f129ccf0cdfcb083e462",
- "sha256:944494be42fa630134bf907714d40207e646fd5a94423c90d5b514f7b0713fea",
- "sha256:a161de7e50224e8e3de6e184707476b5a989037dcb24292b391a3d66ff158e70",
- "sha256:a4844ebb2be14768f7994f2017f70aca39d658a96c786211be5ddbe1c68794c1",
- "sha256:c2b509ac3d4b988ae8769901c66345425e361d518aecbe4acbfc2567e416626a",
- "sha256:c9959d49a77b0e07559e579f38b2f3711c2b8716b8410b320bf9713013215a1b",
- "sha256:d8cdee92bc930d8b09d8bd2043cedd544d9c8bd7436a77678dd602467a993080",
- "sha256:e15199cdb423316e15f108f51249e44eb156ae5dba232cb73be555324a1d49c2"
- ],
- "version": "==1.4.2"
+ "sha256:04a54f126a0732af75e5edc9addeaa2113e2ca7c6fce8974a63549a70a25e50e",
+ "sha256:3cc860d72ed989f3b1f3abbd6ecf38e412de722fb38b8f1b1a086315cf0d69c5",
+ "sha256:5d84cc36981eb5a8533be79d6c43454c8e6a39ee3118ceaadbd3c029ab2ee580",
+ "sha256:5e447e7f3780f44f890360ea973418025e8c0cdcd7d6a1b221d952600fd945dc",
+ "sha256:61d3ea3c175fe45f1498af868879c6ffeb989d4143ac542163c45538ba5ec21b",
+ "sha256:67c5ea0970da882eaf9efcf65b66792557c526f8e55f752194eff8ec722c75c2",
+ "sha256:6f6898429ec3c4cfbef12907047136fd7b9e81a6ee9f105b45505e633427330a",
+ "sha256:7ce35944e8e61927a8f4eb78f5bc5d1e6da6d40eadd77e3f79d4e9399e263921",
+ "sha256:b7c199d2cbaf892ba0f91ed36d12ff41ecd0dde46cbf64ff4bfe997a3ebc925e",
+ "sha256:c15d71a640fb1f8e98a1423f9c64d7f1f6a3a168f803042eaf3a5b5022fde0c1",
+ "sha256:c22607421f49c0cb6ff3ed593a49b6a99c6ffdeaaa6c944cdda83c2393c8864d",
+ "sha256:c604998ab8115db802cc55cb1b91619b2831a6128a62ca7eea577fc8ea4d3131",
+ "sha256:d088ea9319e49273f25b1c96a3763bf19a882cff774d1792ae6fba34bd40550a",
+ "sha256:db9eb8307219d7e09b33bcb43287222ef35cbcf1586ba9472b0a4b833666ada1",
+ "sha256:e31fef4e7b68184545c3d68baec7074532e077bd1906b040ecfba659737df188",
+ "sha256:e32f0fb443afcfe7f01f95172b66f279938fbc6bdaebe294b0ff6747fb6db020",
+ "sha256:fcbe419805c9b20db9a51d33b942feddbf6e7fb468cb20686fd7089d4164c12a"
+ ],
+ "markers": "python_version >= '3.5'",
+ "version": "==1.6.0"
}
},
"develop": {
@@ -685,60 +716,66 @@
},
"attrs": {
"hashes": [
- "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c",
- "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"
+ "sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594",
+ "sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc"
],
- "version": "==19.3.0"
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "version": "==20.2.0"
},
"cfgv": {
"hashes": [
- "sha256:1ccf53320421aeeb915275a196e23b3b8ae87dea8ac6698b1638001d4a486d53",
- "sha256:c8e8f552ffcc6194f4e18dd4f68d9aef0c0d58ae7e7be8c82bee3c5e9edfa513"
+ "sha256:32e43d604bbe7896fe7c248a9c2276447dbef840feb28fe20494f62af110211d",
+ "sha256:cf22deb93d4bcf92f345a5c3cd39d3d41d6340adc60c78bbbd6588c384fda6a1"
],
- "version": "==3.1.0"
+ "markers": "python_full_version >= '3.6.1'",
+ "version": "==3.2.0"
},
"coverage": {
"hashes": [
- "sha256:00f1d23f4336efc3b311ed0d807feb45098fc86dee1ca13b3d6768cdab187c8a",
- "sha256:01333e1bd22c59713ba8a79f088b3955946e293114479bbfc2e37d522be03355",
- "sha256:0cb4be7e784dcdc050fc58ef05b71aa8e89b7e6636b99967fadbdba694cf2b65",
- "sha256:0e61d9803d5851849c24f78227939c701ced6704f337cad0a91e0972c51c1ee7",
- "sha256:1601e480b9b99697a570cea7ef749e88123c04b92d84cedaa01e117436b4a0a9",
- "sha256:2742c7515b9eb368718cd091bad1a1b44135cc72468c731302b3d641895b83d1",
- "sha256:2d27a3f742c98e5c6b461ee6ef7287400a1956c11421eb574d843d9ec1f772f0",
- "sha256:402e1744733df483b93abbf209283898e9f0d67470707e3c7516d84f48524f55",
- "sha256:5c542d1e62eece33c306d66fe0a5c4f7f7b3c08fecc46ead86d7916684b36d6c",
- "sha256:5f2294dbf7875b991c381e3d5af2bcc3494d836affa52b809c91697449d0eda6",
- "sha256:6402bd2fdedabbdb63a316308142597534ea8e1895f4e7d8bf7476c5e8751fef",
- "sha256:66460ab1599d3cf894bb6baee8c684788819b71a5dc1e8fa2ecc152e5d752019",
- "sha256:782caea581a6e9ff75eccda79287daefd1d2631cc09d642b6ee2d6da21fc0a4e",
- "sha256:79a3cfd6346ce6c13145731d39db47b7a7b859c0272f02cdb89a3bdcbae233a0",
- "sha256:7a5bdad4edec57b5fb8dae7d3ee58622d626fd3a0be0dfceda162a7035885ecf",
- "sha256:8fa0cbc7ecad630e5b0f4f35b0f6ad419246b02bc750de7ac66db92667996d24",
- "sha256:a027ef0492ede1e03a8054e3c37b8def89a1e3c471482e9f046906ba4f2aafd2",
- "sha256:a3f3654d5734a3ece152636aad89f58afc9213c6520062db3978239db122f03c",
- "sha256:a82b92b04a23d3c8a581fc049228bafde988abacba397d57ce95fe95e0338ab4",
- "sha256:acf3763ed01af8410fc36afea23707d4ea58ba7e86a8ee915dfb9ceff9ef69d0",
- "sha256:adeb4c5b608574a3d647011af36f7586811a2c1197c861aedb548dd2453b41cd",
- "sha256:b83835506dfc185a319031cf853fa4bb1b3974b1f913f5bb1a0f3d98bdcded04",
- "sha256:bb28a7245de68bf29f6fb199545d072d1036a1917dca17a1e75bbb919e14ee8e",
- "sha256:bf9cb9a9fd8891e7efd2d44deb24b86d647394b9705b744ff6f8261e6f29a730",
- "sha256:c317eaf5ff46a34305b202e73404f55f7389ef834b8dbf4da09b9b9b37f76dd2",
- "sha256:dbe8c6ae7534b5b024296464f387d57c13caa942f6d8e6e0346f27e509f0f768",
- "sha256:de807ae933cfb7f0c7d9d981a053772452217df2bf38e7e6267c9cbf9545a796",
- "sha256:dead2ddede4c7ba6cb3a721870f5141c97dc7d85a079edb4bd8d88c3ad5b20c7",
- "sha256:dec5202bfe6f672d4511086e125db035a52b00f1648d6407cc8e526912c0353a",
- "sha256:e1ea316102ea1e1770724db01998d1603ed921c54a86a2efcb03428d5417e489",
- "sha256:f90bfc4ad18450c80b024036eaf91e4a246ae287701aaa88eaebebf150868052"
- ],
- "index": "pypi",
- "version": "==5.1"
+ "sha256:0203acd33d2298e19b57451ebb0bed0ab0c602e5cf5a818591b4918b1f97d516",
+ "sha256:0f313707cdecd5cd3e217fc68c78a960b616604b559e9ea60cc16795c4304259",
+ "sha256:1c6703094c81fa55b816f5ae542c6ffc625fec769f22b053adb42ad712d086c9",
+ "sha256:1d44bb3a652fed01f1f2c10d5477956116e9b391320c94d36c6bf13b088a1097",
+ "sha256:280baa8ec489c4f542f8940f9c4c2181f0306a8ee1a54eceba071a449fb870a0",
+ "sha256:29a6272fec10623fcbe158fdf9abc7a5fa032048ac1d8631f14b50fbfc10d17f",
+ "sha256:2b31f46bf7b31e6aa690d4c7a3d51bb262438c6dcb0d528adde446531d0d3bb7",
+ "sha256:2d43af2be93ffbad25dd959899b5b809618a496926146ce98ee0b23683f8c51c",
+ "sha256:381ead10b9b9af5f64646cd27107fb27b614ee7040bb1226f9c07ba96625cbb5",
+ "sha256:47a11bdbd8ada9b7ee628596f9d97fbd3851bd9999d398e9436bd67376dbece7",
+ "sha256:4d6a42744139a7fa5b46a264874a781e8694bb32f1d76d8137b68138686f1729",
+ "sha256:50691e744714856f03a86df3e2bff847c2acede4c191f9a1da38f088df342978",
+ "sha256:530cc8aaf11cc2ac7430f3614b04645662ef20c348dce4167c22d99bec3480e9",
+ "sha256:582ddfbe712025448206a5bc45855d16c2e491c2dd102ee9a2841418ac1c629f",
+ "sha256:63808c30b41f3bbf65e29f7280bf793c79f54fb807057de7e5238ffc7cc4d7b9",
+ "sha256:71b69bd716698fa62cd97137d6f2fdf49f534decb23a2c6fc80813e8b7be6822",
+ "sha256:7858847f2d84bf6e64c7f66498e851c54de8ea06a6f96a32a1d192d846734418",
+ "sha256:78e93cc3571fd928a39c0b26767c986188a4118edc67bc0695bc7a284da22e82",
+ "sha256:7f43286f13d91a34fadf61ae252a51a130223c52bfefb50310d5b2deb062cf0f",
+ "sha256:86e9f8cd4b0cdd57b4ae71a9c186717daa4c5a99f3238a8723f416256e0b064d",
+ "sha256:8f264ba2701b8c9f815b272ad568d555ef98dfe1576802ab3149c3629a9f2221",
+ "sha256:9342dd70a1e151684727c9c91ea003b2fb33523bf19385d4554f7897ca0141d4",
+ "sha256:9361de40701666b034c59ad9e317bae95c973b9ff92513dd0eced11c6adf2e21",
+ "sha256:9669179786254a2e7e57f0ecf224e978471491d660aaca833f845b72a2df3709",
+ "sha256:aac1ba0a253e17889550ddb1b60a2063f7474155465577caa2a3b131224cfd54",
+ "sha256:aef72eae10b5e3116bac6957de1df4d75909fc76d1499a53fb6387434b6bcd8d",
+ "sha256:bd3166bb3b111e76a4f8e2980fa1addf2920a4ca9b2b8ca36a3bc3dedc618270",
+ "sha256:c1b78fb9700fc961f53386ad2fd86d87091e06ede5d118b8a50dea285a071c24",
+ "sha256:c3888a051226e676e383de03bf49eb633cd39fc829516e5334e69b8d81aae751",
+ "sha256:c5f17ad25d2c1286436761b462e22b5020d83316f8e8fcb5deb2b3151f8f1d3a",
+ "sha256:c851b35fc078389bc16b915a0a7c1d5923e12e2c5aeec58c52f4aa8085ac8237",
+ "sha256:cb7df71de0af56000115eafd000b867d1261f786b5eebd88a0ca6360cccfaca7",
+ "sha256:cedb2f9e1f990918ea061f28a0f0077a07702e3819602d3507e2ff98c8d20636",
+ "sha256:e8caf961e1b1a945db76f1b5fa9c91498d15f545ac0ababbe575cfab185d3bd8"
+ ],
+ "index": "pypi",
+ "version": "==5.3"
},
"distlib": {
"hashes": [
- "sha256:2e166e231a26b36d6dfe35a48c4464346620f8645ed0ace01ee31822b288de21"
+ "sha256:8c09de2c67b3e7deef7184574fc060ab8a793e7adbb183d942c389c8b13c52fb",
+ "sha256:edf6116872c863e1aa9d5bb7cb5e05a022c519a4594dc703843343a9ddd9bff1"
],
- "version": "==0.3.0"
+ "version": "==0.3.1"
},
"filelock": {
"hashes": [
@@ -749,19 +786,19 @@
},
"flake8": {
"hashes": [
- "sha256:c69ac1668e434d37a2d2880b3ca9aafd54b3a10a3ac1ab101d22f29e29cf8634",
- "sha256:ccaa799ef9893cebe69fdfefed76865aeaefbb94cb8545617b2298786a4de9a5"
+ "sha256:15e351d19611c887e482fb960eae4d44845013cc142d42896e9862f775d8cf5c",
+ "sha256:f04b9fcbac03b0a3e58c0ab3a0ecc462e023a9faf046d57794184028123aa208"
],
"index": "pypi",
- "version": "==3.8.2"
+ "version": "==3.8.3"
},
"flake8-annotations": {
"hashes": [
- "sha256:9091d920406a7ff10e401e0dd1baa396d1d7d2e3d101a9beecf815f5894ad554",
- "sha256:f59fdceb8c8f380a20aed20e1ba8a57bde05935958166c52be2249f113f7ab75"
+ "sha256:09fe1aa3f40cb8fef632a0ab3614050a7584bb884b6134e70cf1fc9eeee642fa",
+ "sha256:5bda552f074fd6e34276c7761756fa07d824ffac91ce9c0a8555eb2bc5b92d7a"
],
"index": "pypi",
- "version": "==2.1.0"
+ "version": "==2.4.0"
},
"flake8-bugbear": {
"hashes": [
@@ -819,10 +856,11 @@
},
"identify": {
"hashes": [
- "sha256:0f3c3aac62b51b86fea6ff52fe8ff9e06f57f10411502443809064d23e16f1c2",
- "sha256:f9ad3d41f01e98eb066b6e05c5b184fd1e925fadec48eb165b4e01c72a1ef3a7"
+ "sha256:7c22c384a2c9b32c5cc891d13f923f6b2653aa83e2d75d8f79be240d6c86c4f4",
+ "sha256:da683bfb7669fa749fc7731f378229e2dbf29a1d1337cbde04106f02236eb29d"
],
- "version": "==1.4.16"
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "version": "==1.5.5"
},
"mccabe": {
"hashes": [
@@ -833,45 +871,49 @@
},
"nodeenv": {
"hashes": [
- "sha256:5b2438f2e42af54ca968dd1b374d14a1194848955187b0e5e4be1f73813a5212"
+ "sha256:5304d424c529c997bc888453aeaa6362d242b6b4631e90f3d4bf1b290f1c84a9",
+ "sha256:ab45090ae383b716c4ef89e690c41ff8c2b257b85b309f01f3654df3d084bd7c"
],
- "version": "==1.3.5"
+ "version": "==1.5.0"
},
"pep8-naming": {
"hashes": [
- "sha256:5d9f1056cb9427ce344e98d1a7f5665710e2f20f748438e308995852cfa24164",
- "sha256:f3b4a5f9dd72b991bf7d8e2a341d2e1aa3a884a769b5aaac4f56825c1763bf3a"
+ "sha256:a1dd47dd243adfe8a83616e27cf03164960b507530f155db94e10b36a6cd6724",
+ "sha256:f43bfe3eea7e0d73e8b5d07d6407ab47f2476ccaeff6937c84275cd30b016738"
],
"index": "pypi",
- "version": "==0.10.0"
+ "version": "==0.11.1"
},
"pre-commit": {
"hashes": [
- "sha256:5559e09afcac7808933951ffaf4ff9aac524f31efbc3f24d021540b6c579813c",
- "sha256:703e2e34cbe0eedb0d319eff9f7b83e2022bb5a3ab5289a6a8841441076514d0"
+ "sha256:810aef2a2ba4f31eed1941fc270e72696a1ad5590b9751839c90807d0fff6b9a",
+ "sha256:c54fd3e574565fe128ecc5e7d2f91279772ddb03f8729645fa812fe809084a70"
],
"index": "pypi",
- "version": "==2.4.0"
+ "version": "==2.7.1"
},
"pycodestyle": {
"hashes": [
"sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367",
"sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"
],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.6.0"
},
"pydocstyle": {
"hashes": [
- "sha256:da7831660b7355307b32778c4a0dbfb137d89254ef31a2b2978f50fc0b4d7586",
- "sha256:f4f5d210610c2d153fae39093d44224c17429e2ad7da12a8b419aba5c2f614b5"
+ "sha256:19b86fa8617ed916776a11cd8bc0197e5b9856d5433b777f51a3defe13075325",
+ "sha256:aca749e190a01726a4fb472dd4ef23b5c9da7b9205c0a7857c06533de13fd678"
],
- "version": "==5.0.2"
+ "markers": "python_version >= '3.5'",
+ "version": "==5.1.1"
},
"pyflakes": {
"hashes": [
"sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92",
"sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"
],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.2.0"
},
"pyyaml": {
@@ -896,6 +938,7 @@
"sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
"sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.15.0"
},
"snowballstemmer": {
@@ -914,18 +957,19 @@
},
"unittest-xml-reporting": {
"hashes": [
- "sha256:74eaf7739a7957a74f52b8187c5616f61157372189bef0a32ba5c30bbc00e58a",
- "sha256:e09b8ae70cce9904cdd331f53bf929150962869a5324ab7ff3dd6c8b87e01f7d"
+ "sha256:7bf515ea8cb244255a25100cd29db611a73f8d3d0aaf672ed3266307e14cc1ca",
+ "sha256:984cebba69e889401bfe3adb9088ca376b3a1f923f0590d005126c1bffd1a695"
],
"index": "pypi",
- "version": "==3.0.2"
+ "version": "==3.0.4"
},
"virtualenv": {
"hashes": [
- "sha256:a116629d4e7f4d03433b8afa27f43deba09d48bc48f5ecefa4f015a178efb6cf",
- "sha256:a730548b27366c5e6cbdf6f97406d861cccece2e22275e8e1a757aeff5e00c70"
+ "sha256:43add625c53c596d38f971a465553f6318decc39d98512bc100fa1b1e839c8dc",
+ "sha256:e0305af10299a7fb0d69393d8f04cb2965dda9351140d11ac8db4e5e3970451b"
],
- "version": "==20.0.21"
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "version": "==20.0.31"
}
}
}
diff --git a/README.md b/README.md
index cae7c3454..b37ece296 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# 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)
+[![Discord](https://img.shields.io/static/v1?label=Python%20Discord&logo=discord&message=%3E100k%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)
diff --git a/bot/__init__.py b/bot/__init__.py
index d63086fe2..4fce04532 100644
--- a/bot/__init__.py
+++ b/bot/__init__.py
@@ -2,10 +2,14 @@ import asyncio
import logging
import os
import sys
+from functools import partial, partialmethod
from logging import Logger, handlers
from pathlib import Path
import coloredlogs
+from discord.ext import commands
+
+from bot.command import Command
TRACE_LEVEL = logging.TRACE = 5
logging.addLevelName(TRACE_LEVEL, "TRACE")
@@ -60,9 +64,15 @@ 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__)
+logging.getLogger("async_rediscache").setLevel(logging.WARNING)
# 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)
diff --git a/bot/__main__.py b/bot/__main__.py
index 3d414c4b8..367be1300 100644
--- a/bot/__main__.py
+++ b/bot/__main__.py
@@ -1,15 +1,19 @@
+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
+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
@@ -24,60 +28,49 @@ sentry_sdk.init(
]
)
+# 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]
+intents = discord.Intents().all()
+intents.presences = False
+intents.dm_typing = False
+intents.dm_reactions = False
+intents.invites = False
+intents.webhooks = False
+intents.integrations = False
bot = Bot(
+ redis_session=redis_session,
+ loop=loop,
command_prefix=when_mentioned_or(constants.Bot.prefix),
- activity=discord.Game(name="Commands: !help"),
+ 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,
)
-# Internal/debug
-bot.load_extension("bot.cogs.error_handler")
-bot.load_extension("bot.cogs.filtering")
-bot.load_extension("bot.cogs.logging")
-bot.load_extension("bot.cogs.security")
-bot.load_extension("bot.cogs.config_verifier")
-
-# Commands, etc
-bot.load_extension("bot.cogs.antimalware")
-bot.load_extension("bot.cogs.antispam")
-bot.load_extension("bot.cogs.bot")
-bot.load_extension("bot.cogs.clean")
-bot.load_extension("bot.cogs.extensions")
-bot.load_extension("bot.cogs.help")
-
-bot.load_extension("bot.cogs.doc")
-bot.load_extension("bot.cogs.verification")
-
-# Feature cogs
-bot.load_extension("bot.cogs.alias")
-bot.load_extension("bot.cogs.defcon")
-bot.load_extension("bot.cogs.duck_pond")
-bot.load_extension("bot.cogs.eval")
-bot.load_extension("bot.cogs.information")
-bot.load_extension("bot.cogs.jams")
-bot.load_extension("bot.cogs.moderation")
-bot.load_extension("bot.cogs.python_news")
-bot.load_extension("bot.cogs.off_topic_names")
-bot.load_extension("bot.cogs.reddit")
-bot.load_extension("bot.cogs.reminders")
-bot.load_extension("bot.cogs.site")
-bot.load_extension("bot.cogs.snekbox")
-bot.load_extension("bot.cogs.stats")
-bot.load_extension("bot.cogs.sync")
-bot.load_extension("bot.cogs.tags")
-bot.load_extension("bot.cogs.token_remover")
-bot.load_extension("bot.cogs.utils")
-bot.load_extension("bot.cogs.watchchannels")
-bot.load_extension("bot.cogs.webhook_remover")
-bot.load_extension("bot.cogs.wolfram")
-bot.load_extension("bot.cogs.code_snippets")
-
-if constants.HelpChannels.enable:
- bot.load_extension("bot.cogs.help_channels")
+# Load extensions.
+extensions = set(EXTENSIONS) # Create a mutable copy.
+if not constants.HelpChannels.enable:
+ extensions.remove("bot.exts.help_channels")
-# 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()
+for extension in extensions:
+ bot.load_extension(extension)
bot.run(constants.Bot.token)
diff --git a/bot/bot.py b/bot/bot.py
index 313652d11..b2e5237fe 100644
--- a/bot/bot.py
+++ b/bot/bot.py
@@ -2,12 +2,12 @@ import asyncio
import logging
import socket
import warnings
-from typing import Optional
+from collections import defaultdict
+from typing import Dict, Optional
import aiohttp
-import aioredis
import discord
-import fakeredis.aioredis
+from async_rediscache import RedisSession
from discord.ext import commands
from sentry_sdk import push_scope
@@ -20,7 +20,7 @@ log = logging.getLogger('bot')
class Bot(commands.Bot):
"""A subclass of `discord.ext.commands.Bot` with an aiohttp session and an API client."""
- def __init__(self, *args, **kwargs):
+ def __init__(self, *args, redis_session: RedisSession, **kwargs):
if "connector" in kwargs:
warnings.warn(
"If login() is called (or the bot is started), the connector will be overwritten "
@@ -30,10 +30,9 @@ class Bot(commands.Bot):
super().__init__(*args, **kwargs)
self.http_session: Optional[aiohttp.ClientSession] = None
- self.redis_session: Optional[aioredis.Redis] = None
- self.redis_ready = asyncio.Event()
- self.redis_closed = False
+ self.redis_session = redis_session
self.api_client = api.APIClient(loop=self.loop)
+ self.filter_list_cache = defaultdict(dict)
self._connector = None
self._resolver = None
@@ -49,35 +48,78 @@ class Bot(commands.Bot):
self.stats = AsyncStatsClient(self.loop, statsd_url, 8125, prefix="bot")
- async def _create_redis_session(self) -> None:
- """
- Create the Redis connection pool, and then open the redis event gate.
+ async def cache_filter_list_data(self) -> None:
+ """Cache all the data in the FilterList on the site."""
+ full_cache = await self.api_client.get('bot/filter-lists')
- If constants.Redis.use_fakeredis is True, we'll set up a fake redis pool instead
- of attempting to communicate with a real Redis server. This is useful because it
- means contributors don't necessarily need to get Redis running locally just
- to run the bot.
+ for item in full_cache:
+ self.insert_item_into_filter_list_cache(item)
- The fakeredis cache won't have persistence across restarts, but that
- usually won't matter for local bot testing.
- """
- if constants.Redis.use_fakeredis:
- log.info("Using fakeredis instead of communicating with a real Redis server.")
- self.redis_session = await fakeredis.aioredis.create_redis_pool()
- else:
- self.redis_session = await aioredis.create_redis_pool(
- address=(constants.Redis.host, constants.Redis.port),
- password=constants.Redis.password,
+ def _recreate(self) -> None:
+ """Re-create the connector, aiohttp session, the APIClient and the Redis session."""
+ # Use asyncio for DNS resolution instead of threads so threads aren't spammed.
+ # Doesn't seem to have any state with regards to being closed, so no need to worry?
+ self._resolver = aiohttp.AsyncResolver()
+
+ # Its __del__ does send a warning but it doesn't always show up for some reason.
+ if self._connector and not self._connector._closed:
+ log.warning(
+ "The previous connector was not closed; it will remain open and be overwritten"
+ )
+
+ if self.redis_session.closed:
+ # If the RedisSession was somehow closed, we try to reconnect it
+ # here. Normally, this shouldn't happen.
+ self.loop.create_task(self.redis_session.connect())
+
+ # Use AF_INET as its socket family to prevent HTTPS related problems both locally
+ # and in production.
+ self._connector = aiohttp.TCPConnector(
+ resolver=self._resolver,
+ family=socket.AF_INET,
+ )
+
+ # Client.login() will call HTTPClient.static_login() which will create a session using
+ # this connector attribute.
+ self.http.connector = self._connector
+
+ # Its __del__ does send a warning but it doesn't always show up for some reason.
+ if self.http_session and not self.http_session.closed:
+ log.warning(
+ "The previous session was not closed; it will remain open and be overwritten"
)
- self.redis_closed = False
- self.redis_ready.set()
+ self.http_session = aiohttp.ClientSession(connector=self._connector)
+ self.api_client.recreate(force=True, connector=self._connector)
+
+ # Build the FilterList cache
+ self.loop.create_task(self.cache_filter_list_data())
def add_cog(self, cog: commands.Cog) -> None:
"""Adds a "cog" to the bot and logs the operation."""
super().add_cog(cog)
log.info(f"Cog loaded: {cog.qualified_name}")
+ def add_command(self, command: commands.Command) -> None:
+ """Add `command` as normal and then add its root aliases to the bot."""
+ super().add_command(command)
+ self._add_root_aliases(command)
+
+ def remove_command(self, name: str) -> Optional[commands.Command]:
+ """
+ Remove a command/alias as normal and then remove its root aliases from the bot.
+
+ Individual root aliases cannot be removed by this function.
+ To remove them, either remove the entire command or manually edit `bot.all_commands`.
+ """
+ command = super().remove_command(name)
+ if command is None:
+ # Even if it's a root alias, there's no way to get the Bot instance to remove the alias.
+ return
+
+ self._remove_root_aliases(command)
+ return command
+
def clear(self) -> None:
"""
Clears the internal state of the bot and recreates the connector and sessions.
@@ -108,10 +150,20 @@ class Bot(commands.Bot):
self.stats._transport.close()
if self.redis_session:
- self.redis_closed = True
- self.redis_session.close()
- self.redis_ready.clear()
- await self.redis_session.wait_closed()
+ await self.redis_session.close()
+
+ def insert_item_into_filter_list_cache(self, item: Dict[str, str]) -> None:
+ """Add an item to the bots filter_list_cache."""
+ type_ = item["type"]
+ allowed = item["allowed"]
+ content = item["content"]
+
+ self.filter_list_cache[f"{type_}.{allowed}"][content] = {
+ "id": item["id"],
+ "comment": item["comment"],
+ "created_at": item["created_at"],
+ "updated_at": item["updated_at"],
+ }
async def login(self, *args, **kwargs) -> None:
"""Re-create the connector and set up sessions before logging into Discord."""
@@ -119,46 +171,6 @@ class Bot(commands.Bot):
await self.stats.create_socket()
await super().login(*args, **kwargs)
- def _recreate(self) -> None:
- """Re-create the connector, aiohttp session, the APIClient and the Redis session."""
- # Use asyncio for DNS resolution instead of threads so threads aren't spammed.
- # Doesn't seem to have any state with regards to being closed, so no need to worry?
- self._resolver = aiohttp.AsyncResolver()
-
- # Its __del__ does send a warning but it doesn't always show up for some reason.
- if self._connector and not self._connector._closed:
- log.warning(
- "The previous connector was not closed; it will remain open and be overwritten"
- )
-
- if self.redis_session and not self.redis_session.closed:
- log.warning(
- "The previous redis pool was not closed; it will remain open and be overwritten"
- )
-
- # Create the redis session
- self.loop.create_task(self._create_redis_session())
-
- # Use AF_INET as its socket family to prevent HTTPS related problems both locally
- # and in production.
- self._connector = aiohttp.TCPConnector(
- resolver=self._resolver,
- family=socket.AF_INET,
- )
-
- # Client.login() will call HTTPClient.static_login() which will create a session using
- # this connector attribute.
- self.http.connector = self._connector
-
- # Its __del__ does send a warning but it doesn't always show up for some reason.
- if self.http_session and not self.http_session.closed:
- log.warning(
- "The previous session was not closed; it will remain open and be overwritten"
- )
-
- self.http_session = aiohttp.ClientSession(connector=self._connector)
- self.api_client.recreate(force=True, connector=self._connector)
-
async def on_guild_available(self, guild: discord.Guild) -> None:
"""
Set the internal guild available event when constants.Guild.id becomes available.
@@ -210,3 +222,24 @@ class Bot(commands.Bot):
scope.set_extra("kwargs", kwargs)
log.exception(f"Unhandled exception in {event}.")
+
+ def _add_root_aliases(self, command: commands.Command) -> None:
+ """Recursively add root aliases for `command` and any of its subcommands."""
+ if isinstance(command, commands.Group):
+ for subcommand in command.commands:
+ self._add_root_aliases(subcommand)
+
+ for alias in getattr(command, "root_aliases", ()):
+ if alias in self.all_commands:
+ raise commands.CommandRegistrationError(alias, alias_conflict=True)
+
+ self.all_commands[alias] = command
+
+ def _remove_root_aliases(self, command: commands.Command) -> None:
+ """Recursively remove root aliases for `command` and any of its subcommands."""
+ if isinstance(command, commands.Group):
+ for subcommand in command.commands:
+ self._remove_root_aliases(subcommand)
+
+ for alias in getattr(command, "root_aliases", ()):
+ self.all_commands.pop(alias, None)
diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py
deleted file mode 100644
index 55c7efe65..000000000
--- a/bot/cogs/alias.py
+++ /dev/null
@@ -1,153 +0,0 @@
-import inspect
-import logging
-
-from discord import Colour, Embed
-from discord.ext.commands import (
- Cog, Command, Context, Greedy,
- clean_content, command, group,
-)
-
-from bot.bot import Bot
-from bot.cogs.extensions import Extension
-from bot.converters import FetchedMember, 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="resources", aliases=("resource",), hidden=True)
- async def site_resources_alias(self, ctx: Context) -> None:
- """Alias for invoking <prefix>site resources."""
- await self.invoke(ctx, "site resources")
-
- @command(name="tools", hidden=True)
- async def site_tools_alias(self, ctx: Context) -> None:
- """Alias for invoking <prefix>site tools."""
- await self.invoke(ctx, "site tools")
-
- @command(name="watch", hidden=True)
- async def bigbrother_watch_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:
- """Alias for invoking <prefix>bigbrother watch [user] [reason]."""
- await self.invoke(ctx, "bigbrother watch", user, reason=reason)
-
- @command(name="unwatch", hidden=True)
- async def bigbrother_unwatch_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:
- """Alias for invoking <prefix>bigbrother unwatch [user] [reason]."""
- await self.invoke(ctx, "bigbrother unwatch", user, reason=reason)
-
- @command(name="home", hidden=True)
- async def site_home_alias(self, ctx: Context) -> None:
- """Alias for invoking <prefix>site home."""
- await self.invoke(ctx, "site home")
-
- @command(name="faq", hidden=True)
- async def site_faq_alias(self, ctx: Context) -> None:
- """Alias for invoking <prefix>site faq."""
- await self.invoke(ctx, "site faq")
-
- @command(name="rules", aliases=("rule",), hidden=True)
- async def site_rules_alias(self, ctx: Context, rules: Greedy[int], *_: str) -> None:
- """Alias for invoking <prefix>site rules."""
- await self.invoke(ctx, "site rules", *rules)
-
- @command(name="reload", hidden=True)
- async def extensions_reload_alias(self, ctx: Context, *extensions: Extension) -> None:
- """Alias for invoking <prefix>extensions reload [extensions...]."""
- await self.invoke(ctx, "extensions reload", *extensions)
-
- @command(name="defon", hidden=True)
- async def defcon_enable_alias(self, ctx: Context) -> None:
- """Alias for invoking <prefix>defcon enable."""
- await self.invoke(ctx, "defcon enable")
-
- @command(name="defoff", hidden=True)
- async def defcon_disable_alias(self, ctx: Context) -> None:
- """Alias for invoking <prefix>defcon disable."""
- await self.invoke(ctx, "defcon disable")
-
- @command(name="exception", hidden=True)
- async def tags_get_traceback_alias(self, ctx: Context) -> None:
- """Alias for invoking <prefix>tags get traceback."""
- await self.invoke(ctx, "tags get", tag_name="traceback")
-
- @group(name="get",
- aliases=("show", "g"),
- hidden=True,
- invoke_without_command=True)
- async def get_group_alias(self, ctx: 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)
-
- @command(name="nominate", hidden=True)
- async def nomination_add_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:
- """Alias for invoking <prefix>talentpool add [user] [reason]."""
- await self.invoke(ctx, "talentpool add", user, reason=reason)
-
- @command(name="unnominate", hidden=True)
- async def nomination_end_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:
- """Alias for invoking <prefix>nomination end [user] [reason]."""
- await self.invoke(ctx, "nomination end", user, reason=reason)
-
- @command(name="nominees", hidden=True)
- async def nominees_alias(self, ctx: Context) -> None:
- """Alias for invoking <prefix>tp watched."""
- await self.invoke(ctx, "talentpool watched")
-
-
-def setup(bot: Bot) -> None:
- """Load the Alias cog."""
- bot.add_cog(Alias(bot))
diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py
deleted file mode 100644
index a79b37d25..000000000
--- a/bot/cogs/bot.py
+++ /dev/null
@@ -1,381 +0,0 @@
-import ast
-import logging
-import re
-import time
-from typing import Optional, Tuple
-
-from discord import Embed, Message, RawMessageUpdateEvent, TextChannel
-from discord.ext.commands import Cog, Context, command, group
-
-from bot.bot import Bot
-from bot.cogs.token_remover import TokenRemover
-from bot.constants import Categories, Channels, DEBUG_MODE, Guild, MODERATION_ROLES, Roles, URLs
-from bot.decorators import with_role
-from bot.utils.messages import wait_for_deletion
-
-log = logging.getLogger(__name__)
-
-RE_MARKDOWN = re.compile(r'([*_~`|>])')
-
-
-class BotCog(Cog, name="Bot"):
- """Bot information commands."""
-
- 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)
- @with_role(Roles.verified)
- async def botinfo_group(self, ctx: Context) -> None:
- """Bot informational commands."""
- await ctx.send_help(ctx.command)
-
- @botinfo_group.command(name='about', aliases=('info',), hidden=True)
- @with_role(Roles.verified)
- async def about_command(self, ctx: Context) -> None:
- """Get information about the bot."""
- embed = Embed(
- description="A utility bot designed just for the Python server! Try `!help` for more info.",
- url="https://github.com/python-discord/bot"
- )
-
- embed.add_field(name="Total Users", value=str(len(self.bot.get_guild(Guild.id).members)))
- embed.set_author(
- name="Python Bot",
- url="https://github.com/python-discord/bot",
- icon_url=URLs.bot_avatar
- )
-
- await ctx.send(embed=embed)
-
- @command(name='echo', aliases=('print',))
- @with_role(*MODERATION_ROLES)
- async def echo_command(self, ctx: Context, channel: Optional[TextChannel], *, text: str) -> None:
- """Repeat the given message in either a specified channel or the current channel."""
- if channel is None:
- await ctx.send(text)
- else:
- await channel.send(text)
-
- @command(name='embed')
- @with_role(*MODERATION_ROLES)
- async def embed_command(self, ctx: Context, *, text: str) -> None:
- """Send the input within an embed to the current channel."""
- embed = Embed(description=text)
- await ctx.send(embed=embed)
-
- def codeblock_stripping(self, msg: str, bad_ticks: bool) -> 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)
- )
-
- 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, user_ids=(msg.author.id,), client=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."""
- bot.add_cog(BotCog(bot))
diff --git a/bot/cogs/moderation/__init__.py b/bot/cogs/moderation/__init__.py
deleted file mode 100644
index 6880ca1bd..000000000
--- a/bot/cogs/moderation/__init__.py
+++ /dev/null
@@ -1,15 +0,0 @@
-from bot.bot import Bot
-from .infractions import Infractions
-from .management import ModManagement
-from .modlog import ModLog
-from .silence import Silence
-from .superstarify import Superstarify
-
-
-def setup(bot: Bot) -> None:
- """Load the Infractions, ModManagement, ModLog, Silence, and Superstarify cogs."""
- bot.add_cog(Infractions(bot))
- bot.add_cog(ModLog(bot))
- bot.add_cog(ModManagement(bot))
- bot.add_cog(Silence(bot))
- bot.add_cog(Superstarify(bot))
diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py
deleted file mode 100644
index c8ab6443b..000000000
--- a/bot/cogs/moderation/silence.py
+++ /dev/null
@@ -1,184 +0,0 @@
-import asyncio
-import logging
-from contextlib import suppress
-from typing import NamedTuple, Optional
-
-from discord import TextChannel
-from discord.ext import commands, tasks
-from discord.ext.commands import Context
-
-from bot.bot import Bot
-from bot.constants import Channels, Emojis, Guild, MODERATION_ROLES, Roles
-from bot.converters import HushDurationConverter
-from bot.utils.checks import with_role_check
-from bot.utils.scheduling import Scheduler
-
-log = logging.getLogger(__name__)
-
-
-class TaskData(NamedTuple):
- """Data for a scheduled task."""
-
- delay: int
- ctx: Context
-
-
-class SilenceNotifier(tasks.Loop):
- """Loop notifier for posting notices to `alert_channel` containing added channels."""
-
- def __init__(self, alert_channel: TextChannel):
- super().__init__(self._notifier, seconds=1, minutes=0, hours=0, count=None, reconnect=True, loop=None)
- self._silenced_channels = {}
- self._alert_channel = alert_channel
-
- def add_channel(self, channel: TextChannel) -> None:
- """Add channel to `_silenced_channels` and start loop if not launched."""
- if not self._silenced_channels:
- self.start()
- log.info("Starting notifier loop.")
- self._silenced_channels[channel] = self._current_loop
-
- def remove_channel(self, channel: TextChannel) -> None:
- """Remove channel from `_silenced_channels` and stop loop if no channels remain."""
- with suppress(KeyError):
- del self._silenced_channels[channel]
- if not self._silenced_channels:
- self.stop()
- log.info("Stopping notifier loop.")
-
- async def _notifier(self) -> None:
- """Post notice of `_silenced_channels` with their silenced duration to `_alert_channel` periodically."""
- # Wait for 15 minutes between notices with pause at start of loop.
- if self._current_loop and not self._current_loop/60 % 15:
- log.debug(
- f"Sending notice with channels: "
- f"{', '.join(f'#{channel} ({channel.id})' for channel in self._silenced_channels)}."
- )
- channels_text = ', '.join(
- f"{channel.mention} for {(self._current_loop-start)//60} min"
- for channel, start in self._silenced_channels.items()
- )
- await self._alert_channel.send(f"<@&{Roles.moderators}> currently silenced channels: {channels_text}")
-
-
-class Silence(Scheduler, commands.Cog):
- """Commands for stopping channel messages for `verified` role in a channel."""
-
- def __init__(self, bot: Bot):
- super().__init__()
- self.bot = bot
- self.muted_channels = set()
- self._get_instance_vars_task = self.bot.loop.create_task(self._get_instance_vars())
- self._get_instance_vars_event = asyncio.Event()
-
- async def _scheduled_task(self, task: TaskData) -> None:
- """Calls `self.unsilence` on expired silenced channel to unsilence it."""
- await asyncio.sleep(task.delay)
- log.info("Unsilencing channel after set delay.")
-
- # Because `self.unsilence` explicitly cancels this scheduled task, it is shielded
- # to avoid prematurely cancelling itself
- await asyncio.shield(task.ctx.invoke(self.unsilence))
-
- async def _get_instance_vars(self) -> None:
- """Get instance variables after they're available to get from the guild."""
- await self.bot.wait_until_guild_available()
- guild = self.bot.get_guild(Guild.id)
- self._verified_role = guild.get_role(Roles.verified)
- self._mod_alerts_channel = self.bot.get_channel(Channels.mod_alerts)
- self._mod_log_channel = self.bot.get_channel(Channels.mod_log)
- self.notifier = SilenceNotifier(self._mod_log_channel)
- self._get_instance_vars_event.set()
-
- @commands.command(aliases=("hush",))
- async def silence(self, ctx: Context, duration: HushDurationConverter = 10) -> None:
- """
- Silence the current channel for `duration` minutes or `forever`.
-
- Duration is capped at 15 minutes, passing forever makes the silence indefinite.
- Indefinitely silenced channels get added to a notifier which posts notices every 15 minutes from the start.
- """
- await self._get_instance_vars_event.wait()
- log.debug(f"{ctx.author} is silencing channel #{ctx.channel}.")
- if not await self._silence(ctx.channel, persistent=(duration is None), duration=duration):
- await ctx.send(f"{Emojis.cross_mark} current channel is already silenced.")
- return
- if duration is None:
- await ctx.send(f"{Emojis.check_mark} silenced current channel indefinitely.")
- return
-
- await ctx.send(f"{Emojis.check_mark} silenced current channel for {duration} minute(s).")
-
- task_data = TaskData(
- delay=duration*60,
- ctx=ctx
- )
-
- self.schedule_task(ctx.channel.id, task_data)
-
- @commands.command(aliases=("unhush",))
- async def unsilence(self, ctx: Context) -> None:
- """
- Unsilence the current channel.
-
- If the channel was silenced indefinitely, notifications for the channel will stop.
- """
- await self._get_instance_vars_event.wait()
- log.debug(f"Unsilencing channel #{ctx.channel} from {ctx.author}'s command.")
- if not await self._unsilence(ctx.channel):
- await ctx.send(f"{Emojis.cross_mark} current channel was not silenced.")
- else:
- await ctx.send(f"{Emojis.check_mark} unsilenced current channel.")
-
- async def _silence(self, channel: TextChannel, persistent: bool, duration: Optional[int]) -> bool:
- """
- Silence `channel` for `self._verified_role`.
-
- If `persistent` is `True` add `channel` to notifier.
- `duration` is only used for logging; if None is passed `persistent` should be True to not log None.
- Return `True` if channel permissions were changed, `False` otherwise.
- """
- current_overwrite = channel.overwrites_for(self._verified_role)
- if current_overwrite.send_messages is False:
- log.info(f"Tried to silence channel #{channel} ({channel.id}) but the channel was already silenced.")
- return False
- await channel.set_permissions(self._verified_role, **dict(current_overwrite, send_messages=False))
- self.muted_channels.add(channel)
- if persistent:
- log.info(f"Silenced #{channel} ({channel.id}) indefinitely.")
- self.notifier.add_channel(channel)
- return True
-
- log.info(f"Silenced #{channel} ({channel.id}) for {duration} minute(s).")
- return True
-
- async def _unsilence(self, channel: TextChannel) -> bool:
- """
- Unsilence `channel`.
-
- Check if `channel` is silenced through a `PermissionOverwrite`,
- if it is unsilence it and remove it from the notifier.
- Return `True` if channel permissions were changed, `False` otherwise.
- """
- current_overwrite = channel.overwrites_for(self._verified_role)
- if current_overwrite.send_messages is False:
- await channel.set_permissions(self._verified_role, **dict(current_overwrite, send_messages=None))
- log.info(f"Unsilenced channel #{channel} ({channel.id}).")
- self.cancel_task(channel.id)
- self.notifier.remove_channel(channel)
- self.muted_channels.discard(channel)
- return True
- log.info(f"Tried to unsilence channel #{channel} ({channel.id}) but the channel was not silenced.")
- return False
-
- def cog_unload(self) -> None:
- """Send alert with silenced channels on unload."""
- if self.muted_channels:
- channels_string = ''.join(channel.mention for channel in self.muted_channels)
- message = f"<@&{Roles.moderators}> channels left silenced on cog unload: {channels_string}"
- asyncio.create_task(self._mod_alerts_channel.send(message))
-
- # This cannot be static (must have a __func__ attribute).
- def cog_check(self, ctx: Context) -> bool:
- """Only allow moderators to invoke the commands in this cog."""
- return with_role_check(ctx, *MODERATION_ROLES)
diff --git a/bot/cogs/sync/__init__.py b/bot/cogs/sync/__init__.py
deleted file mode 100644
index fe7df4e9b..000000000
--- a/bot/cogs/sync/__init__.py
+++ /dev/null
@@ -1,7 +0,0 @@
-from bot.bot import Bot
-from .cog import Sync
-
-
-def setup(bot: Bot) -> None:
- """Load the Sync cog."""
- bot.add_cog(Sync(bot))
diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py
deleted file mode 100644
index 536455668..000000000
--- a/bot/cogs/sync/syncers.py
+++ /dev/null
@@ -1,342 +0,0 @@
-import abc
-import asyncio
-import logging
-import typing as t
-from collections import namedtuple
-from functools import partial
-
-from discord import Guild, HTTPException, Member, Message, Reaction, User
-from discord.ext.commands import Context
-
-from bot import constants
-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'))
-
-
-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
-
- @property
- @abc.abstractmethod
- def name(self) -> 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
-
- message = await channel.send(f"{self._CORE_DEV_MENTION}{msg_content}")
- 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
-
- @abc.abstractmethod
- async def _get_diff(self, guild: Guild) -> _Diff:
- """Return the difference between the cache of `guild` and the database."""
- raise NotImplementedError # pragma: no cover
-
- @abc.abstractmethod
- async def _sync(self, 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:
- """
- 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.
- """
- log.info(f"Starting {self.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 ""
-
- try:
- await self._sync(diff)
- except ResponseCodeError as e:
- log.exception(f"{self.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}"
- 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}"
-
- if message:
- await message.edit(content=content)
-
-
-class RoleSyncer(Syncer):
- """Synchronise the database with roles in the cache."""
-
- name = "role"
-
- async def _get_diff(self, 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')
-
- # Pack DB roles and guild roles into one common, hashable format.
- # They're hashable so that they're easily comparable with sets later.
- db_roles = {_Role(**role_dict) for role_dict in roles}
- guild_roles = {
- _Role(
- id=role.id,
- name=role.name,
- colour=role.colour.value,
- permissions=role.permissions.value,
- position=role.position,
- )
- for role in guild.roles
- }
-
- guild_role_ids = {role.id for role in guild_roles}
- api_role_ids = {role.id for role in db_roles}
- new_role_ids = guild_role_ids - api_role_ids
- deleted_role_ids = api_role_ids - guild_role_ids
-
- # New roles are those which are on the cached guild but not on the
- # DB guild, going by the role ID. We need to send them in for creation.
- roles_to_create = {role for role in guild_roles if role.id in new_role_ids}
- roles_to_update = guild_roles - db_roles - roles_to_create
- roles_to_delete = {role for role in db_roles if role.id in deleted_role_ids}
-
- return _Diff(roles_to_create, roles_to_update, roles_to_delete)
-
- async def _sync(self, 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())
-
- log.trace("Syncing updated roles...")
- for role in diff.updated:
- await self.bot.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}')
-
-
-class UserSyncer(Syncer):
- """Synchronise the database with users in the cache."""
-
- name = "user"
-
- async def _get_diff(self, 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 = set()
- users_to_update = set()
-
- 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)
-
- 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)
-
- return _Diff(users_to_create, users_to_update, None)
-
- async def _sync(self, 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())
-
- log.trace("Syncing updated users...")
- for user in diff.updated:
- await self.bot.api_client.put(f'bot/users/{user.id}', json=user._asdict())
diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py
deleted file mode 100644
index ae156cf70..000000000
--- a/bot/cogs/verification.py
+++ /dev/null
@@ -1,191 +0,0 @@
-import logging
-from contextlib import suppress
-
-from discord import Colour, Forbidden, Message, NotFound, Object
-from discord.ext.commands import Cog, Context, command
-
-from bot import constants
-from bot.bot import Bot
-from bot.cogs.moderation import ModLog
-from bot.decorators import in_whitelist, without_role
-from bot.utils.checks import InWhitelistCheckFailure, without_role_check
-
-log = logging.getLogger(__name__)
-
-WELCOME_MESSAGE = f"""
-Hello! Welcome to the server, and thanks for verifying yourself!
-
-For your records, these are the documents you accepted:
-
-`1)` Our rules, here: <https://pythondiscord.com/pages/rules>
-`2)` Our privacy policy, here: <https://pythondiscord.com/pages/privacy> - you can find information on how to have \
-your information removed here as well.
-
-Feel free to review them at any point!
-
-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}>.
-"""
-
-BOT_MESSAGE_DELETE_DELAY = 10
-
-
-class Verification(Cog):
- """User verification and role self-management."""
-
- def __init__(self, bot: Bot):
- self.bot = bot
-
- @property
- def mod_log(self) -> ModLog:
- """Get currently loaded ModLog cog instance."""
- return self.bot.get_cog("ModLog")
-
- @Cog.listener()
- async def on_message(self, message: Message) -> None:
- """Check new message event for messages to the checkpoint channel & process."""
- if message.channel.id != constants.Channels.verification:
- return # Only listen for #checkpoint messages
-
- if message.author.bot:
- # They're a bot, delete their message after the delay.
- await message.delete(delay=BOT_MESSAGE_DELETE_DELAY)
- return
-
- # if a user mentions a role or guild member
- # alert the mods in mod-alerts channel
- if message.mentions or message.role_mentions:
- log.debug(
- f"{message.author} mentioned one or more users "
- f"and/or roles in {message.channel.name}"
- )
-
- embed_text = (
- f"{message.author.mention} sent a message in "
- f"{message.channel.mention} that contained user and/or role mentions."
- f"\n\n**Original message:**\n>>> {message.content}"
- )
-
- # Send pretty mod log embed to mod-alerts
- await self.mod_log.send_log_message(
- icon_url=constants.Icons.filtering,
- colour=Colour(constants.Colours.soft_red),
- title=f"User/Role mentioned in {message.channel.name}",
- text=embed_text,
- thumbnail=message.author.avatar_url_as(static_format="png"),
- channel_id=constants.Channels.mod_alerts,
- )
-
- ctx: Context = await self.bot.get_context(message)
- if ctx.command is not None and ctx.command.name == "accept":
- return
-
- if any(r.id == constants.Roles.verified for r in ctx.author.roles):
- log.info(
- f"{ctx.author} posted '{ctx.message.content}' "
- "in the verification channel, but is already verified."
- )
- return
-
- log.debug(
- f"{ctx.author} posted '{ctx.message.content}' in the verification "
- "channel. We are providing instructions how to verify."
- )
- await ctx.send(
- f"{ctx.author.mention} Please type `!accept` to verify that you accept our rules, "
- f"and gain access to the rest of the server.",
- delete_after=20
- )
-
- log.trace(f"Deleting the message posted by {ctx.author}")
- with suppress(NotFound):
- await ctx.message.delete()
-
- @command(name='accept', aliases=('verify', 'verified', 'accepted'), hidden=True)
- @without_role(constants.Roles.verified)
- @in_whitelist(channels=(constants.Channels.verification,))
- async def accept_command(self, ctx: Context, *_) -> None: # We don't actually care about the args
- """Accept our rules and gain access to the rest of the server."""
- log.debug(f"{ctx.author} called !accept. Assigning the 'Developer' role.")
- await ctx.author.add_roles(Object(constants.Roles.verified), reason="Accepted the rules")
- try:
- await ctx.author.send(WELCOME_MESSAGE)
- except Forbidden:
- log.info(f"Sending welcome message failed for {ctx.author}.")
- finally:
- log.trace(f"Deleting accept message by {ctx.author}.")
- with suppress(NotFound):
- self.mod_log.ignore(constants.Event.message_delete, ctx.message.id)
- await ctx.message.delete()
-
- @command(name='subscribe')
- @in_whitelist(channels=(constants.Channels.bot_commands,))
- async def subscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args
- """Subscribe to announcement notifications by assigning yourself the role."""
- has_role = False
-
- for role in ctx.author.roles:
- if role.id == constants.Roles.announcements:
- has_role = True
- break
-
- if has_role:
- await ctx.send(f"{ctx.author.mention} You're already subscribed!")
- return
-
- log.debug(f"{ctx.author} called !subscribe. Assigning the 'Announcements' role.")
- await ctx.author.add_roles(Object(constants.Roles.announcements), reason="Subscribed to announcements")
-
- log.trace(f"Deleting the message posted by {ctx.author}.")
-
- await ctx.send(
- f"{ctx.author.mention} Subscribed to <#{constants.Channels.announcements}> notifications.",
- )
-
- @command(name='unsubscribe')
- @in_whitelist(channels=(constants.Channels.bot_commands,))
- async def unsubscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args
- """Unsubscribe from announcement notifications by removing the role from yourself."""
- has_role = False
-
- for role in ctx.author.roles:
- if role.id == constants.Roles.announcements:
- has_role = True
- break
-
- if not has_role:
- await ctx.send(f"{ctx.author.mention} You're already unsubscribed!")
- return
-
- log.debug(f"{ctx.author} called !unsubscribe. Removing the 'Announcements' role.")
- await ctx.author.remove_roles(Object(constants.Roles.announcements), reason="Unsubscribed from announcements")
-
- log.trace(f"Deleting the message posted by {ctx.author}.")
-
- await ctx.send(
- f"{ctx.author.mention} Unsubscribed from <#{constants.Channels.announcements}> notifications."
- )
-
- # This cannot be static (must have a __func__ attribute).
- async def cog_command_error(self, ctx: Context, error: Exception) -> None:
- """Check for & ignore any InWhitelistCheckFailure."""
- if isinstance(error, InWhitelistCheckFailure):
- error.handled = True
-
- @staticmethod
- def bot_check(ctx: Context) -> bool:
- """Block any command within the verification channel that is not !accept."""
- if ctx.channel.id == constants.Channels.verification and without_role_check(ctx, *constants.MODERATION_ROLES):
- return ctx.command.name == "accept"
- else:
- return True
-
-
-def setup(bot: Bot) -> None:
- """Load the Verification cog."""
- bot.add_cog(Verification(bot))
diff --git a/bot/cogs/watchchannels/__init__.py b/bot/cogs/watchchannels/__init__.py
deleted file mode 100644
index 69d118df6..000000000
--- a/bot/cogs/watchchannels/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-from bot.bot import Bot
-from .bigbrother import BigBrother
-from .talentpool import TalentPool
-
-
-def setup(bot: Bot) -> None:
- """Load the BigBrother and TalentPool cogs."""
- bot.add_cog(BigBrother(bot))
- bot.add_cog(TalentPool(bot))
diff --git a/bot/cogs/wolfram.py b/bot/cogs/wolfram.py
deleted file mode 100644
index e6cae3bb8..000000000
--- a/bot/cogs/wolfram.py
+++ /dev/null
@@ -1,280 +0,0 @@
-import logging
-from io import BytesIO
-from typing import Callable, List, Optional, Tuple
-from urllib import parse
-
-import discord
-from dateutil.relativedelta import relativedelta
-from discord import Embed
-from discord.ext import commands
-from discord.ext.commands import BucketType, Cog, Context, check, group
-
-from bot.bot import Bot
-from bot.constants import Colours, STAFF_ROLES, Wolfram
-from bot.pagination import ImagePaginator
-from bot.utils.time import humanize_delta
-
-log = logging.getLogger(__name__)
-
-APPID = Wolfram.key
-DEFAULT_OUTPUT_FORMAT = "JSON"
-QUERY = "http://api.wolframalpha.com/v2/{request}?{data}"
-WOLF_IMAGE = "https://www.symbols.com/gi.php?type=1&id=2886&i=1"
-
-MAX_PODS = 20
-
-# Allows for 10 wolfram calls pr user pr day
-usercd = commands.CooldownMapping.from_cooldown(Wolfram.user_limit_day, 60*60*24, BucketType.user)
-
-# Allows for max api requests / days in month per day for the entire guild (Temporary)
-guildcd = commands.CooldownMapping.from_cooldown(Wolfram.guild_limit_day, 60*60*24, BucketType.guild)
-
-
-async def send_embed(
- ctx: Context,
- message_txt: str,
- colour: int = Colours.soft_red,
- footer: str = None,
- img_url: str = None,
- f: discord.File = None
-) -> None:
- """Generate & send a response embed with Wolfram as the author."""
- embed = Embed(colour=colour)
- embed.description = message_txt
- embed.set_author(name="Wolfram Alpha",
- icon_url=WOLF_IMAGE,
- url="https://www.wolframalpha.com/")
- if footer:
- embed.set_footer(text=footer)
-
- if img_url:
- embed.set_image(url=img_url)
-
- await ctx.send(embed=embed, file=f)
-
-
-def custom_cooldown(*ignore: List[int]) -> Callable:
- """
- Implement per-user and per-guild cooldowns for requests to the Wolfram API.
-
- A list of roles may be provided to ignore the per-user cooldown
- """
- async def predicate(ctx: Context) -> bool:
- if ctx.invoked_with == 'help':
- # if the invoked command is help we don't want to increase the ratelimits since it's not actually
- # invoking the command/making a request, so instead just check if the user/guild are on cooldown.
- guild_cooldown = not guildcd.get_bucket(ctx.message).get_tokens() == 0 # if guild is on cooldown
- if not any(r.id in ignore for r in ctx.author.roles): # check user bucket if user is not ignored
- return guild_cooldown and not usercd.get_bucket(ctx.message).get_tokens() == 0
- return guild_cooldown
-
- user_bucket = usercd.get_bucket(ctx.message)
-
- if all(role.id not in ignore for role in ctx.author.roles):
- user_rate = user_bucket.update_rate_limit()
-
- if user_rate:
- # Can't use api; cause: member limit
- delta = relativedelta(seconds=int(user_rate))
- cooldown = humanize_delta(delta)
- message = (
- "You've used up your limit for Wolfram|Alpha requests.\n"
- f"Cooldown: {cooldown}"
- )
- await send_embed(ctx, message)
- return False
-
- guild_bucket = guildcd.get_bucket(ctx.message)
- guild_rate = guild_bucket.update_rate_limit()
-
- # Repr has a token attribute to read requests left
- log.debug(guild_bucket)
-
- if guild_rate:
- # Can't use api; cause: guild limit
- message = (
- "The max limit of requests for the server has been reached for today.\n"
- f"Cooldown: {int(guild_rate)}"
- )
- await send_embed(ctx, message)
- return False
-
- return True
- return check(predicate)
-
-
-async def get_pod_pages(ctx: Context, bot: Bot, query: str) -> Optional[List[Tuple]]:
- """Get the Wolfram API pod pages for the provided query."""
- async with ctx.channel.typing():
- url_str = parse.urlencode({
- "input": query,
- "appid": APPID,
- "output": DEFAULT_OUTPUT_FORMAT,
- "format": "image,plaintext"
- })
- request_url = QUERY.format(request="query", data=url_str)
-
- async with bot.http_session.get(request_url) as response:
- json = await response.json(content_type='text/plain')
-
- result = json["queryresult"]
-
- if result["error"]:
- # API key not set up correctly
- if result["error"]["msg"] == "Invalid appid":
- message = "Wolfram API key is invalid or missing."
- log.warning(
- "API key seems to be missing, or invalid when "
- f"processing a wolfram request: {url_str}, Response: {json}"
- )
- await send_embed(ctx, message)
- return
-
- message = "Something went wrong internally with your request, please notify staff!"
- log.warning(f"Something went wrong getting a response from wolfram: {url_str}, Response: {json}")
- await send_embed(ctx, message)
- return
-
- if not result["success"]:
- message = f"I couldn't find anything for {query}."
- await send_embed(ctx, message)
- return
-
- if not result["numpods"]:
- message = "Could not find any results."
- await send_embed(ctx, message)
- return
-
- pods = result["pods"]
- pages = []
- for pod in pods[:MAX_PODS]:
- subs = pod.get("subpods")
-
- for sub in subs:
- title = sub.get("title") or sub.get("plaintext") or sub.get("id", "")
- img = sub["img"]["src"]
- pages.append((title, img))
- return pages
-
-
-class Wolfram(Cog):
- """Commands for interacting with the Wolfram|Alpha API."""
-
- def __init__(self, bot: Bot):
- self.bot = bot
-
- @group(name="wolfram", aliases=("wolf", "wa"), invoke_without_command=True)
- @custom_cooldown(*STAFF_ROLES)
- async def wolfram_command(self, ctx: Context, *, query: str) -> None:
- """Requests all answers on a single image, sends an image of all related pods."""
- url_str = parse.urlencode({
- "i": query,
- "appid": APPID,
- })
- query = QUERY.format(request="simple", data=url_str)
-
- # Give feedback that the bot is working.
- async with ctx.channel.typing():
- async with self.bot.http_session.get(query) as response:
- status = response.status
- image_bytes = await response.read()
-
- f = discord.File(BytesIO(image_bytes), filename="image.png")
- image_url = "attachment://image.png"
-
- if status == 501:
- message = "Failed to get response"
- footer = ""
- color = Colours.soft_red
- elif status == 400:
- message = "No input found"
- footer = ""
- color = Colours.soft_red
- elif status == 403:
- message = "Wolfram API key is invalid or missing."
- footer = ""
- color = Colours.soft_red
- else:
- message = ""
- footer = "View original for a bigger picture."
- color = Colours.soft_orange
-
- # Sends a "blank" embed if no request is received, unsure how to fix
- await send_embed(ctx, message, color, footer=footer, img_url=image_url, f=f)
-
- @wolfram_command.command(name="page", aliases=("pa", "p"))
- @custom_cooldown(*STAFF_ROLES)
- async def wolfram_page_command(self, ctx: Context, *, query: str) -> None:
- """
- Requests a drawn image of given query.
-
- Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc.
- """
- pages = await get_pod_pages(ctx, self.bot, query)
-
- if not pages:
- return
-
- embed = Embed()
- embed.set_author(name="Wolfram Alpha",
- icon_url=WOLF_IMAGE,
- url="https://www.wolframalpha.com/")
- embed.colour = Colours.soft_orange
-
- await ImagePaginator.paginate(pages, ctx, embed)
-
- @wolfram_command.command(name="cut", aliases=("c",))
- @custom_cooldown(*STAFF_ROLES)
- async def wolfram_cut_command(self, ctx: Context, *, query: str) -> None:
- """
- Requests a drawn image of given query.
-
- Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc.
- """
- pages = await get_pod_pages(ctx, self.bot, query)
-
- if not pages:
- return
-
- if len(pages) >= 2:
- page = pages[1]
- else:
- page = pages[0]
-
- await send_embed(ctx, page[0], colour=Colours.soft_orange, img_url=page[1])
-
- @wolfram_command.command(name="short", aliases=("sh", "s"))
- @custom_cooldown(*STAFF_ROLES)
- async def wolfram_short_command(self, ctx: Context, *, query: str) -> None:
- """Requests an answer to a simple question."""
- url_str = parse.urlencode({
- "i": query,
- "appid": APPID,
- })
- query = QUERY.format(request="result", data=url_str)
-
- # Give feedback that the bot is working.
- async with ctx.channel.typing():
- async with self.bot.http_session.get(query) as response:
- status = response.status
- response_text = await response.text()
-
- if status == 501:
- message = "Failed to get response"
- color = Colours.soft_red
- elif status == 400:
- message = "No input found"
- color = Colours.soft_red
- elif response_text == "Error 1: Invalid appid":
- message = "Wolfram API key is invalid or missing."
- color = Colours.soft_red
- else:
- message = response_text
- color = Colours.soft_orange
-
- await send_embed(ctx, message, color)
-
-
-def setup(bot: Bot) -> None:
- """Load the Wolfram cog."""
- bot.add_cog(Wolfram(bot))
diff --git a/bot/command.py b/bot/command.py
new file mode 100644
index 000000000..0fb900f7b
--- /dev/null
+++ b/bot/command.py
@@ -0,0 +1,18 @@
+from discord.ext import commands
+
+
+class Command(commands.Command):
+ """
+ A `discord.ext.commands.Command` subclass which supports root aliases.
+
+ A `root_aliases` keyword argument is added, which is a sequence of alias names that will act as
+ top-level commands rather than being aliases of the command's group. It's stored as an attribute
+ also named `root_aliases`.
+ """
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.root_aliases = kwargs.get("root_aliases", [])
+
+ if not isinstance(self.root_aliases, (list, tuple)):
+ raise TypeError("Root aliases of a command must be a list or a tuple of strings.")
diff --git a/bot/constants.py b/bot/constants.py
index a1b392c82..23d5b4304 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -217,6 +217,7 @@ class Filter(metaclass=YAMLGetter):
filter_zalgo: bool
filter_invites: bool
filter_domains: bool
+ filter_everyone_ping: bool
watch_regex: bool
watch_rich_embeds: bool
@@ -224,13 +225,10 @@ class Filter(metaclass=YAMLGetter):
notify_user_zalgo: bool
notify_user_invites: bool
notify_user_domains: bool
+ notify_user_everyone_ping: bool
ping_everyone: bool
offensive_msg_delete_days: int
- guild_invite_whitelist: List[int]
- domain_blacklist: List[str]
- word_watchlist: List[str]
- token_watchlist: List[str]
channel_whitelist: List[int]
role_whitelist: List[int]
@@ -256,7 +254,7 @@ class DuckPond(metaclass=YAMLGetter):
section = "duck_pond"
threshold: int
- custom_emojis: List[int]
+ channel_blacklist: List[int]
class Emojis(metaclass=YAMLGetter):
@@ -272,6 +270,21 @@ class Emojis(metaclass=YAMLGetter):
status_idle: str
status_dnd: str
+ badge_staff: str
+ badge_partner: str
+ badge_hypesquad: str
+ badge_bug_hunter: str
+ badge_hypesquad_bravery: str
+ badge_hypesquad_brilliance: str
+ badge_hypesquad_balance: str
+ badge_early_supporter: str
+ badge_bug_hunter_level_2: str
+ badge_verified_bot_developer: str
+
+ incident_actioned: str
+ incident_unactioned: str
+ incident_investigating: str
+
failmail: str
trashcan: str
@@ -281,20 +294,6 @@ class Emojis(metaclass=YAMLGetter):
cross_mark: str
check_mark: str
- ducky_yellow: int
- ducky_blurple: int
- ducky_regal: int
- ducky_camo: int
- ducky_ninja: int
- ducky_devil: int
- ducky_tube: int
- ducky_hunt: int
- ducky_wizard: int
- ducky_party: int
- ducky_angel: int
- ducky_maul: int
- ducky_santa: int
-
upvotes: str
comments: str
user: str
@@ -378,30 +377,39 @@ class Categories(metaclass=YAMLGetter):
help_in_use: int
help_dormant: int
modmail: int
+ voice: int
class Channels(metaclass=YAMLGetter):
section = "guild"
subsection = "channels"
+ admin_announcements: int
admin_spam: int
admins: int
announcements: int
attachment_log: int
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
dev_core: int
dev_log: int
+ dm_log: int
esoteric: int
helpers: int
how_to_get_help: int
incidents: int
+ incidents_archive: int
+ mailing_lists: int
message_log: int
meta: int
mod_alerts: int
+ mod_announcements: int
mod_log: int
mod_spam: int
mods: int
@@ -410,11 +418,16 @@ class Channels(metaclass=YAMLGetter):
off_topic_2: int
organisation: int
python_discussion: int
+ python_events: int
+ python_news: int
reddit: int
+ staff_announcements: int
talent_pool: int
user_event_announcements: int
user_log: int
verification: int
+ voice_chat: int
+ voice_gate: int
voice_log: int
@@ -422,11 +435,13 @@ class Webhooks(metaclass=YAMLGetter):
section = "guild"
subsection = "webhooks"
- talent_pool: int
big_brother: int
- reddit: int
- duck_pond: int
dev_log: int
+ dm_log: int
+ duck_pond: int
+ incidents_archive: int
+ reddit: int
+ talent_pool: int
class Roles(metaclass=YAMLGetter):
@@ -445,21 +460,26 @@ 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):
section = "guild"
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]
- staff_channels: List[int]
staff_roles: List[int]
+
class Keys(metaclass=YAMLGetter):
section = "keys"
@@ -480,25 +500,13 @@ class URLs(metaclass=YAMLGetter):
bot_avatar: str
github_bot_repo: str
- # Site endpoints
+ # Base site vars
site: str
site_api: str
- site_superstarify_api: str
- site_logs_api: str
- site_logs_view: str
- site_reminders_api: str
- site_reminders_user_api: str
site_schema: str
- site_settings_api: str
- site_tags_api: str
- site_user_api: str
- site_user_complete_api: str
- site_infractions: str
- site_infractions_user: str
- site_infractions_type: str
- site_infractions_by_id: str
- site_infractions_user_type_current: str
- site_infractions_user_type: str
+
+ # Site endpoints
+ site_logs_view: str
paste_service: str
@@ -510,14 +518,6 @@ class Reddit(metaclass=YAMLGetter):
secret: Optional[str]
-class Wolfram(metaclass=YAMLGetter):
- section = "wolfram"
-
- user_limit_day: int
- guild_limit_day: int
- key: Optional[str]
-
-
class AntiSpam(metaclass=YAMLGetter):
section = 'anti_spam'
@@ -528,12 +528,6 @@ class AntiSpam(metaclass=YAMLGetter):
rules: Dict[str, Dict[str, int]]
-class AntiMalware(metaclass=YAMLGetter):
- section = "anti_malware"
-
- whitelist: list
-
-
class BigBrother(metaclass=YAMLGetter):
section = 'big_brother'
@@ -541,6 +535,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'
@@ -573,13 +576,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'
@@ -588,6 +584,24 @@ class PythonNews(metaclass=YAMLGetter):
webhook: int
+class Verification(metaclass=YAMLGetter):
+ section = "verification"
+
+ unverified_after: int
+ kicked_after: int
+ reminder_frequency: int
+ bot_message_delete_delay: int
+ kick_confirmation_threshold: float
+
+
+class VoiceGate(metaclass=YAMLGetter):
+ section = "voice_gate"
+
+ minimum_days_verified: int
+ minimum_messages: int
+ bot_message_delete_delay: int
+
+
class Event(Enum):
"""
Event names. This does not include every event (for example, raw
@@ -626,9 +640,11 @@ MODERATION_ROLES = Guild.moderation_roles
STAFF_ROLES = Guild.staff_roles
# Channel combinations
-STAFF_CHANNELS = Guild.staff_channels
MODERATION_CHANNELS = Guild.moderation_channels
+# Category combinations
+MODERATION_CATEGORIES = Guild.moderation_categories
+
# Bot replies
NEGATIVE_REPLIES = [
"Noooooo!!",
diff --git a/bot/converters.py b/bot/converters.py
index 4deb59f87..2e118d476 100644
--- a/bot/converters.py
+++ b/bot/converters.py
@@ -2,6 +2,7 @@ import logging
import re
import typing as t
from datetime import datetime
+from functools import partial
from ssl import CertificateError
import dateutil.parser
@@ -9,11 +10,18 @@ import dateutil.tz
import discord
from aiohttp import ClientConnectorError
from dateutil.relativedelta import relativedelta
-from discord.ext.commands import BadArgument, Context, Converter, UserConverter
+from discord.ext.commands import BadArgument, Bot, Context, Converter, IDConverter, UserConverter
+from discord.utils import DISCORD_EPOCH, snowflake_time
+from bot.api import ResponseCodeError
+from bot.constants import URLs
+from bot.utils.regex import INVITE_RE
log = logging.getLogger(__name__)
+DISCORD_EPOCH_DT = datetime.utcfromtimestamp(DISCORD_EPOCH / 1000)
+RE_USER_MENTION = re.compile(r"<@!?([0-9]+)>$")
+
def allowed_strings(*values, preserve_case: bool = False) -> t.Callable[[str], str]:
"""
@@ -34,6 +42,90 @@ def allowed_strings(*values, preserve_case: bool = False) -> t.Callable[[str], s
return converter
+class ValidDiscordServerInvite(Converter):
+ """
+ A converter that validates whether a given string is a valid Discord server invite.
+
+ Raises 'BadArgument' if:
+ - The string is not a valid Discord server invite.
+ - The string is valid, but is an invite for a group DM.
+ - The string is valid, but is expired.
+
+ Returns a (partial) guild object if:
+ - The string is a valid vanity
+ - The string is a full invite URI
+ - The string contains the invite code (the stuff after discord.gg/)
+
+ See the Discord API docs for documentation on the guild object:
+ https://discord.com/developers/docs/resources/guild#guild-object
+ """
+
+ async def convert(self, ctx: Context, server_invite: str) -> dict:
+ """Check whether the string is a valid Discord server invite."""
+ invite_code = INVITE_RE.search(server_invite)
+ if invite_code:
+ response = await ctx.bot.http_session.get(
+ f"{URLs.discord_invite_api}/{invite_code[1]}"
+ )
+ if response.status != 404:
+ invite_data = await response.json()
+ return invite_data.get("guild")
+
+ id_converter = IDConverter()
+ if id_converter._get_id_match(server_invite):
+ raise BadArgument("Guild IDs are not supported, only invites.")
+
+ raise BadArgument("This does not appear to be a valid Discord server invite.")
+
+
+class ValidFilterListType(Converter):
+ """
+ A converter that checks whether the given string is a valid FilterList type.
+
+ Raises `BadArgument` if the argument is not a valid FilterList type, and simply
+ passes through the given argument otherwise.
+ """
+
+ @staticmethod
+ async def get_valid_types(bot: Bot) -> list:
+ """
+ Try to get a list of valid filter list types.
+
+ Raise a BadArgument if the API can't respond.
+ """
+ try:
+ valid_types = await bot.api_client.get('bot/filter-lists/get-types')
+ except ResponseCodeError:
+ raise BadArgument("Cannot validate list_type: Unable to fetch valid types from API.")
+
+ return [enum for enum, classname in valid_types]
+
+ async def convert(self, ctx: Context, list_type: str) -> str:
+ """Checks whether the given string is a valid FilterList type."""
+ valid_types = await self.get_valid_types(ctx.bot)
+ list_type = list_type.upper()
+
+ if list_type not in valid_types:
+
+ # Maybe the user is using the plural form of this type,
+ # e.g. "guild_invites" instead of "guild_invite".
+ #
+ # This code will support the simple plural form (a single 's' at the end),
+ # which works for all current list types, but if a list type is added in the future
+ # which has an irregular plural form (like 'ies'), this code will need to be
+ # refactored to support this.
+ if list_type.endswith("S") and list_type[:-1] in valid_types:
+ list_type = list_type[:-1]
+
+ else:
+ valid_types_list = '\n'.join([f"• {type_.lower()}" for type_ in valid_types])
+ raise BadArgument(
+ f"You have provided an invalid list type!\n\n"
+ f"Please provide one of the following: \n{valid_types_list}"
+ )
+ return list_type
+
+
class ValidPythonIdentifier(Converter):
"""
A converter that checks whether the given string is a valid Python identifier.
@@ -85,17 +177,42 @@ class ValidURL(Converter):
return url
-class InfractionSearchQuery(Converter):
- """A converter that checks if the argument is a Discord user, and if not, falls back to a string."""
+class Snowflake(IDConverter):
+ """
+ Converts to an int if the argument is a valid Discord snowflake.
+
+ A snowflake is valid if:
+
+ * It consists of 15-21 digits (0-9)
+ * Its parsed datetime is after the Discord epoch
+ * Its parsed datetime is less than 1 day after the current time
+ """
+
+ async def convert(self, ctx: Context, arg: str) -> int:
+ """
+ Ensure `arg` matches the ID pattern and its timestamp is in range.
+
+ Return `arg` as an int if it's a valid snowflake.
+ """
+ error = f"Invalid snowflake {arg!r}"
+
+ if not self._get_id_match(arg):
+ raise BadArgument(error)
+
+ snowflake = int(arg)
- @staticmethod
- async def convert(ctx: Context, arg: str) -> t.Union[discord.Member, str]:
- """Check if the argument is a Discord user, and if not, falls back to a string."""
try:
- maybe_snowflake = arg.strip("<@!>")
- return await ctx.bot.fetch_user(maybe_snowflake)
- except (discord.NotFound, discord.HTTPException):
- return arg
+ time = snowflake_time(snowflake)
+ except (OverflowError, OSError) as e:
+ # Not sure if this can ever even happen, but let's be safe.
+ raise BadArgument(f"{error}: {e}")
+
+ if time < DISCORD_EPOCH_DT:
+ raise BadArgument(f"{error}: timestamp is before the Discord epoch.")
+ elif (datetime.utcnow() - time).days < -1:
+ raise BadArgument(f"{error}: timestamp is too far into the future.")
+
+ return snowflake
class Subreddit(Converter):
@@ -181,8 +298,8 @@ class TagContentConverter(Converter):
return tag_content
-class Duration(Converter):
- """Convert duration strings into UTC datetime.datetime objects."""
+class DurationDelta(Converter):
+ """Convert duration strings into dateutil.relativedelta.relativedelta objects."""
duration_parser = re.compile(
r"((?P<years>\d+?) ?(years|year|Y|y) ?)?"
@@ -194,9 +311,9 @@ class Duration(Converter):
r"((?P<seconds>\d+?) ?(seconds|second|S|s))?"
)
- async def convert(self, ctx: Context, duration: str) -> datetime:
+ async def convert(self, ctx: Context, duration: str) -> relativedelta:
"""
- Converts a `duration` string to a datetime object that's `duration` in the future.
+ Converts a `duration` string to a relativedelta object.
The converter supports the following symbols for each unit of time:
- years: `Y`, `y`, `year`, `years`
@@ -215,6 +332,20 @@ class Duration(Converter):
duration_dict = {unit: int(amount) for unit, amount in match.groupdict(default=0).items()}
delta = relativedelta(**duration_dict)
+
+ return delta
+
+
+class Duration(DurationDelta):
+ """Convert duration strings into UTC datetime.datetime objects."""
+
+ async def convert(self, ctx: Context, duration: str) -> datetime:
+ """
+ Converts a `duration` string to a datetime object that's `duration` in the future.
+
+ The converter supports the same symbols for each unit of time as its parent class.
+ """
+ delta = await super().convert(ctx, duration)
now = datetime.utcnow()
try:
@@ -223,6 +354,32 @@ class Duration(Converter):
raise BadArgument(f"`{duration}` results in a datetime outside the supported range.")
+class OffTopicName(Converter):
+ """A converter that ensures an added off-topic name is valid."""
+
+ async def convert(self, ctx: Context, argument: str) -> str:
+ """Attempt to replace any invalid characters with their approximate Unicode equivalent."""
+ allowed_characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ!?'`-"
+
+ # Chain multiple words to a single one
+ argument = "-".join(argument.split())
+
+ if not (2 <= len(argument) <= 96):
+ raise BadArgument("Channel name must be between 2 and 96 chars long")
+
+ elif not all(c.isalnum() or c in allowed_characters for c in argument):
+ raise BadArgument(
+ "Channel name must only consist of "
+ "alphanumeric characters, minus signs or apostrophes."
+ )
+
+ # Replace invalid characters with unicode alternatives.
+ table = str.maketrans(
+ allowed_characters, '𝖠𝖡𝖢𝖣𝖤𝖥𝖦𝖧𝖨𝖩𝖪𝖫𝖬𝖭𝖮𝖯𝖰𝖱𝖲𝖳𝖴𝖵𝖶𝖷𝖸𝖹ǃ?’’-'
+ )
+ return argument.translate(table)
+
+
class ISODateTime(Converter):
"""Converts an ISO-8601 datetime string into a datetime.datetime."""
@@ -316,6 +473,24 @@ def proxy_user(user_id: str) -> discord.Object:
return user
+class UserMentionOrID(UserConverter):
+ """
+ Converts to a `discord.User`, but only if a mention or userID is provided.
+
+ Unlike the default `UserConverter`, it doesn't allow conversion from a name or name#descrim.
+ This is useful in cases where that lookup strategy would lead to ambiguity.
+ """
+
+ async def convert(self, ctx: Context, argument: str) -> discord.User:
+ """Convert the `arg` to a `discord.User`."""
+ match = self._get_id_match(argument) or RE_USER_MENTION.match(argument)
+
+ if match is not None:
+ return await super().convert(ctx, argument)
+ else:
+ raise BadArgument(f"`{argument}` is not a User mention or a User ID.")
+
+
class FetchedUser(UserConverter):
"""
Converts to a `discord.User` or, if it fails, a `discord.Object`.
@@ -361,5 +536,19 @@ class FetchedUser(UserConverter):
raise BadArgument(f"User `{arg}` does not exist")
+def _snowflake_from_regex(pattern: t.Pattern, arg: str) -> int:
+ """
+ Extract the snowflake from `arg` using a regex `pattern` and return it as an int.
+
+ The snowflake is expected to be within the first capture group in `pattern`.
+ """
+ match = pattern.match(arg)
+ if not match:
+ raise BadArgument(f"Mention {str!r} is invalid.")
+
+ return int(match.group(1))
+
+
Expiry = t.Union[Duration, ISODateTime]
FetchedMember = t.Union[discord.Member, FetchedUser]
+UserMention = partial(_snowflake_from_regex, RE_USER_MENTION)
diff --git a/bot/decorators.py b/bot/decorators.py
index 500197c89..063c8f878 100644
--- a/bot/decorators.py
+++ b/bot/decorators.py
@@ -1,30 +1,28 @@
+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
-from discord.errors import 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.utils.checks import in_whitelist_check, with_role_check, without_role_check
+from bot.constants import Channels, RedirectOutput
+from bot.utils import function
+from bot.utils.checks import in_whitelist_check
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.
@@ -32,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
@@ -45,54 +43,26 @@ def in_whitelist(
return commands.check(predicate)
-def with_role(*role_ids: int) -> Callable:
- """Returns True if the user has any one of the roles in role_ids."""
- async def predicate(ctx: Context) -> bool:
- """With role checker predicate."""
- return with_role_check(ctx, *role_ids)
- return commands.check(predicate)
-
-
-def without_role(*role_ids: int) -> Callable:
- """Returns True if the user does not have any of the roles in role_ids."""
- async def predicate(ctx: Context) -> bool:
- return without_role_check(ctx, *role_ids)
- return commands.check(predicate)
-
-
-def locked() -> Callable:
+def has_no_roles(*roles: t.Union[str, int]) -> t.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.
+ Returns True if the user does not have any of the roles specified.
- This decorator must go before (below) the `command` decorator.
+ `roles` are the names or IDs of the disallowed roles.
"""
- 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 def predicate(ctx: Context) -> bool:
+ try:
+ await commands.has_any_role(*roles).predicate(ctx)
+ except commands.MissingAnyRole:
+ return True
+ else:
+ # This error is never shown to users, so don't bother trying to make it too pretty.
+ roles_ = ", ".join(f"'{item}'" for item in roles)
+ raise commands.CheckFailure(f"You have at least one of the disallowed roles: {roles_}")
- async with func.__locks.setdefault(ctx.author.id, Lock()):
- await func(self, ctx, *args, **kwargs)
- return inner
- return wrap
+ return commands.check(predicate)
-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.
@@ -100,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:
@@ -119,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()
@@ -140,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} "
@@ -182,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/cogs/__init__.py b/bot/exts/__init__.py
index e69de29bb..e69de29bb 100644
--- a/bot/cogs/__init__.py
+++ b/bot/exts/__init__.py
diff --git a/tests/bot/cogs/__init__.py b/bot/exts/backend/__init__.py
index e69de29bb..e69de29bb 100644
--- a/tests/bot/cogs/__init__.py
+++ b/bot/exts/backend/__init__.py
diff --git a/bot/cogs/config_verifier.py b/bot/exts/backend/config_verifier.py
index d72c6c22e..d72c6c22e 100644
--- a/bot/cogs/config_verifier.py
+++ b/bot/exts/backend/config_verifier.py
diff --git a/bot/cogs/error_handler.py b/bot/exts/backend/error_handler.py
index 5de961116..c643d346e 100644
--- a/bot/cogs/error_handler.py
+++ b/bot/exts/backend/error_handler.py
@@ -2,13 +2,15 @@ import contextlib
import logging
import typing as t
+from discord import Embed
from discord.ext.commands import Cog, Context, errors
from sentry_sdk import push_scope
from bot.api import ResponseCodeError
from bot.bot import Bot
-from bot.constants import Channels
+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__)
@@ -20,6 +22,14 @@ class ErrorHandler(Cog):
def __init__(self, bot: Bot):
self.bot = bot
+ def _get_error_embed(self, title: str, body: str) -> Embed:
+ """Return an embed that contains the exception."""
+ return Embed(
+ title=title,
+ colour=Colours.soft_red,
+ description=body
+ )
+
@Cog.listener()
async def on_command_error(self, ctx: Context, e: errors.CommandError) -> None:
"""
@@ -66,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.
@@ -162,25 +174,34 @@ class ErrorHandler(Cog):
prepared_help_command = self.get_help_command(ctx)
if isinstance(e, errors.MissingRequiredArgument):
- await ctx.send(f"Missing required argument `{e.param.name}`.")
+ embed = self._get_error_embed("Missing required argument", e.param.name)
+ await ctx.send(embed=embed)
await prepared_help_command
self.bot.stats.incr("errors.missing_required_argument")
elif isinstance(e, errors.TooManyArguments):
- await ctx.send("Too many arguments provided.")
+ embed = self._get_error_embed("Too many arguments", str(e))
+ await ctx.send(embed=embed)
await prepared_help_command
self.bot.stats.incr("errors.too_many_arguments")
elif isinstance(e, errors.BadArgument):
- await ctx.send(f"Bad argument: {e}\n")
+ embed = self._get_error_embed("Bad argument", str(e))
+ await ctx.send(embed=embed)
await prepared_help_command
self.bot.stats.incr("errors.bad_argument")
elif isinstance(e, errors.BadUnionArgument):
- await ctx.send(f"Bad argument: {e}\n```{e.errors[-1]}```")
+ embed = self._get_error_embed("Bad argument", f"{e}\n{e.errors[-1]}")
+ await ctx.send(embed=embed)
self.bot.stats.incr("errors.bad_union_argument")
elif isinstance(e, errors.ArgumentParsingError):
- await ctx.send(f"Argument parsing error: {e}")
+ embed = self._get_error_embed("Argument parsing error", str(e))
+ await ctx.send(embed=embed)
self.bot.stats.incr("errors.argument_parsing_error")
else:
- await ctx.send("Something about your input seems off. Check the arguments:")
+ embed = self._get_error_embed(
+ "Input error",
+ "Something about your input seems off. Check the arguments and try again."
+ )
+ await ctx.send(embed=embed)
await prepared_help_command
self.bot.stats.incr("errors.other_user_input_error")
diff --git a/bot/cogs/logging.py b/bot/exts/backend/logging.py
index 94fa2b139..94fa2b139 100644
--- a/bot/cogs/logging.py
+++ b/bot/exts/backend/logging.py
diff --git a/bot/exts/backend/sync/__init__.py b/bot/exts/backend/sync/__init__.py
new file mode 100644
index 000000000..829098f79
--- /dev/null
+++ b/bot/exts/backend/sync/__init__.py
@@ -0,0 +1,8 @@
+from bot.bot import Bot
+
+
+def setup(bot: Bot) -> None:
+ """Load the Sync cog."""
+ # Defer import to reduce side effects from importing the sync package.
+ from bot.exts.backend.sync._cog import Sync
+ bot.add_cog(Sync(bot))
diff --git a/bot/cogs/sync/cog.py b/bot/exts/backend/sync/_cog.py
index 5ace957e7..6e85e2b7d 100644
--- a/bot/cogs/sync/cog.py
+++ b/bot/exts/backend/sync/_cog.py
@@ -8,7 +8,7 @@ from discord.ext.commands import Cog, Context
from bot import constants
from bot.api import ResponseCodeError
from bot.bot import Bot
-from bot.cogs.sync import syncers
+from bot.exts.backend.sync import _syncers
log = logging.getLogger(__name__)
@@ -18,8 +18,8 @@ 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.role_syncer = _syncers.RoleSyncer(self.bot)
+ self.user_syncer = _syncers.UserSyncer(self.bot)
self.bot.loop.create_task(self.sync_guild())
diff --git a/bot/exts/backend/sync/_syncers.py b/bot/exts/backend/sync/_syncers.py
new file mode 100644
index 000000000..38468c2b1
--- /dev/null
+++ b/bot/exts/backend/sync/_syncers.py
@@ -0,0 +1,208 @@
+import abc
+import logging
+import typing as t
+from collections import namedtuple
+
+from discord import Guild
+from discord.ext.commands import Context
+
+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'))
+_Diff = namedtuple('Diff', ('created', 'updated', 'deleted'))
+
+
+class Syncer(abc.ABC):
+ """Base class for synchronising the database with objects in the Discord cache."""
+
+ def __init__(self, bot: Bot) -> None:
+ self.bot = bot
+
+ @property
+ @abc.abstractmethod
+ def name(self) -> str:
+ """The name of the syncer; used in output messages and logging."""
+ raise NotImplementedError # pragma: no cover
+
+ @abc.abstractmethod
+ async def _get_diff(self, guild: Guild) -> _Diff:
+ """Return the difference between the cache of `guild` and the database."""
+ raise NotImplementedError # pragma: no cover
+
+ @abc.abstractmethod
+ async def _sync(self, diff: _Diff) -> None:
+ """Perform the API calls for synchronisation."""
+ raise NotImplementedError # pragma: no cover
+
+ async def sync(self, guild: Guild, ctx: t.Optional[Context] = None) -> None:
+ """
+ Synchronise the database with the cache of `guild`.
+
+ If `ctx` is given, send a message with the results.
+ """
+ log.info(f"Starting {self.name} syncer.")
+
+ if ctx:
+ message = await ctx.send(f"📊 Synchronising {self.name}s.")
+ else:
+ message = None
+ diff = await self._get_diff(guild)
+
+ try:
+ await self._sync(diff)
+ except ResponseCodeError as e:
+ log.exception(f"{self.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: Synchronisation of {self.name}s failed: {results}"
+ else:
+ 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"{self.name} syncer finished: {results}.")
+ content = f":ok_hand: Synchronisation of {self.name}s complete: {results}"
+
+ if message:
+ await message.edit(content=content)
+
+
+class RoleSyncer(Syncer):
+ """Synchronise the database with roles in the cache."""
+
+ name = "role"
+
+ async def _get_diff(self, 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')
+
+ # Pack DB roles and guild roles into one common, hashable format.
+ # They're hashable so that they're easily comparable with sets later.
+ db_roles = {_Role(**role_dict) for role_dict in roles}
+ guild_roles = {
+ _Role(
+ id=role.id,
+ name=role.name,
+ colour=role.colour.value,
+ permissions=role.permissions.value,
+ position=role.position,
+ )
+ for role in guild.roles
+ }
+
+ guild_role_ids = {role.id for role in guild_roles}
+ api_role_ids = {role.id for role in db_roles}
+ new_role_ids = guild_role_ids - api_role_ids
+ deleted_role_ids = api_role_ids - guild_role_ids
+
+ # New roles are those which are on the cached guild but not on the
+ # DB guild, going by the role ID. We need to send them in for creation.
+ roles_to_create = {role for role in guild_roles if role.id in new_role_ids}
+ roles_to_update = guild_roles - db_roles - roles_to_create
+ roles_to_delete = {role for role in db_roles if role.id in deleted_role_ids}
+
+ return _Diff(roles_to_create, roles_to_update, roles_to_delete)
+
+ async def _sync(self, 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())
+
+ log.trace("Syncing updated roles...")
+ for role in diff.updated:
+ await self.bot.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}')
+
+
+class UserSyncer(Syncer):
+ """Synchronise the database with users in the cache."""
+
+ name = "user"
+
+ async def _get_diff(self, guild: Guild) -> _Diff:
+ """Return the difference of users between the cache of `guild` and the database."""
+ log.trace("Getting the diff for users.")
+
+ users_to_create = []
+ users_to_update = []
+ seen_guild_users = set()
+
+ async for db_user in self._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
+
+ if guild_user := guild.get_member(db_user["id"]):
+ seen_guild_users.add(guild_user.id)
+
+ maybe_update("name", guild_user.name)
+ maybe_update("discriminator", int(guild_user.discriminator))
+ maybe_update("in_guild", True)
+
+ 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.
+ 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 _get_users(self) -> t.AsyncIterable:
+ """GET users from database."""
+ query_params = {
+ "page": 1
+ }
+ while query_params["page"]:
+ res = await self.bot.api_client.get("bot/users", params=query_params)
+ for user in res["results"]:
+ yield user
+
+ query_params["page"] = res["next_page_no"]
+
+ async def _sync(self, diff: _Diff) -> None:
+ """Synchronise the database with the user cache of `guild`."""
+ log.trace("Syncing created users...")
+ if diff.created:
+ await self.bot.api_client.post("bot/users", json=diff.created)
+
+ log.trace("Syncing updated users...")
+ if diff.updated:
+ await self.bot.api_client.patch("bot/users/bulk_patch", json=diff.updated)
diff --git a/tests/bot/cogs/moderation/__init__.py b/bot/exts/filters/__init__.py
index e69de29bb..e69de29bb 100644
--- a/tests/bot/cogs/moderation/__init__.py
+++ b/bot/exts/filters/__init__.py
diff --git a/bot/cogs/antimalware.py b/bot/exts/filters/antimalware.py
index ea257442e..26f00e91f 100644
--- a/bot/cogs/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 AntiMalware as AntiMalwareConfig, Channels, STAFF_ROLES, URLs
+from bot.constants import Channels, Filter, URLs
log = logging.getLogger(__name__)
@@ -27,7 +27,7 @@ TXT_EMBED_DESCRIPTION = (
DISALLOWED_EMBED_DESCRIPTION = (
"It looks like you tried to attach file type(s) that we do not allow ({blocked_extensions_str}). "
- f"We currently allow the following file types: **{', '.join(AntiMalwareConfig.whitelist)}**.\n\n"
+ "We currently allow the following file types: **{joined_whitelist}**.\n\n"
"Feel free to ask in {meta_channel_mention} if you think this is a mistake."
)
@@ -38,6 +38,16 @@ class AntiMalware(Cog):
def __init__(self, bot: Bot):
self.bot = bot
+ def _get_whitelisted_file_formats(self) -> list:
+ """Get the file formats currently on the whitelist."""
+ return self.bot.filter_list_cache['FILE_FORMAT.True'].keys()
+
+ def _get_disallowed_extensions(self, message: Message) -> t.Iterable[str]:
+ """Get an iterable containing all the disallowed extensions of attachments."""
+ file_extensions = {splitext(attachment.filename.lower())[1] for attachment in message.attachments}
+ extensions_blocked = file_extensions - set(self._get_whitelisted_file_formats())
+ return extensions_blocked
+
@Cog.listener()
async def on_message(self, message: Message) -> None:
"""Identify messages with prohibited attachments."""
@@ -45,13 +55,17 @@ class AntiMalware(Cog):
if not message.attachments or not message.guild:
return
+ # Ignore webhook and bot messages
+ if message.webhook_id or message.author.bot:
+ return
+
# 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()
- extensions_blocked = self.get_disallowed_extensions(message)
+ extensions_blocked = self._get_disallowed_extensions(message)
blocked_extensions_str = ', '.join(extensions_blocked)
if ".py" in extensions_blocked:
# Short-circuit on *.py files to provide a pastebin link
@@ -63,6 +77,7 @@ class AntiMalware(Cog):
elif extensions_blocked:
meta_channel = self.bot.get_channel(Channels.meta)
embed.description = DISALLOWED_EMBED_DESCRIPTION.format(
+ joined_whitelist=', '.join(self._get_whitelisted_file_formats()),
blocked_extensions_str=blocked_extensions_str,
meta_channel_mention=meta_channel.mention,
)
@@ -81,13 +96,6 @@ class AntiMalware(Cog):
except NotFound:
log.info(f"Tried to delete message `{message.id}`, but message could not be found.")
- @classmethod
- def get_disallowed_extensions(cls, message: Message) -> t.Iterable[str]:
- """Get an iterable containing all the disallowed extensions of attachments."""
- file_extensions = {splitext(attachment.filename.lower())[1] for attachment in message.attachments}
- extensions_blocked = file_extensions - set(AntiMalwareConfig.whitelist)
- return extensions_blocked
-
def setup(bot: Bot) -> None:
"""Load the AntiMalware cog."""
diff --git a/bot/cogs/antispam.py b/bot/exts/filters/antispam.py
index 0bcca578d..af8528a68 100644
--- a/bot/cogs/antispam.py
+++ b/bot/exts/filters/antispam.py
@@ -11,15 +11,14 @@ from discord.ext.commands import Cog
from bot import rules
from bot.bot import Bot
-from bot.cogs.moderation import ModLog
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.utils.messages import send_attachments
+from bot.exts.moderation.modlog import ModLog
+from bot.utils.messages import format_user, send_attachments
log = logging.getLogger(__name__)
@@ -27,14 +26,15 @@ log = logging.getLogger(__name__)
RULE_FUNCTION_MAPPING = {
'attachments': rules.apply_attachments,
'burst': rules.apply_burst,
- 'burst_shared': rules.apply_burst_shared,
+ # burst shared is temporarily disabled due to a bug
+ # 'burst_shared': rules.apply_burst_shared,
'chars': rules.apply_chars,
'discord_emojis': rules.apply_discord_emojis,
'duplicates': rules.apply_duplicates,
'links': rules.apply_links,
'mentions': rules.apply_mentions,
'newlines': rules.apply_newlines,
- 'role_mentions': rules.apply_role_mentions
+ 'role_mentions': rules.apply_role_mentions,
}
@@ -67,7 +67,7 @@ class DeletionContext:
async def upload_messages(self, actor_id: int, modlog: ModLog) -> None:
"""Method that takes care of uploading the queue and posting modlog alert."""
- triggered_by_users = ", ".join(f"{m} (`{m.id}`)" for m in self.members.values())
+ triggered_by_users = ", ".join(format_user(m) for m in self.members.values())
mod_alert_message = (
f"**Triggered by:** {triggered_by_users}\n"
@@ -148,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
@@ -219,7 +219,6 @@ class AntiSpam(Cog):
# Get context and make sure the bot becomes the actor of infraction by patching the `author` attributes
context = await self.bot.get_context(msg)
context.author = self.bot.user
- context.message.author = self.bot.user
# Since we're going to invoke the tempmute command directly, we need to manually call the converter.
dt_remove_role_after = await self.expiration_date_converter.convert(context, f"{remove_role_after}S")
diff --git a/bot/exts/filters/filter_lists.py b/bot/exts/filters/filter_lists.py
new file mode 100644
index 000000000..232c1e48b
--- /dev/null
+++ b/bot/exts/filters/filter_lists.py
@@ -0,0 +1,272 @@
+import logging
+from typing import Optional
+
+from discord import Colour, Embed
+from discord.ext.commands import BadArgument, Cog, Context, IDConverter, group, has_any_role
+
+from bot import constants
+from bot.api import ResponseCodeError
+from bot.bot import Bot
+from bot.converters import ValidDiscordServerInvite, ValidFilterListType
+from bot.pagination import LinePaginator
+
+log = logging.getLogger(__name__)
+
+
+class FilterLists(Cog):
+ """Commands for blacklisting and whitelisting things."""
+
+ methods_with_filterlist_types = [
+ "allow_add",
+ "allow_delete",
+ "allow_get",
+ "deny_add",
+ "deny_delete",
+ "deny_get",
+ ]
+
+ def __init__(self, bot: Bot) -> None:
+ self.bot = bot
+ self.bot.loop.create_task(self._amend_docstrings())
+
+ async def _amend_docstrings(self) -> None:
+ """Add the valid FilterList types to the docstrings, so they'll appear in !help invocations."""
+ await self.bot.wait_until_guild_available()
+
+ # Add valid filterlist types to the docstrings
+ valid_types = await ValidFilterListType.get_valid_types(self.bot)
+ valid_types = [f"`{type_.lower()}`" for type_ in valid_types]
+
+ for method_name in self.methods_with_filterlist_types:
+ command = getattr(self, method_name)
+ command.help = (
+ f"{command.help}\n\nValid **list_type** values are {', '.join(valid_types)}."
+ )
+
+ async def _add_data(
+ self,
+ ctx: Context,
+ allowed: bool,
+ list_type: ValidFilterListType,
+ content: str,
+ comment: Optional[str] = None,
+ ) -> None:
+ """Add an item to a filterlist."""
+ allow_type = "whitelist" if allowed else "blacklist"
+
+ # If this is a server invite, we gotta validate it.
+ if list_type == "GUILD_INVITE":
+ guild_data = await self._validate_guild_invite(ctx, content)
+ content = guild_data.get("id")
+
+ # Unless the user has specified another comment, let's
+ # use the server name as the comment so that the list
+ # of guild IDs will be more easily readable when we
+ # display it.
+ if not comment:
+ comment = guild_data.get("name")
+
+ # If it's a file format, let's make sure it has a leading dot.
+ elif list_type == "FILE_FORMAT" and not content.startswith("."):
+ content = f".{content}"
+
+ # Try to add the item to the database
+ log.trace(f"Trying to add the {content} item to the {list_type} {allow_type}")
+ payload = {
+ "allowed": allowed,
+ "type": list_type,
+ "content": content,
+ "comment": comment,
+ }
+
+ try:
+ item = await self.bot.api_client.post(
+ "bot/filter-lists",
+ json=payload
+ )
+ except ResponseCodeError as e:
+ if e.status == 400:
+ await ctx.message.add_reaction("❌")
+ log.debug(
+ f"{ctx.author} tried to add data to a {allow_type}, but the API returned 400, "
+ "probably because the request violated the UniqueConstraint."
+ )
+ raise BadArgument(
+ f"Unable to add the item to the {allow_type}. "
+ "The item probably already exists. Keep in mind that a "
+ "blacklist and a whitelist for the same item cannot co-exist, "
+ "and we do not permit any duplicates."
+ )
+ raise
+
+ # Insert the item into the cache
+ self.bot.insert_item_into_filter_list_cache(item)
+ await ctx.message.add_reaction("✅")
+
+ async def _delete_data(self, ctx: Context, allowed: bool, list_type: ValidFilterListType, content: str) -> None:
+ """Remove an item from a filterlist."""
+ allow_type = "whitelist" if allowed else "blacklist"
+
+ # If this is a server invite, we need to convert it.
+ if list_type == "GUILD_INVITE" and not IDConverter()._get_id_match(content):
+ guild_data = await self._validate_guild_invite(ctx, content)
+ content = guild_data.get("id")
+
+ # If it's a file format, let's make sure it has a leading dot.
+ elif list_type == "FILE_FORMAT" and not content.startswith("."):
+ content = f".{content}"
+
+ # Find the content and delete it.
+ log.trace(f"Trying to delete the {content} item from the {list_type} {allow_type}")
+ item = self.bot.filter_list_cache[f"{list_type}.{allowed}"].get(content)
+
+ if item is not None:
+ try:
+ await self.bot.api_client.delete(
+ f"bot/filter-lists/{item['id']}"
+ )
+ del self.bot.filter_list_cache[f"{list_type}.{allowed}"][content]
+ await ctx.message.add_reaction("✅")
+ except ResponseCodeError as e:
+ log.debug(
+ f"{ctx.author} tried to delete an item with the id {item['id']}, but "
+ f"the API raised an unexpected error: {e}"
+ )
+ await ctx.message.add_reaction("❌")
+ else:
+ await ctx.message.add_reaction("❌")
+
+ async def _list_all_data(self, ctx: Context, allowed: bool, list_type: ValidFilterListType) -> None:
+ """Paginate and display all items in a filterlist."""
+ allow_type = "whitelist" if allowed else "blacklist"
+ result = self.bot.filter_list_cache[f"{list_type}.{allowed}"]
+
+ # Build a list of lines we want to show in the paginator
+ lines = []
+ for content, metadata in result.items():
+ line = f"• `{content}`"
+
+ if comment := metadata.get("comment"):
+ line += f" - {comment}"
+
+ lines.append(line)
+ lines = sorted(lines)
+
+ # Build the embed
+ list_type_plural = list_type.lower().replace("_", " ").title() + "s"
+ embed = Embed(
+ title=f"{allow_type.title()}ed {list_type_plural} ({len(result)} total)",
+ colour=Colour.blue()
+ )
+ log.trace(f"Trying to list {len(result)} items from the {list_type.lower()} {allow_type}")
+
+ if result:
+ await LinePaginator.paginate(lines, ctx, embed, max_lines=15, empty=False)
+ else:
+ embed.description = "Hmmm, seems like there's nothing here yet."
+ await ctx.send(embed=embed)
+ await ctx.message.add_reaction("❌")
+
+ async def _sync_data(self, ctx: Context) -> None:
+ """Syncs the filterlists with the API."""
+ try:
+ log.trace("Attempting to sync FilterList cache with data from the API.")
+ await self.bot.cache_filter_list_data()
+ await ctx.message.add_reaction("✅")
+ except ResponseCodeError as e:
+ log.debug(
+ f"{ctx.author} tried to sync FilterList cache data but "
+ f"the API raised an unexpected error: {e}"
+ )
+ await ctx.message.add_reaction("❌")
+
+ @staticmethod
+ async def _validate_guild_invite(ctx: Context, invite: str) -> dict:
+ """
+ Validates a guild invite, and returns the guild info as a dict.
+
+ Will raise a BadArgument if the guild invite is invalid.
+ """
+ log.trace(f"Attempting to validate whether or not {invite} is a guild invite.")
+ validator = ValidDiscordServerInvite()
+ guild_data = await validator.convert(ctx, invite)
+
+ # If we make it this far without raising a BadArgument, the invite is
+ # valid. Let's return a dict of guild information.
+ log.trace(f"{invite} validated as server invite. Converting to ID.")
+ return guild_data
+
+ @group(aliases=("allowlist", "allow", "al", "wl"))
+ async def whitelist(self, ctx: Context) -> None:
+ """Group for whitelisting commands."""
+ if not ctx.invoked_subcommand:
+ await ctx.send_help(ctx.command)
+
+ @group(aliases=("denylist", "deny", "bl", "dl"))
+ async def blacklist(self, ctx: Context) -> None:
+ """Group for blacklisting commands."""
+ if not ctx.invoked_subcommand:
+ await ctx.send_help(ctx.command)
+
+ @whitelist.command(name="add", aliases=("a", "set"))
+ async def allow_add(
+ self,
+ ctx: Context,
+ list_type: ValidFilterListType,
+ content: str,
+ *,
+ comment: Optional[str] = None,
+ ) -> None:
+ """Add an item to the specified allowlist."""
+ await self._add_data(ctx, True, list_type, content, comment)
+
+ @blacklist.command(name="add", aliases=("a", "set"))
+ async def deny_add(
+ self,
+ ctx: Context,
+ list_type: ValidFilterListType,
+ content: str,
+ *,
+ comment: Optional[str] = None,
+ ) -> None:
+ """Add an item to the specified denylist."""
+ await self._add_data(ctx, False, list_type, content, comment)
+
+ @whitelist.command(name="remove", aliases=("delete", "rm",))
+ async def allow_delete(self, ctx: Context, list_type: ValidFilterListType, content: str) -> None:
+ """Remove an item from the specified allowlist."""
+ await self._delete_data(ctx, True, list_type, content)
+
+ @blacklist.command(name="remove", aliases=("delete", "rm",))
+ async def deny_delete(self, ctx: Context, list_type: ValidFilterListType, content: str) -> None:
+ """Remove an item from the specified denylist."""
+ await self._delete_data(ctx, False, list_type, content)
+
+ @whitelist.command(name="get", aliases=("list", "ls", "fetch", "show"))
+ async def allow_get(self, ctx: Context, list_type: ValidFilterListType) -> None:
+ """Get the contents of a specified allowlist."""
+ await self._list_all_data(ctx, True, list_type)
+
+ @blacklist.command(name="get", aliases=("list", "ls", "fetch", "show"))
+ async def deny_get(self, ctx: Context, list_type: ValidFilterListType) -> None:
+ """Get the contents of a specified denylist."""
+ await self._list_all_data(ctx, False, list_type)
+
+ @whitelist.command(name="sync", aliases=("s",))
+ async def allow_sync(self, ctx: Context) -> None:
+ """Syncs both allowlists and denylists with the API."""
+ await self._sync_data(ctx)
+
+ @blacklist.command(name="sync", aliases=("s",))
+ async def deny_sync(self, ctx: Context) -> None:
+ """Syncs both allowlists and denylists with the API."""
+ await self._sync_data(ctx)
+
+ async def cog_check(self, ctx: Context) -> bool:
+ """Only allow moderators to invoke the commands in this cog."""
+ return await has_any_role(*constants.MODERATION_ROLES).predicate(ctx)
+
+
+def setup(bot: Bot) -> None:
+ """Load the FilterLists cog."""
+ bot.add_cog(FilterLists(bot))
diff --git a/bot/cogs/filtering.py b/bot/exts/filters/filtering.py
index 76ea68660..92cdfb8f5 100644
--- a/bot/cogs/filtering.py
+++ b/bot/exts/filters/filtering.py
@@ -2,65 +2,56 @@ import asyncio
import logging
import re
from datetime import datetime, timedelta
-from typing import List, Mapping, Optional, Union
+from typing import Any, Dict, List, Mapping, NamedTuple, Optional, Union
import dateutil
import discord.errors
+from async_rediscache import RedisCache
from dateutil.relativedelta import relativedelta
from discord import Colour, HTTPException, Member, Message, NotFound, TextChannel
from discord.ext.commands import Cog
from discord.utils import escape_markdown
+from bot.api import ResponseCodeError
from bot.bot import Bot
-from bot.cogs.moderation import ModLog
from bot.constants import (
- Channels, Colours,
- Filter, Icons, URLs
+ Channels, Colours, Filter,
+ Guild, Icons, URLs
)
-from bot.utils.redis_cache import RedisCache
+from bot.exts.moderation.modlog import ModLog
+from bot.utils.messages import format_user
+from bot.utils.regex import INVITE_RE
from bot.utils.scheduling import Scheduler
-from bot.utils.time import wait_until
log = logging.getLogger(__name__)
-INVITE_RE = re.compile(
- r"(?:discord(?:[\.,]|dot)gg|" # Could be discord.gg/
- r"discord(?:[\.,]|dot)com(?:\/|slash)invite|" # or discord.com/invite/
- r"discordapp(?:[\.,]|dot)com(?:\/|slash)invite|" # or discordapp.com/invite/
- r"discord(?:[\.,]|dot)me|" # or discord.me
- r"discord(?:[\.,]|dot)io" # or discord.io.
- r")(?:[\/]|slash)" # / or 'slash'
- r"([a-zA-Z0-9]+)", # the invite code itself
- flags=re.IGNORECASE
+# Regular expressions
+CODE_BLOCK_RE = re.compile(
+ r"(?P<delim>``?)[^`]+?(?P=delim)(?!`+)" # Inline codeblock
+ r"|```(.+?)```", # Multiline codeblock
+ re.DOTALL | re.MULTILINE
)
-
+EVERYONE_PING_RE = re.compile(rf"@everyone|<@&{Guild.id}>|@here")
SPOILER_RE = re.compile(r"(\|\|.+?\|\|)", re.DOTALL)
URL_RE = re.compile(r"(https?://[^\s]+)", flags=re.IGNORECASE)
ZALGO_RE = re.compile(r"[\u0300-\u036F\u0489]")
-WORD_WATCHLIST_PATTERNS = [
- re.compile(fr'\b{expression}\b', flags=re.IGNORECASE) for expression in Filter.word_watchlist
-]
-TOKEN_WATCHLIST_PATTERNS = [
- re.compile(fr'{expression}', flags=re.IGNORECASE) for expression in Filter.token_watchlist
-]
-WATCHLIST_PATTERNS = WORD_WATCHLIST_PATTERNS + TOKEN_WATCHLIST_PATTERNS
-
+# Other constants.
DAYS_BETWEEN_ALERTS = 3
+OFFENSIVE_MSG_DELETE_TIME = timedelta(days=Filter.offensive_msg_delete_days)
+FilterMatch = Union[re.Match, dict, bool, List[discord.Embed]]
-def expand_spoilers(text: str) -> str:
- """Return a string containing all interpretations of a spoilered message."""
- split_text = SPOILER_RE.split(text)
- return ''.join(
- split_text[0::2] + split_text[1::2] + split_text
- )
+class Stats(NamedTuple):
+ """Additional stats on a triggered filter to append to a mod log."""
-OFFENSIVE_MSG_DELETE_TIME = timedelta(days=Filter.offensive_msg_delete_days)
+ message_content: str
+ additional_embeds: Optional[List[discord.Embed]]
+ additional_embeds_msg: Optional[str]
-class Filtering(Cog, Scheduler):
+class Filtering(Cog):
"""Filtering out invites, blacklisting domains, and warning us of certain regular expressions."""
# Redis cache mapping a user ID to the last timestamp a bad nickname alert was sent
@@ -68,8 +59,7 @@ class Filtering(Cog, Scheduler):
def __init__(self, bot: Bot):
self.bot = bot
- super().__init__()
-
+ self.scheduler = Scheduler(self.__class__.__name__)
self.name_lock = asyncio.Lock()
staff_mistake_str = "If you believe this was a mistake, please let staff know!"
@@ -109,6 +99,19 @@ class Filtering(Cog, Scheduler):
),
"schedule_deletion": False
},
+ "filter_everyone_ping": {
+ "enabled": Filter.filter_everyone_ping,
+ "function": self._has_everyone_ping,
+ "type": "filter",
+ "content_only": True,
+ "user_notification": Filter.notify_user_everyone_ping,
+ "notification_msg": (
+ "Please don't try to ping `@everyone` or `@here`. "
+ f"Your message has been removed. {staff_mistake_str}"
+ ),
+ "schedule_deletion": False,
+ "ping_everyone": False
+ },
"watch_regex": {
"enabled": Filter.watch_regex,
"function": self._has_watch_regex_match,
@@ -127,6 +130,22 @@ class Filtering(Cog, Scheduler):
self.bot.loop.create_task(self.reschedule_offensive_msg_deletion())
+ def cog_unload(self) -> None:
+ """Cancel scheduled tasks."""
+ self.scheduler.cancel_all()
+
+ def _get_filterlist_items(self, list_type: str, *, allowed: bool) -> list:
+ """Fetch items from the filter_list_cache."""
+ return self.bot.filter_list_cache[f"{list_type.upper()}.{allowed}"].keys()
+
+ @staticmethod
+ def _expand_spoilers(text: str) -> str:
+ """Return a string containing all interpretations of a spoilered message."""
+ split_text = SPOILER_RE.split(text)
+ return ''.join(
+ split_text[0::2] + split_text[1::2] + split_text
+ )
+
@property
def mod_log(self) -> ModLog:
"""Get currently loaded ModLog cog instance."""
@@ -136,7 +155,10 @@ class Filtering(Cog, Scheduler):
async def on_message(self, msg: Message) -> None:
"""Invoke message filter for new messages."""
await self._filter_message(msg)
- await self.check_bad_words_in_name(msg.author)
+
+ # Ignore webhook messages.
+ if msg.webhook_id is None:
+ await self.check_bad_words_in_name(msg.author)
@Cog.listener()
async def on_message_edit(self, before: Message, after: Message) -> None:
@@ -151,12 +173,12 @@ class Filtering(Cog, Scheduler):
delta = relativedelta(after.edited_at, before.edited_at).microseconds
await self._filter_message(after, delta)
- @staticmethod
- def get_name_matches(name: str) -> List[re.Match]:
+ def get_name_matches(self, name: str) -> List[re.Match]:
"""Check bad words from passed string (name). Return list of matches."""
matches = []
- for pattern in WATCHLIST_PATTERNS:
- if match := pattern.search(name):
+ watchlist_patterns = self._get_filterlist_items('filter_token', allowed=False)
+ for pattern in watchlist_patterns:
+ if match := re.search(pattern, name, flags=re.IGNORECASE):
matches.append(match)
return matches
@@ -183,8 +205,8 @@ class Filtering(Cog, Scheduler):
log.info(f"Sending bad nickname alert for '{member.display_name}' ({member.id}).")
log_string = (
- f"**User:** {member.mention} (`{member.id}`)\n"
- f"**Display Name:** {member.display_name}\n"
+ f"**User:** {format_user(member)}\n"
+ f"**Display Name:** {escape_markdown(member.display_name)}\n"
f"**Bad Matches:** {', '.join(match.group() for match in matches)}"
)
@@ -200,24 +222,40 @@ class Filtering(Cog, Scheduler):
# Update time when alert sent
await self.name_alerts.set(member.id, datetime.utcnow().timestamp())
- async def _filter_message(self, msg: Message, delta: Optional[int] = None) -> None:
- """Filter the input message to see if it violates any of our rules, and then respond accordingly."""
+ async def filter_eval(self, result: str, msg: Message) -> bool:
+ """
+ Filter the result of an !eval to see if it violates any of our rules, and then respond accordingly.
+
+ Also requires the original message, to check whether to filter and for mod logs.
+ Returns whether a filter was triggered or not.
+ """
+ filter_triggered = False
# Should we filter this message?
- role_whitelisted = False
+ if self._check_filter(msg):
+ for filter_name, _filter in self.filters.items():
+ # Is this specific filter enabled in the config?
+ # We also do not need to worry about filters that take the full message,
+ # since all we have is an arbitrary string.
+ if _filter["enabled"] and _filter["content_only"]:
+ match = await _filter["function"](result)
- if type(msg.author) is Member: # Only Member has roles, not User.
- for role in msg.author.roles:
- if role.id in Filter.role_whitelist:
- role_whitelisted = True
+ if match:
+ # If this is a filter (not a watchlist), we set the variable so we know
+ # that it has been triggered
+ if _filter["type"] == "filter":
+ filter_triggered = True
- filter_message = (
- msg.channel.id not in Filter.channel_whitelist # Channel not in whitelist
- and not role_whitelisted # Role not in whitelist
- and not msg.author.bot # Author not a bot
- )
+ stats = self._add_stats(filter_name, match, result)
+ await self._send_log(filter_name, _filter["type"], msg, stats, is_eval=True)
+
+ break # We don't want multiple filters to trigger
- # If none of the above, we can start filtering.
- if filter_message:
+ return filter_triggered
+
+ async def _filter_message(self, msg: Message, delta: Optional[int] = None) -> None:
+ """Filter the input message to see if it violates any of our rules, and then respond accordingly."""
+ # Should we filter this message?
+ if self._check_filter(msg):
for filter_name, _filter in self.filters.items():
# Is this specific filter enabled in the config?
if _filter["enabled"]:
@@ -267,77 +305,118 @@ class Filtering(Cog, Scheduler):
'delete_date': delete_date
}
- await self.bot.api_client.post('bot/offensive-messages', json=data)
- self.schedule_task(msg.id, data)
- log.trace(f"Offensive message {msg.id} will be deleted on {delete_date}")
-
- if is_private:
- channel_str = "via DM"
- else:
- channel_str = f"in {msg.channel.mention}"
-
- # Word and match stats for watch_regex
- if filter_name == "watch_regex":
- surroundings = match.string[max(match.start() - 10, 0): match.end() + 10]
- message_content = (
- f"**Match:** '{match[0]}'\n"
- f"**Location:** '...{escape_markdown(surroundings)}...'\n"
- f"\n**Original Message:**\n{escape_markdown(msg.content)}"
- )
- else: # Use content of discord Message
- message_content = msg.content
-
- message = (
- f"The {filter_name} {_filter['type']} was triggered "
- f"by **{msg.author}** "
- f"(`{msg.author.id}`) {channel_str} with [the "
- f"following message]({msg.jump_url}):\n\n"
- f"{message_content}"
- )
-
- log.debug(message)
-
- self.bot.stats.incr(f"filters.{filter_name}")
-
- additional_embeds = None
- additional_embeds_msg = None
-
- # The function returns True for invalid invites.
- # They have no data so additional embeds can't be created for them.
- if filter_name == "filter_invites" and match is not True:
- additional_embeds = []
- for invite, data in match.items():
- embed = discord.Embed(description=(
- f"**Members:**\n{data['members']}\n"
- f"**Active:**\n{data['active']}"
- ))
- embed.set_author(name=data["name"])
- embed.set_thumbnail(url=data["icon"])
- embed.set_footer(text=f"Guild Invite Code: {invite}")
- additional_embeds.append(embed)
- additional_embeds_msg = "For the following guild(s):"
-
- elif filter_name == "watch_rich_embeds":
- additional_embeds = msg.embeds
- additional_embeds_msg = "With the following embed(s):"
-
- # Send pretty mod log embed to mod-alerts
- await self.mod_log.send_log_message(
- icon_url=Icons.filtering,
- colour=Colour(Colours.soft_red),
- title=f"{_filter['type'].title()} triggered!",
- text=message,
- thumbnail=msg.author.avatar_url_as(static_format="png"),
- channel_id=Channels.mod_alerts,
- ping_everyone=Filter.ping_everyone,
- additional_embeds=additional_embeds,
- additional_embeds_msg=additional_embeds_msg
- )
+ try:
+ await self.bot.api_client.post('bot/offensive-messages', json=data)
+ except ResponseCodeError as e:
+ if e.status == 400 and "already exists" in e.response_json.get("id", [""])[0]:
+ log.debug(f"Offensive message {msg.id} already exists.")
+ else:
+ log.error(f"Offensive message {msg.id} failed to post: {e}")
+ else:
+ self.schedule_msg_delete(data)
+ log.trace(f"Offensive message {msg.id} will be deleted on {delete_date}")
+
+ stats = self._add_stats(filter_name, match, msg.content)
+ await self._send_log(filter_name, _filter, msg, stats)
break # We don't want multiple filters to trigger
+ async def _send_log(
+ self,
+ filter_name: str,
+ _filter: Dict[str, Any],
+ msg: discord.Message,
+ stats: Stats,
+ *,
+ is_eval: bool = False,
+ ) -> None:
+ """Send a mod log for a triggered filter."""
+ if msg.channel.type is discord.ChannelType.private:
+ channel_str = "via DM"
+ ping_everyone = False
+ else:
+ channel_str = f"in {msg.channel.mention}"
+ # Allow specific filters to override ping_everyone
+ ping_everyone = Filter.ping_everyone and _filter.get("ping_everyone", True)
+
+ eval_msg = "using !eval " if is_eval else ""
+ message = (
+ f"The {filter_name} {_filter['type']} was triggered by {format_user(msg.author)} "
+ f"{channel_str} {eval_msg}with [the following message]({msg.jump_url}):\n\n"
+ f"{stats.message_content}"
+ )
+
+ log.debug(message)
+
+ # Send pretty mod log embed to mod-alerts
+ await self.mod_log.send_log_message(
+ icon_url=Icons.filtering,
+ colour=Colour(Colours.soft_red),
+ title=f"{_filter['type'].title()} triggered!",
+ text=message,
+ thumbnail=msg.author.avatar_url_as(static_format="png"),
+ channel_id=Channels.mod_alerts,
+ ping_everyone=ping_everyone,
+ additional_embeds=stats.additional_embeds,
+ additional_embeds_msg=stats.additional_embeds_msg
+ )
+
+ def _add_stats(self, name: str, match: FilterMatch, content: str) -> Stats:
+ """Adds relevant statistical information to the relevant filter and increments the bot's stats."""
+ # Word and match stats for watch_regex
+ if name == "watch_regex":
+ surroundings = match.string[max(match.start() - 10, 0): match.end() + 10]
+ message_content = (
+ f"**Match:** '{match[0]}'\n"
+ f"**Location:** '...{escape_markdown(surroundings)}...'\n"
+ f"\n**Original Message:**\n{escape_markdown(content)}"
+ )
+ else: # Use original content
+ message_content = content
+
+ additional_embeds = None
+ additional_embeds_msg = None
+
+ self.bot.stats.incr(f"filters.{name}")
+
+ # The function returns True for invalid invites.
+ # They have no data so additional embeds can't be created for them.
+ if name == "filter_invites" and match is not True:
+ additional_embeds = []
+ for _, data in match.items():
+ embed = discord.Embed(description=(
+ f"**Members:**\n{data['members']}\n"
+ f"**Active:**\n{data['active']}"
+ ))
+ embed.set_author(name=data["name"])
+ embed.set_thumbnail(url=data["icon"])
+ embed.set_footer(text=f"Guild ID: {data['id']}")
+ additional_embeds.append(embed)
+ additional_embeds_msg = "For the following guild(s):"
+
+ elif name == "watch_rich_embeds":
+ additional_embeds = match
+ additional_embeds_msg = "With the following embed(s):"
+
+ return Stats(message_content, additional_embeds, additional_embeds_msg)
+
@staticmethod
- async def _has_watch_regex_match(text: str) -> Union[bool, re.Match]:
+ def _check_filter(msg: Message) -> bool:
+ """Check whitelists to see if we should filter this message."""
+ role_whitelisted = False
+
+ if type(msg.author) is Member: # Only Member has roles, not User.
+ for role in msg.author.roles:
+ if role.id in Filter.role_whitelist:
+ role_whitelisted = True
+
+ return (
+ msg.channel.id not in Filter.channel_whitelist # Channel not in whitelist
+ and not role_whitelisted # Role not in whitelist
+ and not msg.author.bot # Author not a bot
+ )
+
+ async def _has_watch_regex_match(self, text: str) -> Union[bool, re.Match]:
"""
Return True if `text` matches any regex from `word_watchlist` or `token_watchlist` configs.
@@ -345,26 +424,27 @@ class Filtering(Cog, Scheduler):
matched as-is. Spoilers are expanded, if any, and URLs are ignored.
"""
if SPOILER_RE.search(text):
- text = expand_spoilers(text)
+ text = self._expand_spoilers(text)
# Make sure it's not a URL
if URL_RE.search(text):
return False
- for pattern in WATCHLIST_PATTERNS:
- match = pattern.search(text)
+ watchlist_patterns = self._get_filterlist_items('filter_token', allowed=False)
+ for pattern in watchlist_patterns:
+ match = re.search(pattern, text, flags=re.IGNORECASE)
if match:
return match
- @staticmethod
- async def _has_urls(text: str) -> bool:
+ async def _has_urls(self, text: str) -> bool:
"""Returns True if the text contains one of the blacklisted URLs from the config file."""
if not URL_RE.search(text):
return False
text = text.lower()
+ domain_blacklist = self._get_filterlist_items("domain_name", allowed=False)
- for url in Filter.domain_blacklist:
+ for url in domain_blacklist:
if url.lower() in text:
return True
@@ -388,7 +468,7 @@ class Filtering(Cog, Scheduler):
Attempts to catch some of common ways to try to cheat the system.
"""
- # Remove backslashes to prevent escape character around fuckery like
+ # Remove backslashes to prevent escape character aroundfuckery like
# discord\.gg/gdudes-pony-farm
text = text.replace("\\", "")
@@ -409,9 +489,22 @@ class Filtering(Cog, Scheduler):
# between invalid and expired invites
return True
- guild_id = int(guild.get("id"))
+ guild_id = guild.get("id")
+ guild_invite_whitelist = self._get_filterlist_items("guild_invite", allowed=True)
+ guild_invite_blacklist = self._get_filterlist_items("guild_invite", allowed=False)
- if guild_id not in Filter.guild_invite_whitelist:
+ # Is this invite allowed?
+ guild_partnered_or_verified = (
+ 'PARTNERED' in guild.get("features", [])
+ or 'VERIFIED' in guild.get("features", [])
+ )
+ invite_not_allowed = (
+ guild_id in guild_invite_blacklist # Blacklisted guilds are never permitted.
+ or guild_id not in guild_invite_whitelist # Whitelisted guilds are always permitted.
+ and not guild_partnered_or_verified # Otherwise guilds have to be Verified or Partnered.
+ )
+
+ if invite_not_allowed:
guild_icon_hash = guild["icon"]
guild_icon = (
"https://cdn.discordapp.com/icons/"
@@ -420,6 +513,7 @@ class Filtering(Cog, Scheduler):
invite_data[invite] = {
"name": guild["name"],
+ "id": guild['id'],
"icon": guild_icon,
"members": response["approximate_member_count"],
"active": response["approximate_presence_count"]
@@ -428,7 +522,7 @@ class Filtering(Cog, Scheduler):
return invite_data if invite_data else False
@staticmethod
- async def _has_rich_embed(msg: Message) -> bool:
+ async def _has_rich_embed(msg: Message) -> Union[bool, List[discord.Embed]]:
"""Determines if `msg` contains any rich embeds not auto-generated from a URL."""
if msg.embeds:
for embed in msg.embeds:
@@ -437,7 +531,7 @@ class Filtering(Cog, Scheduler):
if not embed.url or embed.url not in urls:
# If `embed.url` does not exist or if `embed.url` is not part of the content
# of the message, it's unlikely to be an auto-generated embed by Discord.
- return True
+ return msg.embeds
else:
log.trace(
"Found a rich embed sent by a regular user account, "
@@ -446,6 +540,16 @@ class Filtering(Cog, Scheduler):
return False
return False
+ @staticmethod
+ async def _has_everyone_ping(text: str) -> bool:
+ """Determines if `msg` contains an @everyone or @here ping outside of a codeblock."""
+ # First pass to avoid running re.sub on every message
+ if not EVERYONE_PING_RE.search(text):
+ return False
+
+ content_without_codeblocks = CODE_BLOCK_RE.sub("", text)
+ return bool(EVERYONE_PING_RE.search(content_without_codeblocks))
+
async def notify_member(self, filtered_member: Member, reason: str, channel: TextChannel) -> None:
"""
Notify filtered_member about a moderation action with the reason str.
@@ -457,12 +561,10 @@ class Filtering(Cog, Scheduler):
except discord.errors.Forbidden:
await channel.send(f"{filtered_member.mention} {reason}")
- async def _scheduled_task(self, msg: dict) -> None:
+ def schedule_msg_delete(self, msg: dict) -> None:
"""Delete an offensive message once its deletion date is reached."""
delete_at = dateutil.parser.isoparse(msg['delete_date']).replace(tzinfo=None)
-
- await wait_until(delete_at)
- await self.delete_offensive_msg(msg)
+ self.scheduler.schedule_at(delete_at, msg['id'], self.delete_offensive_msg(msg))
async def reschedule_offensive_msg_deletion(self) -> None:
"""Get all the pending message deletion from the API and reschedule them."""
@@ -477,7 +579,7 @@ class Filtering(Cog, Scheduler):
if delete_at < now:
await self.delete_offensive_msg(msg)
else:
- self.schedule_task(msg['id'], msg)
+ self.schedule_msg_delete(msg)
async def delete_offensive_msg(self, msg: Mapping[str, str]) -> None:
"""Delete an offensive message, and then delete it from the db."""
diff --git a/bot/cogs/security.py b/bot/exts/filters/security.py
index c680c5e27..c680c5e27 100644
--- a/bot/cogs/security.py
+++ b/bot/exts/filters/security.py
diff --git a/bot/cogs/token_remover.py b/bot/exts/filters/token_remover.py
index ef979f222..bd6a1f97a 100644
--- a/bot/cogs/token_remover.py
+++ b/bot/exts/filters/token_remover.py
@@ -9,15 +9,21 @@ from discord.ext.commands import Cog
from bot import utils
from bot.bot import Bot
-from bot.cogs.moderation import ModLog
from bot.constants import Channels, Colours, Event, Icons
+from bot.exts.moderation.modlog import ModLog
+from bot.utils.messages import format_user
log = logging.getLogger(__name__)
LOG_MESSAGE = (
- "Censored a seemingly valid token sent by {author} (`{author_id}`) in {channel}, "
+ "Censored a seemingly valid token sent by {author} in {channel}, "
"token was `{user_id}.{timestamp}.{hmac}`"
)
+UNKNOWN_USER_LOG_MESSAGE = "Decoded user ID: `{user_id}` (Not present in server)."
+KNOWN_USER_LOG_MESSAGE = (
+ "Decoded user ID: `{user_id}` **(Present in server)**.\n"
+ "This matches `{user_name}` and means this is likely a valid **{kind}** token."
+)
DELETION_MESSAGE_TEMPLATE = (
"Hey {mention}! I noticed you posted a seemingly valid Discord API "
"token in your message and have removed your message. "
@@ -93,6 +99,7 @@ class TokenRemover(Cog):
await msg.channel.send(DELETION_MESSAGE_TEMPLATE.format(mention=msg.author.mention))
log_message = self.format_log_message(msg, found_token)
+ userid_message, mention_everyone = self.format_userid_log_message(msg, found_token)
log.debug(log_message)
# Send pretty mod log embed to mod-alerts
@@ -100,19 +107,43 @@ class TokenRemover(Cog):
icon_url=Icons.token_removed,
colour=Colour(Colours.soft_red),
title="Token removed!",
- text=log_message,
+ text=log_message + "\n" + userid_message,
thumbnail=msg.author.avatar_url_as(static_format="png"),
channel_id=Channels.mod_alerts,
+ ping_everyone=mention_everyone,
)
self.bot.stats.incr("tokens.removed_tokens")
+ @classmethod
+ def format_userid_log_message(cls, msg: Message, token: Token) -> t.Tuple[str, bool]:
+ """
+ Format the portion of the log message that includes details about the detected user ID.
+
+ If the user is resolved to a member, the format includes the user ID, name, and the
+ kind of user detected.
+
+ If we resolve to a member and it is not a bot, we also return True to ping everyone.
+
+ Returns a tuple of (log_message, mention_everyone)
+ """
+ user_id = cls.extract_user_id(token.user_id)
+ user = msg.guild.get_member(user_id)
+
+ if user:
+ return KNOWN_USER_LOG_MESSAGE.format(
+ user_id=user_id,
+ user_name=str(user),
+ kind="BOT" if user.bot else "USER",
+ ), not user.bot
+ else:
+ return UNKNOWN_USER_LOG_MESSAGE.format(user_id=user_id), False
+
@staticmethod
def format_log_message(msg: Message, token: Token) -> str:
- """Return the log message to send for `token` being censored in `msg`."""
+ """Return the generic portion of the log message to send for `token` being censored in `msg`."""
return LOG_MESSAGE.format(
- author=msg.author,
- author_id=msg.author.id,
+ author=format_user(msg.author),
channel=msg.channel.mention,
user_id=token.user_id,
timestamp=token.timestamp,
@@ -126,7 +157,11 @@ class TokenRemover(Cog):
# token check (e.g. `message.channel.send` also matches our token pattern)
for match in TOKEN_RE.finditer(msg.content):
token = Token(*match.groups())
- if cls.is_valid_user_id(token.user_id) and cls.is_valid_timestamp(token.timestamp):
+ if (
+ (cls.extract_user_id(token.user_id) is not None)
+ and cls.is_valid_timestamp(token.timestamp)
+ and cls.is_maybe_valid_hmac(token.hmac)
+ ):
# Short-circuit on first match
return token
@@ -134,22 +169,20 @@ class TokenRemover(Cog):
return
@staticmethod
- def is_valid_user_id(b64_content: str) -> bool:
- """
- Check potential token to see if it contains a valid Discord user ID.
-
- See: https://discordapp.com/developers/docs/reference#snowflakes
- """
+ def extract_user_id(b64_content: str) -> t.Optional[int]:
+ """Return a user ID integer from part of a potential token, or None if it couldn't be decoded."""
b64_content = utils.pad_base64(b64_content)
try:
decoded_bytes = base64.urlsafe_b64decode(b64_content)
string = decoded_bytes.decode('utf-8')
-
- # isdigit on its own would match a lot of other Unicode characters, hence the isascii.
- return string.isascii() and string.isdigit()
+ if not (string.isascii() and string.isdigit()):
+ # This case triggers if there are fancy unicode digits in the base64 encoding,
+ # that means it's not a valid user id.
+ return None
+ return int(string)
except (binascii.Error, ValueError):
- return False
+ return None
@staticmethod
def is_valid_timestamp(b64_content: str) -> bool:
@@ -176,6 +209,24 @@ class TokenRemover(Cog):
log.debug(f"Invalid token timestamp '{b64_content}': smaller than Discord epoch")
return False
+ @staticmethod
+ def is_maybe_valid_hmac(b64_content: str) -> bool:
+ """
+ Determine if a given HMAC portion of a token is potentially valid.
+
+ If the HMAC has 3 or less characters, it's probably a dummy value like "xxxxxxxxxx",
+ and thus the token can probably be skipped.
+ """
+ unique = len(set(b64_content.lower()))
+ if unique <= 3:
+ log.debug(
+ f"Considering the HMAC {b64_content} a dummy because it has {unique}"
+ " case-insensitively unique characters"
+ )
+ return False
+ else:
+ return True
+
def setup(bot: Bot) -> None:
"""Load the TokenRemover cog."""
diff --git a/bot/cogs/webhook_remover.py b/bot/exts/filters/webhook_remover.py
index 543869215..08fe94055 100644
--- a/bot/cogs/webhook_remover.py
+++ b/bot/exts/filters/webhook_remover.py
@@ -5,10 +5,11 @@ from discord import Colour, Message, NotFound
from discord.ext.commands import Cog
from bot.bot import Bot
-from bot.cogs.moderation.modlog import ModLog
from bot.constants import Channels, Colours, Event, Icons
+from bot.exts.moderation.modlog import ModLog
+from bot.utils.messages import format_user
-WEBHOOK_URL_RE = re.compile(r"((?:https?://)?discordapp\.com/api/webhooks/\d+/)\S+/?", re.I)
+WEBHOOK_URL_RE = re.compile(r"((?:https?://)?discord(?:app)?\.com/api/webhooks/\d+/)\S+/?", re.IGNORECASE)
ALERT_MESSAGE_TEMPLATE = (
"{user}, looks like you posted a Discord webhook URL. Therefore, your "
@@ -45,8 +46,8 @@ class WebhookRemover(Cog):
await msg.channel.send(ALERT_MESSAGE_TEMPLATE.format(user=msg.author.mention))
message = (
- f"{msg.author} (`{msg.author.id}`) posted a Discord webhook URL "
- f"to #{msg.channel}. Webhook URL was `{redacted_url}`"
+ f"{format_user(msg.author)} posted a Discord webhook URL to {msg.channel.mention}. "
+ f"Webhook URL was `{redacted_url}`"
)
log.debug(message)
diff --git a/tests/bot/cogs/sync/__init__.py b/bot/exts/fun/__init__.py
index e69de29bb..e69de29bb 100644
--- a/tests/bot/cogs/sync/__init__.py
+++ b/bot/exts/fun/__init__.py
diff --git a/bot/cogs/duck_pond.py b/bot/exts/fun/duck_pond.py
index 5b6a7fd62..48aa2749c 100644
--- a/bot/cogs/duck_pond.py
+++ b/bot/exts/fun/duck_pond.py
@@ -1,13 +1,16 @@
+import asyncio
import logging
-from typing import Optional, Union
+from typing import Union
import discord
from discord import Color, Embed, Member, Message, RawReactionActionEvent, User, errors
-from discord.ext.commands import Cog
+from discord.ext.commands import Cog, Context, command
from bot import constants
from bot.bot import Bot
-from bot.utils.messages import send_attachments, sub_clyde
+from bot.utils.checks import has_any_role
+from bot.utils.messages import send_attachments
+from bot.utils.webhooks import send_webhook
log = logging.getLogger(__name__)
@@ -18,7 +21,10 @@ class DuckPond(Cog):
def __init__(self, bot: Bot):
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
async def fetch_webhook(self) -> None:
"""Fetches the webhook object, so we can post to it."""
@@ -47,23 +53,13 @@ class DuckPond(Cog):
return True
return False
- async def send_webhook(
- self,
- content: Optional[str] = None,
- username: Optional[str] = None,
- avatar_url: Optional[str] = None,
- embed: Optional[Embed] = None,
- ) -> None:
- """Send a webhook to the duck_pond channel."""
- try:
- await self.webhook.send(
- content=content,
- username=sub_clyde(username),
- avatar_url=avatar_url,
- embed=embed
- )
- except discord.HTTPException:
- log.exception("Failed to send a message to the Duck Pool webhook")
+ @staticmethod
+ def _is_duck_emoji(emoji: Union[str, discord.PartialEmoji, discord.Emoji]) -> bool:
+ """Check if the emoji is a valid duck emoji."""
+ if isinstance(emoji, str):
+ return emoji == "🦆"
+ else:
+ return hasattr(emoji, "name") and emoji.name.startswith("ducky_")
async def count_ducks(self, message: Message) -> int:
"""
@@ -71,33 +67,24 @@ class DuckPond(Cog):
Only counts ducks added by staff members.
"""
- duck_count = 0
- duck_reactors = []
+ duck_reactors = set()
+ # iterate over all reactions
for reaction in message.reactions:
- async for user in reaction.users():
-
- # Is the user a staff member and not already counted as reactor?
- if not self.is_staff(user) or user.id in duck_reactors:
- continue
-
- # Is the emoji a duck?
- if hasattr(reaction.emoji, "id"):
- if reaction.emoji.id in constants.DuckPond.custom_emojis:
- duck_count += 1
- duck_reactors.append(user.id)
- elif isinstance(reaction.emoji, str):
- if reaction.emoji == "🦆":
- duck_count += 1
- duck_reactors.append(user.id)
- return duck_count
+ # check if the current reaction is a duck
+ if not self._is_duck_emoji(reaction.emoji):
+ continue
+
+ # update the set of reactors with all staff reactors
+ duck_reactors |= {user.id async for user in reaction.users() if self.is_staff(user)}
+
+ return len(duck_reactors)
async def relay_message(self, message: Message) -> None:
"""Relays the message's content and attachments to the duck pond channel."""
- clean_content = message.clean_content
-
- if clean_content:
- await self.send_webhook(
+ if message.clean_content:
+ await send_webhook(
+ webhook=self.webhook,
content=message.clean_content,
username=message.author.display_name,
avatar_url=message.author.avatar_url
@@ -111,7 +98,8 @@ class DuckPond(Cog):
description=":x: **This message contained an attachment, but it could not be retrieved**",
color=Color.red()
)
- await self.send_webhook(
+ await send_webhook(
+ webhook=self.webhook,
embed=e,
username=message.author.display_name,
avatar_url=message.author.avatar_url
@@ -119,18 +107,35 @@ class DuckPond(Cog):
except discord.HTTPException:
log.exception("Failed to send an attachment to the webhook")
- await message.add_reaction("✅")
+ async def locked_relay(self, message: discord.Message) -> bool:
+ """Relay a message after obtaining the relay lock."""
+ if self.relay_lock is None:
+ # Lazily load the lock to ensure it's created within the
+ # appropriate event loop.
+ self.relay_lock = asyncio.Lock()
- @staticmethod
- def _payload_has_duckpond_emoji(payload: RawReactionActionEvent) -> bool:
+ async with self.relay_lock:
+ # check if the message has a checkmark after acquiring the lock
+ if await self.has_green_checkmark(message):
+ return False
+
+ # relay the message
+ await self.relay_message(message)
+
+ # add a green checkmark to indicate that the message was relayed
+ await message.add_reaction("✅")
+ return True
+
+ def _payload_has_duckpond_emoji(self, emoji: discord.PartialEmoji) -> bool:
"""Test if the RawReactionActionEvent payload contains a duckpond emoji."""
- if payload.emoji.is_custom_emoji():
- if payload.emoji.id in constants.DuckPond.custom_emojis:
- return True
- elif payload.emoji.name == "🦆":
- return True
+ if emoji.is_unicode_emoji():
+ # For unicode PartialEmojis, the `name` attribute is just the string
+ # representation of the emoji. This is what the helper method
+ # expects, as unicode emojis show up as just a `str` instance when
+ # inspecting the reactions attached to a message.
+ emoji = emoji.name
- return False
+ return self._is_duck_emoji(emoji)
@Cog.listener()
async def on_raw_reaction_add(self, payload: RawReactionActionEvent) -> None:
@@ -141,33 +146,51 @@ 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
+
# Is the emoji in the reaction a duck?
- if not self._payload_has_duckpond_emoji(payload):
+ if not self._payload_has_duckpond_emoji(payload.emoji):
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)
- # Is the member a human and a staff member?
- if not self.is_staff(member) or member.bot:
+ # Was the message sent by a human staff member?
+ if not self.is_staff(message.author) or message.author.bot:
return
- # Does the message already have a green checkmark?
- if await self.has_green_checkmark(message):
+ # Is the reactor a human staff member?
+ if not self.is_staff(member) or member.bot:
return
# Time to count our ducks!
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:
- await self.relay_message(message)
+ 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 == "✅":
@@ -176,6 +199,15 @@ class DuckPond(Cog):
if duck_count >= constants.DuckPond.threshold:
await message.add_reaction("✅")
+ @command(name="duckify", aliases=("duckpond", "pondify"))
+ @has_any_role(constants.Roles.admins)
+ async def duckify(self, ctx: Context, message: discord.Message) -> None:
+ """Relay a message to the duckpond, no ducks required!"""
+ if await self.locked_relay(message):
+ await ctx.message.add_reaction("🦆")
+ else:
+ await ctx.message.add_reaction("❌")
+
def setup(bot: Bot) -> None:
"""Load the DuckPond cog."""
diff --git a/bot/cogs/off_topic_names.py b/bot/exts/fun/off_topic_names.py
index 201579a0b..7fc93b88c 100644
--- a/bot/cogs/off_topic_names.py
+++ b/bot/exts/fun/off_topic_names.py
@@ -1,49 +1,21 @@
-import asyncio
import difflib
import logging
from datetime import datetime, timedelta
from discord import Colour, Embed
-from discord.ext.commands import BadArgument, Cog, Context, Converter, group
+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
from bot.constants import Channels, MODERATION_ROLES
-from bot.decorators import with_role
+from bot.converters import OffTopicName
from bot.pagination import LinePaginator
-
CHANNELS = (Channels.off_topic_0, Channels.off_topic_1, Channels.off_topic_2)
log = logging.getLogger(__name__)
-class OffTopicName(Converter):
- """A converter that ensures an added off-topic name is valid."""
-
- @staticmethod
- async def convert(ctx: Context, argument: str) -> str:
- """Attempt to replace any invalid characters with their approximate Unicode equivalent."""
- allowed_characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ!?'`-"
-
- # Chain multiple words to a single one
- argument = "-".join(argument.split())
-
- if not (2 <= len(argument) <= 96):
- raise BadArgument("Channel name must be between 2 and 96 chars long")
-
- elif not all(c.isalnum() or c in allowed_characters for c in argument):
- raise BadArgument(
- "Channel name must only consist of "
- "alphanumeric characters, minus signs or apostrophes."
- )
-
- # Replace invalid characters with unicode alternatives.
- table = str.maketrans(
- allowed_characters, '𝖠𝖡𝖢𝖣𝖤𝖥𝖦𝖧𝖨𝖩𝖪𝖫𝖬𝖭𝖮𝖯𝖰𝖱𝖲𝖳𝖴𝖵𝖶𝖷𝖸𝖹ǃ?’’-'
- )
- return argument.translate(table)
-
-
async def update_names(bot: Bot) -> None:
"""Background updater task that performs the daily channel name update."""
while True:
@@ -51,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(
@@ -94,13 +65,13 @@ class OffTopicNames(Cog):
self.updater_task = self.bot.loop.create_task(coro)
@group(name='otname', aliases=('otnames', 'otn'), invoke_without_command=True)
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
async def otname_group(self, ctx: Context) -> None:
"""Add or list items from the off-topic channel name rotation."""
await ctx.send_help(ctx.command)
@otname_group.command(name='add', aliases=('a',))
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
async def add_command(self, ctx: Context, *, name: OffTopicName) -> None:
"""
Adds a new off-topic name to the rotation.
@@ -123,7 +94,7 @@ class OffTopicNames(Cog):
await self._add_name(ctx, name)
@otname_group.command(name='forceadd', aliases=('fa',))
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
async def force_add_command(self, ctx: Context, *, name: OffTopicName) -> None:
"""Forcefully adds a new off-topic name to the rotation."""
await self._add_name(ctx, name)
@@ -136,7 +107,7 @@ class OffTopicNames(Cog):
await ctx.send(f":ok_hand: Added `{name}` to the names list.")
@otname_group.command(name='delete', aliases=('remove', 'rm', 'del', 'd'))
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
async def delete_command(self, ctx: Context, *, name: OffTopicName) -> None:
"""Removes a off-topic name from the rotation."""
await self.bot.api_client.delete(f'bot/off-topic-channel-names/{name}')
@@ -145,7 +116,7 @@ class OffTopicNames(Cog):
await ctx.send(f":ok_hand: Removed `{name}` from the names list.")
@otname_group.command(name='list', aliases=('l',))
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
async def list_command(self, ctx: Context) -> None:
"""
Lists all currently known off-topic channel names in a paginator.
@@ -165,7 +136,7 @@ class OffTopicNames(Cog):
await ctx.send(embed=embed)
@otname_group.command(name='search', aliases=('s',))
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
async def search_command(self, ctx: Context, *, query: OffTopicName) -> None:
"""Search for an off-topic name."""
result = await self.bot.api_client.get('bot/off-topic-channel-names')
diff --git a/bot/cogs/help_channels.py b/bot/exts/help_channels.py
index 187adfe51..062d4fcfe 100644
--- a/bot/cogs/help_channels.py
+++ b/bot/exts/help_channels.py
@@ -1,5 +1,4 @@
import asyncio
-import inspect
import json
import logging
import random
@@ -10,12 +9,12 @@ from pathlib import Path
import discord
import discord.abc
+from async_rediscache import RedisCache
from discord.ext import commands
from bot import constants
from bot.bot import Bot
-from bot.utils import RedisCache
-from bot.utils.checks import with_role_check
+from bot.utils import channel as channel_utils
from bot.utils.scheduling import Scheduler
log = logging.getLogger(__name__)
@@ -35,12 +34,9 @@ and will be yours until it has been inactive for {constants.HelpChannels.idle_mi
is closed manually with `!close`. When that happens, it will be set to **dormant** and moved into \
the **Help: Dormant** category.
-You may claim a new channel once every {constants.HelpChannels.claim_minutes} minutes. If you \
-currently cannot send a message in this channel, it means you are on cooldown and need to wait.
-
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}).
+check out our guide on **[asking good questions]({ASKING_GUIDE_URL})**.
"""
DORMANT_MSG = f"""
@@ -51,20 +47,13 @@ channel until it becomes available again.
If your question wasn't answered yet, you can claim a new help channel from the \
**Help: Available** category by simply asking your question again. Consider rephrasing the \
question to maximize your chance of getting a good answer. If you're not sure how, have a look \
-through our guide for [asking a good question]({ASKING_GUIDE_URL}).
+through our guide for **[asking a good question]({ASKING_GUIDE_URL})**.
"""
CoroutineFunc = t.Callable[..., t.Coroutine]
-class TaskData(t.NamedTuple):
- """Data for a scheduled task."""
-
- wait_time: int
- callback: t.Awaitable
-
-
-class HelpChannels(Scheduler, commands.Cog):
+class HelpChannels(commands.Cog):
"""
Manage the help channel system of the guild.
@@ -113,10 +102,13 @@ class HelpChannels(Scheduler, commands.Cog):
# RedisCache[discord.TextChannel.id, UtcPosixTimestamp]
claim_times = RedisCache()
- def __init__(self, bot: Bot):
- super().__init__()
+ # This cache maps a help channel to original question message in same channel.
+ # RedisCache[discord.TextChannel.id, discord.Message.id]
+ question_messages = RedisCache()
+ def __init__(self, bot: Bot):
self.bot = bot
+ self.scheduler = Scheduler(self.__class__.__name__)
# Categories
self.available_category: discord.CategoryChannel = None
@@ -145,7 +137,7 @@ class HelpChannels(Scheduler, commands.Cog):
for task in self.queue_tasks:
task.cancel()
- self.cancel_all()
+ self.scheduler.cancel_all()
def create_channel_queue(self) -> asyncio.Queue:
"""
@@ -204,12 +196,12 @@ class HelpChannels(Scheduler, commands.Cog):
return True
log.trace(f"{ctx.author} is not the help channel claimant, checking roles.")
- role_check = with_role_check(ctx, *constants.HelpChannels.cmd_whitelist)
+ has_role = await commands.has_any_role(*constants.HelpChannels.cmd_whitelist).predicate(ctx)
- if role_check:
+ if has_role:
self.bot.stats.incr("help.dormant_invoke.staff")
- return role_check
+ return has_role
@commands.command(name="close", aliases=["dormant", "solved"], enabled=False)
async def close_command(self, ctx: commands.Context) -> None:
@@ -223,16 +215,14 @@ class HelpChannels(Scheduler, commands.Cog):
log.trace("close command invoked; checking if the channel is in-use.")
if ctx.channel.category == self.in_use_category:
if await self.dormant_check(ctx):
-
- # Remove the claimant and the cooldown role
- await self.help_channel_claimants.delete(ctx.channel.id)
await self.remove_cooldown_role(ctx.author)
# Ignore missing task when cooldown has passed but the channel still isn't dormant.
- self.cancel_task(ctx.author.id, ignore_missing=True)
+ if ctx.author.id in self.scheduler:
+ self.scheduler.cancel(ctx.author.id)
await self.move_to_dormant(ctx.channel, "command")
- self.cancel_task(ctx.channel.id)
+ self.scheduler.cancel(ctx.channel.id)
else:
log.debug(f"{ctx.author} invoked command 'dormant' outside an in-use help channel")
@@ -371,21 +361,36 @@ class HelpChannels(Scheduler, commands.Cog):
channels = list(self.get_category_channels(self.available_category))
missing = constants.HelpChannels.max_available - len(channels)
- log.trace(f"Moving {missing} missing channels to the Available category.")
+ # If we've got less than `max_available` channel available, we should add some.
+ if missing > 0:
+ log.trace(f"Moving {missing} missing channels to the Available category.")
+ for _ in range(missing):
+ await self.move_to_available()
- for _ in range(missing):
- await self.move_to_available()
+ # If for some reason we have more than `max_available` channels available,
+ # we should move the superfluous ones over to dormant.
+ elif missing < 0:
+ log.trace(f"Moving {abs(missing)} superfluous available channels over to the Dormant category.")
+ for channel in channels[:abs(missing)]:
+ await self.move_to_dormant(channel, "auto")
async def init_categories(self) -> None:
"""Get the help category objects. Remove the cog if retrieval fails."""
log.trace("Getting the CategoryChannel objects for the help categories.")
try:
- self.available_category = await self.try_get_channel(
- constants.Categories.help_available
+ self.available_category = await channel_utils.try_get_channel(
+ constants.Categories.help_available,
+ self.bot
+ )
+ self.in_use_category = await channel_utils.try_get_channel(
+ constants.Categories.help_in_use,
+ self.bot
+ )
+ self.dormant_category = await channel_utils.try_get_channel(
+ constants.Categories.help_dormant,
+ self.bot
)
- 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)
except discord.HTTPException:
log.exception("Failed to get a category; cog will be removed")
self.bot.remove_cog(self.qualified_name)
@@ -439,14 +444,11 @@ class HelpChannels(Scheduler, commands.Cog):
if not message or not message.embeds:
return False
- embed = message.embeds[0]
- return message.author == self.bot.user and embed.description.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
+ bot_msg_desc = message.embeds[0].description
+ if bot_msg_desc is discord.Embed.Empty:
+ log.trace("Last message was a bot embed but it was empty.")
+ return False
+ return message.author == self.bot.user and bot_msg_desc.strip() == description.strip()
async def move_idle_channel(self, channel: discord.TextChannel, has_task: bool = True) -> None:
"""
@@ -474,16 +476,15 @@ class HelpChannels(Scheduler, commands.Cog):
else:
# Cancel the existing task, if any.
if has_task:
- self.cancel_task(channel.id)
-
- data = TaskData(idle_seconds - time_elapsed, self.move_idle_channel(channel))
+ self.scheduler.cancel(channel.id)
+ delay = idle_seconds - time_elapsed
log.info(
f"#{channel} ({channel.id}) is still active; "
- f"scheduling it to be moved after {data.wait_time} seconds."
+ f"scheduling it to be moved after {delay} seconds."
)
- self.schedule_task(channel.id, data)
+ self.scheduler.schedule_later(delay, channel.id, self.move_idle_channel(channel))
async def move_to_bottom_position(self, channel: discord.TextChannel, category_id: int, **options) -> None:
"""
@@ -495,11 +496,11 @@ class HelpChannels(Scheduler, 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, self.bot)
payload = [{"id": c.id, "position": c.position} for c in category.channels]
@@ -550,6 +551,7 @@ class HelpChannels(Scheduler, commands.Cog):
"""
log.info(f"Moving #{channel} ({channel.id}) to the Dormant category.")
+ await self.help_channel_claimants.delete(channel.id)
await self.move_to_bottom_position(
channel=channel,
category_id=constants.Categories.help_dormant,
@@ -572,6 +574,8 @@ class HelpChannels(Scheduler, commands.Cog):
embed = discord.Embed(description=DORMANT_MSG)
await channel.send(embed=embed)
+ await self.unpin(channel)
+
log.trace(f"Pushing #{channel} ({channel.id}) into the channel queue.")
self.channel_queue.put_nowait(channel)
self.report_stats()
@@ -588,8 +592,7 @@ class HelpChannels(Scheduler, commands.Cog):
timeout = constants.HelpChannels.idle_minutes * 60
log.trace(f"Scheduling #{channel} ({channel.id}) to become dormant in {timeout} sec.")
- data = TaskData(timeout, self.move_idle_channel(channel))
- self.schedule_task(channel.id, data)
+ self.scheduler.schedule_later(timeout, channel.id, self.move_idle_channel(channel))
self.report_stats()
async def notify(self) -> None:
@@ -624,11 +627,13 @@ class HelpChannels(Scheduler, commands.Cog):
channel = self.bot.get_channel(constants.HelpChannels.notify_channel)
mentions = " ".join(f"<@&{role}>" for role in constants.HelpChannels.notify_roles)
+ allowed_roles = [discord.Object(id_) for id_ in constants.HelpChannels.notify_roles]
message = await channel.send(
f"{mentions} A new available help channel is needed but there "
f"are no more dormant ones. Consider freeing up some in-use channels manually by "
- f"using the `{constants.Bot.prefix}dormant` command within the channels."
+ f"using the `{constants.Bot.prefix}dormant` command within the channels.",
+ allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles)
)
self.bot.stats.incr("help.out_of_channel_alerts")
@@ -643,7 +648,7 @@ class HelpChannels(Scheduler, 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
@@ -668,7 +673,8 @@ class HelpChannels(Scheduler, 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.")
@@ -678,7 +684,7 @@ class HelpChannels(Scheduler, 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."
@@ -688,6 +694,9 @@ class HelpChannels(Scheduler, commands.Cog):
log.info(f"Channel #{channel} was claimed by `{message.author.id}`.")
await self.move_to_in_use(channel)
await self.revoke_send_permissions(message.author)
+
+ await self.pin(message)
+
# Add user with channel for dormant check.
await self.help_channel_claimants.set(channel.id, message.author.id)
@@ -713,7 +722,7 @@ class HelpChannels(Scheduler, 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):
@@ -722,15 +731,28 @@ class HelpChannels(Scheduler, commands.Cog):
log.info(f"Claimant of #{msg.channel} ({msg.author}) deleted message, channel is empty now. Rescheduling task.")
# Cancel existing dormant task before scheduling new.
- self.cancel_task(msg.channel.id)
+ self.scheduler.cancel(msg.channel.id)
- task = TaskData(constants.HelpChannels.deleted_idle_minutes * 60, self.move_idle_channel(msg.channel))
- self.schedule_task(msg.channel.id, task)
+ delay = constants.HelpChannels.deleted_idle_minutes * 60
+ self.scheduler.schedule_later(delay, msg.channel.id, self.move_idle_channel(msg.channel))
async def is_empty(self, channel: discord.TextChannel) -> bool:
- """Return True if the most recent message in `channel` is the bot's `AVAILABLE_MSG`."""
- msg = await self.get_last_message(channel)
- return self.match_bot_embed(msg, AVAILABLE_MSG)
+ """Return True if there's an AVAILABLE_MSG and the messages leading up are bot messages."""
+ log.trace(f"Checking if #{channel} ({channel.id}) is empty.")
+
+ # A limit of 100 results in a single API call.
+ # If AVAILABLE_MSG isn't found within 100 messages, then assume the channel is not empty.
+ # Not gonna do an extensive search for it cause it's too expensive.
+ async for msg in channel.history(limit=100):
+ if not msg.author.bot:
+ log.trace(f"#{channel} ({channel.id}) has a non-bot message.")
+ return False
+
+ if self.match_bot_embed(msg, AVAILABLE_MSG):
+ log.trace(f"#{channel} ({channel.id}) has the available message embed.")
+ return True
+
+ return False
async def check_cooldowns(self) -> None:
"""Remove expired cooldowns and re-schedule active ones."""
@@ -752,8 +774,8 @@ class HelpChannels(Scheduler, commands.Cog):
await self.remove_cooldown_role(member)
else:
# The member is still on a cooldown; re-schedule it for the remaining time.
- remaining = cooldown - in_use_time.seconds
- await self.schedule_cooldown_expiration(member, remaining)
+ delay = cooldown - in_use_time.seconds
+ self.scheduler.schedule_later(delay, member.id, self.remove_cooldown_role(member))
async def add_cooldown_role(self, member: discord.Member) -> None:
"""Add the help cooldown role to `member`."""
@@ -804,16 +826,11 @@ class HelpChannels(Scheduler, commands.Cog):
# Cancel the existing task, if any.
# Would mean the user somehow bypassed the lack of permissions (e.g. user is guild owner).
- self.cancel_task(member.id, ignore_missing=True)
-
- await self.schedule_cooldown_expiration(member, constants.HelpChannels.claim_minutes * 60)
-
- async def schedule_cooldown_expiration(self, member: discord.Member, seconds: int) -> None:
- """Schedule the cooldown role for `member` to be removed after a duration of `seconds`."""
- log.trace(f"Scheduling removal of {member}'s ({member.id}) cooldown.")
+ if member.id in self.scheduler:
+ self.scheduler.cancel(member.id)
- callback = self.remove_cooldown_role(member)
- self.schedule_task(member.id, TaskData(seconds, callback))
+ delay = constants.HelpChannels.claim_minutes * 60
+ self.scheduler.schedule_later(delay, member.id, self.remove_cooldown_role(member))
async def send_available_message(self, channel: discord.TextChannel) -> None:
"""Send the available message by editing a dormant message or sending a new message."""
@@ -830,17 +847,46 @@ class HelpChannels(Scheduler, 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}.")
+ 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.
- 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)
+ Return True if successful and False otherwise.
+ """
+ channel_str = f"#{channel} ({channel.id})"
+ if pin:
+ func = self.bot.http.pin_message
+ verb = "pin"
+ else:
+ func = self.bot.http.unpin_message
+ verb = "unpin"
- log.trace(f"Channel #{channel} ({channel_id}) retrieved.")
- return channel
+ try:
+ await func(channel.id, msg_id)
+ except discord.HTTPException as e:
+ if e.code == 10008:
+ log.debug(f"Message {msg_id} in {channel_str} doesn't exist; can't {verb}.")
+ else:
+ log.exception(
+ f"Error {verb}ning message {msg_id} in {channel_str}: {e.status} ({e.code})"
+ )
+ return False
+ else:
+ log.trace(f"{verb.capitalize()}ned message {msg_id} in {channel_str}.")
+ return True
+
+ async def pin(self, message: discord.Message) -> None:
+ """Pin an initial question `message` and store it in a cache."""
+ if await self.pin_wrapper(message.id, message.channel, pin=True):
+ await self.question_messages.set(message.channel.id, message.id)
+
+ async def unpin(self, channel: discord.TextChannel) -> None:
+ """Unpin the initial question message sent in `channel`."""
+ msg_id = await self.question_messages.pop(channel.id)
+ if msg_id is None:
+ log.debug(f"#{channel} ({channel.id}) doesn't have a message pinned.")
+ else:
+ await self.pin_wrapper(msg_id, channel, pin=False)
async def wait_for_dormant_channel(self) -> discord.TextChannel:
"""Wait for a dormant channel to become available in the queue and return it."""
@@ -855,21 +901,6 @@ class HelpChannels(Scheduler, commands.Cog):
return channel
- async def _scheduled_task(self, data: TaskData) -> None:
- """Await the `data.callback` coroutine after waiting for `data.wait_time` seconds."""
- try:
- log.trace(f"Waiting {data.wait_time} seconds before awaiting callback.")
- await asyncio.sleep(data.wait_time)
-
- # Use asyncio.shield to prevent callback from cancelling itself.
- # The parent task (_scheduled_task) will still get cancelled.
- log.trace("Done waiting; now awaiting the callback.")
- await asyncio.shield(data.callback)
- finally:
- if inspect.iscoroutine(data.callback):
- log.trace("Explicitly closing coroutine.")
- data.callback.close()
-
def validate_config() -> None:
"""Raise a ValueError if the cog's config is invalid."""
diff --git a/tests/bot/patches/__init__.py b/bot/exts/info/__init__.py
index e69de29bb..e69de29bb 100644
--- a/tests/bot/patches/__init__.py
+++ b/bot/exts/info/__init__.py
diff --git a/bot/cogs/code_snippets.py b/bot/exts/info/code_snippets.py
index 3d38ef1c3..3d38ef1c3 100644
--- a/bot/cogs/code_snippets.py
+++ b/bot/exts/info/code_snippets.py
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..1e0feab0d
--- /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,), self.bot))
+
+ # 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..508f157fb
--- /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("python")
+ 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("python")
+
+ # 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..a98218dfb
--- /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", "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>[^\W_]+\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/cogs/doc.py b/bot/exts/info/doc.py
index 204cffb37..c16a99225 100644
--- a/bot/cogs/doc.py
+++ b/bot/exts/info/doc.py
@@ -21,8 +21,8 @@ from urllib3.exceptions import ProtocolError
from bot.bot import Bot
from bot.constants import MODERATION_ROLES, RedirectOutput
from bot.converters import ValidPythonIdentifier, ValidURL
-from bot.decorators import with_role
from bot.pagination import LinePaginator
+from bot.utils.messages import wait_for_deletion
log = logging.getLogger(__name__)
@@ -345,7 +345,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:
@@ -391,10 +391,11 @@ class Doc(commands.Cog):
await error_message.delete(delay=NOT_FOUND_DELETE_DELAY)
await ctx.message.delete(delay=NOT_FOUND_DELETE_DELAY)
else:
- await ctx.send(embed=doc_embed)
+ msg = await ctx.send(embed=doc_embed)
+ await wait_for_deletion(msg, (ctx.author.id,), client=self.bot)
@docs_group.command(name='set', aliases=('s',))
- @with_role(*MODERATION_ROLES)
+ @commands.has_any_role(*MODERATION_ROLES)
async def set_command(
self, ctx: commands.Context, package_name: ValidPythonIdentifier,
base_url: ValidURL, inventory_url: InventoryURL
@@ -431,7 +432,7 @@ class Doc(commands.Cog):
await ctx.send(f"Added package `{package_name}` to database and refreshed inventory.")
@docs_group.command(name='delete', aliases=('remove', 'rm', 'd'))
- @with_role(*MODERATION_ROLES)
+ @commands.has_any_role(*MODERATION_ROLES)
async def delete_command(self, ctx: commands.Context, package_name: ValidPythonIdentifier) -> None:
"""
Removes the specified package from the database.
@@ -448,7 +449,7 @@ class Doc(commands.Cog):
await ctx.send(f"Successfully deleted `{package_name}` and refreshed inventory.")
@docs_group.command(name="refresh", aliases=("rfsh", "r"))
- @with_role(*MODERATION_ROLES)
+ @commands.has_any_role(*MODERATION_ROLES)
async def refresh_command(self, ctx: commands.Context) -> None:
"""Refresh inventories and send differences to channel."""
old_inventories = set(self.base_urls)
diff --git a/bot/cogs/help.py b/bot/exts/info/help.py
index 832f6ea6b..599c5d5c0 100644
--- a/bot/cogs/help.py
+++ b/bot/exts/info/help.py
@@ -1,50 +1,28 @@
import itertools
import logging
-from asyncio import TimeoutError
from collections import namedtuple
from contextlib import suppress
from typing import List, Union
-from discord import Colour, Embed, Member, Message, NotFound, Reaction, User
+from discord import Colour, Embed
from discord.ext.commands import Bot, Cog, Command, Context, Group, HelpCommand
from fuzzywuzzy import fuzz, process
+from fuzzywuzzy.utils import full_process
from bot import constants
-from bot.constants import Channels, Emojis, STAFF_ROLES
+from bot.constants import Channels, STAFF_ROLES
from bot.decorators import redirect_output
from bot.pagination import LinePaginator
+from bot.utils.messages import wait_for_deletion
log = logging.getLogger(__name__)
COMMANDS_PER_PAGE = 8
-DELETE_EMOJI = Emojis.trashcan
PREFIX = constants.Bot.prefix
Category = namedtuple("Category", ["name", "description", "cogs"])
-async def help_cleanup(bot: Bot, author: Member, message: Message) -> None:
- """
- Runs the cleanup for the help command.
-
- Adds the :trashcan: reaction that, when clicked, will delete the help message.
- After a 300 second timeout, the reaction will be removed.
- """
- def check(reaction: Reaction, user: User) -> bool:
- """Checks the reaction is :trashcan:, the author is original author and messages are the same."""
- return str(reaction) == DELETE_EMOJI and user.id == author.id and reaction.message.id == message.id
-
- await message.add_reaction(DELETE_EMOJI)
-
- try:
- await bot.wait_for("reaction_add", check=check, timeout=300)
- await message.delete()
- except TimeoutError:
- await message.remove_reaction(DELETE_EMOJI, bot.user)
- except NotFound:
- pass
-
-
class HelpQueryNotFound(ValueError):
"""
Raised when a HelpSession Query doesn't match a command or cog.
@@ -146,7 +124,13 @@ class CustomHelpCommand(HelpCommand):
Will return an instance of the `HelpQueryNotFound` exception with the error message and possible matches.
"""
choices = await self.get_all_help_choices()
- result = process.extractBests(string, choices, scorer=fuzz.ratio, score_cutoff=60)
+
+ # Run fuzzywuzzy's processor beforehand, and avoid matching if processed string is empty
+ # This avoids fuzzywuzzy from raising a warning on inputs with only non-alphanumeric characters
+ if (processed := full_process(string)):
+ result = process.extractBests(processed, choices, scorer=fuzz.ratio, score_cutoff=60, processor=None)
+ else:
+ result = []
return HelpQueryNotFound(f'Query "{string}" not found.', dict(result))
@@ -183,7 +167,9 @@ class CustomHelpCommand(HelpCommand):
command_details = f"**```{PREFIX}{name} {command.signature}```**\n"
# show command aliases
- aliases = ", ".join(f"`{alias}`" if not parent else f"`{parent} {alias}`" for alias in command.aliases)
+ aliases = [f"`{alias}`" if not parent else f"`{parent} {alias}`" for alias in command.aliases]
+ aliases += [f"`{alias}`" for alias in getattr(command, "root_aliases", ())]
+ aliases = ", ".join(sorted(aliases))
if aliases:
command_details += f"**Can also use:** {aliases}\n\n"
@@ -200,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 help_cleanup(self.context.bot, self.context.author, message)
+ await wait_for_deletion(message, (self.context.author.id,), self.context.bot)
@staticmethod
def get_commands_brief_details(commands_: List[Command], return_as_list: bool = False) -> Union[List[str], str]:
@@ -239,11 +225,11 @@ class CustomHelpCommand(HelpCommand):
embed.description += f"\n**Subcommands:**\n{command_details}"
message = await self.context.send(embed=embed)
- await help_cleanup(self.context.bot, self.context.author, message)
+ await wait_for_deletion(message, (self.context.author.id,), self.context.bot)
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()
@@ -255,7 +241,7 @@ class CustomHelpCommand(HelpCommand):
embed.description += f"\n\n**Commands:**\n{command_details}"
message = await self.context.send(embed=embed)
- await help_cleanup(self.context.bot, self.context.author, message)
+ await wait_for_deletion(message, (self.context.author.id,), self.context.bot)
@staticmethod
def _category_key(command: Command) -> str:
diff --git a/bot/cogs/information.py b/bot/exts/info/information.py
index f0bd1afdb..5aaf85e5a 100644
--- a/bot/cogs/information.py
+++ b/bot/exts/info/information.py
@@ -4,23 +4,31 @@ import pprint
import textwrap
from collections import Counter, defaultdict
from string import Template
-from typing import Any, Mapping, Optional, Union
+from typing import Any, Mapping, Optional, Tuple, Union
-from discord import ChannelType, Colour, Embed, Guild, Member, Message, Role, Status, utils
+from discord import ChannelType, Colour, Embed, Guild, Message, Role, Status, utils
from discord.abc import GuildChannel
-from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group
-from discord.utils import escape_markdown
+from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group, has_any_role
from bot import constants
from bot.bot import Bot
-from bot.decorators import in_whitelist, with_role
+from bot.converters import FetchedMember
+from bot.decorators import in_whitelist
from bot.pagination import LinePaginator
-from bot.utils.checks import InWhitelistCheckFailure, cooldown_with_role_bypass, with_role_check
+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
log = logging.getLogger(__name__)
+STATUS_EMOTES = {
+ Status.offline: constants.Emojis.status_offline,
+ Status.dnd: constants.Emojis.status_dnd,
+ Status.idle: constants.Emojis.status_idle
+}
+
+
class Information(Cog):
"""A cog with commands for generating embeds with server info, such as server stats and user info."""
@@ -70,7 +78,7 @@ class Information(Cog):
channel_type_list = sorted(channel_type_list)
return "\n".join(channel_type_list)
- @with_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."""
@@ -90,7 +98,7 @@ class Information(Cog):
await LinePaginator.paginate(role_list, ctx, embed, empty=False)
- @with_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:
"""
@@ -116,10 +124,7 @@ class Information(Cog):
parsed_roles.append(role)
if failed_roles:
- await ctx.send(
- ":x: I could not convert the following role names to a role: \n- "
- "\n- ".join(failed_roles)
- )
+ await ctx.send(f":x: Could not retrieve the following roles: {', '.join(failed_roles)}")
for role in parsed_roles:
h, s, v = colorsys.rgb_to_hsv(*role.colour.to_rgb())
@@ -149,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?
@@ -157,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**
@@ -177,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)
@@ -188,96 +193,102 @@ 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
# Do a role check if this is being executed on someone other than the caller
- elif user != ctx.author and not with_role_check(ctx, *constants.MODERATION_ROLES):
+ elif user != ctx.author and await has_no_roles_check(ctx, *constants.MODERATION_ROLES):
await ctx.send("You may not use this command on users other than yourself.")
return
- # Non-staff may only do this in #bot-commands
- if not with_role_check(ctx, *constants.STAFF_ROLES):
- if not ctx.channel.id == constants.Channels.bot_commands:
- raise InWhitelistCheckFailure(constants.Channels.bot_commands)
-
- embed = await self.create_user_embed(ctx, user)
-
- await ctx.send(embed=embed)
+ # Will redirect to #bot-commands if it fails.
+ if in_whitelist_check(ctx, roles=constants.STAFF_ROLES):
+ 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)
+ on_server = bool(ctx.guild.get_member(user.id))
- # Custom status
- custom_status = ''
- for activity in user.activities:
- # Check activity.state for None value if user has a custom status set
- # This guards against a custom status with an emoji but no text, which will cause
- # escape_markdown to raise an exception
- # This can be reworked after a move to d.py 1.3.0+, which adds a CustomActivity class
- if activity.name == 'Custom Status' and activity.state:
- state = escape_markdown(activity.state)
- custom_status = f'Status: {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})"
- joined = time_since(user.joined_at, precision="days")
- roles = ", ".join(role.mention for role in user.roles[1:])
+ badges = []
- description = [
- textwrap.dedent(f"""
- **User Information**
- Created: {created}
- Profile: {user.mention}
- ID: {user.id}
- {custom_status}
- **Member Information**
- Joined: {joined}
- Roles: {roles or None}
- """).strip()
+ for badge, is_set in user.public_flags:
+ if is_set and (emoji := getattr(constants.Emojis, f"badge_{badge}", None)):
+ badges.append(emoji)
+
+ 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 = [
+ (
+ "User information",
+ textwrap.dedent(f"""
+ Created: {created}
+ Profile: {user.mention}
+ ID: {user.id}
+ """).strip()
+ ),
+ (
+ "Member information",
+ membership
+ ),
]
# Show more verbose output in moderation channels for infractions and nominations
- if ctx.channel.id in constants.MODERATION_CHANNELS:
- description.append(await self.expanded_user_infraction_counts(user))
- description.append(await self.user_nomination_counts(user))
+ if is_mod_channel(ctx.channel):
+ fields.append(await self.expanded_user_infraction_counts(user))
+ fields.append(await self.user_nomination_counts(user))
else:
- description.append(await self.basic_user_infraction_counts(user))
+ fields.append(await self.basic_user_infraction_counts(user))
# Let's build the embed now
embed = Embed(
title=name,
- description="\n\n".join(description)
+ description=" ".join(badges)
)
+ for field_name, field_content in fields:
+ embed.add_field(name=field_name, value=field_content, inline=False)
+
embed.set_thumbnail(url=user.avatar_url_as(static_format="png"))
embed.colour = user.top_role.colour if roles else Colour.blurple()
return embed
- async def basic_user_infraction_counts(self, member: Member) -> 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)
}
)
total_infractions = len(infractions)
active_infractions = sum(infraction['active'] for infraction in infractions)
- infraction_output = f"**Infractions**\nTotal: {total_infractions}\nActive: {active_infractions}"
+ infraction_output = f"Total: {total_infractions}\nActive: {active_infractions}"
- return infraction_output
+ return "Infractions", infraction_output
- async def expanded_user_infraction_counts(self, member: Member) -> str:
+ async def expanded_user_infraction_counts(self, user: FetchedMember) -> Tuple[str, str]:
"""
Gets expanded infraction counts for the given `member`.
@@ -287,13 +298,13 @@ class Information(Cog):
infractions = await self.bot.api_client.get(
'bot/infractions',
params={
- 'user__id': str(member.id)
+ 'user__id': str(user.id)
}
)
- infraction_output = ["**Infractions**"]
+ infraction_output = []
if not infractions:
- infraction_output.append("This user has never received an infraction.")
+ infraction_output.append("No infractions")
else:
# Count infractions split by `type` and `active` status for this user
infraction_types = set()
@@ -316,32 +327,32 @@ class Information(Cog):
infraction_output.append(line)
- return "\n".join(infraction_output)
+ return "Infractions", "\n".join(infraction_output)
- async def user_nomination_counts(self, member: Member) -> 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)
}
)
- output = ["**Nominations**"]
+ output = []
if not nominations:
- output.append("This user has never been nominated.")
+ output.append("No nominations")
else:
count = len(nominations)
is_currently_nominated = any(nomination["active"] for nomination in nominations)
nomination_noun = "nomination" if count == 1 else "nominations"
if is_currently_nominated:
- output.append(f"This user is **currently** nominated ({count} {nomination_noun} in total).")
+ output.append(f"This user is **currently** nominated\n({count} {nomination_noun} in total)")
else:
output.append(f"This user has {count} historical {nomination_noun}, but is currently not nominated.")
- return "\n".join(output)
+ return "Nominations", "\n".join(output)
def format_fields(self, mapping: Mapping[str, Any], field_width: Optional[int] = None) -> str:
"""Format a mapping to be readable to a human."""
@@ -379,7 +390,7 @@ class Information(Cog):
return out.rstrip()
@cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=constants.STAFF_ROLES)
- @group(invoke_without_command=True)
+ @group(invoke_without_command=True, enabled=False)
@in_whitelist(channels=(constants.Channels.bot_commands,), roles=constants.STAFF_ROLES)
async def raw(self, ctx: Context, *, message: Message, json: bool = False) -> None:
"""Shows information about the raw API response."""
@@ -414,7 +425,7 @@ class Information(Cog):
for page in paginator.pages:
await ctx.send(page)
- @raw.command()
+ @raw.command(enabled=False)
async def json(self, ctx: Context, message: Message) -> None:
"""Shows information about the raw API response in a copy-pasteable Python format."""
await ctx.invoke(self.raw, message=message, json=True)
diff --git a/bot/cogs/python_news.py b/bot/exts/info/python_news.py
index adefd5c7c..0ab5738a4 100644
--- a/bot/cogs/python_news.py
+++ b/bot/exts/info/python_news.py
@@ -10,7 +10,7 @@ from discord.ext.tasks import loop
from bot import constants
from bot.bot import Bot
-from bot.utils.messages import sub_clyde
+from bot.utils.webhooks import send_webhook
PEPS_RSS_URL = "https://www.python.org/dev/peps/peps.rss/"
@@ -100,13 +100,21 @@ class PythonNews(Cog):
):
continue
- msg = await self.send_webhook(
+ # Build an embed and send a webhook
+ embed = discord.Embed(
title=new["title"],
description=new["summary"],
timestamp=new_datetime,
url=new["link"],
- webhook_profile_name=data["feed"]["title"],
- footer=data["feed"]["title"]
+ colour=constants.Colours.soft_green
+ )
+ embed.set_footer(text=data["feed"]["title"], icon_url=AVATAR_URL)
+ msg = await send_webhook(
+ webhook=self.webhook,
+ username=data["feed"]["title"],
+ embed=embed,
+ avatar_url=AVATAR_URL,
+ wait=True,
)
payload["data"]["pep"].append(pep_nr)
@@ -161,15 +169,29 @@ class PythonNews(Cog):
content = email_information["content"]
link = THREAD_URL.format(id=thread["href"].split("/")[-2], list=maillist)
- msg = await self.send_webhook(
+
+ # Build an embed and send a message to the webhook
+ embed = discord.Embed(
title=thread_information["subject"],
description=content[:500] + f"... [continue reading]({link})" if len(content) > 500 else content,
timestamp=new_date,
url=link,
- author=f"{email_information['sender_name']} ({email_information['sender']['address']})",
- author_url=MAILMAN_PROFILE_URL.format(id=email_information["sender"]["mailman_id"]),
- webhook_profile_name=self.webhook_names[maillist],
- footer=f"Posted to {self.webhook_names[maillist]}"
+ colour=constants.Colours.soft_green
+ )
+ embed.set_author(
+ name=f"{email_information['sender_name']} ({email_information['sender']['address']})",
+ url=MAILMAN_PROFILE_URL.format(id=email_information["sender"]["mailman_id"]),
+ )
+ embed.set_footer(
+ text=f"Posted to {self.webhook_names[maillist]}",
+ icon_url=AVATAR_URL,
+ )
+ msg = await send_webhook(
+ webhook=self.webhook,
+ username=self.webhook_names[maillist],
+ embed=embed,
+ avatar_url=AVATAR_URL,
+ wait=True,
)
payload["data"][maillist].append(thread_information["thread_id"])
@@ -182,38 +204,6 @@ class PythonNews(Cog):
await self.bot.api_client.put("bot/bot-settings/news", json=payload)
- async def send_webhook(self,
- title: str,
- description: str,
- timestamp: datetime,
- url: str,
- webhook_profile_name: str,
- footer: str,
- author: t.Optional[str] = None,
- author_url: t.Optional[str] = None,
- ) -> discord.Message:
- """Send webhook entry and return sent message."""
- embed = discord.Embed(
- title=title,
- description=description,
- timestamp=timestamp,
- url=url,
- colour=constants.Colours.soft_green
- )
- if author and author_url:
- embed.set_author(
- name=author,
- url=author_url
- )
- embed.set_footer(text=footer, icon_url=AVATAR_URL)
-
- return await self.webhook.send(
- embed=embed,
- username=sub_clyde(webhook_profile_name),
- avatar_url=AVATAR_URL,
- wait=True
- )
-
async def get_thread_and_first_mail(self, maillist: str, thread_identifier: str) -> t.Tuple[t.Any, t.Any]:
"""Get mail thread and first mail from mail.python.org based on `maillist` and `thread_identifier`."""
async with self.bot.http_session.get(
diff --git a/bot/cogs/reddit.py b/bot/exts/info/reddit.py
index d853ab2ea..bad4c504d 100644
--- a/bot/cogs/reddit.py
+++ b/bot/exts/info/reddit.py
@@ -8,13 +8,13 @@ from typing import List
from aiohttp import BasicAuth, ClientError
from discord import Colour, Embed, TextChannel
-from discord.ext.commands import Cog, Context, group
+from discord.ext.commands import Cog, Context, group, has_any_role
from discord.ext.tasks import loop
+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
from bot.converters import Subreddit
-from bot.decorators import with_role
from bot.pagination import LinePaginator
from bot.utils.messages import sub_clyde
@@ -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."
)
@@ -187,6 +189,8 @@ class Reddit(Cog):
author = data["author"]
title = textwrap.shorten(data["title"], width=64, placeholder="...")
+ # Normal brackets interfere with Markdown.
+ title = escape_markdown(title).replace("[", "⦋").replace("]", "⦌")
link = self.URL + data["permalink"]
embed.description += (
@@ -201,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:
@@ -279,7 +283,7 @@ class Reddit(Cog):
await ctx.send(content=f"Here are this week's top {subreddit} posts!", embed=embed)
- @with_role(*STAFF_ROLES)
+ @has_any_role(*STAFF_ROLES)
@reddit_group.command(name="subreddits", aliases=("subs",))
async def subreddits_command(self, ctx: Context) -> None:
"""Send a paginated embed of all the subreddits we're relaying."""
diff --git a/bot/cogs/site.py b/bot/exts/info/site.py
index ac29daa1d..fb5b99086 100644
--- a/bot/cogs/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
@@ -23,7 +23,7 @@ class Site(Cog):
"""Commands for getting info about our website."""
await ctx.send_help(ctx.command)
- @site_group.command(name="home", aliases=("about",))
+ @site_group.command(name="home", aliases=("about",), root_aliases=("home",))
async def site_main(self, ctx: Context) -> None:
"""Info about the website itself."""
url = f"{URLs.site_schema}{URLs.site}/"
@@ -40,7 +40,7 @@ class Site(Cog):
await ctx.send(embed=embed)
- @site_group.command(name="resources")
+ @site_group.command(name="resources", root_aliases=("resources", "resource"))
async def site_resources(self, ctx: Context) -> None:
"""Info about the site's Resources page."""
learning_url = f"{PAGES_URL}/resources"
@@ -56,7 +56,7 @@ class Site(Cog):
await ctx.send(embed=embed)
- @site_group.command(name="tools")
+ @site_group.command(name="tools", root_aliases=("tools",))
async def site_tools(self, ctx: Context) -> None:
"""Info about the site's Tools page."""
tools_url = f"{PAGES_URL}/resources/tools"
@@ -87,7 +87,7 @@ class Site(Cog):
await ctx.send(embed=embed)
- @site_group.command(name="faq")
+ @site_group.command(name="faq", root_aliases=("faq",))
async def site_faq(self, ctx: Context) -> None:
"""Info about the site's FAQ page."""
url = f"{PAGES_URL}/frequently-asked-questions"
@@ -104,11 +104,10 @@ class Site(Cog):
await ctx.send(embed=embed)
- @site_group.command(aliases=['r', 'rule'], name='rules')
- async def site_rules(self, ctx: Context, *rules: int) -> None:
+ @site_group.command(name="rules", aliases=("r", "rule"), root_aliases=("rules", "rule"))
+ 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
new file mode 100644
index 000000000..7b41352d4
--- /dev/null
+++ b/bot/exts/info/source.py
@@ -0,0 +1,131 @@
+import inspect
+from pathlib import Path
+from typing import Optional, Tuple, Union
+
+from discord import Embed, utils
+from discord.ext import commands
+
+from bot.bot import Bot
+from bot.constants import URLs
+
+SourceType = Union[commands.HelpCommand, commands.Command, commands.Cog, str, commands.ExtensionNotLoaded]
+
+
+class SourceConverter(commands.Converter):
+ """Convert an argument into a help command, tag, command, or cog."""
+
+ async def convert(self, ctx: commands.Context, argument: str) -> SourceType:
+ """Convert argument into source object."""
+ if argument.lower().startswith("help"):
+ return ctx.bot.help_command
+
+ cog = ctx.bot.get_cog(argument)
+ if cog:
+ return cog
+
+ cmd = ctx.bot.get_command(argument)
+ if cmd:
+ return cmd
+
+ tags_cog = ctx.bot.get_cog("Tags")
+ show_tag = True
+
+ if not tags_cog:
+ show_tag = False
+ elif argument.lower() in tags_cog._cache:
+ return argument.lower()
+
+ escaped_arg = utils.escape_markdown(argument)
+
+ raise commands.BadArgument(
+ f"Unable to convert '{escaped_arg}' to valid command{', tag,' if show_tag else ''} or Cog."
+ )
+
+
+class BotSource(commands.Cog):
+ """Displays information about the bot's source code."""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+
+ @commands.command(name="source", aliases=("src",))
+ async def source_command(self, ctx: commands.Context, *, source_item: SourceConverter = None) -> None:
+ """Display information and a GitHub link to the source code of a command, tag, or cog."""
+ if not source_item:
+ embed = Embed(title="Bot's GitHub Repository")
+ embed.add_field(name="Repository", value=f"[Go to GitHub]({URLs.github_bot_repo})")
+ embed.set_thumbnail(url="https://avatars1.githubusercontent.com/u/9919")
+ await ctx.send(embed=embed)
+ return
+
+ embed = await self.build_embed(source_item)
+ await ctx.send(embed=embed)
+
+ def get_source_link(self, source_item: SourceType) -> Tuple[str, str, Optional[int]]:
+ """
+ Build GitHub link of source item, return this link, file location and first line number.
+
+ Raise BadArgument if `source_item` is a dynamically-created object (e.g. via internal eval).
+ """
+ if isinstance(source_item, commands.Command):
+ 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"]
+ else:
+ src = type(source_item)
+ try:
+ filename = inspect.getsourcefile(src)
+ except TypeError:
+ raise commands.BadArgument("Cannot get source for a dynamically-created object.")
+
+ if not isinstance(source_item, str):
+ try:
+ lines, first_line_no = inspect.getsourcelines(src)
+ except OSError:
+ raise commands.BadArgument("Cannot get source for a dynamically-created object.")
+
+ lines_extension = f"#L{first_line_no}-L{first_line_no+len(lines)-1}"
+ else:
+ first_line_no = None
+ lines_extension = ""
+
+ # Handle tag file location differently than others to avoid errors in some cases
+ if not first_line_no:
+ file_location = Path(filename).relative_to("/bot/")
+ else:
+ file_location = Path(filename).relative_to(Path.cwd()).as_posix()
+
+ url = f"{URLs.github_bot_repo}/blob/master/{file_location}{lines_extension}"
+
+ return url, file_location, first_line_no or None
+
+ async def build_embed(self, source_object: SourceType) -> Optional[Embed]:
+ """Build embed based on source object."""
+ url, location, first_line = self.get_source_link(source_object)
+
+ if isinstance(source_object, commands.HelpCommand):
+ title = "Help Command"
+ description = source_object.__doc__.splitlines()[1]
+ elif isinstance(source_object, commands.Command):
+ description = source_object.short_doc
+ title = f"Command: {source_object.qualified_name}"
+ elif isinstance(source_object, str):
+ title = f"Tag: {source_object}"
+ description = ""
+ else:
+ title = f"Cog: {source_object.qualified_name}"
+ description = source_object.description.splitlines()[0]
+
+ embed = Embed(title=title, description=description)
+ embed.add_field(name="Source Code", value=f"[Go to GitHub]({url})")
+ line_text = f":{first_line}" if first_line else ""
+ embed.set_footer(text=f"{location}{line_text}")
+
+ return embed
+
+
+def setup(bot: Bot) -> None:
+ """Load the BotSource cog."""
+ bot.add_cog(BotSource(bot))
diff --git a/bot/cogs/stats.py b/bot/exts/info/stats.py
index d42f55466..4d8bb645e 100644
--- a/bot/cogs/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/cogs/tags.py b/bot/exts/info/tags.py
index 6f03a3475..ae95ac1ef 100644
--- a/bot/cogs/tags.py
+++ b/bot/exts/info/tags.py
@@ -47,6 +47,7 @@ class Tags(Cog):
"description": file.read_text(encoding="utf8"),
},
"restricted_to": "developers",
+ "location": f"/bot/{file}"
}
# Convert to a list to allow negative indexing.
@@ -159,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:
@@ -235,7 +236,7 @@ class Tags(Cog):
await wait_for_deletion(
await ctx.send(embed=Embed.from_dict(tag['embed'])),
[ctx.author.id],
- client=self.bot
+ self.bot
)
elif founds and len(tag_name) >= 3:
await wait_for_deletion(
@@ -246,7 +247,7 @@ class Tags(Cog):
)
),
[ctx.author.id],
- client=self.bot
+ self.bot
)
else:
diff --git a/bot/exts/moderation/__init__.py b/bot/exts/moderation/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/bot/exts/moderation/__init__.py
diff --git a/bot/cogs/defcon.py b/bot/exts/moderation/defcon.py
index 4c0ad5914..caa6fb917 100644
--- a/bot/cogs/defcon.py
+++ b/bot/exts/moderation/defcon.py
@@ -6,12 +6,12 @@ from datetime import datetime, timedelta
from enum import Enum
from discord import Colour, Embed, Member
-from discord.ext.commands import Cog, Context, group
+from discord.ext.commands import Cog, Context, group, has_any_role
from bot.bot import Bot
-from bot.cogs.moderation import ModLog
-from bot.constants import Channels, Colours, Emojis, Event, Icons, Roles
-from bot.decorators import with_role
+from bot.constants import Channels, Colours, Emojis, Event, Icons, MODERATION_ROLES, Roles
+from bot.exts.moderation.modlog import ModLog
+from bot.utils.messages import format_user
log = logging.getLogger(__name__)
@@ -107,7 +107,7 @@ class Defcon(Cog):
self.bot.stats.incr("defcon.leaves")
message = (
- f"{member} (`{member.id}`) was denied entry because their account is too new."
+ f"{format_user(member)} was denied entry because their account is too new."
)
if not message_sent:
@@ -119,7 +119,7 @@ class Defcon(Cog):
)
@group(name='defcon', aliases=('dc',), invoke_without_command=True)
- @with_role(Roles.admins, Roles.owners)
+ @has_any_role(*MODERATION_ROLES)
async def defcon_group(self, ctx: Context) -> None:
"""Check the DEFCON status or run a subcommand."""
await ctx.send_help(ctx.command)
@@ -162,8 +162,8 @@ class Defcon(Cog):
self.bot.stats.gauge("defcon.threshold", days)
- @defcon_group.command(name='enable', aliases=('on', 'e'))
- @with_role(Roles.admins, Roles.owners)
+ @defcon_group.command(name='enable', aliases=('on', 'e'), root_aliases=("defon",))
+ @has_any_role(*MODERATION_ROLES)
async def enable_command(self, ctx: Context) -> None:
"""
Enable DEFCON mode. Useful in a pinch, but be sure you know what you're doing!
@@ -175,8 +175,8 @@ class Defcon(Cog):
await self._defcon_action(ctx, days=0, action=Action.ENABLED)
await self.update_channel_topic()
- @defcon_group.command(name='disable', aliases=('off', 'd'))
- @with_role(Roles.admins, Roles.owners)
+ @defcon_group.command(name='disable', aliases=('off', 'd'), root_aliases=("defoff",))
+ @has_any_role(*MODERATION_ROLES)
async def disable_command(self, ctx: Context) -> None:
"""Disable DEFCON mode. Useful in a pinch, but be sure you know what you're doing!"""
self.enabled = False
@@ -184,7 +184,7 @@ class Defcon(Cog):
await self.update_channel_topic()
@defcon_group.command(name='status', aliases=('s',))
- @with_role(Roles.admins, Roles.owners)
+ @has_any_role(*MODERATION_ROLES)
async def status_command(self, ctx: Context) -> None:
"""Check the current status of DEFCON mode."""
embed = Embed(
@@ -196,7 +196,7 @@ class Defcon(Cog):
await ctx.send(embed=embed)
@defcon_group.command(name='days')
- @with_role(Roles.admins, Roles.owners)
+ @has_any_role(*MODERATION_ROLES)
async def days_command(self, ctx: Context, days: int) -> None:
"""Set how old an account must be to join the server, in days, with DEFCON mode enabled."""
self.days = timedelta(days=days)
diff --git a/bot/exts/moderation/dm_relay.py b/bot/exts/moderation/dm_relay.py
new file mode 100644
index 000000000..4d5142b55
--- /dev/null
+++ b/bot/exts/moderation/dm_relay.py
@@ -0,0 +1,128 @@
+import logging
+from typing import Optional
+
+import discord
+from async_rediscache import RedisCache
+from discord import Color
+from discord.ext import commands
+from discord.ext.commands import Cog
+
+from bot import constants
+from bot.bot import Bot
+from bot.converters import UserMentionOrID
+from bot.utils.checks import in_whitelist_check
+from bot.utils.messages import send_attachments
+from bot.utils.webhooks import send_webhook
+
+log = logging.getLogger(__name__)
+
+
+class DMRelay(Cog):
+ """Relay direct messages to and from the bot."""
+
+ # RedisCache[str, t.Union[discord.User.id, discord.Member.id]]
+ dm_cache = RedisCache()
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+ self.webhook_id = constants.Webhooks.dm_log
+ self.webhook = None
+ self.bot.loop.create_task(self.fetch_webhook())
+
+ @commands.command(aliases=("reply",))
+ async def send_dm(self, ctx: commands.Context, member: Optional[UserMentionOrID], *, message: str) -> None:
+ """
+ Allows you to send a DM to a user from the bot.
+
+ If `member` is not provided, it will send to the last user who DM'd the bot.
+
+ This feature should be used extremely sparingly. Use ModMail if you need to have a serious
+ conversation with a user. This is just for responding to extraordinary DMs, having a little
+ fun with users, and telling people they are DMing the wrong bot.
+
+ NOTE: This feature will be removed if it is overused.
+ """
+ if not member:
+ user_id = await self.dm_cache.get("last_user")
+ member = ctx.guild.get_member(user_id) if user_id else None
+
+ # If we still don't have a Member at this point, give up
+ if not member:
+ log.debug("This bot has never gotten a DM, or the RedisCache has been cleared.")
+ await ctx.message.add_reaction("❌")
+ return
+
+ try:
+ await member.send(message)
+ except discord.errors.Forbidden:
+ log.debug("User has disabled DMs.")
+ await ctx.message.add_reaction("❌")
+ else:
+ await ctx.message.add_reaction("✅")
+ self.bot.stats.incr("dm_relay.dm_sent")
+
+ async def fetch_webhook(self) -> None:
+ """Fetches the webhook object, so we can post to it."""
+ await self.bot.wait_until_guild_available()
+
+ try:
+ self.webhook = await self.bot.fetch_webhook(self.webhook_id)
+ except discord.HTTPException:
+ log.exception(f"Failed to fetch webhook with id `{self.webhook_id}`")
+
+ @Cog.listener()
+ async def on_message(self, message: discord.Message) -> None:
+ """Relays the message's content and attachments to the dm_log channel."""
+ # Only relay DMs from humans
+ if message.author.bot or message.guild or self.webhook is None:
+ return
+
+ if message.clean_content:
+ await send_webhook(
+ webhook=self.webhook,
+ content=message.clean_content,
+ username=f"{message.author.display_name} ({message.author.id})",
+ avatar_url=message.author.avatar_url
+ )
+ await self.dm_cache.set("last_user", message.author.id)
+ self.bot.stats.incr("dm_relay.dm_received")
+
+ # Handle any attachments
+ if message.attachments:
+ try:
+ 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**",
+ color=Color.red()
+ )
+ await send_webhook(
+ webhook=self.webhook,
+ embed=e,
+ username=f"{message.author.display_name} ({message.author.id})",
+ avatar_url=message.author.avatar_url
+ )
+ except discord.HTTPException:
+ log.exception("Failed to send an attachment to the webhook")
+
+ async def cog_check(self, ctx: commands.Context) -> bool:
+ """Only allow moderators to invoke the commands in this cog."""
+ checks = [
+ await commands.has_any_role(*constants.MODERATION_ROLES).predicate(ctx),
+ in_whitelist_check(
+ ctx,
+ channels=[constants.Channels.dm_log],
+ redirect=None,
+ fail_silently=True,
+ )
+ ]
+ return all(checks)
+
+
+def setup(bot: Bot) -> None:
+ """Load the DMRelay cog."""
+ bot.add_cog(DMRelay(bot))
diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py
new file mode 100644
index 000000000..0e479d33f
--- /dev/null
+++ b/bot/exts/moderation/incidents.py
@@ -0,0 +1,412 @@
+import asyncio
+import logging
+import typing as t
+from datetime import datetime
+from enum import Enum
+
+import discord
+from discord.ext.commands import Cog
+
+from bot.bot import Bot
+from bot.constants import Channels, Colours, Emojis, Guild, Webhooks
+from bot.utils.messages import sub_clyde
+
+log = logging.getLogger(__name__)
+
+# Amount of messages for `crawl_task` to process at most on start-up - limited to 50
+# as in practice, there should never be this many messages, and if there are,
+# something has likely gone very wrong
+CRAWL_LIMIT = 50
+
+# Seconds for `crawl_task` to sleep after adding reactions to a message
+CRAWL_SLEEP = 2
+
+
+class Signal(Enum):
+ """
+ Recognized incident status signals.
+
+ This binds emoji to actions. The bot will only react to emoji linked here.
+ All other signals are seen as invalid.
+ """
+
+ ACTIONED = Emojis.incident_actioned
+ NOT_ACTIONED = Emojis.incident_unactioned
+ INVESTIGATING = Emojis.incident_investigating
+
+
+# Reactions from non-mod roles will be removed
+ALLOWED_ROLES: t.Set[int] = set(Guild.moderation_roles)
+
+# Message must have all of these emoji to pass the `has_signals` check
+ALL_SIGNALS: t.Set[str] = {signal.value for signal in Signal}
+
+# An embed coupled with an optional file to be dispatched
+# If the file is not None, the embed attempts to show it in its body
+FileEmbed = t.Tuple[discord.Embed, t.Optional[discord.File]]
+
+
+async def download_file(attachment: discord.Attachment) -> t.Optional[discord.File]:
+ """
+ Download & return `attachment` file.
+
+ If the download fails, the reason is logged and None will be returned.
+ 404 and 403 errors are only logged at debug level.
+ """
+ log.debug(f"Attempting to download attachment: {attachment.filename}")
+ try:
+ return await attachment.to_file()
+ except (discord.NotFound, discord.Forbidden) as exc:
+ log.debug(f"Failed to download attachment: {exc}")
+ except Exception:
+ log.exception("Failed to download attachment")
+
+
+async def make_embed(incident: discord.Message, outcome: Signal, actioned_by: discord.Member) -> FileEmbed:
+ """
+ Create an embed representation of `incident` for the #incidents-archive channel.
+
+ The name & discriminator of `actioned_by` and `outcome` will be presented in the
+ embed footer. Additionally, the embed is coloured based on `outcome`.
+
+ The author of `incident` is not shown in the embed. It is assumed that this piece
+ of information will be relayed in other ways, e.g. webhook username.
+
+ As mentions in embeds do not ping, we do not need to use `incident.clean_content`.
+
+ If `incident` contains attachments, the first attachment will be downloaded and
+ returned alongside the embed. The embed attempts to display the attachment.
+ Should the download fail, we fallback on linking the `proxy_url`, which should
+ remain functional for some time after the original message is deleted.
+ """
+ log.trace(f"Creating embed for {incident.id=}")
+
+ if outcome is Signal.ACTIONED:
+ colour = Colours.soft_green
+ footer = f"Actioned by {actioned_by}"
+ else:
+ colour = Colours.soft_red
+ footer = f"Rejected by {actioned_by}"
+
+ embed = discord.Embed(
+ description=incident.content,
+ timestamp=datetime.utcnow(),
+ colour=colour,
+ )
+ embed.set_footer(text=footer, icon_url=actioned_by.avatar_url)
+
+ if incident.attachments:
+ attachment = incident.attachments[0] # User-sent messages can only contain one attachment
+ file = await download_file(attachment)
+
+ if file is not None:
+ embed.set_image(url=f"attachment://{attachment.filename}") # Embed displays the attached file
+ else:
+ embed.set_author(name="[Failed to relay attachment]", url=attachment.proxy_url) # Embed links the file
+ else:
+ file = None
+
+ return embed, file
+
+
+def is_incident(message: discord.Message) -> bool:
+ """True if `message` qualifies as an incident, False otherwise."""
+ conditions = (
+ message.channel.id == Channels.incidents, # Message sent in #incidents
+ not message.author.bot, # Not by a bot
+ not message.content.startswith("#"), # Doesn't start with a hash
+ not message.pinned, # And isn't header
+ )
+ return all(conditions)
+
+
+def own_reactions(message: discord.Message) -> t.Set[str]:
+ """Get the set of reactions placed on `message` by the bot itself."""
+ return {str(reaction.emoji) for reaction in message.reactions if reaction.me}
+
+
+def has_signals(message: discord.Message) -> bool:
+ """True if `message` already has all `Signal` reactions, False otherwise."""
+ return ALL_SIGNALS.issubset(own_reactions(message))
+
+
+async def add_signals(incident: discord.Message) -> None:
+ """
+ Add `Signal` member emoji to `incident` as reactions.
+
+ If the emoji has already been placed on `incident` by the bot, it will be skipped.
+ """
+ existing_reacts = own_reactions(incident)
+
+ for signal_emoji in Signal:
+ if signal_emoji.value in existing_reacts: # This would not raise, but it is a superfluous API call
+ log.trace(f"Skipping emoji as it's already been placed: {signal_emoji}")
+ else:
+ log.trace(f"Adding reaction: {signal_emoji}")
+ await incident.add_reaction(signal_emoji.value)
+
+
+class Incidents(Cog):
+ """
+ Automation for the #incidents channel.
+
+ This cog does not provide a command API, it only reacts to the following events.
+
+ On start-up:
+ * Crawl #incidents and add missing `Signal` emoji where appropriate
+ * This is to retro-actively add the available options for messages which
+ were sent while the bot wasn't listening
+ * Pinned messages and message starting with # do not qualify as incidents
+ * See: `crawl_incidents`
+
+ On message:
+ * Add `Signal` member emoji if message qualifies as an incident
+ * Ignore messages starting with #
+ * Use this if verbal communication is necessary
+ * Each such message must be deleted manually once appropriate
+ * See: `on_message`
+
+ On reaction:
+ * Remove reaction if not permitted
+ * User does not have any of the roles in `ALLOWED_ROLES`
+ * Used emoji is not a `Signal` member
+ * If `Signal.ACTIONED` or `Signal.NOT_ACTIONED` were chosen, attempt to
+ relay the incident message to #incidents-archive
+ * If relay successful, delete original message
+ * See: `on_raw_reaction_add`
+
+ Please refer to function docstrings for implementation details.
+ """
+
+ def __init__(self, bot: Bot) -> None:
+ """Prepare `event_lock` and schedule `crawl_task` on start-up."""
+ self.bot = bot
+
+ self.event_lock = asyncio.Lock()
+ self.crawl_task = self.bot.loop.create_task(self.crawl_incidents())
+
+ async def crawl_incidents(self) -> None:
+ """
+ Crawl #incidents and add missing emoji where necessary.
+
+ This is to catch-up should an incident be reported while the bot wasn't listening.
+ After adding each reaction, we take a short break to avoid drowning in ratelimits.
+
+ Once this task is scheduled, listeners that change messages should await it.
+ The crawl assumes that the channel history doesn't change as we go over it.
+
+ Behaviour is configured by: `CRAWL_LIMIT`, `CRAWL_SLEEP`.
+ """
+ await self.bot.wait_until_guild_available()
+ incidents: discord.TextChannel = self.bot.get_channel(Channels.incidents)
+
+ log.debug(f"Crawling messages in #incidents: {CRAWL_LIMIT=}, {CRAWL_SLEEP=}")
+ async for message in incidents.history(limit=CRAWL_LIMIT):
+
+ if not is_incident(message):
+ log.trace(f"Skipping message {message.id}: not an incident")
+ continue
+
+ if has_signals(message):
+ log.trace(f"Skipping message {message.id}: already has all signals")
+ continue
+
+ await add_signals(message)
+ await asyncio.sleep(CRAWL_SLEEP)
+
+ log.debug("Crawl task finished!")
+
+ async def archive(self, incident: discord.Message, outcome: Signal, actioned_by: discord.Member) -> bool:
+ """
+ Relay an embed representation of `incident` to the #incidents-archive channel.
+
+ The following pieces of information are relayed:
+ * Incident message content (as embed description)
+ * Incident attachment (if image, shown in archive embed)
+ * Incident author name (as webhook author)
+ * Incident author avatar (as webhook avatar)
+ * Resolution signal `outcome` (as embed colour & footer)
+ * Moderator `actioned_by` (name & discriminator shown in footer)
+
+ If `incident` contains an attachment, we try to add it to the archive embed. There is
+ no handing of extensions / file types - we simply dispatch the attachment file with the
+ webhook, and try to display it in the embed. Testing indicates that if the attachment
+ cannot be displayed (e.g. a text file), it's invisible in the embed, with no error.
+
+ Return True if the relay finishes successfully. If anything goes wrong, meaning
+ not all information was relayed, return False. This signals that the original
+ message is not safe to be deleted, as we will lose some information.
+ """
+ log.info(f"Archiving incident: {incident.id} (outcome: {outcome}, actioned by: {actioned_by})")
+ embed, attachment_file = await make_embed(incident, outcome, actioned_by)
+
+ try:
+ webhook = await self.bot.fetch_webhook(Webhooks.incidents_archive)
+ await webhook.send(
+ embed=embed,
+ username=sub_clyde(incident.author.name),
+ avatar_url=incident.author.avatar_url,
+ file=attachment_file,
+ )
+ except Exception:
+ log.exception(f"Failed to archive incident {incident.id} to #incidents-archive")
+ return False
+ else:
+ log.trace("Message archived successfully!")
+ return True
+
+ def make_confirmation_task(self, incident: discord.Message, timeout: int = 5) -> asyncio.Task:
+ """
+ Create a task to wait `timeout` seconds for `incident` to be deleted.
+
+ If `timeout` passes, this will raise `asyncio.TimeoutError`, signaling that we haven't
+ been able to confirm that the message was deleted.
+ """
+ log.trace(f"Confirmation task will wait {timeout=} seconds for {incident.id=} to be deleted")
+
+ def check(payload: discord.RawReactionActionEvent) -> bool:
+ return payload.message_id == incident.id
+
+ coroutine = self.bot.wait_for(event="raw_message_delete", check=check, timeout=timeout)
+ return self.bot.loop.create_task(coroutine)
+
+ async def process_event(self, reaction: str, incident: discord.Message, member: discord.Member) -> None:
+ """
+ Process a `reaction_add` event in #incidents.
+
+ First, we check that the reaction is a recognized `Signal` member, and that it was sent by
+ a permitted user (at least one role in `ALLOWED_ROLES`). If not, the reaction is removed.
+
+ If the reaction was either `Signal.ACTIONED` or `Signal.NOT_ACTIONED`, we attempt to relay
+ the report to #incidents-archive. If successful, the original message is deleted.
+
+ We do not release `event_lock` until we receive the corresponding `message_delete` event.
+ This ensures that if there is a racing event awaiting the lock, it will fail to find the
+ message, and will abort. There is a `timeout` to ensure that this doesn't hold the lock
+ forever should something go wrong.
+ """
+ members_roles: t.Set[int] = {role.id for role in member.roles}
+ if not members_roles & ALLOWED_ROLES: # Intersection is truthy on at least 1 common element
+ log.debug(f"Removing invalid reaction: user {member} is not permitted to send signals")
+ await incident.remove_reaction(reaction, member)
+ return
+
+ try:
+ signal = Signal(reaction)
+ except ValueError:
+ log.debug(f"Removing invalid reaction: emoji {reaction} is not a valid signal")
+ await incident.remove_reaction(reaction, member)
+ return
+
+ log.trace(f"Received signal: {signal}")
+
+ if signal not in (Signal.ACTIONED, Signal.NOT_ACTIONED):
+ log.debug("Reaction was valid, but no action is currently defined for it")
+ return
+
+ relay_successful = await self.archive(incident, signal, actioned_by=member)
+ if not relay_successful:
+ log.trace("Original message will not be deleted as we failed to relay it to the archive")
+ return
+
+ timeout = 5 # Seconds
+ confirmation_task = self.make_confirmation_task(incident, timeout)
+
+ log.trace("Deleting original message")
+ await incident.delete()
+
+ log.trace(f"Awaiting deletion confirmation: {timeout=} seconds")
+ try:
+ await confirmation_task
+ except asyncio.TimeoutError:
+ log.info(f"Did not receive incident deletion confirmation within {timeout} seconds!")
+ else:
+ log.trace("Deletion was confirmed")
+
+ async def resolve_message(self, message_id: int) -> t.Optional[discord.Message]:
+ """
+ Get `discord.Message` for `message_id` from cache, or API.
+
+ We first look into the local cache to see if the message is present.
+
+ If not, we try to fetch the message from the API. This is necessary for messages
+ which were sent before the bot's current session.
+
+ In an edge-case, it is also possible that the message was already deleted, and
+ the API will respond with a 404. In such a case, None will be returned.
+ This signals that the event for `message_id` should be ignored.
+ """
+ await self.bot.wait_until_guild_available() # First make sure that the cache is ready
+ log.trace(f"Resolving message for: {message_id=}")
+ message: t.Optional[discord.Message] = self.bot._connection._get_message(message_id)
+
+ if message is not None:
+ log.trace("Message was found in cache")
+ return message
+
+ log.trace("Message not found, attempting to fetch")
+ try:
+ message = await self.bot.get_channel(Channels.incidents).fetch_message(message_id)
+ except discord.NotFound:
+ log.trace("Message doesn't exist, it was likely already relayed")
+ except Exception:
+ log.exception(f"Failed to fetch message {message_id}!")
+ else:
+ log.trace("Message fetched successfully!")
+ return message
+
+ @Cog.listener()
+ async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent) -> None:
+ """
+ Pre-process `payload` and pass it to `process_event` if appropriate.
+
+ We abort instantly if `payload` doesn't relate to a message sent in #incidents,
+ or if it was sent by a bot.
+
+ If `payload` relates to a message in #incidents, we first ensure that `crawl_task` has
+ finished, to make sure we don't mutate channel state as we're crawling it.
+
+ Next, we acquire `event_lock` - to prevent racing, events are processed one at a time.
+
+ Once we have the lock, the `discord.Message` object for this event must be resolved.
+ If the lock was previously held by an event which successfully relayed the incident,
+ this will fail and we abort the current event.
+
+ Finally, with both the lock and the `discord.Message` instance in our hands, we delegate
+ to `process_event` to handle the event.
+
+ The justification for using a raw listener is the need to receive events for messages
+ which were not cached in the current session. As a result, a certain amount of
+ complexity is introduced, but at the moment this doesn't appear to be avoidable.
+ """
+ if payload.channel_id != Channels.incidents or payload.member.bot:
+ return
+
+ log.trace(f"Received reaction add event in #incidents, waiting for crawler: {self.crawl_task.done()=}")
+ await self.crawl_task
+
+ log.trace(f"Acquiring event lock: {self.event_lock.locked()=}")
+ async with self.event_lock:
+ message = await self.resolve_message(payload.message_id)
+
+ if message is None:
+ log.debug("Listener will abort as related message does not exist!")
+ return
+
+ if not is_incident(message):
+ log.debug("Ignoring event for a non-incident message")
+ return
+
+ await self.process_event(str(payload.emoji), message, payload.member)
+ log.trace("Releasing event lock")
+
+ @Cog.listener()
+ async def on_message(self, message: discord.Message) -> None:
+ """Pass `message` to `add_signals` if and only if it satisfies `is_incident`."""
+ if is_incident(message):
+ await add_signals(message)
+
+
+def setup(bot: Bot) -> None:
+ """Load the Incidents cog."""
+ bot.add_cog(Incidents(bot))
diff --git a/bot/exts/moderation/infraction/__init__.py b/bot/exts/moderation/infraction/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/bot/exts/moderation/infraction/__init__.py
diff --git a/bot/cogs/moderation/scheduler.py b/bot/exts/moderation/infraction/_scheduler.py
index d75a72ddb..bebade0ae 100644
--- a/bot/cogs/moderation/scheduler.py
+++ b/bot/exts/moderation/infraction/_scheduler.py
@@ -1,4 +1,3 @@
-import asyncio
import logging
import textwrap
import typing as t
@@ -13,25 +12,29 @@ 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, STAFF_CHANNELS
-from bot.utils import time
-from bot.utils.scheduling import Scheduler
-from . import utils
-from .modlog import ModLog
-from .utils import UserSnowflake
+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__)
-class InfractionScheduler(Scheduler):
+class InfractionScheduler:
"""Handles the application, pardoning, and expiration of infractions."""
def __init__(self, bot: Bot, supported_infractions: t.Container[str]):
- super().__init__()
-
self.bot = bot
+ self.scheduler = scheduling.Scheduler(self.__class__.__name__)
+
self.bot.loop.create_task(self.reschedule_infractions(supported_infractions))
+ def cog_unload(self) -> None:
+ """Cancel scheduled tasks."""
+ self.scheduler.cancel_all()
+
@property
def mod_log(self) -> ModLog:
"""Get the currently loaded ModLog cog instance."""
@@ -49,11 +52,11 @@ class InfractionScheduler(Scheduler):
)
for infraction in infractions:
if infraction["expires_at"] is not None and infraction["type"] in supported_infractions:
- self.schedule_task(infraction["id"], infraction)
+ self.schedule_expiration(infraction)
async def reapply_infraction(
self,
- infraction: utils.Infraction,
+ infraction: _utils.Infraction,
apply_coro: t.Optional[t.Awaitable]
) -> None:
"""Reapply an infraction if it's still active or deactivate it if less than 60 sec left."""
@@ -77,13 +80,13 @@ class InfractionScheduler(Scheduler):
async def apply_infraction(
self,
ctx: Context,
- infraction: utils.Infraction,
+ infraction: _utils.Infraction,
user: UserSnowflake,
action_coro: t.Optional[t.Awaitable] = None
) -> None:
"""Apply an infraction to the user, log the infraction, and optionally notify the user."""
infr_type = infraction["type"]
- icon = utils.INFRACTION_ICONS[infr_type][0]
+ icon = _utils.INFRACTION_ICONS[infr_type][0]
reason = infraction["reason"]
expiry = time.format_infraction_with_duration(infraction["expires_at"])
id_ = infraction['id']
@@ -123,7 +126,7 @@ class InfractionScheduler(Scheduler):
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"
@@ -134,11 +137,7 @@ class InfractionScheduler(Scheduler):
)
if reason:
end_msg = f" (reason: {textwrap.shorten(reason, width=1500, placeholder='...')})"
- elif ctx.channel.id not in STAFF_CHANNELS:
- log.trace(
- f"Infraction #{id_} context is not in a staff 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(
@@ -146,7 +145,7 @@ class InfractionScheduler(Scheduler):
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:
@@ -155,15 +154,16 @@ class InfractionScheduler(Scheduler):
await action_coro
if expiry:
# Schedule the expiration of the infraction.
- self.schedule_task(infraction["id"], infraction)
+ self.schedule_expiration(infraction)
except discord.HTTPException as e:
# Accordingly display that applying the infraction failed.
+ # Don't use ctx.message.author; antispam only patches ctx.author.
confirm_msg = ":x: failed to apply"
expiry_msg = ""
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:
@@ -180,22 +180,23 @@ class InfractionScheduler(Scheduler):
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.")
await ctx.send(f"{dm_result}{confirm_msg}{infr_message}.")
# Send a log message to the mod log.
+ # Don't use ctx.message.author for the actor; antispam only patches ctx.author.
log.trace(f"Sending apply mod log for infraction #{id_}.")
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: {user.mention} (`{user.id}`)
- Actor: {ctx.message.author}{dm_log_text}{expiry_log_text}
+ Member: {messages.format_user(user)}
+ Actor: {ctx.author.mention}{dm_log_text}{expiry_log_text}
Reason: {reason}
"""),
content=log_content,
@@ -238,48 +239,12 @@ class InfractionScheduler(Scheduler):
# Deactivate the infraction and cancel its scheduled expiration task.
log_text = await self.deactivate_infraction(response[0], send_log=False)
- log_text["Member"] = f"{user.mention}(`{user.id}`)"
- log_text["Actor"] = str(ctx.message.author)
+ log_text["Member"] = messages.format_user(user)
+ log_text["Actor"] = ctx.author.mention
log_content = None
id_ = response[0]['id']
footer = f"ID: {id_}"
- # If multiple active infractions were found, mark them as inactive in the database
- # and cancel their expiration tasks.
- if len(response) > 1:
- log.info(
- f"Found more than one active {infr_type} infraction for user {user.id}; "
- "deactivating the extra active infractions too."
- )
-
- footer = f"Infraction IDs: {', '.join(str(infr['id']) for infr in response)}"
-
- log_note = f"Found multiple **active** {infr_type} infractions in the database."
- if "Note" in log_text:
- log_text["Note"] = f" {log_note}"
- else:
- log_text["Note"] = log_note
-
- # deactivate_infraction() is not called again because:
- # 1. Discord cannot store multiple active bans or assign multiples of the same role
- # 2. It would send a pardon DM for each active infraction, which is redundant
- for infraction in response[1:]:
- id_ = infraction['id']
- try:
- # Mark infraction as inactive in the database.
- await self.bot.api_client.patch(
- f"bot/infractions/{id_}",
- json={"active": False}
- )
- except ResponseCodeError:
- log.exception(f"Failed to deactivate infraction #{id_} ({infr_type})")
- # This is simpler and cleaner than trying to concatenate all the errors.
- log_text["Failure"] = "See bot's logs for details."
-
- # Cancel pending expiration task.
- if infraction["expires_at"] is not None:
- self.cancel_task(infraction["id"])
-
# Accordingly display whether the user was successfully notified via DM.
dm_emoji = ""
if log_text.get("DM") == "Sent":
@@ -304,7 +269,7 @@ class InfractionScheduler(Scheduler):
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', '')}"
)
@@ -313,9 +278,9 @@ class InfractionScheduler(Scheduler):
# Send a log message to the mod log.
await self.mod_log.send_log_message(
- icon_url=utils.INFRACTION_ICONS[infr_type][1],
+ 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,
@@ -324,7 +289,7 @@ class InfractionScheduler(Scheduler):
async def deactivate_infraction(
self,
- infraction: utils.Infraction,
+ infraction: _utils.Infraction,
send_log: bool = True
) -> t.Dict[str, str]:
"""
@@ -353,7 +318,7 @@ class InfractionScheduler(Scheduler):
log_content = None
log_text = {
"Member": f"<@{user_id}>",
- "Actor": str(self.bot.get_user(actor) or actor),
+ "Actor": f"<@{actor}>",
"Reason": infraction["reason"],
"Created": created,
}
@@ -415,7 +380,7 @@ class InfractionScheduler(Scheduler):
# Cancel the expiration task.
if infraction["expires_at"] is not None:
- self.cancel_task(infraction["id"])
+ self.scheduler.cancel(infraction["id"])
# Send a log message to the mod log.
if send_log:
@@ -429,7 +394,7 @@ class InfractionScheduler(Scheduler):
log.trace(f"Sending deactivation mod log for infraction #{id_}.")
await self.mod_log.send_log_message(
- icon_url=utils.INFRACTION_ICONS[type_][1],
+ icon_url=_utils.INFRACTION_ICONS[type_][1],
colour=Colours.soft_green,
title=f"Infraction {log_title}: {type_}",
thumbnail=avatar,
@@ -441,7 +406,7 @@ class InfractionScheduler(Scheduler):
return log_text
@abstractmethod
- async def _pardon_action(self, infraction: utils.Infraction) -> t.Optional[t.Dict[str, str]]:
+ 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.
@@ -449,7 +414,7 @@ class InfractionScheduler(Scheduler):
"""
raise NotImplementedError
- async def _scheduled_task(self, infraction: utils.Infraction) -> None:
+ def schedule_expiration(self, infraction: _utils.Infraction) -> None:
"""
Marks an infraction expired after the delay from time of scheduling to time of expiration.
@@ -457,8 +422,4 @@ class InfractionScheduler(Scheduler):
expiration task is cancelled.
"""
expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None)
- await time.wait_until(expiry)
-
- # Because deactivate_infraction() explicitly cancels this scheduled task, it is shielded
- # to avoid prematurely cancelling itself.
- await asyncio.shield(self.deactivate_infraction(infraction))
+ self.scheduler.schedule_at(expiry, infraction["id"], self.deactivate_infraction(infraction))
diff --git a/bot/cogs/moderation/utils.py b/bot/exts/moderation/infraction/_utils.py
index fb55287b6..d0dc3f0a1 100644
--- a/bot/cogs/moderation/utils.py
+++ b/bot/exts/moderation/infraction/_utils.py
@@ -1,5 +1,4 @@
import logging
-import textwrap
import typing as t
from datetime import datetime
@@ -19,15 +18,28 @@ 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]
UserSnowflake = t.Union[UserObject, discord.Object]
Infraction = t.Dict[str, t.Union[str, int, bool]]
+APPEAL_EMAIL = "[email protected]"
+
+INFRACTION_TITLE = f"Please review our rules over at {RULES_URL}"
+INFRACTION_APPEAL_FOOTER = f"To appeal this infraction, send an e-mail to {APPEAL_EMAIL}"
+INFRACTION_AUTHOR_NAME = "Infraction information"
+
+INFRACTION_DESCRIPTION_TEMPLATE = (
+ "**Type:** {type}\n"
+ "**Expires:** {expires}\n"
+ "**Reason:** {reason}\n"
+)
+
async def post_user(ctx: Context, user: UserSnowflake) -> t.Optional[dict]:
"""
@@ -70,7 +82,7 @@ async def post_infraction(
log.trace(f"Posting {infr_type} infraction for {user} to the API.")
payload = {
- "actor": ctx.message.author.id,
+ "actor": ctx.author.id, # Don't use ctx.message.author; antispam only patches ctx.author.
"hidden": hidden,
"reason": reason,
"type": infr_type,
@@ -142,25 +154,27 @@ async def notify_infraction(
"""DM a user about their new infraction and return True if the DM is successful."""
log.trace(f"Sending {user} a DM about their {infr_type} infraction.")
- text = textwrap.dedent(f"""
- **Type:** {infr_type.capitalize()}
- **Expires:** {expires_at or "N/A"}
- **Reason:** {reason or "No reason provided."}
- """)
+ text = INFRACTION_DESCRIPTION_TEMPLATE.format(
+ type=infr_type.title(),
+ expires=expires_at or "N/A",
+ reason=reason or "No reason provided."
+ )
+
+ # For case when other fields than reason is too long and this reach limit, then force-shorten string
+ if len(text) > 2048:
+ text = f"{text[:2045]}..."
embed = discord.Embed(
- description=textwrap.shorten(text, width=2048, placeholder="..."),
+ description=text,
colour=Colours.soft_red
)
- embed.set_author(name="Infraction information", icon_url=icon_url, url=RULES_URL)
- embed.title = f"Please review our rules over at {RULES_URL}"
+ embed.set_author(name=INFRACTION_AUTHOR_NAME, icon_url=icon_url, url=RULES_URL)
+ embed.title = INFRACTION_TITLE
embed.url = RULES_URL
if infr_type in APPEALABLE_INFRACTIONS:
- embed.set_footer(
- text="To appeal this infraction, send an e-mail to [email protected]"
- )
+ embed.set_footer(text=INFRACTION_APPEAL_FOOTER)
return await send_private_embed(user, embed)
diff --git a/bot/cogs/moderation/infractions.py b/bot/exts/moderation/infraction/infractions.py
index 3b28526b2..746d4e154 100644
--- a/bot/cogs/moderation/infractions.py
+++ b/bot/exts/moderation/infraction/infractions.py
@@ -12,10 +12,10 @@ from bot.bot import Bot
from bot.constants import Event
from bot.converters import Expiry, FetchedMember
from bot.decorators import respect_role_hierarchy
-from bot.utils.checks import with_role_check
-from . import utils
-from .scheduler import InfractionScheduler
-from .utils import UserSnowflake
+from bot.exts.moderation.infraction import _utils
+from bot.exts.moderation.infraction._scheduler import InfractionScheduler
+from bot.exts.moderation.infraction._utils import UserSnowflake
+from bot.utils.messages import format_user
log = logging.getLogger(__name__)
@@ -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:
@@ -55,7 +56,7 @@ class Infractions(InfractionScheduler, commands.Cog):
@command()
async def warn(self, ctx: Context, user: Member, *, reason: t.Optional[str] = None) -> None:
"""Warn a user for the given reason."""
- infraction = await utils.post_infraction(ctx, user, "warning", reason, active=False)
+ infraction = await _utils.post_infraction(ctx, user, "warning", reason, active=False)
if infraction is None:
return
@@ -64,13 +65,35 @@ class Infractions(InfractionScheduler, commands.Cog):
@command()
async def kick(self, ctx: Context, user: Member, *, reason: t.Optional[str] = None) -> None:
"""Kick a user for the given reason."""
- await self.apply_kick(ctx, user, reason, active=False)
+ await self.apply_kick(ctx, user, reason)
@command()
async def ban(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None:
"""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,13 +142,39 @@ 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
@command(hidden=True)
async def note(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None:
"""Create a private note for a user with the given reason without notifying the user."""
- infraction = await utils.post_infraction(ctx, user, "note", reason, hidden=True, active=False)
+ infraction = await _utils.post_infraction(ctx, user, "note", reason, hidden=True, active=False)
if infraction is None:
return
@@ -134,7 +183,7 @@ class Infractions(InfractionScheduler, commands.Cog):
@command(hidden=True, aliases=['shadowkick', 'skick'])
async def shadow_kick(self, ctx: Context, user: Member, *, reason: t.Optional[str] = None) -> None:
"""Kick a user for the given reason without notifying the user."""
- await self.apply_kick(ctx, user, reason, hidden=True, active=False)
+ await self.apply_kick(ctx, user, reason, hidden=True)
@command(hidden=True, aliases=['shadowban', 'sban'])
async def shadow_ban(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None:
@@ -208,15 +257,20 @@ 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
async def apply_mute(self, ctx: Context, user: Member, reason: t.Optional[str], **kwargs) -> None:
"""Apply a mute infraction with kwargs passed to `post_infraction`."""
- if await utils.get_active_infraction(ctx, user, "mute"):
+ if await _utils.get_active_infraction(ctx, user, "mute"):
return
- infraction = await utils.post_infraction(ctx, user, "mute", reason, active=True, **kwargs)
+ infraction = await _utils.post_infraction(ctx, user, "mute", reason, active=True, **kwargs)
if infraction is None:
return
@@ -230,10 +284,10 @@ 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)
+ infraction = await _utils.post_infraction(ctx, user, "kick", reason, active=False, **kwargs)
if infraction is None:
return
@@ -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`.
@@ -254,7 +315,7 @@ class Infractions(InfractionScheduler, commands.Cog):
"""
# In the case of a permanent ban, we don't need get_active_infractions to tell us if one is active
is_temporary = kwargs.get("expires_at") is not None
- active_infraction = await utils.get_active_infraction(ctx, user, "ban", is_temporary)
+ active_infraction = await _utils.get_active_infraction(ctx, user, "ban", is_temporary)
if active_infraction:
if is_temporary:
@@ -269,7 +330,7 @@ class Infractions(InfractionScheduler, commands.Cog):
log.trace("Old tempban is being replaced by new permaban.")
await self.pardon_infraction(ctx, "ban", user, is_temporary)
- infraction = await utils.post_infraction(ctx, user, "ban", reason, active=True, **kwargs)
+ infraction = await _utils.post_infraction(ctx, user, "ban", reason, active=True, **kwargs)
if infraction is None:
return
@@ -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
@@ -309,14 +390,14 @@ class Infractions(InfractionScheduler, commands.Cog):
await user.remove_roles(self._muted_role, reason=reason)
# DM the user about the expiration.
- notified = await utils.notify_pardon(
+ notified = await _utils.notify_pardon(
user=user,
title="You have been unmuted",
content="You may now send messages in the server.",
- icon_url=utils.INFRACTION_ICONS["mute"][1]
+ icon_url=_utils.INFRACTION_ICONS["mute"][1]
)
- log_text["Member"] = f"{user.mention}(`{user.id}`)"
+ log_text["Member"] = format_user(user)
log_text["DM"] = "Sent" if notified else "**Failed**"
else:
log.info(f"Failed to unmute user {user_id}: user not found")
@@ -339,7 +420,28 @@ class Infractions(InfractionScheduler, commands.Cog):
return log_text
- async def _pardon_action(self, infraction: utils.Infraction) -> t.Optional[t.Dict[str, str]]:
+ 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,13 +455,15 @@ 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
# This cannot be static (must have a __func__ attribute).
- def cog_check(self, ctx: Context) -> bool:
+ async def cog_check(self, ctx: Context) -> bool:
"""Only allow moderators to invoke the commands in this cog."""
- return with_role_check(ctx, *constants.MODERATION_ROLES)
+ return await commands.has_any_role(*constants.MODERATION_ROLES).predicate(ctx)
# This cannot be static (must have a __func__ attribute).
async def cog_command_error(self, ctx: Context, error: Exception) -> None:
@@ -368,3 +472,8 @@ class Infractions(InfractionScheduler, commands.Cog):
if discord.User in error.converters or discord.Member in error.converters:
await ctx.send(str(error.errors[0]))
error.handled = True
+
+
+def setup(bot: Bot) -> None:
+ """Load the Infractions cog."""
+ bot.add_cog(Infractions(bot))
diff --git a/bot/cogs/moderation/management.py b/bot/exts/moderation/infraction/management.py
index 617d957ed..394f63da3 100644
--- a/bot/cogs/moderation/management.py
+++ b/bot/exts/moderation/infraction/management.py
@@ -6,16 +6,16 @@ from datetime import datetime
import discord
from discord.ext import commands
from discord.ext.commands import Context
+from discord.utils import escape_markdown
from bot import constants
from bot.bot import Bot
-from bot.converters import Expiry, InfractionSearchQuery, allowed_strings, proxy_user
+from bot.converters import Expiry, Snowflake, UserMention, allowed_strings, proxy_user
+from bot.exts.moderation.infraction.infractions import Infractions
+from bot.exts.moderation.modlog import ModLog
from bot.pagination import LinePaginator
-from bot.utils import time
-from bot.utils.checks import in_whitelist_check, with_role_check
-from . import utils
-from .infractions import Infractions
-from .modlog import ModLog
+from bot.utils import messages, time
+from bot.utils.channel import is_mod_channel
log = logging.getLogger(__name__)
@@ -135,11 +135,11 @@ class ModManagement(commands.Cog):
if 'expires_at' in request_data:
# A scheduled task should only exist if the old infraction wasn't permanent
if old_infraction['expires_at']:
- self.infractions_cog.cancel_task(new_infraction['id'])
+ self.infractions_cog.scheduler.cancel(new_infraction['id'])
# If the infraction was not marked as permanent, schedule a new expiration task
if request_data['expires_at']:
- self.infractions_cog.schedule_task(new_infraction['id'], new_infraction)
+ self.infractions_cog.schedule_expiration(new_infraction)
log_text += f"""
Previous expiry: {old_infraction['expires_at'] or "Permanent"}
@@ -154,16 +154,12 @@ class ModManagement(commands.Cog):
user = ctx.guild.get_member(user_id)
if user:
- user_text = f"{user.mention} (`{user.id}`)"
+ user_text = messages.format_user(user)
thumbnail = user.avatar_url_as(static_format="png")
else:
- user_text = f"`{user_id}`"
+ user_text = f"<@{user_id}>"
thumbnail = None
- # The infraction's actor
- actor_id = new_infraction['actor']
- actor = ctx.guild.get_member(actor_id) or f"`{actor_id}`"
-
await self.mod_log.send_log_message(
icon_url=constants.Icons.pencil,
colour=discord.Colour.blurple(),
@@ -171,8 +167,8 @@ class ModManagement(commands.Cog):
thumbnail=thumbnail,
text=textwrap.dedent(f"""
Member: {user_text}
- Actor: {actor}
- Edited by: {ctx.message.author}{log_text}
+ Actor: <@{new_infraction['actor']}>
+ Edited by: {ctx.message.author.mention}{log_text}
""")
)
@@ -180,20 +176,27 @@ class ModManagement(commands.Cog):
# region: Search infractions
@infraction_group.group(name="search", invoke_without_command=True)
- async def infraction_search_group(self, ctx: Context, query: InfractionSearchQuery) -> None:
+ async def infraction_search_group(self, ctx: Context, query: t.Union[UserMention, Snowflake, str]) -> None:
"""Searches for infractions in the database."""
- if isinstance(query, discord.User):
- await ctx.invoke(self.search_user, query)
+ if isinstance(query, int):
+ 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:
"""Search for infractions by member."""
infraction_list = await self.bot.api_client.get(
- 'bot/infractions',
+ 'bot/infractions/expanded',
params={'user__id': str(user.id)}
)
+
+ user = self.bot.get_user(user.id)
+ if not user and infraction_list:
+ # Use the user data retrieved from the DB for the username.
+ user = infraction_list[0]["user"]
+ user = escape_markdown(user["name"]) + f"#{user['discriminator']:04}"
+
embed = discord.Embed(
title=f"Infractions for {user} ({len(infraction_list)} total)",
colour=discord.Colour.orange()
@@ -204,7 +207,7 @@ class ModManagement(commands.Cog):
async def search_reason(self, ctx: Context, reason: str) -> None:
"""Search for infractions by their reason. Use Re2 for matching."""
infraction_list = await self.bot.api_client.get(
- 'bot/infractions',
+ 'bot/infractions/expanded',
params={'search': reason}
)
embed = discord.Embed(
@@ -220,7 +223,7 @@ class ModManagement(commands.Cog):
self,
ctx: Context,
embed: discord.Embed,
- infractions: t.Iterable[utils.Infraction]
+ infractions: t.Iterable[t.Dict[str, t.Any]]
) -> None:
"""Send a paginated embed of infractions for the specified user."""
if not infractions:
@@ -241,37 +244,43 @@ class ModManagement(commands.Cog):
max_size=1000
)
- def infraction_to_string(self, infraction: utils.Infraction) -> str:
+ def infraction_to_string(self, infraction: t.Dict[str, t.Any]) -> str:
"""Convert the infraction object to a string representation."""
- actor_id = infraction["actor"]
- guild = self.bot.get_guild(constants.Guild.id)
- actor = guild.get_member(actor_id)
active = infraction["active"]
- user_id = infraction["user"]
- hidden = infraction["hidden"]
+ user = infraction["user"]
+ expires_at = infraction["expires_at"]
created = time.format_infraction(infraction["inserted_at"])
+ # Format the user string.
+ if user_obj := self.bot.get_user(user["id"]):
+ # The user is in the cache.
+ user_str = messages.format_user(user_obj)
+ else:
+ # Use the user data retrieved from the DB.
+ name = escape_markdown(user['name'])
+ user_str = f"<@{user['id']}> ({name}#{user['discriminator']:04})"
+
if active:
- remaining = time.until_expiration(infraction["expires_at"]) or "Expired"
+ remaining = time.until_expiration(expires_at) or "Expired"
else:
remaining = "Inactive"
- if infraction["expires_at"] is None:
+ if expires_at is None:
expires = "*Permanent*"
else:
date_from = datetime.strptime(created, time.INFRACTION_FORMAT)
- expires = time.format_infraction_with_duration(infraction["expires_at"], date_from)
+ expires = time.format_infraction_with_duration(expires_at, date_from)
lines = textwrap.dedent(f"""
{"**===============**" if active else "==============="}
Status: {"__**Active**__" if active else "Inactive"}
- User: {self.bot.get_user(user_id)} (`{user_id}`)
+ User: {user_str}
Type: **{infraction["type"]}**
- Shadow: {hidden}
+ Shadow: {infraction["hidden"]}
Created: {created}
Expires: {expires}
Remaining: {remaining}
- Actor: {actor.mention if actor else actor_id}
+ Actor: <@{infraction["actor"]["id"]}>
ID: `{infraction["id"]}`
Reason: {infraction["reason"] or "*None*"}
{"**===============**" if active else "==============="}
@@ -282,17 +291,11 @@ class ModManagement(commands.Cog):
# endregion
# This cannot be static (must have a __func__ attribute).
- def cog_check(self, ctx: Context) -> bool:
+ async def cog_check(self, ctx: Context) -> bool:
"""Only allow moderators inside moderator channels to invoke the commands in this cog."""
checks = [
- with_role_check(ctx, *constants.MODERATION_ROLES),
- in_whitelist_check(
- ctx,
- channels=constants.MODERATION_CHANNELS,
- categories=[constants.Categories.modmail],
- redirect=None,
- fail_silently=True,
- )
+ await commands.has_any_role(*constants.MODERATION_ROLES).predicate(ctx),
+ is_mod_channel(ctx.channel)
]
return all(checks)
@@ -303,3 +306,8 @@ class ModManagement(commands.Cog):
if discord.User in error.converters:
await ctx.send(str(error.errors[0]))
error.handled = True
+
+
+def setup(bot: Bot) -> None:
+ """Load the ModManagement cog."""
+ bot.add_cog(ModManagement(bot))
diff --git a/bot/cogs/moderation/superstarify.py b/bot/exts/moderation/infraction/superstarify.py
index 45a010f00..adfe42fcd 100644
--- a/bot/cogs/moderation/superstarify.py
+++ b/bot/exts/moderation/infraction/superstarify.py
@@ -6,15 +6,16 @@ import typing as t
from pathlib import Path
from discord import Colour, Embed, Member
-from discord.ext.commands import Cog, Context, command
+from discord.ext.commands import Cog, Context, command, has_any_role
+from discord.utils import escape_markdown
from bot import constants
from bot.bot import Bot
from bot.converters import Expiry
-from bot.utils.checks import with_role_check
+from bot.exts.moderation.infraction import _utils
+from bot.exts.moderation.infraction._scheduler import InfractionScheduler
+from bot.utils.messages import format_user
from bot.utils.time import format_infraction
-from . import utils
-from .scheduler import InfractionScheduler
log = logging.getLogger(__name__)
NICKNAME_POLICY_URL = "https://pythondiscord.com/pages/rules/#nickname-policy"
@@ -67,7 +68,7 @@ class Superstarify(InfractionScheduler, Cog):
reason=f"Superstarified member tried to escape the prison: {infraction['id']}"
)
- notified = await utils.notify_infraction(
+ notified = await _utils.notify_infraction(
user=after,
infr_type="Superstarify",
expires_at=format_infraction(infraction["expires_at"]),
@@ -76,7 +77,7 @@ class Superstarify(InfractionScheduler, Cog):
f"from **{before.display_name}** to **{after.display_name}**, but as you "
"are currently in superstar-prison, you do not have permission to do so."
),
- icon_url=utils.INFRACTION_ICONS["superstar"][0]
+ icon_url=_utils.INFRACTION_ICONS["superstar"][0]
)
if not notified:
@@ -130,15 +131,15 @@ class Superstarify(InfractionScheduler, Cog):
An optional reason can be provided. If no reason is given, the original name will be shown
in a generated reason.
"""
- if await utils.get_active_infraction(ctx, member, "superstar"):
+ if await _utils.get_active_infraction(ctx, member, "superstar"):
return
# Post the infraction to the API
- reason = reason or f"old nick: {member.display_name}"
- infraction = await utils.post_infraction(ctx, member, "superstar", reason, duration, active=True)
+ 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"]
- old_nick = member.display_name
forced_nick = self.get_nick(id_, member.id)
expiry_str = format_infraction(infraction["expires_at"])
@@ -146,14 +147,17 @@ class Superstarify(InfractionScheduler, Cog):
log.debug(f"Changing nickname of {member} to {forced_nick}.")
self.mod_log.ignore(constants.Event.member_update, member.id)
await member.edit(nick=forced_nick, reason=reason)
- self.schedule_task(id_, infraction)
+ self.schedule_expiration(infraction)
+
+ 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.
- await utils.notify_infraction(
+ await _utils.notify_infraction(
user=member,
infr_type="Superstarify",
expires_at=expiry_str,
- icon_url=utils.INFRACTION_ICONS["superstar"][0],
+ icon_url=_utils.INFRACTION_ICONS["superstar"][0],
reason=f"Your nickname didn't comply with our [nickname policy]({NICKNAME_POLICY_URL})."
)
@@ -176,13 +180,13 @@ class Superstarify(InfractionScheduler, Cog):
# Log to the mod log channel.
log.trace(f"Sending apply mod log for superstar #{id_}.")
await self.mod_log.send_log_message(
- icon_url=utils.INFRACTION_ICONS["superstar"][0],
+ icon_url=_utils.INFRACTION_ICONS["superstar"][0],
colour=Colour.gold(),
title="Member achieved superstardom",
thumbnail=member.avatar_url_as(static_format="png"),
text=textwrap.dedent(f"""
- Member: {member.mention} (`{member.id}`)
- Actor: {ctx.message.author}
+ Member: {member.mention}
+ Actor: {ctx.message.author.mention}
Expires: {expiry_str}
Old nickname: `{old_nick}`
New nickname: `{forced_nick}`
@@ -196,7 +200,7 @@ class Superstarify(InfractionScheduler, Cog):
"""Remove the superstarify infraction and allow the user to change their nickname."""
await self.pardon_infraction(ctx, "superstar", member)
- async def _pardon_action(self, infraction: utils.Infraction) -> t.Optional[t.Dict[str, str]]:
+ async def _pardon_action(self, infraction: _utils.Infraction) -> t.Optional[t.Dict[str, str]]:
"""Pardon a superstar infraction and return a log dict."""
if infraction["type"] != "superstar":
return
@@ -213,15 +217,15 @@ class Superstarify(InfractionScheduler, Cog):
return {}
# DM the user about the expiration.
- notified = await utils.notify_pardon(
+ notified = await _utils.notify_pardon(
user=user,
title="You are no longer superstarified",
content="You may now change your nickname on the server.",
- icon_url=utils.INFRACTION_ICONS["superstar"][1]
+ icon_url=_utils.INFRACTION_ICONS["superstar"][1]
)
return {
- "Member": f"{user.mention}(`{user.id}`)",
+ "Member": format_user(user),
"DM": "Sent" if notified else "**Failed**"
}
@@ -234,6 +238,11 @@ class Superstarify(InfractionScheduler, Cog):
return rng.choice(STAR_NAMES)
# This cannot be static (must have a __func__ attribute).
- def cog_check(self, ctx: Context) -> bool:
+ async def cog_check(self, ctx: Context) -> bool:
"""Only allow moderators to invoke the commands in this cog."""
- return with_role_check(ctx, *constants.MODERATION_ROLES)
+ return await has_any_role(*constants.MODERATION_ROLES).predicate(ctx)
+
+
+def setup(bot: Bot) -> None:
+ """Load the Superstarify cog."""
+ bot.add_cog(Superstarify(bot))
diff --git a/bot/cogs/moderation/modlog.py b/bot/exts/moderation/modlog.py
index 41472c64c..b01de0ee3 100644
--- a/bot/cogs/moderation/modlog.py
+++ b/bot/exts/moderation/modlog.py
@@ -12,10 +12,10 @@ from deepdiff import DeepDiff
from discord import Colour
from discord.abc import GuildChannel
from discord.ext.commands import Cog, Context
-from discord.utils import escape_markdown
from bot.bot import Bot
from bot.constants import Categories, Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, URLs
+from bot.utils.messages import format_user
from bot.utils.time import humanize_delta
log = logging.getLogger(__name__)
@@ -24,7 +24,6 @@ GUILD_CHANNEL = t.Union[discord.CategoryChannel, discord.TextChannel, discord.Vo
CHANNEL_CHANGES_UNSUPPORTED = ("permissions",)
CHANNEL_CHANGES_SUPPRESSED = ("_overwrites", "position")
-MEMBER_CHANGES_SUPPRESSED = ("status", "activities", "_client_status", "nick")
ROLE_CHANGES_UNSUPPORTED = ("colour", "permissions")
VOICE_STATE_ATTRIBUTES = {
@@ -64,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,
}
@@ -121,8 +120,17 @@ class ModLog(Cog, name="ModLog"):
else:
content = "@everyone"
+ # Truncate content to 2000 characters and append an ellipsis.
+ if content and len(content) > 2000:
+ content = content[:2000 - 3] + "..."
+
channel = self.bot.get_channel(channel_id)
- log_message = await channel.send(content=content, embed=embed, files=files)
+ log_message = await channel.send(
+ content=content,
+ embed=embed,
+ files=files,
+ allowed_mentions=discord.AllowedMentions(everyone=True)
+ )
if additional_embeds:
if additional_embeds_msg:
@@ -388,7 +396,7 @@ class ModLog(Cog, name="ModLog"):
await self.send_log_message(
Icons.user_ban, Colours.soft_red,
- "User banned", f"{member} (`{member.id}`)",
+ "User banned", format_user(member),
thumbnail=member.avatar_url_as(static_format="png"),
channel_id=Channels.user_log
)
@@ -399,12 +407,10 @@ class ModLog(Cog, name="ModLog"):
if member.guild.id != GuildConstant.id:
return
- member_str = escape_markdown(str(member))
- message = f"{member_str} (`{member.id}`)"
now = datetime.utcnow()
difference = abs(relativedelta(now, member.created_at))
- message += "\n\n**Account age:** " + humanize_delta(difference)
+ message = format_user(member) + "\n\n**Account age:** " + humanize_delta(difference)
if difference.days < 1 and difference.months < 1 and difference.years < 1: # New user account!
message = f"{Emojis.new} {message}"
@@ -426,10 +432,9 @@ class ModLog(Cog, name="ModLog"):
self._ignored[Event.member_remove].remove(member.id)
return
- member_str = escape_markdown(str(member))
await self.send_log_message(
Icons.sign_out, Colours.soft_red,
- "User left", f"{member_str} (`{member.id}`)",
+ "User left", format_user(member),
thumbnail=member.avatar_url_as(static_format="png"),
channel_id=Channels.user_log
)
@@ -444,14 +449,28 @@ class ModLog(Cog, name="ModLog"):
self._ignored[Event.member_unban].remove(member.id)
return
- member_str = escape_markdown(str(member))
await self.send_log_message(
Icons.user_unban, Colour.blurple(),
- "User unbanned", f"{member_str} (`{member.id}`)",
+ "User unbanned", format_user(member),
thumbnail=member.avatar_url_as(static_format="png"),
channel_id=Channels.mod_log
)
+ @staticmethod
+ def get_role_diff(before: t.List[discord.Role], after: t.List[discord.Role]) -> t.List[str]:
+ """Return a list of strings describing the roles added and removed."""
+ changes = []
+ before_roles = set(before)
+ after_roles = set(after)
+
+ for role in (before_roles - after_roles):
+ changes.append(f"**Role removed:** {role.name} (`{role.id}`)")
+
+ for role in (after_roles - before_roles):
+ changes.append(f"**Role added:** {role.name} (`{role.id}`)")
+
+ return changes
+
@Cog.listener()
async def on_member_update(self, before: discord.Member, after: discord.Member) -> None:
"""Log member update event to user log."""
@@ -462,74 +481,27 @@ class ModLog(Cog, name="ModLog"):
self._ignored[Event.member_update].remove(before.id)
return
- diff = DeepDiff(before, after)
- changes = []
- done = []
-
- diff_values = {}
-
- diff_values.update(diff.get("values_changed", {}))
- diff_values.update(diff.get("type_changes", {}))
- diff_values.update(diff.get("iterable_item_removed", {}))
- diff_values.update(diff.get("iterable_item_added", {}))
-
- diff_user = DeepDiff(before._user, after._user)
-
- diff_values.update(diff_user.get("values_changed", {}))
- diff_values.update(diff_user.get("type_changes", {}))
- diff_values.update(diff_user.get("iterable_item_removed", {}))
- diff_values.update(diff_user.get("iterable_item_added", {}))
-
- for key, value in diff_values.items():
- if not key: # Not sure why, but it happens
- continue
-
- key = key[5:] # Remove "root." prefix
+ changes = self.get_role_diff(before.roles, after.roles)
- if "[" in key:
- key = key.split("[", 1)[0]
+ # The regex is a simple way to exclude all sequence and mapping types.
+ diff = DeepDiff(before, after, exclude_regex_paths=r".*\[.*")
- if "." in key:
- key = key.split(".", 1)[0]
+ # A type change seems to always take precedent over a value change. Furthermore, it will
+ # include the value change along with the type change anyway. Therefore, it's OK to
+ # "overwrite" values_changed; in practice there will never even be anything to overwrite.
+ diff_values = {**diff.get("values_changed", {}), **diff.get("type_changes", {})}
- if key in done or key in MEMBER_CHANGES_SUPPRESSED:
+ for attr, value in diff_values.items():
+ if not attr: # Not sure why, but it happens.
continue
- if key == "_roles":
- new_roles = after.roles
- old_roles = before.roles
-
- for role in old_roles:
- if role not in new_roles:
- changes.append(f"**Role removed:** {role.name} (`{role.id}`)")
-
- for role in new_roles:
- if role not in old_roles:
- changes.append(f"**Role added:** {role.name} (`{role.id}`)")
-
- else:
- new = value.get("new_value")
- old = value.get("old_value")
-
- if new and old:
- changes.append(f"**{key.title()}:** `{old}` **→** `{new}`")
-
- done.append(key)
-
- if before.name != after.name:
- changes.append(
- f"**Username:** `{before.name}` **→** `{after.name}`"
- )
+ attr = attr[5:] # Remove "root." prefix.
+ attr = attr.replace("_", " ").replace(".", " ").capitalize()
- if before.discriminator != after.discriminator:
- changes.append(
- f"**Discriminator:** `{before.discriminator}` **→** `{after.discriminator}`"
- )
+ new = value.get("new_value")
+ old = value.get("old_value")
- if before.display_name != after.display_name:
- changes.append(
- f"**Display name:** `{before.display_name}` **→** `{after.display_name}`"
- )
+ changes.append(f"**{attr}:** `{old}` **→** `{new}`")
if not changes:
return
@@ -539,12 +511,13 @@ class ModLog(Cog, name="ModLog"):
for item in sorted(changes):
message += f"{Emojis.bullet} {item}\n"
- member_str = escape_markdown(str(after))
- message = f"**{member_str}** (`{after.id}`)\n{message}"
+ message = f"{format_user(after)}\n{message}"
await self.send_log_message(
- Icons.user_update, Colour.blurple(),
- "Member updated", message,
+ icon_url=Icons.user_update,
+ colour=Colour.blurple(),
+ title="Member updated",
+ text=message,
thumbnail=after.avatar_url_as(static_format="png"),
channel_id=Channels.user_log
)
@@ -571,17 +544,16 @@ class ModLog(Cog, name="ModLog"):
if author.bot:
return
- author_str = escape_markdown(str(author))
if channel.category:
response = (
- f"**Author:** {author_str} (`{author.id}`)\n"
+ f"**Author:** {format_user(author)}\n"
f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n"
f"**Message ID:** `{message.id}`\n"
"\n"
)
else:
response = (
- f"**Author:** {author_str} (`{author.id}`)\n"
+ f"**Author:** {format_user(author)}\n"
f"**Channel:** #{channel.name} (`{channel.id}`)\n"
f"**Message ID:** `{message.id}`\n"
"\n"
@@ -667,9 +639,6 @@ class ModLog(Cog, name="ModLog"):
if msg_before.content == msg_after.content:
return
- author = msg_before.author
- author_str = escape_markdown(str(author))
-
channel = msg_before.channel
channel_name = f"{channel.category}/#{channel.name}" if channel.category else f"#{channel.name}"
@@ -701,7 +670,7 @@ class ModLog(Cog, name="ModLog"):
content_after.append(sub)
response = (
- f"**Author:** {author_str} (`{author.id}`)\n"
+ f"**Author:** {format_user(msg_before.author)}\n"
f"**Channel:** {channel_name} (`{channel.id}`)\n"
f"**Message ID:** `{msg_before.id}`\n"
"\n"
@@ -753,12 +722,11 @@ class ModLog(Cog, name="ModLog"):
self._cached_edits.remove(event.message_id)
return
- author = message.author
channel = message.channel
channel_name = f"{channel.category}/#{channel.name}" if channel.category else f"#{channel.name}"
before_response = (
- f"**Author:** {author} (`{author.id}`)\n"
+ f"**Author:** {format_user(message.author)}\n"
f"**Channel:** {channel_name} (`{channel.id}`)\n"
f"**Message ID:** `{message.id}`\n"
"\n"
@@ -766,7 +734,7 @@ class ModLog(Cog, name="ModLog"):
)
after_response = (
- f"**Author:** {author} (`{author.id}`)\n"
+ f"**Author:** {format_user(message.author)}\n"
f"**Channel:** {channel_name} (`{channel.id}`)\n"
f"**Message ID:** `{message.id}`\n"
"\n"
@@ -844,9 +812,8 @@ class ModLog(Cog, name="ModLog"):
if not changes:
return
- member_str = escape_markdown(str(member))
message = "\n".join(f"{Emojis.bullet} {item}" for item in sorted(changes))
- message = f"**{member_str}** (`{member.id}`)\n{message}"
+ message = f"{format_user(member)}\n{message}"
await self.send_log_message(
icon_url=icon,
@@ -856,3 +823,8 @@ class ModLog(Cog, name="ModLog"):
thumbnail=member.avatar_url_as(static_format="png"),
channel_id=Channels.voice_log
)
+
+
+def setup(bot: Bot) -> None:
+ """Load the ModLog cog."""
+ bot.add_cog(ModLog(bot))
diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py
new file mode 100644
index 000000000..e6712b3b6
--- /dev/null
+++ b/bot/exts/moderation/silence.py
@@ -0,0 +1,255 @@
+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
+
+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."""
+
+ def __init__(self, alert_channel: TextChannel):
+ super().__init__(self._notifier, seconds=1, minutes=0, hours=0, count=None, reconnect=True, loop=None)
+ self._silenced_channels = {}
+ self._alert_channel = alert_channel
+
+ def add_channel(self, channel: TextChannel) -> None:
+ """Add channel to `_silenced_channels` and start loop if not launched."""
+ if not self._silenced_channels:
+ self.start()
+ log.info("Starting notifier loop.")
+ self._silenced_channels[channel] = self._current_loop
+
+ def remove_channel(self, channel: TextChannel) -> None:
+ """Remove channel from `_silenced_channels` and stop loop if no channels remain."""
+ with suppress(KeyError):
+ del self._silenced_channels[channel]
+ if not self._silenced_channels:
+ self.stop()
+ log.info("Stopping notifier loop.")
+
+ async def _notifier(self) -> None:
+ """Post notice of `_silenced_channels` with their silenced duration to `_alert_channel` periodically."""
+ # Wait for 15 minutes between notices with pause at start of loop.
+ if self._current_loop and not self._current_loop/60 % 15:
+ log.debug(
+ f"Sending notice with channels: "
+ f"{', '.join(f'#{channel} ({channel.id})' for channel in self._silenced_channels)}."
+ )
+ channels_text = ', '.join(
+ f"{channel.mention} for {(self._current_loop-start)//60} min"
+ for channel, start in self._silenced_channels.items()
+ )
+ await self._alert_channel.send(f"<@&{Roles.moderators}> currently silenced channels: {channels_text}")
+
+
+class Silence(commands.Cog):
+ """Commands for stopping channel messages for `verified` role in a channel."""
+
+ # 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._init_task = self.bot.loop.create_task(self._async_init())
+
+ 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.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`.
+
+ 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._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 self._schedule_unsilence(ctx, duration)
+
+ 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:
+ """
+ Unsilence the current channel.
+
+ If the channel was silenced indefinitely, notifications for the channel will stop.
+ """
+ await self._init_task
+ log.debug(f"Unsilencing channel #{ctx.channel} from {ctx.author}'s command.")
+ 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 channel.send(MSG_UNSILENCE_SUCCESS)
+
+ 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 channel.id in self.scheduler or all(val is False for val in prev_overwrites.values()):
+ return False
+
+ 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`.
+
+ 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.
+ """
+ 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:
+ """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:
+ """Only allow moderators to invoke the commands in this cog."""
+ return await commands.has_any_role(*MODERATION_ROLES).predicate(ctx)
+
+
+def setup(bot: Bot) -> None:
+ """Load the Silence cog."""
+ bot.add_cog(Silence(bot))
diff --git a/bot/exts/moderation/slowmode.py b/bot/exts/moderation/slowmode.py
new file mode 100644
index 000000000..efd862aa5
--- /dev/null
+++ b/bot/exts/moderation/slowmode.py
@@ -0,0 +1,96 @@
+import logging
+from datetime import datetime
+from typing import Optional
+
+from dateutil.relativedelta import relativedelta
+from discord import TextChannel
+from discord.ext.commands import Cog, Context, group, has_any_role
+
+from bot.bot import Bot
+from bot.constants import Emojis, MODERATION_ROLES
+from bot.converters import DurationDelta
+from bot.utils import time
+
+log = logging.getLogger(__name__)
+
+SLOWMODE_MAX_DELAY = 21600 # seconds
+
+
+class Slowmode(Cog):
+ """Commands for getting and setting slowmode delays of text channels."""
+
+ def __init__(self, bot: Bot) -> None:
+ self.bot = bot
+
+ @group(name='slowmode', aliases=['sm'], invoke_without_command=True)
+ async def slowmode_group(self, ctx: Context) -> None:
+ """Get or set the slowmode delay for the text channel this was invoked in or a given text channel."""
+ await ctx.send_help(ctx.command)
+
+ @slowmode_group.command(name='get', aliases=['g'])
+ async def get_slowmode(self, ctx: Context, channel: Optional[TextChannel]) -> None:
+ """Get the slowmode delay for a text channel."""
+ # Use the channel this command was invoked in if one was not given
+ if channel is None:
+ channel = ctx.channel
+
+ delay = relativedelta(seconds=channel.slowmode_delay)
+ humanized_delay = time.humanize_delta(delay)
+
+ await ctx.send(f'The slowmode delay for {channel.mention} is {humanized_delay}.')
+
+ @slowmode_group.command(name='set', aliases=['s'])
+ async def set_slowmode(self, ctx: Context, channel: Optional[TextChannel], delay: DurationDelta) -> None:
+ """Set the slowmode delay for a text channel."""
+ # Use the channel this command was invoked in if one was not given
+ if channel is None:
+ channel = ctx.channel
+
+ # Convert `dateutil.relativedelta.relativedelta` to `datetime.timedelta`
+ # Must do this to get the delta in a particular unit of time
+ utcnow = datetime.utcnow()
+ slowmode_delay = (utcnow + delay - utcnow).total_seconds()
+
+ humanized_delay = time.humanize_delta(delay)
+
+ # Ensure the delay is within discord's limits
+ if slowmode_delay <= SLOWMODE_MAX_DELAY:
+ log.info(f'{ctx.author} set the slowmode delay for #{channel} to {humanized_delay}.')
+
+ await channel.edit(slowmode_delay=slowmode_delay)
+ await ctx.send(
+ f'{Emojis.check_mark} The slowmode delay for {channel.mention} is now {humanized_delay}.'
+ )
+
+ else:
+ log.info(
+ f'{ctx.author} tried to set the slowmode delay of #{channel} to {humanized_delay}, '
+ 'which is not between 0 and 6 hours.'
+ )
+
+ await ctx.send(
+ f'{Emojis.cross_mark} The slowmode delay must be between 0 and 6 hours.'
+ )
+
+ @slowmode_group.command(name='reset', aliases=['r'])
+ async def reset_slowmode(self, ctx: Context, channel: Optional[TextChannel]) -> None:
+ """Reset the slowmode delay for a text channel to 0 seconds."""
+ # Use the channel this command was invoked in if one was not given
+ if channel is None:
+ channel = ctx.channel
+
+ log.info(f'{ctx.author} reset the slowmode delay for #{channel} to 0 seconds.')
+
+ await channel.edit(slowmode_delay=0)
+ await ctx.send(
+ f'{Emojis.check_mark} The slowmode delay for {channel.mention} has been reset to 0 seconds.'
+ )
+
+ async def cog_check(self, ctx: Context) -> bool:
+ """Only allow moderators to invoke the commands in this cog."""
+ return await has_any_role(*MODERATION_ROLES).predicate(ctx)
+
+
+def setup(bot: Bot) -> None:
+ """Load the Slowmode cog."""
+ bot.add_cog(Slowmode(bot))
diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py
new file mode 100644
index 000000000..c599156d0
--- /dev/null
+++ b/bot/exts/moderation/verification.py
@@ -0,0 +1,856 @@
+import asyncio
+import logging
+import typing as t
+from contextlib import suppress
+from datetime import datetime, timedelta
+
+import discord
+from async_rediscache import RedisCache
+from discord.ext import tasks
+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
+from bot.utils.checks import InWhitelistCheckFailure, has_no_roles_check
+from bot.utils.messages import format_user
+
+log = logging.getLogger(__name__)
+
+# Sent via DMs once user joins the guild
+ON_JOIN_MESSAGE = f"""
+Welcome to Python Discord!
+
+To show you what kind of community we are, we've created this video:
+https://youtu.be/ZH26PuX3re0
+
+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
+VERIFIED_MESSAGE = f"""
+Thanks for verifying yourself!
+
+For your records, these are the documents you accepted:
+
+`1)` Our rules, here: <https://pythondiscord.com/pages/rules>
+`2)` Our privacy policy, here: <https://pythondiscord.com/pages/privacy> - you can find information on how to have \
+your information removed here as well.
+
+Feel free to review them at any point!
+
+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}>.
+"""
+
+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 \
+within `{constants.Verification.kicked_after}` days. If this was an accident, please feel free to join us again!
+
+{constants.Guild.invite}
+"""
+
+# Sent periodically in the verification channel
+REMINDER_MESSAGE = f"""
+<@&{constants.Roles.unverified}>
+
+Welcome to Python Discord! Please read the documents mentioned above and type `!accept` to gain permissions \
+to send messages in the community!
+
+You will be kicked if you don't verify within `{constants.Verification.kicked_after}` days.
+""".strip()
+
+# An async function taking a Member param
+Request = t.Callable[[discord.Member], t.Awaitable]
+
+
+class StopExecution(Exception):
+ """Signals that a task should halt immediately & alert admins."""
+
+ def __init__(self, reason: discord.HTTPException) -> None:
+ super().__init__()
+ self.reason = reason
+
+
+class Limit(t.NamedTuple):
+ """Composition over config for throttling requests."""
+
+ batch_size: int # Amount of requests after which to pause
+ sleep_secs: int # Sleep this many seconds after each batch
+
+
+def mention_role(role_id: int) -> discord.AllowedMentions:
+ """Construct an allowed mentions instance that allows pinging `role_id`."""
+ return discord.AllowedMentions(roles=[discord.Object(role_id)])
+
+
+def is_verified(member: discord.Member) -> bool:
+ """
+ Check whether `member` is considered verified.
+
+ Members are considered verified if they have at least 1 role other than
+ the default role (@everyone) and the @Unverified role.
+ """
+ unverified_roles = {
+ member.guild.get_role(constants.Roles.unverified),
+ member.guild.default_role,
+ }
+ 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.
+
+ There are two internal tasks in this cog:
+
+ * `update_unverified_members`
+ * Unverified members are given the @Unverified role after configured `unverified_after` days
+ * Unverified members are kicked after configured `kicked_after` days
+ * `ping_unverified`
+ * Periodically ping the @Unverified role in the verification channel
+
+ Statistics are collected in the 'verification.' namespace.
+
+ Moderators+ can use the `verification` command group to start or stop both internal
+ tasks, if necessary. Settings are persisted in Redis across sessions.
+
+ Additionally, this cog offers the !accept, !subscribe and !unsubscribe commands,
+ and keeps the verification channel clean by deleting messages.
+ """
+
+ # Persist task settings & last sent `REMINDER_MESSAGE` id
+ # RedisCache[
+ # "tasks_running": int (0 or 1),
+ # "last_reminder": int (discord.Message.id),
+ # ]
+ 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
+ self.bot.loop.create_task(self._maybe_start_tasks())
+
+ def cog_unload(self) -> None:
+ """
+ Cancel internal tasks.
+
+ This is necessary, as tasks are not automatically cancelled on cog unload.
+ """
+ self._stop_tasks(gracefully=False)
+
+ @property
+ def mod_log(self) -> ModLog:
+ """Get currently loaded ModLog cog instance."""
+ return self.bot.get_cog("ModLog")
+
+ async def _maybe_start_tasks(self) -> None:
+ """
+ Poll Redis to check whether internal tasks should start.
+
+ Redis must be interfaced with from an async function.
+ """
+ log.trace("Checking whether background tasks should begin")
+ setting: t.Optional[int] = await self.task_cache.get("tasks_running") # This can be None if never set
+
+ if setting:
+ log.trace("Background tasks will be started")
+ self.update_unverified_members.start()
+ self.ping_unverified.start()
+
+ def _stop_tasks(self, *, gracefully: bool) -> None:
+ """
+ Stop the update users & ping @Unverified tasks.
+
+ If `gracefully` is True, the tasks will be able to finish their current iteration.
+ Otherwise, they are cancelled immediately.
+ """
+ log.info(f"Stopping internal tasks ({gracefully=})")
+ if gracefully:
+ self.update_unverified_members.stop()
+ self.ping_unverified.stop()
+ else:
+ self.update_unverified_members.cancel()
+ self.ping_unverified.cancel()
+
+ # region: automatically update unverified users
+
+ async def _verify_kick(self, n_members: int) -> bool:
+ """
+ Determine whether `n_members` is a reasonable amount of members to kick.
+
+ First, `n_members` is checked against the size of the PyDis guild. If `n_members` are
+ more than the configured `kick_confirmation_threshold` of the guild, the operation
+ must be confirmed by staff in #core-dev. Otherwise, the operation is seen as safe.
+ """
+ log.debug(f"Checking whether {n_members} members are safe to kick")
+
+ await self.bot.wait_until_guild_available() # Ensure cache is populated before we grab the guild
+ pydis = self.bot.get_guild(constants.Guild.id)
+
+ percentage = n_members / len(pydis.members)
+ if percentage < constants.Verification.kick_confirmation_threshold:
+ log.debug(f"Kicking {percentage:.2%} of the guild's population is seen as safe")
+ return True
+
+ # Since `n_members` is a suspiciously large number, we will ask for confirmation
+ log.debug("Amount of users is too large, requesting staff confirmation")
+
+ core_dev_channel = pydis.get_channel(constants.Channels.dev_core)
+ core_dev_ping = f"<@&{constants.Roles.core_developers}>"
+
+ confirmation_msg = await core_dev_channel.send(
+ f"{core_dev_ping} Verification determined that `{n_members}` members should be kicked as they haven't "
+ f"verified in `{constants.Verification.kicked_after}` days. This is `{percentage:.2%}` of the guild's "
+ f"population. Proceed?",
+ allowed_mentions=mention_role(constants.Roles.core_developers),
+ )
+
+ options = (constants.Emojis.incident_actioned, constants.Emojis.incident_unactioned)
+ for option in options:
+ await confirmation_msg.add_reaction(option)
+
+ core_dev_ids = [member.id for member in pydis.get_role(constants.Roles.core_developers).members]
+
+ def check(reaction: discord.Reaction, user: discord.User) -> bool:
+ """Check whether `reaction` is a valid reaction to `confirmation_msg`."""
+ return (
+ reaction.message.id == confirmation_msg.id # Reacted to `confirmation_msg`
+ and str(reaction.emoji) in options # With one of `options`
+ and user.id in core_dev_ids # By a core developer
+ )
+
+ timeout = 60 * 5 # Seconds, i.e. 5 minutes
+ try:
+ choice, _ = await self.bot.wait_for("reaction_add", check=check, timeout=timeout)
+ except asyncio.TimeoutError:
+ log.debug("Staff prompt not answered, aborting operation")
+ return False
+ finally:
+ with suppress(discord.HTTPException):
+ await confirmation_msg.clear_reactions()
+
+ result = str(choice) == constants.Emojis.incident_actioned
+ log.debug(f"Received answer: {choice}, result: {result}")
+
+ # Edit the prompt message to reflect the final choice
+ if result is True:
+ result_msg = f":ok_hand: {core_dev_ping} Request to kick `{n_members}` members was authorized!"
+ else:
+ result_msg = f":warning: {core_dev_ping} Request to kick `{n_members}` members was denied!"
+
+ with suppress(discord.HTTPException):
+ await confirmation_msg.edit(content=result_msg)
+
+ return result
+
+ async def _alert_admins(self, exception: discord.HTTPException) -> None:
+ """
+ Ping @Admins with information about `exception`.
+
+ This is used when a critical `exception` caused a verification task to abort.
+ """
+ await self.bot.wait_until_guild_available()
+ log.info(f"Sending admin alert regarding exception: {exception}")
+
+ admins_channel = self.bot.get_guild(constants.Guild.id).get_channel(constants.Channels.admins)
+ ping = f"<@&{constants.Roles.admins}>"
+
+ await admins_channel.send(
+ f"{ping} Aborted updating unverified users due to the following exception:\n"
+ f"```{exception}```\n"
+ f"Internal tasks will be stopped.",
+ allowed_mentions=mention_role(constants.Roles.admins),
+ )
+
+ async def _send_requests(self, members: t.Collection[discord.Member], request: Request, limit: Limit) -> int:
+ """
+ Pass `members` one by one to `request` handling Discord exceptions.
+
+ This coroutine serves as a generic `request` executor for kicking members and adding
+ roles, as it allows us to define the error handling logic in one place only.
+
+ Any `request` has the ability to completely abort the execution by raising `StopExecution`.
+ In such a case, the @Admins will be alerted of the reason attribute.
+
+ To avoid rate-limits, pass a `limit` configuring the batch size and the amount of seconds
+ to sleep between batches.
+
+ Returns the amount of successful requests. Failed requests are logged at info level.
+ """
+ log.trace(f"Sending {len(members)} requests")
+ n_success, bad_statuses = 0, set()
+
+ for progress, member in enumerate(members, start=1):
+ if is_verified(member): # Member could have verified in the meantime
+ continue
+ try:
+ await request(member)
+ except StopExecution as stop_execution:
+ await self._alert_admins(stop_execution.reason)
+ await self.task_cache.set("tasks_running", 0)
+ self._stop_tasks(gracefully=True) # Gracefully finish current iteration, then stop
+ break
+ except discord.HTTPException as http_exc:
+ bad_statuses.add(http_exc.status)
+ else:
+ n_success += 1
+
+ if progress % limit.batch_size == 0:
+ log.trace(f"Processed {progress} requests, pausing for {limit.sleep_secs} seconds")
+ await asyncio.sleep(limit.sleep_secs)
+
+ if bad_statuses:
+ log.info(f"Failed to send {len(members) - n_success} requests due to following statuses: {bad_statuses}")
+
+ 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.
+
+ Due to strict ratelimits on sending messages (120 requests / 60 secs), we sleep for a second
+ after each 2 requests to allow breathing room for other features.
+
+ Note that this is a potentially destructive operation. Returns the amount of successful requests.
+ """
+ log.info(f"Kicking {len(members)} members (not verified after {constants.Verification.kicked_after} days)")
+
+ async def kick_request(member: discord.Member) -> None:
+ """Send `KICKED_MESSAGE` to `member` and kick them from the guild."""
+ try:
+ 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)
+
+ return n_kicked
+
+ async def _give_role(self, members: t.Collection[discord.Member], role: discord.Role) -> int:
+ """
+ Give `role` to all `members`.
+
+ We pause for a second after batches of 25 requests to ensure ratelimits aren't exceeded.
+
+ Returns the amount of successful requests.
+ """
+ log.info(
+ f"Assigning {role} role to {len(members)} members (not verified "
+ f"after {constants.Verification.unverified_after} days)"
+ )
+
+ async def role_request(member: discord.Member) -> None:
+ """Add `role` to `member`."""
+ await member.add_roles(role, reason=f"Not verified after {constants.Verification.unverified_after} days")
+
+ return await self._send_requests(members, role_request, Limit(batch_size=25, sleep_secs=1))
+
+ async def _check_members(self) -> t.Tuple[t.Set[discord.Member], t.Set[discord.Member]]:
+ """
+ Check in on the verification status of PyDis members.
+
+ This coroutine finds two sets of users:
+ * Not verified after configured `unverified_after` days, should be given the @Unverified role
+ * Not verified after configured `kicked_after` days, should be kicked from the guild
+
+ These sets are always disjoint, i.e. share no common members.
+ """
+ await self.bot.wait_until_guild_available() # Ensure cache is ready
+ pydis = self.bot.get_guild(constants.Guild.id)
+
+ unverified = pydis.get_role(constants.Roles.unverified)
+ current_dt = datetime.utcnow() # Discord timestamps are UTC
+
+ # Users to be given the @Unverified role, and those to be kicked, these should be entirely disjoint
+ for_role, for_kick = set(), set()
+
+ log.debug("Checking verification status of guild members")
+ for member in pydis.members:
+
+ # Skip verified members, bots, and members for which we do not know their join date,
+ # this should be extremely rare but docs mention that it can happen
+ if is_verified(member) or member.bot or member.joined_at is None:
+ continue
+
+ # At this point, we know that `member` is an unverified user, and we will decide what
+ # to do with them based on time passed since their join date
+ since_join = current_dt - member.joined_at
+
+ if since_join > timedelta(days=constants.Verification.kicked_after):
+ for_kick.add(member) # User should be removed from the guild
+
+ elif (
+ since_join > timedelta(days=constants.Verification.unverified_after)
+ and unverified not in member.roles
+ ):
+ for_role.add(member) # User should be given the @Unverified role
+
+ log.debug(f"Found {len(for_role)} users for {unverified} role, {len(for_kick)} users to be kicked")
+ return for_role, for_kick
+
+ @tasks.loop(minutes=30)
+ async def update_unverified_members(self) -> None:
+ """
+ Periodically call `_check_members` and update unverified members accordingly.
+
+ After each run, a summary will be sent to the modlog channel. If a suspiciously high
+ amount of members to be kicked is found, the operation is guarded by `_verify_kick`.
+ """
+ log.info("Updating unverified guild members")
+
+ await self.bot.wait_until_guild_available()
+ unverified = self.bot.get_guild(constants.Guild.id).get_role(constants.Roles.unverified)
+
+ for_role, for_kick = await self._check_members()
+
+ if not for_role:
+ role_report = f"Found no users to be assigned the {unverified.mention} role."
+ else:
+ n_roles = await self._give_role(for_role, unverified)
+ role_report = f"Assigned {unverified.mention} role to `{n_roles}`/`{len(for_role)}` members."
+
+ if not for_kick:
+ kick_report = "Found no users to be kicked."
+ elif not await self._verify_kick(len(for_kick)):
+ kick_report = f"Not authorized to kick `{len(for_kick)}` members."
+ else:
+ n_kicks = await self._kick_members(for_kick)
+ kick_report = f"Kicked `{n_kicks}`/`{len(for_kick)}` members from the guild."
+
+ await self.mod_log.send_log_message(
+ icon_url=self.bot.user.avatar_url,
+ colour=discord.Colour.blurple(),
+ title="Verification system",
+ text=f"{kick_report}\n{role_report}",
+ )
+
+ # endregion
+ # region: periodically ping @Unverified
+
+ @tasks.loop(hours=constants.Verification.reminder_frequency)
+ async def ping_unverified(self) -> None:
+ """
+ Delete latest `REMINDER_MESSAGE` and send it again.
+
+ This utilizes RedisCache to persist the latest reminder message id.
+ """
+ await self.bot.wait_until_guild_available()
+ verification = self.bot.get_guild(constants.Guild.id).get_channel(constants.Channels.verification)
+
+ last_reminder: t.Optional[int] = await self.task_cache.get("last_reminder")
+
+ if last_reminder is not None:
+ log.trace(f"Found verification reminder message in cache, deleting: {last_reminder}")
+
+ with suppress(discord.HTTPException): # If something goes wrong, just ignore it
+ await self.bot.http.delete_message(verification.id, last_reminder)
+
+ log.trace("Sending verification reminder")
+ new_reminder = await verification.send(
+ REMINDER_MESSAGE, allowed_mentions=mention_role(constants.Roles.unverified),
+ )
+
+ await self.task_cache.set("last_reminder", new_reminder.id)
+
+ @ping_unverified.before_loop
+ async def _before_first_ping(self) -> None:
+ """
+ Sleep until `REMINDER_MESSAGE` should be sent again.
+
+ If latest reminder is not cached, exit instantly. Otherwise, wait wait until the
+ configured `reminder_frequency` has passed.
+ """
+ last_reminder: t.Optional[int] = await self.task_cache.get("last_reminder")
+
+ if last_reminder is None:
+ log.trace("Latest verification reminder message not cached, task will not wait")
+ return
+
+ # Convert cached message id into a timestamp
+ time_since = datetime.utcnow() - snowflake_time(last_reminder)
+ log.trace(f"Time since latest verification reminder: {time_since}")
+
+ to_sleep = timedelta(hours=constants.Verification.reminder_frequency) - time_since
+ log.trace(f"Time to sleep until next ping: {to_sleep}")
+
+ # Delta can be negative if `reminder_frequency` has already passed
+ secs = max(to_sleep.total_seconds(), 0)
+ await asyncio.sleep(secs)
+
+ # endregion
+ # region: listeners
+
+ @Cog.listener()
+ async def on_member_join(self, member: discord.Member) -> None:
+ """Attempt to send initial direct message to each new member."""
+ 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}")
+ 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:
+ """Check new message event for messages to the checkpoint channel & process."""
+ if message.channel.id != constants.Channels.verification:
+ return # Only listen for #checkpoint messages
+
+ if message.content == REMINDER_MESSAGE:
+ return # Ignore bots own verification reminder
+
+ if message.author.bot:
+ # They're a bot, delete their message after the delay.
+ await message.delete(delay=constants.Verification.bot_message_delete_delay)
+ return
+
+ # if a user mentions a role or guild member
+ # alert the mods in mod-alerts channel
+ if message.mentions or message.role_mentions:
+ log.debug(
+ f"{message.author} mentioned one or more users "
+ f"and/or roles in {message.channel.name}"
+ )
+
+ embed_text = (
+ f"{format_user(message.author)} sent a message in "
+ f"{message.channel.mention} that contained user and/or role mentions."
+ f"\n\n**Original message:**\n>>> {message.content}"
+ )
+
+ # Send pretty mod log embed to mod-alerts
+ await self.mod_log.send_log_message(
+ icon_url=constants.Icons.filtering,
+ colour=discord.Colour(constants.Colours.soft_red),
+ title=f"User/Role mentioned in {message.channel.name}",
+ text=embed_text,
+ thumbnail=message.author.avatar_url_as(static_format="png"),
+ channel_id=constants.Channels.mod_alerts,
+ )
+
+ ctx: Context = await self.bot.get_context(message)
+ if ctx.command is not None and ctx.command.name == "accept":
+ return
+
+ if any(r.id == constants.Roles.verified for r in ctx.author.roles):
+ log.info(
+ f"{ctx.author} posted '{ctx.message.content}' "
+ "in the verification channel, but is already verified."
+ )
+ return
+
+ log.debug(
+ f"{ctx.author} posted '{ctx.message.content}' in the verification "
+ "channel. We are providing instructions how to verify."
+ )
+ await ctx.send(
+ f"{ctx.author.mention} Please type `!accept` to verify that you accept our rules, "
+ f"and gain access to the rest of the server.",
+ delete_after=20
+ )
+
+ log.trace(f"Deleting the message posted by {ctx.author}")
+ with suppress(discord.NotFound):
+ await ctx.message.delete()
+
+ # endregion
+ # region: task management commands
+
+ @has_any_role(*constants.MODERATION_ROLES)
+ @group(name="verification")
+ async def verification_group(self, ctx: Context) -> None:
+ """Manage internal verification tasks."""
+ if ctx.invoked_subcommand is None:
+ await ctx.send_help(ctx.command)
+
+ @verification_group.command(name="status")
+ async def status_cmd(self, ctx: Context) -> None:
+ """Check whether verification tasks are running."""
+ log.trace("Checking status of verification tasks")
+
+ if self.update_unverified_members.is_running():
+ update_status = f"{constants.Emojis.incident_actioned} Member update task is running."
+ else:
+ update_status = f"{constants.Emojis.incident_unactioned} Member update task is **not** running."
+
+ mention = f"<@&{constants.Roles.unverified}>"
+ if self.ping_unverified.is_running():
+ ping_status = f"{constants.Emojis.incident_actioned} Ping {mention} task is running."
+ else:
+ ping_status = f"{constants.Emojis.incident_unactioned} Ping {mention} task is **not** running."
+
+ embed = discord.Embed(
+ title="Verification system",
+ description=f"{update_status}\n{ping_status}",
+ colour=discord.Colour.blurple(),
+ )
+ await ctx.send(embed=embed)
+
+ @verification_group.command(name="start")
+ async def start_cmd(self, ctx: Context) -> None:
+ """Start verification tasks if they are not already running."""
+ log.info("Starting verification tasks")
+
+ if not self.update_unverified_members.is_running():
+ self.update_unverified_members.start()
+
+ if not self.ping_unverified.is_running():
+ self.ping_unverified.start()
+
+ await self.task_cache.set("tasks_running", 1)
+
+ colour = discord.Colour.blurple()
+ await ctx.send(embed=discord.Embed(title="Verification system", description="Done. :ok_hand:", colour=colour))
+
+ @verification_group.command(name="stop", aliases=["kill"])
+ async def stop_cmd(self, ctx: Context) -> None:
+ """Stop verification tasks."""
+ log.info("Stopping verification tasks")
+
+ self._stop_tasks(gracefully=False)
+ await self.task_cache.set("tasks_running", 0)
+
+ colour = discord.Colour.blurple()
+ await ctx.send(embed=discord.Embed(title="Verification system", description="Tasks canceled.", colour=colour))
+
+ # endregion
+ # region: accept and subscribe commands
+
+ def _bump_verified_stats(self, verified_member: discord.Member) -> None:
+ """
+ Increment verification stats for `verified_member`.
+
+ Each member falls into one of the three categories:
+ * Verified within 24 hours after joining
+ * Does not have @Unverified role yet
+ * Does have @Unverified role
+
+ Stats for member kicking are handled separately.
+ """
+ if verified_member.joined_at is None: # Docs mention this can happen
+ return
+
+ if (datetime.utcnow() - verified_member.joined_at) < timedelta(hours=24):
+ category = "accepted_on_day_one"
+ elif constants.Roles.unverified not in [role.id for role in verified_member.roles]:
+ category = "accepted_before_unverified"
+ else:
+ category = "accepted_after_unverified"
+
+ log.trace(f"Bumping verification stats in category: {category}")
+ self.bot.stats.incr(f"verification.{category}")
+
+ @command(name='accept', aliases=('verify', 'verified', 'accepted'), hidden=True)
+ @has_no_roles(constants.Roles.verified)
+ @in_whitelist(channels=(constants.Channels.verification,))
+ async def accept_command(self, ctx: Context, *_) -> None: # We don't actually care about the args
+ """Accept our rules and gain access to the rest of the server."""
+ log.debug(f"{ctx.author} called !accept. Assigning the 'Developer' role.")
+ await ctx.author.add_roles(discord.Object(constants.Roles.verified), reason="Accepted the rules")
+
+ self._bump_verified_stats(ctx.author) # This checks for @Unverified so make sure it's not yet removed
+
+ if constants.Roles.unverified in [role.id for role in ctx.author.roles]:
+ log.debug(f"Removing Unverified role from: {ctx.author}")
+ await ctx.author.remove_roles(discord.Object(constants.Roles.unverified))
+
+ try:
+ 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):
+ self.mod_log.ignore(constants.Event.message_delete, ctx.message.id)
+ await ctx.message.delete()
+
+ @command(name='subscribe')
+ @in_whitelist(channels=(constants.Channels.bot_commands,))
+ async def subscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args
+ """Subscribe to announcement notifications by assigning yourself the role."""
+ has_role = False
+
+ for role in ctx.author.roles:
+ if role.id == constants.Roles.announcements:
+ has_role = True
+ break
+
+ if has_role:
+ await ctx.send(f"{ctx.author.mention} You're already subscribed!")
+ return
+
+ log.debug(f"{ctx.author} called !subscribe. Assigning the 'Announcements' role.")
+ await ctx.author.add_roles(discord.Object(constants.Roles.announcements), reason="Subscribed to announcements")
+
+ log.trace(f"Deleting the message posted by {ctx.author}.")
+
+ await ctx.send(
+ f"{ctx.author.mention} Subscribed to <#{constants.Channels.announcements}> notifications.",
+ )
+
+ @command(name='unsubscribe')
+ @in_whitelist(channels=(constants.Channels.bot_commands,))
+ async def unsubscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args
+ """Unsubscribe from announcement notifications by removing the role from yourself."""
+ has_role = False
+
+ for role in ctx.author.roles:
+ if role.id == constants.Roles.announcements:
+ has_role = True
+ break
+
+ if not has_role:
+ await ctx.send(f"{ctx.author.mention} You're already unsubscribed!")
+ return
+
+ log.debug(f"{ctx.author} called !unsubscribe. Removing the 'Announcements' role.")
+ await ctx.author.remove_roles(
+ discord.Object(constants.Roles.announcements), reason="Unsubscribed from announcements"
+ )
+
+ log.trace(f"Deleting the message posted by {ctx.author}.")
+
+ await ctx.send(
+ f"{ctx.author.mention} Unsubscribed from <#{constants.Channels.announcements}> notifications."
+ )
+
+ # endregion
+ # region: miscellaneous
+
+ # This cannot be static (must have a __func__ attribute).
+ async def cog_command_error(self, ctx: Context, error: Exception) -> None:
+ """Check for & ignore any InWhitelistCheckFailure."""
+ if isinstance(error, InWhitelistCheckFailure):
+ error.handled = True
+
+ @staticmethod
+ async def bot_check(ctx: Context) -> bool:
+ """Block any command within the verification channel that is not !accept."""
+ is_verification = ctx.channel.id == constants.Channels.verification
+ if is_verification and await has_no_roles_check(ctx, *constants.MODERATION_ROLES):
+ return ctx.command.name == "accept"
+ else:
+ return True
+
+ # endregion
+
+
+def setup(bot: Bot) -> None:
+ """Load the Verification cog."""
+ bot.add_cog(Verification(bot))
diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py
new file mode 100644
index 000000000..c2743e136
--- /dev/null
+++ b/bot/exts/moderation/voice_gate.py
@@ -0,0 +1,168 @@
+import asyncio
+import logging
+from contextlib import suppress
+from datetime import datetime, timedelta
+
+import discord
+from dateutil import parser
+from discord import Colour
+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__)
+
+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",
+}
+
+
+class VoiceGate(Cog):
+ """Voice channels verification management."""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+
+ @property
+ def mod_log(self) -> ModLog:
+ """Get the currently loaded ModLog cog instance."""
+ return self.bot.get_cog("ModLog")
+
+ @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
+ """
+ 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"]
+ }
+ 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 bot sent message, delete it after some time
+ if message.author.bot:
+ 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()
+
+ 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/moderation/watchchannels/__init__.py b/bot/exts/moderation/watchchannels/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/bot/exts/moderation/watchchannels/__init__.py
diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/exts/moderation/watchchannels/_watchchannel.py
index 7c58a0fb5..7118dee02 100644
--- a/bot/cogs/watchchannels/watchchannel.py
+++ b/bot/exts/moderation/watchchannels/_watchchannel.py
@@ -14,8 +14,10 @@ from discord.ext.commands import Cog, Context
from bot.api import ResponseCodeError
from bot.bot import Bot
-from bot.cogs.moderation import ModLog
from bot.constants import BigBrother as BigBrotherConfig, Guild as GuildConfig, Icons
+from bot.exts.filters.token_remover import TokenRemover
+from bot.exts.filters.webhook_remover import WEBHOOK_URL_RE
+from bot.exts.moderation.modlog import ModLog
from bot.pagination import LinePaginator
from bot.utils import CogABCMeta, messages
from bot.utils.time import time_since
@@ -226,14 +228,16 @@ class WatchChannel(metaclass=CogABCMeta):
await self.send_header(msg)
- cleaned_content = msg.clean_content
-
- if cleaned_content:
+ if TokenRemover.find_token_in_message(msg) or WEBHOOK_URL_RE.search(msg.content):
+ cleaned_content = "Content is censored because it contains a bot or webhook token."
+ elif cleaned_content := msg.clean_content:
# Put all non-media URLs in a code block to prevent embeds
media_urls = {embed.url for embed in msg.embeds if embed.type in ("image", "video")}
for url in URL_RE.findall(cleaned_content):
if url not in media_urls:
cleaned_content = cleaned_content.replace(url, f"`{url}`")
+
+ if cleaned_content:
await self.webhook_send(
cleaned_content,
username=msg.author.display_name,
@@ -287,10 +291,14 @@ class WatchChannel(metaclass=CogABCMeta):
await self.webhook_send(embed=embed, username=msg.author.display_name, avatar_url=msg.author.avatar_url)
- async def list_watched_users(self, ctx: Context, update_cache: bool = True) -> None:
+ async def list_watched_users(
+ self, ctx: Context, oldest_first: bool = False, update_cache: bool = True
+ ) -> None:
"""
Gives an overview of the watched user list for this channel.
+ The optional kwarg `oldest_first` orders the list by oldest entry.
+
The optional kwarg `update_cache` specifies whether the cache should
be refreshed by polling the API.
"""
@@ -305,7 +313,11 @@ class WatchChannel(metaclass=CogABCMeta):
time_delta = self._get_time_delta(inserted_at)
lines.append(f"• <@{user_id}> (added {time_delta})")
+ if oldest_first:
+ lines.reverse()
+
lines = lines or ("There's nothing here yet.",)
+
embed = Embed(
title=f"{self.__class__.__name__} watched users ({'updated' if update_cache else 'cached'})",
color=Color.blue()
diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/exts/moderation/watchchannels/bigbrother.py
index 702d371f4..3b44056d3 100644
--- a/bot/cogs/watchchannels/bigbrother.py
+++ b/bot/exts/moderation/watchchannels/bigbrother.py
@@ -2,14 +2,13 @@ import logging
import textwrap
from collections import ChainMap
-from discord.ext.commands import Cog, Context, group
+from discord.ext.commands import Cog, Context, group, has_any_role
from bot.bot import Bot
-from bot.cogs.moderation.utils import post_infraction
from bot.constants import Channels, MODERATION_ROLES, Webhooks
from bot.converters import FetchedMember
-from bot.decorators import with_role
-from .watchchannel import WatchChannel
+from bot.exts.moderation.infraction._utils import post_infraction
+from bot.exts.moderation.watchchannels._watchchannel import WatchChannel
log = logging.getLogger(__name__)
@@ -28,24 +27,39 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"):
)
@group(name='bigbrother', aliases=('bb',), invoke_without_command=True)
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
async def bigbrother_group(self, ctx: Context) -> None:
"""Monitors users by relaying their messages to the Big Brother watch channel."""
await ctx.send_help(ctx.command)
@bigbrother_group.command(name='watched', aliases=('all', 'list'))
- @with_role(*MODERATION_ROLES)
- async def watched_command(self, ctx: Context, update_cache: bool = True) -> None:
+ @has_any_role(*MODERATION_ROLES)
+ async def watched_command(
+ self, ctx: Context, oldest_first: bool = False, update_cache: bool = True
+ ) -> None:
"""
Shows the users that are currently being monitored by Big Brother.
+ The optional kwarg `oldest_first` can be used to order the list by oldest watched.
+
The optional kwarg `update_cache` can be used to update the user
cache using the API before listing the users.
"""
- await self.list_watched_users(ctx, update_cache)
+ await self.list_watched_users(ctx, oldest_first=oldest_first, update_cache=update_cache)
+
+ @bigbrother_group.command(name='oldest')
+ @has_any_role(*MODERATION_ROLES)
+ async def oldest_command(self, ctx: Context, update_cache: bool = True) -> None:
+ """
+ Shows Big Brother monitored users ordered by oldest watched.
- @bigbrother_group.command(name='watch', aliases=('w',))
- @with_role(*MODERATION_ROLES)
+ The optional kwarg `update_cache` can be used to update the user
+ cache using the API before listing the users.
+ """
+ await ctx.invoke(self.watched_command, oldest_first=True, update_cache=update_cache)
+
+ @bigbrother_group.command(name='watch', aliases=('w',), root_aliases=('watch',))
+ @has_any_role(*MODERATION_ROLES)
async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:
"""
Relay messages sent by the given `user` to the `#big-brother` channel.
@@ -55,8 +69,8 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"):
"""
await self.apply_watch(ctx, user, reason)
- @bigbrother_group.command(name='unwatch', aliases=('uw',))
- @with_role(*MODERATION_ROLES)
+ @bigbrother_group.command(name='unwatch', aliases=('uw',), root_aliases=('unwatch',))
+ @has_any_role(*MODERATION_ROLES)
async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:
"""Stop relaying messages by the given `user`."""
await self.apply_unwatch(ctx, user, reason)
@@ -116,8 +130,8 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"):
active_watches = await self.bot.api_client.get(
self.api_endpoint,
params=ChainMap(
+ {"user__id": str(user.id)},
self.api_default_params,
- {"user__id": str(user.id)}
)
)
if active_watches:
@@ -148,3 +162,8 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"):
message = ":x: The specified user is currently not being watched."
await ctx.send(message)
+
+
+def setup(bot: Bot) -> None:
+ """Load the BigBrother cog."""
+ bot.add_cog(BigBrother(bot))
diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/exts/moderation/watchchannels/talentpool.py
index 33550f68e..a77dbe156 100644
--- a/bot/cogs/watchchannels/talentpool.py
+++ b/bot/exts/moderation/watchchannels/talentpool.py
@@ -1,18 +1,18 @@
import logging
import textwrap
from collections import ChainMap
+from typing import Union
-from discord import Color, Embed, Member
-from discord.ext.commands import Cog, Context, group
+from discord import Color, Embed, Member, User
+from discord.ext.commands import Cog, Context, group, has_any_role
from bot.api import ResponseCodeError
from bot.bot import Bot
from bot.constants import Channels, Guild, MODERATION_ROLES, STAFF_ROLES, Webhooks
from bot.converters import FetchedMember
-from bot.decorators import with_role
+from bot.exts.moderation.watchchannels._watchchannel import WatchChannel
from bot.pagination import LinePaginator
from bot.utils import time
-from .watchchannel import WatchChannel
log = logging.getLogger(__name__)
@@ -31,24 +31,39 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
)
@group(name='talentpool', aliases=('tp', 'talent', 'nomination', 'n'), invoke_without_command=True)
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
async def nomination_group(self, ctx: Context) -> None:
"""Highlights the activity of helper nominees by relaying their messages to the talent pool channel."""
await ctx.send_help(ctx.command)
- @nomination_group.command(name='watched', aliases=('all', 'list'))
- @with_role(*MODERATION_ROLES)
- async def watched_command(self, ctx: Context, update_cache: bool = True) -> None:
+ @nomination_group.command(name='watched', aliases=('all', 'list'), root_aliases=("nominees",))
+ @has_any_role(*MODERATION_ROLES)
+ async def watched_command(
+ self, ctx: Context, oldest_first: bool = False, update_cache: bool = True
+ ) -> None:
"""
Shows the users that are currently being monitored in the talent pool.
+ The optional kwarg `oldest_first` can be used to order the list by oldest nomination.
+
The optional kwarg `update_cache` can be used to update the user
cache using the API before listing the users.
"""
- await self.list_watched_users(ctx, update_cache)
+ await self.list_watched_users(ctx, oldest_first=oldest_first, update_cache=update_cache)
+
+ @nomination_group.command(name='oldest')
+ @has_any_role(*MODERATION_ROLES)
+ async def oldest_command(self, ctx: Context, update_cache: bool = True) -> None:
+ """
+ Shows talent pool monitored users ordered by oldest nomination.
- @nomination_group.command(name='watch', aliases=('w', 'add', 'a'))
- @with_role(*STAFF_ROLES)
+ The optional kwarg `update_cache` can be used to update the user
+ cache using the API before listing the users.
+ """
+ await ctx.invoke(self.watched_command, oldest_first=True, update_cache=update_cache)
+
+ @nomination_group.command(name='watch', aliases=('w', 'add', 'a'), root_aliases=("nominate",))
+ @has_any_role(*STAFF_ROLES)
async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:
"""
Relay messages sent by the given `user` to the `#talent-pool` channel.
@@ -113,7 +128,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
await ctx.send(msg)
@nomination_group.command(name='history', aliases=('info', 'search'))
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
async def history_command(self, ctx: Context, user: FetchedMember) -> None:
"""Shows the specified user's nomination history."""
result = await self.bot.api_client.get(
@@ -141,42 +156,27 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
max_size=1000
)
- @nomination_group.command(name='unwatch', aliases=('end', ))
- @with_role(*MODERATION_ROLES)
+ @nomination_group.command(name='unwatch', aliases=('end', ), root_aliases=("unnominate",))
+ @has_any_role(*MODERATION_ROLES)
async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:
"""
Ends the active nomination of the specified user with the given reason.
Providing a `reason` is required.
"""
- active_nomination = await self.bot.api_client.get(
- self.api_endpoint,
- params=ChainMap(
- self.api_default_params,
- {"user__id": str(user.id)}
- )
- )
-
- if not active_nomination:
+ if await self.unwatch(user.id, reason):
+ await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed")
+ else:
await ctx.send(":x: The specified user does not have an active nomination")
- return
-
- [nomination] = active_nomination
- await self.bot.api_client.patch(
- f"{self.api_endpoint}/{nomination['id']}",
- json={'end_reason': reason, 'active': False}
- )
- await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed")
- self._remove_user(user.id)
@nomination_group.group(name='edit', aliases=('e',), invoke_without_command=True)
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
async def nomination_edit_group(self, ctx: Context) -> None:
"""Commands to edit nominations."""
await ctx.send_help(ctx.command)
@nomination_edit_group.command(name='reason')
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
async def edit_reason_command(self, ctx: Context, nomination_id: int, *, reason: str) -> None:
"""
Edits the reason/unnominate reason for the nomination with the given `id` depending on the status.
@@ -205,6 +205,36 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
await ctx.send(f":white_check_mark: Updated the {field} of the nomination!")
+ @Cog.listener()
+ async def on_member_ban(self, guild: Guild, user: Union[User, Member]) -> None:
+ """Remove `user` from the talent pool after they are banned."""
+ await self.unwatch(user.id, "User was banned.")
+
+ async def unwatch(self, user_id: int, reason: str) -> bool:
+ """End the active nomination of a user with the given reason and return True on success."""
+ active_nomination = await self.bot.api_client.get(
+ self.api_endpoint,
+ params=ChainMap(
+ {"user__id": str(user_id)},
+ self.api_default_params,
+ )
+ )
+
+ if not active_nomination:
+ log.debug(f"No active nominate exists for {user_id=}")
+ return False
+
+ log.info(f"Ending nomination: {user_id=} {reason=}")
+
+ nomination = active_nomination[0]
+ await self.bot.api_client.patch(
+ f"{self.api_endpoint}/{nomination['id']}",
+ json={'end_reason': reason, 'active': False}
+ )
+ self._remove_user(user_id)
+
+ return True
+
def _nomination_to_string(self, nomination_object: dict) -> str:
"""Creates a string representation of a nomination."""
guild = self.bot.get_guild(Guild.id)
@@ -247,3 +277,8 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
)
return lines.strip()
+
+
+def setup(bot: Bot) -> None:
+ """Load the TalentPool cog."""
+ bot.add_cog(TalentPool(bot))
diff --git a/bot/exts/utils/__init__.py b/bot/exts/utils/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/bot/exts/utils/__init__.py
diff --git a/bot/exts/utils/bot.py b/bot/exts/utils/bot.py
new file mode 100644
index 000000000..69d623581
--- /dev/null
+++ b/bot/exts/utils/bot.py
@@ -0,0 +1,66 @@
+import logging
+from typing import Optional
+
+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 Guild, MODERATION_ROLES, Roles, URLs
+
+log = logging.getLogger(__name__)
+
+
+class BotCog(Cog, name="Bot"):
+ """Bot information commands."""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+
+ @group(invoke_without_command=True, name="bot", hidden=True)
+ @has_any_role(Roles.verified)
+ async def botinfo_group(self, ctx: Context) -> None:
+ """Bot informational commands."""
+ await ctx.send_help(ctx.command)
+
+ @botinfo_group.command(name='about', aliases=('info',), hidden=True)
+ @has_any_role(Roles.verified)
+ async def about_command(self, ctx: Context) -> None:
+ """Get information about the bot."""
+ embed = Embed(
+ description="A utility bot designed just for the Python server! Try `!help` for more info.",
+ url="https://github.com/python-discord/bot"
+ )
+
+ embed.add_field(name="Total Users", value=str(len(self.bot.get_guild(Guild.id).members)))
+ embed.set_author(
+ name="Python Bot",
+ url="https://github.com/python-discord/bot",
+ icon_url=URLs.bot_avatar
+ )
+
+ await ctx.send(embed=embed)
+
+ @command(name='echo', aliases=('print',))
+ @has_any_role(*MODERATION_ROLES)
+ async def echo_command(self, ctx: Context, channel: Optional[TextChannel], *, text: str) -> None:
+ """Repeat the given message in either a specified channel or the current channel."""
+ if channel is None:
+ await ctx.send(text)
+ else:
+ await channel.send(text)
+
+ @command(name='embed')
+ @has_any_role(*MODERATION_ROLES)
+ async def embed_command(self, ctx: Context, channel: Optional[TextChannel], *, text: str) -> None:
+ """Send the input within an embed to either a specified channel or the current channel."""
+ embed = Embed(description=text)
+
+ if channel is None:
+ await ctx.send(embed=embed)
+ else:
+ await channel.send(embed=embed)
+
+
+def setup(bot: Bot) -> None:
+ """Load the Bot cog."""
+ bot.add_cog(BotCog(bot))
diff --git a/bot/cogs/clean.py b/bot/exts/utils/clean.py
index 368d91c85..bf25cb4c2 100644
--- a/bot/cogs/clean.py
+++ b/bot/exts/utils/clean.py
@@ -5,14 +5,13 @@ from typing import Iterable, Optional
from discord import Colour, Embed, Message, TextChannel, User
from discord.ext import commands
-from discord.ext.commands import Cog, Context, group
+from discord.ext.commands import Cog, Context, group, has_any_role
from bot.bot import Bot
-from bot.cogs.moderation import ModLog
from bot.constants import (
Channels, CleanMessages, Colours, Event, Icons, MODERATION_ROLES, NEGATIVE_REPLIES
)
-from bot.decorators import with_role
+from bot.exts.moderation.modlog import ModLog
log = logging.getLogger(__name__)
@@ -45,6 +44,7 @@ class Clean(Cog):
bots_only: bool = False,
user: User = None,
regex: Optional[str] = None,
+ until_message: Optional[Message] = None,
) -> None:
"""A helper function that does the actual message cleaning."""
def predicate_bots_only(message: Message) -> bool:
@@ -129,6 +129,20 @@ class Clean(Cog):
if not self.cleaning:
return
+ # If we are looking for specific message.
+ if until_message:
+
+ # we could use ID's here however in case if the message we are looking for gets deleted,
+ # we won't have a way to figure that out thus checking for datetime should be more reliable
+ if message.created_at < until_message.created_at:
+ # means we have found the message until which we were supposed to be deleting.
+ break
+
+ # Since we will be using `delete_messages` method of a TextChannel and we need message objects to
+ # use it as well as to send logs we will start appending messages here instead adding them from
+ # purge.
+ messages.append(message)
+
# If the message passes predicate, let's save it.
if predicate is None or predicate(message):
message_ids.append(message.id)
@@ -138,7 +152,14 @@ class Clean(Cog):
# Now let's delete the actual messages with purge.
self.mod_log.ignore(Event.message_delete, *message_ids)
for channel in channels:
- messages += await channel.purge(limit=amount, check=predicate)
+ if until_message:
+ for i in range(0, len(messages), 100):
+ # while purge automatically handles the amount of messages
+ # delete_messages only allows for up to 100 messages at once
+ # thus we need to paginate the amount to always be <= 100
+ await channel.delete_messages(messages[i:i + 100])
+ else:
+ messages += await channel.purge(limit=amount, check=predicate)
# Reverse the list to restore chronological order
if messages:
@@ -157,7 +178,8 @@ class Clean(Cog):
target_channels = ", ".join(channel.mention for channel in channels)
message = (
- f"**{len(message_ids)}** messages deleted in {target_channels} by **{ctx.author.name}**\n\n"
+ f"**{len(message_ids)}** messages deleted in {target_channels} by "
+ f"{ctx.author.mention}\n\n"
f"A log of the deleted messages can be found [here]({log_url})."
)
@@ -170,13 +192,13 @@ class Clean(Cog):
)
@group(invoke_without_command=True, name="clean", aliases=["purge"])
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
async def clean_group(self, ctx: Context) -> None:
"""Commands for cleaning messages in channels."""
await ctx.send_help(ctx.command)
@clean_group.command(name="user", aliases=["users"])
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
async def clean_user(
self,
ctx: Context,
@@ -188,7 +210,7 @@ class Clean(Cog):
await self._clean_messages(amount, ctx, user=user, channels=channels)
@clean_group.command(name="all", aliases=["everything"])
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
async def clean_all(
self,
ctx: Context,
@@ -199,7 +221,7 @@ class Clean(Cog):
await self._clean_messages(amount, ctx, channels=channels)
@clean_group.command(name="bots", aliases=["bot"])
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
async def clean_bots(
self,
ctx: Context,
@@ -210,7 +232,7 @@ class Clean(Cog):
await self._clean_messages(amount, ctx, bots_only=True, channels=channels)
@clean_group.command(name="regex", aliases=["word", "expression"])
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
async def clean_regex(
self,
ctx: Context,
@@ -221,8 +243,19 @@ class Clean(Cog):
"""Delete all messages that match a certain regex, stop cleaning after traversing `amount` messages."""
await self._clean_messages(amount, ctx, regex=regex, channels=channels)
+ @clean_group.command(name="message", aliases=["messages"])
+ @has_any_role(*MODERATION_ROLES)
+ async def clean_message(self, ctx: Context, message: Message) -> None:
+ """Delete all messages until certain message, stop cleaning after hitting the `message`."""
+ await self._clean_messages(
+ CleanMessages.message_limit,
+ ctx,
+ channels=[message.channel],
+ until_message=message
+ )
+
@clean_group.command(name="stop", aliases=["cancel", "abort"])
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
async def clean_cancel(self, ctx: Context) -> None:
"""If there is an ongoing cleaning process, attempt to immediately cancel it."""
self.cleaning = False
diff --git a/bot/cogs/extensions.py b/bot/exts/utils/extensions.py
index 365f198ff..418db0150 100644
--- a/bot/cogs/extensions.py
+++ b/bot/exts/utils/extensions.py
@@ -2,25 +2,22 @@ import functools
import logging
import typing as t
from enum import Enum
-from pkgutil import iter_modules
from discord import Colour, Embed
from discord.ext import commands
from discord.ext.commands import Context, group
+from bot import exts
from bot.bot import Bot
from bot.constants import Emojis, MODERATION_ROLES, Roles, URLs
from bot.pagination import LinePaginator
-from bot.utils.checks import with_role_check
+from bot.utils.extensions import EXTENSIONS, unqualify
log = logging.getLogger(__name__)
-UNLOAD_BLACKLIST = {"bot.cogs.extensions", "bot.cogs.modlog"}
-EXTENSIONS = frozenset(
- ext.name
- for ext in iter_modules(("bot/cogs",), "bot.cogs.")
- if ext.name[-1] != "_"
-)
+
+UNLOAD_BLACKLIST = {f"{exts.__name__}.utils.extensions", f"{exts.__name__}.moderation.modlog"}
+BASE_PATH_LEN = len(exts.__name__.split("."))
class Action(Enum):
@@ -47,11 +44,25 @@ class Extension(commands.Converter):
argument = argument.lower()
- if "." not in argument:
- argument = f"bot.cogs.{argument}"
-
if argument in EXTENSIONS:
return argument
+ elif (qualified_arg := f"{exts.__name__}.{argument}") in EXTENSIONS:
+ return qualified_arg
+
+ matches = []
+ for ext in EXTENSIONS:
+ if argument == unqualify(ext):
+ matches.append(ext)
+
+ if len(matches) > 1:
+ matches.sort()
+ names = "\n".join(matches)
+ raise commands.BadArgument(
+ f":x: `{argument}` is an ambiguous extension name. "
+ f"Please use one of the following fully-qualified names.```\n{names}```"
+ )
+ elif matches:
+ return matches[0]
else:
raise commands.BadArgument(f":x: Could not find the extension `{argument}`.")
@@ -107,7 +118,7 @@ class Extensions(commands.Cog):
await ctx.send(msg)
- @extensions_group.command(name="reload", aliases=("r",))
+ @extensions_group.command(name="reload", aliases=("r",), root_aliases=("reload",))
async def reload_command(self, ctx: Context, *extensions: Extension) -> None:
r"""
Reload extensions given their fully qualified or unqualified names.
@@ -139,27 +150,44 @@ class Extensions(commands.Cog):
Grey indicates that the extension is unloaded.
Green indicates that the extension is currently loaded.
"""
- embed = Embed()
- lines = []
-
- embed.colour = Colour.blurple()
+ embed = Embed(colour=Colour.blurple())
embed.set_author(
name="Extensions List",
url=URLs.github_bot_repo,
icon_url=URLs.bot_avatar
)
- for ext in sorted(list(EXTENSIONS)):
+ lines = []
+ categories = self.group_extension_statuses()
+ for category, extensions in sorted(categories.items()):
+ # Treat each category as a single line by concatenating everything.
+ # This ensures the paginator will not cut off a page in the middle of a category.
+ category = category.replace("_", " ").title()
+ extensions = "\n".join(sorted(extensions))
+ lines.append(f"**{category}**\n{extensions}\n")
+
+ log.debug(f"{ctx.author} requested a list of all cogs. Returning a paginated list.")
+ await LinePaginator.paginate(lines, ctx, embed, scale_to_size=700, empty=False)
+
+ def group_extension_statuses(self) -> t.Mapping[str, str]:
+ """Return a mapping of extension names and statuses to their categories."""
+ categories = {}
+
+ for ext in EXTENSIONS:
if ext in self.bot.extensions:
status = Emojis.status_online
else:
status = Emojis.status_offline
- ext = ext.rsplit(".", 1)[1]
- lines.append(f"{status} {ext}")
+ path = ext.split(".")
+ if len(path) > BASE_PATH_LEN + 1:
+ category = " - ".join(path[BASE_PATH_LEN:-1])
+ else:
+ category = "uncategorised"
- log.debug(f"{ctx.author} requested a list of all cogs. Returning a paginated list.")
- await LinePaginator.paginate(lines, ctx, embed, max_size=300, empty=False)
+ categories.setdefault(category, []).append(f"{status} {path[-1]}")
+
+ return categories
def batch_manage(self, action: Action, *extensions: str) -> str:
"""
@@ -219,9 +247,9 @@ class Extensions(commands.Cog):
return msg, error_msg
# This cannot be static (must have a __func__ attribute).
- def cog_check(self, ctx: Context) -> bool:
+ async def cog_check(self, ctx: Context) -> bool:
"""Only allow moderators and core developers to invoke the commands in this cog."""
- return with_role_check(ctx, *MODERATION_ROLES, Roles.core_developers)
+ return await commands.has_any_role(*MODERATION_ROLES, Roles.core_developers).predicate(ctx)
# This cannot be static (must have a __func__ attribute).
async def cog_command_error(self, ctx: Context, error: Exception) -> None:
diff --git a/bot/cogs/eval.py b/bot/exts/utils/internal.py
index eb8bfb1cf..1b4900f42 100644
--- a/bot/cogs/eval.py
+++ b/bot/exts/utils/internal.py
@@ -5,22 +5,24 @@ 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
import discord
-from discord.ext.commands import Cog, Context, group
+from discord.ext.commands import Cog, Context, group, has_any_role
from bot.bot import Bot
from bot.constants import Roles
-from bot.decorators import with_role
from bot.interpreter import Interpreter
+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
@@ -30,6 +32,17 @@ class CodeEval(Cog):
self.interpreter = Interpreter(bot)
+ 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."""
self._ = out
@@ -171,17 +184,41 @@ async def func(): # (None,) -> Any
res = traceback.format_exc()
out, embed = self._format(code, res)
+ out = out.rstrip("\n") # Strip empty lines from output
+
+ # Truncate output to max 15 lines or 1500 characters
+ newline_truncate_index = find_nth_occurrence(out, "\n", 15)
+
+ if newline_truncate_index is None or newline_truncate_index > 1500:
+ truncate_index = 1500
+ else:
+ truncate_index = newline_truncate_index
+
+ if len(out) > truncate_index:
+ paste_link = await send_to_paste_service(self.bot.http_session, out, extension="py")
+ if paste_link is not None:
+ paste_text = f"full contents at {paste_link}"
+ else:
+ paste_text = "failed to upload contents to paste service."
+
+ await ctx.send(
+ f"```py\n{out[:truncate_index]}\n```"
+ f"... response truncated; {paste_text}",
+ embed=embed
+ )
+ return
+
await ctx.send(f"```py\n{out}```", embed=embed)
@group(name='internal', aliases=('int',))
- @with_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:
await ctx.send_help(ctx.command)
@internal_group.command(name='eval', aliases=('e',))
- @with_role(Roles.admins, Roles.owners)
+ @has_any_role(Roles.admins, Roles.owners)
async def eval(self, ctx: Context, *, code: str) -> None:
"""Run eval in a REPL-like format."""
code = code.strip("`")
@@ -196,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/cogs/jams.py b/bot/exts/utils/jams.py
index 1d062b0c2..1c0988343 100644
--- a/bot/cogs/jams.py
+++ b/bot/exts/utils/jams.py
@@ -1,15 +1,18 @@
import logging
+import typing as t
-from discord import Member, PermissionOverwrite, utils
+from discord import CategoryChannel, Guild, Member, PermissionOverwrite, Role
from discord.ext import commands
from more_itertools import unique_everseen
from bot.bot import Bot
from bot.constants import Roles
-from bot.decorators import with_role
log = logging.getLogger(__name__)
+MAX_CHANNELS = 50
+CATEGORY_NAME = "Code Jam"
+
class CodeJams(commands.Cog):
"""Manages the code-jam related parts of our server."""
@@ -18,7 +21,7 @@ class CodeJams(commands.Cog):
self.bot = bot
@commands.command()
- @with_role(Roles.admins)
+ @commands.has_any_role(Roles.admins)
async def createteam(self, ctx: commands.Context, team_name: str, members: commands.Greedy[Member]) -> None:
"""
Create team channels (voice and text) in the Code Jams category, assign roles, and add overwrites for the team.
@@ -40,22 +43,47 @@ class CodeJams(commands.Cog):
)
return
- code_jam_category = utils.get(ctx.guild.categories, name="Code Jam")
+ team_channel = await self.create_channels(ctx.guild, team_name, members)
+ await self.add_roles(ctx.guild, members)
- if code_jam_category is None:
- log.info("Code Jam category not found, creating it.")
+ await ctx.send(
+ f":ok_hand: Team created: {team_channel}\n"
+ f"**Team Leader:** {members[0].mention}\n"
+ f"**Team Members:** {' '.join(member.mention for member in members[1:])}"
+ )
- category_overwrites = {
- ctx.guild.default_role: PermissionOverwrite(read_messages=False),
- ctx.guild.me: PermissionOverwrite(read_messages=True)
- }
+ async def get_category(self, guild: Guild) -> CategoryChannel:
+ """
+ Return a code jam category.
- code_jam_category = await ctx.guild.create_category_channel(
- "Code Jam",
- overwrites=category_overwrites,
- reason="It's code jam time!"
- )
+ If all categories are full or none exist, create a new category.
+ """
+ for category in guild.categories:
+ # Need 2 available spaces: one for the text channel and one for voice.
+ if category.name == CATEGORY_NAME and MAX_CHANNELS - len(category.channels) >= 2:
+ return category
+
+ return await self.create_category(guild)
+
+ @staticmethod
+ async def create_category(guild: Guild) -> CategoryChannel:
+ """Create a new code jam category and return it."""
+ log.info("Creating a new code jam category.")
+
+ category_overwrites = {
+ guild.default_role: PermissionOverwrite(read_messages=False),
+ guild.me: PermissionOverwrite(read_messages=True)
+ }
+
+ return await guild.create_category_channel(
+ CATEGORY_NAME,
+ overwrites=category_overwrites,
+ reason="It's code jam time!"
+ )
+ @staticmethod
+ def get_overwrites(members: t.List[Member], guild: Guild) -> t.Dict[t.Union[Member, Role], PermissionOverwrite]:
+ """Get code jam team channels permission overwrites."""
# First member is always the team leader
team_channel_overwrites = {
members[0]: PermissionOverwrite(
@@ -64,8 +92,8 @@ class CodeJams(commands.Cog):
manage_webhooks=True,
connect=True
),
- ctx.guild.default_role: PermissionOverwrite(read_messages=False, connect=False),
- ctx.guild.get_role(Roles.verified): PermissionOverwrite(
+ guild.default_role: PermissionOverwrite(read_messages=False, connect=False),
+ guild.get_role(Roles.verified): PermissionOverwrite(
read_messages=False,
connect=False
)
@@ -78,8 +106,16 @@ class CodeJams(commands.Cog):
connect=True
)
+ return team_channel_overwrites
+
+ async def create_channels(self, guild: Guild, team_name: str, members: t.List[Member]) -> str:
+ """Create team text and voice channels. Return the mention for the text channel."""
+ # Get permission overwrites and category
+ team_channel_overwrites = self.get_overwrites(members, guild)
+ code_jam_category = await self.get_category(guild)
+
# Create a text channel for the team
- team_channel = await ctx.guild.create_text_channel(
+ team_channel = await guild.create_text_channel(
team_name,
overwrites=team_channel_overwrites,
category=code_jam_category
@@ -88,26 +124,25 @@ class CodeJams(commands.Cog):
# Create a voice channel for the team
team_voice_name = " ".join(team_name.split("-")).title()
- await ctx.guild.create_voice_channel(
+ await guild.create_voice_channel(
team_voice_name,
overwrites=team_channel_overwrites,
category=code_jam_category
)
+ return team_channel.mention
+
+ @staticmethod
+ async def add_roles(guild: Guild, members: t.List[Member]) -> None:
+ """Assign team leader and jammer roles."""
# Assign team leader role
- await members[0].add_roles(ctx.guild.get_role(Roles.team_leaders))
+ await members[0].add_roles(guild.get_role(Roles.team_leaders))
# Assign rest of roles
- jammer_role = ctx.guild.get_role(Roles.jammers)
+ jammer_role = guild.get_role(Roles.jammers)
for member in members:
await member.add_roles(jammer_role)
- await ctx.send(
- f":ok_hand: Team created: {team_channel.mention}\n"
- f"**Team Leader:** {members[0].mention}\n"
- f"**Team Members:** {' '.join(member.mention for member in members[1:])}"
- )
-
def setup(bot: Bot) -> None:
"""Load the CodeJams cog."""
diff --git a/bot/exts/utils/ping.py b/bot/exts/utils/ping.py
new file mode 100644
index 000000000..572fc934b
--- /dev/null
+++ b/bot/exts/utils/ping.py
@@ -0,0 +1,59 @@
+import socket
+from datetime import datetime
+
+import aioping
+from discord import Embed
+from discord.ext import commands
+
+from bot.bot import Bot
+from bot.constants import Channels, Emojis, STAFF_ROLES, URLs
+from bot.decorators import in_whitelist
+
+DESCRIPTIONS = (
+ "Command processing time",
+ "Python Discord website latency",
+ "Discord API latency"
+)
+ROUND_LATENCY = 3
+
+
+class Latency(commands.Cog):
+ """Getting the latency between the bot and websites."""
+
+ def __init__(self, bot: Bot) -> None:
+ self.bot = bot
+
+ @commands.command()
+ @in_whitelist(channels=(Channels.bot_commands,), roles=STAFF_ROLES)
+ async def ping(self, ctx: commands.Context) -> None:
+ """
+ Gets different measures of latency within the bot.
+
+ Returns bot, Python Discord Site, Discord Protocol latency.
+ """
+ # 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 = f"{bot_ping:.{ROUND_LATENCY}f} ms"
+
+ try:
+ delay = await aioping.ping(URLs.site, family=socket.AddressFamily.AF_INET) * 1000
+ site_ping = f"{delay:.{ROUND_LATENCY}f} ms"
+
+ except TimeoutError:
+ site_ping = f"{Emojis.cross_mark} Connection timed out."
+
+ # Discord Protocol latency return value is in seconds, must be multiplied by 1000 to get milliseconds.
+ discord_ping = f"{self.bot.latency * 1000:.{ROUND_LATENCY}f} ms"
+
+ embed = Embed(title="Pong!")
+
+ for desc, latency in zip(DESCRIPTIONS, [bot_ping, site_ping, discord_ping]):
+ embed.add_field(name=desc, value=latency, inline=False)
+
+ await ctx.send(embed=embed)
+
+
+def setup(bot: Bot) -> None:
+ """Load the Latency cog."""
+ bot.add_cog(Latency(bot))
diff --git a/bot/cogs/reminders.py b/bot/exts/utils/reminders.py
index c242d2920..3113a1149 100644
--- a/bot/cogs/reminders.py
+++ b/bot/exts/utils/reminders.py
@@ -9,31 +9,40 @@ from operator import itemgetter
import discord
from dateutil.parser import isoparse
from dateutil.relativedelta import relativedelta
-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 Guild, Icons, NEGATIVE_REPLIES, POSITIVE_REPLIES, STAFF_ROLES
+from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, Roles, STAFF_ROLES
from bot.converters import Duration
from bot.pagination import LinePaginator
-from bot.utils.checks import without_role_check
+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, wait_until
+from bot.utils.time import humanize_delta
log = logging.getLogger(__name__)
+LOCK_NAMESPACE = "reminder"
WHITELISTED_CHANNELS = Guild.reminder_whitelist
MAXIMUM_REMINDERS = 5
+Mentionable = t.Union[discord.Member, discord.Role]
-class Reminders(Scheduler, Cog):
+
+class Reminders(Cog):
"""Provide in-channel reminder functionality."""
def __init__(self, bot: Bot):
self.bot = bot
- super().__init__()
+ self.scheduler = Scheduler(self.__class__.__name__)
self.bot.loop.create_task(self.reschedule_reminders())
+ def cog_unload(self) -> None:
+ """Cancel scheduled tasks."""
+ self.scheduler.cancel_all()
+
async def reschedule_reminders(self) -> None:
"""Get all current reminders from the API and reschedule them."""
await self.bot.wait_until_guild_available()
@@ -45,7 +54,7 @@ class Reminders(Scheduler, 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
@@ -56,13 +65,9 @@ class Reminders(Scheduler, Cog):
late = relativedelta(now, remind_at)
await self.send_reminder(reminder, late)
else:
- self.schedule_task(reminder["id"], reminder)
+ 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'])
@@ -73,7 +78,7 @@ class Reminders(Scheduler, 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
@@ -81,7 +86,7 @@ class Reminders(Scheduler, 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."""
@@ -99,38 +104,78 @@ class Reminders(Scheduler, Cog):
await ctx.send(embed=embed)
- async def _scheduled_task(self, reminder: dict) -> None:
- """A coroutine which sends the reminder once the time is reached, and cancels the running task."""
- reminder_id = reminder["id"]
- reminder_datetime = isoparse(reminder['expiration']).replace(tzinfo=None)
+ @staticmethod
+ async def _check_mentions(ctx: Context, mentions: t.Iterable[Mentionable]) -> t.Tuple[bool, str]:
+ """
+ Returns whether or not the list of mentions is allowed.
+
+ Conditions:
+ - Role reminders are Mods+
+ - Reminders for other users are Helpers+
+
+ If mentions aren't allowed, also return the type of mention(s) disallowed.
+ """
+ if await has_no_roles_check(ctx, *STAFF_ROLES):
+ return False, "members/roles"
+ elif await has_no_roles_check(ctx, *MODERATION_ROLES):
+ return all(isinstance(mention, discord.Member) for mention in mentions), "roles"
+ else:
+ return True, ""
- # Send the reminder message once the desired duration has passed
- await wait_until(reminder_datetime)
- await self.send_reminder(reminder)
+ @staticmethod
+ async def validate_mentions(ctx: Context, mentions: t.Iterable[Mentionable]) -> bool:
+ """
+ Filter mentions to see if the user can mention, and sends a denial if not allowed.
- log.debug(f"Deleting reminder {reminder_id} (the user has been reminded).")
- await self._delete_reminder(reminder_id)
+ Returns whether or not the validation is successful.
+ """
+ mentions_allowed, disallowed_mentions = await Reminders._check_mentions(ctx, mentions)
+
+ if not mentions or mentions_allowed:
+ return True
+ else:
+ await send_denial(ctx, f"You can't mention other {disallowed_mentions} in your reminder!")
+ return False
+
+ def get_mentionables(self, mention_ids: t.List[int]) -> t.Iterator[Mentionable]:
+ """Converts Role and Member ids to their corresponding objects if possible."""
+ guild = self.bot.get_guild(Guild.id)
+ for mention_id in mention_ids:
+ if (mentionable := (guild.get_member(mention_id) or guild.get_role(mention_id))):
+ yield mentionable
+
+ def schedule_reminder(self, reminder: dict) -> None:
+ """A coroutine which sends the reminder once the time is reached, and cancels the running task."""
+ reminder_datetime = isoparse(reminder['expiration']).replace(tzinfo=None)
+ self.scheduler.schedule_at(reminder_datetime, reminder["id"], self.send_reminder(reminder))
- 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))
+ async def _edit_reminder(self, reminder_id: int, payload: dict) -> dict:
+ """
+ Edits a reminder in the database given the ID and payload.
- if cancel_task:
- # Now we can remove it from the schedule list
- self.cancel_task(reminder_id)
+ Returns the edited reminder.
+ """
+ # Send the request to update the reminder in the database
+ reminder = await self.bot.api_client.patch(
+ 'bot/reminders/' + str(reminder_id),
+ json=payload
+ )
+ return reminder
async def _reschedule_reminder(self, reminder: dict) -> None:
"""Reschedule a reminder object."""
log.trace(f"Cancelling old task #{reminder['id']}")
- self.cancel_task(reminder["id"])
+ self.scheduler.cancel(reminder["id"])
log.trace(f"Scheduling new task #{reminder['id']}")
- self.schedule_task(reminder["id"], reminder)
+ 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()
@@ -152,36 +197,38 @@ class Reminders(Scheduler, Cog):
name=f"Sorry it arrived {humanize_delta(late, max_units=2)} late!"
)
- await channel.send(
- content=user.mention,
- embed=embed
+ additional_mentions = ' '.join(
+ mentionable.mention for mentionable in self.get_mentionables(reminder["mentions"])
)
- 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, expiration: Duration, *, content: str) -> None:
+ 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, 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(self, ctx: Context, expiration: Duration, *, content: str) -> t.Optional[discord.Message]:
+ async def new_reminder(
+ self, ctx: Context, mentions: Greedy[Mentionable], expiration: Duration, *, content: str
+ ) -> None:
"""
Set yourself a simple reminder.
Expiration is parsed per: http://strftime.org/
"""
- embed = discord.Embed()
-
# If the user is not staff, we need to verify whether or not to make a reminder at all.
- if without_role_check(ctx, *STAFF_ROLES):
+ if await has_no_roles_check(ctx, *STAFF_ROLES):
# If they don't have permission to set a reminder in this channel
if ctx.channel.id not in WHITELISTED_CHANNELS:
- embed.colour = discord.Colour.red()
- embed.title = random.choice(NEGATIVE_REPLIES)
- embed.description = "Sorry, you can't do that here!"
-
- return await ctx.send(embed=embed)
+ await send_denial(ctx, "Sorry, you can't do that here!")
+ return
# Get their current active reminders
active_reminders = await self.bot.api_client.get(
@@ -194,11 +241,18 @@ class Reminders(Scheduler, Cog):
# Let's limit this, so we don't get 10 000
# reminders from kip or something like that :P
if len(active_reminders) > MAXIMUM_REMINDERS:
- embed.colour = discord.Colour.red()
- embed.title = random.choice(NEGATIVE_REPLIES)
- embed.description = "You have too many active reminders!"
+ await send_denial(ctx, "You have too many active reminders!")
+ return
+
+ # Remove duplicate mentions
+ mentions = set(mentions)
+ mentions.discard(ctx.author)
+
+ # Filter mentions to see if the user can mention members/roles
+ if not await self.validate_mentions(ctx, mentions):
+ return
- return await ctx.send(embed=embed)
+ mention_ids = [mention.id for mention in mentions]
# Now we can attempt to actually set the reminder.
reminder = await self.bot.api_client.post(
@@ -208,25 +262,31 @@ class Reminders(Scheduler, Cog):
'channel_id': ctx.message.channel.id,
'jump_url': ctx.message.jump_url,
'content': content,
- 'expiration': expiration.isoformat()
+ 'expiration': expiration.isoformat(),
+ 'mentions': mention_ids,
}
)
now = datetime.utcnow() - timedelta(seconds=1)
humanized_delta = humanize_delta(relativedelta(expiration, now))
+ 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(
ctx,
- on_success=f"Your reminder will arrive in {humanized_delta}!",
+ on_success=mention_string,
reminder_id=reminder["id"],
delivery_dt=expiration,
)
- self.schedule_task(reminder["id"], reminder)
+ self.schedule_reminder(reminder)
@remind_group.command(name="list")
- async def list_reminders(self, ctx: Context) -> t.Optional[discord.Message]:
+ async def list_reminders(self, ctx: Context) -> None:
"""View a paginated embed of all reminders for your user."""
# Get all the user's reminders from the database.
data = await self.bot.api_client.get(
@@ -239,7 +299,7 @@ class Reminders(Scheduler, Cog):
# Make a list of tuples so it can be sorted by time.
reminders = sorted(
(
- (rem['content'], rem['expiration'], rem['id'])
+ (rem['content'], rem['expiration'], rem['id'], rem['mentions'])
for rem in data
),
key=itemgetter(1)
@@ -247,13 +307,19 @@ class Reminders(Scheduler, Cog):
lines = []
- for content, remind_at, id_ in reminders:
+ for content, remind_at, id_, mentions in reminders:
# Parse and humanize the time, make it pretty :D
remind_datetime = isoparse(remind_at).replace(tzinfo=None)
time = humanize_delta(relativedelta(remind_datetime, now))
+ mentions = ", ".join(
+ # Both Role and User objects have the `name` attribute
+ mention.name for mention in self.get_mentionables(mentions)
+ )
+ mention_string = f"\n**Mentions:** {mentions}" if mentions else ""
+
text = textwrap.dedent(f"""
- **Reminder #{id_}:** *expires in {time}* (ID: {id_})
+ **Reminder #{id_}:** *expires in {time}* (ID: {id_}){mention_string}
{content}
""").strip()
@@ -266,7 +332,8 @@ class Reminders(Scheduler, Cog):
# Remind the user that they have no reminders :^)
if not lines:
embed.description = "No active reminders could be found."
- return await ctx.send(embed=embed)
+ await ctx.send(embed=embed)
+ return
# Construct the embed and paginate it.
embed.colour = discord.Colour.blurple()
@@ -286,37 +353,40 @@ class Reminders(Scheduler, Cog):
@edit_reminder_group.command(name="duration", aliases=("time",))
async def edit_reminder_duration(self, ctx: Context, id_: int, expiration: Duration) -> None:
"""
- Edit one of your reminder's expiration.
+ Edit one of your reminder's expiration.
Expiration is parsed per: http://strftime.org/
"""
- # Send the request to update the reminder in the database
- reminder = await self.bot.api_client.patch(
- 'bot/reminders/' + str(id_),
- json={'expiration': expiration.isoformat()}
- )
-
- # Send a confirmation message to the channel
- await self._send_confirmation(
- ctx,
- on_success="That reminder has been edited successfully!",
- reminder_id=id_,
- delivery_dt=expiration,
- )
-
- await self._reschedule_reminder(reminder)
+ await self.edit_reminder(ctx, id_, {'expiration': expiration.isoformat()})
@edit_reminder_group.command(name="content", aliases=("reason",))
async def edit_reminder_content(self, ctx: Context, id_: int, *, content: str) -> None:
"""Edit one of your reminder's content."""
- # Send the request to update the reminder in the database
- reminder = await self.bot.api_client.patch(
- 'bot/reminders/' + str(id_),
- json={'content': content}
- )
+ await self.edit_reminder(ctx, id_, {"content": content})
+
+ @edit_reminder_group.command(name="mentions", aliases=("pings",))
+ async def edit_reminder_mentions(self, ctx: Context, id_: int, mentions: Greedy[Mentionable]) -> None:
+ """Edit one of your reminder's mentions."""
+ # Remove duplicate mentions
+ mentions = set(mentions)
+ mentions.discard(ctx.author)
+
+ # Filter mentions to see if the user can mention members/roles
+ if not await self.validate_mentions(ctx, mentions):
+ return
- # Parse the reminder expiration back into a datetime for the confirmation message
- expiration = isoparse(reminder['expiration']).replace(tzinfo=None)
+ 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_):
+ return
+ reminder = await self._edit_reminder(id_, payload)
+
+ # Parse the reminder expiration back into a datetime
+ expiration = isoparse(reminder["expiration"]).replace(tzinfo=None)
# Send a confirmation message to the channel
await self._send_confirmation(
@@ -328,9 +398,15 @@ class Reminders(Scheduler, 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."""
- await self._delete_reminder(id_)
+ if not await self._can_modify(ctx, id_):
+ return
+
+ 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!",
@@ -338,6 +414,24 @@ class Reminders(Scheduler, Cog):
delivery_dt=None,
)
+ async def _can_modify(self, ctx: Context, reminder_id: t.Union[str, int]) -> bool:
+ """
+ Check whether the reminder can be modified by the ctx author.
+
+ The check passes when the user is an admin, or if they created the reminder.
+ """
+ if await has_any_role_check(ctx, Roles.admins):
+ return True
+
+ api_response = await self.bot.api_client.get(f"bot/reminders/{reminder_id}")
+ if not api_response["author"] == ctx.author.id:
+ log.debug(f"{ctx.author} is not the reminder author and does not pass the check.")
+ await send_denial(ctx, "You can't modify reminders of other users!")
+ return False
+
+ log.debug(f"{ctx.author} is the reminder author and passes the check.")
+ return True
+
def setup(bot: Bot) -> None:
"""Load the Reminders cog."""
diff --git a/bot/cogs/snekbox.py b/bot/exts/utils/snekbox.py
index a2a7574d4..41cb00541 100644
--- a/bot/cogs/snekbox.py
+++ b/bot/exts/utils/snekbox.py
@@ -14,20 +14,19 @@ from discord.ext.commands import Cog, Context, command, guild_only
from bot.bot import Bot
from bot.constants import Categories, Channels, Roles, URLs
from bot.decorators import in_whitelist
+from bot.utils import send_to_paste_service
from bot.utils.messages import wait_for_deletion
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(
@@ -37,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
@@ -71,37 +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"
-
- url = URLs.paste_service.format(key="documents")
- try:
- async with self.bot.http_session.post(url, data=output, raise_for_status=True) as resp:
- data = await resp.json()
-
- if "key" in data:
- return URLs.paste_service.format(key=data["key"])
- except Exception:
- # 400 (Bad Request) means there are too many characters
- log.exception("Failed to upload full output to paste service!")
+ return await send_to_paste_service(self.bot.http_session, 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
@@ -159,6 +157,7 @@ class Snekbox(Cog):
output = output.replace("<!@", "<!@\u200B") # Zero-width space
if ESCAPE_REGEX.findall(output):
+ paste_link = await self.upload_output(original_output)
return "Code block escape attempt detected; will not output result", paste_link
truncated = False
@@ -202,7 +201,7 @@ class Snekbox(Cog):
output, paste_link = await self.format_output(results["stdout"])
icon = self.get_status_emoji(results)
- msg = f"{ctx.author.mention} {icon} {msg}.\n\n```py\n{output}\n```"
+ msg = f"{ctx.author.mention} {icon} {msg}.\n\n```\n{output}\n```"
if paste_link:
msg = f"{msg}\nFull output: {paste_link}"
@@ -212,10 +211,15 @@ class Snekbox(Cog):
else:
self.bot.stats.incr("snekbox.python.success")
- response = await ctx.send(msg)
- self.bot.loop.create_task(
- wait_for_deletion(response, user_ids=(ctx.author.id,), client=ctx.bot)
- )
+ filter_cog = self.bot.get_cog("Filtering")
+ filter_triggered = False
+ if filter_cog:
+ filter_triggered = await filter_cog.filter_eval(msg, ctx.message)
+ if filter_triggered:
+ 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))
log.info(f"{ctx.author}'s job had a return code of {results['returncode']}")
return response
@@ -244,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/cogs/utils.py b/bot/exts/utils/utils.py
index 697bf60ce..3e9230414 100644
--- a/bot/cogs/utils.py
+++ b/bot/exts/utils/utils.py
@@ -7,11 +7,13 @@ from io import StringIO
from typing import Tuple, Union
from discord import Colour, Embed, utils
-from discord.ext.commands import BadArgument, Cog, Context, command
+from discord.ext.commands import BadArgument, Cog, Context, clean_content, command, has_any_role
from bot.bot import Bot
from bot.constants import Channels, MODERATION_ROLES, STAFF_ROLES
-from bot.decorators import in_whitelist, with_role
+from bot.decorators import in_whitelist
+from bot.pagination import LinePaginator
+from bot.utils import messages
log = logging.getLogger(__name__)
@@ -82,7 +84,7 @@ class Utils(Cog):
# Assemble the embed
pep_embed = Embed(
title=f"**PEP {pep_number} - {pep_header['Title']}**",
- description=f"[Link]({self.base_pep_url}{pep_number:04})",
+ url=f"{self.base_pep_url}{pep_number:04}"
)
pep_embed.set_thumbnail(url=ICON_URL)
@@ -117,25 +119,18 @@ class Utils(Cog):
@command()
@in_whitelist(channels=(Channels.bot_commands,), roles=STAFF_ROLES)
async def charinfo(self, ctx: Context, *, characters: str) -> None:
- """Shows you information on up to 25 unicode characters."""
+ """Shows you information on up to 50 unicode characters."""
match = re.match(r"<(a?):(\w+):(\d+)>", characters)
if match:
- embed = Embed(
- title="Non-Character Detected",
- description=(
- "Only unicode characters can be processed, but a custom Discord emoji "
- "was found. Please remove it and try again."
- )
+ return await messages.send_denial(
+ ctx,
+ "**Non-Character Detected**\n"
+ "Only unicode characters can be processed, but a custom Discord emoji "
+ "was found. Please remove it and try again."
)
- embed.colour = Colour.red()
- await ctx.send(embed=embed)
- return
- if len(characters) > 25:
- embed = Embed(title=f"Too many characters ({len(characters)}/25)")
- embed.colour = Colour.red()
- await ctx.send(embed=embed)
- return
+ if len(characters) > 50:
+ return await messages.send_denial(ctx, f"Too many characters ({len(characters)}/50)")
def get_info(char: str) -> Tuple[str, str]:
digit = f"{ord(char):x}"
@@ -148,15 +143,14 @@ class Utils(Cog):
info = f"`{u_code.ljust(10)}`: {name} - {utils.escape_markdown(char)}"
return info, u_code
- charlist, rawlist = zip(*(get_info(c) for c in characters))
-
- embed = Embed(description="\n".join(charlist))
- embed.set_author(name="Character Info")
+ char_list, raw_list = zip(*(get_info(c) for c in characters))
+ embed = Embed().set_author(name="Character Info")
if len(characters) > 1:
- embed.add_field(name='Raw', value=f"`{''.join(rawlist)}`", inline=False)
+ # Maximum length possible is 502 out of 1024, so there's no need to truncate.
+ embed.add_field(name='Full Raw Text', value=f"`{''.join(raw_list)}`", inline=False)
- await ctx.send(embed=embed)
+ await LinePaginator.paginate(char_list, ctx, embed, max_lines=10, max_size=2000, empty=False)
@command()
async def zen(self, ctx: Context, *, search_value: Union[int, str, None] = None) -> None:
@@ -230,14 +224,16 @@ class Utils(Cog):
await ctx.send(embed=embed)
@command(aliases=("poll",))
- @with_role(*MODERATION_ROLES)
- async def vote(self, ctx: Context, title: str, *options: str) -> None:
+ @has_any_role(*MODERATION_ROLES)
+ async def vote(self, ctx: Context, title: clean_content(fix_channel_mentions=True), *options: str) -> None:
"""
Build a quick voting poll with matching reactions with the provided options.
A maximum of 20 options can be provided, as Discord supports a max of 20
reactions on a single message.
"""
+ if len(title) > 256:
+ raise BadArgument("The title cannot be longer than 256 characters.")
if len(options) < 2:
raise BadArgument("Please provide at least 2 options.")
if len(options) > 20:
@@ -254,7 +250,7 @@ class Utils(Cog):
"""Send information 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")
diff --git a/bot/pagination.py b/bot/pagination.py
index 94c2d7c0c..182b2fa76 100644
--- a/bot/pagination.py
+++ b/bot/pagination.py
@@ -313,8 +313,6 @@ class LinePaginator(Paginator):
log.debug(f"Got first page reaction - changing to page 1/{len(paginator.pages)}")
- embed.description = ""
- await message.edit(embed=embed)
embed.description = paginator.pages[current_page]
if footer_text:
embed.set_footer(text=f"{footer_text} (Page {current_page + 1}/{len(paginator.pages)})")
@@ -328,8 +326,6 @@ class LinePaginator(Paginator):
log.debug(f"Got last page reaction - changing to page {current_page + 1}/{len(paginator.pages)}")
- embed.description = ""
- await message.edit(embed=embed)
embed.description = paginator.pages[current_page]
if footer_text:
embed.set_footer(text=f"{footer_text} (Page {current_page + 1}/{len(paginator.pages)})")
@@ -347,8 +343,6 @@ class LinePaginator(Paginator):
current_page -= 1
log.debug(f"Got previous page reaction - changing to page {current_page + 1}/{len(paginator.pages)}")
- embed.description = ""
- await message.edit(embed=embed)
embed.description = paginator.pages[current_page]
if footer_text:
@@ -368,8 +362,6 @@ class LinePaginator(Paginator):
current_page += 1
log.debug(f"Got next page reaction - changing to page {current_page + 1}/{len(paginator.pages)}")
- embed.description = ""
- await message.edit(embed=embed)
embed.description = paginator.pages[current_page]
if footer_text:
@@ -382,169 +374,3 @@ class LinePaginator(Paginator):
log.debug("Ending pagination and clearing reactions.")
with suppress(discord.NotFound):
await message.clear_reactions()
-
-
-class ImagePaginator(Paginator):
- """
- Helper class that paginates images for embeds in messages.
-
- Close resemblance to LinePaginator, except focuses on images over text.
-
- Refer to ImagePaginator.paginate for documentation on how to use.
- """
-
- def __init__(self, prefix: str = "", suffix: str = ""):
- super().__init__(prefix, suffix)
- self._current_page = [prefix]
- self.images = []
- self._pages = []
- self._count = 0
-
- def add_line(self, line: str = '', *, empty: bool = False) -> None:
- """Adds a line to each page."""
- if line:
- self._count = len(line)
- else:
- self._count = 0
- self._current_page.append(line)
- self.close_page()
-
- def add_image(self, image: str = None) -> None:
- """Adds an image to a page."""
- self.images.append(image)
-
- @classmethod
- async def paginate(
- cls,
- pages: t.List[t.Tuple[str, str]],
- ctx: Context, embed: discord.Embed,
- prefix: str = "",
- suffix: str = "",
- timeout: int = 300,
- exception_on_empty_embed: bool = False
- ) -> t.Optional[discord.Message]:
- """
- Use a paginator and set of reactions to provide pagination over a set of title/image pairs.
-
- The reactions are used to switch page, or to finish with pagination.
-
- When used, this will send a message using `ctx.send()` and apply a set of reactions to it. These reactions may
- be used to change page, or to remove pagination from the message.
-
- Note: Pagination will be removed automatically if no reaction is added for five minutes (300 seconds).
-
- Example:
- >>> embed = discord.Embed()
- >>> embed.set_author(name="Some Operation", url=url, icon_url=icon)
- >>> await ImagePaginator.paginate(pages, ctx, embed)
- """
- def check_event(reaction_: discord.Reaction, member: discord.Member) -> bool:
- """Checks each reaction added, if it matches our conditions pass the wait_for."""
- return all((
- # Reaction is on the same message sent
- reaction_.message.id == message.id,
- # The reaction is part of the navigation menu
- str(reaction_.emoji) in PAGINATION_EMOJI,
- # The reactor is not a bot
- not member.bot
- ))
-
- paginator = cls(prefix=prefix, suffix=suffix)
- current_page = 0
-
- if not pages:
- if exception_on_empty_embed:
- log.exception("Pagination asked for empty image list")
- raise EmptyPaginatorEmbed("No images to paginate")
-
- log.debug("No images to add to paginator, adding '(no images to display)' message")
- pages.append(("(no images to display)", ""))
-
- for text, image_url in pages:
- paginator.add_line(text)
- paginator.add_image(image_url)
-
- embed.description = paginator.pages[current_page]
- image = paginator.images[current_page]
-
- if image:
- embed.set_image(url=image)
-
- if len(paginator.pages) <= 1:
- return await ctx.send(embed=embed)
-
- embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}")
- message = await ctx.send(embed=embed)
-
- for emoji in PAGINATION_EMOJI:
- await message.add_reaction(emoji)
-
- while True:
- # Start waiting for reactions
- try:
- reaction, user = await ctx.bot.wait_for("reaction_add", timeout=timeout, check=check_event)
- except asyncio.TimeoutError:
- log.debug("Timed out waiting for a reaction")
- break # We're done, no reactions for the last 5 minutes
-
- # Deletes the users reaction
- await message.remove_reaction(reaction.emoji, user)
-
- # Delete reaction press - [:trashcan:]
- if str(reaction.emoji) == DELETE_EMOJI:
- log.debug("Got delete reaction")
- return await message.delete()
-
- # First reaction press - [:track_previous:]
- if reaction.emoji == FIRST_EMOJI:
- if current_page == 0:
- log.debug("Got first page reaction, but we're on the first page - ignoring")
- continue
-
- current_page = 0
- reaction_type = "first"
-
- # Last reaction press - [:track_next:]
- if reaction.emoji == LAST_EMOJI:
- if current_page >= len(paginator.pages) - 1:
- log.debug("Got last page reaction, but we're on the last page - ignoring")
- continue
-
- current_page = len(paginator.pages) - 1
- reaction_type = "last"
-
- # Previous reaction press - [:arrow_left: ]
- if reaction.emoji == LEFT_EMOJI:
- if current_page <= 0:
- log.debug("Got previous page reaction, but we're on the first page - ignoring")
- continue
-
- current_page -= 1
- reaction_type = "previous"
-
- # Next reaction press - [:arrow_right:]
- if reaction.emoji == RIGHT_EMOJI:
- if current_page >= len(paginator.pages) - 1:
- log.debug("Got next page reaction, but we're on the last page - ignoring")
- continue
-
- current_page += 1
- reaction_type = "next"
-
- # Magic happens here, after page and reaction_type is set
- embed.description = ""
- await message.edit(embed=embed)
- embed.description = paginator.pages[current_page]
-
- image = paginator.images[current_page]
- if image:
- embed.set_image(url=image)
-
- embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}")
- log.debug(f"Got {reaction_type} page reaction - changing to page {current_page + 1}/{len(paginator.pages)}")
-
- await message.edit(embed=embed)
-
- log.debug("Ending pagination and clearing reactions.")
- with suppress(discord.NotFound):
- await message.clear_reactions()
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/ask.md b/bot/resources/tags/ask.md
deleted file mode 100644
index e2c2a88f6..000000000
--- a/bot/resources/tags/ask.md
+++ /dev/null
@@ -1,9 +0,0 @@
-Asking good questions will yield a much higher chance of a quick response:
-
-• Don't ask to ask your question, just go ahead and tell us your problem.
-• Don't ask if anyone is knowledgeable in some area, filtering serves no purpose.
-• Try to solve the problem on your own first, we're not going to write code for you.
-• Show us the code you've tried and any errors or unexpected results it's giving.
-• Be patient while we're helping you.
-
-You can find a much more detailed explanation [on our website](https://pythondiscord.com/pages/asking-good-questions/).
diff --git a/bot/resources/tags/kindling-projects.md b/bot/resources/tags/kindling-projects.md
new file mode 100644
index 000000000..54ed8c961
--- /dev/null
+++ b/bot/resources/tags/kindling-projects.md
@@ -0,0 +1,3 @@
+**Kindling Projects**
+
+The [Kindling projects page](https://nedbatchelder.com/text/kindling.html) on Ned Batchelder's website contains a list of projects and ideas programmers can tackle to build their skills and knowledge.
diff --git a/bot/resources/tags/or-gotcha.md b/bot/resources/tags/or-gotcha.md
index 00c2db1f8..d75a73d78 100644
--- a/bot/resources/tags/or-gotcha.md
+++ b/bot/resources/tags/or-gotcha.md
@@ -3,7 +3,7 @@ When checking if something is equal to one thing or another, you might think tha
if favorite_fruit == 'grapefruit' or 'lemon':
print("That's a weird favorite fruit to have.")
```
-After all, that's how you would normally phrase it in plain English. In Python, however, you have to have _complete instructions on both sides of the logical operator_.
+While this makes sense in English, it may not behave the way you would expect. In Python, you should have _[complete instructions on both sides of the logical operator](https://docs.python.org/3/reference/expressions.html#boolean-operations)_.
So, if you want to check if something is equal to one thing or another, there are two common ways:
```py
diff --git a/bot/resources/tags/range-len.md b/bot/resources/tags/range-len.md
new file mode 100644
index 000000000..65665eccf
--- /dev/null
+++ b/bot/resources/tags/range-len.md
@@ -0,0 +1,11 @@
+Iterating over `range(len(...))` is a common approach to accessing each item in an ordered collection.
+```py
+for i in range(len(my_list)):
+ do_something(my_list[i])
+```
+The pythonic syntax is much simpler, and is guaranteed to produce elements in the same order:
+```py
+for item in my_list:
+ do_something(item)
+```
+Python has other solutions for cases when the index itself might be needed. To get the element at the same index from two or more lists, use [zip](https://docs.python.org/3/library/functions.html#zip). To get both the index and the element at that index, use [enumerate](https://docs.python.org/3/library/functions.html#enumerate).
diff --git a/bot/resources/tags/traceback.md b/bot/resources/tags/traceback.md
index 46ef40aa1..e770fa86d 100644
--- a/bot/resources/tags/traceback.md
+++ b/bot/resources/tags/traceback.md
@@ -11,7 +11,7 @@ ZeroDivisionError: integer division or modulo by zero
```
The best way to read your traceback is bottom to top.
-• Identify the exception raised (e.g. ZeroDivisonError)
+• Identify the exception raised (e.g. ZeroDivisionError)
• Make note of the line number, and navigate there in your program.
• Try to understand why the error occurred.
diff --git a/bot/rules/burst_shared.py b/bot/rules/burst_shared.py
index bbe9271b3..0e66df69c 100644
--- a/bot/rules/burst_shared.py
+++ b/bot/rules/burst_shared.py
@@ -2,11 +2,20 @@ from typing import Dict, Iterable, List, Optional, Tuple
from discord import Member, Message
+from bot.constants import Channels
+
async def apply(
last_message: Message, recent_messages: List[Message], config: Dict[str, int]
) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]:
- """Detects repeated messages sent by multiple users."""
+ """
+ Detects repeated messages sent by multiple users.
+
+ This filter never triggers in the verification channel.
+ """
+ if last_message.channel.id == Channels.verification:
+ return
+
total_recent = len(recent_messages)
if total_recent > config['max']:
diff --git a/bot/rules/discord_emojis.py b/bot/rules/discord_emojis.py
index 5bab514f2..6e47f0197 100644
--- a/bot/rules/discord_emojis.py
+++ b/bot/rules/discord_emojis.py
@@ -5,6 +5,7 @@ from discord import Member, Message
DISCORD_EMOJI_RE = re.compile(r"<:\w+:\d+>")
+CODE_BLOCK_RE = re.compile(r"```.*?```", flags=re.DOTALL)
async def apply(
@@ -17,8 +18,9 @@ async def apply(
if msg.author == last_message.author
)
+ # Get rid of code blocks in the message before searching for emojis.
total_emojis = sum(
- len(DISCORD_EMOJI_RE.findall(msg.content))
+ len(DISCORD_EMOJI_RE.findall(CODE_BLOCK_RE.sub("", msg.content)))
for msg in relevant_messages
)
diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py
index 5a6e1811b..13533a467 100644
--- a/bot/utils/__init__.py
+++ b/bot/utils/__init__.py
@@ -1,18 +1,4 @@
-from abc import ABCMeta
+from bot.utils.helpers import CogABCMeta, find_nth_occurrence, has_lines, pad_base64
+from bot.utils.services import send_to_paste_service
-from discord.ext.commands import CogMeta
-
-from bot.utils.redis_cache import RedisCache
-
-__all__ = ['RedisCache', 'CogABCMeta']
-
-
-class CogABCMeta(CogMeta, ABCMeta):
- """Metaclass for ABCs meant to be implemented as Cogs."""
-
- pass
-
-
-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)
+__all__ = ['CogABCMeta', 'find_nth_occurrence', 'has_lines', 'pad_base64', 'send_to_paste_service']
diff --git a/bot/utils/channel.py b/bot/utils/channel.py
new file mode 100644
index 000000000..6bf70bfde
--- /dev/null
+++ b/bot/utils/channel.py
@@ -0,0 +1,49 @@
+import logging
+
+import discord
+
+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, client: discord.Client) -> discord.abc.GuildChannel:
+ """Attempt to get or fetch a channel and return it."""
+ log.trace(f"Getting the channel {channel_id}.")
+
+ channel = client.get_channel(channel_id)
+ if not channel:
+ log.debug(f"Channel {channel_id} is not in cache; fetching from API.")
+ channel = await client.fetch_channel(channel_id)
+
+ log.trace(f"Channel #{channel} ({channel_id}) retrieved.")
+ return channel
diff --git a/bot/utils/checks.py b/bot/utils/checks.py
index f0ef36302..460a937d8 100644
--- a/bot/utils/checks.py
+++ b/bot/utils/checks.py
@@ -1,6 +1,6 @@
import datetime
import logging
-from typing import Callable, Container, Iterable, Optional
+from typing import Callable, Container, Iterable, Optional, Union
from discord.ext.commands import (
BucketType,
@@ -11,6 +11,8 @@ from discord.ext.commands import (
Context,
Cooldown,
CooldownMapping,
+ NoPrivateMessage,
+ has_any_role,
)
from bot import constants
@@ -89,35 +91,32 @@ def in_whitelist_check(
return False
-def with_role_check(ctx: Context, *role_ids: int) -> bool:
- """Returns True if the user has any one of the roles in role_ids."""
- if not ctx.guild: # Return False in a DM
- log.trace(f"{ctx.author} tried to use the '{ctx.command.name}'command from a DM. "
- "This command is restricted by the with_role decorator. Rejecting request.")
- return False
+async def has_any_role_check(ctx: Context, *roles: Union[str, int]) -> bool:
+ """
+ Returns True if the context's author has any of the specified roles.
- for role in ctx.author.roles:
- if role.id in role_ids:
- log.trace(f"{ctx.author} has the '{role.name}' role, and passes the check.")
- return True
+ `roles` are the names or IDs of the roles for which to check.
+ False is always returns if the context is outside a guild.
+ """
+ try:
+ return await has_any_role(*roles).predicate(ctx)
+ except CheckFailure:
+ return False
- log.trace(f"{ctx.author} does not have the required role to use "
- f"the '{ctx.command.name}' command, so the request is rejected.")
- return False
+async def has_no_roles_check(ctx: Context, *roles: Union[str, int]) -> bool:
+ """
+ Returns True if the context's author doesn't have any of the specified roles.
-def without_role_check(ctx: Context, *role_ids: int) -> bool:
- """Returns True if the user does not have any of the roles in role_ids."""
- if not ctx.guild: # Return False in a DM
- log.trace(f"{ctx.author} tried to use the '{ctx.command.name}' command from a DM. "
- "This command is restricted by the without_role decorator. Rejecting request.")
+ `roles` are the names or IDs of the roles for which to check.
+ False is always returns if the context is outside a guild.
+ """
+ try:
+ return not await has_any_role(*roles).predicate(ctx)
+ except NoPrivateMessage:
return False
-
- author_roles = [role.id for role in ctx.author.roles]
- check = all(role not in author_roles for role in role_ids)
- log.trace(f"{ctx.author} tried to call the '{ctx.command.name}' command. "
- f"The result of the without_role check was {check}.")
- return check
+ except CheckFailure:
+ return True
def cooldown_with_role_bypass(rate: int, per: float, type: BucketType = BucketType.default, *,
diff --git a/bot/utils/extensions.py b/bot/utils/extensions.py
new file mode 100644
index 000000000..50350ea8d
--- /dev/null
+++ b/bot/utils/extensions.py
@@ -0,0 +1,34 @@
+import importlib
+import inspect
+import pkgutil
+from typing import Iterator, NoReturn
+
+from bot import exts
+
+
+def unqualify(name: str) -> str:
+ """Return an unqualified name given a qualified module/package `name`."""
+ return name.rsplit(".", maxsplit=1)[-1]
+
+
+def walk_extensions() -> Iterator[str]:
+ """Yield extension names from the bot.exts subpackage."""
+
+ def on_error(name: str) -> NoReturn:
+ raise ImportError(name=name) # pragma: no cover
+
+ for module in pkgutil.walk_packages(exts.__path__, f"{exts.__name__}.", onerror=on_error):
+ if unqualify(module.name).startswith("_"):
+ # Ignore module/package names starting with an underscore.
+ continue
+
+ if module.ispkg:
+ imported = importlib.import_module(module.name)
+ if not inspect.isfunction(getattr(imported, "setup", None)):
+ # If it lacks a setup function, it's not an extension.
+ continue
+
+ yield module.name
+
+
+EXTENSIONS = frozenset(walk_extensions())
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
new file mode 100644
index 000000000..3501a3933
--- /dev/null
+++ b/bot/utils/helpers.py
@@ -0,0 +1,32 @@
+from abc import ABCMeta
+from typing import Optional
+
+from discord.ext.commands import CogMeta
+
+
+class CogABCMeta(CogMeta, ABCMeta):
+ """Metaclass for ABCs meant to be implemented as Cogs."""
+
+
+def find_nth_occurrence(string: str, substring: str, n: int) -> Optional[int]:
+ """Return index of `n`th occurrence of `substring` in `string`, or None if not found."""
+ index = 0
+ for _ in range(n):
+ index = string.find(substring, index+1)
+ if index == -1:
+ return None
+ 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 a40a12e98..b6c7cab50 100644
--- a/bot/utils/messages.py
+++ b/bot/utils/messages.py
@@ -1,46 +1,46 @@
import asyncio
import contextlib
import logging
+import random
import re
from io import BytesIO
from typing import List, Optional, Sequence, Union
-from discord import Client, Embed, File, Member, Message, Reaction, TextChannel, Webhook
-from discord.abc import Snowflake
+import discord
from discord.errors import HTTPException
+from discord.ext.commands import Context
-from bot.constants import Emojis
+from bot.constants import Emojis, NEGATIVE_REPLIES
log = logging.getLogger(__name__)
async def wait_for_deletion(
- message: Message,
- user_ids: Sequence[Snowflake],
+ 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,
- client: Optional[Client] = None
) -> None:
"""
Wait for up to `timeout` seconds for a reaction by any of the specified `user_ids` to delete the message.
An `attach_emojis` bool may be specified to determine whether to attach the given
- `deletion_emojis` to the message in the given `context`
-
- A `client` instance may be optionally specified, otherwise client will be taken from the
- guild of the message.
+ `deletion_emojis` to the message in the given `context`.
"""
- if message.guild is None and client is None:
+ if message.guild is None:
raise ValueError("Message must be sent on a guild")
- bot = client or message.guild.me
-
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: Reaction, user: Member) -> bool:
+ def check(reaction: discord.Reaction, user: discord.Member) -> bool:
"""Check that the deletion emoji is reacted by the appropriate user."""
return (
reaction.message.id == message.id
@@ -49,22 +49,31 @@ async def wait_for_deletion(
)
with contextlib.suppress(asyncio.TimeoutError):
- await bot.wait_for('reaction_add', check=check, timeout=timeout)
+ await client.wait_for('reaction_add', check=check, timeout=timeout)
await message.delete()
async def send_attachments(
- message: Message,
- destination: Union[TextChannel, Webhook],
- link_large: bool = True
+ message: discord.Message,
+ destination: Union[discord.TextChannel, discord.Webhook],
+ 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:
@@ -78,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)
- attachment_file = File(file, filename=attachment.filename)
+ await attachment.save(file, use_cached=use_cached)
+ attachment_file = discord.File(file, filename=attachment.filename)
- if isinstance(destination, TextChannel):
- msg = await destination.send(file=attachment_file)
+ if isinstance(destination, discord.TextChannel):
+ 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,17 +107,13 @@ async def send_attachments(
if link_large and large:
desc = "\n".join(f"[{attachment.filename}]({attachment.url})" for attachment in large)
- embed = Embed(description=desc)
+ embed = discord.Embed(description=desc)
embed.set_footer(text="Attachments exceed upload size limit.")
- if isinstance(destination, TextChannel):
- await destination.send(embed=embed)
+ if isinstance(destination, discord.TextChannel):
+ 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
@@ -132,3 +133,18 @@ def sub_clyde(username: Optional[str]) -> Optional[str]:
return re.sub(r"(clyd)(e)", replace_e, username, flags=re.I)
else:
return username # Empty string or None
+
+
+async def send_denial(ctx: Context, reason: str) -> None:
+ """Send an embed denying the user with the given reason."""
+ embed = discord.Embed()
+ embed.colour = discord.Colour.red()
+ embed.title = random.choice(NEGATIVE_REPLIES)
+ embed.description = reason
+
+ await ctx.send(embed=embed)
+
+
+def format_user(user: discord.abc.User) -> str:
+ """Return a string for `user` which has their mention and ID."""
+ return f"{user.mention} (`{user.id}`)"
diff --git a/bot/utils/redis_cache.py b/bot/utils/redis_cache.py
deleted file mode 100644
index 58cfe1df5..000000000
--- a/bot/utils/redis_cache.py
+++ /dev/null
@@ -1,415 +0,0 @@
-from __future__ import annotations
-
-import asyncio
-import logging
-from functools import partialmethod
-from typing import Any, Dict, ItemsView, Optional, Tuple, Union
-
-from bot.bot import Bot
-
-log = logging.getLogger(__name__)
-
-# Type aliases
-RedisKeyType = Union[str, int]
-RedisValueType = Union[str, int, float, bool]
-RedisKeyOrValue = Union[RedisKeyType, RedisValueType]
-
-# Prefix tuples
-_PrefixTuple = Tuple[Tuple[str, Any], ...]
-_VALUE_PREFIXES = (
- ("f|", float),
- ("i|", int),
- ("s|", str),
- ("b|", bool),
-)
-_KEY_PREFIXES = (
- ("i|", int),
- ("s|", str),
-)
-
-
-class NoBotInstanceError(RuntimeError):
- """Raised when RedisCache is created without an available bot instance on the owner class."""
-
-
-class NoNamespaceError(RuntimeError):
- """Raised when RedisCache has no namespace, for example if it is not assigned to a class attribute."""
-
-
-class NoParentInstanceError(RuntimeError):
- """Raised when the parent instance is available, for example if called by accessing the parent class directly."""
-
-
-class RedisCache:
- """
- A simplified interface for a Redis connection.
-
- We implement several convenient methods that are fairly similar to have a dict
- behaves, and should be familiar to Python users. The biggest difference is that
- all the public methods in this class are coroutines, and must be awaited.
-
- Because of limitations in Redis, this cache will only accept strings and integers for keys,
- and strings, integers, floats and booleans for values.
-
- Please note that this class MUST be created as a class attribute, and that that class
- must also contain an attribute with an instance of our Bot. See `__get__` and `__set_name__`
- for more information about how this works.
-
- Simple example for how to use this:
-
- class SomeCog(Cog):
- # To initialize a valid RedisCache, just add it as a class attribute here.
- # Do not add it to the __init__ method or anywhere else, it MUST be a class
- # attribute. Do not pass any parameters.
- cache = RedisCache()
-
- async def my_method(self):
-
- # Now we're ready to use the RedisCache.
- # One thing to note here is that this will not work unless
- # we access self.cache through an _instance_ of this class.
- #
- # For example, attempting to use SomeCog.cache will _not_ work,
- # you _must_ instantiate the class first and use that instance.
- #
- # Now we can store some stuff in the cache just by doing this.
- # This data will persist through restarts!
- await self.cache.set("key", "value")
-
- # To get the data, simply do this.
- value = await self.cache.get("key")
-
- # Other methods work more or less like a dictionary.
- # Checking if something is in the cache
- await self.cache.contains("key")
-
- # iterating the cache
- async for key, value in self.cache.items():
- print(value)
-
- # We can even iterate in a comprehension!
- consumed = [value async for key, value in self.cache.items()]
- """
-
- _namespaces = []
-
- def __init__(self) -> None:
- """Initialize the RedisCache."""
- self._namespace = None
- self.bot = None
- self._increment_lock = None
-
- def _set_namespace(self, namespace: str) -> None:
- """Try to set the namespace, but do not permit collisions."""
- log.trace(f"RedisCache setting namespace to {namespace}")
- self._namespaces.append(namespace)
- self._namespace = namespace
-
- @staticmethod
- def _to_typestring(key_or_value: RedisKeyOrValue, prefixes: _PrefixTuple) -> str:
- """Turn a valid Redis type into a typestring."""
- for prefix, _type in prefixes:
- # Convert bools into integers before storing them.
- if type(key_or_value) is bool:
- bool_int = int(key_or_value)
- return f"{prefix}{bool_int}"
-
- # isinstance is a bad idea here, because isintance(False, int) == True.
- if type(key_or_value) is _type:
- return f"{prefix}{key_or_value}"
-
- raise TypeError(f"RedisCache._to_typestring only supports the following: {prefixes}.")
-
- @staticmethod
- def _from_typestring(key_or_value: Union[bytes, str], prefixes: _PrefixTuple) -> RedisKeyOrValue:
- """Deserialize a typestring into a valid Redis type."""
- # Stuff that comes out of Redis will be bytestrings, so let's decode those.
- if isinstance(key_or_value, bytes):
- key_or_value = key_or_value.decode('utf-8')
-
- # Now we convert our unicode string back into the type it originally was.
- for prefix, _type in prefixes:
- if key_or_value.startswith(prefix):
-
- # For booleans, we need special handling because bool("False") is True.
- if prefix == "b|":
- value = key_or_value[len(prefix):]
- return bool(int(value))
-
- # Otherwise we can just convert normally.
- return _type(key_or_value[len(prefix):])
- raise TypeError(f"RedisCache._from_typestring only supports the following: {prefixes}.")
-
- # Add some nice partials to call our generic typestring converters.
- # These are basically methods that will fill in some of the parameters for you, so that
- # any call to _key_to_typestring will be like calling _to_typestring with the two parameters
- # at `prefixes` and `types_string` pre-filled.
- #
- # See https://docs.python.org/3/library/functools.html#functools.partialmethod
- _key_to_typestring = partialmethod(_to_typestring, prefixes=_KEY_PREFIXES)
- _value_to_typestring = partialmethod(_to_typestring, prefixes=_VALUE_PREFIXES)
- _key_from_typestring = partialmethod(_from_typestring, prefixes=_KEY_PREFIXES)
- _value_from_typestring = partialmethod(_from_typestring, prefixes=_VALUE_PREFIXES)
-
- def _dict_from_typestring(self, dictionary: Dict) -> Dict:
- """Turns all contents of a dict into valid Redis types."""
- return {self._key_from_typestring(key): self._value_from_typestring(value) for key, value in dictionary.items()}
-
- def _dict_to_typestring(self, dictionary: Dict) -> Dict:
- """Turns all contents of a dict into typestrings."""
- return {self._key_to_typestring(key): self._value_to_typestring(value) for key, value in dictionary.items()}
-
- async def _validate_cache(self) -> None:
- """Validate that the RedisCache is ready to be used."""
- if self._namespace is None:
- error_message = (
- "Critical error: RedisCache has no namespace. "
- "This object must be initialized as a class attribute."
- )
- log.error(error_message)
- raise NoNamespaceError(error_message)
-
- if self.bot is None:
- error_message = (
- "Critical error: RedisCache has no `Bot` instance. "
- "This happens when the class RedisCache was created in doesn't "
- "have a Bot instance. Please make sure that you're instantiating "
- "the RedisCache inside a class that has a Bot instance attribute."
- )
- log.error(error_message)
- raise NoBotInstanceError(error_message)
-
- if not self.bot.redis_closed:
- await self.bot.redis_ready.wait()
-
- def __set_name__(self, owner: Any, attribute_name: str) -> None:
- """
- Set the namespace to Class.attribute_name.
-
- Called automatically when this class is constructed inside a class as an attribute.
-
- This class MUST be created as a class attribute in a class, otherwise it will raise
- exceptions whenever a method is used. This is because it uses this method to create
- a namespace like `MyCog.my_class_attribute` which is used as a hash name when we store
- stuff in Redis, to prevent collisions.
- """
- self._set_namespace(f"{owner.__name__}.{attribute_name}")
-
- def __get__(self, instance: RedisCache, owner: Any) -> RedisCache:
- """
- This is called if the RedisCache is a class attribute, and is accessed.
-
- The class this object is instantiated in must contain an attribute with an
- instance of Bot. This is because Bot contains our redis_session, which is
- the mechanism by which we will communicate with the Redis server.
-
- Any attempt to use RedisCache in a class that does not have a Bot instance
- will fail. It is mostly intended to be used inside of a Cog, although theoretically
- it should work in any class that has a Bot instance.
- """
- if self.bot:
- return self
-
- if self._namespace is None:
- error_message = "RedisCache must be a class attribute."
- log.error(error_message)
- raise NoNamespaceError(error_message)
-
- if instance is None:
- error_message = (
- "You must access the RedisCache instance through the cog instance "
- "before accessing it using the cog's class object."
- )
- log.error(error_message)
- raise NoParentInstanceError(error_message)
-
- for attribute in vars(instance).values():
- if isinstance(attribute, Bot):
- self.bot = attribute
- self._redis = self.bot.redis_session
- return self
- else:
- error_message = (
- "Critical error: RedisCache has no `Bot` instance. "
- "This happens when the class RedisCache was created in doesn't "
- "have a Bot instance. Please make sure that you're instantiating "
- "the RedisCache inside a class that has a Bot instance attribute."
- )
- log.error(error_message)
- raise NoBotInstanceError(error_message)
-
- def __repr__(self) -> str:
- """Return a beautiful representation of this object instance."""
- return f"RedisCache(namespace={self._namespace!r})"
-
- async def set(self, key: RedisKeyType, value: RedisValueType) -> None:
- """Store an item in the Redis cache."""
- await self._validate_cache()
-
- # Convert to a typestring and then set it
- key = self._key_to_typestring(key)
- value = self._value_to_typestring(value)
-
- log.trace(f"Setting {key} to {value}.")
- await self._redis.hset(self._namespace, key, value)
-
- async def get(self, key: RedisKeyType, default: Optional[RedisValueType] = None) -> Optional[RedisValueType]:
- """Get an item from the Redis cache."""
- await self._validate_cache()
- key = self._key_to_typestring(key)
-
- log.trace(f"Attempting to retrieve {key}.")
- value = await self._redis.hget(self._namespace, key)
-
- if value is None:
- log.trace(f"Value not found, returning default value {default}")
- return default
- else:
- value = self._value_from_typestring(value)
- log.trace(f"Value found, returning value {value}")
- return value
-
- async def delete(self, key: RedisKeyType) -> None:
- """
- Delete an item from the Redis cache.
-
- If we try to delete a key that does not exist, it will simply be ignored.
-
- See https://redis.io/commands/hdel for more info on how this works.
- """
- await self._validate_cache()
- key = self._key_to_typestring(key)
-
- log.trace(f"Attempting to delete {key}.")
- return await self._redis.hdel(self._namespace, key)
-
- async def contains(self, key: RedisKeyType) -> bool:
- """
- Check if a key exists in the Redis cache.
-
- Return True if the key exists, otherwise False.
- """
- await self._validate_cache()
- key = self._key_to_typestring(key)
- exists = await self._redis.hexists(self._namespace, key)
-
- log.trace(f"Testing if {key} exists in the RedisCache - Result is {exists}")
- return exists
-
- async def items(self) -> ItemsView:
- """
- Fetch all the key/value pairs in the cache.
-
- Returns a normal ItemsView, like you would get from dict.items().
-
- Keep in mind that these items are just a _copy_ of the data in the
- RedisCache - any changes you make to them will not be reflected
- into the RedisCache itself. If you want to change these, you need
- to make a .set call.
-
- Example:
- items = await my_cache.items()
- for key, value in items:
- # Iterate like a normal dictionary
- """
- await self._validate_cache()
- items = self._dict_from_typestring(
- await self._redis.hgetall(self._namespace)
- ).items()
-
- log.trace(f"Retrieving all key/value pairs from cache, total of {len(items)} items.")
- return items
-
- async def length(self) -> int:
- """Return the number of items in the Redis cache."""
- await self._validate_cache()
- number_of_items = await self._redis.hlen(self._namespace)
- log.trace(f"Returning length. Result is {number_of_items}.")
- return number_of_items
-
- async def to_dict(self) -> Dict:
- """Convert to dict and return."""
- return {key: value for key, value in await self.items()}
-
- async def clear(self) -> None:
- """Deletes the entire hash from the Redis cache."""
- await self._validate_cache()
- log.trace("Clearing the cache of all key/value pairs.")
- await self._redis.delete(self._namespace)
-
- async def pop(self, key: RedisKeyType, default: Optional[RedisValueType] = None) -> RedisValueType:
- """Get the item, remove it from the cache, and provide a default if not found."""
- log.trace(f"Attempting to pop {key}.")
- value = await self.get(key, default)
-
- log.trace(
- f"Attempting to delete item with key '{key}' from the cache. "
- "If this key doesn't exist, nothing will happen."
- )
- await self.delete(key)
-
- return value
-
- async def update(self, items: Dict[RedisKeyType, RedisValueType]) -> None:
- """
- Update the Redis cache with multiple values.
-
- This works exactly like dict.update from a normal dictionary. You pass
- a dictionary with one or more key/value pairs into this method. If the keys
- do not exist in the RedisCache, they are created. If they do exist, the values
- are updated with the new ones from `items`.
-
- Please note that keys and the values in the `items` dictionary
- must consist of valid RedisKeyTypes and RedisValueTypes.
- """
- await self._validate_cache()
- log.trace(f"Updating the cache with the following items:\n{items}")
- await self._redis.hmset_dict(self._namespace, self._dict_to_typestring(items))
-
- async def increment(self, key: RedisKeyType, amount: Optional[int, float] = 1) -> None:
- """
- Increment the value by `amount`.
-
- This works for both floats and ints, but will raise a TypeError
- if you try to do it for any other type of value.
-
- This also supports negative amounts, although it would provide better
- readability to use .decrement() for that.
- """
- log.trace(f"Attempting to increment/decrement the value with the key {key} by {amount}.")
-
- # We initialize the lock here, because we need to ensure we get it
- # running on the same loop as the calling coroutine.
- #
- # If we initialized the lock in the __init__, the loop that the coroutine this method
- # would be called from might not exist yet, and so the lock would be on a different
- # loop, which would raise RuntimeErrors.
- if self._increment_lock is None:
- self._increment_lock = asyncio.Lock()
-
- # Since this has several API calls, we need a lock to prevent race conditions
- async with self._increment_lock:
- value = await self.get(key)
-
- # Can't increment a non-existing value
- if value is None:
- error_message = "The provided key does not exist!"
- log.error(error_message)
- raise KeyError(error_message)
-
- # If it does exist, and it's an int or a float, increment and set it.
- if isinstance(value, int) or isinstance(value, float):
- value += amount
- await self.set(key, value)
- else:
- error_message = "You may only increment or decrement values that are integers or floats."
- log.error(error_message)
- raise TypeError(error_message)
-
- async def decrement(self, key: RedisKeyType, amount: Optional[int, float] = 1) -> None:
- """
- Decrement the value by `amount`.
-
- Basically just does the opposite of .increment.
- """
- await self.increment(key, -amount)
diff --git a/bot/utils/regex.py b/bot/utils/regex.py
new file mode 100644
index 000000000..0d2068f90
--- /dev/null
+++ b/bot/utils/regex.py
@@ -0,0 +1,12 @@
+import re
+
+INVITE_RE = re.compile(
+ r"(?:discord(?:[\.,]|dot)gg|" # Could be discord.gg/
+ r"discord(?:[\.,]|dot)com(?:\/|slash)invite|" # or discord.com/invite/
+ r"discordapp(?:[\.,]|dot)com(?:\/|slash)invite|" # or discordapp.com/invite/
+ r"discord(?:[\.,]|dot)me|" # or discord.me
+ r"discord(?:[\.,]|dot)io" # or discord.io.
+ r")(?:[\/]|slash)" # / or 'slash'
+ r"([a-zA-Z0-9\-]+)", # the invite code itself
+ flags=re.IGNORECASE
+)
diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py
index 8b778a093..03f31d78f 100644
--- a/bot/utils/scheduling.py
+++ b/bot/utils/scheduling.py
@@ -1,81 +1,126 @@
import asyncio
import contextlib
+import inspect
import logging
import typing as t
-from abc import abstractmethod
+from datetime import datetime
from functools import partial
-from bot.utils import CogABCMeta
-log = logging.getLogger(__name__)
+class Scheduler:
+ """
+ Schedule the execution of coroutines and keep track of them.
+ When instantiating a Scheduler, a name must be provided. This name is used to distinguish the
+ instance's log messages from other instances. Using the name of the class or module containing
+ the instance is suggested.
-class Scheduler(metaclass=CogABCMeta):
- """Task scheduler."""
+ Coroutines can be scheduled immediately with `schedule` or in the future with `schedule_at`
+ or `schedule_later`. A unique ID is required to be given in order to keep track of the
+ resulting Tasks. Any scheduled task can be cancelled prematurely using `cancel` by providing
+ the same ID used to schedule it. The `in` operator is supported for checking if a task with a
+ given ID is currently scheduled.
- def __init__(self):
- # Keep track of the child cog's name so the logs are clear.
- self.cog_name = self.__class__.__name__
+ Any exception raised in a scheduled task is logged when the task is done.
+ """
- self._scheduled_tasks: t.Dict[t.Hashable, asyncio.Task] = {}
+ def __init__(self, name: str):
+ self.name = name
- @abstractmethod
- async def _scheduled_task(self, task_object: t.Any) -> None:
- """
- A coroutine which handles the scheduling.
+ self._log = logging.getLogger(f"{__name__}.{name}")
+ self._scheduled_tasks: t.Dict[t.Hashable, asyncio.Task] = {}
- This is added to the scheduled tasks, and should wait the task duration, execute the desired
- code, then clean up the task.
+ def __contains__(self, task_id: t.Hashable) -> bool:
+ """Return True if a task with the given `task_id` is currently scheduled."""
+ return task_id in self._scheduled_tasks
- For example, in Reminders this will wait for the reminder duration, send the reminder,
- then make a site API request to delete the reminder from the database.
+ def schedule(self, task_id: t.Hashable, coroutine: t.Coroutine) -> None:
"""
+ Schedule the execution of a `coroutine`.
- def schedule_task(self, task_id: t.Hashable, task_data: t.Any) -> None:
+ If a task with `task_id` already exists, close `coroutine` instead of scheduling it. This
+ prevents unawaited coroutine warnings. Don't pass a coroutine that'll be re-used elsewhere.
"""
- Schedules a task.
+ self._log.trace(f"Scheduling task #{task_id}...")
- `task_data` is passed to the `Scheduler._scheduled_task()` coroutine.
- """
- log.trace(f"{self.cog_name}: scheduling task #{task_id}...")
+ msg = f"Cannot schedule an already started coroutine for #{task_id}"
+ assert inspect.getcoroutinestate(coroutine) == "CORO_CREATED", msg
if task_id in self._scheduled_tasks:
- log.debug(
- f"{self.cog_name}: did not schedule task #{task_id}; task was already scheduled."
- )
+ self._log.debug(f"Did not schedule task #{task_id}; task was already scheduled.")
+ coroutine.close()
return
- task = asyncio.create_task(self._scheduled_task(task_data))
+ task = asyncio.create_task(coroutine, name=f"{self.name}_{task_id}")
task.add_done_callback(partial(self._task_done_callback, task_id))
self._scheduled_tasks[task_id] = task
- log.debug(f"{self.cog_name}: scheduled task #{task_id} {id(task)}.")
+ self._log.debug(f"Scheduled task #{task_id} {id(task)}.")
+
+ def schedule_at(self, time: datetime, task_id: t.Hashable, coroutine: t.Coroutine) -> None:
+ """
+ Schedule `coroutine` to be executed at the given naïve UTC `time`.
+
+ If `time` is in the past, schedule `coroutine` immediately.
+
+ If a task with `task_id` already exists, close `coroutine` instead of scheduling it. This
+ prevents unawaited coroutine warnings. Don't pass a coroutine that'll be re-used elsewhere.
+ """
+ delay = (time - datetime.utcnow()).total_seconds()
+ if delay > 0:
+ coroutine = self._await_later(delay, task_id, coroutine)
+
+ self.schedule(task_id, coroutine)
- def cancel_task(self, task_id: t.Hashable, ignore_missing: bool = False) -> None:
+ def schedule_later(self, delay: t.Union[int, float], task_id: t.Hashable, coroutine: t.Coroutine) -> None:
"""
- Unschedule the task identified by `task_id`.
+ Schedule `coroutine` to be executed after the given `delay` number of seconds.
- If `ignore_missing` is True, a warning will not be sent if a task isn't found.
+ If a task with `task_id` already exists, close `coroutine` instead of scheduling it. This
+ prevents unawaited coroutine warnings. Don't pass a coroutine that'll be re-used elsewhere.
"""
- log.trace(f"{self.cog_name}: cancelling task #{task_id}...")
- task = self._scheduled_tasks.get(task_id)
+ self.schedule(task_id, self._await_later(delay, task_id, coroutine))
- if not task:
- if not ignore_missing:
- log.warning(f"{self.cog_name}: failed to unschedule {task_id} (no task found).")
- return
+ def cancel(self, task_id: t.Hashable) -> None:
+ """Unschedule the task identified by `task_id`. Log a warning if the task doesn't exist."""
+ self._log.trace(f"Cancelling task #{task_id}...")
- del self._scheduled_tasks[task_id]
- task.cancel()
+ try:
+ task = self._scheduled_tasks.pop(task_id)
+ except KeyError:
+ self._log.warning(f"Failed to unschedule {task_id} (no task found).")
+ else:
+ task.cancel()
- log.debug(f"{self.cog_name}: unscheduled task #{task_id} {id(task)}.")
+ self._log.debug(f"Unscheduled task #{task_id} {id(task)}.")
def cancel_all(self) -> None:
"""Unschedule all known tasks."""
- log.debug(f"{self.cog_name}: unscheduling all tasks")
+ self._log.debug("Unscheduling all tasks")
for task_id in self._scheduled_tasks.copy():
- self.cancel_task(task_id, ignore_missing=True)
+ self.cancel(task_id)
+
+ async def _await_later(self, delay: t.Union[int, float], task_id: t.Hashable, coroutine: t.Coroutine) -> None:
+ """Await `coroutine` after the given `delay` number of seconds."""
+ try:
+ self._log.trace(f"Waiting {delay} seconds before awaiting coroutine for #{task_id}.")
+ await asyncio.sleep(delay)
+
+ # Use asyncio.shield to prevent the coroutine from cancelling itself.
+ self._log.trace(f"Done waiting for #{task_id}; now awaiting the coroutine.")
+ await asyncio.shield(coroutine)
+ finally:
+ # Close it to prevent unawaited coroutine warnings,
+ # which would happen if the task was cancelled during the sleep.
+ # Only close it if it's not been awaited yet. This check is important because the
+ # coroutine may cancel this task, which would also trigger the finally block.
+ state = inspect.getcoroutinestate(coroutine)
+ if state == "CORO_CREATED":
+ self._log.debug(f"Explicitly closing the coroutine for #{task_id}.")
+ coroutine.close()
+ else:
+ self._log.debug(f"Finally block reached for #{task_id}; {state=}")
def _task_done_callback(self, task_id: t.Hashable, done_task: asyncio.Task) -> None:
"""
@@ -84,24 +129,24 @@ class Scheduler(metaclass=CogABCMeta):
If `done_task` and the task associated with `task_id` are different, then the latter
will not be deleted. In this case, a new task was likely rescheduled with the same ID.
"""
- log.trace(f"{self.cog_name}: performing done callback for task #{task_id} {id(done_task)}.")
+ self._log.trace(f"Performing done callback for task #{task_id} {id(done_task)}.")
scheduled_task = self._scheduled_tasks.get(task_id)
if scheduled_task and done_task is scheduled_task:
- # A task for the ID exists and its the same as the done task.
+ # A task for the ID exists and is the same as the done task.
# Since this is the done callback, the task is already done so no need to cancel it.
- log.trace(f"{self.cog_name}: deleting task #{task_id} {id(done_task)}.")
+ self._log.trace(f"Deleting task #{task_id} {id(done_task)}.")
del self._scheduled_tasks[task_id]
elif scheduled_task:
# A new task was likely rescheduled with the same ID.
- log.debug(
- f"{self.cog_name}: the scheduled task #{task_id} {id(scheduled_task)} "
+ self._log.debug(
+ f"The scheduled task #{task_id} {id(scheduled_task)} "
f"and the done task {id(done_task)} differ."
)
elif not done_task.cancelled():
- log.warning(
- f"{self.cog_name}: task #{task_id} not found while handling task {id(done_task)}! "
+ self._log.warning(
+ f"Task #{task_id} not found while handling task {id(done_task)}! "
f"A task somehow got unscheduled improperly (i.e. deleted but not cancelled)."
)
@@ -109,7 +154,4 @@ class Scheduler(metaclass=CogABCMeta):
exception = done_task.exception()
# Log the exception if one exists.
if exception:
- log.error(
- f"{self.cog_name}: error in task #{task_id} {id(done_task)}!",
- exc_info=exception
- )
+ self._log.error(f"Error in task #{task_id} {id(done_task)}!", exc_info=exception)
diff --git a/bot/utils/services.py b/bot/utils/services.py
new file mode 100644
index 000000000..087b9f969
--- /dev/null
+++ b/bot/utils/services.py
@@ -0,0 +1,54 @@
+import logging
+from typing import Optional
+
+from aiohttp import ClientConnectorError, ClientSession
+
+from bot.constants import URLs
+
+log = logging.getLogger(__name__)
+
+FAILED_REQUEST_ATTEMPTS = 3
+
+
+async def send_to_paste_service(http_session: ClientSession, 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.
+ """
+ extension = extension and f".{extension}"
+ log.debug(f"Sending contents of size {len(contents.encode())} bytes to paste service.")
+ 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:
+ response_json = await response.json()
+ except ClientConnectorError:
+ log.warning(
+ f"Failed to connect to paste service at url {paste_url}, "
+ f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})."
+ )
+ continue
+ except Exception:
+ log.exception(
+ f"An unexpected error has occurred during handling of the request, "
+ f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})."
+ )
+ continue
+
+ if "message" in response_json:
+ log.warning(
+ f"Paste service returned error {response_json['message']} with status code {response.status}, "
+ f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})."
+ )
+ continue
+ elif "key" in response_json:
+ log.info(f"Successfully uploaded contents to paste service behind key {response_json['key']}.")
+ return URLs.paste_service.format(key=response_json['key']) + extension
+ log.warning(
+ f"Got unexpected JSON response from paste service: {response_json}\n"
+ f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})."
+ )
diff --git a/bot/utils/time.py b/bot/utils/time.py
index 77060143c..47e49904b 100644
--- a/bot/utils/time.py
+++ b/bot/utils/time.py
@@ -20,7 +20,9 @@ def _stringify_time_unit(value: int, unit: str) -> str:
>>> _stringify_time_unit(0, "minutes")
"less than a minute"
"""
- if value == 1:
+ if unit == "seconds" and value == 0:
+ return "0 seconds"
+ elif value == 1:
return f"{value} {unit[:-1]}"
elif value == 0:
return f"less than a {unit[:-1]}"
diff --git a/bot/utils/webhooks.py b/bot/utils/webhooks.py
new file mode 100644
index 000000000..66f82ec66
--- /dev/null
+++ b/bot/utils/webhooks.py
@@ -0,0 +1,34 @@
+import logging
+from typing import Optional
+
+import discord
+from discord import Embed
+
+from bot.utils.messages import sub_clyde
+
+log = logging.getLogger(__name__)
+
+
+async def send_webhook(
+ webhook: discord.Webhook,
+ content: Optional[str] = None,
+ username: Optional[str] = None,
+ avatar_url: Optional[str] = None,
+ embed: Optional[Embed] = None,
+ wait: Optional[bool] = False
+) -> discord.Message:
+ """
+ Send a message using the provided webhook.
+
+ This uses sub_clyde() and tries for an HTTPException to ensure it doesn't crash.
+ """
+ try:
+ return await webhook.send(
+ content=content,
+ username=sub_clyde(username),
+ avatar_url=avatar_url,
+ embed=embed,
+ wait=wait,
+ )
+ except discord.HTTPException:
+ log.exception("Failed to send a message to the webhook!")
diff --git a/config-default.yml b/config-default.yml
index 64c4e715b..071f6e1ec 100644
--- a/config-default.yml
+++ b/config-default.yml
@@ -38,6 +38,21 @@ style:
status_dnd: "<:status_dnd:470326272082313216>"
status_offline: "<:status_offline:470326266537705472>"
+ badge_staff: "<:discord_staff:743882896498098226>"
+ badge_partner: "<:partner:748666453242413136>"
+ badge_hypesquad: "<:hypesquad_events:743882896892362873>"
+ badge_bug_hunter: "<:bug_hunter_lvl1:743882896372269137>"
+ badge_hypesquad_bravery: "<:hypesquad_bravery:743882896745693335>"
+ badge_hypesquad_brilliance: "<:hypesquad_brilliance:743882896938631248>"
+ badge_hypesquad_balance: "<:hypesquad_balance:743882896460480625>"
+ badge_early_supporter: "<:early_supporter:743882896909140058>"
+ badge_bug_hunter_level_2: "<:bug_hunter_lvl2:743882896611344505>"
+ badge_verified_bot_developer: "<:verified_bot_dev:743882897299210310>"
+
+ incident_actioned: "<:incident_actioned:719645530128646266>"
+ incident_unactioned: "<:incident_unactioned:719645583245180960>"
+ incident_investigating: "<:incident_investigating:719645658671480924>"
+
failmail: "<:failmail:633660039931887616>"
trashcan: "<:trashcan:637136429717389331>"
@@ -47,23 +62,10 @@ style:
cross_mark: "\u274C"
check_mark: "\u2705"
- ducky_yellow: &DUCKY_YELLOW 574951975574175744
- ducky_blurple: &DUCKY_BLURPLE 574951975310065675
- ducky_regal: &DUCKY_REGAL 637883439185395712
- ducky_camo: &DUCKY_CAMO 637914731566596096
- ducky_ninja: &DUCKY_NINJA 637923502535606293
- ducky_devil: &DUCKY_DEVIL 637925314982576139
- ducky_tube: &DUCKY_TUBE 637881368008851456
- ducky_hunt: &DUCKY_HUNT 639355090909528084
- ducky_wizard: &DUCKY_WIZARD 639355996954689536
- ducky_party: &DUCKY_PARTY 639468753440210977
- ducky_angel: &DUCKY_ANGEL 640121935610511361
- ducky_maul: &DUCKY_MAUL 640137724958867467
- ducky_santa: &DUCKY_SANTA 655360331002019870
-
- upvotes: "<:upvotes:638729835245731840>"
- comments: "<:comments:638729835073765387>"
- user: "<:user:638729835442602003>"
+ # emotes used for #reddit
+ upvotes: "<:reddit_upvotes:755845219890757644>"
+ comments: "<:reddit_comments:755845255001014384>"
+ user: "<:reddit_users:755845303822974997>"
icons:
crown_blurple: "https://cdn.discordapp.com/emojis/469964153289965568.png"
@@ -117,19 +119,28 @@ style:
voice_state_green: "https://cdn.discordapp.com/emojis/656899770094452754.png"
voice_state_red: "https://cdn.discordapp.com/emojis/656899769905709076.png"
+
guild:
id: 267624335836053506
+ invite: "https://discord.gg/python"
categories:
help_available: 691405807388196926
help_in_use: 696958401460043776
help_dormant: 691405908919451718
- modmail: 714494672835444826
+ modmail: &MODMAIL 714494672835444826
+ logs: &LOGS 468520609152892958
+ voice: 356013253765234688
channels:
- announcements: 354619224620138496
- user_event_announcements: &USER_EVENT_A 592000283102674944
- python_news: &PYNEWS_CHANNEL 704372456592506880
+ # Public announcement and news channels
+ change_log: &CHANGE_LOG 748238795236704388
+ announcements: &ANNOUNCEMENTS 354619224620138496
+ python_news: &PYNEWS_CHANNEL 704372456592506880
+ python_events: &PYEVENTS_CHANNEL 729674110270963822
+ mailing_lists: &MAILING_LISTS 704372456592506880
+ reddit: &REDDIT_CHANNEL 458224812528238616
+ user_event_announcements: &USER_EVENT_A 592000283102674944
# Development
dev_contrib: &DEV_CONTRIB 635950537262759947
@@ -137,8 +148,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
@@ -150,6 +161,7 @@ guild:
mod_log: &MOD_LOG 282638479504965634
user_log: 528976905546760203
voice_log: 640292421988646961
+ dm_log: 653713721625018428
# Off-topic
off_topic_0: 291284109232308226
@@ -159,22 +171,32 @@ guild:
# Special
bot_commands: &BOT_CMD 267659945086812160
esoteric: 470884583684964352
- reddit: 458224812528238616
verification: 352442727016693763
+ voice_gate: 764802555427029012
# Staff
admins: &ADMINS 365960823622991872
admin_spam: &ADMIN_SPAM 563594791770914816
defcon: &DEFCON 464469101889454091
helpers: &HELPERS 385474242440986624
+ 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
- incidents: 714214212200562749
+ duck_pond: &DUCK_POND 637820308341915648
+
+ # Staff announcement channels
+ staff_announcements: &STAFF_ANNOUNCEMENTS 464033278631084042
+ mod_announcements: &MOD_ANNOUNCEMENTS 372115205867700225
+ 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
@@ -182,19 +204,13 @@ guild:
big_brother_logs: &BB_LOGS 468507907357409333
talent_pool: &TALENT_POOL 534321732593647616
- staff_channels:
- - *ADMINS
- - *ADMIN_SPAM
- - *DEFCON
- - *HELPERS
- - *MODS
- - *MOD_SPAM
- - *ORGANISATION
+ moderation_categories:
+ - *MODMAIL
+ - *LOGS
moderation_channels:
- *ADMINS
- *ADMIN_SPAM
- - *MOD_ALERTS
- *MODS
- *MOD_SPAM
@@ -218,9 +234,11 @@ guild:
muted: &MUTED_ROLE 277914926603829249
partners: 323426753857191936
python_community: &PY_COMMUNITY_ROLE 458226413825294336
+ sprinters: &SPRINTERS 758422482289426471
- # This is the Developers role on PyDis, here named verified for readability reasons
- verified: 352427296948486144
+ unverified: 739794855945044069
+ verified: 352427296948486144 # @Developers on PyDis
+ voice_verified: 764802720779337729
# Staff
admins: &ADMINS_ROLE 267628507062992896
@@ -230,8 +248,8 @@ guild:
owners: &OWNERS_ROLE 267627879762755584
# Code Jam
- jammers: 591786436651646989
- team_leaders: 501324292341104650
+ jammers: 737249140966162473
+ team_leaders: 737250302834638889
moderation_roles:
- *OWNERS_ROLE
@@ -245,124 +263,36 @@ guild:
- *HELPERS_ROLE
webhooks:
- talent_pool: 569145364800602132
- big_brother: 569133704568373283
- reddit: 635408384794951680
- duck_pond: 637821475327311927
- dev_log: 680501655111729222
- python_news: &PYNEWS_WEBHOOK 704381182279942324
+ big_brother: 569133704568373283
+ dev_log: 680501655111729222
+ dm_log: 654567640664244225
+ duck_pond: 637821475327311927
+ incidents_archive: 720671599790915702
+ python_news: &PYNEWS_WEBHOOK 704381182279942324
+ reddit: 635408384794951680
+ talent_pool: 569145364800602132
filter:
-
# What do we filter?
- filter_zalgo: false
- filter_invites: true
- filter_domains: true
- watch_regex: true
- watch_rich_embeds: true
+ filter_zalgo: false
+ filter_invites: true
+ filter_domains: true
+ filter_everyone_ping: true
+ watch_regex: true
+ watch_rich_embeds: true
# Notify user on filter?
# Notifications are not expected for "watchlist" type filters
- notify_user_zalgo: false
- notify_user_invites: true
- notify_user_domains: false
+ notify_user_zalgo: false
+ notify_user_invites: true
+ notify_user_domains: false
+ notify_user_everyone_ping: true
# Filter configuration
- ping_everyone: true # Ping @everyone when we send a mod-alert?
+ ping_everyone: true
offensive_msg_delete_days: 7 # How many days before deleting an offensive message?
- guild_invite_whitelist:
- - 280033776820813825 # Functional Programming
- - 267624335836053506 # Python Discord
- - 440186186024222721 # Python Discord: Emojis 1
- - 578587418123304970 # Python Discord: Emojis 2
- - 273944235143593984 # STEM
- - 348658686962696195 # RLBot
- - 531221516914917387 # Pallets
- - 249111029668249601 # Gentoo
- - 327254708534116352 # Adafruit
- - 544525886180032552 # kennethreitz.org
- - 590806733924859943 # Discord Hack Week
- - 423249981340778496 # Kivy
- - 197038439483310086 # Discord Testers
- - 286633898581164032 # Ren'Py
- - 349505959032389632 # PyGame
- - 438622377094414346 # Pyglet
- - 524691714909274162 # Panda3D
- - 336642139381301249 # discord.py
- - 405403391410438165 # Sentdex
- - 172018499005317120 # The Coding Den
- - 666560367173828639 # PyWeek
- - 702724176489873509 # Microsoft Python
- - 81384788765712384 # Discord API
- - 613425648685547541 # Discord Developers
- - 185590609631903755 # Blender Hub
- - 420324994703163402 # /r/FlutterDev
- - 488751051629920277 # Python Atlanta
- - 143867839282020352 # C#
-
- domain_blacklist:
- - pornhub.com
- - liveleak.com
- - grabify.link
- - bmwforum.co
- - leancoding.co
- - spottyfly.com
- - stopify.co
- - yoütu.be
- - discörd.com
- - minecräft.com
- - freegiftcards.co
- - disçordapp.com
- - fortnight.space
- - fortnitechat.site
- - joinmy.site
- - curiouscat.club
- - catsnthings.fun
- - yourtube.site
- - youtubeshort.watch
- - catsnthing.com
- - youtubeshort.pro
- - canadianlumberjacks.online
- - poweredbydialup.club
- - poweredbydialup.online
- - poweredbysecurity.org
- - poweredbysecurity.online
- - ssteam.site
- - steamwalletgift.com
- - discord.gift
- - lmgtfy.com
-
- word_watchlist:
- - goo+ks*
- - ky+s+
- - ki+ke+s*
- - beaner+s?
- - coo+ns*
- - nig+lets*
- - slant-eyes*
- - towe?l-?head+s*
- - chi*n+k+s*
- - spick*s*
- - kill* +(?:yo)?urself+
- - jew+s*
- - suicide
- - rape
- - (re+)tar+(d+|t+)(ed)?
- - ta+r+d+
- - cunts*
- - trann*y
- - shemale
-
- token_watchlist:
- - fa+g+s*
- - 卐
- - 卍
- - cuck(?!oo+)
- - nigg+(?:e*r+|a+h*?|u+h+)s?
- - fag+o+t+s*
-
# Censor doesn't apply to these
channel_whitelist:
- *ADMINS
@@ -380,6 +310,7 @@ filter:
- *OWNERS_ROLE
- *HELPERS_ROLE
- *PY_COMMUNITY_ROLE
+ - *SPRINTERS
keys:
@@ -394,24 +325,7 @@ urls:
site_staff: &STAFF !JOIN ["staff.", *DOMAIN]
site_schema: &SCHEMA "https://"
- site_bigbrother_api: !JOIN [*SCHEMA, *API, "/bot/bigbrother"]
- site_docs_api: !JOIN [*SCHEMA, *API, "/bot/docs"]
- site_superstarify_api: !JOIN [*SCHEMA, *API, "/bot/superstarify"]
- site_infractions: !JOIN [*SCHEMA, *API, "/bot/infractions"]
- site_infractions_user: !JOIN [*SCHEMA, *API, "/bot/infractions/user/{user_id}"]
- site_infractions_type: !JOIN [*SCHEMA, *API, "/bot/infractions/type/{infraction_type}"]
- site_infractions_by_id: !JOIN [*SCHEMA, *API, "/bot/infractions/id/{infraction_id}"]
- site_infractions_user_type_current: !JOIN [*SCHEMA, *API, "/bot/infractions/user/{user_id}/{infraction_type}/current"]
- site_infractions_user_type: !JOIN [*SCHEMA, *API, "/bot/infractions/user/{user_id}/{infraction_type}"]
- site_logs_api: !JOIN [*SCHEMA, *API, "/bot/logs"]
site_logs_view: !JOIN [*SCHEMA, *STAFF, "/bot/logs"]
- site_off_topic_names_api: !JOIN [*SCHEMA, *API, "/bot/off-topic-names"]
- site_reminders_api: !JOIN [*SCHEMA, *API, "/bot/reminders"]
- site_reminders_user_api: !JOIN [*SCHEMA, *API, "/bot/reminders/user"]
- site_settings_api: !JOIN [*SCHEMA, *API, "/bot/settings"]
- site_tags_api: !JOIN [*SCHEMA, *API, "/bot/tags"]
- site_user_api: !JOIN [*SCHEMA, *API, "/bot/users"]
- site_user_complete_api: !JOIN [*SCHEMA, *API, "/bot/users/complete"]
paste_service: !JOIN [*SCHEMA, *PASTE, "/{key}"]
# Snekbox
@@ -425,6 +339,7 @@ urls:
bot_avatar: "https://raw.githubusercontent.com/discord-python/branding/master/logos/logo_circle/logo_circle.png"
github_bot_repo: "https://github.com/python-discord/bot"
+
anti_spam:
# Clean messages that violate a rule.
clean_offending: true
@@ -443,9 +358,13 @@ anti_spam:
interval: 10
max: 7
- burst_shared:
- interval: 10
- max: 20
+ # Burst shared it (temporarily) disabled to prevent
+ # the bug that triggers multiple infractions/DMs per
+ # user. It also tends to catch a lot of innocent users
+ # now that we're so big.
+ # burst_shared:
+ # interval: 10
+ # max: 20
chars:
interval: 5
@@ -477,35 +396,6 @@ anti_spam:
max: 3
-anti_malware:
- whitelist:
- - '.3gp'
- - '.3g2'
- - '.avi'
- - '.bmp'
- - '.gif'
- - '.h264'
- - '.jpg'
- - '.jpeg'
- - '.m4v'
- - '.mkv'
- - '.mov'
- - '.mp4'
- - '.mpeg'
- - '.mpg'
- - '.png'
- - '.tiff'
- - '.wmv'
- - '.svg'
- - '.psd' # Photoshop
- - '.ai' # Illustrator
- - '.aep' # After Effects
- - '.xcf' # GIMP
- - '.mp3'
- - '.wav'
- - '.ogg'
-
-
reddit:
subreddits:
- 'r/Python'
@@ -513,18 +403,28 @@ reddit:
secret: !ENV "REDDIT_SECRET"
-wolfram:
- # Max requests per day.
- user_limit_day: 10
- guild_limit_day: 67
- key: !ENV "WOLFRAM_API_KEY"
-
-
big_brother:
log_delay: 15
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.
@@ -546,8 +446,8 @@ help_channels:
# Allowed duration of inactivity before making a channel dormant
idle_minutes: 30
- # Allowed duration of inactivity when question message deleted
- # and no one other sent before message making channel dormant.
+ # Allowed duration of inactivity when channel is empty (due to deleted messages)
+ # before message making a channel dormant
deleted_idle_minutes: 5
# Maximum number of channels to put in the available category
@@ -573,38 +473,55 @@ help_channels:
notify_roles:
- *HELPERS_ROLE
+
redirect_output:
delete_invocation: true
delete_delay: 15
-sync:
- confirm_timeout: 300
- max_diff: 10
duck_pond:
- threshold: 5
- custom_emojis:
- - *DUCKY_YELLOW
- - *DUCKY_BLURPLE
- - *DUCKY_CAMO
- - *DUCKY_DEVIL
- - *DUCKY_NINJA
- - *DUCKY_REGAL
- - *DUCKY_TUBE
- - *DUCKY_HUNT
- - *DUCKY_WIZARD
- - *DUCKY_PARTY
- - *DUCKY_ANGEL
- - *DUCKY_MAUL
- - *DUCKY_SANTA
+ threshold: 4
+ channel_blacklist:
+ - *ANNOUNCEMENTS
+ - *PYNEWS_CHANNEL
+ - *PYEVENTS_CHANNEL
+ - *MAILING_LISTS
+ - *REDDIT_CHANNEL
+ - *USER_EVENT_A
+ - *DUCK_POND
+ - *CHANGE_LOG
+ - *STAFF_ANNOUNCEMENTS
+ - *MOD_ANNOUNCEMENTS
+ - *ADMIN_ANNOUNCEMENTS
+
python_news:
mail_lists:
- 'python-ideas'
- 'python-announce-list'
- 'pypi-announce'
+ - 'python-dev'
channel: *PYNEWS_CHANNEL
webhook: *PYNEWS_WEBHOOK
+
+verification:
+ unverified_after: 3 # Days after which non-Developers receive the @Unverified role
+ kicked_after: 30 # Days after which non-Developers get kicked from the guild
+ reminder_frequency: 28 # Hours between @Unverified pings
+ bot_message_delete_delay: 10 # Seconds before deleting bots response in #verification
+
+ # Number in range [0, 1] determining the percentage of unverified users that are safe
+ # to be kicked from the guild in one batch, any larger amount will require staff confirmation,
+ # set this to 0 to require explicit approval for batches of any size
+ 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
+
+
config:
required_keys: ['bot.token']
diff --git a/docker-compose.yml b/docker-compose.yml
index cff7d33d6..8be5aac0e 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -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/cogs/moderation/test_infractions.py b/tests/bot/cogs/moderation/test_infractions.py
deleted file mode 100644
index da4e92ccc..000000000
--- a/tests/bot/cogs/moderation/test_infractions.py
+++ /dev/null
@@ -1,55 +0,0 @@
-import textwrap
-import unittest
-from unittest.mock import AsyncMock, Mock, patch
-
-from bot.cogs.moderation.infractions import Infractions
-from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole
-
-
-class TruncationTests(unittest.IsolatedAsyncioTestCase):
- """Tests for ban and kick command reason truncation."""
-
- def setUp(self):
- self.bot = MockBot()
- self.cog = Infractions(self.bot)
- self.user = MockMember(id=1234, top_role=MockRole(id=3577, position=10))
- self.target = MockMember(id=1265, top_role=MockRole(id=9876, position=0))
- self.guild = MockGuild(id=4567)
- self.ctx = MockContext(bot=self.bot, author=self.user, guild=self.guild)
-
- @patch("bot.cogs.moderation.utils.get_active_infraction")
- @patch("bot.cogs.moderation.utils.post_infraction")
- async def test_apply_ban_reason_truncation(self, post_infraction_mock, get_active_mock):
- """Should truncate reason for `ctx.guild.ban`."""
- get_active_mock.return_value = None
- post_infraction_mock.return_value = {"foo": "bar"}
-
- self.cog.apply_infraction = AsyncMock()
- self.bot.get_cog.return_value = AsyncMock()
- self.cog.mod_log.ignore = Mock()
- self.ctx.guild.ban = Mock()
-
- await self.cog.apply_ban(self.ctx, self.target, "foo bar" * 3000)
- self.ctx.guild.ban.assert_called_once_with(
- self.target,
- reason=textwrap.shorten("foo bar" * 3000, 512, placeholder="..."),
- delete_message_days=0
- )
- self.cog.apply_infraction.assert_awaited_once_with(
- self.ctx, {"foo": "bar"}, self.target, self.ctx.guild.ban.return_value
- )
-
- @patch("bot.cogs.moderation.utils.post_infraction")
- async def test_apply_kick_reason_truncation(self, post_infraction_mock):
- """Should truncate reason for `Member.kick`."""
- post_infraction_mock.return_value = {"foo": "bar"}
-
- self.cog.apply_infraction = AsyncMock()
- self.cog.mod_log.ignore = Mock()
- self.target.kick = Mock()
-
- await self.cog.apply_kick(self.ctx, self.target, "foo bar" * 3000)
- self.target.kick.assert_called_once_with(reason=textwrap.shorten("foo bar" * 3000, 512, placeholder="..."))
- self.cog.apply_infraction.assert_awaited_once_with(
- self.ctx, {"foo": "bar"}, self.target, self.target.kick.return_value
- )
diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py
deleted file mode 100644
index ab3d0742a..000000000
--- a/tests/bot/cogs/moderation/test_silence.py
+++ /dev/null
@@ -1,261 +0,0 @@
-import unittest
-from unittest import mock
-from unittest.mock import MagicMock, Mock
-
-from discord import PermissionOverwrite
-
-from bot.cogs.moderation.silence import Silence, SilenceNotifier
-from bot.constants import Channels, Emojis, Guild, Roles
-from tests.helpers import MockBot, MockContext, MockTextChannel
-
-
-class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase):
- def setUp(self) -> None:
- self.alert_channel = MockTextChannel()
- self.notifier = SilenceNotifier(self.alert_channel)
- self.notifier.stop = self.notifier_stop_mock = Mock()
- self.notifier.start = self.notifier_start_mock = Mock()
-
- def test_add_channel_adds_channel(self):
- """Channel in FirstHash with current loop is added to internal set."""
- channel = Mock()
- with mock.patch.object(self.notifier, "_silenced_channels") as silenced_channels:
- self.notifier.add_channel(channel)
- silenced_channels.__setitem__.assert_called_with(channel, self.notifier._current_loop)
-
- def test_add_channel_starts_loop(self):
- """Loop is started if `_silenced_channels` was empty."""
- self.notifier.add_channel(Mock())
- self.notifier_start_mock.assert_called_once()
-
- def test_add_channel_skips_start_with_channels(self):
- """Loop start is not called when `_silenced_channels` is not empty."""
- with mock.patch.object(self.notifier, "_silenced_channels"):
- self.notifier.add_channel(Mock())
- self.notifier_start_mock.assert_not_called()
-
- def test_remove_channel_removes_channel(self):
- """Channel in FirstHash is removed from `_silenced_channels`."""
- channel = Mock()
- with mock.patch.object(self.notifier, "_silenced_channels") as silenced_channels:
- self.notifier.remove_channel(channel)
- silenced_channels.__delitem__.assert_called_with(channel)
-
- def test_remove_channel_stops_loop(self):
- """Notifier loop is stopped if `_silenced_channels` is empty after remove."""
- with mock.patch.object(self.notifier, "_silenced_channels", __bool__=lambda _: False):
- self.notifier.remove_channel(Mock())
- self.notifier_stop_mock.assert_called_once()
-
- def test_remove_channel_skips_stop_with_channels(self):
- """Notifier loop is not stopped if `_silenced_channels` is not empty after remove."""
- self.notifier.remove_channel(Mock())
- self.notifier_stop_mock.assert_not_called()
-
- async def test_notifier_private_sends_alert(self):
- """Alert is sent on 15 min intervals."""
- test_cases = (900, 1800, 2700)
- for current_loop in test_cases:
- with self.subTest(current_loop=current_loop):
- with mock.patch.object(self.notifier, "_current_loop", new=current_loop):
- await self.notifier._notifier()
- self.alert_channel.send.assert_called_once_with(f"<@&{Roles.moderators}> currently silenced channels: ")
- self.alert_channel.send.reset_mock()
-
- async def test_notifier_skips_alert(self):
- """Alert is skipped on first loop or not an increment of 900."""
- test_cases = (0, 15, 5000)
- for current_loop in test_cases:
- with self.subTest(current_loop=current_loop):
- with mock.patch.object(self.notifier, "_current_loop", new=current_loop):
- await self.notifier._notifier()
- self.alert_channel.send.assert_not_called()
-
-
-class SilenceTests(unittest.IsolatedAsyncioTestCase):
- def setUp(self) -> None:
- self.bot = MockBot()
- self.cog = Silence(self.bot)
- self.ctx = MockContext()
- self.cog._verified_role = None
- # Set event so command callbacks can continue.
- self.cog._get_instance_vars_event.set()
-
- async def test_instance_vars_got_guild(self):
- """Bot got guild after it became available."""
- await self.cog._get_instance_vars()
- self.bot.wait_until_guild_available.assert_called_once()
- self.bot.get_guild.assert_called_once_with(Guild.id)
-
- async def test_instance_vars_got_role(self):
- """Got `Roles.verified` role from guild."""
- await self.cog._get_instance_vars()
- guild = self.bot.get_guild()
- guild.get_role.assert_called_once_with(Roles.verified)
-
- async def test_instance_vars_got_channels(self):
- """Got channels from bot."""
- await self.cog._get_instance_vars()
- self.bot.get_channel.called_once_with(Channels.mod_alerts)
- self.bot.get_channel.called_once_with(Channels.mod_log)
-
- @mock.patch("bot.cogs.moderation.silence.SilenceNotifier")
- async def test_instance_vars_got_notifier(self, notifier):
- """Notifier was started with channel."""
- mod_log = MockTextChannel()
- self.bot.get_channel.side_effect = (None, mod_log)
- await self.cog._get_instance_vars()
- notifier.assert_called_once_with(mod_log)
- self.bot.get_channel.side_effect = None
-
- async def test_silence_sent_correct_discord_message(self):
- """Check if proper message was sent when called with duration in channel with previous state."""
- test_cases = (
- (0.0001, f"{Emojis.check_mark} silenced current channel for 0.0001 minute(s).", True,),
- (None, f"{Emojis.check_mark} silenced current channel indefinitely.", True,),
- (5, f"{Emojis.cross_mark} current channel is already silenced.", False,),
- )
- for duration, result_message, _silence_patch_return in test_cases:
- with self.subTest(
- silence_duration=duration,
- result_message=result_message,
- starting_unsilenced_state=_silence_patch_return
- ):
- with mock.patch.object(self.cog, "_silence", return_value=_silence_patch_return):
- await self.cog.silence.callback(self.cog, self.ctx, duration)
- self.ctx.send.assert_called_once_with(result_message)
- self.ctx.reset_mock()
-
- async def test_unsilence_sent_correct_discord_message(self):
- """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 _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'])
-
- async def test_silence_private_preserves_permissions(self):
- """Previous permissions were preserved when channel was silenced."""
- channel = MockTextChannel()
- # Set up mock channel permission state.
- mock_permissions = PermissionOverwrite()
- mock_permissions_dict = dict(mock_permissions)
- channel.overwrites_for.return_value = mock_permissions
- await self.cog._silence(channel, False, None)
- new_permissions = channel.set_permissions.call_args.kwargs
- # Remove 'send_messages' key because it got changed in the method.
- del new_permissions['send_messages']
- del mock_permissions_dict['send_messages']
- self.assertDictEqual(mock_permissions_dict, new_permissions)
-
- async def test_silence_private_notifier(self):
- """Channel should be added to notifier with `persistent` set to `True`, and the other way around."""
- channel = MockTextChannel()
- with mock.patch.object(self.cog, "notifier", create=True):
- with self.subTest(persistent=True):
- await self.cog._silence(channel, True, None)
- self.cog.notifier.add_channel.assert_called_once()
-
- with mock.patch.object(self.cog, "notifier", create=True):
- with self.subTest(persistent=False):
- await self.cog._silence(channel, False, None)
- self.cog.notifier.add_channel.assert_not_called()
-
- async def test_silence_private_added_muted_channel(self):
- """Channel was added to `muted_channels` on silence."""
- channel = MockTextChannel()
- with mock.patch.object(self.cog, "muted_channels") as muted_channels:
- await self.cog._silence(channel, False, None)
- muted_channels.add.assert_called_once_with(channel)
-
- async def test_unsilence_private_for_false(self):
- """Permissions are not set and `False` is returned in an unsilenced channel."""
- channel = Mock()
- self.assertFalse(await self.cog._unsilence(channel))
- channel.set_permissions.assert_not_called()
-
- @mock.patch.object(Silence, "notifier", create=True)
- async def test_unsilence_private_unsilenced_channel(self, _):
- """Channel had `send_message` permissions restored"""
- perm_overwrite = MagicMock(send_messages=False)
- channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite))
- self.assertTrue(await self.cog._unsilence(channel))
- channel.set_permissions.assert_called_once()
- self.assertIsNone(channel.set_permissions.call_args.kwargs['send_messages'])
-
- @mock.patch.object(Silence, "notifier", create=True)
- async def test_unsilence_private_removed_notifier(self, notifier):
- """Channel was removed from `notifier` on unsilence."""
- perm_overwrite = MagicMock(send_messages=False)
- channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite))
- await self.cog._unsilence(channel)
- notifier.remove_channel.assert_called_once_with(channel)
-
- @mock.patch.object(Silence, "notifier", create=True)
- async def test_unsilence_private_removed_muted_channel(self, _):
- """Channel was removed from `muted_channels` on unsilence."""
- perm_overwrite = MagicMock(send_messages=False)
- channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite))
- with mock.patch.object(self.cog, "muted_channels") as muted_channels:
- await self.cog._unsilence(channel)
- muted_channels.discard.assert_called_once_with(channel)
-
- @mock.patch.object(Silence, "notifier", create=True)
- async def test_unsilence_private_preserves_permissions(self, _):
- """Previous permissions were preserved when channel was unsilenced."""
- channel = MockTextChannel()
- # Set up mock channel permission state.
- mock_permissions = PermissionOverwrite(send_messages=False)
- mock_permissions_dict = dict(mock_permissions)
- channel.overwrites_for.return_value = mock_permissions
- await self.cog._unsilence(channel)
- new_permissions = channel.set_permissions.call_args.kwargs
- # Remove 'send_messages' key because it got changed in the method.
- del new_permissions['send_messages']
- del mock_permissions_dict['send_messages']
- self.assertDictEqual(mock_permissions_dict, new_permissions)
-
- @mock.patch("bot.cogs.moderation.silence.asyncio")
- @mock.patch.object(Silence, "_mod_alerts_channel", create=True)
- def test_cog_unload_starts_task(self, alert_channel, asyncio_mock):
- """Task for sending an alert was created with present `muted_channels`."""
- with mock.patch.object(self.cog, "muted_channels"):
- self.cog.cog_unload()
- alert_channel.send.assert_called_once_with(f"<@&{Roles.moderators}> channels left silenced on cog unload: ")
- asyncio_mock.create_task.assert_called_once_with(alert_channel.send())
-
- @mock.patch("bot.cogs.moderation.silence.asyncio")
- def test_cog_unload_skips_task_start(self, asyncio_mock):
- """No task created with no channels."""
- self.cog.cog_unload()
- asyncio_mock.create_task.assert_not_called()
-
- @mock.patch("bot.cogs.moderation.silence.with_role_check")
- @mock.patch("bot.cogs.moderation.silence.MODERATION_ROLES", new=(1, 2, 3))
- def test_cog_check(self, role_check):
- """Role check is called with `MODERATION_ROLES`"""
- self.cog.cog_check(self.ctx)
- role_check.assert_called_once_with(self.ctx, *(1, 2, 3))
diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py
deleted file mode 100644
index 70aea2bab..000000000
--- a/tests/bot/cogs/sync/test_base.py
+++ /dev/null
@@ -1,404 +0,0 @@
-import asyncio
-import unittest
-from unittest import mock
-
-import discord
-
-from bot import constants
-from bot.api import ResponseCodeError
-from bot.cogs.sync.syncers import Syncer, _Diff
-from tests import helpers
-
-
-class TestSyncer(Syncer):
- """Syncer subclass with mocks for abstract methods for testing purposes."""
-
- name = "test"
- _get_diff = mock.AsyncMock()
- _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)
-
- self.syncer._get_diff.assert_called_once_with(guild)
- self.syncer._get_confirmation_result.assert_called_once()
-
- if confirmed:
- self.syncer._sync.assert_called_once_with(diff)
- else:
- self.syncer._sync.assert_not_called()
-
- 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)
-
- async def test_sync_message_edited(self):
- """The message should be edited if one was sent, even if the sync has an API error."""
- subtests = (
- (None, None, False),
- (helpers.MockMessage(), None, True),
- (helpers.MockMessage(), ResponseCodeError(mock.MagicMock()), True),
- )
-
- 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)
- )
-
- guild = helpers.MockGuild()
- await self.syncer.sync(guild)
-
- 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()
- subtests = (
- (None, self.bot.user, None),
- (helpers.MockContext(author=mock_member), mock_member, 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)
-
- 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/cogs/test_duck_pond.py b/tests/bot/cogs/test_duck_pond.py
deleted file mode 100644
index a8c0107c6..000000000
--- a/tests/bot/cogs/test_duck_pond.py
+++ /dev/null
@@ -1,575 +0,0 @@
-import asyncio
-import logging
-import typing
-import unittest
-from unittest.mock import AsyncMock, MagicMock, patch
-
-import discord
-
-from bot import constants
-from bot.cogs import duck_pond
-from tests import base
-from tests import helpers
-
-MODULE_PATH = "bot.cogs.duck_pond"
-
-
-class DuckPondTests(base.LoggingTestsMixin, unittest.IsolatedAsyncioTestCase):
- """Tests for DuckPond functionality."""
-
- @classmethod
- def setUpClass(cls):
- """Sets up the objects that only have to be initialized once."""
- cls.nonstaff_member = helpers.MockMember(name="Non-staffer")
-
- cls.staff_role = helpers.MockRole(name="Staff role", id=constants.STAFF_ROLES[0])
- cls.staff_member = helpers.MockMember(name="staffer", roles=[cls.staff_role])
-
- cls.checkmark_emoji = "\N{White Heavy Check Mark}"
- cls.thumbs_up_emoji = "\N{Thumbs Up Sign}"
- cls.unicode_duck_emoji = "\N{Duck}"
- cls.duck_pond_emoji = helpers.MockPartialEmoji(id=constants.DuckPond.custom_emojis[0])
- cls.non_duck_custom_emoji = helpers.MockPartialEmoji(id=123)
-
- def setUp(self):
- """Sets up the objects that need to be refreshed before each test."""
- self.bot = helpers.MockBot(user=helpers.MockMember(id=46692))
- self.cog = duck_pond.DuckPond(bot=self.bot)
-
- def test_duck_pond_correctly_initializes(self):
- """`__init__ should set `bot` and `webhook_id` attributes and schedule `fetch_webhook`."""
- bot = helpers.MockBot()
- cog = MagicMock()
-
- duck_pond.DuckPond.__init__(cog, bot)
-
- self.assertEqual(cog.bot, bot)
- self.assertEqual(cog.webhook_id, constants.Webhooks.duck_pond)
- bot.loop.create_task.assert_called_once_with(cog.fetch_webhook())
-
- def test_fetch_webhook_succeeds_without_connectivity_issues(self):
- """The `fetch_webhook` method waits until `READY` event and sets the `webhook` attribute."""
- self.bot.fetch_webhook.return_value = "dummy webhook"
- self.cog.webhook_id = 1
-
- asyncio.run(self.cog.fetch_webhook())
-
- self.bot.wait_until_guild_available.assert_called_once()
- self.bot.fetch_webhook.assert_called_once_with(1)
- self.assertEqual(self.cog.webhook, "dummy webhook")
-
- def test_fetch_webhook_logs_when_unable_to_fetch_webhook(self):
- """The `fetch_webhook` method should log an exception when it fails to fetch the webhook."""
- self.bot.fetch_webhook.side_effect = discord.HTTPException(response=MagicMock(), message="Not found.")
- self.cog.webhook_id = 1
-
- log = logging.getLogger('bot.cogs.duck_pond')
- with self.assertLogs(logger=log, level=logging.ERROR) as log_watcher:
- asyncio.run(self.cog.fetch_webhook())
-
- self.bot.wait_until_guild_available.assert_called_once()
- self.bot.fetch_webhook.assert_called_once_with(1)
-
- self.assertEqual(len(log_watcher.records), 1)
-
- record = log_watcher.records[0]
- self.assertEqual(record.levelno, logging.ERROR)
-
- def test_is_staff_returns_correct_values_based_on_instance_passed(self):
- """The `is_staff` method should return correct values based on the instance passed."""
- test_cases = (
- (helpers.MockUser(name="User instance"), False),
- (helpers.MockMember(name="Member instance without staff role"), False),
- (helpers.MockMember(name="Member instance with staff role", roles=[self.staff_role]), True)
- )
-
- for user, expected_return in test_cases:
- actual_return = self.cog.is_staff(user)
- with self.subTest(user_type=user.name, expected_return=expected_return, actual_return=actual_return):
- self.assertEqual(expected_return, actual_return)
-
- async def test_has_green_checkmark_correctly_detects_presence_of_green_checkmark_emoji(self):
- """The `has_green_checkmark` method should only return `True` if one is present."""
- test_cases = (
- (
- "No reactions", helpers.MockMessage(), False
- ),
- (
- "No green check mark reactions",
- helpers.MockMessage(reactions=[
- helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.bot.user]),
- helpers.MockReaction(emoji=self.thumbs_up_emoji, users=[self.bot.user])
- ]),
- False
- ),
- (
- "Green check mark reaction, but not from our bot",
- helpers.MockMessage(reactions=[
- helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.bot.user]),
- helpers.MockReaction(emoji=self.checkmark_emoji, users=[self.staff_member])
- ]),
- False
- ),
- (
- "Green check mark reaction, with one from the bot",
- helpers.MockMessage(reactions=[
- helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.bot.user]),
- helpers.MockReaction(emoji=self.checkmark_emoji, users=[self.staff_member, self.bot.user])
- ]),
- True
- )
- )
-
- for description, message, expected_return in test_cases:
- actual_return = await self.cog.has_green_checkmark(message)
- with self.subTest(
- test_case=description,
- expected_return=expected_return,
- actual_return=actual_return
- ):
- self.assertEqual(expected_return, actual_return)
-
- def test_send_webhook_correctly_passes_on_arguments(self):
- """The `send_webhook` method should pass the arguments to the webhook correctly."""
- self.cog.webhook = helpers.MockAsyncWebhook()
-
- content = "fake content"
- username = "fake username"
- avatar_url = "fake avatar_url"
- embed = "fake embed"
-
- asyncio.run(self.cog.send_webhook(content, username, avatar_url, embed))
-
- self.cog.webhook.send.assert_called_once_with(
- content=content,
- username=username,
- avatar_url=avatar_url,
- embed=embed
- )
-
- def test_send_webhook_logs_when_sending_message_fails(self):
- """The `send_webhook` method should catch a `discord.HTTPException` and log accordingly."""
- self.cog.webhook = helpers.MockAsyncWebhook()
- self.cog.webhook.send.side_effect = discord.HTTPException(response=MagicMock(), message="Something failed.")
-
- log = logging.getLogger('bot.cogs.duck_pond')
- with self.assertLogs(logger=log, level=logging.ERROR) as log_watcher:
- asyncio.run(self.cog.send_webhook())
-
- self.assertEqual(len(log_watcher.records), 1)
-
- record = log_watcher.records[0]
- self.assertEqual(record.levelno, logging.ERROR)
-
- def _get_reaction(
- self,
- emoji: typing.Union[str, helpers.MockEmoji],
- staff: int = 0,
- nonstaff: int = 0
- ) -> helpers.MockReaction:
- staffers = [helpers.MockMember(roles=[self.staff_role]) for _ in range(staff)]
- nonstaffers = [helpers.MockMember() for _ in range(nonstaff)]
- return helpers.MockReaction(emoji=emoji, users=staffers + nonstaffers)
-
- async def test_count_ducks_correctly_counts_the_number_of_eligible_duck_emojis(self):
- """The `count_ducks` method should return the number of unique staffers who gave a duck."""
- test_cases = (
- # Simple test cases
- # A message without reactions should return 0
- (
- "No reactions",
- helpers.MockMessage(),
- 0
- ),
- # A message with a non-duck reaction from a non-staffer should return 0
- (
- "Non-duck reaction from non-staffer",
- helpers.MockMessage(reactions=[self._get_reaction(emoji=self.thumbs_up_emoji, nonstaff=1)]),
- 0
- ),
- # A message with a non-duck reaction from a staffer should return 0
- (
- "Non-duck reaction from staffer",
- helpers.MockMessage(reactions=[self._get_reaction(emoji=self.non_duck_custom_emoji, staff=1)]),
- 0
- ),
- # A message with a non-duck reaction from a non-staffer and staffer should return 0
- (
- "Non-duck reaction from staffer + non-staffer",
- helpers.MockMessage(reactions=[self._get_reaction(emoji=self.thumbs_up_emoji, staff=1, nonstaff=1)]),
- 0
- ),
- # A message with a unicode duck reaction from a non-staffer should return 0
- (
- "Unicode Duck Reaction from non-staffer",
- helpers.MockMessage(reactions=[self._get_reaction(emoji=self.unicode_duck_emoji, nonstaff=1)]),
- 0
- ),
- # A message with a unicode duck reaction from a staffer should return 1
- (
- "Unicode Duck Reaction from staffer",
- helpers.MockMessage(reactions=[self._get_reaction(emoji=self.unicode_duck_emoji, staff=1)]),
- 1
- ),
- # A message with a unicode duck reaction from a non-staffer and staffer should return 1
- (
- "Unicode Duck Reaction from staffer + non-staffer",
- helpers.MockMessage(reactions=[self._get_reaction(emoji=self.unicode_duck_emoji, staff=1, nonstaff=1)]),
- 1
- ),
- # A message with a duckpond duck reaction from a non-staffer should return 0
- (
- "Duckpond Duck Reaction from non-staffer",
- helpers.MockMessage(reactions=[self._get_reaction(emoji=self.duck_pond_emoji, nonstaff=1)]),
- 0
- ),
- # A message with a duckpond duck reaction from a staffer should return 1
- (
- "Duckpond Duck Reaction from staffer",
- helpers.MockMessage(reactions=[self._get_reaction(emoji=self.duck_pond_emoji, staff=1)]),
- 1
- ),
- # A message with a duckpond duck reaction from a non-staffer and staffer should return 1
- (
- "Duckpond Duck Reaction from staffer + non-staffer",
- helpers.MockMessage(reactions=[self._get_reaction(emoji=self.duck_pond_emoji, staff=1, nonstaff=1)]),
- 1
- ),
-
- # Complex test cases
- # A message with duckpond duck reactions from 3 staffers and 2 non-staffers returns 3
- (
- "Duckpond Duck Reaction from 3 staffers + 2 non-staffers",
- helpers.MockMessage(reactions=[self._get_reaction(emoji=self.duck_pond_emoji, staff=3, nonstaff=2)]),
- 3
- ),
- # A staffer with multiple duck reactions only counts once
- (
- "Two different duck reactions from the same staffer",
- helpers.MockMessage(
- reactions=[
- helpers.MockReaction(emoji=self.duck_pond_emoji, users=[self.staff_member]),
- helpers.MockReaction(emoji=self.unicode_duck_emoji, users=[self.staff_member]),
- ]
- ),
- 1
- ),
- # A non-string emoji does not count (to test the `isinstance(reaction.emoji, str)` elif)
- (
- "Reaction with non-Emoji/str emoij from 3 staffers + 2 non-staffers",
- helpers.MockMessage(reactions=[self._get_reaction(emoji=100, staff=3, nonstaff=2)]),
- 0
- ),
- # We correctly sum when multiple reactions are provided.
- (
- "Duckpond Duck Reaction from 3 staffers + 2 non-staffers",
- helpers.MockMessage(
- reactions=[
- self._get_reaction(emoji=self.duck_pond_emoji, staff=3, nonstaff=2),
- self._get_reaction(emoji=self.unicode_duck_emoji, staff=4, nonstaff=9),
- ]
- ),
- 3 + 4
- ),
- )
-
- for description, message, expected_count in test_cases:
- actual_count = await self.cog.count_ducks(message)
- with self.subTest(test_case=description, expected_count=expected_count, actual_count=actual_count):
- self.assertEqual(expected_count, actual_count)
-
- async def test_relay_message_correctly_relays_content_and_attachments(self):
- """The `relay_message` method should correctly relay message content and attachments."""
- send_webhook_path = f"{MODULE_PATH}.DuckPond.send_webhook"
- send_attachments_path = f"{MODULE_PATH}.send_attachments"
-
- self.cog.webhook = helpers.MockAsyncWebhook()
-
- test_values = (
- (helpers.MockMessage(clean_content="", attachments=[]), False, False),
- (helpers.MockMessage(clean_content="message", attachments=[]), True, False),
- (helpers.MockMessage(clean_content="", attachments=["attachment"]), False, True),
- (helpers.MockMessage(clean_content="message", attachments=["attachment"]), True, True),
- )
-
- for message, expect_webhook_call, expect_attachment_call in test_values:
- with patch(send_webhook_path, new_callable=AsyncMock) as send_webhook:
- with patch(send_attachments_path, new_callable=AsyncMock) as send_attachments:
- with self.subTest(clean_content=message.clean_content, attachments=message.attachments):
- await self.cog.relay_message(message)
-
- self.assertEqual(expect_webhook_call, send_webhook.called)
- self.assertEqual(expect_attachment_call, send_attachments.called)
-
- message.add_reaction.assert_called_once_with(self.checkmark_emoji)
-
- @patch(f"{MODULE_PATH}.send_attachments", new_callable=AsyncMock)
- async def test_relay_message_handles_irretrievable_attachment_exceptions(self, send_attachments):
- """The `relay_message` method should handle irretrievable attachments."""
- message = helpers.MockMessage(clean_content="message", attachments=["attachment"])
- side_effects = (discord.errors.Forbidden(MagicMock(), ""), discord.errors.NotFound(MagicMock(), ""))
-
- self.cog.webhook = helpers.MockAsyncWebhook()
- log = logging.getLogger("bot.cogs.duck_pond")
-
- for side_effect in side_effects: # pragma: no cover
- send_attachments.side_effect = side_effect
- with patch(f"{MODULE_PATH}.DuckPond.send_webhook", new_callable=AsyncMock) as send_webhook:
- with self.subTest(side_effect=type(side_effect).__name__):
- with self.assertNotLogs(logger=log, level=logging.ERROR):
- await self.cog.relay_message(message)
-
- self.assertEqual(send_webhook.call_count, 2)
-
- @patch(f"{MODULE_PATH}.DuckPond.send_webhook", new_callable=AsyncMock)
- @patch(f"{MODULE_PATH}.send_attachments", new_callable=AsyncMock)
- async def test_relay_message_handles_attachment_http_error(self, send_attachments, send_webhook):
- """The `relay_message` method should handle irretrievable attachments."""
- message = helpers.MockMessage(clean_content="message", attachments=["attachment"])
-
- self.cog.webhook = helpers.MockAsyncWebhook()
- log = logging.getLogger("bot.cogs.duck_pond")
-
- side_effect = discord.HTTPException(MagicMock(), "")
- send_attachments.side_effect = side_effect
- with self.subTest(side_effect=type(side_effect).__name__):
- with self.assertLogs(logger=log, level=logging.ERROR) as log_watcher:
- await self.cog.relay_message(message)
-
- send_webhook.assert_called_once_with(
- content=message.clean_content,
- username=message.author.display_name,
- avatar_url=message.author.avatar_url
- )
-
- self.assertEqual(len(log_watcher.records), 1)
-
- record = log_watcher.records[0]
- self.assertEqual(record.levelno, logging.ERROR)
-
- def _mock_payload(self, label: str, is_custom_emoji: bool, id_: int, emoji_name: str):
- """Creates a mock `on_raw_reaction_add` payload with the specified emoji data."""
- payload = MagicMock(name=label)
- payload.emoji.is_custom_emoji.return_value = is_custom_emoji
- payload.emoji.id = id_
- payload.emoji.name = emoji_name
- return payload
-
- async def test_payload_has_duckpond_emoji_correctly_detects_relevant_emojis(self):
- """The `on_raw_reaction_add` event handler should ignore irrelevant emojis."""
- test_values = (
- # Custom Emojis
- (
- self._mock_payload(
- label="Custom Duckpond Emoji",
- is_custom_emoji=True,
- id_=constants.DuckPond.custom_emojis[0],
- emoji_name=""
- ),
- True
- ),
- (
- self._mock_payload(
- label="Custom Non-Duckpond Emoji",
- is_custom_emoji=True,
- id_=123,
- emoji_name=""
- ),
- False
- ),
- # Unicode Emojis
- (
- self._mock_payload(
- label="Unicode Duck Emoji",
- is_custom_emoji=False,
- id_=1,
- emoji_name=self.unicode_duck_emoji
- ),
- True
- ),
- (
- self._mock_payload(
- label="Unicode Non-Duck Emoji",
- is_custom_emoji=False,
- id_=1,
- emoji_name=self.thumbs_up_emoji
- ),
- False
- ),
- )
-
- for payload, expected_return in test_values:
- actual_return = self.cog._payload_has_duckpond_emoji(payload)
- with self.subTest(case=payload._mock_name, expected_return=expected_return, actual_return=actual_return):
- self.assertEqual(expected_return, actual_return)
-
- @patch(f"{MODULE_PATH}.discord.utils.get")
- @patch(f"{MODULE_PATH}.DuckPond._payload_has_duckpond_emoji", new=MagicMock(return_value=False))
- def test_on_raw_reaction_add_returns_early_with_payload_without_duck_emoji(self, utils_get):
- """The `on_raw_reaction_add` method should return early if the payload does not contain a duck emoji."""
- self.assertIsNone(asyncio.run(self.cog.on_raw_reaction_add(payload=MagicMock())))
-
- # Ensure we've returned before making an unnecessary API call in the lines of code after the emoji check
- utils_get.assert_not_called()
-
- def _raw_reaction_mocks(self, channel_id, message_id, user_id):
- """Sets up mocks for tests of the `on_raw_reaction_add` event listener."""
- channel = helpers.MockTextChannel(id=channel_id)
- self.bot.get_all_channels.return_value = (channel,)
-
- message = helpers.MockMessage(id=message_id)
-
- channel.fetch_message.return_value = message
-
- member = helpers.MockMember(id=user_id, roles=[self.staff_role])
- message.guild.members = (member,)
-
- payload = MagicMock(channel_id=channel_id, message_id=message_id, user_id=user_id)
-
- return channel, message, member, payload
-
- async def test_on_raw_reaction_add_returns_for_bot_and_non_staff_members(self):
- """The `on_raw_reaction_add` event handler should return for bot users or non-staff members."""
- channel_id = 1234
- message_id = 2345
- user_id = 3456
-
- channel, message, _, payload = self._raw_reaction_mocks(channel_id, message_id, user_id)
-
- test_cases = (
- ("non-staff member", helpers.MockMember(id=user_id)),
- ("bot staff member", helpers.MockMember(id=user_id, roles=[self.staff_role], bot=True)),
- )
-
- payload.emoji = self.duck_pond_emoji
-
- for description, member in test_cases:
- message.guild.members = (member, )
- with self.subTest(test_case=description), patch(f"{MODULE_PATH}.DuckPond.has_green_checkmark") as checkmark:
- checkmark.side_effect = AssertionError(
- "Expected method to return before calling `self.has_green_checkmark`."
- )
- self.assertIsNone(await self.cog.on_raw_reaction_add(payload))
-
- # Check that we did make it past the payload checks
- channel.fetch_message.assert_called_once()
- channel.fetch_message.reset_mock()
-
- @patch(f"{MODULE_PATH}.DuckPond.is_staff")
- @patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=AsyncMock)
- def test_on_raw_reaction_add_returns_on_message_with_green_checkmark_placed_by_bot(self, count_ducks, is_staff):
- """The `on_raw_reaction_add` event should return when the message has a green check mark placed by the bot."""
- channel_id = 31415926535
- message_id = 27182818284
- user_id = 16180339887
-
- channel, message, member, payload = self._raw_reaction_mocks(channel_id, message_id, user_id)
-
- payload.emoji = helpers.MockPartialEmoji(name=self.unicode_duck_emoji)
- payload.emoji.is_custom_emoji.return_value = False
-
- message.reactions = [helpers.MockReaction(emoji=self.checkmark_emoji, users=[self.bot.user])]
-
- is_staff.return_value = True
- count_ducks.side_effect = AssertionError("Expected method to return before calling `self.count_ducks`")
-
- self.assertIsNone(asyncio.run(self.cog.on_raw_reaction_add(payload)))
-
- # Assert that we've made it past `self.is_staff`
- is_staff.assert_called_once()
-
- async def test_on_raw_reaction_add_does_not_relay_below_duck_threshold(self):
- """The `on_raw_reaction_add` listener should not relay messages or attachments below the duck threshold."""
- test_cases = (
- (constants.DuckPond.threshold - 1, False),
- (constants.DuckPond.threshold, True),
- (constants.DuckPond.threshold + 1, True),
- )
-
- channel, message, member, payload = self._raw_reaction_mocks(channel_id=3, message_id=4, user_id=5)
-
- payload.emoji = self.duck_pond_emoji
-
- for duck_count, should_relay in test_cases:
- with patch(f"{MODULE_PATH}.DuckPond.relay_message", new_callable=AsyncMock) as relay_message:
- with patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=AsyncMock) as count_ducks:
- count_ducks.return_value = duck_count
- with self.subTest(duck_count=duck_count, should_relay=should_relay):
- await self.cog.on_raw_reaction_add(payload)
-
- # Confirm that we've made it past counting
- count_ducks.assert_called_once()
-
- # Did we relay a message?
- has_relayed = relay_message.called
- self.assertEqual(has_relayed, should_relay)
-
- if should_relay:
- relay_message.assert_called_once_with(message)
-
- async def test_on_raw_reaction_remove_prevents_removal_of_green_checkmark_depending_on_the_duck_count(self):
- """The `on_raw_reaction_remove` listener prevents removal of the check mark on messages with enough ducks."""
- checkmark = helpers.MockPartialEmoji(name=self.checkmark_emoji)
-
- message = helpers.MockMessage(id=1234)
-
- channel = helpers.MockTextChannel(id=98765)
- channel.fetch_message.return_value = message
-
- self.bot.get_all_channels.return_value = (channel, )
-
- payload = MagicMock(channel_id=channel.id, message_id=message.id, emoji=checkmark)
-
- test_cases = (
- (constants.DuckPond.threshold - 1, False),
- (constants.DuckPond.threshold, True),
- (constants.DuckPond.threshold + 1, True),
- )
- for duck_count, should_re_add_checkmark in test_cases:
- with patch(f"{MODULE_PATH}.DuckPond.count_ducks", new_callable=AsyncMock) as count_ducks:
- count_ducks.return_value = duck_count
- with self.subTest(duck_count=duck_count, should_re_add_checkmark=should_re_add_checkmark):
- await self.cog.on_raw_reaction_remove(payload)
-
- # Check if we fetched the message
- channel.fetch_message.assert_called_once_with(message.id)
-
- # Check if we actually counted the number of ducks
- count_ducks.assert_called_once_with(message)
-
- has_re_added_checkmark = message.add_reaction.called
- self.assertEqual(should_re_add_checkmark, has_re_added_checkmark)
-
- if should_re_add_checkmark:
- message.add_reaction.assert_called_once_with(self.checkmark_emoji)
- message.add_reaction.reset_mock()
-
- # reset mocks
- channel.fetch_message.reset_mock()
- message.reset_mock()
-
- def test_on_raw_reaction_remove_ignores_removal_of_non_checkmark_reactions(self):
- """The `on_raw_reaction_remove` listener should ignore the removal of non-check mark emojis."""
- channel = helpers.MockTextChannel(id=98765)
-
- channel.fetch_message.side_effect = AssertionError(
- "Expected method to return before calling `channel.fetch_message`"
- )
-
- self.bot.get_all_channels.return_value = (channel, )
-
- payload = MagicMock(emoji=helpers.MockPartialEmoji(name=self.thumbs_up_emoji), channel_id=channel.id)
-
- self.assertIsNone(asyncio.run(self.cog.on_raw_reaction_remove(payload)))
-
- channel.fetch_message.assert_not_called()
-
-
-class DuckPondSetupTests(unittest.TestCase):
- """Tests setup of the `DuckPond` cog."""
-
- def test_setup(self):
- """Setup of the extension should call add_cog."""
- bot = helpers.MockBot()
- duck_pond.setup(bot)
- bot.add_cog.assert_called_once()
diff --git a/tests/bot/exts/__init__.py b/tests/bot/exts/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/tests/bot/exts/__init__.py
diff --git a/tests/bot/exts/backend/__init__.py b/tests/bot/exts/backend/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/tests/bot/exts/backend/__init__.py
diff --git a/tests/bot/exts/backend/sync/__init__.py b/tests/bot/exts/backend/sync/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/tests/bot/exts/backend/sync/__init__.py
diff --git a/tests/bot/exts/backend/sync/test_base.py b/tests/bot/exts/backend/sync/test_base.py
new file mode 100644
index 000000000..4953550f9
--- /dev/null
+++ b/tests/bot/exts/backend/sync/test_base.py
@@ -0,0 +1,73 @@
+import unittest
+from unittest import mock
+
+
+from bot.api import ResponseCodeError
+from bot.exts.backend.sync._syncers import Syncer
+from tests import helpers
+
+
+class TestSyncer(Syncer):
+ """Syncer subclass with mocks for abstract methods for testing purposes."""
+
+ name = "test"
+ _get_diff = mock.AsyncMock()
+ _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 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)
+ self.guild = helpers.MockGuild()
+
+ # Make sure `_get_diff` returns a MagicMock, not an AsyncMock
+ self.syncer._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."""
+ subtests = (
+ (None, None, False),
+ (helpers.MockMessage(), None, True),
+ (helpers.MockMessage(), ResponseCodeError(mock.MagicMock()), True),
+ )
+
+ 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
+ ctx = helpers.MockContext()
+ ctx.send.return_value = message
+
+ await self.syncer.sync(self.guild, ctx)
+
+ if should_edit:
+ message.edit.assert_called_once()
+ self.assertIn("content", message.edit.call_args[1])
+
+ async def test_sync_message_sent(self):
+ """If ctx is given, a new message should be sent."""
+ subtests = (
+ (None, None),
+ (helpers.MockContext(), helpers.MockMessage()),
+ )
+
+ for ctx, message in subtests:
+ with self.subTest(ctx=ctx, message=message):
+ await self.syncer.sync(self.guild, ctx)
+
+ if ctx is not None:
+ ctx.send.assert_called_once()
diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/exts/backend/sync/test_cog.py
index 120bc991d..063a82754 100644
--- a/tests/bot/cogs/sync/test_cog.py
+++ b/tests/bot/exts/backend/sync/test_cog.py
@@ -5,8 +5,9 @@ import discord
from bot import constants
from bot.api import ResponseCodeError
-from bot.cogs import sync
-from bot.cogs.sync.syncers import Syncer
+from bot.exts.backend import sync
+from bot.exts.backend.sync._cog import Sync
+from bot.exts.backend.sync._syncers import Syncer
from tests import helpers
from tests.base import CommandTestCase
@@ -29,19 +30,19 @@ class SyncCogTestCase(unittest.IsolatedAsyncioTestCase):
self.bot = helpers.MockBot()
self.role_syncer_patcher = mock.patch(
- "bot.cogs.sync.syncers.RoleSyncer",
+ "bot.exts.backend.sync._syncers.RoleSyncer",
autospec=Syncer,
spec_set=True
)
self.user_syncer_patcher = mock.patch(
- "bot.cogs.sync.syncers.UserSyncer",
+ "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.Sync(self.bot)
+ self.cog = Sync(self.bot)
def tearDown(self):
self.role_syncer_patcher.stop()
@@ -59,7 +60,7 @@ class SyncCogTestCase(unittest.IsolatedAsyncioTestCase):
class SyncCogTests(SyncCogTestCase):
"""Tests for the Sync cog."""
- @mock.patch.object(sync.Sync, "sync_guild", new_callable=mock.MagicMock)
+ @mock.patch.object(Sync, "sync_guild", new_callable=mock.MagicMock)
def test_sync_cog_init(self, sync_guild):
"""Should instantiate syncers and run a sync for the guild."""
# Reset because a Sync cog was already instantiated in setUp.
@@ -70,7 +71,7 @@ class SyncCogTests(SyncCogTestCase):
mock_sync_guild_coro = mock.MagicMock()
sync_guild.return_value = mock_sync_guild_coro
- sync.Sync(self.bot)
+ Sync(self.bot)
self.RoleSyncer.assert_called_once_with(self.bot)
self.UserSyncer.assert_called_once_with(self.bot)
@@ -131,7 +132,7 @@ class SyncCogListenerTests(SyncCogTestCase):
super().setUp()
self.cog.patch_user = mock.AsyncMock(spec_set=self.cog.patch_user)
- self.guild_id_patcher = mock.patch("bot.cogs.sync.cog.constants.Guild.id", 5)
+ self.guild_id_patcher = mock.patch("bot.exts.backend.sync._cog.constants.Guild.id", 5)
self.guild_id = self.guild_id_patcher.start()
self.guild = helpers.MockGuild(id=self.guild_id)
@@ -391,14 +392,14 @@ 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)
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)
diff --git a/tests/bot/cogs/sync/test_roles.py b/tests/bot/exts/backend/sync/test_roles.py
index 79eee98f4..7b9f40cad 100644
--- a/tests/bot/cogs/sync/test_roles.py
+++ b/tests/bot/exts/backend/sync/test_roles.py
@@ -3,7 +3,7 @@ from unittest import mock
import discord
-from bot.cogs.sync.syncers import RoleSyncer, _Diff, _Role
+from bot.exts.backend.sync._syncers import RoleSyncer, _Diff, _Role
from tests import helpers
diff --git a/tests/bot/cogs/sync/test_users.py b/tests/bot/exts/backend/sync/test_users.py
index 002a947ad..9f380a15d 100644
--- a/tests/bot/cogs/sync/test_users.py
+++ b/tests/bot/exts/backend/sync/test_users.py
@@ -1,7 +1,6 @@
import unittest
-from unittest import mock
-from bot.cogs.sync.syncers import UserSyncer, _Diff, _User
+from bot.exts.backend.sync._syncers import UserSyncer, _Diff
from tests import helpers
@@ -10,7 +9,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
@@ -40,22 +39,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)
+ 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())
+ guild.get_member.return_value = self.get_mock_member(fake_user())
actual_diff = await self.syncer._get_diff(guild)
- expected_diff = (set(), set(), None)
+ expected_diff = ([], [], None)
self.assertEqual(actual_diff, expected_diff)
@@ -63,59 +82,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)
+ 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)
-
+ guild.get_member.side_effect = [
+ self.get_mock_member(fake_user()),
+ self.get_mock_member(new_user)
+ ]
actual_diff = await self.syncer._get_diff(guild)
- expected_diff = ({_User(**new_user)}, set(), None)
+ 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)
+ 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)
+ 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)
+ expected_diff = ([], [], None)
self.assertEqual(actual_diff, expected_diff)
@@ -131,13 +193,10 @@ class UserSyncerSyncTests(unittest.IsolatedAsyncioTestCase):
"""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)
+ diff = _Diff(users, [], None)
await self.syncer._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 +205,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)
+ diff = _Diff([], users, None)
await self.syncer._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/cogs/test_logging.py b/tests/bot/exts/backend/test_logging.py
index 8a18fdcd6..466f207d9 100644
--- a/tests/bot/cogs/test_logging.py
+++ b/tests/bot/exts/backend/test_logging.py
@@ -2,7 +2,7 @@ import unittest
from unittest.mock import patch
from bot import constants
-from bot.cogs.logging import Logging
+from bot.exts.backend.logging import Logging
from tests.helpers import MockBot, MockTextChannel
@@ -14,7 +14,7 @@ class LoggingTests(unittest.IsolatedAsyncioTestCase):
self.cog = Logging(self.bot)
self.dev_log = MockTextChannel(id=1234, name="dev-log")
- @patch("bot.cogs.logging.DEBUG_MODE", False)
+ @patch("bot.exts.backend.logging.DEBUG_MODE", False)
async def test_debug_mode_false(self):
"""Should send connected message to dev-log."""
self.bot.get_channel.return_value = self.dev_log
@@ -24,7 +24,7 @@ class LoggingTests(unittest.IsolatedAsyncioTestCase):
self.bot.get_channel.assert_called_once_with(constants.Channels.dev_log)
self.dev_log.send.assert_awaited_once()
- @patch("bot.cogs.logging.DEBUG_MODE", True)
+ @patch("bot.exts.backend.logging.DEBUG_MODE", True)
async def test_debug_mode_true(self):
"""Should not send anything to dev-log."""
await self.cog.startup_greeting()
diff --git a/tests/bot/exts/filters/__init__.py b/tests/bot/exts/filters/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/tests/bot/exts/filters/__init__.py
diff --git a/tests/bot/cogs/test_antimalware.py b/tests/bot/exts/filters/test_antimalware.py
index f219fc1ba..3393c6cdc 100644
--- a/tests/bot/cogs/test_antimalware.py
+++ b/tests/bot/exts/filters/test_antimalware.py
@@ -1,28 +1,35 @@
import unittest
-from unittest.mock import AsyncMock, Mock, patch
+from unittest.mock import AsyncMock, Mock
from discord import NotFound
-from bot.cogs import antimalware
-from bot.constants import AntiMalware as AntiMalwareConfig, Channels, STAFF_ROLES
+from bot.constants import Channels, STAFF_ROLES
+from bot.exts.filters import antimalware
from tests.helpers import MockAttachment, MockBot, MockMessage, MockRole
-MODULE = "bot.cogs.antimalware"
-
-@patch(f"{MODULE}.AntiMalwareConfig.whitelist", new=[".first", ".second", ".third"])
class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase):
"""Test the AntiMalware cog."""
def setUp(self):
"""Sets up fresh objects for each test."""
self.bot = MockBot()
+ self.bot.filter_list_cache = {
+ "FILE_FORMAT.True": {
+ ".first": {},
+ ".second": {},
+ ".third": {},
+ }
+ }
self.cog = antimalware.AntiMalware(self.bot)
self.message = MockMessage()
+ self.message.webhook_id = None
+ self.message.author.bot = None
+ self.whitelist = [".first", ".second", ".third"]
async def test_message_with_allowed_attachment(self):
"""Messages with allowed extensions should not be deleted"""
- attachment = MockAttachment(filename=f"python{AntiMalwareConfig.whitelist[0]}")
+ attachment = MockAttachment(filename="python.first")
self.message.attachments = [attachment]
await self.cog.on_message(self.message)
@@ -43,6 +50,26 @@ class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase):
self.message.delete.assert_not_called()
+ async def test_webhook_message_with_illegal_extension(self):
+ """A webhook message containing an illegal extension should be ignored."""
+ attachment = MockAttachment(filename="python.disallowed")
+ self.message.webhook_id = 697140105563078727
+ self.message.attachments = [attachment]
+
+ await self.cog.on_message(self.message)
+
+ self.message.delete.assert_not_called()
+
+ async def test_bot_message_with_illegal_extension(self):
+ """A bot message containing an illegal extension should be ignored."""
+ attachment = MockAttachment(filename="python.disallowed")
+ self.message.author.bot = 409107086526644234
+ self.message.attachments = [attachment]
+
+ await self.cog.on_message(self.message)
+
+ self.message.delete.assert_not_called()
+
async def test_message_with_illegal_extension_gets_deleted(self):
"""A message containing an illegal extension should send an embed."""
attachment = MockAttachment(filename="python.disallowed")
@@ -93,7 +120,7 @@ class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(embed.description, antimalware.TXT_EMBED_DESCRIPTION.format.return_value)
antimalware.TXT_EMBED_DESCRIPTION.format.assert_called_with(cmd_channel_mention=cmd_channel.mention)
- async def test_other_disallowed_extention_embed_description(self):
+ async def test_other_disallowed_extension_embed_description(self):
"""Test the description for a non .py/.txt disallowed extension."""
attachment = MockAttachment(filename="python.disallowed")
self.message.attachments = [attachment]
@@ -109,6 +136,7 @@ class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(embed.description, antimalware.DISALLOWED_EMBED_DESCRIPTION.format.return_value)
antimalware.DISALLOWED_EMBED_DESCRIPTION.format.assert_called_with(
+ joined_whitelist=", ".join(self.whitelist),
blocked_extensions_str=".disallowed",
meta_channel_mention=meta_channel.mention
)
@@ -135,7 +163,7 @@ class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase):
"""The return value should include all non-whitelisted extensions."""
test_values = (
([], []),
- (AntiMalwareConfig.whitelist, []),
+ (self.whitelist, []),
([".first"], []),
([".first", ".disallowed"], [".disallowed"]),
([".disallowed"], [".disallowed"]),
@@ -145,7 +173,7 @@ class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase):
for extensions, expected_disallowed_extensions in test_values:
with self.subTest(extensions=extensions, expected_disallowed_extensions=expected_disallowed_extensions):
self.message.attachments = [MockAttachment(filename=f"filename{extension}") for extension in extensions]
- disallowed_extensions = self.cog.get_disallowed_extensions(self.message)
+ disallowed_extensions = self.cog._get_disallowed_extensions(self.message)
self.assertCountEqual(disallowed_extensions, expected_disallowed_extensions)
diff --git a/tests/bot/cogs/test_antispam.py b/tests/bot/exts/filters/test_antispam.py
index ce5472c71..6a0e4fded 100644
--- a/tests/bot/cogs/test_antispam.py
+++ b/tests/bot/exts/filters/test_antispam.py
@@ -1,6 +1,6 @@
import unittest
-from bot.cogs import antispam
+from bot.exts.filters import antispam
class AntispamConfigurationValidationTests(unittest.TestCase):
diff --git a/tests/bot/cogs/test_security.py b/tests/bot/exts/filters/test_security.py
index 9d1a62f7e..c0c3baa42 100644
--- a/tests/bot/cogs/test_security.py
+++ b/tests/bot/exts/filters/test_security.py
@@ -3,7 +3,7 @@ from unittest.mock import MagicMock
from discord.ext.commands import NoPrivateMessage
-from bot.cogs import security
+from bot.exts.filters import security
from tests.helpers import MockBot, MockContext
diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/exts/filters/test_token_remover.py
index 3349caa73..f99cc3370 100644
--- a/tests/bot/cogs/test_token_remover.py
+++ b/tests/bot/exts/filters/test_token_remover.py
@@ -6,9 +6,10 @@ from unittest.mock import MagicMock
from discord import Colour, NotFound
from bot import constants
-from bot.cogs import token_remover
-from bot.cogs.moderation import ModLog
-from bot.cogs.token_remover import Token, TokenRemover
+from bot.exts.filters import token_remover
+from bot.exts.filters.token_remover import Token, TokenRemover
+from bot.exts.moderation.modlog import ModLog
+from bot.utils.messages import format_user
from tests.helpers import MockBot, MockMessage, autospec
@@ -22,23 +23,25 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):
self.msg = MockMessage(id=555, content="hello world")
self.msg.channel.mention = "#lemonade-stand"
+ self.msg.guild.get_member.return_value.bot = False
+ self.msg.guild.get_member.return_value.__str__.return_value = "Woody"
self.msg.author.__str__ = MagicMock(return_value=self.msg.author.name)
self.msg.author.avatar_url_as.return_value = "picture-lemon.png"
- def test_is_valid_user_id_valid(self):
- """Should consider user IDs valid if they decode entirely to ASCII digits."""
- ids = (
- "NDcyMjY1OTQzMDYyNDEzMzMy",
- "NDc1MDczNjI5Mzk5NTQ3OTA0",
- "NDY3MjIzMjMwNjUwNzc3NjQx",
+ def test_extract_user_id_valid(self):
+ """Should consider user IDs valid if they decode into an integer ID."""
+ id_pairs = (
+ ("NDcyMjY1OTQzMDYyNDEzMzMy", 472265943062413332),
+ ("NDc1MDczNjI5Mzk5NTQ3OTA0", 475073629399547904),
+ ("NDY3MjIzMjMwNjUwNzc3NjQx", 467223230650777641),
)
- for user_id in ids:
- with self.subTest(user_id=user_id):
- result = TokenRemover.is_valid_user_id(user_id)
- self.assertTrue(result)
+ for token_id, user_id in id_pairs:
+ with self.subTest(token_id=token_id):
+ result = TokenRemover.extract_user_id(token_id)
+ self.assertEqual(result, user_id)
- def test_is_valid_user_id_invalid(self):
+ def test_extract_user_id_invalid(self):
"""Should consider non-digit and non-ASCII IDs invalid."""
ids = (
("SGVsbG8gd29ybGQ", "non-digit ASCII"),
@@ -52,8 +55,8 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):
for user_id, msg in ids:
with self.subTest(msg=msg):
- result = TokenRemover.is_valid_user_id(user_id)
- self.assertFalse(result)
+ result = TokenRemover.extract_user_id(user_id)
+ self.assertIsNone(result)
def test_is_valid_timestamp_valid(self):
"""Should consider timestamps valid if they're greater than the Discord epoch."""
@@ -85,6 +88,34 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):
result = TokenRemover.is_valid_timestamp(timestamp)
self.assertFalse(result)
+ def test_is_valid_hmac_valid(self):
+ """Should consider an HMAC valid if it has at least 3 unique characters."""
+ valid_hmacs = (
+ "VXmErH7j511turNpfURmb0rVNm8",
+ "Ysnu2wacjaKs7qnoo46S8Dm2us8",
+ "sJf6omBPORBPju3WJEIAcwW9Zds",
+ "s45jqDV_Iisn-symw0yDRrk_jf4",
+ )
+
+ for hmac in valid_hmacs:
+ with self.subTest(msg=hmac):
+ result = TokenRemover.is_maybe_valid_hmac(hmac)
+ self.assertTrue(result)
+
+ def test_is_invalid_hmac_invalid(self):
+ """Should consider an HMAC invalid if has fewer than 3 unique characters."""
+ invalid_hmacs = (
+ ("xxxxxxxxxxxxxxxxxx", "Single character"),
+ ("XxXxXxXxXxXxXxXxXx", "Single character alternating case"),
+ ("ASFasfASFasfASFASsf", "Three characters alternating-case"),
+ ("asdasdasdasdasdasdasd", "Three characters one case"),
+ )
+
+ for hmac, msg in invalid_hmacs:
+ with self.subTest(msg=msg):
+ result = TokenRemover.is_maybe_valid_hmac(hmac)
+ self.assertFalse(result)
+
def test_mod_log_property(self):
"""The `mod_log` property should ask the bot to return the `ModLog` cog."""
self.bot.get_cog.return_value = 'lemon'
@@ -132,7 +163,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):
await cog.on_message(msg)
find_token_in_message.assert_not_called()
- @autospec("bot.cogs.token_remover", "TOKEN_RE")
+ @autospec("bot.exts.filters.token_remover", "TOKEN_RE")
def test_find_token_no_matches(self, token_re):
"""None should be returned if the regex matches no tokens in a message."""
token_re.finditer.return_value = ()
@@ -142,11 +173,18 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):
self.assertIsNone(return_value)
token_re.finditer.assert_called_once_with(self.msg.content)
- @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp")
- @autospec("bot.cogs.token_remover", "Token")
- @autospec("bot.cogs.token_remover", "TOKEN_RE")
- def test_find_token_valid_match(self, token_re, token_cls, is_valid_id, is_valid_timestamp):
- """The first match with a valid user ID and timestamp should be returned as a `Token`."""
+ @autospec(TokenRemover, "extract_user_id", "is_valid_timestamp", "is_maybe_valid_hmac")
+ @autospec("bot.exts.filters.token_remover", "Token")
+ @autospec("bot.exts.filters.token_remover", "TOKEN_RE")
+ def test_find_token_valid_match(
+ self,
+ token_re,
+ token_cls,
+ extract_user_id,
+ is_valid_timestamp,
+ is_maybe_valid_hmac,
+ ):
+ """The first match with a valid user ID, timestamp, and HMAC should be returned as a `Token`."""
matches = [
mock.create_autospec(Match, spec_set=True, instance=True),
mock.create_autospec(Match, spec_set=True, instance=True),
@@ -158,23 +196,32 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):
token_re.finditer.return_value = matches
token_cls.side_effect = tokens
- is_valid_id.side_effect = (False, True) # The 1st match will be invalid, 2nd one valid.
+ extract_user_id.side_effect = (None, True) # The 1st match will be invalid, 2nd one valid.
is_valid_timestamp.return_value = True
+ is_maybe_valid_hmac.return_value = True
return_value = TokenRemover.find_token_in_message(self.msg)
self.assertEqual(tokens[1], return_value)
token_re.finditer.assert_called_once_with(self.msg.content)
- @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp")
- @autospec("bot.cogs.token_remover", "Token")
- @autospec("bot.cogs.token_remover", "TOKEN_RE")
- def test_find_token_invalid_matches(self, token_re, token_cls, is_valid_id, is_valid_timestamp):
- """None should be returned if no matches have valid user IDs or timestamps."""
+ @autospec(TokenRemover, "extract_user_id", "is_valid_timestamp", "is_maybe_valid_hmac")
+ @autospec("bot.exts.filters.token_remover", "Token")
+ @autospec("bot.exts.filters.token_remover", "TOKEN_RE")
+ def test_find_token_invalid_matches(
+ self,
+ token_re,
+ token_cls,
+ extract_user_id,
+ is_valid_timestamp,
+ is_maybe_valid_hmac,
+ ):
+ """None should be returned if no matches have valid user IDs, HMACs, and timestamps."""
token_re.finditer.return_value = [mock.create_autospec(Match, spec_set=True, instance=True)]
token_cls.return_value = mock.create_autospec(Token, spec_set=True, instance=True)
- is_valid_id.return_value = False
+ extract_user_id.return_value = None
is_valid_timestamp.return_value = False
+ is_maybe_valid_hmac.return_value = False
return_value = TokenRemover.find_token_in_message(self.msg)
@@ -230,36 +277,85 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):
results = [match[0] for match in results]
self.assertCountEqual((token_1, token_2), results)
- @autospec("bot.cogs.token_remover", "LOG_MESSAGE")
+ @autospec("bot.exts.filters.token_remover", "LOG_MESSAGE")
def test_format_log_message(self, log_message):
"""Should correctly format the log message with info from the message and token."""
- token = Token("NDY3MjIzMjMwNjUwNzc3NjQx", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4")
+ token = Token("NDcyMjY1OTQzMDYyNDEzMzMy", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4")
log_message.format.return_value = "Howdy"
return_value = TokenRemover.format_log_message(self.msg, token)
self.assertEqual(return_value, log_message.format.return_value)
log_message.format.assert_called_once_with(
- author=self.msg.author,
- author_id=self.msg.author.id,
+ author=format_user(self.msg.author),
channel=self.msg.channel.mention,
user_id=token.user_id,
timestamp=token.timestamp,
hmac="x" * len(token.hmac),
)
+ @autospec("bot.exts.filters.token_remover", "UNKNOWN_USER_LOG_MESSAGE")
+ def test_format_userid_log_message_unknown(self, unknown_user_log_message):
+ """Should correctly format the user ID portion when the actual user it belongs to is unknown."""
+ token = Token("NDcyMjY1OTQzMDYyNDEzMzMy", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4")
+ unknown_user_log_message.format.return_value = " Partner"
+ msg = MockMessage(id=555, content="hello world")
+ msg.guild.get_member.return_value = None
+
+ return_value = TokenRemover.format_userid_log_message(msg, token)
+
+ self.assertEqual(return_value, (unknown_user_log_message.format.return_value, False))
+ unknown_user_log_message.format.assert_called_once_with(user_id=472265943062413332)
+
+ @autospec("bot.exts.filters.token_remover", "KNOWN_USER_LOG_MESSAGE")
+ def test_format_userid_log_message_bot(self, known_user_log_message):
+ """Should correctly format the user ID portion when the ID belongs to a known bot."""
+ token = Token("NDcyMjY1OTQzMDYyNDEzMzMy", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4")
+ known_user_log_message.format.return_value = " Partner"
+ msg = MockMessage(id=555, content="hello world")
+ msg.guild.get_member.return_value.__str__.return_value = "Sam"
+ msg.guild.get_member.return_value.bot = True
+
+ return_value = TokenRemover.format_userid_log_message(msg, token)
+
+ self.assertEqual(return_value, (known_user_log_message.format.return_value, False))
+
+ known_user_log_message.format.assert_called_once_with(
+ user_id=472265943062413332,
+ user_name="Sam",
+ kind="BOT",
+ )
+
+ @autospec("bot.exts.filters.token_remover", "KNOWN_USER_LOG_MESSAGE")
+ def test_format_log_message_user_token_user(self, user_token_message):
+ """Should correctly format the user ID portion when the ID belongs to a known user."""
+ token = Token("NDY3MjIzMjMwNjUwNzc3NjQx", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4")
+ user_token_message.format.return_value = "Partner"
+
+ return_value = TokenRemover.format_userid_log_message(self.msg, token)
+
+ self.assertEqual(return_value, (user_token_message.format.return_value, True))
+ user_token_message.format.assert_called_once_with(
+ user_id=467223230650777641,
+ user_name="Woody",
+ kind="USER",
+ )
+
@mock.patch.object(TokenRemover, "mod_log", new_callable=mock.PropertyMock)
- @autospec("bot.cogs.token_remover", "log")
- @autospec(TokenRemover, "format_log_message")
- async def test_take_action(self, format_log_message, logger, mod_log_property):
+ @autospec("bot.exts.filters.token_remover", "log")
+ @autospec(TokenRemover, "format_log_message", "format_userid_log_message")
+ async def test_take_action(self, format_log_message, format_userid_log_message, logger, mod_log_property):
"""Should delete the message and send a mod log."""
cog = TokenRemover(self.bot)
mod_log = mock.create_autospec(ModLog, spec_set=True, instance=True)
token = mock.create_autospec(Token, spec_set=True, instance=True)
+ token.user_id = "no-id"
log_msg = "testing123"
+ userid_log_message = "userid-log-message"
mod_log_property.return_value = mod_log
format_log_message.return_value = log_msg
+ format_userid_log_message.return_value = (userid_log_message, True)
await cog.take_action(self.msg, token)
@@ -269,6 +365,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):
)
format_log_message.assert_called_once_with(self.msg, token)
+ format_userid_log_message.assert_called_once_with(self.msg, token)
logger.debug.assert_called_with(log_msg)
self.bot.stats.incr.assert_called_once_with("tokens.removed_tokens")
@@ -277,9 +374,10 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):
icon_url=constants.Icons.token_removed,
colour=Colour(constants.Colours.soft_red),
title="Token removed!",
- text=log_msg,
+ text=log_msg + "\n" + userid_log_message,
thumbnail=self.msg.author.avatar_url_as.return_value,
- channel_id=constants.Channels.mod_alerts
+ channel_id=constants.Channels.mod_alerts,
+ ping_everyone=True,
)
@mock.patch.object(TokenRemover, "mod_log", new_callable=mock.PropertyMock)
@@ -299,7 +397,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):
class TokenRemoverExtensionTests(unittest.TestCase):
"""Tests for the token_remover extension."""
- @autospec("bot.cogs.token_remover", "TokenRemover")
+ @autospec("bot.exts.filters.token_remover", "TokenRemover")
def test_extension_setup(self, cog):
"""The TokenRemover cog should be added."""
bot = MockBot()
diff --git a/tests/bot/exts/info/__init__.py b/tests/bot/exts/info/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/tests/bot/exts/info/__init__.py
diff --git a/tests/bot/cogs/test_information.py b/tests/bot/exts/info/test_information.py
index 79c0e0ad3..daede54c5 100644
--- a/tests/bot/cogs/test_information.py
+++ b/tests/bot/exts/info/test_information.py
@@ -1,4 +1,3 @@
-import asyncio
import textwrap
import unittest
import unittest.mock
@@ -6,14 +5,14 @@ import unittest.mock
import discord
from bot import constants
-from bot.cogs import information
+from bot.exts.info import information
from bot.utils.checks import InWhitelistCheckFailure
from tests import helpers
-COG_PATH = "bot.cogs.information.Information"
+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.cogs.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"]
@@ -215,12 +138,12 @@ class UserInfractionHelperMethodTests(unittest.TestCase):
with self.subTest(method=method, api_response=api_response, expected_lines=expected_lines):
self.bot.api_client.get.return_value = api_response
- expected_output = "\n".join(default_header + expected_lines)
- actual_output = asyncio.run(method(self.member))
+ expected_output = "\n".join(expected_lines)
+ actual_output = await method(self.member)
- self.assertEqual(expected_output, actual_output)
+ 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
@@ -249,16 +172,16 @@ class UserInfractionHelperMethodTests(unittest.TestCase):
},
)
- header = ["**Infractions**"]
+ 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 = (
{
"api response": [],
- "expected_lines": ["This user has never received an infraction."],
+ "expected_lines": ["No infractions"],
},
# Shows non-hidden inactive infraction as expected
{
@@ -304,24 +227,24 @@ class UserInfractionHelperMethodTests(unittest.TestCase):
},
)
- header = ["**Infractions**"]
+ 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 = (
{
"api response": [],
- "expected_lines": ["This user has never been nominated."],
+ "expected_lines": ["No nominations"],
},
{
"api response": [{'active': True}],
- "expected_lines": ["This user is **currently** nominated (1 nomination in total)."],
+ "expected_lines": ["This user is **currently** nominated", "(1 nomination in total)"],
},
{
"api response": [{'active': True}, {'active': False}],
- "expected_lines": ["This user is **currently** nominated (2 nominations in total)."],
+ "expected_lines": ["This user is **currently** nominated", "(2 nominations in total)"],
},
{
"api response": [{'active': False}],
@@ -334,14 +257,14 @@ class UserInfractionHelperMethodTests(unittest.TestCase):
)
- header = ["**Nominations**"]
+ 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)
[email protected]("bot.cogs.information.time_since", new=unittest.mock.MagicMock(return_value="1 year ago"))
[email protected]("bot.cogs.information.constants.MODERATION_CHANNELS", new=[50])
-class UserEmbedTests(unittest.TestCase):
[email protected]("bot.exts.info.information.time_since", new=unittest.mock.MagicMock(return_value="1 year ago"))
[email protected]("bot.exts.info.information.constants.MODERATION_CHANNELS", new=[50])
+class UserEmbedTests(unittest.IsolatedAsyncioTestCase):
"""Tests for the creation of the `!user` embed."""
def setUp(self):
@@ -350,32 +273,41 @@ class UserEmbedTests(unittest.TestCase):
self.bot.api_client.get = unittest.mock.AsyncMock()
self.cog = information.Information(self.bot)
- @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value=""))
- def test_create_user_embed_uses_string_representation_of_user_in_title_if_nick_is_not_available(self):
+ @unittest.mock.patch(
+ f"{COG_PATH}.basic_user_infraction_counts",
+ new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions"))
+ )
+ 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")
- @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value=""))
- def test_create_user_embed_uses_nick_in_title_if_available(self):
+ @unittest.mock.patch(
+ f"{COG_PATH}.basic_user_infraction_counts",
+ new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions"))
+ )
+ 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)")
- @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value=""))
- def test_create_user_embed_ignores_everyone_role(self):
+ @unittest.mock.patch(
+ f"{COG_PATH}.basic_user_infraction_counts",
+ new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions"))
+ )
+ 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')
@@ -384,80 +316,92 @@ 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.description)
- self.assertNotIn("&Everyone", embed.description)
+ 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))
moderators_role = helpers.MockRole(name='Moderators')
moderators_role.colour = 100
- infraction_counts.return_value = "expanded infractions info"
- nomination_counts.return_value = "nomination info"
+ infraction_counts.return_value = ("Infractions", "expanded infractions info")
+ 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)
self.assertEqual(
textwrap.dedent(f"""
- **User Information**
Created: {"1 year ago"}
Profile: {user.mention}
ID: {user.id}
+ """).strip(),
+ embed.fields[0].value
+ )
- **Member Information**
+ self.assertEqual(
+ textwrap.dedent(f"""
Joined: {"1 year ago"}
Roles: &Moderators
-
- expanded infractions info
-
- nomination info
""").strip(),
- embed.description
+ embed.fields[1].value
)
@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))
moderators_role = helpers.MockRole(name='Moderators')
moderators_role.colour = 100
- infraction_counts.return_value = "basic infractions info"
+ 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)
self.assertEqual(
textwrap.dedent(f"""
- **User Information**
Created: {"1 year ago"}
Profile: {user.mention}
ID: {user.id}
+ """).strip(),
+ embed.fields[0].value
+ )
- **Member Information**
+ self.assertEqual(
+ textwrap.dedent(f"""
Joined: {"1 year ago"}
Roles: &Moderators
-
- basic infractions info
""").strip(),
- embed.description
+ embed.fields[1].value
+ )
+
+ self.assertEqual(
+ "basic infractions info",
+ embed.fields[2].value
)
- @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value=""))
- def test_create_user_embed_uses_top_role_colour_when_user_has_roles(self):
+ @unittest.mock.patch(
+ f"{COG_PATH}.basic_user_infraction_counts",
+ new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions"))
+ )
+ 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()
@@ -465,35 +409,41 @@ 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))
- @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value=""))
- def test_create_user_embed_uses_blurple_colour_when_user_has_no_roles(self):
+ @unittest.mock.patch(
+ f"{COG_PATH}.basic_user_infraction_counts",
+ new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions"))
+ )
+ 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())
- @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value=""))
- def test_create_user_embed_uses_png_format_of_user_avatar_as_thumbnail(self):
+ @unittest.mock.patch(
+ f"{COG_PATH}.basic_user_infraction_counts",
+ new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions"))
+ )
+ 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")
[email protected]("bot.cogs.information.constants")
-class UserCommandTests(unittest.TestCase):
[email protected]("bot.exts.info.information.constants")
+class UserCommandTests(unittest.IsolatedAsyncioTestCase):
"""Tests for the `!user` command."""
def setUp(self):
@@ -509,76 +459,70 @@ class UserCommandTests(unittest.TestCase):
self.moderator = helpers.MockMember(id=2, name="riffautae", roles=[self.moderator_role])
self.target = helpers.MockMember(id=3, name="__fluzz__")
- def test_regular_member_cannot_target_another_member(self, constants):
+ # There's no way to mock the channel constant without deferring imports. The constant is
+ # used as a default value for a parameter, which gets defined upon import.
+ self.bot_command_channel = helpers.MockTextChannel(id=constants.Channels.bot_commands)
+
+ 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]
- constants.Channels.bot_commands = 50
-
ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=100))
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.cogs.information.Information.create_user_embed", new_callable=unittest.mock.AsyncMock)
- def test_regular_user_may_use_command_in_bot_commands_channel(self, create_embed, constants):
+ @unittest.mock.patch("bot.exts.info.information.Information.create_user_embed")
+ 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]
- constants.Channels.bot_commands = 50
+ ctx = helpers.MockContext(author=self.author, channel=self.bot_command_channel)
- ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=50))
-
- 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.cogs.information.Information.create_user_embed", new_callable=unittest.mock.AsyncMock)
- def test_regular_user_can_explicitly_target_themselves(self, create_embed, constants):
+ @unittest.mock.patch("bot.exts.info.information.Information.create_user_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]
- constants.Channels.bot_commands = 50
-
- ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=50))
+ 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.cogs.information.Information.create_user_embed", new_callable=unittest.mock.AsyncMock)
- def test_staff_members_can_bypass_channel_restriction(self, create_embed, constants):
+ @unittest.mock.patch("bot.exts.info.information.Information.create_user_embed")
+ 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]
- constants.Channels.bot_commands = 50
-
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.cogs.information.Information.create_user_embed", new_callable=unittest.mock.AsyncMock)
- def test_moderators_can_target_another_member(self, create_embed, constants):
+ @unittest.mock.patch("bot.exts.info.information.Information.create_user_embed")
+ 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/__init__.py b/tests/bot/exts/moderation/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/tests/bot/exts/moderation/__init__.py
diff --git a/tests/bot/exts/moderation/infraction/__init__.py b/tests/bot/exts/moderation/infraction/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/tests/bot/exts/moderation/infraction/__init__.py
diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py
new file mode 100644
index 000000000..bf557a484
--- /dev/null
+++ b/tests/bot/exts/moderation/infraction/test_infractions.py
@@ -0,0 +1,201 @@
+import textwrap
+import unittest
+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
+
+
+class TruncationTests(unittest.IsolatedAsyncioTestCase):
+ """Tests for ban and kick command reason truncation."""
+
+ def setUp(self):
+ self.bot = MockBot()
+ self.cog = Infractions(self.bot)
+ self.user = MockMember(id=1234, top_role=MockRole(id=3577, position=10))
+ self.target = MockMember(id=1265, top_role=MockRole(id=9876, position=0))
+ self.guild = MockGuild(id=4567)
+ self.ctx = MockContext(bot=self.bot, author=self.user, guild=self.guild)
+
+ @patch("bot.exts.moderation.infraction._utils.get_active_infraction")
+ @patch("bot.exts.moderation.infraction._utils.post_infraction")
+ async def test_apply_ban_reason_truncation(self, post_infraction_mock, get_active_mock):
+ """Should truncate reason for `ctx.guild.ban`."""
+ get_active_mock.return_value = None
+ post_infraction_mock.return_value = {"foo": "bar"}
+
+ self.cog.apply_infraction = AsyncMock()
+ self.bot.get_cog.return_value = AsyncMock()
+ self.cog.mod_log.ignore = Mock()
+ self.ctx.guild.ban = Mock()
+
+ await self.cog.apply_ban(self.ctx, self.target, "foo bar" * 3000)
+ self.ctx.guild.ban.assert_called_once_with(
+ self.target,
+ reason=textwrap.shorten("foo bar" * 3000, 512, placeholder="..."),
+ delete_message_days=0
+ )
+ self.cog.apply_infraction.assert_awaited_once_with(
+ self.ctx, {"foo": "bar"}, self.target, self.ctx.guild.ban.return_value
+ )
+
+ @patch("bot.exts.moderation.infraction._utils.post_infraction")
+ async def test_apply_kick_reason_truncation(self, post_infraction_mock):
+ """Should truncate reason for `Member.kick`."""
+ post_infraction_mock.return_value = {"foo": "bar"}
+
+ self.cog.apply_infraction = AsyncMock()
+ self.cog.mod_log.ignore = Mock()
+ self.target.kick = Mock()
+
+ await self.cog.apply_kick(self.ctx, self.target, "foo bar" * 3000)
+ self.target.kick.assert_called_once_with(reason=textwrap.shorten("foo bar" * 3000, 512, placeholder="..."))
+ 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/infraction/test_utils.py b/tests/bot/exts/moderation/infraction/test_utils.py
new file mode 100644
index 000000000..5b62463e0
--- /dev/null
+++ b/tests/bot/exts/moderation/infraction/test_utils.py
@@ -0,0 +1,359 @@
+import unittest
+from collections import namedtuple
+from datetime import datetime
+from unittest.mock import AsyncMock, MagicMock, call, patch
+
+from discord import Embed, Forbidden, HTTPException, NotFound
+
+from bot.api import ResponseCodeError
+from bot.constants import Colours, Icons
+from bot.exts.moderation.infraction import _utils as utils
+from tests.helpers import MockBot, MockContext, MockMember, MockUser
+
+
+class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):
+ """Tests Moderation utils."""
+
+ def setUp(self):
+ self.bot = MockBot()
+ self.member = MockMember(id=1234)
+ self.user = MockUser(id=1234)
+ self.ctx = MockContext(bot=self.bot, author=self.member)
+
+ async def test_post_user(self):
+ """Should POST a new user and return the response if successful or otherwise send an error message."""
+ user = MockUser(discriminator=5678, id=1234, name="Test user")
+ not_user = MagicMock(discriminator=3333, id=5678, name="Wrong user")
+ test_cases = [
+ {
+ "user": user,
+ "post_result": "bar",
+ "raise_error": None,
+ "payload": {
+ "discriminator": 5678,
+ "id": self.user.id,
+ "in_guild": False,
+ "name": "Test user",
+ "roles": []
+ }
+ },
+ {
+ "user": self.member,
+ "post_result": "foo",
+ "raise_error": ResponseCodeError(MagicMock(status=400), "foo"),
+ "payload": {
+ "discriminator": 0,
+ "id": self.member.id,
+ "in_guild": False,
+ "name": "Name unknown",
+ "roles": []
+ }
+ },
+ {
+ "user": not_user,
+ "post_result": "bar",
+ "raise_error": None,
+ "payload": {
+ "discriminator": not_user.discriminator,
+ "id": not_user.id,
+ "in_guild": False,
+ "name": not_user.name,
+ "roles": []
+ }
+ }
+ ]
+
+ for case in test_cases:
+ user = case["user"]
+ post_result = case["post_result"]
+ raise_error = case["raise_error"]
+ payload = case["payload"]
+
+ with self.subTest(user=user, post_result=post_result, raise_error=raise_error, payload=payload):
+ self.bot.api_client.post.reset_mock(side_effect=True)
+ self.ctx.bot.api_client.post.return_value = post_result
+
+ self.ctx.bot.api_client.post.side_effect = raise_error
+
+ result = await utils.post_user(self.ctx, user)
+
+ if raise_error:
+ self.assertIsNone(result)
+ self.ctx.send.assert_awaited_once()
+ self.assertIn(str(raise_error.status), self.ctx.send.call_args[0][0])
+ else:
+ self.assertEqual(result, post_result)
+ self.bot.api_client.post.assert_awaited_once_with("bot/users", json=payload)
+
+ async def test_get_active_infraction(self):
+ """
+ Should request the API for active infractions and return infraction if the user has one or `None` otherwise.
+
+ A message should be sent to the context indicating a user already has an infraction, if that's the case.
+ """
+ test_case = namedtuple("test_case", ["get_return_value", "expected_output", "infraction_nr", "send_msg"])
+ test_cases = [
+ test_case([], None, None, True),
+ test_case([{"id": 123987}], {"id": 123987}, "123987", False),
+ test_case([{"id": 123987}], {"id": 123987}, "123987", True)
+ ]
+
+ for case in test_cases:
+ with self.subTest(return_value=case.get_return_value, expected=case.expected_output):
+ self.bot.api_client.get.reset_mock()
+ self.ctx.send.reset_mock()
+
+ params = {
+ "active": "true",
+ "type": "ban",
+ "user__id": str(self.member.id)
+ }
+
+ self.bot.api_client.get.return_value = case.get_return_value
+
+ result = await utils.get_active_infraction(self.ctx, self.member, "ban", send_msg=case.send_msg)
+ self.assertEqual(result, case.expected_output)
+ self.bot.api_client.get.assert_awaited_once_with("bot/infractions", params=params)
+
+ if case.send_msg and case.get_return_value:
+ self.ctx.send.assert_awaited_once()
+ sent_message = self.ctx.send.call_args[0][0]
+ self.assertIn(case.infraction_nr, sent_message)
+ self.assertIn("ban", sent_message)
+ else:
+ self.ctx.send.assert_not_awaited()
+
+ @patch("bot.exts.moderation.infraction._utils.send_private_embed")
+ async def test_notify_infraction(self, send_private_embed_mock):
+ """
+ Should send an embed of a certain format as a DM and return `True` if DM successful.
+
+ Appealable infractions should have the appeal message in the embed's footer.
+ """
+ test_cases = [
+ {
+ "args": (self.user, "ban", "2020-02-26 09:20 (23 hours and 59 minutes)"),
+ "expected_output": Embed(
+ title=utils.INFRACTION_TITLE,
+ description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format(
+ type="Ban",
+ expires="2020-02-26 09:20 (23 hours and 59 minutes)",
+ reason="No reason provided."
+ ),
+ colour=Colours.soft_red,
+ url=utils.RULES_URL
+ ).set_author(
+ name=utils.INFRACTION_AUTHOR_NAME,
+ url=utils.RULES_URL,
+ icon_url=Icons.token_removed
+ ).set_footer(text=utils.INFRACTION_APPEAL_FOOTER),
+ "send_result": True
+ },
+ {
+ "args": (self.user, "warning", None, "Test reason."),
+ "expected_output": Embed(
+ title=utils.INFRACTION_TITLE,
+ description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format(
+ type="Warning",
+ expires="N/A",
+ reason="Test reason."
+ ),
+ colour=Colours.soft_red,
+ url=utils.RULES_URL
+ ).set_author(
+ name=utils.INFRACTION_AUTHOR_NAME,
+ url=utils.RULES_URL,
+ icon_url=Icons.token_removed
+ ),
+ "send_result": False
+ },
+ {
+ "args": (self.user, "note", None, None, Icons.defcon_denied),
+ "expected_output": Embed(
+ title=utils.INFRACTION_TITLE,
+ description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format(
+ type="Note",
+ expires="N/A",
+ reason="No reason provided."
+ ),
+ colour=Colours.soft_red,
+ url=utils.RULES_URL
+ ).set_author(
+ name=utils.INFRACTION_AUTHOR_NAME,
+ url=utils.RULES_URL,
+ icon_url=Icons.defcon_denied
+ ),
+ "send_result": False
+ },
+ {
+ "args": (self.user, "mute", "2020-02-26 09:20 (23 hours and 59 minutes)", "Test", Icons.defcon_denied),
+ "expected_output": Embed(
+ title=utils.INFRACTION_TITLE,
+ description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format(
+ type="Mute",
+ expires="2020-02-26 09:20 (23 hours and 59 minutes)",
+ reason="Test"
+ ),
+ colour=Colours.soft_red,
+ url=utils.RULES_URL
+ ).set_author(
+ name=utils.INFRACTION_AUTHOR_NAME,
+ url=utils.RULES_URL,
+ icon_url=Icons.defcon_denied
+ ).set_footer(text=utils.INFRACTION_APPEAL_FOOTER),
+ "send_result": False
+ },
+ {
+ "args": (self.user, "mute", None, "foo bar" * 4000, Icons.defcon_denied),
+ "expected_output": Embed(
+ title=utils.INFRACTION_TITLE,
+ description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format(
+ type="Mute",
+ expires="N/A",
+ reason="foo bar" * 4000
+ )[:2045] + "...",
+ colour=Colours.soft_red,
+ url=utils.RULES_URL
+ ).set_author(
+ name=utils.INFRACTION_AUTHOR_NAME,
+ url=utils.RULES_URL,
+ icon_url=Icons.defcon_denied
+ ).set_footer(text=utils.INFRACTION_APPEAL_FOOTER),
+ "send_result": True
+ }
+ ]
+
+ for case in test_cases:
+ with self.subTest(args=case["args"], expected=case["expected_output"], send=case["send_result"]):
+ send_private_embed_mock.reset_mock()
+
+ send_private_embed_mock.return_value = case["send_result"]
+ result = await utils.notify_infraction(*case["args"])
+
+ self.assertEqual(case["send_result"], result)
+
+ embed = send_private_embed_mock.call_args[0][1]
+
+ self.assertEqual(embed.to_dict(), case["expected_output"].to_dict())
+
+ send_private_embed_mock.assert_awaited_once_with(case["args"][0], embed)
+
+ @patch("bot.exts.moderation.infraction._utils.send_private_embed")
+ async def test_notify_pardon(self, send_private_embed_mock):
+ """Should send an embed of a certain format as a DM and return `True` if DM successful."""
+ test_case = namedtuple("test_case", ["args", "icon", "send_result"])
+ test_cases = [
+ test_case((self.user, "Test title", "Example content"), Icons.user_verified, True),
+ test_case((self.user, "Test title", "Example content", Icons.user_update), Icons.user_update, False)
+ ]
+
+ for case in test_cases:
+ expected = Embed(
+ description="Example content",
+ colour=Colours.soft_green
+ ).set_author(
+ name="Test title",
+ icon_url=case.icon
+ )
+
+ with self.subTest(args=case.args, expected=expected):
+ send_private_embed_mock.reset_mock()
+
+ send_private_embed_mock.return_value = case.send_result
+
+ result = await utils.notify_pardon(*case.args)
+ self.assertEqual(case.send_result, result)
+
+ embed = send_private_embed_mock.call_args[0][1]
+ self.assertEqual(embed.to_dict(), expected.to_dict())
+
+ send_private_embed_mock.assert_awaited_once_with(case.args[0], embed)
+
+ async def test_send_private_embed(self):
+ """Should DM the user and return `True` on success or `False` on failure."""
+ embed = Embed(title="Test", description="Test val")
+
+ test_case = namedtuple("test_case", ["expected_output", "raised_exception"])
+ test_cases = [
+ test_case(True, None),
+ test_case(False, HTTPException(AsyncMock(), AsyncMock())),
+ test_case(False, Forbidden(AsyncMock(), AsyncMock())),
+ test_case(False, NotFound(AsyncMock(), AsyncMock()))
+ ]
+
+ for case in test_cases:
+ with self.subTest(expected=case.expected_output, raised=case.raised_exception):
+ self.user.send.reset_mock(side_effect=True)
+ self.user.send.side_effect = case.raised_exception
+
+ result = await utils.send_private_embed(self.user, embed)
+
+ self.assertEqual(result, case.expected_output)
+ self.user.send.assert_awaited_once_with(embed=embed)
+
+
+class TestPostInfraction(unittest.IsolatedAsyncioTestCase):
+ """Tests for the `post_infraction` function."""
+
+ def setUp(self):
+ self.bot = MockBot()
+ self.member = MockMember(id=1234)
+ self.user = MockUser(id=1234)
+ self.ctx = MockContext(bot=self.bot, author=self.member)
+
+ async def test_normal_post_infraction(self):
+ """Should return response from POST request if there are no errors."""
+ now = datetime.now()
+ payload = {
+ "actor": self.ctx.author.id,
+ "hidden": True,
+ "reason": "Test reason",
+ "type": "ban",
+ "user": self.member.id,
+ "active": False,
+ "expires_at": now.isoformat()
+ }
+
+ self.ctx.bot.api_client.post.return_value = "foo"
+ actual = await utils.post_infraction(self.ctx, self.member, "ban", "Test reason", now, True, False)
+
+ self.assertEqual(actual, "foo")
+ self.ctx.bot.api_client.post.assert_awaited_once_with("bot/infractions", json=payload)
+
+ async def test_unknown_error_post_infraction(self):
+ """Should send an error message to chat when a non-400 error occurs."""
+ self.ctx.bot.api_client.post.side_effect = ResponseCodeError(AsyncMock(), AsyncMock())
+ self.ctx.bot.api_client.post.side_effect.status = 500
+
+ actual = await utils.post_infraction(self.ctx, self.user, "ban", "Test reason")
+ self.assertIsNone(actual)
+
+ self.assertTrue("500" in self.ctx.send.call_args[0][0])
+
+ @patch("bot.exts.moderation.infraction._utils.post_user", return_value=None)
+ async def test_user_not_found_none_post_infraction(self, post_user_mock):
+ """Should abort and return `None` when a new user fails to be posted."""
+ self.bot.api_client.post.side_effect = ResponseCodeError(MagicMock(status=400), {"user": "foo"})
+
+ actual = await utils.post_infraction(self.ctx, self.user, "mute", "Test reason")
+ self.assertIsNone(actual)
+ post_user_mock.assert_awaited_once_with(self.ctx, self.user)
+
+ @patch("bot.exts.moderation.infraction._utils.post_user", return_value="bar")
+ async def test_first_fail_second_success_user_post_infraction(self, post_user_mock):
+ """Should post the user if they don't exist, POST infraction again, and return the response if successful."""
+ payload = {
+ "actor": self.ctx.author.id,
+ "hidden": False,
+ "reason": "Test reason",
+ "type": "mute",
+ "user": self.user.id,
+ "active": True
+ }
+
+ self.bot.api_client.post.side_effect = [ResponseCodeError(MagicMock(status=400), {"user": "foo"}), "foo"]
+
+ actual = await utils.post_infraction(self.ctx, self.user, "mute", "Test reason")
+ self.assertEqual(actual, "foo")
+ self.bot.api_client.post.assert_has_awaits([call("bot/infractions", json=payload)] * 2)
+ post_user_mock.assert_awaited_once_with(self.ctx, self.user)
diff --git a/tests/bot/exts/moderation/test_incidents.py b/tests/bot/exts/moderation/test_incidents.py
new file mode 100644
index 000000000..cbf7f7bcf
--- /dev/null
+++ b/tests/bot/exts/moderation/test_incidents.py
@@ -0,0 +1,770 @@
+import asyncio
+import enum
+import logging
+import typing as t
+import unittest
+from unittest.mock import AsyncMock, MagicMock, call, patch
+
+import aiohttp
+import discord
+
+from bot.constants import Colours
+from bot.exts.moderation import incidents
+from tests.helpers import (
+ MockAsyncWebhook,
+ MockAttachment,
+ MockBot,
+ MockMember,
+ MockMessage,
+ MockReaction,
+ MockRole,
+ MockTextChannel,
+ MockUser,
+)
+
+
+class MockAsyncIterable:
+ """
+ Helper for mocking asynchronous for loops.
+
+ It does not appear that the `unittest` library currently provides anything that would
+ allow us to simply mock an async iterator, such as `discord.TextChannel.history`.
+
+ We therefore write our own helper to wrap a regular synchronous iterable, and feed
+ its values via `__anext__` rather than `__next__`.
+
+ This class was written for the purposes of testing the `Incidents` cog - it may not
+ be generic enough to be placed in the `tests.helpers` module.
+ """
+
+ def __init__(self, messages: t.Iterable):
+ """Take a sync iterable to be wrapped."""
+ self.iter_messages = iter(messages)
+
+ def __aiter__(self):
+ """Return `self` as we provide the `__anext__` method."""
+ return self
+
+ async def __anext__(self):
+ """
+ Feed the next item, or raise `StopAsyncIteration`.
+
+ Since we're wrapping a sync iterator, it will communicate that it has been depleted
+ by raising a `StopIteration`. The `async for` construct does not expect it, and we
+ therefore need to substitute it for the appropriate exception type.
+ """
+ try:
+ return next(self.iter_messages)
+ except StopIteration:
+ raise StopAsyncIteration
+
+
+class MockSignal(enum.Enum):
+ A = "A"
+ B = "B"
+
+
+mock_404 = discord.NotFound(
+ response=MagicMock(aiohttp.ClientResponse), # Mock the erroneous response
+ message="Not found",
+)
+
+
+class TestDownloadFile(unittest.IsolatedAsyncioTestCase):
+ """Collection of tests for the `download_file` helper function."""
+
+ async def test_download_file_success(self):
+ """If `to_file` succeeds, function returns the acquired `discord.File`."""
+ file = MagicMock(discord.File, filename="bigbadlemon.jpg")
+ attachment = MockAttachment(to_file=AsyncMock(return_value=file))
+
+ acquired_file = await incidents.download_file(attachment)
+ self.assertIs(file, acquired_file)
+
+ async def test_download_file_404(self):
+ """If `to_file` encounters a 404, function handles the exception & returns None."""
+ attachment = MockAttachment(to_file=AsyncMock(side_effect=mock_404))
+
+ acquired_file = await incidents.download_file(attachment)
+ self.assertIsNone(acquired_file)
+
+ async def test_download_file_fail(self):
+ """If `to_file` fails on a non-404 error, function logs the exception & returns None."""
+ arbitrary_error = discord.HTTPException(MagicMock(aiohttp.ClientResponse), "Arbitrary API error")
+ attachment = MockAttachment(to_file=AsyncMock(side_effect=arbitrary_error))
+
+ with self.assertLogs(logger=incidents.log, level=logging.ERROR):
+ acquired_file = await incidents.download_file(attachment)
+
+ self.assertIsNone(acquired_file)
+
+
+class TestMakeEmbed(unittest.IsolatedAsyncioTestCase):
+ """Collection of tests for the `make_embed` helper function."""
+
+ async def test_make_embed_actioned(self):
+ """Embed is coloured green and footer contains 'Actioned' when `outcome=Signal.ACTIONED`."""
+ embed, file = await incidents.make_embed(MockMessage(), incidents.Signal.ACTIONED, MockMember())
+
+ self.assertEqual(embed.colour.value, Colours.soft_green)
+ self.assertIn("Actioned", embed.footer.text)
+
+ async def test_make_embed_not_actioned(self):
+ """Embed is coloured red and footer contains 'Rejected' when `outcome=Signal.NOT_ACTIONED`."""
+ embed, file = await incidents.make_embed(MockMessage(), incidents.Signal.NOT_ACTIONED, MockMember())
+
+ self.assertEqual(embed.colour.value, Colours.soft_red)
+ self.assertIn("Rejected", embed.footer.text)
+
+ async def test_make_embed_content(self):
+ """Incident content appears as embed description."""
+ incident = MockMessage(content="this is an incident")
+ embed, file = await incidents.make_embed(incident, incidents.Signal.ACTIONED, MockMember())
+
+ self.assertEqual(incident.content, embed.description)
+
+ async def test_make_embed_with_attachment_succeeds(self):
+ """Incident's attachment is downloaded and displayed in the embed's image field."""
+ file = MagicMock(discord.File, filename="bigbadjoe.jpg")
+ attachment = MockAttachment(filename="bigbadjoe.jpg")
+ incident = MockMessage(content="this is an incident", attachments=[attachment])
+
+ # Patch `download_file` to return our `file`
+ with patch("bot.exts.moderation.incidents.download_file", AsyncMock(return_value=file)):
+ embed, returned_file = await incidents.make_embed(incident, incidents.Signal.ACTIONED, MockMember())
+
+ self.assertIs(file, returned_file)
+ self.assertEqual("attachment://bigbadjoe.jpg", embed.image.url)
+
+ async def test_make_embed_with_attachment_fails(self):
+ """Incident's attachment fails to download, proxy url is linked instead."""
+ attachment = MockAttachment(proxy_url="discord.com/bigbadjoe.jpg")
+ incident = MockMessage(content="this is an incident", attachments=[attachment])
+
+ # Patch `download_file` to return None as if the download failed
+ with patch("bot.exts.moderation.incidents.download_file", AsyncMock(return_value=None)):
+ embed, returned_file = await incidents.make_embed(incident, incidents.Signal.ACTIONED, MockMember())
+
+ self.assertIsNone(returned_file)
+
+ # The author name field is simply expected to have something in it, we do not assert the message
+ self.assertGreater(len(embed.author.name), 0)
+ self.assertEqual(embed.author.url, "discord.com/bigbadjoe.jpg") # However, it should link the exact url
+
+
+@patch("bot.constants.Channels.incidents", 123)
+class TestIsIncident(unittest.TestCase):
+ """
+ Collection of tests for the `is_incident` helper function.
+
+ In `setUp`, we will create a mock message which should qualify as an incident. Each
+ test case will then mutate this instance to make it **not** qualify, in various ways.
+
+ Notice that we patch the #incidents channel id globally for this class.
+ """
+
+ def setUp(self) -> None:
+ """Prepare a mock message which should qualify as an incident."""
+ self.incident = MockMessage(
+ channel=MockTextChannel(id=123),
+ content="this is an incident",
+ author=MockUser(bot=False),
+ pinned=False,
+ )
+
+ def test_is_incident_true(self):
+ """Message qualifies as an incident if unchanged."""
+ self.assertTrue(incidents.is_incident(self.incident))
+
+ def check_false(self):
+ """Assert that `self.incident` does **not** qualify as an incident."""
+ self.assertFalse(incidents.is_incident(self.incident))
+
+ def test_is_incident_false_channel(self):
+ """Message doesn't qualify if sent outside of #incidents."""
+ self.incident.channel = MockTextChannel(id=456)
+ self.check_false()
+
+ def test_is_incident_false_content(self):
+ """Message doesn't qualify if content begins with hash symbol."""
+ self.incident.content = "# this is a comment message"
+ self.check_false()
+
+ def test_is_incident_false_author(self):
+ """Message doesn't qualify if author is a bot."""
+ self.incident.author = MockUser(bot=True)
+ self.check_false()
+
+ def test_is_incident_false_pinned(self):
+ """Message doesn't qualify if it is pinned."""
+ self.incident.pinned = True
+ self.check_false()
+
+
+class TestOwnReactions(unittest.TestCase):
+ """Assertions for the `own_reactions` function."""
+
+ def test_own_reactions(self):
+ """Only bot's own emoji are extracted from the input incident."""
+ reactions = (
+ MockReaction(emoji="A", me=True),
+ MockReaction(emoji="B", me=True),
+ MockReaction(emoji="C", me=False),
+ )
+ message = MockMessage(reactions=reactions)
+ self.assertSetEqual(incidents.own_reactions(message), {"A", "B"})
+
+
+@patch("bot.exts.moderation.incidents.ALL_SIGNALS", {"A", "B"})
+class TestHasSignals(unittest.TestCase):
+ """
+ Assertions for the `has_signals` function.
+
+ We patch `ALL_SIGNALS` globally. Each test function then patches `own_reactions`
+ as appropriate.
+ """
+
+ def test_has_signals_true(self):
+ """True when `own_reactions` returns all emoji in `ALL_SIGNALS`."""
+ message = MockMessage()
+ own_reactions = MagicMock(return_value={"A", "B"})
+
+ with patch("bot.exts.moderation.incidents.own_reactions", own_reactions):
+ self.assertTrue(incidents.has_signals(message))
+
+ def test_has_signals_false(self):
+ """False when `own_reactions` does not return all emoji in `ALL_SIGNALS`."""
+ message = MockMessage()
+ own_reactions = MagicMock(return_value={"A", "C"})
+
+ with patch("bot.exts.moderation.incidents.own_reactions", own_reactions):
+ self.assertFalse(incidents.has_signals(message))
+
+
+@patch("bot.exts.moderation.incidents.Signal", MockSignal)
+class TestAddSignals(unittest.IsolatedAsyncioTestCase):
+ """
+ Assertions for the `add_signals` coroutine.
+
+ These are all fairly similar and could go into a single test function, but I found the
+ patching & sub-testing fairly awkward in that case and decided to split them up
+ to avoid unnecessary syntax noise.
+ """
+
+ def setUp(self):
+ """Prepare a mock incident message for tests to use."""
+ self.incident = MockMessage()
+
+ @patch("bot.exts.moderation.incidents.own_reactions", MagicMock(return_value=set()))
+ async def test_add_signals_missing(self):
+ """All emoji are added when none are present."""
+ await incidents.add_signals(self.incident)
+ self.incident.add_reaction.assert_has_calls([call("A"), call("B")])
+
+ @patch("bot.exts.moderation.incidents.own_reactions", MagicMock(return_value={"A"}))
+ async def test_add_signals_partial(self):
+ """Only missing emoji are added when some are present."""
+ await incidents.add_signals(self.incident)
+ self.incident.add_reaction.assert_has_calls([call("B")])
+
+ @patch("bot.exts.moderation.incidents.own_reactions", MagicMock(return_value={"A", "B"}))
+ async def test_add_signals_present(self):
+ """No emoji are added when all are present."""
+ await incidents.add_signals(self.incident)
+ self.incident.add_reaction.assert_not_called()
+
+
+class TestIncidents(unittest.IsolatedAsyncioTestCase):
+ """
+ Tests for bound methods of the `Incidents` cog.
+
+ Use this as a base class for `Incidents` tests - it will prepare a fresh instance
+ for each test function, but not make any assertions on its own. Tests can mutate
+ the instance as they wish.
+ """
+
+ def setUp(self):
+ """
+ Prepare a fresh `Incidents` instance for each test.
+
+ Note that this will not schedule `crawl_incidents` in the background, as everything
+ is being mocked. The `crawl_task` attribute will end up being None.
+ """
+ self.cog_instance = incidents.Incidents(MockBot())
+
+
+@patch("asyncio.sleep", AsyncMock()) # Prevent the coro from sleeping to speed up the test
+class TestCrawlIncidents(TestIncidents):
+ """
+ Tests for the `Incidents.crawl_incidents` coroutine.
+
+ Apart from `test_crawl_incidents_waits_until_cache_ready`, all tests in this class
+ will patch the return values of `is_incident` and `has_signal` and then observe
+ whether the `AsyncMock` for `add_signals` was awaited or not.
+
+ The `add_signals` mock is added by each test separately to ensure it is clean (has not
+ been awaited by another test yet). The mock can be reset, but this appears to be the
+ cleaner way.
+
+ For each test, we inject a mock channel with a history of 1 message only (see: `setUp`).
+ """
+
+ def setUp(self):
+ """For each test, ensure `bot.get_channel` returns a channel with 1 arbitrary message."""
+ super().setUp() # First ensure we get `cog_instance` from parent
+
+ incidents_history = MagicMock(return_value=MockAsyncIterable([MockMessage()]))
+ self.cog_instance.bot.get_channel = MagicMock(return_value=MockTextChannel(history=incidents_history))
+
+ async def test_crawl_incidents_waits_until_cache_ready(self):
+ """
+ The coroutine will await the `wait_until_guild_available` event.
+
+ Since this task is schedule in the `__init__`, it is critical that it waits for the
+ cache to be ready, so that it can safely get the #incidents channel.
+ """
+ await self.cog_instance.crawl_incidents()
+ self.cog_instance.bot.wait_until_guild_available.assert_awaited()
+
+ @patch("bot.exts.moderation.incidents.add_signals", AsyncMock())
+ @patch("bot.exts.moderation.incidents.is_incident", MagicMock(return_value=False)) # Message doesn't qualify
+ @patch("bot.exts.moderation.incidents.has_signals", MagicMock(return_value=False))
+ async def test_crawl_incidents_noop_if_is_not_incident(self):
+ """Signals are not added for a non-incident message."""
+ await self.cog_instance.crawl_incidents()
+ incidents.add_signals.assert_not_awaited()
+
+ @patch("bot.exts.moderation.incidents.add_signals", AsyncMock())
+ @patch("bot.exts.moderation.incidents.is_incident", MagicMock(return_value=True)) # Message qualifies
+ @patch("bot.exts.moderation.incidents.has_signals", MagicMock(return_value=True)) # But already has signals
+ async def test_crawl_incidents_noop_if_message_already_has_signals(self):
+ """Signals are not added for messages which already have them."""
+ await self.cog_instance.crawl_incidents()
+ incidents.add_signals.assert_not_awaited()
+
+ @patch("bot.exts.moderation.incidents.add_signals", AsyncMock())
+ @patch("bot.exts.moderation.incidents.is_incident", MagicMock(return_value=True)) # Message qualifies
+ @patch("bot.exts.moderation.incidents.has_signals", MagicMock(return_value=False)) # And doesn't have signals
+ async def test_crawl_incidents_add_signals_called(self):
+ """Message has signals added as it does not have them yet and qualifies as an incident."""
+ await self.cog_instance.crawl_incidents()
+ incidents.add_signals.assert_awaited_once()
+
+
+class TestArchive(TestIncidents):
+ """Tests for the `Incidents.archive` coroutine."""
+
+ async def test_archive_webhook_not_found(self):
+ """
+ Method recovers and returns False when the webhook is not found.
+
+ Implicitly, this also tests that the error is handled internally and doesn't
+ propagate out of the method, which is just as important.
+ """
+ self.cog_instance.bot.fetch_webhook = AsyncMock(side_effect=mock_404)
+ self.assertFalse(
+ await self.cog_instance.archive(incident=MockMessage(), outcome=MagicMock(), actioned_by=MockMember())
+ )
+
+ async def test_archive_relays_incident(self):
+ """
+ If webhook is found, method relays `incident` properly.
+
+ This test will assert that the fetched webhook's `send` method is fed the correct arguments,
+ and that the `archive` method returns True.
+ """
+ webhook = MockAsyncWebhook()
+ self.cog_instance.bot.fetch_webhook = AsyncMock(return_value=webhook) # Patch in our webhook
+
+ # Define our own `incident` to be archived
+ incident = MockMessage(
+ content="this is an incident",
+ author=MockUser(name="author_name", avatar_url="author_avatar"),
+ id=123,
+ )
+ built_embed = MagicMock(discord.Embed, id=123) # We patch `make_embed` to return this
+
+ with patch("bot.exts.moderation.incidents.make_embed", AsyncMock(return_value=(built_embed, None))):
+ archive_return = await self.cog_instance.archive(incident, MagicMock(value="A"), MockMember())
+
+ # Now we check that the webhook was given the correct args, and that `archive` returned True
+ webhook.send.assert_called_once_with(
+ embed=built_embed,
+ username="author_name",
+ avatar_url="author_avatar",
+ file=None,
+ )
+ self.assertTrue(archive_return)
+
+ async def test_archive_clyde_username(self):
+ """
+ The archive webhook username is cleansed using `sub_clyde`.
+
+ Discord will reject any webhook with "clyde" in the username field, as it impersonates
+ the official Clyde bot. Since we do not control what the username will be (the incident
+ author name is used), we must ensure the name is cleansed, otherwise the relay may fail.
+
+ This test assumes the username is passed as a kwarg. If this test fails, please review
+ whether the passed argument is being retrieved correctly.
+ """
+ webhook = MockAsyncWebhook()
+ self.cog_instance.bot.fetch_webhook = AsyncMock(return_value=webhook)
+
+ message_from_clyde = MockMessage(author=MockUser(name="clyde the great"))
+ await self.cog_instance.archive(message_from_clyde, MagicMock(incidents.Signal), MockMember())
+
+ self.assertNotIn("clyde", webhook.send.call_args.kwargs["username"])
+
+
+class TestMakeConfirmationTask(TestIncidents):
+ """
+ Tests for the `Incidents.make_confirmation_task` method.
+
+ Writing tests for this method is difficult, as it mostly just delegates the provided
+ information elsewhere. There is very little internal logic. Whether our approach
+ works conceptually is difficult to prove using unit tests.
+ """
+
+ def test_make_confirmation_task_check(self):
+ """
+ The internal check will recognize the passed incident.
+
+ This is a little tricky - we first pass a message with a specific `id` in, and then
+ retrieve the built check from the `call_args` of the `wait_for` method. This relies
+ on the check being passed as a kwarg.
+
+ Once the check is retrieved, we assert that it gives True for our incident's `id`,
+ and False for any other.
+
+ If this function begins to fail, first check that `created_check` is being retrieved
+ correctly. It should be the function that is built locally in the tested method.
+ """
+ self.cog_instance.make_confirmation_task(MockMessage(id=123))
+
+ self.cog_instance.bot.wait_for.assert_called_once()
+ created_check = self.cog_instance.bot.wait_for.call_args.kwargs["check"]
+
+ # The `message_id` matches the `id` of our incident
+ self.assertTrue(created_check(payload=MagicMock(message_id=123)))
+
+ # This `message_id` does not match
+ self.assertFalse(created_check(payload=MagicMock(message_id=0)))
+
+
+@patch("bot.exts.moderation.incidents.ALLOWED_ROLES", {1, 2})
+@patch("bot.exts.moderation.incidents.Incidents.make_confirmation_task", AsyncMock()) # Generic awaitable
+class TestProcessEvent(TestIncidents):
+ """Tests for the `Incidents.process_event` coroutine."""
+
+ async def test_process_event_bad_role(self):
+ """The reaction is removed when the author lacks all allowed roles."""
+ incident = MockMessage()
+ member = MockMember(roles=[MockRole(id=0)]) # Must have role 1 or 2
+
+ await self.cog_instance.process_event("reaction", incident, member)
+ incident.remove_reaction.assert_called_once_with("reaction", member)
+
+ async def test_process_event_bad_emoji(self):
+ """
+ The reaction is removed when an invalid emoji is used.
+
+ This requires that we pass in a `member` with valid roles, as we need the role check
+ to succeed.
+ """
+ incident = MockMessage()
+ member = MockMember(roles=[MockRole(id=1)]) # Member has allowed role
+
+ await self.cog_instance.process_event("invalid_signal", incident, member)
+ incident.remove_reaction.assert_called_once_with("invalid_signal", member)
+
+ async def test_process_event_no_archive_on_investigating(self):
+ """Message is not archived on `Signal.INVESTIGATING`."""
+ with patch("bot.exts.moderation.incidents.Incidents.archive", AsyncMock()) as mocked_archive:
+ await self.cog_instance.process_event(
+ reaction=incidents.Signal.INVESTIGATING.value,
+ incident=MockMessage(),
+ member=MockMember(roles=[MockRole(id=1)]),
+ )
+
+ mocked_archive.assert_not_called()
+
+ async def test_process_event_no_delete_if_archive_fails(self):
+ """
+ Original message is not deleted when `Incidents.archive` returns False.
+
+ This is the way of signaling that the relay failed, and we should not remove the original,
+ as that would result in losing the incident record.
+ """
+ incident = MockMessage()
+
+ with patch("bot.exts.moderation.incidents.Incidents.archive", AsyncMock(return_value=False)):
+ await self.cog_instance.process_event(
+ reaction=incidents.Signal.ACTIONED.value,
+ incident=incident,
+ member=MockMember(roles=[MockRole(id=1)])
+ )
+
+ incident.delete.assert_not_called()
+
+ async def test_process_event_confirmation_task_is_awaited(self):
+ """Task given by `Incidents.make_confirmation_task` is awaited before method exits."""
+ mock_task = AsyncMock()
+
+ with patch("bot.exts.moderation.incidents.Incidents.make_confirmation_task", mock_task):
+ await self.cog_instance.process_event(
+ reaction=incidents.Signal.ACTIONED.value,
+ incident=MockMessage(),
+ member=MockMember(roles=[MockRole(id=1)])
+ )
+
+ mock_task.assert_awaited()
+
+ async def test_process_event_confirmation_task_timeout_is_handled(self):
+ """
+ Confirmation task `asyncio.TimeoutError` is handled gracefully.
+
+ We have `make_confirmation_task` return a mock with a side effect, and then catch the
+ exception should it propagate out of `process_event`. This is so that we can then manually
+ fail the test with a more informative message than just the plain traceback.
+ """
+ mock_task = AsyncMock(side_effect=asyncio.TimeoutError())
+
+ try:
+ with patch("bot.exts.moderation.incidents.Incidents.make_confirmation_task", mock_task):
+ await self.cog_instance.process_event(
+ reaction=incidents.Signal.ACTIONED.value,
+ incident=MockMessage(),
+ member=MockMember(roles=[MockRole(id=1)])
+ )
+ except asyncio.TimeoutError:
+ self.fail("TimeoutError was not handled gracefully, and propagated out of `process_event`!")
+
+
+class TestResolveMessage(TestIncidents):
+ """Tests for the `Incidents.resolve_message` coroutine."""
+
+ async def test_resolve_message_pass_message_id(self):
+ """Method will call `_get_message` with the passed `message_id`."""
+ await self.cog_instance.resolve_message(123)
+ self.cog_instance.bot._connection._get_message.assert_called_once_with(123)
+
+ async def test_resolve_message_in_cache(self):
+ """
+ No API call is made if the queried message exists in the cache.
+
+ We mock the `_get_message` return value regardless of input. Whether it finds the message
+ internally is considered d.py's responsibility, not ours.
+ """
+ cached_message = MockMessage(id=123)
+ self.cog_instance.bot._connection._get_message = MagicMock(return_value=cached_message)
+
+ return_value = await self.cog_instance.resolve_message(123)
+
+ self.assertIs(return_value, cached_message)
+ self.cog_instance.bot.get_channel.assert_not_called() # The `fetch_message` line was never hit
+
+ async def test_resolve_message_not_in_cache(self):
+ """
+ The message is retrieved from the API if it isn't cached.
+
+ This is desired behaviour for messages which exist, but were sent before the bot's
+ current session.
+ """
+ self.cog_instance.bot._connection._get_message = MagicMock(return_value=None) # Cache returns None
+
+ # API returns our message
+ uncached_message = MockMessage()
+ fetch_message = AsyncMock(return_value=uncached_message)
+ self.cog_instance.bot.get_channel = MagicMock(return_value=MockTextChannel(fetch_message=fetch_message))
+
+ retrieved_message = await self.cog_instance.resolve_message(123)
+ self.assertIs(retrieved_message, uncached_message)
+
+ async def test_resolve_message_doesnt_exist(self):
+ """
+ If the API returns a 404, the function handles it gracefully and returns None.
+
+ This is an edge-case happening with racing events - event A will relay the message
+ to the archive and delete the original. Once event B acquires the `event_lock`,
+ it will not find the message in the cache, and will ask the API.
+ """
+ self.cog_instance.bot._connection._get_message = MagicMock(return_value=None) # Cache returns None
+
+ fetch_message = AsyncMock(side_effect=mock_404)
+ self.cog_instance.bot.get_channel = MagicMock(return_value=MockTextChannel(fetch_message=fetch_message))
+
+ self.assertIsNone(await self.cog_instance.resolve_message(123))
+
+ async def test_resolve_message_fetch_fails(self):
+ """
+ Non-404 errors are handled, logged & None is returned.
+
+ In contrast with a 404, this should make an error-level log. We assert that at least
+ one such log was made - we do not make any assertions about the log's message.
+ """
+ self.cog_instance.bot._connection._get_message = MagicMock(return_value=None) # Cache returns None
+
+ arbitrary_error = discord.HTTPException(
+ response=MagicMock(aiohttp.ClientResponse),
+ message="Arbitrary error",
+ )
+ fetch_message = AsyncMock(side_effect=arbitrary_error)
+ self.cog_instance.bot.get_channel = MagicMock(return_value=MockTextChannel(fetch_message=fetch_message))
+
+ with self.assertLogs(logger=incidents.log, level=logging.ERROR):
+ self.assertIsNone(await self.cog_instance.resolve_message(123))
+
+
+@patch("bot.constants.Channels.incidents", 123)
+class TestOnRawReactionAdd(TestIncidents):
+ """
+ Tests for the `Incidents.on_raw_reaction_add` listener.
+
+ Writing tests for this listener comes with additional complexity due to the listener
+ awaiting the `crawl_task` task. See `asyncSetUp` for further details, which attempts
+ to make unit testing this function possible.
+ """
+
+ def setUp(self):
+ """
+ Prepare & assign `payload` attribute.
+
+ This attribute represents an *ideal* payload which will not be rejected by the
+ listener. As each test will receive a fresh instance, it can be mutated to
+ observe how the listener's behaviour changes with different attributes on
+ the passed payload.
+ """
+ super().setUp() # Ensure `cog_instance` is assigned
+
+ self.payload = MagicMock(
+ discord.RawReactionActionEvent,
+ channel_id=123, # Patched at class level
+ message_id=456,
+ member=MockMember(bot=False),
+ emoji="reaction",
+ )
+
+ async def asyncSetUp(self): # noqa: N802
+ """
+ Prepare an empty task and assign it as `crawl_task`.
+
+ It appears that the `unittest` framework does not provide anything for mocking
+ asyncio tasks. An `AsyncMock` instance can be called and then awaited, however,
+ it does not provide the `done` method or any other parts of the `asyncio.Task`
+ interface.
+
+ Although we do not need to make any assertions about the task itself while
+ testing the listener, the code will still await it and call the `done` method,
+ and so we must inject something that will not fail on either action.
+
+ Note that this is done in an `asyncSetUp`, which runs after `setUp`.
+ The justification is that creating an actual task requires the event
+ loop to be ready, which is not the case in the `setUp`.
+ """
+ mock_task = asyncio.create_task(AsyncMock()()) # Mock async func, then a coro
+ self.cog_instance.crawl_task = mock_task
+
+ async def test_on_raw_reaction_add_wrong_channel(self):
+ """
+ Events outside of #incidents will be ignored.
+
+ We check this by asserting that `resolve_message` was never queried.
+ """
+ self.payload.channel_id = 0
+ self.cog_instance.resolve_message = AsyncMock()
+
+ await self.cog_instance.on_raw_reaction_add(self.payload)
+ self.cog_instance.resolve_message.assert_not_called()
+
+ async def test_on_raw_reaction_add_user_is_bot(self):
+ """
+ Events dispatched by bot accounts will be ignored.
+
+ We check this by asserting that `resolve_message` was never queried.
+ """
+ self.payload.member = MockMember(bot=True)
+ self.cog_instance.resolve_message = AsyncMock()
+
+ await self.cog_instance.on_raw_reaction_add(self.payload)
+ self.cog_instance.resolve_message.assert_not_called()
+
+ async def test_on_raw_reaction_add_message_doesnt_exist(self):
+ """
+ Listener gracefully handles the case where `resolve_message` gives None.
+
+ We check this by asserting that `process_event` was never called.
+ """
+ self.cog_instance.process_event = AsyncMock()
+ self.cog_instance.resolve_message = AsyncMock(return_value=None)
+
+ await self.cog_instance.on_raw_reaction_add(self.payload)
+ self.cog_instance.process_event.assert_not_called()
+
+ async def test_on_raw_reaction_add_message_is_not_an_incident(self):
+ """
+ The event won't be processed if the related message is not an incident.
+
+ This is an edge-case that can happen if someone manually leaves a reaction
+ on a pinned message, or a comment.
+
+ We check this by asserting that `process_event` was never called.
+ """
+ self.cog_instance.process_event = AsyncMock()
+ self.cog_instance.resolve_message = AsyncMock(return_value=MockMessage())
+
+ with patch("bot.exts.moderation.incidents.is_incident", MagicMock(return_value=False)):
+ await self.cog_instance.on_raw_reaction_add(self.payload)
+
+ self.cog_instance.process_event.assert_not_called()
+
+ async def test_on_raw_reaction_add_valid_event_is_processed(self):
+ """
+ If the reaction event is valid, it is passed to `process_event`.
+
+ This is the case when everything goes right:
+ * The reaction was placed in #incidents, and not by a bot
+ * The message was found successfully
+ * The message qualifies as an incident
+
+ Additionally, we check that all arguments were passed as expected.
+ """
+ incident = MockMessage(id=1)
+
+ self.cog_instance.process_event = AsyncMock()
+ self.cog_instance.resolve_message = AsyncMock(return_value=incident)
+
+ with patch("bot.exts.moderation.incidents.is_incident", MagicMock(return_value=True)):
+ await self.cog_instance.on_raw_reaction_add(self.payload)
+
+ self.cog_instance.process_event.assert_called_with(
+ "reaction", # Defined in `self.payload`
+ incident,
+ self.payload.member,
+ )
+
+
+class TestOnMessage(TestIncidents):
+ """
+ Tests for the `Incidents.on_message` listener.
+
+ Notice the decorators mocking the `is_incident` return value. The `is_incidents`
+ function is tested in `TestIsIncident` - here we do not worry about it.
+ """
+
+ @patch("bot.exts.moderation.incidents.is_incident", MagicMock(return_value=True))
+ async def test_on_message_incident(self):
+ """Messages qualifying as incidents are passed to `add_signals`."""
+ incident = MockMessage()
+
+ with patch("bot.exts.moderation.incidents.add_signals", AsyncMock()) as mock_add_signals:
+ await self.cog_instance.on_message(incident)
+
+ mock_add_signals.assert_called_once_with(incident)
+
+ @patch("bot.exts.moderation.incidents.is_incident", MagicMock(return_value=False))
+ async def test_on_message_non_incident(self):
+ """Messages not qualifying as incidents are ignored."""
+ with patch("bot.exts.moderation.incidents.add_signals", AsyncMock()) as mock_add_signals:
+ await self.cog_instance.on_message(MockMessage())
+
+ mock_add_signals.assert_not_called()
diff --git a/tests/bot/cogs/moderation/test_modlog.py b/tests/bot/exts/moderation/test_modlog.py
index f2809f40a..f8f142484 100644
--- a/tests/bot/cogs/moderation/test_modlog.py
+++ b/tests/bot/exts/moderation/test_modlog.py
@@ -2,7 +2,7 @@ import unittest
import discord
-from bot.cogs.moderation.modlog import ModLog
+from bot.exts.moderation.modlog import ModLog
from tests.helpers import MockBot, MockTextChannel
diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py
new file mode 100644
index 000000000..104293d8e
--- /dev/null
+++ b/tests/bot/exts/moderation/test_silence.py
@@ -0,0 +1,502 @@
+import asyncio
+import unittest
+from datetime import datetime, timezone
+from unittest import mock
+from unittest.mock import Mock
+
+from async_rediscache import RedisSession
+from discord import PermissionOverwrite
+
+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 = 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 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)
+ silenced_channels.__setitem__.assert_called_with(channel, self.notifier._current_loop)
+
+ def test_add_channel_starts_loop(self):
+ """Loop is started if `_silenced_channels` was empty."""
+ self.notifier.add_channel(Mock())
+ self.notifier_start_mock.assert_called_once()
+
+ def test_add_channel_skips_start_with_channels(self):
+ """Loop start is not called when `_silenced_channels` is not empty."""
+ with mock.patch.object(self.notifier, "_silenced_channels"):
+ self.notifier.add_channel(Mock())
+ self.notifier_start_mock.assert_not_called()
+
+ def test_remove_channel_removes_channel(self):
+ """Channel is removed from `_silenced_channels`."""
+ channel = Mock()
+ with mock.patch.object(self.notifier, "_silenced_channels") as silenced_channels:
+ self.notifier.remove_channel(channel)
+ silenced_channels.__delitem__.assert_called_with(channel)
+
+ def test_remove_channel_stops_loop(self):
+ """Notifier loop is stopped if `_silenced_channels` is empty after remove."""
+ with mock.patch.object(self.notifier, "_silenced_channels", __bool__=lambda _: False):
+ self.notifier.remove_channel(Mock())
+ self.notifier_stop_mock.assert_called_once()
+
+ def test_remove_channel_skips_stop_with_channels(self):
+ """Notifier loop is not stopped if `_silenced_channels` is not empty after remove."""
+ self.notifier.remove_channel(Mock())
+ self.notifier_stop_mock.assert_not_called()
+
+ async def test_notifier_private_sends_alert(self):
+ """Alert is sent on 15 min intervals."""
+ test_cases = (900, 1800, 2700)
+ for current_loop in test_cases:
+ with self.subTest(current_loop=current_loop):
+ with mock.patch.object(self.notifier, "_current_loop", new=current_loop):
+ await self.notifier._notifier()
+ self.alert_channel.send.assert_called_once_with(
+ f"<@&{Roles.moderators}> currently silenced channels: "
+ )
+ self.alert_channel.send.reset_mock()
+
+ async def test_notifier_skips_alert(self):
+ """Alert is skipped on first loop or not an increment of 900."""
+ test_cases = (0, 15, 5000)
+ for current_loop in test_cases:
+ with self.subTest(current_loop=current_loop):
+ with mock.patch.object(self.notifier, "_current_loop", new=current_loop):
+ await self.notifier._notifier()
+ self.alert_channel.send.assert_not_called()
+
+
+@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.Silence(self.bot)
+
+ @autospec(silence, "SilenceNotifier", pass_mocks=False)
+ async def test_async_init_got_guild(self):
+ """Bot got guild after it became available."""
+ await self.cog._async_init()
+ self.bot.wait_until_guild_available.assert_awaited_once()
+ self.bot.get_guild.assert_called_once_with(Guild.id)
+
+ @autospec(silence, "SilenceNotifier", pass_mocks=False)
+ async def test_async_init_got_role(self):
+ """Got `Roles.verified` role from guild."""
+ guild = self.bot.get_guild()
+ guild.get_role.side_effect = lambda id_: Mock(id=id_)
+
+ 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."""
+ 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)
+
+ @autospec(silence, "SilenceNotifier")
+ async def test_async_init_got_notifier(self, notifier):
+ """Notifier was started with channel."""
+ 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, silence.MSG_SILENCE_SUCCESS.format(duration=0.0001), True,),
+ (None, silence.MSG_SILENCE_PERMANENT, True,),
+ (5, silence.MSG_SILENCE_FAIL, False,),
+ )
+ 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 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_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()
+
+ self.assertFalse(await self.cog._unsilence(channel))
+ channel.set_permissions.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,
+ )
+
+ # 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/moderation/test_slowmode.py b/tests/bot/exts/moderation/test_slowmode.py
new file mode 100644
index 000000000..dad751e0d
--- /dev/null
+++ b/tests/bot/exts/moderation/test_slowmode.py
@@ -0,0 +1,113 @@
+import unittest
+from unittest import mock
+
+from dateutil.relativedelta import relativedelta
+
+from bot.constants import Emojis
+from bot.exts.moderation.slowmode import Slowmode
+from tests.helpers import MockBot, MockContext, MockTextChannel
+
+
+class SlowmodeTests(unittest.IsolatedAsyncioTestCase):
+
+ def setUp(self) -> None:
+ self.bot = MockBot()
+ self.cog = Slowmode(self.bot)
+ self.ctx = MockContext()
+
+ async def test_get_slowmode_no_channel(self) -> None:
+ """Get slowmode without a given channel."""
+ self.ctx.channel = MockTextChannel(name='python-general', slowmode_delay=5)
+
+ await self.cog.get_slowmode(self.cog, self.ctx, None)
+ self.ctx.send.assert_called_once_with("The slowmode delay for #python-general is 5 seconds.")
+
+ async def test_get_slowmode_with_channel(self) -> None:
+ """Get slowmode with a given channel."""
+ text_channel = MockTextChannel(name='python-language', slowmode_delay=2)
+
+ await self.cog.get_slowmode(self.cog, self.ctx, text_channel)
+ self.ctx.send.assert_called_once_with('The slowmode delay for #python-language is 2 seconds.')
+
+ async def test_set_slowmode_no_channel(self) -> None:
+ """Set slowmode without a given channel."""
+ test_cases = (
+ ('helpers', 23, True, f'{Emojis.check_mark} The slowmode delay for #helpers is now 23 seconds.'),
+ ('mods', 76526, False, f'{Emojis.cross_mark} The slowmode delay must be between 0 and 6 hours.'),
+ ('admins', 97, True, f'{Emojis.check_mark} The slowmode delay for #admins is now 1 minute and 37 seconds.')
+ )
+
+ for channel_name, seconds, edited, result_msg in test_cases:
+ with self.subTest(
+ channel_mention=channel_name,
+ seconds=seconds,
+ edited=edited,
+ result_msg=result_msg
+ ):
+ self.ctx.channel = MockTextChannel(name=channel_name)
+
+ await self.cog.set_slowmode(self.cog, self.ctx, None, relativedelta(seconds=seconds))
+
+ if edited:
+ self.ctx.channel.edit.assert_awaited_once_with(slowmode_delay=float(seconds))
+ else:
+ self.ctx.channel.edit.assert_not_called()
+
+ self.ctx.send.assert_called_once_with(result_msg)
+
+ self.ctx.reset_mock()
+
+ async def test_set_slowmode_with_channel(self) -> None:
+ """Set slowmode with a given channel."""
+ test_cases = (
+ ('bot-commands', 12, True, f'{Emojis.check_mark} The slowmode delay for #bot-commands is now 12 seconds.'),
+ ('mod-spam', 21, True, f'{Emojis.check_mark} The slowmode delay for #mod-spam is now 21 seconds.'),
+ ('admin-spam', 4323598, False, f'{Emojis.cross_mark} The slowmode delay must be between 0 and 6 hours.')
+ )
+
+ for channel_name, seconds, edited, result_msg in test_cases:
+ with self.subTest(
+ channel_mention=channel_name,
+ seconds=seconds,
+ edited=edited,
+ result_msg=result_msg
+ ):
+ text_channel = MockTextChannel(name=channel_name)
+
+ await self.cog.set_slowmode(self.cog, self.ctx, text_channel, relativedelta(seconds=seconds))
+
+ if edited:
+ text_channel.edit.assert_awaited_once_with(slowmode_delay=float(seconds))
+ else:
+ text_channel.edit.assert_not_called()
+
+ self.ctx.send.assert_called_once_with(result_msg)
+
+ self.ctx.reset_mock()
+
+ async def test_reset_slowmode_no_channel(self) -> None:
+ """Reset slowmode without a given channel."""
+ self.ctx.channel = MockTextChannel(name='careers', slowmode_delay=6)
+
+ await self.cog.reset_slowmode(self.cog, self.ctx, None)
+ self.ctx.send.assert_called_once_with(
+ f'{Emojis.check_mark} The slowmode delay for #careers has been reset to 0 seconds.'
+ )
+
+ async def test_reset_slowmode_with_channel(self) -> None:
+ """Reset slowmode with a given channel."""
+ text_channel = MockTextChannel(name='meta', slowmode_delay=1)
+
+ await self.cog.reset_slowmode(self.cog, self.ctx, text_channel)
+ self.ctx.send.assert_called_once_with(
+ f'{Emojis.check_mark} The slowmode delay for #meta has been reset to 0 seconds.'
+ )
+
+ @mock.patch("bot.exts.moderation.slowmode.has_any_role")
+ @mock.patch("bot.exts.moderation.slowmode.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)
diff --git a/tests/bot/cogs/test_cogs.py b/tests/bot/exts/test_cogs.py
index fdda59a8f..f8e120262 100644
--- a/tests/bot/cogs/test_cogs.py
+++ b/tests/bot/exts/test_cogs.py
@@ -10,7 +10,7 @@ from unittest import mock
from discord.ext import commands
-from bot import cogs
+from bot import exts
class CommandNameTests(unittest.TestCase):
@@ -29,13 +29,14 @@ class CommandNameTests(unittest.TestCase):
@staticmethod
def walk_modules() -> t.Iterator[ModuleType]:
- """Yield imported modules from the bot.cogs subpackage."""
+ """Yield imported modules from the bot.exts subpackage."""
def on_error(name: str) -> t.NoReturn:
raise ImportError(name=name) # pragma: no cover
# The mock prevents asyncio.get_event_loop() from being called.
with mock.patch("discord.ext.tasks.loop"):
- for module in pkgutil.walk_packages(cogs.__path__, "bot.cogs.", onerror=on_error):
+ prefix = f"{exts.__name__}."
+ for module in pkgutil.walk_packages(exts.__path__, prefix, onerror=on_error):
if not module.ispkg:
yield importlib.import_module(module.name)
@@ -53,6 +54,7 @@ class CommandNameTests(unittest.TestCase):
"""Return a list of all qualified names, including aliases, for the `command`."""
names = [f"{command.full_parent_name} {alias}".strip() for alias in command.aliases]
names.append(command.qualified_name)
+ names += getattr(command, "root_aliases", [])
return names
diff --git a/tests/bot/exts/utils/__init__.py b/tests/bot/exts/utils/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/tests/bot/exts/utils/__init__.py
diff --git a/tests/bot/exts/utils/test_jams.py b/tests/bot/exts/utils/test_jams.py
new file mode 100644
index 000000000..45e7b5b51
--- /dev/null
+++ b/tests/bot/exts/utils/test_jams.py
@@ -0,0 +1,173 @@
+import unittest
+from unittest.mock import AsyncMock, MagicMock, create_autospec
+
+from discord import CategoryChannel
+
+from bot.constants import Roles
+from bot.exts.utils import jams
+from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole, MockTextChannel
+
+
+def get_mock_category(channel_count: int, name: str) -> CategoryChannel:
+ """Return a mocked code jam category."""
+ category = create_autospec(CategoryChannel, spec_set=True, instance=True)
+ category.name = name
+ category.channels = [MockTextChannel() for _ in range(channel_count)]
+
+ return category
+
+
+class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase):
+ """Tests for `createteam` command."""
+
+ def setUp(self):
+ self.bot = MockBot()
+ self.admin_role = MockRole(name="Admins", id=Roles.admins)
+ self.command_user = MockMember([self.admin_role])
+ self.guild = MockGuild([self.admin_role])
+ self.ctx = MockContext(bot=self.bot, author=self.command_user, guild=self.guild)
+ self.cog = jams.CodeJams(self.bot)
+
+ async def test_too_small_amount_of_team_members_passed(self):
+ """Should `ctx.send` and exit early when too small amount of members."""
+ for case in (1, 2):
+ with self.subTest(amount_of_members=case):
+ self.cog.create_channels = AsyncMock()
+ self.cog.add_roles = AsyncMock()
+
+ self.ctx.reset_mock()
+ members = (MockMember() for _ in range(case))
+ await self.cog.createteam(self.cog, self.ctx, "foo", members)
+
+ self.ctx.send.assert_awaited_once()
+ self.cog.create_channels.assert_not_awaited()
+ self.cog.add_roles.assert_not_awaited()
+
+ async def test_duplicate_members_provided(self):
+ """Should `ctx.send` and exit early because duplicate members provided and total there is only 1 member."""
+ self.cog.create_channels = AsyncMock()
+ self.cog.add_roles = AsyncMock()
+
+ member = MockMember()
+ await self.cog.createteam(self.cog, self.ctx, "foo", (member for _ in range(5)))
+
+ self.ctx.send.assert_awaited_once()
+ self.cog.create_channels.assert_not_awaited()
+ self.cog.add_roles.assert_not_awaited()
+
+ async def test_result_sending(self):
+ """Should call `ctx.send` when everything goes right."""
+ self.cog.create_channels = AsyncMock()
+ self.cog.add_roles = AsyncMock()
+
+ members = [MockMember() for _ in range(5)]
+ await self.cog.createteam(self.cog, self.ctx, "foo", members)
+
+ self.cog.create_channels.assert_awaited_once()
+ self.cog.add_roles.assert_awaited_once()
+ self.ctx.send.assert_awaited_once()
+
+ async def test_category_doesnt_exist(self):
+ """Should create a new code jam category."""
+ subtests = (
+ [],
+ [get_mock_category(jams.MAX_CHANNELS - 1, jams.CATEGORY_NAME)],
+ [get_mock_category(jams.MAX_CHANNELS - 2, "other")],
+ )
+
+ for categories in subtests:
+ self.guild.reset_mock()
+ self.guild.categories = categories
+
+ with self.subTest(categories=categories):
+ actual_category = await self.cog.get_category(self.guild)
+
+ self.guild.create_category_channel.assert_awaited_once()
+ category_overwrites = self.guild.create_category_channel.call_args[1]["overwrites"]
+
+ self.assertFalse(category_overwrites[self.guild.default_role].read_messages)
+ self.assertTrue(category_overwrites[self.guild.me].read_messages)
+ self.assertEqual(self.guild.create_category_channel.return_value, actual_category)
+
+ async def test_category_channel_exist(self):
+ """Should not try to create category channel."""
+ expected_category = get_mock_category(jams.MAX_CHANNELS - 2, jams.CATEGORY_NAME)
+ self.guild.categories = [
+ get_mock_category(jams.MAX_CHANNELS - 2, "other"),
+ expected_category,
+ get_mock_category(0, jams.CATEGORY_NAME),
+ ]
+
+ actual_category = await self.cog.get_category(self.guild)
+ self.assertEqual(expected_category, actual_category)
+
+ async def test_channel_overwrites(self):
+ """Should have correct permission overwrites for users and roles."""
+ leader = MockMember()
+ members = [leader] + [MockMember() for _ in range(4)]
+ overwrites = self.cog.get_overwrites(members, self.guild)
+
+ # Leader permission overwrites
+ self.assertTrue(overwrites[leader].manage_messages)
+ self.assertTrue(overwrites[leader].read_messages)
+ self.assertTrue(overwrites[leader].manage_webhooks)
+ self.assertTrue(overwrites[leader].connect)
+
+ # Other members permission overwrites
+ for member in members[1:]:
+ self.assertTrue(overwrites[member].read_messages)
+ self.assertTrue(overwrites[member].connect)
+
+ # Everyone and verified role overwrite
+ self.assertFalse(overwrites[self.guild.default_role].read_messages)
+ self.assertFalse(overwrites[self.guild.default_role].connect)
+ self.assertFalse(overwrites[self.guild.get_role(Roles.verified)].read_messages)
+ self.assertFalse(overwrites[self.guild.get_role(Roles.verified)].connect)
+
+ async def test_team_channels_creation(self):
+ """Should create new voice and text channel for team."""
+ members = [MockMember() for _ in range(5)]
+
+ self.cog.get_overwrites = MagicMock()
+ self.cog.get_category = AsyncMock()
+ self.ctx.guild.create_text_channel.return_value = MockTextChannel(mention="foobar-channel")
+ actual = await self.cog.create_channels(self.guild, "my-team", members)
+
+ self.assertEqual("foobar-channel", actual)
+ self.cog.get_overwrites.assert_called_once_with(members, self.guild)
+ self.cog.get_category.assert_awaited_once_with(self.guild)
+
+ self.guild.create_text_channel.assert_awaited_once_with(
+ "my-team",
+ overwrites=self.cog.get_overwrites.return_value,
+ category=self.cog.get_category.return_value
+ )
+ self.guild.create_voice_channel.assert_awaited_once_with(
+ "My Team",
+ overwrites=self.cog.get_overwrites.return_value,
+ category=self.cog.get_category.return_value
+ )
+
+ async def test_jam_roles_adding(self):
+ """Should add team leader role to leader and jam role to every team member."""
+ leader_role = MockRole(name="Team Leader")
+ jam_role = MockRole(name="Jammer")
+ self.guild.get_role.side_effect = [leader_role, jam_role]
+
+ leader = MockMember()
+ members = [leader] + [MockMember() for _ in range(4)]
+ await self.cog.add_roles(self.guild, members)
+
+ leader.add_roles.assert_any_await(leader_role)
+ for member in members:
+ member.add_roles.assert_any_await(jam_role)
+
+
+class CodeJamSetup(unittest.TestCase):
+ """Test for `setup` function of `CodeJam` cog."""
+
+ def test_setup(self):
+ """Should call `bot.add_cog`."""
+ bot = MockBot()
+ jams.setup(bot)
+ bot.add_cog.assert_called_once()
diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/exts/utils/test_snekbox.py
index cf9adbee0..9a42d0610 100644
--- a/tests/bot/cogs/test_snekbox.py
+++ b/tests/bot/exts/utils/test_snekbox.py
@@ -1,13 +1,12 @@
import asyncio
-import logging
import unittest
from unittest.mock import AsyncMock, MagicMock, Mock, call, create_autospec, patch
from discord.ext import commands
from bot import constants
-from bot.cogs import snekbox
-from bot.cogs.snekbox import Snekbox
+from bot.exts.utils import snekbox
+from bot.exts.utils.snekbox import Snekbox
from tests.helpers import MockBot, MockContext, MockMessage, MockReaction, MockUser
@@ -39,49 +38,27 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):
result = await self.cog.upload_output("-" * (snekbox.MAX_PASTE_LEN + 1))
self.assertEqual(result, "too long to upload")
- async def test_upload_output(self):
+ @patch("bot.exts.utils.snekbox.send_to_paste_service")
+ async def test_upload_output(self, mock_paste_util):
"""Upload the eval output to the URLs.paste_service.format(key="documents") endpoint."""
- key = "MarkDiamond"
- resp = MagicMock()
- resp.json = AsyncMock(return_value={"key": key})
-
- context_manager = MagicMock()
- context_manager.__aenter__.return_value = resp
- self.bot.http_session.post.return_value = context_manager
-
- self.assertEqual(
- await self.cog.upload_output("My awesome output"),
- constants.URLs.paste_service.format(key=key)
- )
- self.bot.http_session.post.assert_called_with(
- constants.URLs.paste_service.format(key="documents"),
- data="My awesome output",
- raise_for_status=True
+ await self.cog.upload_output("Test output.")
+ mock_paste_util.assert_called_once_with(
+ self.bot.http_session, "Test output.", extension="txt"
)
- async def test_upload_output_gracefully_fallback_if_exception_during_request(self):
- """Output upload gracefully fallback if the upload fail."""
- resp = MagicMock()
- resp.json = AsyncMock(side_effect=Exception)
-
- context_manager = MagicMock()
- context_manager.__aenter__.return_value = resp
- self.bot.http_session.post.return_value = context_manager
-
- log = logging.getLogger("bot.cogs.snekbox")
- with self.assertLogs(logger=log, level='ERROR'):
- await self.cog.upload_output('My awesome output!')
-
- async def test_upload_output_gracefully_fallback_if_no_key_in_response(self):
- """Output upload gracefully fallback if there is no key entry in the response body."""
- self.assertEqual((await self.cog.upload_output('My awesome output!')), None)
-
def test_prepare_input(self):
cases = (
('print("Hello world!")', 'print("Hello world!")', 'non-formatted'),
('`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}.'):
@@ -99,14 +76,14 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):
actual = self.cog.get_results_message({'stdout': stdout, 'returncode': returncode})
self.assertEqual(actual, expected)
- @patch('bot.cogs.snekbox.Signals', side_effect=ValueError)
+ @patch('bot.exts.utils.snekbox.Signals', side_effect=ValueError)
def test_get_results_message_invalid_signal(self, mock_signals: Mock):
self.assertEqual(
self.cog.get_results_message({'stdout': '', 'returncode': 127}),
('Your eval job has completed with return code 127', '')
)
- @patch('bot.cogs.snekbox.Signals')
+ @patch('bot.exts.utils.snekbox.Signals')
def test_get_results_message_valid_signal(self, mock_signals: Mock):
mock_signals.return_value.name = 'SIGTEST'
self.assertEqual(
@@ -147,12 +124,12 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):
('<!@', ("<!@\u200B", None), r'Convert <!@ to <!@\u200B'),
(
'\u202E\u202E\u202E',
- ('Code block escape attempt detected; will not output result', None),
+ ('Code block escape attempt detected; will not output result', 'https://testificate.com/'),
'Detect RIGHT-TO-LEFT OVERRIDE'
),
(
'\u200B\u200B\u200B',
- ('Code block escape attempt detected; will not output result', None),
+ ('Code block escape attempt detected; will not output result', 'https://testificate.com/'),
'Detect ZERO WIDTH SPACE'
),
('long\nbeard', ('001 | long\n002 | beard', None), 'Two line output'),
@@ -184,7 +161,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)
@@ -198,7 +175,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)
@@ -210,7 +187,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!"
)
@@ -218,8 +195,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."""
@@ -233,9 +210,13 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):
self.cog.get_status_emoji = MagicMock(return_value=':yay!:')
self.cog.format_output = AsyncMock(return_value=('[No output]', None))
+ mocked_filter_cog = MagicMock()
+ mocked_filter_cog.filter_eval = AsyncMock(return_value=False)
+ self.bot.get_cog.return_value = mocked_filter_cog
+
await self.cog.send_eval(ctx, 'MyAwesomeCode')
ctx.send.assert_called_once_with(
- '@LemonLemonishBeard#0042 :yay!: Return code 0.\n\n```py\n[No output]\n```'
+ '@LemonLemonishBeard#0042 :yay!: Return code 0.\n\n```\n[No output]\n```'
)
self.cog.post_eval.assert_called_once_with('MyAwesomeCode')
self.cog.get_status_emoji.assert_called_once_with({'stdout': '', 'returncode': 0})
@@ -254,10 +235,14 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):
self.cog.get_status_emoji = MagicMock(return_value=':yay!:')
self.cog.format_output = AsyncMock(return_value=('Way too long beard', 'lookatmybeard.com'))
+ mocked_filter_cog = MagicMock()
+ mocked_filter_cog.filter_eval = AsyncMock(return_value=False)
+ self.bot.get_cog.return_value = mocked_filter_cog
+
await self.cog.send_eval(ctx, 'MyAwesomeCode')
ctx.send.assert_called_once_with(
'@LemonLemonishBeard#0042 :yay!: Return code 0.'
- '\n\n```py\nWay too long beard\n```\nFull output: lookatmybeard.com'
+ '\n\n```\nWay too long beard\n```\nFull output: lookatmybeard.com'
)
self.cog.post_eval.assert_called_once_with('MyAwesomeCode')
self.cog.get_status_emoji.assert_called_once_with({'stdout': 'Way too long beard', 'returncode': 0})
@@ -275,16 +260,20 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):
self.cog.get_status_emoji = MagicMock(return_value=':nope!:')
self.cog.format_output = AsyncMock() # This function isn't called
+ mocked_filter_cog = MagicMock()
+ mocked_filter_cog.filter_eval = AsyncMock(return_value=False)
+ self.bot.get_cog.return_value = mocked_filter_cog
+
await self.cog.send_eval(ctx, 'MyAwesomeCode')
ctx.send.assert_called_once_with(
- '@LemonLemonishBeard#0042 :nope!: Return code 127.\n\n```py\nBeard got stuck in the eval\n```'
+ '@LemonLemonishBeard#0042 :nope!: Return code 127.\n\n```\nBeard got stuck in the eval\n```'
)
self.cog.post_eval.assert_called_once_with('MyAwesomeCode')
self.cog.get_status_emoji.assert_called_once_with({'stdout': 'ERROR', 'returncode': 127})
self.cog.get_results_message.assert_called_once_with({'stdout': 'ERROR', 'returncode': 127})
self.cog.format_output.assert_not_called()
- @patch("bot.cogs.snekbox.partial")
+ @patch("bot.exts.utils.snekbox.partial")
async def test_continue_eval_does_continue(self, partial_mock):
"""Test that the continue_eval function does continue if required conditions are met."""
ctx = MockContext(message=MockMessage(add_reaction=AsyncMock(), clear_reactions=AsyncMock()))
@@ -308,7 +297,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):
@@ -317,7 +306,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/test_pagination.py b/tests/bot/test_pagination.py
index ce880d457..630f2516d 100644
--- a/tests/bot/test_pagination.py
+++ b/tests/bot/test_pagination.py
@@ -44,18 +44,3 @@ class LinePaginatorTests(TestCase):
self.paginator.add_line('x' * (self.paginator.scale_to_size + 1))
# Note: item at index 1 is the truncated line, index 0 is prefix
self.assertEqual(self.paginator._current_page[1], 'x' * self.paginator.scale_to_size)
-
-
-class ImagePaginatorTests(TestCase):
- """Tests functionality of the `ImagePaginator`."""
-
- def setUp(self):
- """Create a paginator for the test method."""
- self.paginator = pagination.ImagePaginator()
-
- def test_add_image_appends_image(self):
- """`add_image` appends the image to the image list."""
- image = 'lemon'
- self.paginator.add_image(image)
-
- assert self.paginator.images == [image]
diff --git a/tests/bot/utils/test_checks.py b/tests/bot/utils/test_checks.py
index de72e5748..883465e0b 100644
--- a/tests/bot/utils/test_checks.py
+++ b/tests/bot/utils/test_checks.py
@@ -1,48 +1,50 @@
import unittest
from unittest.mock import MagicMock
+from discord import DMChannel
+
from bot.utils import checks
from bot.utils.checks import InWhitelistCheckFailure
from tests.helpers import MockContext, MockRole
-class ChecksTests(unittest.TestCase):
+class ChecksTests(unittest.IsolatedAsyncioTestCase):
"""Tests the check functions defined in `bot.checks`."""
def setUp(self):
self.ctx = MockContext()
- def test_with_role_check_without_guild(self):
- """`with_role_check` returns `False` if `Context.guild` is None."""
- self.ctx.guild = None
- self.assertFalse(checks.with_role_check(self.ctx))
+ async def test_has_any_role_check_without_guild(self):
+ """`has_any_role_check` returns `False` for non-guild channels."""
+ self.ctx.channel = MagicMock(DMChannel)
+ self.assertFalse(await checks.has_any_role_check(self.ctx))
- def test_with_role_check_without_required_roles(self):
- """`with_role_check` returns `False` if `Context.author` lacks the required role."""
+ async def test_has_any_role_check_without_required_roles(self):
+ """`has_any_role_check` returns `False` if `Context.author` lacks the required role."""
self.ctx.author.roles = []
- self.assertFalse(checks.with_role_check(self.ctx))
+ self.assertFalse(await checks.has_any_role_check(self.ctx))
- def test_with_role_check_with_guild_and_required_role(self):
- """`with_role_check` returns `True` if `Context.author` has the required role."""
+ async def test_has_any_role_check_with_guild_and_required_role(self):
+ """`has_any_role_check` returns `True` if `Context.author` has the required role."""
self.ctx.author.roles.append(MockRole(id=10))
- self.assertTrue(checks.with_role_check(self.ctx, 10))
+ self.assertTrue(await checks.has_any_role_check(self.ctx, 10))
- def test_without_role_check_without_guild(self):
- """`without_role_check` should return `False` when `Context.guild` is None."""
- self.ctx.guild = None
- self.assertFalse(checks.without_role_check(self.ctx))
+ async def test_has_no_roles_check_without_guild(self):
+ """`has_no_roles_check` should return `False` when `Context.guild` is None."""
+ self.ctx.channel = MagicMock(DMChannel)
+ self.assertFalse(await checks.has_no_roles_check(self.ctx))
- def test_without_role_check_returns_false_with_unwanted_role(self):
- """`without_role_check` returns `False` if `Context.author` has unwanted role."""
+ async def test_has_no_roles_check_returns_false_with_unwanted_role(self):
+ """`has_no_roles_check` returns `False` if `Context.author` has unwanted role."""
role_id = 42
self.ctx.author.roles.append(MockRole(id=role_id))
- self.assertFalse(checks.without_role_check(self.ctx, role_id))
+ self.assertFalse(await checks.has_no_roles_check(self.ctx, role_id))
- def test_without_role_check_returns_true_without_unwanted_role(self):
- """`without_role_check` returns `True` if `Context.author` does not have unwanted role."""
+ async def test_has_no_roles_check_returns_true_without_unwanted_role(self):
+ """`has_no_roles_check` returns `True` if `Context.author` does not have unwanted role."""
role_id = 42
self.ctx.author.roles.append(MockRole(id=role_id))
- self.assertTrue(checks.without_role_check(self.ctx, role_id + 10))
+ self.assertTrue(await checks.has_no_roles_check(self.ctx, role_id + 10))
def test_in_whitelist_check_correct_channel(self):
"""`in_whitelist_check` returns `True` if `Context.channel.id` is in the channel list."""
diff --git a/tests/bot/utils/test_redis_cache.py b/tests/bot/utils/test_redis_cache.py
deleted file mode 100644
index a2f0fe55d..000000000
--- a/tests/bot/utils/test_redis_cache.py
+++ /dev/null
@@ -1,265 +0,0 @@
-import asyncio
-import unittest
-
-import fakeredis.aioredis
-
-from bot.utils import RedisCache
-from bot.utils.redis_cache import NoBotInstanceError, NoNamespaceError, NoParentInstanceError
-from tests import helpers
-
-
-class RedisCacheTests(unittest.IsolatedAsyncioTestCase):
- """Tests the RedisCache class from utils.redis_dict.py."""
-
- async def asyncSetUp(self): # noqa: N802
- """Sets up the objects that only have to be initialized once."""
- self.bot = helpers.MockBot()
- self.bot.redis_session = await fakeredis.aioredis.create_redis_pool()
-
- # Okay, so this is necessary so that we can create a clean new
- # class for every test method, and we want that because it will
- # ensure we get a fresh loop, which is necessary for test_increment_lock
- # to be able to pass.
- class DummyCog:
- """A dummy cog, for dummies."""
-
- redis = RedisCache()
-
- def __init__(self, bot: helpers.MockBot):
- self.bot = bot
-
- self.cog = DummyCog(self.bot)
-
- await self.cog.redis.clear()
-
- def test_class_attribute_namespace(self):
- """Test that RedisDict creates a namespace automatically for class attributes."""
- self.assertEqual(self.cog.redis._namespace, "DummyCog.redis")
-
- async def test_class_attribute_required(self):
- """Test that errors are raised when not assigned as a class attribute."""
- bad_cache = RedisCache()
- self.assertIs(bad_cache._namespace, None)
-
- with self.assertRaises(RuntimeError):
- await bad_cache.set("test", "me_up_deadman")
-
- async def test_set_get_item(self):
- """Test that users can set and get items from the RedisDict."""
- test_cases = (
- ('favorite_fruit', 'melon'),
- ('favorite_number', 86),
- ('favorite_fraction', 86.54),
- ('favorite_boolean', False),
- ('other_boolean', True),
- )
-
- # Test that we can get and set different types.
- for test in test_cases:
- await self.cog.redis.set(*test)
- self.assertEqual(await self.cog.redis.get(test[0]), test[1])
-
- # Test that .get allows a default value
- self.assertEqual(await self.cog.redis.get('favorite_nothing', "bearclaw"), "bearclaw")
-
- async def test_set_item_type(self):
- """Test that .set rejects keys and values that are not permitted."""
- fruits = ["lemon", "melon", "apple"]
-
- with self.assertRaises(TypeError):
- await self.cog.redis.set(fruits, "nice")
-
- with self.assertRaises(TypeError):
- await self.cog.redis.set(4.23, "nice")
-
- async def test_delete_item(self):
- """Test that .delete allows us to delete stuff from the RedisCache."""
- # Add an item and verify that it gets added
- await self.cog.redis.set("internet", "firetruck")
- self.assertEqual(await self.cog.redis.get("internet"), "firetruck")
-
- # Delete that item and verify that it gets deleted
- await self.cog.redis.delete("internet")
- self.assertIs(await self.cog.redis.get("internet"), None)
-
- async def test_contains(self):
- """Test that we can check membership with .contains."""
- await self.cog.redis.set('favorite_country', "Burkina Faso")
-
- self.assertIs(await self.cog.redis.contains('favorite_country'), True)
- self.assertIs(await self.cog.redis.contains('favorite_dentist'), False)
-
- async def test_items(self):
- """Test that the RedisDict can be iterated."""
- # Set up our test cases in the Redis cache
- test_cases = [
- ('favorite_turtle', 'Donatello'),
- ('second_favorite_turtle', 'Leonardo'),
- ('third_favorite_turtle', 'Raphael'),
- ]
- for key, value in test_cases:
- await self.cog.redis.set(key, value)
-
- # Consume the AsyncIterator into a regular list, easier to compare that way.
- redis_items = [item for item in await self.cog.redis.items()]
-
- # These sequences are probably in the same order now, but probably
- # isn't good enough for tests. Let's not rely on .hgetall always
- # returning things in sequence, and just sort both lists to be safe.
- redis_items = sorted(redis_items)
- test_cases = sorted(test_cases)
-
- # If these are equal now, everything works fine.
- self.assertSequenceEqual(test_cases, redis_items)
-
- async def test_length(self):
- """Test that we can get the correct .length from the RedisDict."""
- await self.cog.redis.set('one', 1)
- await self.cog.redis.set('two', 2)
- await self.cog.redis.set('three', 3)
- self.assertEqual(await self.cog.redis.length(), 3)
-
- await self.cog.redis.set('four', 4)
- self.assertEqual(await self.cog.redis.length(), 4)
-
- async def test_to_dict(self):
- """Test that the .to_dict method returns a workable dictionary copy."""
- copy = await self.cog.redis.to_dict()
- local_copy = {key: value for key, value in await self.cog.redis.items()}
- self.assertIs(type(copy), dict)
- self.assertDictEqual(copy, local_copy)
-
- async def test_clear(self):
- """Test that the .clear method removes the entire hash."""
- await self.cog.redis.set('teddy', 'with me')
- await self.cog.redis.set('in my dreams', 'you have a weird hat')
- self.assertEqual(await self.cog.redis.length(), 2)
-
- await self.cog.redis.clear()
- self.assertEqual(await self.cog.redis.length(), 0)
-
- async def test_pop(self):
- """Test that we can .pop an item from the RedisDict."""
- await self.cog.redis.set('john', 'was afraid')
-
- self.assertEqual(await self.cog.redis.pop('john'), 'was afraid')
- self.assertEqual(await self.cog.redis.pop('pete', 'breakneck'), 'breakneck')
- self.assertEqual(await self.cog.redis.length(), 0)
-
- async def test_update(self):
- """Test that we can .update the RedisDict with multiple items."""
- await self.cog.redis.set("reckfried", "lona")
- await self.cog.redis.set("bel air", "prince")
- await self.cog.redis.update({
- "reckfried": "jona",
- "mega": "hungry, though",
- })
-
- result = {
- "reckfried": "jona",
- "bel air": "prince",
- "mega": "hungry, though",
- }
- self.assertDictEqual(await self.cog.redis.to_dict(), result)
-
- def test_typestring_conversion(self):
- """Test the typestring-related helper functions."""
- conversion_tests = (
- (12, "i|12"),
- (12.4, "f|12.4"),
- ("cowabunga", "s|cowabunga"),
- )
-
- # Test conversion to typestring
- for _input, expected in conversion_tests:
- self.assertEqual(self.cog.redis._value_to_typestring(_input), expected)
-
- # Test conversion from typestrings
- for _input, expected in conversion_tests:
- self.assertEqual(self.cog.redis._value_from_typestring(expected), _input)
-
- # Test that exceptions are raised on invalid input
- with self.assertRaises(TypeError):
- self.cog.redis._value_to_typestring(["internet"])
- self.cog.redis._value_from_typestring("o|firedog")
-
- async def test_increment_decrement(self):
- """Test .increment and .decrement methods."""
- await self.cog.redis.set("entropic", 5)
- await self.cog.redis.set("disentropic", 12.5)
-
- # Test default increment
- await self.cog.redis.increment("entropic")
- self.assertEqual(await self.cog.redis.get("entropic"), 6)
-
- # Test default decrement
- await self.cog.redis.decrement("entropic")
- self.assertEqual(await self.cog.redis.get("entropic"), 5)
-
- # Test float increment with float
- await self.cog.redis.increment("disentropic", 2.0)
- self.assertEqual(await self.cog.redis.get("disentropic"), 14.5)
-
- # Test float increment with int
- await self.cog.redis.increment("disentropic", 2)
- self.assertEqual(await self.cog.redis.get("disentropic"), 16.5)
-
- # Test negative increments, because why not.
- await self.cog.redis.increment("entropic", -5)
- self.assertEqual(await self.cog.redis.get("entropic"), 0)
-
- # Negative decrements? Sure.
- await self.cog.redis.decrement("entropic", -5)
- self.assertEqual(await self.cog.redis.get("entropic"), 5)
-
- # What about if we use a negative float to decrement an int?
- # This should convert the type into a float.
- await self.cog.redis.decrement("entropic", -2.5)
- self.assertEqual(await self.cog.redis.get("entropic"), 7.5)
-
- # Let's test that they raise the right errors
- with self.assertRaises(KeyError):
- await self.cog.redis.increment("doesn't_exist!")
-
- await self.cog.redis.set("stringthing", "stringthing")
- with self.assertRaises(TypeError):
- await self.cog.redis.increment("stringthing")
-
- async def test_increment_lock(self):
- """Test that we can't produce a race condition in .increment."""
- await self.cog.redis.set("test_key", 0)
- tasks = []
-
- # Increment this a lot in different tasks
- for _ in range(100):
- task = asyncio.create_task(
- self.cog.redis.increment("test_key", 1)
- )
- tasks.append(task)
- await asyncio.gather(*tasks)
-
- # Confirm that the value has been incremented the exact right number of times.
- value = await self.cog.redis.get("test_key")
- self.assertEqual(value, 100)
-
- async def test_exceptions_raised(self):
- """Testing that the various RuntimeErrors are reachable."""
- class MyCog:
- cache = RedisCache()
-
- def __init__(self):
- self.other_cache = RedisCache()
-
- cog = MyCog()
-
- # Raises "No Bot instance"
- with self.assertRaises(NoBotInstanceError):
- await cog.cache.get("john")
-
- # Raises "RedisCache has no namespace"
- with self.assertRaises(NoNamespaceError):
- await cog.other_cache.get("was")
-
- # Raises "You must access the RedisCache instance through the cog instance"
- with self.assertRaises(NoParentInstanceError):
- await MyCog.cache.get("afraid")
diff --git a/tests/bot/utils/test_services.py b/tests/bot/utils/test_services.py
new file mode 100644
index 000000000..5e0855704
--- /dev/null
+++ b/tests/bot/utils/test_services.py
@@ -0,0 +1,74 @@
+import logging
+import unittest
+from unittest.mock import AsyncMock, MagicMock, Mock, patch
+
+from aiohttp import ClientConnectorError
+
+from bot.utils.services import FAILED_REQUEST_ATTEMPTS, send_to_paste_service
+
+
+class PasteTests(unittest.IsolatedAsyncioTestCase):
+ def setUp(self) -> None:
+ self.http_session = MagicMock()
+
+ @patch("bot.utils.services.URLs.paste_service", "https://paste_service.com/{key}")
+ async def test_url_and_sent_contents(self):
+ """Correct url was used and post was called with expected data."""
+ 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")
+
+ @patch("bot.utils.services.URLs.paste_service", "https://paste_service.com/{key}")
+ async def test_paste_returns_correct_url_on_success(self):
+ """Url with specified extension is returned on successful requests."""
+ key = "paste_key"
+ test_cases = (
+ (f"https://paste_service.com/{key}.txt", "txt"),
+ (f"https://paste_service.com/{key}.py", "py"),
+ (f"https://paste_service.com/{key}", ""),
+ )
+ response = MagicMock(
+ json=AsyncMock(return_value={"key": key})
+ )
+ self.http_session.post().__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),
+ 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()
+
+ 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)
+ self.assertIsNone(result)
+
+ self.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.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.assertLogs("bot.utils", logging.ERROR)
+ self.assertIsNone(result)
diff --git a/tests/helpers.py b/tests/helpers.py
index facc4e1af..870f66197 100644
--- a/tests/helpers.py
+++ b/tests/helpers.py
@@ -5,7 +5,7 @@ import itertools
import logging
import unittest.mock
from asyncio import AbstractEventLoop
-from typing import Callable, Iterable, Optional
+from typing import Iterable, Optional
import discord
from aiohttp import ClientSession
@@ -14,6 +14,7 @@ from discord.ext.commands import Context
from bot.api import APIClient
from bot.async_stats import AsyncStatsClient
from bot.bot import Bot
+from tests._autospec import autospec # noqa: F401 other modules import it via this module
for logger in logging.Logger.manager.loggerDict.values():
@@ -26,24 +27,6 @@ for logger in logging.Logger.manager.loggerDict.values():
logger.setLevel(logging.CRITICAL)
-def autospec(target, *attributes: str, **kwargs) -> Callable:
- """Patch multiple `attributes` of a `target` with autospecced mocks and `spec_set` as True."""
- # Caller's kwargs should take priority and overwrite the defaults.
- kwargs = {'spec_set': True, 'autospec': True, **kwargs}
-
- # Import the target if it's a string.
- # This is to support both object and string targets like patch.multiple.
- if type(target) is str:
- target = unittest.mock._importer(target)
-
- def decorator(func):
- for attribute in attributes:
- patcher = unittest.mock.patch.object(target, attribute, **kwargs)
- func = patcher(func)
- return func
- return decorator
-
-
class HashableMixin(discord.mixins.EqualityComparable):
"""
Mixin that provides similar hashing and equality functionality as discord.py's `Hashable` mixin.
@@ -308,7 +291,11 @@ class MockBot(CustomMockMixin, unittest.mock.MagicMock):
Instances of this class will follow the specifications of `discord.ext.commands.Bot` instances.
For more information, see the `MockGuild` docstring.
"""
- spec_set = Bot(command_prefix=unittest.mock.MagicMock(), loop=_get_mock_loop())
+ spec_set = Bot(
+ command_prefix=unittest.mock.MagicMock(),
+ loop=_get_mock_loop(),
+ redis_session=unittest.mock.MagicMock(),
+ )
additional_spec_asyncs = ("wait_for", "redis_ready")
def __init__(self, **kwargs) -> None: