diff options
Diffstat (limited to '')
| -rw-r--r-- | Pipfile | 3 | ||||
| -rw-r--r-- | Pipfile.lock | 251 | ||||
| -rw-r--r-- | bot/__main__.py | 73 | ||||
| -rw-r--r-- | bot/bot.py | 49 | ||||
| -rw-r--r-- | bot/cogs/moderation/__init__.py | 19 | ||||
| -rw-r--r-- | bot/cogs/sync/__init__.py | 7 | ||||
| -rw-r--r-- | bot/cogs/verification.py | 191 | ||||
| -rw-r--r-- | bot/cogs/watchchannels/__init__.py | 9 | ||||
| -rw-r--r-- | bot/constants.py | 37 | ||||
| -rw-r--r-- | bot/decorators.py | 29 | ||||
| -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__.py | 8 | ||||
| -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) | 5 | ||||
| -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) | 42 | ||||
| -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) | 2 | ||||
| -rw-r--r-- | bot/exts/filters/webhook_remover.py (renamed from bot/cogs/webhook_remover.py) | 2 | ||||
| -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__.py | 0 | ||||
| -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) | 15 | ||||
| -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__.py | 0 | ||||
| -rw-r--r-- | bot/exts/moderation/defcon.py (renamed from bot/cogs/defcon.py) | 15 | ||||
| -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__.py | 0 | ||||
| -rw-r--r-- | bot/exts/moderation/infraction/_scheduler.py (renamed from bot/cogs/moderation/scheduler.py) | 24 | ||||
| -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) | 36 | ||||
| -rw-r--r-- | bot/exts/moderation/infraction/management.py (renamed from bot/cogs/moderation/management.py) | 21 | ||||
| -rw-r--r-- | bot/exts/moderation/infraction/superstarify.py (renamed from bot/cogs/moderation/superstarify.py) | 36 | ||||
| -rw-r--r-- | bot/exts/moderation/modlog.py (renamed from bot/cogs/moderation/modlog.py) | 5 | ||||
| -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.py | 752 | ||||
| -rw-r--r-- | bot/exts/moderation/watchchannels/__init__.py | 0 | ||||
| -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__.py | 0 | ||||
| -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) | 19 | ||||
| -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.py | 59 | ||||
| -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__.py | 1 | ||||
| -rw-r--r-- | bot/rules/burst_shared.py | 11 | ||||
| -rw-r--r-- | bot/rules/everyone_ping.py | 41 | ||||
| -rw-r--r-- | bot/utils/__init__.py | 20 | ||||
| -rw-r--r-- | bot/utils/checks.py | 49 | ||||
| -rw-r--r-- | bot/utils/extensions.py | 34 | ||||
| -rw-r--r-- | bot/utils/helpers.py | 23 | ||||
| -rw-r--r-- | bot/utils/redis_cache.py | 414 | ||||
| -rw-r--r-- | bot/utils/services.py | 54 | ||||
| -rw-r--r-- | config-default.yml | 109 | ||||
| -rw-r--r-- | tests/bot/cogs/test_duck_pond.py | 548 | ||||
| -rw-r--r-- | tests/bot/exts/__init__.py | 0 | ||||
| -rw-r--r-- | tests/bot/exts/backend/__init__.py | 0 | ||||
| -rw-r--r-- | tests/bot/exts/backend/sync/__init__.py | 0 | ||||
| -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__.py | 0 | ||||
| -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) | 22 | ||||
| -rw-r--r-- | tests/bot/exts/info/__init__.py | 0 | ||||
| -rw-r--r-- | tests/bot/exts/info/test_information.py (renamed from tests/bot/cogs/test_information.py) | 20 | ||||
| -rw-r--r-- | tests/bot/exts/moderation/__init__.py | 0 | ||||
| -rw-r--r-- | tests/bot/exts/moderation/infraction/__init__.py | 0 | ||||
| -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.py | 359 | ||||
| -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__.py | 0 | ||||
| -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.py | 44 | ||||
| -rw-r--r-- | tests/bot/utils/test_redis_cache.py | 265 | ||||
| -rw-r--r-- | tests/bot/utils/test_services.py | 74 | ||||
| -rw-r--r-- | tests/helpers.py | 6 | 
111 files changed, 2207 insertions, 2227 deletions
| @@ -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 3129354d3..b798f42aa 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,6 +464,7 @@ 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] @@ -571,6 +568,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 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 e2013dafd..e2013dafd 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..f2a2689e1 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,6 +18,7 @@ from bot.constants import (      STAFF_ROLES,  )  from bot.converters import Duration +from bot.exts.moderation.modlog import ModLog  from bot.utils.messages import send_attachments @@ -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,  } 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..b7eb41244 100644 --- a/bot/cogs/filtering.py +++ b/bot/exts/filters/filtering.py @@ -6,6 +6,7 @@ from typing import List, Mapping, Optional, Tuple, 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,23 @@ 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.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]") @@ -82,6 +88,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, @@ -332,6 +351,9 @@ class Filtering(Cog):                          log.debug(message) +                        # Allow specific filters to override ping_everyone +                        ping_everyone = Filter.ping_everyone and _filter.get("ping_everyone", True) +                          # Send pretty mod log embed to mod-alerts                          await self.mod_log.send_log_message(                              icon_url=Icons.filtering, @@ -340,7 +362,7 @@ class Filtering(Cog):                              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, +                            ping_everyone=ping_everyone if not is_private else False,                              additional_embeds=additional_embeds,                              additional_embeds_msg=additional_embeds_msg                          ) @@ -528,6 +550,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..0eda3dc6a 100644 --- a/bot/cogs/token_remover.py +++ b/bot/exts/filters/token_remover.py @@ -9,8 +9,8 @@ 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  log = logging.getLogger(__name__) diff --git a/bot/cogs/webhook_remover.py b/bot/exts/filters/webhook_remover.py index 5812da87c..ca126ebf5 100644 --- a/bot/cogs/webhook_remover.py +++ b/bot/exts/filters/webhook_remover.py @@ -5,8 +5,8 @@ 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  WEBHOOK_URL_RE = re.compile(r"((?:https?://)?discord(?:app)?\.com/api/webhooks/\d+/)\S+/?", re.IGNORECASE) 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..581b3a227 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 InWhitelistCheckFailure, cooldown_with_role_bypass, has_no_roles_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,12 +198,12 @@ 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 await has_no_roles_check(ctx, *constants.STAFF_ROLES):              if not ctx.channel.id == constants.Channels.bot_commands:                  raise InWhitelistCheckFailure(constants.Channels.bot_commands) 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..3bf462877 100644 --- a/bot/cogs/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -6,12 +6,11 @@ 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  log = logging.getLogger(__name__) @@ -119,7 +118,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 +162,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 +175,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 +183,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 +195,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..cf48ef2ac 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -13,11 +13,11 @@ from bot import constants  from bot.api import ResponseCodeError  from bot.bot import Bot  from bot.constants import Colours, STAFF_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 time  from bot.utils.scheduling import Scheduler -from . import utils -from .modlog import ModLog -from .utils import UserSnowflake  log = logging.getLogger(__name__) @@ -56,7 +56,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 +80,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 +126,7 @@ class InfractionScheduler:                  log.error(f"Failed to DM {user.id}: could not fetch user (status {e.status})")              else:                  # Accordingly display whether the user was successfully notified via DM. -                if await utils.notify_infraction(user, infr_type, expiry, reason, icon): +                if await _utils.notify_infraction(user, infr_type, expiry, reason, icon):                      dm_result = ":incoming_envelope: "                      dm_log_text = "\nDM: Sent" @@ -318,7 +318,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 +329,7 @@ class InfractionScheduler:      async def deactivate_infraction(          self, -        infraction: utils.Infraction, +        infraction: _utils.Infraction,          send_log: bool = True      ) -> t.Dict[str, str]:          """ @@ -434,7 +434,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 +446,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 +454,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..5fa62d3c4 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -12,10 +12,9 @@ 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  log = logging.getLogger(__name__) @@ -55,7 +54,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 +124,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 +212,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 +232,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 +253,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 +268,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,11 +308,11 @@ 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}`)" @@ -339,7 +338,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 +356,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 +367,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..15ee28537 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -10,12 +10,12 @@ from discord.ext.commands import Context  from bot import constants  from bot.bot import Bot  from bot.converters import Expiry, InfractionSearchQuery, allowed_strings, proxy_user +from bot.exts.moderation.infraction import _utils +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.checks import in_whitelist_check  log = logging.getLogger(__name__) @@ -220,7 +220,7 @@ class ModManagement(commands.Cog):          self,          ctx: Context,          embed: discord.Embed, -        infractions: t.Iterable[utils.Infraction] +        infractions: t.Iterable[_utils.Infraction]      ) -> None:          """Send a paginated embed of infractions for the specified user."""          if not infractions: @@ -241,7 +241,7 @@ class ModManagement(commands.Cog):              max_size=1000          ) -    def infraction_to_string(self, infraction: utils.Infraction) -> str: +    def infraction_to_string(self, infraction: _utils.Infraction) -> str:          """Convert the infraction object to a string representation."""          actor_id = infraction["actor"]          guild = self.bot.get_guild(constants.Guild.id) @@ -282,10 +282,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 +303,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..29f41f2ab 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/exts/moderation/infraction/superstarify.py @@ -6,15 +6,14 @@ 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 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.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 +66,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 +75,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,12 +129,12 @@ 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 @@ -149,11 +148,11 @@ class Superstarify(InfractionScheduler, Cog):          self.schedule_expiration(infraction)          # 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,7 +175,7 @@ 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"), @@ -196,7 +195,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,11 +212,11 @@ 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 { @@ -234,6 +233,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..b0d9b5b2b 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -834,3 +834,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..6dad82d1e --- /dev/null +++ b/bot/exts/moderation/verification.py @@ -0,0 +1,752 @@ +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 + +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"{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=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..236603dba 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__) @@ -192,13 +191,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 +209,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 +220,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 +231,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 +243,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 +254,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/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 d48739002..b4cdce38f 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 @@ -236,8 +234,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 +271,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 +389,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: @@ -461,21 +455,19 @@ redirect_output:      delete_delay: 15  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: @@ -485,5 +477,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 8d6f48333..4953550f9 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/exts/backend/sync/test_base.py @@ -3,7 +3,7 @@ from unittest import mock  from bot.api import ResponseCodeError -from bot.cogs.sync.syncers import Syncer +from bot.exts.backend.sync._syncers import Syncer  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..a0ff8a877 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/exts/filters/test_token_remover.py @@ -6,9 +6,9 @@ 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 tests.helpers import MockBot, MockMessage, autospec @@ -132,7 +132,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 +143,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 +167,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 +230,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") @@ -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..ba8d5d608 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.""" @@ -554,7 +554,7 @@ class UserCommandTests(unittest.TestCase):          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] @@ -567,7 +567,7 @@ class UserCommandTests(unittest.TestCase):          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_regular_user_can_explicitly_target_themselves(self, create_embed, constants):          """A user should target itself with `!user` when a `user` argument was not provided."""          constants.STAFF_ROLES = [self.moderator_role.id] @@ -580,7 +580,7 @@ class UserCommandTests(unittest.TestCase):          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] @@ -593,7 +593,7 @@ 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] 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: | 
