aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Matteo Bertucci <[email protected]>2020-09-23 15:39:11 +0200
committerGravatar GitHub <[email protected]>2020-09-23 15:39:11 +0200
commit397179e9127279d87fdf127d1e75490c18dfb73e (patch)
tree19797e60768b6d912fa5a076bd46b2848a30f8a7
parentUpdate guilds.md (diff)
parentClean: fix mention in mod log message (diff)
Merge branch 'master' into master
-rw-r--r--Pipfile3
-rw-r--r--Pipfile.lock251
-rw-r--r--bot/__main__.py73
-rw-r--r--bot/bot.py49
-rw-r--r--bot/cogs/moderation/__init__.py19
-rw-r--r--bot/cogs/sync/__init__.py7
-rw-r--r--bot/cogs/verification.py191
-rw-r--r--bot/cogs/watchchannels/__init__.py9
-rw-r--r--bot/constants.py39
-rw-r--r--bot/converters.py67
-rw-r--r--bot/decorators.py29
-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/alias.py (renamed from bot/cogs/alias.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)0
-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.py (renamed from bot/cogs/sync/syncers.py)0
-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)0
-rw-r--r--bot/exts/filters/antispam.py (renamed from bot/cogs/antispam.py)9
-rw-r--r--bot/exts/filters/filter_lists.py (renamed from bot/cogs/filter_lists.py)7
-rw-r--r--bot/exts/filters/filtering.py (renamed from bot/cogs/filtering.py)162
-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)8
-rw-r--r--bot/exts/filters/webhook_remover.py (renamed from bot/cogs/webhook_remover.py)7
-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)101
-rw-r--r--bot/exts/fun/off_topic_names.py (renamed from bot/cogs/off_topic_names.py)15
-rw-r--r--bot/exts/help_channels.py (renamed from bot/cogs/help_channels.py)9
-rw-r--r--bot/exts/info/__init__.py0
-rw-r--r--bot/exts/info/doc.py (renamed from bot/cogs/doc.py)7
-rw-r--r--bot/exts/info/help.py (renamed from bot/cogs/help.py)0
-rw-r--r--bot/exts/info/information.py (renamed from bot/cogs/information.py)33
-rw-r--r--bot/exts/info/python_news.py (renamed from bot/cogs/python_news.py)0
-rw-r--r--bot/exts/info/reddit.py (renamed from bot/cogs/reddit.py)5
-rw-r--r--bot/exts/info/site.py (renamed from bot/cogs/site.py)0
-rw-r--r--bot/exts/info/source.py (renamed from bot/cogs/source.py)0
-rw-r--r--bot/exts/info/stats.py (renamed from bot/cogs/stats.py)0
-rw-r--r--bot/exts/info/tags.py (renamed from bot/cogs/tags.py)0
-rw-r--r--bot/exts/moderation/__init__.py0
-rw-r--r--bot/exts/moderation/defcon.py (renamed from bot/cogs/defcon.py)18
-rw-r--r--bot/exts/moderation/dm_relay.py (renamed from bot/cogs/dm_relay.py)8
-rw-r--r--bot/exts/moderation/incidents.py (renamed from bot/cogs/moderation/incidents.py)5
-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)81
-rw-r--r--bot/exts/moderation/infraction/_utils.py (renamed from bot/cogs/moderation/utils.py)37
-rw-r--r--bot/exts/moderation/infraction/infractions.py (renamed from bot/cogs/moderation/infractions.py)39
-rw-r--r--bot/exts/moderation/infraction/management.py (renamed from bot/cogs/moderation/management.py)82
-rw-r--r--bot/exts/moderation/infraction/superstarify.py (renamed from bot/cogs/moderation/superstarify.py)48
-rw-r--r--bot/exts/moderation/modlog.py (renamed from bot/cogs/moderation/modlog.py)40
-rw-r--r--bot/exts/moderation/silence.py (renamed from bot/cogs/moderation/silence.py)10
-rw-r--r--bot/exts/moderation/slowmode.py (renamed from bot/cogs/moderation/slowmode.py)7
-rw-r--r--bot/exts/moderation/verification.py753
-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)6
-rw-r--r--bot/exts/moderation/watchchannels/bigbrother.py (renamed from bot/cogs/watchchannels/bigbrother.py)22
-rw-r--r--bot/exts/moderation/watchchannels/talentpool.py (renamed from bot/cogs/watchchannels/talentpool.py)26
-rw-r--r--bot/exts/utils/__init__.py0
-rw-r--r--bot/exts/utils/bot.py (renamed from bot/cogs/bot.py)15
-rw-r--r--bot/exts/utils/clean.py (renamed from bot/cogs/clean.py)22
-rw-r--r--bot/exts/utils/eval.py (renamed from bot/cogs/eval.py)32
-rw-r--r--bot/exts/utils/extensions.py (renamed from bot/cogs/extensions.py)72
-rw-r--r--bot/exts/utils/jams.py (renamed from bot/cogs/jams.py)3
-rw-r--r--bot/exts/utils/ping.py59
-rw-r--r--bot/exts/utils/reminders.py (renamed from bot/cogs/reminders.py)10
-rw-r--r--bot/exts/utils/snekbox.py (renamed from bot/cogs/snekbox.py)14
-rw-r--r--bot/exts/utils/utils.py (renamed from bot/cogs/utils.py)6
-rw-r--r--bot/rules/__init__.py1
-rw-r--r--bot/rules/burst_shared.py11
-rw-r--r--bot/rules/everyone_ping.py41
-rw-r--r--bot/utils/__init__.py20
-rw-r--r--bot/utils/checks.py49
-rw-r--r--bot/utils/extensions.py34
-rw-r--r--bot/utils/helpers.py23
-rw-r--r--bot/utils/messages.py34
-rw-r--r--bot/utils/redis_cache.py414
-rw-r--r--bot/utils/services.py54
-rw-r--r--config-default.yml118
-rw-r--r--tests/bot/cogs/test_duck_pond.py548
-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.py (renamed from tests/bot/cogs/sync/test_base.py)2
-rw-r--r--tests/bot/exts/backend/sync/test_cog.py (renamed from tests/bot/cogs/sync/test_cog.py)17
-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)2
-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)2
-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)26
-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)40
-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.py (renamed from tests/bot/cogs/moderation/test_infractions.py)8
-rw-r--r--tests/bot/exts/moderation/infraction/test_utils.py359
-rw-r--r--tests/bot/exts/moderation/test_incidents.py (renamed from tests/bot/cogs/moderation/test_incidents.py)66
-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.py (renamed from tests/bot/cogs/moderation/test_silence.py)20
-rw-r--r--tests/bot/exts/moderation/test_slowmode.py (renamed from tests/bot/cogs/test_slowmode.py)14
-rw-r--r--tests/bot/exts/test_cogs.py (renamed from tests/bot/cogs/test_cogs.py)7
-rw-r--r--tests/bot/exts/utils/__init__.py0
-rw-r--r--tests/bot/exts/utils/test_jams.py (renamed from tests/bot/cogs/test_jams.py)2
-rw-r--r--tests/bot/exts/utils/test_snekbox.py (renamed from tests/bot/cogs/test_snekbox.py)54
-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.py6
113 files changed, 2436 insertions, 2462 deletions
diff --git a/Pipfile b/Pipfile
index 6fff2223e..e6f84d911 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.4.0"
-fakeredis = "~=1.4"
feedparser = "~=5.2"
fuzzywuzzy = "~=0.17"
lxml = "~=4.4"
diff --git a/Pipfile.lock b/Pipfile.lock
index 50ddd478c..f75852081 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
- "sha256": "1905fd7eb15074ddbf04f2177b6cdd65edc4c74cb5fcbf4e6ca08ef649ba8a3c"
+ "sha256": "644012a1c3fa3e3a30f8b8f8e672c468dfaa155d9e43d26e2be8713c8dc5ebb3"
},
"pipfile-spec": 6,
"requires": {
@@ -18,11 +18,11 @@
"default": {
"aio-pika": {
"hashes": [
- "sha256:c4cbbeb85b3c7bf81bc127371846cd949e6231717ce1e6ac7ee1dd5ede21f866",
- "sha256:ec7fef24f588d90314873463ab4f2c3debce0bd8830e49e3786586be96bc2e8e"
+ "sha256:4a20d4d941e1f113a950ea529a90bd9159c8d7aafaa1c71e9c707c8c2b526ea6",
+ "sha256:7bf3f183df1eb348d007210a0c1a3c5c755f1b3def1a9a395e93f30b91da1daf"
],
"index": "pypi",
- "version": "==6.6.1"
+ "version": "==6.7.0"
},
"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",
@@ -73,6 +81,18 @@
],
"version": "==0.7.12"
},
+ "async-rediscache": {
+ "extras": [
+ "fakeredis"
+ ],
+ "hashes": [
+ "sha256:407aed1aad97bf22f690eca5369806d22eefc8ca104a52c1f1bd47dd6db45fc2",
+ "sha256:563aaff79ec611a92a0ad78e39ff159e3a4b4cf0bea41e061de5f3701a17d50c"
+ ],
+ "index": "pypi",
+ "markers": "python_version ~= '3.7'",
+ "version": "==0.1.2"
+ },
"async-timeout": {
"hashes": [
"sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f",
@@ -83,11 +103,11 @@
},
"attrs": {
"hashes": [
- "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c",
- "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"
+ "sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594",
+ "sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
- "version": "==19.3.0"
+ "version": "==20.2.0"
},
"babel": {
"hashes": [
@@ -115,36 +135,36 @@
},
"cffi": {
"hashes": [
- "sha256:267adcf6e68d77ba154334a3e4fc921b8e63cbb38ca00d33d40655d4228502bc",
- "sha256:26f33e8f6a70c255767e3c3f957ccafc7f1f706b966e110b855bfe944511f1f9",
- "sha256:3cd2c044517f38d1b577f05927fb9729d3396f1d44d0c659a445599e79519792",
- "sha256:4a03416915b82b81af5502459a8a9dd62a3c299b295dcdf470877cb948d655f2",
- "sha256:4ce1e995aeecf7cc32380bc11598bfdfa017d592259d5da00fc7ded11e61d022",
- "sha256:4f53e4128c81ca3212ff4cf097c797ab44646a40b42ec02a891155cd7a2ba4d8",
- "sha256:4fa72a52a906425416f41738728268072d5acfd48cbe7796af07a923236bcf96",
- "sha256:66dd45eb9530e3dde8f7c009f84568bc7cac489b93d04ac86e3111fb46e470c2",
- "sha256:6923d077d9ae9e8bacbdb1c07ae78405a9306c8fd1af13bfa06ca891095eb995",
- "sha256:833401b15de1bb92791d7b6fb353d4af60dc688eaa521bd97203dcd2d124a7c1",
- "sha256:8416ed88ddc057bab0526d4e4e9f3660f614ac2394b5e019a628cdfff3733849",
- "sha256:892daa86384994fdf4856cb43c93f40cbe80f7f95bb5da94971b39c7f54b3a9c",
- "sha256:98be759efdb5e5fa161e46d404f4e0ce388e72fbf7d9baf010aff16689e22abe",
- "sha256:a6d28e7f14ecf3b2ad67c4f106841218c8ab12a0683b1528534a6c87d2307af3",
- "sha256:b1d6ebc891607e71fd9da71688fcf332a6630b7f5b7f5549e6e631821c0e5d90",
- "sha256:b2a2b0d276a136146e012154baefaea2758ef1f56ae9f4e01c612b0831e0bd2f",
- "sha256:b87dfa9f10a470eee7f24234a37d1d5f51e5f5fa9eeffda7c282e2b8f5162eb1",
- "sha256:bac0d6f7728a9cc3c1e06d4fcbac12aaa70e9379b3025b27ec1226f0e2d404cf",
- "sha256:c991112622baee0ae4d55c008380c32ecfd0ad417bcd0417ba432e6ba7328caa",
- "sha256:cda422d54ee7905bfc53ee6915ab68fe7b230cacf581110df4272ee10462aadc",
- "sha256:d3148b6ba3923c5850ea197a91a42683f946dba7e8eb82dfa211ab7e708de939",
- "sha256:d6033b4ffa34ef70f0b8086fd4c3df4bf801fee485a8a7d4519399818351aa8e",
- "sha256:ddff0b2bd7edcc8c82d1adde6dbbf5e60d57ce985402541cd2985c27f7bec2a0",
- "sha256:e23cb7f1d8e0f93addf0cae3c5b6f00324cccb4a7949ee558d7b6ca973ab8ae9",
- "sha256:effd2ba52cee4ceff1a77f20d2a9f9bf8d50353c854a282b8760ac15b9833168",
- "sha256:f90c2267101010de42f7273c94a1f026e56cbc043f9330acd8a80e64300aba33",
- "sha256:f960375e9823ae6a07072ff7f8a85954e5a6434f97869f50d0e41649a1c8144f",
- "sha256:fcf32bf76dc25e30ed793145a57426064520890d7c02866eb93d3e4abe516948"
- ],
- "version": "==1.14.1"
+ "sha256:0da50dcbccd7cb7e6c741ab7912b2eff48e85af217d72b57f80ebc616257125e",
+ "sha256:12a453e03124069b6896107ee133ae3ab04c624bb10683e1ed1c1663df17c13c",
+ "sha256:15419020b0e812b40d96ec9d369b2bc8109cc3295eac6e013d3261343580cc7e",
+ "sha256:15a5f59a4808f82d8ec7364cbace851df591c2d43bc76bcbe5c4543a7ddd1bf1",
+ "sha256:23e44937d7695c27c66a54d793dd4b45889a81b35c0751ba91040fe825ec59c4",
+ "sha256:29c4688ace466a365b85a51dcc5e3c853c1d283f293dfcc12f7a77e498f160d2",
+ "sha256:57214fa5430399dffd54f4be37b56fe22cedb2b98862550d43cc085fb698dc2c",
+ "sha256:577791f948d34d569acb2d1add5831731c59d5a0c50a6d9f629ae1cefd9ca4a0",
+ "sha256:6539314d84c4d36f28d73adc1b45e9f4ee2a89cdc7e5d2b0a6dbacba31906798",
+ "sha256:65867d63f0fd1b500fa343d7798fa64e9e681b594e0a07dc934c13e76ee28fb1",
+ "sha256:672b539db20fef6b03d6f7a14b5825d57c98e4026401fce838849f8de73fe4d4",
+ "sha256:6843db0343e12e3f52cc58430ad559d850a53684f5b352540ca3f1bc56df0731",
+ "sha256:7057613efefd36cacabbdbcef010e0a9c20a88fc07eb3e616019ea1692fa5df4",
+ "sha256:76ada88d62eb24de7051c5157a1a78fd853cca9b91c0713c2e973e4196271d0c",
+ "sha256:837398c2ec00228679513802e3744d1e8e3cb1204aa6ad408b6aff081e99a487",
+ "sha256:8662aabfeab00cea149a3d1c2999b0731e70c6b5bac596d95d13f643e76d3d4e",
+ "sha256:95e9094162fa712f18b4f60896e34b621df99147c2cee216cfa8f022294e8e9f",
+ "sha256:99cc66b33c418cd579c0f03b77b94263c305c389cb0c6972dac420f24b3bf123",
+ "sha256:9b219511d8b64d3fa14261963933be34028ea0e57455baf6781fe399c2c3206c",
+ "sha256:ae8f34d50af2c2154035984b8b5fc5d9ed63f32fe615646ab435b05b132ca91b",
+ "sha256:b9aa9d8818c2e917fa2c105ad538e222a5bce59777133840b93134022a7ce650",
+ "sha256:bf44a9a0141a082e89c90e8d785b212a872db793a0080c20f6ae6e2a0ebf82ad",
+ "sha256:c0b48b98d79cf795b0916c57bebbc6d16bb43b9fc9b8c9f57f4cf05881904c75",
+ "sha256:da9d3c506f43e220336433dffe643fbfa40096d408cb9b7f2477892f369d5f82",
+ "sha256:e4082d832e36e7f9b2278bc774886ca8207346b99f278e54c9de4834f17232f7",
+ "sha256:e4b9b7af398c32e408c00eb4e0d33ced2f9121fd9fb978e6c1b57edd014a7d15",
+ "sha256:e613514a82539fc48291d01933951a13ae93b6b444a88782480be32245ed4afa",
+ "sha256:f5033952def24172e60493b68717792e3aebb387a8d186c43c020d9363ee7281"
+ ],
+ "version": "==1.14.2"
},
"chardet": {
"hashes": [
@@ -188,11 +208,11 @@
},
"discord.py": {
"hashes": [
- "sha256:2b1846bfa382b54f4eace8e437a9f59f185388c5b08749ac0e1bbd98e05bfde5",
- "sha256:f3db9531fccc391f51de65cfa46133106a9ba12ff2927aca6c14bffd3b7f17b5"
+ "sha256:98ea3096a3585c9c379209926f530808f5fcf4930928d8cfb579d2562d119570",
+ "sha256:f9decb3bfa94613d922376288617e6a6f969260923643e2897f4540c34793442"
],
"markers": "python_full_version >= '3.5.3'",
- "version": "==1.4.0"
+ "version": "==1.4.1"
},
"docutils": {
"hashes": [
@@ -204,11 +224,10 @@
},
"fakeredis": {
"hashes": [
- "sha256:790c85ad0f3b2967aba1f51767021bc59760fcb612159584be018ea7384f7fd2",
- "sha256:fdfe06f277092d022c271fcaefdc1f0c8d9bfa8cb15374cae41d66a20bd96d2b"
+ "sha256:7ea0866ba5edb40fe2e9b1722535df0c7e6b91d518aa5f50d96c2fff3ea7f4c2",
+ "sha256:aad8836ffe0319ffbba66dcf872ac6e7e32d1f19790e31296ba58445efb0a5c7"
],
- "index": "pypi",
- "version": "==1.4.2"
+ "version": "==1.4.3"
},
"feedparser": {
"hashes": [
@@ -350,10 +369,11 @@
},
"markdownify": {
"hashes": [
- "sha256:28ce67d1888e4908faaab7b04d2193cda70ea4f902f156a21d0aaea55e63e0a1"
+ "sha256:30be8340724e706c9e811c27fe8c1542cf74a15b46827924fff5c54b40dd9b0d",
+ "sha256:a69588194fd76634f0139d6801b820fd652dc5eeba9530e90d323dfdc0155252"
],
"index": "pypi",
- "version": "==0.4.1"
+ "version": "==0.5.3"
},
"markupsafe": {
"hashes": [
@@ -396,11 +416,11 @@
},
"more-itertools": {
"hashes": [
- "sha256:68c70cc7167bdf5c7c9d8f6954a7837089c6a36bf565383919bb595efb8a17e5",
- "sha256:b78134b2063dd214000685165d81c154522c3ee0a1c0d4d113c80361c234c5a2"
+ "sha256:6f83822ae94818eae2612063a5101a7311e68ae8002005b5e05f03fd74a86a20",
+ "sha256:9b30f12df9393f0d28af9210ff8efe48d10c94f73e5daf886f10c4b0b0b4f03c"
],
"index": "pypi",
- "version": "==8.4.0"
+ "version": "==8.5.0"
},
"multidict": {
"hashes": [
@@ -491,11 +511,11 @@
},
"pygments": {
"hashes": [
- "sha256:647344a061c249a3b74e230c739f434d7ea4d8b1d5f3721bc0f3558049b38f44",
- "sha256:ff7a40b4860b727ab48fad6360eb351cc1b33cbf9b15a0f689ca5353e9463324"
+ "sha256:307543fe65c0947b126e83dd5a61bd8acbd84abec11f43caebaf5534cbc17998",
+ "sha256:926c3f319eda178d1bd90851e4317e6d8cdb5e292a3386aac9bd75eca29cf9c7"
],
"markers": "python_version >= '3.5'",
- "version": "==2.6.1"
+ "version": "==2.7.1"
},
"pyparsing": {
"hashes": [
@@ -555,11 +575,11 @@
},
"sentry-sdk": {
"hashes": [
- "sha256:21b17d6aa064c0fb703a7c00f77cf6c9c497cf2f83345c28892980a5e742d116",
- "sha256:4fc97114c77d005467b9b1a29f042e2bc01923cb683b0ef0bbda46e79fa12532"
+ "sha256:1a086486ff9da15791f294f6e9915eb3747d161ef64dee2d038a4d0b4a369b24",
+ "sha256:45486deb031cea6bbb25a540d7adb4dd48cd8a1cc31e6a5ce9fb4f792a572e9a"
],
"index": "pypi",
- "version": "==0.16.3"
+ "version": "==0.17.6"
},
"six": {
"hashes": [
@@ -697,11 +717,11 @@
},
"attrs": {
"hashes": [
- "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c",
- "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"
+ "sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594",
+ "sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
- "version": "==19.3.0"
+ "version": "==20.2.0"
},
"cfgv": {
"hashes": [
@@ -713,43 +733,43 @@
},
"coverage": {
"hashes": [
- "sha256:098a703d913be6fbd146a8c50cc76513d726b022d170e5e98dc56d958fd592fb",
- "sha256:16042dc7f8e632e0dcd5206a5095ebd18cb1d005f4c89694f7f8aafd96dd43a3",
- "sha256:1adb6be0dcef0cf9434619d3b892772fdb48e793300f9d762e480e043bd8e716",
- "sha256:27ca5a2bc04d68f0776f2cdcb8bbd508bbe430a7bf9c02315cd05fb1d86d0034",
- "sha256:28f42dc5172ebdc32622a2c3f7ead1b836cdbf253569ae5673f499e35db0bac3",
- "sha256:2fcc8b58953d74d199a1a4d633df8146f0ac36c4e720b4a1997e9b6327af43a8",
- "sha256:304fbe451698373dc6653772c72c5d5e883a4aadaf20343592a7abb2e643dae0",
- "sha256:30bc103587e0d3df9e52cd9da1dd915265a22fad0b72afe54daf840c984b564f",
- "sha256:40f70f81be4d34f8d491e55936904db5c527b0711b2a46513641a5729783c2e4",
- "sha256:4186fc95c9febeab5681bc3248553d5ec8c2999b8424d4fc3a39c9cba5796962",
- "sha256:46794c815e56f1431c66d81943fa90721bb858375fb36e5903697d5eef88627d",
- "sha256:4869ab1c1ed33953bb2433ce7b894a28d724b7aa76c19b11e2878034a4e4680b",
- "sha256:4f6428b55d2916a69f8d6453e48a505c07b2245653b0aa9f0dee38785939f5e4",
- "sha256:52f185ffd3291196dc1aae506b42e178a592b0b60a8610b108e6ad892cfc1bb3",
- "sha256:538f2fd5eb64366f37c97fdb3077d665fa946d2b6d95447622292f38407f9258",
- "sha256:64c4f340338c68c463f1b56e3f2f0423f7b17ba6c3febae80b81f0e093077f59",
- "sha256:675192fca634f0df69af3493a48224f211f8db4e84452b08d5fcebb9167adb01",
- "sha256:700997b77cfab016533b3e7dbc03b71d33ee4df1d79f2463a318ca0263fc29dd",
- "sha256:8505e614c983834239f865da2dd336dcf9d72776b951d5dfa5ac36b987726e1b",
- "sha256:962c44070c281d86398aeb8f64e1bf37816a4dfc6f4c0f114756b14fc575621d",
- "sha256:9e536783a5acee79a9b308be97d3952b662748c4037b6a24cbb339dc7ed8eb89",
- "sha256:9ea749fd447ce7fb1ac71f7616371f04054d969d412d37611716721931e36efd",
- "sha256:a34cb28e0747ea15e82d13e14de606747e9e484fb28d63c999483f5d5188e89b",
- "sha256:a3ee9c793ffefe2944d3a2bd928a0e436cd0ac2d9e3723152d6fd5398838ce7d",
- "sha256:aab75d99f3f2874733946a7648ce87a50019eb90baef931698f96b76b6769a46",
- "sha256:b1ed2bdb27b4c9fc87058a1cb751c4df8752002143ed393899edb82b131e0546",
- "sha256:b360d8fd88d2bad01cb953d81fd2edd4be539df7bfec41e8753fe9f4456a5082",
- "sha256:b8f58c7db64d8f27078cbf2a4391af6aa4e4767cc08b37555c4ae064b8558d9b",
- "sha256:c1bbb628ed5192124889b51204de27c575b3ffc05a5a91307e7640eff1d48da4",
- "sha256:c2ff24df02a125b7b346c4c9078c8936da06964cc2d276292c357d64378158f8",
- "sha256:c890728a93fffd0407d7d37c1e6083ff3f9f211c83b4316fae3778417eab9811",
- "sha256:c96472b8ca5dc135fb0aa62f79b033f02aa434fb03a8b190600a5ae4102df1fd",
- "sha256:ce7866f29d3025b5b34c2e944e66ebef0d92e4a4f2463f7266daa03a1332a651",
- "sha256:e26c993bd4b220429d4ec8c1468eca445a4064a61c74ca08da7429af9bc53bb0"
- ],
- "index": "pypi",
- "version": "==5.2.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": [
@@ -775,11 +795,11 @@
},
"flake8-annotations": {
"hashes": [
- "sha256:7816a5d8f65ffdf37b8e21e5b17e0fd1e492aa92638573276de066e889a22b26",
- "sha256:8d18db74a750dd97f40b483cc3ef80d07d03f687525bad8fd83365dcd3bfd414"
+ "sha256:09fe1aa3f40cb8fef632a0ab3614050a7584bb884b6134e70cf1fc9eeee642fa",
+ "sha256:5bda552f074fd6e34276c7761756fa07d824ffac91ce9c0a8555eb2bc5b92d7a"
],
"index": "pypi",
- "version": "==2.3.0"
+ "version": "==2.4.0"
},
"flake8-bugbear": {
"hashes": [
@@ -837,11 +857,11 @@
},
"identify": {
"hashes": [
- "sha256:110ed090fec6bce1aabe3c72d9258a9de82207adeaa5a05cd75c635880312f9a",
- "sha256:ccd88716b890ecbe10920659450a635d2d25de499b9a638525a48b48261d989b"
+ "sha256:c770074ae1f19e08aadbda1c886bc6d0cb55ffdc503a8c0fe8699af2fc9664ae",
+ "sha256:d02d004568c5a01261839a05e91705e3e9f5c57a3551648f9b3fb2b9c62c0f62"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
- "version": "==1.4.25"
+ "version": "==1.5.3"
},
"mccabe": {
"hashes": [
@@ -852,9 +872,10 @@
},
"nodeenv": {
"hashes": [
- "sha256:4b0b77afa3ba9b54f4b6396e60b0c83f59eaeb2d63dc3cc7a70f7f4af96c82bc"
+ "sha256:5304d424c529c997bc888453aeaa6362d242b6b4631e90f3d4bf1b290f1c84a9",
+ "sha256:ab45090ae383b716c4ef89e690c41ff8c2b257b85b309f01f3654df3d084bd7c"
],
- "version": "==1.4.0"
+ "version": "==1.5.0"
},
"pep8-naming": {
"hashes": [
@@ -866,11 +887,11 @@
},
"pre-commit": {
"hashes": [
- "sha256:1657663fdd63a321a4a739915d7d03baedd555b25054449090f97bb0cb30a915",
- "sha256:e8b1315c585052e729ab7e99dcca5698266bedce9067d21dc909c23e3ceed626"
+ "sha256:810aef2a2ba4f31eed1941fc270e72696a1ad5590b9751839c90807d0fff6b9a",
+ "sha256:c54fd3e574565fe128ecc5e7d2f91279772ddb03f8729645fa812fe809084a70"
],
"index": "pypi",
- "version": "==2.6.0"
+ "version": "==2.7.1"
},
"pycodestyle": {
"hashes": [
@@ -882,11 +903,11 @@
},
"pydocstyle": {
"hashes": [
- "sha256:da7831660b7355307b32778c4a0dbfb137d89254ef31a2b2978f50fc0b4d7586",
- "sha256:f4f5d210610c2d153fae39093d44224c17429e2ad7da12a8b419aba5c2f614b5"
+ "sha256:19b86fa8617ed916776a11cd8bc0197e5b9856d5433b777f51a3defe13075325",
+ "sha256:aca749e190a01726a4fb472dd4ef23b5c9da7b9205c0a7857c06533de13fd678"
],
"markers": "python_version >= '3.5'",
- "version": "==5.0.2"
+ "version": "==5.1.1"
},
"pyflakes": {
"hashes": [
@@ -937,19 +958,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:7b54fd606a1b85f83de49ad8d80dbec08e983a2d2f96685045b262ebc7481ee5",
- "sha256:8cd7b2a4850b003a11be2fc213e206419efab41115cc14bca20e69654f2ac08e"
+ "sha256:43add625c53c596d38f971a465553f6318decc39d98512bc100fa1b1e839c8dc",
+ "sha256:e0305af10299a7fb0d69393d8f04cb2965dda9351140d11ac8db4e5e3970451b"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
- "version": "==20.0.30"
+ "version": "==20.0.31"
}
}
}
diff --git a/bot/__main__.py b/bot/__main__.py
index fe2cf90e6..a07bc21d6 100644
--- a/bot/__main__.py
+++ b/bot/__main__.py
@@ -1,7 +1,9 @@
+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
@@ -9,7 +11,9 @@ from sentry_sdk.integrations.redis import RedisIntegration
from bot import constants, patches
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,8 +28,28 @@ 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]
bot = Bot(
+ redis_session=redis_session,
+ loop=loop,
command_prefix=when_mentioned_or(constants.Bot.prefix),
activity=discord.Game(name="Commands: !help"),
case_insensitive=True,
@@ -33,50 +57,13 @@ bot = Bot(
allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles)
)
-# Internal/debug
-bot.load_extension("bot.cogs.config_verifier")
-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")
-
-# 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.doc")
-bot.load_extension("bot.cogs.extensions")
-bot.load_extension("bot.cogs.help")
-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.dm_relay")
-bot.load_extension("bot.cogs.duck_pond")
-bot.load_extension("bot.cogs.eval")
-bot.load_extension("bot.cogs.filter_lists")
-bot.load_extension("bot.cogs.information")
-bot.load_extension("bot.cogs.jams")
-bot.load_extension("bot.cogs.moderation")
-bot.load_extension("bot.cogs.off_topic_names")
-bot.load_extension("bot.cogs.python_news")
-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.source")
-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")
+# Load extensions.
+extensions = set(EXTENSIONS) # Create a mutable copy.
+if not constants.HelpChannels.enable:
+ extensions.remove("bot.exts.help_channels")
-if constants.HelpChannels.enable:
- bot.load_extension("bot.cogs.help_channels")
+for extension in extensions:
+ bot.load_extension(extension)
# Apply `message_edited_at` patch if discord.py did not yet release a bug fix.
if not hasattr(discord.message.Message, '_handle_edited_timestamp'):
diff --git a/bot/bot.py b/bot/bot.py
index d25074fd9..b2e5237fe 100644
--- a/bot/bot.py
+++ b/bot/bot.py
@@ -6,9 +6,8 @@ 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
@@ -21,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 "
@@ -31,9 +30,7 @@ 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)
@@ -58,30 +55,6 @@ class Bot(commands.Bot):
for item in full_cache:
self.insert_item_into_filter_list_cache(item)
- async def _create_redis_session(self) -> None:
- """
- Create the Redis connection pool, and then open the redis event gate.
-
- 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.
-
- 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,
- )
-
- self.redis_closed = False
- self.redis_ready.set()
-
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.
@@ -94,13 +67,10 @@ class Bot(commands.Bot):
"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())
+ 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.
@@ -180,10 +150,7 @@ 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."""
diff --git a/bot/cogs/moderation/__init__.py b/bot/cogs/moderation/__init__.py
deleted file mode 100644
index 995187ef0..000000000
--- a/bot/cogs/moderation/__init__.py
+++ /dev/null
@@ -1,19 +0,0 @@
-from bot.bot import Bot
-from .incidents import Incidents
-from .infractions import Infractions
-from .management import ModManagement
-from .modlog import ModLog
-from .silence import Silence
-from .slowmode import Slowmode
-from .superstarify import Superstarify
-
-
-def setup(bot: Bot) -> None:
- """Load the Incidents, Infractions, ModManagement, ModLog, Silence, Slowmode and Superstarify cogs."""
- bot.add_cog(Incidents(bot))
- bot.add_cog(Infractions(bot))
- bot.add_cog(ModLog(bot))
- bot.add_cog(ModManagement(bot))
- bot.add_cog(Silence(bot))
- bot.add_cog(Slowmode(bot))
- bot.add_cog(Superstarify(bot))
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/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/constants.py b/bot/constants.py
index 17fe34e95..c710e2dff 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,6 +225,7 @@ 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
@@ -252,7 +254,7 @@ class DuckPond(metaclass=YAMLGetter):
section = "duck_pond"
threshold: int
- custom_emojis: List[int]
+ channel_blacklist: List[int]
class Emojis(metaclass=YAMLGetter):
@@ -292,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
@@ -395,12 +383,14 @@ 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
cooldown: int
defcon: int
dev_contrib: int
@@ -412,9 +402,11 @@ class Channels(metaclass=YAMLGetter):
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
@@ -423,7 +415,10 @@ 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
@@ -461,6 +456,7 @@ class Roles(metaclass=YAMLGetter):
partners: int
python_community: int
team_leaders: int
+ unverified: int
verified: int # This is the Developers role on PyDis, here named verified for readability reasons.
@@ -468,11 +464,11 @@ class Guild(metaclass=YAMLGetter):
section = "guild"
id: int
+ invite: str # Discord invite, gets embedded in chat
moderation_channels: List[int]
moderation_roles: List[int]
modlog_blacklist: List[int]
reminder_whitelist: List[int]
- staff_channels: List[int]
staff_roles: List[int]
@@ -578,6 +574,16 @@ 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 Event(Enum):
"""
Event names. This does not include every event (for example, raw
@@ -616,7 +622,6 @@ MODERATION_ROLES = Guild.moderation_roles
STAFF_ROLES = Guild.staff_roles
# Channel combinations
-STAFF_CHANNELS = Guild.staff_channels
MODERATION_CHANNELS = Guild.moderation_channels
# Bot replies
diff --git a/bot/converters.py b/bot/converters.py
index 1358cbf1e..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
@@ -10,6 +11,7 @@ import discord
from aiohttp import ClientConnectorError
from dateutil.relativedelta import relativedelta
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
@@ -17,6 +19,9 @@ 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]:
"""
@@ -172,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):
@@ -447,14 +477,13 @@ class UserMentionOrID(UserConverter):
"""
Converts to a `discord.User`, but only if a mention or userID is provided.
- Unlike the default `UserConverter`, it does allow conversion from name, or name#descrim.
-
+ 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.match(r'<@!?([0-9]+)>$', argument)
+ match = self._get_id_match(argument) or RE_USER_MENTION.match(argument)
if match is not None:
return await super().convert(ctx, argument)
@@ -507,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..2518124da 100644
--- a/bot/decorators.py
+++ b/bot/decorators.py
@@ -6,13 +6,12 @@ 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 Colour, Embed, 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.utils.checks import in_whitelist_check
log = logging.getLogger(__name__)
@@ -45,18 +44,22 @@ 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 has_no_roles(*roles: Union[str, int]) -> Callable:
+ """
+ Returns True if the user does not have any of the roles specified.
-def without_role(*role_ids: int) -> Callable:
- """Returns True if the user does not have any of the roles in role_ids."""
+ `roles` are the names or IDs of the disallowed roles.
+ """
async def predicate(ctx: Context) -> bool:
- return without_role_check(ctx, *role_ids)
+ 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_}")
+
return commands.check(predicate)
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/alias.py b/bot/exts/backend/alias.py
index c6ba8d6f3..c6ba8d6f3 100644
--- a/bot/cogs/alias.py
+++ b/bot/exts/backend/alias.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 f9d4de638..f9d4de638 100644
--- a/bot/cogs/error_handler.py
+++ b/bot/exts/backend/error_handler.py
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/cogs/sync/syncers.py b/bot/exts/backend/sync/_syncers.py
index f7ba811bc..f7ba811bc 100644
--- a/bot/cogs/sync/syncers.py
+++ b/bot/exts/backend/sync/_syncers.py
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 7894ec48f..7894ec48f 100644
--- a/bot/cogs/antimalware.py
+++ b/bot/exts/filters/antimalware.py
diff --git a/bot/cogs/antispam.py b/bot/exts/filters/antispam.py
index 3ad487d8c..4964283f1 100644
--- a/bot/cogs/antispam.py
+++ b/bot/exts/filters/antispam.py
@@ -11,7 +11,6 @@ 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,
@@ -19,7 +18,8 @@ from bot.constants import (
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__)
@@ -36,9 +36,6 @@ RULE_FUNCTION_MAPPING = {
'mentions': rules.apply_mentions,
'newlines': rules.apply_newlines,
'role_mentions': rules.apply_role_mentions,
- # the everyone filter is temporarily disabled until
- # it has been improved.
- # 'everyone_ping': rules.apply_everyone_ping,
}
@@ -71,7 +68,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"
diff --git a/bot/cogs/filter_lists.py b/bot/exts/filters/filter_lists.py
index c15adc461..232c1e48b 100644
--- a/bot/cogs/filter_lists.py
+++ b/bot/exts/filters/filter_lists.py
@@ -2,14 +2,13 @@ import logging
from typing import Optional
from discord import Colour, Embed
-from discord.ext.commands import BadArgument, Cog, Context, IDConverter, group
+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
-from bot.utils.checks import with_role_check
log = logging.getLogger(__name__)
@@ -263,9 +262,9 @@ class FilterLists(Cog):
"""Syncs both allowlists and denylists with the API."""
await self._sync_data(ctx)
- 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:
diff --git a/bot/cogs/filtering.py b/bot/exts/filters/filtering.py
index 99b659bff..92cdfb8f5 100644
--- a/bot/cogs/filtering.py
+++ b/bot/exts/filters/filtering.py
@@ -2,10 +2,11 @@ import asyncio
import logging
import re
from datetime import datetime, timedelta
-from typing import List, Mapping, Optional, Tuple, 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
@@ -13,18 +14,24 @@ 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
log = logging.getLogger(__name__)
# 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]")
@@ -33,6 +40,16 @@ ZALGO_RE = re.compile(r"[\u0300-\u036F\u0489]")
DAYS_BETWEEN_ALERTS = 3
OFFENSIVE_MSG_DELETE_TIME = timedelta(days=Filter.offensive_msg_delete_days)
+FilterMatch = Union[re.Match, dict, bool, List[discord.Embed]]
+
+
+class Stats(NamedTuple):
+ """Additional stats on a triggered filter to append to a mod log."""
+
+ message_content: str
+ additional_embeds: Optional[List[discord.Embed]]
+ additional_embeds_msg: Optional[str]
+
class Filtering(Cog):
"""Filtering out invites, blacklisting domains, and warning us of certain regular expressions."""
@@ -82,6 +99,19 @@ class Filtering(Cog):
),
"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,
@@ -175,8 +205,8 @@ class Filtering(Cog):
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)}"
)
@@ -215,35 +245,8 @@ class Filtering(Cog):
if _filter["type"] == "filter":
filter_triggered = True
- # We do not have to check against DM channels since !eval cannot be used there.
- channel_str = f"in {msg.channel.mention}"
-
- message_content, additional_embeds, additional_embeds_msg = self._add_stats(
- filter_name, match, result
- )
-
- message = (
- f"The {filter_name} {_filter['type']} was triggered "
- f"by **{msg.author}** "
- f"(`{msg.author.id}`) {channel_str} using !eval with "
- f"[the following message]({msg.jump_url}):\n\n"
- f"{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=Filter.ping_everyone,
- additional_embeds=additional_embeds,
- additional_embeds_msg=additional_embeds_msg
- )
+ 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
@@ -313,43 +316,52 @@ class Filtering(Cog):
self.schedule_msg_delete(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}"
-
- message_content, additional_embeds, additional_embeds_msg = self._add_stats(
- filter_name, match, msg.content
- )
+ stats = self._add_stats(filter_name, match, msg.content)
+ await self._send_log(filter_name, _filter, msg, stats)
- 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}"
- )
+ break # We don't want multiple filters to trigger
- 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=Filter.ping_everyone if not is_private else False,
- additional_embeds=additional_embeds,
- additional_embeds_msg=additional_embeds_msg
- )
+ 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}"
+ )
- break # We don't want multiple filters to trigger
+ 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: Union[re.Match, dict, bool, List[discord.Embed]], content: str) -> Tuple[
- str, Optional[List[discord.Embed]], Optional[str]
- ]:
+ 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":
@@ -386,7 +398,7 @@ class Filtering(Cog):
additional_embeds = match
additional_embeds_msg = "With the following embed(s):"
- return message_content, additional_embeds, additional_embeds_msg
+ return Stats(message_content, additional_embeds, additional_embeds_msg)
@staticmethod
def _check_filter(msg: Message) -> bool:
@@ -528,6 +540,16 @@ class Filtering(Cog):
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.
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..ba86e557a 100644
--- a/bot/cogs/token_remover.py
+++ b/bot/exts/filters/token_remover.py
@@ -9,13 +9,14 @@ 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}`"
)
DELETION_MESSAGE_TEMPLATE = (
@@ -111,8 +112,7 @@ class TokenRemover(Cog):
def format_log_message(msg: Message, token: Token) -> str:
"""Return 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,
diff --git a/bot/cogs/webhook_remover.py b/bot/exts/filters/webhook_remover.py
index 5812da87c..08fe94055 100644
--- a/bot/cogs/webhook_remover.py
+++ b/bot/exts/filters/webhook_remover.py
@@ -5,8 +5,9 @@ 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?://)?discord(?:app)?\.com/api/webhooks/\d+/)\S+/?", re.IGNORECASE)
@@ -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 7021069fa..6c2d22b9c 100644
--- a/bot/cogs/duck_pond.py
+++ b/bot/exts/fun/duck_pond.py
@@ -1,12 +1,14 @@
+import asyncio
import logging
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.checks import has_any_role
from bot.utils.messages import send_attachments
from bot.utils.webhooks import send_webhook
@@ -21,6 +23,7 @@ class DuckPond(Cog):
self.webhook_id = constants.Webhooks.duck_pond
self.webhook = None
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."""
@@ -49,32 +52,32 @@ class DuckPond(Cog):
return True
return False
+ @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:
"""
Count the number of ducks in the reactions of a specific message.
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."""
@@ -103,18 +106,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:
@@ -125,20 +145,24 @@ class DuckPond(Cog):
amount of ducks specified in the config under duck_pond/threshold, it will
send the message off to the duck pond.
"""
+ # 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)
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!
@@ -146,7 +170,7 @@ class DuckPond(Cog):
# 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)
+ await self.locked_relay(message)
@Cog.listener()
async def on_raw_reaction_remove(self, payload: RawReactionActionEvent) -> None:
@@ -160,6 +184,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 ce95450e0..b9d235fa2 100644
--- a/bot/cogs/off_topic_names.py
+++ b/bot/exts/fun/off_topic_names.py
@@ -4,13 +4,12 @@ import logging
from datetime import datetime, timedelta
from discord import Colour, Embed
-from discord.ext.commands import Cog, Context, group
+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, MODERATION_ROLES
from bot.converters import OffTopicName
-from bot.decorators import with_role
from bot.pagination import LinePaginator
CHANNELS = (Channels.off_topic_0, Channels.off_topic_1, Channels.off_topic_2)
@@ -67,13 +66,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.
@@ -96,7 +95,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)
@@ -109,7 +108,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}')
@@ -118,7 +117,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.
@@ -138,7 +137,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 0f9cac89e..9e33a6aba 100644
--- a/bot/cogs/help_channels.py
+++ b/bot/exts/help_channels.py
@@ -9,12 +9,11 @@ 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.scheduling import Scheduler
log = logging.getLogger(__name__)
@@ -196,12 +195,12 @@ class HelpChannels(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:
diff --git a/bot/exts/info/__init__.py b/bot/exts/info/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/bot/exts/info/__init__.py
diff --git a/bot/cogs/doc.py b/bot/exts/info/doc.py
index 30c793c75..e50b9b32b 100644
--- a/bot/cogs/doc.py
+++ b/bot/exts/info/doc.py
@@ -21,7 +21,6 @@ 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
@@ -396,7 +395,7 @@ class Doc(commands.Cog):
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
@@ -433,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.
@@ -450,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 99d503f5c..99d503f5c 100644
--- a/bot/cogs/help.py
+++ b/bot/exts/info/help.py
diff --git a/bot/cogs/information.py b/bot/exts/info/information.py
index 55ecb2836..156dfec35 100644
--- a/bot/cogs/information.py
+++ b/bot/exts/info/information.py
@@ -8,18 +8,19 @@ from typing import Any, Mapping, Optional, Tuple, Union
from discord import ChannelType, Colour, CustomActivity, Embed, Guild, Member, Message, Role, Status, utils
from discord.abc import GuildChannel
-from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group
+from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group, has_any_role
from discord.utils import escape_markdown
from bot import constants
from bot.bot import Bot
-from bot.decorators import in_whitelist, with_role
+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.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,
@@ -76,7 +77,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.MODERATION_ROLES)
@command(name="roles")
async def roles_info(self, ctx: Context) -> None:
"""Returns a list of all roles and their corresponding IDs."""
@@ -96,7 +97,7 @@ class Information(Cog):
await LinePaginator.paginate(role_list, ctx, embed, empty=False)
- @with_role(*constants.MODERATION_ROLES)
+ @has_any_role(*constants.MODERATION_ROLES)
@command(name="role")
async def role_info(self, ctx: Context, *roles: Union[Role, str]) -> None:
"""
@@ -197,18 +198,14 @@ class Information(Cog):
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:
"""Creates an embed containing information on the `user`."""
@@ -277,8 +274,14 @@ class Information(Cog):
)
]
+ # Use getattr to future-proof for commands invoked via DMs.
+ show_verbose = (
+ ctx.channel.id in constants.MODERATION_CHANNELS
+ or getattr(ctx.channel, "category_id", None) == constants.Categories.modmail
+ )
+
# Show more verbose output in moderation channels for infractions and nominations
- if ctx.channel.id in constants.MODERATION_CHANNELS:
+ if show_verbose:
fields.append(await self.expanded_user_infraction_counts(user))
fields.append(await self.user_nomination_counts(user))
else:
diff --git a/bot/cogs/python_news.py b/bot/exts/info/python_news.py
index 0ab5738a4..0ab5738a4 100644
--- a/bot/cogs/python_news.py
+++ b/bot/exts/info/python_news.py
diff --git a/bot/cogs/reddit.py b/bot/exts/info/reddit.py
index 5d9e2c20b..635162308 100644
--- a/bot/cogs/reddit.py
+++ b/bot/exts/info/reddit.py
@@ -8,14 +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
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
@@ -282,7 +281,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 2d3a3d9f3..2d3a3d9f3 100644
--- a/bot/cogs/site.py
+++ b/bot/exts/info/site.py
diff --git a/bot/cogs/source.py b/bot/exts/info/source.py
index 205e0ba81..205e0ba81 100644
--- a/bot/cogs/source.py
+++ b/bot/exts/info/source.py
diff --git a/bot/cogs/stats.py b/bot/exts/info/stats.py
index d42f55466..d42f55466 100644
--- a/bot/cogs/stats.py
+++ b/bot/exts/info/stats.py
diff --git a/bot/cogs/tags.py b/bot/exts/info/tags.py
index d01647312..d01647312 100644
--- a/bot/cogs/tags.py
+++ b/bot/exts/info/tags.py
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 9087ac454..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, MODERATION_ROLES, Roles
-from bot.decorators import with_role
+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(*MODERATION_ROLES)
+ @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)
@@ -163,7 +163,7 @@ class Defcon(Cog):
self.bot.stats.gauge("defcon.threshold", days)
@defcon_group.command(name='enable', aliases=('on', 'e'), root_aliases=("defon",))
- @with_role(*MODERATION_ROLES)
+ @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!
@@ -176,7 +176,7 @@ class Defcon(Cog):
await self.update_channel_topic()
@defcon_group.command(name='disable', aliases=('off', 'd'), root_aliases=("defoff",))
- @with_role(*MODERATION_ROLES)
+ @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(*MODERATION_ROLES)
+ @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(*MODERATION_ROLES)
+ @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/cogs/dm_relay.py b/bot/exts/moderation/dm_relay.py
index 0d8f340b4..14263e004 100644
--- a/bot/cogs/dm_relay.py
+++ b/bot/exts/moderation/dm_relay.py
@@ -2,6 +2,7 @@ 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
@@ -9,8 +10,7 @@ from discord.ext.commands import Cog
from bot import constants
from bot.bot import Bot
from bot.converters import UserMentionOrID
-from bot.utils import RedisCache
-from bot.utils.checks import in_whitelist_check, with_role_check
+from bot.utils.checks import in_whitelist_check
from bot.utils.messages import send_attachments
from bot.utils.webhooks import send_webhook
@@ -105,10 +105,10 @@ class DMRelay(Cog):
except discord.HTTPException:
log.exception("Failed to send an attachment to the webhook")
- def cog_check(self, ctx: commands.Context) -> bool:
+ async def cog_check(self, ctx: commands.Context) -> bool:
"""Only allow moderators to invoke the commands in this cog."""
checks = [
- with_role_check(ctx, *constants.MODERATION_ROLES),
+ await commands.has_any_role(*constants.MODERATION_ROLES).predicate(ctx),
in_whitelist_check(
ctx,
channels=[constants.Channels.dm_log],
diff --git a/bot/cogs/moderation/incidents.py b/bot/exts/moderation/incidents.py
index 3605ab1d2..e49913552 100644
--- a/bot/cogs/moderation/incidents.py
+++ b/bot/exts/moderation/incidents.py
@@ -405,3 +405,8 @@ class Incidents(Cog):
"""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 051f6c52c..814b17830 100644
--- a/bot/cogs/moderation/scheduler.py
+++ b/bot/exts/moderation/infraction/_scheduler.py
@@ -12,12 +12,11 @@ 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, MODERATION_CHANNELS
+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
log = logging.getLogger(__name__)
@@ -27,7 +26,7 @@ class InfractionScheduler:
def __init__(self, bot: Bot, supported_infractions: t.Container[str]):
self.bot = bot
- self.scheduler = Scheduler(self.__class__.__name__)
+ self.scheduler = scheduling.Scheduler(self.__class__.__name__)
self.bot.loop.create_task(self.reschedule_infractions(supported_infractions))
@@ -56,7 +55,7 @@ class InfractionScheduler:
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."""
@@ -80,13 +79,13 @@ class InfractionScheduler:
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']
@@ -126,7 +125,7 @@ class InfractionScheduler:
log.error(f"Failed to DM {user.id}: could not fetch user (status {e.status})")
else:
# Accordingly display whether the user was successfully notified via DM.
- if await utils.notify_infraction(user, infr_type, expiry, reason, icon):
+ if await _utils.notify_infraction(user, infr_type, expiry, reason, icon):
dm_result = ":incoming_envelope: "
dm_log_text = "\nDM: Sent"
@@ -137,9 +136,9 @@ class InfractionScheduler:
)
if reason:
end_msg = f" (reason: {textwrap.shorten(reason, width=1500, placeholder='...')})"
- elif ctx.channel.id not in STAFF_CHANNELS:
+ elif ctx.channel.id not in MODERATION_CHANNELS:
log.trace(
- f"Infraction #{id_} context is not in a staff channel; omitting infraction count."
+ f"Infraction #{id_} context is not in a mod channel; omitting infraction count."
)
else:
log.trace(f"Fetching total infraction count for {user}.")
@@ -199,8 +198,8 @@ class InfractionScheduler:
title=f"Infraction {log_title}: {infr_type}",
thumbnail=user.avatar_url_as(static_format="png"),
text=textwrap.dedent(f"""
- Member: {user.mention} (`{user.id}`)
- Actor: {ctx.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,
@@ -243,48 +242,12 @@ class InfractionScheduler:
# 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.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.scheduler.cancel(infraction["id"])
-
# Accordingly display whether the user was successfully notified via DM.
dm_emoji = ""
if log_text.get("DM") == "Sent":
@@ -318,7 +281,7 @@ class InfractionScheduler:
# 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}",
thumbnail=user.avatar_url_as(static_format="png"),
@@ -329,7 +292,7 @@ class InfractionScheduler:
async def deactivate_infraction(
self,
- infraction: utils.Infraction,
+ infraction: _utils.Infraction,
send_log: bool = True
) -> t.Dict[str, str]:
"""
@@ -358,7 +321,7 @@ class InfractionScheduler:
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,
}
@@ -434,7 +397,7 @@ class InfractionScheduler:
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,
@@ -446,7 +409,7 @@ class InfractionScheduler:
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.
@@ -454,7 +417,7 @@ class InfractionScheduler:
"""
raise NotImplementedError
- def schedule_expiration(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.
diff --git a/bot/cogs/moderation/utils.py b/bot/exts/moderation/infraction/_utils.py
index f21272102..1d91964f1 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
@@ -28,6 +27,18 @@ 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]:
"""
@@ -142,25 +153,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.capitalize(),
+ 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 8df642428..ef6f6e3c6 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__)
@@ -55,7 +55,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
@@ -125,7 +125,7 @@ class Infractions(InfractionScheduler, commands.Cog):
@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
@@ -213,10 +213,10 @@ class Infractions(InfractionScheduler, commands.Cog):
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
@@ -233,7 +233,7 @@ class Infractions(InfractionScheduler, commands.Cog):
@respect_role_hierarchy()
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
@@ -254,7 +254,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 +269,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
@@ -309,14 +309,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 +339,7 @@ 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_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.
@@ -357,9 +357,9 @@ class Infractions(InfractionScheduler, 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 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 +368,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 672bb0e9c..de4fb4175 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.checks import in_whitelist_check
log = logging.getLogger(__name__)
@@ -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,10 +176,10 @@ 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 ctx.invoke(self.search_user, discord.Object(query))
else:
await ctx.invoke(self.search_reason, query)
@@ -191,9 +187,16 @@ class ModManagement(commands.Cog):
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 = 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,10 +291,10 @@ 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),
+ await commands.has_any_role(*constants.MODERATION_ROLES).predicate(ctx),
in_whitelist_check(
ctx,
channels=constants.MODERATION_CHANNELS,
@@ -303,3 +312,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 867de815a..eec63f5b3 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,14 @@ 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)
+ 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"])
@@ -148,12 +148,15 @@ class Superstarify(InfractionScheduler, Cog):
await member.edit(nick=forced_nick, reason=reason)
self.schedule_expiration(infraction)
+ old_nick = escape_markdown(member.display_name)
+ 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 +179,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 +199,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 +216,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 +237,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 5f30d3744..41ed46b69 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__)
@@ -396,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
)
@@ -407,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}"
@@ -434,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
)
@@ -452,10 +449,9 @@ 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
)
@@ -515,8 +511,7 @@ 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(
icon_url=Icons.user_update,
@@ -549,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"
@@ -645,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}"
@@ -679,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"
@@ -731,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"
@@ -744,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"
@@ -822,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,
@@ -834,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/cogs/moderation/silence.py b/bot/exts/moderation/silence.py
index f8a6592bc..ac0c1c85e 100644
--- a/bot/cogs/moderation/silence.py
+++ b/bot/exts/moderation/silence.py
@@ -10,7 +10,6 @@ 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__)
@@ -160,6 +159,11 @@ class Silence(commands.Cog):
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:
+ async def cog_check(self, ctx: Context) -> bool:
"""Only allow moderators to invoke the commands in this cog."""
- return with_role_check(ctx, *MODERATION_ROLES)
+ 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/cogs/moderation/slowmode.py b/bot/exts/moderation/slowmode.py
index 1d055afac..efd862aa5 100644
--- a/bot/cogs/moderation/slowmode.py
+++ b/bot/exts/moderation/slowmode.py
@@ -4,12 +4,11 @@ from typing import Optional
from dateutil.relativedelta import relativedelta
from discord import TextChannel
-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 Emojis, MODERATION_ROLES
from bot.converters import DurationDelta
-from bot.decorators import with_role_check
from bot.utils import time
log = logging.getLogger(__name__)
@@ -87,9 +86,9 @@ class Slowmode(Cog):
f'{Emojis.check_mark} The slowmode delay for {channel.mention} has been reset to 0 seconds.'
)
- 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, *MODERATION_ROLES)
+ return await has_any_role(*MODERATION_ROLES).predicate(ctx)
def setup(bot: Bot) -> None:
diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py
new file mode 100644
index 000000000..210c7a1af
--- /dev/null
+++ b/bot/exts/moderation/verification.py
@@ -0,0 +1,753 @@
+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.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"""
+Hello! Welcome to Python Discord!
+
+As a new user, you have read-only access to a few select channels to give you a taste of what our server is like.
+
+In order to see the rest of the channels and to send messages, you first have to accept our rules. To do so, \
+please visit <#{constants.Channels.verification}>. Thank you!
+"""
+
+# 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}>.
+"""
+
+# 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
+
+
+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()
+
+ 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.info(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 _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 member.send(KICKED_MESSAGE)
+ except discord.Forbidden as exc_403:
+ log.trace(f"DM dispatch failed on 403 error with code: {exc_403.code}")
+ if exc_403.code != 50_007: # 403 raised for any other reason than disabled DMs
+ raise StopExecution(reason=exc_403)
+ await member.kick(reason=f"User has not verified in {constants.Verification.kicked_after} days")
+
+ 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
+
+ log.trace(f"Sending on join message to new member: {member.id}")
+ with suppress(discord.Forbidden):
+ await member.send(ON_JOIN_MESSAGE)
+
+ @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 ctx.author.send(VERIFIED_MESSAGE)
+ except discord.Forbidden:
+ log.info(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/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 a58b604c0..7118dee02 100644
--- a/bot/cogs/watchchannels/watchchannel.py
+++ b/bot/exts/moderation/watchchannels/_watchchannel.py
@@ -14,10 +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.cogs.token_remover import TokenRemover
-from bot.cogs.webhook_remover import WEBHOOK_URL_RE
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
diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/exts/moderation/watchchannels/bigbrother.py
index 11ab8917a..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,13 +27,13 @@ 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)
+ @has_any_role(*MODERATION_ROLES)
async def watched_command(
self, ctx: Context, oldest_first: bool = False, update_cache: bool = True
) -> None:
@@ -49,7 +48,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"):
await self.list_watched_users(ctx, oldest_first=oldest_first, update_cache=update_cache)
@bigbrother_group.command(name='oldest')
- @with_role(*MODERATION_ROLES)
+ @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.
@@ -60,7 +59,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"):
await ctx.invoke(self.watched_command, oldest_first=True, update_cache=update_cache)
@bigbrother_group.command(name='watch', aliases=('w',), root_aliases=('watch',))
- @with_role(*MODERATION_ROLES)
+ @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.
@@ -71,7 +70,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"):
await self.apply_watch(ctx, user, reason)
@bigbrother_group.command(name='unwatch', aliases=('uw',), root_aliases=('unwatch',))
- @with_role(*MODERATION_ROLES)
+ @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)
@@ -163,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 76d6fe9bd..a77dbe156 100644
--- a/bot/cogs/watchchannels/talentpool.py
+++ b/bot/exts/moderation/watchchannels/talentpool.py
@@ -4,16 +4,15 @@ from collections import ChainMap
from typing import Union
from discord import Color, Embed, Member, User
-from discord.ext.commands import Cog, Context, group
+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__)
@@ -32,13 +31,13 @@ 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'), root_aliases=("nominees",))
- @with_role(*MODERATION_ROLES)
+ @has_any_role(*MODERATION_ROLES)
async def watched_command(
self, ctx: Context, oldest_first: bool = False, update_cache: bool = True
) -> None:
@@ -53,7 +52,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
await self.list_watched_users(ctx, oldest_first=oldest_first, update_cache=update_cache)
@nomination_group.command(name='oldest')
- @with_role(*MODERATION_ROLES)
+ @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.
@@ -64,7 +63,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
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",))
- @with_role(*STAFF_ROLES)
+ @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.
@@ -129,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(
@@ -158,7 +157,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
)
@nomination_group.command(name='unwatch', aliases=('end', ), root_aliases=("unnominate",))
- @with_role(*MODERATION_ROLES)
+ @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.
@@ -171,13 +170,13 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
await ctx.send(":x: The specified user does not have an active nomination")
@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.
@@ -278,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/cogs/bot.py b/bot/exts/utils/bot.py
index ddd1cef8d..7ed487d47 100644
--- a/bot/cogs/bot.py
+++ b/bot/exts/utils/bot.py
@@ -5,13 +5,12 @@ import time
from typing import Optional, Tuple
from discord import Embed, Message, RawMessageUpdateEvent, TextChannel
-from discord.ext.commands import Cog, Context, command, group
+from discord.ext.commands import Cog, Context, command, group, has_any_role
from bot.bot import Bot
-from bot.cogs.token_remover import TokenRemover
-from bot.cogs.webhook_remover import WEBHOOK_URL_RE
from bot.constants import Categories, Channels, DEBUG_MODE, Guild, MODERATION_ROLES, Roles, URLs
-from bot.decorators import with_role
+from bot.exts.filters.token_remover import TokenRemover
+from bot.exts.filters.webhook_remover import WEBHOOK_URL_RE
from bot.utils.messages import wait_for_deletion
log = logging.getLogger(__name__)
@@ -39,13 +38,13 @@ class BotCog(Cog, name="Bot"):
self.codeblock_message_ids = {}
@group(invoke_without_command=True, name="bot", hidden=True)
- @with_role(Roles.verified)
+ @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)
- @with_role(Roles.verified)
+ @has_any_role(Roles.verified)
async def about_command(self, ctx: Context) -> None:
"""Get information about the bot."""
embed = Embed(
@@ -63,7 +62,7 @@ class BotCog(Cog, name="Bot"):
await ctx.send(embed=embed)
@command(name='echo', aliases=('print',))
- @with_role(*MODERATION_ROLES)
+ @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:
@@ -72,7 +71,7 @@ class BotCog(Cog, name="Bot"):
await channel.send(text)
@command(name='embed')
- @with_role(*MODERATION_ROLES)
+ @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)
diff --git a/bot/cogs/clean.py b/bot/exts/utils/clean.py
index f436e531a..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__)
@@ -179,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})."
)
@@ -192,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,
@@ -210,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,
@@ -221,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,
@@ -232,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,
@@ -244,7 +244,7 @@ class Clean(Cog):
await self._clean_messages(amount, ctx, regex=regex, channels=channels)
@clean_group.command(name="message", aliases=["messages"])
- @with_role(*MODERATION_ROLES)
+ @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(
@@ -255,7 +255,7 @@ class Clean(Cog):
)
@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/eval.py b/bot/exts/utils/eval.py
index eb8bfb1cf..6419b320e 100644
--- a/bot/cogs/eval.py
+++ b/bot/exts/utils/eval.py
@@ -9,12 +9,12 @@ 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__)
@@ -171,17 +171,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)
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("`")
diff --git a/bot/cogs/extensions.py b/bot/exts/utils/extensions.py
index 396e406b0..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}`.")
@@ -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/jams.py b/bot/exts/utils/jams.py
index b3102db2f..1c0988343 100644
--- a/bot/cogs/jams.py
+++ b/bot/exts/utils/jams.py
@@ -7,7 +7,6 @@ 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__)
@@ -22,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.
diff --git a/bot/exts/utils/ping.py b/bot/exts/utils/ping.py
new file mode 100644
index 000000000..a9ca3dbeb
--- /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 08bce2153..6806f2889 100644
--- a/bot/cogs/reminders.py
+++ b/bot/exts/utils/reminders.py
@@ -15,7 +15,7 @@ from bot.bot import Bot
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 with_role_check, without_role_check
+from bot.utils.checks import has_any_role_check, has_no_roles_check
from bot.utils.messages import send_denial
from bot.utils.scheduling import Scheduler
from bot.utils.time import humanize_delta
@@ -117,9 +117,9 @@ class Reminders(Cog):
If mentions aren't allowed, also return the type of mention(s) disallowed.
"""
- if without_role_check(ctx, *STAFF_ROLES):
+ if await has_no_roles_check(ctx, *STAFF_ROLES):
return False, "members/roles"
- elif without_role_check(ctx, *MODERATION_ROLES):
+ elif await has_no_roles_check(ctx, *MODERATION_ROLES):
return all(isinstance(mention, discord.Member) for mention in mentions), "roles"
else:
return True, ""
@@ -240,7 +240,7 @@ class Reminders(Cog):
Expiration is parsed per: http://strftime.org/
"""
# 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:
@@ -431,7 +431,7 @@ class Reminders(Cog):
The check passes when the user is an admin, or if they created the reminder.
"""
- if with_role_check(ctx, Roles.admins):
+ if await has_any_role_check(ctx, Roles.admins):
return True
api_response = await self.bot.api_client.get(f"bot/reminders/{reminder_id}")
diff --git a/bot/cogs/snekbox.py b/bot/exts/utils/snekbox.py
index 63e6d7f31..b3baffba2 100644
--- a/bot/cogs/snekbox.py
+++ b/bot/exts/utils/snekbox.py
@@ -14,6 +14,7 @@ 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__)
@@ -71,17 +72,7 @@ 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:
@@ -159,6 +150,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
diff --git a/bot/cogs/utils.py b/bot/exts/utils/utils.py
index d96abbd5a..6b6941064 100644
--- a/bot/cogs/utils.py
+++ b/bot/exts/utils/utils.py
@@ -7,11 +7,11 @@ from io import StringIO
from typing import Tuple, Union
from discord import Colour, Embed, utils
-from discord.ext.commands import BadArgument, Cog, Context, clean_content, 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
@@ -224,7 +224,7 @@ class Utils(Cog):
await ctx.send(embed=embed)
@command(aliases=("poll",))
- @with_role(*MODERATION_ROLES)
+ @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.
diff --git a/bot/rules/__init__.py b/bot/rules/__init__.py
index 8a69cadee..a01ceae73 100644
--- a/bot/rules/__init__.py
+++ b/bot/rules/__init__.py
@@ -10,4 +10,3 @@ from .links import apply as apply_links
from .mentions import apply as apply_mentions
from .newlines import apply as apply_newlines
from .role_mentions import apply as apply_role_mentions
-from .everyone_ping import apply as apply_everyone_ping
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/everyone_ping.py b/bot/rules/everyone_ping.py
deleted file mode 100644
index 89d9fe570..000000000
--- a/bot/rules/everyone_ping.py
+++ /dev/null
@@ -1,41 +0,0 @@
-import random
-import re
-from typing import Dict, Iterable, List, Optional, Tuple
-
-from discord import Embed, Member, Message
-
-from bot.constants import Colours, Guild, NEGATIVE_REPLIES
-
-# Generate regex for checking for pings:
-guild_id = Guild.id
-EVERYONE_RE_INLINE_CODE = re.compile(rf"^(?!`).*@everyone.*(?!`)$|^(?!`).*<@&{guild_id}>.*(?!`)$")
-EVERYONE_RE_MULTILINE_CODE = re.compile(rf"^(?!```).*@everyone.*(?!```)$|^(?!```).*<@&{guild_id}>.*(?!```)$")
-
-
-async def apply(
- last_message: Message,
- recent_messages: List[Message],
- config: Dict[str, int],
-) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]:
- """Detects if a user has sent an '@everyone' ping."""
- relevant_messages = tuple(msg for msg in recent_messages if msg.author == last_message.author)
-
- everyone_messages_count = 0
- for msg in relevant_messages:
- num_everyone_pings_inline = len(re.findall(EVERYONE_RE_INLINE_CODE, msg.content))
- num_everyone_pings_multiline = len(re.findall(EVERYONE_RE_MULTILINE_CODE, msg.content))
- if num_everyone_pings_inline and num_everyone_pings_multiline:
- everyone_messages_count += 1
-
- if everyone_messages_count > config["max"]:
- # Send the channel an embed giving the user more info:
- embed_text = f"Please don't try to ping {last_message.guild.member_count:,} people."
- embed = Embed(title=random.choice(NEGATIVE_REPLIES), description=embed_text, colour=Colours.soft_red)
- await last_message.channel.send(embed=embed)
-
- return (
- "pinged the everyone role",
- (last_message.author,),
- relevant_messages,
- )
- return None
diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py
index 5a6e1811b..60170a88f 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, 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', 'pad_base64', 'send_to_paste_service']
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/helpers.py b/bot/utils/helpers.py
new file mode 100644
index 000000000..d9b60af07
--- /dev/null
+++ b/bot/utils/helpers.py
@@ -0,0 +1,23 @@
+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 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/messages.py b/bot/utils/messages.py
index aa8f17f75..74956ed24 100644
--- a/bot/utils/messages.py
+++ b/bot/utils/messages.py
@@ -6,10 +6,10 @@ import re
from io import BytesIO
from typing import List, Optional, Sequence, Union
-from discord import Client, Colour, 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 discord.utils import escape_markdown
from bot.constants import Emojis, NEGATIVE_REPLIES
@@ -17,9 +17,9 @@ log = logging.getLogger(__name__)
async def wait_for_deletion(
- message: Message,
- user_ids: Sequence[Snowflake],
- client: Client,
+ 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,
@@ -37,7 +37,7 @@ async def wait_for_deletion(
for emoji in deletion_emojis:
await message.add_reaction(emoji)
- 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
@@ -51,8 +51,8 @@ async def wait_for_deletion(
async def send_attachments(
- message: Message,
- destination: Union[TextChannel, Webhook],
+ message: discord.Message,
+ destination: Union[discord.TextChannel, discord.Webhook],
link_large: bool = True
) -> List[str]:
"""
@@ -76,9 +76,9 @@ async def send_attachments(
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)
+ attachment_file = discord.File(file, filename=attachment.filename)
- if isinstance(destination, TextChannel):
+ if isinstance(destination, discord.TextChannel):
msg = await destination.send(file=attachment_file)
urls.append(msg.attachments[0].url)
else:
@@ -99,10 +99,10 @@ 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):
+ if isinstance(destination, discord.TextChannel):
await destination.send(embed=embed)
else:
await destination.send(
@@ -133,9 +133,15 @@ def sub_clyde(username: Optional[str]) -> Optional[str]:
async def send_denial(ctx: Context, reason: str) -> None:
"""Send an embed denying the user with the given reason."""
- embed = Embed()
- embed.colour = Colour.red()
+ 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 name#discriminator."""
+ name = escape_markdown(str(user))
+ return f"{user.mention} ({name})"
diff --git a/bot/utils/redis_cache.py b/bot/utils/redis_cache.py
deleted file mode 100644
index 52b689b49..000000000
--- a/bot/utils/redis_cache.py
+++ /dev/null
@@ -1,414 +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
- 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.bot.redis_session.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.bot.redis_session.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.bot.redis_session.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.bot.redis_session.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.bot.redis_session.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.bot.redis_session.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.bot.redis_session.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.bot.redis_session.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/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/config-default.yml b/config-default.yml
index 6e7cff92d..e7669e6db 100644
--- a/config-default.yml
+++ b/config-default.yml
@@ -62,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"
@@ -134,6 +121,7 @@ style:
guild:
id: 267624335836053506
+ invite: "https://discord.gg/python"
categories:
help_available: 691405807388196926
@@ -142,9 +130,14 @@ guild:
modmail: 714494672835444826
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
@@ -175,7 +168,6 @@ guild:
# Special
bot_commands: &BOT_CMD 267659945086812160
esoteric: 470884583684964352
- reddit: 458224812528238616
verification: 352442727016693763
# Staff
@@ -190,6 +182,12 @@ guild:
mod_spam: &MOD_SPAM 620607373828030464
organisation: &ORGANISATION 551789653284356126
staff_lounge: &STAFF_LOUNGE 464905259261755392
+ duck_pond: &DUCK_POND 637820308341915648
+
+ # Staff announcement channels
+ staff_announcements: &STAFF_ANNOUNCEMENTS 464033278631084042
+ mod_announcements: &MOD_ANNOUNCEMENTS 372115205867700225
+ admin_announcements: &ADMIN_ANNOUNCEMENTS 749736155569848370
# Voice
admins_voice: &ADMINS_VOICE 500734494840717332
@@ -199,15 +197,6 @@ guild:
big_brother_logs: &BB_LOGS 468507907357409333
talent_pool: &TALENT_POOL 534321732593647616
- staff_channels:
- - *ADMINS
- - *ADMIN_SPAM
- - *DEFCON
- - *HELPERS
- - *MODS
- - *MOD_SPAM
- - *ORGANISATION
-
moderation_channels:
- *ADMINS
- *ADMIN_SPAM
@@ -236,8 +225,8 @@ guild:
partners: 323426753857191936
python_community: &PY_COMMUNITY_ROLE 458226413825294336
- # This is the Developers role on PyDis, here named verified for readability reasons
- verified: 352427296948486144
+ unverified: 739794855945044069
+ verified: 352427296948486144 # @Developers on PyDis
# Staff
admins: &ADMINS_ROLE 267628507062992896
@@ -273,17 +262,19 @@ guild:
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
@@ -389,12 +380,6 @@ anti_spam:
interval: 10
max: 3
- # The everyone ping filter is temporarily disabled
- # until we've fixed a couple of bugs.
- # everyone_ping:
- # interval: 10
- # max: 0
-
reddit:
subreddits:
@@ -465,21 +450,19 @@ sync:
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:
@@ -489,5 +472,18 @@ python_news:
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%
+
+
config:
required_keys: ['bot.token']
diff --git a/tests/bot/cogs/test_duck_pond.py b/tests/bot/cogs/test_duck_pond.py
deleted file mode 100644
index cfe10aebf..000000000
--- a/tests/bot/cogs/test_duck_pond.py
+++ /dev/null
@@ -1,548 +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 _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}.send_webhook"
- send_attachments_path = f"{MODULE_PATH}.send_attachments"
- author = MagicMock(
- display_name="x",
- avatar_url="https://"
- )
-
- self.cog.webhook = helpers.MockAsyncWebhook()
-
- test_values = (
- (helpers.MockMessage(author=author, clean_content="", attachments=[]), False, False),
- (helpers.MockMessage(author=author, clean_content="message", attachments=[]), True, False),
- (helpers.MockMessage(author=author, clean_content="", attachments=["attachment"]), False, True),
- (helpers.MockMessage(author=author, 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}.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}.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(
- webhook=self.cog.webhook,
- 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/cogs/sync/test_base.py b/tests/bot/exts/backend/sync/test_base.py
index 70aea2bab..886c243cf 100644
--- a/tests/bot/cogs/sync/test_base.py
+++ b/tests/bot/exts/backend/sync/test_base.py
@@ -6,7 +6,7 @@ import discord
from bot import constants
from bot.api import ResponseCodeError
-from bot.cogs.sync.syncers import Syncer, _Diff
+from bot.exts.backend.sync._syncers import Syncer, _Diff
from tests import helpers
diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/exts/backend/sync/test_cog.py
index 120bc991d..1b89564f2 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)
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..c0a1da35c 100644
--- a/tests/bot/cogs/sync/test_users.py
+++ b/tests/bot/exts/backend/sync/test_users.py
@@ -1,7 +1,7 @@
import unittest
from unittest import mock
-from bot.cogs.sync.syncers import UserSyncer, _Diff, _User
+from bot.exts.backend.sync._syncers import UserSyncer, _Diff, _User
from tests import helpers
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 f50c0492d..3393c6cdc 100644
--- a/tests/bot/cogs/test_antimalware.py
+++ b/tests/bot/exts/filters/test_antimalware.py
@@ -3,8 +3,8 @@ from unittest.mock import AsyncMock, Mock
from discord import NotFound
-from bot.cogs import antimalware
from bot.constants import Channels, STAFF_ROLES
+from bot.exts.filters import antimalware
from tests.helpers import MockAttachment, MockBot, MockMessage, MockRole
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..ea822053b 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
@@ -132,7 +133,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 = ()
@@ -143,8 +144,8 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):
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")
+ @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, is_valid_id, is_valid_timestamp):
"""The first match with a valid user ID and timestamp should be returned as a `Token`."""
matches = [
@@ -167,8 +168,8 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):
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")
+ @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, is_valid_id, is_valid_timestamp):
"""None should be returned if no matches have valid user IDs or timestamps."""
token_re.finditer.return_value = [mock.create_autospec(Match, spec_set=True, instance=True)]
@@ -230,7 +231,7 @@ 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")
@@ -240,8 +241,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):
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,
@@ -249,7 +249,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):
)
@mock.patch.object(TokenRemover, "mod_log", new_callable=mock.PropertyMock)
- @autospec("bot.cogs.token_remover", "log")
+ @autospec("bot.exts.filters.token_remover", "log")
@autospec(TokenRemover, "format_log_message")
async def test_take_action(self, format_log_message, logger, mod_log_property):
"""Should delete the message and send a mod log."""
@@ -299,7 +299,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 77b0ddf17..d3f2995fb 100644
--- a/tests/bot/cogs/test_information.py
+++ b/tests/bot/exts/info/test_information.py
@@ -6,11 +6,11 @@ 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):
@@ -97,7 +97,7 @@ 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')
+ @unittest.mock.patch('bot.exts.info.information.time_since')
def test_server_info_command(self, time_since_patch):
time_since_patch.return_value = '2 days ago'
@@ -339,8 +339,8 @@ class UserInfractionHelperMethodTests(unittest.TestCase):
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])
[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.TestCase):
"""Tests for the creation of the `!user` embed."""
@@ -515,7 +515,7 @@ class UserEmbedTests(unittest.TestCase):
self.assertEqual(embed.thumbnail.url, "avatar url")
[email protected]("bot.cogs.information.constants")
[email protected]("bot.exts.info.information.constants")
class UserCommandTests(unittest.TestCase):
"""Tests for the `!user` command."""
@@ -532,10 +532,13 @@ class UserCommandTests(unittest.TestCase):
self.moderator = helpers.MockMember(id=2, name="riffautae", roles=[self.moderator_role])
self.target = helpers.MockMember(id=3, name="__fluzz__")
+ # 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)
+
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))
@@ -546,46 +549,38 @@ class UserCommandTests(unittest.TestCase):
"""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))
- @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=unittest.mock.AsyncMock)
+ @unittest.mock.patch("bot.exts.info.information.Information.create_user_embed")
def test_regular_user_may_use_command_in_bot_commands_channel(self, create_embed, constants):
"""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=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))
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")
+ 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))
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)
+ @unittest.mock.patch("bot.exts.info.information.Information.create_user_embed")
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))
@@ -593,12 +588,11 @@ class UserCommandTests(unittest.TestCase):
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)
+ @unittest.mock.patch("bot.exts.info.information.Information.create_user_embed")
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))
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/cogs/moderation/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py
index da4e92ccc..be1b649e1 100644
--- a/tests/bot/cogs/moderation/test_infractions.py
+++ b/tests/bot/exts/moderation/infraction/test_infractions.py
@@ -2,7 +2,7 @@ import textwrap
import unittest
from unittest.mock import AsyncMock, Mock, patch
-from bot.cogs.moderation.infractions import Infractions
+from bot.exts.moderation.infraction.infractions import Infractions
from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole
@@ -17,8 +17,8 @@ class TruncationTests(unittest.IsolatedAsyncioTestCase):
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")
+ @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
@@ -39,7 +39,7 @@ class TruncationTests(unittest.IsolatedAsyncioTestCase):
self.ctx, {"foo": "bar"}, self.target, self.ctx.guild.ban.return_value
)
- @patch("bot.cogs.moderation.utils.post_infraction")
+ @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"}
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/cogs/moderation/test_incidents.py b/tests/bot/exts/moderation/test_incidents.py
index 435a1cd51..cbf7f7bcf 100644
--- a/tests/bot/cogs/moderation/test_incidents.py
+++ b/tests/bot/exts/moderation/test_incidents.py
@@ -8,8 +8,8 @@ from unittest.mock import AsyncMock, MagicMock, call, patch
import aiohttp
import discord
-from bot.cogs.moderation import Incidents, incidents
from bot.constants import Colours
+from bot.exts.moderation import incidents
from tests.helpers import (
MockAsyncWebhook,
MockAttachment,
@@ -130,7 +130,7 @@ class TestMakeEmbed(unittest.IsolatedAsyncioTestCase):
incident = MockMessage(content="this is an incident", attachments=[attachment])
# Patch `download_file` to return our `file`
- with patch("bot.cogs.moderation.incidents.download_file", AsyncMock(return_value=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)
@@ -142,7 +142,7 @@ class TestMakeEmbed(unittest.IsolatedAsyncioTestCase):
incident = MockMessage(content="this is an incident", attachments=[attachment])
# Patch `download_file` to return None as if the download failed
- with patch("bot.cogs.moderation.incidents.download_file", AsyncMock(return_value=None)):
+ 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)
@@ -215,7 +215,7 @@ class TestOwnReactions(unittest.TestCase):
self.assertSetEqual(incidents.own_reactions(message), {"A", "B"})
-@patch("bot.cogs.moderation.incidents.ALL_SIGNALS", {"A", "B"})
+@patch("bot.exts.moderation.incidents.ALL_SIGNALS", {"A", "B"})
class TestHasSignals(unittest.TestCase):
"""
Assertions for the `has_signals` function.
@@ -229,7 +229,7 @@ class TestHasSignals(unittest.TestCase):
message = MockMessage()
own_reactions = MagicMock(return_value={"A", "B"})
- with patch("bot.cogs.moderation.incidents.own_reactions", own_reactions):
+ with patch("bot.exts.moderation.incidents.own_reactions", own_reactions):
self.assertTrue(incidents.has_signals(message))
def test_has_signals_false(self):
@@ -237,11 +237,11 @@ class TestHasSignals(unittest.TestCase):
message = MockMessage()
own_reactions = MagicMock(return_value={"A", "C"})
- with patch("bot.cogs.moderation.incidents.own_reactions", own_reactions):
+ with patch("bot.exts.moderation.incidents.own_reactions", own_reactions):
self.assertFalse(incidents.has_signals(message))
-@patch("bot.cogs.moderation.incidents.Signal", MockSignal)
+@patch("bot.exts.moderation.incidents.Signal", MockSignal)
class TestAddSignals(unittest.IsolatedAsyncioTestCase):
"""
Assertions for the `add_signals` coroutine.
@@ -255,19 +255,19 @@ class TestAddSignals(unittest.IsolatedAsyncioTestCase):
"""Prepare a mock incident message for tests to use."""
self.incident = MockMessage()
- @patch("bot.cogs.moderation.incidents.own_reactions", MagicMock(return_value=set()))
+ @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.cogs.moderation.incidents.own_reactions", MagicMock(return_value={"A"}))
+ @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.cogs.moderation.incidents.own_reactions", MagicMock(return_value={"A", "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)
@@ -290,7 +290,7 @@ class TestIncidents(unittest.IsolatedAsyncioTestCase):
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(MockBot())
+ self.cog_instance = incidents.Incidents(MockBot())
@patch("asyncio.sleep", AsyncMock()) # Prevent the coro from sleeping to speed up the test
@@ -326,25 +326,25 @@ class TestCrawlIncidents(TestIncidents):
await self.cog_instance.crawl_incidents()
self.cog_instance.bot.wait_until_guild_available.assert_awaited()
- @patch("bot.cogs.moderation.incidents.add_signals", AsyncMock())
- @patch("bot.cogs.moderation.incidents.is_incident", MagicMock(return_value=False)) # Message doesn't qualify
- @patch("bot.cogs.moderation.incidents.has_signals", MagicMock(return_value=False))
+ @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.cogs.moderation.incidents.add_signals", AsyncMock())
- @patch("bot.cogs.moderation.incidents.is_incident", MagicMock(return_value=True)) # Message qualifies
- @patch("bot.cogs.moderation.incidents.has_signals", MagicMock(return_value=True)) # But already has signals
+ @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.cogs.moderation.incidents.add_signals", AsyncMock())
- @patch("bot.cogs.moderation.incidents.is_incident", MagicMock(return_value=True)) # Message qualifies
- @patch("bot.cogs.moderation.incidents.has_signals", MagicMock(return_value=False)) # And doesn't have signals
+ @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()
@@ -384,7 +384,7 @@ class TestArchive(TestIncidents):
)
built_embed = MagicMock(discord.Embed, id=123) # We patch `make_embed` to return this
- with patch("bot.cogs.moderation.incidents.make_embed", AsyncMock(return_value=(built_embed, None))):
+ 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
@@ -451,8 +451,8 @@ class TestMakeConfirmationTask(TestIncidents):
self.assertFalse(created_check(payload=MagicMock(message_id=0)))
-@patch("bot.cogs.moderation.incidents.ALLOWED_ROLES", {1, 2})
-@patch("bot.cogs.moderation.incidents.Incidents.make_confirmation_task", AsyncMock()) # Generic awaitable
+@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."""
@@ -479,7 +479,7 @@ class TestProcessEvent(TestIncidents):
async def test_process_event_no_archive_on_investigating(self):
"""Message is not archived on `Signal.INVESTIGATING`."""
- with patch("bot.cogs.moderation.incidents.Incidents.archive", AsyncMock()) as mocked_archive:
+ 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(),
@@ -497,7 +497,7 @@ class TestProcessEvent(TestIncidents):
"""
incident = MockMessage()
- with patch("bot.cogs.moderation.incidents.Incidents.archive", AsyncMock(return_value=False)):
+ 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,
@@ -510,7 +510,7 @@ class TestProcessEvent(TestIncidents):
"""Task given by `Incidents.make_confirmation_task` is awaited before method exits."""
mock_task = AsyncMock()
- with patch("bot.cogs.moderation.incidents.Incidents.make_confirmation_task", mock_task):
+ 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(),
@@ -530,7 +530,7 @@ class TestProcessEvent(TestIncidents):
mock_task = AsyncMock(side_effect=asyncio.TimeoutError())
try:
- with patch("bot.cogs.moderation.incidents.Incidents.make_confirmation_task", mock_task):
+ 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(),
@@ -712,7 +712,7 @@ class TestOnRawReactionAdd(TestIncidents):
self.cog_instance.process_event = AsyncMock()
self.cog_instance.resolve_message = AsyncMock(return_value=MockMessage())
- with patch("bot.cogs.moderation.incidents.is_incident", MagicMock(return_value=False)):
+ 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()
@@ -733,7 +733,7 @@ class TestOnRawReactionAdd(TestIncidents):
self.cog_instance.process_event = AsyncMock()
self.cog_instance.resolve_message = AsyncMock(return_value=incident)
- with patch("bot.cogs.moderation.incidents.is_incident", MagicMock(return_value=True)):
+ 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(
@@ -751,20 +751,20 @@ class TestOnMessage(TestIncidents):
function is tested in `TestIsIncident` - here we do not worry about it.
"""
- @patch("bot.cogs.moderation.incidents.is_incident", MagicMock(return_value=True))
+ @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.cogs.moderation.incidents.add_signals", AsyncMock()) as mock_add_signals:
+ 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.cogs.moderation.incidents.is_incident", MagicMock(return_value=False))
+ @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.cogs.moderation.incidents.add_signals", AsyncMock()) as mock_add_signals:
+ 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/cogs/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py
index ab3d0742a..e2d44c637 100644
--- a/tests/bot/cogs/moderation/test_silence.py
+++ b/tests/bot/exts/moderation/test_silence.py
@@ -4,8 +4,8 @@ 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 bot.exts.moderation.silence import Silence, SilenceNotifier
from tests.helpers import MockBot, MockContext, MockTextChannel
@@ -99,7 +99,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase):
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")
+ @mock.patch("bot.exts.moderation.silence.SilenceNotifier")
async def test_instance_vars_got_notifier(self, notifier):
"""Notifier was started with channel."""
mod_log = MockTextChannel()
@@ -238,7 +238,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase):
del mock_permissions_dict['send_messages']
self.assertDictEqual(mock_permissions_dict, new_permissions)
- @mock.patch("bot.cogs.moderation.silence.asyncio")
+ @mock.patch("bot.exts.moderation.silence.asyncio")
@mock.patch.object(Silence, "_mod_alerts_channel", create=True)
def test_cog_unload_starts_task(self, alert_channel, asyncio_mock):
"""Task for sending an alert was created with present `muted_channels`."""
@@ -247,15 +247,17 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase):
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")
+ @mock.patch("bot.exts.moderation.silence.asyncio")
def test_cog_unload_skips_task_start(self, asyncio_mock):
"""No task created with no channels."""
self.cog.cog_unload()
asyncio_mock.create_task.assert_not_called()
- @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):
+ @mock.patch("discord.ext.commands.has_any_role")
+ @mock.patch("bot.exts.moderation.silence.MODERATION_ROLES", new=(1, 2, 3))
+ async def test_cog_check(self, role_check):
"""Role check is called with `MODERATION_ROLES`"""
- self.cog.cog_check(self.ctx)
- role_check.assert_called_once_with(self.ctx, *(1, 2, 3))
+ 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_slowmode.py b/tests/bot/exts/moderation/test_slowmode.py
index f442814c8..dad751e0d 100644
--- a/tests/bot/cogs/test_slowmode.py
+++ b/tests/bot/exts/moderation/test_slowmode.py
@@ -3,8 +3,8 @@ from unittest import mock
from dateutil.relativedelta import relativedelta
-from bot.cogs.moderation.slowmode import Slowmode
from bot.constants import Emojis
+from bot.exts.moderation.slowmode import Slowmode
from tests.helpers import MockBot, MockContext, MockTextChannel
@@ -103,9 +103,11 @@ class SlowmodeTests(unittest.IsolatedAsyncioTestCase):
f'{Emojis.check_mark} The slowmode delay for #meta has been reset to 0 seconds.'
)
- @mock.patch("bot.cogs.moderation.slowmode.with_role_check")
- @mock.patch("bot.cogs.moderation.slowmode.MODERATION_ROLES", new=(1, 2, 3))
- def test_cog_check(self, role_check):
+ @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`"""
- self.cog.cog_check(self.ctx)
- role_check.assert_called_once_with(self.ctx, *(1, 2, 3))
+ 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 30a04422a..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)
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/cogs/test_jams.py b/tests/bot/exts/utils/test_jams.py
index b4ad8535f..45e7b5b51 100644
--- a/tests/bot/cogs/test_jams.py
+++ b/tests/bot/exts/utils/test_jams.py
@@ -3,8 +3,8 @@ from unittest.mock import AsyncMock, MagicMock, create_autospec
from discord import CategoryChannel
-from bot.cogs import jams
from bot.constants import Roles
+from bot.exts.utils import jams
from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole, MockTextChannel
diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/exts/utils/test_snekbox.py
index 343e37db9..40b2202aa 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,43 +38,14 @@ 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'),
@@ -99,14 +69,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 +117,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'),
@@ -296,7 +266,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):
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()))
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..e47fdf28f 100644
--- a/tests/helpers.py
+++ b/tests/helpers.py
@@ -308,7 +308,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: